feat(Collections): map region collections

This commit is contained in:
Jake Turner 2025-12-21 16:14:43 -08:00 committed by Jake Turner
parent f618512ad1
commit 6ac9d147cf
12 changed files with 508 additions and 232 deletions

View File

@ -1,5 +1,6 @@
import { MapService } from '#services/map_service'
import {
downloadCollectionValidator,
filenameParamValidator,
remoteDownloadValidator,
remoteDownloadValidatorOptional,
@ -36,6 +37,16 @@ export default class MapsController {
}
}
async downloadCollection({ request }: HttpContext) {
const payload = await request.validateUsing(downloadCollectionValidator)
const resources = await this.mapService.downloadCollection(payload.slug)
return {
message: 'Collection download started successfully',
slug: payload.slug,
resources,
}
}
// For providing a "preflight" check in the UI before actually starting a background download
async downloadRemotePreflight({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadValidator)
@ -43,6 +54,15 @@ export default class MapsController {
return info
}
async fetchLatestCollections({}: HttpContext) {
const success = await this.mapService.fetchLatestCollections()
return { success }
}
async listCuratedCollections({}: HttpContext) {
return await this.mapService.listCuratedCollections()
}
async listRegions({}: HttpContext) {
return await this.mapService.listRegions()
}

View File

@ -36,12 +36,12 @@ export default class ZimController {
async downloadCollection({ request }: HttpContext) {
const payload = await request.validateUsing(downloadCollectionValidator)
const resource_count = await this.zimService.downloadCollection(payload.slug)
const resources = await this.zimService.downloadCollection(payload.slug)
return {
message: 'Download started successfully',
slug: payload.slug,
resource_count,
resources,
}
}

View File

@ -5,6 +5,7 @@ import { doResumableDownload } from '../utils/downloads.js'
import { createHash } from 'crypto'
import { DockerService } from '#services/docker_service'
import { ZimService } from '#services/zim_service'
import { MapService } from '#services/map_service'
export class RunDownloadJob {
static get queue() {
@ -23,9 +24,9 @@ export class RunDownloadJob {
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype } =
job.data as RunDownloadJobParams
// console.log("Simulating delay for job for URL:", url)
// await new Promise((resolve) => setTimeout(resolve, 30000)) // Simulate initial delay
// console.log("Starting download for URL:", url)
// console.log("Simulating delay for job for URL:", url)
// await new Promise((resolve) => setTimeout(resolve, 30000)) // Simulate initial delay
// console.log("Starting download for URL:", url)
// // simulate progress updates for demonstration
// for (let progress = 0; progress <= 100; progress += 10) {
@ -45,17 +46,20 @@ export class RunDownloadJob {
job.updateProgress(Math.floor(progressPercent))
},
async onComplete(url) {
if (filetype === 'zim') {
try {
try {
if (filetype === 'zim') {
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true)
} catch (error) {
console.error(
`[RunDownloadJob] Error in ZIM download success callback for URL ${url}:`,
error
)
} else if (filetype === 'map') {
const mapsService = new MapService()
await mapsService.downloadRemoteSuccessCallback([url], false)
}
} catch (error) {
console.error(
`[RunDownloadJob] Error in ZIM download success callback for URL ${url}:`,
error
)
}
job.updateProgress(100)
},

View File

@ -1,5 +1,9 @@
import { BaseStylesFile, MapLayer } from '../../types/maps.js'
import { FileEntry } from '../../types/files.js'
import {
DownloadCollectionOperation,
DownloadRemoteSuccessCallback,
FileEntry,
} from '../../types/files.js'
import { doResumableDownloadWithRetry } from '../utils/downloads.js'
import { extract } from 'tar'
import env from '#start/env'
@ -16,6 +20,11 @@ import urlJoin from 'url-join'
import axios from 'axios'
import { RunDownloadJob } from '#jobs/run_download_job'
import logger from '@adonisjs/core/services/logger'
import { CuratedCollectionsFile, CuratedCollectionWithStatus } from '../../types/downloads.js'
import CuratedCollection from '#models/curated_collection'
import vine from '@vinejs/vine'
import { curatedCollectionsFileSchema } from '#validators/curated_collections'
import CuratedCollectionResource from '#models/curated_collection_resource'
const BASE_ASSETS_MIME_TYPES = [
'application/gzip',
@ -23,11 +32,19 @@ const BASE_ASSETS_MIME_TYPES = [
'application/octet-stream',
]
const COLLECTIONS_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json'
const PMTILES_ATTRIBUTION =
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
const PMTILES_MIME_TYPES = ['application/vnd.pmtiles', 'application/octet-stream']
export class MapService {
interface IMapService {
downloadCollection: DownloadCollectionOperation
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
}
export class MapService implements IMapService {
private readonly mapStoragePath = '/storage/maps'
private readonly baseStylesFile = 'nomad-base-styles.json'
private readonly basemapsAssetsDir = 'basemaps-assets'
@ -80,6 +97,62 @@ export class MapService {
return true
}
async downloadCollection(slug: string) {
const collection = await CuratedCollection.query()
.where('slug', slug)
.andWhere('type', 'map')
.first()
if (!collection) {
return null
}
const resources = await collection.related('resources').query().where('downloaded', false)
if (resources.length === 0) {
return null
}
const downloadUrls = resources.map((res) => res.url)
const downloadFilenames: string[] = []
for (const url of downloadUrls) {
const existing = await RunDownloadJob.getByUrl(url)
if (existing) {
logger.warn(`[MapService] Download already in progress for URL ${url}, skipping.`)
continue
}
// Extract the filename from the URL
const filename = url.split('/').pop()
if (!filename) {
logger.warn(`[MapService] Could not determine filename from URL ${url}, skipping.`)
continue
}
downloadFilenames.push(filename)
const filepath = join(process.cwd(), this.mapStoragePath, 'pmtiles', filename)
await RunDownloadJob.dispatch({
url,
filepath,
timeout: 30000,
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true,
filetype: 'map',
})
}
return downloadFilenames.length > 0 ? downloadFilenames : null
}
async downloadRemoteSuccessCallback(urls: string[], _: boolean) {
const resources = await CuratedCollectionResource.query().whereIn('url', urls)
for (const resource of resources) {
resource.downloaded = true
await resource.save()
logger.info(`[MapService] Marked resource as downloaded: ${resource.url}`)
}
}
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> {
const parsed = new URL(url)
if (!parsed.pathname.endsWith('.pmtiles')) {
@ -105,7 +178,7 @@ export class MapService {
timeout: 30000,
allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true,
filetype: 'pmtiles',
filetype: 'map',
})
if (!result.job) {
@ -193,6 +266,47 @@ export class MapService {
return !!baseStyleItem && !!basemapsAssetsItem
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
const collections = await CuratedCollection.query().where('type', 'map').preload('resources')
return collections.map((collection) => ({
...(collection.serialize() as CuratedCollection),
all_downloaded: collection.resources.every((res) => res.downloaded),
}))
}
async fetchLatestCollections(): Promise<boolean> {
try {
const response = await axios.get<CuratedCollectionsFile>(COLLECTIONS_URL)
const validated = await vine.validate({
schema: curatedCollectionsFileSchema,
data: response.data,
})
for (const collection of validated.collections) {
const collectionResult = await CuratedCollection.updateOrCreate(
{ slug: collection.slug },
{
...collection,
type: 'map',
}
)
logger.info(`[MapService] Upserted curated collection: ${collection.slug}`)
await collectionResult.related('resources').createMany(collection.resources)
logger.info(
`[MapService] Upserted ${collection.resources.length} resources for collection: ${collection.slug}`
)
}
return true
} catch (error) {
console.error(error)
logger.error(`[MapService] Failed to download latest Kiwix collections:`, error)
return false
}
}
private async listMapStorageItems(): Promise<FileEntry[]> {
await ensureDirectoryExists(this.baseDirPath)
return await listDirectoryContents(this.baseDirPath)

View File

@ -23,13 +23,20 @@ import { curatedCollectionsFileSchema } from '#validators/curated_collections'
import CuratedCollection from '#models/curated_collection'
import CuratedCollectionResource from '#models/curated_collection_resource'
import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadCollectionOperation, DownloadRemoteSuccessCallback } from '../../types/files.js'
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
const COLLECTIONS_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/kiwix.json'
interface IZimService {
downloadCollection: DownloadCollectionOperation
downloadRemoteSuccessCallback: DownloadRemoteSuccessCallback
}
@inject()
export class ZimService {
export class ZimService implements IZimService {
constructor(private dockerService: DockerService) {}
async list() {
@ -176,8 +183,8 @@ export class ZimService {
}
}
async downloadCollection(slug: string): Promise<string[] | null> {
const collection = await CuratedCollection.find(slug)
async downloadCollection(slug: string) {
const collection = await CuratedCollection.query().where('slug', slug).andWhere('type', 'zim').first()
if (!collection) {
return null
}
@ -218,7 +225,7 @@ export class ZimService {
}
return downloadFilenames.length > 0 ? downloadFilenames : null
}
}
async downloadRemoteSuccessCallback(urls: string[], restart = true) {
// Restart KIWIX container to pick up new ZIM file
@ -239,7 +246,7 @@ export class ZimService {
}
async listCuratedCollections(): Promise<CuratedCollectionWithStatus[]> {
const collections = await CuratedCollection.query().preload('resources')
const collections = await CuratedCollection.query().where('type', 'zim').preload('resources')
return collections.map((collection) => ({
...(collection.serialize() as CuratedCollection),
all_downloaded: collection.resources.every((res) => res.downloaded),

View File

@ -4,6 +4,7 @@ import { ServiceSlim } from '../../types/services'
import { FileEntry } from '../../types/files'
import { SystemInformationResponse } from '../../types/system'
import { CuratedCollectionWithStatus, DownloadJobWithProgress } from '../../types/downloads'
import { catchInternal } from './util'
class API {
private client: AxiosInstance
@ -17,99 +18,134 @@ class API {
})
}
async listDocs() {
try {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/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 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 downloadRemoteMapRegion(url: string) {
try {
const response = await this.client.post<{ message: string; filename: string; url: string }>(
'/maps/download-remote',
{ url }
)
return response.data
} catch (error) {
console.error('Error downloading remote map region:', error)
throw error
}
}
async downloadRemoteMapRegionPreflight(url: string) {
try {
const response = await this.client.post<
{ filename: string; size: number } | { message: string }
>('/maps/download-remote-preflight', { url })
return response.data
} catch (error) {
console.error('Error preflighting remote map region download:', error)
throw error
}
}
async listServices() {
try {
const response = await this.client.get<Array<ServiceSlim>>('/system/services')
return response.data
} catch (error) {
console.error('Error listing services:', 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 affectService(service_name: string, action: 'start' | 'stop' | 'restart') {
try {
return catchInternal(async () => {
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 listZimFiles() {
return await this.client.get<ListZimFilesResponse>('/zim/list')
async downloadBaseMapAssets() {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean }>('/maps/download-base-assets')
return response.data
})()
}
async downloadMapCollection(slug: string): Promise<{
message: string
slug: string
resources: string[] | null
}> {
return catchInternal(async () => {
const response = await this.client.post('/maps/download-collection', { slug })
return response.data
})()
}
async downloadZimCollection(slug: string): Promise<{
message: string
slug: string
resources: string[] | null
}> {
return catchInternal(async () => {
const response = await this.client.post('/zim/download-collection', { slug })
return response.data
})()
}
async downloadRemoteMapRegion(url: string) {
return catchInternal(async () => {
const response = await this.client.post<{ message: string; filename: string; url: string }>(
'/maps/download-remote',
{ url }
)
return response.data
})()
}
async downloadRemoteMapRegionPreflight(url: string) {
return catchInternal(async () => {
const response = await this.client.post<
{ filename: string; size: number } | { message: string }
>('/maps/download-remote-preflight', { url })
return response.data
})()
}
async fetchLatestMapCollections(): Promise<{ success: boolean } | undefined> {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean }>(
'/maps/fetch-latest-collections'
)
return response.data
})()
}
async fetchLatestZimCollections(): Promise<{ success: boolean } | undefined> {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean }>('/zim/fetch-latest-collections')
return response.data
})()
}
async getSystemInfo() {
return catchInternal(async () => {
const response = await this.client.get<SystemInformationResponse>('/system/info')
return response.data
})()
}
async installService(service_name: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/install',
{ service_name }
)
return response.data
})()
}
async listCuratedMapCollections() {
return catchInternal(async () => {
const response = await this.client.get<CuratedCollectionWithStatus[]>(
'/maps/curated-collections'
)
return response.data
})()
}
async listCuratedZimCollections() {
return catchInternal(async () => {
const response = await this.client.get<CuratedCollectionWithStatus[]>(
'/zim/curated-collections'
)
return response.data
})()
}
async listDocs() {
return catchInternal(async () => {
const response = await this.client.get<Array<{ title: string; slug: string }>>('/docs/list')
return response.data
})()
}
async listMapRegionFiles() {
return catchInternal(async () => {
const response = await this.client.get<{ files: FileEntry[] }>('/maps/regions')
return response.data.files
})()
}
async listServices() {
return catchInternal(async () => {
const response = await this.client.get<Array<ServiceSlim>>('/system/services')
return response.data
})()
}
async listRemoteZimFiles({
@ -121,94 +157,29 @@ class API {
count?: number
query?: string
}) {
return await this.client.get<ListRemoteZimFilesResponse>('/zim/list-remote', {
params: {
start,
count,
query,
},
})
return catchInternal(async () => {
return await this.client.get<ListRemoteZimFilesResponse>('/zim/list-remote', {
params: {
start,
count,
query,
},
})
})()
}
async listCuratedZimCollections() {
try {
const response = await this.client.get<CuratedCollectionWithStatus[]>(
'/zim/curated-collections'
)
return response.data
} catch (error) {
console.error('Error listing curated ZIM collections:', error)
throw error
}
async listZimFiles() {
return catchInternal(async () => {
return await this.client.get<ListZimFilesResponse>('/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 downloadZimCollection(slug: string): Promise<{
message: string
slug: string
resource_count: number
}> {
try {
const response = await this.client.post('/zim/download-collection', { slug })
return response.data
} catch (error) {
console.error('Error downloading ZIM collection:', error)
throw error
}
}
async fetchLatestZimCollections(): Promise<{ success: boolean }> {
try {
const response = await this.client.post<{ success: boolean }>('/zim/fetch-latest-collections')
return response.data
} catch (error) {
console.error('Error fetching latest ZIM collections:', 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
}
}
async getSystemInfo() {
try {
const response = await this.client.get<SystemInformationResponse>('/system/info')
return response.data
} catch (error) {
console.error('Error fetching system info:', error)
throw error
}
}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
try {
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[] | undefined> {
return catchInternal(async () => {
const endpoint = filetype ? `/downloads/jobs/${filetype}` : '/downloads/jobs'
const response = await this.client.get<DownloadJobWithProgress[]>(endpoint)
return response.data
} catch (error) {
console.error('Error listing download jobs:', error)
throw error
}
})()
}
}

View File

@ -1,55 +1,87 @@
import axios from "axios";
import axios from 'axios'
export function capitalizeFirstLetter(str?: string | null): string {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
if (!str) return ''
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export async function testInternetConnection(): Promise<boolean> {
try {
const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', {
timeout: 5000,
});
return response.status === 200;
})
return response.status === 200
} catch (error) {
console.error("Error testing internet connection:", error);
return false;
console.error('Error testing internet connection:', error)
return false
}
}
export function generateRandomString(length: number): string {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
result += characters.charAt(Math.floor(Math.random() * characters.length))
}
return result;
return result
}
export function generateUUID(): string {
const arr = new Uint8Array(16);
const arr = new Uint8Array(16)
if (window.crypto && window.crypto.getRandomValues) {
window.crypto.getRandomValues(arr);
window.crypto.getRandomValues(arr)
} else {
// Fallback for non-secure contexts where window.crypto is not available
// This is not cryptographically secure, but can be used for non-critical purposes
for (let i = 0; i < 16; i++) {
arr[i] = Math.floor(Math.random() * 256);
arr[i] = Math.floor(Math.random() * 256)
}
}
arr[6] = (arr[6] & 0x0f) | 0x40; // Version 4
arr[8] = (arr[8] & 0x3f) | 0x80; // Variant bits
arr[6] = (arr[6] & 0x0f) | 0x40 // Version 4
arr[8] = (arr[8] & 0x3f) | 0x80 // Variant bits
const hex = Array.from(arr, byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
const hex = Array.from(arr, (byte) => byte.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/**
* Extracts the file name from a given path while handling both forward and backward slashes.
* @param path The full file path.
* @returns The extracted file name.
*/
export const extractFileName = (path: string) => {
if (!path) return ''
if (path.includes('/')) {
return path.substring(path.lastIndexOf('/') + 1)
}
if (path.includes('\\')) {
return path.substring(path.lastIndexOf('\\') + 1)
}
return path
}
/**
* A higher-order function that wraps an asynchronous function to catch and log internal errors.
* @param fn The asynchronous function to be wrapped.
* @returns A new function that executes the original function and logs any errors. Returns undefined in case of an error.
*/
export function catchInternal<Fn extends (...args: any[]) => any>(fn: Fn): (...args: Parameters<Fn>) => Promise<ReturnType<Fn> | undefined> {
return async (...args: any[]) => {
try {
return await fn(...args)
} catch (error) {
console.error('Internal error caught:', error)
return undefined
}
}
}

View File

@ -10,19 +10,44 @@ import { useNotifications } from '~/context/NotificationContext'
import { useState } from 'react'
import api from '~/lib/api'
import DownloadURLModal from '~/components/DownloadURLModal'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import useDownloads from '~/hooks/useDownloads'
import StyledSectionHeader from '~/components/StyledSectionHeader'
import HorizontalBarChart from '~/components/HorizontalBarChart'
import { extractFileName } from '~/lib/util'
import CuratedCollectionCard from '~/components/CuratedCollectionCard'
import { CuratedCollectionWithStatus } from '../../../types/downloads'
const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
export default function MapsManager(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
}) {
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const { addNotification } = useNotifications()
const [downloading, setDownloading] = useState(false)
const { data: curatedCollections } = useQuery({
queryKey: [CURATED_COLLECTIONS_KEY],
queryFn: () => api.listCuratedMapCollections(),
refetchOnWindowFocus: false,
})
const { data: downloads, invalidate: invalidateDownloads } = useDownloads({
filetype: 'map',
enabled: true,
})
async function downloadBaseAssets() {
try {
setDownloading(true)
const res = await api.downloadBaseMapAssets()
if (!res) {
throw new Error('An unknown error occurred while downloading base assets.')
}
if (res.success) {
addNotification({
type: 'success',
@ -41,6 +66,24 @@ export default function MapsManager(props: {
}
}
async function downloadFile(record: string) {
try {
//await api.downloadRemoteZimFile(record.download_url)
invalidateDownloads()
} catch (error) {
console.error('Error downloading file:', error)
}
}
async function downloadCollection(record: CuratedCollectionWithStatus) {
try {
await api.downloadMapCollection(record.slug)
invalidateDownloads()
} catch (error) {
console.error('Error downloading collection:', error)
}
}
async function confirmDeleteFile(file: FileEntry) {
openModal(
<StyledModal
@ -62,6 +105,42 @@ export default function MapsManager(props: {
)
}
async function confirmDownload(record: CuratedCollectionWithStatus) {
const isCollection = 'resources' in record
openModal(
<StyledModal
title="Confirm Download?"
onConfirm={() => {
if (isCollection) {
if (record.all_downloaded) {
addNotification({
message: `All resources in the collection "${record.name}" have already been downloaded.`,
type: 'info',
})
return
}
downloadCollection(record)
} else {
downloadFile(record)
}
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Download"
cancelText="Cancel"
confirmVariant="primary"
>
<p className="text-gray-700">
Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?
It may take some time for it to be available depending on the file size and your internet
connection.
</p>
</StyledModal>,
'confirm-download-file-modal'
)
}
async function openDownloadModal() {
openModal(
<DownloadURLModal
@ -73,6 +152,17 @@ export default function MapsManager(props: {
)
}
const fetchLatestCollections = useMutation({
mutationFn: () => api.fetchLatestMapCollections(),
onSuccess: () => {
addNotification({
message: 'Successfully fetched the latest map collections.',
type: 'success',
})
queryClient.invalidateQueries({ queryKey: [CURATED_COLLECTIONS_KEY] })
},
})
return (
<SettingsLayout>
<Head title="Maps Manager" />
@ -81,20 +171,40 @@ export default function MapsManager(props: {
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Maps Manager</h1>
<p className="text-gray-500">Manage your stored map data files.</p>
<p className="text-gray-500">Manage your stored map files and explore new regions!</p>
</div>
<div className="flex space-x-4">
<StyledButton
variant="primary"
onClick={openDownloadModal}
loading={downloading}
icon="CloudArrowDownIcon"
>
Download Custom Map File
</StyledButton>
<StyledButton
onClick={() => fetchLatestCollections.mutate()}
disabled={fetchLatestCollections.isPending}
icon="CloudArrowDownIcon"
>
Fetch Latest Collections
</StyledButton>
</div>
<StyledButton
variant="primary"
onClick={openDownloadModal}
loading={downloading}
icon="CloudArrowDownIcon"
>
Download New Map File
</StyledButton>
</div>
{!props.maps.baseAssetsExist && (
<MissingBaseAssetsAlert loading={downloading} onClickDownload={downloadBaseAssets} />
)}
<StyledSectionHeader title="Curated Map Collections" className="mt-8 mb-4" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{curatedCollections?.map((collection) => (
<CuratedCollectionCard
key={collection.slug}
collection={collection}
onClick={(collection) => confirmDownload(collection)}
/>
))}
</div>
<StyledSectionHeader title="Stored Map Files" className="mt-12 mb-4" />
<StyledTable<FileEntry & { actions?: any }>
className="font-semibold mt-4"
rowLines={true}
@ -122,6 +232,28 @@ export default function MapsManager(props: {
]}
data={props.maps.regionFiles || []}
/>
<StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />
<div className="space-y-4">
{downloads && downloads.length > 0 ? (
downloads.map((download) => (
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<HorizontalBarChart
items={[
{
label: extractFileName(download.filepath) || download.url,
value: download.progress,
total: '100%',
used: `${download.progress}%`,
type: download.filetype,
},
]}
/>
</div>
))
) : (
<p className="text-gray-500">No active downloads</p>
)}
</div>
</main>
</div>
</SettingsLayout>

View File

@ -12,7 +12,7 @@ import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { Head } from '@inertiajs/react'
import { ListRemoteZimFilesResponse, RemoteZimFileEntry } from '../../../../types/zim'
import { formatBytes } from '~/lib/util'
import { extractFileName, formatBytes } from '~/lib/util'
import StyledButton from '~/components/StyledButton'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
@ -188,17 +188,6 @@ export default function ZimRemoteExplorer() {
},
})
const extractFileName = (path: string) => {
if (!path) return ''
if (path.includes('/')) {
return path.substring(path.lastIndexOf('/') + 1)
}
if (path.includes('\\')) {
return path.substring(path.lastIndexOf('\\') + 1)
}
return path
}
return (
<SettingsLayout>
<Head title="ZIM Remote Explorer | Project N.O.M.A.D." />

View File

@ -12,7 +12,8 @@
"test": "node ace test",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"work:downloads": "node ace queue:work --queue=downloads"
},
"imports": {
"#controllers/*": "./app/controllers/*.js",

View File

@ -52,9 +52,12 @@ router
router.get('/regions', [MapsController, 'listRegions'])
router.get('/styles', [MapsController, 'styles'])
router.get('/preflight', [MapsController, 'checkBaseAssets'])
router.get('/curated-collections', [MapsController, 'listCuratedCollections'])
router.post('/fetch-latest-collections', [MapsController, 'fetchLatestCollections'])
router.post('/download-base-assets', [MapsController, 'downloadBaseAssets'])
router.post('/download-remote', [MapsController, 'downloadRemote'])
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
router.post('/download-collection', [MapsController, 'downloadCollection'])
router.delete('/:filename', [MapsController, 'delete'])
})
.prefix('/api/maps')

View File

@ -28,3 +28,6 @@ export type DownloadOptions = {
onError?: (error: Error) => void
onComplete?: (filepath: string) => void
}
export type DownloadCollectionOperation = (slug: string) => Promise<string[] | null>
export type DownloadRemoteSuccessCallback = (urls: string[], restart: boolean) => Promise<void>