diff --git a/admin/app/controllers/maps_controller.ts b/admin/app/controllers/maps_controller.ts new file mode 100644 index 0000000..f33b549 --- /dev/null +++ b/admin/app/controllers/maps_controller.ts @@ -0,0 +1,66 @@ +import { MapService } from '#services/map_service' +import { + filenameValidator, + remoteDownloadValidator, + remoteDownloadValidatorOptional, +} from '#validators/common' +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +@inject() +export default class MapsController { + constructor(private mapService: MapService) {} + + async index({ inertia }: HttpContext) { + return inertia.render('maps') + } + + async checkBaseAssets({}: HttpContext) { + const exists = await this.mapService.checkBaseAssetsExist() + return { exists } + } + + async downloadBaseAssets({ request }: HttpContext) { + const payload = await request.validateUsing(remoteDownloadValidatorOptional) + await this.mapService.downloadBaseAssets(payload.url) + return { success: true } + } + + async downloadRemote({ request }: HttpContext) { + const payload = await request.validateUsing(remoteDownloadValidator) + const filename = await this.mapService.downloadRemote(payload.url) + return { + message: 'Download started successfully', + filename, + url: payload.url, + } + } + + async listRegions({}: HttpContext) { + return await this.mapService.listRegions() + } + + async styles({ response }: HttpContext) { + const styles = await this.mapService.generateStylesJSON() + return response.json(styles) + } + + async delete({ request, response }: HttpContext) { + const payload = await request.validateUsing(filenameValidator) + + try { + await this.mapService.delete(payload.filename) + } catch (error) { + if (error.message === 'not_found') { + return response.status(404).send({ + message: `Map file with key ${payload.filename} not found`, + }) + } + throw error // Re-throw any other errors and let the global error handler catch + } + + return { + message: 'Map file deleted successfully', + } + } +} diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index 3eee416..1321821 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -1,3 +1,4 @@ +import { MapService } from '#services/map_service'; import { SystemService } from '#services/system_service'; import { inject } from '@adonisjs/core'; import type { HttpContext } from '@adonisjs/core/http' @@ -6,6 +7,7 @@ import type { HttpContext } from '@adonisjs/core/http' export default class SettingsController { constructor( private systemService: SystemService, + private mapService: MapService ) { } async system({ inertia }: HttpContext) { @@ -25,11 +27,22 @@ export default class SettingsController { } }); } - + async legal({ inertia }: HttpContext) { return inertia.render('settings/legal'); } + async maps({ inertia }: HttpContext) { + const baseAssetsCheck = await this.mapService.checkBaseAssetsExist(); + const regionFiles = await this.mapService.listRegions(); + return inertia.render('settings/maps', { + maps: { + baseAssetsExist: baseAssetsCheck, + regionFiles: regionFiles.files + } + }); + } + async zim({ inertia }: HttpContext) { return inertia.render('settings/zim/index') } diff --git a/admin/app/controllers/zim_controller.ts b/admin/app/controllers/zim_controller.ts index c3c8b5a..380d0de 100644 --- a/admin/app/controllers/zim_controller.ts +++ b/admin/app/controllers/zim_controller.ts @@ -1,49 +1,48 @@ -import { ZimService } from '#services/zim_service'; -import { inject } from '@adonisjs/core'; +import { ZimService } from '#services/zim_service' +import { filenameValidator, remoteDownloadValidator } from '#validators/common' +import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' @inject() export default class ZimController { - constructor( - private zimService: ZimService - ) { } + constructor(private zimService: ZimService) {} - async list({ }: HttpContext) { - return await this.zimService.list(); + async list({}: HttpContext) { + return await this.zimService.list() + } + + async listRemote({ request }: HttpContext) { + const { start = 0, count = 12 } = request.qs() + return await this.zimService.listRemote({ start, count }) + } + + async downloadRemote({ request }: HttpContext) { + const payload = await request.validateUsing(remoteDownloadValidator) + const filename = await this.zimService.downloadRemote(payload.url) + + return { + message: 'Download started successfully', + filename, + url: payload.url, + } + } + + async delete({ request, response }: HttpContext) { + const payload = await request.validateUsing(filenameValidator) + + try { + await this.zimService.delete(payload.filename) + } catch (error) { + if (error.message === 'not_found') { + return response.status(404).send({ + message: `ZIM file with key ${payload.filename} not found`, + }) + } + throw error // Re-throw any other errors and let the global error handler catch } - async listRemote({ request }: HttpContext) { - const { start = 0, count = 12 } = request.qs(); - return await this.zimService.listRemote({ start, count }); + return { + message: 'ZIM file deleted successfully', } - - async downloadRemote({ request, response }: HttpContext) { - const { url } = request.body() - const filename = await this.zimService.downloadRemote(url); - - response.status(200).send({ - message: 'Download started successfully', - filename, - url - }); - } - - async delete({ request, response }: HttpContext) { - const { key } = request.params(); - - try { - await this.zimService.delete(key); - } catch (error) { - if (error.message === 'not_found') { - return response.status(404).send({ - message: `ZIM file with key ${key} not found` - }); - } - throw error; // Re-throw any other errors and let the global error handler catch - } - - response.status(200).send({ - message: 'ZIM file deleted successfully' - }); - } -} \ No newline at end of file + } +} diff --git a/admin/app/services/map_service.ts b/admin/app/services/map_service.ts new file mode 100644 index 0000000..491d4cb --- /dev/null +++ b/admin/app/services/map_service.ts @@ -0,0 +1,235 @@ +import { BaseStylesFile, MapLayer } from '../../types/maps.js' +import { FileEntry } from '../../types/files.js' +import { doBackgroundDownload, doResumableDownloadWithRetry } from '../utils/downloads.js' +import { extract } from 'tar' +import env from '#start/env' +import { + listDirectoryContentsRecursive, + listDirectoryContents, + getFileStatsIfExists, + deleteFileIfExists, + getFile, +} from '../utils/fs.js' +import { join } from 'path' +import urlJoin from 'url-join' + +const BASE_ASSETS_MIME_TYPES = [ + 'application/gzip', + 'application/x-gzip', + 'application/octet-stream', +] +const BROADCAST_CHANNEL = 'map-downloads' + +const PMTILES_ATTRIBUTION = + 'Protomaps © OpenStreetMap' +const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream'] + +export class MapService { + private readonly mapStoragePath = '/storage/maps' + private readonly baseStylesFile = 'nomad-base-styles.json' + private readonly basemapsAssetsDir = 'basemaps-assets' + private readonly baseAssetsTarFile = 'base-assets.tar.gz' + private activeDownloads = new Map() + + async listRegions() { + const files = (await this.listAllMapStorageItems()).filter( + (item) => item.type === 'file' && item.name.endsWith('.pmtiles') + ) + + return { + files, + } + } + + async downloadBaseAssets(url?: string) { + const tempTarPath = join(process.cwd(), this.mapStoragePath, this.baseAssetsTarFile) + + const defaultTarFileURL = new URL( + this.baseAssetsTarFile, + 'https://github.com/Crosstalk-Solutions/project-nomad-maps/blob/master' + ) + defaultTarFileURL.searchParams.append('raw', 'true') + + const resolvedURL = url ? new URL(url) : defaultTarFileURL + await doResumableDownloadWithRetry({ + url: resolvedURL.toString(), + path: tempTarPath, + timeout: 30000, + max_retries: 2, + allowedMimeTypes: BASE_ASSETS_MIME_TYPES, + onProgress(progress) { + console.log( + `Downloading: ${progress.downloadedBytes.toFixed(2)}b / ${progress.totalBytes.toFixed(2)}b` + ) + }, + onAttemptError(error, attempt) { + console.error(`Attempt ${attempt} to download tar file failed: ${error.message}`) + }, + }) + const tarFileBuffer = await getFileStatsIfExists(tempTarPath) + if (!tarFileBuffer) { + throw new Error(`Failed to download tar file`) + } + + await extract({ + cwd: join(process.cwd(), this.mapStoragePath), + file: tempTarPath, + strip: 1, + }) + + await deleteFileIfExists(tempTarPath) + + return true + } + + async downloadRemote(url: string): Promise { + const parsed = new URL(url) + if (!parsed.pathname.endsWith('.pmtiles')) { + throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`) + } + + const existing = this.activeDownloads.get(url) + if (existing) { + throw new Error(`Download already in progress for URL ${url}`) + } + + const filename = url.split('/').pop() + if (!filename) { + throw new Error('Could not determine filename from URL') + } + + const path = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename) + + // Don't await the download, run it in the background + doBackgroundDownload({ + url, + path, + timeout: 30000, + allowedMimeTypes: PMTILES_MIME_TYPES, + forceNew: true, + channel: BROADCAST_CHANNEL, + activeDownloads: this.activeDownloads, + }) + + return filename + } + + async generateStylesJSON() { + if (!(await this.checkBaseAssetsExist())) { + throw new Error('Base map assets are missing from storage/maps') + } + + const baseStylePath = join(process.cwd(), this.mapStoragePath, this.baseStylesFile) + const baseStyle = await getFile(baseStylePath, 'string') + if (!baseStyle) { + throw new Error('Base styles file not found in storage/maps') + } + + const localUrl = env.get('URL') + const rawStyles = JSON.parse(baseStyle.toString()) as BaseStylesFile + + const regions = (await this.listRegions()).files + const sources = this.generateSourcesArray(regions) + + const baseUrl = urlJoin(localUrl, this.mapStoragePath, this.basemapsAssetsDir) + + const styles = await this.generateStylesFile( + rawStyles, + sources, + urlJoin(baseUrl, 'sprites/v4/light'), + urlJoin(baseUrl, 'fonts/{fontstack}/{range}.pbf') + ) + + return styles + } + + async checkBaseAssetsExist() { + const storageContents = await this.listMapStorageItems() + const baseStyleItem = storageContents.find( + (item) => item.type === 'file' && item.name === this.baseStylesFile + ) + const basemapsAssetsItem = storageContents.find( + (item) => item.type === 'directory' && item.name === this.basemapsAssetsDir + ) + + return !!baseStyleItem && !!basemapsAssetsItem + } + + private async listMapStorageItems(): Promise { + const dirPath = join(process.cwd(), this.mapStoragePath) + return await listDirectoryContents(dirPath) + } + + private async listAllMapStorageItems(): Promise { + const dirPath = join(process.cwd(), this.mapStoragePath) + return await listDirectoryContentsRecursive(dirPath) + } + + private generateSourcesArray(regions: FileEntry[]): BaseStylesFile['sources'][] { + const localUrl = env.get('URL') + const sources: BaseStylesFile['sources'][] = [] + + for (const region of regions) { + if (region.type === 'file' && region.name.endsWith('.pmtiles')) { + const regionName = region.name.replace('.pmtiles', '') + const source: BaseStylesFile['sources'] = {} + source[regionName] = { + type: 'vector', + attribution: PMTILES_ATTRIBUTION, + url: `pmtiles://http://${urlJoin(localUrl, this.mapStoragePath, 'pmtiles', region.name)}`, + } + sources.push(source) + } + } + + return sources + } + + private async generateStylesFile( + template: BaseStylesFile, + sources: BaseStylesFile['sources'][], + sprites: string, + glyphs: string + ): Promise { + const layersTemplates = template.layers.filter((layer) => layer.source) + const withoutSources = template.layers.filter((layer) => !layer.source) + + template.sources = {} // Clear existing sources + template.layers = [...withoutSources] // Start with layers that don't depend on sources + + for (const source of sources) { + for (const layerTemplate of layersTemplates) { + const layer: MapLayer = { + ...layerTemplate, + id: `${layerTemplate.id}-${Object.keys(source)[0]}`, + type: layerTemplate.type, + source: Object.keys(source)[0], + } + template.layers.push(layer) + } + + template.sources = Object.assign(template.sources, source) + } + + template.sprite = sprites + template.glyphs = glyphs + + return template + } + + async delete(file: string): Promise { + let fileName = file + if (!fileName.endsWith('.zim')) { + fileName += '.zim' + } + + const fullPath = join(process.cwd(), this.mapStoragePath, fileName) + + const exists = await getFileStatsIfExists(fullPath) + if (!exists) { + throw new Error('not_found') + } + + await deleteFileIfExists(fullPath) + } +} diff --git a/admin/app/services/zim_service.ts b/admin/app/services/zim_service.ts index d70a17d..dfa66fa 100644 --- a/admin/app/services/zim_service.ts +++ b/admin/app/services/zim_service.ts @@ -1,90 +1,100 @@ -import drive from "@adonisjs/drive/services/main"; -import { DownloadOptions, DownloadProgress, ListRemoteZimFilesResponse, RawRemoteZimFileEntry, RemoteZimFileEntry, ZimFilesEntry } from "../../types/zim.js"; -import axios from "axios"; +import { + ListRemoteZimFilesResponse, + RawRemoteZimFileEntry, + RemoteZimFileEntry, +} from '../../types/zim.js' +import axios from 'axios' import { XMLParser } from 'fast-xml-parser' -import { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from "../../util/zim.js"; -import transmit from "@adonisjs/transmit/services/main"; -import { Transform } from "stream"; -import logger from "@adonisjs/core/services/logger"; -import { DockerService } from "./docker_service.js"; -import { inject } from "@adonisjs/core"; +import { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from '../../util/zim.js' +import transmit from '@adonisjs/transmit/services/main' +import logger from '@adonisjs/core/services/logger' +import { DockerService } from './docker_service.js' +import { inject } from '@adonisjs/core' +import { doBackgroundDownload } from '../utils/downloads.js' +import { + deleteFileIfExists, + ensureDirectoryExists, + getFileStatsIfExists, + listDirectoryContents, +} from '../utils/fs.js' +import { join } from 'path' + +const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream'] +const BROADCAST_CHANNEL = 'zim-downloads' @inject() export class ZimService { - private activeDownloads = new Map(); + private zimStoragePath = '/storage/zim' + private activeDownloads = new Map() - constructor( - private dockerService: DockerService - ) {} + constructor(private dockerService: DockerService) {} async list() { - const disk = drive.use('fs'); - const contents = await disk.listAll('/zim') + const dirPath = join(process.cwd(), this.zimStoragePath) - const files: ZimFilesEntry[] = [] - for (let item of contents.objects) { - if (item.isFile) { - files.push({ - type: 'file', - key: item.key, - name: item.name - }) - } else { - files.push({ - type: 'directory', - prefix: item.prefix, - name: item.name - }) - } - } + await ensureDirectoryExists(dirPath) + + const files = await listDirectoryContents(dirPath) return { files, - next: contents.paginationToken } } - async listRemote({ start, count }: { start: number, count: number }): Promise { + async listRemote({ + start, + count, + }: { + start: number + count: number + }): Promise { const LIBRARY_BASE_URL = 'https://browse.library.kiwix.org/catalog/v2/entries' const res = await axios.get(LIBRARY_BASE_URL, { params: { start: start, count: count, - lang: 'eng' + lang: 'eng', }, - responseType: 'text' - }); + responseType: 'text', + }) - const data = res.data; + const data = res.data const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', textNodeName: '#text', - }); - const result = parser.parse(data); + }) + const result = parser.parse(data) if (!isRawListRemoteZimFilesResponse(result)) { - throw new Error('Invalid response format from remote library'); + throw new Error('Invalid response format from remote library') } const filtered = result.feed.entry.filter((entry: any) => { - return isRawRemoteZimFileEntry(entry); + return isRawRemoteZimFileEntry(entry) }) const mapped: (RemoteZimFileEntry | null)[] = filtered.map((entry: RawRemoteZimFileEntry) => { const downloadLink = entry.link.find((link: any) => { - return typeof link === 'object' && 'rel' in link && 'length' in link && 'href' in link && 'type' in link && link.type === 'application/x-zim' - }); + return ( + typeof link === 'object' && + 'rel' in link && + 'length' in link && + 'href' in link && + 'type' in link && + link.type === 'application/x-zim' + ) + }) if (!downloadLink) { return null } // downloadLink['href'] will end with .meta4, we need to remove that to get the actual download URL - const download_url = downloadLink['href'].substring(0, downloadLink['href'].length - 6); - const file_name = download_url.split('/').pop() || `${entry.title}.zim`; - const sizeBytes = parseInt(downloadLink['length'], 10); + const download_url = downloadLink['href'].substring(0, downloadLink['href'].length - 6) + const file_name = download_url.split('/').pop() || `${entry.title}.zim` + const sizeBytes = parseInt(downloadLink['length'], 10) return { id: entry.id, @@ -94,281 +104,96 @@ export class ZimService { size_bytes: sizeBytes || 0, download_url: download_url, author: entry.author.name, - file_name: file_name + file_name: file_name, } - }); + }) // Filter out any null entries (those without a valid download link) // or files that already exist in the local storage - const existing = await this.list(); - const existingKeys = new Set(existing.files.map(file => file.name)); - const withoutExisting = mapped.filter((entry): entry is RemoteZimFileEntry => entry !== null && !existingKeys.has(entry.file_name)); + const existing = await this.list() + const existingKeys = new Set(existing.files.map((file) => file.name)) + const withoutExisting = mapped.filter( + (entry): entry is RemoteZimFileEntry => entry !== null && !existingKeys.has(entry.file_name) + ) return { items: withoutExisting, has_more: result.feed.totalResults > start, total_count: result.feed.totalResults, - }; + } } - async downloadRemote(url: string, opts: DownloadOptions = {}): Promise { - if (!url.endsWith('.zim')) { - throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`); + async downloadRemote(url: string): Promise { + const parsed = new URL(url) + if (!parsed.pathname.endsWith('.zim')) { + throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`) } - const existing = this.activeDownloads.get(url); + const existing = this.activeDownloads.get(url) if (existing) { - throw new Error(`Download already in progress for URL ${url}`); + throw new Error(`Download already in progress for URL ${url}`) } + await ensureDirectoryExists(join(process.cwd(), this.zimStoragePath)) + // Extract the filename from the URL - const filename = url.split('/').pop() || `downloaded-${Date.now()}.zim`; - const path = `/zim/${filename}`; + const filename = url.split('/').pop() + if (!filename) { + throw new Error('Could not determine filename from URL') + } - this._runDownload(url, path, opts); // Don't await - let run in background + const path = join(process.cwd(), this.zimStoragePath, filename) - return filename; + // Don't await the download, run it in the background + doBackgroundDownload({ + url, + path, + channel: BROADCAST_CHANNEL, + activeDownloads: this.activeDownloads, + allowedMimeTypes: ZIM_MIME_TYPES, + timeout: 30000, + forceNew: true, + onComplete: async () => { + // Restart KIWIX container to pick up new ZIM file + await this.dockerService + .affectContainer(DockerService.KIWIX_SERVICE_NAME, 'restart') + .catch((error) => { + logger.error(`Failed to restart KIWIX container:`, error) // Don't stop the download completion, just log the error. + }) + }, + }) + + return filename } getActiveDownloads(): string[] { - return Array.from(this.activeDownloads.keys()); + return Array.from(this.activeDownloads.keys()) } cancelDownload(url: string): boolean { - const entry = this.activeDownloads.get(url); + const entry = this.activeDownloads.get(url) if (entry) { - entry.abort(); - this.activeDownloads.delete(url); - transmit.broadcast(`zim-downloads`, { url, status: 'cancelled' }); - return true; + entry.abort() + this.activeDownloads.delete(url) + transmit.broadcast(BROADCAST_CHANNEL, { url, status: 'cancelled' }) + return true } - return false; + return false } - async delete(key: string): Promise { - let fileName = key; + async delete(file: string): Promise { + let fileName = file if (!fileName.endsWith('.zim')) { - fileName += '.zim'; + fileName += '.zim' } - const disk = drive.use('fs'); - const exists = await disk.exists(fileName); + const fullPath = join(process.cwd(), this.zimStoragePath, fileName) + const exists = await getFileStatsIfExists(fullPath) if (!exists) { - throw new Error('not_found'); + throw new Error('not_found') } - await disk.delete(fileName); + await deleteFileIfExists(fullPath) } - - private async _runDownload(url: string, path: string, opts: DownloadOptions = {}): Promise { - try { - const { - max_retries = 3, - retry_delay = 2000, - timeout = 30000, - onError, - }: DownloadOptions = opts; - - let attempt = 0; - while (attempt < max_retries) { - try { - const abortController = new AbortController(); - this.activeDownloads.set(url, abortController); - - await this._attemptDownload( - url, - path, - abortController.signal, - timeout, - ); - - transmit.broadcast('zim-downloads', { url, path, status: 'completed', progress: { downloaded_bytes: 0, total_bytes: 0, percentage: 100, speed: '0 B/s', time_remaining: 0 } }); - await this.dockerService.affectContainer(DockerService.KIWIX_SERVICE_NAME, 'restart').catch((error) => { - logger.error(`Failed to restart KIWIX container:`, error); // Don't stop the download completion, just log the error. - }); - - break; // Exit loop on success - } catch (error) { - attempt++; - - const isAborted = error.name === 'AbortError' || error.code === 'ABORT_ERR'; - const isNetworkError = error.code === 'ECONNRESET' || - error.code === 'ENOTFOUND' || - error.code === 'ETIMEDOUT'; - - onError?.(error); - if (isAborted) { - throw new Error(`Download aborted for URL: ${url}`); - } - - if (attempt < max_retries && isNetworkError) { - await this.delay(retry_delay); - continue; - } - } - } - } catch (error) { - logger.error(`Failed to download ${url}:`, error); - transmit.broadcast('zim-downloads', { url, error: error.message, status: 'failed' }); - } finally { - this.activeDownloads.delete(url); - return url; - } - } - - private async _attemptDownload( - url: string, - path: string, - signal: AbortSignal, - timeout: number, - ): Promise { - const disk = drive.use('fs'); - - // Check if partial file exists for resume - let startByte = 0; - let appendMode = false; - - if (await disk.exists(path)) { - const stats = await disk.getMetaData(path); - startByte = stats.contentLength; - appendMode = true; - } - - // Get file info with HEAD request first - const headResponse = await axios.head(url, { - signal, - timeout - }); - - const totalBytes = parseInt(headResponse.headers['content-length'] || '0'); - const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes'; - - // If file is already complete - if (startByte === totalBytes && totalBytes > 0) { - logger.info(`File ${path} is already complete`); - return path; - } - - // If server doesn't support range requests and we have a partial file, delete it - if (!supportsRangeRequests && startByte > 0) { - await disk.delete(path); - startByte = 0; - appendMode = false; - } - - const headers: Record = {}; - if (supportsRangeRequests && startByte > 0) { - headers.Range = `bytes=${startByte}-`; - } - - const response = await axios.get(url, { - responseType: 'stream', - headers, - signal, - timeout - }); - - if (response.status !== 200 && response.status !== 206) { - throw new Error(`Failed to download: HTTP ${response.status}`); - } - - return new Promise((resolve, reject) => { - let downloadedBytes = startByte; - let lastProgressTime = Date.now(); - let lastDownloadedBytes = startByte; - - // Progress tracking stream to monitor data flow - const progressStream = new Transform({ - transform(chunk: Buffer, _: any, callback: Function) { - downloadedBytes += chunk.length; - this.push(chunk); - callback(); - } - }); - - // Update progress every 500ms - const progressInterval = setInterval(() => { - this.updateProgress({ - downloadedBytes, - totalBytes, - lastProgressTime, - lastDownloadedBytes, - url - }); - }, 500); - - // Handle errors and cleanup - const cleanup = (error?: Error) => { - clearInterval(progressInterval); - progressStream.destroy(); - response.data.destroy(); - if (error) { - reject(error); - } - }; - - response.data.on('error', cleanup); - progressStream.on('error', cleanup); - - signal.addEventListener('abort', () => { - cleanup(new Error('Download aborted')); - }); - - // Pipe through progress stream and then to disk - const sourceStream = response.data.pipe(progressStream); - - // Use disk.putStream with append mode for resumable downloads - disk.putStream(path, sourceStream, { append: appendMode }) - .then(() => { - clearInterval(progressInterval); - resolve(path); - }) - .catch(cleanup); - }); - } - - private updateProgress({ - downloadedBytes, - totalBytes, - lastProgressTime, - lastDownloadedBytes, - url - }: { - downloadedBytes: number; - totalBytes: number; - lastProgressTime: number; - lastDownloadedBytes: number; - url: string; - }) { - const now = Date.now(); - const timeDiff = (now - lastProgressTime) / 1000; - const bytesDiff = downloadedBytes - lastDownloadedBytes; - const rawSpeed = timeDiff > 0 ? bytesDiff / timeDiff : 0; - const timeRemaining = rawSpeed > 0 ? (totalBytes - downloadedBytes) / rawSpeed : 0; - const speed = this.formatSpeed(rawSpeed); - - const progress: DownloadProgress = { - downloaded_bytes: downloadedBytes, - total_bytes: totalBytes, - percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0, - speed, - time_remaining: timeRemaining - }; - - transmit.broadcast('zim-downloads', { url, progress, status: "in_progress" }); - - lastProgressTime = now; - lastDownloadedBytes = downloadedBytes; - }; - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - private formatSpeed(bytesPerSecond: number): string { - if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`; - if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`; - return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; - } -} \ No newline at end of file +} diff --git a/admin/app/utils/downloads.ts b/admin/app/utils/downloads.ts new file mode 100644 index 0000000..eb99133 --- /dev/null +++ b/admin/app/utils/downloads.ts @@ -0,0 +1,289 @@ +import { + DoBackgroundDownloadParams, + DoResumableDownloadParams, + DoResumableDownloadProgress, + DoResumableDownloadWithRetryParams, +} from '../../types/downloads.js' +import axios from 'axios' +import { Transform } from 'stream' +import { deleteFileIfExists, getFileStatsIfExists } from './fs.js' +import { createWriteStream } from 'fs' +import { formatSpeed } from './misc.js' +import { DownloadProgress } from '../../types/files.js' +import transmit from '@adonisjs/transmit/services/main' +import logger from '@adonisjs/core/services/logger' + +/** + * Perform a resumable download with progress tracking + * @param param0 - Download parameters. Leave allowedMimeTypes empty to skip mime type checking. + * Otherwise, mime types should be in the format "application/pdf", "image/png", etc. + * @returns Path to the downloaded file + */ +export async function doResumableDownload({ + url, + path, + timeout = 30000, + signal, + onProgress, + forceNew = false, + allowedMimeTypes, +}: DoResumableDownloadParams): Promise { + // Check if partial file exists for resume + let startByte = 0 + let appendMode = false + + console.log(`Starting download from ${url} to ${path}`) + console.log('Checking for existing file to resume...') + const existingStats = await getFileStatsIfExists(path) + if (existingStats && !forceNew) { + startByte = existingStats.size + appendMode = true + } + + // Get file info with HEAD request first + const headResponse = await axios.head(url, { + signal, + timeout, + }) + + const contentType = headResponse.headers['content-type'] || '' + const totalBytes = parseInt(headResponse.headers['content-length'] || '0') + const supportsRangeRequests = headResponse.headers['accept-ranges'] === 'bytes' + + // If allowedMimeTypes is provided, check content type + if (allowedMimeTypes && allowedMimeTypes.length > 0) { + const isMimeTypeAllowed = allowedMimeTypes.some((mimeType) => contentType.includes(mimeType)) + if (!isMimeTypeAllowed) { + throw new Error(`MIME type ${contentType} is not allowed`) + } + } + + // If file is already complete and not forcing overwrite just return path + if (startByte === totalBytes && totalBytes > 0 && !forceNew) { + return path + } + + // If server doesn't support range requests and we have a partial file, delete it + if (!supportsRangeRequests && startByte > 0) { + await deleteFileIfExists(path) + startByte = 0 + appendMode = false + } + + const headers: Record = {} + if (supportsRangeRequests && startByte > 0) { + headers.Range = `bytes=${startByte}-` + } + + const response = await axios.get(url, { + responseType: 'stream', + headers, + signal, + timeout, + }) + + if (response.status !== 200 && response.status !== 206) { + throw new Error(`Failed to download: HTTP ${response.status}`) + } + + return new Promise((resolve, reject) => { + let downloadedBytes = startByte + let lastProgressTime = Date.now() + let lastDownloadedBytes = startByte + + // Progress tracking stream to monitor data flow + const progressStream = new Transform({ + transform(chunk: Buffer, _: any, callback: Function) { + downloadedBytes += chunk.length + + // Update progress tracking + const now = Date.now() + if (onProgress && now - lastProgressTime >= 500) { + lastProgressTime = now + lastDownloadedBytes = downloadedBytes + onProgress({ + downloadedBytes, + totalBytes, + lastProgressTime, + lastDownloadedBytes, + url, + }) + } + + this.push(chunk) + callback() + }, + }) + + const writeStream = createWriteStream(path, { + flags: appendMode ? 'a' : 'w', + }) + + // Handle errors and cleanup + const cleanup = (error?: Error) => { + progressStream.destroy() + response.data.destroy() + writeStream.destroy() + if (error) { + reject(error) + } + } + + response.data.on('error', cleanup) + progressStream.on('error', cleanup) + writeStream.on('error', cleanup) + writeStream.on('error', cleanup) + + signal?.addEventListener('abort', () => { + cleanup(new Error('Download aborted')) + }) + + writeStream.on('finish', () => { + if (onProgress) { + onProgress({ + downloadedBytes, + totalBytes, + lastProgressTime: Date.now(), + lastDownloadedBytes: downloadedBytes, + url, + }) + } + resolve(path) + }) + + // Pipe: response -> progressStream -> writeStream + response.data.pipe(progressStream).pipe(writeStream) + }) +} + +export async function doResumableDownloadWithRetry({ + url, + path, + signal, + timeout = 30000, + onProgress, + max_retries = 3, + retry_delay = 2000, + onAttemptError, + allowedMimeTypes, +}: DoResumableDownloadWithRetryParams): Promise { + let attempt = 0 + let lastError: Error | null = null + + while (attempt < max_retries) { + try { + const result = await doResumableDownload({ + url, + path, + signal, + timeout, + allowedMimeTypes, + onProgress, + }) + + return result // return on success + } catch (error) { + attempt++ + lastError = error as Error + + const isAborted = error.name === 'AbortError' || error.code === 'ABORT_ERR' + const isNetworkError = + error.code === 'ECONNRESET' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT' + + onAttemptError?.(error, attempt) + if (isAborted) { + throw new Error(`Download aborted for URL: ${url}`) + } + + if (attempt < max_retries && isNetworkError) { + await delay(retry_delay) + continue + } + + // If max retries reached or non-retriable error, throw + if (attempt >= max_retries || !isNetworkError) { + throw error + } + } + } + + // should not reach here, but TypeScript needs a return + throw lastError || new Error('Unknown error during download') +} + +export async function doBackgroundDownload(params: DoBackgroundDownloadParams): Promise { + const { url, path, channel, activeDownloads, onComplete, ...restParams } = params + + try { + const abortController = new AbortController() + activeDownloads.set(url, abortController) + + await doResumableDownloadWithRetry({ + url, + path, + signal: abortController.signal, + ...restParams, + onProgress: (progressData) => { + sendProgressBroadcast(channel, progressData) + }, + }) + + sendCompletedBroadcast(channel, url, path) + + if (onComplete) { + await onComplete(url, path) + } + } catch (error) { + logger.error(`Background download failed for ${url}: ${error.message}`) + sendErrorBroadcast(channel, url, error.message) + } finally { + activeDownloads.delete(url) + } +} + +export function sendProgressBroadcast( + channel: string, + progressData: DoResumableDownloadProgress, + status = 'in_progress' +) { + const { downloadedBytes, totalBytes, lastProgressTime, lastDownloadedBytes, url } = progressData + const now = Date.now() + const timeDiff = (now - lastProgressTime) / 1000 + const bytesDiff = downloadedBytes - lastDownloadedBytes + const rawSpeed = timeDiff > 0 ? bytesDiff / timeDiff : 0 + const timeRemaining = rawSpeed > 0 ? (totalBytes - downloadedBytes) / rawSpeed : 0 + const speed = formatSpeed(rawSpeed) + + const progress: DownloadProgress = { + downloaded_bytes: downloadedBytes, + total_bytes: totalBytes, + percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0, + speed, + time_remaining: timeRemaining, + } + + transmit.broadcast(channel, { url, progress, status }) +} + +export function sendCompletedBroadcast(channel: string, url: string, path: string) { + transmit.broadcast(channel, { + url, + path, + status: 'completed', + progress: { + downloaded_bytes: 0, + total_bytes: 0, + percentage: 100, + speed: '0 B/s', + time_remaining: 0, + }, + }) +} + +export function sendErrorBroadcast(channel: string, url: string, errorMessage: string) { + transmit.broadcast(channel, { url, error: errorMessage, status: 'failed' }) +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/admin/app/utils/fs.ts b/admin/app/utils/fs.ts new file mode 100644 index 0000000..c1484fa --- /dev/null +++ b/admin/app/utils/fs.ts @@ -0,0 +1,115 @@ +import { mkdir, readdir, readFile, stat, unlink } from 'fs/promises' +import { join } from 'path' +import { DriveDisks } from '@adonisjs/drive/types' +import driveConfig from '#config/drive' +import app from '@adonisjs/core/services/app' +import { FileEntry } from '../../types/files.js' + +export async function listDirectoryContents(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }) + const results: FileEntry[] = [] + for (const entry of entries) { + if (entry.isFile()) { + results.push({ + type: 'file', + key: join(path, entry.name), + name: entry.name, + }) + } else if (entry.isDirectory()) { + results.push({ + type: 'directory', + prefix: join(path, entry.name), + name: entry.name, + }) + } + } + return results +} + +export async function listDirectoryContentsRecursive(path: string): Promise { + let results: FileEntry[] = [] + const entries = await readdir(path, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = join(path, entry.name) + if (entry.isDirectory()) { + const subdirectoryContents = await listDirectoryContentsRecursive(fullPath) + results = results.concat(subdirectoryContents) + } else { + results.push({ + type: 'file', + key: fullPath, + name: entry.name, + }) + } + } + return results +} + +export async function ensureDirectoryExists(path: string): Promise { + try { + await stat(path) + } catch (error) { + if (error.code === 'ENOENT') { + await mkdir(path, { recursive: true }) + } + } +} + +export async function getFile(path: string, returnType: 'buffer'): Promise +export async function getFile(path: string, returnType: 'string'): Promise +export async function getFile(path: string, returnType: 'buffer' | 'string' = 'buffer'): Promise { + try { + if (returnType === 'buffer') { + return await readFile(path) + } else { + return await readFile(path, 'utf-8') + } + } catch (error) { + if (error.code === 'ENOENT') { + return null + } + throw error + } +} + +export async function getFileStatsIfExists( + path: string +): Promise<{ size: number; modifiedTime: Date } | null> { + try { + const stats = await stat(path) + return { + size: stats.size, + modifiedTime: stats.mtime, + } + } catch (error) { + if (error.code === 'ENOENT') { + return null + } + throw error + } +} + +export async function deleteFileIfExists(path: string): Promise { + try { + await unlink(path) + } catch (error) { + if (error.code !== 'ENOENT') { + throw error + } + } +} + +export async function getFullDrivePath(diskName: keyof DriveDisks): Promise { + const config = await driveConfig.resolver(app) + const serviceConfig = config.config.services[diskName] + const resolved = serviceConfig() + if (!resolved) { + throw new Error(`Disk ${diskName} not configured`) + } + + let path = resolved.options.location + if (path instanceof URL) { + return path.pathname + } + return path +} diff --git a/admin/app/utils/misc.ts b/admin/app/utils/misc.ts new file mode 100644 index 0000000..bda8d23 --- /dev/null +++ b/admin/app/utils/misc.ts @@ -0,0 +1,5 @@ +export function formatSpeed(bytesPerSecond: number): string { + if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s` + if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s` + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s` +} diff --git a/admin/app/utils/url.ts b/admin/app/utils/url.ts new file mode 100644 index 0000000..e69de29 diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts new file mode 100644 index 0000000..d2cd235 --- /dev/null +++ b/admin/app/validators/common.ts @@ -0,0 +1,19 @@ +import vine from '@vinejs/vine' + +export const remoteDownloadValidator = vine.compile( + vine.object({ + url: vine.string().url().trim(), + }) +) + +export const remoteDownloadValidatorOptional = vine.compile( + vine.object({ + url: vine.string().url().trim().optional(), + }) +) + +export const filenameValidator = vine.compile( + vine.object({ + filename: vine.string().trim().minLength(1).maxLength(4096), + }) +) diff --git a/admin/app/validators/zim.ts b/admin/app/validators/zim.ts new file mode 100644 index 0000000..e69de29 diff --git a/admin/inertia/components/Alert.tsx b/admin/inertia/components/Alert.tsx index 58639a1..2da737d 100644 --- a/admin/inertia/components/Alert.tsx +++ b/admin/inertia/components/Alert.tsx @@ -2,13 +2,14 @@ import { ExclamationTriangleIcon, XCircleIcon } from '@heroicons/react/24/solid' import { IconCircleCheck } from '@tabler/icons-react' import classNames from '~/lib/classNames' -interface AlertProps extends React.HTMLAttributes { +export type AlertProps = React.HTMLAttributes & { title: string message?: string type: 'warning' | 'error' | 'success' + children?: React.ReactNode } -export default function Alert({ title, message, type, ...props }: AlertProps) { +export default function Alert({ title, message, type, children, ...props }: AlertProps) { const getIcon = () => { const Icon = type === 'warning' @@ -43,16 +44,19 @@ export default function Alert({ title, message, type, ...props }: AlertProps) { 'border border-gray-200 rounded-md p-3 shadow-xs' )} > -
-
{getIcon()}
-
-

{title}

- {message && ( -
-

{message}

-
- )} +
+
+
{getIcon()}
+
+

{title}

+ {message && ( +
+

{message}

+
+ )} +
+ {children}
) diff --git a/admin/inertia/components/AlertWithButton.tsx b/admin/inertia/components/AlertWithButton.tsx new file mode 100644 index 0000000..a7d28ea --- /dev/null +++ b/admin/inertia/components/AlertWithButton.tsx @@ -0,0 +1,16 @@ +import Alert, { AlertProps } from './Alert' +import StyledButton, { StyledButtonProps } from './StyledButton' + +export type AlertWithButtonProps = { + buttonProps: StyledButtonProps +} & AlertProps + +const AlertWithButton = ({ buttonProps, ...alertProps }: AlertWithButtonProps) => { + return ( + + + + ) +} + +export default AlertWithButton \ No newline at end of file diff --git a/admin/inertia/components/layout/BackToHomeHeader.tsx b/admin/inertia/components/layout/BackToHomeHeader.tsx new file mode 100644 index 0000000..9777c05 --- /dev/null +++ b/admin/inertia/components/layout/BackToHomeHeader.tsx @@ -0,0 +1,22 @@ +import { Link } from '@inertiajs/react' +import { IconArrowLeft } from '@tabler/icons-react' +import classNames from '~/lib/classNames' + +interface BackToHomeHeaderProps { + className?: string + children?: React.ReactNode +} + +export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) { + return ( +
+
+ + +

Back to Home

+ +
+
{children}
+
+ ) +} diff --git a/admin/inertia/components/layout/MissingBaseAssetsAlert.tsx b/admin/inertia/components/layout/MissingBaseAssetsAlert.tsx new file mode 100644 index 0000000..e4a2fc6 --- /dev/null +++ b/admin/inertia/components/layout/MissingBaseAssetsAlert.tsx @@ -0,0 +1,29 @@ +import AlertWithButton from "../AlertWithButton" + +export type MissingBaseAssetsAlertProps = { + onClickDownload?: () => Promise + loading?: boolean +} + +const MissingBaseAssetsAlert = (props: MissingBaseAssetsAlertProps) => { + return ( + { + if (props.onClickDownload) { + return props.onClickDownload() + } + } + }} + /> + ) +} + +export default MissingBaseAssetsAlert diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx new file mode 100644 index 0000000..f5e282e --- /dev/null +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -0,0 +1,44 @@ +import Map, { FullscreenControl, NavigationControl, MapProvider } from 'react-map-gl/maplibre' +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +import { Protocol } from 'pmtiles' +import { useEffect } from 'react' +import { useWindowSize } from 'usehooks-ts' + +export default function MapComponent() { + const { width = 0, height = 0 } = useWindowSize() + + // Add the PMTiles protocol to maplibre-gl + useEffect(() => { + let protocol = new Protocol() + maplibregl.addProtocol('pmtiles', protocol.tile) + return () => { + maplibregl.removeProtocol('pmtiles') + } + }, []) + + return ( + + + + + + + ) +} diff --git a/admin/inertia/hooks/useMapRegionFiles.ts b/admin/inertia/hooks/useMapRegionFiles.ts new file mode 100644 index 0000000..8d5b620 --- /dev/null +++ b/admin/inertia/hooks/useMapRegionFiles.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' +import { FileEntry } from '../../types/files' +import api from '~/lib/api' + +const useMapRegionFiles = () => { + return useQuery({ + queryKey: ['map-region-files'], + queryFn: () => api.listMapRegionFiles(), + staleTime: 5 * 60 * 1000, // 5 minutes + }) +} + +export default useMapRegionFiles diff --git a/admin/inertia/layouts/AppLayout.tsx b/admin/inertia/layouts/AppLayout.tsx index 86117d3..a31e3b1 100644 --- a/admin/inertia/layouts/AppLayout.tsx +++ b/admin/inertia/layouts/AppLayout.tsx @@ -3,7 +3,7 @@ import Footer from "~/components/Footer"; export default function AppLayout({ children }: { children: React.ReactNode }) { return (
-
+
window.location.href = '/home'}> Project Nomad Logo

Command Center

diff --git a/admin/inertia/layouts/MapsLayout.tsx b/admin/inertia/layouts/MapsLayout.tsx new file mode 100644 index 0000000..91c5c57 --- /dev/null +++ b/admin/inertia/layouts/MapsLayout.tsx @@ -0,0 +1,10 @@ +import Footer from '~/components/Footer' + +export default function MapsLayout({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+
+ ) +} diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index 1d4c3ef..9c77d86 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -4,13 +4,14 @@ import { FolderIcon, MagnifyingGlassIcon, } from '@heroicons/react/24/outline' -import { IconDashboard, IconGavel } from '@tabler/icons-react' +import { IconDashboard, IconGavel, IconMapRoute } from '@tabler/icons-react' import StyledSidebar from '~/components/StyledSidebar' import { getServiceLink } from '~/lib/navigation' const navigation = [ { name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false }, { name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false }, + { name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false }, { name: 'Service Logs & Metrics', href: getServiceLink('9999'), diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 62384dc..df17985 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1,96 +1,122 @@ -import axios from "axios"; -import { ListRemoteZimFilesResponse, ListZimFilesResponse } from "../../types/zim"; -import { ServiceSlim } from "../../types/services"; +import axios from 'axios' +import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim' +import { ServiceSlim } from '../../types/services' +import { FileEntry } from '../../types/files' class API { - private client; + private client - constructor() { - this.client = axios.create({ - baseURL: "/api", - headers: { - "Content-Type": "application/json", - }, - }); + constructor() { + this.client = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + }) + } + + async listDocs() { + try { + const response = await this.client.get>('/docs/list') + return response.data + } catch (error) { + console.error('Error listing docs:', error) + throw error } + } - async listDocs() { - try { - const response = await this.client.get>("/docs/list"); - return response.data; - } catch (error) { - console.error("Error listing docs:", error); - throw error; - } + async listMapRegionFiles() { + try { + const response = await this.client.get<{ files: FileEntry[] }>('/maps/regions') + return response.data.files + } catch (error) { + console.error('Error listing map region files:', error) + throw error } + } - async listServices() { - try { - const response = await this.client.get>("/system/services"); - return response.data; - } catch (error) { - console.error("Error listing services:", error); - throw error; - } + async downloadBaseMapAssets() { + try { + const response = await this.client.post<{ success: boolean }>('/maps/download-base-assets') + return response.data + } catch (error) { + console.error('Error downloading base map assets:', error) + throw error } + } - - async installService(service_name: string) { - try { - const response = await this.client.post<{ success: boolean; message: string }>("/system/services/install", { service_name }); - return response.data; - } catch (error) { - console.error("Error installing service:", error); - throw error; - } + async listServices() { + try { + const response = await this.client.get>('/system/services') + return response.data + } catch (error) { + console.error('Error listing services:', error) + throw error } + } - async affectService(service_name: string, action: "start" | "stop" | "restart") { - try { - const response = await this.client.post<{ success: boolean; message: string }>("/system/services/affect", { service_name, action }); - return response.data; - } catch (error) { - console.error("Error affecting service:", error); - throw error; - } + async installService(service_name: string) { + try { + const response = await this.client.post<{ success: boolean; message: string }>( + '/system/services/install', + { service_name } + ) + return response.data + } catch (error) { + console.error('Error installing service:', error) + throw error } + } - async listZimFiles() { - return await this.client.get("/zim/list"); + async affectService(service_name: string, action: 'start' | 'stop' | 'restart') { + try { + const response = await this.client.post<{ success: boolean; message: string }>( + '/system/services/affect', + { service_name, action } + ) + return response.data + } catch (error) { + console.error('Error affecting service:', error) + throw error } + } - async listRemoteZimFiles({ start = 0, count = 12 }: { start?: number; count?: number }) { - return await this.client.get("/zim/list-remote", { - params: { - start, - count - } - }); - } + async listZimFiles() { + return await this.client.get('/zim/list') + } - async downloadRemoteZimFile(url: string): Promise<{ - message: string; - filename: string; - url: string; - }> { - try { - const response = await this.client.post("/zim/download-remote", { url }); - return response.data; - } catch (error) { - console.error("Error downloading remote ZIM file:", error); - throw error; - } - } + async listRemoteZimFiles({ start = 0, count = 12 }: { start?: number; count?: number }) { + return await this.client.get('/zim/list-remote', { + params: { + start, + count, + }, + }) + } - async deleteZimFile(key: string) { - try { - const response = await this.client.delete(`/zim/${key}`); - return response.data; - } catch (error) { - console.error("Error deleting ZIM file:", error); - throw error; - } + async downloadRemoteZimFile(url: string): Promise<{ + message: string + filename: string + url: string + }> { + try { + const response = await this.client.post('/zim/download-remote', { url }) + return response.data + } catch (error) { + console.error('Error downloading remote ZIM file:', error) + throw error } + } + + async deleteZimFile(key: string) { + try { + const response = await this.client.delete(`/zim/${key}`) + return response.data + } catch (error) { + console.error('Error deleting ZIM file:', error) + throw error + } + } } -export default new API(); \ No newline at end of file +export default new API() diff --git a/admin/inertia/pages/docs/show.tsx b/admin/inertia/pages/docs/show.tsx index 8d763e1..7ca5ae2 100644 --- a/admin/inertia/pages/docs/show.tsx +++ b/admin/inertia/pages/docs/show.tsx @@ -5,7 +5,7 @@ import DocsLayout from '~/layouts/DocsLayout' export default function Show({ content }: { content: any; }) { return ( - +
diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index 024f74a..a4a294e 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -1,4 +1,4 @@ -import { IconHelp, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react' +import { IconHelp, IconMapRoute, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react' import { Head } from '@inertiajs/react' import BouncingLogo from '~/components/BouncingLogo' import AppLayout from '~/layouts/AppLayout' @@ -21,6 +21,14 @@ const STATIC_ITEMS = [ icon: , installed: true, }, + { + label: 'Maps', + to: '/maps', + target: '', + description: 'View offline maps', + icon: , + installed: true, + }, { label: 'Settings', to: '/settings/system', @@ -52,7 +60,7 @@ export default function Home(props: { return ( - +
{items.map((item) => ( diff --git a/admin/inertia/pages/maps.tsx b/admin/inertia/pages/maps.tsx new file mode 100644 index 0000000..9bdeadb --- /dev/null +++ b/admin/inertia/pages/maps.tsx @@ -0,0 +1,27 @@ +import MapsLayout from '~/layouts/MapsLayout' +import { Head, Link } from '@inertiajs/react' +import MapComponent from '~/components/maps/MapComponent' +import StyledButton from '~/components/StyledButton' +import { IconArrowLeft } from '@tabler/icons-react' + +export default function Maps() { + return ( + + +
+ + +

Back to Home

+ + + + Manage Map Regions + + +
+
+ +
+
+ ) +} diff --git a/admin/inertia/pages/settings/apps.tsx b/admin/inertia/pages/settings/apps.tsx index 0d5c73d..5eb5231 100644 --- a/admin/inertia/pages/settings/apps.tsx +++ b/admin/inertia/pages/settings/apps.tsx @@ -209,7 +209,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] return ( - +

Apps

diff --git a/admin/inertia/pages/settings/legal.tsx b/admin/inertia/pages/settings/legal.tsx index aeb1945..20d6b9c 100644 --- a/admin/inertia/pages/settings/legal.tsx +++ b/admin/inertia/pages/settings/legal.tsx @@ -4,7 +4,7 @@ import SettingsLayout from '~/layouts/SettingsLayout' export default function SettingsPage() { return ( - +

Legal Notices

diff --git a/admin/inertia/pages/settings/maps.tsx b/admin/inertia/pages/settings/maps.tsx new file mode 100644 index 0000000..6030439 --- /dev/null +++ b/admin/inertia/pages/settings/maps.tsx @@ -0,0 +1,117 @@ +import { Head, router } from '@inertiajs/react' +import StyledTable from '~/components/StyledTable' +import SettingsLayout from '~/layouts/SettingsLayout' +import StyledButton from '~/components/StyledButton' +import { useModals } from '~/context/ModalContext' +import StyledModal from '~/components/StyledModal' +import { FileEntry } from '../../../types/files' +import MissingBaseAssetsAlert from '~/components/layout/MissingBaseAssetsAlert' +import { useNotifications } from '~/context/NotificationContext' +import { useState } from 'react' +import api from '~/lib/api' + +export default function MapsManager(props: { + maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } +}) { + const { openModal, closeAllModals } = useModals() + const { addNotification } = useNotifications() + const [downloading, setDownloading] = useState(false) + + async function downloadBaseAssets() { + try { + setDownloading(true) + + const res = await api.downloadBaseMapAssets() + if (res.success) { + addNotification({ + type: 'success', + message: 'Base map assets downloaded successfully.', + }) + router.reload() + } + } catch (error) { + console.error('Error downloading base assets:', error) + addNotification({ + type: 'error', + message: 'An error occurred while downloading the base map assets. Please try again.', + }) + } finally { + setDownloading(false) + } + } + + async function confirmDeleteFile(file: FileEntry) { + openModal( + { + closeAllModals() + }} + onCancel={closeAllModals} + open={true} + confirmText="Delete" + cancelText="Cancel" + confirmVariant="danger" + > +

+ Are you sure you want to delete {file.name}? This action cannot be undone. +

+
, + 'confirm-delete-file-modal' + ) + } + + return ( + + +
+
+
+
+

Maps Manager

+

Manage your stored map data files.

+
+ + Download New Map File + +
+ {!props.maps.baseAssetsExist && ( + + )} + + className="font-semibold mt-4" + rowLines={true} + loading={false} + compact + columns={[ + { accessor: 'name', title: 'Name' }, + { + accessor: 'actions', + title: 'Actions', + render: (record) => ( +
+ { + confirmDeleteFile(record) + }} + > + Delete + +
+ ), + }, + ]} + data={props.maps.regionFiles || []} + /> +
+
+
+ ) +} diff --git a/admin/inertia/pages/settings/system.tsx b/admin/inertia/pages/settings/system.tsx index 4d45abf..724a593 100644 --- a/admin/inertia/pages/settings/system.tsx +++ b/admin/inertia/pages/settings/system.tsx @@ -28,7 +28,7 @@ export default function SettingsPage(props: { }) { return ( - +

System Information

diff --git a/admin/inertia/pages/settings/zim/index.tsx b/admin/inertia/pages/settings/zim/index.tsx index c97b622..1acbc08 100644 --- a/admin/inertia/pages/settings/zim/index.tsx +++ b/admin/inertia/pages/settings/zim/index.tsx @@ -1,20 +1,20 @@ -import { Head, Link } from '@inertiajs/react' +import { Head } from '@inertiajs/react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import StyledTable from '~/components/StyledTable' import SettingsLayout from '~/layouts/SettingsLayout' -import { ZimFilesEntry } from '../../../../types/zim' import api from '~/lib/api' import StyledButton from '~/components/StyledButton' import { useModals } from '~/context/ModalContext' import StyledModal from '~/components/StyledModal' import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus' import Alert from '~/components/Alert' +import { FileEntry } from '../../../../types/files' export default function ZimPage() { const queryClient = useQueryClient() const { openModal, closeAllModals } = useModals() const { isInstalled } = useServiceInstalledStatus('nomad_kiwix_serve') - const { data, isLoading } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ['zim-files'], queryFn: getFiles, }) @@ -24,7 +24,7 @@ export default function ZimPage() { return res.data.files } - async function confirmDeleteFile(file: ZimFilesEntry) { + async function confirmDeleteFile(file: FileEntry) { openModal( api.deleteZimFile(file.name.replace('.zim', '')), + mutationFn: async (file: FileEntry) => api.deleteZimFile(file.name.replace('.zim', '')), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['zim-files'] }) }, @@ -73,7 +73,7 @@ export default function ZimPage() { className="!mt-6" /> )} - + className="font-semibold mt-4" rowLines={true} loading={isLoading} diff --git a/admin/inertia/providers/NotificationProvider.tsx b/admin/inertia/providers/NotificationProvider.tsx index e12df7d..aa5d888 100644 --- a/admin/inertia/providers/NotificationProvider.tsx +++ b/admin/inertia/providers/NotificationProvider.tsx @@ -64,7 +64,7 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => {
-

{notification.message}

+

{notification.message}

diff --git a/admin/package-lock.json b/admin/package-lock.json index 7687ab9..9d31116 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -25,6 +25,7 @@ "@heroicons/react": "^2.2.0", "@inertiajs/react": "^2.0.13", "@markdoc/markdoc": "^0.5.2", + "@protomaps/basemaps": "^5.7.0", "@tabler/icons-react": "^3.34.0", "@tailwindcss/vite": "^4.1.10", "@tanstack/react-query": "^5.81.5", @@ -39,15 +40,21 @@ "edge.js": "^6.2.1", "fast-xml-parser": "^5.2.5", "luxon": "^3.6.1", + "maplibre-gl": "^4.7.1", "mysql2": "^3.14.1", "pino-pretty": "^13.0.0", + "pmtiles": "^4.3.0", "postcss": "^8.5.6", "react": "^19.1.0", "react-adonis-transmit": "^1.0.1", "react-dom": "^19.1.0", + "react-map-gl": "^8.1.0", "reflect-metadata": "^0.2.2", "systeminformation": "^5.27.7", "tailwindcss": "^4.1.10", + "tar": "^7.5.2", + "url-join": "^5.0.0", + "usehooks-ts": "^3.1.1", "yaml": "^2.8.0" }, "devDependencies": { @@ -2334,6 +2341,101 @@ "node": ">=8" } }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, "node_modules/@markdoc/markdoc": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@markdoc/markdoc/-/markdoc-0.5.2.tgz", @@ -2702,6 +2804,15 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@protomaps/basemaps": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@protomaps/basemaps/-/basemaps-5.7.0.tgz", + "integrity": "sha512-vIInnzVSxHuOcvj1BFGkCjlFxG/9a1GV23t98kGEVcPUM7aEqTnf6loUHTRJYX5eCz+WCO16N0aibr1SLg830Q==", + "license": "BSD-3-Clause", + "bin": { + "generate_style": "src/cli.ts" + } + }, "node_modules/@react-aria/focus": { "version": "3.20.5", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.5.tgz", @@ -3967,6 +4078,21 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/he": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz", @@ -3994,6 +4120,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, "node_modules/@types/markdown-it": { "version": "12.2.3", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", @@ -4028,6 +4171,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/pluralize": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz", @@ -4087,6 +4236,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/validator": { "version": "13.15.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", @@ -4381,6 +4539,66 @@ "node": ">=18.16.0" } }, + "node_modules/@vis.gl/react-mapbox": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz", + "integrity": "sha512-FwvH822oxEjWYOr+pP2L8hpv+7cZB2UsQbHHHT0ryrkvvqzmTgt7qHDhamv0EobKw86e1I+B4ojENdJ5G5BkyQ==", + "license": "MIT", + "peerDependencies": { + "mapbox-gl": ">=3.5.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.0.tgz", + "integrity": "sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1" + }, + "peerDependencies": { + "maplibre-gl": ">=4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", @@ -4555,6 +4773,15 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/as-table": { "version": "1.0.55", "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", @@ -4583,6 +4810,15 @@ "node": ">=12" } }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -4871,6 +5107,25 @@ "node": ">= 0.8" } }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -5729,6 +5984,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/edge-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/edge-error/-/edge-error-4.0.2.tgz", @@ -6390,6 +6651,18 @@ "node": ">=6" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-copy": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", @@ -6921,6 +7194,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -7028,6 +7307,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/getopts": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", @@ -7040,6 +7328,12 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7052,6 +7346,53 @@ "node": ">=10.13.0" } }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -7450,6 +7791,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7505,6 +7855,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -7544,6 +7906,15 @@ "devOptional": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jest-diff": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", @@ -7646,6 +8017,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7680,6 +8057,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7690,6 +8073,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -8071,6 +8463,12 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8239,6 +8637,47 @@ "dev": true, "license": "ISC" }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/matchit": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/matchit/-/matchit-1.1.0.tgz", @@ -8422,9 +8861,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -8437,6 +8876,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "devOptional": true, "license": "MIT", "bin": { "mkdirp": "dist/cjs/src/bin.js" @@ -8460,6 +8900,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -8954,6 +9400,19 @@ "node": ">= 14.16" } }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", @@ -9097,6 +9556,15 @@ "node": ">=4" } }, + "node_modules/pmtiles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.0.tgz", + "integrity": "sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -9131,6 +9599,12 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -9318,6 +9792,12 @@ "node": ">=12.0.0" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9416,6 +9896,12 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -9563,6 +10049,30 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-map-gl": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz", + "integrity": "sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg==", + "license": "MIT", + "dependencies": { + "@vis.gl/react-mapbox": "8.1.0", + "@vis.gl/react-maplibre": "8.1.0" + }, + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -9753,6 +10263,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -9859,6 +10378,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -10012,6 +10537,21 @@ "node": ">= 0.8.0" } }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10234,6 +10774,41 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10306,6 +10881,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -10509,6 +11121,15 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.0.0.tgz", @@ -10597,16 +11218,15 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "license": "ISC", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -10746,6 +11366,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tmp-cache": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/tmp-cache/-/tmp-cache-1.1.0.tgz", @@ -10977,6 +11603,21 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -11033,6 +11674,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -11082,6 +11738,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", @@ -11091,6 +11756,21 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11222,6 +11902,17 @@ "vite": "^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/admin/package.json b/admin/package.json index 4d37397..8d03582 100644 --- a/admin/package.json +++ b/admin/package.json @@ -71,6 +71,7 @@ "@heroicons/react": "^2.2.0", "@inertiajs/react": "^2.0.13", "@markdoc/markdoc": "^0.5.2", + "@protomaps/basemaps": "^5.7.0", "@tabler/icons-react": "^3.34.0", "@tailwindcss/vite": "^4.1.10", "@tanstack/react-query": "^5.81.5", @@ -85,15 +86,21 @@ "edge.js": "^6.2.1", "fast-xml-parser": "^5.2.5", "luxon": "^3.6.1", + "maplibre-gl": "^4.7.1", "mysql2": "^3.14.1", "pino-pretty": "^13.0.0", + "pmtiles": "^4.3.0", "postcss": "^8.5.6", "react": "^19.1.0", "react-adonis-transmit": "^1.0.1", "react-dom": "^19.1.0", + "react-map-gl": "^8.1.0", "reflect-metadata": "^0.2.2", "systeminformation": "^5.27.7", "tailwindcss": "^4.1.10", + "tar": "^7.5.2", + "url-join": "^5.0.0", + "usehooks-ts": "^3.1.1", "yaml": "^2.8.0" }, "hotHook": { diff --git a/admin/start/env.ts b/admin/start/env.ts index 43be42f..4cb7593 100644 --- a/admin/start/env.ts +++ b/admin/start/env.ts @@ -16,6 +16,7 @@ export default await Env.create(new URL('../', import.meta.url), { PORT: Env.schema.number(), APP_KEY: Env.schema.string(), HOST: Env.schema.string({ format: 'host' }), + URL: Env.schema.string(), LOG_LEVEL: Env.schema.string(), /* diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 9c9054b..0cbcfb2 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -8,50 +8,75 @@ */ import DocsController from '#controllers/docs_controller' import HomeController from '#controllers/home_controller' +import MapsController from '#controllers/maps_controller' import SettingsController from '#controllers/settings_controller' import SystemController from '#controllers/system_controller' import ZimController from '#controllers/zim_controller' import router from '@adonisjs/core/services/router' import transmit from '@adonisjs/transmit/services/main' -transmit.registerRoutes(); +transmit.registerRoutes() -router.get('/', [HomeController, 'index']); -router.get('/home', [HomeController, 'home']); +router.get('/', [HomeController, 'index']) +router.get('/home', [HomeController, 'home']) router.on('/about').renderInertia('about') -router.group(() => { +router + .group(() => { router.get('/system', [SettingsController, 'system']) router.get('/apps', [SettingsController, 'apps']) router.get('/legal', [SettingsController, 'legal']) + router.get('/maps', [SettingsController, 'maps']) router.get('/zim', [SettingsController, 'zim']) router.get('/zim/remote-explorer', [SettingsController, 'zimRemote']) -}).prefix('/settings') + }) + .prefix('/settings') -router.group(() => { +router + .group(() => { router.get('/:slug', [DocsController, 'show']) router.get('/', ({ inertia }) => { - return inertia.render('Docs/Index', { - title: "Documentation", - content: "Welcome to the documentation!" - }); - }); -}).prefix('/docs') + return inertia.render('Docs/Index', { + title: 'Documentation', + content: 'Welcome to the documentation!', + }) + }) + }) + .prefix('/docs') -router.group(() => { +router.get('/maps', [MapsController, 'index']) + +router + .group(() => { + router.get('/regions', [MapsController, 'listRegions']) + router.get('/styles', [MapsController, 'styles']) + router.get('/preflight', [MapsController, 'checkBaseAssets']) + router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']) + router.post('/download-remote', [MapsController, 'downloadRemote']) + router.delete('/:filename', [MapsController, 'delete']) + }) + .prefix('/api/maps') + +router + .group(() => { router.get('/list', [DocsController, 'list']) -}).prefix('/api/docs') + }) + .prefix('/api/docs') -router.group(() => { +router + .group(() => { router.get('/info', [SystemController, 'getSystemInfo']) router.get('/services', [SystemController, 'getServices']) router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/install', [SystemController, 'installService']) -}).prefix('/api/system') + }) + .prefix('/api/system') -router.group(() => { +router + .group(() => { router.get('/list', [ZimController, 'list']) router.get('/list-remote', [ZimController, 'listRemote']) router.post('/download-remote', [ZimController, 'downloadRemote']) - router.delete('/:key', [ZimController, 'delete']) -}).prefix('/api/zim') \ No newline at end of file + router.delete('/:filename', [ZimController, 'delete']) + }) + .prefix('/api/zim') diff --git a/admin/types/downloads.ts b/admin/types/downloads.ts new file mode 100644 index 0000000..e4e1f96 --- /dev/null +++ b/admin/types/downloads.ts @@ -0,0 +1,32 @@ +export type DoResumableDownloadParams = { + url: string + path: string + timeout: number + allowedMimeTypes: string[] + signal?: AbortSignal + onProgress?: (progress: DoResumableDownloadProgress) => void + forceNew?: boolean +} + +export type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & { + max_retries?: number + retry_delay?: number + onAttemptError?: (error: Error, attempt: number) => void +} + +export type DoResumableDownloadProgress = { + downloadedBytes: number + totalBytes: number + lastProgressTime: number + lastDownloadedBytes: number + url: string +} + +export type DoBackgroundDownloadParams = Omit< + DoResumableDownloadWithRetryParams, + 'onProgress' | 'onAttemptError' | 'signal' +> & { + channel: string + activeDownloads: Map + onComplete?: (url: string, path: string) => void | Promise +} diff --git a/admin/types/files.ts b/admin/types/files.ts new file mode 100644 index 0000000..d3a8aa6 --- /dev/null +++ b/admin/types/files.ts @@ -0,0 +1,30 @@ +/* General file transfer/download utility types */ + +export type FileEntry = + | { + type: 'file' + key: string + name: string + } + | { + type: 'directory' + prefix: string + name: string + } + +export type DownloadProgress = { + downloaded_bytes: number + total_bytes: number + percentage: number + speed: string + time_remaining: number +} + +export type DownloadOptions = { + max_retries?: number + retry_delay?: number + chunk_size?: number + timeout?: number + onError?: (error: Error) => void + onComplete?: (filepath: string) => void +} diff --git a/admin/types/maps.ts b/admin/types/maps.ts new file mode 100644 index 0000000..f907126 --- /dev/null +++ b/admin/types/maps.ts @@ -0,0 +1,23 @@ +export type BaseStylesFile = { + version: number + sources: { + [key: string]: MapSource + } + layers: MapLayer[] + sprite: string + glyphs: string +} + +export type MapSource = { + type: 'vector' | 'raster' | 'raster-dem' | 'geojson' | 'image' | 'video' + attribution?: string + url: string +} + +export type MapLayer = { + 'id': string + 'type': string + 'source'?: string + 'source-layer'?: string + [key: string]: any +} diff --git a/admin/types/zim.ts b/admin/types/zim.ts index 1f7b9f4..33be262 100644 --- a/admin/types/zim.ts +++ b/admin/types/zim.ts @@ -1,16 +1,7 @@ -export type ZimFilesEntry = - { - type: 'file' - key: string; - name: string; - } | { - type: 'directory'; - prefix: string; - name: string; - } +import { FileEntry } from './files.js' export type ListZimFilesResponse = { - files: ZimFilesEntry[] + files: FileEntry[] next?: string } @@ -65,21 +56,4 @@ export type RemoteZimFileEntry = { download_url: string; author: string; file_name: string; -} - -export type DownloadProgress = { - downloaded_bytes: number; - total_bytes: number; - percentage: number; - speed: string; - time_remaining: number; -} - -export type DownloadOptions = { - max_retries?: number; - retry_delay?: number; - chunk_size?: number; - timeout?: number; - onError?: (error: Error) => void; - onComplete?: (filepath: string) => void; } \ No newline at end of file