@@ -17,19 +17,21 @@ import {
1717 ZIM_STORAGE_PATH ,
1818} from '../utils/fs.js'
1919import { join } from 'path'
20- import { CuratedCategory , CuratedCollectionWithStatus , CuratedCollectionsFile } from '../../types/downloads.js'
20+ import { CuratedCategory , CuratedCollectionWithStatus , CuratedCollectionsFile , WikipediaOption , WikipediaState } from '../../types/downloads.js'
2121import vine from '@vinejs/vine'
22- import { curatedCategoriesFileSchema , curatedCollectionsFileSchema } from '#validators/curated_collections'
22+ import { curatedCategoriesFileSchema , curatedCollectionsFileSchema , wikipediaOptionsFileSchema } from '#validators/curated_collections'
2323import CuratedCollection from '#models/curated_collection'
2424import CuratedCollectionResource from '#models/curated_collection_resource'
2525import InstalledTier from '#models/installed_tier'
26+ import WikipediaSelection from '#models/wikipedia_selection'
2627import { RunDownloadJob } from '#jobs/run_download_job'
2728import { DownloadCollectionOperation , DownloadRemoteSuccessCallback } from '../../types/files.js'
2829
2930const ZIM_MIME_TYPES = [ 'application/x-zim' , 'application/x-openzim' , 'application/octet-stream' ]
3031const CATEGORIES_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/kiwix-categories.json'
3132const 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}
0 commit comments