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 { ZimService } from '#services/zim_service'
import { CollectionManifestService } from '#services/collection_manifest_service'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@ -26,4 +27,22 @@ export default class EasySetupController {
async listCuratedCategories({}: HttpContext) {
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 {
downloadCollectionValidator,
downloadCategoryTierValidator,
filenameParamValidator,
remoteDownloadWithMetadataValidator,
selectWikipediaValidator,
@ -25,7 +25,7 @@ export default class ZimController {
async downloadRemote({ request }: HttpContext) {
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 {
message: 'Download started successfully',
@ -35,26 +35,25 @@ export default class ZimController {
}
}
async downloadCollection({ request }: HttpContext) {
const payload = await request.validateUsing(downloadCollectionValidator)
const resources = await this.zimService.downloadCollection(payload.slug)
async listCuratedCategories({}: HttpContext) {
return await this.zimService.listCuratedCategories()
}
async downloadCategoryTier({ request }: HttpContext) {
const payload = await request.validateUsing(downloadCategoryTierValidator)
const resources = await this.zimService.downloadCategoryTier(
payload.categorySlug,
payload.tierSlug
)
return {
message: 'Download started successfully',
slug: payload.slug,
categorySlug: payload.categorySlug,
tierSlug: payload.tierSlug,
resources,
}
}
async listCuratedCollections({}: HttpContext) {
return this.zimService.listCuratedCollections()
}
async fetchLatestCollections({}: HttpContext) {
const success = await this.zimService.fetchLatestCollections()
return { success }
}
async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameParamValidator)

View File

@ -22,7 +22,7 @@ export class RunDownloadJob {
}
async handle(job: Job) {
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype } =
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
job.data as RunDownloadJobParams
await doResumableDownload({
@ -37,6 +37,26 @@ export class RunDownloadJob {
},
async onComplete(url) {
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') {
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
@ -57,7 +77,7 @@ export class RunDownloadJob {
}
} catch (error) {
console.error(
`[RunDownloadJob] Error in ZIM download success callback for URL ${url}:`,
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
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 {
DownloadCollectionOperation,
DownloadRemoteSuccessCallback,
FileEntry,
} from '../../types/files.js'
@ -16,14 +15,11 @@ import {
} from '../utils/fs.js'
import { join } from 'path'
import urlJoin from 'url-join'
import axios from 'axios'
import { RunDownloadJob } from '#jobs/run_download_job'
import logger from '@adonisjs/core/services/logger'
import { CuratedCollectionsFile, CuratedCollectionWithStatus } from '../../types/downloads.js'
import CuratedCollection from '#models/curated_collection'
import vine from '@vinejs/vine'
import { curatedCollectionsFileSchema } from '#validators/curated_collections'
import CuratedCollectionResource from '#models/curated_collection_resource'
import InstalledResource from '#models/installed_resource'
import { CollectionManifestService } from './collection_manifest_service.js'
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
const BASE_ASSETS_MIME_TYPES = [
'application/gzip',
@ -31,15 +27,11 @@ const BASE_ASSETS_MIME_TYPES = [
'application/octet-stream',
]
const COLLECTIONS_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json'
const PMTILES_ATTRIBUTION =
'<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']
interface IMapService {
downloadCollection: DownloadCollectionOperation
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
}
@ -99,34 +91,33 @@ export class MapService implements IMapService {
return true
}
async downloadCollection(slug: string) {
const collection = await CuratedCollection.query()
.where('slug', slug)
.andWhere('type', 'map')
.first()
if (!collection) {
return null
}
async downloadCollection(slug: string): Promise<string[] | null> {
const manifestService = new CollectionManifestService()
const spec = await manifestService.getSpecWithFallback<MapsSpec>('maps')
if (!spec) return null
const resources = await collection.related('resources').query().where('downloaded', false)
if (resources.length === 0) {
return null
}
const collection = spec.collections.find((c) => c.slug === slug)
if (!collection) 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[] = []
for (const url of downloadUrls) {
const existing = await RunDownloadJob.getByUrl(url)
for (const resource of toDownload) {
const existing = await RunDownloadJob.getByUrl(resource.url)
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
}
// Extract the filename from the URL
const filename = url.split('/').pop()
const filename = resource.url.split('/').pop()
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
}
@ -134,12 +125,17 @@ export class MapService implements IMapService {
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
await RunDownloadJob.dispatch({
url,
url: resource.url,
filepath,
timeout: 30000,
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true,
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) {
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
for (const resource of resources) {
resource.downloaded = true
await resource.save()
logger.info(`[MapService] Marked resource as downloaded: ${resource.url}`)
// Create InstalledResource entries for downloaded map files
for (const url of urls) {
const filename = url.split('/').pop()
if (!filename) continue
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
const result = await RunDownloadJob.dispatch({
url,
@ -190,6 +214,7 @@ export class MapService implements IMapService {
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true,
filetype: 'map',
resourceMetadata,
})
if (!result.job) {
@ -219,6 +244,7 @@ export class MapService implements IMapService {
}
// Perform a HEAD request to get the content length
const { default: axios } = await import('axios')
const response = await axios.head(url)
if (response.status !== 200) {
@ -229,7 +255,7 @@ export class MapService implements IMapService {
const size = contentLength ? parseInt(contentLength, 10) : 0
return { filename, size }
} catch (error) {
} catch (error: any) {
return { message: `Preflight check failed: ${error.message}` }
}
}
@ -268,53 +294,14 @@ export class MapService implements IMapService {
return styles
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
const collections = await CuratedCollection.query().where('type', 'map').preload('resources')
return collections.map((collection) => ({
...(collection.serialize() as CuratedCollection),
all_downloaded: collection.resources.every((res) => res.downloaded),
}))
async listCuratedCollections(): Promise<CollectionWithStatus[]> {
const manifestService = new CollectionManifestService()
return manifestService.getMapCollectionsWithStatus()
}
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,
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
}
const manifestService = new CollectionManifestService()
return manifestService.fetchAndCacheSpec('maps')
}
async ensureBaseAssets(): Promise<boolean> {
@ -361,7 +348,9 @@ export class MapService implements IMapService {
for (const region of regions) {
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 sourceUrl = urlJoin(baseUrl, region.name)
@ -411,11 +400,11 @@ export class MapService implements IMapService {
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.zim')) {
fileName += '.zim'
if (!fileName.endsWith('.pmtiles')) {
fileName += '.pmtiles'
}
const fullPath = join(this.baseDirPath, fileName)
const fullPath = join(this.baseDirPath, 'pmtiles', fileName)
const exists = await getFileStatsIfExists(fullPath)
if (!exists) {
@ -423,6 +412,16 @@ export class MapService implements IMapService {
}
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,
} from '../utils/fs.js'
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 { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections'
import CuratedCollection from '#models/curated_collection'
import CuratedCollectionResource from '#models/curated_collection_resource'
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
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 { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.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 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'
interface IZimService {
downloadCollection: DownloadCollectionOperation
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
}
@inject()
export class ZimService implements IZimService {
export class ZimService {
constructor(private dockerService: DockerService) { }
async list() {
@ -52,24 +41,8 @@ export class ZimService implements IZimService {
const all = await listDirectoryContents(dirPath)
const files = all.filter((item) => item.name.endsWith('.zim'))
// Fetch metadata for all files
const metadataRecords = await ZimFileMetadata.all()
const metadataMap = new Map(metadataRecords.map((m) => [m.filename, m]))
// Enrich files with metadata
const enrichedFiles = files.map((file) => {
const metadata = metadataMap.get(file.name)
return {
...file,
title: metadata?.title || null,
summary: metadata?.summary || null,
author: metadata?.author || null,
size_bytes: metadata?.size_bytes || null,
}
})
return {
files: enrichedFiles,
files,
}
}
@ -164,10 +137,7 @@ export class ZimService implements IZimService {
}
}
async downloadRemote(
url: string,
metadata?: { title: string; summary?: string; author?: string; size_bytes?: number }
): Promise<{ filename: string; jobId?: string }> {
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
const parsed = new URL(url)
if (!parsed.pathname.endsWith('.zim')) {
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
@ -186,19 +156,11 @@ export class ZimService implements IZimService {
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
// Store metadata if provided
if (metadata) {
await ZimFileMetadata.updateOrCreate(
{ filename },
{
title: metadata.title,
summary: metadata.summary || null,
author: metadata.author || null,
size_bytes: metadata.size_bytes || null,
}
)
logger.info(`[ZimService] Stored metadata for ZIM file: ${filename}`)
}
// Parse resource metadata for the download job
const parsedFilename = CollectionManifestService.parseZimFilename(filename)
const resourceMetadata = parsedFilename
? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
: undefined
// Dispatch a background download job
const result = await RunDownloadJob.dispatch({
@ -208,6 +170,7 @@ export class ZimService implements IZimService {
allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
filetype: 'zim',
resourceMetadata,
})
if (!result || !result.job) {
@ -222,44 +185,64 @@ export class ZimService implements IZimService {
}
}
async downloadCollection(slug: string) {
const collection = await CuratedCollection.query().where('slug', slug).andWhere('type', 'zim').first()
if (!collection) {
return null
async listCuratedCategories(): Promise<CategoryWithStatus[]> {
const manifestService = new CollectionManifestService()
return manifestService.getCategoriesWithStatus()
}
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)
if (resources.length === 0) {
return null
const category = spec.categories.find((c) => c.slug === categorySlug)
if (!category) {
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[] = []
for (const url of downloadUrls) {
const existing = await RunDownloadJob.getByUrl(url)
if (existing) {
logger.warn(`[ZimService] Download already in progress for URL ${url}, skipping.`)
for (const resource of toDownload) {
const existingJob = await RunDownloadJob.getByUrl(resource.url)
if (existingJob) {
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
continue
}
// Extract the filename from the URL
const filename = url.split('/').pop()
if (!filename) {
logger.warn(`[ZimService] Could not determine filename from URL ${url}, skipping.`)
continue
}
const filename = resource.url.split('/').pop()
if (!filename) continue
downloadFilenames.push(filename)
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
await RunDownloadJob.dispatch({
url,
url: resource.url,
filepath,
timeout: 30000,
allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true,
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
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
for (const resource of resources) {
resource.downloaded = true
await resource.save()
}
}
// Create InstalledResource entries for downloaded files
for (const url of urls) {
// Skip Wikipedia files (managed separately)
if (url.includes('wikipedia_en_')) continue
async listCuratedCategories(): Promise<CuratedCategory[]> {
try {
const response = await axios.get(CATEGORIES_URL)
const data = response.data
const filename = url.split('/').pop()
if (!filename) continue
const validated = await vine.validate({
schema: curatedCategoriesFileSchema,
data,
});
const parsed = CollectionManifestService.parseZimFilename(filename)
if (!parsed) continue
// Dynamically determine installed tier for each category
const categoriesWithStatus = await Promise.all(
validated.categories.map(async (category) => {
const installedTierSlug = await this.getInstalledTierForCategory(category)
return {
...category,
installedTierSlug,
}
})
)
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
const stats = await getFileStatsIfExists(filepath)
return categoriesWithStatus
} catch (error) {
logger.error(`[ZimService] Failed to fetch curated categories:`, error)
throw new Error('Failed to fetch curated categories or invalid format was received')
}
}
/**
* 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 },
try {
const { DateTime } = await import('luxon')
await InstalledResource.updateOrCreate(
{ resource_id: parsed.resource_id, resource_type: 'zim' },
{
...restCollection,
type: 'zim',
version: parsed.version,
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}`)
// 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(
`[ZimService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
)
logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)
} catch (error) {
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
}
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)
// Clean up metadata
await ZimFileMetadata.query().where('filename', fileName).delete()
logger.info(`[ZimService] Deleted metadata for ZIM file: ${fileName}`)
// Clean up InstalledResource entry
const parsed = CollectionManifestService.parseZimFilename(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

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(
vine.object({
optionId: vine.string().trim().minLength(1),

View File

@ -1,30 +1,20 @@
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(),
description: vine.string(),
url: vine.string().url(),
size_mb: vine.number().min(0).optional(),
})
export const curatedCollectionValidator = vine.object({
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),
})
// ---- ZIM Categories spec (versioned) ----
export const curatedCollectionsFileSchema = vine.object({
collections: vine.array(curatedCollectionValidator).minLength(1),
})
/**
* 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({
export const zimCategoriesSpecSchema = vine.object({
spec_version: vine.string(),
categories: vine.array(
vine.object({
name: vine.string(),
@ -39,16 +29,47 @@ export const curatedCategoriesFileSchema = vine.object({
description: vine.string(),
recommended: vine.boolean().optional(),
includesTier: vine.string().optional(),
resources: vine.array(curatedCollectionResourceValidator),
resources: vine.array(specResourceValidator),
})
),
})
),
})
/**
* For validating the Wikipedia options file
*/
// ---- Maps spec (versioned) ----
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({
id: vine.string(),
name: vine.string(),

View File

@ -36,6 +36,15 @@ new Ignitor(APP_ROOT, { importer: IMPORTER })
})
app.listen('SIGTERM', () => 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()
.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 DynamicIcon, { DynamicIconName } from './DynamicIcon'
import { CuratedCategory, CategoryTier } from '../../types/downloads'
import type { CategoryWithStatus, SpecTier } from '../../types/collections'
import classNames from 'classnames'
import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'
export interface CategoryCardProps {
category: CuratedCategory
selectedTier?: CategoryTier | null
onClick?: (category: CuratedCategory) => void
category: CategoryWithStatus
selectedTier?: SpecTier | null
onClick?: (category: CategoryWithStatus) => void
}
const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {
// 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)
// Add included tier sizes recursively

View File

@ -1,12 +1,12 @@
import { formatBytes } from '~/lib/util'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import { CuratedCollectionWithStatus } from '../../types/downloads'
import type { CollectionWithStatus } from '../../types/collections'
import classNames from 'classnames'
import { IconCircleCheck } from '@tabler/icons-react'
export interface CuratedCollectionCardProps {
collection: CuratedCollectionWithStatus
onClick?: (collection: CuratedCollectionWithStatus) => void;
collection: CollectionWithStatus
onClick?: (collection: CollectionWithStatus) => void;
size?: 'small' | 'large'
}
@ -19,11 +19,11 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
<div
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',
{ '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' }
)}
onClick={() => {
if (collection.all_downloaded) {
if (collection.all_installed) {
return
}
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" />
<h3 className="text-lg font-semibold">{collection.name}</h3>
</div>
{collection.all_downloaded && (
{collection.all_installed && (
<div className="flex items-center">
<IconCircleCheck
className="w-5 h-5 text-lime-400 ml-2"

View File

@ -1,7 +1,8 @@
import { Fragment, useState, useEffect } from 'react'
import { Dialog, Transition } from '@headlessui/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 classNames from 'classnames'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
@ -9,9 +10,9 @@ import DynamicIcon, { DynamicIconName } from './DynamicIcon'
interface TierSelectionModalProps {
isOpen: boolean
onClose: () => void
category: CuratedCategory | null
category: CategoryWithStatus | null
selectedTierSlug?: string | null
onSelectTier: (category: CuratedCategory, tier: CategoryTier) => void
onSelectTier: (category: CategoryWithStatus, tier: SpecTier) => void
}
const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
@ -34,24 +35,15 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
if (!category) return null
// Get all resources for a tier (including inherited resources)
const getAllResourcesForTier = (tier: CategoryTier): CategoryResource[] => {
const resources = [...tier.resources]
if (tier.includesTier) {
const includedTier = category.tiers.find(t => t.slug === tier.includesTier)
if (includedTier) {
resources.unshift(...getAllResourcesForTier(includedTier))
}
}
return resources
const getAllResourcesForTier = (tier: SpecTier): SpecResource[] => {
return resolveTierResources(tier, category.tiers)
}
const getTierTotalSize = (tier: CategoryTier): number => {
const getTierTotalSize = (tier: SpecTier): number => {
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
if (localSelectedSlug === tier.slug) {
setLocalSelectedSlug(null)

View File

@ -3,12 +3,8 @@ import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zi
import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files'
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
import {
CuratedCategory,
CuratedCollectionWithStatus,
DownloadJobWithProgress,
WikipediaState,
} from '../../types/downloads'
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
import type { CategoryWithStatus, CollectionWithStatus, CollectionUpdateCheckResult } from '../../types/collections'
import { catchInternal } from './util'
import { NomadOllamaModel, OllamaChatRequest } from '../../types/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
slug: string
categorySlug: string
tierSlug: string
resources: string[] | null
}> {
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
})()
}
@ -130,9 +127,18 @@ class API {
})()
}
async fetchLatestZimCollections(): Promise<{ success: boolean } | undefined> {
async checkForCollectionUpdates() {
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
})()
}
@ -189,14 +195,14 @@ class API {
async getBenchmarkResults() {
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
})()
}
async getLatestBenchmarkResult() {
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
})()
}
@ -341,25 +347,16 @@ class API {
async listCuratedMapCollections() {
return catchInternal(async () => {
const response = await this.client.get<CuratedCollectionWithStatus[]>(
const response = await this.client.get<CollectionWithStatus[]>(
'/maps/curated-collections'
)
return response.data
})()
}
async listCuratedZimCollections() {
return catchInternal(async () => {
const response = await this.client.get<CuratedCollectionWithStatus[]>(
'/zim/curated-collections'
)
return response.data
})()
}
async listCuratedCategories() {
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
})()
}

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 { useSystemInfo } from '~/hooks/useSystemInfo'
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'
// Capability definitions - maps user-friendly categories to services
@ -105,38 +106,21 @@ const ADDITIONAL_TOOLS: Capability[] = [
type WizardStep = 1 | 2 | 3 | 4
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
const CURATED_ZIM_COLLECTIONS_KEY = 'curated-zim-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories'
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[] } }) {
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([])
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
const [selectedZimCollections, setSelectedZimCollections] = useState<string[]>([])
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [showAdditionalTools, setShowAdditionalTools] = useState(false)
// 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 [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null)
const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)
// Wikipedia selection state
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
@ -149,7 +133,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const anySelectionMade =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedZimCollections.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -160,12 +143,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
refetchOnWindowFocus: false,
})
const { data: zimCollections, isLoading: isLoadingZims } = useQuery({
queryKey: [CURATED_ZIM_COLLECTIONS_KEY],
queryFn: () => api.listCuratedZimCollections(),
refetchOnWindowFocus: false,
})
// Fetch curated categories with tiers
const { data: categories, isLoading: isLoadingCategories } = useQuery({
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) => {
setSelectedAiModels((prev) =>
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
const handleCategoryClick = (category: CuratedCategory) => {
const handleCategoryClick = (category: CategoryWithStatus) => {
if (!isOnline) return
setActiveCategory(category)
setTierModalOpen(true)
}
const handleTierSelect = (category: CuratedCategory, tier: CategoryTier) => {
const handleTierSelect = (category: CategoryWithStatus, tier: SpecTier) => {
setSelectedTiers((prev) => {
const newMap = new Map(prev)
// If same tier is selected, deselect it
@ -239,14 +210,14 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
setActiveCategory(null)
}
// Get all resources from selected tiers for downloading
const getSelectedTierResources = (): CategoryResource[] => {
// Get all resources from selected tiers for storage projection
const getSelectedTierResources = (): SpecResource[] => {
if (!categories) return []
const resources: CategoryResource[] = []
const resources: SpecResource[] = []
selectedTiers.forEach((tier, categorySlug) => {
const category = categories.find((c) => c.slug === categorySlug)
if (category) {
resources.push(...getAllResourcesForTier(tier, category.tiers))
resources.push(...resolveTierResources(tier, category.tiers))
}
})
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
if (recommendedModels) {
selectedAiModels.forEach((modelName) => {
@ -315,12 +276,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
}, [
selectedTiers,
selectedMapCollections,
selectedZimCollections,
selectedAiModels,
selectedWikipedia,
categories,
mapCollections,
zimCollections,
recommendedModels,
wikipediaState,
])
@ -392,12 +351,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
await Promise.all(installPromises)
// Download collections, individual tier resources, and AI models
const tierResources = getSelectedTierResources()
// Download collections, category tiers, and AI models
const categoryTierPromises: Promise<any>[] = []
selectedTiers.forEach((tier, categorySlug) => {
categoryTierPromises.push(api.downloadCategoryTier(categorySlug, tier.slug))
})
const downloadPromises = [
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)),
...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)),
...categoryTierPromises,
...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
]
@ -425,25 +387,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
}
}
const fetchLatestMapCollections = useMutation({
mutationFn: () => api.fetchLatestMapCollections(),
const refreshManifests = useMutation({
mutationFn: () => api.refreshManifests(),
onSuccess: () => {
addNotification({
message: 'Successfully fetched the latest map collections.',
type: 'success',
})
queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_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] })
queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
},
})
@ -452,18 +400,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [currentStep])
// Auto-fetch latest collections if the list is empty
// Refresh manifests on mount to ensure we have latest data
useEffect(() => {
if (mapCollections && mapCollections.length === 0 && !fetchLatestMapCollections.isPending) {
fetchLatestMapCollections.mutate()
if (!refreshManifests.isPending) {
refreshManifests.mutate()
}
}, [mapCollections, fetchLatestMapCollections])
useEffect(() => {
if (zimCollections && zimCollections.length === 0 && !fetchLatestZIMCollections.isPending) {
fetchLatestZIMCollections.mutate()
}
}, [zimCollections, fetchLatestZIMCollections])
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Set Easy Setup as visited when user lands on this page
useEffect(() => {
@ -789,13 +731,13 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<div
key={collection.slug}
onClick={() =>
isOnline && !collection.all_downloaded && toggleMapCollection(collection.slug)
isOnline && !collection.all_installed && toggleMapCollection(collection.slug)
}
className={classNames(
'relative',
selectedMapCollections.includes(collection.slug) &&
'ring-4 ring-desert-green rounded-lg',
collection.all_downloaded && 'opacity-75',
collection.all_installed && 'opacity-75',
!isOnline && 'opacity-50 cursor-not-allowed'
)}
>
@ -996,49 +938,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</>
) : 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 =
selectedServices.length > 0 ||
selectedMapCollections.length > 0 ||
selectedZimCollections.length > 0 ||
selectedTiers.size > 0 ||
selectedAiModels.length > 0 ||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
@ -1122,25 +1020,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</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 && (
<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">
@ -1149,7 +1028,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
const category = categories?.find((c) => c.slug === categorySlug)
if (!category) return null
const resources = getAllResourcesForTier(tier, category.tiers)
const resources = resolveTierResources(tier, category.tiers)
return (
<div key={categorySlug} className="mb-4 last:mb-0">
<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'}`
})()}
, {selectedMapCollections.length} map region
{selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length}{' '}
content pack{selectedZimCollections.length !== 1 && 's'},{' '}
{selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}
content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
</p>
</div>

View File

@ -13,7 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import useDownloads from '~/hooks/useDownloads'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import { CuratedCollectionWithStatus } from '../../../types/downloads'
import type { CollectionWithStatus } from '../../../types/collections'
import ActiveDownloads from '~/components/ActiveDownloads'
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 {
await api.downloadMapCollection(record.slug)
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
openModal(
<StyledModal
title="Confirm Download?"
onConfirm={() => {
if (isCollection) {
if (record.all_downloaded) {
if (record.all_installed) {
addNotification({
message: `All resources in the collection "${record.name}" have already been downloaded.`,
type: 'info',

View File

@ -23,37 +23,18 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import Input from '~/components/inputs/Input'
import { IconSearch, IconBooks } from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import CategoryCard from '~/components/CategoryCard'
import TierSelectionModal from '~/components/TierSelectionModal'
import WikipediaSelector from '~/components/WikipediaSelector'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import {
CuratedCollectionWithStatus,
CuratedCategory,
CategoryTier,
CategoryResource,
} from '../../../../types/downloads'
import type { CategoryWithStatus, SpecTier } from '../../../../types/collections'
import useDownloads from '~/hooks/useDownloads'
import ActiveDownloads from '~/components/ActiveDownloads'
import { SERVICE_NAMES } from '../../../../constants/service_names'
const CURATED_COLLECTIONS_KEY = 'curated-zim-collections'
const CURATED_CATEGORIES_KEY = 'curated-categories'
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() {
const queryClient = useQueryClient()
const tableParentRef = useRef<HTMLDivElement>(null)
@ -69,7 +50,7 @@ export default function ZimRemoteExplorer() {
// Category/tier selection state
const [tierModalOpen, setTierModalOpen] = useState(false)
const [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null)
const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)
// Wikipedia selection state
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
@ -79,12 +60,6 @@ export default function ZimRemoteExplorer() {
setQuery(val)
}, 400)
const { data: curatedCollections } = useQuery({
queryKey: [CURATED_COLLECTIONS_KEY],
queryFn: () => api.listCuratedZimCollections(),
refetchOnWindowFocus: false,
})
// Fetch curated categories with tiers
const { data: categories } = useQuery({
queryKey: [CURATED_CATEGORIES_KEY],
@ -170,24 +145,12 @@ export default function ZimRemoteExplorer() {
fetchOnBottomReached(tableParentRef.current)
}, [fetchOnBottomReached])
async function confirmDownload(record: RemoteZimFileEntry | CuratedCollectionWithStatus) {
const isCollection = 'resources' in record
async function confirmDownload(record: RemoteZimFileEntry) {
openModal(
<StyledModal
title="Confirm Download?"
onConfirm={() => {
if (isCollection) {
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)
}
downloadFile(record)
closeAllModals()
}}
onCancel={closeAllModals}
@ -198,7 +161,7 @@ export default function ZimRemoteExplorer() {
>
<p className="text-gray-700">
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
application will be restarted after the download is complete.
</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
const handleCategoryClick = (category: CuratedCategory) => {
const handleCategoryClick = (category: CategoryWithStatus) => {
if (!isOnline) return
setActiveCategory(category)
setTierModalOpen(true)
}
const handleTierSelect = async (category: CuratedCategory, tier: CategoryTier) => {
// Get all resources for this tier (including inherited ones)
const resources = getAllResourcesForTier(tier, category.tiers)
// Download each resource
const handleTierSelect = async (category: CategoryWithStatus, tier: SpecTier) => {
try {
for (const resource of resources) {
await api.downloadRemoteZimFile(resource.url)
}
await api.downloadCategoryTier(category.slug, tier.slug)
addNotification({
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`,
message: `Started downloading "${category.name} - ${tier.name}"`,
type: 'success',
})
invalidateDownloads()
@ -309,24 +257,17 @@ export default function ZimRemoteExplorer() {
}
}
const fetchLatestCollections = useMutation({
mutationFn: () => api.fetchLatestZimCollections(),
const refreshManifests = useMutation({
mutationFn: () => api.refreshManifests(),
onSuccess: () => {
addNotification({
message: 'Successfully fetched the latest ZIM collections.',
message: 'Successfully refreshed content collections.',
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 (
<SettingsLayout>
<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" />
<StyledButton
onClick={() => fetchLatestCollections.mutate()}
disabled={fetchLatestCollections.isPending}
onClick={() => refreshManifests.mutate()}
disabled={refreshManifests.isPending}
icon="IconCloudDownload"
>
Fetch Latest Collections
Refresh Collections
</StyledButton>
{/* Wikipedia Selector */}
@ -386,7 +327,7 @@ export default function ZimRemoteExplorer() {
</div>
) : null}
{/* Tiered Category Collections - matches Easy Setup Wizard */}
{/* Tiered Category Collections */}
<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">
<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 */
<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>
<p className="text-gray-500 mt-4">No curated content categories available.</p>
)}
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-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 SettingsController from '#controllers/settings_controller'
import SystemController from '#controllers/system_controller'
import CollectionUpdatesController from '#controllers/collection_updates_controller'
import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router'
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/complete', [EasySetupController, 'complete'])
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
.group(() => {
@ -145,10 +148,9 @@ router
.group(() => {
router.get('/list', [ZimController, 'list'])
router.get('/list-remote', [ZimController, 'listRemote'])
router.get('/curated-collections', [ZimController, 'listCuratedCollections'])
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
router.get('/curated-categories', [ZimController, 'listCuratedCategories'])
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.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
}
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<
DoResumableDownloadParams,
'onProgress' | 'onComplete' | 'signal'
> & {
filetype: string
resourceMetadata?: {
resource_id: string
version: string
collection_ref: string | null
}
}
export type DownloadJobWithProgress = {
@ -60,37 +43,6 @@ export type DownloadJobWithProgress = {
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
export type WikipediaOption = {
id: string

View File

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

View File

@ -1,4 +1,5 @@
{
"spec_version": "2026-02-11",
"categories": [
{
"name": "Medicine",
@ -14,24 +15,32 @@
"recommended": true,
"resources": [
{
"id": "zimgit-medicine_en",
"version": "2024-08",
"title": "Medical Library",
"description": "Field and emergency medicine books and guides",
"url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim",
"size_mb": 67
},
{
"id": "nhs.uk_en_medicines",
"version": "2025-12",
"title": "NHS Medicines A to Z",
"description": "How medicines work, dosages, side effects, and interactions",
"url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim",
"size_mb": 16
},
{
"id": "fas-military-medicine_en",
"version": "2025-06",
"title": "Military Medicine",
"description": "Tactical and field medicine manuals",
"url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim",
"size_mb": 78
},
{
"id": "wwwnc.cdc.gov_en_all",
"version": "2024-11",
"title": "CDC Health Information",
"description": "Disease prevention, travel health, and outbreak information",
"url": "https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim",
@ -46,6 +55,8 @@
"includesTier": "medicine-essential",
"resources": [
{
"id": "medlineplus.gov_en_all",
"version": "2025-01",
"title": "MedlinePlus",
"description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements",
"url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim",
@ -60,18 +71,24 @@
"includesTier": "medicine-standard",
"resources": [
{
"id": "wikipedia_en_medicine_maxi",
"version": "2026-01",
"title": "Wikipedia Medicine",
"description": "Curated medical articles from Wikipedia with images",
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim",
"size_mb": 2000
},
{
"id": "libretexts.org_en_med",
"version": "2025-01",
"title": "LibreTexts Medicine",
"description": "Open-source medical textbooks and educational content",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim",
"size_mb": 1100
},
{
"id": "librepathology_en_all_maxi",
"version": "2025-09",
"title": "LibrePathology",
"description": "Pathology reference for disease identification",
"url": "https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim",
@ -95,12 +112,16 @@
"recommended": true,
"resources": [
{
"id": "canadian_prepper_winterprepping_en",
"version": "2025-11",
"title": "Canadian Prepper: Winter Prepping",
"description": "Video guides for winter survival and cold weather emergencies",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim",
"size_mb": 1340
},
{
"id": "canadian_prepper_bugoutroll_en",
"version": "2025-08",
"title": "Canadian Prepper: Bug Out Roll",
"description": "Essential gear selection for your bug-out bag",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim",
@ -115,12 +136,16 @@
"includesTier": "survival-essential",
"resources": [
{
"id": "canadian_prepper_bugoutconcepts_en",
"version": "2025-11",
"title": "Canadian Prepper: Bug Out Concepts",
"description": "Strategies and planning for emergency evacuation",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim",
"size_mb": 2890
},
{
"id": "urban-prepper_en_all",
"version": "2025-11",
"title": "Urban Prepper",
"description": "Comprehensive urban emergency preparedness video series",
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim",
@ -135,6 +160,8 @@
"includesTier": "survival-standard",
"resources": [
{
"id": "canadian_prepper_preppingfood_en",
"version": "2025-09",
"title": "Canadian Prepper: Prepping Food",
"description": "Long-term food storage and survival meal preparation",
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim",
@ -158,6 +185,8 @@
"recommended": true,
"resources": [
{
"id": "wikibooks_en_all_nopic",
"version": "2025-10",
"title": "Wikibooks",
"description": "Open-content textbooks covering math, science, computing, and more",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim",
@ -172,42 +201,56 @@
"includesTier": "education-essential",
"resources": [
{
"id": "ted_mul_ted-ed",
"version": "2025-07",
"title": "TED-Ed",
"description": "Educational video lessons on science, history, literature, and more",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim",
"size_mb": 5610
},
{
"id": "wikiversity_en_all_maxi",
"version": "2025-11",
"title": "Wikiversity",
"description": "Tutorials, courses, and learning materials for all levels",
"url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim",
"size_mb": 2370
},
{
"id": "libretexts.org_en_math",
"version": "2025-01",
"title": "LibreTexts Mathematics",
"description": "Open-source math textbooks from algebra to calculus",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim",
"size_mb": 831
},
{
"id": "libretexts.org_en_phys",
"version": "2025-01",
"title": "LibreTexts Physics",
"description": "Physics courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim",
"size_mb": 560
},
{
"id": "libretexts.org_en_chem",
"version": "2025-01",
"title": "LibreTexts Chemistry",
"description": "Chemistry courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim",
"size_mb": 2180
},
{
"id": "libretexts.org_en_bio",
"version": "2025-01",
"title": "LibreTexts Biology",
"description": "Biology courses and textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim",
"size_mb": 2240
},
{
"id": "gutenberg_en_education",
"version": "2025-12",
"title": "Project Gutenberg: Education",
"description": "Classic educational texts and resources",
"url": "https://download.kiwix.org/zim/gutenberg/gutenberg_en_education_2025-12.zim",
@ -222,36 +265,48 @@
"includesTier": "education-standard",
"resources": [
{
"id": "wikibooks_en_all_maxi",
"version": "2025-10",
"title": "Wikibooks (With Images)",
"description": "Open textbooks with full illustrations and diagrams",
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim",
"size_mb": 5400
},
{
"id": "ted_mul_ted-conference",
"version": "2025-08",
"title": "TED Conference",
"description": "Main TED conference talks on ideas worth spreading",
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim",
"size_mb": 16500
},
{
"id": "libretexts.org_en_human",
"version": "2025-01",
"title": "LibreTexts Humanities",
"description": "Literature, philosophy, history, and social sciences",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim",
"size_mb": 3730
},
{
"id": "libretexts.org_en_geo",
"version": "2025-01",
"title": "LibreTexts Geosciences",
"description": "Earth science, geology, and environmental studies",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim",
"size_mb": 1190
},
{
"id": "libretexts.org_en_eng",
"version": "2025-01",
"title": "LibreTexts Engineering",
"description": "Engineering courses and technical references",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim",
"size_mb": 678
},
{
"id": "libretexts.org_en_biz",
"version": "2025-01",
"title": "LibreTexts Business",
"description": "Business, economics, and management textbooks",
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim",
@ -275,12 +330,16 @@
"recommended": true,
"resources": [
{
"id": "woodworking.stackexchange.com_en_all",
"version": "2025-12",
"title": "Woodworking Q&A",
"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",
"size_mb": 99
},
{
"id": "mechanics.stackexchange.com_en_all",
"version": "2025-12",
"title": "Motor Vehicle Maintenance Q&A",
"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",
@ -295,6 +354,8 @@
"includesTier": "diy-essential",
"resources": [
{
"id": "diy.stackexchange.com_en_all",
"version": "2025-12",
"title": "DIY & Home Improvement Q&A",
"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",
@ -309,6 +370,8 @@
"includesTier": "diy-standard",
"resources": [
{
"id": "ifixit_en_all",
"version": "2025-12",
"title": "iFixit Repair Guides",
"description": "Step-by-step repair guides for electronics, appliances, and vehicles",
"url": "https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim",
@ -332,12 +395,16 @@
"recommended": true,
"resources": [
{
"id": "foss.cooking_en_all",
"version": "2025-11",
"title": "FOSS Cooking",
"description": "Quick and easy cooking guides and recipes",
"url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim",
"size_mb": 24
},
{
"id": "based.cooking_en_all",
"version": "2025-11",
"title": "Based.Cooking",
"description": "Simple, practical recipes from the community",
"url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim",
@ -352,18 +419,24 @@
"includesTier": "agriculture-essential",
"resources": [
{
"id": "gardening.stackexchange.com_en_all",
"version": "2025-12",
"title": "Gardening Q&A",
"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",
"size_mb": 923
},
{
"id": "cooking.stackexchange.com_en_all",
"version": "2025-12",
"title": "Cooking Q&A",
"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",
"size_mb": 236
},
{
"id": "zimgit-food-preparation_en",
"version": "2025-04",
"title": "Food for Preppers",
"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",
@ -378,6 +451,8 @@
"includesTier": "agriculture-standard",
"resources": [
{
"id": "lrnselfreliance_en_all",
"version": "2025-12",
"title": "Learning Self-Reliance: Homesteading",
"description": "Beekeeping, animal husbandry, and sustainable living practices",
"url": "https://download.kiwix.org/zim/videos/lrnselfreliance_en_all_2025-12.zim",
@ -401,30 +476,40 @@
"recommended": true,
"resources": [
{
"id": "freecodecamp_en_all",
"version": "2025-11",
"title": "freeCodeCamp",
"description": "Interactive programming tutorials - JavaScript, algorithms, and data structures",
"url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim",
"size_mb": 8
},
{
"id": "devdocs_en_python",
"version": "2026-01",
"title": "Python Documentation",
"description": "Complete Python language reference and tutorials",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim",
"size_mb": 4
},
{
"id": "devdocs_en_javascript",
"version": "2026-01",
"title": "JavaScript Documentation",
"description": "MDN JavaScript reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim",
"size_mb": 3
},
{
"id": "devdocs_en_html",
"version": "2026-01",
"title": "HTML Documentation",
"description": "MDN HTML elements and attributes reference",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_html_2026-01.zim",
"size_mb": 2
},
{
"id": "devdocs_en_css",
"version": "2026-01",
"title": "CSS Documentation",
"description": "MDN CSS properties and selectors reference",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim",
@ -439,30 +524,40 @@
"includesTier": "computing-essential",
"resources": [
{
"id": "arduino.stackexchange.com_en_all",
"version": "2025-12",
"title": "Arduino Q&A",
"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",
"size_mb": 247
},
{
"id": "raspberrypi.stackexchange.com_en_all",
"version": "2025-12",
"title": "Raspberry Pi Q&A",
"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",
"size_mb": 285
},
{
"id": "devdocs_en_node",
"version": "2026-01",
"title": "Node.js Documentation",
"description": "Node.js API reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim",
"size_mb": 1
},
{
"id": "devdocs_en_react",
"version": "2026-01",
"title": "React Documentation",
"description": "React library reference and tutorials",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-01.zim",
"size_mb": 3
},
{
"id": "devdocs_en_git",
"version": "2026-01",
"title": "Git Documentation",
"description": "Git version control reference",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim",
@ -477,24 +572,32 @@
"includesTier": "computing-standard",
"resources": [
{
"id": "electronics.stackexchange.com_en_all",
"version": "2025-12",
"title": "Electronics Q&A",
"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",
"size_mb": 3800
},
{
"id": "robotics.stackexchange.com_en_all",
"version": "2025-12",
"title": "Robotics Q&A",
"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",
"size_mb": 233
},
{
"id": "devdocs_en_docker",
"version": "2026-01",
"title": "Docker Documentation",
"description": "Docker container reference and guides",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim",
"size_mb": 2
},
{
"id": "devdocs_en_bash",
"version": "2026-01",
"title": "Linux Documentation",
"description": "Linux command reference and system administration",
"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": [
{
"name": "Pacific Region",
@ -8,33 +9,43 @@
"language": "en",
"resources": [
{
"id": "alaska",
"version": "2025-12",
"title": "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
},
{
"id": "california",
"version": "2025-12",
"title": "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
},
{
"id": "hawaii",
"version": "2025-12",
"title": "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
},
{
"id": "oregon",
"version": "2025-12",
"title": "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
},
{
"id": "washington",
"version": "2025-12",
"title": "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
}
]
@ -47,51 +58,67 @@
"language": "en",
"resources": [
{
"id": "arizona",
"version": "2025-12",
"title": "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
},
{
"id": "colorado",
"version": "2025-12",
"title": "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
},
{
"id": "idaho",
"version": "2025-12",
"title": "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
},
{
"id": "montana",
"version": "2025-12",
"title": "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
},
{
"id": "nevada",
"version": "2025-12",
"title": "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
},
{
"id": "new_mexico",
"version": "2025-12",
"title": "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
},
{
"id": "utah",
"version": "2025-12",
"title": "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
},
{
"id": "wyoming",
"version": "2025-12",
"title": "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
}
]
@ -104,27 +131,35 @@
"language": "en",
"resources": [
{
"id": "arkansas",
"version": "2025-12",
"title": "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
},
{
"id": "oklahoma",
"version": "2025-12",
"title": "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
},
{
"id": "louisiana",
"version": "2025-12",
"title": "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
},
{
"id": "texas",
"version": "2025-12",
"title": "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
}
]
@ -137,27 +172,35 @@
"language": "en",
"resources": [
{
"id": "alabama",
"version": "2025-12",
"title": "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
},
{
"id": "kentucky",
"version": "2025-12",
"title": "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
},
{
"id": "mississippi",
"version": "2025-12",
"title": "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
},
{
"id": "tennessee",
"version": "2025-12",
"title": "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
}
]
@ -170,57 +213,75 @@
"language": "en",
"resources": [
{
"id": "delaware",
"version": "2025-12",
"title": "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
},
{
"id": "district_of_columbia",
"version": "2025-12",
"title": "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
},
{
"id": "florida",
"version": "2025-12",
"title": "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
},
{
"id": "georgia",
"version": "2025-12",
"title": "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
},
{
"id": "maryland",
"version": "2025-12",
"title": "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
},
{
"id": "north_carolina",
"version": "2025-12",
"title": "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
},
{
"id": "south_carolina",
"version": "2025-12",
"title": "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
},
{
"id": "virginia",
"version": "2025-12",
"title": "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
},
{
"id": "west_virginia",
"version": "2025-12",
"title": "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
}
]
@ -233,45 +294,59 @@
"language": "en",
"resources": [
{
"id": "iowa",
"version": "2025-12",
"title": "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
},
{
"id": "kansas",
"version": "2025-12",
"title": "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
},
{
"id": "minnesota",
"version": "2025-12",
"title": "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
},
{
"id": "missouri",
"version": "2025-12",
"title": "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
},
{
"id": "nebraska",
"version": "2025-12",
"title": "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
},
{
"id": "north_dakota",
"version": "2025-12",
"title": "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
},
{
"id": "south_dakota",
"version": "2025-12",
"title": "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
}
]
@ -284,27 +359,35 @@
"language": "en",
"resources": [
{
"id": "illinois",
"version": "2025-12",
"title": "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
},
{
"id": "indianamichigan",
"version": "2025-12",
"title": "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
},
{
"id": "ohio",
"version": "2025-12",
"title": "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
},
{
"id": "wisconsin",
"version": "2025-12",
"title": "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
}
]
@ -317,21 +400,27 @@
"language": "en",
"resources": [
{
"id": "new_jersey",
"version": "2025-12",
"title": "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
},
{
"id": "new_york",
"version": "2025-12",
"title": "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
},
{
"id": "pennsylvania",
"version": "2025-12",
"title": "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
}
]
@ -344,39 +433,51 @@
"language": "en",
"resources": [
{
"id": "connecticut",
"version": "2025-12",
"title": "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
},
{
"id": "maine",
"version": "2025-12",
"title": "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
},
{
"id": "massachusetts",
"version": "2025-12",
"title": "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
},
{
"id": "new_hampshire",
"version": "2025-12",
"title": "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
},
{
"id": "rhode_island",
"version": "2025-12",
"title": "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
},
{
"id": "vermont",
"version": "2025-12",
"title": "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
}
]

View File

@ -1,46 +1,53 @@
{
"spec_version": "2026-02-11",
"options": [
{
"id": "none",
"name": "No Wikipedia",
"description": "Skip Wikipedia installation",
"size_mb": 0,
"url": null
"url": null,
"version": null
},
{
"id": "top-mini",
"name": "Quick Reference",
"description": "Top 100,000 articles with minimal images. Great for quick lookups.",
"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",
"name": "Popular Articles",
"description": "Top articles without images. Good balance of content and size.",
"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",
"name": "Complete Wikipedia (Compact)",
"description": "All 6+ million articles in condensed format.",
"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",
"name": "Complete Wikipedia (No Images)",
"description": "All articles without images. Comprehensive offline reference.",
"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",
"name": "Complete Wikipedia (Full)",
"description": "The complete experience with all images and media.",
"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"
}
]
}