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.",