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: update bookmarks

+862 -462
+2 -2
data/cli/shared/geofabrik/get-geofabrik-regions.ts
··· 2 2 * All of these regions should have a `.poly` and a `-latest.osm.pbf` file 3 3 * associated to them. 4 4 */ 5 - import * as cheerio from 'npm:cheerio' 5 + import * as cheerio from 'cheerio' 6 6 7 7 const baseURL = 'https://download.geofabrik.de/' 8 8 const regions = new Set() ··· 47 47 await Promise.all(childrenResp) 48 48 } 49 49 50 - async function parseGeofabrikDirectory(html: string) { 50 + function parseGeofabrikDirectory(html: string) { 51 51 const $ = cheerio.load(html) 52 52 const rows = $('tr') 53 53
+2 -1
data/deno.json
··· 2 2 "imports": { 3 3 "@cliffy/command": "jsr:@cliffy/command@^1.0.0", 4 4 "@std/fs": "jsr:@std/fs@^1.0.0", 5 - "@std/path": "jsr:@std/path@^1.1.4" 5 + "@std/path": "jsr:@std/path@^1.1.4", 6 + "cheerio": "npm:cheerio@^1.2.0" 6 7 } 7 8 }
+3 -2
deno.json
··· 1 1 { 2 - "version": "0.0.1", 2 + "version": "0.1.0", 3 3 "workspace": ["./data"], 4 4 "tasks": { 5 - "data": "deno run -A ./data/cli/main.ts" 5 + "data": "deno run -A ./data/cli/main.ts", 6 + "test": "deno fmt && deno lint && deno check ./www/index.ts && deno check ./data/cli/main.ts" 6 7 }, 7 8 "compilerOptions": { 8 9 "lib": [
+144 -1
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:cheerio@^1.2.0": "1.2.0", 23 24 "npm:fflate@~0.8.2": "0.8.2", 24 25 "npm:lit@^3.3.2": "3.3.2", 25 26 "npm:maplibre-gl@^5.21.0": "5.21.0", ··· 60 61 "jsr:@cliffy/internal", 61 62 "jsr:@cliffy/table", 62 63 "jsr:@std/fmt", 64 + "jsr:@std/semver", 63 65 "jsr:@std/text" 64 66 ] 65 67 }, ··· 199 201 "@types/trusted-types@2.0.7": { 200 202 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 201 203 }, 204 + "boolbase@1.0.0": { 205 + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" 206 + }, 207 + "cheerio-select@2.1.0": { 208 + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", 209 + "dependencies": [ 210 + "boolbase", 211 + "css-select", 212 + "css-what", 213 + "domelementtype", 214 + "domhandler", 215 + "domutils" 216 + ] 217 + }, 218 + "cheerio@1.2.0": { 219 + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", 220 + "dependencies": [ 221 + "cheerio-select", 222 + "dom-serializer", 223 + "domhandler", 224 + "domutils", 225 + "encoding-sniffer", 226 + "htmlparser2", 227 + "parse5", 228 + "parse5-htmlparser2-tree-adapter", 229 + "parse5-parser-stream", 230 + "undici", 231 + "whatwg-mimetype" 232 + ] 233 + }, 234 + "css-select@5.2.2": { 235 + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", 236 + "dependencies": [ 237 + "boolbase", 238 + "css-what", 239 + "domhandler", 240 + "domutils", 241 + "nth-check" 242 + ] 243 + }, 244 + "css-what@6.2.2": { 245 + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" 246 + }, 247 + "dom-serializer@2.0.0": { 248 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 249 + "dependencies": [ 250 + "domelementtype", 251 + "domhandler", 252 + "entities@4.5.0" 253 + ] 254 + }, 255 + "domelementtype@2.3.0": { 256 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" 257 + }, 258 + "domhandler@5.0.3": { 259 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 260 + "dependencies": [ 261 + "domelementtype" 262 + ] 263 + }, 264 + "domutils@3.2.2": { 265 + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 266 + "dependencies": [ 267 + "dom-serializer", 268 + "domelementtype", 269 + "domhandler" 270 + ] 271 + }, 202 272 "earcut@3.0.2": { 203 273 "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==" 204 274 }, 275 + "encoding-sniffer@0.2.1": { 276 + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", 277 + "dependencies": [ 278 + "iconv-lite", 279 + "whatwg-encoding" 280 + ] 281 + }, 282 + "entities@4.5.0": { 283 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" 284 + }, 285 + "entities@6.0.1": { 286 + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" 287 + }, 288 + "entities@7.0.1": { 289 + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" 290 + }, 205 291 "fetch-blob@3.2.0": { 206 292 "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 207 293 "dependencies": [ ··· 214 300 }, 215 301 "gl-matrix@3.4.4": { 216 302 "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==" 303 + }, 304 + "htmlparser2@10.1.0": { 305 + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", 306 + "dependencies": [ 307 + "domelementtype", 308 + "domhandler", 309 + "domutils", 310 + "entities@7.0.1" 311 + ] 312 + }, 313 + "iconv-lite@0.6.3": { 314 + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 315 + "dependencies": [ 316 + "safer-buffer" 317 + ] 217 318 }, 218 319 "json-stringify-pretty-compact@4.0.0": { 219 320 "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==" ··· 283 384 "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 284 385 "deprecated": true 285 386 }, 387 + "nth-check@2.1.1": { 388 + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", 389 + "dependencies": [ 390 + "boolbase" 391 + ] 392 + }, 393 + "parse5-htmlparser2-tree-adapter@7.1.0": { 394 + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", 395 + "dependencies": [ 396 + "domhandler", 397 + "parse5" 398 + ] 399 + }, 400 + "parse5-parser-stream@7.1.2": { 401 + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", 402 + "dependencies": [ 403 + "parse5" 404 + ] 405 + }, 406 + "parse5@7.3.0": { 407 + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", 408 + "dependencies": [ 409 + "entities@6.0.1" 410 + ] 411 + }, 286 412 "pbf@4.0.1": { 287 413 "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", 288 414 "dependencies": [ ··· 317 443 "rw@1.3.3": { 318 444 "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" 319 445 }, 446 + "safer-buffer@2.1.2": { 447 + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 448 + }, 320 449 "supercluster@8.0.1": { 321 450 "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", 322 451 "dependencies": [ ··· 325 454 }, 326 455 "tinyqueue@3.0.0": { 327 456 "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" 457 + }, 458 + "undici@7.22.0": { 459 + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==" 328 460 }, 329 461 "web-streams-polyfill@3.3.3": { 330 462 "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" 463 + }, 464 + "whatwg-encoding@3.1.1": { 465 + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", 466 + "dependencies": [ 467 + "iconv-lite" 468 + ], 469 + "deprecated": true 470 + }, 471 + "whatwg-mimetype@4.0.0": { 472 + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" 331 473 } 332 474 }, 333 475 "workspace": { ··· 348 490 "dependencies": [ 349 491 "jsr:@cliffy/command@1", 350 492 "jsr:@std/fs@1", 351 - "jsr:@std/path@^1.1.4" 493 + "jsr:@std/path@^1.1.4", 494 + "npm:cheerio@^1.2.0" 352 495 ] 353 496 } 354 497 }
+102
www/models/adapters/geojson.ts
··· 1 + import { z } from '@zod/zod/mini' 2 + import { Lat, Lng } from '../schema/v0.ts' 3 + import type { BookmarkProperties } from '../schema/v0.ts' 4 + import type { ImportResult, ParsedBookmark } from './types.ts' 5 + 6 + const GeoJsonPoint = z.object({ 7 + type: z.literal('Feature'), 8 + geometry: z.object({ 9 + type: z.literal('Point'), 10 + coordinates: z.tuple([Lng, Lat]), 11 + }), 12 + properties: z.nullable(z.record(z.string(), z.unknown())), 13 + }) 14 + type GeoJsonPoint = z.infer<typeof GeoJsonPoint> 15 + 16 + const GeoJsonInput = z.union([ 17 + z.object({ 18 + type: z.literal('FeatureCollection'), 19 + features: z.array(z.unknown()), 20 + }), 21 + GeoJsonPoint, 22 + ]) 23 + 24 + /** Extract BookmarkProperties from a GeoJSON feature's property bag. */ 25 + function extractProperties( 26 + raw: Record<string, unknown>, 27 + ): Partial<BookmarkProperties> { 28 + // Support flat address strings or nested location objects (e.g. Google Takeout) 29 + const loc = (typeof raw.location === 'object' && raw.location !== null) 30 + ? raw.location as Record<string, unknown> 31 + : undefined 32 + 33 + const name = (raw.name ?? raw.Name ?? loc?.name) as string | undefined 34 + const displayName = (raw.displayName ?? raw.display_name) as 35 + | string 36 + | undefined 37 + 38 + // Address may be a string (display text) or an object with a displayText field 39 + let addressText: string | undefined 40 + if (typeof raw.address === 'string') { 41 + addressText = raw.address || undefined 42 + } else if (typeof loc?.address === 'string') { 43 + addressText = loc.address || undefined 44 + } 45 + 46 + const description = (raw.description ?? raw.desc) as string | undefined 47 + const googleMapsUrl = (raw.googleMapsUrl ?? raw.google_maps_url) as 48 + | string 49 + | undefined 50 + 51 + // Nominatim-style fields that may appear in exported GeoJSON 52 + const nominatimId = typeof raw.nominatimId === 'number' 53 + ? raw.nominatimId 54 + : typeof raw.place_id === 'number' 55 + ? raw.place_id 56 + : undefined 57 + const nominatimCategory = 58 + (raw.nominatimCategory ?? raw.class ?? raw.category) as string | undefined 59 + const nominatimType = (raw.nominatimType ?? raw.type) as string | undefined 60 + const nominatimPlaceRank = (raw.nominatimPlaceRank ?? raw.place_rank) as 61 + | number 62 + | undefined 63 + const importance = raw.importance as number | undefined 64 + const osmId = (raw.osmId ?? raw.osm_id) as number | undefined 65 + const osmType = (raw.osmType ?? raw.osm_type) as string | undefined 66 + 67 + return { 68 + name: name?.trim() || undefined, 69 + displayName: displayName?.trim() || undefined, 70 + description: description?.trim() || undefined, 71 + googleMapsUrl: googleMapsUrl?.trim() || undefined, 72 + nominatimId, 73 + nominatimCategory, 74 + nominatimType, 75 + nominatimPlaceRank, 76 + importance, 77 + osmId, 78 + osmType, 79 + address: addressText ? { displayText: addressText.trim() } : undefined, 80 + } 81 + } 82 + 83 + export function parseGeoJson(text: string): ImportResult { 84 + const raw = GeoJsonInput.parse(JSON.parse(text)) 85 + const rawFeatures = raw.type === 'FeatureCollection' ? raw.features : [raw] 86 + 87 + const bookmarks: ParsedBookmark[] = [] 88 + for (const feature of rawFeatures) { 89 + const result = GeoJsonPoint.safeParse(feature) 90 + if (!result.success) continue 91 + const { geometry, properties } = result.data 92 + const [lng, lat] = geometry.coordinates 93 + bookmarks.push({ 94 + lat, 95 + lng, 96 + folderTempId: null, 97 + properties: extractProperties(properties ?? {}), 98 + }) 99 + } 100 + 101 + return { folders: [], bookmarks } 102 + }
+23
www/models/adapters/gpx.ts
··· 1 + import type { ImportResult } from './types.ts' 2 + 3 + export function parseGpx(text: string): ImportResult { 4 + const doc = new DOMParser().parseFromString(text, 'text/xml') 5 + const bookmarks: ImportResult['bookmarks'] = [] 6 + 7 + for (const wpt of doc.querySelectorAll('wpt')) { 8 + const lat = parseFloat(wpt.getAttribute('lat') ?? '') 9 + const lng = parseFloat(wpt.getAttribute('lon') ?? '') 10 + if (isNaN(lat) || isNaN(lng)) continue 11 + const name = wpt.querySelector('name')?.textContent?.trim() || undefined 12 + const description = wpt.querySelector('desc')?.textContent?.trim() || 13 + undefined 14 + bookmarks.push({ 15 + lat, 16 + lng, 17 + folderTempId: null, 18 + properties: { name, description }, 19 + }) 20 + } 21 + 22 + return { folders: [], bookmarks } 23 + }
+72
www/models/adapters/kml.ts
··· 1 + import { unzipSync } from 'fflate' 2 + import type { ImportResult, ParsedBookmark } from './types.ts' 3 + 4 + export type { ImportResult, ParsedBookmark } from './types.ts' 5 + 6 + export function parseKml(text: string): ImportResult { 7 + const doc = new DOMParser().parseFromString(text, 'text/xml') 8 + const folders: ImportResult['folders'] = [] 9 + const bookmarks: ParsedBookmark[] = [] 10 + let counter = 0 11 + 12 + // Prefer <Document> as root container, fall back to document element 13 + const root = doc.querySelector('Document') ?? doc.documentElement 14 + 15 + for (const child of root.children) { 16 + if (child.localName === 'Folder') { 17 + const tempId = `f${counter++}` 18 + const name = child.querySelector('name')?.textContent?.trim() ?? 19 + 'Imported Folder' 20 + folders.push({ tempId, name }) 21 + // Collect all placemarks inside this folder (nested folders are flattened) 22 + for (const pm of child.querySelectorAll('Placemark')) { 23 + const bm = parsePlacemark(pm, tempId) 24 + if (bm) bookmarks.push(bm) 25 + } 26 + } else if (child.localName === 'Placemark') { 27 + const bm = parsePlacemark(child, null) 28 + if (bm) bookmarks.push(bm) 29 + } 30 + } 31 + 32 + return { folders, bookmarks } 33 + } 34 + 35 + export function parseKmz(buffer: ArrayBuffer): ImportResult { 36 + const files = unzipSync(new Uint8Array(buffer)) 37 + const entry = Object.keys(files).find((k) => k === 'doc.kml') ?? 38 + Object.keys(files).find((k) => k.endsWith('.kml')) 39 + if (!entry) throw new Error('No KML file found in KMZ archive') 40 + return parseKml(new TextDecoder().decode(files[entry])) 41 + } 42 + 43 + function parsePlacemark( 44 + el: Element, 45 + folderTempId: string | null, 46 + ): ParsedBookmark | null { 47 + const coordText = el.querySelector('coordinates')?.textContent?.trim() 48 + if (!coordText) return null 49 + // KML coordinates are lng,lat,alt — take the first point 50 + const parts = coordText.split(/\s+/)[0].split(',') 51 + if (parts.length < 2) return null 52 + const lng = parseFloat(parts[0]) 53 + const lat = parseFloat(parts[1]) 54 + if (isNaN(lat) || isNaN(lng)) return null 55 + 56 + const name = el.querySelector('name')?.textContent?.trim() || undefined 57 + const addressText = el.querySelector('address')?.textContent?.trim() || 58 + undefined 59 + const description = el.querySelector('description')?.textContent?.trim() || 60 + undefined 61 + 62 + return { 63 + lat, 64 + lng, 65 + folderTempId, 66 + properties: { 67 + name, 68 + description, 69 + address: addressText ? { displayText: addressText } : undefined, 70 + }, 71 + } 72 + }
+34
www/models/adapters/nominatim.ts
··· 1 + import type { NominatimResult } from '../../utils/nominatim.ts' 2 + import type { BookmarkProperties } from '../schema/v0.ts' 3 + 4 + export function from(result: NominatimResult): BookmarkProperties { 5 + const a = result.address 6 + return { 7 + displayName: result.display_name, 8 + name: result.name, 9 + importance: result.importance, 10 + nominatimId: result.place_id, 11 + nominatimCategory: result.class, 12 + nominatimType: result.type, 13 + nominatimPlaceRank: result.place_rank, 14 + osmId: result.osm_id, 15 + osmType: result.osm_type, 16 + address: a 17 + ? { 18 + city: a.city, 19 + cityDistrict: a.city_district, 20 + country: a.country, 21 + countryCode: a.country_code, 22 + historic: a.historic, 23 + houseNumber: a.house_number, 24 + postcode: a.postcode, 25 + region: a.region, 26 + road: a.road, 27 + suburb: a.suburb, 28 + state: a.state, 29 + 'ISO3166-2-lvl4': a['ISO3166-2-lvl4'], 30 + 'ISO3166-2-lvl6': a['ISO3166-2-lvl6'], 31 + } 32 + : undefined, 33 + } 34 + }
+16
www/models/adapters/types.ts
··· 1 + import type { BookmarkProperties } from '../schema/v0.ts' 2 + 3 + export type ParsedFolder = { tempId: string; name: string } 4 + 5 + export type ParsedBookmark = { 6 + lat: number 7 + lng: number 8 + folderTempId: string | null 9 + properties: Partial<BookmarkProperties> 10 + isDuplicate?: boolean 11 + } 12 + 13 + export type ImportResult = { 14 + folders: ParsedFolder[] 15 + bookmarks: ParsedBookmark[] 16 + }
+19 -20
www/models/app.ts
··· 2 2 import { 3 3 AppState, 4 4 Bookmark, 5 - BookmarkFolder, 5 + BookmarkCollection, 6 + BookmarkProperties, 6 7 LastView, 7 8 SearchHistoryEntry, 8 9 } from './schema.ts' ··· 39 40 return this.store.bookmarks 40 41 } 41 42 42 - get bookmarkFolders(): BookmarkFolder[] { 43 - return this.store.bookmarkFolders 43 + get bookmarkCollections(): BookmarkCollection[] { 44 + return this.store.bookmarkCollections 44 45 } 45 46 46 47 async addBookmark( 47 - name: string, 48 48 lat: number, 49 49 lng: number, 50 50 zoom: number, 51 - folderId: string | null = null, 52 - address?: string, 51 + properties: BookmarkProperties, 52 + categories: string[] = [], 53 53 ): Promise<void> { 54 - await this.store.addBookmark(name, lat, lng, zoom, folderId, address) 54 + await this.store.addBookmark(lat, lng, zoom, properties, categories) 55 55 this.notify() 56 56 } 57 57 ··· 60 60 this.notify() 61 61 } 62 62 63 - async moveBookmark(id: string, folderId: string | null): Promise<void> { 64 - await this.store.moveBookmark(id, folderId) 65 - this.notify() 66 - } 67 - 68 63 async updateBookmark( 69 64 id: string, 70 - updates: Partial<Pick<Bookmark, 'name' | 'address' | 'notes' | 'folderId'>>, 65 + updates: { 66 + properties?: Partial<BookmarkProperties> 67 + categories?: string[] 68 + zoom?: number 69 + }, 71 70 ): Promise<void> { 72 71 await this.store.updateBookmark(id, updates) 73 72 this.notify() 74 73 } 75 74 76 - async addFolder(name: string, color?: string): Promise<void> { 77 - await this.store.addFolder(name, color) 75 + async addCollection(name: string, color?: string): Promise<void> { 76 + await this.store.addCollection(name, color) 78 77 this.notify() 79 78 } 80 79 81 - async updateFolder( 80 + async updateCollection( 82 81 id: string, 83 - updates: Partial<Pick<BookmarkFolder, 'name' | 'color' | 'icon'>>, 82 + updates: Partial<Pick<BookmarkCollection, 'name' | 'color' | 'icon'>>, 84 83 ): Promise<void> { 85 - await this.store.updateFolder(id, updates) 84 + await this.store.updateCollection(id, updates) 86 85 this.notify() 87 86 } 88 87 89 - async deleteFolder(id: string): Promise<void> { 90 - await this.store.deleteFolder(id) 88 + async deleteCollection(id: string): Promise<void> { 89 + await this.store.deleteCollection(id) 91 90 this.notify() 92 91 } 93 92
+6 -1
www/models/schema.ts
··· 1 1 export { 2 2 AppState, 3 3 Bookmark, 4 - BookmarkFolder, 4 + BookmarkAddress, 5 + BookmarkCollection, 6 + bookmarkDisplayName, 7 + BookmarkProperties, 5 8 LastView, 9 + Lat, 10 + Lng, 6 11 SearchHistoryEntry, 7 12 StoreState, 8 13 } from './schema/v0.ts'
+72 -11
www/models/schema/v0.ts
··· 1 1 import { z } from '@zod/zod/mini' 2 2 3 + /** Latitude: max/min (90 / -90) */ 4 + export const Lat = z.number().check(z.maximum(90), z.minimum(-90)) 5 + export type Lat = z.infer<typeof Lat> 6 + 7 + /** Longitude: max/min (180 / -180) */ 8 + export const Lng = z.number().check(z.maximum(180), z.minimum(-180)) 9 + export type Lng = z.infer<typeof Lng> 10 + 3 11 export const SearchHistoryEntry = z.object({ 4 12 query: z.string(), 5 13 timestamp: z.string(), 6 14 }) 7 15 export type SearchHistoryEntry = z.infer<typeof SearchHistoryEntry> 8 16 17 + export const BookmarkAddress = z.object({ 18 + city: z.optional(z.string()), 19 + cityBlock: z.optional(z.string()), 20 + cityDistrict: z.optional(z.string()), 21 + country: z.optional(z.string()), 22 + countryCode: z.optional(z.string()), 23 + displayText: z.optional(z.string()), 24 + historic: z.optional(z.string()), 25 + houseNumber: z.optional(z.string()), 26 + postcode: z.optional(z.string()), 27 + region: z.optional(z.string()), 28 + road: z.optional(z.string()), 29 + suburb: z.optional(z.string()), 30 + state: z.optional(z.string()), 31 + type: z.optional(z.string()), 32 + 'ISO3166-2-lvl4': z.optional(z.string()), 33 + 'ISO3166-2-lvl6': z.optional(z.string()), 34 + }) 35 + export type BookmarkAddress = z.infer<typeof BookmarkAddress> 36 + 37 + export const BookmarkProperties = z.object({ 38 + address: z.optional(BookmarkAddress), 39 + addressType: z.optional(z.string()), 40 + displayName: z.optional(z.string()), 41 + description: z.optional(z.string()), 42 + googleMapsUrl: z.optional(z.string()), 43 + importance: z.optional(z.number()), 44 + name: z.optional(z.string()), 45 + nominatimCategory: z.optional(z.string()), 46 + nominatimId: z.optional(z.number()), 47 + nominatimPlaceRank: z.optional(z.number()), 48 + nominatimType: z.optional(z.string()), 49 + osmId: z.optional(z.number()), 50 + osmType: z.optional(z.string()), 51 + }) 52 + export type BookmarkProperties = z.infer<typeof BookmarkProperties> 53 + 9 54 export const Bookmark = z.object({ 10 55 id: z.string(), 11 - name: z.string(), 12 - address: z.optional(z.string()), 13 - notes: z.optional(z.string()), 14 - lat: z.number(), 15 - lng: z.number(), 56 + type: z.literal('Feature'), 57 + geometry: z.object({ 58 + type: z.literal('Point'), 59 + // GeoJSON coordinate order: [lng, lat] 60 + coordinates: z.tuple([Lng, Lat]), 61 + }), 62 + properties: BookmarkProperties, 63 + categories: z._default(z.array(z.string()), []), 16 64 zoom: z._default(z.number(), 12), 17 - folderId: z._default(z.nullable(z.string()), null), 18 65 createdAt: z.string(), 66 + updatedAt: z.string(), 19 67 }) 20 68 export type Bookmark = z.infer<typeof Bookmark> 21 69 22 - export const BookmarkFolder = z.object({ 70 + /** Resolves a human-readable name for display, falling through available fields. */ 71 + export function bookmarkDisplayName(b: Bookmark): string { 72 + return ( 73 + b.properties.displayName ?? 74 + b.properties.name ?? 75 + b.properties.address?.displayText ?? 76 + `${b.geometry.coordinates[1].toFixed(4)}, ${ 77 + b.geometry.coordinates[0].toFixed(4) 78 + }` 79 + ) 80 + } 81 + 82 + export const BookmarkCollection = z.object({ 23 83 id: z.string(), 24 84 name: z.string(), 25 85 color: z.optional(z.string()), 26 86 icon: z.optional(z.string()), 27 87 createdAt: z.string(), 88 + updatedAt: z.string(), 28 89 }) 29 - export type BookmarkFolder = z.infer<typeof BookmarkFolder> 90 + export type BookmarkCollection = z.infer<typeof BookmarkCollection> 30 91 31 92 export const LastView = z.object({ 32 - lat: z.number(), 33 - lng: z.number(), 93 + lat: Lat, 94 + lng: Lng, 34 95 zoom: z.number(), 35 96 }) 36 97 export type LastView = z.infer<typeof LastView> ··· 39 100 version: z.optional(z.string()), 40 101 searchHistory: z._default(z.array(SearchHistoryEntry), []), 41 102 bookmarks: z._default(z.array(Bookmark), []), 42 - bookmarkFolders: z._default(z.array(BookmarkFolder), []), 103 + bookmarkCollections: z._default(z.array(BookmarkCollection), []), 43 104 geocodingBookmarksEnabled: z._default(z.boolean(), false), 44 105 onlineSearchEnabled: z._default(z.boolean(), true), 45 106 lastView: z._default(z.nullable(LastView), null),
+51 -37
www/models/store.ts
··· 2 2 import useJSON from '@civility/sync/json' 3 3 import { 4 4 Bookmark, 5 - BookmarkFolder, 5 + BookmarkCollection, 6 + BookmarkProperties, 6 7 LastView, 7 8 SearchHistoryEntry, 8 9 storeMigrationConfig, ··· 28 29 return this.#sync.state.data.bookmarks ?? [] 29 30 } 30 31 31 - get bookmarkFolders(): BookmarkFolder[] { 32 - return this.#sync.state.data.bookmarkFolders ?? [] 32 + get bookmarkCollections(): BookmarkCollection[] { 33 + return this.#sync.state.data.bookmarkCollections ?? [] 33 34 } 34 35 35 36 get geocodingBookmarksEnabled(): boolean { ··· 98 99 // ====== BOOKMARKS ====== 99 100 100 101 async addBookmark( 101 - name: string, 102 102 lat: number, 103 103 lng: number, 104 104 zoom: number, 105 - folderId: string | null = null, 106 - address?: string, 105 + properties: BookmarkProperties, 106 + categories: string[] = [], 107 107 ): Promise<void> { 108 + const now = new Date().toISOString() 108 109 const data = await this.#sync.get() 109 110 data.bookmarks = [ 110 111 ...(data.bookmarks ?? []), 111 112 Bookmark.parse({ 112 113 id: crypto.randomUUID(), 113 - name, 114 - address, 115 - lat, 116 - lng, 114 + type: 'Feature', 115 + geometry: { type: 'Point', coordinates: [lng, lat] }, 116 + properties, 117 + categories, 117 118 zoom, 118 - folderId, 119 - createdAt: new Date().toISOString(), 119 + createdAt: now, 120 + updatedAt: now, 120 121 }), 121 122 ] 122 123 await this.#sync.set(data) ··· 125 126 async deleteBookmark(id: string): Promise<void> { 126 127 const data = await this.#sync.get() 127 128 data.bookmarks = (data.bookmarks ?? []).filter((b) => b.id !== id) 128 - await this.#sync.set(data) 129 - } 130 - 131 - async moveBookmark(id: string, folderId: string | null): Promise<void> { 132 - const data = await this.#sync.get() 133 - const idx = (data.bookmarks ?? []).findIndex((b) => b.id === id) 134 - if (idx === -1) return 135 - data.bookmarks[idx] = { ...data.bookmarks[idx], folderId } 136 129 await this.#sync.set(data) 137 130 } 138 131 139 132 async updateBookmark( 140 133 id: string, 141 - updates: Partial<Pick<Bookmark, 'name' | 'address' | 'notes' | 'folderId'>>, 134 + updates: { 135 + properties?: Partial<BookmarkProperties> 136 + categories?: string[] 137 + zoom?: number 138 + }, 142 139 ): Promise<void> { 143 140 const data = await this.#sync.get() 144 141 const idx = (data.bookmarks ?? []).findIndex((b) => b.id === id) 145 142 if (idx === -1) return 146 - data.bookmarks[idx] = { ...data.bookmarks[idx], ...updates } 143 + const existing = data.bookmarks[idx] 144 + data.bookmarks[idx] = { 145 + ...existing, 146 + ...(updates.categories !== undefined && 147 + { categories: updates.categories }), 148 + ...(updates.zoom !== undefined && { zoom: updates.zoom }), 149 + properties: updates.properties 150 + ? { ...existing.properties, ...updates.properties } 151 + : existing.properties, 152 + updatedAt: new Date().toISOString(), 153 + } 147 154 await this.#sync.set(data) 148 155 } 149 156 150 - // ====== FOLDERS ====== 157 + // ====== COLLECTIONS ====== 151 158 152 - async addFolder(name: string, color?: string): Promise<void> { 159 + async addCollection(name: string, color?: string): Promise<void> { 160 + const now = new Date().toISOString() 153 161 const data = await this.#sync.get() 154 - data.bookmarkFolders = [ 155 - ...(data.bookmarkFolders ?? []), 156 - BookmarkFolder.parse({ 162 + data.bookmarkCollections = [ 163 + ...(data.bookmarkCollections ?? []), 164 + BookmarkCollection.parse({ 157 165 id: crypto.randomUUID(), 158 166 name, 159 167 color, 160 - createdAt: new Date().toISOString(), 168 + createdAt: now, 169 + updatedAt: now, 161 170 }), 162 171 ] 163 172 await this.#sync.set(data) 164 173 } 165 174 166 - async updateFolder( 175 + async updateCollection( 167 176 id: string, 168 - updates: Partial<Pick<BookmarkFolder, 'name' | 'color' | 'icon'>>, 177 + updates: Partial<Pick<BookmarkCollection, 'name' | 'color' | 'icon'>>, 169 178 ): Promise<void> { 170 179 const data = await this.#sync.get() 171 - const idx = (data.bookmarkFolders ?? []).findIndex((f) => f.id === id) 180 + const idx = (data.bookmarkCollections ?? []).findIndex((c) => c.id === id) 172 181 if (idx === -1) return 173 - data.bookmarkFolders[idx] = { ...data.bookmarkFolders[idx], ...updates } 182 + data.bookmarkCollections[idx] = { 183 + ...data.bookmarkCollections[idx], 184 + ...updates, 185 + updatedAt: new Date().toISOString(), 186 + } 174 187 await this.#sync.set(data) 175 188 } 176 189 177 - async deleteFolder(id: string): Promise<void> { 190 + async deleteCollection(id: string): Promise<void> { 178 191 const data = await this.#sync.get() 179 - data.bookmarkFolders = (data.bookmarkFolders ?? []).filter((f) => 180 - f.id !== id 192 + data.bookmarkCollections = (data.bookmarkCollections ?? []).filter((c) => 193 + c.id !== id 181 194 ) 182 - // unfiled bookmarks that were in this folder 183 195 data.bookmarks = (data.bookmarks ?? []).map((b) => 184 - b.folderId === id ? { ...b, folderId: null } : b 196 + b.categories.includes(id) 197 + ? { ...b, categories: b.categories.filter((c) => c !== id) } 198 + : b 185 199 ) 186 200 await this.#sync.set(data) 187 201 }
+180 -169
www/routes/bookmarks.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 - import type { Bookmark, BookmarkFolder } from '../models/schema.ts' 4 3 import { 5 - type ImportResult, 6 - parseGeoJson, 7 - parseGpx, 8 - parseKml, 9 - parseKmz, 10 - } from '../utils/import_bookmarks.ts' 4 + type Bookmark, 5 + type BookmarkCollection, 6 + bookmarkDisplayName, 7 + } from '../models/schema.ts' 8 + import { parseGeoJson } from '../models/adapters/geojson.ts' 9 + import { parseGpx } from '../models/adapters/gpx.ts' 10 + import { parseKml, parseKmz } from '../models/adapters/kml.ts' 11 + import type { ImportResult } from '../models/adapters/types.ts' 11 12 import { setMapNav } from '../utils/nav.ts' 12 13 13 14 export class BookmarksPage extends LitElement { 14 15 private bookmarks: Bookmark[] = [] 15 - private folders: BookmarkFolder[] = [] 16 - private expandedFolders = new Set<string>() 16 + private collections: BookmarkCollection[] = [] 17 + private expandedCollections = new Set<string>() 17 18 private showAddBookmark = false 18 - private showAddFolder = false 19 + private showAddCollection = false 19 20 private editingBookmark: Bookmark | null = null 20 - private editingFolder: BookmarkFolder | null = null 21 + private editingCollection: BookmarkCollection | null = null 21 22 private pendingImport: ImportResult | null = null 22 23 private importError: string | null = null 23 - private importTargetFolderId: string | null = null 24 + private importTargetCollectionId: string | null = null 24 25 25 26 protected override createRenderRoot() { 26 27 return this ··· 44 45 45 46 #sync() { 46 47 this.bookmarks = app.bookmarks 47 - this.folders = app.bookmarkFolders 48 + this.collections = app.bookmarkCollections 48 49 } 49 50 50 - #toggleFolder(id: string) { 51 - if (this.expandedFolders.has(id)) { 52 - this.expandedFolders.delete(id) 51 + #toggleCollection(id: string) { 52 + if (this.expandedCollections.has(id)) { 53 + this.expandedCollections.delete(id) 53 54 } else { 54 - this.expandedFolders.add(id) 55 + this.expandedCollections.add(id) 55 56 } 56 57 this.requestUpdate() 57 58 } 58 59 59 60 #navigateTo(b: Bookmark) { 60 - setMapNav({ lat: b.lat, lng: b.lng, zoom: b.zoom }) 61 + const [lng, lat] = b.geometry.coordinates 62 + setMapNav({ lat, lng, zoom: b.zoom }) 61 63 location.hash = '#!/' 62 64 } 63 65 ··· 65 67 await app.deleteBookmark(id) 66 68 } 67 69 68 - async #moveBookmark(id: string, folderId: string | null) { 69 - await app.moveBookmark(id, folderId) 70 - } 71 - 72 70 async #submitEditBookmark(e: Event) { 73 71 e.preventDefault() 74 72 if (!this.editingBookmark) return 75 73 const form = e.target as HTMLFormElement 76 74 const fd = new FormData(form) 77 - const name = (fd.get('name') as string).trim() 78 - const address = (fd.get('address') as string).trim() || undefined 79 - const notes = (fd.get('notes') as string).trim() || undefined 80 - const folderId = (fd.get('folderId') as string) || null 81 - if (!name) return 75 + const name = (fd.get('name') as string).trim() || undefined 76 + const addressText = (fd.get('address') as string).trim() || undefined 77 + const description = (fd.get('description') as string).trim() || undefined 78 + const collectionId = (fd.get('collectionId') as string) || null 79 + const categories = collectionId ? [collectionId] : [] 82 80 await app.updateBookmark(this.editingBookmark.id, { 83 - name, 84 - address, 85 - notes, 86 - folderId, 81 + properties: { 82 + ...this.editingBookmark.properties, 83 + name, 84 + description, 85 + address: addressText 86 + ? { 87 + ...this.editingBookmark.properties.address, 88 + displayText: addressText, 89 + } 90 + : this.editingBookmark.properties.address, 91 + }, 92 + categories, 87 93 }) 88 94 this.editingBookmark = null 89 95 this.requestUpdate() ··· 102 108 if (!file) return 103 109 input.value = '' 104 110 this.importError = null 105 - this.importTargetFolderId = null 111 + this.importTargetCollectionId = null 106 112 try { 107 113 if (file.name.endsWith('.kmz')) { 108 - this.pendingImport = await parseKmz(await file.arrayBuffer()) 114 + this.pendingImport = parseKmz(await file.arrayBuffer()) 109 115 } else if (file.name.endsWith('.gpx')) { 110 116 this.pendingImport = parseGpx(await file.text()) 111 117 } else if (file.name.endsWith('.json')) { ··· 117 123 for (const bm of this.pendingImport.bookmarks) { 118 124 bm.isDuplicate = existing.some( 119 125 (e) => 120 - Math.abs(e.lat - bm.lat) < 0.0001 && 121 - Math.abs(e.lng - bm.lng) < 0.0001, 126 + Math.abs(e.geometry.coordinates[1] - bm.lat) < 0.0001 && 127 + Math.abs(e.geometry.coordinates[0] - bm.lng) < 0.0001, 122 128 ) 123 129 } 124 130 } catch (err) { ··· 132 138 async #confirmImport() { 133 139 if (!this.pendingImport) return 134 140 const { folders, bookmarks } = this.pendingImport 135 - const targetFolderId = this.importTargetFolderId 141 + const targetCollectionId = this.importTargetCollectionId 136 142 this.pendingImport = null 137 - this.importTargetFolderId = null 143 + this.importTargetCollectionId = null 138 144 this.requestUpdate() 139 - const folderIdMap = new Map<string, string>() 145 + const collectionIdMap = new Map<string, string>() 140 146 for (const folder of folders) { 141 - await app.addFolder(folder.name) 142 - const created = app.bookmarkFolders.find((f) => f.name === folder.name) 143 - if (created) folderIdMap.set(folder.tempId, created.id) 147 + await app.addCollection(folder.name) 148 + const created = app.bookmarkCollections.find((c) => 149 + c.name === folder.name 150 + ) 151 + if (created) collectionIdMap.set(folder.tempId, created.id) 144 152 } 145 153 for (const bm of bookmarks) { 146 154 if (bm.isDuplicate) continue 147 - const folderId = targetFolderId ?? 148 - (bm.folderTempId ? (folderIdMap.get(bm.folderTempId) ?? null) : null) 149 - await app.addBookmark(bm.name, bm.lat, bm.lng, 12, folderId, bm.address) 155 + const collectionId = targetCollectionId ?? 156 + (bm.folderTempId 157 + ? (collectionIdMap.get(bm.folderTempId) ?? null) 158 + : null) 159 + const categories = collectionId ? [collectionId] : [] 160 + await app.addBookmark(bm.lat, bm.lng, 12, bm.properties, categories) 150 161 } 151 162 } 152 163 153 - async #deleteFolder(id: string) { 154 - await app.deleteFolder(id) 155 - this.expandedFolders.delete(id) 164 + async #deleteCollection(id: string) { 165 + await app.deleteCollection(id) 166 + this.expandedCollections.delete(id) 156 167 } 157 168 158 - async #submitAddFolder(e: Event) { 169 + async #submitAddCollection(e: Event) { 159 170 e.preventDefault() 160 171 const form = e.target as HTMLFormElement 161 172 const fd = new FormData(form) 162 173 const name = (fd.get('name') as string).trim() 163 174 const color = (fd.get('color') as string) || undefined 164 175 if (!name) return 165 - await app.addFolder(name, color) 176 + await app.addCollection(name, color) 166 177 form.reset() 167 - this.showAddFolder = false 178 + this.showAddCollection = false 168 179 this.requestUpdate() 169 180 } 170 181 171 - async #submitEditFolder(e: Event) { 182 + async #submitEditCollection(e: Event) { 172 183 e.preventDefault() 173 - if (!this.editingFolder) return 184 + if (!this.editingCollection) return 174 185 const form = e.target as HTMLFormElement 175 186 const fd = new FormData(form) 176 187 const name = (fd.get('name') as string).trim() 177 188 const color = (fd.get('color') as string) || undefined 178 189 if (!name) return 179 - await app.updateFolder(this.editingFolder.id, { name, color }) 180 - this.editingFolder = null 190 + await app.updateCollection(this.editingCollection.id, { name, color }) 191 + this.editingCollection = null 181 192 this.requestUpdate() 182 193 } 183 194 ··· 189 200 const lat = parseFloat(fd.get('lat') as string) 190 201 const lng = parseFloat(fd.get('lng') as string) 191 202 const zoom = parseFloat(fd.get('zoom') as string) || 12 192 - const folderId = (fd.get('folderId') as string) || null 203 + const collectionId = (fd.get('collectionId') as string) || null 204 + const categories = collectionId ? [collectionId] : [] 193 205 if (!name || isNaN(lat) || isNaN(lng)) return 194 - await app.addBookmark(name, lat, lng, zoom, folderId) 206 + await app.addBookmark(lat, lng, zoom, { name }, categories) 195 207 form.reset() 196 208 this.showAddBookmark = false 197 209 this.requestUpdate() 198 210 } 199 211 200 212 override render(): TemplateResult { 201 - const unfiled = this.bookmarks.filter((b) => b.folderId === null) 202 - const hasFolders = this.folders.length > 0 203 - const isEmpty = this.bookmarks.length === 0 && !hasFolders 213 + const uncategorized = this.bookmarks.filter((b) => 214 + b.categories.length === 0 215 + ) 216 + const hasCollections = this.collections.length > 0 217 + const isEmpty = this.bookmarks.length === 0 && !hasCollections 204 218 205 219 return html` 206 220 <div class="bm-toolbar"> 207 221 <button 208 222 @click="${() => { 209 - this.showAddFolder = !this.showAddFolder 223 + this.showAddCollection = !this.showAddCollection 210 224 this.showAddBookmark = false 211 225 this.requestUpdate() 212 226 }}" 213 227 > 214 - New Folder 228 + New Collection 215 229 </button> 216 230 <button 217 231 @click="${() => { 218 232 this.showAddBookmark = !this.showAddBookmark 219 - this.showAddFolder = false 233 + this.showAddCollection = false 220 234 this.requestUpdate() 221 235 }}" 222 236 > ··· 241 255 ? html` 242 256 <p class="bm-import-error">${this.importError}</p> 243 257 ` 244 - : ''} ${this.showAddFolder 258 + : ''} ${this.showAddCollection 245 259 ? html` 246 - <form class="bm-add-form" @submit="${this.#submitAddFolder}"> 260 + <form class="bm-add-form" @submit="${this.#submitAddCollection}"> 247 261 <input 248 262 name="name" 249 263 type="text" 250 - placeholder="Folder name" 264 + placeholder="Collection name" 251 265 required 252 266 autocomplete="off" 253 267 autofocus 254 268 > 255 269 <div class="bm-folder-color-row"> 256 - <label for="add-folder-color">Color</label> 270 + <label for="add-collection-color">Color</label> 257 271 <input 258 - id="add-folder-color" 272 + id="add-collection-color" 259 273 name="color" 260 274 type="color" 261 275 value="#e05c2a" ··· 266 280 <button 267 281 type="button" 268 282 @click="${() => { 269 - this.showAddFolder = false 283 + this.showAddCollection = false 270 284 this.requestUpdate() 271 285 }}" 272 286 > ··· 279 293 ? html` 280 294 <p class="bm-empty">No bookmarks yet. Add one with the button above.</p> 281 295 ` 282 - : ''} ${this.folders.map((folder) => { 283 - const items = this.bookmarks.filter((b) => b.folderId === folder.id) 284 - const expanded = this.expandedFolders.has(folder.id) 296 + : ''} ${this.collections.map((collection) => { 297 + const items = this.bookmarks.filter((b) => 298 + b.categories.includes(collection.id) 299 + ) 300 + const expanded = this.expandedCollections.has(collection.id) 285 301 return html` 286 302 <div class="bm-folder"> 287 303 <div 288 304 class="bm-folder-header" 289 305 role="button" 290 - @click="${() => this.#toggleFolder(folder.id)}" 306 + @click="${() => this.#toggleCollection(collection.id)}" 291 307 > 292 308 <span class="bm-folder-toggle" aria-hidden="true">${expanded 293 309 ? '▾' 294 310 : '▸'}</span> 295 - ${folder.color 311 + ${collection.color 296 312 ? html` 297 313 <span 298 314 class="bm-folder-swatch" 299 - style="background:${folder.color}" 315 + style="background:${collection.color}" 300 316 aria-hidden="true" 301 317 ></span> 302 318 ` 303 319 : ''} 304 - <span class="bm-folder-name">${folder.name}</span> 320 + <span class="bm-folder-name">${collection.name}</span> 305 321 <span class="bm-folder-count">${items.length}</span> 306 322 <button 307 323 class="bm-icon-btn" 308 - aria-label="Edit folder" 324 + aria-label="Edit collection" 309 325 @click="${(e: Event) => { 310 326 e.stopPropagation() 311 - this.editingFolder = folder 327 + this.editingCollection = collection 312 328 this.requestUpdate() 313 329 }}" 314 330 > ··· 316 332 </button> 317 333 <button 318 334 class="bm-icon-btn" 319 - aria-label="Delete folder" 335 + aria-label="Delete collection" 320 336 @click="${(e: Event) => { 321 337 e.stopPropagation() 322 - this.#deleteFolder(folder.id) 338 + this.#deleteCollection(collection.id) 323 339 }}" 324 340 > 325 341 <img src="/static/icons/x.svg" alt="" aria-hidden="true"> ··· 330 346 <div class="bm-folder-body"> 331 347 ${items.length === 0 332 348 ? html` 333 - <p class="bm-empty bm-empty--folder">No bookmarks in this folder.</p> 349 + <p class="bm-empty bm-empty--folder">No bookmarks in this collection.</p> 334 350 ` 335 351 : items.map((b) => this.#renderBookmark(b))} 336 352 </div> ··· 338 354 : ''} 339 355 </div> 340 356 ` 341 - })} ${unfiled.length > 0 357 + })} ${uncategorized.length > 0 342 358 ? html` 343 359 <div class="bm-section"> 344 - ${hasFolders 360 + ${hasCollections 345 361 ? html` 346 362 <h3 class="bm-section-title">No Category</h3> 347 363 ` 348 - : ''} ${unfiled.map((b) => this.#renderBookmark(b))} 364 + : ''} ${uncategorized.map((b) => this.#renderBookmark(b))} 349 365 </div> 350 366 ` 351 367 : ''} ${this.showAddBookmark ··· 373 389 max="22" 374 390 > 375 391 </div> 376 - ${hasFolders 392 + ${hasCollections 377 393 ? html` 378 - <select name="folderId"> 394 + <select name="collectionId"> 379 395 <option value="">No Category</option> 380 - ${this.folders.map((f) => 396 + ${this.collections.map((c) => 381 397 html` 382 - <option value="${f.id}">${f.name}</option> 398 + <option value="${c.id}">${c.name}</option> 383 399 ` 384 400 )} 385 401 </select> ··· 405 421 ?open="${this.pendingImport !== null}" 406 422 @dismiss="${() => { 407 423 this.pendingImport = null 408 - this.importTargetFolderId = null 424 + this.importTargetCollectionId = null 409 425 this.requestUpdate() 410 426 }}" 411 427 > ··· 436 452 } 437 453 if (folderCount > 0) { 438 454 parts.push( 439 - `${folderCount} folder${folderCount === 1 ? '' : 's'}`, 455 + `${folderCount} collection${ 456 + folderCount === 1 ? '' : 's' 457 + }`, 440 458 ) 441 459 } 442 460 return parts.join(' · ') || 'Nothing to import' ··· 450 468 return html` 451 469 <div class="bm-import-group"> 452 470 <p class="bm-import-group-name">${folder.name}</p> 453 - ${items.map( 454 - (b) => 455 - html` 456 - <div 457 - class="bm-import-item${b.isDuplicate 458 - ? ' bm-import-item--duplicate' 459 - : ''}" 460 - > 461 - <span class="bm-item-name">${b.name}</span> 462 - <span class="bm-item-coords">${b.lat.toFixed( 463 - 4, 464 - )}, ${b.lng.toFixed(4)}</span> 465 - ${b.isDuplicate 466 - ? html` 467 - <span class="bm-import-dupe-badge">exists</span> 468 - ` 469 - : ''} 470 - </div> 471 - `, 472 - )} 471 + ${items.map((b) => this.#renderImportItem(b))} 473 472 </div> 474 473 ` 475 474 })} ${this.pendingImport.bookmarks.filter( ··· 483 482 ` 484 483 : ''} ${this.pendingImport.bookmarks 485 484 .filter((b) => b.folderTempId === null) 486 - .map( 487 - (b) => 488 - html` 489 - <div 490 - class="bm-import-item${b.isDuplicate 491 - ? ' bm-import-item--duplicate' 492 - : ''}" 493 - > 494 - <span class="bm-item-name">${b.name}</span> 495 - <span class="bm-item-coords">${b.lat.toFixed( 496 - 4, 497 - )}, ${b.lng.toFixed(4)}</span> 498 - ${b.isDuplicate 499 - ? html` 500 - <span class="bm-import-dupe-badge">exists</span> 501 - ` 502 - : ''} 503 - </div> 504 - `, 505 - )} 485 + .map((b) => this.#renderImportItem(b))} 506 486 </div> 507 487 ` 508 488 : ''} 509 489 </div> 510 490 ${this.pendingImport.folders.length === 0 && 511 - this.folders.length > 0 491 + this.collections.length > 0 512 492 ? html` 513 493 <select 514 - .value="${this.importTargetFolderId ?? ''}" 494 + .value="${this.importTargetCollectionId ?? ''}" 515 495 @change="${(e: Event) => { 516 - this.importTargetFolderId = 496 + this.importTargetCollectionId = 517 497 (e.target as HTMLSelectElement).value || null 518 498 }}" 519 499 > 520 500 <option value="">No Category</option> 521 - ${this.folders.map( 522 - (f) => 501 + ${this.collections.map( 502 + (c) => 523 503 html` 524 504 <option 525 - value="${f.id}" 526 - ?selected="${this.importTargetFolderId === f.id}" 505 + value="${c.id}" 506 + ?selected="${this.importTargetCollectionId === 507 + c.id}" 527 508 > 528 - ${f.name} 509 + ${c.name} 529 510 </option> 530 511 `, 531 512 )} ··· 538 519 type="button" 539 520 @click="${() => { 540 521 this.pendingImport = null 541 - this.importTargetFolderId = null 522 + this.importTargetCollectionId = null 542 523 this.requestUpdate() 543 524 }}" 544 525 > ··· 568 549 name="name" 569 550 type="text" 570 551 placeholder="Name" 571 - .value="${this.editingBookmark.name}" 572 - required 552 + .value="${this.editingBookmark.properties.name ?? ''}" 573 553 autocomplete="off" 574 554 autofocus 575 555 > 576 556 <textarea 577 557 name="address" 578 558 placeholder="Address" 579 - rows="3" 559 + rows="2" 580 560 autocomplete="off" 581 - .value="${this.editingBookmark.address ?? ''}" 561 + .value="${this.editingBookmark.properties.address 562 + ?.displayText ?? ''}" 582 563 ></textarea> 583 564 <textarea 584 - name="notes" 585 - placeholder="Notes" 565 + name="description" 566 + placeholder="Description" 586 567 rows="4" 587 568 autocomplete="off" 588 - .value="${this.editingBookmark.notes ?? ''}" 569 + .value="${this.editingBookmark.properties.description ?? 570 + ''}" 589 571 ></textarea> 590 - <p class="bm-dialog-coords">${this.editingBookmark.lat 591 - .toFixed(5)}, ${this.editingBookmark.lng.toFixed( 572 + <p class="bm-dialog-coords"> 573 + ${this.editingBookmark.geometry.coordinates[1].toFixed( 592 574 5, 593 - )} · zoom ${this.editingBookmark.zoom.toFixed(1)}</p> 594 - ${this.folders.length > 0 575 + )}, ${this.editingBookmark.geometry.coordinates[0].toFixed( 576 + 5, 577 + )} · zoom ${this.editingBookmark.zoom.toFixed(1)} 578 + </p> 579 + ${this.collections.length > 0 595 580 ? html` 596 - <select name="folderId"> 581 + <select name="collectionId"> 597 582 <option 598 583 value="" 599 - ?selected="${this.editingBookmark.folderId === null}" 584 + ?selected="${this.editingBookmark.categories 585 + .length === 586 + 0}" 600 587 > 601 588 No Category 602 589 </option> 603 - ${this.folders.map((f) => 590 + ${this.collections.map((c) => 604 591 html` 605 592 <option 606 - value="${f.id}" 607 - ?selected="${this.editingBookmark!.folderId === 608 - f.id}" 593 + value="${c.id}" 594 + ?selected="${this.editingBookmark!.categories 595 + .includes(c.id)}" 609 596 > 610 - ${f.name} 597 + ${c.name} 611 598 </option> 612 599 ` 613 600 )} ··· 640 627 </dialog> 641 628 </ui-dialog> 642 629 <ui-dialog 643 - ?open="${this.editingFolder !== null}" 630 + ?open="${this.editingCollection !== null}" 644 631 @dismiss="${() => { 645 - this.editingFolder = null 632 + this.editingCollection = null 646 633 this.requestUpdate() 647 634 }}" 648 635 > 649 636 <dialog> 650 637 <article class="bm-dialog"> 651 - ${this.editingFolder 638 + ${this.editingCollection 652 639 ? html` 653 - <h2 class="bm-dialog-title">Edit Folder</h2> 654 - <form @submit="${this.#submitEditFolder}"> 640 + <h2 class="bm-dialog-title">Edit Collection</h2> 641 + <form @submit="${this.#submitEditCollection}"> 655 642 <input 656 643 name="name" 657 644 type="text" 658 - placeholder="Folder name" 659 - .value="${this.editingFolder.name}" 645 + placeholder="Collection name" 646 + .value="${this.editingCollection.name}" 660 647 required 661 648 autocomplete="off" 662 649 autofocus 663 650 > 664 651 <div class="bm-folder-color-row"> 665 - <label for="edit-folder-color">Color</label> 652 + <label for="edit-collection-color">Color</label> 666 653 <input 667 - id="edit-folder-color" 654 + id="edit-collection-color" 668 655 name="color" 669 656 type="color" 670 - .value="${this.editingFolder.color ?? '#e05c2a'}" 657 + .value="${this.editingCollection.color ?? '#e05c2a'}" 671 658 > 672 659 </div> 673 660 <div class="bm-form-actions"> ··· 675 662 <button 676 663 type="button" 677 664 @click="${() => { 678 - this.editingFolder = null 665 + this.editingCollection = null 679 666 this.requestUpdate() 680 667 }}" 681 668 > ··· 691 678 ` 692 679 } 693 680 681 + #renderImportItem(b: ImportResult['bookmarks'][number]): TemplateResult { 682 + const name = b.properties.displayName ?? b.properties.name ?? 683 + b.properties.address?.displayText ?? 684 + `${b.lat.toFixed(4)}, ${b.lng.toFixed(4)}` 685 + return html` 686 + <div 687 + class="bm-import-item${b.isDuplicate 688 + ? ' bm-import-item--duplicate' 689 + : ''}" 690 + > 691 + <span class="bm-item-name">${name}</span> 692 + <span class="bm-item-coords">${b.lat.toFixed(4)}, ${b.lng.toFixed( 693 + 4, 694 + )}</span> 695 + ${b.isDuplicate 696 + ? html` 697 + <span class="bm-import-dupe-badge">exists</span> 698 + ` 699 + : ''} 700 + </div> 701 + ` 702 + } 703 + 694 704 #renderBookmark(b: Bookmark): TemplateResult { 705 + const [lng, lat] = b.geometry.coordinates 695 706 return html` 696 707 <div class="bm-item"> 697 708 <div class="bm-item-info"> 698 - <span class="bm-item-name">${b.name}</span> 699 - <span class="bm-item-coords">${b.lat.toFixed(5)}, ${b.lng.toFixed( 709 + <span class="bm-item-name">${bookmarkDisplayName(b)}</span> 710 + <span class="bm-item-coords">${lat.toFixed(5)}, ${lng.toFixed( 700 711 5, 701 712 )}</span> 702 713 </div>
+26 -27
www/routes/map.ts
··· 6 6 import layers from '../utils/layers.ts' 7 7 import worldLayers from '../utils/world_layers.ts' 8 8 import app from '../models/app.ts' 9 + import { bookmarkDisplayName } from '../models/schema.ts' 10 + import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 9 11 import { nominatimReverse } from '../utils/nominatim.ts' 10 12 11 13 const protocol = new Protocol() ··· 52 54 ` 53 55 } 54 56 55 - override async firstUpdated(): Promise<void> { 57 + override firstUpdated(): void { 56 58 const container = this.querySelector<HTMLElement>('#map') 57 59 if (!container) return 58 60 ··· 163 165 164 166 button.addEventListener('click', async () => { 165 167 button.disabled = true 166 - await app.addBookmark( 167 - input.value.trim() || name, 168 - lat, 169 - lng, 170 - zoom, 171 - null, 172 - address, 173 - ) 168 + await app.addBookmark(lat, lng, zoom, { 169 + name: input.value.trim() || name, 170 + address: address ? { displayText: address } : undefined, 171 + }) 174 172 button.textContent = 'Saved!' 175 173 }) 176 174 ··· 210 208 button.textContent = 'Add to bookmarks' 211 209 button.style.cssText = 'width:100%' 212 210 213 - let resolvedAddress: string | undefined 211 + // Resolved nominatim properties to use when saving 212 + let resolvedProperties: ReturnType<typeof nominatimToProperties> | undefined 214 213 215 214 button.addEventListener('click', async () => { 216 215 button.disabled = true 217 - await app.addBookmark( 218 - input.value.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`, 219 - lat, 220 - lng, 221 - zoom, 222 - null, 223 - resolvedAddress, 224 - ) 216 + const coordsFallback = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 217 + const name = input.value.trim() || coordsFallback 218 + await app.addBookmark(lat, lng, zoom, { 219 + ...(resolvedProperties ?? {}), 220 + name, 221 + }) 225 222 button.textContent = 'Saved!' 226 223 }) 227 224 ··· 243 240 addrEl.remove() 244 241 return 245 242 } 246 - resolvedAddress = result.display_name 243 + resolvedProperties = nominatimToProperties(result) 247 244 addrEl.textContent = result.display_name 248 245 const coordsPlaceholder = `${lat.toFixed(5)}, ${lng.toFixed(5)}` 249 246 if (input.value === coordsPlaceholder) { ··· 256 253 257 254 #renderBookmarkMarkers() { 258 255 if (!this.#map) return 259 - const folderColors = new Map( 260 - app.bookmarkFolders.map((f) => [f.id, f.color ?? null]), 256 + const collectionColors = new Map( 257 + app.bookmarkCollections.map((c) => [c.id, c.color ?? null]), 261 258 ) 262 259 const geojson = { 263 260 type: 'FeatureCollection' as const, 264 261 features: app.bookmarks.map((b) => ({ 265 - type: 'Feature' as const, 266 - geometry: { type: 'Point' as const, coordinates: [b.lng, b.lat] }, 262 + ...b, 267 263 properties: { 268 - name: b.name, 269 - color: b.folderId ? (folderColors.get(b.folderId) ?? null) : null, 264 + ...b.properties, 265 + _displayName: bookmarkDisplayName(b), 266 + _color: b.categories[0] 267 + ? (collectionColors.get(b.categories[0]) ?? null) 268 + : null, 270 269 }, 271 270 })), 272 271 } ··· 284 283 source: 'bookmarks', 285 284 paint: { 286 285 'circle-radius': 8, 287 - 'circle-color': ['coalesce', ['get', 'color'], '#e05c2a'], 286 + 'circle-color': ['coalesce', ['get', '_color'], '#e05c2a'], 288 287 'circle-stroke-width': 2, 289 288 'circle-stroke-color': '#fff', 290 289 }, ··· 294 293 const feature = e.features[0] 295 294 const coords = (feature.geometry as { coordinates: [number, number] }) 296 295 .coordinates 297 - const name = feature.properties?.name as string 296 + const name = feature.properties?._displayName as string 298 297 this.#bookmarkPopup?.remove() 299 298 this.#bookmarkPopup = new maplibregl.Popup({ offset: 10 }) 300 299 .setLngLat(coords)
+80 -72
www/routes/search.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 - import { type Bookmark } from '../models/schema.ts' 3 + import { type Bookmark, bookmarkDisplayName } from '../models/schema.ts' 4 + import { from as nominatimToProperties } from '../models/adapters/nominatim.ts' 4 5 import { setMapNav } from '../utils/nav.ts' 5 6 import { coordsFromDirectInput } from '../utils/geocode.ts' 6 7 import { type NominatimResult, nominatimSearch } from '../utils/nominatim.ts' ··· 25 26 private results: NominatimResult[] = [] 26 27 private loading = false 27 28 private error: string | null = null 29 + private savedResultIds = new Set<number>() 28 30 29 31 protected override createRenderRoot() { 30 32 return this ··· 55 57 } 56 58 57 59 const q = query.toLowerCase() 58 - this.bookmarkResults = app.bookmarks.filter((b) => 59 - b.name.toLowerCase().includes(q) || 60 - b.address?.toLowerCase().includes(q) 61 - ) 60 + this.bookmarkResults = app.bookmarks.filter((b) => { 61 + const name = bookmarkDisplayName(b).toLowerCase() 62 + return ( 63 + name.includes(q) || 64 + b.properties.address?.displayText?.toLowerCase().includes(q) || 65 + b.properties.displayName?.toLowerCase().includes(q) 66 + ) 67 + }) 62 68 63 69 if (!app.onlineSearchEnabled) { 64 70 this.loading = false ··· 71 77 this.loading = true 72 78 this.error = null 73 79 this.results = [] 80 + this.savedResultIds = new Set() 74 81 this.requestUpdate() 75 82 try { 76 83 this.results = await nominatimSearch(query) ··· 93 100 } 94 101 95 102 #handleBookmarkClick = (bookmark: Bookmark) => { 96 - setMapNav({ lat: bookmark.lat, lng: bookmark.lng, zoom: bookmark.zoom }) 103 + const [lng, lat] = bookmark.geometry.coordinates 104 + setMapNav({ lat, lng, zoom: bookmark.zoom }) 97 105 location.hash = '#!/' 98 106 } 99 107 ··· 108 116 address: result.display_name, 109 117 }) 110 118 location.hash = '#!/' 119 + } 120 + 121 + #handleSaveResult = async (result: NominatimResult) => { 122 + const lat = parseFloat(result.lat) 123 + const lng = parseFloat(result.lon) 124 + const zoom = zoomFromBoundingbox(result.boundingbox) 125 + await app.addBookmark(lat, lng, zoom, nominatimToProperties(result)) 126 + this.savedResultIds = new Set([...this.savedResultIds, result.place_id]) 127 + this.requestUpdate() 111 128 } 112 129 113 130 #handleInput = (e: Event) => { ··· 147 164 const q = this.inputValue.trim().toLowerCase() 148 165 const predBookmarks = q && !this.loading && !this.error && 149 166 this.bookmarkResults.length === 0 && this.results.length === 0 150 - ? app.bookmarks.filter((b) => 151 - b.name.toLowerCase().includes(q) || 152 - b.address?.toLowerCase().includes(q) 153 - ) 167 + ? app.bookmarks.filter((b) => { 168 + const name = bookmarkDisplayName(b).toLowerCase() 169 + return ( 170 + name.includes(q) || 171 + b.properties.address?.displayText?.toLowerCase().includes(q) || 172 + b.properties.displayName?.toLowerCase().includes(q) 173 + ) 174 + }) 154 175 : [] 155 176 const predHistory = q && !this.loading && !this.error && 156 177 this.bookmarkResults.length === 0 && this.results.length === 0 ··· 163 184 ? html` 164 185 <div class="search-history-list" role="list"> 165 186 ${this.bookmarkResults.map((b) => 166 - html` 167 - <button 168 - class="search-history-item" 169 - role="listitem" 170 - @click="${() => this.#handleBookmarkClick(b)}" 171 - > 172 - <span>${b.name}</span> 173 - <img 174 - src="/static/icons/bookmark.svg" 175 - alt="Bookmark" 176 - aria-hidden="true" 177 - style="width:16px;height:16px" 178 - > 179 - </button> 180 - ` 187 + this.#renderBookmarkResult(b) 181 188 )} 182 189 </div> 183 190 ` ··· 198 205 return html` 199 206 <div class="search-history-list" role="list"> 200 207 ${this.bookmarkResults.map((b) => 201 - html` 202 - <button 203 - class="search-history-item" 204 - role="listitem" 205 - @click="${() => this.#handleBookmarkClick(b)}" 206 - > 207 - <span>${b.name}</span> 208 - <img 209 - src="/static/icons/bookmark.svg" 210 - alt="Bookmark" 211 - aria-hidden="true" 212 - style="width:16px;height:16px" 213 - > 214 - </button> 215 - ` 208 + this.#renderBookmarkResult(b) 216 209 )} ${this.results.map((result) => 217 - html` 218 - <button 219 - class="search-history-item" 220 - role="listitem" 221 - @click="${() => this.#handleResultClick(result)}" 222 - > 223 - <span>${result.display_name}</span> 224 - <img 225 - src="/static/icons/navigation.svg" 226 - alt="" 227 - aria-hidden="true" 228 - style="width:16px;height:16px" 229 - > 230 - </button> 231 - ` 210 + this.#renderNominatimResult(result) 232 211 )} 233 212 </div> 234 213 ` ··· 240 219 ? html` 241 220 <p class="search-section-title">Bookmarks</p> 242 221 <div class="search-history-list" role="list"> 243 - ${predBookmarks.map((b) => 244 - html` 245 - <button 246 - class="search-history-item" 247 - role="listitem" 248 - @click="${() => this.#handleBookmarkClick(b)}" 249 - > 250 - <span>${b.name}</span> 251 - <img 252 - src="/static/icons/bookmark.svg" 253 - alt="Bookmark" 254 - aria-hidden="true" 255 - style="width:16px;height:16px" 256 - > 257 - </button> 258 - ` 259 - )} 222 + ${predBookmarks.map((b) => this.#renderBookmarkResult(b))} 260 223 </div> 261 224 ` 262 225 : html` ··· 322 285 <p class="search-empty">Search for a location to get started.</p> 323 286 ` 324 287 })()} 288 + ` 289 + } 290 + 291 + #renderBookmarkResult(b: Bookmark): TemplateResult { 292 + return html` 293 + <button 294 + class="search-history-item" 295 + role="listitem" 296 + @click="${() => this.#handleBookmarkClick(b)}" 297 + > 298 + <span>${bookmarkDisplayName(b)}</span> 299 + <img 300 + src="/static/icons/bookmark.svg" 301 + alt="Bookmark" 302 + aria-hidden="true" 303 + style="width:16px;height:16px" 304 + > 305 + </button> 306 + ` 307 + } 308 + 309 + #renderNominatimResult(result: NominatimResult): TemplateResult { 310 + const saved = this.savedResultIds.has(result.place_id) 311 + return html` 312 + <div class="search-history-item search-result-row" role="listitem"> 313 + <button 314 + class="search-result-name" 315 + @click="${() => this.#handleResultClick(result)}" 316 + > 317 + <span>${result.display_name}</span> 318 + </button> 319 + <button 320 + class="search-result-save" 321 + aria-label="${saved ? 'Saved' : 'Save bookmark'}" 322 + ?disabled="${saved}" 323 + @click="${() => this.#handleSaveResult(result)}" 324 + > 325 + <img 326 + src="/static/icons/bookmark.svg" 327 + alt="" 328 + aria-hidden="true" 329 + style="width:16px;height:16px;opacity:${saved ? '1' : '0.4'}" 330 + > 331 + </button> 332 + </div> 325 333 ` 326 334 } 327 335 }
+4 -4
www/routes/settings-downloads.ts
··· 131 131 return html` 132 132 <details> 133 133 <summary>${slugToLabel(key)}</summary> 134 - ${node.children.size > 0 ? this.#renderTree(node.children) : ''} 135 - ${directTiles.length > 0 134 + ${node.children.size > 0 135 + ? this.#renderTree(node.children) 136 + : ''} ${directTiles.length > 0 136 137 ? html` 137 138 <div class="tile-list"> 138 139 ${directTiles.map((tile) => this.#renderTileItem(tile))} ··· 177 178 ${filteredTiles.map((tile) => this.#renderTileItem(tile))} 178 179 </div> 179 180 ` 180 - : this.#renderTree(this.#buildTree(this.tiles)) 181 - } 181 + : this.#renderTree(this.#buildTree(this.tiles))} 182 182 </section> 183 183 ` 184 184 }
-115
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 - isDuplicate?: boolean 12 - } 13 - 14 - export type ImportResult = { 15 - folders: ParsedFolder[] 16 - bookmarks: ParsedBookmark[] 17 - } 18 - 19 - export function parseKml(text: string): ImportResult { 20 - const doc = new DOMParser().parseFromString(text, 'text/xml') 21 - const folders: ParsedFolder[] = [] 22 - const bookmarks: ParsedBookmark[] = [] 23 - let counter = 0 24 - 25 - // Prefer <Document> as root container, fall back to document element 26 - const root = doc.querySelector('Document') ?? doc.documentElement 27 - 28 - for (const child of root.children) { 29 - if (child.localName === 'Folder') { 30 - const tempId = `f${counter++}` 31 - const name = child.querySelector('name')?.textContent?.trim() ?? 32 - 'Imported Folder' 33 - folders.push({ tempId, name }) 34 - // Collect all placemarks inside this folder (nested folders are flattened) 35 - for (const pm of child.querySelectorAll('Placemark')) { 36 - const bm = parsePlacemark(pm, tempId) 37 - if (bm) bookmarks.push(bm) 38 - } 39 - } else if (child.localName === 'Placemark') { 40 - const bm = parsePlacemark(child, null) 41 - if (bm) bookmarks.push(bm) 42 - } 43 - } 44 - 45 - return { folders, bookmarks } 46 - } 47 - 48 - export function parseGpx(text: string): ImportResult { 49 - const doc = new DOMParser().parseFromString(text, 'text/xml') 50 - const bookmarks: ParsedBookmark[] = [] 51 - 52 - for (const wpt of doc.querySelectorAll('wpt')) { 53 - const lat = parseFloat(wpt.getAttribute('lat') ?? '') 54 - const lng = parseFloat(wpt.getAttribute('lon') ?? '') 55 - if (isNaN(lat) || isNaN(lng)) continue 56 - const name = wpt.querySelector('name')?.textContent?.trim() ?? 'Waypoint' 57 - const desc = wpt.querySelector('desc')?.textContent?.trim() || undefined 58 - bookmarks.push({ name, lat, lng, folderTempId: null, address: desc }) 59 - } 60 - 61 - return { folders: [], bookmarks } 62 - } 63 - 64 - export function parseGeoJson(text: string): ImportResult { 65 - const data = JSON.parse(text) 66 - const bookmarks: ParsedBookmark[] = [] 67 - 68 - const features = data.type === 'FeatureCollection' 69 - ? data.features 70 - : data.type === 'Feature' 71 - ? [data] 72 - : [] 73 - 74 - for (const feature of features) { 75 - if (feature?.geometry?.type !== 'Point') continue 76 - const [lng, lat] = feature.geometry.coordinates as [number, number] 77 - if (isNaN(lat) || isNaN(lng)) continue 78 - const props = feature.properties ?? {} 79 - const loc = props.location as Record<string, string> | undefined 80 - const name = ( 81 - (props.name ?? props.Name ?? loc?.name) as string | undefined 82 - )?.trim() || 'Unnamed' 83 - const address = ( 84 - (props.address ?? loc?.address ?? props.description) as string | undefined 85 - )?.trim() || undefined 86 - bookmarks.push({ name, lat, lng, folderTempId: null, address }) 87 - } 88 - 89 - return { folders: [], bookmarks } 90 - } 91 - 92 - export async function parseKmz(buffer: ArrayBuffer): Promise<ImportResult> { 93 - const files = unzipSync(new Uint8Array(buffer)) 94 - const entry = Object.keys(files).find((k) => k === 'doc.kml') ?? 95 - Object.keys(files).find((k) => k.endsWith('.kml')) 96 - if (!entry) throw new Error('No KML file found in KMZ archive') 97 - return parseKml(new TextDecoder().decode(files[entry])) 98 - } 99 - 100 - function parsePlacemark( 101 - el: Element, 102 - folderTempId: string | null, 103 - ): ParsedBookmark | null { 104 - const coordText = el.querySelector('coordinates')?.textContent?.trim() 105 - if (!coordText) return null 106 - // KML coordinates are lng,lat,alt — take the first point 107 - const parts = coordText.split(/\s+/)[0].split(',') 108 - if (parts.length < 2) return null 109 - const lng = parseFloat(parts[0]) 110 - const lat = parseFloat(parts[1]) 111 - if (isNaN(lat) || isNaN(lng)) return null 112 - const name = el.querySelector('name')?.textContent?.trim() ?? 'Unnamed' 113 - const address = el.querySelector('address')?.textContent?.trim() || undefined 114 - return { name, lat, lng, folderTempId, address } 115 - }
+26
www/utils/nominatim.ts
··· 4 4 return { 'Accept-Language': navigator.language || 'en' } 5 5 } 6 6 7 + export interface NominatimAddress { 8 + city?: string 9 + city_district?: string 10 + country?: string 11 + country_code?: string 12 + house_number?: string 13 + historic?: string 14 + 'ISO3166-2-lvl4'?: string 15 + 'ISO3166-2-lvl6'?: string 16 + postcode?: string 17 + region?: string 18 + road?: string 19 + suburb?: string 20 + state?: string 21 + } 22 + 7 23 export interface NominatimResult { 8 24 place_id: number 9 25 display_name: string 10 26 lat: string 11 27 lon: string 12 28 boundingbox: [string, string, string, string] 29 + name?: string 30 + class?: string 31 + type?: string 32 + importance?: number 33 + place_rank?: number 34 + osm_type?: string 35 + osm_id?: number 36 + address?: NominatimAddress 13 37 } 14 38 15 39 export async function nominatimSearch( ··· 20 44 url.searchParams.set('q', query) 21 45 url.searchParams.set('format', 'json') 22 46 url.searchParams.set('limit', String(limit)) 47 + url.searchParams.set('addressdetails', '1') 23 48 const res = await fetch(url.toString(), { headers: headers() }) 24 49 if (!res.ok) throw new Error(`Nominatim error: ${res.status}`) 25 50 return res.json() as Promise<NominatimResult[]> ··· 33 58 url.searchParams.set('lat', String(lat)) 34 59 url.searchParams.set('lon', String(lon)) 35 60 url.searchParams.set('format', 'json') 61 + url.searchParams.set('addressdetails', '1') 36 62 const res = await fetch(url.toString(), { headers: headers() }) 37 63 if (!res.ok) throw new Error(`Nominatim error: ${res.status}`) 38 64 return res.json() as Promise<NominatimResult>