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) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-06-02 15:46:30 -07:00 committed by jakeaturner
parent 7943ae5d1f
commit e38dbf8a65
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
6 changed files with 98 additions and 2 deletions

View File

@ -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)

View File

@ -187,7 +187,21 @@ export class KiwixLibraryService {
.filter((b) => b.id && b.path)
}
async rebuildFromDisk(opts?: { excludeFilenames?: string[] }): Promise<void> {
/**
* 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<number> {
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<number> {
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<void> {

View File

@ -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<void> {
let fileName = file
if (!fileName.endsWith('.zim')) {

View File

@ -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<DownloadJobWithProgress[] | undefined> {
return catchInternal(async () => {
const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs'

View File

@ -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<SortKey>('size')
const [sortDirection, setSortDirection] = useState<SortDirection>('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 (
<SettingsLayout>
<Head title="Content Manager | Project N.O.M.A.D." />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Manager</h1>
<p className="text-text-muted">
Manage your stored content files.
</p>
</div>
{isInstalled && (
<StyledButton
variant="secondary"
icon={'IconRefresh'}
loading={rescanMutation.isPending}
title="Rebuild the Kiwix library index from the files on disk. Use this after manually adding ZIM files outside of NOMAD."
onClick={() => rescanMutation.mutate()}
>
Rescan Library
</StyledButton>
)}
</div>
{!isInstalled && (
<Alert

View File

@ -196,6 +196,8 @@ router
router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary'])
router.get('/browse-library', [ZimController, 'browseLibrary'])
router.post('/rescan-library', [ZimController, 'rescanLibrary'])
router.delete('/:filename', [ZimController, 'delete'])
})
.prefix('/api/zim')