feat: [wip] custom map and zim downloads

This commit is contained in:
Jake Turner 2025-12-01 22:34:09 -08:00
parent dc2bae1065
commit 606dd3ad0b
No known key found for this signature in database
GPG Key ID: 694BC38EF2ED4844
18 changed files with 386 additions and 85 deletions

View File

@ -36,6 +36,14 @@ export default class MapsController {
}
}
// For providing a "preflight" check in the UI before actually starting a background download
async downloadRemotePreflight({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadValidator)
console.log(payload)
const info = await this.mapService.downloadRemotePreflight(payload.url)
return info
}
async listRegions({}: HttpContext) {
return await this.mapService.listRegions()
}

View File

@ -1,5 +1,6 @@
import { ZimService } from '#services/zim_service'
import { filenameValidator, remoteDownloadValidator } from '#validators/common'
import { listRemoteZimValidator } from '#validators/zim'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@ -12,8 +13,9 @@ export default class ZimController {
}
async listRemote({ request }: HttpContext) {
const { start = 0, count = 12 } = request.qs()
return await this.zimService.listRemote({ start, count })
const payload = await request.validateUsing(listRemoteZimValidator)
const { start = 0, count = 12, query } = payload
return await this.zimService.listRemote({ start, count, query })
}
async downloadRemote({ request }: HttpContext) {
@ -27,6 +29,10 @@ export default class ZimController {
}
}
async listActiveDownloads({}: HttpContext) {
return this.zimService.listActiveDownloads()
}
async delete({ request, response }: HttpContext) {
const payload = await request.validateUsing(filenameValidator)

View File

@ -12,6 +12,7 @@ import {
} from '../utils/fs.js'
import { join } from 'path'
import urlJoin from 'url-join'
import axios from 'axios'
const BASE_ASSETS_MIME_TYPES = [
'application/gzip',
@ -114,6 +115,36 @@ export class MapService {
return filename
}
async downloadRemotePreflight(
url: string
): Promise<{ filename: string; size: number } | { message: string }> {
try {
const parsed = new URL(url)
if (!parsed.pathname.endsWith('.pmtiles')) {
throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)
}
const filename = url.split('/').pop()
if (!filename) {
throw new Error('Could not determine filename from URL')
}
// Perform a HEAD request to get the content length
const response = await axios.head(url)
if (response.status !== 200) {
throw new Error(`Failed to fetch file info: ${response.status} ${response.statusText}`)
}
const contentLength = response.headers['content-length']
const size = contentLength ? parseInt(contentLength, 10) : 0
return { filename, size }
} catch (error) {
return { message: `Preflight check failed: ${error.message}` }
}
}
async generateStylesJSON() {
if (!(await this.checkBaseAssetsExist())) {
throw new Error('Base map assets are missing from storage/maps')

View File

@ -44,9 +44,11 @@ export class ZimService {
async listRemote({
start,
count,
query,
}: {
start: number
count: number
query?: string
}): Promise<ListRemoteZimFilesResponse> {
const LIBRARY_BASE_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'
@ -55,6 +57,7 @@ export class ZimService {
start: start,
count: count,
lang: 'eng',
...(query ? { q: query } : {}),
},
responseType: 'text',
})
@ -71,7 +74,8 @@ export class ZimService {
throw new Error('Invalid response format from remote library')
}
const filtered = result.feed.entry.filter((entry: any) => {
const entries = Array.isArray(result.feed.entry) ? result.feed.entry : [result.feed.entry]
const filtered = entries.filter((entry: any) => {
return isRawRemoteZimFileEntry(entry)
})
@ -166,7 +170,7 @@ export class ZimService {
return filename
}
getActiveDownloads(): string[] {
listActiveDownloads(): string[] {
return Array.from(this.activeDownloads.keys())
}

View File

@ -2,13 +2,17 @@ import vine from '@vinejs/vine'
export const remoteDownloadValidator = vine.compile(
vine.object({
url: vine.string().url().trim(),
url: vine.string().url({
require_tld: false, // Allow local URLs
}).trim(),
})
)
export const remoteDownloadValidatorOptional = vine.compile(
vine.object({
url: vine.string().url().trim().optional(),
url: vine.string().url({
require_tld: false, // Allow local URLs
}).trim().optional(),
})
)

View File

@ -0,0 +1,9 @@
import vine from '@vinejs/vine'
export const listRemoteZimValidator = vine.compile(
vine.object({
start: vine.number().min(0).optional(),
count: vine.number().min(1).max(100).optional(),
query: vine.string().optional(),
})
)

View File

@ -24,27 +24,6 @@ export default class ServiceSeeder extends BaseSeeder {
is_dependency_service: false,
depends_on: null,
},
{
service_name: DockerService.OPENSTREETMAP_SERVICE_NAME,
friendly_name: 'OpenStreetMap Tile Server',
description: 'Self-hosted OpenStreetMap tile server',
container_image: 'overv/openstreetmap-tile-server',
container_command: 'run',
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
Binds: [
`${DockerService.NOMAD_STORAGE_ABS_PATH}/osm/db:/data/database:rw`,
`${DockerService.NOMAD_STORAGE_ABS_PATH}/osm/tiles:/data/tiles:rw`
],
PortBindings: { '80/tcp': [{ HostPort: '9000' }] }
}
}),
ui_location: '9000',
installed: false,
is_dependency_service: false,
depends_on: null,
},
{
service_name: DockerService.OLLAMA_SERVICE_NAME,
friendly_name: 'Ollama',

View File

@ -0,0 +1,83 @@
import { useState } from 'react'
import StyledModal, { StyledModalProps } from './StyledModal'
import Input from './inputs/Input'
import api from '~/lib/api'
export type DownloadURLModalProps = Omit<
StyledModalProps,
'onConfirm' | 'open' | 'confirmText' | 'cancelText' | 'confirmVariant' | 'children'
> & {
suggestedURL?: string
onPreflightSuccess?: (url: string) => void
}
const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
suggestedURL,
onPreflightSuccess,
...modalProps
}) => {
const [url, setUrl] = useState<string>(suggestedURL || '')
const [messages, setMessages] = useState<string[]>([])
const [passedPreflight, setPassedPreflight] = useState<boolean>(false)
async function runPreflightCheck(downloadUrl: string) {
try {
setMessages([`Running preflight check for URL: ${downloadUrl}`])
const res = await api.downloadRemoteMapRegionPreflight(downloadUrl)
if ('message' in res) {
throw new Error(res.message)
}
setMessages((prev) => [
...prev,
`Preflight check passed. Filename: ${res.filename}, Size: ${(res.size / (1024 * 1024)).toFixed(2)} MB`,
])
setPassedPreflight(true)
} catch (error) {
console.error('Preflight check failed:', error)
setMessages((prev) => [...prev, `Preflight check failed: ${error.message}`])
setPassedPreflight(false)
}
}
return (
<StyledModal
{...modalProps}
onConfirm={() => runPreflightCheck(url)}
open={true}
confirmText="Download"
confirmIcon="ArrowDownTrayIcon"
cancelText="Cancel"
confirmVariant="primary"
large
>
<div className="flex flex-col pb-4">
<p className="text-gray-700 mb-8">
Enter the URL of the map region file you wish to download. The URL must be publicly
reachable and end with .pmtiles. A preflight check will be run to verify the file's
availability, type, and approximate size.
</p>
<Input
name="download-url"
label=""
placeholder={suggestedURL || 'Enter download URL...'}
className="mb-4"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left">
{messages.map((message, idx) => (
<p
key={idx}
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3"
>
{message}
</p>
))}
</div>
</div>
</StyledModal>
)
}
export default DownloadURLModal

View File

@ -3,11 +3,13 @@ import StyledButton, { StyledButtonProps } from './StyledButton'
import React from 'react'
import classNames from '~/lib/classNames'
interface StyledModalProps {
export type StyledModalProps = {
onClose?: () => void
title: string
cancelText?: string
cancelIcon?: StyledButtonProps['icon']
confirmText?: string
confirmIcon?: StyledButtonProps['icon']
confirmVariant?: StyledButtonProps['variant']
open: boolean
onCancel?: () => void
@ -23,7 +25,9 @@ const StyledModal: React.FC<StyledModalProps> = ({
open,
onClose,
cancelText = 'Cancel',
cancelIcon,
confirmText = 'Confirm',
confirmIcon,
confirmVariant = 'action',
onCancel,
onConfirm,
@ -68,10 +72,11 @@ const StyledModal: React.FC<StyledModalProps> = ({
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
{cancelText && onCancel && (
<StyledButton
variant="secondary"
variant="outline"
onClick={() => {
if (onCancel) onCancel()
}}
icon={cancelIcon}
>
{cancelText}
</StyledButton>
@ -82,6 +87,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
onClick={() => {
if (onConfirm) onConfirm()
}}
icon={confirmIcon}
>
{confirmText}
</StyledButton>

View File

@ -48,7 +48,7 @@ function StyledTable<T extends { [key: string]: any }>({
return (
<div
className={classNames(
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-3 shadow-md',
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md',
className
)}
ref={ref}
@ -107,7 +107,7 @@ function StyledTable<T extends { [key: string]: any }>({
))}
{!loading && data.length === 0 && (
<tr>
<td colSpan={columns.length} className="!text-center ">
<td colSpan={columns.length} className="!text-center py-8 text-gray-500">
{noDataText}
</td>
</tr>

View File

@ -0,0 +1,59 @@
import classNames from "classnames";
import { InputHTMLAttributes } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
name: string;
label: string;
className?: string;
labelClassName?: string;
inputClassName?: string;
leftIcon?: React.ReactNode;
error?: boolean;
required?: boolean;
}
const Input: React.FC<InputProps> = ({
className,
label,
name,
labelClassName,
inputClassName,
leftIcon,
error,
required,
...props
}) => {
return (
<div className={classNames(className)}>
<label
htmlFor={name}
className={classNames("block text-base/6 font-medium text-gray-700", labelClassName)}
>
{label}{required ? "*" : ""}
</label>
<div className="mt-1.5">
<div className="relative">
{leftIcon && (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
{leftIcon}
</div>
)}
<input
id={name}
name={name}
placeholder={props.placeholder || label}
className={classNames(
inputClassName,
"block w-full rounded-md bg-white px-3 py-2 text-base text-gray-900 border border-gray-400 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
leftIcon ? "pl-10" : "pl-3",
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
)}
{...props}
/>
</div>
</div>
</div>
);
};
export default Input;

View File

@ -0,0 +1,23 @@
import { useRef, useEffect } from "react";
const useDebounce = () => {
const timeout = useRef<number | undefined>(400);
const debounce =
(func: Function, wait: number = 0) =>
(...args: any[]) => {
clearTimeout(timeout.current);
timeout.current = window.setTimeout(() => func(...args), wait);
};
useEffect(() => {
return () => {
if (!timeout.current) return;
clearTimeout(timeout.current);
};
}, []);
return { debounce };
};
export default useDebounce;

View File

@ -46,6 +46,31 @@ class API {
}
}
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')
@ -86,15 +111,34 @@ class API {
return await this.client.get<ListZimFilesResponse>('/zim/list')
}
async listRemoteZimFiles({ start = 0, count = 12 }: { start?: number; count?: number }) {
async listRemoteZimFiles({
start = 0,
count = 12,
query,
}: {
start?: number
count?: number
query?: string
}) {
return await this.client.get<ListRemoteZimFilesResponse>('/zim/list-remote', {
params: {
start,
count,
query,
},
})
}
async listActiveZimDownloads(): Promise<string[]> {
try {
const response = await this.client.get<string[]>('/zim/active-downloads')
return response.data
} catch (error) {
console.error('Error listing active ZIM downloads:', error)
throw error
}
}
async downloadRemoteZimFile(url: string): Promise<{
message: string
filename: string

View File

@ -9,6 +9,7 @@ import MissingBaseAssetsAlert from '~/components/layout/MissingBaseAssetsAlert'
import { useNotifications } from '~/context/NotificationContext'
import { useState } from 'react'
import api from '~/lib/api'
import DownloadURLModal from '~/components/DownloadURLModal'
export default function MapsManager(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
@ -61,6 +62,17 @@ export default function MapsManager(props: {
)
}
async function openDownloadModal() {
openModal(
<DownloadURLModal
title="Download Map File"
suggestedURL="https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/"
onCancel={() => closeAllModals()}
/>,
'download-map-file-modal'
)
}
return (
<SettingsLayout>
<Head title="Maps Manager" />
@ -73,9 +85,9 @@ export default function MapsManager(props: {
</div>
<StyledButton
variant="primary"
onClick={downloadBaseAssets}
onClick={openDownloadModal}
loading={downloading}
icon='CloudArrowDownIcon'
icon="CloudArrowDownIcon"
>
Download New Map File
</StyledButton>

View File

@ -16,6 +16,9 @@ import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
import Alert from '~/components/Alert'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import Input from '~/components/inputs/Input'
import { IconSearch } from '@tabler/icons-react'
import useDebounce from '~/hooks/useDebounce'
export default function ZimRemoteExplorer() {
const tableParentRef = useRef<HTMLDivElement>(null)
@ -24,17 +27,26 @@ export default function ZimRemoteExplorer() {
const { addNotification } = useNotifications()
const { isOnline } = useInternetStatus()
const { isInstalled } = useServiceInstalledStatus('nomad_kiwix_serve')
const { debounce } = useDebounce()
const [query, setQuery] = useState('')
const [queryUI, setQueryUI] = useState('')
const [activeDownloads, setActiveDownloads] = useState<
Map<string, { status: string; progress: number; speed: string }>
>(new Map<string, { status: string; progress: number; speed: string }>())
const debouncedSetQuery = debounce((val: string) => {
setQuery(val)
}, 400)
const { data, fetchNextPage, isFetching, isLoading } =
useInfiniteQuery<ListRemoteZimFilesResponse>({
queryKey: ['remote-zim-files'],
queryKey: ['remote-zim-files', query],
queryFn: async ({ pageParam = 0 }) => {
const pageParsed = parseInt((pageParam as number).toString(), 10)
const start = isNaN(pageParsed) ? 0 : pageParsed * 12
const res = await api.listRemoteZimFiles({ start, count: 12 })
const res = await api.listRemoteZimFiles({ start, count: 12, query: query || undefined })
return res.data
},
initialPageParam: 0,
@ -166,6 +178,20 @@ export default function ZimRemoteExplorer() {
className="!mt-6"
/>
)}
<div className="flex justify-start mt-4">
<Input
name="search"
label=""
placeholder="Search available ZIM files..."
value={queryUI}
onChange={(e) => {
setQueryUI(e.target.value)
debouncedSetQuery(e.target.value)
}}
className="w-1/3"
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
/>
</div>
<StyledTable<RemoteZimFileEntry & { actions?: any }>
data={flatData.map((i, idx) => {
const row = virtualizer.getVirtualItems().find((v) => v.index === idx)

View File

@ -53,6 +53,7 @@ router
router.get('/preflight', [MapsController, 'checkBaseAssets'])
router.post('/download-base-assets', [MapsController, 'downloadBaseAssets'])
router.post('/download-remote', [MapsController, 'downloadRemote'])
router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight'])
router.delete('/:filename', [MapsController, 'delete'])
})
.prefix('/api/maps')
@ -76,6 +77,7 @@ router
.group(() => {
router.get('/list', [ZimController, 'list'])
router.get('/list-remote', [ZimController, 'listRemote'])
router.get('/active-downloads', [ZimController, 'listActiveDownloads'])
router.post('/download-remote', [ZimController, 'downloadRemote'])
router.delete('/:filename', [ZimController, 'delete'])
})

View File

@ -1,59 +1,59 @@
import { FileEntry } from './files.js'
export type ListZimFilesResponse = {
files: FileEntry[]
next?: string
files: FileEntry[]
next?: string
}
export type ListRemoteZimFilesResponse = {
items: RemoteZimFileEntry[];
has_more: boolean;
total_count: number;
items: RemoteZimFileEntry[]
has_more: boolean
total_count: number
}
export type RawRemoteZimFileEntry = {
id: string;
title: string;
updated: string;
summary: string;
language: string;
name: string;
flavour: string;
category: string;
tags: string;
articleCount: number;
mediaCount: number;
link: Record<string, string>[];
author: {
name: string
};
publisher: {
name: string;
};
'dc:issued': string;
'id': string
'title': string
'updated': string
'summary': string
'language': string
'name': string
'flavour': string
'category': string
'tags': string
'articleCount': number
'mediaCount': number
'link': Record<string, string>[]
'author': {
name: string
}
'publisher': {
name: string
}
'dc:issued': string
}
export type RawListRemoteZimFilesResponse = {
'?xml': string;
feed: {
id: string;
link: string[];
title: string;
updated: string;
totalResults: number;
startIndex: number;
itemsPerPage: number;
entry: any[];
}
'?xml': string
'feed': {
id: string
link: string[]
title: string
updated: string
totalResults: number
startIndex: number
itemsPerPage: number
entry: RawRemoteZimFileEntry | RawRemoteZimFileEntry[]
}
}
export type RemoteZimFileEntry = {
id: string;
title: string;
updated: string;
summary: string;
size_bytes: number;
download_url: string;
author: string;
file_name: string;
}
id: string
title: string
updated: string
summary: string
size_bytes: number
download_url: string
author: string
file_name: string
}

View File

@ -1,10 +1,15 @@
import { RawListRemoteZimFilesResponse, RawRemoteZimFileEntry } from "../types/zim.js";
import { RawListRemoteZimFilesResponse, RawRemoteZimFileEntry } from '../types/zim.js'
export function isRawListRemoteZimFilesResponse(obj: any): obj is RawListRemoteZimFilesResponse {
return obj && typeof obj === 'object' && 'feed' in obj && 'entry' in obj.feed && Array.isArray(obj.feed.entry);
return (
obj &&
typeof obj === 'object' &&
'feed' in obj &&
'entry' in obj.feed &&
typeof obj.feed.entry === 'object' // could be array or single object but typeof array is technically 'object'
)
}
export function isRawRemoteZimFileEntry(obj: any): obj is RawRemoteZimFileEntry {
return obj && typeof obj === 'object' && 'id' in obj && 'title' in obj && 'summary' in obj;
}
return obj && typeof obj === 'object' && 'id' in obj && 'title' in obj && 'summary' in obj
}