11import { BaseStylesFile , MapLayer } from '../../types/maps.js'
2- import { FileEntry } from '../../types/files.js'
2+ import {
3+ DownloadCollectionOperation ,
4+ DownloadRemoteSuccessCallback ,
5+ FileEntry ,
6+ } from '../../types/files.js'
37import { doResumableDownloadWithRetry } from '../utils/downloads.js'
48import { extract } from 'tar'
59import env from '#start/env'
@@ -16,18 +20,31 @@ import urlJoin from 'url-join'
1620import axios from 'axios'
1721import { RunDownloadJob } from '#jobs/run_download_job'
1822import logger from '@adonisjs/core/services/logger'
23+ import { CuratedCollectionsFile , CuratedCollectionWithStatus } from '../../types/downloads.js'
24+ import CuratedCollection from '#models/curated_collection'
25+ import vine from '@vinejs/vine'
26+ import { curatedCollectionsFileSchema } from '#validators/curated_collections'
27+ import CuratedCollectionResource from '#models/curated_collection_resource'
1928
2029const BASE_ASSETS_MIME_TYPES = [
2130 'application/gzip' ,
2231 'application/x-gzip' ,
2332 'application/octet-stream' ,
2433]
2534
35+ const COLLECTIONS_URL =
36+ 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json'
37+
2638const PMTILES_ATTRIBUTION =
2739 '<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
2840const PMTILES_MIME_TYPES = [ 'application/vnd.pmtiles' , 'application/octet-stream' ]
2941
30- export class MapService {
42+ interface IMapService {
43+ downloadCollection : DownloadCollectionOperation
44+ downloadRemoteSuccessCallback : DownloadRemoteSuccessCallback
45+ }
46+
47+ export class MapService implements IMapService {
3148 private readonly mapStoragePath = '/storage/maps'
3249 private readonly baseStylesFile = 'nomad-base-styles.json'
3350 private readonly basemapsAssetsDir = 'basemaps-assets'
@@ -80,6 +97,62 @@ export class MapService {
8097 return true
8198 }
8299
100+ async downloadCollection ( slug : string ) {
101+ const collection = await CuratedCollection . query ( )
102+ . where ( 'slug' , slug )
103+ . andWhere ( 'type' , 'map' )
104+ . first ( )
105+ if ( ! collection ) {
106+ return null
107+ }
108+
109+ const resources = await collection . related ( 'resources' ) . query ( ) . where ( 'downloaded' , false )
110+ if ( resources . length === 0 ) {
111+ return null
112+ }
113+
114+ const downloadUrls = resources . map ( ( res ) => res . url )
115+ const downloadFilenames : string [ ] = [ ]
116+
117+ for ( const url of downloadUrls ) {
118+ const existing = await RunDownloadJob . getByUrl ( url )
119+ if ( existing ) {
120+ logger . warn ( `[MapService] Download already in progress for URL ${ url } , skipping.` )
121+ continue
122+ }
123+
124+ // Extract the filename from the URL
125+ const filename = url . split ( '/' ) . pop ( )
126+ if ( ! filename ) {
127+ logger . warn ( `[MapService] Could not determine filename from URL ${ url } , skipping.` )
128+ continue
129+ }
130+
131+ downloadFilenames . push ( filename )
132+ const filepath = join ( process . cwd ( ) , this . mapStoragePath , 'pmtiles' , filename )
133+
134+ await RunDownloadJob . dispatch ( {
135+ url,
136+ filepath,
137+ timeout : 30000 ,
138+ allowedMimeTypes : PMTILES_MIME_TYPES ,
139+ forceNew : true ,
140+ filetype : 'map' ,
141+ } )
142+ }
143+
144+ return downloadFilenames . length > 0 ? downloadFilenames : null
145+ }
146+
147+ async downloadRemoteSuccessCallback ( urls : string [ ] , _ : boolean ) {
148+ const resources = await CuratedCollectionResource . query ( ) . whereIn ( 'url' , urls )
149+ for ( const resource of resources ) {
150+ resource . downloaded = true
151+ await resource . save ( )
152+ logger . info ( `[MapService] Marked resource as downloaded: ${ resource . url } ` )
153+ }
154+ }
155+
83156 async downloadRemote ( url : string ) : Promise < { filename : string ; jobId ?: string } > {
84157 const parsed = new URL ( url )
85158 if ( ! parsed . pathname . endsWith ( '.pmtiles' ) ) {
@@ -105,7 +178,7 @@ export class MapService {
105178 timeout : 30000 ,
106179 allowedMimeTypes : PMTILES_MIME_TYPES ,
107180 forceNew : true ,
108- filetype : 'pmtiles ' ,
181+ filetype : 'map ' ,
109182 } )
110183
111184 if ( ! result . job ) {
@@ -193,6 +266,47 @@ export class MapService {
193266 return ! ! baseStyleItem && ! ! basemapsAssetsItem
194267 }
195268
269+ async listCuratedCollections ( ) : Promise < CuratedCollectionWithStatus [ ] > {
270+ const collections = await CuratedCollection . query ( ) . where ( 'type' , 'map' ) . preload ( 'resources' )
271+ return collections . map ( ( collection ) => ( {
272+ ...( collection . serialize ( ) as CuratedCollection ) ,
273+ all_downloaded : collection . resources . every ( ( res ) => res . downloaded ) ,
274+ } ) )
275+ }
276+
277+ async fetchLatestCollections ( ) : Promise < boolean > {
278+ try {
279+ const response = await axios . get < CuratedCollectionsFile > ( COLLECTIONS_URL )
280+
281+ const validated = await vine . validate ( {
282+ schema : curatedCollectionsFileSchema ,
283+ data : response . data ,
284+ } )
285+
286+ for ( const collection of validated . collections ) {
287+ const collectionResult = await CuratedCollection . updateOrCreate (
288+ { slug : collection . slug } ,
289+ {
290+ ...collection ,
291+ type : 'map' ,
292+ }
293+ )
294+ logger . info ( `[MapService] Upserted curated collection: ${ collection . slug } ` )
295+
296+ await collectionResult . related ( 'resources' ) . createMany ( collection . resources )
297+ logger . info (
298+ `[MapService] Upserted ${ collection . resources . length } resources for collection: ${ collection . slug } `
299+ )
300+ }
301+
302+ return true
303+ } catch ( error ) {
304+ console . error ( error )
305+ logger . error ( `[MapService] Failed to download latest Kiwix collections:` , error )
306+ return false
307+ }
308+ }
309+
196310 private async listMapStorageItems ( ) : Promise < FileEntry [ ] > {
197311 await ensureDirectoryExists ( this . baseDirPath )
198312 return await listDirectoryContents ( this . baseDirPath )
0 commit comments