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:
parent
7943ae5d1f
commit
e38dbf8a65
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue