From e38dbf8a650b6ad6ca9ba2ea5e598f46401c5c4d Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Tue, 2 Jun 2026 15:46:30 -0700 Subject: [PATCH] feat(zim): add "Rescan Library" button for sideloaded ZIM files Adds a user-facing trigger to rebuild the Kiwix library index from the ZIM files currently on disk. Covers the sideload case: a user copies a .zim onto the box (USB, SSH, network share) outside NOMAD's download flow, and Kiwix has no way to discover it without regenerating the library index. Reuses the existing native KiwixLibraryService.rebuildFromDisk() (which also extracts embedded favicons natively), so no kiwix-tools container and no icon-patch step are needed. In library mode (--monitorLibrary) kiwix-serve hot-reloads the XML automatically; only legacy glob-mode containers are restarted. - KiwixLibraryService.rebuildFromDisk now returns the book count; adds getBookCount() for the before/after delta - ZimService.rescanLibrary() orchestrates rebuild + legacy restart - POST /api/zim/rescan-library + ZimController.rescanLibrary - "Rescan Library" button on the Content Manager page (shown when Kiwix is installed) with a success toast reporting books found Co-Authored-By: Claude Opus 4.8 (1M context) --- admin/app/controllers/zim_controller.ts | 8 ++++++ admin/app/services/kiwix_library_service.ts | 17 ++++++++++- admin/app/services/zim_service.ts | 30 ++++++++++++++++++++ admin/inertia/lib/api.ts | 12 ++++++++ admin/inertia/pages/settings/zim/index.tsx | 31 ++++++++++++++++++++- admin/start/routes.ts | 2 ++ 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index 006e59b..0087e76 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -56,6 +56,14 @@ export default class ZimController { } } + async rescanLibrary({}: HttpContext) { + const result = await this.zimService.rescanLibrary() + return { + message: 'Kiwix library rescanned', + ...result, + } + } + async delete({ request, response }: HttpContext) { const payload = await request.validateUsing(filenameParamValidator) diff --git a/admin/app/services/kiwix_library_service.ts b/admin/app/services/kiwix_library_service.ts index c7aeabd..183afe6 100644 --- a/admin/app/services/kiwix_library_service.ts +++ b/admin/app/services/kiwix_library_service.ts @@ -187,7 +187,21 @@ export class KiwixLibraryService { .filter((b) => b.id && b.path) } - async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise { + /** + * Returns the number of books currently listed in the library XML, or 0 if the + * file doesn't exist yet. Used to report a before/after delta on a manual rescan. + */ + async getBookCount(): Promise { + try { + const content = await readFile(this.getLibraryFilePath(), 'utf-8') + return this._parseExistingBooks(content).length + } catch (err: any) { + if (err.code === 'ENOENT') return 0 + throw err + } + } + + async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise { const dirPath = join(process.cwd(), ZIM_STORAGE_PATH) await ensureDirectoryExists(dirPath) @@ -221,6 +235,7 @@ export class KiwixLibraryService { const xml = this._buildXml(books) await this._atomicWrite(xml) logger.info(`[KiwixLibraryService] Rebuilt library XML with ${books.length} book(s).`) + return books.length } async addBook(filename: string): Promise { diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index 4c8d430..aeaa8ff 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -390,6 +390,36 @@ export class ZimService { } } + /** + * Rebuilds the kiwix library XML from whatever ZIM files are currently on disk. + * + * This is the manual counterpart to the automatic rebuilds that run after a + * download or delete. It exists for the sideload case: a user copies a .zim file + * onto the box (USB, SSH, network share) outside the download flow, and kiwix has + * no way to discover it without regenerating the library index. + * + * In library mode (--monitorLibrary) kiwix-serve hot-reloads the XML on its own, so + * no restart is needed. Only legacy glob-mode containers are restarted to pick up + * the change. Returns the book count before and after plus the number added. + */ + async rescanLibrary(): Promise<{ before: number; after: number; added: number }> { + const kiwixLibraryService = new KiwixLibraryService() + const before = await kiwixLibraryService.getBookCount() + const after = await kiwixLibraryService.rebuildFromDisk() + + const isLegacy = await this.dockerService.isKiwixOnLegacyConfig() + if (isLegacy) { + logger.info('[ZimService] Kiwix in legacy mode — restarting container after rescan.') + await this.dockerService + .affectContainer(SERVICE_NAMES.KIWIX, 'restart') + .catch((error) => { + logger.error('[ZimService] Failed to restart KIWIX container after rescan:', error) + }) + } + + return { before, after, added: Math.max(0, after - before) } + } + async delete(file: string): Promise { let fileName = file if (!fileName.endsWith('.zim')) { diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index b6372e9..83950a9 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -762,6 +762,18 @@ class API { })() } + async rescanZimLibrary() { + return catchInternal(async () => { + const response = await this.client.post<{ + message: string + before: number + after: number + added: number + }>('/zim/rescan-library') + return response.data + })() + } + async listDownloadJobs(filetype?: string): Promise { return catchInternal(async () => { const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs' diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index 68ae2b1..2c727dc 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -9,6 +9,7 @@ import { useModals } from '~/context/ModalContext' import StyledModal from '~/components/StyledModal' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' +import { useNotifications } from '~/context/NotificationContext' import { ZimFileWithMetadata } from '../../../../types/zim' import { SERVICE_NAMES } from '../../../../constants/service_names' import { formatBytes } from '~/lib/util' @@ -20,6 +21,7 @@ type SortDirection = 'asc' | 'desc' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() + const { addNotification } = useNotifications() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) const [sortKey, setSortKey] = useState('size') const [sortDirection, setSortDirection] = useState('desc') @@ -105,18 +107,45 @@ export default function ZimPage() { }, }) + const rescanMutation = useMutation({ + mutationFn: async () => api.rescanZimLibrary(), + onSuccess: (result) => { + // catchInternal returns undefined on error (and shows its own error toast) + if (!result) return + queryClient.invalidateQueries({ queryKey: ['zim-files'] }) + addNotification({ + type: 'success', + message: + result.added > 0 + ? `Found ${result.added} new ${result.added === 1 ? 'book' : 'books'}. Library now has ${result.after}.` + : `Library is up to date (${result.after} ${result.after === 1 ? 'book' : 'books'}).`, + }) + }, + }) + return (
-
+

Content Manager

Manage your stored content files.

+ {isInstalled && ( + rescanMutation.mutate()} + > + Rescan Library + + )}
{!isInstalled && (