mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(library): rebuild the folder picker as a tree with a live plan
Pick folders from a lazily loaded tree with find-by-name and typed paths, beside a plan showing the books each organization mode would import, overlaps with other libraries, and folders no library reads yet. New ManageLibraries /path routes walk folders within entry and time bounds. Fix the scanner filing a disc-named library folder such as /mnt/disk1 under the folder above it, and move books already filed there onto their primary file on the next scan.
This commit is contained in:
@@ -1,334 +1,585 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, ChevronRight, ChevronUp, Folder, FolderOpen, FolderPlus, HardDrive, Info, Loader2, Search, X } from '@lucide/vue'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import { useEventListener, useMediaQuery } from '@vueuse/core'
|
||||
import { Check, ChevronUp, FolderTree, Info, Radio, Undo2, X } from '@lucide/vue'
|
||||
import type { OrganizationMode } from '@bookorbit/types'
|
||||
import { useModal } from '@/composables/useModal'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useFolderBrowser } from '../composables/useFolderBrowser'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { formatList } from '@/i18n/formatters'
|
||||
import { consolidateFolderPaths, normalizeFolderPath } from '../composables/folder-paths'
|
||||
import { FOLDER_PICKER, type FolderPickerContext } from '../composables/folder-picker/folder-picker-context'
|
||||
import {
|
||||
ancestorsWithin,
|
||||
booksAs,
|
||||
isAtOrInside,
|
||||
isInside,
|
||||
leafName,
|
||||
parentPath,
|
||||
type FolderPickerKind,
|
||||
type FolderStatus,
|
||||
type PickerLibrary,
|
||||
} from '../composables/folder-picker/folder-picker-rules'
|
||||
import { rememberRecentFolders } from '../composables/folder-picker/recent-folders'
|
||||
import { useFolderPickerData } from '../composables/folder-picker/useFolderPickerData'
|
||||
import { useFolderPickerSelection } from '../composables/folder-picker/useFolderPickerSelection'
|
||||
import { useFolderPickerTree } from '../composables/folder-picker/useFolderPickerTree'
|
||||
import { usePickerPlanWidth } from '../composables/folder-picker/usePickerPlanWidth'
|
||||
import FolderPickerDivider from './folder-picker/FolderPickerDivider.vue'
|
||||
import FolderPickerPlan from './folder-picker/FolderPickerPlan.vue'
|
||||
import FolderPickerTree from './folder-picker/FolderPickerTree.vue'
|
||||
import PickerKbd from './folder-picker/PickerKbd.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Folders the library already reads. */
|
||||
selectedPaths?: string[]
|
||||
multiple?: boolean
|
||||
kind?: FolderPickerKind
|
||||
libraryName?: string
|
||||
libraryIcon?: string | null
|
||||
/** The library being edited, left out of the others when checking overlaps. */
|
||||
libraryId?: number | null
|
||||
organizationMode?: OrganizationMode
|
||||
organizationModeLocked?: boolean
|
||||
}>(),
|
||||
{
|
||||
selectedPaths: () => [],
|
||||
multiple: true,
|
||||
kind: 'books',
|
||||
libraryName: '',
|
||||
libraryIcon: null,
|
||||
libraryId: null,
|
||||
organizationMode: 'book_per_folder',
|
||||
organizationModeLocked: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [paths: string[]]
|
||||
close: []
|
||||
'update:organizationMode': [mode: OrganizationMode]
|
||||
}>()
|
||||
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const {
|
||||
currentPath,
|
||||
loading,
|
||||
search,
|
||||
error,
|
||||
selectedPaths,
|
||||
selectionNotice,
|
||||
creatingFolder,
|
||||
newFolderName,
|
||||
createError,
|
||||
createLoading,
|
||||
newFolderInput,
|
||||
filteredEntries,
|
||||
breadcrumbs,
|
||||
canGoUp,
|
||||
selectedCount,
|
||||
navigate,
|
||||
goUp,
|
||||
reloadCurrent,
|
||||
clearSearch,
|
||||
isSelected,
|
||||
existingPathStatus,
|
||||
selectedPathStatus,
|
||||
isSelectionDisabled,
|
||||
toggleSelection,
|
||||
toggleCurrentPath,
|
||||
toggleNewFolder,
|
||||
cancelNewFolder,
|
||||
submitNewFolder,
|
||||
onNewFolderKeydown,
|
||||
} = useFolderBrowser(() => props.selectedPaths)
|
||||
const FLASH_MS = 1300
|
||||
|
||||
const addButtonLabel = computed(() => {
|
||||
if (selectedCount.value === 0) return 'Add folders'
|
||||
return `Add ${selectedCount.value} folder${selectedCount.value === 1 ? '' : 's'}`
|
||||
const { t } = useI18n()
|
||||
const desktop = useMediaQuery('(min-width: 640px)')
|
||||
const phone = computed(() => !desktop.value)
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
const grid = ref<HTMLElement | null>(null)
|
||||
const treePane = ref<InstanceType<typeof FolderPickerTree> | null>(null)
|
||||
const sheetOpen = ref(false)
|
||||
const flash = ref<string | null>(null)
|
||||
const findQuery = ref('')
|
||||
const newFolderName = ref('')
|
||||
const newFolderError = ref<string | null>(null)
|
||||
const newFolderBusy = ref(false)
|
||||
const menuOpen = ref(false)
|
||||
const mode = ref<OrganizationMode>(props.organizationMode)
|
||||
/** The mode as the picker opened, put back if it is cancelled. */
|
||||
const openingMode = props.organizationMode
|
||||
let flashTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let revealRun = 0
|
||||
|
||||
const data = useFolderPickerData()
|
||||
const tree = useFolderPickerTree(data, () => [...own.value, ...selection.selected.value])
|
||||
const planWidth = usePickerPlanWidth()
|
||||
|
||||
const own = computed(() => consolidateFolderPaths(props.selectedPaths))
|
||||
const libraryName = computed(() => props.libraryName.trim() || t('library.folderBrowser.unnamedLibrary'))
|
||||
const libraries = computed<PickerLibrary[]>(() => {
|
||||
const list: PickerLibrary[] = (data.places.value?.libraries ?? [])
|
||||
.filter((library) => library.id !== props.libraryId)
|
||||
.map((library) => ({ id: library.id, name: library.name, icon: library.icon, folders: library.folders.map(normalizeFolderPath) }))
|
||||
if (own.value.length) list.push({ id: props.libraryId, name: libraryName.value, icon: props.libraryIcon, folders: own.value, self: true })
|
||||
return list
|
||||
})
|
||||
const others = computed(() => libraries.value.filter((library) => !library.self))
|
||||
const multiple = computed(() => props.multiple)
|
||||
const kind = computed(() => props.kind)
|
||||
const modeLocked = computed(() => props.organizationModeLocked || props.kind !== 'books')
|
||||
|
||||
const selection = useFolderPickerSelection({
|
||||
data,
|
||||
own: () => own.value,
|
||||
others: () => others.value,
|
||||
multiple: () => props.multiple,
|
||||
appDataPath: () => data.places.value?.appDataPath ?? null,
|
||||
mode,
|
||||
onModeChange: (next) => emit('update:organizationMode', next),
|
||||
})
|
||||
|
||||
const selected = selection.selected
|
||||
const storage = computed(() => props.kind === 'podcastStorage')
|
||||
const title = computed(() => (storage.value ? t('library.folderBrowser.titleStorage') : t('library.folderBrowser.title')))
|
||||
const subtitle = computed(() => {
|
||||
if (storage.value) return t('library.folderBrowser.subtitleStorage', { name: libraryName.value })
|
||||
if (props.kind === 'podcastLocal') return t('library.folderBrowser.subtitleLocal', { name: libraryName.value })
|
||||
return t('library.folderBrowser.subtitle', {
|
||||
name: libraryName.value,
|
||||
mode: mode.value === 'book_per_file' ? t('library.folderBrowser.mode.file') : t('library.folderBrowser.mode.folder'),
|
||||
})
|
||||
})
|
||||
const totalBooks = computed(() => selected.value.reduce((sum, path) => sum + (booksIn(path) ?? 0), 0))
|
||||
const addLabel = computed(() => {
|
||||
const count = selected.value.length
|
||||
if (!props.multiple) return t('library.folderBrowser.footer.use')
|
||||
if (phone.value) return count ? t('library.folderBrowser.footer.addShortCount', { count }) : t('library.folderBrowser.footer.addShort')
|
||||
return count ? t('library.folderBrowser.footer.addCount', { count }) : t('library.folderBrowser.footer.add')
|
||||
})
|
||||
const undoMessage = computed(() => (selection.undo.value ? t(selection.undo.value.notice.key, selection.undo.value.notice.params ?? {}) : null))
|
||||
const noticeMessage = computed(() => (selection.notice.value ? t(selection.notice.value.key, selection.notice.value.params ?? {}) : null))
|
||||
|
||||
function booksIn(path: string): number | null {
|
||||
const summary = data.summaries.get(normalizeFolderPath(path))
|
||||
if (!summary) return null
|
||||
return props.kind === 'books' ? booksAs(summary, mode.value) : summary.files
|
||||
}
|
||||
|
||||
function countLabel(count: number): string {
|
||||
return t(props.kind === 'books' ? 'library.folderBrowser.count.books' : 'library.folderBrowser.count.episodes', { count })
|
||||
}
|
||||
|
||||
function holdsLabel(count: number): string {
|
||||
return t(props.kind === 'books' ? 'library.folderBrowser.plan.holdsBooks' : 'library.folderBrowser.plan.holdsEpisodes', { count })
|
||||
}
|
||||
|
||||
function describeOverlap(path: string, status: FolderStatus): string {
|
||||
const kind = status.over ?? status.kind
|
||||
if (kind === 'contains') {
|
||||
const libs = status.libraries ?? []
|
||||
if (libs.length === 1) {
|
||||
const folder = libs[0]!.folders.find((own) => isInside(own, path)) ?? libs[0]!.folders[0]!
|
||||
return t('library.folderBrowser.plan.attention.containsOne', { folder: leafName(folder), library: libs[0]!.name })
|
||||
}
|
||||
return t('library.folderBrowser.plan.attention.containsMany', { libraries: formatList(libs.map((library) => library.name)) })
|
||||
}
|
||||
const library = status.library?.name ?? ''
|
||||
if (status.exact)
|
||||
return t('library.folderBrowser.plan.attention.overlapExact', { library, count: data.summaries.get(normalizeFolderPath(path))?.files ?? 0 })
|
||||
return t('library.folderBrowser.plan.attention.overlapInside', { library })
|
||||
}
|
||||
|
||||
function flashRow(path: string) {
|
||||
flash.value = path
|
||||
if (flashTimer) clearTimeout(flashTimer)
|
||||
flashTimer = setTimeout(() => {
|
||||
if (flash.value === path) flash.value = null
|
||||
}, FLASH_MS)
|
||||
}
|
||||
|
||||
/** Opens every folder above `path`, waits for them to list, then focuses and tints its row. */
|
||||
async function reveal(path: string) {
|
||||
const key = normalizeFolderPath(path)
|
||||
// A later reveal wins, even when this one's listings arrive last.
|
||||
const run = ++revealRun
|
||||
sheetOpen.value = false
|
||||
findQuery.value = ''
|
||||
tree.findResults.value = null
|
||||
tree.reveal(key)
|
||||
for (const folder of [tree.root.value, ...ancestorsWithin(key, tree.root.value)]) await data.ensureListing(folder)
|
||||
if (run !== revealRun) return
|
||||
flashRow(key)
|
||||
await nextTick()
|
||||
void treePane.value?.focusRow(key)
|
||||
}
|
||||
|
||||
function focusFolder(path: string) {
|
||||
const key = normalizeFolderPath(path)
|
||||
const finding = tree.findResults.value !== null
|
||||
if (!finding && tree.hasChildren(key)) tree.setExpanded(key, tree.focus.value === key ? !tree.expanded.value.has(key) : true)
|
||||
tree.focus.value = key
|
||||
void treePane.value?.focusRow(key)
|
||||
}
|
||||
|
||||
function toggleFolder(path: string, event?: MouseEvent | KeyboardEvent) {
|
||||
const key = normalizeFolderPath(path)
|
||||
if (event?.shiftKey && props.multiple) {
|
||||
// Only the siblings on screen, so a range across find results never ticks folders it did not show.
|
||||
const parent = parentPath(key)
|
||||
selection.tickRange(
|
||||
key,
|
||||
tree.rows.value.filter((row) => row.type === 'folder' && parentPath(row.path) === parent).map((row) => row.path),
|
||||
)
|
||||
} else {
|
||||
selection.toggle(key)
|
||||
}
|
||||
}
|
||||
|
||||
function openNewFolder(parent: string) {
|
||||
const key = normalizeFolderPath(parent)
|
||||
findQuery.value = ''
|
||||
tree.findResults.value = null
|
||||
newFolderName.value = ''
|
||||
newFolderError.value = null
|
||||
tree.expanded.value = new Set([...tree.expanded.value, ...ancestorsWithin(key, tree.root.value), key])
|
||||
tree.newFolderParent.value = key
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
tree.newFolderParent.value = null
|
||||
newFolderError.value = null
|
||||
}
|
||||
|
||||
async function submitNewFolder() {
|
||||
const parent = tree.newFolderParent.value
|
||||
const name = newFolderName.value.trim()
|
||||
if (!parent || newFolderBusy.value) return
|
||||
if (!name) {
|
||||
newFolderError.value = t('library.folderBrowser.newFolder.errors.empty')
|
||||
return
|
||||
}
|
||||
if (/[\\/]/.test(name) || name.startsWith('.')) {
|
||||
newFolderError.value = t('library.folderBrowser.newFolder.errors.invalid')
|
||||
return
|
||||
}
|
||||
newFolderBusy.value = true
|
||||
const result = await data.createFolder(parent, name)
|
||||
newFolderBusy.value = false
|
||||
if ('error' in result) {
|
||||
newFolderError.value = t(`library.folderBrowser.newFolder.errors.${result.error}`, { name })
|
||||
return
|
||||
}
|
||||
tree.newFolderParent.value = null
|
||||
await reveal(result.path)
|
||||
selection.toggle(result.path)
|
||||
// Made inside a folder already read, it is included rather than ticked.
|
||||
const ticked = selection.selected.value.includes(result.path)
|
||||
selection.notice.value = {
|
||||
key: ticked ? 'library.folderBrowser.notice.created' : 'library.folderBrowser.notice.createdOnly',
|
||||
params: { path: result.path },
|
||||
}
|
||||
}
|
||||
|
||||
const context: FolderPickerContext = {
|
||||
data,
|
||||
tree,
|
||||
selection,
|
||||
kind,
|
||||
multiple,
|
||||
libraries,
|
||||
others,
|
||||
own,
|
||||
libraryName,
|
||||
mode,
|
||||
modeLocked,
|
||||
phone,
|
||||
flash,
|
||||
findQuery,
|
||||
newFolder: { name: newFolderName, error: newFolderError, busy: newFolderBusy },
|
||||
menuOpen,
|
||||
reveal: (path) => void reveal(path),
|
||||
focusFolder,
|
||||
toggleFolder,
|
||||
openNewFolder,
|
||||
submitNewFolder,
|
||||
cancelNewFolder,
|
||||
booksIn,
|
||||
countLabel,
|
||||
holdsLabel,
|
||||
describeOverlap,
|
||||
}
|
||||
provide(FOLDER_PICKER, context)
|
||||
|
||||
function gridWidth(): number {
|
||||
return grid.value?.offsetWidth ?? 0
|
||||
}
|
||||
|
||||
function onPlanWidth(value: number) {
|
||||
planWidth.width.value = value
|
||||
}
|
||||
|
||||
function onPlanCommit() {
|
||||
planWidth.save()
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (createLoading.value) return
|
||||
if (newFolderBusy.value) return
|
||||
// Cancelling undoes everything done in the picker, the organization switch included.
|
||||
if (mode.value !== openingMode) emit('update:organizationMode', openingMode)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function addSelectedFolders() {
|
||||
if (selectedPaths.value.length === 0) return
|
||||
emit('select', selectedPaths.value)
|
||||
function onBackdrop() {
|
||||
requestClose()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (selected.value.length === 0) return
|
||||
rememberRecentFolders(selected.value)
|
||||
emit('select', [...selected.value])
|
||||
}
|
||||
|
||||
function onUndo() {
|
||||
selection.restore()
|
||||
}
|
||||
|
||||
function toggleSheet() {
|
||||
sheetOpen.value = !sheetOpen.value
|
||||
}
|
||||
|
||||
function closeSheet() {
|
||||
sheetOpen.value = false
|
||||
}
|
||||
|
||||
function onPanelKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
const typing = target.matches('input[type=text], input:not([type]), textarea')
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'l') {
|
||||
event.preventDefault()
|
||||
const from = tree.focus.value && tree.focus.value !== tree.root.value ? `${tree.focus.value}/` : '/'
|
||||
treePane.value?.focusField(from)
|
||||
} else if (!typing && event.key === '/') {
|
||||
event.preventDefault()
|
||||
treePane.value?.focusField()
|
||||
} else if (event.key === 'Escape' && sheetOpen.value) {
|
||||
// Closes the plan sheet only; the picker's own Escape listener never sees it.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sheetOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clampPlan() {
|
||||
if (gridWidth()) planWidth.width.value = planWidth.clamp(planWidth.width.value, gridWidth())
|
||||
}
|
||||
|
||||
function retryPlaces() {
|
||||
void open()
|
||||
}
|
||||
|
||||
useModal({
|
||||
container: panel,
|
||||
onClose: requestClose,
|
||||
disabled: () => createLoading.value,
|
||||
disabled: () => newFolderBusy.value || menuOpen.value,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.organizationMode,
|
||||
(next) => (mode.value = next),
|
||||
)
|
||||
|
||||
// The plan keeps its width when the window grows, and gives way when it shrinks.
|
||||
watch(desktop, () => void nextTick(clampPlan))
|
||||
useEventListener(window, 'resize', clampPlan, { passive: true })
|
||||
|
||||
// Leaving find mode, the focused folder may sit in a folded branch: unfold the way to it.
|
||||
watch(
|
||||
() => tree.findResults.value,
|
||||
async (now, before) => {
|
||||
const focus = tree.focus.value
|
||||
if (now !== null || !before || !focus) return
|
||||
tree.reveal(focus)
|
||||
for (const folder of [tree.root.value, ...ancestorsWithin(focus, tree.root.value)]) await data.ensureListing(folder)
|
||||
await nextTick()
|
||||
treePane.value?.ensureVisible(focus)
|
||||
},
|
||||
)
|
||||
|
||||
async function open() {
|
||||
await data.loadPlaces()
|
||||
await nextTick()
|
||||
clampPlan()
|
||||
const places = data.places.value
|
||||
if (!places) return
|
||||
const opened = new Set<string>()
|
||||
const firstMount = places.mounts.find((mount) => !places.appDataPath || !isAtOrInside(mount, places.appDataPath))
|
||||
if (firstMount && firstMount !== normalizeFolderPath(places.root)) opened.add(normalizeFolderPath(firstMount))
|
||||
// With nothing mounted separately, open the way to where the libraries already are.
|
||||
const landmarks = own.value.length ? own.value : opened.size ? [] : others.value.flatMap((library) => library.folders).slice(0, 1)
|
||||
for (const path of landmarks) for (const parent of ancestorsWithin(path, places.root)) opened.add(parent)
|
||||
tree.expanded.value = opened
|
||||
if (props.kind === 'books') void data.loadUnclaimed()
|
||||
// Open on the deepest folder unfolded, so the libraries' landmarks are on screen.
|
||||
const deepest = [...opened].sort((a, b) => b.length - a.length)[0]
|
||||
if (deepest) {
|
||||
for (const folder of [tree.root.value, ...ancestorsWithin(deepest, tree.root.value), deepest]) await data.ensureListing(folder)
|
||||
await nextTick()
|
||||
treePane.value?.scrollToTop(deepest)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(open)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
data.dispose()
|
||||
if (flashTimer) clearTimeout(flashTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[80] flex items-end justify-center bg-scrim sm:items-center sm:p-4" role="presentation" @click.self="requestClose">
|
||||
<div v-if="!phone" class="fixed inset-0 z-[80] flex items-center justify-center bg-scrim p-8" role="presentation" @click.self="onBackdrop">
|
||||
<div
|
||||
ref="panel"
|
||||
class="flex h-dvh w-full flex-col overflow-hidden bg-background shadow-2xl outline-none sm:h-[min(82dvh,700px)] sm:max-w-xl sm:rounded-lg sm:border sm:border-border"
|
||||
class="flex h-[min(92%,60rem)] w-full max-w-[76rem] flex-col overflow-hidden rounded-xl border border-border bg-background shadow-2xl outline-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="folder-picker-title"
|
||||
aria-describedby="folder-picker-description"
|
||||
aria-describedby="folder-picker-subtitle"
|
||||
tabindex="-1"
|
||||
@keydown="onPanelKeydown"
|
||||
>
|
||||
<header class="flex shrink-0 items-start justify-between gap-3 border-b border-border bg-card px-4 py-3">
|
||||
<div class="flex min-w-0 items-start gap-2.5">
|
||||
<HardDrive :size="18" class="mt-0.5 shrink-0 text-primary" />
|
||||
<div class="min-w-0">
|
||||
<h2 id="folder-picker-title" class="text-base font-semibold text-foreground">{{ t('library.folderPicker.title') }}</h2>
|
||||
<p id="folder-picker-description" class="text-xs text-muted-foreground">{{ t('library.folderPicker.description') }}</p>
|
||||
</div>
|
||||
<header class="flex shrink-0 items-center gap-3 border-b border-border bg-card px-4 py-3">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary" aria-hidden="true">
|
||||
<Radio v-if="storage" :size="18" />
|
||||
<FolderTree v-else :size="18" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="folder-picker-title" class="truncate text-base font-semibold text-foreground">{{ title }}</h2>
|
||||
<p id="folder-picker-subtitle" class="truncate text-xs text-muted-foreground">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('library.folderPicker.closeAria')"
|
||||
:aria-label="t('library.folderBrowser.close')"
|
||||
@click="requestClose"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
class="flex h-9 shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-muted/25 px-3"
|
||||
:aria-label="t('library.folderPicker.currentFolderAria')"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div ref="grid" class="relative grid min-h-0 flex-1" :style="{ gridTemplateColumns: `minmax(0,1fr) ${planWidth.width.value}px` }">
|
||||
<FolderPickerTree ref="treePane" @retry="retryPlaces" />
|
||||
<FolderPickerPlan :width="planWidth.width.value" />
|
||||
<FolderPickerDivider
|
||||
:width="planWidth.width.value"
|
||||
:grid-width="gridWidth"
|
||||
:clamp="planWidth.clamp"
|
||||
@update:width="onPlanWidth"
|
||||
@commit="onPlanCommit"
|
||||
/>
|
||||
</div>
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-card px-3 py-2.5">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 text-xs text-muted-foreground">
|
||||
<template v-if="undoMessage">
|
||||
<Info :size="13" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<span class="truncate text-foreground" role="status">{{ undoMessage }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mr-1 flex size-7 shrink-0 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
:disabled="!canGoUp"
|
||||
:aria-label="t('library.folderPicker.parentFolder')"
|
||||
@click="goUp"
|
||||
class="ms-1 inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-primary hover:bg-primary/10"
|
||||
@click="onUndo"
|
||||
>
|
||||
<ChevronUp :size="14" />
|
||||
<Undo2 :size="13" aria-hidden="true" />
|
||||
{{ t('library.folderBrowser.notice.undo') }}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('library.folderPicker.parentFolder') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
||||
<ChevronRight v-if="index > 0" :size="12" class="shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 whitespace-nowrap rounded-xs px-1.5 py-0.5 text-xs transition-colors"
|
||||
:class="
|
||||
index === breadcrumbs.length - 1
|
||||
? 'bg-background font-medium text-foreground shadow-xs'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
"
|
||||
@click="navigate(crumb.path)"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="grid shrink-0 gap-2 border-b border-border p-2 sm:grid-cols-[1fr_auto] sm:px-3">
|
||||
<label class="flex h-9 items-center gap-2 rounded-md border border-input bg-background px-2.5 focus-within:ring-1 focus-within:ring-ring">
|
||||
<Search :size="15" class="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="t('library.folderPicker.filterPlaceholder')"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
v-if="search"
|
||||
type="button"
|
||||
class="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('library.folderPicker.clearFilterAria')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="noticeMessage">
|
||||
<Info :size="13" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<span class="truncate" role="status">{{ noticeMessage }}</span>
|
||||
</template>
|
||||
<i18n-t v-else keypath="library.folderBrowser.footer.hints" scope="global">
|
||||
<template #move><PickerKbd>↑</PickerKbd><PickerKbd>↓</PickerKbd></template>
|
||||
<template #fold><PickerKbd>←</PickerKbd><PickerKbd>→</PickerKbd></template>
|
||||
<template #tick
|
||||
><PickerKbd>{{ t('library.folderBrowser.keys.space') }}</PickerKbd></template
|
||||
>
|
||||
<template #range><PickerKbd>⇧</PickerKbd></template>
|
||||
<template #find><PickerKbd>/</PickerKbd></template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
||||
@click="toggleNewFolder"
|
||||
class="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
|
||||
@click="requestClose"
|
||||
>
|
||||
<FolderPlus :size="15" />
|
||||
{{ t('library.folderPicker.newFolder') }}
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="selected.length === 0"
|
||||
@click="confirm"
|
||||
>
|
||||
<Check :size="16" aria-hidden="true" />
|
||||
{{ addLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="creatingFolder" class="shrink-0 border-b border-border bg-muted/20 p-2 sm:px-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<label
|
||||
class="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-md border border-input bg-background px-2.5 focus-within:ring-1 focus-within:ring-ring"
|
||||
>
|
||||
<FolderPlus :size="15" class="shrink-0 text-primary" />
|
||||
<input
|
||||
ref="newFolderInput"
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
:placeholder="t('library.folderPicker.newFolderNamePlaceholder')"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
@keydown="onNewFolderKeydown"
|
||||
/>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2 sm:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="!newFolderName.trim() || createLoading"
|
||||
@click="submitNewFolder"
|
||||
>
|
||||
<Loader2 v-if="createLoading" :size="15" class="animate-spin" />
|
||||
{{ t('library.folderPicker.create') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md border border-border px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="cancelNewFolder"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="createError" class="mt-2 text-sm text-destructive">{{ createError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<div v-if="loading" class="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 :size="24" class="animate-spin" />
|
||||
<p class="text-sm">{{ t('library.folderPicker.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<FolderOpen :size="30" class="text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">{{ error }}</p>
|
||||
<button type="button" class="h-10 rounded-lg border border-border px-4 text-sm font-medium hover:bg-muted" @click="reloadCurrent">
|
||||
{{ t('reader.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full min-h-0 flex-col">
|
||||
<label
|
||||
class="flex h-11 shrink-0 cursor-pointer items-center gap-2.5 border-b border-border bg-muted/15 px-3 transition-colors hover:bg-muted/40"
|
||||
:class="[isSelected(currentPath) ? 'bg-primary/10' : '', isSelectionDisabled(currentPath) ? 'cursor-not-allowed opacity-60' : '']"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 shrink-0 accent-primary"
|
||||
:checked="isSelected(currentPath)"
|
||||
:disabled="isSelectionDisabled(currentPath)"
|
||||
@change="toggleCurrentPath"
|
||||
/>
|
||||
<FolderOpen :size="17" class="shrink-0 text-primary" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{{ t('library.folderPicker.selectCurrent') }}</span>
|
||||
<span class="max-w-40 truncate font-mono text-xs text-muted-foreground">{{ currentPath }}</span>
|
||||
<span
|
||||
v-if="existingPathStatus(currentPath) || selectedPathStatus(currentPath)"
|
||||
class="shrink-0 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ existingPathStatus(currentPath) || selectedPathStatus(currentPath) }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div v-if="filteredEntries.length === 0" class="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<Folder :size="28" class="text-muted-foreground" />
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ search ? t('library.folderPicker.noMatches') : t('library.folderPicker.empty') }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ search ? t('library.folderPicker.noMatchesHint') : t('library.folderPicker.emptyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RecycleScroller v-else class="min-h-0 flex-1 overflow-y-auto" :items="filteredEntries" :item-size="40" key-field="path">
|
||||
<template #default="{ item: entry }">
|
||||
<div class="flex h-10 items-stretch border-b border-border/60 px-1.5 sm:px-2">
|
||||
<label
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-xs px-2 transition-colors hover:bg-muted/50"
|
||||
:class="isSelectionDisabled(entry.path) ? 'cursor-not-allowed opacity-60' : ''"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-4 shrink-0 accent-primary"
|
||||
:checked="isSelected(entry.path)"
|
||||
:disabled="isSelectionDisabled(entry.path)"
|
||||
@change="toggleSelection(entry.path)"
|
||||
/>
|
||||
<Folder :size="16" class="shrink-0 text-primary" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-foreground">{{ entry.name }}</span>
|
||||
<span
|
||||
v-if="existingPathStatus(entry.path) || selectedPathStatus(entry.path)"
|
||||
class="block truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ existingPathStatus(entry.path) || selectedPathStatus(entry.path) }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('library.folderPicker.openFolderAria', { name: entry.name })"
|
||||
@click="navigate(entry.path)"
|
||||
>
|
||||
<ChevronRight :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="shrink-0 border-t border-border bg-card px-3 py-2.5">
|
||||
<div v-if="selectionNotice" class="mb-2 flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<Info :size="14" class="shrink-0 text-primary" />
|
||||
<span>{{ selectionNotice }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('library.folderPicker.selectedCount', { count: selectedCount }) }}
|
||||
</p>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md border border-border px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="addSelectedFolders"
|
||||
>
|
||||
<Check :size="16" />
|
||||
{{ addButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
ref="panel"
|
||||
class="fixed inset-0 z-[80] flex flex-col bg-background outline-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="folder-picker-title"
|
||||
aria-describedby="folder-picker-subtitle"
|
||||
tabindex="-1"
|
||||
@keydown="onPanelKeydown"
|
||||
>
|
||||
<header class="flex shrink-0 items-center gap-3 border-b border-border bg-card px-3 py-2.5">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary" aria-hidden="true">
|
||||
<Radio v-if="storage" :size="18" />
|
||||
<FolderTree v-else :size="18" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="folder-picker-title" class="truncate text-base font-semibold text-foreground">{{ title }}</h2>
|
||||
<p id="folder-picker-subtitle" class="truncate text-xs text-muted-foreground">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('library.folderBrowser.close')"
|
||||
@click="requestClose"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<FolderPickerTree ref="treePane" class="min-h-0 flex-1" @retry="retryPlaces" />
|
||||
<div v-if="undoMessage || noticeMessage" class="flex shrink-0 items-center gap-1.5 border-t border-border bg-card px-3 py-1.5 text-xs">
|
||||
<Info :size="13" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<span class="min-w-0 flex-1 truncate" :class="undoMessage ? 'text-foreground' : 'text-muted-foreground'" role="status">{{
|
||||
undoMessage ?? noticeMessage
|
||||
}}</span>
|
||||
<button
|
||||
v-if="undoMessage"
|
||||
type="button"
|
||||
class="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs font-medium text-primary hover:bg-primary/10"
|
||||
@click="onUndo"
|
||||
>
|
||||
<Undo2 :size="13" aria-hidden="true" />
|
||||
{{ t('library.folderBrowser.notice.undo') }}
|
||||
</button>
|
||||
</div>
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-card px-3 pb-3 pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded-md py-1 pe-2 ps-1 text-start hover:bg-muted"
|
||||
:aria-expanded="sheetOpen"
|
||||
@click="toggleSheet"
|
||||
>
|
||||
<span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/12 text-xs font-semibold tabular-nums text-primary">{{
|
||||
selected.length
|
||||
}}</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-medium text-foreground">
|
||||
{{ selected.length ? (multiple ? countLabel(totalBooks) : selected[0]) : t('library.folderBrowser.footer.nothingPicked') }}
|
||||
</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ t('library.folderBrowser.footer.seePlan') }}</span>
|
||||
</span>
|
||||
<ChevronUp :size="15" class="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="selected.length === 0"
|
||||
@click="confirm"
|
||||
>
|
||||
<Check :size="16" aria-hidden="true" />
|
||||
{{ addLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
<div v-if="sheetOpen" class="absolute inset-0 z-40 flex flex-col justify-end bg-scrim" @click.self="closeSheet">
|
||||
<div
|
||||
class="flex h-[82%] flex-col overflow-hidden rounded-t-2xl border-t border-border bg-background shadow-2xl"
|
||||
role="dialog"
|
||||
:aria-label="t('library.folderBrowser.plan.label')"
|
||||
>
|
||||
<div class="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted" aria-hidden="true" />
|
||||
<FolderPickerPlan :width="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CircleCheck, CircleX, FolderOpen, FolderPlus, FolderSearch, Info, Loader2, Plus, RefreshCw, Trash2, TriangleAlert } from '@lucide/vue'
|
||||
import type { LibraryStats, LibraryType } from '@bookorbit/types'
|
||||
import type { LibraryStats, LibraryType, OrganizationMode } from '@bookorbit/types'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import LibraryFormatBar from '@/features/settings/libraries/components/LibraryFormatBar.vue'
|
||||
import type { FolderCheck } from '../composables/useLibraryCreator'
|
||||
@@ -20,14 +20,28 @@ const props = withDefaults(
|
||||
libraryType: LibraryType
|
||||
checks?: Record<string, FolderCheck>
|
||||
stats?: LibraryStats | null
|
||||
libraryName?: string
|
||||
libraryIcon?: string | null
|
||||
libraryId?: number | null
|
||||
organizationMode?: OrganizationMode
|
||||
organizationModeLocked?: boolean
|
||||
}>(),
|
||||
{ checks: () => ({}), stats: null },
|
||||
{
|
||||
checks: () => ({}),
|
||||
stats: null,
|
||||
libraryName: '',
|
||||
libraryIcon: null,
|
||||
libraryId: null,
|
||||
organizationMode: 'book_per_folder',
|
||||
organizationModeLocked: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:folders': [value: string[]]
|
||||
'update:localFolders': [value: string[]]
|
||||
'update:pickerOpen': [value: boolean]
|
||||
'update:organizationMode': [value: OrganizationMode]
|
||||
check: [paths: string[]]
|
||||
}>()
|
||||
|
||||
@@ -111,6 +125,10 @@ function handleNestedPickerChange(value: boolean) {
|
||||
function addBrowsedFolders(paths: string[]) {
|
||||
addSelectedFolders(podcast.value ? paths.slice(0, 1) : paths)
|
||||
}
|
||||
|
||||
function handleOrganizationMode(value: OrganizationMode) {
|
||||
emit('update:organizationMode', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -263,10 +281,26 @@ function addBrowsedFolders(paths: string[]) {
|
||||
<LibraryCreatorLocalFolders
|
||||
v-if="podcast"
|
||||
:local-folders="localFolders"
|
||||
:library-name="libraryName"
|
||||
:library-icon="libraryIcon"
|
||||
:library-id="libraryId"
|
||||
@update:local-folders="handleLocalFoldersUpdate"
|
||||
@update:picker-open="handleNestedPickerChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FolderPickerModal v-if="pickerOpen" :selected-paths="folders" @select="addBrowsedFolders" @close="closePicker" />
|
||||
<FolderPickerModal
|
||||
v-if="pickerOpen"
|
||||
:selected-paths="folders"
|
||||
:multiple="!podcast"
|
||||
:kind="podcast ? 'podcastStorage' : 'books'"
|
||||
:library-name="libraryName"
|
||||
:library-icon="libraryIcon"
|
||||
:library-id="libraryId"
|
||||
:organization-mode="organizationMode"
|
||||
:organization-mode-locked="organizationModeLocked"
|
||||
@select="addBrowsedFolders"
|
||||
@close="closePicker"
|
||||
@update:organization-mode="handleOrganizationMode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,9 +6,15 @@ import FolderPickerModal from './FolderPickerModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
localFolders: string[]
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
localFolders: string[]
|
||||
libraryName?: string
|
||||
libraryIcon?: string | null
|
||||
libraryId?: number | null
|
||||
}>(),
|
||||
{ libraryName: '', libraryIcon: null, libraryId: null },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:localFolders': [value: string[]]
|
||||
@@ -130,5 +136,14 @@ const {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FolderPickerModal v-if="pickerOpen" :selected-paths="localFolders" @select="addBrowsedFolders" @close="closePicker" />
|
||||
<FolderPickerModal
|
||||
v-if="pickerOpen"
|
||||
:selected-paths="localFolders"
|
||||
kind="podcastLocal"
|
||||
:library-name="libraryName"
|
||||
:library-icon="libraryIcon"
|
||||
:library-id="libraryId"
|
||||
@select="addBrowsedFolders"
|
||||
@close="closePicker"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -272,6 +272,11 @@ const sectionProps = computed(() => ({
|
||||
libraryType: form.type,
|
||||
checks: folderChecks.value,
|
||||
stats: stats.value,
|
||||
libraryName: form.name,
|
||||
libraryIcon: form.icon,
|
||||
libraryId: editingLibraryId.value,
|
||||
organizationMode: form.organizationMode,
|
||||
organizationModeLocked: !creating.value,
|
||||
},
|
||||
scanner: {
|
||||
organizationMode: form.organizationMode,
|
||||
|
||||
@@ -1,112 +1,372 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import FolderPickerModal from '../FolderPickerModal.vue'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}))
|
||||
const entry = (path: string, extra: Record<string, unknown> = {}) => ({
|
||||
name: path.slice(path.lastIndexOf('/') + 1),
|
||||
path,
|
||||
mount: false,
|
||||
system: false,
|
||||
appData: false,
|
||||
hasChildren: false,
|
||||
...extra,
|
||||
})
|
||||
const LISTINGS: Record<string, unknown[]> = {
|
||||
'/': [entry('/books', { mount: true, hasChildren: true }), entry('/data', { appData: true }), entry('/usr', { system: true, hasChildren: true })],
|
||||
'/books': [entry('/books/Fiction', { hasChildren: true }), entry('/books/Science Fiction'), entry('/books/Unsorted')],
|
||||
}
|
||||
const FILES: Record<string, number> = { '/books': 300, '/books/Fiction': 139, '/books/Science Fiction': 45, '/books/Unsorted': 12, '/data': 0 }
|
||||
|
||||
const tooltipStubs = {
|
||||
function json(body: unknown, status = 200) {
|
||||
return Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }))
|
||||
}
|
||||
|
||||
function summary(path: string) {
|
||||
const files = FILES[path] ?? 0
|
||||
return {
|
||||
path,
|
||||
state: 'ok',
|
||||
files,
|
||||
folderBooks: files,
|
||||
formats: files ? { epub: files } : {},
|
||||
folders: 0,
|
||||
mergedFolders: path === '/books/Unsorted' ? 2 : 0,
|
||||
splitAudioFolders: 0,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
function server(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const url = new URL(String(input), 'http://localhost')
|
||||
const path = url.searchParams.get('path') ?? ''
|
||||
if (url.pathname === '/api/v1/path/places')
|
||||
return json({
|
||||
root: '/',
|
||||
mounts: ['/books'],
|
||||
appDataPath: '/data',
|
||||
libraries: [{ id: 7, name: 'Novels', icon: 'BookOpen', type: 'books', folders: ['/books/Fiction'] }],
|
||||
})
|
||||
if (url.pathname === '/api/v1/path/listing')
|
||||
return json({ path, state: path === '/proc' ? 'blocked' : 'ok', entries: LISTINGS[path] ?? [], truncated: false })
|
||||
if (url.pathname === '/api/v1/path/search') return json({ results: ['/books/Fiction', '/books/Unsorted'], truncated: false })
|
||||
if (url.pathname === '/api/v1/path/summaries') return json((JSON.parse(String(init?.body)).paths as string[]).map(summary))
|
||||
if (url.pathname === '/api/v1/path/inspect') return json({ ...summary(path), bytes: 2048, samples: ['Ann Leckie/Ancillary Justice.epub'] })
|
||||
if (url.pathname === '/api/v1/path/unclaimed')
|
||||
return json({ folders: [{ path: '/books/Science Fiction', files: 45, formats: { epub: 45 } }], truncated: false })
|
||||
if (url.pathname === '/api/v1/path' && init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body))
|
||||
return json({ name: body.name, path: `${body.parentPath}/${body.name}` })
|
||||
}
|
||||
return json({}, 404)
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
Teleport: true,
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
RecycleScroller: {
|
||||
props: ['items'],
|
||||
template: '<div role="tree"><template v-for="item in items" :key="item.key"><slot :item="item" /></template><slot name="after" /></div>',
|
||||
},
|
||||
}
|
||||
|
||||
function useViewport(desktop: boolean) {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn((query: string) => ({
|
||||
matches: desktop && query.includes('min-width: 640px'),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function pressEscape(target: EventTarget = window) {
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
function mountPicker(props: Record<string, unknown> = {}) {
|
||||
return mount(FolderPickerModal, { props: { libraryName: 'Science Fiction', ...props }, global: { stubs }, attachTo: document.body })
|
||||
}
|
||||
|
||||
const row = (wrapper: ReturnType<typeof mountPicker>, path: string) => wrapper.find(`[role="treeitem"][data-path="${path}"]`)
|
||||
const button = (wrapper: ReturnType<typeof mountPicker>, text: string) => {
|
||||
const found = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text)
|
||||
if (!found) throw new Error(`Button not found: ${text}`)
|
||||
return found
|
||||
}
|
||||
|
||||
async function settle(ms = 60) {
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('FolderPickerModal', () => {
|
||||
beforeEach(() => {
|
||||
apiMock.mockReset()
|
||||
apiMock.mockImplementation(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url === '/api/v1/path/config') return jsonResponse({ root: '/books' })
|
||||
if (url === '/api/v1/path?path=%2Fbooks') return jsonResponse([{ name: 'Sci-Fi', path: '/books/Sci-Fi' }])
|
||||
if (url === '/api/v1/path?path=%2Fbooks%2FSci-Fi') return jsonResponse([])
|
||||
if (url === '/api/v1/path' && init?.method === 'POST') return jsonResponse({ name: 'New', path: '/books/New' })
|
||||
return jsonResponse({}, 404)
|
||||
})
|
||||
apiMock.mockImplementation(server)
|
||||
localStorage.clear()
|
||||
useViewport(true)
|
||||
})
|
||||
|
||||
it('loads the configured browse root before listing folders', async () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('opens on the first mount, with library landmarks, the data folder marked and system folders hidden', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await flushPromises()
|
||||
await settle()
|
||||
|
||||
expect(apiMock).toHaveBeenNthCalledWith(1, '/api/v1/path/config')
|
||||
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/v1/path?path=%2Fbooks')
|
||||
expect(wrapper.text()).toContain('/books')
|
||||
expect(wrapper.text()).toContain('Sci-Fi')
|
||||
expect(wrapper.find('h2').text()).toBe('Choose folders')
|
||||
expect(wrapper.text()).toContain('For Science Fiction · Folder as Book')
|
||||
expect(row(wrapper, '/books').attributes('aria-expanded')).toBe('true')
|
||||
expect(row(wrapper, '/books/Fiction').text()).toContain('Novels')
|
||||
expect(row(wrapper, '/data').text()).toContain('BookOrbit data')
|
||||
expect(row(wrapper, '/usr').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('1 system folder hidden at /')
|
||||
expect(wrapper.text()).toContain('Not in any library yet')
|
||||
})
|
||||
|
||||
it('does not navigate above the configured browse root', async () => {
|
||||
it('adds the ticked folders and remembers where they were', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(button(wrapper, 'Add folders').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.get('button[aria-label="Open Sci-Fi"]').trigger('click')
|
||||
await flushPromises()
|
||||
await (wrapper.vm as unknown as { goUp: () => Promise<void> }).goUp()
|
||||
await row(wrapper, '/books/Science Fiction').find('input').trigger('click')
|
||||
await settle()
|
||||
await button(wrapper, 'Add 1 folder').trigger('click')
|
||||
|
||||
expect((wrapper.vm as unknown as { currentPath: string }).currentPath).toBe('/books')
|
||||
expect(wrapper.emitted('select')).toEqual([[['/books/Science Fiction']]])
|
||||
expect(JSON.parse(localStorage.getItem('bookorbit:folder-picker:recent') ?? '[]')).toEqual(['/books'])
|
||||
})
|
||||
|
||||
it('selects multiple folders without navigating into them', async () => {
|
||||
it('warns about an overlap on a ticked folder and offers to leave the other library’s folder out', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await flushPromises()
|
||||
await settle()
|
||||
|
||||
await folderRow(wrapper, 'Sci-Fi').get('input[type="checkbox"]').setValue(true)
|
||||
await buttonByText(wrapper, 'Add 1 folder').trigger('click')
|
||||
await row(wrapper, '/books').find('input').trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([[['/books/Sci-Fi']]])
|
||||
expect((wrapper.vm as unknown as { currentPath: string }).currentPath).toBe('/books')
|
||||
expect(wrapper.text()).toContain('Contains Fiction, which Novels reads.')
|
||||
await button(wrapper, 'Leave out Fiction').trigger('click')
|
||||
await settle()
|
||||
await button(wrapper, 'Add 2 folders').trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([[['/books/Science Fiction', '/books/Unsorted']]])
|
||||
})
|
||||
|
||||
it('marks folders that are already part of the library', async () => {
|
||||
const wrapper = mountPicker(['/books/Sci-Fi'])
|
||||
await flushPromises()
|
||||
|
||||
const row = folderRow(wrapper, 'Sci-Fi')
|
||||
expect(row.text()).toContain('Already added')
|
||||
expect(row.get('input[type="checkbox"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('creates folders under the configured browse root', async () => {
|
||||
it('shows the books each mode would import and switches the library’s mode', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await flushPromises()
|
||||
await settle()
|
||||
await row(wrapper, '/books/Unsorted').find('input').trigger('click')
|
||||
await settle()
|
||||
|
||||
await buttonByText(wrapper, 'New folder').trigger('click')
|
||||
await wrapper.get('input[placeholder="New folder name"]').setValue('New')
|
||||
await buttonByText(wrapper, 'Create').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('2 folders here hold several books')
|
||||
await button(wrapper, 'Use File as Book').trigger('click')
|
||||
|
||||
const createCall = apiMock.mock.calls.find(([url, init]) => String(url) === '/api/v1/path' && init?.method === 'POST')
|
||||
expect(JSON.parse(createCall?.[1]?.body as string)).toEqual({ parentPath: '/books', name: 'New' })
|
||||
expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file']])
|
||||
expect(wrapper.text()).toContain('Switched to File as Book')
|
||||
await button(wrapper, 'Undo').trigger('click')
|
||||
expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file'], ['book_per_folder']])
|
||||
})
|
||||
|
||||
it('puts the organization back when the picker is cancelled after switching it', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await row(wrapper, '/books/Unsorted').find('input').trigger('click')
|
||||
await settle()
|
||||
await button(wrapper, 'Use File as Book').trigger('click')
|
||||
|
||||
await button(wrapper, 'Cancel').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:organizationMode')).toEqual([['book_per_file'], ['book_per_folder']])
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes the View menu with Escape, not the picker', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await wrapper.find('[aria-label="View options"]').trigger('click')
|
||||
await settle()
|
||||
expect(wrapper.find('[aria-label="View options"]').attributes('aria-expanded')).toBe('true')
|
||||
|
||||
pressEscape()
|
||||
await settle()
|
||||
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('on a phone, closes the plan sheet with Escape, not the picker', async () => {
|
||||
useViewport(false)
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await wrapper
|
||||
.findAll('button[aria-expanded]')
|
||||
.find((candidate) => candidate.text().includes('See the plan'))!
|
||||
.trigger('click')
|
||||
expect(wrapper.find('[role="dialog"] [role="dialog"]').exists()).toBe(true)
|
||||
|
||||
pressEscape(wrapper.find('[role="dialog"] [role="dialog"]').element)
|
||||
await settle()
|
||||
|
||||
expect(wrapper.find('[role="dialog"] [role="dialog"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ticks a Shift range only across the folders a find shows', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await wrapper.find('#folder-picker-field').setValue('o')
|
||||
await new Promise((resolve) => setTimeout(resolve, 260))
|
||||
await settle()
|
||||
|
||||
await row(wrapper, '/books/Fiction').find('input').trigger('click')
|
||||
await row(wrapper, '/books/Unsorted').find('input').trigger('click', { shiftKey: true })
|
||||
await settle()
|
||||
|
||||
expect(row(wrapper, '/books/Science Fiction').exists()).toBe(false)
|
||||
await button(wrapper, 'Add 2 folders').trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([[['/books/Fiction', '/books/Unsorted']]])
|
||||
})
|
||||
|
||||
it('leaves Enter on the hidden-system-folders button to that button, not the focused folder', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await row(wrapper, '/books/Unsorted').find('[data-row-focus]').trigger('click')
|
||||
|
||||
await button(wrapper, '1 system folder hidden at /').trigger('keydown', { key: 'Enter' })
|
||||
await settle()
|
||||
|
||||
expect(button(wrapper, 'Add folders').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('makes the first folder reachable with Tab before any is focused', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
|
||||
const stops = wrapper.findAll('[role="treeitem"] [data-row-focus]').filter((focus) => focus.attributes('tabindex') === '0')
|
||||
expect(stops.map((focus) => focus.element.closest('[role="treeitem"]')!.getAttribute('data-path'))).toEqual(['/books'])
|
||||
})
|
||||
|
||||
it('shows the mode as fixed when editing a library', async () => {
|
||||
const wrapper = mountPicker({ organizationModeLocked: true, libraryName: 'Novels', selectedPaths: ['/books/Fiction'], libraryId: 7 })
|
||||
await settle()
|
||||
await row(wrapper, '/books/Unsorted').find('input').trigger('click')
|
||||
await settle()
|
||||
|
||||
expect(wrapper.text()).toContain('Folder as Book, fixed since Novels was created')
|
||||
expect(wrapper.text()).toContain('Organization is fixed once a library exists.')
|
||||
expect(wrapper.findAll('button').some((candidate) => candidate.text().includes('Use File as Book'))).toBe(false)
|
||||
expect(row(wrapper, '/books/Fiction').text()).toContain('This library')
|
||||
})
|
||||
|
||||
it('previews a focused folder before it is ticked', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
|
||||
await row(wrapper, '/books/Science Fiction').find('[data-row-focus]').trigger('click')
|
||||
await settle(200)
|
||||
|
||||
expect(wrapper.text()).toContain('Looking at')
|
||||
expect(wrapper.text()).toContain('Would add 45 books')
|
||||
expect(wrapper.text()).toContain('Ann Leckie/Ancillary Justice.epub')
|
||||
})
|
||||
|
||||
it('chooses one storage folder for podcasts', async () => {
|
||||
const wrapper = mountPicker({ kind: 'podcastStorage', multiple: false, libraryName: 'History Podcasts' })
|
||||
await settle()
|
||||
|
||||
expect(wrapper.find('h2').text()).toBe('Choose a storage folder')
|
||||
expect(row(wrapper, '/books/Unsorted').find('input').attributes('type')).toBe('radio')
|
||||
await row(wrapper, '/books/Unsorted').find('input').trigger('click')
|
||||
await row(wrapper, '/books/Science Fiction').find('input').trigger('click')
|
||||
await settle()
|
||||
await button(wrapper, 'Use folder').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([[['/books/Science Fiction']]])
|
||||
})
|
||||
|
||||
it('completes a typed path with Tab and closes the list with Escape without closing the dialog', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
const field = wrapper.find('#folder-picker-field')
|
||||
|
||||
await field.setValue('/books/Sc')
|
||||
await settle()
|
||||
await field.trigger('keydown', { key: 'Tab' })
|
||||
await settle()
|
||||
expect((field.element as HTMLInputElement).value).toBe('/books/Science Fiction/')
|
||||
|
||||
await field.trigger('keydown', { key: 'Escape' })
|
||||
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('says a typed system path is never opened instead of jumping to it', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
const field = wrapper.find('#folder-picker-field')
|
||||
|
||||
await field.setValue('/proc')
|
||||
await field.trigger('keydown', { key: 'Enter' })
|
||||
await settle()
|
||||
|
||||
expect(wrapper.text()).toContain('BookOrbit never opens /proc: it is a system folder.')
|
||||
expect(field.attributes('aria-activedescendant')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('makes a new folder inside the focused one and ticks it', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await row(wrapper, '/books/Fiction').find('[data-row-focus]').trigger('click')
|
||||
await settle()
|
||||
|
||||
await button(wrapper, 'New folder').trigger('click')
|
||||
await settle()
|
||||
const input = wrapper.find('#folder-picker-new-folder')
|
||||
await input.setValue('Fantasy')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await settle()
|
||||
|
||||
const create = apiMock.mock.calls.find(([url, init]) => String(url) === '/api/v1/path' && init?.method === 'POST')
|
||||
expect(JSON.parse(String(create?.[1]?.body))).toEqual({ parentPath: '/books/Fiction', name: 'Fantasy' })
|
||||
expect(wrapper.text()).toContain('Made /books/Fiction/Fantasy and ticked it.')
|
||||
expect(button(wrapper, 'Add 1 folder').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('says a new folder inside this library’s folder was made, without claiming it was ticked', async () => {
|
||||
const wrapper = mountPicker({ selectedPaths: ['/books/Fiction'], libraryId: 7, libraryName: 'Novels' })
|
||||
await settle()
|
||||
await row(wrapper, '/books/Fiction').find('[data-row-focus]').trigger('click')
|
||||
await settle()
|
||||
|
||||
await button(wrapper, 'New folder').trigger('click')
|
||||
await settle()
|
||||
const input = wrapper.find('#folder-picker-new-folder')
|
||||
await input.setValue('Fantasy')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await settle()
|
||||
|
||||
expect(wrapper.text()).toContain('Made /books/Fiction/Fantasy.')
|
||||
expect(wrapper.text()).not.toContain('and ticked it')
|
||||
})
|
||||
|
||||
it('refuses a folder name with a slash before asking the server', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await settle()
|
||||
await button(wrapper, 'New folder').trigger('click')
|
||||
await settle()
|
||||
const input = wrapper.find('#folder-picker-new-folder')
|
||||
await input.setValue('a/b')
|
||||
await input.trigger('keydown', { key: 'Enter' })
|
||||
await settle()
|
||||
|
||||
expect(wrapper.text()).toContain('Use one folder name, without slashes or a leading dot.')
|
||||
expect(apiMock.mock.calls.some(([url, init]) => String(url) === '/api/v1/path' && init?.method === 'POST')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function mountPicker(selectedPaths: string[] = []) {
|
||||
return mount(FolderPickerModal, {
|
||||
props: { selectedPaths },
|
||||
global: {
|
||||
stubs: tooltipStubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function buttonByText(wrapper: ReturnType<typeof mountPicker>, text: string) {
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text)
|
||||
if (!button) throw new Error(`Button not found: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
function folderRow(wrapper: ReturnType<typeof mountPicker>, text: string) {
|
||||
const row = wrapper.findAll('label').find((candidate) => candidate.text().includes(text))
|
||||
if (!row) throw new Error(`Folder row not found: ${text}`)
|
||||
return row
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatColorVar } from '@/features/book/lib/format-colors'
|
||||
import FolderFormatLegend from './FolderFormatLegend.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ formats: Record<string, number>; limit?: number }>(), { limit: 6 })
|
||||
|
||||
const segments = computed(() => {
|
||||
const sorted = Object.entries(props.formats).sort((a, b) => b[1] - a[1])
|
||||
const total = sorted.reduce((sum, [, count]) => sum + count, 0) || 1
|
||||
return sorted.map(([format, count]) => ({ format, width: `${(count / total) * 100}%`, color: formatColorVar(format) }))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex h-1 w-full gap-px overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||
<span
|
||||
v-for="segment in segments"
|
||||
:key="segment.format"
|
||||
class="h-full first:rounded-s-full last:rounded-e-full"
|
||||
:style="{ width: segment.width, backgroundColor: segment.color }"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="segments.length" class="mt-1.5"><FolderFormatLegend :formats="formats" :limit="limit" /></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatColorVar } from '@/features/book/lib/format-colors'
|
||||
|
||||
const props = withDefaults(defineProps<{ formats: Record<string, number>; limit?: number; nowrap?: boolean; more?: boolean }>(), {
|
||||
limit: 3,
|
||||
nowrap: false,
|
||||
more: true,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sorted = computed(() => Object.entries(props.formats).sort((a, b) => b[1] - a[1]))
|
||||
const shown = computed(() => sorted.value.slice(0, props.limit))
|
||||
const hidden = computed(() => (props.more ? sorted.value.length - shown.value.length : 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="flex items-center gap-x-2.5 gap-y-1 text-xs text-muted-foreground"
|
||||
:class="nowrap ? 'min-w-0 flex-nowrap overflow-hidden' : 'flex-wrap'"
|
||||
>
|
||||
<span v-for="[format, count] in shown" :key="format" class="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span class="size-1.5 shrink-0 rounded-[2px]" :style="{ backgroundColor: formatColorVar(format) }" aria-hidden="true" />
|
||||
{{ format.toUpperCase() }} <span class="font-medium tabular-nums text-foreground">{{ formatNumber(count) }}</span>
|
||||
</span>
|
||||
<span v-if="hidden > 0" class="whitespace-nowrap">{{ t('library.folderBrowser.count.more', { count: hidden }) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { normalizeFolderPath } from '../../composables/folder-paths'
|
||||
import { leafName, parentPath } from '../../composables/folder-picker/folder-picker-rules'
|
||||
|
||||
const props = withDefaults(defineProps<{ path: string; textClass?: string }>(), { textClass: 'text-[12.5px]' })
|
||||
|
||||
const normalized = computed(() => normalizeFolderPath(props.path))
|
||||
const parent = computed(() => {
|
||||
if (normalized.value === '/') return ''
|
||||
const above = parentPath(normalized.value)
|
||||
return above === '/' ? '/' : `${above}/`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The parent gives way first; the folder name only truncates once it alone is too wide. -->
|
||||
<span class="flex min-w-0 max-w-full font-mono" :class="textClass" dir="ltr" :title="normalized">
|
||||
<span class="min-w-0 truncate text-path-dim">{{ parent }}</span>
|
||||
<span class="max-w-full shrink-0 truncate font-semibold text-foreground">{{ leafName(normalized) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { PLAN_WIDTH } from '../../composables/folder-picker/usePickerPlanWidth'
|
||||
|
||||
const props = defineProps<{
|
||||
width: number
|
||||
/** Width of the grid the divider splits, for the limits. */
|
||||
gridWidth: () => number
|
||||
clamp: (value: number, gridWidth: number) => number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:width': [value: number]
|
||||
commit: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const handle = ref<HTMLElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
let stopDrag: (() => void) | null = null
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
const element = handle.value
|
||||
const grid = element?.parentElement
|
||||
if (!element || !grid) return
|
||||
event.preventDefault()
|
||||
element.focus({ preventScroll: true })
|
||||
element.setPointerCapture(event.pointerId)
|
||||
dragging.value = true
|
||||
const rect = grid.getBoundingClientRect()
|
||||
const scale = rect.width / grid.offsetWidth || 1
|
||||
const rtl = getComputedStyle(grid).direction === 'rtl'
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const fromEnd = rtl ? moveEvent.clientX - rect.left : rect.right - moveEvent.clientX
|
||||
emit('update:width', props.clamp(fromEnd / scale, grid.offsetWidth))
|
||||
}
|
||||
const end = () => {
|
||||
element.removeEventListener('pointermove', move)
|
||||
element.removeEventListener('pointerup', end)
|
||||
element.removeEventListener('pointercancel', end)
|
||||
dragging.value = false
|
||||
stopDrag = null
|
||||
emit('commit')
|
||||
}
|
||||
element.addEventListener('pointermove', move)
|
||||
element.addEventListener('pointerup', end)
|
||||
element.addEventListener('pointercancel', end)
|
||||
stopDrag = end
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
emit('update:width', props.clamp(PLAN_WIDTH.default, props.gridWidth()))
|
||||
emit('commit')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const rtl = getComputedStyle(event.currentTarget as HTMLElement).direction === 'rtl'
|
||||
const grow = ({ ArrowLeft: rtl ? -1 : 1, ArrowRight: rtl ? 1 : -1 } as Record<string, number>)[event.key]
|
||||
let next: number
|
||||
if (grow) next = props.width + grow * PLAN_WIDTH.step
|
||||
else if (event.key === 'Home') next = PLAN_WIDTH.min
|
||||
else if (event.key === 'End') next = Number.POSITIVE_INFINITY
|
||||
else if (event.key === 'Enter') next = PLAN_WIDTH.default
|
||||
else return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('update:width', props.clamp(next, props.gridWidth()))
|
||||
emit('commit')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => stopDrag?.())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Sits on the border the halves already share: a faint grip at rest, a line on hover, focus or drag. -->
|
||||
<div
|
||||
ref="handle"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-orientation="vertical"
|
||||
:aria-label="t('library.folderBrowser.divider.label')"
|
||||
:aria-valuemin="PLAN_WIDTH.min"
|
||||
:aria-valuemax="Math.max(PLAN_WIDTH.min, gridWidth() - PLAN_WIDTH.treeMin)"
|
||||
:aria-valuenow="width"
|
||||
:title="t('library.folderBrowser.divider.title')"
|
||||
class="group absolute inset-y-0 z-20 w-2 cursor-col-resize touch-none focus-visible:outline-none"
|
||||
:data-dragging="dragging ? '' : undefined"
|
||||
:style="{ insetInlineEnd: `${width - 4}px` }"
|
||||
@pointerdown="onPointerDown"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 start-1/2 w-px -translate-x-1/2 transition-[width,background-color] duration-150 group-hover:w-[3px] group-hover:bg-primary/60 group-focus-visible:w-[3px] group-focus-visible:bg-primary group-data-[dragging]:w-[3px] group-data-[dragging]:bg-primary"
|
||||
/>
|
||||
<span
|
||||
class="absolute start-1/2 top-1/2 h-9 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-background transition-colors group-hover:border-primary/60 group-data-[dragging]:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,413 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Clock, CornerDownLeft, Folder, HardDrive, Search, X } from '@lucide/vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { normalizeFolderPath } from '../../composables/folder-paths'
|
||||
import { isAtOrInside, leafName, parentPath } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import { readRecentFolders } from '../../composables/folder-picker/recent-folders'
|
||||
import PickerKbd from './PickerKbd.vue'
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
path: string
|
||||
section?: string
|
||||
title: string
|
||||
sub: string
|
||||
/** Title is a name (sans) rather than a path (mono). */
|
||||
named?: boolean
|
||||
icon: 'mount' | 'folder' | 'recent' | 'library'
|
||||
libraryIcon?: string | null
|
||||
}
|
||||
|
||||
const SEARCH_DELAY_MS = 220
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
const tree = picker.tree
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const active = ref(0)
|
||||
const pathError = ref<string | null>(null)
|
||||
const query = picker.findQuery
|
||||
const recent = readRecentFolders()
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchAbort: AbortController | null = null
|
||||
|
||||
const mode = computed<'empty' | 'path' | 'find'>(() => (query.value.trim().startsWith('/') ? 'path' : query.value.trim() ? 'find' : 'empty'))
|
||||
|
||||
const completionBase = computed(() => {
|
||||
const typed = query.value.trim()
|
||||
return typed.endsWith('/') ? normalizeFolderPath(typed) : parentPath(typed)
|
||||
})
|
||||
|
||||
const options = computed<Option[]>(() => {
|
||||
if (mode.value === 'path') {
|
||||
const typed = query.value.trim()
|
||||
const stem = typed.endsWith('/') ? '' : leafName(typed).toLowerCase()
|
||||
const listing = picker.data.listings.get(completionBase.value)
|
||||
return (listing?.entries ?? [])
|
||||
.filter((entry) => (tree.showSystem.value || !entry.system) && entry.name.toLowerCase().startsWith(stem))
|
||||
.slice(0, 8)
|
||||
.map((entry) => {
|
||||
const files = picker.data.summaries.get(normalizeFolderPath(entry.path))?.files
|
||||
return {
|
||||
id: entry.path,
|
||||
path: normalizeFolderPath(entry.path),
|
||||
title: entry.name,
|
||||
sub: files ? picker.countLabel(files) : '',
|
||||
named: true,
|
||||
icon: entry.mount ? 'mount' : 'folder',
|
||||
}
|
||||
})
|
||||
}
|
||||
if (mode.value !== 'empty') return []
|
||||
const places = picker.data.places.value
|
||||
const out: Option[] = []
|
||||
const books = (path: string) => {
|
||||
const files = picker.data.summaries.get(normalizeFolderPath(path))?.files
|
||||
return files === undefined ? '' : picker.countLabel(files)
|
||||
}
|
||||
for (const mount of places?.mounts ?? []) {
|
||||
if (places?.appDataPath && isAtOrInside(mount, places.appDataPath)) continue
|
||||
out.push({ id: `mount:${mount}`, path: mount, section: t('library.folderBrowser.field.mounted'), title: mount, sub: books(mount), icon: 'mount' })
|
||||
}
|
||||
for (const library of picker.libraries.value) {
|
||||
const folder = library.folders[0]
|
||||
if (!folder) continue
|
||||
const title = library.self ? t('library.folderBrowser.field.thisLibrary', { name: library.name }) : library.name
|
||||
out.push({
|
||||
id: `library:${library.id ?? 'self'}`,
|
||||
path: normalizeFolderPath(folder),
|
||||
section: t('library.folderBrowser.field.libraries'),
|
||||
title,
|
||||
sub: folder,
|
||||
named: true,
|
||||
icon: 'library',
|
||||
libraryIcon: library.icon,
|
||||
})
|
||||
}
|
||||
for (const path of recent.filter((folder) => !places || isAtOrInside(folder, places.root))) {
|
||||
out.push({ id: `recent:${path}`, path, section: t('library.folderBrowser.field.recent'), title: path, sub: books(path), icon: 'recent' })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** The options in their sections, for the listbox's groups. */
|
||||
const groups = computed(() => {
|
||||
const out: Array<{ section: string; items: Array<{ option: Option; index: number }> }> = []
|
||||
options.value.forEach((option, index) => {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.section === (option.section ?? '')) last.items.push({ option, index })
|
||||
else out.push({ section: option.section ?? '', items: [{ option, index }] })
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
const listed = computed(() => open.value && mode.value !== 'find' && (options.value.length > 0 || mode.value === 'path'))
|
||||
const activeIndex = computed(() => Math.min(active.value, Math.max(0, options.value.length - 1)))
|
||||
const showsOptions = computed(() => listed.value && !pathError.value && options.value.length > 0)
|
||||
const activeId = computed(() => (showsOptions.value ? `folder-picker-option-${activeIndex.value}` : undefined))
|
||||
|
||||
function openList() {
|
||||
if (open.value) return
|
||||
open.value = true
|
||||
active.value = 0
|
||||
if (mode.value === 'empty') picker.data.requestSummaries(options.value.map((option) => option.path))
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function reveal(path: string) {
|
||||
picker.reveal(path)
|
||||
query.value = ''
|
||||
pathError.value = null
|
||||
open.value = false
|
||||
input.value?.blur()
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
searchAbort?.abort()
|
||||
const text = query.value.trim()
|
||||
if (mode.value !== 'find') {
|
||||
tree.findResults.value = null
|
||||
tree.findTruncated.value = false
|
||||
return
|
||||
}
|
||||
searchAbort = new AbortController()
|
||||
const signal = searchAbort.signal
|
||||
void picker.data.searchFolders(text, signal, tree.showSystem.value).then((result) => {
|
||||
if (signal.aborted || query.value.trim() !== text) return
|
||||
tree.findResults.value = result?.results ?? []
|
||||
tree.findTruncated.value = result?.truncated ?? false
|
||||
picker.data.requestSummaries(tree.findResults.value)
|
||||
})
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
open.value = true
|
||||
active.value = 0
|
||||
pathError.value = null
|
||||
if (mode.value === 'path') void picker.data.ensureListing(completionBase.value)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(runSearch, mode.value === 'find' ? SEARCH_DELAY_MS : 0)
|
||||
}
|
||||
|
||||
async function submitPath() {
|
||||
const typed = normalizeFolderPath(query.value)
|
||||
const listing = await picker.data.ensureListing(typed)
|
||||
if (listing?.state === 'blocked') {
|
||||
pathError.value = t('library.folderBrowser.field.pathBlocked', { path: typed })
|
||||
return
|
||||
}
|
||||
if (listing && listing.state !== 'missing') return reveal(typed)
|
||||
const option = options.value[activeIndex.value]
|
||||
if (option) return reveal(option.path)
|
||||
const siblings = (await picker.data.ensureListing(parentPath(typed)))?.entries ?? []
|
||||
const near = siblings.find((entry) => entry.name.toLowerCase().startsWith(leafName(typed).slice(0, 3).toLowerCase()))
|
||||
pathError.value = near
|
||||
? t('library.folderBrowser.field.pathMissingSuggest', { path: typed, suggestion: normalizeFolderPath(near.path) })
|
||||
: t('library.folderBrowser.field.pathMissing', { path: typed })
|
||||
}
|
||||
|
||||
function goToFirstMatch() {
|
||||
const first = tree.rows.value.find((row) => row.hit)
|
||||
if (!first) return
|
||||
input.value?.blur()
|
||||
picker.focusFolder(first.path)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const count = options.value.length
|
||||
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && listed.value && count) {
|
||||
active.value = Math.max(0, Math.min(count - 1, activeIndex.value + (event.key === 'ArrowDown' ? 1 : -1)))
|
||||
} else if (event.key === 'ArrowDown' && mode.value === 'find') {
|
||||
goToFirstMatch()
|
||||
} else if (event.key === 'Tab' && mode.value === 'path' && count && !event.shiftKey) {
|
||||
query.value = `${options.value[activeIndex.value]!.path}/`
|
||||
onInput()
|
||||
} else if (event.key === 'Enter') {
|
||||
if (mode.value === 'empty' && listed.value && count) reveal(options.value[activeIndex.value]!.path)
|
||||
else if (mode.value === 'path') void submitPath()
|
||||
else if (mode.value === 'find') goToFirstMatch()
|
||||
} else if (event.key === 'Escape') {
|
||||
if (open.value && mode.value !== 'find') close()
|
||||
else {
|
||||
query.value = ''
|
||||
onInput()
|
||||
close()
|
||||
input.value?.blur()
|
||||
}
|
||||
event.stopPropagation()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function onOptionClick(option: Option) {
|
||||
reveal(option.path)
|
||||
}
|
||||
|
||||
function onOptionHover(index: number) {
|
||||
active.value = index
|
||||
}
|
||||
|
||||
function clear() {
|
||||
query.value = ''
|
||||
onInput()
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function onOutside(event: PointerEvent) {
|
||||
if (open.value && !root.value?.contains(event.target as Node)) close()
|
||||
}
|
||||
|
||||
/** Focuses the field; with a path, starts typing from it. */
|
||||
function focus(prefill?: string) {
|
||||
if (prefill !== undefined) {
|
||||
query.value = prefill
|
||||
onInput()
|
||||
}
|
||||
input.value?.focus()
|
||||
openList()
|
||||
}
|
||||
|
||||
watch(mode, (next) => {
|
||||
if (next !== 'find') {
|
||||
tree.findResults.value = null
|
||||
tree.findTruncated.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Showing system folders widens what a find can match.
|
||||
watch(
|
||||
() => tree.showSystem.value,
|
||||
() => {
|
||||
if (mode.value === 'find') runSearch()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => document.addEventListener('pointerdown', onOutside, true))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onOutside, true)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchAbort?.abort()
|
||||
})
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative min-w-0 flex-1">
|
||||
<label class="flex h-9 items-center gap-2 rounded-md border border-input bg-background px-2.5 focus-within:ring-2 focus-within:ring-ring/60">
|
||||
<CornerDownLeft v-if="mode === 'path'" :size="15" class="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<Search v-else :size="15" class="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
id="folder-picker-field"
|
||||
ref="input"
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="folder-picker-options"
|
||||
:aria-expanded="listed"
|
||||
:aria-activedescendant="activeId"
|
||||
:aria-label="t('library.folderBrowser.field.label')"
|
||||
class="min-w-0 flex-1 bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||
:class="mode === 'path' ? 'font-mono text-[13px]' : 'text-sm'"
|
||||
:placeholder="t('library.folderBrowser.field.placeholder')"
|
||||
@input="onInput"
|
||||
@focus="openList"
|
||||
@click="openList"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button
|
||||
v-if="query"
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('library.folderBrowser.field.clear')"
|
||||
@click="clear"
|
||||
>
|
||||
<X :size="13" />
|
||||
</button>
|
||||
<PickerKbd v-else-if="!picker.phone.value">/</PickerKbd>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="listed"
|
||||
class="absolute inset-x-0 top-full z-30 mt-1 max-h-80 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
>
|
||||
<p v-if="pathError" class="px-2 py-1.5 text-xs text-destructive" role="alert">{{ pathError }}</p>
|
||||
<template v-else-if="mode === 'path'">
|
||||
<i18n-t
|
||||
v-if="!options.length"
|
||||
keypath="library.folderBrowser.field.noCompletion"
|
||||
tag="p"
|
||||
scope="global"
|
||||
class="px-2 py-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<template #path>{{ completionBase }}</template>
|
||||
<template #key><PickerKbd>↵</PickerKbd></template>
|
||||
</i18n-t>
|
||||
<template v-else>
|
||||
<p class="px-2 pb-1 pt-1.5 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground" aria-hidden="true">
|
||||
{{ t('library.folderBrowser.field.inFolder', { path: completionBase }) }}
|
||||
</p>
|
||||
<div id="folder-picker-options" role="listbox" :aria-label="t('library.folderBrowser.field.inFolder', { path: completionBase })">
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:id="`folder-picker-option-${index}`"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
:aria-selected="index === activeIndex"
|
||||
class="flex w-full items-center gap-2.5 rounded-sm px-2 py-1.5 text-start"
|
||||
:class="index === activeIndex ? 'bg-muted' : 'hover:bg-muted/60'"
|
||||
@mouseenter="onOptionHover(index)"
|
||||
@click="onOptionClick(option)"
|
||||
>
|
||||
<HardDrive v-if="option.icon === 'mount'" :size="15" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<Folder v-else :size="15" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">{{ option.title }}</span>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ option.sub }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<i18n-t
|
||||
keypath="library.folderBrowser.field.completeHint"
|
||||
tag="p"
|
||||
scope="global"
|
||||
class="flex flex-wrap items-center gap-1 border-t border-border px-2 pb-1 pt-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<template #tab
|
||||
><PickerKbd>{{ t('library.folderBrowser.keys.tab') }}</PickerKbd></template
|
||||
>
|
||||
<template #enter><PickerKbd>↵</PickerKbd></template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div id="folder-picker-options" role="listbox" :aria-label="t('library.folderBrowser.field.label')">
|
||||
<div
|
||||
v-for="(group, groupIndex) in groups"
|
||||
:key="group.section"
|
||||
role="group"
|
||||
:aria-labelledby="group.section ? `folder-picker-section-${groupIndex}` : undefined"
|
||||
>
|
||||
<p
|
||||
v-if="group.section"
|
||||
:id="`folder-picker-section-${groupIndex}`"
|
||||
class="px-2 pb-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
:class="groupIndex === 0 ? 'pt-1' : 'pt-2'"
|
||||
>
|
||||
{{ group.section }}
|
||||
</p>
|
||||
<button
|
||||
v-for="{ option, index } in group.items"
|
||||
:id="`folder-picker-option-${index}`"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
:aria-selected="index === activeIndex"
|
||||
class="flex w-full items-center gap-2.5 rounded-sm px-2 py-1.5 text-start"
|
||||
:class="index === activeIndex ? 'bg-muted' : 'hover:bg-muted/60'"
|
||||
@mouseenter="onOptionHover(index)"
|
||||
@click="onOptionClick(option)"
|
||||
>
|
||||
<HardDrive v-if="option.icon === 'mount'" :size="15" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<Clock v-else-if="option.icon === 'recent'" :size="15" class="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<AppIcon v-else :icon="option.libraryIcon" fallback="Library" :size="15" class="shrink-0 text-primary" />
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-foreground"
|
||||
:class="option.named ? 'text-sm' : 'font-mono text-[12.5px]'"
|
||||
:dir="option.named ? 'auto' : 'ltr'"
|
||||
>{{ option.title }}</span
|
||||
>
|
||||
<span
|
||||
class="max-w-[50%] shrink-0 truncate text-xs text-muted-foreground"
|
||||
:class="option.named ? 'font-mono' : 'tabular-nums'"
|
||||
dir="ltr"
|
||||
>{{ option.sub }}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<i18n-t
|
||||
keypath="library.folderBrowser.field.jumpHint"
|
||||
tag="p"
|
||||
scope="global"
|
||||
class="flex flex-wrap items-center gap-1 border-t border-border px-2 pb-1 pt-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<template #key><PickerKbd>/</PickerKbd></template>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { FolderPlus } from '@lucide/vue'
|
||||
import { leafName } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import type { TreeRow } from '../../composables/folder-picker/useFolderPickerTree'
|
||||
import PickerKbd from './PickerKbd.vue'
|
||||
|
||||
const props = defineProps<{ row: TreeRow }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const { name, error, busy } = picker.newFolder
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void picker.submitNewFolder()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
picker.cancelNewFolder()
|
||||
}
|
||||
}
|
||||
|
||||
function onCreate() {
|
||||
void picker.submitNewFolder()
|
||||
}
|
||||
|
||||
onMounted(() => input.value?.focus())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5 rounded-md bg-primary/8 py-1 pe-2 ps-1.5" :class="picker.phone.value ? 'h-11' : 'h-8'">
|
||||
<span
|
||||
v-for="level in row.depth"
|
||||
:key="level"
|
||||
class="relative w-[18px] shrink-0 self-stretch before:absolute before:inset-y-0 before:start-[8px] before:w-px before:bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="size-5 shrink-0" />
|
||||
<FolderPlus :size="15" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<input
|
||||
id="folder-picker-new-folder"
|
||||
ref="input"
|
||||
v-model="name"
|
||||
autocomplete="off"
|
||||
class="h-6 min-w-0 flex-1 rounded-[5px] border border-input bg-background px-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
:placeholder="t('library.folderBrowser.newFolder.placeholder', { name: leafName(props.row.path) })"
|
||||
:aria-label="t('library.folderBrowser.newFolder.inputLabel')"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:aria-describedby="error ? 'folder-picker-new-folder-error' : undefined"
|
||||
:disabled="busy"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<span
|
||||
v-if="error"
|
||||
id="folder-picker-new-folder-error"
|
||||
class="max-w-[45%] shrink-0 truncate text-[11px] text-destructive"
|
||||
role="alert"
|
||||
:title="error"
|
||||
>{{ error }}</span
|
||||
>
|
||||
<button
|
||||
v-else-if="picker.phone.value"
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="busy"
|
||||
@click="onCreate"
|
||||
>
|
||||
{{ t('library.folderBrowser.newFolder.create') }}
|
||||
</button>
|
||||
<i18n-t v-else keypath="library.folderBrowser.newFolder.hint" tag="span" scope="global" class="shrink-0 text-[11px] text-muted-foreground">
|
||||
<template #enter><PickerKbd>↵</PickerKbd></template>
|
||||
<template #escape
|
||||
><PickerKbd>{{ t('library.folderBrowser.keys.escape') }}</PickerKbd></template
|
||||
>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CircleCheck, Plus } from '@lucide/vue'
|
||||
import { booksAs, isAtOrInside } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import { normalizeFolderPath } from '../../composables/folder-paths'
|
||||
import FolderFormatLegend from './FolderFormatLegend.vue'
|
||||
import FolderPathLine from './FolderPathLine.vue'
|
||||
import FolderPickerPlanRow from './FolderPickerPlanRow.vue'
|
||||
import FolderPickerPreview from './FolderPickerPreview.vue'
|
||||
import FolderPickerTotals from './FolderPickerTotals.vue'
|
||||
|
||||
const props = defineProps<{ width: number }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
|
||||
const selected = computed(() => picker.selection.selected.value)
|
||||
const storage = computed(() => picker.kind.value === 'podcastStorage')
|
||||
const wide = computed(() => props.width >= 480)
|
||||
const suggestions = computed(() => {
|
||||
if (picker.kind.value !== 'books') return []
|
||||
return (picker.data.unclaimed.value?.folders ?? [])
|
||||
.map((folder) => ({ ...folder, path: normalizeFolderPath(folder.path) }))
|
||||
.filter(
|
||||
(folder) =>
|
||||
!selected.value.some((path) => isAtOrInside(folder.path, path)) && !picker.own.value.some((path) => isAtOrInside(folder.path, path)),
|
||||
)
|
||||
.slice(0, selected.value.length ? 3 : 5)
|
||||
})
|
||||
|
||||
function ownBooks(path: string): string {
|
||||
const summary = picker.data.summaries.get(path)
|
||||
if (!summary) return ''
|
||||
const count = picker.kind.value === 'books' ? booksAs(summary, picker.mode.value) : summary.files
|
||||
return t('library.folderBrowser.plan.already', { books: picker.countLabel(count) })
|
||||
}
|
||||
|
||||
function onReveal(path: string) {
|
||||
picker.reveal(path)
|
||||
}
|
||||
|
||||
function onAdd(path: string) {
|
||||
picker.toggleFolder(path)
|
||||
}
|
||||
|
||||
// Picked folders and this library's own folders need their counts and sizes whatever is on screen.
|
||||
watch(
|
||||
[selected, () => picker.own.value],
|
||||
([paths, own]) => {
|
||||
picker.data.requestSummaries([...paths, ...own])
|
||||
for (const path of paths) void picker.data.ensureInspection(path)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="flex min-h-0 flex-1 flex-col"
|
||||
:class="picker.phone.value ? '' : 'border-s border-border bg-card/40'"
|
||||
:aria-label="t('library.folderBrowser.plan.label')"
|
||||
>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-3">
|
||||
<FolderPickerPreview v-if="picker.phone.value" />
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h4 class="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{{ storage ? t('library.folderBrowser.plan.storage') : t('library.folderBrowser.plan.willRead', { name: picker.libraryName.value }) }}
|
||||
</h4>
|
||||
<span v-if="selected.length && !storage" class="ms-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{{ t('library.folderBrowser.plan.newFolders', { count: selected.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
<ul v-if="picker.own.value.length" class="mb-2 space-y-1.5">
|
||||
<li v-for="path in picker.own.value" :key="path" class="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
||||
<CircleCheck :size="14" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1"><FolderPathLine :path="path" /></div>
|
||||
<span class="shrink-0 text-xs text-muted-foreground">{{ ownBooks(path) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="selected.length" class="space-y-2">
|
||||
<FolderPickerPlanRow v-for="path in selected" :key="path" :path="path" :wide="wide" />
|
||||
</ul>
|
||||
<div v-else class="rounded-lg border border-dashed border-border px-4 py-5 text-center">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ storage ? t('library.folderBrowser.plan.emptyStorageTitle') : t('library.folderBrowser.plan.emptyTitle') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{ storage ? t('library.folderBrowser.plan.emptyStorageHint') : t('library.folderBrowser.plan.emptyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="suggestions.length" class="mt-5">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h4 class="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{{ t('library.folderBrowser.plan.suggestions') }}
|
||||
</h4>
|
||||
<span class="ms-auto flex items-center gap-1 text-xs text-muted-foreground">{{ t('library.folderBrowser.plan.suggestionsMeta') }}</span>
|
||||
</div>
|
||||
<ul class="divide-y divide-border overflow-hidden rounded-lg border border-border bg-background">
|
||||
<li v-for="folder in suggestions" :key="folder.path" class="flex items-center gap-2 px-3 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<button type="button" class="block w-full text-start" :title="t('library.folderBrowser.plan.showTitle')" @click="onReveal(folder.path)">
|
||||
<FolderPathLine :path="folder.path" />
|
||||
</button>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="shrink-0">{{ t('library.folderBrowser.count.books', { count: folder.files }) }}</span>
|
||||
<FolderFormatLegend :formats="folder.formats" :limit="2" nowrap :more="false" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-md border border-border px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
@click="onAdd(folder.path)"
|
||||
>
|
||||
<Plus :size="13" aria-hidden="true" />
|
||||
{{ t('library.folderBrowser.plan.add') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<FolderPickerPreview v-if="!picker.phone.value" />
|
||||
<FolderPickerTotals />
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BookCopy, CircleCheck, CircleMinus, FolderOpen, Lightbulb, ListTree, TriangleAlert, X } from '@lucide/vue'
|
||||
import { formatList } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { attentionFor, leafName } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import FolderFormatLegend from './FolderFormatLegend.vue'
|
||||
import FolderPathLine from './FolderPathLine.vue'
|
||||
|
||||
const props = defineProps<{ path: string; wide: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
|
||||
const status = computed(() => picker.selection.status(props.path))
|
||||
const summary = computed(() => picker.data.summaries.get(props.path))
|
||||
const inspection = computed(() => picker.data.inspections.get(props.path))
|
||||
const books = computed(() => picker.booksIn(props.path))
|
||||
const attention = computed(() => attentionFor(summary.value, status.value, picker.mode.value, picker.kind.value))
|
||||
const name = computed(() => leafName(props.path))
|
||||
|
||||
const countLine = computed(() => {
|
||||
if (books.value === null) return ''
|
||||
const count =
|
||||
picker.kind.value === 'podcastStorage' && !books.value && !summary.value?.folders
|
||||
? t('library.folderBrowser.plan.empty')
|
||||
: picker.countLabel(books.value)
|
||||
return inspection.value?.bytes ? t('library.folderBrowser.plan.rowSize', { books: count, size: formatBytes(inspection.value.bytes) }) : count
|
||||
})
|
||||
|
||||
const fitText = computed(() => {
|
||||
if (picker.kind.value === 'podcastStorage') return t('library.folderBrowser.plan.fit.storage')
|
||||
if (!summary.value?.files) return t('library.folderBrowser.plan.fit.empty')
|
||||
return picker.mode.value === 'book_per_file' ? t('library.folderBrowser.plan.fit.file') : t('library.folderBrowser.plan.fit.folder')
|
||||
})
|
||||
|
||||
interface Action {
|
||||
key: string
|
||||
label: string
|
||||
icon: typeof X
|
||||
run: () => void
|
||||
}
|
||||
|
||||
const issue = computed<{ text: string; icon: typeof X; actions: Action[] } | null>(() => {
|
||||
const found = attention.value
|
||||
if (!found) return null
|
||||
const locked = picker.modeLocked.value
|
||||
const lockNote = locked ? ` ${t('library.folderBrowser.plan.attention.locked')}` : ''
|
||||
if (found.kind === 'notEmpty') {
|
||||
const held = [
|
||||
summary.value?.files ? picker.holdsLabel(summary.value.files) : '',
|
||||
summary.value?.folders ? t('library.folderBrowser.plan.holdsFolders', { count: summary.value.folders }) : '',
|
||||
].filter(Boolean)
|
||||
const what = held.length === 2 ? t('library.folderBrowser.plan.holdsBoth', { books: held[0]!, folders: held[1]! }) : (held[0] ?? '')
|
||||
return { text: t('library.folderBrowser.plan.attention.notEmpty', { what }), icon: Lightbulb, actions: [] }
|
||||
}
|
||||
if (found.kind === 'contains') {
|
||||
const libraries = found.status?.libraries ?? []
|
||||
const single = libraries.length === 1 ? libraries[0]! : null
|
||||
const folder = single ? leafName(single.folders.find((own) => own.startsWith(`${props.path}/`)) ?? single.folders[0]!) : ''
|
||||
return {
|
||||
text: single
|
||||
? t('library.folderBrowser.plan.attention.containsOne', { folder, library: single.name })
|
||||
: t('library.folderBrowser.plan.attention.containsMany', { libraries: formatList(libraries.map((library) => library.name)) }),
|
||||
icon: TriangleAlert,
|
||||
actions: [
|
||||
{
|
||||
key: 'leaveOut',
|
||||
label: single ? t('library.folderBrowser.plan.attention.leaveOutOne', { folder }) : t('library.folderBrowser.plan.attention.leaveOutMany'),
|
||||
icon: CircleMinus,
|
||||
run: () => void picker.selection.leaveOut(props.path),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (found.kind === 'overlap') {
|
||||
return {
|
||||
text: picker.describeOverlap(props.path, found.status!),
|
||||
icon: TriangleAlert,
|
||||
actions: [{ key: 'remove', label: t('library.folderBrowser.plan.attention.remove'), icon: X, run: () => picker.selection.remove(props.path) }],
|
||||
}
|
||||
}
|
||||
if (found.kind === 'merge') {
|
||||
return {
|
||||
text: t('library.folderBrowser.plan.attention.merge', { count: summary.value!.mergedFolders }) + lockNote,
|
||||
icon: TriangleAlert,
|
||||
actions: locked
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'file',
|
||||
label: t('library.folderBrowser.plan.attention.useFile'),
|
||||
icon: BookCopy,
|
||||
run: () => picker.selection.setMode('book_per_file'),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: t('library.folderBrowser.plan.attention.split', { count: summary.value!.splitAudioFolders }) + lockNote,
|
||||
icon: TriangleAlert,
|
||||
actions: locked
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'folder',
|
||||
label: t('library.folderBrowser.plan.attention.useFolder'),
|
||||
icon: BookCopy,
|
||||
run: () => picker.selection.setMode('book_per_folder'),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
function onReveal() {
|
||||
picker.reveal(props.path)
|
||||
}
|
||||
|
||||
function onRemove() {
|
||||
picker.selection.remove(props.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="overflow-hidden rounded-lg border bg-background"
|
||||
:class="[issue ? 'border-warning/40' : 'border-border', picker.flash.value === path ? 'ring-2 ring-primary/40' : '']"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 px-3 py-2">
|
||||
<FolderOpen :size="15" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<FolderPathLine :path="path" />
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<span :class="wide ? 'shrink-0' : 'truncate'">{{ countLine }}</span>
|
||||
<FolderFormatLegend v-if="wide && summary?.files" :formats="summary.formats" :limit="3" nowrap :more="false" />
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!issue" class="text-success" :title="fitText"><CircleCheck :size="14" /></span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('library.folderBrowser.plan.show', { name })"
|
||||
:title="t('library.folderBrowser.plan.showTitle')"
|
||||
@click="onReveal"
|
||||
>
|
||||
<ListTree :size="13" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
:aria-label="t('library.folderBrowser.plan.remove', { name })"
|
||||
@click="onRemove"
|
||||
>
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="issue" class="flex items-start gap-2 border-t border-warning/25 bg-warning/8 px-3 py-2 text-xs text-foreground">
|
||||
<component :is="issue.icon" :size="13" class="mt-px shrink-0 text-warning" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p>{{ issue.text }}</p>
|
||||
<div v-if="issue.actions.length" class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="action in issue.actions"
|
||||
:key="action.key"
|
||||
type="button"
|
||||
class="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-background px-2 text-xs font-medium text-foreground hover:bg-muted"
|
||||
@click="action.run"
|
||||
>
|
||||
<component :is="action.icon" :size="13" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, CircleCheck, Database, Lightbulb, Lock, Plus, TriangleAlert } from '@lucide/vue'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { leafName, overlapStatus } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import FolderFormatLegend from './FolderFormatLegend.vue'
|
||||
import FolderPathLine from './FolderPathLine.vue'
|
||||
import PickerKbd from './PickerKbd.vue'
|
||||
|
||||
/** Arrowing through rows asks for a folder only once the focus settles on it. */
|
||||
const INSPECT_DELAY_MS = 150
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
let inspectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const path = computed(() => picker.tree.focus.value)
|
||||
const status = computed(() => (path.value ? picker.selection.status(path.value) : null))
|
||||
/** Ticked folders are in the plan already, and a folder inside one is explained by its tick. */
|
||||
const shown = computed(
|
||||
() =>
|
||||
!!path.value &&
|
||||
path.value !== picker.tree.root.value &&
|
||||
status.value?.kind !== 'selected' &&
|
||||
status.value?.kind !== 'included' &&
|
||||
status.value?.kind !== 'ownCovered',
|
||||
)
|
||||
const inspection = computed(() => (path.value ? picker.data.inspections.get(path.value) : undefined))
|
||||
const failed = computed(() => !!path.value && picker.data.failedInspections.has(path.value))
|
||||
const books = computed(() => (path.value ? picker.booksIn(path.value) : null))
|
||||
const overlap = computed(() => (path.value ? overlapStatus(path.value, picker.others.value) : null))
|
||||
const storage = computed(() => picker.kind.value === 'podcastStorage')
|
||||
const bookLibrary = computed(() => picker.kind.value === 'books')
|
||||
|
||||
const blockReason = computed(() => {
|
||||
const current = status.value
|
||||
if (!current?.block || !path.value) return null
|
||||
if (current.kind === 'denied') return { icon: Lock, text: t('library.folderBrowser.reason.denied') }
|
||||
if (current.kind === 'appData') return { icon: Database, text: t('library.folderBrowser.reason.appData') }
|
||||
return { icon: CircleCheck, text: t('library.folderBrowser.reason.own', { name: leafName(path.value) }) }
|
||||
})
|
||||
|
||||
const held = computed(() => {
|
||||
const found = inspection.value
|
||||
if (!found) return ''
|
||||
const parts = [
|
||||
found.files ? picker.holdsLabel(found.files) : '',
|
||||
found.folders ? t('library.folderBrowser.plan.holdsFolders', { count: found.folders }) : '',
|
||||
].filter(Boolean)
|
||||
return parts.length === 2 ? t('library.folderBrowser.plan.holdsBoth', { books: parts[0]!, folders: parts[1]! }) : (parts[0] ?? '')
|
||||
})
|
||||
|
||||
const fit = computed(() => {
|
||||
const found = inspection.value
|
||||
if (!bookLibrary.value || !found?.files) return null
|
||||
if (picker.mode.value === 'book_per_folder' && found.mergedFolders > 0) return { warn: true, text: t('library.folderBrowser.plan.preview.merge') }
|
||||
if (picker.mode.value === 'book_per_file' && found.splitAudioFolders > 0) return { warn: true, text: t('library.folderBrowser.plan.preview.split') }
|
||||
return {
|
||||
warn: false,
|
||||
text: picker.mode.value === 'book_per_file' ? t('library.folderBrowser.plan.fit.file') : t('library.folderBrowser.plan.fit.folder'),
|
||||
}
|
||||
})
|
||||
|
||||
function onAdd() {
|
||||
if (path.value) picker.toggleFolder(path.value)
|
||||
}
|
||||
|
||||
function inspectFocused() {
|
||||
inspectTimer = null
|
||||
if (path.value && shown.value && !status.value?.block) void picker.data.ensureInspection(path.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
[path, shown],
|
||||
() => {
|
||||
if (inspectTimer) clearTimeout(inspectTimer)
|
||||
inspectTimer = setTimeout(inspectFocused, INSPECT_DELAY_MS)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (inspectTimer) clearTimeout(inspectTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shown && path" :class="picker.phone.value ? 'mb-4' : 'shrink-0 border-t border-border px-4 pb-3 pt-2.5'">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h4 class="text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">{{ t('library.folderBrowser.plan.preview.label') }}</h4>
|
||||
<i18n-t
|
||||
v-if="!blockReason && !picker.phone.value"
|
||||
keypath="library.folderBrowser.plan.preview.ticks"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="ms-auto flex items-center gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<template #key
|
||||
><PickerKbd>{{ t('library.folderBrowser.keys.space') }}</PickerKbd></template
|
||||
>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="rounded-lg border border-dashed border-border bg-background/60 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1"><FolderPathLine :path="path" /></div>
|
||||
<button
|
||||
v-if="!blockReason"
|
||||
type="button"
|
||||
class="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-md border border-border px-3 text-xs font-medium text-foreground transition-colors hover:bg-muted"
|
||||
@click="onAdd"
|
||||
>
|
||||
<Check v-if="!picker.multiple.value" :size="13" aria-hidden="true" />
|
||||
<Plus v-else :size="13" aria-hidden="true" />
|
||||
{{ picker.multiple.value ? t('library.folderBrowser.plan.add') : t('library.folderBrowser.plan.preview.use') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="blockReason" class="mt-1.5 flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||
<component :is="blockReason.icon" :size="13" class="mt-px shrink-0" aria-hidden="true" />
|
||||
<span>{{ blockReason.text }}</span>
|
||||
</p>
|
||||
<template v-else-if="!inspection && failed" />
|
||||
<div v-else-if="!inspection" class="mt-2 space-y-1.5" aria-hidden="true">
|
||||
<span class="block h-2.5 w-40 animate-pulse rounded-full bg-muted" />
|
||||
<span class="block h-2.5 w-56 animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="storage" class="mt-1 text-xs text-muted-foreground">
|
||||
{{ held ? t('library.folderBrowser.plan.preview.holds', { what: held }) : t('library.folderBrowser.plan.preview.storageEmpty') }}
|
||||
</p>
|
||||
<i18n-t
|
||||
v-else-if="books"
|
||||
keypath="library.folderBrowser.plan.preview.wouldAdd"
|
||||
tag="p"
|
||||
scope="global"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<template #books>
|
||||
<span class="font-medium text-foreground">{{ picker.countLabel(books) }}</span>
|
||||
</template>
|
||||
<template #size>{{ formatBytes(inspection.bytes) }}</template>
|
||||
</i18n-t>
|
||||
<p v-else class="mt-1 text-xs text-muted-foreground">{{ t('library.folderBrowser.plan.preview.empty') }}</p>
|
||||
<div v-if="inspection.files" class="mt-1.5"><FolderFormatLegend :formats="inspection.formats" :limit="4" /></div>
|
||||
<p v-if="fit" class="mt-1.5 flex items-start gap-1.5 text-xs text-foreground">
|
||||
<TriangleAlert v-if="fit.warn" :size="13" class="mt-px shrink-0 text-warning" aria-hidden="true" />
|
||||
<CircleCheck v-else :size="13" class="mt-px shrink-0 text-success" aria-hidden="true" />
|
||||
<span>{{ fit.text }}</span>
|
||||
</p>
|
||||
<p v-if="storage" class="mt-1.5 flex items-start gap-1.5 text-xs text-foreground">
|
||||
<Lightbulb v-if="held" :size="13" class="mt-px shrink-0 text-warning" aria-hidden="true" />
|
||||
<CircleCheck v-else :size="13" class="mt-px shrink-0 text-success" aria-hidden="true" />
|
||||
<span>{{ held ? t('library.folderBrowser.plan.preview.storageTip') : t('library.folderBrowser.plan.preview.storageFine') }}</span>
|
||||
</p>
|
||||
<p v-if="overlap" class="mt-1 flex items-start gap-1.5 text-xs text-foreground">
|
||||
<TriangleAlert :size="13" class="mt-px shrink-0 text-warning" aria-hidden="true" />
|
||||
<span>{{ picker.describeOverlap(path, overlap) }}</span>
|
||||
</p>
|
||||
<ul v-if="inspection.samples.length" class="mt-2 space-y-0.5">
|
||||
<li v-for="sample in inspection.samples" :key="sample" class="truncate font-mono text-[11px] text-path-dim" dir="ltr" :title="sample">
|
||||
{{ sample }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
|
||||
import { Check, FolderPlus, SlidersHorizontal } from '@lucide/vue'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { isAtOrInside, leafName } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import type { TreeSort } from '../../composables/folder-picker/useFolderPickerTree'
|
||||
import FolderPickerField from './FolderPickerField.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
const tree = picker.tree
|
||||
const field = ref<InstanceType<typeof FolderPickerField> | null>(null)
|
||||
|
||||
const viewChanged = computed(() => tree.sort.value !== 'name' || tree.hideEmpty.value || tree.showSystem.value)
|
||||
/** New folders go in the folder in focus, or the first mount that is not BookOrbit's data before anything is focused. */
|
||||
const target = computed(() => {
|
||||
const focus = tree.focus.value
|
||||
if (focus && !['denied', 'appData'].includes(picker.selection.status(focus)?.kind ?? '')) return focus
|
||||
const places = picker.data.places.value
|
||||
return places?.mounts.find((mount) => !places.appDataPath || !isAtOrInside(mount, places.appDataPath)) ?? tree.root.value
|
||||
})
|
||||
|
||||
const outline =
|
||||
'inline-flex h-9 items-center justify-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50'
|
||||
const iconButton = 'flex size-9 shrink-0 items-center justify-center rounded-md text-foreground transition-colors hover:bg-muted'
|
||||
const item =
|
||||
'flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-foreground outline-none hover:bg-muted data-[highlighted]:bg-muted'
|
||||
const heading = 'px-2 pb-1 pt-1.5 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground'
|
||||
|
||||
function onSort(value: unknown) {
|
||||
if (value === 'name' || value === 'books') tree.sort.value = value satisfies TreeSort
|
||||
}
|
||||
|
||||
function onShowEmpty(value: boolean | 'indeterminate') {
|
||||
tree.hideEmpty.value = value !== true
|
||||
}
|
||||
|
||||
function onShowSystem(value: boolean | 'indeterminate') {
|
||||
tree.showSystem.value = value === true
|
||||
}
|
||||
|
||||
function onMenuOpen(open: boolean) {
|
||||
picker.menuOpen.value = open
|
||||
}
|
||||
|
||||
function onNewFolder() {
|
||||
picker.openNewFolder(target.value)
|
||||
}
|
||||
|
||||
defineExpose({ focusField: (prefill?: string) => field.value?.focus(prefill) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border py-2" :class="picker.phone.value ? 'px-2' : 'px-3'">
|
||||
<FolderPickerField ref="field" />
|
||||
<DropdownMenu :open="picker.menuOpen.value" @update:open="onMenuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button type="button" class="relative" :class="picker.phone.value ? iconButton : outline" :aria-label="t('library.folderBrowser.view.label')">
|
||||
<SlidersHorizontal :size="15" aria-hidden="true" />
|
||||
<template v-if="!picker.phone.value">{{ t('library.folderBrowser.view.button') }}</template>
|
||||
<span v-if="viewChanged" class="absolute -end-0.5 -top-0.5 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" :side-offset="4" class="z-[90] w-64 border-border shadow-lg">
|
||||
<p :class="heading">{{ t('library.folderBrowser.view.sort') }}</p>
|
||||
<DropdownMenuRadioGroup :model-value="tree.sort.value" @update:model-value="onSort">
|
||||
<DropdownMenuRadioItem value="name" :class="item">
|
||||
<Check v-if="tree.sort.value === 'name'" :size="15" /><span v-else class="size-[15px]" />{{ t('library.folderBrowser.view.sortName') }}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="books" :class="item">
|
||||
<Check v-if="tree.sort.value === 'books'" :size="15" /><span v-else class="size-[15px]" />{{ t('library.folderBrowser.view.sortBooks') }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<div class="my-1 h-px bg-border" />
|
||||
<p :class="heading">{{ t('library.folderBrowser.view.show') }}</p>
|
||||
<DropdownMenuCheckboxItem :model-value="!tree.hideEmpty.value" :class="item" @update:model-value="onShowEmpty">
|
||||
<Check v-if="!tree.hideEmpty.value" :size="15" /><span v-else class="size-[15px]" />{{ t('library.folderBrowser.view.showEmpty') }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem :model-value="tree.showSystem.value" :class="item" @update:model-value="onShowSystem">
|
||||
<Check v-if="tree.showSystem.value" :size="15" /><span v-else class="size-[15px]" />{{ t('library.folderBrowser.view.showSystem') }}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
type="button"
|
||||
:class="picker.phone.value ? iconButton : outline"
|
||||
:aria-label="t('library.folderBrowser.newFolder.label', { name: leafName(target) })"
|
||||
:title="t('library.folderBrowser.newFolder.label', { name: target })"
|
||||
@click="onNewFolder"
|
||||
>
|
||||
<FolderPlus :size="15" aria-hidden="true" />
|
||||
<template v-if="!picker.phone.value">{{ t('library.folderBrowser.newFolder.button') }}</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Lock } from '@lucide/vue'
|
||||
import type { OrganizationMode } from '@bookorbit/types'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { booksAs, overlapStatus } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import FolderFormatBar from './FolderFormatBar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
|
||||
const selected = computed(() => picker.selection.selected.value)
|
||||
const shown = computed(() => picker.kind.value === 'books' && selected.value.length > 0)
|
||||
const total = (mode: OrganizationMode) => selected.value.reduce((sum, path) => sum + booksAs(picker.data.summaries.get(path), mode), 0)
|
||||
/** Until every picked folder is counted, a partial total would read as the answer. */
|
||||
const counted = computed(() => selected.value.every((path) => picker.data.summaries.has(path)))
|
||||
const bytes = computed(() => selected.value.reduce((sum, path) => sum + (picker.data.inspections.get(path)?.bytes ?? 0), 0))
|
||||
const bytesKnown = computed(() => selected.value.every((path) => picker.data.inspections.has(path)))
|
||||
const shared = computed(() =>
|
||||
selected.value
|
||||
.filter((path) => overlapStatus(path, picker.others.value)?.kind === 'overlap')
|
||||
.reduce((sum, path) => sum + (picker.data.summaries.get(path)?.files ?? 0), 0),
|
||||
)
|
||||
const formats = computed(() => {
|
||||
const merged: Record<string, number> = {}
|
||||
for (const path of selected.value)
|
||||
for (const [format, count] of Object.entries(picker.data.summaries.get(path)?.formats ?? {})) merged[format] = (merged[format] ?? 0) + count
|
||||
return merged
|
||||
})
|
||||
const modes: OrganizationMode[] = ['book_per_folder', 'book_per_file']
|
||||
const modeName = (mode: OrganizationMode) =>
|
||||
mode === 'book_per_file' ? t('library.folderBrowser.mode.file') : t('library.folderBrowser.mode.folder')
|
||||
|
||||
function onMode(mode: OrganizationMode) {
|
||||
picker.selection.setMode(mode)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shown" class="shrink-0 border-t border-border px-4 py-3">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2">
|
||||
<span v-if="counted" class="text-xl font-semibold tabular-nums text-foreground">{{ formatNumber(total(picker.mode.value)) }}</span>
|
||||
<span v-else class="inline-block h-5 w-10 animate-pulse self-center rounded bg-muted" aria-hidden="true" />
|
||||
<span class="text-sm text-muted-foreground">{{
|
||||
bytesKnown
|
||||
? t('library.folderBrowser.plan.totals.books', { count: total(picker.mode.value), size: formatBytes(bytes) })
|
||||
: t('library.folderBrowser.plan.totals.booksOnly', { count: total(picker.mode.value) })
|
||||
}}</span>
|
||||
<span v-if="shared" class="text-xs text-warning">{{ t('library.folderBrowser.plan.totals.shared', { count: formatNumber(shared) }) }}</span>
|
||||
</div>
|
||||
<div class="mt-2"><FolderFormatBar :formats="formats" :limit="5" /></div>
|
||||
<div v-if="!picker.modeLocked.value" class="mt-3">
|
||||
<p class="mb-1 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{{ t('library.folderBrowser.plan.totals.organize') }}
|
||||
</p>
|
||||
<div class="flex gap-0.5 rounded-md bg-muted p-0.5" role="group" :aria-label="t('library.folderBrowser.plan.totals.organizeLabel')">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode"
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 flex-col items-start rounded-[5px] px-2 py-1 text-start"
|
||||
:class="picker.mode.value === mode ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="picker.mode.value === mode"
|
||||
:title="mode === 'book_per_folder' ? t('library.folderBrowser.plan.totals.folderTitle') : t('library.folderBrowser.plan.totals.fileTitle')"
|
||||
@click="onMode(mode)"
|
||||
>
|
||||
<span class="text-xs font-medium">{{ modeName(mode) }}</span>
|
||||
<span v-if="counted" class="text-[11px] tabular-nums">{{
|
||||
t('library.folderBrowser.plan.totals.segmentBooks', { count: total(mode) })
|
||||
}}</span>
|
||||
<span v-else class="my-[3px] inline-block h-2.5 w-12 animate-pulse rounded-full bg-muted" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock :size="12" aria-hidden="true" />
|
||||
{{ t('library.folderBrowser.plan.totals.locked', { mode: modeName(picker.mode.value), name: picker.libraryName.value }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { EyeOff } from '@lucide/vue'
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { normalizeFolderPath } from '../../composables/folder-paths'
|
||||
import { ancestorsWithin, isInside, parentPath } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import type { TreeRow } from '../../composables/folder-picker/useFolderPickerTree'
|
||||
import FolderPickerNewFolderRow from './FolderPickerNewFolderRow.vue'
|
||||
import FolderPickerToolbar from './FolderPickerToolbar.vue'
|
||||
import FolderPickerTreeRow from './FolderPickerTreeRow.vue'
|
||||
import PickerKbd from './PickerKbd.vue'
|
||||
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
const tree = picker.tree
|
||||
|
||||
const scroller = ref<{ $el: HTMLElement } | null>(null)
|
||||
const toolbar = ref<InstanceType<typeof FolderPickerToolbar> | null>(null)
|
||||
const sticky = ref<TreeRow[]>([])
|
||||
const rowHeight = computed(() => (picker.phone.value ? 44 : 32))
|
||||
const padTop = computed(() => (picker.phone.value ? 4 : 6))
|
||||
const finding = computed(() => tree.findResults.value !== null)
|
||||
const rowIndex = computed(() => new Map(tree.rows.value.map((row, index) => [row.key, index])))
|
||||
const rootListing = computed(() => picker.data.listings.get(tree.root.value))
|
||||
|
||||
function scrollerElement(): HTMLElement | null {
|
||||
return scroller.value?.$el ?? null
|
||||
}
|
||||
|
||||
/** While a long folder scrolls past, its parents stay pinned above it. */
|
||||
function updateSticky() {
|
||||
const element = scrollerElement()
|
||||
if (!element || element.scrollTop <= padTop.value) {
|
||||
sticky.value = []
|
||||
return
|
||||
}
|
||||
const rows = tree.rows.value
|
||||
let chain: TreeRow[] = []
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const row = rows[Math.max(0, Math.floor((element.scrollTop + chain.length * rowHeight.value - padTop.value) / rowHeight.value))]
|
||||
if (!row || row.type !== 'folder') break
|
||||
chain = ancestorsWithin(row.path, tree.root.value)
|
||||
.map((path) => rowIndex.value.get(path))
|
||||
.filter((index): index is number => index !== undefined)
|
||||
.map((index) => rows[index]!)
|
||||
.slice(-3)
|
||||
}
|
||||
sticky.value = chain
|
||||
// Pinned parents are often far above the rows on screen, whose counts are the only ones asked for.
|
||||
if (chain.length) picker.data.requestSummaries(chain.map((row) => row.path))
|
||||
}
|
||||
|
||||
function onUpdate(startIndex: number, endIndex: number) {
|
||||
const rows = tree.rows.value.slice(Math.max(0, startIndex - 10), endIndex + 10)
|
||||
picker.data.requestSummaries(rows.filter((row) => row.type === 'folder').map((row) => row.path))
|
||||
}
|
||||
|
||||
/** Keeps a row on screen without jumping: scrolls only as far as needed, clear of the pinned parents. */
|
||||
function ensureVisible(path: string) {
|
||||
const element = scrollerElement()
|
||||
const index = rowIndex.value.get(normalizeFolderPath(path))
|
||||
if (!element || index === undefined) return
|
||||
const top = padTop.value + index * rowHeight.value
|
||||
const pinned = sticky.value.length * rowHeight.value
|
||||
if (top - pinned < element.scrollTop) element.scrollTop = Math.max(0, top - pinned)
|
||||
else if (top + rowHeight.value > element.scrollTop + element.clientHeight) element.scrollTop = top + rowHeight.value - element.clientHeight
|
||||
}
|
||||
|
||||
/** Puts a folder's row at the top, below any pinned parents, as the picker opens on it. */
|
||||
function scrollToTop(path: string) {
|
||||
const element = scrollerElement()
|
||||
const index = rowIndex.value.get(normalizeFolderPath(path))
|
||||
if (!element || index === undefined) return
|
||||
element.scrollTop = Math.max(0, padTop.value + index * rowHeight.value - Math.min(index, 3) * rowHeight.value)
|
||||
}
|
||||
|
||||
/** A scrolled-to row renders a frame or two later, so the focus waits for it. */
|
||||
async function focusRow(path: string) {
|
||||
ensureVisible(path)
|
||||
const selector = `[data-path="${CSS.escape(normalizeFolderPath(path))}"] [data-row-focus]`
|
||||
await nextTick()
|
||||
for (let frame = 0; frame < 4; frame++) {
|
||||
const button = scrollerElement()?.querySelector<HTMLElement>(selector)
|
||||
if (button) return button.focus({ preventScroll: true })
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
function folderRows(): string[] {
|
||||
return tree.rows.value.filter((row) => row.type === 'folder').map((row) => row.path)
|
||||
}
|
||||
|
||||
/** Keys act on the row whose name has the focus; any other control in the list keeps its own keys. */
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
const item = target.matches('[data-row-focus]') ? target.closest<HTMLElement>('[role="treeitem"][data-path]') : null
|
||||
const current = item?.dataset.path
|
||||
if (!current) return
|
||||
const paths = folderRows()
|
||||
const index = paths.indexOf(current)
|
||||
const rtl = getComputedStyle(item).direction === 'rtl'
|
||||
const key = rtl && event.key === 'ArrowLeft' ? 'ArrowRight' : rtl && event.key === 'ArrowRight' ? 'ArrowLeft' : event.key
|
||||
let next: string | null = null
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
||||
if (!paths.length) return
|
||||
next = paths[index < 0 ? 0 : Math.max(0, Math.min(paths.length - 1, index + (key === 'ArrowDown' ? 1 : -1)))]!
|
||||
} else if (key === 'ArrowRight') {
|
||||
if (tree.hasChildren(current) && !tree.expanded.value.has(current) && !finding.value) tree.setExpanded(current, true)
|
||||
else next = tree.visibleChildren(current)[0]?.path ?? null
|
||||
} else if (key === 'ArrowLeft') {
|
||||
if (tree.expanded.value.has(current) && !finding.value) tree.setExpanded(current, false)
|
||||
else if (parentPath(current) !== tree.root.value && isInside(current, tree.root.value)) next = parentPath(current)
|
||||
} else if (key === ' ' || key === 'Enter') {
|
||||
tree.focus.value = current
|
||||
picker.toggleFolder(current, event)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (next) {
|
||||
tree.focus.value = normalizeFolderPath(next)
|
||||
void focusRow(next)
|
||||
}
|
||||
}
|
||||
|
||||
function onShowSystem() {
|
||||
tree.showSystem.value = true
|
||||
}
|
||||
|
||||
function onRetry() {
|
||||
emit('retry')
|
||||
}
|
||||
|
||||
function moreLabel(path: string): string {
|
||||
return t('library.folderBrowser.tree.moreFolders', { count: formatNumber(picker.data.listings.get(path)?.entries.length ?? 0) })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [tree.rows.value.length, picker.phone.value],
|
||||
() => void nextTick(updateSticky),
|
||||
)
|
||||
|
||||
// The scroller is re-created when the list swaps for an empty state, so the listener follows it.
|
||||
let scrollTarget: HTMLElement | null = null
|
||||
watch(
|
||||
scroller,
|
||||
() => {
|
||||
scrollTarget?.removeEventListener('scroll', updateSticky)
|
||||
scrollTarget = scrollerElement()
|
||||
scrollTarget?.addEventListener('scroll', updateSticky, { passive: true })
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
onBeforeUnmount(() => scrollTarget?.removeEventListener('scroll', updateSticky))
|
||||
|
||||
function focusField(prefill?: string) {
|
||||
toolbar.value?.focusField(prefill)
|
||||
}
|
||||
|
||||
defineExpose({ focusRow, ensureVisible, scrollToTop, focusField })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<FolderPickerToolbar ref="toolbar" />
|
||||
<p
|
||||
v-if="finding && picker.findQuery.value.trim()"
|
||||
class="flex shrink-0 flex-wrap items-center gap-1 border-b border-border/60 px-4 py-1.5 text-xs text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
{{ t('library.folderBrowser.field.findSummary', { count: tree.findResults.value!.length, query: picker.findQuery.value.trim() }) }}
|
||||
<i18n-t v-if="tree.findResults.value!.length" keypath="library.folderBrowser.field.findEnter" tag="span" scope="global">
|
||||
<template #key><PickerKbd>↵</PickerKbd></template>
|
||||
</i18n-t>
|
||||
<span v-if="tree.findTruncated.value">{{ t('library.folderBrowser.field.findTruncated', { count: tree.findResults.value!.length }) }}</span>
|
||||
</p>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<!-- A visual copy of rows that are in the tree below; hidden from assistive technology so they are not read twice. -->
|
||||
<div
|
||||
v-if="sticky.length"
|
||||
class="absolute inset-x-0 top-0 z-10 border-b border-border bg-background py-px shadow-[0_6px_12px_-10px_rgb(0_0_0/0.5)]"
|
||||
:class="picker.phone.value ? 'px-1' : 'px-2'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FolderPickerTreeRow v-for="row in sticky" :key="row.key" :row="row" pinned />
|
||||
</div>
|
||||
<div
|
||||
v-if="picker.data.placesFailed.value"
|
||||
class="flex flex-col items-center gap-3 px-6 py-10 text-center text-sm text-muted-foreground"
|
||||
role="alert"
|
||||
>
|
||||
<p>{{ t('library.folderBrowser.tree.placesFailed') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
||||
@click="onRetry"
|
||||
>
|
||||
{{ t('library.folderBrowser.tree.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else-if="rootListing && rootListing.state !== 'ok'" class="px-6 py-10 text-center text-sm text-muted-foreground" role="alert">
|
||||
{{ t('library.folderBrowser.tree.unreadable', { path: tree.root.value }) }}
|
||||
</p>
|
||||
<p v-else-if="finding && !tree.rows.value.length" class="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{{ t('library.folderBrowser.field.findNone') }}
|
||||
</p>
|
||||
<RecycleScroller
|
||||
v-else
|
||||
ref="scroller"
|
||||
:items="tree.rows.value"
|
||||
:item-size="rowHeight"
|
||||
key-field="key"
|
||||
emit-update
|
||||
class="h-full overflow-y-auto"
|
||||
:class="picker.phone.value ? 'px-1 py-1' : 'px-2 py-1.5'"
|
||||
role="tree"
|
||||
:aria-label="t('library.folderBrowser.tree.label')"
|
||||
:aria-multiselectable="picker.multiple.value"
|
||||
@update="onUpdate"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<FolderPickerNewFolderRow v-if="item.type === 'new'" :row="item" />
|
||||
<div
|
||||
v-else-if="item.type === 'loading'"
|
||||
class="flex items-center gap-1.5 pe-2"
|
||||
:class="picker.phone.value ? 'h-11 ps-1' : 'h-8 ps-1.5'"
|
||||
role="presentation"
|
||||
>
|
||||
<span v-for="level in item.depth" :key="level" class="w-[18px] shrink-0" />
|
||||
<span class="size-5 shrink-0" />
|
||||
<span class="inline-block h-2 w-32 animate-pulse rounded-full bg-muted" />
|
||||
<span class="sr-only">{{ t('library.folderBrowser.tree.loading') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type === 'more'"
|
||||
class="flex items-center gap-1.5 pe-2 text-xs text-muted-foreground"
|
||||
:class="picker.phone.value ? 'h-11 ps-1' : 'h-8 ps-1.5'"
|
||||
role="presentation"
|
||||
>
|
||||
<span v-for="level in item.depth" :key="level" class="w-[18px] shrink-0" />
|
||||
<span class="size-5 shrink-0" />
|
||||
<span class="min-w-0 truncate" :title="moreLabel(item.path)">{{ moreLabel(item.path) }}</span>
|
||||
</div>
|
||||
<FolderPickerTreeRow v-else :row="item" />
|
||||
</template>
|
||||
<template #after>
|
||||
<button
|
||||
v-if="!finding && tree.hiddenSystemCount.value"
|
||||
type="button"
|
||||
class="ms-7 mt-1 flex h-7 items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
@click="onShowSystem"
|
||||
>
|
||||
<EyeOff :size="13" aria-hidden="true" />
|
||||
{{ t('library.folderBrowser.tree.systemHidden', { count: tree.hiddenSystemCount.value, path: tree.root.value }) }}
|
||||
</button>
|
||||
</template>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, ChevronRight, Database, Folder, FolderLock, FolderOpen, HardDrive, Lock, TriangleAlert } from '@lucide/vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { formatList, formatNumber } from '@/i18n/formatters'
|
||||
import { isInside, leafName, type PickerLibrary } from '../../composables/folder-picker/folder-picker-rules'
|
||||
import { useFolderPickerContext } from '../../composables/folder-picker/folder-picker-context'
|
||||
import type { TreeRow } from '../../composables/folder-picker/useFolderPickerTree'
|
||||
|
||||
/** `pinned`: a copy shown above the list while its folder scrolls past, not a tree item of its own. */
|
||||
const props = defineProps<{ row: TreeRow; pinned?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const picker = useFolderPickerContext()
|
||||
|
||||
const path = computed(() => props.row.path)
|
||||
const name = computed(() => props.row.entry.name || leafName(path.value))
|
||||
const status = computed(() => picker.selection.status(path.value))
|
||||
const summary = computed(() => picker.data.summaries.get(path.value))
|
||||
const canOpen = computed(() => picker.tree.hasChildren(path.value))
|
||||
const finding = computed(() => picker.tree.findResults.value !== null)
|
||||
const open = computed(() => canOpen.value && (finding.value || picker.tree.expanded.value.has(path.value)))
|
||||
const focused = computed(() => picker.tree.focus.value === path.value)
|
||||
const tabbable = computed(() => !props.pinned && picker.tree.tabStop.value === path.value)
|
||||
const mixedCount = computed(() => (status.value ? 0 : picker.selection.selected.value.filter((folder) => isInside(folder, path.value)).length))
|
||||
const quietName = computed(() => ['included', 'ownCovered', 'denied', 'appData'].includes(status.value?.kind ?? ''))
|
||||
const denied = computed(() => status.value?.kind === 'denied')
|
||||
const checked = computed(() => ['selected', 'included', 'own', 'ownCovered'].includes(status.value?.kind ?? ''))
|
||||
const here = computed<PickerLibrary[]>(() => picker.libraries.value.filter((library) => library.folders.includes(path.value)))
|
||||
const below = computed<PickerLibrary[]>(() =>
|
||||
open.value ? [] : picker.libraries.value.filter((library) => library.folders.some((folder) => isInside(folder, path.value))),
|
||||
)
|
||||
const books = computed(() => picker.booksIn(path.value))
|
||||
|
||||
const tint = computed(() => {
|
||||
if (picker.flash.value === path.value) return 'bg-primary/25'
|
||||
if (focused.value) return 'bg-primary/12'
|
||||
if (status.value?.kind === 'selected') return 'bg-primary/6'
|
||||
return 'hover:bg-muted/60'
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
if (denied.value) return { component: FolderLock, class: 'text-muted-foreground' }
|
||||
if (props.row.entry.appData) return { component: Database, class: 'text-muted-foreground' }
|
||||
if (props.row.entry.mount) return { component: HardDrive, class: 'text-primary' }
|
||||
return { component: open.value ? FolderOpen : Folder, class: 'text-primary' }
|
||||
})
|
||||
|
||||
const nameParts = computed(() => {
|
||||
const query = picker.findQuery.value.trim()
|
||||
const index = finding.value && query ? name.value.toLowerCase().indexOf(query.toLowerCase()) : -1
|
||||
if (index < 0) return null
|
||||
return [name.value.slice(0, index), name.value.slice(index, index + query.length), name.value.slice(index + query.length)]
|
||||
})
|
||||
|
||||
const countLabel = computed(() => {
|
||||
if (!summary.value || books.value === null || books.value === 0) return ''
|
||||
const value = formatNumber(books.value)
|
||||
return summary.value.truncated ? t('library.folderBrowser.count.atLeast', { count: value }) : value
|
||||
})
|
||||
|
||||
function onExpand() {
|
||||
picker.tree.setExpanded(path.value, !open.value)
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
picker.focusFolder(path.value)
|
||||
}
|
||||
|
||||
async function onToggle(event: MouseEvent) {
|
||||
const box = event.currentTarget as HTMLInputElement
|
||||
picker.toggleFolder(path.value, event)
|
||||
// A refused or unchanged tick leaves the box as the browser flipped it; Vue patches only changed values.
|
||||
await nextTick()
|
||||
box.checked = checked.value
|
||||
box.indeterminate = mixedCount.value > 0
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
picker.toggleFolder(path.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md pe-2 transition-colors duration-500"
|
||||
:class="[picker.phone.value ? 'h-11 ps-1' : 'h-8 ps-1.5', tint]"
|
||||
:role="pinned ? undefined : 'treeitem'"
|
||||
:aria-level="pinned ? undefined : row.depth + 1"
|
||||
:aria-expanded="!pinned && canOpen ? open : undefined"
|
||||
:aria-selected="pinned ? undefined : checked"
|
||||
:data-path="pinned ? undefined : path"
|
||||
>
|
||||
<span
|
||||
v-for="level in row.depth"
|
||||
:key="level"
|
||||
class="relative w-[18px] shrink-0 self-stretch before:absolute before:inset-y-0 before:start-[8px] before:w-px before:bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
v-if="canOpen"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:aria-label="open ? t('library.folderBrowser.tree.collapse', { name }) : t('library.folderBrowser.tree.expand', { name })"
|
||||
@click="onExpand"
|
||||
>
|
||||
<ChevronDown v-if="open" :size="14" />
|
||||
<ChevronRight v-else :size="14" />
|
||||
</button>
|
||||
<span v-else class="size-5 shrink-0" />
|
||||
<input
|
||||
:type="picker.multiple.value ? 'checkbox' : 'radio'"
|
||||
tabindex="-1"
|
||||
class="size-3.5 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
||||
:checked="checked"
|
||||
:disabled="status?.block"
|
||||
:indeterminate="mixedCount > 0"
|
||||
:aria-label="picker.multiple.value ? t('library.folderBrowser.tree.select', { name }) : t('library.folderBrowser.tree.use', { name })"
|
||||
@click="onToggle"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full min-w-0 flex-1 items-center gap-2 text-start focus-visible:outline-none"
|
||||
:tabindex="tabbable ? 0 : -1"
|
||||
:data-row-focus="pinned ? undefined : ''"
|
||||
@click="onFocus"
|
||||
@dblclick="onDoubleClick"
|
||||
>
|
||||
<component :is="icon.component" :size="15" class="shrink-0" :class="icon.class" aria-hidden="true" />
|
||||
<span
|
||||
class="min-w-0 truncate text-[13px]"
|
||||
:class="[quietName ? 'text-muted-foreground' : 'text-foreground', focused ? 'font-medium' : '']"
|
||||
:title="path"
|
||||
>
|
||||
<template v-if="nameParts"
|
||||
>{{ nameParts[0] }}<mark class="rounded-[2px] bg-primary/25 text-foreground">{{ nameParts[1] }}</mark
|
||||
>{{ nameParts[2] }}</template
|
||||
>
|
||||
<template v-else>{{ name }}</template>
|
||||
</span>
|
||||
<span
|
||||
v-for="library in here"
|
||||
:key="library.id ?? 'self'"
|
||||
class="inline-flex shrink-0 items-center gap-1 rounded-[5px] border px-1.5 text-[11px] font-medium leading-[18px]"
|
||||
:class="library.self ? 'border-primary/40 text-primary' : 'border-border text-muted-foreground'"
|
||||
:title="t('library.folderBrowser.tree.readsThis', { name: library.name })"
|
||||
>
|
||||
<AppIcon :icon="library.icon" fallback="Library" :size="11" />
|
||||
<template v-if="!picker.phone.value">{{ library.self ? t('library.folderBrowser.tree.thisLibrary') : library.name }}</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="below.length"
|
||||
class="flex shrink-0 items-center gap-0.5 text-muted-foreground"
|
||||
:title="t('library.folderBrowser.tree.librariesInside', { names: formatList(below.map((library) => library.name)), count: below.length })"
|
||||
>
|
||||
<AppIcon v-for="library in below.slice(0, 4)" :key="library.id ?? 'self'" :icon="library.icon" fallback="Library" :size="12" />
|
||||
</span>
|
||||
<span v-if="mixedCount" class="shrink-0 text-[11px] font-medium text-primary">{{
|
||||
t('library.folderBrowser.tree.inside', { count: mixedCount })
|
||||
}}</span>
|
||||
<span v-if="status?.kind === 'selected' && status.warn" class="shrink-0 text-warning" :title="picker.describeOverlap(path, status)">
|
||||
<TriangleAlert :size="14" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="denied"
|
||||
class="inline-flex max-w-44 shrink-0 items-center gap-1 rounded-full bg-destructive/12 px-2 py-0.5 text-[11px] leading-[calc(1/0.75)] font-medium text-destructive"
|
||||
>
|
||||
<Lock :size="11" class="shrink-0" />
|
||||
<span class="truncate">{{ t('library.folderBrowser.tree.noAccess') }}</span>
|
||||
</span>
|
||||
<span v-else-if="row.entry.appData" class="shrink-0 rounded-[5px] border border-border px-1.5 text-[11px] leading-[18px] text-muted-foreground">
|
||||
{{ t('library.folderBrowser.tree.appData') }}
|
||||
</span>
|
||||
</button>
|
||||
<span class="w-12 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
|
||||
<template v-if="!denied && !row.entry.appData">
|
||||
<span v-if="!summary" class="inline-block h-2 w-7 animate-pulse rounded-full bg-muted align-middle" aria-hidden="true" />
|
||||
<template v-else>{{ countLabel }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<kbd
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-border bg-card px-1 font-sans text-[10.5px] font-semibold text-foreground shadow-[inset_0_-1px_0_0_var(--border)]"
|
||||
>
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { FolderSummary } from '@bookorbit/types'
|
||||
import {
|
||||
ancestorsWithin,
|
||||
attentionFor,
|
||||
booksAs,
|
||||
folderStatus,
|
||||
overlapStatus,
|
||||
rangeBetween,
|
||||
toggleFolder,
|
||||
type PickerLibrary,
|
||||
type StatusContext,
|
||||
} from '../folder-picker-rules'
|
||||
|
||||
const novels: PickerLibrary = { id: 1, name: 'Novels', icon: 'BookOpen', folders: ['/books/Ebooks/Fiction'] }
|
||||
const comics: PickerLibrary = { id: 2, name: 'Comics', icon: null, folders: ['/books/Comics'] }
|
||||
|
||||
function context(overrides: Partial<StatusContext> = {}): StatusContext {
|
||||
return { denied: false, appData: false, own: [], selected: [], others: [novels, comics], ...overrides }
|
||||
}
|
||||
|
||||
function summary(overrides: Partial<FolderSummary> = {}): FolderSummary {
|
||||
return {
|
||||
path: '/x',
|
||||
state: 'ok',
|
||||
files: 12,
|
||||
folderBooks: 3,
|
||||
formats: { epub: 12 },
|
||||
folders: 3,
|
||||
mergedFolders: 0,
|
||||
splitAudioFolders: 0,
|
||||
truncated: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('folderStatus', () => {
|
||||
it('refuses unreadable folders and the data folder before anything else', () => {
|
||||
expect(folderStatus('/books', context({ denied: true }))).toEqual({ kind: 'denied', block: true })
|
||||
expect(folderStatus('/data', context({ appData: true }))).toEqual({ kind: 'appData', block: true })
|
||||
})
|
||||
|
||||
it('marks this library’s own folders and everything inside them', () => {
|
||||
const ctx = context({ own: ['/books/Ebooks/Fiction/'] })
|
||||
|
||||
expect(folderStatus('/books/Ebooks/Fiction', ctx)).toEqual({ kind: 'own', block: true })
|
||||
expect(folderStatus('/books/Ebooks/Fiction/Jane Austen', ctx)).toEqual({ kind: 'ownCovered', block: true, cover: '/books/Ebooks/Fiction' })
|
||||
})
|
||||
|
||||
it('keeps the overlap warning on a ticked folder', () => {
|
||||
const status = folderStatus('/books/Ebooks', context({ selected: ['/books/Ebooks'] }))
|
||||
|
||||
expect(status).toMatchObject({ kind: 'selected', warn: true, over: 'contains', library: novels })
|
||||
})
|
||||
|
||||
it('shows folders under a ticked one as included', () => {
|
||||
expect(folderStatus('/books/Ebooks/Science Fiction/Ann Leckie', context({ selected: ['/books/Ebooks/Science Fiction'] }))).toEqual({
|
||||
kind: 'included',
|
||||
block: true,
|
||||
cover: '/books/Ebooks/Science Fiction',
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet for a folder no library touches', () => {
|
||||
expect(folderStatus('/books/Unsorted', context())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlapStatus', () => {
|
||||
it('tells a library’s own folder, a folder inside it, and a folder holding it apart', () => {
|
||||
expect(overlapStatus('/books/Comics', [novels, comics])).toMatchObject({ kind: 'overlap', exact: true, library: comics })
|
||||
expect(overlapStatus('/books/Comics/Saga (2012)', [novels, comics])).toMatchObject({ kind: 'overlap', exact: false, library: comics })
|
||||
expect(overlapStatus('/books', [novels, comics])).toMatchObject({ kind: 'contains', libraries: [novels, comics] })
|
||||
expect(overlapStatus('/media', [novels, comics])).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat a sibling with a shared prefix as inside', () => {
|
||||
expect(overlapStatus('/books/Comics Archive', [comics])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleFolder', () => {
|
||||
it('folds picks inside a newly ticked parent and reports them', () => {
|
||||
expect(toggleFolder(['/books/Ebooks/Fiction/Jane Austen', '/media'], '/books/Ebooks', true)).toEqual({
|
||||
selected: ['/books/Ebooks', '/media'],
|
||||
folded: ['/books/Ebooks/Fiction/Jane Austen'],
|
||||
})
|
||||
})
|
||||
|
||||
it('unticks a ticked folder', () => {
|
||||
expect(toggleFolder(['/books', '/media'], '/books/', true)).toEqual({ selected: ['/media'], folded: [] })
|
||||
})
|
||||
|
||||
it('keeps one folder in single mode', () => {
|
||||
expect(toggleFolder(['/books/Podcasts'], '/media/nas', false)).toEqual({ selected: ['/media/nas'], folded: [] })
|
||||
expect(toggleFolder(['/media/nas'], '/media/nas', false)).toEqual({ selected: [], folded: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('booksAs', () => {
|
||||
it('counts folders as books in Folder as Book and files in File as Book', () => {
|
||||
expect(booksAs(summary(), 'book_per_folder')).toBe(3)
|
||||
expect(booksAs(summary(), 'book_per_file')).toBe(12)
|
||||
expect(booksAs(undefined, 'book_per_file')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attentionFor', () => {
|
||||
it('flags merging folders only in Folder as Book, and split audiobooks only in File as Book', () => {
|
||||
expect(attentionFor(summary({ mergedFolders: 2 }), null, 'book_per_folder', 'books')).toEqual({ kind: 'merge' })
|
||||
expect(attentionFor(summary({ mergedFolders: 2 }), null, 'book_per_file', 'books')).toBeNull()
|
||||
expect(attentionFor(summary({ splitAudioFolders: 1 }), null, 'book_per_file', 'books')).toEqual({ kind: 'split' })
|
||||
})
|
||||
|
||||
it('puts overlaps first, as contains or overlap', () => {
|
||||
const contains = folderStatus('/books', context({ selected: ['/books'] }))
|
||||
const inside = folderStatus('/books/Comics/Saga', context({ selected: ['/books/Comics/Saga'] }))
|
||||
|
||||
expect(attentionFor(summary({ mergedFolders: 3 }), contains, 'book_per_folder', 'books')).toMatchObject({ kind: 'contains' })
|
||||
expect(attentionFor(summary(), inside, 'book_per_folder', 'books')).toMatchObject({ kind: 'overlap' })
|
||||
})
|
||||
|
||||
it('asks podcast storage for an empty folder, and nothing of local podcast folders', () => {
|
||||
expect(attentionFor(summary({ files: 0, folders: 1 }), null, 'book_per_folder', 'podcastStorage')).toEqual({ kind: 'notEmpty' })
|
||||
expect(attentionFor(summary({ files: 0, folders: 0 }), null, 'book_per_folder', 'podcastStorage')).toBeNull()
|
||||
expect(attentionFor(summary({ mergedFolders: 4 }), null, 'book_per_folder', 'podcastLocal')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rangeBetween and ancestorsWithin', () => {
|
||||
it('returns the run between two siblings either way round', () => {
|
||||
const siblings = ['/a', '/b', '/c', '/d']
|
||||
|
||||
expect(rangeBetween(siblings, '/b', '/d')).toEqual(['/b', '/c', '/d'])
|
||||
expect(rangeBetween(siblings, '/d', '/b')).toEqual(['/b', '/c', '/d'])
|
||||
expect(rangeBetween(siblings, '/x', '/b')).toEqual([])
|
||||
})
|
||||
|
||||
it('lists the folders between a root and a path, and stops at the filesystem root', () => {
|
||||
expect(ancestorsWithin('/books/Ebooks/Fiction/Jane Austen', '/')).toEqual(['/books', '/books/Ebooks', '/books/Ebooks/Fiction'])
|
||||
expect(ancestorsWithin('/books/Ebooks/Fiction', '/books')).toEqual(['/books/Ebooks'])
|
||||
expect(ancestorsWithin('/books', '/')).toEqual([])
|
||||
expect(ancestorsWithin('/elsewhere/x', '/books')).toEqual([])
|
||||
})
|
||||
})
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderSummary } from '@bookorbit/types'
|
||||
import { useFolderPickerData } from '../useFolderPickerData'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }))
|
||||
}
|
||||
|
||||
function summary(path: string, files: number): FolderSummary {
|
||||
return { path, state: 'ok', files, folderBooks: files, formats: {}, folders: 0, mergedFolders: 0, splitAudioFolders: 0, truncated: false }
|
||||
}
|
||||
|
||||
describe('useFolderPickerData', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('batches counts for rows that appear together into one request, and never asks twice', async () => {
|
||||
apiMock.mockImplementation((_url, init) => json((JSON.parse(String(init?.body)).paths as string[]).map((path) => summary(path, 3))))
|
||||
const data = useFolderPickerData()
|
||||
|
||||
data.requestSummaries(['/books/a', '/books/b'])
|
||||
data.requestSummaries(['/books/b/', '/books/c'])
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
data.requestSummaries(['/books/a'])
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(String(apiMock.mock.calls[0]![1]!.body))).toEqual({ paths: ['/books/a', '/books/b', '/books/c'] })
|
||||
expect(data.summaries.get('/books/c')?.files).toBe(3)
|
||||
})
|
||||
|
||||
it('asks again for counts a failed batch did not return', async () => {
|
||||
apiMock
|
||||
.mockImplementationOnce(() => json({}, 500))
|
||||
.mockImplementation((_url, init) => json((JSON.parse(String(init?.body)).paths as string[]).map((path) => summary(path, 1))))
|
||||
const data = useFolderPickerData()
|
||||
|
||||
data.requestSummaries(['/books/a'])
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
data.requestSummaries(['/books/a'])
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(2)
|
||||
expect(data.summaries.has('/books/a')).toBe(true)
|
||||
})
|
||||
|
||||
it('lists a folder once however often it is asked for', async () => {
|
||||
apiMock.mockImplementation(() => json({ path: '/books', state: 'ok', entries: [] }))
|
||||
const data = useFolderPickerData()
|
||||
|
||||
await Promise.all([data.ensureListing('/books'), data.ensureListing('/books/')])
|
||||
await data.ensureListing('/books')
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/path/listing?path=%2Fbooks')
|
||||
})
|
||||
|
||||
it('treats a refused listing as blocked rather than empty', async () => {
|
||||
apiMock.mockImplementation(() => json({ message: 'outside' }, 403))
|
||||
const data = useFolderPickerData()
|
||||
|
||||
await expect(data.ensureListing('/outside')).resolves.toEqual({ path: '/outside', state: 'blocked', entries: [], truncated: false })
|
||||
})
|
||||
|
||||
it('asks again, a little later, for counts the server had no time for', async () => {
|
||||
let calls = 0
|
||||
apiMock.mockImplementation((_url, init) => {
|
||||
calls += 1
|
||||
const paths = JSON.parse(String(init?.body)).paths as string[]
|
||||
return json(paths.map((path) => (calls === 1 ? { ...summary(path, 0), state: 'pending' } : summary(path, 7))))
|
||||
})
|
||||
const data = useFolderPickerData()
|
||||
|
||||
data.requestSummaries(['/books/slow'])
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
expect(data.summaries.has('/books/slow')).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(data.summaries.get('/books/slow')?.files).toBe(7)
|
||||
})
|
||||
|
||||
it('runs only a few inspections at once, and stops waiting on one that failed', async () => {
|
||||
const releases: Array<() => void> = []
|
||||
apiMock.mockImplementation(
|
||||
(url) =>
|
||||
new Promise((resolve) =>
|
||||
releases.push(() => resolve(String(url).includes('bad') ? new Response('{}', { status: 500 }) : new Response('{}'))),
|
||||
),
|
||||
)
|
||||
const data = useFolderPickerData()
|
||||
|
||||
const requests = ['/a', '/b', '/bad', '/d', '/e'].map((path) => data.ensureInspection(path))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(apiMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
releases.shift()!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(apiMock).toHaveBeenCalledTimes(4)
|
||||
|
||||
while (releases.length) {
|
||||
releases.shift()!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
await Promise.all(requests)
|
||||
expect(data.failedInspections.has('/bad')).toBe(true)
|
||||
})
|
||||
|
||||
it('asks for system folders too when they are shown', async () => {
|
||||
apiMock.mockImplementation(() => json({ results: [], truncated: false }))
|
||||
const data = useFolderPickerData()
|
||||
|
||||
await data.searchFolders('lib', undefined, true)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/path/search?q=lib&system=true', { signal: undefined })
|
||||
})
|
||||
|
||||
it('maps folder creation failures to reasons, and asks again for the counts above the new folder', async () => {
|
||||
const data = useFolderPickerData()
|
||||
data.summaries.set('/books', summary('/books', 5))
|
||||
data.summaries.set('/media', summary('/media', 9))
|
||||
apiMock.mockImplementationOnce(() => json({}, 409)).mockImplementationOnce(() => json({}, 403))
|
||||
|
||||
await expect(data.createFolder('/books', 'New')).resolves.toEqual({ error: 'exists' })
|
||||
await expect(data.createFolder('/books', 'New')).resolves.toEqual({ error: 'denied' })
|
||||
|
||||
apiMock.mockImplementation((url, init) =>
|
||||
init?.method === 'POST' ? json({ name: 'New', path: '/books/New' }) : json({ path: '/books', state: 'ok', entries: [] }),
|
||||
)
|
||||
await expect(data.createFolder('/books/', 'New')).resolves.toEqual({ path: '/books/New' })
|
||||
expect(JSON.parse(String(apiMock.mock.calls.find(([, init]) => init?.method === 'POST' && String(init.body).includes('New'))![1]!.body))).toEqual(
|
||||
{ parentPath: '/books', name: 'New' },
|
||||
)
|
||||
expect([...data.summaries.keys()]).toEqual(['/media'])
|
||||
apiMock.mockImplementation((_url, init) => json((JSON.parse(String(init?.body)).paths as string[]).map((path) => summary(path, 6))))
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
expect(data.summaries.get('/books')?.files).toBe(6)
|
||||
})
|
||||
})
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref, shallowReactive } from 'vue'
|
||||
import type { FolderListing, FolderSummary, OrganizationMode } from '@bookorbit/types'
|
||||
import type { PickerLibrary } from '../folder-picker-rules'
|
||||
import type { FolderPickerData } from '../useFolderPickerData'
|
||||
import { useFolderPickerSelection } from '../useFolderPickerSelection'
|
||||
|
||||
const novels: PickerLibrary = { id: 1, name: 'Novels', icon: null, folders: ['/books/Ebooks/Fiction'] }
|
||||
|
||||
function setup(options: { multiple?: boolean; own?: string[]; denied?: string[]; uncounted?: string[] } = {}) {
|
||||
const listings = shallowReactive(new Map<string, FolderListing>())
|
||||
const summaries = shallowReactive(new Map<string, FolderSummary>())
|
||||
for (const path of options.denied ?? []) listings.set(path, { path, state: 'denied', entries: [], truncated: false })
|
||||
const tree: Record<string, string[]> = {
|
||||
'/books': ['/books/Ebooks', '/books/Unsorted', '/books/Podcasts'],
|
||||
'/books/Ebooks': ['/books/Ebooks/Fiction', '/books/Ebooks/Science Fiction', '/books/Ebooks/Cookbooks'],
|
||||
}
|
||||
const files: Record<string, number> = {
|
||||
'/books/Unsorted': 12,
|
||||
'/books/Podcasts': 0,
|
||||
'/books/Ebooks/Science Fiction': 45,
|
||||
'/books/Ebooks/Cookbooks': 8,
|
||||
}
|
||||
const data = {
|
||||
listings,
|
||||
summaries,
|
||||
ensureListing: vi.fn<(path: string) => Promise<FolderListing>>(async (path: string) => ({
|
||||
path,
|
||||
state: 'ok' as const,
|
||||
truncated: false,
|
||||
entries: (tree[path] ?? []).map((child) => ({
|
||||
name: child.split('/').pop()!,
|
||||
path: child,
|
||||
mount: false,
|
||||
system: false,
|
||||
appData: false,
|
||||
hasChildren: true,
|
||||
})),
|
||||
})),
|
||||
summariesFor: vi.fn<(paths: string[]) => Promise<Map<string, FolderSummary>>>(
|
||||
async (paths: string[]) =>
|
||||
new Map(paths.filter((path) => !options.uncounted?.includes(path)).map((path) => [path, { files: files[path] ?? 0 } as FolderSummary])),
|
||||
),
|
||||
} as unknown as FolderPickerData
|
||||
const mode = ref<OrganizationMode>('book_per_folder')
|
||||
const onModeChange = vi.fn<(mode: OrganizationMode) => void>()
|
||||
const selection = useFolderPickerSelection({
|
||||
data,
|
||||
own: () => options.own ?? [],
|
||||
others: () => [novels],
|
||||
multiple: () => options.multiple ?? true,
|
||||
appDataPath: () => '/data',
|
||||
mode,
|
||||
onModeChange,
|
||||
})
|
||||
return { selection, mode, onModeChange, data }
|
||||
}
|
||||
|
||||
describe('useFolderPickerSelection', () => {
|
||||
it('ticks and unticks folders', () => {
|
||||
const { selection } = setup()
|
||||
|
||||
selection.toggle('/books/Unsorted')
|
||||
selection.toggle('/media/nas/')
|
||||
expect(selection.selected.value).toEqual(['/books/Unsorted', '/media/nas'])
|
||||
|
||||
selection.toggle('/books/Unsorted')
|
||||
expect(selection.selected.value).toEqual(['/media/nas'])
|
||||
})
|
||||
|
||||
it('refuses unreadable folders, the data folder and this library’s own folders, saying why', () => {
|
||||
const { selection } = setup({ own: ['/books/Ebooks/Science Fiction'], denied: ['/media/locked'] })
|
||||
|
||||
selection.toggle('/media/locked')
|
||||
expect(selection.notice.value).toEqual({ key: 'library.folderBrowser.reason.denied' })
|
||||
selection.toggle('/data/covers')
|
||||
expect(selection.notice.value).toEqual({ key: 'library.folderBrowser.reason.appData' })
|
||||
selection.toggle('/books/Ebooks/Science Fiction')
|
||||
expect(selection.notice.value).toMatchObject({ key: 'library.folderBrowser.reason.own' })
|
||||
expect(selection.selected.value).toEqual([])
|
||||
})
|
||||
|
||||
it('says when ticking a parent folds earlier picks into it', () => {
|
||||
const { selection } = setup()
|
||||
selection.toggle('/books/Ebooks/Cookbooks')
|
||||
|
||||
selection.toggle('/books/Ebooks')
|
||||
|
||||
expect(selection.selected.value).toEqual(['/books/Ebooks'])
|
||||
expect(selection.notice.value).toEqual({
|
||||
key: 'library.folderBrowser.notice.folded',
|
||||
params: { path: '/books/Ebooks', count: 1, name: 'Cookbooks' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ticks the run of siblings from the last ticked folder, skipping refused ones', () => {
|
||||
const { selection } = setup({ denied: ['/books/b'] })
|
||||
const siblings = ['/books/a', '/books/b', '/books/c', '/books/d']
|
||||
selection.toggle('/books/a')
|
||||
|
||||
selection.tickRange('/books/d', siblings)
|
||||
|
||||
expect(selection.selected.value).toEqual(['/books/a', '/books/c', '/books/d'])
|
||||
expect(selection.notice.value).toMatchObject({ key: 'library.folderBrowser.notice.range', params: { count: 2, from: 'a', to: 'd' } })
|
||||
})
|
||||
|
||||
it('says why a folder inside one of this library’s folders cannot be ticked', () => {
|
||||
const { selection } = setup({ own: ['/books/Ebooks'] })
|
||||
|
||||
selection.toggle('/books/Ebooks/Cookbooks')
|
||||
|
||||
expect(selection.notice.value).toEqual({ key: 'library.folderBrowser.reason.ownCovered', params: { path: '/books/Ebooks' } })
|
||||
})
|
||||
|
||||
it('keeps one folder in single mode', () => {
|
||||
const { selection } = setup({ multiple: false })
|
||||
selection.toggle('/books/Podcasts')
|
||||
selection.toggle('/media/nas')
|
||||
|
||||
expect(selection.selected.value).toEqual(['/media/nas'])
|
||||
})
|
||||
|
||||
it('undoes a removal and a mode switch', () => {
|
||||
const { selection, mode, onModeChange } = setup()
|
||||
selection.toggle('/books/Unsorted')
|
||||
|
||||
selection.setMode('book_per_file')
|
||||
expect(mode.value).toBe('book_per_file')
|
||||
expect(onModeChange).toHaveBeenLastCalledWith('book_per_file')
|
||||
|
||||
selection.restore()
|
||||
expect(mode.value).toBe('book_per_folder')
|
||||
expect(onModeChange).toHaveBeenLastCalledWith('book_per_folder')
|
||||
|
||||
selection.remove('/books/Unsorted')
|
||||
expect(selection.undo.value?.notice).toEqual({ key: 'library.folderBrowser.notice.removed', params: { name: 'Unsorted' } })
|
||||
selection.restore()
|
||||
expect(selection.selected.value).toEqual(['/books/Unsorted'])
|
||||
})
|
||||
|
||||
it('leaves out what another library reads, keeping the folders around it that hold books', async () => {
|
||||
const { selection } = setup()
|
||||
selection.toggle('/books')
|
||||
|
||||
await selection.leaveOut('/books')
|
||||
|
||||
expect(selection.selected.value).toEqual(['/books/Ebooks/Cookbooks', '/books/Ebooks/Science Fiction', '/books/Unsorted'])
|
||||
expect(selection.undo.value?.notice).toEqual({ key: 'library.folderBrowser.notice.leftOut', params: { name: 'books', count: 3 } })
|
||||
})
|
||||
|
||||
it('leaves out once when asked twice at the same time', async () => {
|
||||
const { selection } = setup()
|
||||
selection.toggle('/books')
|
||||
|
||||
await Promise.all([selection.leaveOut('/books'), selection.leaveOut('/books')])
|
||||
|
||||
expect(selection.selected.value).toEqual(['/books/Ebooks/Cookbooks', '/books/Ebooks/Science Fiction', '/books/Unsorted'])
|
||||
})
|
||||
|
||||
it('keeps a folder whose count could not be had rather than dropping its books', async () => {
|
||||
const { selection } = setup({ uncounted: ['/books/Podcasts'] })
|
||||
selection.toggle('/books')
|
||||
|
||||
await selection.leaveOut('/books')
|
||||
|
||||
expect(selection.selected.value).toContain('/books/Podcasts')
|
||||
})
|
||||
})
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref, shallowReactive } from 'vue'
|
||||
import type { FolderListing, FolderListingEntry, FolderPlaces, FolderSummary } from '@bookorbit/types'
|
||||
import type { FolderPickerData } from '../useFolderPickerData'
|
||||
import { useFolderPickerTree } from '../useFolderPickerTree'
|
||||
|
||||
function entry(path: string, extra: Partial<FolderListingEntry> = {}): FolderListingEntry {
|
||||
return { name: path.slice(path.lastIndexOf('/') + 1), path, mount: false, system: false, appData: false, hasChildren: true, ...extra }
|
||||
}
|
||||
|
||||
function fakeData(
|
||||
listings: Record<string, FolderListingEntry[]>,
|
||||
files: Record<string, number> = {},
|
||||
options: { truncatedListings?: string[]; truncatedCounts?: string[] } = {},
|
||||
) {
|
||||
const places = ref<FolderPlaces | null>({ root: '/', mounts: ['/books'], appDataPath: '/data', libraries: [] })
|
||||
const map = shallowReactive(new Map<string, FolderListing>())
|
||||
for (const [path, entries] of Object.entries(listings))
|
||||
map.set(path, { path, state: 'ok', entries, truncated: !!options.truncatedListings?.includes(path) })
|
||||
const summaries = shallowReactive(new Map<string, FolderSummary>())
|
||||
for (const [path, count] of Object.entries(files))
|
||||
summaries.set(path, {
|
||||
path,
|
||||
state: 'ok',
|
||||
files: count,
|
||||
folderBooks: count,
|
||||
formats: {},
|
||||
folders: 0,
|
||||
mergedFolders: 0,
|
||||
splitAudioFolders: 0,
|
||||
truncated: !!options.truncatedCounts?.includes(path),
|
||||
})
|
||||
return { places, listings: map, summaries, ensureListing: vi.fn<() => Promise<null>>(() => Promise.resolve(null)) } as unknown as FolderPickerData
|
||||
}
|
||||
|
||||
const server = {
|
||||
'/': [entry('/books', { mount: true }), entry('/usr', { system: true }), entry('/mnt', { hasChildren: false })],
|
||||
'/books': [entry('/books/Manga'), entry('/books/Ebooks'), entry('/books/Podcasts', { hasChildren: false })],
|
||||
'/books/Ebooks': [entry('/books/Ebooks/Science Fiction')],
|
||||
}
|
||||
|
||||
describe('useFolderPickerTree', () => {
|
||||
it('flattens open folders into rows, hiding system folders', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server))
|
||||
tree.expanded.value = new Set(['/books'])
|
||||
|
||||
expect(tree.rows.value.map((row) => [row.path, row.depth])).toEqual([
|
||||
['/books', 0],
|
||||
['/books/Ebooks', 1],
|
||||
['/books/Manga', 1],
|
||||
['/books/Podcasts', 1],
|
||||
['/mnt', 0],
|
||||
])
|
||||
expect(tree.hiddenSystemCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps a hidden system folder on the way to a picked folder', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server), () => ['/usr/share/books'])
|
||||
|
||||
expect(tree.rows.value.some((row) => row.path === '/usr')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a loading row under a folder opened before it has listed', () => {
|
||||
const data = fakeData(server)
|
||||
const tree = useFolderPickerTree(data)
|
||||
tree.expanded.value = new Set(['/books', '/books/Manga'])
|
||||
|
||||
expect(tree.rows.value.find((row) => row.type === 'loading')).toMatchObject({ path: '/books/Manga', depth: 2 })
|
||||
})
|
||||
|
||||
it('puts the new-folder row first inside its folder', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server))
|
||||
tree.expanded.value = new Set(['/books'])
|
||||
tree.newFolderParent.value = '/books'
|
||||
|
||||
expect(tree.rows.value[1]).toMatchObject({ type: 'new', path: '/books', depth: 1 })
|
||||
})
|
||||
|
||||
it('sorts by most books and hides folders without books when asked', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server, { '/books/Manga': 180, '/books/Ebooks': 210, '/books/Podcasts': 0 }))
|
||||
tree.expanded.value = new Set(['/books'])
|
||||
tree.sort.value = 'books'
|
||||
tree.hideEmpty.value = true
|
||||
|
||||
expect(tree.rows.value.filter((row) => row.depth === 1).map((row) => row.path)).toEqual(['/books/Ebooks', '/books/Manga'])
|
||||
})
|
||||
|
||||
it('keeps a folder whose count stopped short at zero, since it may hold books', () => {
|
||||
const tree = useFolderPickerTree(
|
||||
fakeData(server, { '/books/Manga': 0, '/books/Ebooks': 0, '/books/Podcasts': 0 }, { truncatedCounts: ['/books/Ebooks'] }),
|
||||
)
|
||||
tree.expanded.value = new Set(['/books'])
|
||||
tree.hideEmpty.value = true
|
||||
|
||||
expect(tree.rows.value.filter((row) => row.depth === 1).map((row) => row.path)).toEqual(['/books/Ebooks'])
|
||||
})
|
||||
|
||||
it('ends a folder listed only in part with a row saying how to reach the rest', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server, {}, { truncatedListings: ['/books'] }))
|
||||
tree.expanded.value = new Set(['/books'])
|
||||
|
||||
expect(tree.rows.value.map((row) => [row.type, row.path]).slice(1, 5)).toEqual([
|
||||
['folder', '/books/Ebooks'],
|
||||
['folder', '/books/Manga'],
|
||||
['folder', '/books/Podcasts'],
|
||||
['more', '/books'],
|
||||
])
|
||||
})
|
||||
|
||||
it('makes the first folder the tab stop until one is focused', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server))
|
||||
tree.expanded.value = new Set(['/books'])
|
||||
|
||||
expect(tree.tabStop.value).toBe('/books')
|
||||
tree.focus.value = '/books/Manga'
|
||||
expect(tree.tabStop.value).toBe('/books/Manga')
|
||||
tree.focus.value = '/books/Ebooks/Science Fiction'
|
||||
expect(tree.tabStop.value).toBe('/books')
|
||||
})
|
||||
|
||||
it('counts only the system folders it actually hides', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server), () => ['/usr/local/books'])
|
||||
|
||||
expect(tree.hiddenSystemCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('shows find matches with the folders around them', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server))
|
||||
tree.findResults.value = ['/books/Ebooks/Science Fiction/Martha Wells', '/books/Manga']
|
||||
|
||||
expect(tree.rows.value.map((row) => [row.path, row.depth, !!row.hit])).toEqual([
|
||||
['/books', 0, false],
|
||||
['/books/Ebooks', 1, false],
|
||||
['/books/Ebooks/Science Fiction', 2, false],
|
||||
['/books/Ebooks/Science Fiction/Martha Wells', 3, true],
|
||||
['/books/Manga', 1, true],
|
||||
])
|
||||
})
|
||||
|
||||
it('knows a folder is a leaf once its listing is empty, before that from its parent', () => {
|
||||
const tree = useFolderPickerTree(fakeData({ ...server, '/books/Manga': [] }))
|
||||
|
||||
expect(tree.hasChildren('/books/Manga')).toBe(false)
|
||||
expect(tree.hasChildren('/books/Ebooks')).toBe(true)
|
||||
expect(tree.hasChildren('/books/Podcasts')).toBe(false)
|
||||
})
|
||||
|
||||
it('reveals a folder by opening its parents and focusing it', () => {
|
||||
const tree = useFolderPickerTree(fakeData(server))
|
||||
|
||||
tree.reveal('/books/Ebooks/Science Fiction/')
|
||||
|
||||
expect([...tree.expanded.value]).toEqual(['/books', '/books/Ebooks'])
|
||||
expect(tree.focus.value).toBe('/books/Ebooks/Science Fiction')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { inject, type ComputedRef, type InjectionKey, type Ref } from 'vue'
|
||||
import type { OrganizationMode } from '@bookorbit/types'
|
||||
import type { FolderPickerKind, FolderStatus, PickerLibrary } from './folder-picker-rules'
|
||||
import type { FolderPickerData } from './useFolderPickerData'
|
||||
import type { FolderPickerSelection } from './useFolderPickerSelection'
|
||||
import type { FolderPickerTree } from './useFolderPickerTree'
|
||||
|
||||
export interface FolderPickerContext {
|
||||
data: FolderPickerData
|
||||
tree: FolderPickerTree
|
||||
selection: FolderPickerSelection
|
||||
kind: ComputedRef<FolderPickerKind>
|
||||
multiple: ComputedRef<boolean>
|
||||
/** Every library with folders, this one included and marked `self`. */
|
||||
libraries: ComputedRef<PickerLibrary[]>
|
||||
others: ComputedRef<PickerLibrary[]>
|
||||
own: ComputedRef<string[]>
|
||||
libraryName: ComputedRef<string>
|
||||
mode: Ref<OrganizationMode>
|
||||
modeLocked: ComputedRef<boolean>
|
||||
phone: Ref<boolean>
|
||||
/** A folder just revealed, tinted for a moment so the eye finds it. */
|
||||
flash: Ref<string | null>
|
||||
/** The text being found by name, for highlighting matches. */
|
||||
findQuery: Ref<string>
|
||||
newFolder: { name: Ref<string>; error: Ref<string | null>; busy: Ref<boolean> }
|
||||
/** The View menu is open; Escape then closes the menu, not the picker. */
|
||||
menuOpen: Ref<boolean>
|
||||
reveal: (path: string) => void
|
||||
focusFolder: (path: string) => void
|
||||
toggleFolder: (path: string, event?: MouseEvent | KeyboardEvent) => void
|
||||
openNewFolder: (parent: string) => void
|
||||
submitNewFolder: () => Promise<void>
|
||||
cancelNewFolder: () => void
|
||||
booksIn: (path: string) => number | null
|
||||
/** "3 books", or "3 episodes" when choosing for podcasts. */
|
||||
countLabel: (count: number) => string
|
||||
/** "3 books" as what a folder holds, or "3 episodes" for podcasts. */
|
||||
holdsLabel: (count: number) => string
|
||||
/** Why ticking a folder would put books in two libraries, for a status that warns. */
|
||||
describeOverlap: (path: string, status: FolderStatus) => string
|
||||
}
|
||||
|
||||
export const FOLDER_PICKER: InjectionKey<FolderPickerContext> = Symbol('folder-picker')
|
||||
|
||||
export function useFolderPickerContext(): FolderPickerContext {
|
||||
const context = inject(FOLDER_PICKER)
|
||||
if (!context) throw new Error('Folder picker parts need the context provided by FolderPickerModal')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { FolderSummary, OrganizationMode } from '@bookorbit/types'
|
||||
import { consolidateFolderPaths, normalizeFolderPath } from '../folder-paths'
|
||||
|
||||
/** What the picker is choosing folders for. */
|
||||
export type FolderPickerKind = 'books' | 'podcastStorage' | 'podcastLocal'
|
||||
|
||||
export interface PickerLibrary {
|
||||
id: number | null
|
||||
name: string
|
||||
icon: string | null
|
||||
folders: string[]
|
||||
/** The library being created or edited. */
|
||||
self?: boolean
|
||||
}
|
||||
|
||||
export type FolderStatusKind = 'denied' | 'appData' | 'own' | 'ownCovered' | 'selected' | 'included' | 'overlap' | 'contains'
|
||||
|
||||
export interface FolderStatus {
|
||||
kind: FolderStatusKind
|
||||
/** Ticking is refused. */
|
||||
block?: boolean
|
||||
/** Ticking is allowed but would put books in two libraries. */
|
||||
warn?: boolean
|
||||
/** For a ticked folder that overlaps: which overlap it is. */
|
||||
over?: 'overlap' | 'contains'
|
||||
/** The ticked or owned folder that includes this one. */
|
||||
cover?: string
|
||||
/** The library a folder sits in, or holds. */
|
||||
library?: PickerLibrary
|
||||
/** Every library a folder holds, for `contains`. */
|
||||
libraries?: PickerLibrary[]
|
||||
/** `overlap` at the library's own folder rather than below it. */
|
||||
exact?: boolean
|
||||
}
|
||||
|
||||
export interface StatusContext {
|
||||
denied: boolean
|
||||
appData: boolean
|
||||
own: string[]
|
||||
selected: string[]
|
||||
others: PickerLibrary[]
|
||||
}
|
||||
|
||||
export function isInside(path: string, parent: string): boolean {
|
||||
const a = normalizeFolderPath(path)
|
||||
const b = normalizeFolderPath(parent)
|
||||
return a !== b && (b === '/' ? a.startsWith('/') : a.startsWith(`${b}/`))
|
||||
}
|
||||
|
||||
export function isAtOrInside(path: string, parent: string): boolean {
|
||||
return normalizeFolderPath(path) === normalizeFolderPath(parent) || isInside(path, parent)
|
||||
}
|
||||
|
||||
export function parentPath(path: string): string {
|
||||
const parts = normalizeFolderPath(path).split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
return parts.length ? `/${parts.join('/')}` : '/'
|
||||
}
|
||||
|
||||
/** Folders between `root` (exclusive) and `path` (exclusive), outermost first. */
|
||||
export function ancestorsWithin(path: string, root: string): string[] {
|
||||
const top = normalizeFolderPath(root)
|
||||
const out: string[] = []
|
||||
for (let parent = parentPath(path); parent !== top && parent !== '/' && isInside(parent, top); parent = parentPath(parent)) out.unshift(parent)
|
||||
return out
|
||||
}
|
||||
|
||||
export function leafName(path: string): string {
|
||||
const normalized = normalizeFolderPath(path)
|
||||
return normalized === '/' ? '/' : normalized.slice(normalized.lastIndexOf('/') + 1)
|
||||
}
|
||||
|
||||
/** Another library already reading this folder, a folder around it, or a folder inside it. */
|
||||
export function overlapStatus(path: string, others: PickerLibrary[]): FolderStatus | null {
|
||||
const at = others.find((library) => library.folders.some((folder) => normalizeFolderPath(folder) === normalizeFolderPath(path)))
|
||||
if (at) return { kind: 'overlap', warn: true, library: at, exact: true }
|
||||
const around = others.find((library) => library.folders.some((folder) => isInside(path, folder)))
|
||||
if (around) return { kind: 'overlap', warn: true, library: around, exact: false }
|
||||
const within = others.filter((library) => library.folders.some((folder) => isInside(folder, path)))
|
||||
if (within.length > 0) return { kind: 'contains', warn: true, library: within[0], libraries: within }
|
||||
return null
|
||||
}
|
||||
|
||||
/** One status per folder, the first that applies. */
|
||||
export function folderStatus(path: string, context: StatusContext): FolderStatus | null {
|
||||
const normalized = normalizeFolderPath(path)
|
||||
if (context.denied) return { kind: 'denied', block: true }
|
||||
if (context.appData) return { kind: 'appData', block: true }
|
||||
if (context.own.some((folder) => normalizeFolderPath(folder) === normalized)) return { kind: 'own', block: true }
|
||||
const ownCover = context.own.find((folder) => isInside(normalized, folder))
|
||||
if (ownCover) return { kind: 'ownCovered', block: true, cover: normalizeFolderPath(ownCover) }
|
||||
// A ticked folder keeps its overlap warning: ticking it is exactly when the warning matters.
|
||||
const overlap = overlapStatus(normalized, context.others)
|
||||
if (context.selected.includes(normalized))
|
||||
return overlap ? { ...overlap, kind: 'selected', over: overlap.kind as 'overlap' | 'contains' } : { kind: 'selected' }
|
||||
const selectedCover = context.selected.find((folder) => isInside(normalized, folder))
|
||||
if (selectedCover) return { kind: 'included', block: true, cover: selectedCover }
|
||||
return overlap
|
||||
}
|
||||
|
||||
export interface ToggleResult {
|
||||
selected: string[]
|
||||
/** Picks folded into the newly ticked parent. */
|
||||
folded: string[]
|
||||
}
|
||||
|
||||
export function toggleFolder(selected: string[], path: string, multiple: boolean): ToggleResult {
|
||||
const normalized = normalizeFolderPath(path)
|
||||
if (!multiple) return { selected: selected[0] === normalized ? [] : [normalized], folded: [] }
|
||||
if (selected.includes(normalized)) return { selected: selected.filter((folder) => folder !== normalized), folded: [] }
|
||||
const folded = selected.filter((folder) => isInside(folder, normalized))
|
||||
return { selected: consolidateFolderPaths([...selected, normalized]).sort(), folded }
|
||||
}
|
||||
|
||||
/** Books the library would import from a folder in each organization mode. */
|
||||
export function booksAs(summary: FolderSummary | undefined, mode: OrganizationMode): number {
|
||||
if (!summary) return 0
|
||||
return mode === 'book_per_file' ? summary.files : summary.folderBooks
|
||||
}
|
||||
|
||||
export type AttentionKind = 'contains' | 'overlap' | 'merge' | 'split' | 'notEmpty'
|
||||
|
||||
export interface Attention {
|
||||
kind: AttentionKind
|
||||
status?: FolderStatus
|
||||
}
|
||||
|
||||
/** What a picked folder needs before it is added, or null when it is fine as it is. */
|
||||
export function attentionFor(
|
||||
summary: FolderSummary | undefined,
|
||||
status: FolderStatus | null,
|
||||
mode: OrganizationMode,
|
||||
kind: FolderPickerKind,
|
||||
): Attention | null {
|
||||
if (kind === 'podcastStorage') return summary && (summary.files > 0 || summary.folders > 0) ? { kind: 'notEmpty' } : null
|
||||
if (status?.warn) return { kind: status.over === 'contains' ? 'contains' : 'overlap', status }
|
||||
if (kind !== 'books' || !summary) return null
|
||||
if (mode === 'book_per_folder' && summary.mergedFolders > 0) return { kind: 'merge' }
|
||||
if (mode === 'book_per_file' && summary.splitAudioFolders > 0) return { kind: 'split' }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Siblings from `from` to `to`, inclusive, in list order. */
|
||||
export function rangeBetween(siblings: string[], from: string, to: string): string[] {
|
||||
const a = siblings.indexOf(from)
|
||||
const b = siblings.indexOf(to)
|
||||
if (a < 0 || b < 0) return []
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a]
|
||||
return siblings.slice(lo, hi + 1)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { normalizeFolderPath } from '../folder-paths'
|
||||
import { parentPath } from './folder-picker-rules'
|
||||
|
||||
const STORAGE_KEY = 'bookorbit:folder-picker:recent'
|
||||
const LIMIT = 5
|
||||
|
||||
/** Folders the picker last added from, newest first, in this browser. */
|
||||
export function readRecentFolders(): string[] {
|
||||
try {
|
||||
const stored: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
|
||||
return Array.isArray(stored) ? stored.filter((value): value is string => typeof value === 'string').slice(0, LIMIT) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembers where the added folders sit, so the next picker can jump back there. */
|
||||
export function rememberRecentFolders(added: string[]) {
|
||||
const parents = added.map((path) => parentPath(normalizeFolderPath(path))).filter((path) => path !== '/')
|
||||
const next = [...new Set([...parents, ...readRecentFolders()])].slice(0, LIMIT)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// Private windows forget it.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { ref, shallowReactive } from 'vue'
|
||||
import type {
|
||||
CreateFolderResult,
|
||||
FolderInspection,
|
||||
FolderListing,
|
||||
FolderPlaces,
|
||||
FolderSearchResult,
|
||||
FolderSummary,
|
||||
UnclaimedFolders,
|
||||
} from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { normalizeFolderPath } from '../folder-paths'
|
||||
import { isAtOrInside } from './folder-picker-rules'
|
||||
|
||||
/** Rows that scroll into view within this window share one request. */
|
||||
const SUMMARY_BATCH_DELAY_MS = 40
|
||||
const SUMMARY_BATCH_SIZE = 100
|
||||
/** A folder the server ran out of time for is asked for again, a few times, a little later each time. */
|
||||
const PENDING_RETRY_MS = 300
|
||||
const PENDING_RETRIES = 5
|
||||
/** Each inspection walks a folder and sizes its files, so only a few run at once. */
|
||||
const INSPECTION_CONCURRENCY = 3
|
||||
|
||||
export type CreateFolderError = 'exists' | 'denied' | 'invalid' | 'failed'
|
||||
|
||||
/**
|
||||
* Everything the folder picker reads from the server, cached for the life of the dialog. Requests
|
||||
* are deduplicated, and counts for visible rows are batched.
|
||||
*/
|
||||
export function useFolderPickerData() {
|
||||
const places = ref<FolderPlaces | null>(null)
|
||||
const placesFailed = ref(false)
|
||||
const unclaimed = ref<UnclaimedFolders | null>(null)
|
||||
const listings = shallowReactive(new Map<string, FolderListing>())
|
||||
const summaries = shallowReactive(new Map<string, FolderSummary>())
|
||||
const inspections = shallowReactive(new Map<string, FolderInspection>())
|
||||
const pendingListings = new Map<string, Promise<FolderListing | null>>()
|
||||
const pendingInspections = new Map<string, Promise<FolderInspection | null>>()
|
||||
const requestedSummaries = new Set<string>()
|
||||
const pendingRetries = new Map<string, number>()
|
||||
/** Folders whose inspection failed, so the preview stops waiting for it. */
|
||||
const failedInspections = shallowReactive(new Set<string>())
|
||||
let summaryQueue: string[] = []
|
||||
let disposed = false
|
||||
let summaryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let inspecting = 0
|
||||
const inspectionQueue: Array<() => void> = []
|
||||
|
||||
async function loadPlaces() {
|
||||
try {
|
||||
const response = await api('/api/v1/path/places')
|
||||
if (!response.ok) throw new Error(String(response.status))
|
||||
places.value = await response.json()
|
||||
placesFailed.value = false
|
||||
} catch {
|
||||
placesFailed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function ensureListing(path: string): Promise<FolderListing | null> {
|
||||
const key = normalizeFolderPath(path)
|
||||
const known = listings.get(key)
|
||||
if (known) return Promise.resolve(known)
|
||||
const pending = pendingListings.get(key)
|
||||
if (pending) return pending
|
||||
const request = (async () => {
|
||||
try {
|
||||
const response = await api(`/api/v1/path/listing?path=${encodeURIComponent(key)}`)
|
||||
const listing: FolderListing = response.ok
|
||||
? await response.json()
|
||||
: { path: key, state: response.status === 403 ? 'blocked' : 'missing', entries: [], truncated: false }
|
||||
listings.set(key, listing)
|
||||
return listing
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
pendingListings.delete(key)
|
||||
}
|
||||
})()
|
||||
pendingListings.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
function requestSummaries(paths: string[]) {
|
||||
for (const path of paths) {
|
||||
const key = normalizeFolderPath(path)
|
||||
if (summaries.has(key) || requestedSummaries.has(key)) continue
|
||||
requestedSummaries.add(key)
|
||||
summaryQueue.push(key)
|
||||
}
|
||||
if (summaryQueue.length > 0 && !summaryTimer) summaryTimer = setTimeout(flushSummaries, SUMMARY_BATCH_DELAY_MS)
|
||||
}
|
||||
|
||||
async function flushSummaries() {
|
||||
summaryTimer = null
|
||||
const queue = summaryQueue
|
||||
summaryQueue = []
|
||||
for (let i = 0; i < queue.length; i += SUMMARY_BATCH_SIZE) {
|
||||
const batch = queue.slice(i, i + SUMMARY_BATCH_SIZE)
|
||||
try {
|
||||
const response = await api('/api/v1/path/summaries', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: batch }),
|
||||
})
|
||||
if (!response.ok) throw new Error(String(response.status))
|
||||
const results: FolderSummary[] = await response.json()
|
||||
for (const summary of results) {
|
||||
const key = normalizeFolderPath(summary.path)
|
||||
if (summary.state === 'pending') retryLater(key)
|
||||
else summaries.set(key, summary)
|
||||
}
|
||||
} catch {
|
||||
// Unanswered paths may be asked for again when they next scroll into view.
|
||||
for (const path of batch) requestedSummaries.delete(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retryLater(path: string) {
|
||||
requestedSummaries.delete(path)
|
||||
const attempt = (pendingRetries.get(path) ?? 0) + 1
|
||||
pendingRetries.set(path, attempt)
|
||||
if (attempt > PENDING_RETRIES) return
|
||||
setTimeout(() => {
|
||||
if (!disposed) requestSummaries([path])
|
||||
}, PENDING_RETRY_MS * attempt)
|
||||
}
|
||||
|
||||
/** Counts for `paths` now, for a decision that cannot wait for rows to scroll into view. */
|
||||
async function summariesFor(paths: string[]): Promise<Map<string, FolderSummary>> {
|
||||
const keys = [...new Set(paths.map(normalizeFolderPath))]
|
||||
const missing = keys.filter((key) => !summaries.has(key))
|
||||
for (let i = 0; i < missing.length; i += SUMMARY_BATCH_SIZE) {
|
||||
const batch = missing.slice(i, i + SUMMARY_BATCH_SIZE)
|
||||
try {
|
||||
const response = await api('/api/v1/path/summaries', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: batch }),
|
||||
})
|
||||
if (!response.ok) continue
|
||||
const results: FolderSummary[] = await response.json()
|
||||
for (const summary of results) if (summary.state !== 'pending') summaries.set(normalizeFolderPath(summary.path), summary)
|
||||
} catch {
|
||||
// Missing answers count as unknown.
|
||||
}
|
||||
}
|
||||
return new Map(keys.flatMap((key) => (summaries.has(key) ? [[key, summaries.get(key)!] as const] : [])))
|
||||
}
|
||||
|
||||
function ensureInspection(path: string): Promise<FolderInspection | null> {
|
||||
const key = normalizeFolderPath(path)
|
||||
const known = inspections.get(key)
|
||||
if (known) return Promise.resolve(known)
|
||||
const pending = pendingInspections.get(key)
|
||||
if (pending) return pending
|
||||
const request = (async () => {
|
||||
if (inspecting >= INSPECTION_CONCURRENCY) await new Promise<void>((resolve) => inspectionQueue.push(resolve))
|
||||
inspecting += 1
|
||||
try {
|
||||
if (disposed) return null
|
||||
const response = await api(`/api/v1/path/inspect?path=${encodeURIComponent(key)}`)
|
||||
if (!response.ok) {
|
||||
failedInspections.add(key)
|
||||
return null
|
||||
}
|
||||
const inspection: FolderInspection = await response.json()
|
||||
failedInspections.delete(key)
|
||||
inspections.set(key, inspection)
|
||||
if (!summaries.has(key)) summaries.set(key, inspection)
|
||||
return inspection
|
||||
} catch {
|
||||
failedInspections.add(key)
|
||||
return null
|
||||
} finally {
|
||||
pendingInspections.delete(key)
|
||||
inspecting -= 1
|
||||
inspectionQueue.shift()?.()
|
||||
}
|
||||
})()
|
||||
pendingInspections.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function loadUnclaimed() {
|
||||
try {
|
||||
const response = await api('/api/v1/path/unclaimed')
|
||||
if (response.ok) unclaimed.value = await response.json()
|
||||
} catch {
|
||||
unclaimed.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function searchFolders(query: string, signal?: AbortSignal, includeSystem = false): Promise<FolderSearchResult | null> {
|
||||
try {
|
||||
const response = await api(`/api/v1/path/search?q=${encodeURIComponent(query)}${includeSystem ? '&system=true' : ''}`, { signal })
|
||||
return response.ok ? await response.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(parent: string, name: string): Promise<{ path: string } | { error: CreateFolderError }> {
|
||||
try {
|
||||
const response = await api('/api/v1/path', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parentPath: normalizeFolderPath(parent), name }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error: CreateFolderError =
|
||||
response.status === 409 ? 'exists' : response.status === 403 ? 'denied' : response.status === 400 ? 'invalid' : 'failed'
|
||||
return { error }
|
||||
}
|
||||
const created: CreateFolderResult = await response.json()
|
||||
// A new folder changes what the folders above it hold, so their counts are asked for again.
|
||||
const parentKey = normalizeFolderPath(parent)
|
||||
const stale = [...summaries.keys()].filter((key) => isAtOrInside(parentKey, key))
|
||||
for (const key of stale) {
|
||||
summaries.delete(key)
|
||||
requestedSummaries.delete(key)
|
||||
}
|
||||
for (const key of inspections.keys()) if (isAtOrInside(parentKey, key)) inspections.delete(key)
|
||||
listings.delete(parentKey)
|
||||
void ensureListing(parentKey)
|
||||
requestSummaries(stale)
|
||||
return { path: normalizeFolderPath(created.path) }
|
||||
} catch {
|
||||
return { error: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
disposed = true
|
||||
if (summaryTimer) clearTimeout(summaryTimer)
|
||||
summaryTimer = null
|
||||
for (const release of inspectionQueue.splice(0)) release()
|
||||
}
|
||||
|
||||
return {
|
||||
places,
|
||||
placesFailed,
|
||||
unclaimed,
|
||||
listings,
|
||||
summaries,
|
||||
inspections,
|
||||
failedInspections,
|
||||
loadPlaces,
|
||||
ensureListing,
|
||||
requestSummaries,
|
||||
summariesFor,
|
||||
ensureInspection,
|
||||
loadUnclaimed,
|
||||
searchFolders,
|
||||
createFolder,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
export type FolderPickerData = ReturnType<typeof useFolderPickerData>
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { OrganizationMode } from '@bookorbit/types'
|
||||
import { normalizeFolderPath } from '../folder-paths'
|
||||
import { folderStatus, isInside, leafName, rangeBetween, toggleFolder, type FolderStatus, type PickerLibrary } from './folder-picker-rules'
|
||||
import type { FolderPickerData } from './useFolderPickerData'
|
||||
|
||||
/** A message for the footer, translated where it is shown. */
|
||||
export interface PickerNotice {
|
||||
key: string
|
||||
params?: Record<string, string | number>
|
||||
}
|
||||
|
||||
interface SelectionOptions {
|
||||
data: FolderPickerData
|
||||
own: () => string[]
|
||||
others: () => PickerLibrary[]
|
||||
multiple: () => boolean
|
||||
appDataPath: () => string | null
|
||||
mode: Ref<OrganizationMode>
|
||||
onModeChange: (mode: OrganizationMode) => void
|
||||
}
|
||||
|
||||
export function useFolderPickerSelection(options: SelectionOptions) {
|
||||
const selected = ref<string[]>([])
|
||||
const notice = ref<PickerNotice | null>(null)
|
||||
const undo = ref<{ selected: string[]; mode: OrganizationMode; notice: PickerNotice } | null>(null)
|
||||
const lastTick = ref<string | null>(null)
|
||||
|
||||
function isDenied(path: string): boolean {
|
||||
const key = normalizeFolderPath(path)
|
||||
const state = options.data.listings.get(key)?.state ?? options.data.summaries.get(key)?.state
|
||||
return state === 'denied'
|
||||
}
|
||||
|
||||
function isAppData(path: string): boolean {
|
||||
const appData = options.appDataPath()
|
||||
const key = normalizeFolderPath(path)
|
||||
return !!appData && (key === normalizeFolderPath(appData) || isInside(key, appData))
|
||||
}
|
||||
|
||||
function status(path: string): FolderStatus | null {
|
||||
return folderStatus(path, {
|
||||
denied: isDenied(path),
|
||||
appData: isAppData(path),
|
||||
own: options.own(),
|
||||
selected: selected.value,
|
||||
others: options.others(),
|
||||
})
|
||||
}
|
||||
|
||||
function blockedNotice(path: string, current: FolderStatus): PickerNotice {
|
||||
const name = leafName(path)
|
||||
if (current.kind === 'denied') return { key: 'library.folderBrowser.reason.denied' }
|
||||
if (current.kind === 'appData') return { key: 'library.folderBrowser.reason.appData' }
|
||||
if (current.kind === 'own') return { key: 'library.folderBrowser.reason.own', params: { name } }
|
||||
if (current.kind === 'ownCovered') return { key: 'library.folderBrowser.reason.ownCovered', params: { path: current.cover ?? '' } }
|
||||
return { key: 'library.folderBrowser.reason.included', params: { path: current.cover ?? '' } }
|
||||
}
|
||||
|
||||
function toggle(path: string) {
|
||||
const current = status(path)
|
||||
undo.value = null
|
||||
if (current?.block) {
|
||||
notice.value = blockedNotice(path, current)
|
||||
return
|
||||
}
|
||||
const result = toggleFolder(selected.value, path, options.multiple())
|
||||
selected.value = result.selected
|
||||
lastTick.value = normalizeFolderPath(path)
|
||||
notice.value = result.folded.length
|
||||
? {
|
||||
key: 'library.folderBrowser.notice.folded',
|
||||
params: { path: normalizeFolderPath(path), count: result.folded.length, name: leafName(result.folded[0]!) },
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
/** Shift-click: tick every sibling from the last ticked folder to this one. */
|
||||
function tickRange(path: string, siblings: string[]) {
|
||||
const run = lastTick.value ? rangeBetween(siblings, lastTick.value, normalizeFolderPath(path)) : []
|
||||
if (!options.multiple() || run.length === 0) return toggle(path)
|
||||
undo.value = null
|
||||
let ticked = 0
|
||||
for (const folder of run) {
|
||||
if (selected.value.includes(folder) || status(folder)?.block) continue
|
||||
selected.value = toggleFolder(selected.value, folder, true).selected
|
||||
ticked += 1
|
||||
}
|
||||
lastTick.value = normalizeFolderPath(path)
|
||||
notice.value = ticked
|
||||
? { key: 'library.folderBrowser.notice.range', params: { count: ticked, from: leafName(run[0]!), to: leafName(run[run.length - 1]!) } }
|
||||
: null
|
||||
}
|
||||
|
||||
function snapshot(next: PickerNotice) {
|
||||
undo.value = { selected: [...selected.value], mode: options.mode.value, notice: next }
|
||||
notice.value = null
|
||||
}
|
||||
|
||||
function remove(path: string) {
|
||||
snapshot({ key: 'library.folderBrowser.notice.removed', params: { name: leafName(path) } })
|
||||
selected.value = selected.value.filter((folder) => folder !== normalizeFolderPath(path))
|
||||
}
|
||||
|
||||
function setMode(mode: OrganizationMode) {
|
||||
if (options.mode.value === mode) return
|
||||
snapshot({ key: mode === 'book_per_file' ? 'library.folderBrowser.notice.modeFile' : 'library.folderBrowser.notice.modeFolder' })
|
||||
options.mode.value = mode
|
||||
options.onModeChange(mode)
|
||||
}
|
||||
|
||||
let leaving = false
|
||||
|
||||
/** Swaps a picked folder for the folders inside it, leaving out whatever another library reads. */
|
||||
async function leaveOut(path: string) {
|
||||
if (leaving) return
|
||||
leaving = true
|
||||
try {
|
||||
const others = options.others()
|
||||
const readByOther = (folder: string) => others.some((library) => library.folders.some((own) => normalizeFolderPath(own) === folder))
|
||||
const holdsOther = (folder: string) => others.some((library) => library.folders.some((own) => isInside(own, folder)))
|
||||
const keep: string[] = []
|
||||
const walk = async (folder: string) => {
|
||||
const listing = await options.data.ensureListing(folder)
|
||||
const children = (listing?.entries ?? []).filter((entry) => !entry.system && !entry.appData).map((entry) => normalizeFolderPath(entry.path))
|
||||
const counts = await options.data.summariesFor(children)
|
||||
for (const child of children) {
|
||||
if (readByOther(child) || isDenied(child)) continue
|
||||
if (holdsOther(child)) await walk(child)
|
||||
// A folder whose count could not be had is kept: dropping it could lose books.
|
||||
else if ((counts.get(child)?.files ?? 1) > 0) keep.push(child)
|
||||
}
|
||||
}
|
||||
const key = normalizeFolderPath(path)
|
||||
await walk(key)
|
||||
if (!selected.value.includes(key)) return
|
||||
snapshot({ key: 'library.folderBrowser.notice.leftOut', params: { name: leafName(key), count: keep.length } })
|
||||
selected.value = [...new Set([...selected.value.filter((folder) => folder !== key), ...keep])].sort()
|
||||
} finally {
|
||||
leaving = false
|
||||
}
|
||||
}
|
||||
|
||||
function restore() {
|
||||
if (!undo.value) return
|
||||
selected.value = undo.value.selected
|
||||
if (undo.value.mode !== options.mode.value) {
|
||||
options.mode.value = undo.value.mode
|
||||
options.onModeChange(undo.value.mode)
|
||||
}
|
||||
undo.value = null
|
||||
notice.value = null
|
||||
}
|
||||
|
||||
return { selected, notice, undo, lastTick, status, toggle, tickRange, remove, setMode, leaveOut, restore }
|
||||
}
|
||||
|
||||
export type FolderPickerSelection = ReturnType<typeof useFolderPickerSelection>
|
||||
@@ -0,0 +1,198 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { FolderListing, FolderListingEntry } from '@bookorbit/types'
|
||||
import { normalizeFolderPath } from '../folder-paths'
|
||||
import { ancestorsWithin, isAtOrInside, isInside, leafName } from './folder-picker-rules'
|
||||
import type { FolderPickerData } from './useFolderPickerData'
|
||||
|
||||
export interface TreeRow {
|
||||
key: string
|
||||
/** `more`: a folder listed only in part, which says how to reach the rest. */
|
||||
type: 'folder' | 'new' | 'loading' | 'more'
|
||||
/** The folder itself; for the other rows, the folder they sit in. */
|
||||
path: string
|
||||
depth: number
|
||||
entry: FolderListingEntry
|
||||
/** A find match rather than a parent shown for context. */
|
||||
hit?: boolean
|
||||
}
|
||||
|
||||
export type TreeSort = 'name' | 'books'
|
||||
|
||||
const segments = (path: string) => normalizeFolderPath(path).split('/').filter(Boolean)
|
||||
|
||||
function comparePaths(a: string, b: string): number {
|
||||
const left = segments(a)
|
||||
const right = segments(b)
|
||||
for (let i = 0; i < Math.min(left.length, right.length); i++) {
|
||||
const order = left[i]!.localeCompare(right[i]!, undefined, { sensitivity: 'base' })
|
||||
if (order !== 0) return order
|
||||
}
|
||||
return left.length - right.length
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder tree as flat rows for a virtual list, from the listings loaded so far. `pinned` lists
|
||||
* folders that stay reachable even under a hidden system folder: the ones picked or already read.
|
||||
*/
|
||||
export function useFolderPickerTree(data: FolderPickerData, pinned: () => string[] = () => []) {
|
||||
const expanded = ref<Set<string>>(new Set())
|
||||
const focus = ref<string | null>(null)
|
||||
const sort = ref<TreeSort>('name')
|
||||
const hideEmpty = ref(false)
|
||||
const showSystem = ref(false)
|
||||
const newFolderParent = ref<string | null>(null)
|
||||
/** Matching paths while finding by name; null when not finding. */
|
||||
const findResults = ref<string[] | null>(null)
|
||||
const findTruncated = ref(false)
|
||||
|
||||
const root = computed(() => normalizeFolderPath(data.places.value?.root ?? '/'))
|
||||
|
||||
const entries = computed(() => {
|
||||
const index = new Map<string, FolderListingEntry>()
|
||||
for (const listing of data.listings.values()) for (const entry of listing.entries) index.set(normalizeFolderPath(entry.path), entry)
|
||||
return index
|
||||
})
|
||||
|
||||
function entryFor(path: string): FolderListingEntry {
|
||||
const key = normalizeFolderPath(path)
|
||||
return entries.value.get(key) ?? { name: leafName(key), path: key, mount: false, system: false, appData: false, hasChildren: null }
|
||||
}
|
||||
|
||||
// A listing is sorted by name once; re-sorting thousands of rows on every keypress would lag.
|
||||
const nameOrder = new WeakMap<FolderListing, FolderListingEntry[]>()
|
||||
function byName(listing: FolderListing): FolderListingEntry[] {
|
||||
let sorted = nameOrder.get(listing)
|
||||
if (!sorted) {
|
||||
sorted = [...listing.entries].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||
nameOrder.set(listing, sorted)
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
/** Book files in a folder, or undefined while unknown: an unfinished count of zero is not an empty folder. */
|
||||
function knownFiles(path: string): number | undefined {
|
||||
const summary = data.summaries.get(normalizeFolderPath(path))
|
||||
if (!summary || (summary.truncated && summary.files === 0)) return undefined
|
||||
return summary.files
|
||||
}
|
||||
|
||||
function visibleChildren(path: string): FolderListingEntry[] {
|
||||
const listing = data.listings.get(normalizeFolderPath(path))
|
||||
if (!listing) return []
|
||||
// Read only for the few entries that would otherwise be hidden, so focus moves do not re-sort the tree.
|
||||
let kept: string[] | null = null
|
||||
const keep = (entry: FolderListingEntry) => {
|
||||
kept ??= [focus.value, newFolderParent.value, ...pinned()].filter((folder): folder is string => !!folder)
|
||||
return kept.some((folder) => isAtOrInside(folder, entry.path))
|
||||
}
|
||||
const shown = byName(listing).filter(
|
||||
(entry) => ((showSystem.value || !entry.system) && (!hideEmpty.value || knownFiles(entry.path) !== 0)) || keep(entry),
|
||||
)
|
||||
if (sort.value !== 'books') return shown
|
||||
return [...shown].sort((a, b) => (knownFiles(b.path) ?? -1) - (knownFiles(a.path) ?? -1))
|
||||
}
|
||||
|
||||
/** Whether a folder can be opened: known from its listing once loaded, else from its parent's listing. */
|
||||
function hasChildren(path: string): boolean {
|
||||
const listing = data.listings.get(normalizeFolderPath(path))
|
||||
if (listing) return visibleChildren(path).length > 0
|
||||
return entryFor(path).hasChildren !== false
|
||||
}
|
||||
|
||||
const rows = computed<TreeRow[]>(() => {
|
||||
const out: TreeRow[] = []
|
||||
if (findResults.value) {
|
||||
const hits = new Set(findResults.value.map(normalizeFolderPath))
|
||||
const shown = new Set<string>()
|
||||
for (const hit of hits) {
|
||||
if (!isInside(hit, root.value)) continue
|
||||
shown.add(hit)
|
||||
for (const parent of ancestorsWithin(hit, root.value)) shown.add(parent)
|
||||
}
|
||||
const base = segments(root.value).length
|
||||
for (const path of [...shown].sort(comparePaths)) {
|
||||
out.push({ key: path, type: 'folder', path, depth: segments(path).length - base - 1, entry: entryFor(path), hit: hits.has(path) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const walk = (path: string, depth: number) => {
|
||||
if (newFolderParent.value === path) out.push({ key: `new:${path}`, type: 'new', path, depth, entry: entryFor(path) })
|
||||
for (const entry of visibleChildren(path)) {
|
||||
const child = normalizeFolderPath(entry.path)
|
||||
out.push({ key: child, type: 'folder', path: child, depth, entry })
|
||||
if (!expanded.value.has(child)) continue
|
||||
if (data.listings.has(child)) walk(child, depth + 1)
|
||||
else out.push({ key: `loading:${child}`, type: 'loading', path: child, depth: depth + 1, entry })
|
||||
}
|
||||
if (data.listings.get(path)?.truncated) out.push({ key: `more:${path}`, type: 'more', path, depth, entry: entryFor(path) })
|
||||
}
|
||||
walk(root.value, 0)
|
||||
return out
|
||||
})
|
||||
|
||||
/** The one row reached with Tab: the focused folder, or the first one before anything is focused. */
|
||||
const tabStop = computed(() => {
|
||||
let first: string | null = null
|
||||
for (const row of rows.value) {
|
||||
if (row.type !== 'folder') continue
|
||||
if (row.path === focus.value) return row.path
|
||||
first ??= row.path
|
||||
}
|
||||
return first
|
||||
})
|
||||
|
||||
const hiddenSystemCount = computed(() => {
|
||||
const listing = data.listings.get(root.value)
|
||||
if (showSystem.value || !listing) return 0
|
||||
return listing.entries.filter((entry) => entry.system).length - visibleChildren(root.value).filter((entry) => entry.system).length
|
||||
})
|
||||
|
||||
function setExpanded(path: string, open: boolean) {
|
||||
const key = normalizeFolderPath(path)
|
||||
if (expanded.value.has(key) === open) return
|
||||
const next = new Set(expanded.value)
|
||||
if (open) next.add(key)
|
||||
else next.delete(key)
|
||||
expanded.value = next
|
||||
}
|
||||
|
||||
/** Opens every folder above `path` and focuses it. */
|
||||
function reveal(path: string) {
|
||||
const key = normalizeFolderPath(path)
|
||||
expanded.value = new Set([...expanded.value, ...ancestorsWithin(key, root.value)])
|
||||
focus.value = key
|
||||
}
|
||||
|
||||
// Listings load as their folders open.
|
||||
watch(
|
||||
[expanded, root, () => data.places.value],
|
||||
() => {
|
||||
if (!data.places.value) return
|
||||
void data.ensureListing(root.value)
|
||||
for (const path of expanded.value) if (!data.listings.has(path)) void data.ensureListing(path)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
expanded,
|
||||
focus,
|
||||
sort,
|
||||
hideEmpty,
|
||||
showSystem,
|
||||
newFolderParent,
|
||||
findResults,
|
||||
findTruncated,
|
||||
rows,
|
||||
tabStop,
|
||||
hiddenSystemCount,
|
||||
entryFor,
|
||||
visibleChildren,
|
||||
hasChildren,
|
||||
setExpanded,
|
||||
reveal,
|
||||
}
|
||||
}
|
||||
|
||||
export type FolderPickerTree = ReturnType<typeof useFolderPickerTree>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'bookorbit:folder-picker:plan-width'
|
||||
|
||||
export const PLAN_WIDTH = { min: 288, default: 384, treeMin: 420, step: 16 } as const
|
||||
|
||||
/** The plan's width is a per-browser preference: the divider between the halves drags. */
|
||||
export function usePickerPlanWidth() {
|
||||
const width = ref<number>(read())
|
||||
|
||||
function read(): number {
|
||||
try {
|
||||
const stored = Number(localStorage.getItem(STORAGE_KEY))
|
||||
return Number.isFinite(stored) && stored >= PLAN_WIDTH.min ? stored : PLAN_WIDTH.default
|
||||
} catch {
|
||||
return PLAN_WIDTH.default
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, gridWidth: number): number {
|
||||
return Math.round(Math.max(PLAN_WIDTH.min, Math.min(value, gridWidth - PLAN_WIDTH.treeMin)))
|
||||
}
|
||||
|
||||
function save() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(width.value))
|
||||
} catch {
|
||||
// Private windows forget it; the default still works.
|
||||
}
|
||||
}
|
||||
|
||||
return { width, clamp, save }
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import type { CreateFolderResult, DirectoryEntry, PathConfig } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { consolidateFolderPaths, coveringFolderPath, normalizeFolderPath } from './folder-paths'
|
||||
|
||||
export function useFolderBrowser(existingPaths: () => string[]) {
|
||||
const browseRoot = ref('/')
|
||||
const currentPath = ref('/')
|
||||
const entries = ref<DirectoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const search = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const selectedPaths = ref<string[]>([])
|
||||
const selectionNotice = ref<string | null>(null)
|
||||
const creatingFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const createError = ref<string | null>(null)
|
||||
const createLoading = ref(false)
|
||||
const newFolderInput = ref<HTMLInputElement | null>(null)
|
||||
let loadSequence = 0
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
return query ? entries.value.filter((entry) => entry.name.toLowerCase().includes(query)) : entries.value
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const root = normalizeFolderPath(browseRoot.value)
|
||||
const current = normalizeFolderPath(currentPath.value)
|
||||
const crumbs = [{ label: root, path: root }]
|
||||
if (current === root) return crumbs
|
||||
|
||||
const relative = root === '/' ? current.slice(1) : current.slice(root.length + 1)
|
||||
const parts = relative.split('/').filter(Boolean)
|
||||
let built = root === '/' ? '' : root
|
||||
for (const part of parts) {
|
||||
built = built ? `${built}/${part}` : `/${part}`
|
||||
crumbs.push({ label: part, path: built })
|
||||
}
|
||||
return crumbs
|
||||
})
|
||||
|
||||
const canGoUp = computed(() => normalizeFolderPath(currentPath.value) !== normalizeFolderPath(browseRoot.value))
|
||||
const selectedCount = computed(() => selectedPaths.value.length)
|
||||
|
||||
function navigate(path: string) {
|
||||
search.value = ''
|
||||
cancelNewFolder()
|
||||
const normalized = normalizeFolderPath(path)
|
||||
currentPath.value = isAtOrBelowBrowseRoot(normalized) ? normalized : browseRoot.value
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
if (!canGoUp.value) return
|
||||
const parts = currentPath.value.split('/').filter(Boolean)
|
||||
parts.pop()
|
||||
const parent = parts.length === 0 ? '/' : `/${parts.join('/')}`
|
||||
navigate(parent)
|
||||
}
|
||||
|
||||
async function loadEntries(path: string) {
|
||||
const requestSequence = ++loadSequence
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api(`/api/v1/path?path=${encodeURIComponent(path)}`)
|
||||
if (requestSequence !== loadSequence) return
|
||||
if (response.ok) {
|
||||
entries.value = await response.json()
|
||||
} else {
|
||||
error.value = 'This directory could not be read.'
|
||||
entries.value = []
|
||||
}
|
||||
} catch {
|
||||
if (requestSequence !== loadSequence) return
|
||||
error.value = 'BookOrbit could not connect to the server.'
|
||||
entries.value = []
|
||||
} finally {
|
||||
if (requestSequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await api('/api/v1/path/config')
|
||||
if (response.ok) {
|
||||
const config: PathConfig = await response.json()
|
||||
browseRoot.value = normalizeFolderPath(config.root)
|
||||
}
|
||||
} catch {
|
||||
browseRoot.value = '/'
|
||||
} finally {
|
||||
currentPath.value = browseRoot.value
|
||||
await loadEntries(currentPath.value)
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function reloadCurrent() {
|
||||
void loadEntries(currentPath.value)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
search.value = ''
|
||||
}
|
||||
|
||||
function isSelected(path: string): boolean {
|
||||
return selectedPaths.value.includes(normalizeFolderPath(path))
|
||||
}
|
||||
|
||||
function existingPathStatus(path: string): string | null {
|
||||
const normalized = normalizeFolderPath(path)
|
||||
const existing = existingPaths().map(normalizeFolderPath)
|
||||
if (existing.includes(normalized)) return 'Already added'
|
||||
const coveringPath = coveringFolderPath(normalized, existing)
|
||||
return coveringPath ? `Covered by ${coveringPath}` : null
|
||||
}
|
||||
|
||||
function selectedPathStatus(path: string): string | null {
|
||||
if (isSelected(path)) return 'Selected'
|
||||
const coveringPath = coveringFolderPath(path, selectedPaths.value)
|
||||
return coveringPath ? `Covered by ${coveringPath}` : null
|
||||
}
|
||||
|
||||
function isSelectionDisabled(path: string): boolean {
|
||||
return existingPathStatus(path) !== null || (!isSelected(path) && selectedPathStatus(path) !== null)
|
||||
}
|
||||
|
||||
function toggleSelection(path: string) {
|
||||
const normalized = normalizeFolderPath(path)
|
||||
const unavailableReason = existingPathStatus(normalized)
|
||||
if (unavailableReason) {
|
||||
selectionNotice.value = unavailableReason
|
||||
return
|
||||
}
|
||||
|
||||
if (isSelected(normalized)) {
|
||||
selectedPaths.value = selectedPaths.value.filter((selected) => selected !== normalized)
|
||||
selectionNotice.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const coveringPath = coveringFolderPath(normalized, selectedPaths.value)
|
||||
if (coveringPath) {
|
||||
selectionNotice.value = `${normalized} is already covered by ${coveringPath}.`
|
||||
return
|
||||
}
|
||||
|
||||
const previousCount = selectedPaths.value.length
|
||||
selectedPaths.value = consolidateFolderPaths([...selectedPaths.value, normalized])
|
||||
selectionNotice.value = selectedPaths.value.length <= previousCount ? 'Nested selections were consolidated into the top-level folder.' : null
|
||||
}
|
||||
|
||||
function toggleCurrentPath() {
|
||||
toggleSelection(currentPath.value)
|
||||
}
|
||||
|
||||
function toggleNewFolder() {
|
||||
creatingFolder.value = !creatingFolder.value
|
||||
newFolderName.value = ''
|
||||
createError.value = null
|
||||
if (creatingFolder.value) void nextTick(() => newFolderInput.value?.focus())
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
creatingFolder.value = false
|
||||
newFolderName.value = ''
|
||||
createError.value = null
|
||||
}
|
||||
|
||||
async function submitNewFolder() {
|
||||
const name = newFolderName.value.trim()
|
||||
if (!name || createLoading.value) return
|
||||
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..' || name.startsWith('.')) {
|
||||
createError.value = 'Enter one folder name without slashes or a leading dot.'
|
||||
return
|
||||
}
|
||||
|
||||
createLoading.value = true
|
||||
createError.value = null
|
||||
try {
|
||||
const response = await api('/api/v1/path', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parentPath: currentPath.value, name }),
|
||||
})
|
||||
if (response.ok) {
|
||||
const created: CreateFolderResult = await response.json()
|
||||
cancelNewFolder()
|
||||
navigate(created.path)
|
||||
} else {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
const message = Array.isArray(body?.message) ? body.message[0] : body?.message
|
||||
createError.value = message ?? 'The folder could not be created.'
|
||||
}
|
||||
} catch {
|
||||
createError.value = 'BookOrbit could not connect to the server.'
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onNewFolderKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submitNewFolder()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelNewFolder()
|
||||
}
|
||||
}
|
||||
|
||||
function isAtOrBelowBrowseRoot(path: string): boolean {
|
||||
const root = normalizeFolderPath(browseRoot.value)
|
||||
return root === '/' || path === root || path.startsWith(`${root}/`)
|
||||
}
|
||||
|
||||
watch(currentPath, (path) => {
|
||||
if (initialized.value) void loadEntries(path)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadConfig()
|
||||
})
|
||||
|
||||
return {
|
||||
browseRoot,
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
search,
|
||||
error,
|
||||
selectedPaths,
|
||||
selectionNotice,
|
||||
creatingFolder,
|
||||
newFolderName,
|
||||
createError,
|
||||
createLoading,
|
||||
newFolderInput,
|
||||
filteredEntries,
|
||||
breadcrumbs,
|
||||
canGoUp,
|
||||
selectedCount,
|
||||
navigate,
|
||||
goUp,
|
||||
reloadCurrent,
|
||||
clearSearch,
|
||||
isSelected,
|
||||
existingPathStatus,
|
||||
selectedPathStatus,
|
||||
isSelectionDisabled,
|
||||
toggleSelection,
|
||||
toggleCurrentPath,
|
||||
toggleNewFolder,
|
||||
cancelNewFolder,
|
||||
submitNewFolder,
|
||||
onNewFolderKeydown,
|
||||
}
|
||||
}
|
||||
@@ -11808,6 +11808,194 @@
|
||||
"createFolder": "Could not create folder"
|
||||
}
|
||||
},
|
||||
"folderBrowser": {
|
||||
"title": "Choose folders",
|
||||
"titleStorage": "Choose a storage folder",
|
||||
"subtitle": "For {name} · {mode}",
|
||||
"subtitleStorage": "For {name} · one folder; each show gets its own folder inside",
|
||||
"subtitleLocal": "For {name} · folders that already hold episodes",
|
||||
"unnamedLibrary": "the new library",
|
||||
"close": "Close",
|
||||
"mode": {
|
||||
"folder": "Folder as Book",
|
||||
"file": "File as Book"
|
||||
},
|
||||
"field": {
|
||||
"placeholder": "Find a folder, or paste a path",
|
||||
"label": "Find a folder or type a path",
|
||||
"clear": "Clear",
|
||||
"mounted": "Mounted",
|
||||
"libraries": "Libraries",
|
||||
"recent": "Recent",
|
||||
"thisLibrary": "{name} (this library)",
|
||||
"jumpHint": "Type to find a folder by name, or start with {key} to type a path",
|
||||
"inFolder": "In {path}",
|
||||
"completeHint": "{tab} complete {enter} show in tree",
|
||||
"noCompletion": "No folder in {path} starts with that. {key} tries the path as typed.",
|
||||
"pathMissing": "{path} doesn't exist on the server.",
|
||||
"pathMissingSuggest": "{path} doesn't exist on the server. Did you mean {suggestion}?",
|
||||
"pathBlocked": "BookOrbit never opens {path}: it is a system folder.",
|
||||
"findSummary": "{count, plural, one {# folder matches} other {# folders match}} “{query}”, shown with the folders around {count, plural, one {it} other {them}}.",
|
||||
"findEnter": "{key} goes to the first.",
|
||||
"findTruncated": "Showing the first {count} matches.",
|
||||
"findNone": "No folder on the server matches. Try fewer letters."
|
||||
},
|
||||
"view": {
|
||||
"button": "View",
|
||||
"label": "View options",
|
||||
"sort": "Sort",
|
||||
"sortName": "Name",
|
||||
"sortBooks": "Most books first",
|
||||
"show": "Show",
|
||||
"showEmpty": "Folders without books",
|
||||
"showSystem": "System folders"
|
||||
},
|
||||
"newFolder": {
|
||||
"button": "New folder",
|
||||
"label": "New folder in {name}",
|
||||
"placeholder": "New folder in {name}",
|
||||
"inputLabel": "New folder name",
|
||||
"hint": "{enter} create {escape} cancel",
|
||||
"create": "Create",
|
||||
"errors": {
|
||||
"empty": "Enter a folder name.",
|
||||
"invalid": "Use one folder name, without slashes or a leading dot.",
|
||||
"exists": "{name} already exists here.",
|
||||
"denied": "BookOrbit can't write here.",
|
||||
"failed": "The folder could not be created."
|
||||
}
|
||||
},
|
||||
"tree": {
|
||||
"label": "Server folders",
|
||||
"expand": "Expand {name}",
|
||||
"collapse": "Collapse {name}",
|
||||
"select": "Select {name}",
|
||||
"use": "Use {name}",
|
||||
"readsThis": "{name} reads this folder",
|
||||
"thisLibrary": "This library",
|
||||
"librariesInside": "{names} {count, plural, one {reads a folder} other {read folders}} inside",
|
||||
"inside": "{count} inside",
|
||||
"appData": "BookOrbit data",
|
||||
"noAccess": "No access",
|
||||
"loading": "Loading folders",
|
||||
"systemHidden": "{count, plural, one {# system folder} other {# system folders}} hidden at {path}",
|
||||
"moreFolders": "Only the first {count} folders here are listed. Find by name or type a path to reach the rest.",
|
||||
"unreadable": "BookOrbit can't read {path}.",
|
||||
"placesFailed": "BookOrbit could not load the server's folders.",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"count": {
|
||||
"books": "{count, plural, one {# book} other {# books}}",
|
||||
"episodes": "{count, plural, one {# episode} other {# episodes}}",
|
||||
"atLeast": "{count}+",
|
||||
"more": "+{count} more"
|
||||
},
|
||||
"plan": {
|
||||
"label": "What this library will read",
|
||||
"willRead": "{name} will read",
|
||||
"storage": "Episodes will download to",
|
||||
"newFolders": "{count, plural, one {# new folder} other {# new folders}}",
|
||||
"already": "already · {books}",
|
||||
"emptyTitle": "Nothing picked yet",
|
||||
"emptyHint": "Tick folders in the tree. A ticked folder brings everything inside it.",
|
||||
"emptyStorageTitle": "No folder chosen yet",
|
||||
"emptyStorageHint": "Tick one folder in the tree, or make a new one.",
|
||||
"rowSize": "{books} · {size}",
|
||||
"show": "Show {name} in the tree",
|
||||
"showTitle": "Show in the tree",
|
||||
"remove": "Remove {name}",
|
||||
"empty": "Empty",
|
||||
"fit": {
|
||||
"folder": "Each folder holds one book.",
|
||||
"file": "Each file is one book.",
|
||||
"empty": "Empty for now.",
|
||||
"storage": "Empty: a clean place for episodes."
|
||||
},
|
||||
"attention": {
|
||||
"containsOne": "Contains {folder}, which {library} reads. Those books would be in two libraries.",
|
||||
"containsMany": "Contains folders that {libraries} read. Those books would be in two libraries.",
|
||||
"overlapExact": "{library} already reads this folder. Both libraries would import the same {count, plural, one {# book} other {# books}}.",
|
||||
"overlapInside": "This folder is inside {library}'s folder, so its books would be in both libraries.",
|
||||
"merge": "{count, plural, one {# folder here holds} other {# folders here hold}} several books: as Folder as Book each becomes 1 book.",
|
||||
"split": "{count, plural, one {# audiobook here is} other {# audiobooks here are}} in parts: as File as Book each part becomes its own book.",
|
||||
"locked": "Organization is fixed once a library exists.",
|
||||
"notEmpty": "Not empty: episodes would sit beside {what}. An empty folder keeps them apart.",
|
||||
"leaveOutOne": "Leave out {folder}",
|
||||
"leaveOutMany": "Leave them out",
|
||||
"remove": "Remove",
|
||||
"useFile": "Use File as Book",
|
||||
"useFolder": "Use Folder as Book"
|
||||
},
|
||||
"suggestions": "Not in any library yet",
|
||||
"suggestionsMeta": "books no library reads",
|
||||
"add": "Add",
|
||||
"preview": {
|
||||
"label": "Looking at",
|
||||
"ticks": "{key} ticks it",
|
||||
"use": "Use this",
|
||||
"wouldAdd": "Would add {books} · {size}",
|
||||
"empty": "Empty for now",
|
||||
"holds": "Holds {what}",
|
||||
"storageEmpty": "Empty: a clean place for episodes",
|
||||
"storageTip": "Episodes would sit beside what is here. An empty folder keeps them apart.",
|
||||
"storageFine": "Each show gets its own folder inside.",
|
||||
"merge": "Several books share a folder here: as Folder as Book each such folder is 1 book.",
|
||||
"split": "Audiobooks in parts: as File as Book each part is its own book."
|
||||
},
|
||||
"totals": {
|
||||
"books": "{count, plural, one {book} other {books}} · {size}",
|
||||
"booksOnly": "{count, plural, one {book} other {books}}",
|
||||
"shared": "{count} also in other libraries",
|
||||
"organize": "Organize as",
|
||||
"organizeLabel": "Organization",
|
||||
"segmentBooks": "{count, plural, one {# book} other {# books}}",
|
||||
"folderTitle": "Each folder that holds books is one book",
|
||||
"fileTitle": "Each file is one book",
|
||||
"locked": "{mode}, fixed since {name} was created"
|
||||
},
|
||||
"holdsBooks": "{count, plural, one {# book} other {# books}}",
|
||||
"holdsEpisodes": "{count, plural, one {# episode} other {# episodes}}",
|
||||
"holdsFolders": "{count, plural, one {# folder} other {# folders}}",
|
||||
"holdsBoth": "{books} and {folders}"
|
||||
},
|
||||
"reason": {
|
||||
"denied": "BookOrbit can't read this folder. Give the user BookOrbit runs as read access, then reopen it.",
|
||||
"appData": "BookOrbit keeps covers, caches and plugins here. Pick the folder that holds your books instead.",
|
||||
"own": "{name} is already in this library.",
|
||||
"ownCovered": "This library already reads {path}, which includes this folder.",
|
||||
"included": "Included because you selected {path}."
|
||||
},
|
||||
"notice": {
|
||||
"folded": "{path} includes {count, plural, one {{name}} other {# folders you picked}}, so {count, plural, one {it was} other {they were}} folded into it.",
|
||||
"range": "Ticked {count, plural, one {# folder} other {# folders}}, {from} to {to}.",
|
||||
"removed": "Removed {name}",
|
||||
"leftOut": "{name} became {count, plural, one {# folder} other {# folders}} beside the other libraries",
|
||||
"modeFile": "Switched to File as Book; the library's Scanning setting follows",
|
||||
"modeFolder": "Switched to Folder as Book; the library's Scanning setting follows",
|
||||
"created": "Made {path} and ticked it.",
|
||||
"createdOnly": "Made {path}.",
|
||||
"undo": "Undo"
|
||||
},
|
||||
"keys": {
|
||||
"space": "Space",
|
||||
"tab": "Tab",
|
||||
"escape": "Esc"
|
||||
},
|
||||
"footer": {
|
||||
"hints": "{move} move {fold} fold {tick} tick {range} click for a range {find} find",
|
||||
"add": "Add folders",
|
||||
"addCount": "Add {count, plural, one {# folder} other {# folders}}",
|
||||
"addShort": "Add",
|
||||
"addShortCount": "Add {count}",
|
||||
"use": "Use folder",
|
||||
"nothingPicked": "Nothing picked yet",
|
||||
"seePlan": "See the plan"
|
||||
},
|
||||
"divider": {
|
||||
"label": "Resize the plan",
|
||||
"title": "Drag to resize. Double-click to reset."
|
||||
}
|
||||
},
|
||||
"destination": {
|
||||
"loadFailed": "Could not load destination libraries. Close and reopen to try again.",
|
||||
"noLibraries": "No book libraries are available. Create a library or ask an administrator for access.",
|
||||
|
||||
@@ -13,3 +13,95 @@ export interface CreateFolderRequest {
|
||||
}
|
||||
|
||||
export type CreateFolderResult = DirectoryEntry;
|
||||
|
||||
/** Why a folder could or could not be read. `blocked` is a system path BookOrbit never opens. */
|
||||
export type FolderState = "ok" | "denied" | "missing" | "blocked";
|
||||
|
||||
export interface FolderListingEntry extends DirectoryEntry {
|
||||
/** On a different filesystem than its parent: a Docker volume, a NAS share. */
|
||||
mount: boolean;
|
||||
/** An operating-system folder directly under `/` (bin, usr...), hidden unless asked for. */
|
||||
system: boolean;
|
||||
/** BookOrbit's own data folder (covers, caches, plugins). */
|
||||
appData: boolean;
|
||||
/** Whether it holds any listable folder. Null when the parent was too large to check every child. */
|
||||
hasChildren: boolean | null;
|
||||
}
|
||||
|
||||
export interface FolderListing {
|
||||
path: string;
|
||||
state: FolderState;
|
||||
entries: FolderListingEntry[];
|
||||
/** Only the first folders by name are listed; the rest are reached by typing a path or finding. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** `pending`: the request's time ran out before this folder was walked; ask for it again. */
|
||||
export type FolderSummaryState = FolderState | "pending";
|
||||
|
||||
/**
|
||||
* Counts for one folder from a walk that stops at a fixed number of entries. When `truncated` is
|
||||
* true every count is a lower bound.
|
||||
*/
|
||||
export interface FolderSummary {
|
||||
path: string;
|
||||
state: FolderSummaryState;
|
||||
/** Book files: what File as Book would import. */
|
||||
files: number;
|
||||
/** Books as Folder as Book: one per file directly inside, plus one per folder below holding book files. */
|
||||
folderBooks: number;
|
||||
/** Book files per format, keyed by lower-case extension. */
|
||||
formats: Record<string, number>;
|
||||
/** Folders directly inside. */
|
||||
folders: number;
|
||||
/** Folders below holding several differently named books, which Folder as Book reads as one. */
|
||||
mergedFolders: number;
|
||||
/** Folders below holding several audio files, which File as Book reads as one book each. */
|
||||
splitAudioFolders: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface FolderSummariesRequest {
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export interface FolderInspection extends FolderSummary {
|
||||
/** Size of the book files counted. */
|
||||
bytes: number;
|
||||
/** The first book files found, relative to the folder. */
|
||||
samples: string[];
|
||||
}
|
||||
|
||||
export interface FolderSearchResult {
|
||||
results: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface FolderPickerLibrary {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: string;
|
||||
folders: string[];
|
||||
}
|
||||
|
||||
export interface FolderPlaces {
|
||||
root: string;
|
||||
/** Folders on their own filesystem near the browse root. */
|
||||
mounts: string[];
|
||||
/** BookOrbit's data folder when it is inside the browse root. */
|
||||
appDataPath: string | null;
|
||||
/** Every library and the folders it reads, for landmarks and overlap checks. */
|
||||
libraries: FolderPickerLibrary[];
|
||||
}
|
||||
|
||||
export interface UnclaimedFolder {
|
||||
path: string;
|
||||
files: number;
|
||||
formats: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface UnclaimedFolders {
|
||||
folders: UnclaimedFolder[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class FolderPathQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
path?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class FolderSummariesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(4096, { each: true })
|
||||
paths: string[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SearchFoldersQueryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
q: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
path?: string;
|
||||
|
||||
/** "true" also searches the operating-system folders under `/`. */
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
system?: string;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { FolderInsightService } from './folder-insight.service';
|
||||
|
||||
async function files(dir: string, names: string[]) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const name of names) await writeFile(join(dir, name), 'xxxx');
|
||||
}
|
||||
|
||||
describe('FolderInsightService', () => {
|
||||
let root: string;
|
||||
let service: FolderInsightService;
|
||||
const pathPolicy = { resolveBrowsePath: vi.fn(), getAppDataPath: vi.fn(), getCanonicalAppDataPath: vi.fn() };
|
||||
const pathService = { getPlaces: vi.fn() };
|
||||
const pathRepository = { findLibraryFolders: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'folder-insight-'));
|
||||
// Novels reads Fiction; Science Fiction, Cookbooks and Unsorted belong to no library.
|
||||
await files(join(root, 'books', 'Ebooks', 'Fiction', 'Jane Austen', 'Emma (1815)'), ['Emma.epub']);
|
||||
await files(join(root, 'books', 'Ebooks', 'Science Fiction', 'Ann Leckie', 'Ancillary Justice (2013)'), ['Ancillary Justice.epub']);
|
||||
await files(join(root, 'books', 'Ebooks', 'Science Fiction', 'Ann Leckie', 'Provenance (2017)'), ['Provenance.epub']);
|
||||
await files(join(root, 'books', 'Ebooks', 'Science Fiction', 'Martha Wells', 'All Systems Red (2017)'), ['All Systems Red.epub']);
|
||||
await files(join(root, 'books', 'Ebooks', 'Cookbooks'), ['Zahav.pdf', 'Jerusalem.pdf']);
|
||||
await files(join(root, 'books', 'Unsorted'), ['a.epub', 'b.epub', 'c.mobi']);
|
||||
await files(join(root, 'data', 'covers'), ['cover.epub']);
|
||||
|
||||
pathPolicy.resolveBrowsePath.mockImplementation((path?: string) => Promise.resolve(path || root));
|
||||
pathPolicy.getAppDataPath.mockReturnValue(join(root, 'data'));
|
||||
pathPolicy.getCanonicalAppDataPath.mockResolvedValue(join(root, 'data'));
|
||||
pathService.getPlaces.mockResolvedValue({
|
||||
root,
|
||||
mounts: [join(root, 'books'), join(root, 'data')],
|
||||
appDataPath: join(root, 'data'),
|
||||
libraries: [],
|
||||
});
|
||||
pathRepository.findLibraryFolders.mockResolvedValue([
|
||||
{ libraryId: 1, name: 'Novels', icon: null, type: 'books', path: join(root, 'books', 'Ebooks', 'Fiction') },
|
||||
]);
|
||||
service = new FolderInsightService(pathPolicy as never, pathService as never, pathRepository as never);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('summarize', () => {
|
||||
it('answers for each path in the order asked, keeping the caller’s spelling', async () => {
|
||||
const scifi = join(root, 'books', 'Ebooks', 'Science Fiction');
|
||||
const cookbooks = join(root, 'books', 'Ebooks', 'Cookbooks');
|
||||
|
||||
const [first, second] = await service.summarize([scifi, cookbooks]);
|
||||
|
||||
expect(first).toMatchObject({ path: scifi, state: 'ok', files: 3, folderBooks: 3, folders: 2, mergedFolders: 0, truncated: false });
|
||||
expect(second).toMatchObject({ path: cookbooks, files: 2, folderBooks: 2, formats: { pdf: 2 } });
|
||||
});
|
||||
|
||||
it('sees the merge a parent folder would cause', async () => {
|
||||
const [ebooks] = await service.summarize([join(root, 'books', 'Ebooks')]);
|
||||
|
||||
expect(ebooks).toMatchObject({ files: 6, folderBooks: 5, mergedFolders: 1 });
|
||||
});
|
||||
|
||||
it('marks blocked and out-of-root paths without walking them', async () => {
|
||||
pathPolicy.resolveBrowsePath.mockImplementation((path: string) =>
|
||||
path === '/outside' ? Promise.reject(new Error('outside')) : Promise.resolve(path),
|
||||
);
|
||||
|
||||
const [proc, outside] = await service.summarize(['/proc/1', '/outside']);
|
||||
|
||||
expect(proc).toMatchObject({ path: '/proc/1', state: 'blocked', files: 0 });
|
||||
expect(outside).toMatchObject({ path: '/outside', state: 'blocked', files: 0 });
|
||||
});
|
||||
|
||||
it('reports folders the batch had no time left for as pending, never as empty, and does not cache them', async () => {
|
||||
const unsorted = join(root, 'books', 'Unsorted');
|
||||
const cookbooks = join(root, 'books', 'Ebooks', 'Cookbooks');
|
||||
await service.summarize([cookbooks]);
|
||||
|
||||
const [late, cached] = await service.summarize([unsorted, cookbooks], -1);
|
||||
expect(late).toMatchObject({ path: unsorted, state: 'pending', files: 0 });
|
||||
expect(cached).toMatchObject({ path: cookbooks, state: 'ok', files: 2 });
|
||||
|
||||
await expect(service.summarize([unsorted])).resolves.toMatchObject([{ state: 'ok', files: 3 }]);
|
||||
});
|
||||
|
||||
it('refuses a symlink into a blocked system path without walking it', async () => {
|
||||
await symlink('/dev', join(root, 'dev-link'));
|
||||
|
||||
await expect(service.summarize([join(root, 'dev-link')])).resolves.toMatchObject([{ path: join(root, 'dev-link'), state: 'blocked' }]);
|
||||
});
|
||||
|
||||
it('does not keep a failed answer: a folder that appears later is counted', async () => {
|
||||
const later = join(root, 'books', 'Later');
|
||||
await expect(service.summarize([later])).resolves.toMatchObject([{ state: 'missing' }]);
|
||||
|
||||
await files(later, ['Later.epub']);
|
||||
|
||||
await expect(service.summarize([later])).resolves.toMatchObject([{ state: 'ok', files: 1 }]);
|
||||
});
|
||||
|
||||
it('answers a repeat from the cache, and walks again once forgotten', async () => {
|
||||
const unsorted = join(root, 'books', 'Unsorted');
|
||||
await service.summarize([unsorted]);
|
||||
await writeFile(join(unsorted, 'd.epub'), 'x');
|
||||
|
||||
await expect(service.summarize([unsorted])).resolves.toMatchObject([{ files: 3 }]);
|
||||
service.forget();
|
||||
await expect(service.summarize([unsorted])).resolves.toMatchObject([{ files: 4 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
it('adds the size and the first two book files', async () => {
|
||||
await expect(service.inspect(join(root, 'books', 'Ebooks', 'Science Fiction'))).resolves.toMatchObject({
|
||||
files: 3,
|
||||
bytes: 12,
|
||||
samples: ['Ann Leckie/Ancillary Justice (2013)/Ancillary Justice.epub', 'Ann Leckie/Provenance (2017)/Provenance.epub'],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not walk a blocked path', async () => {
|
||||
pathPolicy.resolveBrowsePath.mockResolvedValue('/proc');
|
||||
|
||||
await expect(service.inspect('/proc')).resolves.toMatchObject({ state: 'blocked', bytes: 0, samples: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('unclaimed', () => {
|
||||
it('lists library-shaped folders no library reads, biggest first, skipping the data folder', async () => {
|
||||
const result = await service.unclaimed();
|
||||
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.folders.map((folder) => [folder.path.slice(root.length), folder.files])).toEqual([
|
||||
['/books/Ebooks/Science Fiction', 3],
|
||||
['/books/Unsorted', 3],
|
||||
['/books/Ebooks/Cookbooks', 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('looks around the libraries’ folders when nothing is mounted separately', async () => {
|
||||
pathService.getPlaces.mockResolvedValue({ root, mounts: [], appDataPath: join(root, 'data'), libraries: [] });
|
||||
|
||||
const result = await service.unclaimed();
|
||||
|
||||
expect(result.folders.map((folder) => folder.path.slice(root.length))).toEqual(['/books/Ebooks/Science Fiction', '/books/Ebooks/Cookbooks']);
|
||||
});
|
||||
|
||||
it('shares one scan between pickers asking at the same time', async () => {
|
||||
const [first, second] = await Promise.all([service.unclaimed(), service.unclaimed()]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(pathService.getPlaces).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scans again once a library reads a different set of folders', async () => {
|
||||
await service.unclaimed();
|
||||
pathRepository.findLibraryFolders.mockResolvedValue([
|
||||
{ libraryId: 1, name: 'Novels', icon: null, type: 'books', path: join(root, 'books', 'Ebooks', 'Fiction') },
|
||||
{ libraryId: 2, name: 'Inbox', icon: null, type: 'books', path: join(root, 'books', 'Unsorted') },
|
||||
]);
|
||||
|
||||
const result = await service.unclaimed();
|
||||
|
||||
expect(pathService.getPlaces).toHaveBeenCalledTimes(2);
|
||||
expect(result.folders.map((folder) => folder.path.slice(root.length))).not.toContain('/books/Unsorted');
|
||||
});
|
||||
|
||||
it('does not keep a scan that was running when the cache was cleared', async () => {
|
||||
let release!: () => void;
|
||||
const places = await pathService.getPlaces();
|
||||
pathService.getPlaces.mockImplementationOnce(() => new Promise((resolve) => (release = () => resolve(places))));
|
||||
const running = service.unclaimed();
|
||||
await vi.waitFor(() => expect(release).toBeTypeOf('function'));
|
||||
|
||||
service.forget();
|
||||
release();
|
||||
await running;
|
||||
await service.unclaimed();
|
||||
|
||||
expect(pathService.getPlaces).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('caches the scan until forgotten', async () => {
|
||||
await service.unclaimed();
|
||||
await service.unclaimed();
|
||||
expect(pathService.getPlaces).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.forget();
|
||||
await service.unclaimed();
|
||||
expect(pathService.getPlaces).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { FolderInspection, FolderSummary, UnclaimedFolder, UnclaimedFolders } from '@bookorbit/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Dirent } from 'fs';
|
||||
import { readdir, realpath } from 'fs/promises';
|
||||
import { dirname, join, resolve, sep } from 'path';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { isPrimaryFormat } from '../scanner/lib/classify';
|
||||
import { isBlockedPath, isInsideOrAt, isListableDirectory, mapLimit, realTarget, SYSTEM_FOLDERS, walkFolder, type WalkOutcome } from './folder-walk';
|
||||
import { PathPolicyService } from './path-policy.service';
|
||||
import { PathRepository } from './path.repository';
|
||||
import { PathService } from './path.service';
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60_000;
|
||||
const CACHE_MAX = 2_000;
|
||||
const SUMMARY_MAX_ENTRIES = 10_000;
|
||||
const SUMMARY_BATCH_BUDGET_MS = 3_000;
|
||||
const INSPECT_MAX_ENTRIES = 20_000;
|
||||
const INSPECT_BUDGET_MS = 3_000;
|
||||
const UNCLAIMED_MAX_ENTRIES = 30_000;
|
||||
const UNCLAIMED_BUDGET_MS = 4_000;
|
||||
const UNCLAIMED_MAX_DEPTH = 5;
|
||||
const UNCLAIMED_RESULTS = 8;
|
||||
|
||||
class TtlCache<V> {
|
||||
private readonly entries = new Map<string, { value: V; expires: number }>();
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const hit = this.entries.get(key);
|
||||
if (!hit) return undefined;
|
||||
if (hit.expires < Date.now()) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V) {
|
||||
this.entries.delete(key);
|
||||
this.entries.set(key, { value, expires: Date.now() + CACHE_TTL_MS });
|
||||
if (this.entries.size > CACHE_MAX) this.entries.delete(this.entries.keys().next().value!);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
interface ScanNode {
|
||||
own: number;
|
||||
formats: Record<string, number>;
|
||||
children: string[];
|
||||
}
|
||||
|
||||
function toSummary(path: string, outcome: WalkOutcome): FolderSummary {
|
||||
return {
|
||||
path,
|
||||
state: outcome.state,
|
||||
files: outcome.files,
|
||||
folderBooks: outcome.folderBooks,
|
||||
formats: outcome.formats,
|
||||
folders: outcome.folders,
|
||||
mergedFolders: outcome.mergedFolders,
|
||||
splitAudioFolders: outcome.splitAudioFolders,
|
||||
truncated: outcome.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
const emptySummary = (path: string, state: FolderSummary['state']): FolderSummary => ({
|
||||
path,
|
||||
state,
|
||||
files: 0,
|
||||
folderBooks: 0,
|
||||
formats: {},
|
||||
folders: 0,
|
||||
mergedFolders: 0,
|
||||
splitAudioFolders: 0,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* What folders hold, for the folder picker. Every walk is bounded by entries and time and says when it
|
||||
* stopped, and answers are cached briefly so scrolling back and forth costs nothing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FolderInsightService {
|
||||
private readonly logger = new Logger(FolderInsightService.name);
|
||||
private readonly summaries = new TtlCache<FolderSummary>();
|
||||
private readonly inspections = new TtlCache<FolderInspection>();
|
||||
/** Keyed by the folders libraries read, so a library added or removed elsewhere is seen at once. */
|
||||
private unclaimedCache: { claimedKey: string; value: UnclaimedFolders; expires: number } | null = null;
|
||||
private unclaimedRun: { claimedKey: string; promise: Promise<UnclaimedFolders> } | null = null;
|
||||
/** Bumped by `forget()`, so a walk that started before it does not store its stale answer. */
|
||||
private generation = 0;
|
||||
|
||||
constructor(
|
||||
private readonly pathPolicy: PathPolicyService,
|
||||
private readonly pathService: PathService,
|
||||
private readonly pathRepository: PathRepository,
|
||||
) {}
|
||||
|
||||
async summarize(paths: string[], budgetMs = SUMMARY_BATCH_BUDGET_MS): Promise<FolderSummary[]> {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + budgetMs;
|
||||
let walked = 0;
|
||||
const generation = this.generation;
|
||||
const results = await mapLimit(paths, 4, async (path): Promise<FolderSummary> => {
|
||||
const resolved = await this.resolveOrNull(path);
|
||||
// Walked with symlinks followed, so every folder below is checked against the blocked list by its real path.
|
||||
const target = resolved && (await realTarget(resolved));
|
||||
if (!resolved || !target || isBlockedPath(resolved) || isBlockedPath(target)) return emptySummary(path, 'blocked');
|
||||
const cached = this.summaries.get(target);
|
||||
if (cached) return { ...cached, path };
|
||||
// Folders the batch had no time left for are not reported as empty: the client asks again.
|
||||
if (Date.now() > deadline) return emptySummary(path, 'pending');
|
||||
walked += 1;
|
||||
const outcome = await walkFolder(target, { maxEntries: SUMMARY_MAX_ENTRIES, deadline });
|
||||
const summary = toSummary(path, outcome);
|
||||
if (this.cacheable(outcome, deadline, generation)) this.summaries.set(target, summary);
|
||||
return summary;
|
||||
});
|
||||
if (walked > 0) {
|
||||
this.logger.log(
|
||||
`[path.summaries] [end] paths=${paths.length} walked=${walked} truncated=${results.filter((r) => r.truncated).length} durationMs=${Date.now() - startedAt} - folder summaries completed`,
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async inspect(path: string): Promise<FolderInspection> {
|
||||
const resolved = await this.pathPolicy.resolveBrowsePath(path);
|
||||
const target = await realTarget(resolved);
|
||||
if (isBlockedPath(resolved) || isBlockedPath(target)) return { ...emptySummary(resolved, 'blocked'), bytes: 0, samples: [] };
|
||||
const cached = this.inspections.get(target);
|
||||
if (cached) return { ...cached, path: resolved };
|
||||
const generation = this.generation;
|
||||
const deadline = Date.now() + INSPECT_BUDGET_MS;
|
||||
const outcome = await walkFolder(target, { maxEntries: INSPECT_MAX_ENTRIES, deadline, withSizes: true, samples: 2 });
|
||||
const inspection: FolderInspection = { ...toSummary(resolved, outcome), bytes: outcome.bytes, samples: outcome.samples };
|
||||
if (this.cacheable(outcome, deadline, generation)) this.inspections.set(target, inspection);
|
||||
return inspection;
|
||||
}
|
||||
|
||||
/** A new folder changes what every folder above it holds, so cached answers are dropped. */
|
||||
forget() {
|
||||
this.generation += 1;
|
||||
this.summaries.clear();
|
||||
this.inspections.clear();
|
||||
this.unclaimedCache = null;
|
||||
this.unclaimedRun = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a readable folder's answer is kept: a failure may be a passing one (a share reconnecting),
|
||||
* and a walk cut short by its time budget is finished by the next request instead.
|
||||
*/
|
||||
private cacheable(outcome: WalkOutcome, deadline: number, generation: number): boolean {
|
||||
return outcome.state === 'ok' && generation === this.generation && (!outcome.truncated || Date.now() <= deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders holding books that no library reads, biggest first. Stops at the first folder shaped
|
||||
* like a library (one folder per book, loose files, or series folders) rather than listing every
|
||||
* author folder below it.
|
||||
*/
|
||||
async unclaimed(): Promise<UnclaimedFolders> {
|
||||
const claimed = [...new Set((await this.pathRepository.findLibraryFolders()).map((row) => resolve(row.path)))].sort();
|
||||
const claimedKey = claimed.join('\0');
|
||||
if (this.unclaimedCache?.claimedKey === claimedKey && this.unclaimedCache.expires > Date.now()) return this.unclaimedCache.value;
|
||||
// Pickers opened together share one scan rather than walking the mounts once each.
|
||||
let run = this.unclaimedRun;
|
||||
if (run?.claimedKey !== claimedKey) {
|
||||
const started = { claimedKey, promise: this.scanUnclaimed(claimed, claimedKey) };
|
||||
this.unclaimedRun = run = started;
|
||||
started.promise
|
||||
.finally(() => {
|
||||
if (this.unclaimedRun === started) this.unclaimedRun = null;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return run.promise;
|
||||
}
|
||||
|
||||
private async scanUnclaimed(claimed: string[], claimedKey: string): Promise<UnclaimedFolders> {
|
||||
const startedAt = Date.now();
|
||||
const generation = this.generation;
|
||||
const places = await this.pathService.getPlaces();
|
||||
const rawAppData = this.pathPolicy.getAppDataPath();
|
||||
const appDataPath = await this.pathPolicy.getCanonicalAppDataPath();
|
||||
// Paths are compared as typed and with symlinks followed: the data folder may be reached through a link.
|
||||
const inAppData = (path: string, real: string) =>
|
||||
(!!rawAppData && isInsideOrAt(path, rawAppData)) || (!!appDataPath && (isInsideOrAt(path, appDataPath) || isInsideOrAt(real, appDataPath)));
|
||||
// Without separate mounts (everything on one filesystem), look around the folders libraries already read.
|
||||
const starts = places.mounts.length > 0 ? places.mounts : [...new Set(claimed.map((folder) => dirname(folder)))];
|
||||
const reals = new Map(await Promise.all(starts.map(async (root) => [root, await realpath(root).catch(() => root)] as const)));
|
||||
const roots = starts.filter((root) => !inAppData(root, reals.get(root)!) && !starts.some((other) => other !== root && isInsideOrAt(root, other)));
|
||||
this.logger.log(`[path.unclaimed] [start] roots=${roots.length} claimed=${claimed.length} - unclaimed folder scan started`);
|
||||
|
||||
try {
|
||||
const nodes = new Map<string, ScanNode>();
|
||||
const deadline = startedAt + UNCLAIMED_BUDGET_MS;
|
||||
let entries = 0;
|
||||
let truncated = false;
|
||||
const queue: Array<{ dir: string; real: string; depth: number }> = roots.map((dir) => ({ dir, real: reals.get(dir)!, depth: 0 }));
|
||||
while (queue.length > 0) {
|
||||
if (entries >= UNCLAIMED_MAX_ENTRIES || Date.now() > deadline) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const { dir, real, depth } = queue.shift()!;
|
||||
let dirents: Dirent[];
|
||||
try {
|
||||
dirents = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
entries += dirents.length;
|
||||
const node: ScanNode = { own: 0, formats: {}, children: [] };
|
||||
for (const entry of dirents) {
|
||||
const full = join(dir, entry.name);
|
||||
if (isListableDirectory(entry, full)) {
|
||||
if (dir === sep && SYSTEM_FOLDERS.has(entry.name)) continue;
|
||||
const childReal = join(real, entry.name);
|
||||
if (inAppData(full, childReal) || isBlockedPath(childReal)) continue;
|
||||
node.children.push(full);
|
||||
// Deeper folders are left out of the totals, which are then a lower bound.
|
||||
if (depth < UNCLAIMED_MAX_DEPTH) queue.push({ dir: full, real: childReal, depth: depth + 1 });
|
||||
else truncated = true;
|
||||
} else if (entry.isFile() && isPrimaryFormat(full)) {
|
||||
const format = entry.name.split('.').pop()!.toLowerCase();
|
||||
node.own += 1;
|
||||
node.formats[format] = (node.formats[format] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
nodes.set(dir, node);
|
||||
}
|
||||
|
||||
const totals = new Map<string, { files: number; formats: Record<string, number> }>();
|
||||
const total = (dir: string): { files: number; formats: Record<string, number> } => {
|
||||
const known = totals.get(dir);
|
||||
if (known) return known;
|
||||
const node = nodes.get(dir);
|
||||
const sum = { files: node?.own ?? 0, formats: { ...(node?.formats ?? {}) } };
|
||||
for (const child of node?.children ?? []) {
|
||||
const sub = total(child);
|
||||
sum.files += sub.files;
|
||||
for (const [format, count] of Object.entries(sub.formats)) sum.formats[format] = (sum.formats[format] ?? 0) + count;
|
||||
}
|
||||
totals.set(dir, sum);
|
||||
return sum;
|
||||
};
|
||||
|
||||
const found: UnclaimedFolder[] = [];
|
||||
const pick = (dir: string): boolean => {
|
||||
if (claimed.some((folder) => isInsideOrAt(dir, folder))) return false;
|
||||
const sum = total(dir);
|
||||
if (sum.files === 0) return false;
|
||||
const holdsLibrary = claimed.some((folder) => folder !== dir && isInsideOrAt(folder, dir));
|
||||
if (!holdsLibrary && libraryShaped(dir, nodes)) {
|
||||
found.push({ path: dir, files: sum.files, formats: sum.formats });
|
||||
return true;
|
||||
}
|
||||
const below = (nodes.get(dir)?.children ?? []).map(pick).some(Boolean);
|
||||
if (!below && !holdsLibrary) {
|
||||
found.push({ path: dir, files: sum.files, formats: sum.formats });
|
||||
return true;
|
||||
}
|
||||
return below;
|
||||
};
|
||||
roots.forEach(pick);
|
||||
|
||||
const value: UnclaimedFolders = { folders: found.sort((a, b) => b.files - a.files).slice(0, UNCLAIMED_RESULTS), truncated };
|
||||
if (generation === this.generation) this.unclaimedCache = { claimedKey, value, expires: Date.now() + CACHE_TTL_MS };
|
||||
this.logger.log(
|
||||
`[path.unclaimed] [end] found=${found.length} entries=${entries} truncated=${truncated} durationMs=${Date.now() - startedAt} - unclaimed folder scan completed`,
|
||||
);
|
||||
return value;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[path.unclaimed] [fail] durationMs=${Date.now() - startedAt} errorClass=${error instanceof Error ? error.constructor.name : 'Error'} error="${sanitizeLogValue(error instanceof Error ? error.message : String(error))}" - unclaimed folder scan failed`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveOrNull(path: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.pathPolicy.resolveBrowsePath(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Loose files, series folders, or one folder per book (directly or under author folders). */
|
||||
function libraryShaped(dir: string, nodes: Map<string, ScanNode>): boolean {
|
||||
const node = nodes.get(dir);
|
||||
if (!node) return false;
|
||||
const kids = node.children.map((child) => nodes.get(child)).filter((child): child is ScanNode => !!child);
|
||||
if (node.own > 0 && node.own >= kids.length) return true;
|
||||
if (kids.length === 0) return false;
|
||||
const most = (test: (kid: ScanNode) => boolean) => kids.filter(test).length >= Math.max(1, kids.length * 0.6);
|
||||
const leafy = (kid: ScanNode) => kid.children.length === 0;
|
||||
if (most((kid) => leafy(kid) && kid.own > 0)) return true;
|
||||
return most((kid) => {
|
||||
const titles = kid.children.map((child) => nodes.get(child)).filter((child): child is ScanNode => !!child);
|
||||
return titles.length > 0 && kid.own === 0 && titles.every((title) => title.children.length === 0 && title.own > 0 && title.own <= 2);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { findBookCandidates, findLooseFileCandidates } from '../scanner/lib/walk';
|
||||
import { hasListableChild, isBlockedPath, isBlockedTarget, isInsideOrAt, mapLimit, walkFolder } from './folder-walk';
|
||||
|
||||
const runningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
|
||||
async function files(dir: string, names: string[], size = 10) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
for (const name of names) await writeFile(join(dir, name), 'x'.repeat(size));
|
||||
}
|
||||
|
||||
const unlimited = () => ({ maxEntries: 100_000, deadline: Date.now() + 10_000 });
|
||||
|
||||
describe('walkFolder', () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'folder-walk-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await chmod(root, 0o755).catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('counts every book file directly in the folder as its own book, as the scanner does', async () => {
|
||||
await files(root, ['Dune.epub', 'Emma.pdf', 'cover.jpg', 'notes.txt.bak']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ state: 'ok', files: 2, folderBooks: 2, formats: { epub: 1, pdf: 1 }, folders: 0, mergedFolders: 0 });
|
||||
});
|
||||
|
||||
it('reads one book per folder below, through author folders', async () => {
|
||||
await files(join(root, 'Ann Leckie', 'Ancillary Justice (2013)'), ['Ancillary Justice.epub', 'Ancillary Justice.mobi']);
|
||||
await files(join(root, 'Ann Leckie', 'Provenance (2017)'), ['Provenance.epub']);
|
||||
await files(join(root, 'Martha Wells', 'All Systems Red (2017)'), ['All Systems Red.azw3']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ files: 4, folderBooks: 3, folders: 2, mergedFolders: 0, formats: { epub: 2, mobi: 1, azw3: 1 } });
|
||||
});
|
||||
|
||||
it('flags folders below that hold several differently named books, which Folder as Book merges', async () => {
|
||||
await files(join(root, 'Saga (2012)'), ['Saga 001.cbz', 'Saga 002.cbz', 'Saga 003.cbz']);
|
||||
await files(join(root, 'Watchmen (1986)'), ['Watchmen 001.cbz', 'Watchmen 002.cbz']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ files: 5, folderBooks: 2, mergedFolders: 2, splitAudioFolders: 0 });
|
||||
});
|
||||
|
||||
it('flags audiobooks in parts, which File as Book splits into one book per file', async () => {
|
||||
await files(join(root, 'Andy Weir', 'Project Hail Mary'), ['01.mp3', '02.mp3', '03.mp3']);
|
||||
await files(join(root, 'Andy Weir', 'Artemis'), ['Artemis.m4b']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ files: 4, folderBooks: 2, mergedFolders: 0, splitAudioFolders: 1 });
|
||||
});
|
||||
|
||||
it('folds disc folders and folders named after a sibling file into their parent', async () => {
|
||||
await files(join(root, 'Author', 'Long Book', 'CD 1'), ['01.mp3']);
|
||||
await files(join(root, 'Author', 'Long Book', 'CD 2'), ['01.mp3']);
|
||||
await files(join(root, 'Author', 'Story'), ['Story.epub']);
|
||||
await files(join(root, 'Author', 'Story', 'Story'), ['Story.mp3']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome.folderBooks).toBe(2);
|
||||
});
|
||||
|
||||
it('folds disc folders inside a folder named after a sibling file into one book, as the scanner does', async () => {
|
||||
await files(join(root, 'Author', 'Title'), ['Title.epub']);
|
||||
await files(join(root, 'Author', 'Title', 'Title', 'CD 1'), ['01.mp3']);
|
||||
await files(join(root, 'Author', 'Title', 'Title', 'CD 2'), ['01.mp3']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome.folderBooks).toBe(1);
|
||||
});
|
||||
|
||||
it('folds a folder named after a sidecar file, not only after a book file', async () => {
|
||||
await files(join(root, 'Series'), ['A.epub', 'B.opf']);
|
||||
await files(join(root, 'Series', 'B'), ['01.mp3']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome.folderBooks).toBe(1);
|
||||
});
|
||||
|
||||
it('agrees with the scanner on every layout it groups', async () => {
|
||||
await files(root, ['Loose.epub', 'Loose.opf', 'Other.pdf']);
|
||||
await files(join(root, 'CD 1'), ['01.mp3', '02.mp3']);
|
||||
await files(join(root, 'Ann Leckie', 'Ancillary Justice (2013)'), ['Ancillary Justice.epub', 'Ancillary Justice.mobi', 'cover.jpg']);
|
||||
await files(join(root, 'Saga (2012)'), ['Saga 001.cbz', 'Saga 002.cbz']);
|
||||
await files(join(root, 'Andy Weir', 'Project Hail Mary', 'Disc 1'), ['01.mp3']);
|
||||
await files(join(root, 'Andy Weir', 'Project Hail Mary', 'Disc 2'), ['01.mp3']);
|
||||
await files(join(root, 'Author', 'Title'), ['Title.epub']);
|
||||
await files(join(root, 'Author', 'Title', 'Title', 'CD 1'), ['01.mp3']);
|
||||
await files(join(root, 'Author', 'Title', 'Title', 'CD 2'), ['01.mp3']);
|
||||
await files(join(root, 'Series'), ['A.epub', 'B.opf']);
|
||||
await files(join(root, 'Series', 'B'), ['01.mp3']);
|
||||
await files(join(root, 'Notes'), ['readme.txt']);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome.folderBooks).toBe((await findBookCandidates(root)).candidates.length);
|
||||
expect(outcome.files).toBe((await findLooseFileCandidates(root)).candidates.length);
|
||||
});
|
||||
|
||||
it('skips hidden folders and symlinks', async () => {
|
||||
await files(join(root, '.cache'), ['Hidden.epub']);
|
||||
await files(join(root, 'Real'), ['Real.epub']);
|
||||
await symlink(join(root, 'Real'), join(root, 'Linked'));
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ files: 1, folders: 1 });
|
||||
});
|
||||
|
||||
it('stops at the entry cap and says the counts are a lower bound', async () => {
|
||||
for (let i = 0; i < 5; i++) await files(join(root, `Author ${i}`), [`Book ${i}.epub`]);
|
||||
|
||||
const outcome = await walkFolder(root, { maxEntries: 3, deadline: Date.now() + 10_000 });
|
||||
|
||||
expect(outcome.truncated).toBe(true);
|
||||
expect(outcome.files).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('reads no more of a huge flat folder than the entry budget allows', async () => {
|
||||
await files(
|
||||
root,
|
||||
Array.from({ length: 50 }, (_, i) => `Book ${String(i).padStart(2, '0')}.epub`),
|
||||
);
|
||||
|
||||
const outcome = await walkFolder(root, { maxEntries: 10, deadline: Date.now() + 10_000 });
|
||||
|
||||
expect(outcome).toMatchObject({ truncated: true, files: 10, folderBooks: 10 });
|
||||
});
|
||||
|
||||
it('stops sizing files once the deadline passes mid-folder and says the answer is partial', async () => {
|
||||
await files(root, ['A.epub', 'B.epub'], 100);
|
||||
// Time for the folder and the first file, then the deadline is behind.
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(100);
|
||||
|
||||
try {
|
||||
const outcome = await walkFolder(root, { maxEntries: 100, deadline: 50, withSizes: true });
|
||||
|
||||
expect(outcome).toMatchObject({ truncated: true, files: 2, bytes: 100 });
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(runningAsRoot)('says the counts are a lower bound when a folder below cannot be opened', async () => {
|
||||
await files(join(root, 'Open'), ['Open.epub']);
|
||||
await files(join(root, 'Closed'), ['Closed.epub']);
|
||||
await chmod(join(root, 'Closed'), 0o000);
|
||||
|
||||
try {
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ state: 'ok', files: 1, truncated: true });
|
||||
} finally {
|
||||
await chmod(join(root, 'Closed'), 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it('sums sizes and returns the first files in name order when asked', async () => {
|
||||
await files(join(root, 'B'), ['Second.epub'], 30);
|
||||
await files(join(root, 'A'), ['First.epub'], 20);
|
||||
await files(root, ['Loose.pdf'], 50);
|
||||
|
||||
const outcome = await walkFolder(root, { ...unlimited(), withSizes: true, samples: 2 });
|
||||
|
||||
expect(outcome.bytes).toBe(100);
|
||||
expect(outcome.samples).toEqual(['Loose.pdf', 'A/First.epub']);
|
||||
});
|
||||
|
||||
it('reports a missing folder instead of an empty one', async () => {
|
||||
const outcome = await walkFolder(join(root, 'nope'), unlimited());
|
||||
|
||||
expect(outcome).toMatchObject({ state: 'missing', files: 0 });
|
||||
});
|
||||
|
||||
it.skipIf(runningAsRoot)('reports a folder it may not read as denied', async () => {
|
||||
await chmod(root, 0o000);
|
||||
|
||||
const outcome = await walkFolder(root, unlimited());
|
||||
|
||||
expect(outcome.state).toBe('denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder-walk helpers', () => {
|
||||
it('knows the blocked system paths and their children', () => {
|
||||
expect(isBlockedPath('/proc')).toBe(true);
|
||||
expect(isBlockedPath('/proc/1')).toBe(true);
|
||||
expect(isBlockedPath('/procedures')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a symlink into a blocked system path like the path itself', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'folder-walk-'));
|
||||
try {
|
||||
await symlink('/dev', join(root, 'dev-link'));
|
||||
await mkdir(join(root, 'plain'));
|
||||
|
||||
expect(await isBlockedTarget(join(root, 'dev-link'))).toBe(true);
|
||||
expect(await isBlockedTarget(join(root, 'plain'))).toBe(false);
|
||||
expect(await isBlockedTarget(join(root, 'missing'))).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a folder as inside itself and its ancestors only', () => {
|
||||
expect(isInsideOrAt('/books/Ebooks', '/books')).toBe(true);
|
||||
expect(isInsideOrAt('/books', '/books')).toBe(true);
|
||||
expect(isInsideOrAt('/bookshelf', '/books')).toBe(false);
|
||||
expect(isInsideOrAt('/anything', '/')).toBe(true);
|
||||
});
|
||||
|
||||
it('finds whether a folder has a listable folder inside', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'folder-walk-'));
|
||||
try {
|
||||
await files(join(root, 'empty'), []);
|
||||
await files(join(root, 'files-only'), ['a.epub']);
|
||||
await files(join(root, 'nested', 'inner'), []);
|
||||
await files(join(root, 'hidden-only', '.git'), []);
|
||||
|
||||
expect(await hasListableChild(join(root, 'empty'))).toBe(false);
|
||||
expect(await hasListableChild(join(root, 'files-only'))).toBe(false);
|
||||
expect(await hasListableChild(join(root, 'nested'))).toBe(true);
|
||||
expect(await hasListableChild(join(root, 'hidden-only'))).toBe(false);
|
||||
expect(await hasListableChild(join(root, 'missing'))).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the input order while limiting concurrency', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const results = await mapLimit([30, 10, 20, 5], 2, async (ms) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
inFlight -= 1;
|
||||
return ms * 2;
|
||||
});
|
||||
|
||||
expect(results).toEqual([60, 20, 40, 10]);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { Dirent } from 'fs';
|
||||
import { lstat, readdir, realpath } from 'fs/promises';
|
||||
import { basename, dirname, extname, join, relative, sep } from 'path';
|
||||
|
||||
import { isAudioFormat, isPrimaryFormat } from '../scanner/lib/classify';
|
||||
import { isDiscDirectory, stemOf } from '../scanner/lib/walk';
|
||||
|
||||
export const BLOCKED_PATHS = ['/proc', '/sys', '/dev', '/run', '/var/run', '/etc', '/root'];
|
||||
|
||||
/** Operating-system folders directly under `/`, hidden from the picker unless asked for. */
|
||||
export const SYSTEM_FOLDERS = new Set([
|
||||
'app',
|
||||
'bin',
|
||||
'boot',
|
||||
'home',
|
||||
'lib',
|
||||
'lib32',
|
||||
'lib64',
|
||||
'libx32',
|
||||
'lost+found',
|
||||
'opt',
|
||||
'sbin',
|
||||
'snap',
|
||||
'srv',
|
||||
'tmp',
|
||||
'usr',
|
||||
'var',
|
||||
'Applications',
|
||||
'Library',
|
||||
'System',
|
||||
'Volumes',
|
||||
'cores',
|
||||
'private',
|
||||
]);
|
||||
|
||||
export function isBlockedPath(path: string): boolean {
|
||||
return BLOCKED_PATHS.some((blocked) => path === blocked || path.startsWith(blocked + sep));
|
||||
}
|
||||
|
||||
/** `path` with symlinks followed, or as given when it cannot be resolved (a missing folder). */
|
||||
export async function realTarget(path: string): Promise<string> {
|
||||
try {
|
||||
return await realpath(path);
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocked as typed or once symlinks are followed, so a link to `/etc` is refused like `/etc`. */
|
||||
export async function isBlockedTarget(path: string): Promise<boolean> {
|
||||
return isBlockedPath(path) || isBlockedPath(await realTarget(path));
|
||||
}
|
||||
|
||||
export function isInsideOrAt(path: string, parent: string): boolean {
|
||||
if (path === parent) return true;
|
||||
return parent === sep ? path.startsWith(sep) : path.startsWith(parent + sep);
|
||||
}
|
||||
|
||||
/** A folder BookOrbit lists: a real directory, not hidden, not a symlink, not a blocked system path. */
|
||||
export function isListableDirectory(entry: Dirent, fullPath: string): boolean {
|
||||
return entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith('.') && !isBlockedPath(fullPath);
|
||||
}
|
||||
|
||||
export interface WalkLimits {
|
||||
/** Directory entries to read before stopping; the result is then a lower bound. */
|
||||
maxEntries: number;
|
||||
/** Epoch milliseconds after which the walk stops. */
|
||||
deadline: number;
|
||||
/** Sum the size of every book file counted. */
|
||||
withSizes?: boolean;
|
||||
/** How many book files to return as samples, relative to the root. */
|
||||
samples?: number;
|
||||
}
|
||||
|
||||
export interface WalkOutcome {
|
||||
state: 'ok' | 'denied' | 'missing';
|
||||
files: number;
|
||||
folderBooks: number;
|
||||
formats: Record<string, number>;
|
||||
folders: number;
|
||||
mergedFolders: number;
|
||||
splitAudioFolders: number;
|
||||
truncated: boolean;
|
||||
bytes: number;
|
||||
samples: string[];
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function stateForError(error: unknown): 'denied' | 'missing' {
|
||||
const code = errorCode(error);
|
||||
return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'missing';
|
||||
}
|
||||
|
||||
/** What one folder holds, as the scanner groups it. */
|
||||
interface DirFiles {
|
||||
books: number;
|
||||
audio: number;
|
||||
/** Stems of the non-audio book files: several mean several titles. */
|
||||
bookStems: Set<string>;
|
||||
/** Stems of every file, sidecars included: a subfolder named after one folds into this folder. */
|
||||
stems: Set<string>;
|
||||
}
|
||||
|
||||
function mergeInto(groups: Map<string, DirFiles>, from: string, to: string) {
|
||||
const source = groups.get(from)!;
|
||||
const target = groups.get(to);
|
||||
groups.delete(from);
|
||||
if (!target) {
|
||||
groups.set(to, source);
|
||||
return;
|
||||
}
|
||||
target.books += source.books;
|
||||
target.audio += source.audio;
|
||||
for (const stem of source.bookStems) target.bookStems.add(stem);
|
||||
for (const stem of source.stems) target.stems.add(stem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts what a library would import from `root` by the scanner's grouping rules
|
||||
* (`findBookCandidates`): every book file directly in the root is its own book; below it, Folder as
|
||||
* Book reads each folder holding book files as one book, after folding disc folders ("CD 1") and then
|
||||
* folders named after a sibling file into their parent. Depth-first in name order, so samples are stable.
|
||||
*/
|
||||
export async function walkFolder(root: string, limits: WalkLimits): Promise<WalkOutcome> {
|
||||
const outcome: WalkOutcome = {
|
||||
state: 'ok',
|
||||
files: 0,
|
||||
folderBooks: 0,
|
||||
formats: {},
|
||||
folders: 0,
|
||||
mergedFolders: 0,
|
||||
splitAudioFolders: 0,
|
||||
truncated: false,
|
||||
bytes: 0,
|
||||
samples: [],
|
||||
};
|
||||
const sampleLimit = limits.samples ?? 0;
|
||||
const groups = new Map<string, DirFiles>();
|
||||
const stack = [root];
|
||||
let entries = 0;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
if (entries >= limits.maxEntries || Date.now() > limits.deadline) {
|
||||
outcome.truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let read: { entries: Dirent[]; cut: boolean };
|
||||
try {
|
||||
read = await readEntries(dir, limits.maxEntries - entries);
|
||||
} catch (error) {
|
||||
if (dir === root) {
|
||||
outcome.state = stateForError(error);
|
||||
return outcome;
|
||||
}
|
||||
// Books in a folder that cannot be opened are not counted, so the counts are a lower bound.
|
||||
outcome.truncated = true;
|
||||
continue;
|
||||
}
|
||||
const dirents = read.entries.sort(byName);
|
||||
entries += dirents.length;
|
||||
if (read.cut) outcome.truncated = true;
|
||||
|
||||
const children: string[] = [];
|
||||
const bookFiles: string[] = [];
|
||||
const stems = new Set<string>();
|
||||
for (const entry of dirents) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = join(dir, entry.name);
|
||||
if (isListableDirectory(entry, full)) children.push(full);
|
||||
else if (entry.isFile()) {
|
||||
stems.add(stemOf(entry.name));
|
||||
if (isPrimaryFormat(full)) bookFiles.push(full);
|
||||
}
|
||||
}
|
||||
if (dir === root) outcome.folders = children.length;
|
||||
|
||||
const group: DirFiles = { books: bookFiles.length, audio: 0, bookStems: new Set(), stems };
|
||||
for (const file of bookFiles) {
|
||||
const format = extname(file).slice(1).toLowerCase();
|
||||
outcome.files += 1;
|
||||
outcome.formats[format] = (outcome.formats[format] ?? 0) + 1;
|
||||
if (isAudioFormat(format)) group.audio += 1;
|
||||
else group.bookStems.add(stemOf(basename(file)));
|
||||
if (outcome.samples.length < sampleLimit) outcome.samples.push(relative(root, file).split(sep).join('/'));
|
||||
if (limits.withSizes && !outcome.truncated) {
|
||||
if (Date.now() > limits.deadline) {
|
||||
outcome.truncated = true;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
outcome.bytes += (await lstat(file)).size;
|
||||
} catch {
|
||||
// A file that vanished mid-walk adds nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stems.size > 0) groups.set(dir, group);
|
||||
|
||||
if (read.cut) break;
|
||||
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
|
||||
}
|
||||
|
||||
// The scanner's two folding passes, in its order. The root never folds: its files are each a book.
|
||||
for (const dir of [...groups.keys()]) if (dir !== root && isDiscDirectory(basename(dir))) mergeInto(groups, dir, dirname(dir));
|
||||
const stemNamed = [...groups.keys()].filter((dir) => {
|
||||
const parent = dirname(dir);
|
||||
return dir !== root && parent !== root && !!groups.get(parent)?.stems.has(basename(dir));
|
||||
});
|
||||
for (const dir of stemNamed) if (groups.has(dir)) mergeInto(groups, dir, dirname(dir));
|
||||
|
||||
for (const [dir, group] of groups) {
|
||||
if (group.books === 0) continue;
|
||||
if (dir === root) {
|
||||
outcome.folderBooks += group.books;
|
||||
continue;
|
||||
}
|
||||
outcome.folderBooks += 1;
|
||||
if (group.audio === 0 && group.bookStems.size > 1) outcome.mergedFolders += 1;
|
||||
if (group.audio > 1) outcome.splitAudioFolders += 1;
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Whether `dir` holds at least one listable folder. */
|
||||
export async function hasListableChild(dir: string): Promise<boolean> {
|
||||
try {
|
||||
return (await readdir(dir, { withFileTypes: true })).some((entry) => isListableDirectory(entry, join(dir, entry.name)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `budget` entries of `dir` in name order. `readdir` rather than a streamed `opendir`: on
|
||||
* filesystems that do not report entry types (some NFS and FUSE mounts) `opendir` falls back to a
|
||||
* synchronous lstat per entry, which would stall the server for every user.
|
||||
*/
|
||||
async function readEntries(dir: string, budget: number): Promise<{ entries: Dirent[]; cut: boolean }> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const limit = Math.max(0, budget);
|
||||
if (entries.length <= limit) return { entries, cut: false };
|
||||
return { entries: entries.sort(byName).slice(0, limit), cut: true };
|
||||
}
|
||||
|
||||
/** Code-unit order: stable and cheap on folders of tens of thousands of entries. */
|
||||
function byName(a: Dirent, b: Dirent): number {
|
||||
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Runs `task` over `items` with at most `limit` in flight, keeping the input order. */
|
||||
export async function mapLimit<T, R>(items: T[], limit: number, task: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (next < items.length) {
|
||||
const index = next++;
|
||||
results[index] = await task(items[index]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { mkdtemp, mkdir, rm, symlink } from 'fs/promises';
|
||||
import { mkdtemp, mkdir, realpath, rm, symlink } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
@@ -83,6 +83,22 @@ describe('PathPolicyService', () => {
|
||||
await expect(service.isWithinBrowseRoot(join(root, 'loop'))).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a symlinked data folder to the real path listings report', async () => {
|
||||
const root = await makeTempRoot();
|
||||
await mkdir(join(root, 'real-data'));
|
||||
await symlink(join(root, 'real-data'), join(root, 'data'));
|
||||
const service = new PathPolicyService({ libraryBrowseRoot: root, appDataPath: join(root, 'data') } as never);
|
||||
|
||||
expect(service.getAppDataPath()).toBe(join(root, 'data'));
|
||||
await expect(service.getCanonicalAppDataPath()).resolves.toBe(await realpath(join(root, 'real-data')));
|
||||
});
|
||||
|
||||
it('has no data folder when none is configured', async () => {
|
||||
const service = makeService(await makeTempRoot());
|
||||
|
||||
await expect(service.getCanonicalAppDataPath()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
async function makeTempRoot(): Promise<string> {
|
||||
const path = await mkdtemp(join(tmpdir(), 'bookorbit-path-policy-'));
|
||||
tempRoots.push(path);
|
||||
|
||||
@@ -10,16 +10,30 @@ const MAX_BROWSE_PATH_LENGTH = 4096;
|
||||
@Injectable()
|
||||
export class PathPolicyService {
|
||||
private readonly browseRoot: string;
|
||||
private readonly appDataPath: string | null;
|
||||
private canonicalBrowseRootPromise: Promise<string> | null = null;
|
||||
private canonicalAppDataPathPromise: Promise<string | null> | null = null;
|
||||
|
||||
constructor(@Inject(storageConfig.KEY) storage: ConfigType<typeof storageConfig>) {
|
||||
this.browseRoot = resolve(storage.libraryBrowseRoot);
|
||||
this.appDataPath = storage.appDataPath ? resolve(storage.appDataPath) : null;
|
||||
}
|
||||
|
||||
getBrowseRoot(): string {
|
||||
return this.browseRoot;
|
||||
}
|
||||
|
||||
getAppDataPath(): string | null {
|
||||
return this.appDataPath;
|
||||
}
|
||||
|
||||
/** The data folder with symlinks resolved, so it matches paths read from the disk. */
|
||||
getCanonicalAppDataPath(): Promise<string | null> {
|
||||
if (!this.appDataPath) return Promise.resolve(null);
|
||||
this.canonicalAppDataPathPromise ??= realpath(this.appDataPath).catch(() => this.appDataPath);
|
||||
return this.canonicalAppDataPathPromise;
|
||||
}
|
||||
|
||||
async resolveBrowsePath(rawPath?: string | null): Promise<string> {
|
||||
const inputPath = typeof rawPath === 'string' ? rawPath.trim() : '';
|
||||
if (inputPath.length > MAX_BROWSE_PATH_LENGTH) throw new BadRequestException('Path is too long');
|
||||
|
||||
@@ -5,9 +5,18 @@ describe('PathController', () => {
|
||||
getConfig: vi.fn(),
|
||||
listDirectories: vi.fn(),
|
||||
createDirectory: vi.fn(),
|
||||
listFolder: vi.fn(),
|
||||
searchFolders: vi.fn(),
|
||||
getPlaces: vi.fn(),
|
||||
};
|
||||
const folderInsight = {
|
||||
summarize: vi.fn(),
|
||||
inspect: vi.fn(),
|
||||
unclaimed: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new PathController(pathService as never);
|
||||
const controller = new PathController(pathService as never, folderInsight as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -31,9 +40,54 @@ describe('PathController', () => {
|
||||
expect(pathService.listDirectories).toHaveBeenCalledWith('/books');
|
||||
});
|
||||
|
||||
it('forwards folder creation to the service', async () => {
|
||||
await controller.createFolder({ parentPath: '/books', name: 'scifi' });
|
||||
it('forwards folder creation to the service and drops cached folder counts', async () => {
|
||||
pathService.createDirectory.mockResolvedValue({ name: 'scifi', path: '/books/scifi' });
|
||||
|
||||
await expect(controller.createFolder({ parentPath: '/books', name: 'scifi' })).resolves.toEqual({ name: 'scifi', path: '/books/scifi' });
|
||||
|
||||
expect(pathService.createDirectory).toHaveBeenCalledWith('/books', 'scifi');
|
||||
expect(folderInsight.forget).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cached counts when folder creation fails', async () => {
|
||||
pathService.createDirectory.mockRejectedValue(new Error('exists'));
|
||||
|
||||
await expect(controller.createFolder({ parentPath: '/books', name: 'scifi' })).rejects.toThrow('exists');
|
||||
|
||||
expect(folderInsight.forget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the listing path through to the service', async () => {
|
||||
await controller.listFolder({ path: '/books' });
|
||||
|
||||
expect(pathService.listFolder).toHaveBeenCalledWith('/books');
|
||||
});
|
||||
|
||||
it('summarizes the requested paths in one batch', async () => {
|
||||
await controller.summarize({ paths: ['/books/a', '/books/b'] });
|
||||
|
||||
expect(folderInsight.summarize).toHaveBeenCalledWith(['/books/a', '/books/b']);
|
||||
});
|
||||
|
||||
it('inspects the browse root when no path is given', async () => {
|
||||
await controller.inspect({});
|
||||
|
||||
expect(folderInsight.inspect).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('searches system folders only when asked', async () => {
|
||||
await controller.search({ q: 'wells', path: '/books' });
|
||||
await controller.search({ q: 'usr', system: 'true' });
|
||||
|
||||
expect(pathService.searchFolders).toHaveBeenNthCalledWith(1, 'wells', '/books', false);
|
||||
expect(pathService.searchFolders).toHaveBeenNthCalledWith(2, 'usr', undefined, true);
|
||||
});
|
||||
|
||||
it('returns places and unclaimed folders from their services', async () => {
|
||||
pathService.getPlaces.mockResolvedValue({ root: '/', mounts: ['/books'], appDataPath: '/data', libraries: [] });
|
||||
folderInsight.unclaimed.mockResolvedValue({ folders: [], truncated: false });
|
||||
|
||||
await expect(controller.places()).resolves.toEqual({ root: '/', mounts: ['/books'], appDataPath: '/data', libraries: [] });
|
||||
await expect(controller.unclaimed()).resolves.toEqual({ folders: [], truncated: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import type { PathConfig } from '@bookorbit/types';
|
||||
import type {
|
||||
FolderInspection,
|
||||
FolderListing,
|
||||
FolderPlaces,
|
||||
FolderSearchResult,
|
||||
FolderSummary,
|
||||
PathConfig,
|
||||
UnclaimedFolders,
|
||||
} from '@bookorbit/types';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
|
||||
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { FolderPathQueryDto } from './dto/folder-path-query.dto';
|
||||
import { FolderSummariesDto } from './dto/folder-summaries.dto';
|
||||
import { SearchFoldersQueryDto } from './dto/search-folders-query.dto';
|
||||
import { FolderInsightService } from './folder-insight.service';
|
||||
import { PathService } from './path.service';
|
||||
|
||||
@Controller('path')
|
||||
export class PathController {
|
||||
constructor(private readonly pathService: PathService) {}
|
||||
constructor(
|
||||
private readonly pathService: PathService,
|
||||
private readonly folderInsight: FolderInsightService,
|
||||
) {}
|
||||
|
||||
@Get('config')
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
@@ -22,9 +37,48 @@ export class PathController {
|
||||
return this.pathService.listDirectories(path);
|
||||
}
|
||||
|
||||
@Get('listing')
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
listFolder(@Query() query: FolderPathQueryDto): Promise<FolderListing> {
|
||||
return this.pathService.listFolder(query.path);
|
||||
}
|
||||
|
||||
@Post('summaries')
|
||||
@HttpCode(200)
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
summarize(@Body() dto: FolderSummariesDto): Promise<FolderSummary[]> {
|
||||
return this.folderInsight.summarize(dto.paths);
|
||||
}
|
||||
|
||||
@Get('inspect')
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
inspect(@Query() query: FolderPathQueryDto): Promise<FolderInspection> {
|
||||
return this.folderInsight.inspect(query.path ?? '');
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
search(@Query() query: SearchFoldersQueryDto): Promise<FolderSearchResult> {
|
||||
return this.pathService.searchFolders(query.q, query.path, query.system === 'true');
|
||||
}
|
||||
|
||||
@Get('places')
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
places(): Promise<FolderPlaces> {
|
||||
return this.pathService.getPlaces();
|
||||
}
|
||||
|
||||
@Get('unclaimed')
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
unclaimed(): Promise<UnclaimedFolders> {
|
||||
return this.folderInsight.unclaimed();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.ManageLibraries)
|
||||
createFolder(@Body() dto: CreateFolderDto) {
|
||||
return this.pathService.createDirectory(dto.parentPath, dto.name);
|
||||
async createFolder(@Body() dto: CreateFolderDto) {
|
||||
const created = await this.pathService.createDirectory(dto.parentPath, dto.name);
|
||||
this.folderInsight.forget();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { FolderInsightService } from './folder-insight.service';
|
||||
import { PathController } from './path.controller';
|
||||
import { PathModule } from './path.module';
|
||||
import { PathPolicyService } from './path-policy.service';
|
||||
import { PathRepository } from './path.repository';
|
||||
import { PathService } from './path.service';
|
||||
|
||||
describe('PathModule', () => {
|
||||
it('registers path controller and service', () => {
|
||||
it('registers the path controller, its services and the library folder repository', () => {
|
||||
expect(Reflect.getMetadata('controllers', PathModule)).toEqual([PathController]);
|
||||
expect(Reflect.getMetadata('providers', PathModule)).toEqual([PathPolicyService, PathService]);
|
||||
expect(Reflect.getMetadata('providers', PathModule)).toEqual([PathPolicyService, PathService, PathRepository, FolderInsightService]);
|
||||
expect(Reflect.getMetadata('exports', PathModule)).toEqual([PathPolicyService]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FolderInsightService } from './folder-insight.service';
|
||||
import { PathController } from './path.controller';
|
||||
import { PathPolicyService } from './path-policy.service';
|
||||
import { PathRepository } from './path.repository';
|
||||
import { PathService } from './path.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PathController],
|
||||
providers: [PathPolicyService, PathService],
|
||||
providers: [PathPolicyService, PathService, PathRepository, FolderInsightService],
|
||||
exports: [PathPolicyService],
|
||||
})
|
||||
export class PathModule {}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { APP_FEATURES } from '@bookorbit/types';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { libraries, libraryFolders } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface LibraryFolderRow {
|
||||
libraryId: number;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PathRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
/** Every library's folders, for landmarks and overlap checks. Callers hold ManageLibraries. */
|
||||
findLibraryFolders(): Promise<LibraryFolderRow[]> {
|
||||
return this.db
|
||||
.select({ libraryId: libraries.id, name: libraries.name, icon: libraries.icon, type: libraries.type, path: libraryFolders.path })
|
||||
.from(libraryFolders)
|
||||
.innerJoin(libraries, eq(libraries.id, libraryFolders.libraryId))
|
||||
.where(APP_FEATURES.podcasts ? undefined : eq(libraries.type, 'books'))
|
||||
.orderBy(asc(libraries.displayOrder), asc(libraries.name), asc(libraryFolders.createdAt), asc(libraryFolders.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { PathService } from './path.service';
|
||||
|
||||
const runningAsRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
|
||||
describe('PathService folder picker routes', () => {
|
||||
let root: string;
|
||||
let service: PathService;
|
||||
const pathPolicy = {
|
||||
getBrowseRoot: vi.fn(),
|
||||
getAppDataPath: vi.fn(),
|
||||
getCanonicalAppDataPath: vi.fn(),
|
||||
resolveBrowsePath: vi.fn(),
|
||||
};
|
||||
const pathRepository = { findLibraryFolders: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'path-folders-'));
|
||||
await mkdir(join(root, 'books', 'Ebooks', 'Fiction'), { recursive: true });
|
||||
await mkdir(join(root, 'books', 'Podcasts'), { recursive: true });
|
||||
await mkdir(join(root, 'books', '.hidden'), { recursive: true });
|
||||
await mkdir(join(root, 'data', 'covers'), { recursive: true });
|
||||
await writeFile(join(root, 'books', 'Loose.epub'), 'x');
|
||||
await symlink(join(root, 'books', 'Ebooks'), join(root, 'books', 'Linked'));
|
||||
|
||||
pathPolicy.getBrowseRoot.mockReturnValue(root);
|
||||
pathPolicy.getAppDataPath.mockReturnValue(join(root, 'data'));
|
||||
pathPolicy.getCanonicalAppDataPath.mockResolvedValue(join(root, 'data'));
|
||||
pathPolicy.resolveBrowsePath.mockImplementation((path?: string) => Promise.resolve(path || root));
|
||||
pathRepository.findLibraryFolders.mockResolvedValue([]);
|
||||
service = new PathService(pathPolicy as never, pathRepository as never);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await chmod(join(root, 'books'), 0o755).catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('listFolder', () => {
|
||||
it('lists real folders with whether each has folders of its own, skipping files, hidden folders and symlinks', async () => {
|
||||
const listing = await service.listFolder(join(root, 'books'));
|
||||
|
||||
expect(listing.state).toBe('ok');
|
||||
expect(listing.entries.map((entry) => [entry.name, entry.hasChildren, entry.system, entry.appData])).toEqual([
|
||||
['Ebooks', true, false, false],
|
||||
['Podcasts', false, false, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists a folder of hundreds of folders by name alone, without peeking inside each one', async () => {
|
||||
for (let i = 0; i < 401; i++) await mkdir(join(root, 'books', 'Ebooks', 'Fiction', `Author ${i}`));
|
||||
|
||||
const listing = await service.listFolder(join(root, 'books', 'Ebooks', 'Fiction'));
|
||||
|
||||
expect(listing.entries).toHaveLength(401);
|
||||
expect(listing.entries.every((entry) => entry.hasChildren === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists only the first folders by name from a folder of thousands, and says so', async () => {
|
||||
const crowded = join(root, 'books', 'Ebooks', 'Fiction');
|
||||
for (let i = 0; i <= 5_000; i++) await mkdir(join(crowded, `Author ${String(i).padStart(4, '0')}`));
|
||||
|
||||
const listing = await service.listFolder(crowded);
|
||||
|
||||
expect(listing.truncated).toBe(true);
|
||||
expect(listing.entries).toHaveLength(5_000);
|
||||
expect(listing.entries.at(-1)?.name).toBe('Author 4999');
|
||||
});
|
||||
|
||||
it('marks BookOrbit’s data folder', async () => {
|
||||
const listing = await service.listFolder(root);
|
||||
|
||||
expect(listing.truncated).toBe(false);
|
||||
expect(listing.entries.find((entry) => entry.name === 'data')?.appData).toBe(true);
|
||||
expect(listing.entries.find((entry) => entry.name === 'books')?.appData).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the data folder when the listing reaches it through a symlink', async () => {
|
||||
await mkdir(join(root, 'real', 'inner', 'data'), { recursive: true });
|
||||
await symlink(join(root, 'real'), join(root, 'via'));
|
||||
pathPolicy.getAppDataPath.mockReturnValue(join(root, 'real', 'inner', 'data'));
|
||||
pathPolicy.getCanonicalAppDataPath.mockResolvedValue(await realpath(join(root, 'real', 'inner', 'data')));
|
||||
|
||||
const listing = await service.listFolder(join(root, 'via', 'inner'));
|
||||
|
||||
expect(listing.entries).toMatchObject([{ name: 'data', appData: true }]);
|
||||
});
|
||||
|
||||
it('says a missing folder is missing rather than empty', async () => {
|
||||
await expect(service.listFolder(join(root, 'gone'))).resolves.toEqual({
|
||||
path: join(root, 'gone'),
|
||||
state: 'missing',
|
||||
entries: [],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('never opens blocked system paths', async () => {
|
||||
pathPolicy.resolveBrowsePath.mockResolvedValue('/proc');
|
||||
|
||||
await expect(service.listFolder('/proc')).resolves.toEqual({ path: '/proc', state: 'blocked', entries: [], truncated: false });
|
||||
});
|
||||
|
||||
it('refuses a symlinked folder', async () => {
|
||||
await expect(service.listFolder(join(root, 'books', 'Linked'))).resolves.toMatchObject({ state: 'blocked', entries: [] });
|
||||
});
|
||||
|
||||
it('refuses a folder reached through a symlink into a blocked system path', async () => {
|
||||
await symlink('/dev', join(root, 'dev-link'));
|
||||
|
||||
await expect(service.listFolder(join(root, 'dev-link', 'fd'))).resolves.toMatchObject({ state: 'blocked', entries: [] });
|
||||
});
|
||||
|
||||
it.skipIf(runningAsRoot)('says a folder it may not read is denied', async () => {
|
||||
await chmod(join(root, 'books'), 0o000);
|
||||
|
||||
await expect(service.listFolder(join(root, 'books'))).resolves.toMatchObject({ state: 'denied', entries: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchFolders', () => {
|
||||
it('finds folders by name anywhere below, ignoring case', async () => {
|
||||
await expect(service.searchFolders('FICT')).resolves.toEqual({ results: [join(root, 'books', 'Ebooks', 'Fiction')], truncated: false });
|
||||
});
|
||||
|
||||
it('returns nothing for a blank query', async () => {
|
||||
await expect(service.searchFolders(' ')).resolves.toEqual({ results: [], truncated: false });
|
||||
});
|
||||
|
||||
it('does not follow symlinks or hidden folders', async () => {
|
||||
const { results } = await service.searchFolders('e');
|
||||
|
||||
expect(results.some((path) => path.includes('Linked') || path.includes('.hidden'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not search below a symlink into a blocked system path', async () => {
|
||||
await symlink('/dev', join(root, 'dev-link'));
|
||||
|
||||
await expect(service.searchFolders('fd', join(root, 'dev-link'))).resolves.toEqual({ results: [], truncated: false });
|
||||
});
|
||||
|
||||
it('matches a name stored decomposed when the query is typed composed', async () => {
|
||||
await mkdir(join(root, 'books', 'Cafe\u0301 Stories'));
|
||||
|
||||
const { results } = await service.searchFolders('café');
|
||||
|
||||
expect(results).toEqual([join(root, 'books', 'Cafe\u0301 Stories')]);
|
||||
});
|
||||
|
||||
it('says the results may be incomplete when folders lie deeper than it looks', async () => {
|
||||
await mkdir(join(root, 'books', 'a', 'b', 'c', 'd', 'e', 'f', 'Deep Find'), { recursive: true });
|
||||
|
||||
await expect(service.searchFolders('deep find')).resolves.toEqual({ results: [], truncated: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlaces', () => {
|
||||
it('groups every library’s folders and offers the root when nothing is mounted separately', async () => {
|
||||
pathRepository.findLibraryFolders.mockResolvedValue([
|
||||
{ libraryId: 1, name: 'Novels', icon: 'BookOpen', type: 'books', path: join(root, 'books', 'Ebooks', 'Fiction') },
|
||||
{ libraryId: 2, name: 'Podcasts', icon: null, type: 'podcasts', path: join(root, 'books', 'Podcasts') },
|
||||
{ libraryId: 1, name: 'Novels', icon: 'BookOpen', type: 'books', path: join(root, 'books', 'Extra') },
|
||||
]);
|
||||
|
||||
await expect(service.getPlaces()).resolves.toEqual({
|
||||
root,
|
||||
mounts: [root],
|
||||
appDataPath: join(root, 'data'),
|
||||
libraries: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Novels',
|
||||
icon: 'BookOpen',
|
||||
type: 'books',
|
||||
folders: [join(root, 'books', 'Ebooks', 'Fiction'), join(root, 'books', 'Extra')],
|
||||
},
|
||||
{ id: 2, name: 'Podcasts', icon: null, type: 'podcasts', folders: [join(root, 'books', 'Podcasts')] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out a data folder that sits outside the browse root', async () => {
|
||||
pathPolicy.getCanonicalAppDataPath.mockResolvedValue('/elsewhere/data');
|
||||
|
||||
await expect(service.getPlaces()).resolves.toMatchObject({ appDataPath: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ vi.mock('fs/promises', () => ({
|
||||
}));
|
||||
|
||||
import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||
import { readdir, lstat, mkdir } from 'fs/promises';
|
||||
import { readdir, lstat, mkdir, realpath } from 'fs/promises';
|
||||
|
||||
import { PathService } from './path.service';
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('PathService', () => {
|
||||
pathPolicy.getBrowseRoot.mockReturnValue('/tmp/books');
|
||||
pathPolicy.resolveBrowsePath.mockImplementation((path?: string) => Promise.resolve(path || '/tmp/books'));
|
||||
pathPolicy.assertWithinBrowseRoot.mockImplementation((path: string) => Promise.resolve(path));
|
||||
vi.mocked(realpath).mockImplementation((path) => Promise.resolve(String(path)));
|
||||
service = new PathService(pathPolicy as never);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { CreateFolderResult, DirectoryEntry, PathConfig } from '@bookorbit/types';
|
||||
import type {
|
||||
CreateFolderResult,
|
||||
DirectoryEntry,
|
||||
FolderListing,
|
||||
FolderListingEntry,
|
||||
FolderPlaces,
|
||||
FolderPickerLibrary,
|
||||
FolderSearchResult,
|
||||
PathConfig,
|
||||
} from '@bookorbit/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -8,19 +17,46 @@ import {
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { lstat, mkdir, readdir } from 'fs/promises';
|
||||
import type { Dirent } from 'fs';
|
||||
import { lstat, mkdir, readdir, realpath, stat } from 'fs/promises';
|
||||
import { basename, isAbsolute, join, relative, resolve, sep } from 'path';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import {
|
||||
hasListableChild,
|
||||
isBlockedPath,
|
||||
isBlockedTarget,
|
||||
isInsideOrAt,
|
||||
isListableDirectory,
|
||||
mapLimit,
|
||||
realTarget,
|
||||
stateForError,
|
||||
SYSTEM_FOLDERS,
|
||||
} from './folder-walk';
|
||||
import { PathPolicyService } from './path-policy.service';
|
||||
import { PathRepository } from './path.repository';
|
||||
|
||||
const BLOCKED = ['/proc', '/sys', '/dev', '/run', '/var/run', '/etc', '/root'];
|
||||
/** Past this many folders, children are not opened to learn whether they have their own. */
|
||||
const HAS_CHILDREN_CHECK_LIMIT = 400;
|
||||
/** Folders listed from one folder; the rest are reached by typing a path or finding by name. */
|
||||
const LISTING_MAX_ENTRIES = 5_000;
|
||||
const SEARCH_MAX_DEPTH = 6;
|
||||
const SEARCH_MAX_RESULTS = 200;
|
||||
const SEARCH_MAX_ENTRIES = 20_000;
|
||||
const SEARCH_BUDGET_MS = 2_000;
|
||||
const MOUNT_SCAN_FOLDERS_PER_LEVEL = 50;
|
||||
const MOUNT_SCAN_MAX_STATS = 400;
|
||||
const MOUNT_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
@Injectable()
|
||||
export class PathService {
|
||||
private readonly logger = new Logger(PathService.name);
|
||||
private mountCache: { root: string; mounts: string[]; expires: number } | null = null;
|
||||
|
||||
constructor(private readonly pathPolicy: PathPolicyService) {}
|
||||
constructor(
|
||||
private readonly pathPolicy: PathPolicyService,
|
||||
private readonly pathRepository?: PathRepository,
|
||||
) {}
|
||||
|
||||
getConfig(): PathConfig {
|
||||
return { root: this.pathPolicy.getBrowseRoot() };
|
||||
@@ -32,7 +68,7 @@ export class PathService {
|
||||
const relativePath = relative(browseRoot, resolved);
|
||||
if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) return [];
|
||||
|
||||
if (this.isBlocked(resolved)) {
|
||||
if (await this.isBlocked(resolved)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
@@ -62,7 +98,7 @@ export class PathService {
|
||||
throw new ForbiddenException('Cannot create a folder outside the configured library browse root');
|
||||
}
|
||||
|
||||
if (this.isBlocked(parent)) {
|
||||
if (await this.isBlocked(parent)) {
|
||||
throw new ForbiddenException('Cannot create a folder in this location');
|
||||
}
|
||||
|
||||
@@ -94,8 +130,168 @@ export class PathService {
|
||||
}
|
||||
}
|
||||
|
||||
private isBlocked(resolved: string): boolean {
|
||||
return BLOCKED.some((b) => resolved === b || resolved.startsWith(b + sep));
|
||||
/**
|
||||
* One folder's subfolders with what the picker shows beside them. Unlike `listDirectories`, a folder
|
||||
* that cannot be read says so instead of looking empty.
|
||||
*/
|
||||
async listFolder(rawPath?: string): Promise<FolderListing> {
|
||||
const resolved = resolve(await this.pathPolicy.resolveBrowsePath(rawPath));
|
||||
const blocked: FolderListing = { path: resolved, state: 'blocked', entries: [], truncated: false };
|
||||
if (await this.isBlocked(resolved)) return blocked;
|
||||
|
||||
let dirents: Dirent[];
|
||||
let parentDev: number;
|
||||
let realDir: string;
|
||||
try {
|
||||
const rootStat = await lstat(resolved);
|
||||
if (rootStat.isSymbolicLink()) return blocked;
|
||||
if (!rootStat.isDirectory()) return { ...blocked, state: 'missing' };
|
||||
parentDev = rootStat.dev;
|
||||
dirents = await readdir(resolved, { withFileTypes: true });
|
||||
realDir = await realpath(resolved);
|
||||
} catch (error) {
|
||||
return { ...blocked, state: stateForError(error) };
|
||||
}
|
||||
|
||||
const rawAppData = this.pathPolicy.getAppDataPath();
|
||||
const appDataPath = await this.pathPolicy.getCanonicalAppDataPath();
|
||||
// The data folder may be reached through a symlink in either path, so both spellings are compared.
|
||||
const isAppData = (full: string, name: string) =>
|
||||
(!!rawAppData && isInsideOrAt(full, rawAppData)) ||
|
||||
(!!appDataPath && (isInsideOrAt(full, appDataPath) || isInsideOrAt(join(realDir, name), appDataPath)));
|
||||
const listable = dirents.filter((entry) => isListableDirectory(entry, join(resolved, entry.name)) && !isBlockedPath(join(realDir, entry.name)));
|
||||
const truncated = listable.length > LISTING_MAX_ENTRIES;
|
||||
const dirs = truncated ? listable.sort((a, b) => a.name.localeCompare(b.name)).slice(0, LISTING_MAX_ENTRIES) : listable;
|
||||
// A folder of thousands of author folders is listed by name alone: no per-child stat or peek.
|
||||
const inspectChildren = dirs.length <= HAS_CHILDREN_CHECK_LIMIT;
|
||||
const entries = await mapLimit(dirs, 16, async (entry): Promise<FolderListingEntry> => {
|
||||
const full = join(resolved, entry.name);
|
||||
let mount = false;
|
||||
if (inspectChildren) {
|
||||
try {
|
||||
mount = (await stat(full)).dev !== parentDev;
|
||||
} catch {
|
||||
// An unreadable folder is still listed; opening it reports why.
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: entry.name,
|
||||
path: full,
|
||||
mount,
|
||||
system: resolved === sep && SYSTEM_FOLDERS.has(entry.name),
|
||||
appData: isAppData(full, entry.name),
|
||||
hasChildren: inspectChildren ? await hasListableChild(full) : null,
|
||||
};
|
||||
});
|
||||
return { path: resolved, state: 'ok', entries: entries.sort((a, b) => a.name.localeCompare(b.name)), truncated };
|
||||
}
|
||||
|
||||
/** Folders whose name contains `query`, breadth-first below `rawPath`, within fixed bounds. */
|
||||
async searchFolders(query: string, rawPath?: string, includeSystem = false): Promise<FolderSearchResult> {
|
||||
const root = resolve(await this.pathPolicy.resolveBrowsePath(rawPath));
|
||||
// Names are compared composed and case-folded the same way on every server, whatever its locale.
|
||||
const fold = (name: string) => name.normalize('NFC').toLowerCase();
|
||||
const needle = fold(query.trim());
|
||||
const results: string[] = [];
|
||||
if (!needle || (await this.isBlocked(root))) return { results, truncated: false };
|
||||
|
||||
const deadline = Date.now() + SEARCH_BUDGET_MS;
|
||||
// Each folder travels with its real path, so a symlink in the start path cannot lead into a blocked folder.
|
||||
let queue = [{ dir: root, real: await realTarget(root) }];
|
||||
let entries = 0;
|
||||
for (let depth = 0; depth < SEARCH_MAX_DEPTH && queue.length > 0; depth++) {
|
||||
const next: typeof queue = [];
|
||||
for (const { dir, real } of queue) {
|
||||
if (results.length >= SEARCH_MAX_RESULTS || entries >= SEARCH_MAX_ENTRIES || Date.now() > deadline) return { results, truncated: true };
|
||||
let dirents: Dirent[];
|
||||
try {
|
||||
dirents = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
entries += dirents.length;
|
||||
dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
for (const entry of dirents) {
|
||||
const full = join(dir, entry.name);
|
||||
const childReal = join(real, entry.name);
|
||||
if (!isListableDirectory(entry, full) || isBlockedPath(childReal)) continue;
|
||||
if (!includeSystem && dir === sep && SYSTEM_FOLDERS.has(entry.name)) continue;
|
||||
if (fold(entry.name).includes(needle)) {
|
||||
results.push(full);
|
||||
if (results.length >= SEARCH_MAX_RESULTS) return { results, truncated: true };
|
||||
}
|
||||
next.push({ dir: full, real: childReal });
|
||||
}
|
||||
}
|
||||
queue = next;
|
||||
}
|
||||
// Folders deeper than the search goes were never looked at.
|
||||
return { results, truncated: queue.length > 0 };
|
||||
}
|
||||
|
||||
/** Where a picker starts: mounted folders near the browse root, the data folder, every library's folders. */
|
||||
async getPlaces(): Promise<FolderPlaces> {
|
||||
const root = resolve(this.pathPolicy.getBrowseRoot());
|
||||
const appDataPath = await this.pathPolicy.getCanonicalAppDataPath();
|
||||
const mounts = await this.findMounts(root);
|
||||
const rows = (await this.pathRepository?.findLibraryFolders()) ?? [];
|
||||
const libraries = new Map<number, FolderPickerLibrary>();
|
||||
for (const row of rows) {
|
||||
const library = libraries.get(row.libraryId) ?? { id: row.libraryId, name: row.name, icon: row.icon, type: row.type, folders: [] };
|
||||
library.folders.push(row.path);
|
||||
libraries.set(row.libraryId, library);
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
mounts: mounts.length > 0 || root === sep ? mounts : [root],
|
||||
appDataPath: appDataPath && isInsideOrAt(appDataPath, root) ? appDataPath : null,
|
||||
libraries: [...libraries.values()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders on their own filesystem within two levels of the root. Mounts change with a container
|
||||
* restart, not between picker opens, so the answer is cached; the walk reads only the first folders
|
||||
* of each level, so a root holding thousands of author folders costs the same as `/`.
|
||||
*/
|
||||
private async findMounts(root: string): Promise<string[]> {
|
||||
if (this.mountCache && this.mountCache.root === root && this.mountCache.expires > Date.now()) return this.mountCache.mounts;
|
||||
const mounts: string[] = [];
|
||||
let stats = 0;
|
||||
const visit = async (dir: string, dev: number, depth: number) => {
|
||||
let dirents: Dirent[];
|
||||
try {
|
||||
dirents = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
const dirs = dirents.filter((entry) => isListableDirectory(entry, join(dir, entry.name)) && !(dir === sep && SYSTEM_FOLDERS.has(entry.name)));
|
||||
for (const entry of dirs.slice(0, MOUNT_SCAN_FOLDERS_PER_LEVEL)) {
|
||||
if (stats++ >= MOUNT_SCAN_MAX_STATS) return;
|
||||
const full = join(dir, entry.name);
|
||||
let childDev: number;
|
||||
try {
|
||||
childDev = (await stat(full)).dev;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (childDev !== dev) mounts.push(full);
|
||||
else if (depth < 2) await visit(full, dev, depth + 1);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await visit(root, (await stat(root)).dev, 1);
|
||||
} catch {
|
||||
// An unreadable root has no places to offer.
|
||||
}
|
||||
this.mountCache = { root, mounts, expires: Date.now() + MOUNT_CACHE_TTL_MS };
|
||||
return mounts;
|
||||
}
|
||||
|
||||
private isBlocked(resolved: string): Promise<boolean> {
|
||||
return isBlockedTarget(resolved);
|
||||
}
|
||||
|
||||
private assertSafeName(rawName: string): string {
|
||||
|
||||
@@ -414,6 +414,28 @@ describe('disc folder flattening', () => {
|
||||
expect(candidates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never folds a library folder whose own name looks like a disc folder, such as unRAID's disk1", async () => {
|
||||
await file('disk1/Dune.epub');
|
||||
await file('disk1/Emma.epub');
|
||||
await file('disk1/Saga/Saga 001.cbz');
|
||||
const library = join(root, 'disk1');
|
||||
|
||||
const { candidates } = await findBookCandidates(library);
|
||||
|
||||
expect(candidatePaths(candidates)).toEqual([join(library, 'Dune.epub'), join(library, 'Emma.epub'), join(library, 'Saga')]);
|
||||
});
|
||||
|
||||
it('still reads disc folders directly inside a disc-named library folder as loose files', async () => {
|
||||
await file('Part 1/Intro.mp3');
|
||||
await file('Part 1/CD 1/01.mp3');
|
||||
await file('Part 1/CD 1/02.mp3');
|
||||
const library = join(root, 'Part 1');
|
||||
|
||||
const { candidates } = await findBookCandidates(library);
|
||||
|
||||
expect(candidatePaths(candidates)).toEqual([join(library, 'CD 1', '01.mp3'), join(library, 'CD 1', '02.mp3'), join(library, 'Intro.mp3')]);
|
||||
});
|
||||
|
||||
it('does NOT treat names like "Discography" as disc directories', async () => {
|
||||
await file('AudioBook/Discography/01.mp3');
|
||||
await file('AudioBook/cover.jpg');
|
||||
|
||||
@@ -146,6 +146,8 @@ async function collectByDir(
|
||||
// that turn this dir into a stem-named subdir needing flattening).
|
||||
// Root is exempt: root-level files are never stem-merged into a parent (see
|
||||
// buildBookCandidates: parent === libraryFolderPath is explicitly skipped).
|
||||
// A disc-named library folder is no longer flattened, but it stays unskippable: libraries
|
||||
// scanned while it was are regrouped by their next scan, whatever its mtime.
|
||||
const hasDiscSubdirs = subdirectoryPaths.some((s) => isDiscDirectory(basename(s)));
|
||||
const canSkip = knownDirMtimes && unchangedDirs && dirMtimes && filePaths.length > 0 && !isDiscDirectory(basename(dir)) && !hasDiscSubdirs;
|
||||
|
||||
@@ -207,9 +209,11 @@ export async function findBookCandidates(
|
||||
|
||||
// Flatten disc subdirectories (e.g. "CD 1", "Disc 2") into their parent.
|
||||
// Collect disc dirs first to avoid mutating the map while iterating.
|
||||
// The library folder itself is never flattened, whatever its name: unRAID mounts its drives
|
||||
// as /mnt/disk1, and folding one would file its books under the folder above the library.
|
||||
const discDirs: string[] = [];
|
||||
for (const [dir] of byDir) {
|
||||
if (isDiscDirectory(basename(dir))) {
|
||||
if (dir !== libraryFolderPath && isDiscDirectory(basename(dir))) {
|
||||
discDirs.push(dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,38 @@ export class ScannerRepository {
|
||||
.where(eq(books.libraryFolderId, libraryFolderId));
|
||||
}
|
||||
|
||||
/** Books filed under a library folder but located at `folderPath`, with their content files. */
|
||||
async findBooksAtFolderPath(libraryFolderId: number, folderPath: string) {
|
||||
const rows = await this.db
|
||||
.select({ id: books.id, libraryId: books.libraryId, primaryFileId: books.primaryFileId })
|
||||
.from(books)
|
||||
.where(and(eq(books.libraryFolderId, libraryFolderId), eq(books.folderPath, folderPath)));
|
||||
if (rows.length === 0) return [];
|
||||
const files = await this.db
|
||||
.select({ id: bookFiles.id, bookId: bookFiles.bookId, absolutePath: bookFiles.absolutePath })
|
||||
.from(bookFiles)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
bookFiles.bookId,
|
||||
rows.map((row) => row.id),
|
||||
),
|
||||
eq(bookFiles.role, 'content'),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(bookFiles.sortOrder), asc(bookFiles.id));
|
||||
return rows.map((row) => ({ ...row, contentFiles: files.filter((file) => file.bookId === row.id) }));
|
||||
}
|
||||
|
||||
async bookExistsAtFolderPath(libraryId: number, folderPath: string): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({ id: books.id })
|
||||
.from(books)
|
||||
.where(and(eq(books.libraryId, libraryId), eq(books.folderPath, folderPath)))
|
||||
.limit(1);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async findPrimaryBookFilesByLibrary(libraryId: number) {
|
||||
return this.db
|
||||
.select({ bookId: books.id, absolutePath: bookFiles.absolutePath, format: bookFiles.format })
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
findBookCandidates,
|
||||
findLooseFileCandidates,
|
||||
buildSingleBookCandidate,
|
||||
isDiscDirectory,
|
||||
type WalkResult,
|
||||
} from './lib/walk';
|
||||
import { ScannerRepository } from './scanner.repository';
|
||||
@@ -1132,6 +1133,38 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A library folder named like a disc folder ("disk1", "CD 1") used to be flattened into the folder
|
||||
* above it, so its loose files became one book filed there. Each is now its own book: the old book
|
||||
* moves to its primary file, keeping its progress, notes and edits, and the scan that follows gives
|
||||
* the other files books of their own.
|
||||
*/
|
||||
private async repairFlattenedLibraryFolder(libraryId: number, folder: { id: number; path: string }): Promise<void> {
|
||||
if (!isDiscDirectory(basename(folder.path))) return;
|
||||
const event = 'scanner.repair_flattened_library_folder';
|
||||
try {
|
||||
const stranded = await this.scannerRepo.findBooksAtFolderPath(folder.id, dirname(folder.path));
|
||||
for (const book of stranded) {
|
||||
const inside = book.contentFiles.filter((file) => file.absolutePath.startsWith(folder.path + sep));
|
||||
const target = inside.find((file) => file.id === book.primaryFileId) ?? inside[0];
|
||||
if (!target || (await this.scannerRepo.bookExistsAtFolderPath(book.libraryId, target.absolutePath))) {
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] libraryId=${libraryId} bookId=${book.id} fileCount=${book.contentFiles.length} - no free file left to move the book to`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await this.scannerRepo.updateBookFolderPath(book.id, target.absolutePath);
|
||||
this.logger.log(
|
||||
`[${event}] [end] libraryId=${libraryId} bookId=${book.id} folder="${sanitizeLogValue(target.absolutePath)}" fileCount=${book.contentFiles.length} - book moved from above its library folder to its primary file`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] libraryId=${libraryId} libraryFolderId=${folder.id} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - flattened library folder repair failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCandidateMaps(folderPath: string, libraryId: number): Promise<ScanLookupMaps> {
|
||||
const knownBooks = await this.scannerRepo.findBooksByFolderPath(folderPath, libraryId);
|
||||
const knownFiles = await this.scannerRepo.findBookFilesByBookIds(knownBooks.map((b) => b.id));
|
||||
@@ -1438,6 +1471,7 @@ export class ScannerService implements OnApplicationBootstrap {
|
||||
this.emitFromStore(libraryId, jobId, 'running');
|
||||
|
||||
for (const folder of folders) {
|
||||
await this.repairFlattenedLibraryFolder(libraryId, folder);
|
||||
let candidates: BookCandidate[] = [];
|
||||
let skippedDirs = new Set<string>();
|
||||
let unchangedDirs = new Set<string>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"totalRoutes": 712,
|
||||
"totalRoutes": 719,
|
||||
"byPermission": {
|
||||
"(none)": 298,
|
||||
"Permission.ManageMetadataConfig": 47,
|
||||
@@ -7,7 +7,7 @@
|
||||
"Permission.LibraryEditMetadata": 54,
|
||||
"Permission.BookDockAccess": 17,
|
||||
"Permission.ManageAppSettings": 62,
|
||||
"Permission.ManageLibraries": 24,
|
||||
"Permission.ManageLibraries": 31,
|
||||
"Permission.ManageUsers": 14,
|
||||
"Permission.ManageEmail": 8,
|
||||
"Permission.KoboSync": 13,
|
||||
|
||||
@@ -443,6 +443,90 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "listFolder",
|
||||
"httpMethod": "GET",
|
||||
"path": "/path/listing",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "summarize",
|
||||
"httpMethod": "POST",
|
||||
"path": "/path/summaries",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "inspect",
|
||||
"httpMethod": "GET",
|
||||
"path": "/path/inspect",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "search",
|
||||
"httpMethod": "GET",
|
||||
"path": "/path/search",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "places",
|
||||
"httpMethod": "GET",
|
||||
"path": "/path/places",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "unclaimed",
|
||||
"httpMethod": "GET",
|
||||
"path": "/path/unclaimed",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/path/path.controller.ts",
|
||||
"className": "PathController",
|
||||
"methodName": "createFolder",
|
||||
"httpMethod": "POST",
|
||||
"path": "/path",
|
||||
"permissions": ["Permission.ManageLibraries"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/reader/cbz/cbz.controller.ts",
|
||||
"className": "CbzController",
|
||||
|
||||
@@ -269,7 +269,6 @@
|
||||
"POST /migration/sources/:id/path-mappings/validate",
|
||||
"POST /migration/sources/:id/validate",
|
||||
"POST /migration/sources/test",
|
||||
"POST /path",
|
||||
"POST /readwise/validate-token",
|
||||
"POST /server-fonts/upload",
|
||||
"POST /smart-scopes/:id/books/jump-buckets",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { bookFiles, books } from '../src/db/schema';
|
||||
|
||||
import type { FixtureEntry } from './e2e/scanner/scanner-fixture-builder';
|
||||
import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder';
|
||||
import {
|
||||
@@ -460,6 +464,71 @@ describe('Scanner structures (e2e)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('a library folder named like a disc folder', () => {
|
||||
// unRAID mounts its drives as /mnt/disk1; such a folder was once flattened into the folder above it.
|
||||
const entries = [file('disk1/Dune.epub'), file('disk1/Emma.epub'), file('disk1/Saga/Saga 001.cbz')];
|
||||
const expected = (library: string) =>
|
||||
[join(library, 'Dune.epub'), join(library, 'Emma.epub'), join(library, 'Saga')].map((folderPath) => ({ folderPath, status: 'present' }));
|
||||
|
||||
it(
|
||||
'reads its loose files as books of their own, inside the library',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-');
|
||||
try {
|
||||
const library = join(fixture.rootPath, 'disk1');
|
||||
const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' });
|
||||
|
||||
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
|
||||
|
||||
const state = await loadLibraryBookState(context.db, libraryId);
|
||||
expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual(
|
||||
expected(library),
|
||||
);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'regroups a library scanned while it was flattened, keeping the old book on its primary file',
|
||||
async () => {
|
||||
if (!context) throw new Error('E2E context not initialized');
|
||||
const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-repair-');
|
||||
try {
|
||||
const library = join(fixture.rootPath, 'disk1');
|
||||
const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' });
|
||||
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
|
||||
|
||||
// Rebuild what the flattening scan stored: one book above the library holding every loose file.
|
||||
const rows = await context.db.select({ id: books.id, folderPath: books.folderPath }).from(books).where(eq(books.libraryId, libraryId));
|
||||
const dune = rows.find((row) => row.folderPath === join(library, 'Dune.epub'))!;
|
||||
const emma = rows.find((row) => row.folderPath === join(library, 'Emma.epub'))!;
|
||||
await context.db.update(bookFiles).set({ bookId: dune.id }).where(eq(bookFiles.bookId, emma.id));
|
||||
await context.db.delete(books).where(eq(books.id, emma.id));
|
||||
await context.db.update(books).set({ folderPath: fixture.rootPath }).where(eq(books.id, dune.id));
|
||||
|
||||
await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId));
|
||||
|
||||
const state = await loadLibraryBookState(context.db, libraryId);
|
||||
expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual(
|
||||
expected(library),
|
||||
);
|
||||
const [kept] = await context.db.select({ folderPath: books.folderPath }).from(books).where(eq(books.id, dune.id));
|
||||
expect(kept?.folderPath).toBe(join(library, 'Dune.epub'));
|
||||
expect(state.find((book) => book.folderPath === join(library, 'Emma.epub'))?.filePaths).toEqual([join(library, 'Emma.epub')]);
|
||||
await assertNoIntegrityViolations(context.db);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('organizationMode=book_per_file', () => {
|
||||
for (const scenario of bookPerFileScenarios) {
|
||||
it(
|
||||
|
||||
Reference in New Issue
Block a user