Offline-capable geomap, meant for storing location bookmarks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add bookmark imports

+156
+1
deno.json
··· 24 24 "@civility/ui": "jsr:@civility/ui@^0.2.6", 25 25 "@civility/workers": "jsr:@civility/workers@^0.2.4", 26 26 "@zod/zod": "jsr:@zod/zod@^4.3.6", 27 + "fflate": "npm:fflate@^0.8.2", 27 28 "lit": "npm:lit@^3.3.2", 28 29 "maplibre-gl": "npm:maplibre-gl@^5.21.0", 29 30 "pmtiles": "npm:pmtiles@^4.4.0",
+2
deno.lock
··· 20 20 "jsr:@std/semver@^1.0.8": "1.0.8", 21 21 "jsr:@std/text@^1.0.17": "1.0.17", 22 22 "jsr:@zod/zod@^4.3.6": "4.3.6", 23 + "npm:fflate@~0.8.2": "0.8.2", 23 24 "npm:lit@^3.3.2": "3.3.2", 24 25 "npm:maplibre-gl@^5.21.0": "5.21.0", 25 26 "npm:native-file-system-adapter@^3.0.1": "3.0.1", ··· 336 337 "jsr:@civility/ui@~0.2.6", 337 338 "jsr:@civility/workers@~0.2.4", 338 339 "jsr:@zod/zod@^4.3.6", 340 + "npm:fflate@~0.8.2", 339 341 "npm:lit@^3.3.2", 340 342 "npm:maplibre-gl@^5.21.0", 341 343 "npm:pmtiles-offline@1",
+65
www/routes/bookmarks.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 3 import type { Bookmark, BookmarkFolder } from '../models/schema.ts' 4 + import { 5 + parseKml, 6 + parseKmz, 7 + parseGpx, 8 + } from '../utils/import_bookmarks.ts' 4 9 import { setMapNav } from '../utils/nav.ts' 5 10 6 11 export class BookmarksPage extends LitElement { ··· 10 15 private showAddBookmark = false 11 16 private showAddFolder = false 12 17 private editingBookmark: Bookmark | null = null 18 + private importStatus: string | null = null 13 19 14 20 protected override createRenderRoot() { 15 21 return this ··· 83 89 this.requestUpdate() 84 90 } 85 91 92 + async #handleImport(e: Event) { 93 + const file = (e.target as HTMLInputElement).files?.[0] 94 + if (!file) return 95 + ;(e.target as HTMLInputElement).value = '' 96 + try { 97 + let result 98 + if (file.name.endsWith('.kmz')) { 99 + result = await parseKmz(await file.arrayBuffer()) 100 + } else if (file.name.endsWith('.gpx')) { 101 + result = parseGpx(await file.text()) 102 + } else { 103 + result = parseKml(await file.text()) 104 + } 105 + 106 + const folderIdMap = new Map<string, string>() 107 + for (const folder of result.folders) { 108 + await app.addFolder(folder.name) 109 + const created = app.bookmarkFolders.find((f) => f.name === folder.name) 110 + if (created) folderIdMap.set(folder.tempId, created.id) 111 + } 112 + for (const bm of result.bookmarks) { 113 + const folderId = bm.folderTempId 114 + ? (folderIdMap.get(bm.folderTempId) ?? null) 115 + : null 116 + await app.addBookmark(bm.name, bm.lat, bm.lng, 12, folderId, bm.address) 117 + } 118 + 119 + const parts = [] 120 + if (result.bookmarks.length > 0) { 121 + parts.push( 122 + `${result.bookmarks.length} bookmark${result.bookmarks.length === 1 ? '' : 's'}`, 123 + ) 124 + } 125 + if (result.folders.length > 0) { 126 + parts.push( 127 + `${result.folders.length} folder${result.folders.length === 1 ? '' : 's'}`, 128 + ) 129 + } 130 + this.importStatus = parts.length 131 + ? `Imported ${parts.join(' and ')}.` 132 + : 'Nothing to import.' 133 + } catch (err) { 134 + this.importStatus = `Import failed: ${err instanceof Error ? err.message : 'unknown error'}` 135 + } 136 + this.requestUpdate() 137 + } 138 + 86 139 async #deleteFolder(id: string) { 87 140 await app.deleteFolder(id) 88 141 this.expandedFolders.delete(id) ··· 140 193 > 141 194 + Bookmark 142 195 </button> 196 + <label class="bm-import-label"> 197 + Import 198 + <input 199 + type="file" 200 + accept=".kml,.kmz,.gpx" 201 + style="display:none" 202 + @change="${this.#handleImport}" 203 + > 204 + </label> 143 205 </div> 206 + ${this.importStatus 207 + ? html`<p class="bm-import-status">${this.importStatus}</p>` 208 + : ''} 144 209 145 210 ${this.showAddFolder 146 211 ? html`
+88
www/utils/import_bookmarks.ts
··· 1 + import { unzipSync } from 'fflate' 2 + 3 + export type ParsedFolder = { tempId: string; name: string } 4 + 5 + export type ParsedBookmark = { 6 + name: string 7 + lat: number 8 + lng: number 9 + folderTempId: string | null 10 + address?: string 11 + } 12 + 13 + export type ImportResult = { 14 + folders: ParsedFolder[] 15 + bookmarks: ParsedBookmark[] 16 + } 17 + 18 + export function parseKml(text: string): ImportResult { 19 + const doc = new DOMParser().parseFromString(text, 'text/xml') 20 + const folders: ParsedFolder[] = [] 21 + const bookmarks: ParsedBookmark[] = [] 22 + let counter = 0 23 + 24 + // Prefer <Document> as root container, fall back to document element 25 + const root = doc.querySelector('Document') ?? doc.documentElement 26 + 27 + for (const child of root.children) { 28 + if (child.localName === 'Folder') { 29 + const tempId = `f${counter++}` 30 + const name = 31 + child.querySelector('name')?.textContent?.trim() ?? 'Imported Folder' 32 + folders.push({ tempId, name }) 33 + // Collect all placemarks inside this folder (nested folders are flattened) 34 + for (const pm of child.querySelectorAll('Placemark')) { 35 + const bm = parsePlacemark(pm, tempId) 36 + if (bm) bookmarks.push(bm) 37 + } 38 + } else if (child.localName === 'Placemark') { 39 + const bm = parsePlacemark(child, null) 40 + if (bm) bookmarks.push(bm) 41 + } 42 + } 43 + 44 + return { folders, bookmarks } 45 + } 46 + 47 + export function parseGpx(text: string): ImportResult { 48 + const doc = new DOMParser().parseFromString(text, 'text/xml') 49 + const bookmarks: ParsedBookmark[] = [] 50 + 51 + for (const wpt of doc.querySelectorAll('wpt')) { 52 + const lat = parseFloat(wpt.getAttribute('lat') ?? '') 53 + const lng = parseFloat(wpt.getAttribute('lon') ?? '') 54 + if (isNaN(lat) || isNaN(lng)) continue 55 + const name = wpt.querySelector('name')?.textContent?.trim() ?? 'Waypoint' 56 + const desc = wpt.querySelector('desc')?.textContent?.trim() || undefined 57 + bookmarks.push({ name, lat, lng, folderTempId: null, address: desc }) 58 + } 59 + 60 + return { folders: [], bookmarks } 61 + } 62 + 63 + export async function parseKmz(buffer: ArrayBuffer): Promise<ImportResult> { 64 + const files = unzipSync(new Uint8Array(buffer)) 65 + const entry = 66 + Object.keys(files).find((k) => k === 'doc.kml') ?? 67 + Object.keys(files).find((k) => k.endsWith('.kml')) 68 + if (!entry) throw new Error('No KML file found in KMZ archive') 69 + return parseKml(new TextDecoder().decode(files[entry])) 70 + } 71 + 72 + function parsePlacemark( 73 + el: Element, 74 + folderTempId: string | null, 75 + ): ParsedBookmark | null { 76 + const coordText = el.querySelector('coordinates')?.textContent?.trim() 77 + if (!coordText) return null 78 + // KML coordinates are lng,lat,alt — take the first point 79 + const parts = coordText.split(/\s+/)[0].split(',') 80 + if (parts.length < 2) return null 81 + const lng = parseFloat(parts[0]) 82 + const lat = parseFloat(parts[1]) 83 + if (isNaN(lat) || isNaN(lng)) return null 84 + const name = el.querySelector('name')?.textContent?.trim() ?? 'Unnamed' 85 + const address = 86 + el.querySelector('address')?.textContent?.trim() || undefined 87 + return { name, lat, lng, folderTempId, address } 88 + }