mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-05 08:16:16 +02:00
feat: curated content system overhaul
This commit is contained in:
parent
4ac261477a
commit
32d206cfd7
9
admin/app/controllers/collection_updates_controller.ts
Normal file
9
admin/app/controllers/collection_updates_controller.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { SystemService } from '#services/system_service'
|
import { SystemService } from '#services/system_service'
|
||||||
import { ZimService } from '#services/zim_service'
|
import { ZimService } from '#services/zim_service'
|
||||||
|
import { CollectionManifestService } from '#services/collection_manifest_service'
|
||||||
import { inject } from '@adonisjs/core'
|
import { inject } from '@adonisjs/core'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
|
||||||
|
|
@ -26,4 +27,22 @@ export default class EasySetupController {
|
||||||
async listCuratedCategories({}: HttpContext) {
|
async listCuratedCategories({}: HttpContext) {
|
||||||
return await this.zimService.listCuratedCategories()
|
return await this.zimService.listCuratedCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshManifests({}: HttpContext) {
|
||||||
|
const manifestService = new CollectionManifestService()
|
||||||
|
const [zimChanged, mapsChanged, wikiChanged] = await Promise.all([
|
||||||
|
manifestService.fetchAndCacheSpec('zim_categories'),
|
||||||
|
manifestService.fetchAndCacheSpec('maps'),
|
||||||
|
manifestService.fetchAndCacheSpec('wikipedia'),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
changed: {
|
||||||
|
zim_categories: zimChanged,
|
||||||
|
maps: mapsChanged,
|
||||||
|
wikipedia: wikiChanged,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ZimService } from '#services/zim_service'
|
import { ZimService } from '#services/zim_service'
|
||||||
import {
|
import {
|
||||||
downloadCollectionValidator,
|
downloadCategoryTierValidator,
|
||||||
filenameParamValidator,
|
filenameParamValidator,
|
||||||
remoteDownloadWithMetadataValidator,
|
remoteDownloadWithMetadataValidator,
|
||||||
selectWikipediaValidator,
|
selectWikipediaValidator,
|
||||||
|
|
@ -25,7 +25,7 @@ export default class ZimController {
|
||||||
|
|
||||||
async downloadRemote({ request }: HttpContext) {
|
async downloadRemote({ request }: HttpContext) {
|
||||||
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
|
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
|
||||||
const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
|
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Download started successfully',
|
message: 'Download started successfully',
|
||||||
|
|
@ -35,26 +35,25 @@ export default class ZimController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadCollection({ request }: HttpContext) {
|
async listCuratedCategories({}: HttpContext) {
|
||||||
const payload = await request.validateUsing(downloadCollectionValidator)
|
return await this.zimService.listCuratedCategories()
|
||||||
const resources = await this.zimService.downloadCollection(payload.slug)
|
}
|
||||||
|
|
||||||
|
async downloadCategoryTier({ request }: HttpContext) {
|
||||||
|
const payload = await request.validateUsing(downloadCategoryTierValidator)
|
||||||
|
const resources = await this.zimService.downloadCategoryTier(
|
||||||
|
payload.categorySlug,
|
||||||
|
payload.tierSlug
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Download started successfully',
|
message: 'Download started successfully',
|
||||||
slug: payload.slug,
|
categorySlug: payload.categorySlug,
|
||||||
|
tierSlug: payload.tierSlug,
|
||||||
resources,
|
resources,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCuratedCollections({}: HttpContext) {
|
|
||||||
return this.zimService.listCuratedCollections()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchLatestCollections({}: HttpContext) {
|
|
||||||
const success = await this.zimService.fetchLatestCollections()
|
|
||||||
return { success }
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete({ request, response }: HttpContext) {
|
async delete({ request, response }: HttpContext) {
|
||||||
const payload = await request.validateUsing(filenameParamValidator)
|
const payload = await request.validateUsing(filenameParamValidator)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export class RunDownloadJob {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(job: Job) {
|
async handle(job: Job) {
|
||||||
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype } =
|
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
|
||||||
job.data as RunDownloadJobParams
|
job.data as RunDownloadJobParams
|
||||||
|
|
||||||
await doResumableDownload({
|
await doResumableDownload({
|
||||||
|
|
@ -37,6 +37,26 @@ export class RunDownloadJob {
|
||||||
},
|
},
|
||||||
async onComplete(url) {
|
async onComplete(url) {
|
||||||
try {
|
try {
|
||||||
|
// Create InstalledResource entry if metadata was provided
|
||||||
|
if (resourceMetadata) {
|
||||||
|
const { default: InstalledResource } = await import('#models/installed_resource')
|
||||||
|
const { DateTime } = await import('luxon')
|
||||||
|
const { getFileStatsIfExists } = await import('../utils/fs.js')
|
||||||
|
const stats = await getFileStatsIfExists(filepath)
|
||||||
|
|
||||||
|
await InstalledResource.updateOrCreate(
|
||||||
|
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
|
||||||
|
{
|
||||||
|
version: resourceMetadata.version,
|
||||||
|
collection_ref: resourceMetadata.collection_ref,
|
||||||
|
url: url,
|
||||||
|
file_path: filepath,
|
||||||
|
file_size_bytes: stats ? Number(stats.size) : null,
|
||||||
|
installed_at: DateTime.now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (filetype === 'zim') {
|
if (filetype === 'zim') {
|
||||||
const dockerService = new DockerService()
|
const dockerService = new DockerService()
|
||||||
const zimService = new ZimService(dockerService)
|
const zimService = new ZimService(dockerService)
|
||||||
|
|
@ -57,7 +77,7 @@ export class RunDownloadJob {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[RunDownloadJob] Error in ZIM download success callback for URL ${url}:`,
|
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
admin/app/models/collection_manifest.ts
Normal file
22
admin/app/models/collection_manifest.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
33
admin/app/models/installed_resource.ts
Normal file
33
admin/app/models/installed_resource.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
311
admin/app/services/collection_manifest_service.ts
Normal file
311
admin/app/services/collection_manifest_service.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
130
admin/app/services/collection_update_service.ts
Normal file
130
admin/app/services/collection_update_service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { BaseStylesFile, MapLayer } from '../../types/maps.js'
|
import { BaseStylesFile, MapLayer } from '../../types/maps.js'
|
||||||
import {
|
import {
|
||||||
DownloadCollectionOperation,
|
|
||||||
DownloadRemoteSuccessCallback,
|
DownloadRemoteSuccessCallback,
|
||||||
FileEntry,
|
FileEntry,
|
||||||
} from '../../types/files.js'
|
} from '../../types/files.js'
|
||||||
|
|
@ -16,14 +15,11 @@ import {
|
||||||
} from '../utils/fs.js'
|
} from '../utils/fs.js'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import urlJoin from 'url-join'
|
import urlJoin from 'url-join'
|
||||||
import axios from 'axios'
|
|
||||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import { CuratedCollectionsFile, CuratedCollectionWithStatus } from '../../types/downloads.js'
|
import InstalledResource from '#models/installed_resource'
|
||||||
import CuratedCollection from '#models/curated_collection'
|
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||||
import vine from '@vinejs/vine'
|
import type { CollectionWithStatus, MapsSpec } from '../../types/collections.js'
|
||||||
import { curatedCollectionsFileSchema } from '#validators/curated_collections'
|
|
||||||
import CuratedCollectionResource from '#models/curated_collection_resource'
|
|
||||||
|
|
||||||
const BASE_ASSETS_MIME_TYPES = [
|
const BASE_ASSETS_MIME_TYPES = [
|
||||||
'application/gzip',
|
'application/gzip',
|
||||||
|
|
@ -31,15 +27,11 @@ const BASE_ASSETS_MIME_TYPES = [
|
||||||
'application/octet-stream',
|
'application/octet-stream',
|
||||||
]
|
]
|
||||||
|
|
||||||
const COLLECTIONS_URL =
|
|
||||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json'
|
|
||||||
|
|
||||||
const PMTILES_ATTRIBUTION =
|
const PMTILES_ATTRIBUTION =
|
||||||
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
|
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
|
||||||
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
|
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
|
||||||
|
|
||||||
interface IMapService {
|
interface IMapService {
|
||||||
downloadCollection: DownloadCollectionOperation
|
|
||||||
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
|
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,34 +91,33 @@ export class MapService implements IMapService {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadCollection(slug: string) {
|
async downloadCollection(slug: string): Promise<string[] | null> {
|
||||||
const collection = await CuratedCollection.query()
|
const manifestService = new CollectionManifestService()
|
||||||
.where('slug', slug)
|
const spec = await manifestService.getSpecWithFallback<MapsSpec>('maps')
|
||||||
.andWhere('type', 'map')
|
if (!spec) return null
|
||||||
.first()
|
|
||||||
if (!collection) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const resources = await collection.related('resources').query().where('downloaded', false)
|
const collection = spec.collections.find((c) => c.slug === slug)
|
||||||
if (resources.length === 0) {
|
if (!collection) return null
|
||||||
return null
|
|
||||||
}
|
// Filter out already installed
|
||||||
|
const installed = await InstalledResource.query().where('resource_type', 'map')
|
||||||
|
const installedIds = new Set(installed.map((r) => r.resource_id))
|
||||||
|
const toDownload = collection.resources.filter((r) => !installedIds.has(r.id))
|
||||||
|
|
||||||
|
if (toDownload.length === 0) return null
|
||||||
|
|
||||||
const downloadUrls = resources.map((res) => res.url)
|
|
||||||
const downloadFilenames: string[] = []
|
const downloadFilenames: string[] = []
|
||||||
|
|
||||||
for (const url of downloadUrls) {
|
for (const resource of toDownload) {
|
||||||
const existing = await RunDownloadJob.getByUrl(url)
|
const existing = await RunDownloadJob.getByUrl(resource.url)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
logger.warn(`[MapService] Download already in progress for URL ${url}, skipping.`)
|
logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the filename from the URL
|
const filename = resource.url.split('/').pop()
|
||||||
const filename = url.split('/').pop()
|
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
logger.warn(`[MapService] Could not determine filename from URL ${url}, skipping.`)
|
logger.warn(`[MapService] Could not determine filename from URL ${resource.url}, skipping.`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,12 +125,17 @@ export class MapService implements IMapService {
|
||||||
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
|
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
|
||||||
|
|
||||||
await RunDownloadJob.dispatch({
|
await RunDownloadJob.dispatch({
|
||||||
url,
|
url: resource.url,
|
||||||
filepath,
|
filepath,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
allowedMimeTypes: PMTILES_MIME_TYPES,
|
allowedMimeTypes: PMTILES_MIME_TYPES,
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
filetype: 'map',
|
filetype: 'map',
|
||||||
|
resourceMetadata: {
|
||||||
|
resource_id: resource.id,
|
||||||
|
version: resource.version,
|
||||||
|
collection_ref: slug,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,11 +143,33 @@ export class MapService implements IMapService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadRemoteSuccessCallback(urls: string[], _: boolean) {
|
async downloadRemoteSuccessCallback(urls: string[], _: boolean) {
|
||||||
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
|
// Create InstalledResource entries for downloaded map files
|
||||||
for (const resource of resources) {
|
for (const url of urls) {
|
||||||
resource.downloaded = true
|
const filename = url.split('/').pop()
|
||||||
await resource.save()
|
if (!filename) continue
|
||||||
logger.info(`[MapService] Marked resource as downloaded: ${resource.url}`)
|
|
||||||
|
const parsed = CollectionManifestService.parseMapFilename(filename)
|
||||||
|
if (!parsed) continue
|
||||||
|
|
||||||
|
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
|
||||||
|
const stats = await getFileStatsIfExists(filepath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { DateTime } = await import('luxon')
|
||||||
|
await InstalledResource.updateOrCreate(
|
||||||
|
{ resource_id: parsed.resource_id, resource_type: 'map' },
|
||||||
|
{
|
||||||
|
version: parsed.version,
|
||||||
|
url: url,
|
||||||
|
file_path: filepath,
|
||||||
|
file_size_bytes: stats ? Number(stats.size) : null,
|
||||||
|
installed_at: DateTime.now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(`[MapService] Created InstalledResource entry for: ${parsed.resource_id}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[MapService] Failed to create InstalledResource for ${filename}:`, error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +200,12 @@ export class MapService implements IMapService {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse resource metadata
|
||||||
|
const parsedFilename = CollectionManifestService.parseMapFilename(filename)
|
||||||
|
const resourceMetadata = parsedFilename
|
||||||
|
? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
|
||||||
|
: undefined
|
||||||
|
|
||||||
// Dispatch background job
|
// Dispatch background job
|
||||||
const result = await RunDownloadJob.dispatch({
|
const result = await RunDownloadJob.dispatch({
|
||||||
url,
|
url,
|
||||||
|
|
@ -190,6 +214,7 @@ export class MapService implements IMapService {
|
||||||
allowedMimeTypes: PMTILES_MIME_TYPES,
|
allowedMimeTypes: PMTILES_MIME_TYPES,
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
filetype: 'map',
|
filetype: 'map',
|
||||||
|
resourceMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.job) {
|
if (!result.job) {
|
||||||
|
|
@ -219,6 +244,7 @@ export class MapService implements IMapService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform a HEAD request to get the content length
|
// Perform a HEAD request to get the content length
|
||||||
|
const { default: axios } = await import('axios')
|
||||||
const response = await axios.head(url)
|
const response = await axios.head(url)
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
|
|
@ -229,7 +255,7 @@ export class MapService implements IMapService {
|
||||||
const size = contentLength ? parseInt(contentLength, 10) : 0
|
const size = contentLength ? parseInt(contentLength, 10) : 0
|
||||||
|
|
||||||
return { filename, size }
|
return { filename, size }
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
return { message: `Preflight check failed: ${error.message}` }
|
return { message: `Preflight check failed: ${error.message}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,53 +294,14 @@ export class MapService implements IMapService {
|
||||||
return styles
|
return styles
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
|
async listCuratedCollections(): Promise<CollectionWithStatus[]> {
|
||||||
const collections = await CuratedCollection.query().where('type', 'map').preload('resources')
|
const manifestService = new CollectionManifestService()
|
||||||
return collections.map((collection) => ({
|
return manifestService.getMapCollectionsWithStatus()
|
||||||
...(collection.serialize() as CuratedCollection),
|
|
||||||
all_downloaded: collection.resources.every((res) => res.downloaded),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchLatestCollections(): Promise<boolean> {
|
async fetchLatestCollections(): Promise<boolean> {
|
||||||
try {
|
const manifestService = new CollectionManifestService()
|
||||||
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL)
|
return manifestService.fetchAndCacheSpec('maps')
|
||||||
|
|
||||||
const validated = await vine.validate({
|
|
||||||
schema: curatedCollectionsFileSchema,
|
|
||||||
data: response.data,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const collection of validated.collections) {
|
|
||||||
const { resources, ...restCollection } = collection; // we'll handle resources separately
|
|
||||||
|
|
||||||
// Upsert the collection itself
|
|
||||||
await CuratedCollection.updateOrCreate(
|
|
||||||
{ slug: restCollection.slug },
|
|
||||||
{
|
|
||||||
...restCollection,
|
|
||||||
type: 'map',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.info(`[MapService] Upserted curated collection: ${restCollection.slug}`)
|
|
||||||
|
|
||||||
// Upsert collection's resources
|
|
||||||
const resourcesResult = await CuratedCollectionResource.updateOrCreateMany('url', resources.map((res) => ({
|
|
||||||
...res,
|
|
||||||
curated_collection_slug: restCollection.slug, // add the foreign key
|
|
||||||
})))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[MapService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
logger.error(`[MapService] Failed to download latest Kiwix collections:`, error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureBaseAssets(): Promise<boolean> {
|
async ensureBaseAssets(): Promise<boolean> {
|
||||||
|
|
@ -361,7 +348,9 @@ export class MapService implements IMapService {
|
||||||
|
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
||||||
const regionName = region.name.replace('.pmtiles', '')
|
// Strip .pmtiles and date suffix (e.g. "alaska_2025-12" -> "alaska") for stable source names
|
||||||
|
const parsed = CollectionManifestService.parseMapFilename(region.name)
|
||||||
|
const regionName = parsed ? parsed.resource_id : region.name.replace('.pmtiles', '')
|
||||||
const source: BaseStylesFile['sources'] = {}
|
const source: BaseStylesFile['sources'] = {}
|
||||||
const sourceUrl = urlJoin(baseUrl, region.name)
|
const sourceUrl = urlJoin(baseUrl, region.name)
|
||||||
|
|
||||||
|
|
@ -411,11 +400,11 @@ export class MapService implements IMapService {
|
||||||
|
|
||||||
async delete(file: string): Promise<void> {
|
async delete(file: string): Promise<void> {
|
||||||
let fileName = file
|
let fileName = file
|
||||||
if (!fileName.endsWith('.zim')) {
|
if (!fileName.endsWith('.pmtiles')) {
|
||||||
fileName += '.zim'
|
fileName += '.pmtiles'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = join(this.baseDirPath, fileName)
|
const fullPath = join(this.baseDirPath, 'pmtiles', fileName)
|
||||||
|
|
||||||
const exists = await getFileStatsIfExists(fullPath)
|
const exists = await getFileStatsIfExists(fullPath)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
|
|
@ -423,6 +412,16 @@ export class MapService implements IMapService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteFileIfExists(fullPath)
|
await deleteFileIfExists(fullPath)
|
||||||
|
|
||||||
|
// Clean up InstalledResource entry
|
||||||
|
const parsed = CollectionManifestService.parseMapFilename(fileName)
|
||||||
|
if (parsed) {
|
||||||
|
await InstalledResource.query()
|
||||||
|
.where('resource_id', parsed.resource_id)
|
||||||
|
.where('resource_type', 'map')
|
||||||
|
.delete()
|
||||||
|
logger.info(`[MapService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -17,32 +17,21 @@ import {
|
||||||
ZIM_STORAGE_PATH,
|
ZIM_STORAGE_PATH,
|
||||||
} from '../utils/fs.js'
|
} from '../utils/fs.js'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { CuratedCategory, CuratedCollectionWithStatus, CuratedCollectionsFile, WikipediaOption, WikipediaState } from '../../types/downloads.js'
|
import { WikipediaOption, WikipediaState } from '../../types/downloads.js'
|
||||||
import vine from '@vinejs/vine'
|
import vine from '@vinejs/vine'
|
||||||
import { curatedCategoriesFileSchema, curatedCollectionsFileSchema, wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
||||||
import CuratedCollection from '#models/curated_collection'
|
|
||||||
import CuratedCollectionResource from '#models/curated_collection_resource'
|
|
||||||
import WikipediaSelection from '#models/wikipedia_selection'
|
import WikipediaSelection from '#models/wikipedia_selection'
|
||||||
import ZimFileMetadata from '#models/zim_file_metadata'
|
import InstalledResource from '#models/installed_resource'
|
||||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||||
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
|
|
||||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||||
|
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||||
|
import type { CategoryWithStatus } from '../../types/collections.js'
|
||||||
|
|
||||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||||
const CATEGORIES_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/kiwix-categories.json'
|
|
||||||
const COLLECTIONS_URL =
|
|
||||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/kiwix.json'
|
|
||||||
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json'
|
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface IZimService {
|
|
||||||
downloadCollection: DownloadCollectionOperation
|
|
||||||
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
|
|
||||||
}
|
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export class ZimService implements IZimService {
|
export class ZimService {
|
||||||
constructor(private dockerService: DockerService) { }
|
constructor(private dockerService: DockerService) { }
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
|
|
@ -52,24 +41,8 @@ export class ZimService implements IZimService {
|
||||||
const all = await listDirectoryContents(dirPath)
|
const all = await listDirectoryContents(dirPath)
|
||||||
const files = all.filter((item) => item.name.endsWith('.zim'))
|
const files = all.filter((item) => item.name.endsWith('.zim'))
|
||||||
|
|
||||||
// Fetch metadata for all files
|
|
||||||
const metadataRecords = await ZimFileMetadata.all()
|
|
||||||
const metadataMap = new Map(metadataRecords.map((m) => [m.filename, m]))
|
|
||||||
|
|
||||||
// Enrich files with metadata
|
|
||||||
const enrichedFiles = files.map((file) => {
|
|
||||||
const metadata = metadataMap.get(file.name)
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
title: metadata?.title || null,
|
|
||||||
summary: metadata?.summary || null,
|
|
||||||
author: metadata?.author || null,
|
|
||||||
size_bytes: metadata?.size_bytes || null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
files: enrichedFiles,
|
files,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,10 +137,7 @@ export class ZimService implements IZimService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadRemote(
|
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
|
||||||
url: string,
|
|
||||||
metadata?: { title: string; summary?: string; author?: string; size_bytes?: number }
|
|
||||||
): Promise<{ filename: string; jobId?: string }> {
|
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
if (!parsed.pathname.endsWith('.zim')) {
|
if (!parsed.pathname.endsWith('.zim')) {
|
||||||
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
|
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
|
||||||
|
|
@ -186,19 +156,11 @@ export class ZimService implements IZimService {
|
||||||
|
|
||||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||||
|
|
||||||
// Store metadata if provided
|
// Parse resource metadata for the download job
|
||||||
if (metadata) {
|
const parsedFilename = CollectionManifestService.parseZimFilename(filename)
|
||||||
await ZimFileMetadata.updateOrCreate(
|
const resourceMetadata = parsedFilename
|
||||||
{ filename },
|
? { resource_id: parsedFilename.resource_id, version: parsedFilename.version, collection_ref: null }
|
||||||
{
|
: undefined
|
||||||
title: metadata.title,
|
|
||||||
summary: metadata.summary || null,
|
|
||||||
author: metadata.author || null,
|
|
||||||
size_bytes: metadata.size_bytes || null,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.info(`[ZimService] Stored metadata for ZIM file: ${filename}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch a background download job
|
// Dispatch a background download job
|
||||||
const result = await RunDownloadJob.dispatch({
|
const result = await RunDownloadJob.dispatch({
|
||||||
|
|
@ -208,6 +170,7 @@ export class ZimService implements IZimService {
|
||||||
allowedMimeTypes: ZIM_MIME_TYPES,
|
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
filetype: 'zim',
|
filetype: 'zim',
|
||||||
|
resourceMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result || !result.job) {
|
if (!result || !result.job) {
|
||||||
|
|
@ -222,44 +185,64 @@ export class ZimService implements IZimService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadCollection(slug: string) {
|
async listCuratedCategories(): Promise<CategoryWithStatus[]> {
|
||||||
const collection = await CuratedCollection.query().where('slug', slug).andWhere('type', 'zim').first()
|
const manifestService = new CollectionManifestService()
|
||||||
if (!collection) {
|
return manifestService.getCategoriesWithStatus()
|
||||||
return null
|
}
|
||||||
|
|
||||||
|
async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<string[] | null> {
|
||||||
|
const manifestService = new CollectionManifestService()
|
||||||
|
const spec = await manifestService.getSpecWithFallback<import('../../types/collections.js').ZimCategoriesSpec>('zim_categories')
|
||||||
|
if (!spec) {
|
||||||
|
throw new Error('Could not load ZIM categories spec')
|
||||||
}
|
}
|
||||||
|
|
||||||
const resources = await collection.related('resources').query().where('downloaded', false)
|
const category = spec.categories.find((c) => c.slug === categorySlug)
|
||||||
if (resources.length === 0) {
|
if (!category) {
|
||||||
return null
|
throw new Error(`Category not found: ${categorySlug}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrls = resources.map((res) => res.url)
|
const tier = category.tiers.find((t) => t.slug === tierSlug)
|
||||||
|
if (!tier) {
|
||||||
|
throw new Error(`Tier not found: ${tierSlug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResources = CollectionManifestService.resolveTierResources(tier, category.tiers)
|
||||||
|
|
||||||
|
// Filter out already installed
|
||||||
|
const installed = await InstalledResource.query().where('resource_type', 'zim')
|
||||||
|
const installedIds = new Set(installed.map((r) => r.resource_id))
|
||||||
|
const toDownload = allResources.filter((r) => !installedIds.has(r.id))
|
||||||
|
|
||||||
|
if (toDownload.length === 0) return null
|
||||||
|
|
||||||
const downloadFilenames: string[] = []
|
const downloadFilenames: string[] = []
|
||||||
|
|
||||||
for (const url of downloadUrls) {
|
for (const resource of toDownload) {
|
||||||
const existing = await RunDownloadJob.getByUrl(url)
|
const existingJob = await RunDownloadJob.getByUrl(resource.url)
|
||||||
if (existing) {
|
if (existingJob) {
|
||||||
logger.warn(`[ZimService] Download already in progress for URL ${url}, skipping.`)
|
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the filename from the URL
|
const filename = resource.url.split('/').pop()
|
||||||
const filename = url.split('/').pop()
|
if (!filename) continue
|
||||||
if (!filename) {
|
|
||||||
logger.warn(`[ZimService] Could not determine filename from URL ${url}, skipping.`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadFilenames.push(filename)
|
downloadFilenames.push(filename)
|
||||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||||
|
|
||||||
await RunDownloadJob.dispatch({
|
await RunDownloadJob.dispatch({
|
||||||
url,
|
url: resource.url,
|
||||||
filepath,
|
filepath,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
allowedMimeTypes: ZIM_MIME_TYPES,
|
allowedMimeTypes: ZIM_MIME_TYPES,
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
filetype: 'zim',
|
filetype: 'zim',
|
||||||
|
resourceMetadata: {
|
||||||
|
resource_id: resource.id,
|
||||||
|
version: resource.version,
|
||||||
|
collection_ref: categorySlug,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,130 +293,36 @@ export class ZimService implements IZimService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark any curated collection resources with this download URL as downloaded
|
// Create InstalledResource entries for downloaded files
|
||||||
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
|
for (const url of urls) {
|
||||||
for (const resource of resources) {
|
// Skip Wikipedia files (managed separately)
|
||||||
resource.downloaded = true
|
if (url.includes('wikipedia_en_')) continue
|
||||||
await resource.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCuratedCategories(): Promise<CuratedCategory[]> {
|
const filename = url.split('/').pop()
|
||||||
try {
|
if (!filename) continue
|
||||||
const response = await axios.get(CATEGORIES_URL)
|
|
||||||
const data = response.data
|
|
||||||
|
|
||||||
const validated = await vine.validate({
|
const parsed = CollectionManifestService.parseZimFilename(filename)
|
||||||
schema: curatedCategoriesFileSchema,
|
if (!parsed) continue
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Dynamically determine installed tier for each category
|
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||||
const categoriesWithStatus = await Promise.all(
|
const stats = await getFileStatsIfExists(filepath)
|
||||||
validated.categories.map(async (category) => {
|
|
||||||
const installedTierSlug = await this.getInstalledTierForCategory(category)
|
|
||||||
return {
|
|
||||||
...category,
|
|
||||||
installedTierSlug,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return categoriesWithStatus
|
try {
|
||||||
} catch (error) {
|
const { DateTime } = await import('luxon')
|
||||||
logger.error(`[ZimService] Failed to fetch curated categories:`, error)
|
await InstalledResource.updateOrCreate(
|
||||||
throw new Error('Failed to fetch curated categories or invalid format was received')
|
{ resource_id: parsed.resource_id, resource_type: 'zim' },
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamically determines which tier is installed for a category by checking
|
|
||||||
* which tier's resources are all downloaded. Returns the highest tier that
|
|
||||||
* is fully installed (considering that higher tiers include lower tier resources)
|
|
||||||
*/
|
|
||||||
private async getInstalledTierForCategory(category: CuratedCategory): Promise<string | undefined> {
|
|
||||||
const { files: diskFiles } = await this.list()
|
|
||||||
const diskFilenames = new Set(diskFiles.map((f) => f.name))
|
|
||||||
|
|
||||||
// Get all CuratedCollectionResources marked as downloaded
|
|
||||||
const downloadedResources = await CuratedCollectionResource.query()
|
|
||||||
.where('downloaded', true)
|
|
||||||
.select('url')
|
|
||||||
const downloadedUrls = new Set(downloadedResources.map((r) => r.url))
|
|
||||||
|
|
||||||
// Check each tier from highest to lowest (assuming tiers are ordered from low to high)
|
|
||||||
// We check in reverse to find the highest fully-installed tier
|
|
||||||
const reversedTiers = [...category.tiers].reverse()
|
|
||||||
|
|
||||||
for (const tier of reversedTiers) {
|
|
||||||
const allResourcesInstalled = tier.resources.every((resource) => {
|
|
||||||
// Check if resource is marked as downloaded in database
|
|
||||||
if (downloadedUrls.has(resource.url)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check if file exists on disk (for resources not tracked in CuratedCollectionResource)
|
|
||||||
const filename = resource.url.split('/').pop()
|
|
||||||
if (filename && diskFilenames.has(filename)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (allResourcesInstalled && tier.resources.length > 0) {
|
|
||||||
return tier.slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
|
|
||||||
const collections = await CuratedCollection.query().where('type', 'zim').preload('resources')
|
|
||||||
return collections.map((collection) => ({
|
|
||||||
...(collection.serialize() as CuratedCollection),
|
|
||||||
all_downloaded: collection.resources.every((res) => res.downloaded),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchLatestCollections(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL)
|
|
||||||
|
|
||||||
const validated = await vine.validate({
|
|
||||||
schema: curatedCollectionsFileSchema,
|
|
||||||
data: response.data,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const collection of validated.collections) {
|
|
||||||
const { resources, ...restCollection } = collection; // we'll handle resources separately
|
|
||||||
|
|
||||||
// Upsert the collection itself
|
|
||||||
await CuratedCollection.updateOrCreate(
|
|
||||||
{ slug: restCollection.slug },
|
|
||||||
{
|
{
|
||||||
...restCollection,
|
version: parsed.version,
|
||||||
type: 'zim',
|
url: url,
|
||||||
|
file_path: filepath,
|
||||||
|
file_size_bytes: stats ? Number(stats.size) : null,
|
||||||
|
installed_at: DateTime.now(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info(`[ZimService] Upserted curated collection: ${restCollection.slug}`)
|
logger.info(`[ZimService] Created InstalledResource entry for: ${parsed.resource_id}`)
|
||||||
|
} catch (error) {
|
||||||
// Upsert collection's resources
|
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
|
||||||
const resourcesResult = await CuratedCollectionResource.updateOrCreateMany('url', resources.map((res) => ({
|
|
||||||
...res,
|
|
||||||
curated_collection_slug: restCollection.slug, // add the foreign key
|
|
||||||
})))
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[ZimService] Upserted ${resourcesResult.length} resources for collection: ${restCollection.slug}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[ZimService] Failed to download latest Kiwix collections:`, error)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,9 +341,15 @@ export class ZimService implements IZimService {
|
||||||
|
|
||||||
await deleteFileIfExists(fullPath)
|
await deleteFileIfExists(fullPath)
|
||||||
|
|
||||||
// Clean up metadata
|
// Clean up InstalledResource entry
|
||||||
await ZimFileMetadata.query().where('filename', fileName).delete()
|
const parsed = CollectionManifestService.parseZimFilename(fileName)
|
||||||
logger.info(`[ZimService] Deleted metadata for ZIM file: ${fileName}`)
|
if (parsed) {
|
||||||
|
await InstalledResource.query()
|
||||||
|
.where('resource_id', parsed.resource_id)
|
||||||
|
.where('resource_type', 'zim')
|
||||||
|
.delete()
|
||||||
|
logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wikipedia selector methods
|
// Wikipedia selector methods
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@ export const downloadCollectionValidator = vine.compile(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const downloadCategoryTierValidator = vine.compile(
|
||||||
|
vine.object({
|
||||||
|
categorySlug: vine.string().trim().minLength(1),
|
||||||
|
tierSlug: vine.string().trim().minLength(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const selectWikipediaValidator = vine.compile(
|
export const selectWikipediaValidator = vine.compile(
|
||||||
vine.object({
|
vine.object({
|
||||||
optionId: vine.string().trim().minLength(1),
|
optionId: vine.string().trim().minLength(1),
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,20 @@
|
||||||
import vine from '@vinejs/vine'
|
import vine from '@vinejs/vine'
|
||||||
|
|
||||||
export const curatedCollectionResourceValidator = vine.object({
|
// ---- Versioned resource validators (with id + version) ----
|
||||||
|
|
||||||
|
export const specResourceValidator = vine.object({
|
||||||
|
id: vine.string(),
|
||||||
|
version: vine.string(),
|
||||||
title: vine.string(),
|
title: vine.string(),
|
||||||
description: vine.string(),
|
description: vine.string(),
|
||||||
url: vine.string().url(),
|
url: vine.string().url(),
|
||||||
size_mb: vine.number().min(0).optional(),
|
size_mb: vine.number().min(0).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const curatedCollectionValidator = vine.object({
|
// ---- ZIM Categories spec (versioned) ----
|
||||||
slug: vine.string(),
|
|
||||||
name: vine.string(),
|
|
||||||
description: vine.string(),
|
|
||||||
icon: vine.string(),
|
|
||||||
language: vine.string().minLength(2).maxLength(5),
|
|
||||||
resources: vine.array(curatedCollectionResourceValidator).minLength(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const curatedCollectionsFileSchema = vine.object({
|
export const zimCategoriesSpecSchema = vine.object({
|
||||||
collections: vine.array(curatedCollectionValidator).minLength(1),
|
spec_version: vine.string(),
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For validating the categories file, which has a different structure than the collections file
|
|
||||||
* since it includes tiers within each category.
|
|
||||||
*/
|
|
||||||
export const curatedCategoriesFileSchema = vine.object({
|
|
||||||
categories: vine.array(
|
categories: vine.array(
|
||||||
vine.object({
|
vine.object({
|
||||||
name: vine.string(),
|
name: vine.string(),
|
||||||
|
|
@ -39,16 +29,47 @@ export const curatedCategoriesFileSchema = vine.object({
|
||||||
description: vine.string(),
|
description: vine.string(),
|
||||||
recommended: vine.boolean().optional(),
|
recommended: vine.boolean().optional(),
|
||||||
includesTier: vine.string().optional(),
|
includesTier: vine.string().optional(),
|
||||||
resources: vine.array(curatedCollectionResourceValidator),
|
resources: vine.array(specResourceValidator),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// ---- Maps spec (versioned) ----
|
||||||
* For validating the Wikipedia options file
|
|
||||||
*/
|
export const mapsSpecSchema = vine.object({
|
||||||
|
spec_version: vine.string(),
|
||||||
|
collections: vine.array(
|
||||||
|
vine.object({
|
||||||
|
slug: vine.string(),
|
||||||
|
name: vine.string(),
|
||||||
|
description: vine.string(),
|
||||||
|
icon: vine.string(),
|
||||||
|
language: vine.string().minLength(2).maxLength(5),
|
||||||
|
resources: vine.array(specResourceValidator).minLength(1),
|
||||||
|
})
|
||||||
|
).minLength(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Wikipedia spec (versioned) ----
|
||||||
|
|
||||||
|
export const wikipediaSpecSchema = vine.object({
|
||||||
|
spec_version: vine.string(),
|
||||||
|
options: vine.array(
|
||||||
|
vine.object({
|
||||||
|
id: vine.string(),
|
||||||
|
name: vine.string(),
|
||||||
|
description: vine.string(),
|
||||||
|
size_mb: vine.number().min(0),
|
||||||
|
url: vine.string().url().nullable(),
|
||||||
|
version: vine.string().nullable(),
|
||||||
|
})
|
||||||
|
).minLength(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Wikipedia validators (used by ZimService) ----
|
||||||
|
|
||||||
export const wikipediaOptionSchema = vine.object({
|
export const wikipediaOptionSchema = vine.object({
|
||||||
id: vine.string(),
|
id: vine.string(),
|
||||||
name: vine.string(),
|
name: vine.string(),
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,15 @@ new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||||
})
|
})
|
||||||
app.listen('SIGTERM', () => app.terminate())
|
app.listen('SIGTERM', () => app.terminate())
|
||||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||||
|
app.ready(async () => {
|
||||||
|
try {
|
||||||
|
const collectionManifestService = new (await import('#services/collection_manifest_service')).CollectionManifestService()
|
||||||
|
await collectionManifestService.reconcileFromFilesystem()
|
||||||
|
} catch (error) {
|
||||||
|
// Catch and log any errors during reconciliation to prevent the server from crashing
|
||||||
|
console.error('Error during collection manifest reconciliation:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.httpServer()
|
.httpServer()
|
||||||
.start()
|
.start()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
import { CuratedCategory, CategoryTier } from '../../types/downloads'
|
import type { CategoryWithStatus, SpecTier } from '../../types/collections'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'
|
import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'
|
||||||
|
|
||||||
export interface CategoryCardProps {
|
export interface CategoryCardProps {
|
||||||
category: CuratedCategory
|
category: CategoryWithStatus
|
||||||
selectedTier?: CategoryTier | null
|
selectedTier?: SpecTier | null
|
||||||
onClick?: (category: CuratedCategory) => void
|
onClick?: (category: CategoryWithStatus) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {
|
const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {
|
||||||
// Calculate total size range across all tiers
|
// Calculate total size range across all tiers
|
||||||
const getTierTotalSize = (tier: CategoryTier, allTiers: CategoryTier[]): number => {
|
const getTierTotalSize = (tier: SpecTier, allTiers: SpecTier[]): number => {
|
||||||
let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
|
let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
|
||||||
|
|
||||||
// Add included tier sizes recursively
|
// Add included tier sizes recursively
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
import { CuratedCollectionWithStatus } from '../../types/downloads'
|
import type { CollectionWithStatus } from '../../types/collections'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { IconCircleCheck } from '@tabler/icons-react'
|
import { IconCircleCheck } from '@tabler/icons-react'
|
||||||
|
|
||||||
export interface CuratedCollectionCardProps {
|
export interface CuratedCollectionCardProps {
|
||||||
collection: CuratedCollectionWithStatus
|
collection: CollectionWithStatus
|
||||||
onClick?: (collection: CuratedCollectionWithStatus) => void;
|
onClick?: (collection: CollectionWithStatus) => void;
|
||||||
size?: 'small' | 'large'
|
size?: 'small' | 'large'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,11 +19,11 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'flex flex-col bg-desert-green rounded-lg p-6 text-white border border-b-desert-green shadow-sm hover:shadow-lg transition-shadow cursor-pointer',
|
'flex flex-col bg-desert-green rounded-lg p-6 text-white border border-b-desert-green shadow-sm hover:shadow-lg transition-shadow cursor-pointer',
|
||||||
{ 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_downloaded },
|
{ 'opacity-65 cursor-not-allowed !hover:shadow-sm': collection.all_installed },
|
||||||
{ 'h-56': size === 'small', 'h-80': size === 'large' }
|
{ 'h-56': size === 'small', 'h-80': size === 'large' }
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (collection.all_downloaded) {
|
if (collection.all_installed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
|
|
@ -37,7 +37,7 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
|
||||||
<DynamicIcon icon={collection.icon as DynamicIconName} className="w-6 h-6 mr-2" />
|
<DynamicIcon icon={collection.icon as DynamicIconName} className="w-6 h-6 mr-2" />
|
||||||
<h3 className="text-lg font-semibold">{collection.name}</h3>
|
<h3 className="text-lg font-semibold">{collection.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
{collection.all_downloaded && (
|
{collection.all_installed && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<IconCircleCheck
|
<IconCircleCheck
|
||||||
className="w-5 h-5 text-lime-400 ml-2"
|
className="w-5 h-5 text-lime-400 ml-2"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Fragment, useState, useEffect } from 'react'
|
import { Fragment, useState, useEffect } from 'react'
|
||||||
import { Dialog, Transition } from '@headlessui/react'
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
|
import { IconX, IconCheck, IconInfoCircle } from '@tabler/icons-react'
|
||||||
import { CuratedCategory, CategoryTier, CategoryResource } from '../../types/downloads'
|
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../types/collections'
|
||||||
|
import { resolveTierResources } from '~/lib/collections'
|
||||||
import { formatBytes } from '~/lib/util'
|
import { formatBytes } from '~/lib/util'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
|
|
@ -9,9 +10,9 @@ import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||||
interface TierSelectionModalProps {
|
interface TierSelectionModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
category: CuratedCategory | null
|
category: CategoryWithStatus | null
|
||||||
selectedTierSlug?: string | null
|
selectedTierSlug?: string | null
|
||||||
onSelectTier: (category: CuratedCategory, tier: CategoryTier) => void
|
onSelectTier: (category: CategoryWithStatus, tier: SpecTier) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
|
|
@ -34,24 +35,15 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
||||||
if (!category) return null
|
if (!category) return null
|
||||||
|
|
||||||
// Get all resources for a tier (including inherited resources)
|
// Get all resources for a tier (including inherited resources)
|
||||||
const getAllResourcesForTier = (tier: CategoryTier): CategoryResource[] => {
|
const getAllResourcesForTier = (tier: SpecTier): SpecResource[] => {
|
||||||
const resources = [...tier.resources]
|
return resolveTierResources(tier, category.tiers)
|
||||||
|
|
||||||
if (tier.includesTier) {
|
|
||||||
const includedTier = category.tiers.find(t => t.slug === tier.includesTier)
|
|
||||||
if (includedTier) {
|
|
||||||
resources.unshift(...getAllResourcesForTier(includedTier))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resources
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTierTotalSize = (tier: CategoryTier): number => {
|
const getTierTotalSize = (tier: SpecTier): number => {
|
||||||
return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
|
return getAllResourcesForTier(tier).reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTierClick = (tier: CategoryTier) => {
|
const handleTierClick = (tier: SpecTier) => {
|
||||||
// Toggle selection: if clicking the same tier, deselect it
|
// Toggle selection: if clicking the same tier, deselect it
|
||||||
if (localSelectedSlug === tier.slug) {
|
if (localSelectedSlug === tier.slug) {
|
||||||
setLocalSelectedSlug(null)
|
setLocalSelectedSlug(null)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,8 @@ import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zi
|
||||||
import { ServiceSlim } from '../../types/services'
|
import { ServiceSlim } from '../../types/services'
|
||||||
import { FileEntry } from '../../types/files'
|
import { FileEntry } from '../../types/files'
|
||||||
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
|
import { CheckLatestVersionResult, SystemInformationResponse, SystemUpdateStatus } from '../../types/system'
|
||||||
import {
|
import { DownloadJobWithProgress, WikipediaState } from '../../types/downloads'
|
||||||
CuratedCategory,
|
import type { CategoryWithStatus, CollectionWithStatus, CollectionUpdateCheckResult } from '../../types/collections'
|
||||||
CuratedCollectionWithStatus,
|
|
||||||
DownloadJobWithProgress,
|
|
||||||
WikipediaState,
|
|
||||||
} from '../../types/downloads'
|
|
||||||
import { catchInternal } from './util'
|
import { catchInternal } from './util'
|
||||||
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
|
import { NomadOllamaModel, OllamaChatRequest } from '../../types/ollama'
|
||||||
import { ChatResponse, ModelResponse } from 'ollama'
|
import { ChatResponse, ModelResponse } from 'ollama'
|
||||||
|
|
@ -78,13 +74,14 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadZimCollection(slug: string): Promise<{
|
async downloadCategoryTier(categorySlug: string, tierSlug: string): Promise<{
|
||||||
message: string
|
message: string
|
||||||
slug: string
|
categorySlug: string
|
||||||
|
tierSlug: string
|
||||||
resources: string[] | null
|
resources: string[] | null
|
||||||
}> {
|
}> {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.post('/zim/download-collection', { slug })
|
const response = await this.client.post('/zim/download-category-tier', { categorySlug, tierSlug })
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
@ -130,9 +127,18 @@ class API {
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchLatestZimCollections(): Promise<{ success: boolean } | undefined> {
|
async checkForCollectionUpdates() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.post<{ success: boolean }>('/zim/fetch-latest-collections')
|
const response = await this.client.post<CollectionUpdateCheckResult>('/collection-updates/check')
|
||||||
|
return response.data
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshManifests(): Promise<{ success: boolean; changed: Record<string, boolean> } | undefined> {
|
||||||
|
return catchInternal(async () => {
|
||||||
|
const response = await this.client.post<{ success: boolean; changed: Record<string, boolean> }>(
|
||||||
|
'/manifests/refresh'
|
||||||
|
)
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
@ -189,14 +195,14 @@ class API {
|
||||||
|
|
||||||
async getBenchmarkResults() {
|
async getBenchmarkResults() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<{ results: BenchmarkResult[], total: number}>('/benchmark/results')
|
const response = await this.client.get<{ results: BenchmarkResult[], total: number }>('/benchmark/results')
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLatestBenchmarkResult() {
|
async getLatestBenchmarkResult() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<{ result: BenchmarkResult | null}>('/benchmark/results/latest')
|
const response = await this.client.get<{ result: BenchmarkResult | null }>('/benchmark/results/latest')
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
@ -341,25 +347,16 @@ class API {
|
||||||
|
|
||||||
async listCuratedMapCollections() {
|
async listCuratedMapCollections() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<CuratedCollectionWithStatus[]>(
|
const response = await this.client.get<CollectionWithStatus[]>(
|
||||||
'/maps/curated-collections'
|
'/maps/curated-collections'
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCuratedZimCollections() {
|
|
||||||
return catchInternal(async () => {
|
|
||||||
const response = await this.client.get<CuratedCollectionWithStatus[]>(
|
|
||||||
'/zim/curated-collections'
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
|
|
||||||
async listCuratedCategories() {
|
async listCuratedCategories() {
|
||||||
return catchInternal(async () => {
|
return catchInternal(async () => {
|
||||||
const response = await this.client.get<CuratedCategory[]>('/easy-setup/curated-categories')
|
const response = await this.client.get<CategoryWithStatus[]>('/easy-setup/curated-categories')
|
||||||
return response.data
|
return response.data
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
admin/inertia/lib/collections.ts
Normal file
31
admin/inertia/lib/collections.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,8 @@ import { useNotifications } from '~/context/NotificationContext'
|
||||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||||
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads'
|
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
|
||||||
|
import { resolveTierResources } from '~/lib/collections'
|
||||||
import { SERVICE_NAMES } from '../../../constants/service_names'
|
import { SERVICE_NAMES } from '../../../constants/service_names'
|
||||||
|
|
||||||
// Capability definitions - maps user-friendly categories to services
|
// Capability definitions - maps user-friendly categories to services
|
||||||
|
|
@ -105,38 +106,21 @@ const ADDITIONAL_TOOLS: Capability[] = [
|
||||||
type WizardStep = 1 | 2 | 3 | 4
|
type WizardStep = 1 | 2 | 3 | 4
|
||||||
|
|
||||||
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
|
const CURATED_MAP_COLLECTIONS_KEY = 'curated-map-collections'
|
||||||
const CURATED_ZIM_COLLECTIONS_KEY = 'curated-zim-collections'
|
|
||||||
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
||||||
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
||||||
|
|
||||||
// Helper to get all resources for a tier (including inherited resources)
|
|
||||||
const getAllResourcesForTier = (
|
|
||||||
tier: CategoryTier,
|
|
||||||
allTiers: CategoryTier[]
|
|
||||||
): CategoryResource[] => {
|
|
||||||
const resources = [...tier.resources]
|
|
||||||
if (tier.includesTier) {
|
|
||||||
const includedTier = allTiers.find((t) => t.slug === tier.includesTier)
|
|
||||||
if (includedTier) {
|
|
||||||
resources.unshift(...getAllResourcesForTier(includedTier, allTiers))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resources
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
|
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
|
||||||
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
|
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
|
||||||
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||||||
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
|
const [selectedMapCollections, setSelectedMapCollections] = useState<string[]>([])
|
||||||
const [selectedZimCollections, setSelectedZimCollections] = useState<string[]>([])
|
|
||||||
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
|
const [selectedAiModels, setSelectedAiModels] = useState<string[]>([])
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [showAdditionalTools, setShowAdditionalTools] = useState(false)
|
const [showAdditionalTools, setShowAdditionalTools] = useState(false)
|
||||||
|
|
||||||
// Category/tier selection state
|
// Category/tier selection state
|
||||||
const [selectedTiers, setSelectedTiers] = useState<Map<string, CategoryTier>>(new Map())
|
const [selectedTiers, setSelectedTiers] = useState<Map<string, SpecTier>>(new Map())
|
||||||
const [tierModalOpen, setTierModalOpen] = useState(false)
|
const [tierModalOpen, setTierModalOpen] = useState(false)
|
||||||
const [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null)
|
const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)
|
||||||
|
|
||||||
// Wikipedia selection state
|
// Wikipedia selection state
|
||||||
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
|
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
|
||||||
|
|
@ -149,7 +133,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const anySelectionMade =
|
const anySelectionMade =
|
||||||
selectedServices.length > 0 ||
|
selectedServices.length > 0 ||
|
||||||
selectedMapCollections.length > 0 ||
|
selectedMapCollections.length > 0 ||
|
||||||
selectedZimCollections.length > 0 ||
|
|
||||||
selectedTiers.size > 0 ||
|
selectedTiers.size > 0 ||
|
||||||
selectedAiModels.length > 0 ||
|
selectedAiModels.length > 0 ||
|
||||||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
|
(selectedWikipedia !== null && selectedWikipedia !== 'none')
|
||||||
|
|
@ -160,12 +143,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: zimCollections, isLoading: isLoadingZims } = useQuery({
|
|
||||||
queryKey: [CURATED_ZIM_COLLECTIONS_KEY],
|
|
||||||
queryFn: () => api.listCuratedZimCollections(),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch curated categories with tiers
|
// Fetch curated categories with tiers
|
||||||
const { data: categories, isLoading: isLoadingCategories } = useQuery({
|
const { data: categories, isLoading: isLoadingCategories } = useQuery({
|
||||||
queryKey: [CURATED_CATEGORIES_KEY],
|
queryKey: [CURATED_CATEGORIES_KEY],
|
||||||
|
|
@ -202,12 +179,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleZimCollection = (slug: string) => {
|
|
||||||
setSelectedZimCollections((prev) =>
|
|
||||||
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAiModel = (modelName: string) => {
|
const toggleAiModel = (modelName: string) => {
|
||||||
setSelectedAiModels((prev) =>
|
setSelectedAiModels((prev) =>
|
||||||
prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName]
|
prev.includes(modelName) ? prev.filter((m) => m !== modelName) : [...prev, modelName]
|
||||||
|
|
@ -215,13 +186,13 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category/tier handlers
|
// Category/tier handlers
|
||||||
const handleCategoryClick = (category: CuratedCategory) => {
|
const handleCategoryClick = (category: CategoryWithStatus) => {
|
||||||
if (!isOnline) return
|
if (!isOnline) return
|
||||||
setActiveCategory(category)
|
setActiveCategory(category)
|
||||||
setTierModalOpen(true)
|
setTierModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTierSelect = (category: CuratedCategory, tier: CategoryTier) => {
|
const handleTierSelect = (category: CategoryWithStatus, tier: SpecTier) => {
|
||||||
setSelectedTiers((prev) => {
|
setSelectedTiers((prev) => {
|
||||||
const newMap = new Map(prev)
|
const newMap = new Map(prev)
|
||||||
// If same tier is selected, deselect it
|
// If same tier is selected, deselect it
|
||||||
|
|
@ -239,14 +210,14 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
setActiveCategory(null)
|
setActiveCategory(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all resources from selected tiers for downloading
|
// Get all resources from selected tiers for storage projection
|
||||||
const getSelectedTierResources = (): CategoryResource[] => {
|
const getSelectedTierResources = (): SpecResource[] => {
|
||||||
if (!categories) return []
|
if (!categories) return []
|
||||||
const resources: CategoryResource[] = []
|
const resources: SpecResource[] = []
|
||||||
selectedTiers.forEach((tier, categorySlug) => {
|
selectedTiers.forEach((tier, categorySlug) => {
|
||||||
const category = categories.find((c) => c.slug === categorySlug)
|
const category = categories.find((c) => c.slug === categorySlug)
|
||||||
if (category) {
|
if (category) {
|
||||||
resources.push(...getAllResourcesForTier(tier, category.tiers))
|
resources.push(...resolveTierResources(tier, category.tiers))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return resources
|
return resources
|
||||||
|
|
@ -270,16 +241,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add ZIM collections
|
|
||||||
if (zimCollections) {
|
|
||||||
selectedZimCollections.forEach((slug) => {
|
|
||||||
const collection = zimCollections.find((c) => c.slug === slug)
|
|
||||||
if (collection) {
|
|
||||||
totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add AI models
|
// Add AI models
|
||||||
if (recommendedModels) {
|
if (recommendedModels) {
|
||||||
selectedAiModels.forEach((modelName) => {
|
selectedAiModels.forEach((modelName) => {
|
||||||
|
|
@ -315,12 +276,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
}, [
|
}, [
|
||||||
selectedTiers,
|
selectedTiers,
|
||||||
selectedMapCollections,
|
selectedMapCollections,
|
||||||
selectedZimCollections,
|
|
||||||
selectedAiModels,
|
selectedAiModels,
|
||||||
selectedWikipedia,
|
selectedWikipedia,
|
||||||
categories,
|
categories,
|
||||||
mapCollections,
|
mapCollections,
|
||||||
zimCollections,
|
|
||||||
recommendedModels,
|
recommendedModels,
|
||||||
wikipediaState,
|
wikipediaState,
|
||||||
])
|
])
|
||||||
|
|
@ -392,12 +351,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
|
|
||||||
await Promise.all(installPromises)
|
await Promise.all(installPromises)
|
||||||
|
|
||||||
// Download collections, individual tier resources, and AI models
|
// Download collections, category tiers, and AI models
|
||||||
const tierResources = getSelectedTierResources()
|
const categoryTierPromises: Promise<any>[] = []
|
||||||
|
selectedTiers.forEach((tier, categorySlug) => {
|
||||||
|
categoryTierPromises.push(api.downloadCategoryTier(categorySlug, tier.slug))
|
||||||
|
})
|
||||||
|
|
||||||
const downloadPromises = [
|
const downloadPromises = [
|
||||||
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
|
...selectedMapCollections.map((slug) => api.downloadMapCollection(slug)),
|
||||||
...selectedZimCollections.map((slug) => api.downloadZimCollection(slug)),
|
...categoryTierPromises,
|
||||||
...tierResources.map((resource) => api.downloadRemoteZimFile(resource.url)),
|
|
||||||
...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
|
...selectedAiModels.map((modelName) => api.downloadModel(modelName)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -425,25 +387,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchLatestMapCollections = useMutation({
|
const refreshManifests = useMutation({
|
||||||
mutationFn: () => api.fetchLatestMapCollections(),
|
mutationFn: () => api.refreshManifests(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
addNotification({
|
|
||||||
message: 'Successfully fetched the latest map collections.',
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] })
|
queryClient.invalidateQueries({ queryKey: [CURATED_MAP_COLLECTIONS_KEY] })
|
||||||
},
|
queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
|
||||||
})
|
|
||||||
|
|
||||||
const fetchLatestZIMCollections = useMutation({
|
|
||||||
mutationFn: () => api.fetchLatestZimCollections(),
|
|
||||||
onSuccess: () => {
|
|
||||||
addNotification({
|
|
||||||
message: 'Successfully fetched the latest ZIM collections.',
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: [CURATED_ZIM_COLLECTIONS_KEY] })
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -452,18 +400,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}, [currentStep])
|
}, [currentStep])
|
||||||
|
|
||||||
// Auto-fetch latest collections if the list is empty
|
// Refresh manifests on mount to ensure we have latest data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapCollections && mapCollections.length === 0 && !fetchLatestMapCollections.isPending) {
|
if (!refreshManifests.isPending) {
|
||||||
fetchLatestMapCollections.mutate()
|
refreshManifests.mutate()
|
||||||
}
|
}
|
||||||
}, [mapCollections, fetchLatestMapCollections])
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (zimCollections && zimCollections.length === 0 && !fetchLatestZIMCollections.isPending) {
|
|
||||||
fetchLatestZIMCollections.mutate()
|
|
||||||
}
|
|
||||||
}, [zimCollections, fetchLatestZIMCollections])
|
|
||||||
|
|
||||||
// Set Easy Setup as visited when user lands on this page
|
// Set Easy Setup as visited when user lands on this page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -789,13 +731,13 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<div
|
<div
|
||||||
key={collection.slug}
|
key={collection.slug}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
isOnline && !collection.all_downloaded && toggleMapCollection(collection.slug)
|
isOnline && !collection.all_installed && toggleMapCollection(collection.slug)
|
||||||
}
|
}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative',
|
'relative',
|
||||||
selectedMapCollections.includes(collection.slug) &&
|
selectedMapCollections.includes(collection.slug) &&
|
||||||
'ring-4 ring-desert-green rounded-lg',
|
'ring-4 ring-desert-green rounded-lg',
|
||||||
collection.all_downloaded && 'opacity-75',
|
collection.all_installed && 'opacity-75',
|
||||||
!isOnline && 'opacity-50 cursor-not-allowed'
|
!isOnline && 'opacity-50 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -996,49 +938,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Legacy flat collections - show if available and no categories */}
|
|
||||||
{(!categories || categories.length === 0) && (
|
|
||||||
<>
|
|
||||||
{isLoadingZims ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
) : zimCollections && zimCollections.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{zimCollections.map((collection) => (
|
|
||||||
<div
|
|
||||||
key={collection.slug}
|
|
||||||
onClick={() =>
|
|
||||||
isOnline &&
|
|
||||||
!collection.all_downloaded &&
|
|
||||||
toggleZimCollection(collection.slug)
|
|
||||||
}
|
|
||||||
className={classNames(
|
|
||||||
'relative',
|
|
||||||
selectedZimCollections.includes(collection.slug) &&
|
|
||||||
'ring-4 ring-desert-green rounded-lg',
|
|
||||||
collection.all_downloaded && 'opacity-75',
|
|
||||||
!isOnline && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CuratedCollectionCard collection={collection} size="large" />
|
|
||||||
{selectedZimCollections.includes(collection.slug) && (
|
|
||||||
<div className="absolute top-2 right-2 bg-desert-green rounded-full p-1">
|
|
||||||
<IconCheck size={32} className="text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-600 text-lg">
|
|
||||||
No content collections available at this time.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1059,7 +958,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const hasSelections =
|
const hasSelections =
|
||||||
selectedServices.length > 0 ||
|
selectedServices.length > 0 ||
|
||||||
selectedMapCollections.length > 0 ||
|
selectedMapCollections.length > 0 ||
|
||||||
selectedZimCollections.length > 0 ||
|
|
||||||
selectedTiers.size > 0 ||
|
selectedTiers.size > 0 ||
|
||||||
selectedAiModels.length > 0 ||
|
selectedAiModels.length > 0 ||
|
||||||
(selectedWikipedia !== null && selectedWikipedia !== 'none')
|
(selectedWikipedia !== null && selectedWikipedia !== 'none')
|
||||||
|
|
@ -1122,25 +1020,6 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedZimCollections.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
|
||||||
ZIM Collections to Download ({selectedZimCollections.length})
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{selectedZimCollections.map((slug) => {
|
|
||||||
const collection = zimCollections?.find((c) => c.slug === slug)
|
|
||||||
return (
|
|
||||||
<li key={slug} className="flex items-center">
|
|
||||||
<IconCheck size={20} className="text-desert-green mr-2" />
|
|
||||||
<span className="text-gray-700">{collection?.name || slug}</span>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTiers.size > 0 && (
|
{selectedTiers.size > 0 && (
|
||||||
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||||
|
|
@ -1149,7 +1028,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
|
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
|
||||||
const category = categories?.find((c) => c.slug === categorySlug)
|
const category = categories?.find((c) => c.slug === categorySlug)
|
||||||
if (!category) return null
|
if (!category) return null
|
||||||
const resources = getAllResourcesForTier(tier, category.tiers)
|
const resources = resolveTierResources(tier, category.tiers)
|
||||||
return (
|
return (
|
||||||
<div key={categorySlug} className="mb-4 last:mb-0">
|
<div key={categorySlug} className="mb-4 last:mb-0">
|
||||||
<div className="flex items-center mb-2">
|
<div className="flex items-center mb-2">
|
||||||
|
|
@ -1283,8 +1162,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
return `${count} ${count === 1 ? 'capability' : 'capabilities'}`
|
return `${count} ${count === 1 ? 'capability' : 'capabilities'}`
|
||||||
})()}
|
})()}
|
||||||
, {selectedMapCollections.length} map region
|
, {selectedMapCollections.length} map region
|
||||||
{selectedMapCollections.length !== 1 && 's'}, {selectedZimCollections.length}{' '}
|
{selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}
|
||||||
content pack{selectedZimCollections.length !== 1 && 's'},{' '}
|
content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}
|
||||||
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
|
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import useDownloads from '~/hooks/useDownloads'
|
import useDownloads from '~/hooks/useDownloads'
|
||||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||||
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
||||||
import { CuratedCollectionWithStatus } from '../../../types/downloads'
|
import type { CollectionWithStatus } from '../../../types/collections'
|
||||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
|
|
||||||
|
|
@ -65,7 +65,7 @@ export default function MapsManager(props: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadCollection(record: CuratedCollectionWithStatus) {
|
async function downloadCollection(record: CollectionWithStatus) {
|
||||||
try {
|
try {
|
||||||
await api.downloadMapCollection(record.slug)
|
await api.downloadMapCollection(record.slug)
|
||||||
invalidateDownloads()
|
invalidateDownloads()
|
||||||
|
|
@ -112,14 +112,14 @@ export default function MapsManager(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDownload(record: CuratedCollectionWithStatus) {
|
async function confirmDownload(record: CollectionWithStatus) {
|
||||||
const isCollection = 'resources' in record
|
const isCollection = 'resources' in record
|
||||||
openModal(
|
openModal(
|
||||||
<StyledModal
|
<StyledModal
|
||||||
title="Confirm Download?"
|
title="Confirm Download?"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (isCollection) {
|
if (isCollection) {
|
||||||
if (record.all_downloaded) {
|
if (record.all_installed) {
|
||||||
addNotification({
|
addNotification({
|
||||||
message: `All resources in the collection "${record.name}" have already been downloaded.`,
|
message: `All resources in the collection "${record.name}" have already been downloaded.`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
|
|
|
||||||
|
|
@ -23,37 +23,18 @@ import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||||
import Input from '~/components/inputs/Input'
|
import Input from '~/components/inputs/Input'
|
||||||
import { IconSearch, IconBooks } from '@tabler/icons-react'
|
import { IconSearch, IconBooks } from '@tabler/icons-react'
|
||||||
import useDebounce from '~/hooks/useDebounce'
|
import useDebounce from '~/hooks/useDebounce'
|
||||||
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
|
|
||||||
import CategoryCard from '~/components/CategoryCard'
|
import CategoryCard from '~/components/CategoryCard'
|
||||||
import TierSelectionModal from '~/components/TierSelectionModal'
|
import TierSelectionModal from '~/components/TierSelectionModal'
|
||||||
import WikipediaSelector from '~/components/WikipediaSelector'
|
import WikipediaSelector from '~/components/WikipediaSelector'
|
||||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||||
import {
|
import type { CategoryWithStatus, SpecTier } from '../../../../types/collections'
|
||||||
CuratedCollectionWithStatus,
|
|
||||||
CuratedCategory,
|
|
||||||
CategoryTier,
|
|
||||||
CategoryResource,
|
|
||||||
} from '../../../../types/downloads'
|
|
||||||
import useDownloads from '~/hooks/useDownloads'
|
import useDownloads from '~/hooks/useDownloads'
|
||||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||||
import { SERVICE_NAMES } from '../../../../constants/service_names'
|
import { SERVICE_NAMES } from '../../../../constants/service_names'
|
||||||
|
|
||||||
const CURATED_COLLECTIONS_KEY = 'curated-zim-collections'
|
|
||||||
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
||||||
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
||||||
|
|
||||||
// Helper to get all resources for a tier (including inherited resources)
|
|
||||||
const getAllResourcesForTier = (tier: CategoryTier, allTiers: CategoryTier[]): CategoryResource[] => {
|
|
||||||
const resources = [...tier.resources]
|
|
||||||
if (tier.includesTier) {
|
|
||||||
const includedTier = allTiers.find((t) => t.slug === tier.includesTier)
|
|
||||||
if (includedTier) {
|
|
||||||
resources.unshift(...getAllResourcesForTier(includedTier, allTiers))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resources
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ZimRemoteExplorer() {
|
export default function ZimRemoteExplorer() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const tableParentRef = useRef<HTMLDivElement>(null)
|
const tableParentRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
@ -69,7 +50,7 @@ export default function ZimRemoteExplorer() {
|
||||||
|
|
||||||
// Category/tier selection state
|
// Category/tier selection state
|
||||||
const [tierModalOpen, setTierModalOpen] = useState(false)
|
const [tierModalOpen, setTierModalOpen] = useState(false)
|
||||||
const [activeCategory, setActiveCategory] = useState<CuratedCategory | null>(null)
|
const [activeCategory, setActiveCategory] = useState<CategoryWithStatus | null>(null)
|
||||||
|
|
||||||
// Wikipedia selection state
|
// Wikipedia selection state
|
||||||
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
|
const [selectedWikipedia, setSelectedWikipedia] = useState<string | null>(null)
|
||||||
|
|
@ -79,12 +60,6 @@ export default function ZimRemoteExplorer() {
|
||||||
setQuery(val)
|
setQuery(val)
|
||||||
}, 400)
|
}, 400)
|
||||||
|
|
||||||
const { data: curatedCollections } = useQuery({
|
|
||||||
queryKey: [CURATED_COLLECTIONS_KEY],
|
|
||||||
queryFn: () => api.listCuratedZimCollections(),
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch curated categories with tiers
|
// Fetch curated categories with tiers
|
||||||
const { data: categories } = useQuery({
|
const { data: categories } = useQuery({
|
||||||
queryKey: [CURATED_CATEGORIES_KEY],
|
queryKey: [CURATED_CATEGORIES_KEY],
|
||||||
|
|
@ -170,24 +145,12 @@ export default function ZimRemoteExplorer() {
|
||||||
fetchOnBottomReached(tableParentRef.current)
|
fetchOnBottomReached(tableParentRef.current)
|
||||||
}, [fetchOnBottomReached])
|
}, [fetchOnBottomReached])
|
||||||
|
|
||||||
async function confirmDownload(record: RemoteZimFileEntry | CuratedCollectionWithStatus) {
|
async function confirmDownload(record: RemoteZimFileEntry) {
|
||||||
const isCollection = 'resources' in record
|
|
||||||
openModal(
|
openModal(
|
||||||
<StyledModal
|
<StyledModal
|
||||||
title="Confirm Download?"
|
title="Confirm Download?"
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (isCollection) {
|
downloadFile(record)
|
||||||
if (record.all_downloaded) {
|
|
||||||
addNotification({
|
|
||||||
message: `All resources in the collection "${record.name}" have already been downloaded.`,
|
|
||||||
type: 'info',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
downloadCollection(record)
|
|
||||||
} else {
|
|
||||||
downloadFile(record)
|
|
||||||
}
|
|
||||||
closeAllModals()
|
closeAllModals()
|
||||||
}}
|
}}
|
||||||
onCancel={closeAllModals}
|
onCancel={closeAllModals}
|
||||||
|
|
@ -198,7 +161,7 @@ export default function ZimRemoteExplorer() {
|
||||||
>
|
>
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
Are you sure you want to download{' '}
|
Are you sure you want to download{' '}
|
||||||
<strong>{isCollection ? record.name : record.title}</strong>? It may take some time for it
|
<strong>{record.title}</strong>? It may take some time for it
|
||||||
to be available depending on the file size and your internet connection. The Kiwix
|
to be available depending on the file size and your internet connection. The Kiwix
|
||||||
application will be restarted after the download is complete.
|
application will be restarted after the download is complete.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -221,34 +184,19 @@ export default function ZimRemoteExplorer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadCollection(record: CuratedCollectionWithStatus) {
|
|
||||||
try {
|
|
||||||
await api.downloadZimCollection(record.slug)
|
|
||||||
invalidateDownloads()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading collection:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category/tier handlers
|
// Category/tier handlers
|
||||||
const handleCategoryClick = (category: CuratedCategory) => {
|
const handleCategoryClick = (category: CategoryWithStatus) => {
|
||||||
if (!isOnline) return
|
if (!isOnline) return
|
||||||
setActiveCategory(category)
|
setActiveCategory(category)
|
||||||
setTierModalOpen(true)
|
setTierModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTierSelect = async (category: CuratedCategory, tier: CategoryTier) => {
|
const handleTierSelect = async (category: CategoryWithStatus, tier: SpecTier) => {
|
||||||
// Get all resources for this tier (including inherited ones)
|
|
||||||
const resources = getAllResourcesForTier(tier, category.tiers)
|
|
||||||
|
|
||||||
// Download each resource
|
|
||||||
try {
|
try {
|
||||||
for (const resource of resources) {
|
await api.downloadCategoryTier(category.slug, tier.slug)
|
||||||
await api.downloadRemoteZimFile(resource.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
message: `Started downloading ${resources.length} files from "${category.name} - ${tier.name}"`,
|
message: `Started downloading "${category.name} - ${tier.name}"`,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
invalidateDownloads()
|
invalidateDownloads()
|
||||||
|
|
@ -309,24 +257,17 @@ export default function ZimRemoteExplorer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchLatestCollections = useMutation({
|
const refreshManifests = useMutation({
|
||||||
mutationFn: () => api.fetchLatestZimCollections(),
|
mutationFn: () => api.refreshManifests(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
addNotification({
|
addNotification({
|
||||||
message: 'Successfully fetched the latest ZIM collections.',
|
message: 'Successfully refreshed content collections.',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
})
|
})
|
||||||
queryClient.invalidateQueries({ queryKey: [CURATED_COLLECTIONS_KEY] })
|
queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-fetch latest collections if the list is empty
|
|
||||||
useEffect(() => {
|
|
||||||
if (curatedCollections && curatedCollections.length === 0 && !fetchLatestCollections.isPending) {
|
|
||||||
fetchLatestCollections.mutate()
|
|
||||||
}
|
|
||||||
}, [curatedCollections, fetchLatestCollections])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<Head title="Content Explorer | Project N.O.M.A.D." />
|
<Head title="Content Explorer | Project N.O.M.A.D." />
|
||||||
|
|
@ -357,11 +298,11 @@ export default function ZimRemoteExplorer() {
|
||||||
)}
|
)}
|
||||||
<StyledSectionHeader title="Curated Content Collections" className="mt-8 !mb-4" />
|
<StyledSectionHeader title="Curated Content Collections" className="mt-8 !mb-4" />
|
||||||
<StyledButton
|
<StyledButton
|
||||||
onClick={() => fetchLatestCollections.mutate()}
|
onClick={() => refreshManifests.mutate()}
|
||||||
disabled={fetchLatestCollections.isPending}
|
disabled={refreshManifests.isPending}
|
||||||
icon="IconCloudDownload"
|
icon="IconCloudDownload"
|
||||||
>
|
>
|
||||||
Fetch Latest Collections
|
Refresh Collections
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
|
|
||||||
{/* Wikipedia Selector */}
|
{/* Wikipedia Selector */}
|
||||||
|
|
@ -386,7 +327,7 @@ export default function ZimRemoteExplorer() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Tiered Category Collections - matches Easy Setup Wizard */}
|
{/* Tiered Category Collections */}
|
||||||
<div className="flex items-center gap-3 mt-8 mb-4">
|
<div className="flex items-center gap-3 mt-8 mb-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
|
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
|
||||||
<IconBooks className="w-6 h-6 text-gray-700" />
|
<IconBooks className="w-6 h-6 text-gray-700" />
|
||||||
|
|
@ -419,20 +360,7 @@ export default function ZimRemoteExplorer() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Legacy flat collections - fallback if no categories available */
|
<p className="text-gray-500 mt-4">No curated content categories available.</p>
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{curatedCollections?.map((collection) => (
|
|
||||||
<CuratedCollectionCard
|
|
||||||
key={collection.slug}
|
|
||||||
collection={collection}
|
|
||||||
onClick={(collection) => confirmDownload(collection)}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{curatedCollections && curatedCollections.length === 0 && (
|
|
||||||
<p className="text-gray-500">No curated collections available.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
|
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
|
||||||
<div className="flex justify-start mt-4">
|
<div className="flex justify-start mt-4">
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import OllamaController from '#controllers/ollama_controller'
|
||||||
import RagController from '#controllers/rag_controller'
|
import RagController from '#controllers/rag_controller'
|
||||||
import SettingsController from '#controllers/settings_controller'
|
import SettingsController from '#controllers/settings_controller'
|
||||||
import SystemController from '#controllers/system_controller'
|
import SystemController from '#controllers/system_controller'
|
||||||
|
import CollectionUpdatesController from '#controllers/collection_updates_controller'
|
||||||
import ZimController from '#controllers/zim_controller'
|
import ZimController from '#controllers/zim_controller'
|
||||||
import router from '@adonisjs/core/services/router'
|
import router from '@adonisjs/core/services/router'
|
||||||
import transmit from '@adonisjs/transmit/services/main'
|
import transmit from '@adonisjs/transmit/services/main'
|
||||||
|
|
@ -32,6 +33,8 @@ router.get('/maps', [MapsController, 'index'])
|
||||||
router.get('/easy-setup', [EasySetupController, 'index'])
|
router.get('/easy-setup', [EasySetupController, 'index'])
|
||||||
router.get('/easy-setup/complete', [EasySetupController, 'complete'])
|
router.get('/easy-setup/complete', [EasySetupController, 'complete'])
|
||||||
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])
|
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])
|
||||||
|
router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests'])
|
||||||
|
router.post('/api/collection-updates/check', [CollectionUpdatesController, 'checkForUpdates'])
|
||||||
|
|
||||||
router
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
|
|
@ -145,10 +148,9 @@ router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.get('/list', [ZimController, 'list'])
|
router.get('/list', [ZimController, 'list'])
|
||||||
router.get('/list-remote', [ZimController, 'listRemote'])
|
router.get('/list-remote', [ZimController, 'listRemote'])
|
||||||
router.get('/curated-collections', [ZimController, 'listCuratedCollections'])
|
router.get('/curated-categories', [ZimController, 'listCuratedCategories'])
|
||||||
router.post('/fetch-latest-collections', [ZimController, 'fetchLatestCollections'])
|
|
||||||
router.post('/download-remote', [ZimController, 'downloadRemote'])
|
router.post('/download-remote', [ZimController, 'downloadRemote'])
|
||||||
router.post('/download-collection', [ZimController, 'downloadCollection'])
|
router.post('/download-category-tier', [ZimController, 'downloadCategoryTier'])
|
||||||
|
|
||||||
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
|
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
|
||||||
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
|
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
|
||||||
|
|
|
||||||
86
admin/types/collections.ts
Normal file
86
admin/types/collections.ts
Normal 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[]
|
||||||
|
}
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
|
|
||||||
export type CuratedCollectionType = 'zim' | 'map'
|
|
||||||
|
|
@ -23,33 +23,16 @@ export type DoResumableDownloadProgress = {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CuratedCollection = {
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
language: string
|
|
||||||
resources: {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
size_mb: number
|
|
||||||
url: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CuratedCollectionWithStatus = CuratedCollection & {
|
|
||||||
all_downloaded: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CuratedCollectionsFile = {
|
|
||||||
collections: CuratedCollection[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RunDownloadJobParams = Omit<
|
export type RunDownloadJobParams = Omit<
|
||||||
DoResumableDownloadParams,
|
DoResumableDownloadParams,
|
||||||
'onProgress' | 'onComplete' | 'signal'
|
'onProgress' | 'onComplete' | 'signal'
|
||||||
> & {
|
> & {
|
||||||
filetype: string
|
filetype: string
|
||||||
|
resourceMetadata?: {
|
||||||
|
resource_id: string
|
||||||
|
version: string
|
||||||
|
collection_ref: string | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadJobWithProgress = {
|
export type DownloadJobWithProgress = {
|
||||||
|
|
@ -60,37 +43,6 @@ export type DownloadJobWithProgress = {
|
||||||
filetype: string
|
filetype: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiered category types for curated collections UI
|
|
||||||
export type CategoryResource = {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
size_mb?: number
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CategoryTier = {
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
description: string
|
|
||||||
recommended?: boolean
|
|
||||||
includesTier?: string
|
|
||||||
resources: CategoryResource[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CuratedCategory = {
|
|
||||||
name: string
|
|
||||||
slug: string
|
|
||||||
icon: string
|
|
||||||
description: string
|
|
||||||
language: string
|
|
||||||
tiers: CategoryTier[]
|
|
||||||
installedTierSlug?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CuratedCategoriesFile = {
|
|
||||||
categories: CuratedCategory[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wikipedia selector types
|
// Wikipedia selector types
|
||||||
export type WikipediaOption = {
|
export type WikipediaOption = {
|
||||||
id: string
|
id: string
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,4 @@ export type DownloadOptions = {
|
||||||
onComplete?: (filepath: string) => void
|
onComplete?: (filepath: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadCollectionOperation = (slug: string) => Promise<string[] | null>
|
|
||||||
export type DownloadRemoteSuccessCallback = (urls: string[], restart: boolean) => Promise<void>
|
export type DownloadRemoteSuccessCallback = (urls: string[], restart: boolean) => Promise<void>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"spec_version": "2026-02-11",
|
||||||
"categories": [
|
"categories": [
|
||||||
{
|
{
|
||||||
"name": "Medicine",
|
"name": "Medicine",
|
||||||
|
|
@ -14,24 +15,32 @@
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "zimgit-medicine_en",
|
||||||
|
"version": "2024-08",
|
||||||
"title": "Medical Library",
|
"title": "Medical Library",
|
||||||
"description": "Field and emergency medicine books and guides",
|
"description": "Field and emergency medicine books and guides",
|
||||||
"url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim",
|
"url": "https://download.kiwix.org/zim/other/zimgit-medicine_en_2024-08.zim",
|
||||||
"size_mb": 67
|
"size_mb": 67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "nhs.uk_en_medicines",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "NHS Medicines A to Z",
|
"title": "NHS Medicines A to Z",
|
||||||
"description": "How medicines work, dosages, side effects, and interactions",
|
"description": "How medicines work, dosages, side effects, and interactions",
|
||||||
"url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/zimit/nhs.uk_en_medicines_2025-12.zim",
|
||||||
"size_mb": 16
|
"size_mb": 16
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "fas-military-medicine_en",
|
||||||
|
"version": "2025-06",
|
||||||
"title": "Military Medicine",
|
"title": "Military Medicine",
|
||||||
"description": "Tactical and field medicine manuals",
|
"description": "Tactical and field medicine manuals",
|
||||||
"url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim",
|
"url": "https://download.kiwix.org/zim/zimit/fas-military-medicine_en_2025-06.zim",
|
||||||
"size_mb": 78
|
"size_mb": 78
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "wwwnc.cdc.gov_en_all",
|
||||||
|
"version": "2024-11",
|
||||||
"title": "CDC Health Information",
|
"title": "CDC Health Information",
|
||||||
"description": "Disease prevention, travel health, and outbreak information",
|
"description": "Disease prevention, travel health, and outbreak information",
|
||||||
"url": "https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim",
|
"url": "https://download.kiwix.org/zim/zimit/wwwnc.cdc.gov_en_all_2024-11.zim",
|
||||||
|
|
@ -46,6 +55,8 @@
|
||||||
"includesTier": "medicine-essential",
|
"includesTier": "medicine-essential",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "medlineplus.gov_en_all",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "MedlinePlus",
|
"title": "MedlinePlus",
|
||||||
"description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements",
|
"description": "NIH's consumer health encyclopedia - diseases, conditions, drugs, supplements",
|
||||||
"url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/zimit/medlineplus.gov_en_all_2025-01.zim",
|
||||||
|
|
@ -60,18 +71,24 @@
|
||||||
"includesTier": "medicine-standard",
|
"includesTier": "medicine-standard",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "wikipedia_en_medicine_maxi",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "Wikipedia Medicine",
|
"title": "Wikipedia Medicine",
|
||||||
"description": "Curated medical articles from Wikipedia with images",
|
"description": "Curated medical articles from Wikipedia with images",
|
||||||
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_medicine_maxi_2026-01.zim",
|
||||||
"size_mb": 2000
|
"size_mb": 2000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_med",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Medicine",
|
"title": "LibreTexts Medicine",
|
||||||
"description": "Open-source medical textbooks and educational content",
|
"description": "Open-source medical textbooks and educational content",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_med_2025-01.zim",
|
||||||
"size_mb": 1100
|
"size_mb": 1100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "librepathology_en_all_maxi",
|
||||||
|
"version": "2025-09",
|
||||||
"title": "LibrePathology",
|
"title": "LibrePathology",
|
||||||
"description": "Pathology reference for disease identification",
|
"description": "Pathology reference for disease identification",
|
||||||
"url": "https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim",
|
"url": "https://download.kiwix.org/zim/other/librepathology_en_all_maxi_2025-09.zim",
|
||||||
|
|
@ -95,12 +112,16 @@
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "canadian_prepper_winterprepping_en",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "Canadian Prepper: Winter Prepping",
|
"title": "Canadian Prepper: Winter Prepping",
|
||||||
"description": "Video guides for winter survival and cold weather emergencies",
|
"description": "Video guides for winter survival and cold weather emergencies",
|
||||||
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_winterprepping_en_2025-11.zim",
|
||||||
"size_mb": 1340
|
"size_mb": 1340
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "canadian_prepper_bugoutroll_en",
|
||||||
|
"version": "2025-08",
|
||||||
"title": "Canadian Prepper: Bug Out Roll",
|
"title": "Canadian Prepper: Bug Out Roll",
|
||||||
"description": "Essential gear selection for your bug-out bag",
|
"description": "Essential gear selection for your bug-out bag",
|
||||||
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim",
|
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutroll_en_2025-08.zim",
|
||||||
|
|
@ -115,12 +136,16 @@
|
||||||
"includesTier": "survival-essential",
|
"includesTier": "survival-essential",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "canadian_prepper_bugoutconcepts_en",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "Canadian Prepper: Bug Out Concepts",
|
"title": "Canadian Prepper: Bug Out Concepts",
|
||||||
"description": "Strategies and planning for emergency evacuation",
|
"description": "Strategies and planning for emergency evacuation",
|
||||||
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_bugoutconcepts_en_2025-11.zim",
|
||||||
"size_mb": 2890
|
"size_mb": 2890
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "urban-prepper_en_all",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "Urban Prepper",
|
"title": "Urban Prepper",
|
||||||
"description": "Comprehensive urban emergency preparedness video series",
|
"description": "Comprehensive urban emergency preparedness video series",
|
||||||
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/videos/urban-prepper_en_all_2025-11.zim",
|
||||||
|
|
@ -135,6 +160,8 @@
|
||||||
"includesTier": "survival-standard",
|
"includesTier": "survival-standard",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "canadian_prepper_preppingfood_en",
|
||||||
|
"version": "2025-09",
|
||||||
"title": "Canadian Prepper: Prepping Food",
|
"title": "Canadian Prepper: Prepping Food",
|
||||||
"description": "Long-term food storage and survival meal preparation",
|
"description": "Long-term food storage and survival meal preparation",
|
||||||
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim",
|
"url": "https://download.kiwix.org/zim/videos/canadian_prepper_preppingfood_en_2025-09.zim",
|
||||||
|
|
@ -158,6 +185,8 @@
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "wikibooks_en_all_nopic",
|
||||||
|
"version": "2025-10",
|
||||||
"title": "Wikibooks",
|
"title": "Wikibooks",
|
||||||
"description": "Open-content textbooks covering math, science, computing, and more",
|
"description": "Open-content textbooks covering math, science, computing, and more",
|
||||||
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim",
|
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_nopic_2025-10.zim",
|
||||||
|
|
@ -172,42 +201,56 @@
|
||||||
"includesTier": "education-essential",
|
"includesTier": "education-essential",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "ted_mul_ted-ed",
|
||||||
|
"version": "2025-07",
|
||||||
"title": "TED-Ed",
|
"title": "TED-Ed",
|
||||||
"description": "Educational video lessons on science, history, literature, and more",
|
"description": "Educational video lessons on science, history, literature, and more",
|
||||||
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim",
|
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-ed_2025-07.zim",
|
||||||
"size_mb": 5610
|
"size_mb": 5610
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "wikiversity_en_all_maxi",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "Wikiversity",
|
"title": "Wikiversity",
|
||||||
"description": "Tutorials, courses, and learning materials for all levels",
|
"description": "Tutorials, courses, and learning materials for all levels",
|
||||||
"url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/wikiversity/wikiversity_en_all_maxi_2025-11.zim",
|
||||||
"size_mb": 2370
|
"size_mb": 2370
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_math",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Mathematics",
|
"title": "LibreTexts Mathematics",
|
||||||
"description": "Open-source math textbooks from algebra to calculus",
|
"description": "Open-source math textbooks from algebra to calculus",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_math_2025-01.zim",
|
||||||
"size_mb": 831
|
"size_mb": 831
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_phys",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Physics",
|
"title": "LibreTexts Physics",
|
||||||
"description": "Physics courses and textbooks",
|
"description": "Physics courses and textbooks",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_phys_2025-01.zim",
|
||||||
"size_mb": 560
|
"size_mb": 560
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_chem",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Chemistry",
|
"title": "LibreTexts Chemistry",
|
||||||
"description": "Chemistry courses and textbooks",
|
"description": "Chemistry courses and textbooks",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_chem_2025-01.zim",
|
||||||
"size_mb": 2180
|
"size_mb": 2180
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_bio",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Biology",
|
"title": "LibreTexts Biology",
|
||||||
"description": "Biology courses and textbooks",
|
"description": "Biology courses and textbooks",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_bio_2025-01.zim",
|
||||||
"size_mb": 2240
|
"size_mb": 2240
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "gutenberg_en_education",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Project Gutenberg: Education",
|
"title": "Project Gutenberg: Education",
|
||||||
"description": "Classic educational texts and resources",
|
"description": "Classic educational texts and resources",
|
||||||
"url": "https://download.kiwix.org/zim/gutenberg/gutenberg_en_education_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/gutenberg/gutenberg_en_education_2025-12.zim",
|
||||||
|
|
@ -222,36 +265,48 @@
|
||||||
"includesTier": "education-standard",
|
"includesTier": "education-standard",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "wikibooks_en_all_maxi",
|
||||||
|
"version": "2025-10",
|
||||||
"title": "Wikibooks (With Images)",
|
"title": "Wikibooks (With Images)",
|
||||||
"description": "Open textbooks with full illustrations and diagrams",
|
"description": "Open textbooks with full illustrations and diagrams",
|
||||||
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim",
|
"url": "https://download.kiwix.org/zim/wikibooks/wikibooks_en_all_maxi_2025-10.zim",
|
||||||
"size_mb": 5400
|
"size_mb": 5400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "ted_mul_ted-conference",
|
||||||
|
"version": "2025-08",
|
||||||
"title": "TED Conference",
|
"title": "TED Conference",
|
||||||
"description": "Main TED conference talks on ideas worth spreading",
|
"description": "Main TED conference talks on ideas worth spreading",
|
||||||
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim",
|
"url": "https://download.kiwix.org/zim/ted/ted_mul_ted-conference_2025-08.zim",
|
||||||
"size_mb": 16500
|
"size_mb": 16500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_human",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Humanities",
|
"title": "LibreTexts Humanities",
|
||||||
"description": "Literature, philosophy, history, and social sciences",
|
"description": "Literature, philosophy, history, and social sciences",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_human_2025-01.zim",
|
||||||
"size_mb": 3730
|
"size_mb": 3730
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_geo",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Geosciences",
|
"title": "LibreTexts Geosciences",
|
||||||
"description": "Earth science, geology, and environmental studies",
|
"description": "Earth science, geology, and environmental studies",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_geo_2025-01.zim",
|
||||||
"size_mb": 1190
|
"size_mb": 1190
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_eng",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Engineering",
|
"title": "LibreTexts Engineering",
|
||||||
"description": "Engineering courses and technical references",
|
"description": "Engineering courses and technical references",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_eng_2025-01.zim",
|
||||||
"size_mb": 678
|
"size_mb": 678
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "libretexts.org_en_biz",
|
||||||
|
"version": "2025-01",
|
||||||
"title": "LibreTexts Business",
|
"title": "LibreTexts Business",
|
||||||
"description": "Business, economics, and management textbooks",
|
"description": "Business, economics, and management textbooks",
|
||||||
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim",
|
"url": "https://download.kiwix.org/zim/libretexts/libretexts.org_en_biz_2025-01.zim",
|
||||||
|
|
@ -275,12 +330,16 @@
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "woodworking.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Woodworking Q&A",
|
"title": "Woodworking Q&A",
|
||||||
"description": "Stack Exchange Q&A for carpentry, joinery, and woodcraft",
|
"description": "Stack Exchange Q&A for carpentry, joinery, and woodcraft",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/woodworking.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 99
|
"size_mb": 99
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "mechanics.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Motor Vehicle Maintenance Q&A",
|
"title": "Motor Vehicle Maintenance Q&A",
|
||||||
"description": "Stack Exchange Q&A for car and motorcycle repair",
|
"description": "Stack Exchange Q&A for car and motorcycle repair",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/mechanics.stackexchange.com_en_all_2025-12.zim",
|
||||||
|
|
@ -295,6 +354,8 @@
|
||||||
"includesTier": "diy-essential",
|
"includesTier": "diy-essential",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "diy.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "DIY & Home Improvement Q&A",
|
"title": "DIY & Home Improvement Q&A",
|
||||||
"description": "Stack Exchange Q&A for home repairs, electrical, plumbing, and construction",
|
"description": "Stack Exchange Q&A for home repairs, electrical, plumbing, and construction",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/diy.stackexchange.com_en_all_2025-12.zim",
|
||||||
|
|
@ -309,6 +370,8 @@
|
||||||
"includesTier": "diy-standard",
|
"includesTier": "diy-standard",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "ifixit_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "iFixit Repair Guides",
|
"title": "iFixit Repair Guides",
|
||||||
"description": "Step-by-step repair guides for electronics, appliances, and vehicles",
|
"description": "Step-by-step repair guides for electronics, appliances, and vehicles",
|
||||||
"url": "https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/ifixit/ifixit_en_all_2025-12.zim",
|
||||||
|
|
@ -332,12 +395,16 @@
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "foss.cooking_en_all",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "FOSS Cooking",
|
"title": "FOSS Cooking",
|
||||||
"description": "Quick and easy cooking guides and recipes",
|
"description": "Quick and easy cooking guides and recipes",
|
||||||
"url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/zimit/foss.cooking_en_all_2025-11.zim",
|
||||||
"size_mb": 24
|
"size_mb": 24
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "based.cooking_en_all",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "Based.Cooking",
|
"title": "Based.Cooking",
|
||||||
"description": "Simple, practical recipes from the community",
|
"description": "Simple, practical recipes from the community",
|
||||||
"url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/zimit/based.cooking_en_all_2025-11.zim",
|
||||||
|
|
@ -352,18 +419,24 @@
|
||||||
"includesTier": "agriculture-essential",
|
"includesTier": "agriculture-essential",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "gardening.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Gardening Q&A",
|
"title": "Gardening Q&A",
|
||||||
"description": "Stack Exchange Q&A for growing your own food, plant care, and landscaping",
|
"description": "Stack Exchange Q&A for growing your own food, plant care, and landscaping",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/gardening.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 923
|
"size_mb": 923
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "cooking.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Cooking Q&A",
|
"title": "Cooking Q&A",
|
||||||
"description": "Stack Exchange Q&A for cooking techniques, food safety, and recipes",
|
"description": "Stack Exchange Q&A for cooking techniques, food safety, and recipes",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/cooking.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 236
|
"size_mb": 236
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "zimgit-food-preparation_en",
|
||||||
|
"version": "2025-04",
|
||||||
"title": "Food for Preppers",
|
"title": "Food for Preppers",
|
||||||
"description": "Recipes and techniques for food preservation and long-term storage",
|
"description": "Recipes and techniques for food preservation and long-term storage",
|
||||||
"url": "https://download.kiwix.org/zim/other/zimgit-food-preparation_en_2025-04.zim",
|
"url": "https://download.kiwix.org/zim/other/zimgit-food-preparation_en_2025-04.zim",
|
||||||
|
|
@ -378,6 +451,8 @@
|
||||||
"includesTier": "agriculture-standard",
|
"includesTier": "agriculture-standard",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "lrnselfreliance_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Learning Self-Reliance: Homesteading",
|
"title": "Learning Self-Reliance: Homesteading",
|
||||||
"description": "Beekeeping, animal husbandry, and sustainable living practices",
|
"description": "Beekeeping, animal husbandry, and sustainable living practices",
|
||||||
"url": "https://download.kiwix.org/zim/videos/lrnselfreliance_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/videos/lrnselfreliance_en_all_2025-12.zim",
|
||||||
|
|
@ -401,30 +476,40 @@
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "freecodecamp_en_all",
|
||||||
|
"version": "2025-11",
|
||||||
"title": "freeCodeCamp",
|
"title": "freeCodeCamp",
|
||||||
"description": "Interactive programming tutorials - JavaScript, algorithms, and data structures",
|
"description": "Interactive programming tutorials - JavaScript, algorithms, and data structures",
|
||||||
"url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim",
|
"url": "https://download.kiwix.org/zim/freecodecamp/freecodecamp_en_all_2025-11.zim",
|
||||||
"size_mb": 8
|
"size_mb": 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_python",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "Python Documentation",
|
"title": "Python Documentation",
|
||||||
"description": "Complete Python language reference and tutorials",
|
"description": "Complete Python language reference and tutorials",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_python_2026-01.zim",
|
||||||
"size_mb": 4
|
"size_mb": 4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_javascript",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "JavaScript Documentation",
|
"title": "JavaScript Documentation",
|
||||||
"description": "MDN JavaScript reference and guides",
|
"description": "MDN JavaScript reference and guides",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_javascript_2026-01.zim",
|
||||||
"size_mb": 3
|
"size_mb": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_html",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "HTML Documentation",
|
"title": "HTML Documentation",
|
||||||
"description": "MDN HTML elements and attributes reference",
|
"description": "MDN HTML elements and attributes reference",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_html_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_html_2026-01.zim",
|
||||||
"size_mb": 2
|
"size_mb": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_css",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "CSS Documentation",
|
"title": "CSS Documentation",
|
||||||
"description": "MDN CSS properties and selectors reference",
|
"description": "MDN CSS properties and selectors reference",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_css_2026-01.zim",
|
||||||
|
|
@ -439,30 +524,40 @@
|
||||||
"includesTier": "computing-essential",
|
"includesTier": "computing-essential",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "arduino.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Arduino Q&A",
|
"title": "Arduino Q&A",
|
||||||
"description": "Stack Exchange Q&A for Arduino microcontroller projects",
|
"description": "Stack Exchange Q&A for Arduino microcontroller projects",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/arduino.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 247
|
"size_mb": 247
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "raspberrypi.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Raspberry Pi Q&A",
|
"title": "Raspberry Pi Q&A",
|
||||||
"description": "Stack Exchange Q&A for Raspberry Pi projects and troubleshooting",
|
"description": "Stack Exchange Q&A for Raspberry Pi projects and troubleshooting",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/raspberrypi.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 285
|
"size_mb": 285
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_node",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "Node.js Documentation",
|
"title": "Node.js Documentation",
|
||||||
"description": "Node.js API reference and guides",
|
"description": "Node.js API reference and guides",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_node_2026-01.zim",
|
||||||
"size_mb": 1
|
"size_mb": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_react",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "React Documentation",
|
"title": "React Documentation",
|
||||||
"description": "React library reference and tutorials",
|
"description": "React library reference and tutorials",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-01.zim",
|
||||||
"size_mb": 3
|
"size_mb": 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_git",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "Git Documentation",
|
"title": "Git Documentation",
|
||||||
"description": "Git version control reference",
|
"description": "Git version control reference",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_git_2026-01.zim",
|
||||||
|
|
@ -477,24 +572,32 @@
|
||||||
"includesTier": "computing-standard",
|
"includesTier": "computing-standard",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "electronics.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Electronics Q&A",
|
"title": "Electronics Q&A",
|
||||||
"description": "Stack Exchange Q&A for circuit design, components, and electrical engineering",
|
"description": "Stack Exchange Q&A for circuit design, components, and electrical engineering",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/electronics.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 3800
|
"size_mb": 3800
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "robotics.stackexchange.com_en_all",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Robotics Q&A",
|
"title": "Robotics Q&A",
|
||||||
"description": "Stack Exchange Q&A for robotics projects and automation",
|
"description": "Stack Exchange Q&A for robotics projects and automation",
|
||||||
"url": "https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2025-12.zim",
|
"url": "https://download.kiwix.org/zim/stack_exchange/robotics.stackexchange.com_en_all_2025-12.zim",
|
||||||
"size_mb": 233
|
"size_mb": 233
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_docker",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "Docker Documentation",
|
"title": "Docker Documentation",
|
||||||
"description": "Docker container reference and guides",
|
"description": "Docker container reference and guides",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_docker_2026-01.zim",
|
||||||
"size_mb": 2
|
"size_mb": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "devdocs_en_bash",
|
||||||
|
"version": "2026-01",
|
||||||
"title": "Linux Documentation",
|
"title": "Linux Documentation",
|
||||||
"description": "Linux command reference and system administration",
|
"description": "Linux command reference and system administration",
|
||||||
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim",
|
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_bash_2026-01.zim",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{
|
{
|
||||||
|
"spec_version": "2026-02-11",
|
||||||
"collections": [
|
"collections": [
|
||||||
{
|
{
|
||||||
"name": "Pacific Region",
|
"name": "Pacific Region",
|
||||||
|
|
@ -8,33 +9,43 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "alaska",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Alaska",
|
"title": "Alaska",
|
||||||
"description": "Topographic maps for the state of Alaska.",
|
"description": "Topographic maps for the state of Alaska.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alaska.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alaska_2025-12.pmtiles",
|
||||||
"size_mb": 684
|
"size_mb": 684
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "california",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "California",
|
"title": "California",
|
||||||
"description": "Topographic maps for the state of California.",
|
"description": "Topographic maps for the state of California.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california_2025-12.pmtiles",
|
||||||
"size_mb": 1100
|
"size_mb": 1100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "hawaii",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Hawaii",
|
"title": "Hawaii",
|
||||||
"description": "Topographic maps for the state of Hawaii.",
|
"description": "Topographic maps for the state of Hawaii.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/hawaii.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/hawaii_2025-12.pmtiles",
|
||||||
"size_mb": 28
|
"size_mb": 28
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "oregon",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Oregon",
|
"title": "Oregon",
|
||||||
"description": "Topographic maps for the state of Oregon.",
|
"description": "Topographic maps for the state of Oregon.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oregon.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oregon_2025-12.pmtiles",
|
||||||
"size_mb": 379
|
"size_mb": 379
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "washington",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Washington",
|
"title": "Washington",
|
||||||
"description": "Topographic maps for the state of Washington.",
|
"description": "Topographic maps for the state of Washington.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/washington.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/washington_2025-12.pmtiles",
|
||||||
"size_mb": 466
|
"size_mb": 466
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -47,51 +58,67 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "arizona",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Arizona",
|
"title": "Arizona",
|
||||||
"description": "Topographic maps for the state of Arizona.",
|
"description": "Topographic maps for the state of Arizona.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arizona.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arizona_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "colorado",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Colorado",
|
"title": "Colorado",
|
||||||
"description": "Topographic maps for the state of Colorado.",
|
"description": "Topographic maps for the state of Colorado.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/colorado.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/colorado_2025-12.pmtiles",
|
||||||
"size_mb": 450
|
"size_mb": 450
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "idaho",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Idaho",
|
"title": "Idaho",
|
||||||
"description": "Topographic maps for the state of Idaho.",
|
"description": "Topographic maps for the state of Idaho.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/idaho.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/idaho_2025-12.pmtiles",
|
||||||
"size_mb": 220
|
"size_mb": 220
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "montana",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Montana",
|
"title": "Montana",
|
||||||
"description": "Topographic maps for the state of Montana.",
|
"description": "Topographic maps for the state of Montana.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/montana.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/montana_2025-12.pmtiles",
|
||||||
"size_mb": 270
|
"size_mb": 270
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "nevada",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Nevada",
|
"title": "Nevada",
|
||||||
"description": "Topographic maps for the state of Nevada.",
|
"description": "Topographic maps for the state of Nevada.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nevada.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nevada_2025-12.pmtiles",
|
||||||
"size_mb": 200
|
"size_mb": 200
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "new_mexico",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "New Mexico",
|
"title": "New Mexico",
|
||||||
"description": "Topographic maps for the state of New Mexico.",
|
"description": "Topographic maps for the state of New Mexico.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_mexico.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_mexico_2025-12.pmtiles",
|
||||||
"size_mb": 230
|
"size_mb": 230
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "utah",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Utah",
|
"title": "Utah",
|
||||||
"description": "Topographic maps for the state of Utah.",
|
"description": "Topographic maps for the state of Utah.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/utah.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/utah_2025-12.pmtiles",
|
||||||
"size_mb": 240
|
"size_mb": 240
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "wyoming",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Wyoming",
|
"title": "Wyoming",
|
||||||
"description": "Topographic maps for the state of Wyoming.",
|
"description": "Topographic maps for the state of Wyoming.",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wyoming.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wyoming_2025-12.pmtiles",
|
||||||
"size_mb": 210
|
"size_mb": 210
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -104,27 +131,35 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "arkansas",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Arkansas",
|
"title": "Arkansas",
|
||||||
"description": "Topographic maps for the state of Arkansas",
|
"description": "Topographic maps for the state of Arkansas",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arkansas.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/arkansas_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "oklahoma",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Oklahoma",
|
"title": "Oklahoma",
|
||||||
"description": "Topographic maps for the state of Oklahoma",
|
"description": "Topographic maps for the state of Oklahoma",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oklahoma.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/oklahoma_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "louisiana",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Louisiana",
|
"title": "Louisiana",
|
||||||
"description": "Topographic maps for the state of Louisiana",
|
"description": "Topographic maps for the state of Louisiana",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/louisiana.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/louisiana_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "texas",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Texas",
|
"title": "Texas",
|
||||||
"description": "Topographic maps for the state of Texas",
|
"description": "Topographic maps for the state of Texas",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/texas.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/texas_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -137,27 +172,35 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "alabama",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Alabama",
|
"title": "Alabama",
|
||||||
"description": "Topographic maps for the state of Alabama",
|
"description": "Topographic maps for the state of Alabama",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alabama.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/alabama_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "kentucky",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Kentucky",
|
"title": "Kentucky",
|
||||||
"description": "Topographic maps for the state of Kentucky",
|
"description": "Topographic maps for the state of Kentucky",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kentucky.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kentucky_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "mississippi",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Mississippi",
|
"title": "Mississippi",
|
||||||
"description": "Topographic maps for the state of Mississippi",
|
"description": "Topographic maps for the state of Mississippi",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/mississippi.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/mississippi_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "tennessee",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Tennessee",
|
"title": "Tennessee",
|
||||||
"description": "Topographic maps for the state of Tennessee",
|
"description": "Topographic maps for the state of Tennessee",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/tennessee.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/tennessee_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -170,57 +213,75 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "delaware",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Delaware",
|
"title": "Delaware",
|
||||||
"description": "Topographic maps for the state of Delaware",
|
"description": "Topographic maps for the state of Delaware",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/delaware.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/delaware_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "district_of_columbia",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "District_Of_Columbia",
|
"title": "District_Of_Columbia",
|
||||||
"description": "Topographic maps for the state of District_Of_Columbia",
|
"description": "Topographic maps for the state of District_Of_Columbia",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/district_of_columbia.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/district_of_columbia_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "florida",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Florida",
|
"title": "Florida",
|
||||||
"description": "Topographic maps for the state of Florida",
|
"description": "Topographic maps for the state of Florida",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/florida.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/florida_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "georgia",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Georgia",
|
"title": "Georgia",
|
||||||
"description": "Topographic maps for the state of Georgia",
|
"description": "Topographic maps for the state of Georgia",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/georgia.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/georgia_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "maryland",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Maryland",
|
"title": "Maryland",
|
||||||
"description": "Topographic maps for the state of Maryland",
|
"description": "Topographic maps for the state of Maryland",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maryland.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maryland_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "north_carolina",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "North_Carolina",
|
"title": "North_Carolina",
|
||||||
"description": "Topographic maps for the state of North_Carolina",
|
"description": "Topographic maps for the state of North_Carolina",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_carolina.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_carolina_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "south_carolina",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "South_Carolina",
|
"title": "South_Carolina",
|
||||||
"description": "Topographic maps for the state of South_Carolina",
|
"description": "Topographic maps for the state of South_Carolina",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_carolina.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_carolina_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "virginia",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Virginia",
|
"title": "Virginia",
|
||||||
"description": "Topographic maps for the state of Virginia",
|
"description": "Topographic maps for the state of Virginia",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/virginia.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/virginia_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "west_virginia",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "West_Virginia",
|
"title": "West_Virginia",
|
||||||
"description": "Topographic maps for the state of West_Virginia",
|
"description": "Topographic maps for the state of West_Virginia",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/west_virginia.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/west_virginia_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -233,45 +294,59 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "iowa",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Iowa",
|
"title": "Iowa",
|
||||||
"description": "Topographic maps for the state of Iowa",
|
"description": "Topographic maps for the state of Iowa",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/iowa.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/iowa_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "kansas",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Kansas",
|
"title": "Kansas",
|
||||||
"description": "Topographic maps for the state of Kansas",
|
"description": "Topographic maps for the state of Kansas",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kansas.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/kansas_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "minnesota",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Minnesota",
|
"title": "Minnesota",
|
||||||
"description": "Topographic maps for the state of Minnesota",
|
"description": "Topographic maps for the state of Minnesota",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/minnesota.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/minnesota_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "missouri",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Missouri",
|
"title": "Missouri",
|
||||||
"description": "Topographic maps for the state of Missouri",
|
"description": "Topographic maps for the state of Missouri",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/missouri.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/missouri_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "nebraska",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Nebraska",
|
"title": "Nebraska",
|
||||||
"description": "Topographic maps for the state of Nebraska",
|
"description": "Topographic maps for the state of Nebraska",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nebraska.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/nebraska_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "north_dakota",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "North_Dakota",
|
"title": "North_Dakota",
|
||||||
"description": "Topographic maps for the state of North_Dakota",
|
"description": "Topographic maps for the state of North_Dakota",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_dakota.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/north_dakota_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "south_dakota",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "South_Dakota",
|
"title": "South_Dakota",
|
||||||
"description": "Topographic maps for the state of South_Dakota",
|
"description": "Topographic maps for the state of South_Dakota",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_dakota.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/south_dakota_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -284,27 +359,35 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "illinois",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Illinois",
|
"title": "Illinois",
|
||||||
"description": "Topographic maps for the state of Illinois",
|
"description": "Topographic maps for the state of Illinois",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/illinois.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/illinois_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "indianamichigan",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Indianamichigan",
|
"title": "Indianamichigan",
|
||||||
"description": "Topographic maps for the state of Indianamichigan",
|
"description": "Topographic maps for the state of Indianamichigan",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indianamichigan.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indianamichigan_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "ohio",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Ohio",
|
"title": "Ohio",
|
||||||
"description": "Topographic maps for the state of Ohio",
|
"description": "Topographic maps for the state of Ohio",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/ohio.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/ohio_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "wisconsin",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Wisconsin",
|
"title": "Wisconsin",
|
||||||
"description": "Topographic maps for the state of Wisconsin",
|
"description": "Topographic maps for the state of Wisconsin",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wisconsin.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/wisconsin_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -317,21 +400,27 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "new_jersey",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "New_Jersey",
|
"title": "New_Jersey",
|
||||||
"description": "Topographic maps for the state of New_Jersey",
|
"description": "Topographic maps for the state of New_Jersey",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_jersey.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_jersey_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "new_york",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "New_York",
|
"title": "New_York",
|
||||||
"description": "Topographic maps for the state of New_York",
|
"description": "Topographic maps for the state of New_York",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_york.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_york_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "pennsylvania",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Pennsylvania",
|
"title": "Pennsylvania",
|
||||||
"description": "Topographic maps for the state of Pennsylvania",
|
"description": "Topographic maps for the state of Pennsylvania",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/pennsylvania.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/pennsylvania_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -344,39 +433,51 @@
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
|
"id": "connecticut",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Connecticut",
|
"title": "Connecticut",
|
||||||
"description": "Topographic maps for the state of Connecticut",
|
"description": "Topographic maps for the state of Connecticut",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/connecticut.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/connecticut_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "maine",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Maine",
|
"title": "Maine",
|
||||||
"description": "Topographic maps for the state of Maine",
|
"description": "Topographic maps for the state of Maine",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maine.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/maine_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "massachusetts",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Massachusetts",
|
"title": "Massachusetts",
|
||||||
"description": "Topographic maps for the state of Massachusetts",
|
"description": "Topographic maps for the state of Massachusetts",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/massachusetts.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/massachusetts_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "new_hampshire",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "New_Hampshire",
|
"title": "New_Hampshire",
|
||||||
"description": "Topographic maps for the state of New_Hampshire",
|
"description": "Topographic maps for the state of New_Hampshire",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_hampshire.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/new_hampshire_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "rhode_island",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Rhode_Island",
|
"title": "Rhode_Island",
|
||||||
"description": "Topographic maps for the state of Rhode_Island",
|
"description": "Topographic maps for the state of Rhode_Island",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/rhode_island.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/rhode_island_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "vermont",
|
||||||
|
"version": "2025-12",
|
||||||
"title": "Vermont",
|
"title": "Vermont",
|
||||||
"description": "Topographic maps for the state of Vermont",
|
"description": "Topographic maps for the state of Vermont",
|
||||||
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/vermont.pmtiles",
|
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/vermont_2025-12.pmtiles",
|
||||||
"size_mb": 400
|
"size_mb": 400
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,53 @@
|
||||||
{
|
{
|
||||||
|
"spec_version": "2026-02-11",
|
||||||
"options": [
|
"options": [
|
||||||
{
|
{
|
||||||
"id": "none",
|
"id": "none",
|
||||||
"name": "No Wikipedia",
|
"name": "No Wikipedia",
|
||||||
"description": "Skip Wikipedia installation",
|
"description": "Skip Wikipedia installation",
|
||||||
"size_mb": 0,
|
"size_mb": 0,
|
||||||
"url": null
|
"url": null,
|
||||||
|
"version": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "top-mini",
|
"id": "top-mini",
|
||||||
"name": "Quick Reference",
|
"name": "Quick Reference",
|
||||||
"description": "Top 100,000 articles with minimal images. Great for quick lookups.",
|
"description": "Top 100,000 articles with minimal images. Great for quick lookups.",
|
||||||
"size_mb": 313,
|
"size_mb": 313,
|
||||||
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2025-12.zim"
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2025-12.zim",
|
||||||
|
"version": "2025-12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "top-nopic",
|
"id": "top-nopic",
|
||||||
"name": "Popular Articles",
|
"name": "Popular Articles",
|
||||||
"description": "Top articles without images. Good balance of content and size.",
|
"description": "Top articles without images. Good balance of content and size.",
|
||||||
"size_mb": 2100,
|
"size_mb": 2100,
|
||||||
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2025-12.zim"
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2025-12.zim",
|
||||||
|
"version": "2025-12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "all-mini",
|
"id": "all-mini",
|
||||||
"name": "Complete Wikipedia (Compact)",
|
"name": "Complete Wikipedia (Compact)",
|
||||||
"description": "All 6+ million articles in condensed format.",
|
"description": "All 6+ million articles in condensed format.",
|
||||||
"size_mb": 11400,
|
"size_mb": 11400,
|
||||||
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2025-12.zim"
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2025-12.zim",
|
||||||
|
"version": "2025-12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "all-nopic",
|
"id": "all-nopic",
|
||||||
"name": "Complete Wikipedia (No Images)",
|
"name": "Complete Wikipedia (No Images)",
|
||||||
"description": "All articles without images. Comprehensive offline reference.",
|
"description": "All articles without images. Comprehensive offline reference.",
|
||||||
"size_mb": 25000,
|
"size_mb": 25000,
|
||||||
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim"
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim",
|
||||||
|
"version": "2025-12"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "all-maxi",
|
"id": "all-maxi",
|
||||||
"name": "Complete Wikipedia (Full)",
|
"name": "Complete Wikipedia (Full)",
|
||||||
"description": "The complete experience with all images and media.",
|
"description": "The complete experience with all images and media.",
|
||||||
"size_mb": 102000,
|
"size_mb": 102000,
|
||||||
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2024-01.zim"
|
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2024-01.zim",
|
||||||
|
"version": "2024-01"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user