Files
bookorbit/client/src/features/settings/lib/settings-nav.ts
T
neon 7d823039b0 feat(metadata): rebuild book auto-fetch as library scopes with counts
Show the global rule and each library's own rule on Field Rules-style tabs, with live counts per
condition, the score histogram and the books a run would take. Store only what a library changes,
run every library with its own rule, and record each library's run. Also fix the DjVu queue test
that assumed which request waits, and inventory the DjVu reader routes.
2026-10-16 19:05:32 +00:00

642 lines
23 KiB
TypeScript

import type { Component } from 'vue'
import {
Activity,
AudioLines,
Bell,
BookMarked,
BookOpen,
Database,
DownloadCloud,
FileText,
Filter,
Folder,
Globe,
Headphones,
Highlighter,
History,
Image,
KeyRound,
LayoutGrid,
Library,
LibraryBig,
Link2,
ListChecks,
Lock,
Mail,
Palette,
PanelBottom,
Podcast,
Rss,
ScrollText,
Server,
Shield,
ShieldCheck,
ShieldUser,
SlidersHorizontal,
Smartphone,
Sparkles,
Speech,
Star,
Tablet,
Tag,
Type,
User,
Users,
Wrench,
Zap,
} from '@lucide/vue'
import { APP_FEATURES, Permission } from '@bookorbit/types'
export interface SettingsNavContext {
isSuperuser: boolean
permissions: readonly string[]
isDemoRestricted: boolean
showBookRequests?: boolean
}
/** Live signals the rail renders beside a row. Resolved by the nav, not stored in this file. */
export type SettingsNavStatus = 'libraryScan'
export interface SettingsNavItem {
id: string
routeName: string
labelKey: string
/** The page heading, when the short nav label would not say what the page is on its own. */
titleKey?: string
descriptionKey?: string
icon: Component
/** Extra English search terms so the rail search finds a page by concept, not just by title. */
keywords?: string
status?: SettingsNavStatus
isVisible?: (context: SettingsNavContext) => boolean
children?: SettingsNavItem[]
}
export interface SettingsNavGroup {
id: string
labelKey: string
icon: Component
items: SettingsNavItem[]
}
function anyPermission(...permissions: string[]): (context: SettingsNavContext) => boolean {
return (context) => context.isSuperuser || permissions.some((permission) => context.permissions.includes(permission))
}
function superuserOnly(context: SettingsNavContext): boolean {
return context.isSuperuser
}
export const SETTINGS_NAV: readonly SettingsNavGroup[] = [
{
id: 'you',
labelKey: 'settings.nav.groups.you',
icon: User,
items: [
{
id: 'profile',
routeName: 'settings-account-profile',
labelKey: 'settings.account.tabs.profile',
descriptionKey: 'settings.nav.descriptions.profile',
icon: User,
keywords: 'password email avatar username sessions sign out security',
},
{
id: 'appearance',
routeName: 'settings-appearance-theme',
labelKey: 'settings.common.nav.display',
icon: Palette,
keywords: 'theme dark light accent colour color density radius',
children: [
{
id: 'appearance-theme',
routeName: 'settings-appearance-theme',
labelKey: 'settings.appearance.tabs.theme',
descriptionKey: 'settings.nav.descriptions.appearanceTheme',
icon: Palette,
keywords: 'theme dark light accent colour color background radius',
},
{
id: 'appearance-book-covers',
routeName: 'settings-appearance-book-covers',
labelKey: 'settings.appearance.tabs.book-covers',
descriptionKey: 'settings.nav.descriptions.appearanceBookCovers',
icon: Image,
keywords: 'cover shadow spine artwork placeholder thumbnail',
},
{
id: 'appearance-icons',
routeName: 'settings-appearance-icons',
labelKey: 'settings.appearance.tabs.icons',
descriptionKey: 'settings.nav.descriptions.appearanceIcons',
icon: Sparkles,
keywords: 'icon glyph outline solid',
},
{
id: 'appearance-layout',
routeName: 'settings-appearance-layout',
labelKey: 'settings.appearance.tabs.layout',
descriptionKey: 'settings.nav.descriptions.appearanceLayout',
icon: LayoutGrid,
keywords: 'grid list table density columns spacing',
},
{
id: 'appearance-behavior',
routeName: 'settings-appearance-behavior',
labelKey: 'settings.appearance.tabs.behavior',
descriptionKey: 'settings.nav.descriptions.appearanceBehavior',
icon: SlidersHorizontal,
keywords: 'behaviour animation sorting default view',
},
{
id: 'appearance-language',
routeName: 'settings-appearance-language',
labelKey: 'settings.appearance.tabs.language',
descriptionKey: 'settings.nav.descriptions.appearanceLanguage',
icon: Globe,
keywords: 'language locale translation region date format',
},
],
},
{
id: 'reader',
routeName: 'settings-reader-ebook',
labelKey: 'settings.common.nav.reader',
icon: BookOpen,
keywords: 'reading epub pdf comics audiobook fonts margins',
children: [
{
id: 'reader-ebook',
routeName: 'settings-reader-ebook',
labelKey: 'settings.reader.tabs.ebook',
descriptionKey: 'settings.nav.descriptions.readerEbook',
icon: BookOpen,
keywords: 'epub font size line height margins paged scroll',
},
{
id: 'reader-pdf',
routeName: 'settings-reader-pdf',
labelKey: 'settings.reader.tabs.pdf',
descriptionKey: 'settings.nav.descriptions.readerPdf',
icon: FileText,
keywords: 'pdf zoom text layer render page fit',
},
{
id: 'reader-comics',
routeName: 'settings-reader-comics',
labelKey: 'settings.reader.tabs.comics',
descriptionKey: 'settings.nav.descriptions.readerComics',
icon: Image,
keywords: 'cbz cbr manga webtoon reading direction spread',
},
{
id: 'reader-audio',
routeName: 'settings-reader-audio',
labelKey: 'settings.reader.tabs.audio',
descriptionKey: 'settings.nav.descriptions.readerAudio',
icon: Headphones,
keywords: 'audiobook playback speed skip sleep timer',
},
{
id: 'reader-fonts',
routeName: 'settings-reader-fonts',
labelKey: 'settings.reader.tabs.fonts',
descriptionKey: 'settings.nav.descriptions.readerFonts',
icon: Type,
keywords: 'font typeface family upload reader',
},
{
id: 'reader-general',
routeName: 'settings-reader-general',
labelKey: 'settings.reader.tabs.general',
descriptionKey: 'settings.nav.descriptions.readerGeneral',
icon: SlidersHorizontal,
keywords: 'resume progress sync screen awake defaults',
},
{
id: 'tts',
routeName: 'settings-tts',
labelKey: 'settings.common.nav.tts',
descriptionKey: 'settings.nav.descriptions.readerTts',
icon: AudioLines,
keywords: 'tts text to speech read aloud voice narration speed',
},
],
},
...(APP_FEATURES.podcasts
? [
{
id: 'podcasts',
routeName: 'settings-podcasts',
labelKey: 'settings.common.nav.podcasts',
icon: Podcast,
keywords: 'podcast show episode feed download playback queue',
},
]
: []),
{
id: 'notifications',
routeName: 'settings-notifications',
labelKey: 'settings.account.tabs.notifications',
descriptionKey: 'settings.nav.descriptions.notifications',
icon: Bell,
keywords: 'notify email digest alerts events',
isVisible: (context) => !context.isDemoRestricted && (context.isSuperuser || context.permissions.includes(Permission.NotificationAccess)),
},
{
id: 'privacy',
routeName: 'settings-account-privacy',
labelKey: 'settings.account.tabs.privacy',
descriptionKey: 'settings.nav.descriptions.privacy',
icon: Lock,
keywords: 'privacy sharing public profile activity insights',
},
{
id: 'restrictions',
routeName: 'settings-account-restrictions',
labelKey: 'settings.account.tabs.restrictions',
descriptionKey: 'settings.nav.descriptions.restrictions',
icon: Shield,
keywords: 'restrict age rating mature hide content filter',
},
],
},
{
id: 'library',
labelKey: 'settings.nav.groups.library',
icon: Library,
items: [
{
id: 'libraries',
routeName: 'settings-libraries',
labelKey: 'settings.common.nav.libraries',
descriptionKey: 'settings.nav.descriptions.libraries',
icon: Folder,
keywords: 'library folder path scan watch ignore mount storage',
status: 'libraryScan',
isVisible: anyPermission('manage_libraries'),
},
{
id: 'metadata',
routeName: 'settings-metadata-providers',
labelKey: 'settings.common.nav.metadata',
icon: Database,
keywords: 'metadata provider field rule score author genre custom field',
children: [
{
id: 'metadata-providers',
routeName: 'settings-metadata-providers',
labelKey: 'settings.metadata.tabs.providers',
descriptionKey: 'settings.metadata.tabSubtitles.providers',
icon: Database,
keywords: 'provider source google books open library comicvine priority credentials',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-field-rules',
routeName: 'settings-metadata-field-rules',
labelKey: 'settings.metadata.tabs.field-rules',
descriptionKey: 'settings.metadata.tabSubtitles.field-rules',
icon: SlidersHorizontal,
keywords: 'field rule merge strategy overwrite protect title author series',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-custom-fields',
routeName: 'settings-metadata-custom-fields',
labelKey: 'settings.metadata.tabs.custom-fields',
descriptionKey: 'settings.metadata.tabSubtitles.custom-fields',
icon: Tag,
keywords: 'custom field column attribute picker',
isVisible: anyPermission('manage_libraries'),
},
{
id: 'metadata-score',
routeName: 'settings-metadata-score',
labelKey: 'settings.metadata.tabs.score',
descriptionKey: 'settings.metadata.tabSubtitles.score',
icon: Star,
keywords: 'score weight match confidence similarity threshold',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-auto-fetch',
routeName: 'settings-metadata-auto-fetch',
labelKey: 'settings.metadata.tabs.auto-fetch',
titleKey: 'titles.metadata.auto-fetch',
descriptionKey: 'settings.metadata.tabSubtitles.auto-fetch',
icon: Zap,
keywords: 'auto fetch schedule missing fields import books conditions',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-authors',
routeName: 'settings-metadata-authors',
labelKey: 'settings.metadata.tabs.authors',
descriptionKey: 'settings.metadata.tabSubtitles.authors',
icon: Sparkles,
keywords: 'author biography photo enrichment',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-search-cache',
routeName: 'settings-metadata-search-cache',
labelKey: 'settings.metadata.tabs.search-cache',
descriptionKey: 'settings.metadata.tabSubtitles.search-cache',
icon: History,
keywords: 'cache saved search results provider ttl expiry offline fallback storage clear',
isVisible: anyPermission(Permission.ManageMetadataConfig),
},
{
id: 'metadata-genre-blocklist',
routeName: 'settings-metadata-genre-blocklist',
labelKey: 'settings.metadata.tabs.genre-blocklist',
descriptionKey: 'settings.metadata.tabSubtitles.genre-blocklist',
icon: Filter,
keywords: 'genre tag blocklist exclude ignore',
isVisible: anyPermission('manage_metadata_config'),
},
{
id: 'metadata-reminders',
routeName: 'settings-metadata-reminders',
labelKey: 'metadataReminders.title',
descriptionKey: 'metadataReminders.navDescription',
icon: ListChecks,
keywords: 'missing empty field reminder isbn tags personal preferences completeness',
},
],
},
{
id: 'file-naming',
routeName: 'settings-file-naming',
labelKey: 'settings.system.tabs.file-naming',
descriptionKey: 'settings.nav.descriptions.fileNaming',
icon: FileText,
keywords: 'file naming pattern template rename folder structure',
isVisible: anyPermission(Permission.ManageAppSettings),
},
{
id: 'maintenance',
routeName: 'settings-maintenance',
labelKey: 'settings.system.tabs.maintenance',
descriptionKey: 'settings.nav.descriptions.maintenance',
icon: Wrench,
keywords: 'scan maintenance duplicates orphans cleanup thumbnails cache',
isVisible: anyPermission(Permission.ManageAppSettings),
},
],
},
{
id: 'devices',
labelKey: 'settings.nav.groups.devices',
icon: Smartphone,
items: [
{
id: 'kobo',
routeName: 'settings-kobo',
labelKey: 'settings.common.nav.kobo',
descriptionKey: 'settings.nav.descriptions.kobo',
icon: Tablet,
keywords: 'kobo sync device endpoint store proxy shelf shelves collections',
isVisible: anyPermission(Permission.KoboSync),
},
{
id: 'koreader',
routeName: 'settings-koreader',
labelKey: 'settings.common.nav.koreader',
descriptionKey: 'settings.nav.descriptions.koreader',
icon: Smartphone,
keywords: 'koreader progress sync credentials position',
isVisible: anyPermission(Permission.KoreaderSync),
},
{
id: 'opds',
routeName: 'settings-opds',
labelKey: 'settings.common.nav.opds',
descriptionKey: 'settings.nav.descriptions.opds',
icon: Rss,
keywords: 'opds feed catalog third party reader',
isVisible: anyPermission('opds_access'),
},
{
id: 'email',
routeName: 'settings-email',
labelKey: 'settings.common.nav.email',
descriptionKey: 'settings.nav.descriptions.email',
icon: Mail,
keywords: 'email smtp kindle send to device template sender',
isVisible: anyPermission('email_send', 'manage_email'),
},
],
},
{
id: 'accounts',
labelKey: 'settings.nav.groups.accounts',
icon: Link2,
items: [
{
id: 'hardcover',
routeName: 'settings-hardcover',
labelKey: 'settings.integrations.tabs.hardcover',
descriptionKey: 'settings.nav.descriptions.hardcover',
icon: BookMarked,
keywords: 'hardcover integration token reading status reviews',
isVisible: anyPermission(Permission.HardcoverSync),
},
{
id: 'readwise',
routeName: 'settings-readwise',
labelKey: 'settings.integrations.tabs.readwise',
descriptionKey: 'settings.integrations.providerSubtitles.readwise',
icon: Highlighter,
keywords: 'readwise highlights notes export',
isVisible: anyPermission(Permission.ReadwiseSync),
},
{
id: 'storygraph',
routeName: 'settings-storygraph',
labelKey: 'settings.integrations.tabs.storygraph',
descriptionKey: 'settings.integrations.providerSubtitles.storygraph',
icon: LibraryBig,
keywords: 'storygraph shelves progress mirror',
isVisible: anyPermission(Permission.StorygraphSync),
},
],
},
{
id: 'server',
labelKey: 'settings.nav.groups.server',
icon: Server,
items: [
{
id: 'users-access',
routeName: 'settings-admin-users',
labelKey: 'settings.common.nav.usersAccess',
icon: ShieldUser,
keywords: 'user role permission invite admin superuser sso login access',
children: [
{
id: 'users',
routeName: 'settings-admin-users',
labelKey: 'settings.admin.tabs.users',
descriptionKey: 'settings.nav.descriptions.users',
icon: Users,
keywords: 'user role permission invite admin superuser account',
isVisible: anyPermission('manage_users'),
},
{
id: 'account-activity',
routeName: 'settings-admin-account-activity',
labelKey: 'settings.admin.tabs.account-activity',
descriptionKey: 'settings.nav.descriptions.accountActivity',
icon: Activity,
keywords: 'activity session login usage reading insights',
isVisible: anyPermission('view_user_activity'),
},
{
id: 'magic-links',
routeName: 'settings-admin-magic-links',
labelKey: 'settings.admin.tabs.magic-links',
descriptionKey: 'settings.nav.descriptions.magicLinks',
icon: KeyRound,
keywords: 'magic link share passwordless expiry token invite',
isVisible: superuserOnly,
},
{
id: 'oidc',
routeName: 'settings-admin-oidc',
labelKey: 'settings.admin.tabs.oidc',
descriptionKey: 'settings.nav.descriptions.oidc',
icon: ShieldCheck,
keywords: 'oidc sso single sign on auth provider claims',
isVisible: anyPermission('manage_app_settings'),
},
],
},
{
id: 'requests',
routeName: 'settings-admin-requests',
labelKey: 'settings.system.tabs.requests',
descriptionKey: 'settings.nav.descriptions.requests',
icon: DownloadCloud,
keywords: 'request download client qbittorrent torrent magnet path mapping hardlink',
isVisible: (context) => context.showBookRequests !== false && anyPermission(Permission.ManageAppSettings)(context),
},
{
id: 'admin-tts',
routeName: 'settings-admin-tts',
labelKey: 'settings.admin.tabs.tts',
descriptionKey: 'settings.nav.descriptions.adminTts',
icon: Speech,
keywords: 'tts text to speech provider voice engine openai kokoro',
isVisible: anyPermission('manage_app_settings'),
},
{
id: 'book-dock',
routeName: 'settings-admin-book-dock',
labelKey: 'settings.system.tabs.book-dock',
descriptionKey: 'settings.nav.descriptions.bookDock',
icon: PanelBottom,
keywords: 'dock quick action book page toolbar',
isVisible: anyPermission(Permission.ManageBookDock),
},
{
id: 'server-fonts',
routeName: 'settings-admin-server-fonts',
labelKey: 'settings.admin.tabs.server-fonts',
descriptionKey: 'settings.nav.descriptions.serverFonts',
icon: Type,
keywords: 'server font upload typeface available readers',
isVisible: anyPermission('manage_app_settings'),
},
{
id: 'audit-log',
routeName: 'settings-admin-audit-log',
labelKey: 'settings.system.tabs.audit-log',
descriptionKey: 'settings.nav.descriptions.auditLog',
icon: ScrollText,
keywords: 'audit log history event change security',
isVisible: superuserOnly,
},
],
},
]
function isItemVisible(item: SettingsNavItem, context: SettingsNavContext): boolean {
return item.isVisible ? item.isVisible(context) : true
}
/** A grouping row is only a destination through its children, so it dies with the last of them. */
function visibleItem(item: SettingsNavItem, context: SettingsNavContext): SettingsNavItem | null {
if (!isItemVisible(item, context)) return null
if (!item.children) return item
const children = item.children.filter((child) => isItemVisible(child, context))
if (children.length === 0) return null
return { ...item, children }
}
export function visibleSettingsNav(context: SettingsNavContext): SettingsNavGroup[] {
return SETTINGS_NAV.map((group) => ({
...group,
items: group.items.map((item) => visibleItem(item, context)).filter((item): item is SettingsNavItem => item !== null),
})).filter((group) => group.items.length > 0)
}
export interface SettingsNavSearchHit {
item: SettingsNavItem
group: SettingsNavGroup
rank: number
}
/**
* Matches every term against a page's label, description, keywords and group, so "sync" finds
* Kobo through its keywords. Leaf destinations only: a parent with children is never a target.
*/
export function searchSettingsNav(groups: readonly SettingsNavGroup[], query: string, translate: (key: string) => string): SettingsNavSearchHit[] {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return []
const leaves = groups.flatMap((group) =>
group.items.flatMap((item) => (item.children?.length ? item.children.map((child) => ({ item: child, group })) : [{ item, group }])),
)
return leaves
.map(({ item, group }) => {
const description = item.descriptionKey ? translate(item.descriptionKey) : ''
const haystack = `${translate(item.labelKey)} ${description} ${item.keywords ?? ''} ${translate(group.labelKey)}`.toLowerCase()
if (!terms.every((term) => haystack.includes(term))) return null
const label = translate(item.labelKey).toLowerCase()
const lead = terms[0] ?? ''
const rank = label.startsWith(lead) ? 0 : label.includes(lead) ? 1 : 2
return { item, group, rank }
})
.filter((hit): hit is SettingsNavSearchHit => hit !== null)
.sort((a, b) => a.rank - b.rank)
}
export interface SettingsNavMatch {
group: SettingsNavGroup
item: SettingsNavItem
parent?: SettingsNavItem
}
export function findSettingsNavItem(routeName: string | null | undefined): SettingsNavMatch | null {
if (!routeName) return null
for (const group of SETTINGS_NAV) {
for (const item of group.items) {
if (item.children) {
const child = item.children.find((candidate) => candidate.routeName === routeName)
if (child) return { group, item: child, parent: item }
}
if (item.routeName === routeName && !item.children) return { group, item }
}
}
return null
}
/** First destination the user is actually allowed to open, used when a landing route has no target. */
export function firstVisibleSettingsRoute(context: SettingsNavContext): string {
const groups = visibleSettingsNav(context)
const item = groups[0]?.items[0]
if (!item) return 'settings-account-profile'
return item.children?.[0]?.routeName ?? item.routeName
}