Skip to content

Commit 68f374e

Browse files
chriscrosstalkclaude
authored andcommitted
feat: Add dedicated Wikipedia Selector with smart package management
Adds a standalone Wikipedia selection section that appears prominently in both the Easy Setup Wizard and Content Explorer. Features include: - Six Wikipedia package options ranging from Quick Reference (313MB) to Complete Wikipedia with Full Media (99.6GB) - Card-based radio selection UI with clear size indicators - Smart replacement: downloads new package before deleting old one - Status tracking: shows Installed, Selected, or Downloading badges - "No Wikipedia" option for users who want to skip or remove Wikipedia Technical changes: - New wikipedia_selections database table and model - New /api/zim/wikipedia and /api/zim/wikipedia/select endpoints - WikipediaSelector component with consistent styling - Integration with existing download queue system - Callback updates status to 'installed' on successful download - Wikipedia removed from tiered category system to avoid duplication UI improvements: - Added section dividers and icons (AI Models, Wikipedia, Additional Content) - Consistent spacing between major sections in Easy Setup Wizard - Content Explorer gets matching Wikipedia section with submit button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 80aa556 commit 68f374e

File tree

14 files changed

+734
-26
lines changed

14 files changed

+734
-26
lines changed

admin/app/controllers/zim_controller.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
filenameParamValidator,
55
remoteDownloadValidator,
66
saveInstalledTierValidator,
7+
selectWikipediaValidator,
78
} from '#validators/common'
89
import { listRemoteZimValidator } from '#validators/zim'
910
import { inject } from '@adonisjs/core'
@@ -79,4 +80,15 @@ export default class ZimController {
7980
message: 'ZIM file deleted successfully',
8081
}
8182
}
83+
84+
// Wikipedia selector endpoints
85+
86+
async getWikipediaState({}: HttpContext) {
87+
return this.zimService.getWikipediaState()
88+
}
89+
90+
async selectWikipedia({ request }: HttpContext) {
91+
const payload = await request.validateUsing(selectWikipediaValidator)
92+
return this.zimService.selectWikipedia(payload.optionId)
93+
}
8294
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { DateTime } from 'luxon'
2+
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
3+
4+
export default class WikipediaSelection extends BaseModel {
5+
static namingStrategy = new SnakeCaseNamingStrategy()
6+
7+
@column({ isPrimary: true })
8+
declare id: number
9+
10+
@column()
11+
declare option_id: string
12+
13+
@column()
14+
declare url: string | null
15+
16+
@column()
17+
declare filename: string | null
18+
19+
@column()
20+
declare status: 'none' | 'downloading' | 'installed' | 'failed'
21+
22+
@column.dateTime({ autoCreate: true })
23+
declare created_at: DateTime
24+
25+
@column.dateTime({ autoCreate: true, autoUpdate: true })
26+
declare updated_at: DateTime
27+
}

admin/app/services/zim_service.ts

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,21 @@ import {
1717
ZIM_STORAGE_PATH,
1818
} from '../utils/fs.js'
1919
import { join } from 'path'
20-
import { CuratedCategory, CuratedCollectionWithStatus, CuratedCollectionsFile } from '../../types/downloads.js'
20+
import { CuratedCategory, CuratedCollectionWithStatus, CuratedCollectionsFile, WikipediaOption, WikipediaState } from '../../types/downloads.js'
2121
import vine from '@vinejs/vine'
22-
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema } from '#validators/curated_collections'
22+
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections'
2323
import CuratedCollection from '#models/curated_collection'
2424
import CuratedCollectionResource from '#models/curated_collection_resource'
2525
import InstalledTier from '#models/installed_tier'
26+
import WikipediaSelection from '#models/wikipedia_selection'
2627
import { RunDownloadJob } from '#jobs/run_download_job'
2728
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
2829

2930
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
3031
const CATEGORIES_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/kiwix-categories.json'
3132
const COLLECTIONS_URL =
3233
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/kiwix.json'
34+
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json'
3335

3436

3537

@@ -231,6 +233,13 @@ export class ZimService implements IZimService {
231233
}
232234

233235
async downloadRemoteSuccessCallback(urls: string[], restart = true) {
236+
// Check if any URL is a Wikipedia download and handle it
237+
for (const url of urls) {
238+
if (url.includes('wikipedia_en_')) {
239+
await this.onWikipediaDownloadComplete(url, true)
240+
}
241+
}
242+
234243
// Restart KIWIX container to pick up new ZIM file
235244
if (restart) {
236245
await this.dockerService
@@ -338,4 +347,206 @@ export class ZimService implements IZimService {
338347

339348
await deleteFileIfExists(fullPath)
340349
}
350+
351+
// Wikipedia selector methods
352+
353+
async getWikipediaOptions(): Promise<WikipediaOption[]> {
354+
try {
355+
const response = await axios.get(WIKIPEDIA_OPTIONS_URL)
356+
const data = response.data
357+
358+
const validated = await vine.validate({
359+
schema: wikipediaOptionsFileSchema,
360+
data,
361+
})
362+
363+
return validated.options
364+
} catch (error) {
365+
logger.error(`[ZimService] Failed to fetch Wikipedia options:`, error)
366+
throw new Error('Failed to fetch Wikipedia options')
367+
}
368+
}
369+
370+
async getWikipediaSelection(): Promise<WikipediaSelection | null> {
371+
// Get the single row from wikipedia_selections (there should only ever be one)
372+
return WikipediaSelection.query().first()
373+
}
374+
375+
async getWikipediaState(): Promise<WikipediaState> {
376+
const options = await this.getWikipediaOptions()
377+
const selection = await this.getWikipediaSelection()
378+
379+
return {
380+
options,
381+
currentSelection: selection
382+
? {
383+
optionId: selection.option_id,
384+
status: selection.status,
385+
filename: selection.filename,
386+
url: selection.url,
387+
}
388+
: null,
389+
}
390+
}
391+
392+
async selectWikipedia(optionId: string): Promise<{ success: boolean; jobId?: string; message?: string }> {
393+
const options = await this.getWikipediaOptions()
394+
const selectedOption = options.find((opt) => opt.id === optionId)
395+
396+
if (!selectedOption) {
397+
throw new Error(`Invalid Wikipedia option: ${optionId}`)
398+
}
399+
400+
const currentSelection = await this.getWikipediaSelection()
401+
402+
// If same as currently installed, no action needed
403+
if (currentSelection?.option_id === optionId && currentSelection.status === 'installed') {
404+
return { success: true, message: 'Already installed' }
405+
}
406+
407+
// Handle "none" option - delete current Wikipedia file and update DB
408+
if (optionId === 'none') {
409+
if (currentSelection?.filename) {
410+
try {
411+
await this.delete(currentSelection.filename)
412+
logger.info(`[ZimService] Deleted Wikipedia file: ${currentSelection.filename}`)
413+
} catch (error) {
414+
// File might already be deleted, that's OK
415+
logger.warn(`[ZimService] Could not delete Wikipedia file (may already be gone): ${currentSelection.filename}`)
416+
}
417+
}
418+
419+
// Update or create the selection record (always use first record)
420+
if (currentSelection) {
421+
currentSelection.option_id = 'none'
422+
currentSelection.url = null
423+
currentSelection.filename = null
424+
currentSelection.status = 'none'
425+
await currentSelection.save()
426+
} else {
427+
await WikipediaSelection.create({
428+
option_id: 'none',
429+
url: null,
430+
filename: null,
431+
status: 'none',
432+
})
433+
}
434+
435+
// Restart Kiwix to reflect the change
436+
await this.dockerService
437+
.affectContainer(DockerService.KIWIX_SERVICE_NAME, 'restart')
438+
.catch((error) => {
439+
logger.error(`[ZimService] Failed to restart Kiwix after Wikipedia removal:`, error)
440+
})
441+
442+
return { success: true, message: 'Wikipedia removed' }
443+
}
444+
445+
// Start download for the new Wikipedia option
446+
if (!selectedOption.url) {
447+
throw new Error('Selected Wikipedia option has no download URL')
448+
}
449+
450+
// Check if already downloading
451+
const existingJob = await RunDownloadJob.getByUrl(selectedOption.url)
452+
if (existingJob) {
453+
return { success: false, message: 'Download already in progress' }
454+
}
455+
456+
// Extract filename from URL
457+
const filename = selectedOption.url.split('/').pop()
458+
if (!filename) {
459+
throw new Error('Could not determine filename from URL')
460+
}
461+
462+
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
463+
464+
// Update or create selection record to show downloading status
465+
let selection: WikipediaSelection
466+
if (currentSelection) {
467+
currentSelection.option_id = optionId
468+
currentSelection.url = selectedOption.url
469+
currentSelection.filename = filename
470+
currentSelection.status = 'downloading'
471+
await currentSelection.save()
472+
selection = currentSelection
473+
} else {
474+
selection = await WikipediaSelection.create({
475+
option_id: optionId,
476+
url: selectedOption.url,
477+
filename: filename,
478+
status: 'downloading',
479+
})
480+
}
481+
482+
// Dispatch download job
483+
const result = await RunDownloadJob.dispatch({
484+
url: selectedOption.url,
485+
filepath,
486+
timeout: 30000,
487+
allowedMimeTypes: ZIM_MIME_TYPES,
488+
forceNew: true,
489+
filetype: 'zim',
490+
})
491+
492+
if (!result || !result.job) {
493+
// Revert status on failure to dispatch
494+
selection.option_id = currentSelection?.option_id || 'none'
495+
selection.url = currentSelection?.url || null
496+
selection.filename = currentSelection?.filename || null
497+
selection.status = currentSelection?.status || 'none'
498+
await selection.save()
499+
throw new Error('Failed to dispatch download job')
500+
}
501+
502+
logger.info(`[ZimService] Started Wikipedia download for ${optionId}: ${filename}`)
503+
504+
return {
505+
success: true,
506+
jobId: result.job.id,
507+
message: 'Download started',
508+
}
509+
}
510+
511+
async onWikipediaDownloadComplete(url: string, success: boolean): Promise<void> {
512+
const selection = await this.getWikipediaSelection()
513+
514+
if (!selection || selection.url !== url) {
515+
logger.warn(`[ZimService] Wikipedia download complete callback for unknown URL: ${url}`)
516+
return
517+
}
518+
519+
if (success) {
520+
// Get the old filename before updating (if there was a previous Wikipedia installed)
521+
const options = await this.getWikipediaOptions()
522+
const previousOption = options.find((opt) => opt.id !== selection.option_id && opt.id !== 'none')
523+
524+
// Update status to installed
525+
selection.status = 'installed'
526+
await selection.save()
527+
528+
logger.info(`[ZimService] Wikipedia download completed successfully: ${selection.filename}`)
529+
530+
// Delete the old Wikipedia file if it exists and is different
531+
// We need to find what was previously installed
532+
const existingFiles = await this.list()
533+
const wikipediaFiles = existingFiles.files.filter((f) =>
534+
f.name.startsWith('wikipedia_en_') && f.name !== selection.filename
535+
)
536+
537+
for (const oldFile of wikipediaFiles) {
538+
try {
539+
await this.delete(oldFile.name)
540+
logger.info(`[ZimService] Deleted old Wikipedia file: ${oldFile.name}`)
541+
} catch (error) {
542+
logger.warn(`[ZimService] Could not delete old Wikipedia file: ${oldFile.name}`, error)
543+
}
544+
}
545+
} else {
546+
// Download failed - keep the selection record but mark as failed
547+
selection.status = 'failed'
548+
await selection.save()
549+
logger.error(`[ZimService] Wikipedia download failed for: ${selection.filename}`)
550+
}
551+
}
341552
}

admin/app/validators/common.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,9 @@ export const saveInstalledTierValidator = vine.compile(
4343
tierSlug: vine.string().trim().minLength(1),
4444
})
4545
)
46+
47+
export const selectWikipediaValidator = vine.compile(
48+
vine.object({
49+
optionId: vine.string().trim().minLength(1),
50+
})
51+
)

admin/app/validators/curated_collections.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,18 @@ export const curatedCategoriesFileSchema = vine.object({
4545
})
4646
),
4747
})
48+
49+
/**
50+
* For validating the Wikipedia options file
51+
*/
52+
export const wikipediaOptionSchema = vine.object({
53+
id: vine.string(),
54+
name: vine.string(),
55+
description: vine.string(),
56+
size_mb: vine.number().min(0),
57+
url: vine.string().url().nullable(),
58+
})
59+
60+
export const wikipediaOptionsFileSchema = vine.object({
61+
options: vine.array(wikipediaOptionSchema).minLength(1),
62+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'wikipedia_selections'
5+
6+
async up() {
7+
this.schema.createTable(this.tableName, (table) => {
8+
table.increments('id').primary()
9+
table.string('option_id').notNullable()
10+
table.string('url').nullable()
11+
table.string('filename').nullable()
12+
table.enum('status', ['none', 'downloading', 'installed', 'failed']).defaultTo('none')
13+
table.timestamp('created_at')
14+
table.timestamp('updated_at')
15+
})
16+
}
17+
18+
async down() {
19+
this.schema.dropTable(this.tableName)
20+
}
21+
}

0 commit comments

Comments
 (0)