feat: curated content system overhaul

This commit is contained in:
Jake Turner 2026-02-11 15:44:46 -08:00
parent 4ac261477a
commit 32d206cfd7
No known key found for this signature in database
GPG Key ID: D11724A09ED19E59
36 changed files with 1329 additions and 912 deletions

View File

@ -0,0 +1,9 @@
import { CollectionUpdateService } from '#services/collection_update_service'
import type { HttpContext } from '@adonisjs/core/http'
export default class CollectionUpdatesController {
async checkForUpdates({}: HttpContext) {
const collectionUpdateService = new CollectionUpdateService()
return await collectionUpdateService.checkForUpdates()
}
}

View File

@ -1,5 +1,6 @@
import { SystemService } from '#services/system_service' import { SystemService } from '#services/system_service'
import { ZimService } from '#services/zim_service' import { ZimService } from '#services/zim_service'
import { CollectionManifestService } from '#services/collection_manifest_service'
import { inject } from '@adonisjs/core' import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
@ -26,4 +27,22 @@ export default class EasySetupController {
async listCuratedCategories({}: HttpContext) { async listCuratedCategories({}: HttpContext) {
return await this.zimService.listCuratedCategories() return await this.zimService.listCuratedCategories()
} }
async refreshManifests({}: HttpContext) {
const manifestService = new CollectionManifestService()
const [zimChanged, mapsChanged, wikiChanged] = await Promise.all([
manifestService.fetchAndCacheSpec('zim_categories'),
manifestService.fetchAndCacheSpec('maps'),
manifestService.fetchAndCacheSpec('wikipedia'),
])
return {
success: true,
changed: {
zim_categories: zimChanged,
maps: mapsChanged,
wikipedia: wikiChanged,
},
}
}
} }

View File

@ -1,6 +1,6 @@
import { ZimService } from '#services/zim_service' import { ZimService } from '#services/zim_service'
import { import {
downloadCollectionValidator, downloadCategoryTierValidator,
filenameParamValidator, filenameParamValidator,
remoteDownloadWithMetadataValidator, remoteDownloadWithMetadataValidator,
selectWikipediaValidator, selectWikipediaValidator,
@ -25,7 +25,7 @@ export default class ZimController {
async downloadRemote({ request }: HttpContext) { async downloadRemote({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator) const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata) const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
return { return {
message: 'Download started successfully', message: 'Download started successfully',
@ -35,26 +35,25 @@ export default class ZimController {
} }
} }
async downloadCollection({ request }: HttpContext) { async listCuratedCategories({}: HttpContext) {
const payload = await request.validateUsing(downloadCollectionValidator) return await this.zimService.listCuratedCategories()
const resources = await this.zimService.downloadCollection(payload.slug) }
async downloadCategoryTier({ request }: HttpContext) {
const payload = await request.validateUsing(downloadCategoryTierValidator)
const resources = await this.zimService.downloadCategoryTier(
payload.categorySlug,
payload.tierSlug
)
return { return {
message: 'Download started successfully', message: 'Download started successfully',
slug: payload.slug, categorySlug: payload.categorySlug,
tierSlug: payload.tierSlug,
resources, resources,
} }
} }
async listCuratedCollections({}: HttpContext) {
return this.zimService.listCuratedCollections()
}
async fetchLatestCollections({}: HttpContext) {
const success = await this.zimService.fetchLatestCollections()
return { success }
}
async delete({ request, response }: HttpContext) { async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameParamValidator) const payload = await request.validateUsing(filenameParamValidator)

View File

@ -22,7 +22,7 @@ export class RunDownloadJob {
} }
async handle(job: Job) { async handle(job: Job) {
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype } = const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
job.data as RunDownloadJobParams job.data as RunDownloadJobParams
await doResumableDownload({ await doResumableDownload({
@ -37,6 +37,26 @@ export class RunDownloadJob {
}, },
async onComplete(url) { async onComplete(url) {
try { try {
// Create InstalledResource entry if metadata was provided
if (resourceMetadata) {
const { default: InstalledResource } = await import('#models/installed_resource')
const { DateTime } = await import('luxon')
const { getFileStatsIfExists } = await import('../utils/fs.js')
const stats = await getFileStatsIfExists(filepath)
await InstalledResource.updateOrCreate(
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
{
version: resourceMetadata.version,
collection_ref: resourceMetadata.collection_ref,
url: url,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
}
if (filetype === 'zim') { if (filetype === 'zim') {
const dockerService = new DockerService() const dockerService = new DockerService()
const zimService = new ZimService(dockerService) const zimService = new ZimService(dockerService)
@ -57,7 +77,7 @@ export class RunDownloadJob {
} }
} catch (error) { } catch (error) {
console.error( console.error(
`[RunDownloadJob] Error in ZIM download success callback for URL ${url}:`, `[RunDownloadJob] Error in download success callback for URL ${url}:`,
error error
) )
} }

View File

@ -0,0 +1,22 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import type { ManifestType } from '../../types/collections.js'
export default class CollectionManifest extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare type: ManifestType
@column()
declare spec_version: string
@column({
consume: (value: string) => (typeof value === 'string' ? JSON.parse(value) : value),
prepare: (value: any) => JSON.stringify(value),
})
declare spec_data: any
@column.dateTime()
declare fetched_at: DateTime
}

View File

@ -1,39 +0,0 @@
import { DateTime } from 'luxon'
import { BaseModel, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import CuratedCollectionResource from './curated_collection_resource.js'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import type { CuratedCollectionType } from '../../types/curated_collections.js'
export default class CuratedCollection extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare slug: string
@column()
declare type: CuratedCollectionType
@column()
declare name: string
@column()
declare description: string
@column()
declare icon: string
@column()
declare language: string
@hasMany(() => CuratedCollectionResource, {
foreignKey: 'curated_collection_slug',
localKey: 'slug',
})
declare resources: HasMany<typeof CuratedCollectionResource>
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

@ -1,49 +0,0 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import CuratedCollection from './curated_collection.js'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
export default class CuratedCollectionResource extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
static indexes = [
{
name: 'curated_collection_resources_unique',
columns: ['curated_collection_slug', 'url'],
unique: true,
},
]
@column({ isPrimary: true })
declare id: number
@column()
declare curated_collection_slug: string
@belongsTo(() => CuratedCollection, {
foreignKey: 'slug',
localKey: 'curated_collection_slug',
})
declare curated_collection: BelongsTo<typeof CuratedCollection>
@column()
declare title: string
@column()
declare url: string
@column()
declare description: string
@column()
declare size_mb: number
@column()
declare downloaded: boolean
@column.dateTime({ autoCreate: true })
declare created_at: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime
}

View File

@ -0,0 +1,33 @@
import { DateTime } from 'luxon'
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
export default class InstalledResource extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
declare id: number
@column()
declare resource_id: string
@column()
declare resource_type: 'zim' | 'map'
@column()
declare collection_ref: string | null
@column()
declare version: string
@column()
declare url: string
@column()
declare file_path: string
@column()
declare file_size_bytes: number | null
@column.dateTime()
declare installed_at: DateTime
}

View File

@ -1,30 +0,0 @@
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

@ -0,0 +1,311 @@
import axios from 'axios'
import vine from '@vinejs/vine'
import logger from '@adonisjs/core/services/logger'
import { DateTime } from 'luxon'
import { join } from 'path'
import CollectionManifest from '#models/collection_manifest'
import InstalledResource from '#models/installed_resource'
import { zimCategoriesSpecSchema, mapsSpecSchema, wikipediaSpecSchema } from '#validators/curated_collections'
import {
ensureDirectoryExists,
listDirectoryContents,
getFileStatsIfExists,
ZIM_STORAGE_PATH,
} from '../utils/fs.js'
import type {
ManifestType,
ZimCategoriesSpec,
MapsSpec,
CategoryWithStatus,
CollectionWithStatus,
SpecResource,
SpecTier,
} from '../../types/collections.js'
const SPEC_URLS: Record<ManifestType, string> = {
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/kiwix-categories.json',
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json',
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json',
}
const VALIDATORS: Record<ManifestType, any> = {
zim_categories: zimCategoriesSpecSchema,
maps: mapsSpecSchema,
wikipedia: wikipediaSpecSchema,
}
export class CollectionManifestService {
private readonly mapStoragePath = '/storage/maps'
// ---- Spec management ----
async fetchAndCacheSpec(type: ManifestType): Promise<boolean> {
try {
const response = await axios.get(SPEC_URLS[type], { timeout: 15000 })
const validated = await vine.validate({
schema: VALIDATORS[type],
data: response.data,
})
const existing = await CollectionManifest.find(type)
const specVersion = validated.spec_version
if (existing) {
const changed = existing.spec_version !== specVersion
existing.spec_version = specVersion
existing.spec_data = validated
existing.fetched_at = DateTime.now()
await existing.save()
return changed
}
await CollectionManifest.create({
type,
spec_version: specVersion,
spec_data: validated,
fetched_at: DateTime.now(),
})
return true
} catch (error) {
logger.error(`[CollectionManifestService] Failed to fetch spec for ${type}:`, error?.message || error)
return false
}
}
async getCachedSpec<T>(type: ManifestType): Promise<T | null> {
const manifest = await CollectionManifest.find(type)
if (!manifest) return null
return manifest.spec_data as T
}
async getSpecWithFallback<T>(type: ManifestType): Promise<T | null> {
try {
await this.fetchAndCacheSpec(type)
} catch {
// Fetch failed, will fall back to cache
}
return this.getCachedSpec<T>(type)
}
// ---- Status computation ----
async getCategoriesWithStatus(): Promise<CategoryWithStatus[]> {
const spec = await this.getSpecWithFallback<ZimCategoriesSpec>('zim_categories')
if (!spec) return []
const installedResources = await InstalledResource.query().where('resource_type', 'zim')
const installedMap = new Map(installedResources.map((r) => [r.resource_id, r]))
return spec.categories.map((category) => ({
...category,
installedTierSlug: this.getInstalledTierForCategory(category.tiers, installedMap),
}))
}
async getMapCollectionsWithStatus(): Promise<CollectionWithStatus[]> {
const spec = await this.getSpecWithFallback<MapsSpec>('maps')
if (!spec) return []
const installedResources = await InstalledResource.query().where('resource_type', 'map')
const installedIds = new Set(installedResources.map((r) => r.resource_id))
return spec.collections.map((collection) => {
const installedCount = collection.resources.filter((r) => installedIds.has(r.id)).length
return {
...collection,
all_installed: installedCount === collection.resources.length,
installed_count: installedCount,
total_count: collection.resources.length,
}
})
}
// ---- Tier resolution ----
static resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {
const visited = new Set<string>()
return CollectionManifestService._resolveTierResourcesInner(tier, allTiers, visited)
}
private static _resolveTierResourcesInner(
tier: SpecTier,
allTiers: SpecTier[],
visited: Set<string>
): SpecResource[] {
if (visited.has(tier.slug)) return [] // cycle detection
visited.add(tier.slug)
const resources: SpecResource[] = []
if (tier.includesTier) {
const included = allTiers.find((t) => t.slug === tier.includesTier)
if (included) {
resources.push(...CollectionManifestService._resolveTierResourcesInner(included, allTiers, visited))
}
}
resources.push(...tier.resources)
return resources
}
getInstalledTierForCategory(
tiers: SpecTier[],
installedMap: Map<string, InstalledResource>
): string | undefined {
// Check from highest tier to lowest (tiers are ordered low to high in spec)
const reversedTiers = [...tiers].reverse()
for (const tier of reversedTiers) {
const resolved = CollectionManifestService.resolveTierResources(tier, tiers)
if (resolved.length === 0) continue
const allInstalled = resolved.every((r) => installedMap.has(r.id))
if (allInstalled) {
return tier.slug
}
}
return undefined
}
// ---- Filename parsing ----
static parseZimFilename(filename: string): { resource_id: string; version: string } | null {
const name = filename.replace(/\.zim$/, '')
const match = name.match(/^(.+)_(\d{4}-\d{2})$/)
if (!match) return null
return { resource_id: match[1], version: match[2] }
}
static parseMapFilename(filename: string): { resource_id: string; version: string } | null {
const name = filename.replace(/\.pmtiles$/, '')
const match = name.match(/^(.+)_(\d{4}-\d{2})$/)
if (!match) return null
return { resource_id: match[1], version: match[2] }
}
// ---- Filesystem reconciliation ----
async reconcileFromFilesystem(): Promise<{ zim: number; map: number }> {
let zimCount = 0
let mapCount = 0
// Reconcile ZIM files
try {
const zimDir = join(process.cwd(), ZIM_STORAGE_PATH)
await ensureDirectoryExists(zimDir)
const zimItems = await listDirectoryContents(zimDir)
const zimFiles = zimItems.filter((f) => f.name.endsWith('.zim'))
// Get spec for URL lookup
const zimSpec = await this.getCachedSpec<ZimCategoriesSpec>('zim_categories')
const specResourceMap = new Map<string, SpecResource>()
if (zimSpec) {
for (const cat of zimSpec.categories) {
for (const tier of cat.tiers) {
for (const res of tier.resources) {
specResourceMap.set(res.id, res)
}
}
}
}
const seenZimIds = new Set<string>()
for (const file of zimFiles) {
// Skip Wikipedia files (managed by WikipediaSelection model)
if (file.name.startsWith('wikipedia_en_')) continue
const parsed = CollectionManifestService.parseZimFilename(file.name)
if (!parsed) continue
seenZimIds.add(parsed.resource_id)
const specRes = specResourceMap.get(parsed.resource_id)
const filePath = join(zimDir, file.name)
const stats = await getFileStatsIfExists(filePath)
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'zim' },
{
version: parsed.version,
url: specRes?.url || '',
file_path: filePath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
zimCount++
}
// Remove entries for ZIM files no longer on disk
const existingZim = await InstalledResource.query().where('resource_type', 'zim')
for (const entry of existingZim) {
if (!seenZimIds.has(entry.resource_id)) {
await entry.delete()
}
}
} catch (error) {
logger.error('[CollectionManifestService] Error reconciling ZIM files:', error)
}
// Reconcile map files
try {
const mapDir = join(process.cwd(), this.mapStoragePath, 'pmtiles')
await ensureDirectoryExists(mapDir)
const mapItems = await listDirectoryContents(mapDir)
const mapFiles = mapItems.filter((f) => f.name.endsWith('.pmtiles'))
// Get spec for URL/version lookup
const mapSpec = await this.getCachedSpec<MapsSpec>('maps')
const mapResourceMap = new Map<string, SpecResource>()
if (mapSpec) {
for (const col of mapSpec.collections) {
for (const res of col.resources) {
mapResourceMap.set(res.id, res)
}
}
}
const seenMapIds = new Set<string>()
for (const file of mapFiles) {
const parsed = CollectionManifestService.parseMapFilename(file.name)
if (!parsed) continue
seenMapIds.add(parsed.resource_id)
const specRes = mapResourceMap.get(parsed.resource_id)
const filePath = join(mapDir, file.name)
const stats = await getFileStatsIfExists(filePath)
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'map' },
{
version: parsed.version,
url: specRes?.url || '',
file_path: filePath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
mapCount++
}
// Remove entries for map files no longer on disk
const existingMaps = await InstalledResource.query().where('resource_type', 'map')
for (const entry of existingMaps) {
if (!seenMapIds.has(entry.resource_id)) {
await entry.delete()
}
}
} catch (error) {
logger.error('[CollectionManifestService] Error reconciling map files:', error)
}
logger.info(`[CollectionManifestService] Reconciled ${zimCount} ZIM files, ${mapCount} map files`)
return { zim: zimCount, map: mapCount }
}
}

View File

@ -0,0 +1,130 @@
import logger from '@adonisjs/core/services/logger'
import InstalledResource from '#models/installed_resource'
import { CollectionManifestService } from './collection_manifest_service.js'
import type {
ZimCategoriesSpec,
MapsSpec,
CollectionResourceUpdateInfo,
CollectionUpdateCheckResult,
SpecResource,
} from '../../types/collections.js'
export class CollectionUpdateService {
private manifestService = new CollectionManifestService()
async checkForUpdates(): Promise<CollectionUpdateCheckResult> {
const resourceUpdates: CollectionResourceUpdateInfo[] = []
let specChanged = false
// Check if specs have changed
try {
const [zimChanged, mapsChanged] = await Promise.all([
this.manifestService.fetchAndCacheSpec('zim_categories'),
this.manifestService.fetchAndCacheSpec('maps'),
])
specChanged = zimChanged || mapsChanged
} catch (error) {
logger.error('[CollectionUpdateService] Failed to fetch latest specs:', error)
}
// Check for ZIM resource version updates
const zimUpdates = await this.checkZimUpdates()
resourceUpdates.push(...zimUpdates)
// Check for map resource version updates
const mapUpdates = await this.checkMapUpdates()
resourceUpdates.push(...mapUpdates)
logger.info(
`[CollectionUpdateService] Update check complete: spec_changed=${specChanged}, resource_updates=${resourceUpdates.length}`
)
return { spec_changed: specChanged, resource_updates: resourceUpdates }
}
private async checkZimUpdates(): Promise<CollectionResourceUpdateInfo[]> {
const updates: CollectionResourceUpdateInfo[] = []
try {
const spec = await this.manifestService.getCachedSpec<ZimCategoriesSpec>('zim_categories')
if (!spec) return updates
const installed = await InstalledResource.query().where('resource_type', 'zim')
if (installed.length === 0) return updates
// Build a map of spec resources by ID for quick lookup
const specResourceMap = new Map<string, SpecResource>()
for (const category of spec.categories) {
for (const tier of category.tiers) {
for (const resource of tier.resources) {
// Only keep the latest version if there are duplicates
const existing = specResourceMap.get(resource.id)
if (!existing || resource.version > existing.version) {
specResourceMap.set(resource.id, resource)
}
}
}
}
// Compare installed versions against spec versions
for (const entry of installed) {
const specResource = specResourceMap.get(entry.resource_id)
if (!specResource) continue
if (specResource.version > entry.version) {
updates.push({
resource_id: entry.resource_id,
installed_version: entry.version,
latest_version: specResource.version,
latest_url: specResource.url,
latest_size_mb: specResource.size_mb,
})
}
}
} catch (error) {
logger.error('[CollectionUpdateService] Error checking ZIM updates:', error)
}
return updates
}
private async checkMapUpdates(): Promise<CollectionResourceUpdateInfo[]> {
const updates: CollectionResourceUpdateInfo[] = []
try {
const spec = await this.manifestService.getCachedSpec<MapsSpec>('maps')
if (!spec) return updates
const installed = await InstalledResource.query().where('resource_type', 'map')
if (installed.length === 0) return updates
// Build a map of spec resources by ID
const specResourceMap = new Map<string, SpecResource>()
for (const collection of spec.collections) {
for (const resource of collection.resources) {
specResourceMap.set(resource.id, resource)
}
}
// Compare installed versions against spec versions
for (const entry of installed) {
const specResource = specResourceMap.get(entry.resource_id)
if (!specResource) continue
if (specResource.version > entry.version) {
updates.push({
resource_id: entry.resource_id,
installed_version: entry.version,
latest_version: specResource.version,
latest_url: specResource.url,
latest_size_mb: specResource.size_mb,
})
}
}
} catch (error) {
logger.error('[CollectionUpdateService] Error checking map updates:', error)
}
return updates
}
}

View File

@ -1,6 +1,5 @@
import { BaseStylesFile, MapLayer } from '../../types/maps.js' import { BaseStylesFile, MapLayer } from '../../types/maps.js'
import { import {
DownloadCollectionOperation,
DownloadRemoteSuccessCallback, DownloadRemoteSuccessCallback,
FileEntry, FileEntry,
} from '../../types/files.js' } from '../../types/files.js'
@ -16,14 +15,11 @@ import {
} from '../utils/fs.js' } from '../utils/fs.js'
import { join } from 'path' import { join } from 'path'
import urlJoin from 'url-join' import urlJoin from 'url-join'
import axios from 'axios'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
import { CuratedCollectionsFile, CuratedCollectionWithStatus } from '../../types/downloads.js' import InstalledResource from '#models/installed_resource'
import CuratedCollection from '#models/curated_collection' import { CollectionManifestService } from './collection_manifest_service.js'
import vine from '@vinejs/vine' import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
import { curatedCollectionsFileSchema } from '#validators/curated_collections'
import CuratedCollectionResource from '#models/curated_collection_resource'
const BASE_ASSETS_MIME_TYPES = [ const BASE_ASSETS_MIME_TYPES = [
'application/gzip', 'application/gzip',
@ -31,15 +27,11 @@ const BASE_ASSETS_MIME_TYPES = [
'application/octet-stream', 'application/octet-stream',
] ]
const COLLECTIONS_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json'
const PMTILES_ATTRIBUTION = const PMTILES_ATTRIBUTION =
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>' '<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream'] const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
interface IMapService { interface IMapService {
downloadCollection: DownloadCollectionOperation
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
} }
@ -99,34 +91,33 @@ export class MapService implements IMapService {
return true return true
} }
async downloadCollection(slug: string) { async downloadCollection(slug: string): Promise<string[] | null> {
const collection = await CuratedCollection.query() const manifestService = new CollectionManifestService()
.where('slug', slug) const spec = await manifestService.getSpecWithFallback<MapsSpec>('maps')
.andWhere('type', 'map') if (!spec) return null
.first()
if (!collection) {
return null
}
const resources = await collection.related('resources').query().where('downloaded', false) const collection = spec.collections.find((c) => c.slug === slug)
if (resources.length === 0) { if (!collection) return null
return null
} // Filter out already installed
const installed = await InstalledResource.query().where('resource_type', 'map')
const installedIds = new Set(installed.map((r) => r.resource_id))
const toDownload = collection.resources.filter((r) => !installedIds.has(r.id))
if (toDownload.length === 0) return null
const downloadUrls = resources.map((res) => res.url)
const downloadFilenames: string[] = [] const downloadFilenames: string[] = []
for (const url of downloadUrls) { for (const resource of toDownload) {
const existing = await RunDownloadJob.getByUrl(url) const existing = await RunDownloadJob.getByUrl(resource.url)
if (existing) { if (existing) {
logger.warn(`[MapService] Download already in progress for URL ${url}, skipping.`) logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
continue continue
} }
// Extract the filename from the URL const filename = resource.url.split('/').pop()
const filename = url.split('/').pop()
if (!filename) { if (!filename) {
logger.warn(`[MapService] Could not determine filename from URL ${url}, skipping.`) logger.warn(`[MapService] Could not determine filename from URL ${resource.url}, skipping.`)
continue continue
} }
@ -134,12 +125,17 @@ export class MapService implements IMapService {
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename) const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
await RunDownloadJob.dispatch({ await RunDownloadJob.dispatch({
url, url: resource.url,
filepath, filepath,
timeout: 30000, timeout: 30000,
allowedMimeTypes: PMTILES_MIME_TYPES, allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'map', filetype: 'map',
resourceMetadata: {
resource_id: resource.id,
version: resource.version,
collection_ref: slug,
},
}) })
} }
@ -147,11 +143,33 @@ export class MapService implements IMapService {
} }
async downloadRemoteSuccessCallback(urls: string[], _: boolean) { async downloadRemoteSuccessCallback(urls: string[], _: boolean) {
const resources = await CuratedCollectionResource.query().whereIn('url', urls) // Create InstalledResource entries for downloaded map files
for (const resource of resources) { for (const url of urls) {
resource.downloaded = true const filename = url.split('/').pop()
await resource.save() if (!filename) continue
logger.info(`[MapService] Marked resource as downloaded: ${resource.url}`)
const parsed = CollectionManifestService.parseMapFilename(filename)
if (!parsed) continue
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
const stats = await getFileStatsIfExists(filepath)
try {
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'map' },
{
version: parsed.version,
url: url,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
}
)
logger.info(`[MapService] Created InstalledResource entry for: ${parsed.resource_id}`)
} catch (error) {
logger.error(`[MapService] Failed to create InstalledResource for ${filename}:`, error)
}
} }
} }
@ -182,6 +200,12 @@ export class MapService implements IMapService {
) )
} }
// Parse resource metadata
const parsedFilename = CollectionManifestService.parseMapFilename(filename)
const resourceMetadata = parsedFilename
? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
: undefined
// Dispatch background job // Dispatch background job
const result = await RunDownloadJob.dispatch({ const result = await RunDownloadJob.dispatch({
url, url,
@ -190,6 +214,7 @@ export class MapService implements IMapService {
allowedMimeTypes: PMTILES_MIME_TYPES, allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'map', filetype: 'map',
resourceMetadata,
}) })
if (!result.job) { if (!result.job) {
@ -219,6 +244,7 @@ export class MapService implements IMapService {
} }
// Perform a HEAD request to get the content length // Perform a HEAD request to get the content length
const { default: axios } = await import('axios')
const response = await axios.head(url) const response = await axios.head(url)
if (response.status !== 200) { if (response.status !== 200) {
@ -229,7 +255,7 @@ export class MapService implements IMapService {
const size = contentLength ? parseInt(contentLength, 10) : 0 const size = contentLength ? parseInt(contentLength, 10) : 0
return { filename, size } return { filename, size }
} catch (error) { } catch (error: any) {
return { message: `Preflight check failed: ${error.message}` } return { message: `Preflight check failed: ${error.message}` }
} }
} }
@ -253,7 +279,7 @@ export class MapService implements IMapService {
* This is mainly useful because we need to know what host the user is accessing from in order to * This is mainly useful because we need to know what host the user is accessing from in order to
* properly generate URLs in the styles file * properly generate URLs in the styles file
* e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would * e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would
* fail to load. * fail to load.
*/ */
const sources = this.generateSourcesArray(host, regions) const sources = this.generateSourcesArray(host, regions)
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir) const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir)
@ -268,53 +294,14 @@ export class MapService implements IMapService {
return styles return styles
} }
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> { async listCuratedCollections(): Promise<CollectionWithStatus[]> {
const collections = await CuratedCollection.query().where('type', 'map').preload('resources') const manifestService = new CollectionManifestService()
return collections.map((collection) => ({ return manifestService.getMapCollectionsWithStatus()
...(collection.serialize() as CuratedCollection),
all_downloaded: collection.resources.every((res) => res.downloaded),
}))
} }
async fetchLatestCollections(): Promise<boolean> { async fetchLatestCollections(): Promise<boolean> {
try { const manifestService = new CollectionManifestService()
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL) return manifestService.fetchAndCacheSpec('maps')
const validated = await vine.validate({
schema: curatedCollectionsFileSchema,
data: response.data,
})
for (const collection of validated.collections) {
const { resources, ...restCollection } = collection; // we'll handle resources separately
// Upsert the collection itself
await CuratedCollection.updateOrCreate(
{ slug: restCollection.slug },
{
...restCollection,
type: 'map',
}
)
logger.info(`[MapService] Upserted curated collection: ${restCollection.slug}`)
// Upsert collection's resources
const resourcesResult = await CuratedCollectionResource.updateOrCreateMany('url', resources.map((res) => ({
...res,
curated_collection_slug: restCollection.slug, // add the foreign key
})))
logger.info(
`[MapService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
)
}
return true
} catch (error) {
console.error(error)
logger.error(`[MapService] Failed to download latest Kiwix collections:`, error)
return false
}
} }
async ensureBaseAssets(): Promise<boolean> { async ensureBaseAssets(): Promise<boolean> {
@ -361,7 +348,9 @@ export class MapService implements IMapService {
for (const region of regions) { for (const region of regions) {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) { if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
const regionName = region.name.replace('.pmtiles', '') // Strip .pmtiles and date suffix (e.g. "alaska_2025-12" -> "alaska") for stable source names
const parsed = CollectionManifestService.parseMapFilename(region.name)
const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')
const source: BaseStylesFile['sources'] = {} const source: BaseStylesFile['sources'] = {}
const sourceUrl = urlJoin(baseUrl, region.name) const sourceUrl = urlJoin(baseUrl, region.name)
@ -411,11 +400,11 @@ export class MapService implements IMapService {
async delete(file: string): Promise<void> { async delete(file: string): Promise<void> {
let fileName = file let fileName = file
if (!fileName.endsWith('.zim')) { if (!fileName.endsWith('.pmtiles')) {
fileName += '.zim' fileName += '.pmtiles'
} }
const fullPath = join(this.baseDirPath, fileName) const fullPath = join(this.baseDirPath, 'pmtiles', fileName)
const exists = await getFileStatsIfExists(fullPath) const exists = await getFileStatsIfExists(fullPath)
if (!exists) { if (!exists) {
@ -423,6 +412,16 @@ export class MapService implements IMapService {
} }
await deleteFileIfExists(fullPath) await deleteFileIfExists(fullPath)
// Clean up InstalledResource entry
const parsed = CollectionManifestService.parseMapFilename(fileName)
if (parsed) {
await InstalledResource.query()
.where('resource_id', parsed.resource_id)
.where('resource_type', 'map')
.delete()
logger.info(`[MapService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
}
} }
/* /*

View File

@ -17,32 +17,21 @@ import {
ZIM_STORAGE_PATH, ZIM_STORAGE_PATH,
} from '../utils/fs.js' } from '../utils/fs.js'
import { join } from 'path' import { join } from 'path'
import { CuratedCategory, CuratedCollectionWithStatus, CuratedCollectionsFile, WikipediaOption, WikipediaState } from '../../types/downloads.js' import { WikipediaOption, WikipediaState } from '../../types/downloads.js'
import vine from '@vinejs/vine' import vine from '@vinejs/vine'
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections' import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
import CuratedCollection from '#models/curated_collection'
import CuratedCollectionResource from '#models/curated_collection_resource'
import WikipediaSelection from '#models/wikipedia_selection' import WikipediaSelection from '#models/wikipedia_selection'
import ZimFileMetadata from '#models/zim_file_metadata' import InstalledResource from '#models/installed_resource'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
import { SERVICE_NAMES } from '../../constants/service_names.js' import { SERVICE_NAMES } from '../../constants/service_names.js'
import { CollectionManifestService } from './collection_manifest_service.js'
import type { CategoryWithStatus } from '../../types/collections.js'
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream'] const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
const CATEGORIES_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/kiwix-categories.json'
const COLLECTIONS_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/kiwix.json'
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json' const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json'
interface IZimService {
downloadCollection: DownloadCollectionOperation
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
}
@inject() @inject()
export class ZimService implements IZimService { export class ZimService {
constructor(private dockerService: DockerService) { } constructor(private dockerService: DockerService) { }
async list() { async list() {
@ -52,24 +41,8 @@ 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: enrichedFiles, files,
} }
} }
@ -164,10 +137,7 @@ export class ZimService implements IZimService {
} }
} }
async downloadRemote( async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
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`)
@ -186,19 +156,11 @@ 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 // Parse resource metadata for the download job
if (metadata) { const parsedFilename = CollectionManifestService.parseZimFilename(filename)
await ZimFileMetadata.updateOrCreate( const resourceMetadata = parsedFilename
{ filename }, ? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
{ : undefined
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({
@ -208,6 +170,7 @@ export class ZimService implements IZimService {
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'zim', filetype: 'zim',
resourceMetadata,
}) })
if (!result || !result.job) { if (!result || !result.job) {
@ -222,44 +185,64 @@ export class ZimService implements IZimService {
} }
} }
async downloadCollection(slug: string) { async listCuratedCategories(): Promise<CategoryWithStatus[]> {
const collection = await CuratedCollection.query().where('slug', slug).andWhere('type', 'zim').first() const manifestService = new CollectionManifestService()
if (!collection) { return manifestService.getCategoriesWithStatus()
return null }
async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<string[] | null> {
const manifestService = new CollectionManifestService()
const spec = await manifestService.getSpecWithFallback<import('../../types/collections.js').ZimCategoriesSpec>('zim_categories')
if (!spec) {
throw new Error('Could not load ZIM categories spec')
} }
const resources = await collection.related('resources').query().where('downloaded', false) const category = spec.categories.find((c) => c.slug === categorySlug)
if (resources.length === 0) { if (!category) {
return null throw new Error(`Category not found: ${categorySlug}`)
} }
const downloadUrls = resources.map((res) => res.url) const tier = category.tiers.find((t) => t.slug === tierSlug)
if (!tier) {
throw new Error(`Tier not found: ${tierSlug}`)
}
const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers)
// Filter out already installed
const installed = await InstalledResource.query().where('resource_type', 'zim')
const installedIds = new Set(installed.map((r) => r.resource_id))
const toDownload = allResources.filter((r) => !installedIds.has(r.id))
if (toDownload.length === 0) return null
const downloadFilenames: string[] = [] const downloadFilenames: string[] = []
for (const url of downloadUrls) { for (const resource of toDownload) {
const existing = await RunDownloadJob.getByUrl(url) const existingJob = await RunDownloadJob.getByUrl(resource.url)
if (existing) { if (existingJob) {
logger.warn(`[ZimService] Download already in progress for URL ${url}, skipping.`) logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
continue continue
} }
// Extract the filename from the URL const filename = resource.url.split('/').pop()
const filename = url.split('/').pop() if (!filename) continue
if (!filename) {
logger.warn(`[ZimService] Could not determine filename from URL ${url}, skipping.`)
continue
}
downloadFilenames.push(filename) downloadFilenames.push(filename)
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename) const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
await RunDownloadJob.dispatch({ await RunDownloadJob.dispatch({
url, url: resource.url,
filepath, filepath,
timeout: 30000, timeout: 30000,
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'zim', filetype: 'zim',
resourceMetadata: {
resource_id: resource.id,
version: resource.version,
collection_ref: categorySlug,
},
}) })
} }
@ -310,130 +293,36 @@ export class ZimService implements IZimService {
} }
} }
// Mark any curated collection resources with this download URL as downloaded // Create InstalledResource entries for downloaded files
const resources = await CuratedCollectionResource.query().whereIn('url', urls) for (const url of urls) {
for (const resource of resources) { // Skip Wikipedia files (managed separately)
resource.downloaded = true if (url.includes('wikipedia_en_')) continue
await resource.save()
}
}
async listCuratedCategories(): Promise<CuratedCategory[]> { const filename = url.split('/').pop()
try { if (!filename) continue
const response = await axios.get(CATEGORIES_URL)
const data = response.data
const validated = await vine.validate({ const parsed = CollectionManifestService.parseZimFilename(filename)
schema: curatedCategoriesFileSchema, if (!parsed) continue
data,
});
// Dynamically determine installed tier for each category const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
const categoriesWithStatus = await Promise.all( const stats = await getFileStatsIfExists(filepath)
validated.categories.map(async (category) => {
const installedTierSlug = await this.getInstalledTierForCategory(category)
return {
...category,
installedTierSlug,
}
})
)
return categoriesWithStatus try {
} catch (error) { const { DateTime } = await import('luxon')
logger.error(`[ZimService] Failed to fetch curated categories:`, error) await InstalledResource.updateOrCreate(
throw new Error('Failed to fetch curated categories or invalid format was received') { resource_id: parsed.resource_id, resource_type: 'zim' },
}
}
/**
* Dynamically determines which tier is installed for a category by checking
* which tier's resources are all downloaded. Returns the highest tier that
* is fully installed (considering that higher tiers include lower tier resources)
*/
private async getInstalledTierForCategory(category: CuratedCategory): Promise<string | undefined> {
const { files: diskFiles } = await this.list()
const diskFilenames = new Set(diskFiles.map((f) => f.name))
// Get all CuratedCollectionResources marked as downloaded
const downloadedResources = await CuratedCollectionResource.query()
.where('downloaded', true)
.select('url')
const downloadedUrls = new Set(downloadedResources.map((r) => r.url))
// Check each tier from highest to lowest (assuming tiers are ordered from low to high)
// We check in reverse to find the highest fully-installed tier
const reversedTiers = [...category.tiers].reverse()
for (const tier of reversedTiers) {
const allResourcesInstalled = tier.resources.every((resource) => {
// Check if resource is marked as downloaded in database
if (downloadedUrls.has(resource.url)) {
return true
}
// Fallback: check if file exists on disk (for resources not tracked in CuratedCollectionResource)
const filename = resource.url.split('/').pop()
if (filename && diskFilenames.has(filename)) {
return true
}
return false
})
if (allResourcesInstalled && tier.resources.length > 0) {
return tier.slug
}
}
return undefined
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
const collections = await CuratedCollection.query().where('type', 'zim').preload('resources')
return collections.map((collection) => ({
...(collection.serialize() as CuratedCollection),
all_downloaded: collection.resources.every((res) => res.downloaded),
}))
}
async fetchLatestCollections(): Promise<boolean> {
try {
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL)
const validated = await vine.validate({
schema: curatedCollectionsFileSchema,
data: response.data,
})
for (const collection of validated.collections) {
const { resources, ...restCollection } = collection; // we'll handle resources separately
// Upsert the collection itself
await CuratedCollection.updateOrCreate(
{ slug: restCollection.slug },
{ {
...restCollection, version: parsed.version,
type: 'zim', url: url,
file_path: filepath,
file_size_bytes: stats ? Number(stats.size) : null,
installed_at: DateTime.now(),
} }
) )
logger.info(`[ZimService] Upserted curated collection: ${restCollection.slug}`) logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)
} catch (error) {
// Upsert collection's resources logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
const resourcesResult = await CuratedCollectionResource.updateOrCreateMany('url', resources.map((res) => ({
...res,
curated_collection_slug: restCollection.slug, // add the foreign key
})))
logger.info(
`[ZimService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
)
} }
return true
} catch (error) {
logger.error(`[ZimService] Failed to download latest Kiwix collections:`, error)
return false
} }
} }
@ -452,9 +341,15 @@ export class ZimService implements IZimService {
await deleteFileIfExists(fullPath) await deleteFileIfExists(fullPath)
// Clean up metadata // Clean up InstalledResource entry
await ZimFileMetadata.query().where('filename', fileName).delete() const parsed = CollectionManifestService.parseZimFilename(fileName)
logger.info(`[ZimService] Deleted metadata for ZIM file: ${fileName}`) if (parsed) {
await InstalledResource.query()
.where('resource_id', parsed.resource_id)
.where('resource_type', 'zim')
.delete()
logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
}
} }
// Wikipedia selector methods // Wikipedia selector methods

View File

@ -56,6 +56,13 @@ export const downloadCollectionValidator = vine.compile(
}) })
) )
export const downloadCategoryTierValidator = vine.compile(
vine.object({
categorySlug: vine.string().trim().minLength(1),
tierSlug: vine.string().trim().minLength(1),
})
)
export const selectWikipediaValidator = vine.compile( export const selectWikipediaValidator = vine.compile(
vine.object({ vine.object({
optionId: vine.string().trim().minLength(1), optionId: vine.string().trim().minLength(1),

View File

@ -1,30 +1,20 @@
import vine from '@vinejs/vine' import vine from '@vinejs/vine'
export const curatedCollectionResourceValidator = vine.object({ // ---- Versioned resource validators (with id + version) ----
export const specResourceValidator = vine.object({
id: vine.string(),
version: vine.string(),
title: vine.string(), title: vine.string(),
description: vine.string(), description: vine.string(),
url: vine.string().url(), url: vine.string().url(),
size_mb: vine.number().min(0).optional(), size_mb: vine.number().min(0).optional(),
}) })
export const curatedCollectionValidator = vine.object({ // ---- ZIM Categories spec (versioned) ----
slug: vine.string(),
name: vine.string(),
description: vine.string(),
icon: vine.string(),
language: vine.string().minLength(2).maxLength(5),
resources: vine.array(curatedCollectionResourceValidator).minLength(1),
})
export const curatedCollectionsFileSchema = vine.object({ export const zimCategoriesSpecSchema = vine.object({
collections: vine.array(curatedCollectionValidator).minLength(1), spec_version: vine.string(),
})
/**
* For validating the categories file, which has a different structure than the collections file
* since it includes tiers within each category.
*/
export const curatedCategoriesFileSchema = vine.object({
categories: vine.array( categories: vine.array(
vine.object({ vine.object({
name: vine.string(), name: vine.string(),
@ -39,16 +29,47 @@ export const curatedCategoriesFileSchema = vine.object({
description: vine.string(), description: vine.string(),
recommended: vine.boolean().optional(), recommended: vine.boolean().optional(),
includesTier: vine.string().optional(), includesTier: vine.string().optional(),
resources: vine.array(curatedCollectionResourceValidator), resources: vine.array(specResourceValidator),
}) })
), ),
}) })
), ),
}) })
/** // ---- Maps spec (versioned) ----
* For validating the Wikipedia options file
*/ export const mapsSpecSchema = vine.object({
spec_version: vine.string(),
collections: vine.array(
vine.object({
slug: vine.string(),
name: vine.string(),
description: vine.string(),
icon: vine.string(),
language: vine.string().minLength(2).maxLength(5),
resources: vine.array(specResourceValidator).minLength(1),
})
).minLength(1),
})
// ---- Wikipedia spec (versioned) ----
export const wikipediaSpecSchema = vine.object({
spec_version: vine.string(),
options: vine.array(
vine.object({
id: vine.string(),
name: vine.string(),
description: vine.string(),
size_mb: vine.number().min(0),
url: vine.string().url().nullable(),
version: vine.string().nullable(),
})
).minLength(1),
})
// ---- Wikipedia validators (used by ZimService) ----
export const wikipediaOptionSchema = vine.object({ export const wikipediaOptionSchema = vine.object({
id: vine.string(), id: vine.string(),
name: vine.string(), name: vine.string(),

View File

@ -36,6 +36,15 @@ new Ignitor(APP_ROOT, { importer: IMPORTER })
}) })
app.listen('SIGTERM', () => app.terminate()) app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
app.ready(async () => {
try {
const collectionManifestService = new (await import('#services/collection_manifest_service')).CollectionManifestService()
await collectionManifestService.reconcileFromFilesystem()
} catch (error) {
// Catch and log any errors during reconciliation to prevent the server from crashing
console.error('Error during collection manifest reconciliation:', error)
}
})
}) })
.httpServer() .httpServer()
.start() .start()

View File

@ -0,0 +1,18 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'collection_manifests'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.string('type').primary() // 'zim_categories' | 'maps' | 'wikipedia'
table.string('spec_version').notNullable()
table.json('spec_data').notNullable()
table.timestamp('fetched_at').notNullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'installed_resources'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string('resource_id').notNullable()
table.enum('resource_type', ['zim', 'map']).notNullable()
table.string('collection_ref').nullable()
table.string('version').notNullable()
table.string('url').notNullable()
table.string('file_path').notNullable()
table.bigInteger('file_size_bytes').nullable()
table.timestamp('installed_at').notNullable()
table.unique(['resource_id', 'resource_type'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,13 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
async up() {
this.schema.dropTableIfExists('curated_collection_resources')
this.schema.dropTableIfExists('curated_collections')
this.schema.dropTableIfExists('zim_file_metadata')
}
async down() {
// These tables are legacy and intentionally not recreated
}
}

View File

@ -1,18 +1,18 @@
import { formatBytes } from '~/lib/util' import { formatBytes } from '~/lib/util'
import DynamicIcon, { DynamicIconName } from './DynamicIcon' import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import { CuratedCategory, CategoryTier } from '../../types/downloads' import type { CategoryWithStatus, SpecTier } from '../../types/collections'
import classNames from 'classnames' import classNames from 'classnames'
import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react' import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'
export interface CategoryCardProps { export interface CategoryCardProps {
category: CuratedCategory category: CategoryWithStatus
selectedTier?: CategoryTier | null selectedTier?: SpecTier | null
onClick?: (category: CuratedCategory) => void onClick?: (category: CategoryWithStatus) => void
} }
const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => { const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {
// Calculate total size range across all tiers // Calculate total size range across all tiers
const getTierTotalSize = (tier: CategoryTier, allTiers: CategoryTier[]): number => { const getTierTotalSize = (tier: SpecTier, allTiers: SpecTier[]): number => {
let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0) let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
// Add included tier sizes recursively // Add included tier sizes recursively

View File

@ -1,12 +1,12 @@
import { formatBytes } from '~/lib/util' import { formatBytes } from '~/lib/util'
import DynamicIcon, { DynamicIconName } from './DynamicIcon' import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import { CuratedCollectionWithStatus } from '../../types/downloads' import type { CollectionWithStatus } from '../../types/collections'
import classNames from 'classnames' import classNames from 'classnames'
import { IconCircleCheck } from '@tabler/icons-react' import { IconCircleCheck } from '@tabler/icons-react'
export interface CuratedCollectionCardProps { export interface CuratedCollectionCardProps {
collection: CuratedCollectionWithStatus collection: CollectionWithStatus
onClick?: (collection: CuratedCollectionWithStatus) => void; onClick?: (collection: CollectionWithStatus) => void;
size?: 'small' | 'large' size?: 'small' | 'large'
} }
@ -19,11 +19,11 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
<div <div
className={classNames( className={classNames(
'flex flex-col bg-desert-green rounded-lg p-6 text-white border border-b-desert-green shadow-sm hover:shadow-lg transition-shadow cursor-pointer', 'flex flex-col bg-desert-green rounded-lg p-6 text-white border border-b-desert-green shadow-sm hover:shadow-lg transition-shadow cursor-pointer',
{ 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_downloaded }, { 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_installed },
{ 'h-56': size === 'small', 'h-80': size === 'large' } { 'h-56': size === 'small', 'h-80': size === 'large' }
)} )}
onClick={() => { onClick={() => {
if (collection.all_downloaded) { if (collection.all_installed) {
return return
} }
if (onClick) { if (onClick) {
@ -37,7 +37,7 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
<DynamicIcon icon={collection.icon as DynamicIconName} className="w-6 h-6 mr-2" /> <DynamicIcon icon={collection.icon as DynamicIconName} className="w-6 h-6 mr-2" />
<h3 className="text-lg font-semibold">{collection.name}</h3> <h3 className="text-lg font-semibold">{collection.name}</h3>
</div> </div>
{collection.all_downloaded && ( {collection.all_installed && (
<div className="flex items-center"> <div className="flex items-center">
<IconCircleCheck <IconCircleCheck
className="w-5 h-5 text-lime-400 ml-2" className="w-5 h-5 text-lime-400 ml-2"

View File

@ -1,7 +1,8 @@
import { Fragment, useState, useEffect } from 'react' import { Fragment, useState, useEffect } from 'react'
import { Dialog, Transition } from '@headlessui/react' import { Dialog, Transition } from '@headlessui/react'
import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react' import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads' import type { CategoryWithStatus, SpecTier, SpecResource } from '../../types/collections'
import { resolveTierResources } from '~/lib/collections'
import { formatBytes } from '~/lib/util' import { formatBytes } from '~/lib/util'
import classNames from 'classnames' import classNames from 'classnames'
import DynamicIcon, { DynamicIconName } from './DynamicIcon' import DynamicIcon, { DynamicIconName } from './DynamicIcon'
@ -9,9 +10,9 @@ import DynamicIcon, { DynamicIconName } from './DynamicIcon'
interface TierSelectionModalProps { interface TierSelectionModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
category: CuratedCategory | null category: CategoryWithStatus | null
selectedTierSlug?: string | null selectedTierSlug?: string | null
onSelectTier: (category: CuratedCategory, tier: CategoryTier) => void onSelectTier: (category: CategoryWithStatus, tier: SpecTier) => void
} }
const TierSelectionModal: React.FC<TierSelectionModalProps> = ({ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
@ -34,24 +35,15 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
if (!category) return null if (!category) return null
// Get all resources for a tier (including inherited resources) // Get all resources for a tier (including inherited resources)
const getAllResourcesForTier = (tier: CategoryTier): CategoryResource[] => { const getAllResourcesForTier = (tier: SpecTier): SpecResource[] => {
const resources = [...tier.resources] return resolveTierResources(tier, category.tiers)
if (tier.includesTier) {
const includedTier = category.tiers.find(t => t.slug === tier.includesTier)
if (includedTier) {
resources.unshift(...getAllResourcesForTier(includedTier))
}
}
return resources
} }
const getTierTotalSize = (tier: CategoryTier): number => { const getTierTotalSize = (tier: SpecTier): number => {
return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0) return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
} }
const handleTierClick = (tier: CategoryTier) => { const handleTierClick = (tier: SpecTier) => {
// Toggle selection: if clicking the same tier, deselect it // Toggle selection: if clicking the same tier, deselect it
if (localSelectedSlug === tier.slug) { if (localSelectedSlug === tier.slug) {
setLocalSelectedSlug(null) setLocalSelectedSlug(null)

View File

@ -3,12 +3,8 @@ import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zi
import { ServiceSlim } from '../../types/services' import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files' import { FileEntry } from '../../types/files'
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system' import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import { import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
CuratedCategory, import type { CategoryWithStatus, CollectionWithStatus, CollectionUpdateCheckResult } from '../../types/collections'
CuratedCollectionWithStatus,
DownloadJobWithProgress,
WikipediaState,
} from '../../types/downloads'
import { catchInternal } from './util' import { catchInternal } from './util'
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama' import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
import { ChatResponse, ModelResponse } from 'ollama' import { ChatResponse, ModelResponse } from 'ollama'
@ -78,13 +74,14 @@ class API {
})() })()
} }
async downloadZimCollection(slug: string): Promise<{ async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<{
message: string message: string
slug: string categorySlug: string
tierSlug: string
resources: string[] | null resources: string[] | null
}> { }> {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.post('/zim/download-collection', { slug }) const response = await this.client.post('/zim/download-category-tier', { categorySlug, tierSlug })
return response.data return response.data
})() })()
} }
@ -130,9 +127,18 @@ class API {
})() })()
} }
async fetchLatestZimCollections(): Promise<{ success: boolean } | undefined> { async checkForCollectionUpdates() {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.post<{ success: boolean }>('/zim/fetch-latest-collections') const response = await this.client.post<CollectionUpdateCheckResult>('/collection-updates/check')
return response.data
})()
}
async refreshManifests(): Promise<{ success: boolean; changed: Record<string, boolean> } | undefined> {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; changed: Record<string, boolean> }>(
'/manifests/refresh'
)
return response.data return response.data
})() })()
} }
@ -189,14 +195,14 @@ class API {
async getBenchmarkResults() { async getBenchmarkResults() {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.get<{ results: BenchmarkResult[], total: number}>('/benchmark/results') const response = await this.client.get<{ results: BenchmarkResult[], total: number }>('/benchmark/results')
return response.data return response.data
})() })()
} }
async getLatestBenchmarkResult() { async getLatestBenchmarkResult() {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.get<{ result: BenchmarkResult | null}>('/benchmark/results/latest') const response = await this.client.get<{ result: BenchmarkResult | null }>('/benchmark/results/latest')
return response.data return response.data
})() })()
} }
@ -341,25 +347,16 @@ class API {
async listCuratedMapCollections() { async listCuratedMapCollections() {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.get<CuratedCollectionWithStatus[]>( const response = await this.client.get<CollectionWithStatus[]>(
'/maps/curated-collections' '/maps/curated-collections'
) )
return response.data return response.data
})() })()
} }
async listCuratedZimCollections() {
return catchInternal(async () => {
const response = await this.client.get<CuratedCollectionWithStatus[]>(
'/zim/curated-collections'
)
return response.data
})()
}
async listCuratedCategories() { async listCuratedCategories() {
return catchInternal(async () => { return catchInternal(async () => {
const response = await this.client.get<CuratedCategory[]>('/easy-setup/curated-categories') const response = await this.client.get<CategoryWithStatus[]>('/easy-setup/curated-categories')
return response.data return response.data
})() })()
} }

View File

@ -0,0 +1,31 @@
import type { SpecResource, SpecTier } from '../../types/collections'
/**
* Resolve all resources for a tier, including inherited resources from includesTier chain.
* Shared between frontend components (TierSelectionModal, CategoryCard, EasySetup).
*/
export function resolveTierResources(tier: SpecTier, allTiers: SpecTier[]): SpecResource[] {
const visited = new Set<string>()
return resolveTierResourcesInner(tier, allTiers, visited)
}
function resolveTierResourcesInner(
tier: SpecTier,
allTiers: SpecTier[],
visited: Set<string>
): SpecResource[] {
if (visited.has(tier.slug)) return [] // cycle detection
visited.add(tier.slug)
const resources: SpecResource[] = []
if (tier.includesTier) {
const included = allTiers.find((t) => t.slug === tier.includesTier)
if (included) {
resources.push(...resolveTierResourcesInner(included, allTiers, visited))
}
}
resources.push(...tier.resources)
return resources
}

View File

@ -17,7 +17,8 @@ import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus' import useInternetStatus from '~/hooks/useInternetStatus'
import { useSystemInfo } from '~/hooks/useSystemInfo' import { useSystemInfo } from '~/hooks/useSystemInfo'
import classNames from 'classnames' import classNames from 'classnames'
import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads' import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
import { resolveTierResources } from '~/lib/collections'
import { SERVICE_NAMES } from '../../../constants/service_names' import { SERVICE_NAMES } from '../../../constants/service_names'
// Capability definitions - maps user-friendly categories to services // Capability definitions - maps user-friendly categories to services
@ -105,38 +106,21 @@ const ADDITIONAL_TOOLS: Capability[] = [
type WizardStep = 1 | 2 | 3 | 4 type WizardStep = 1 | 2 | 3 | 4
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections' const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
const CURATED_ZIM_COLLECTIONS_KEY = 'curated-zim-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories' const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state' const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
// Helper to get all resources for a tier (including inherited resources)
const getAllResourcesForTier = (
tier: CategoryTier,
allTiers: CategoryTier[]
): CategoryResource[] => {
const resources = [...tier.resources]
if (tier.includesTier) {
const includedTier = allTiers.find((t) => t.slug === tier.includesTier)
if (includedTier) {
resources.unshift(...getAllResourcesForTier(includedTier, allTiers))
}
}
return resources
}
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) { export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
const [currentStep, setCurrentStep] = useState<WizardStep>(1) const [currentStep, setCurrentStep] = useState<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([]) const [selectedServices, setSelectedServices] = useState<string[]>([])
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([]) const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedZimCollections, setSelectedZimCollections] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([]) const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [showAdditionalTools, setShowAdditionalTools] = useState(false) const [showAdditionalTools, setShowAdditionalTools] = useState(false)
// Category/tier selection state // Category/tier selection state
const [selectedTiers, setSelectedTiers] = useState<Map<string, CategoryTier>>(new Map()) const [selectedTiers, setSelectedTiers] = useState<Map<string, SpecTier>>(new Map())
const [tierModalOpen, setTierModalOpen] = useState(false) const [tierModalOpen, setTierModalOpen] = useState(false)
const [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null) const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)
// Wikipedia selection state // Wikipedia selection state
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null) const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
@ -149,7 +133,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const anySelectionMade = const anySelectionMade =
selectedServices.length > 0 || selectedServices.length > 0 ||
selectedMapCollections.length > 0 || selectedMapCollections.length > 0 ||
selectedZimCollections.length > 0 ||
selectedTiers.size > 0 || selectedTiers.size > 0 ||
selectedAiModels.length > 0 || selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none') (selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -160,12 +143,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}) })
const { data: zimCollections, isLoading: isLoadingZims } = useQuery({
queryKey: [CURATED_ZIM_COLLECTIONS_KEY],
queryFn: () => api.listCuratedZimCollections(),
refetchOnWindowFocus: false,
})
// Fetch curated categories with tiers // Fetch curated categories with tiers
const { data: categories, isLoading: isLoadingCategories } = useQuery({ const { data: categories, isLoading: isLoadingCategories } = useQuery({
queryKey: [CURATED_CATEGORIES_KEY], queryKey: [CURATED_CATEGORIES_KEY],
@ -202,12 +179,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
) )
} }
const toggleZimCollection = (slug: string) => {
setSelectedZimCollections((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
)
}
const toggleAiModel = (modelName: string) => { const toggleAiModel = (modelName: string) => {
setSelectedAiModels((prev) => setSelectedAiModels((prev) =>
prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName] prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName]
@ -215,13 +186,13 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
} }
// Category/tier handlers // Category/tier handlers
const handleCategoryClick = (category: CuratedCategory) => { const handleCategoryClick = (category: CategoryWithStatus) => {
if (!isOnline) return if (!isOnline) return
setActiveCategory(category) setActiveCategory(category)
setTierModalOpen(true) setTierModalOpen(true)
} }
const handleTierSelect = (category: CuratedCategory, tier: CategoryTier) => { const handleTierSelect = (category: CategoryWithStatus, tier: SpecTier) => {
setSelectedTiers((prev) => { setSelectedTiers((prev) => {
const newMap = new Map(prev) const newMap = new Map(prev)
// If same tier is selected, deselect it // If same tier is selected, deselect it
@ -239,14 +210,14 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
setActiveCategory(null) setActiveCategory(null)
} }
// Get all resources from selected tiers for downloading // Get all resources from selected tiers for storage projection
const getSelectedTierResources = (): CategoryResource[] => { const getSelectedTierResources = (): SpecResource[] => {
if (!categories) return [] if (!categories) return []
const resources: CategoryResource[] = [] const resources: SpecResource[] = []
selectedTiers.forEach((tier, categorySlug) => { selectedTiers.forEach((tier, categorySlug) => {
const category = categories.find((c) => c.slug === categorySlug) const category = categories.find((c) => c.slug === categorySlug)
if (category) { if (category) {
resources.push(...getAllResourcesForTier(tier, category.tiers)) resources.push(...resolveTierResources(tier, category.tiers))
} }
}) })
return resources return resources
@ -270,16 +241,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
}) })
} }
// Add ZIM collections
if (zimCollections) {
selectedZimCollections.forEach((slug) => {
const collection = zimCollections.find((c) => c.slug === slug)
if (collection) {
totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
}
})
}
// Add AI models // Add AI models
if (recommendedModels) { if (recommendedModels) {
selectedAiModels.forEach((modelName) => { selectedAiModels.forEach((modelName) => {
@ -315,12 +276,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
}, [ }, [
selectedTiers, selectedTiers,
selectedMapCollections, selectedMapCollections,
selectedZimCollections,
selectedAiModels, selectedAiModels,
selectedWikipedia, selectedWikipedia,
categories, categories,
mapCollections, mapCollections,
zimCollections,
recommendedModels, recommendedModels,
wikipediaState, wikipediaState,
]) ])
@ -392,12 +351,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
await Promise.all(installPromises) await Promise.all(installPromises)
// Download collections, individual tier resources, and AI models // Download collections, category tiers, and AI models
const tierResources = getSelectedTierResources() const categoryTierPromises: Promise<any>[] = []
selectedTiers.forEach((tier, categorySlug) => {
categoryTierPromises.push(api.downloadCategoryTier(categorySlug, tier.slug))
})
const downloadPromises = [ const downloadPromises = [
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)), ...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)), ...categoryTierPromises,
...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)),
...selectedAiModels.map((modelName) => api.downloadModel(modelName)), ...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
] ]
@ -425,25 +387,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
} }
} }
const fetchLatestMapCollections = useMutation({ const refreshManifests = useMutation({
mutationFn: () => api.fetchLatestMapCollections(), mutationFn: () => api.refreshManifests(),
onSuccess: () => { onSuccess: () => {
addNotification({
message: 'Successfully fetched the latest map collections.',
type: 'success',
})
queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] }) queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] })
}, queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
})
const fetchLatestZIMCollections = useMutation({
mutationFn: () => api.fetchLatestZimCollections(),
onSuccess: () => {
addNotification({
message: 'Successfully fetched the latest ZIM collections.',
type: 'success',
})
queryClient.invalidateQueries({ queryKey: [CURATED_ZIM_COLLECTIONS_KEY] })
}, },
}) })
@ -452,18 +400,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
}, [currentStep]) }, [currentStep])
// Auto-fetch latest collections if the list is empty // Refresh manifests on mount to ensure we have latest data
useEffect(() => { useEffect(() => {
if (mapCollections && mapCollections.length === 0 && !fetchLatestMapCollections.isPending) { if (!refreshManifests.isPending) {
fetchLatestMapCollections.mutate() refreshManifests.mutate()
} }
}, [mapCollections, fetchLatestMapCollections]) }, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (zimCollections && zimCollections.length === 0 && !fetchLatestZIMCollections.isPending) {
fetchLatestZIMCollections.mutate()
}
}, [zimCollections, fetchLatestZIMCollections])
// Set Easy Setup as visited when user lands on this page // Set Easy Setup as visited when user lands on this page
useEffect(() => { useEffect(() => {
@ -789,13 +731,13 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<div <div
key={collection.slug} key={collection.slug}
onClick={() => onClick={() =>
isOnline && !collection.all_downloaded && toggleMapCollection(collection.slug) isOnline && !collection.all_installed && toggleMapCollection(collection.slug)
} }
className={classNames( className={classNames(
'relative', 'relative',
selectedMapCollections.includes(collection.slug) && selectedMapCollections.includes(collection.slug) &&
'ring-4 ring-desert-green rounded-lg', 'ring-4 ring-desert-green rounded-lg',
collection.all_downloaded && 'opacity-75', collection.all_installed && 'opacity-75',
!isOnline && 'opacity-50 cursor-not-allowed' !isOnline && 'opacity-50 cursor-not-allowed'
)} )}
> >
@ -996,49 +938,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</> </>
) : null} ) : null}
{/* Legacy flat collections - show if available and no categories */}
{(!categories || categories.length === 0) && (
<>
{isLoadingZims ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : zimCollections && zimCollections.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{zimCollections.map((collection) => (
<div
key={collection.slug}
onClick={() =>
isOnline &&
!collection.all_downloaded &&
toggleZimCollection(collection.slug)
}
className={classNames(
'relative',
selectedZimCollections.includes(collection.slug) &&
'ring-4 ring-desert-green rounded-lg',
collection.all_downloaded && 'opacity-75',
!isOnline && 'opacity-50 cursor-not-allowed'
)}
>
<CuratedCollectionCard collection={collection} size="large" />
{selectedZimCollections.includes(collection.slug) && (
<div className="absolute top-2 right-2 bg-desert-green rounded-full p-1">
<IconCheck size={32} className="text-white" />
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">
No content collections available at this time.
</p>
</div>
)}
</>
)}
</> </>
)} )}
@ -1059,7 +958,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const hasSelections = const hasSelections =
selectedServices.length > 0 || selectedServices.length > 0 ||
selectedMapCollections.length > 0 || selectedMapCollections.length > 0 ||
selectedZimCollections.length > 0 ||
selectedTiers.size > 0 || selectedTiers.size > 0 ||
selectedAiModels.length > 0 || selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none') (selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -1122,25 +1020,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</div> </div>
)} )}
{selectedZimCollections.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
ZIM Collections to Download ({selectedZimCollections.length})
</h3>
<ul className="space-y-2">
{selectedZimCollections.map((slug) => {
const collection = zimCollections?.find((c) => c.slug === slug)
return (
<li key={slug} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{collection?.name || slug}</span>
</li>
)
})}
</ul>
</div>
)}
{selectedTiers.size > 0 && ( {selectedTiers.size > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6"> <div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4"> <h3 className="text-xl font-semibold text-gray-900 mb-4">
@ -1149,7 +1028,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => { {Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
const category = categories?.find((c) => c.slug === categorySlug) const category = categories?.find((c) => c.slug === categorySlug)
if (!category) return null if (!category) return null
const resources = getAllResourcesForTier(tier, category.tiers) const resources = resolveTierResources(tier, category.tiers)
return ( return (
<div key={categorySlug} className="mb-4 last:mb-0"> <div key={categorySlug} className="mb-4 last:mb-0">
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
@ -1283,8 +1162,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return `${count} ${count === 1 ? 'capability' : 'capabilities'}` return `${count} ${count === 1 ? 'capability' : 'capabilities'}`
})()} })()}
, {selectedMapCollections.length} map region , {selectedMapCollections.length} map region
{selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length}{' '} {selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}
content pack{selectedZimCollections.length !== 1 && 's'},{' '} content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected {selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
</p> </p>
</div> </div>

View File

@ -13,7 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import useDownloads from '~/hooks/useDownloads' import useDownloads from '~/hooks/useDownloads'
import StyledSectionHeader from '~/components/StyledSectionHeader' import StyledSectionHeader from '~/components/StyledSectionHeader'
import CuratedCollectionCard from '~/components/CuratedCollectionCard' import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import { CuratedCollectionWithStatus } from '../../../types/downloads' import type { CollectionWithStatus } from '../../../types/collections'
import ActiveDownloads from '~/components/ActiveDownloads' import ActiveDownloads from '~/components/ActiveDownloads'
import Alert from '~/components/Alert' import Alert from '~/components/Alert'
@ -65,7 +65,7 @@ export default function MapsManager(props: {
} }
} }
async function downloadCollection(record: CuratedCollectionWithStatus) { async function downloadCollection(record: CollectionWithStatus) {
try { try {
await api.downloadMapCollection(record.slug) await api.downloadMapCollection(record.slug)
invalidateDownloads() invalidateDownloads()
@ -112,14 +112,14 @@ export default function MapsManager(props: {
) )
} }
async function confirmDownload(record: CuratedCollectionWithStatus) { async function confirmDownload(record: CollectionWithStatus) {
const isCollection = 'resources' in record const isCollection = 'resources' in record
openModal( openModal(
<StyledModal <StyledModal
title="Confirm Download?" title="Confirm Download?"
onConfirm={() => { onConfirm={() => {
if (isCollection) { if (isCollection) {
if (record.all_downloaded) { if (record.all_installed) {
addNotification({ addNotification({
message: `All resources in the collection "${record.name}" have already been downloaded.`, message: `All resources in the collection "${record.name}" have already been downloaded.`,
type: 'info', type: 'info',

View File

@ -23,37 +23,18 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import Input from '~/components/inputs/Input' import Input from '~/components/inputs/Input'
import { IconSearch, IconBooks } from '@tabler/icons-react' import { IconSearch, IconBooks } from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce' import useDebounce from '~/hooks/useDebounce'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import CategoryCard from '~/components/CategoryCard' import CategoryCard from '~/components/CategoryCard'
import TierSelectionModal from '~/components/TierSelectionModal' import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector' import WikipediaSelector from '~/components/WikipediaSelector'
import StyledSectionHeader from '~/components/StyledSectionHeader' import StyledSectionHeader from '~/components/StyledSectionHeader'
import { import type { CategoryWithStatus, SpecTier } from '../../../../types/collections'
CuratedCollectionWithStatus,
CuratedCategory,
CategoryTier,
CategoryResource,
} from '../../../../types/downloads'
import useDownloads from '~/hooks/useDownloads' import useDownloads from '~/hooks/useDownloads'
import ActiveDownloads from '~/components/ActiveDownloads' import ActiveDownloads from '~/components/ActiveDownloads'
import { SERVICE_NAMES } from '../../../../constants/service_names' import { SERVICE_NAMES } from '../../../../constants/service_names'
const CURATED_COLLECTIONS_KEY = 'curated-zim-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories' const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state' const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
// Helper to get all resources for a tier (including inherited resources)
const getAllResourcesForTier = (tier: CategoryTier, allTiers: CategoryTier[]): CategoryResource[] => {
const resources = [...tier.resources]
if (tier.includesTier) {
const includedTier = allTiers.find((t) => t.slug === tier.includesTier)
if (includedTier) {
resources.unshift(...getAllResourcesForTier(includedTier, allTiers))
}
}
return resources
}
export default function ZimRemoteExplorer() { export default function ZimRemoteExplorer() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const tableParentRef = useRef<HTMLDivElement>(null) const tableParentRef = useRef<HTMLDivElement>(null)
@ -69,7 +50,7 @@ export default function ZimRemoteExplorer() {
// Category/tier selection state // Category/tier selection state
const [tierModalOpen, setTierModalOpen] = useState(false) const [tierModalOpen, setTierModalOpen] = useState(false)
const [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null) const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)
// Wikipedia selection state // Wikipedia selection state
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null) const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
@ -79,12 +60,6 @@ export default function ZimRemoteExplorer() {
setQuery(val) setQuery(val)
}, 400) }, 400)
const { data: curatedCollections } = useQuery({
queryKey: [CURATED_COLLECTIONS_KEY],
queryFn: () => api.listCuratedZimCollections(),
refetchOnWindowFocus: false,
})
// Fetch curated categories with tiers // Fetch curated categories with tiers
const { data: categories } = useQuery({ const { data: categories } = useQuery({
queryKey: [CURATED_CATEGORIES_KEY], queryKey: [CURATED_CATEGORIES_KEY],
@ -170,24 +145,12 @@ export default function ZimRemoteExplorer() {
fetchOnBottomReached(tableParentRef.current) fetchOnBottomReached(tableParentRef.current)
}, [fetchOnBottomReached]) }, [fetchOnBottomReached])
async function confirmDownload(record: RemoteZimFileEntry | CuratedCollectionWithStatus) { async function confirmDownload(record: RemoteZimFileEntry) {
const isCollection = 'resources' in record
openModal( openModal(
<StyledModal <StyledModal
title="Confirm Download?" title="Confirm Download?"
onConfirm={() => { onConfirm={() => {
if (isCollection) { downloadFile(record)
if (record.all_downloaded) {
addNotification({
message: `All resources in the collection "${record.name}" have already been downloaded.`,
type: 'info',
})
return
}
downloadCollection(record)
} else {
downloadFile(record)
}
closeAllModals() closeAllModals()
}} }}
onCancel={closeAllModals} onCancel={closeAllModals}
@ -198,7 +161,7 @@ export default function ZimRemoteExplorer() {
> >
<p className="text-gray-700"> <p className="text-gray-700">
Are you sure you want to download{' '} Are you sure you want to download{' '}
<strong>{isCollection ? record.name : record.title}</strong>? It may take some time for it <strong>{record.title}</strong>? It may take some time for it
to be available depending on the file size and your internet connection. The Kiwix to be available depending on the file size and your internet connection. The Kiwix
application will be restarted after the download is complete. application will be restarted after the download is complete.
</p> </p>
@ -221,34 +184,19 @@ export default function ZimRemoteExplorer() {
} }
} }
async function downloadCollection(record: CuratedCollectionWithStatus) {
try {
await api.downloadZimCollection(record.slug)
invalidateDownloads()
} catch (error) {
console.error('Error downloading collection:', error)
}
}
// Category/tier handlers // Category/tier handlers
const handleCategoryClick = (category: CuratedCategory) => { const handleCategoryClick = (category: CategoryWithStatus) => {
if (!isOnline) return if (!isOnline) return
setActiveCategory(category) setActiveCategory(category)
setTierModalOpen(true) setTierModalOpen(true)
} }
const handleTierSelect = async (category: CuratedCategory, tier: CategoryTier) => { const handleTierSelect = async (category: CategoryWithStatus, tier: SpecTier) => {
// Get all resources for this tier (including inherited ones)
const resources = getAllResourcesForTier(tier, category.tiers)
// Download each resource
try { try {
for (const resource of resources) { await api.downloadCategoryTier(category.slug, tier.slug)
await api.downloadRemoteZimFile(resource.url)
}
addNotification({ addNotification({
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`, message: `Started downloading "${category.name} - ${tier.name}"`,
type: 'success', type: 'success',
}) })
invalidateDownloads() invalidateDownloads()
@ -309,24 +257,17 @@ export default function ZimRemoteExplorer() {
} }
} }
const fetchLatestCollections = useMutation({ const refreshManifests = useMutation({
mutationFn: () => api.fetchLatestZimCollections(), mutationFn: () => api.refreshManifests(),
onSuccess: () => { onSuccess: () => {
addNotification({ addNotification({
message: 'Successfully fetched the latest ZIM collections.', message: 'Successfully refreshed content collections.',
type: 'success', type: 'success',
}) })
queryClient.invalidateQueries({ queryKey: [CURATED_COLLECTIONS_KEY] }) queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
}, },
}) })
// Auto-fetch latest collections if the list is empty
useEffect(() => {
if (curatedCollections && curatedCollections.length === 0 && !fetchLatestCollections.isPending) {
fetchLatestCollections.mutate()
}
}, [curatedCollections, fetchLatestCollections])
return ( return (
<SettingsLayout> <SettingsLayout>
<Head title="Content Explorer | Project N.O.M.A.D." /> <Head title="Content Explorer | Project N.O.M.A.D." />
@ -357,11 +298,11 @@ export default function ZimRemoteExplorer() {
)} )}
<StyledSectionHeader title="Curated Content Collections" className="mt-8 !mb-4" /> <StyledSectionHeader title="Curated Content Collections" className="mt-8 !mb-4" />
<StyledButton <StyledButton
onClick={() => fetchLatestCollections.mutate()} onClick={() => refreshManifests.mutate()}
disabled={fetchLatestCollections.isPending} disabled={refreshManifests.isPending}
icon="IconCloudDownload" icon="IconCloudDownload"
> >
Fetch Latest Collections Refresh Collections
</StyledButton> </StyledButton>
{/* Wikipedia Selector */} {/* Wikipedia Selector */}
@ -386,7 +327,7 @@ export default function ZimRemoteExplorer() {
</div> </div>
) : null} ) : null}
{/* Tiered Category Collections - matches Easy Setup Wizard */} {/* Tiered Category Collections */}
<div className="flex items-center gap-3 mt-8 mb-4"> <div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm"> <div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-gray-700" /> <IconBooks className="w-6 h-6 text-gray-700" />
@ -419,20 +360,7 @@ export default function ZimRemoteExplorer() {
/> />
</> </>
) : ( ) : (
/* Legacy flat collections - fallback if no categories available */ <p className="text-gray-500 mt-4">No curated content categories available.</p>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{curatedCollections?.map((collection) => (
<CuratedCollectionCard
key={collection.slug}
collection={collection}
onClick={(collection) => confirmDownload(collection)}
size="large"
/>
))}
{curatedCollections && curatedCollections.length === 0 && (
<p className="text-gray-500">No curated collections available.</p>
)}
</div>
)} )}
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" /> <StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
<div className="flex justify-start mt-4"> <div className="flex justify-start mt-4">

View File

@ -17,6 +17,7 @@ import OllamaController from '#controllers/ollama_controller'
import RagController from '#controllers/rag_controller' import RagController from '#controllers/rag_controller'
import SettingsController from '#controllers/settings_controller' import SettingsController from '#controllers/settings_controller'
import SystemController from '#controllers/system_controller' import SystemController from '#controllers/system_controller'
import CollectionUpdatesController from '#controllers/collection_updates_controller'
import ZimController from '#controllers/zim_controller' import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router' import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main' import transmit from '@adonisjs/transmit/services/main'
@ -32,6 +33,8 @@ router.get('/maps', [MapsController, 'index'])
router.get('/easy-setup', [EasySetupController, 'index']) router.get('/easy-setup', [EasySetupController, 'index'])
router.get('/easy-setup/complete', [EasySetupController, 'complete']) router.get('/easy-setup/complete', [EasySetupController, 'complete'])
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories']) router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])
router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests'])
router.post('/api/collection-updates/check', [CollectionUpdatesController, 'checkForUpdates'])
router router
.group(() => { .group(() => {
@ -145,10 +148,9 @@ router
.group(() => { .group(() => {
router.get('/list', [ZimController, 'list']) router.get('/list', [ZimController, 'list'])
router.get('/list-remote', [ZimController, 'listRemote']) router.get('/list-remote', [ZimController, 'listRemote'])
router.get('/curated-collections', [ZimController, 'listCuratedCollections']) router.get('/curated-categories', [ZimController, 'listCuratedCategories'])
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
router.post('/download-remote', [ZimController, 'downloadRemote']) router.post('/download-remote', [ZimController, 'downloadRemote'])
router.post('/download-collection', [ZimController, 'downloadCollection']) router.post('/download-category-tier', [ZimController, 'downloadCategoryTier'])
router.get('/wikipedia', [ZimController, 'getWikipediaState']) router.get('/wikipedia', [ZimController, 'getWikipediaState'])
router.post('/wikipedia/select', [ZimController, 'selectWikipedia']) router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])

View File

@ -0,0 +1,86 @@
export type SpecResource = {
id: string
version: string
title: string
description: string
url: string
size_mb: number
}
export type SpecTier = {
name: string
slug: string
description: string
recommended?: boolean
includesTier?: string
resources: SpecResource[]
}
export type SpecCategory = {
name: string
slug: string
icon: string
description: string
language: string
tiers: SpecTier[]
}
export type SpecCollection = {
name: string
slug: string
description: string
icon: string
language: string
resources: SpecResource[]
}
export type ZimCategoriesSpec = {
spec_version: string
categories: SpecCategory[]
}
export type MapsSpec = {
spec_version: string
collections: SpecCollection[]
}
export type WikipediaOption = {
id: string
name: string
description: string
size_mb: number
url: string | null
version: string | null
}
export type WikipediaSpec = {
spec_version: string
options: WikipediaOption[]
}
export type ManifestType = 'zim_categories' | 'maps' | 'wikipedia'
export type ResourceStatus = 'installed' | 'not_installed' | 'update_available'
export type CategoryWithStatus = SpecCategory & {
installedTierSlug?: string
}
export type CollectionWithStatus = SpecCollection & {
all_installed: boolean
installed_count: number
total_count: number
}
export type CollectionResourceUpdateInfo = {
resource_id: string
installed_version: string
latest_version: string
latest_url: string
latest_size_mb?: number
}
export type CollectionUpdateCheckResult = {
spec_changed: boolean
resource_updates: CollectionResourceUpdateInfo[]
}

View File

@ -1,2 +0,0 @@
export type CuratedCollectionType = 'zim' | 'map'

View File

@ -23,33 +23,16 @@ export type DoResumableDownloadProgress = {
url: string url: string
} }
export type CuratedCollection = {
name: string
slug: string
description: string
icon: string
language: string
resources: {
title: string
description: string
size_mb: number
url: string
}[]
}
export type CuratedCollectionWithStatus = CuratedCollection & {
all_downloaded: boolean
}
export type CuratedCollectionsFile = {
collections: CuratedCollection[]
}
export type RunDownloadJobParams = Omit< export type RunDownloadJobParams = Omit<
DoResumableDownloadParams, DoResumableDownloadParams,
'onProgress' | 'onComplete' | 'signal' 'onProgress' | 'onComplete' | 'signal'
> & { > & {
filetype: string filetype: string
resourceMetadata?: {
resource_id: string
version: string
collection_ref: string | null
}
} }
export type DownloadJobWithProgress = { export type DownloadJobWithProgress = {
@ -60,37 +43,6 @@ export type DownloadJobWithProgress = {
filetype: string filetype: string
} }
// Tiered category types for curated collections UI
export type CategoryResource = {
title: string
description: string
size_mb?: number
url: string
}
export type CategoryTier = {
name: string
slug: string
description: string
recommended?: boolean
includesTier?: string
resources: CategoryResource[]
}
export type CuratedCategory = {
name: string
slug: string
icon: string
description: string
language: string
tiers: CategoryTier[]
installedTierSlug?: string
}
export type CuratedCategoriesFile = {
categories: CuratedCategory[]
}
// Wikipedia selector types // Wikipedia selector types
export type WikipediaOption = { export type WikipediaOption = {
id: string id: string

View File

@ -29,5 +29,4 @@ export type DownloadOptions = {
onComplete?: (filepath: string) => void onComplete?: (filepath: string) => void
} }
export type DownloadCollectionOperation = (slug: string) => Promise<string[] | null>
export type DownloadRemoteSuccessCallback = (urls: string[], restart: boolean) => Promise<void> export type DownloadRemoteSuccessCallback = (urls: string[], restart: boolean) => Promise<void>

View File

@ -1,4 +1,5 @@
{ {
"spec_version": "2026-02-11",
"categories": [ "categories": [
{ {
"name": "Medicine", "name": "Medicine",
@ -14,24 +15,32 @@
"recommended": true, "recommended": true,
"resources": [ "resources": [
{ {
"id": "zimgit-medicine_en",
"version": "2024-08",
"title": "Medical Library", "title": "Medical Library",
"description": "Field and emergency medicine books and guides", "description": "Field and emergency medicine books and guides",
"url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim", "url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim",
"size_mb": 67 "size_mb": 67
}, },
{ {
"id": "nhs.uk_en_medicines",
"version": "2025-12",
"title": "NHS Medicines A to Z", "title": "NHS Medicines A to Z",
"description": "How medicines work, dosages, side effects, and interactions", "description": "How medicines work, dosages, side effects, and interactions",
"url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim", "url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim",
"size_mb": 16 "size_mb": 16
}, },
{ {
"id": "fas-military-medicine_en",
"version": "2025-06",
"title": "Military Medicine", "title": "Military Medicine",
"description": "Tactical and field medicine manuals", "description": "Tactical and field medicine manuals",
"url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim", "url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim",
"size_mb": 78 "size_mb": 78
}, },
{ {
"id": "wwwnc.cdc.gov_en_all",
"version": "2024-11",
"title": "CDC Health Information", "title": "CDC Health Information",
"description": "Disease prevention, travel health, and outbreak information", "description": "Disease prevention, travel health, and outbreak information",
"url": "https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim", "url": "https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim",
@ -46,6 +55,8 @@
"includesTier": "medicine-essential", "includesTier": "medicine-essential",
"resources": [ "resources": [
{ {
"id": "medlineplus.gov_en_all",
"version": "2025-01",
"title": "MedlinePlus", "title": "MedlinePlus",
"description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements", "description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements",
"url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim", "url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim",
@ -60,18 +71,24 @@
"includesTier": "medicine-standard", "includesTier": "medicine-standard",
"resources": [ "resources": [
{ {
"id": "wikipedia_en_medicine_maxi",
"version": "2026-01",
"title": "Wikipedia Medicine", "title": "Wikipedia Medicine",
"description": "Curated medical articles from Wikipedia with images", "description": "Curated medical articles from Wikipedia with images",
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim", "url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim",
"size_mb": 2000 "size_mb": 2000
}, },
{ {
"id": "libretexts.org_en_med",
"version": "2025-01",
"title": "LibreTexts Medicine", "title": "LibreTexts Medicine",
"description": "Open-source medical textbooks and educational content", "description": "Open-source medical textbooks and educational content",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim",
"size_mb": 1100 "size_mb": 1100
}, },
{ {
"id": "librepathology_en_all_maxi",
"version": "2025-09",
"title": "LibrePathology", "title": "LibrePathology",
"description": "Pathology reference for disease identification", "description": "Pathology reference for disease identification",
"url": "https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim", "url": "https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim",
@ -95,12 +112,16 @@
"recommended": true, "recommended": true,
"resources": [ "resources": [
{ {
"id": "canadian_prepper_winterprepping_en",
"version": "2025-11",
"title": "Canadian Prepper: Winter Prepping", "title": "Canadian Prepper: Winter Prepping",
"description": "Video guides for winter survival and cold weather emergencies", "description": "Video guides for winter survival and cold weather emergencies",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim", "url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim",
"size_mb": 1340 "size_mb": 1340
}, },
{ {
"id": "canadian_prepper_bugoutroll_en",
"version": "2025-08",
"title": "Canadian Prepper: Bug Out Roll", "title": "Canadian Prepper: Bug Out Roll",
"description": "Essential gear selection for your bug-out bag", "description": "Essential gear selection for your bug-out bag",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim", "url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim",
@ -115,12 +136,16 @@
"includesTier": "survival-essential", "includesTier": "survival-essential",
"resources": [ "resources": [
{ {
"id": "canadian_prepper_bugoutconcepts_en",
"version": "2025-11",
"title": "Canadian Prepper: Bug Out Concepts", "title": "Canadian Prepper: Bug Out Concepts",
"description": "Strategies and planning for emergency evacuation", "description": "Strategies and planning for emergency evacuation",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim", "url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim",
"size_mb": 2890 "size_mb": 2890
}, },
{ {
"id": "urban-prepper_en_all",
"version": "2025-11",
"title": "Urban Prepper", "title": "Urban Prepper",
"description": "Comprehensive urban emergency preparedness video series", "description": "Comprehensive urban emergency preparedness video series",
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim", "url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim",
@ -135,6 +160,8 @@
"includesTier": "survival-standard", "includesTier": "survival-standard",
"resources": [ "resources": [
{ {
"id": "canadian_prepper_preppingfood_en",
"version": "2025-09",
"title": "Canadian Prepper: Prepping Food", "title": "Canadian Prepper: Prepping Food",
"description": "Long-term food storage and survival meal preparation", "description": "Long-term food storage and survival meal preparation",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim", "url": "https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim",
@ -158,6 +185,8 @@
"recommended": true, "recommended": true,
"resources": [ "resources": [
{ {
"id": "wikibooks_en_all_nopic",
"version": "2025-10",
"title": "Wikibooks", "title": "Wikibooks",
"description": "Open-content textbooks covering math, science, computing, and more", "description": "Open-content textbooks covering math, science, computing, and more",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim", "url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim",
@ -172,42 +201,56 @@
"includesTier": "education-essential", "includesTier": "education-essential",
"resources": [ "resources": [
{ {
"id": "ted_mul_ted-ed",
"version": "2025-07",
"title": "TED-Ed", "title": "TED-Ed",
"description": "Educational video lessons on science, history, literature, and more", "description": "Educational video lessons on science, history, literature, and more",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim", "url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim",
"size_mb": 5610 "size_mb": 5610
}, },
{ {
"id": "wikiversity_en_all_maxi",
"version": "2025-11",
"title": "Wikiversity", "title": "Wikiversity",
"description": "Tutorials, courses, and learning materials for all levels", "description": "Tutorials, courses, and learning materials for all levels",
"url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim", "url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim",
"size_mb": 2370 "size_mb": 2370
}, },
{ {
"id": "libretexts.org_en_math",
"version": "2025-01",
"title": "LibreTexts Mathematics", "title": "LibreTexts Mathematics",
"description": "Open-source math textbooks from algebra to calculus", "description": "Open-source math textbooks from algebra to calculus",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim",
"size_mb": 831 "size_mb": 831
}, },
{ {
"id": "libretexts.org_en_phys",
"version": "2025-01",
"title": "LibreTexts Physics", "title": "LibreTexts Physics",
"description": "Physics courses and textbooks", "description": "Physics courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim",
"size_mb": 560 "size_mb": 560
}, },
{ {
"id": "libretexts.org_en_chem",
"version": "2025-01",
"title": "LibreTexts Chemistry", "title": "LibreTexts Chemistry",
"description": "Chemistry courses and textbooks", "description": "Chemistry courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim",
"size_mb": 2180 "size_mb": 2180
}, },
{ {
"id": "libretexts.org_en_bio",
"version": "2025-01",
"title": "LibreTexts Biology", "title": "LibreTexts Biology",
"description": "Biology courses and textbooks", "description": "Biology courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim",
"size_mb": 2240 "size_mb": 2240
}, },
{ {
"id": "gutenberg_en_education",
"version": "2025-12",
"title": "Project Gutenberg: Education", "title": "Project Gutenberg: Education",
"description": "Classic educational texts and resources", "description": "Classic educational texts and resources",
"url": "https://download.kiwix.org/zim/gutenberg/gutenberg_en_education_2025-12.zim", "url": "https://download.kiwix.org/zim/gutenberg/gutenberg_en_education_2025-12.zim",
@ -222,36 +265,48 @@
"includesTier": "education-standard", "includesTier": "education-standard",
"resources": [ "resources": [
{ {
"id": "wikibooks_en_all_maxi",
"version": "2025-10",
"title": "Wikibooks (With Images)", "title": "Wikibooks (With Images)",
"description": "Open textbooks with full illustrations and diagrams", "description": "Open textbooks with full illustrations and diagrams",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim", "url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim",
"size_mb": 5400 "size_mb": 5400
}, },
{ {
"id": "ted_mul_ted-conference",
"version": "2025-08",
"title": "TED Conference", "title": "TED Conference",
"description": "Main TED conference talks on ideas worth spreading", "description": "Main TED conference talks on ideas worth spreading",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim", "url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim",
"size_mb": 16500 "size_mb": 16500
}, },
{ {
"id": "libretexts.org_en_human",
"version": "2025-01",
"title": "LibreTexts Humanities", "title": "LibreTexts Humanities",
"description": "Literature, philosophy, history, and social sciences", "description": "Literature, philosophy, history, and social sciences",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim",
"size_mb": 3730 "size_mb": 3730
}, },
{ {
"id": "libretexts.org_en_geo",
"version": "2025-01",
"title": "LibreTexts Geosciences", "title": "LibreTexts Geosciences",
"description": "Earth science, geology, and environmental studies", "description": "Earth science, geology, and environmental studies",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim",
"size_mb": 1190 "size_mb": 1190
}, },
{ {
"id": "libretexts.org_en_eng",
"version": "2025-01",
"title": "LibreTexts Engineering", "title": "LibreTexts Engineering",
"description": "Engineering courses and technical references", "description": "Engineering courses and technical references",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim",
"size_mb": 678 "size_mb": 678
}, },
{ {
"id": "libretexts.org_en_biz",
"version": "2025-01",
"title": "LibreTexts Business", "title": "LibreTexts Business",
"description": "Business, economics, and management textbooks", "description": "Business, economics, and management textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim", "url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim",
@ -275,12 +330,16 @@
"recommended": true, "recommended": true,
"resources": [ "resources": [
{ {
"id": "woodworking.stackexchange.com_en_all",
"version": "2025-12",
"title": "Woodworking Q&A", "title": "Woodworking Q&A",
"description": "Stack Exchange Q&A for carpentry, joinery, and woodcraft", "description": "Stack Exchange Q&A for carpentry, joinery, and woodcraft",
"url": "https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2025-12.zim",
"size_mb": 99 "size_mb": 99
}, },
{ {
"id": "mechanics.stackexchange.com_en_all",
"version": "2025-12",
"title": "Motor Vehicle Maintenance Q&A", "title": "Motor Vehicle Maintenance Q&A",
"description": "Stack Exchange Q&A for car and motorcycle repair", "description": "Stack Exchange Q&A for car and motorcycle repair",
"url": "https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2025-12.zim",
@ -295,6 +354,8 @@
"includesTier": "diy-essential", "includesTier": "diy-essential",
"resources": [ "resources": [
{ {
"id": "diy.stackexchange.com_en_all",
"version": "2025-12",
"title": "DIY & Home Improvement Q&A", "title": "DIY & Home Improvement Q&A",
"description": "Stack Exchange Q&A for home repairs, electrical, plumbing, and construction", "description": "Stack Exchange Q&A for home repairs, electrical, plumbing, and construction",
"url": "https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2025-12.zim",
@ -309,6 +370,8 @@
"includesTier": "diy-standard", "includesTier": "diy-standard",
"resources": [ "resources": [
{ {
"id": "ifixit_en_all",
"version": "2025-12",
"title": "iFixit Repair Guides", "title": "iFixit Repair Guides",
"description": "Step-by-step repair guides for electronics, appliances, and vehicles", "description": "Step-by-step repair guides for electronics, appliances, and vehicles",
"url": "https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim",
@ -332,12 +395,16 @@
"recommended": true, "recommended": true,
"resources": [ "resources": [
{ {
"id": "foss.cooking_en_all",
"version": "2025-11",
"title": "FOSS Cooking", "title": "FOSS Cooking",
"description": "Quick and easy cooking guides and recipes", "description": "Quick and easy cooking guides and recipes",
"url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim", "url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim",
"size_mb": 24 "size_mb": 24
}, },
{ {
"id": "based.cooking_en_all",
"version": "2025-11",
"title": "Based.Cooking", "title": "Based.Cooking",
"description": "Simple, practical recipes from the community", "description": "Simple, practical recipes from the community",
"url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim", "url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim",
@ -352,18 +419,24 @@
"includesTier": "agriculture-essential", "includesTier": "agriculture-essential",
"resources": [ "resources": [
{ {
"id": "gardening.stackexchange.com_en_all",
"version": "2025-12",
"title": "Gardening Q&A", "title": "Gardening Q&A",
"description": "Stack Exchange Q&A for growing your own food, plant care, and landscaping", "description": "Stack Exchange Q&A for growing your own food, plant care, and landscaping",
"url": "https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2025-12.zim",
"size_mb": 923 "size_mb": 923
}, },
{ {
"id": "cooking.stackexchange.com_en_all",
"version": "2025-12",
"title": "Cooking Q&A", "title": "Cooking Q&A",
"description": "Stack Exchange Q&A for cooking techniques, food safety, and recipes", "description": "Stack Exchange Q&A for cooking techniques, food safety, and recipes",
"url": "https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2025-12.zim",
"size_mb": 236 "size_mb": 236
}, },
{ {
"id": "zimgit-food-preparation_en",
"version": "2025-04",
"title": "Food for Preppers", "title": "Food for Preppers",
"description": "Recipes and techniques for food preservation and long-term storage", "description": "Recipes and techniques for food preservation and long-term storage",
"url": "https://download.kiwix.org/zim/other/zimgit-food-preparation_en_2025-04.zim", "url": "https://download.kiwix.org/zim/other/zimgit-food-preparation_en_2025-04.zim",
@ -378,6 +451,8 @@
"includesTier": "agriculture-standard", "includesTier": "agriculture-standard",
"resources": [ "resources": [
{ {
"id": "lrnselfreliance_en_all",
"version": "2025-12",
"title": "Learning Self-Reliance: Homesteading", "title": "Learning Self-Reliance: Homesteading",
"description": "Beekeeping, animal husbandry, and sustainable living practices", "description": "Beekeeping, animal husbandry, and sustainable living practices",
"url": "https://download.kiwix.org/zim/videos/lrnselfreliance_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/videos/lrnselfreliance_en_all_2025-12.zim",
@ -401,30 +476,40 @@
"recommended": true, "recommended": true,
"resources": [ "resources": [
{ {
"id": "freecodecamp_en_all",
"version": "2025-11",
"title": "freeCodeCamp", "title": "freeCodeCamp",
"description": "Interactive programming tutorials - JavaScript, algorithms, and data structures", "description": "Interactive programming tutorials - JavaScript, algorithms, and data structures",
"url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim", "url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim",
"size_mb": 8 "size_mb": 8
}, },
{ {
"id": "devdocs_en_python",
"version": "2026-01",
"title": "Python Documentation", "title": "Python Documentation",
"description": "Complete Python language reference and tutorials", "description": "Complete Python language reference and tutorials",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim",
"size_mb": 4 "size_mb": 4
}, },
{ {
"id": "devdocs_en_javascript",
"version": "2026-01",
"title": "JavaScript Documentation", "title": "JavaScript Documentation",
"description": "MDN JavaScript reference and guides", "description": "MDN JavaScript reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim",
"size_mb": 3 "size_mb": 3
}, },
{ {
"id": "devdocs_en_html",
"version": "2026-01",
"title": "HTML Documentation", "title": "HTML Documentation",
"description": "MDN HTML elements and attributes reference", "description": "MDN HTML elements and attributes reference",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_html_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_html_2026-01.zim",
"size_mb": 2 "size_mb": 2
}, },
{ {
"id": "devdocs_en_css",
"version": "2026-01",
"title": "CSS Documentation", "title": "CSS Documentation",
"description": "MDN CSS properties and selectors reference", "description": "MDN CSS properties and selectors reference",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim",
@ -439,30 +524,40 @@
"includesTier": "computing-essential", "includesTier": "computing-essential",
"resources": [ "resources": [
{ {
"id": "arduino.stackexchange.com_en_all",
"version": "2025-12",
"title": "Arduino Q&A", "title": "Arduino Q&A",
"description": "Stack Exchange Q&A for Arduino microcontroller projects", "description": "Stack Exchange Q&A for Arduino microcontroller projects",
"url": "https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2025-12.zim",
"size_mb": 247 "size_mb": 247
}, },
{ {
"id": "raspberrypi.stackexchange.com_en_all",
"version": "2025-12",
"title": "Raspberry Pi Q&A", "title": "Raspberry Pi Q&A",
"description": "Stack Exchange Q&A for Raspberry Pi projects and troubleshooting", "description": "Stack Exchange Q&A for Raspberry Pi projects and troubleshooting",
"url": "https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2025-12.zim",
"size_mb": 285 "size_mb": 285
}, },
{ {
"id": "devdocs_en_node",
"version": "2026-01",
"title": "Node.js Documentation", "title": "Node.js Documentation",
"description": "Node.js API reference and guides", "description": "Node.js API reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim",
"size_mb": 1 "size_mb": 1
}, },
{ {
"id": "devdocs_en_react",
"version": "2026-01",
"title": "React Documentation", "title": "React Documentation",
"description": "React library reference and tutorials", "description": "React library reference and tutorials",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-01.zim",
"size_mb": 3 "size_mb": 3
}, },
{ {
"id": "devdocs_en_git",
"version": "2026-01",
"title": "Git Documentation", "title": "Git Documentation",
"description": "Git version control reference", "description": "Git version control reference",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim",
@ -477,24 +572,32 @@
"includesTier": "computing-standard", "includesTier": "computing-standard",
"resources": [ "resources": [
{ {
"id": "electronics.stackexchange.com_en_all",
"version": "2025-12",
"title": "Electronics Q&A", "title": "Electronics Q&A",
"description": "Stack Exchange Q&A for circuit design, components, and electrical engineering", "description": "Stack Exchange Q&A for circuit design, components, and electrical engineering",
"url": "https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2025-12.zim",
"size_mb": 3800 "size_mb": 3800
}, },
{ {
"id": "robotics.stackexchange.com_en_all",
"version": "2025-12",
"title": "Robotics Q&A", "title": "Robotics Q&A",
"description": "Stack Exchange Q&A for robotics projects and automation", "description": "Stack Exchange Q&A for robotics projects and automation",
"url": "https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2025-12.zim", "url": "https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2025-12.zim",
"size_mb": 233 "size_mb": 233
}, },
{ {
"id": "devdocs_en_docker",
"version": "2026-01",
"title": "Docker Documentation", "title": "Docker Documentation",
"description": "Docker container reference and guides", "description": "Docker container reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim",
"size_mb": 2 "size_mb": 2
}, },
{ {
"id": "devdocs_en_bash",
"version": "2026-01",
"title": "Linux Documentation", "title": "Linux Documentation",
"description": "Linux command reference and system administration", "description": "Linux command reference and system administration",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim", "url": "https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim",

View File

@ -1,70 +0,0 @@
{
"collections": [
{
"name": "Medicine Essentials",
"slug": "medicine-essentials",
"description": "A comprehensive collection of medical textbooks and resources covering various fields of medicine, including anatomy, physiology, pharmacology, pathology, and clinical practice. Ideal for medical students, healthcare professionals, and anyone interested in medical knowledge.",
"icon": "IconStethoscope",
"language": "en",
"resources": [
{
"title": "Medical Library",
"description": "A collection of books pertaining to field and emergency medicine",
"url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim",
"size_mb": 67
},
{
"title": "Military Medicine",
"description": "Doctrinal and Instructional manuals",
"url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim",
"size_mb": 78
},
{
"title": "NHS' Medicines A to Z",
"description": "How medicines work, how and when to take them",
"url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-09.zim",
"size_mb": 17
}
]
},
{
"name": "Prepper's Library",
"slug": "preppers-library",
"description": "A curated collection of resources for preppers and survivalists, including guides on emergency preparedness, self-sufficiency, first aid, food storage, and survival skills. Perfect for individuals looking to enhance their readiness for various scenarios.",
"icon": "IconShieldCheck",
"language": "en",
"resources": [
{
"title": "Canadian Prepper: Winter Prepping",
"description": "Winter Prepping: Prepare for winter survival and emergencies",
"size_mb": 1250,
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim"
},
{
"title": "Canadian Prepper: Prepping Food",
"description": "Prepping Food: Long-term food storage and survival meals",
"size_mb": 2000,
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim"
},
{
"title": "Urban Prepper",
"description": "A collection of recipes and techniques for preparing food",
"size_mb": 2000,
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim"
},
{
"title": "Canadian Prepper: Bug Out Concepts",
"description": "Bug Out Concepts: Strategies and tips for effective bug-out plans",
"size_mb": 2690,
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim"
},
{
"title": "Canadian Prepper: Bug Out Roll",
"description": "Bug Out Roll: Essential gear for your ultimate bug-out bag",
"size_mb": 929,
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim"
}
]
}
]
}

View File

@ -1,4 +1,5 @@
{ {
"spec_version": "2026-02-11",
"collections": [ "collections": [
{ {
"name": "Pacific Region", "name": "Pacific Region",
@ -8,33 +9,43 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "alaska",
"version": "2025-12",
"title": "Alaska", "title": "Alaska",
"description": "Topographic maps for the state of Alaska.", "description": "Topographic maps for the state of Alaska.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alaska.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alaska_2025-12.pmtiles",
"size_mb": 684 "size_mb": 684
}, },
{ {
"id": "california",
"version": "2025-12",
"title": "California", "title": "California",
"description": "Topographic maps for the state of California.", "description": "Topographic maps for the state of California.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california_2025-12.pmtiles",
"size_mb": 1100 "size_mb": 1100
}, },
{ {
"id": "hawaii",
"version": "2025-12",
"title": "Hawaii", "title": "Hawaii",
"description": "Topographic maps for the state of Hawaii.", "description": "Topographic maps for the state of Hawaii.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/hawaii.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/hawaii_2025-12.pmtiles",
"size_mb": 28 "size_mb": 28
}, },
{ {
"id": "oregon",
"version": "2025-12",
"title": "Oregon", "title": "Oregon",
"description": "Topographic maps for the state of Oregon.", "description": "Topographic maps for the state of Oregon.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oregon.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oregon_2025-12.pmtiles",
"size_mb": 379 "size_mb": 379
}, },
{ {
"id": "washington",
"version": "2025-12",
"title": "Washington", "title": "Washington",
"description": "Topographic maps for the state of Washington.", "description": "Topographic maps for the state of Washington.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/washington.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/washington_2025-12.pmtiles",
"size_mb": 466 "size_mb": 466
} }
] ]
@ -47,51 +58,67 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "arizona",
"version": "2025-12",
"title": "Arizona", "title": "Arizona",
"description": "Topographic maps for the state of Arizona.", "description": "Topographic maps for the state of Arizona.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arizona.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arizona_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "colorado",
"version": "2025-12",
"title": "Colorado", "title": "Colorado",
"description": "Topographic maps for the state of Colorado.", "description": "Topographic maps for the state of Colorado.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/colorado.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/colorado_2025-12.pmtiles",
"size_mb": 450 "size_mb": 450
}, },
{ {
"id": "idaho",
"version": "2025-12",
"title": "Idaho", "title": "Idaho",
"description": "Topographic maps for the state of Idaho.", "description": "Topographic maps for the state of Idaho.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/idaho.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/idaho_2025-12.pmtiles",
"size_mb": 220 "size_mb": 220
}, },
{ {
"id": "montana",
"version": "2025-12",
"title": "Montana", "title": "Montana",
"description": "Topographic maps for the state of Montana.", "description": "Topographic maps for the state of Montana.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/montana.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/montana_2025-12.pmtiles",
"size_mb": 270 "size_mb": 270
}, },
{ {
"id": "nevada",
"version": "2025-12",
"title": "Nevada", "title": "Nevada",
"description": "Topographic maps for the state of Nevada.", "description": "Topographic maps for the state of Nevada.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nevada.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nevada_2025-12.pmtiles",
"size_mb": 200 "size_mb": 200
}, },
{ {
"id": "new_mexico",
"version": "2025-12",
"title": "New Mexico", "title": "New Mexico",
"description": "Topographic maps for the state of New Mexico.", "description": "Topographic maps for the state of New Mexico.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_mexico.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_mexico_2025-12.pmtiles",
"size_mb": 230 "size_mb": 230
}, },
{ {
"id": "utah",
"version": "2025-12",
"title": "Utah", "title": "Utah",
"description": "Topographic maps for the state of Utah.", "description": "Topographic maps for the state of Utah.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/utah.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/utah_2025-12.pmtiles",
"size_mb": 240 "size_mb": 240
}, },
{ {
"id": "wyoming",
"version": "2025-12",
"title": "Wyoming", "title": "Wyoming",
"description": "Topographic maps for the state of Wyoming.", "description": "Topographic maps for the state of Wyoming.",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wyoming.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wyoming_2025-12.pmtiles",
"size_mb": 210 "size_mb": 210
} }
] ]
@ -104,27 +131,35 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "arkansas",
"version": "2025-12",
"title": "Arkansas", "title": "Arkansas",
"description": "Topographic maps for the state of Arkansas", "description": "Topographic maps for the state of Arkansas",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arkansas.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arkansas_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "oklahoma",
"version": "2025-12",
"title": "Oklahoma", "title": "Oklahoma",
"description": "Topographic maps for the state of Oklahoma", "description": "Topographic maps for the state of Oklahoma",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oklahoma.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oklahoma_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "louisiana",
"version": "2025-12",
"title": "Louisiana", "title": "Louisiana",
"description": "Topographic maps for the state of Louisiana", "description": "Topographic maps for the state of Louisiana",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/louisiana.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/louisiana_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "texas",
"version": "2025-12",
"title": "Texas", "title": "Texas",
"description": "Topographic maps for the state of Texas", "description": "Topographic maps for the state of Texas",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/texas.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/texas_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]
@ -137,27 +172,35 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "alabama",
"version": "2025-12",
"title": "Alabama", "title": "Alabama",
"description": "Topographic maps for the state of Alabama", "description": "Topographic maps for the state of Alabama",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alabama.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alabama_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "kentucky",
"version": "2025-12",
"title": "Kentucky", "title": "Kentucky",
"description": "Topographic maps for the state of Kentucky", "description": "Topographic maps for the state of Kentucky",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kentucky.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kentucky_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "mississippi",
"version": "2025-12",
"title": "Mississippi", "title": "Mississippi",
"description": "Topographic maps for the state of Mississippi", "description": "Topographic maps for the state of Mississippi",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/mississippi.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/mississippi_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "tennessee",
"version": "2025-12",
"title": "Tennessee", "title": "Tennessee",
"description": "Topographic maps for the state of Tennessee", "description": "Topographic maps for the state of Tennessee",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/tennessee.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/tennessee_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]
@ -170,57 +213,75 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "delaware",
"version": "2025-12",
"title": "Delaware", "title": "Delaware",
"description": "Topographic maps for the state of Delaware", "description": "Topographic maps for the state of Delaware",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/delaware.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/delaware_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "district_of_columbia",
"version": "2025-12",
"title": "District_Of_Columbia", "title": "District_Of_Columbia",
"description": "Topographic maps for the state of District_Of_Columbia", "description": "Topographic maps for the state of District_Of_Columbia",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/district_of_columbia.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/district_of_columbia_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "florida",
"version": "2025-12",
"title": "Florida", "title": "Florida",
"description": "Topographic maps for the state of Florida", "description": "Topographic maps for the state of Florida",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/florida.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/florida_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "georgia",
"version": "2025-12",
"title": "Georgia", "title": "Georgia",
"description": "Topographic maps for the state of Georgia", "description": "Topographic maps for the state of Georgia",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/georgia.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/georgia_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "maryland",
"version": "2025-12",
"title": "Maryland", "title": "Maryland",
"description": "Topographic maps for the state of Maryland", "description": "Topographic maps for the state of Maryland",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maryland.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maryland_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "north_carolina",
"version": "2025-12",
"title": "North_Carolina", "title": "North_Carolina",
"description": "Topographic maps for the state of North_Carolina", "description": "Topographic maps for the state of North_Carolina",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_carolina.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_carolina_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "south_carolina",
"version": "2025-12",
"title": "South_Carolina", "title": "South_Carolina",
"description": "Topographic maps for the state of South_Carolina", "description": "Topographic maps for the state of South_Carolina",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_carolina.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_carolina_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "virginia",
"version": "2025-12",
"title": "Virginia", "title": "Virginia",
"description": "Topographic maps for the state of Virginia", "description": "Topographic maps for the state of Virginia",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/virginia.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/virginia_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "west_virginia",
"version": "2025-12",
"title": "West_Virginia", "title": "West_Virginia",
"description": "Topographic maps for the state of West_Virginia", "description": "Topographic maps for the state of West_Virginia",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/west_virginia.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/west_virginia_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]
@ -233,45 +294,59 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "iowa",
"version": "2025-12",
"title": "Iowa", "title": "Iowa",
"description": "Topographic maps for the state of Iowa", "description": "Topographic maps for the state of Iowa",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/iowa.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/iowa_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "kansas",
"version": "2025-12",
"title": "Kansas", "title": "Kansas",
"description": "Topographic maps for the state of Kansas", "description": "Topographic maps for the state of Kansas",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kansas.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kansas_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "minnesota",
"version": "2025-12",
"title": "Minnesota", "title": "Minnesota",
"description": "Topographic maps for the state of Minnesota", "description": "Topographic maps for the state of Minnesota",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/minnesota.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/minnesota_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "missouri",
"version": "2025-12",
"title": "Missouri", "title": "Missouri",
"description": "Topographic maps for the state of Missouri", "description": "Topographic maps for the state of Missouri",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/missouri.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/missouri_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "nebraska",
"version": "2025-12",
"title": "Nebraska", "title": "Nebraska",
"description": "Topographic maps for the state of Nebraska", "description": "Topographic maps for the state of Nebraska",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nebraska.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nebraska_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "north_dakota",
"version": "2025-12",
"title": "North_Dakota", "title": "North_Dakota",
"description": "Topographic maps for the state of North_Dakota", "description": "Topographic maps for the state of North_Dakota",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_dakota.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_dakota_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "south_dakota",
"version": "2025-12",
"title": "South_Dakota", "title": "South_Dakota",
"description": "Topographic maps for the state of South_Dakota", "description": "Topographic maps for the state of South_Dakota",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_dakota.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_dakota_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]
@ -284,27 +359,35 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "illinois",
"version": "2025-12",
"title": "Illinois", "title": "Illinois",
"description": "Topographic maps for the state of Illinois", "description": "Topographic maps for the state of Illinois",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/illinois.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/illinois_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "indianamichigan",
"version": "2025-12",
"title": "Indianamichigan", "title": "Indianamichigan",
"description": "Topographic maps for the state of Indianamichigan", "description": "Topographic maps for the state of Indianamichigan",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indianamichigan.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indianamichigan_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "ohio",
"version": "2025-12",
"title": "Ohio", "title": "Ohio",
"description": "Topographic maps for the state of Ohio", "description": "Topographic maps for the state of Ohio",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/ohio.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/ohio_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "wisconsin",
"version": "2025-12",
"title": "Wisconsin", "title": "Wisconsin",
"description": "Topographic maps for the state of Wisconsin", "description": "Topographic maps for the state of Wisconsin",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wisconsin.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wisconsin_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]
@ -317,21 +400,27 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "new_jersey",
"version": "2025-12",
"title": "New_Jersey", "title": "New_Jersey",
"description": "Topographic maps for the state of New_Jersey", "description": "Topographic maps for the state of New_Jersey",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_jersey.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_jersey_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "new_york",
"version": "2025-12",
"title": "New_York", "title": "New_York",
"description": "Topographic maps for the state of New_York", "description": "Topographic maps for the state of New_York",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_york.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_york_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "pennsylvania",
"version": "2025-12",
"title": "Pennsylvania", "title": "Pennsylvania",
"description": "Topographic maps for the state of Pennsylvania", "description": "Topographic maps for the state of Pennsylvania",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/pennsylvania.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/pennsylvania_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]
@ -344,39 +433,51 @@
"language": "en", "language": "en",
"resources": [ "resources": [
{ {
"id": "connecticut",
"version": "2025-12",
"title": "Connecticut", "title": "Connecticut",
"description": "Topographic maps for the state of Connecticut", "description": "Topographic maps for the state of Connecticut",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/connecticut.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/connecticut_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "maine",
"version": "2025-12",
"title": "Maine", "title": "Maine",
"description": "Topographic maps for the state of Maine", "description": "Topographic maps for the state of Maine",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maine.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maine_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "massachusetts",
"version": "2025-12",
"title": "Massachusetts", "title": "Massachusetts",
"description": "Topographic maps for the state of Massachusetts", "description": "Topographic maps for the state of Massachusetts",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/massachusetts.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/massachusetts_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "new_hampshire",
"version": "2025-12",
"title": "New_Hampshire", "title": "New_Hampshire",
"description": "Topographic maps for the state of New_Hampshire", "description": "Topographic maps for the state of New_Hampshire",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_hampshire.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_hampshire_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "rhode_island",
"version": "2025-12",
"title": "Rhode_Island", "title": "Rhode_Island",
"description": "Topographic maps for the state of Rhode_Island", "description": "Topographic maps for the state of Rhode_Island",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/rhode_island.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/rhode_island_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
}, },
{ {
"id": "vermont",
"version": "2025-12",
"title": "Vermont", "title": "Vermont",
"description": "Topographic maps for the state of Vermont", "description": "Topographic maps for the state of Vermont",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/vermont.pmtiles", "url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/vermont_2025-12.pmtiles",
"size_mb": 400 "size_mb": 400
} }
] ]

View File

@ -1,46 +1,53 @@
{ {
"spec_version": "2026-02-11",
"options": [ "options": [
{ {
"id": "none", "id": "none",
"name": "No Wikipedia", "name": "No Wikipedia",
"description": "Skip Wikipedia installation", "description": "Skip Wikipedia installation",
"size_mb": 0, "size_mb": 0,
"url": null "url": null,
"version": null
}, },
{ {
"id": "top-mini", "id": "top-mini",
"name": "Quick Reference", "name": "Quick Reference",
"description": "Top 100,000 articles with minimal images. Great for quick lookups.", "description": "Top 100,000 articles with minimal images. Great for quick lookups.",
"size_mb": 313, "size_mb": 313,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2025-12.zim" "url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2025-12.zim",
"version": "2025-12"
}, },
{ {
"id": "top-nopic", "id": "top-nopic",
"name": "Popular Articles", "name": "Popular Articles",
"description": "Top articles without images. Good balance of content and size.", "description": "Top articles without images. Good balance of content and size.",
"size_mb": 2100, "size_mb": 2100,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2025-12.zim" "url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2025-12.zim",
"version": "2025-12"
}, },
{ {
"id": "all-mini", "id": "all-mini",
"name": "Complete Wikipedia (Compact)", "name": "Complete Wikipedia (Compact)",
"description": "All 6+ million articles in condensed format.", "description": "All 6+ million articles in condensed format.",
"size_mb": 11400, "size_mb": 11400,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2025-12.zim" "url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2025-12.zim",
"version": "2025-12"
}, },
{ {
"id": "all-nopic", "id": "all-nopic",
"name": "Complete Wikipedia (No Images)", "name": "Complete Wikipedia (No Images)",
"description": "All articles without images. Comprehensive offline reference.", "description": "All articles without images. Comprehensive offline reference.",
"size_mb": 25000, "size_mb": 25000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim" "url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim",
"version": "2025-12"
}, },
{ {
"id": "all-maxi", "id": "all-maxi",
"name": "Complete Wikipedia (Full)", "name": "Complete Wikipedia (Full)",
"description": "The complete experience with all images and media.", "description": "The complete experience with all images and media.",
"size_mb": 102000, "size_mb": 102000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2024-01.zim" "url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2024-01.zim",
"version": "2024-01"
} }
] ]
} }