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 committed by Jake Turner
parent 3b31be66f9
commit 2c4fc59428
9 changed files with 156 additions and 15 deletions

View File

@ -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',

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

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

View File

@ -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<FileEntry[]>({
const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({
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(
<StyledModal
title="Confirm Delete?"
@ -48,7 +48,7 @@ export default function ZimPage() {
}
const deleteFileMutation = useMutation({
mutationFn: async (file: FileEntry) => 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"
/>
)}
<StyledTable<FileEntry & { actions?: any }>
<StyledTable<ZimFileWithMetadata & { actions?: any }>
className="font-semibold mt-4"
rowLines={true}
loading={isLoading}
compact
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',
title: 'Actions',

View File

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

View File

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