From 2c4fc59428614ce764eae411662705d23c2482de Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Mon, 2 Feb 2026 15:43:08 -0800 Subject: [PATCH] feat(ContentManager): Display friendly names instead of filenames Content Manager now shows Title and Summary columns from Kiwix metadata instead of just raw filenames. Metadata is captured when files are downloaded from Content Explorer and stored in a new zim_file_metadata table. Existing files without metadata gracefully fall back to showing the filename. Changes: - Add zim_file_metadata table and model for storing title, summary, author - Update download flow to capture and store metadata from Kiwix library - Update Content Manager UI to display Title and Summary columns - Clean up metadata when ZIM files are deleted Co-Authored-By: Claude Opus 4.5 --- admin/app/controllers/zim_controller.ts | 6 +-- admin/app/models/zim_file_metadata.ts | 30 +++++++++++++ admin/app/services/zim_service.ts | 42 ++++++++++++++++++- admin/app/validators/common.ts | 19 +++++++++ ...00000001_create_zim_file_metadata_table.ts | 22 ++++++++++ admin/inertia/lib/api.ts | 7 +++- admin/inertia/pages/settings/zim/index.tsx | 29 ++++++++++--- .../pages/settings/zim/remote-explorer.tsx | 7 +++- admin/types/zim.ts | 9 +++- 9 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 admin/app/models/zim_file_metadata.ts create mode 100644 admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index df58a1d..d1b861b 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -2,7 +2,7 @@ import { ZimService } from '#services/zim_service' import { downloadCollectionValidator, filenameParamValidator, - remoteDownloadValidator, + remoteDownloadWithMetadataValidator, saveInstalledTierValidator, selectWikipediaValidator, } from '#validators/common' @@ -25,8 +25,8 @@ export default class ZimController { } async downloadRemote({ request }: HttpContext) { - const payload = await request.validateUsing(remoteDownloadValidator) - const { filename, jobId } = await this.zimService.downloadRemote(payload.url) + const payload = await request.validateUsing(remoteDownloadWithMetadataValidator) + const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata) return { message: 'Download started successfully', diff --git a/admin/app/models/zim_file_metadata.ts b/admin/app/models/zim_file_metadata.ts new file mode 100644 index 0000000..bc7107d --- /dev/null +++ b/admin/app/models/zim_file_metadata.ts @@ -0,0 +1,30 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' + +export default class ZimFileMetadata extends BaseModel { + static namingStrategy = new SnakeCaseNamingStrategy() + + @column({ isPrimary: true }) + declare id: number + + @column() + declare filename: string + + @column() + declare title: string + + @column() + declare summary: string | null + + @column() + declare author: string | null + + @column() + declare size_bytes: number | null + + @column.dateTime({ autoCreate: true }) + declare created_at: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updated_at: DateTime +} diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index a00a389..870cb95 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -24,6 +24,7 @@ import CuratedCollection from '#models/curated_collection' import CuratedCollectionResource from '#models/curated_collection_resource' import InstalledTier from '#models/installed_tier' import WikipediaSelection from '#models/wikipedia_selection' +import ZimFileMetadata from '#models/zim_file_metadata' import { RunDownloadJob } from '#jobs/run_download_job' import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js' import { SERVICE_NAMES } from '../../constants/service_names.js' @@ -52,8 +53,24 @@ export class ZimService implements IZimService { const all = await listDirectoryContents(dirPath) const files = all.filter((item) => item.name.endsWith('.zim')) + // Fetch metadata for all files + const metadataRecords = await ZimFileMetadata.all() + const metadataMap = new Map(metadataRecords.map((m) => [m.filename, m])) + + // Enrich files with metadata + const enrichedFiles = files.map((file) => { + const metadata = metadataMap.get(file.name) + return { + ...file, + title: metadata?.title || null, + summary: metadata?.summary || null, + author: metadata?.author || null, + size_bytes: metadata?.size_bytes || null, + } + }) + return { - files, + files: enrichedFiles, } } @@ -148,7 +165,10 @@ export class ZimService implements IZimService { } } - async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> { + async downloadRemote( + url: string, + metadata?: { title: string; summary?: string; author?: string; size_bytes?: number } + ): Promise<{ filename: string; jobId?: string }> { const parsed = new URL(url) if (!parsed.pathname.endsWith('.zim')) { throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`) @@ -167,6 +187,20 @@ export class ZimService implements IZimService { const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename) + // Store metadata if provided + if (metadata) { + await ZimFileMetadata.updateOrCreate( + { filename }, + { + title: metadata.title, + summary: metadata.summary || null, + author: metadata.author || null, + size_bytes: metadata.size_bytes || null, + } + ) + logger.info(`[ZimService] Stored metadata for ZIM file: ${filename}`) + } + // Dispatch a background download job const result = await RunDownloadJob.dispatch({ url, @@ -347,6 +381,10 @@ export class ZimService implements IZimService { } await deleteFileIfExists(fullPath) + + // Clean up metadata + await ZimFileMetadata.query().where('filename', fileName).delete() + logger.info(`[ZimService] Deleted metadata for ZIM file: ${fileName}`) } // Wikipedia selector methods diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index 49436dc..dc5dbc1 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -11,6 +11,25 @@ export const remoteDownloadValidator = vine.compile( }) ) +export const remoteDownloadWithMetadataValidator = vine.compile( + vine.object({ + url: vine + .string() + .url({ + require_tld: false, // Allow local URLs + }) + .trim(), + metadata: vine + .object({ + title: vine.string().trim().minLength(1), + summary: vine.string().trim().optional(), + author: vine.string().trim().optional(), + size_bytes: vine.number().optional(), + }) + .optional(), + }) +) + export const remoteDownloadValidatorOptional = vine.compile( vine.object({ url: vine diff --git a/admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts b/admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts new file mode 100644 index 0000000..02bbe53 --- /dev/null +++ b/admin/database/migrations/1769700000001_create_zim_file_metadata_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'zim_file_metadata' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id').primary() + table.string('filename').notNullable().unique() + table.string('title').notNullable() + table.text('summary').nullable() + table.string('author').nullable() + table.bigInteger('size_bytes').nullable() + table.timestamp('created_at') + table.timestamp('updated_at') + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index b9d10a1..aa7853d 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -99,11 +99,14 @@ class API { })() } - async downloadRemoteZimFile(url: string) { + async downloadRemoteZimFile( + url: string, + metadata?: { title: string; summary?: string; author?: string; size_bytes?: number } + ) { return catchInternal(async () => { const response = await this.client.post<{ message: string; filename: string; url: string }>( '/zim/download-remote', - { url } + { url, metadata } ) return response.data })() diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index d784e99..da2238b 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -8,14 +8,14 @@ import { useModals } from '~/context/ModalContext' import StyledModal from '~/components/StyledModal' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' -import { FileEntry } from '../../../../types/files' +import { ZimFileWithMetadata } from '../../../../types/zim' import { SERVICE_NAMES } from '../../../../constants/service_names' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) - const { data, isLoading } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ['zim-files'], queryFn: getFiles, }) @@ -25,7 +25,7 @@ export default function ZimPage() { return res.data.files } - async function confirmDeleteFile(file: FileEntry) { + async function confirmDeleteFile(file: ZimFileWithMetadata) { openModal( api.deleteZimFile(file.name.replace('.zim', '')), + mutationFn: async (file: ZimFileWithMetadata) => api.deleteZimFile(file.name.replace('.zim', '')), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['zim-files'] }) }, @@ -75,13 +75,30 @@ export default function ZimPage() { className="!mt-6" /> )} - + className="font-semibold mt-4" rowLines={true} loading={isLoading} compact columns={[ - { accessor: 'name', title: 'Name' }, + { + accessor: 'title', + title: 'Title', + render: (record) => ( + + {record.title || record.name} + + ), + }, + { + accessor: 'summary', + title: 'Summary', + render: (record) => ( + + {record.summary || '—'} + + ), + }, { accessor: 'actions', title: 'Actions', diff --git a/admin/inertia/pages/settings/zim/remote-explorer.tsx b/admin/inertia/pages/settings/zim/remote-explorer.tsx index fae5e58..7ff4230 100644 --- a/admin/inertia/pages/settings/zim/remote-explorer.tsx +++ b/admin/inertia/pages/settings/zim/remote-explorer.tsx @@ -209,7 +209,12 @@ export default function ZimRemoteExplorer() { async function downloadFile(record: RemoteZimFileEntry) { try { - await api.downloadRemoteZimFile(record.download_url) + await api.downloadRemoteZimFile(record.download_url, { + title: record.title, + summary: record.summary, + author: record.author, + size_bytes: record.size_bytes, + }) invalidateDownloads() } catch (error) { console.error('Error downloading file:', error) diff --git a/admin/types/zim.ts b/admin/types/zim.ts index 305d52d..68f7194 100644 --- a/admin/types/zim.ts +++ b/admin/types/zim.ts @@ -1,7 +1,14 @@ import { FileEntry } from './files.js' +export type ZimFileWithMetadata = FileEntry & { + title: string | null + summary: string | null + author: string | null + size_bytes: number | null +} + export type ListZimFilesResponse = { - files: FileEntry[] + files: ZimFileWithMetadata[] next?: string }