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 <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-02-02 15:43:08 -08:00
parent 3b31be66f9
commit e3c9dffb2e
9 changed files with 156 additions and 15 deletions

View File

@ -2,7 +2,7 @@ import { ZimService } from '#services/zim_service'
import { import {
downloadCollectionValidator, downloadCollectionValidator,
filenameParamValidator, filenameParamValidator,
remoteDownloadValidator, remoteDownloadWithMetadataValidator,
saveInstalledTierValidator, saveInstalledTierValidator,
selectWikipediaValidator, selectWikipediaValidator,
} from '#validators/common' } from '#validators/common'
@ -25,8 +25,8 @@ export default class ZimController {
} }
async downloadRemote({ request }: HttpContext) { async downloadRemote({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadValidator) const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
const { filename, jobId } = await this.zimService.downloadRemote(payload.url) const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
return { return {
message: 'Download started successfully', message: 'Download started successfully',

View File

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

View File

@ -24,6 +24,7 @@ import CuratedCollection from '#models/curated_collection'
import CuratedCollectionResource from '#models/curated_collection_resource' import CuratedCollectionResource from '#models/curated_collection_resource'
import InstalledTier from '#models/installed_tier' import InstalledTier from '#models/installed_tier'
import WikipediaSelection from '#models/wikipedia_selection' import WikipediaSelection from '#models/wikipedia_selection'
import ZimFileMetadata from '#models/zim_file_metadata'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js' import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
@ -52,8 +53,24 @@ export class ZimService implements IZimService {
const all = await listDirectoryContents(dirPath) const all = await listDirectoryContents(dirPath)
const files = all.filter((item) => item.name.endsWith('.zim')) 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 { 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) const parsed = new URL(url)
if (!parsed.pathname.endsWith('.zim')) { if (!parsed.pathname.endsWith('.zim')) {
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .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) 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 // Dispatch a background download job
const result = await RunDownloadJob.dispatch({ const result = await RunDownloadJob.dispatch({
url, url,
@ -347,6 +381,10 @@ export class ZimService implements IZimService {
} }
await deleteFileIfExists(fullPath) 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 // Wikipedia selector methods

View File

@ -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( export const remoteDownloadValidatorOptional = vine.compile(
vine.object({ vine.object({
url: vine url: vine

View File

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

View File

@ -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 () => { return catchInternal(async () => {
const response = await this.client.post<{ message: string; filename: string; url: string }>( const response = await this.client.post<{ message: string; filename: string; url: string }>(
'/zim/download-remote', '/zim/download-remote',
{ url } { url, metadata }
) )
return response.data return response.data
})() })()

View File

@ -8,14 +8,14 @@ import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal' import StyledModal from '~/components/StyledModal'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import Alert from '~/components/Alert' import Alert from '~/components/Alert'
import { FileEntry } from '../../../../types/files' import { ZimFileWithMetadata } from '../../../../types/zim'
import { SERVICE_NAMES } from '../../../../constants/service_names' import { SERVICE_NAMES } from '../../../../constants/service_names'
export default function ZimPage() { export default function ZimPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals() const { openModal, closeAllModals } = useModals()
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX) const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
const { data, isLoading } = useQuery<FileEntry[]>({ const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({
queryKey: ['zim-files'], queryKey: ['zim-files'],
queryFn: getFiles, queryFn: getFiles,
}) })
@ -25,7 +25,7 @@ export default function ZimPage() {
return res.data.files return res.data.files
} }
async function confirmDeleteFile(file: FileEntry) { async function confirmDeleteFile(file: ZimFileWithMetadata) {
openModal( openModal(
<StyledModal <StyledModal
title="Confirm Delete?" title="Confirm Delete?"
@ -48,7 +48,7 @@ export default function ZimPage() {
} }
const deleteFileMutation = useMutation({ const deleteFileMutation = useMutation({
mutationFn: async (file: FileEntry) => api.deleteZimFile(file.name.replace('.zim', '')), mutationFn: async (file: ZimFileWithMetadata) => api.deleteZimFile(file.name.replace('.zim', '')),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['zim-files'] }) queryClient.invalidateQueries({ queryKey: ['zim-files'] })
}, },
@ -75,13 +75,30 @@ export default function ZimPage() {
className="!mt-6" className="!mt-6"
/> />
)} )}
<StyledTable<FileEntry & { actions?: any }> <StyledTable<ZimFileWithMetadata & { actions?: any }>
className="font-semibold mt-4" className="font-semibold mt-4"
rowLines={true} rowLines={true}
loading={isLoading} loading={isLoading}
compact compact
columns={[ columns={[
{ accessor: 'name', title: 'Name' }, {
accessor: 'title',
title: 'Title',
render: (record) => (
<span className="font-medium">
{record.title || record.name}
</span>
),
},
{
accessor: 'summary',
title: 'Summary',
render: (record) => (
<span className="text-gray-600 text-sm line-clamp-2">
{record.summary || '—'}
</span>
),
},
{ {
accessor: 'actions', accessor: 'actions',
title: 'Actions', title: 'Actions',

View File

@ -209,7 +209,12 @@ export default function ZimRemoteExplorer() {
async function downloadFile(record: RemoteZimFileEntry) { async function downloadFile(record: RemoteZimFileEntry) {
try { 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() invalidateDownloads()
} catch (error) { } catch (error) {
console.error('Error downloading file:', error) console.error('Error downloading file:', error)

View File

@ -1,7 +1,14 @@
import { FileEntry } from './files.js' 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 = { export type ListZimFilesResponse = {
files: FileEntry[] files: ZimFileWithMetadata[]
next?: string next?: string
} }