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:
neon
2026-10-24 04:04:00 +00:00
parent d29b06c6e8
commit faee78370d
58 changed files with 6389 additions and 642 deletions
@@ -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>
@@ -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([])
})
})
@@ -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)
})
})
@@ -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')
})
})
@@ -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,
}
}
+188
View File
@@ -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.",
+92
View File
@@ -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);
});
}
+259
View File
@@ -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);
});
});
+269
View File
@@ -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 });
});
});
+59 -5
View File
@@ -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;
}
}
+4 -2
View File
@@ -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]);
});
});
+3 -1
View File
@@ -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 });
});
});
});
+2 -1
View File
@@ -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);
});
+204 -8
View File
@@ -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');
+5 -1
View File
@@ -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",
+69
View File
@@ -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(