mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-06-23 13:48:47 +02:00
Update:Android download to internal storage option #635
This commit is contained in:
parent
fbcb8620f9
commit
373221703d
12 changed files with 126 additions and 71 deletions
|
@ -233,7 +233,7 @@ class AbsDownloader : Plugin() {
|
||||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
Log.d(tag, "Starting podcast episode download")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
@ -165,6 +164,13 @@ class AbsFileSystem : Plugin() {
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getSDKVersion(call: PluginCall) {
|
||||||
|
val jsObject = JSObject()
|
||||||
|
jsObject.put("version", Build.VERSION.SDK_INT)
|
||||||
|
call.resolve(jsObject)
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun scanFolder(call: PluginCall) {
|
fun scanFolder(call: PluginCall) {
|
||||||
val folderId = call.data.getString("folderId", "").toString()
|
val folderId = call.data.getString("folderId", "").toString()
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="300" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
|
||||||
<p class="text-white text-lg truncate">Select Local Folder</p>
|
<p class="text-white text-lg truncate">Select Download Location</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -25,36 +25,48 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
|
||||||
value: Boolean,
|
|
||||||
mediaType: String
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localFolders: []
|
localFolders: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
this.$nextTick(this.init)
|
this.$nextTick(this.init)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.$store.state.globals.showSelectLocalFolderModal
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$store.commit('globals/setShowSelectLocalFolderModal', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
modalData() {
|
||||||
|
return this.$store.state.globals.localFolderSelectData || {}
|
||||||
|
},
|
||||||
|
callback() {
|
||||||
|
return this.modalData.callback
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.modalData.mediaType
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(folder) {
|
clickedOption(folder) {
|
||||||
this.$emit('select', folder)
|
this.show = false
|
||||||
|
if (!this.callback) {
|
||||||
|
console.error('Callback not set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.callback(folder)
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
|
|
||||||
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
|
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
|
||||||
localFolders.push({
|
localFolders.push({
|
||||||
|
|
|
@ -186,28 +186,32 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
let localFolder = selectedLocalFolder
|
||||||
if (!localFolder) {
|
if (!localFolder) {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
console.log('Local folders loaded', localFolders.length)
|
console.log('Local folders loaded', localFolders.length)
|
||||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||||
console.log('Checking local folder', lf.mediaType)
|
console.log('Checking local folder', lf.mediaType)
|
||||||
return lf.mediaType == this.mediaType
|
return lf.mediaType == this.mediaType
|
||||||
})
|
})
|
||||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||||
|
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||||
if (!foldersWithMediaType.length) {
|
if (!foldersWithMediaType.length) {
|
||||||
// No local folders or no local folders with this media type
|
localFolder = {
|
||||||
localFolder = await this.selectFolder()
|
id: `internal-${this.mediaType}`,
|
||||||
} else if (foldersWithMediaType.length == 1) {
|
name: 'Internal App Storage',
|
||||||
console.log('Only 1 local folder with this media type - auto select it')
|
mediaType: this.mediaType
|
||||||
localFolder = foldersWithMediaType[0]
|
|
||||||
} else {
|
|
||||||
console.log('Multiple folders with media type')
|
|
||||||
// this.showSelectLocalFolder = true
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!localFolder) {
|
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||||
return this.$toast.error('Invalid download folder')
|
localFolder = internalStorageFolder
|
||||||
|
} else {
|
||||||
|
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||||
|
mediaType: this.mediaType,
|
||||||
|
callback: (folder) => {
|
||||||
|
this.download(folder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -192,28 +192,32 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
var localFolder = selectedLocalFolder
|
let localFolder = selectedLocalFolder
|
||||||
if (!localFolder) {
|
if (!localFolder) {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
console.log('Local folders loaded', localFolders.length)
|
console.log('Local folders loaded', localFolders.length)
|
||||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||||
console.log('Checking local folder', lf.mediaType)
|
console.log('Checking local folder', lf.mediaType)
|
||||||
return lf.mediaType == this.mediaType
|
return lf.mediaType == this.mediaType
|
||||||
})
|
})
|
||||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||||
|
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||||
if (!foldersWithMediaType.length) {
|
if (!foldersWithMediaType.length) {
|
||||||
// No local folders or no local folders with this media type
|
localFolder = {
|
||||||
localFolder = await this.selectFolder()
|
id: `internal-${this.mediaType}`,
|
||||||
} else if (foldersWithMediaType.length == 1) {
|
name: 'Internal App Storage',
|
||||||
console.log('Only 1 local folder with this media type - auto select it')
|
mediaType: this.mediaType
|
||||||
localFolder = foldersWithMediaType[0]
|
|
||||||
} else {
|
|
||||||
console.log('Multiple folders with media type')
|
|
||||||
// this.showSelectLocalFolder = true
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!localFolder) {
|
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||||
return this.$toast.error('Invalid download folder')
|
localFolder = internalStorageFolder
|
||||||
|
} else {
|
||||||
|
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||||
|
mediaType: this.mediaType,
|
||||||
|
callback: (folder) => {
|
||||||
|
this.download(folder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
<app-audio-player-container ref="streamContainer" />
|
<app-audio-player-container ref="streamContainer" />
|
||||||
<modals-libraries-modal />
|
<modals-libraries-modal />
|
||||||
<modals-playlists-add-create-modal />
|
<modals-playlists-add-create-modal />
|
||||||
|
<modals-select-local-folder-modal />
|
||||||
<app-side-drawer />
|
<app-side-drawer />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -346,19 +346,23 @@ export default {
|
||||||
return lf.mediaType == this.mediaType
|
return lf.mediaType == this.mediaType
|
||||||
})
|
})
|
||||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||||
|
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||||
if (!foldersWithMediaType.length) {
|
if (!foldersWithMediaType.length) {
|
||||||
// No local folders or no local folders with this media type
|
localFolder = {
|
||||||
localFolder = await this.selectFolder()
|
id: `internal-${this.mediaType}`,
|
||||||
} else if (foldersWithMediaType.length == 1) {
|
name: 'Internal App Storage',
|
||||||
console.log('Only 1 local folder with this media type - auto select it')
|
mediaType: this.mediaType
|
||||||
localFolder = foldersWithMediaType[0]
|
|
||||||
} else {
|
|
||||||
console.log('Multiple folders with media type')
|
|
||||||
// this.showSelectLocalFolder = true
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (!localFolder) {
|
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||||
return this.$toast.error('Invalid download folder')
|
localFolder = internalStorageFolder
|
||||||
|
} else {
|
||||||
|
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||||
|
mediaType: this.mediaType,
|
||||||
|
callback: (folder) => {
|
||||||
|
this.download(folder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -622,38 +622,32 @@ export default {
|
||||||
},
|
},
|
||||||
async download(selectedLocalFolder = null) {
|
async download(selectedLocalFolder = null) {
|
||||||
// Get the local folder to download to
|
// Get the local folder to download to
|
||||||
var localFolder = selectedLocalFolder
|
let localFolder = selectedLocalFolder
|
||||||
if (!localFolder) {
|
if (!localFolder) {
|
||||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
const localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
console.log('Local folders loaded', localFolders.length)
|
console.log('Local folders loaded', localFolders.length)
|
||||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
const foldersWithMediaType = localFolders.filter((lf) => {
|
||||||
console.log('Checking local folder', lf.mediaType)
|
console.log('Checking local folder', lf.mediaType)
|
||||||
return lf.mediaType == this.mediaType
|
return lf.mediaType == this.mediaType
|
||||||
})
|
})
|
||||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||||
|
const internalStorageFolder = foldersWithMediaType.find((f) => f.id === `internal-${this.mediaType}`)
|
||||||
if (!foldersWithMediaType.length) {
|
if (!foldersWithMediaType.length) {
|
||||||
localFolder = {
|
localFolder = {
|
||||||
id: `internal-${this.mediaType}`,
|
id: `internal-${this.mediaType}`,
|
||||||
name: 'App Storage',
|
name: 'Internal App Storage',
|
||||||
mediaType: this.mediaType
|
mediaType: this.mediaType
|
||||||
}
|
}
|
||||||
|
} else if (foldersWithMediaType.length === 1 && internalStorageFolder) {
|
||||||
|
localFolder = internalStorageFolder
|
||||||
} else {
|
} else {
|
||||||
this.showSelectLocalFolder = true
|
this.$store.commit('globals/showSelectLocalFolderModal', {
|
||||||
return
|
mediaType: this.mediaType,
|
||||||
|
callback: (folder) => {
|
||||||
|
this.download(folder)
|
||||||
}
|
}
|
||||||
// if (!foldersWithMediaType.length) {
|
})
|
||||||
// // No local folders or no local folders with this media type
|
return
|
||||||
// localFolder = await this.selectFolder()
|
|
||||||
// } else if (foldersWithMediaType.length == 1) {
|
|
||||||
// console.log('Only 1 local folder with this media type - auto select it')
|
|
||||||
// localFolder = foldersWithMediaType[0]
|
|
||||||
// } else {
|
|
||||||
// console.log('Multiple folders with media type')
|
|
||||||
// this.showSelectLocalFolder = true
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
if (!localFolder) {
|
|
||||||
return this.$toast.error('Invalid download folder')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,12 +15,18 @@
|
||||||
<div v-if="!localFolders.length" class="flex justify-center">
|
<div v-if="!localFolders.length" class="flex justify-center">
|
||||||
<p class="text-center">No Media Folders</p>
|
<p class="text-center">No Media Folders</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex border-t border-white border-opacity-10 my-4 py-4">
|
<div v-if="!isAndroid10OrBelow || overrideFolderRestriction" class="flex border-t border-white border-opacity-10 my-4 py-4">
|
||||||
<div class="flex-grow pr-1">
|
<div class="flex-grow pr-1">
|
||||||
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
|
<ui-dropdown v-model="newFolderMediaType" placeholder="Select media type" :items="mediaTypeItems" />
|
||||||
</div>
|
</div>
|
||||||
<ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn>
|
<ui-btn small class="w-28" color="success" @click="selectFolder">New Folder</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="flex border-t border-white border-opacity-10 my-4 py-4">
|
||||||
|
<div class="flex-grow pr-1">
|
||||||
|
<p class="text-sm">Android 10 and below will use internal app storage for downloads.</p>
|
||||||
|
</div>
|
||||||
|
<ui-btn small class="w-28" color="primary" @click="overrideFolderRestriction = true">Override</ui-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -44,7 +50,9 @@ export default {
|
||||||
text: 'Podcasts'
|
text: 'Podcasts'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
syncing: false
|
syncing: false,
|
||||||
|
isAndroid10OrBelow: false,
|
||||||
|
overrideFolderRestriction: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -82,6 +90,10 @@ export default {
|
||||||
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
|
this.$router.push(`/localMedia/folders/${folderObj.id}?scan=1`)
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
|
const androidSdkVersion = await this.$getAndroidSDKVersion()
|
||||||
|
this.isAndroid10OrBelow = !!androidSdkVersion && androidSdkVersion <= 29
|
||||||
|
console.log(`androidSdkVersion=${androidSdkVersion}, isAndroid10OrBelow=${this.isAndroid10OrBelow}`)
|
||||||
|
|
||||||
this.localFolders = (await this.$db.getLocalFolders()) || []
|
this.localFolders = (await this.$db.getLocalFolders()) || []
|
||||||
this.localLibraryItems = await this.$db.getLocalLibraryItems()
|
this.localLibraryItems = await this.$db.getLocalLibraryItems()
|
||||||
}
|
}
|
||||||
|
|
|
@ -272,6 +272,7 @@ export default {
|
||||||
},
|
},
|
||||||
showItemDialog() {
|
showItemDialog() {
|
||||||
this.selectedAudioTrack = null
|
this.selectedAudioTrack = null
|
||||||
|
this.selectedEpisode = null
|
||||||
this.showDialog = true
|
this.showDialog = true
|
||||||
},
|
},
|
||||||
showTrackDialog(track) {
|
showTrackDialog(track) {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import Vue from 'vue'
|
||||||
import vClickOutside from 'v-click-outside'
|
import vClickOutside from 'v-click-outside'
|
||||||
import { App } from '@capacitor/app'
|
import { App } from '@capacitor/app'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
import { AbsFileSystem } from '@/plugins/capacitor'
|
||||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||||
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
import { formatDistance, format, addDays, isDate } from 'date-fns'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
@ -17,6 +18,13 @@ if (Capacitor.getPlatform() != 'web') {
|
||||||
|
|
||||||
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
|
Vue.prototype.$getAndroidSDKVersion = async () => {
|
||||||
|
if (Capacitor.getPlatform() !== 'android') return null
|
||||||
|
const data = await AbsFileSystem.getSDKVersion()
|
||||||
|
if (isNaN(data?.version)) return null
|
||||||
|
return Number(data.version)
|
||||||
|
}
|
||||||
|
|
||||||
Vue.prototype.$encodeUriPath = (path) => {
|
Vue.prototype.$encodeUriPath = (path) => {
|
||||||
return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
|
return path.replace(/\\/g, '/').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,8 @@ export const state = () => ({
|
||||||
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'],
|
libraryIcons: ['database', 'audiobookshelf', 'books-1', 'books-2', 'book-1', 'microphone-1', 'microphone-3', 'radio', 'podcast', 'rss', 'headphones', 'music', 'file-picture', 'rocket', 'power', 'star', 'heart'],
|
||||||
selectedPlaylistItems: [],
|
selectedPlaylistItems: [],
|
||||||
showPlaylistsAddCreateModal: false,
|
showPlaylistsAddCreateModal: false,
|
||||||
|
showSelectLocalFolderModal: false,
|
||||||
|
localFolderSelectData: null,
|
||||||
hapticFeedback: 'LIGHT'
|
hapticFeedback: 'LIGHT'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -181,6 +183,13 @@ export const mutations = {
|
||||||
setShowPlaylistsAddCreateModal(state, val) {
|
setShowPlaylistsAddCreateModal(state, val) {
|
||||||
state.showPlaylistsAddCreateModal = val
|
state.showPlaylistsAddCreateModal = val
|
||||||
},
|
},
|
||||||
|
showSelectLocalFolderModal(state, data) {
|
||||||
|
state.localFolderSelectData = data
|
||||||
|
state.showSelectLocalFolderModal = true
|
||||||
|
},
|
||||||
|
setShowSelectLocalFolderModal(state, val) {
|
||||||
|
state.showSelectLocalFolderModal = val
|
||||||
|
},
|
||||||
setHapticFeedback(state, val) {
|
setHapticFeedback(state, val) {
|
||||||
state.hapticFeedback = val || 'LIGHT'
|
state.hapticFeedback = val || 'LIGHT'
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue