An app for logging board climbs
0
fork

Configure Feed

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

feat: move to civility/store@1.0.0

+239 -253
+3 -2
deno.json
··· 18 18 "fetch:benchmarks": "deno run --allow-net --allow-read --allow-write --allow-env scripts/moon.ts" 19 19 }, 20 20 "imports": { 21 - "@civility/sync": "jsr:@civility/sync@^0.1.0", 22 - "@civility/store": "jsr:@civility/store@^0.3.0", 21 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.2", 22 + "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.2/idb", 23 + "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.2", 23 24 "@civility/ui": "jsr:@civility/ui@^0.2.2", 24 25 "@civility/workers": "jsr:@civility/workers@^0.2.3", 25 26 "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.2",
+16 -113
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@civility/store@0.3": "0.3.0", 5 - "jsr:@civility/sync@0.1": "0.1.0", 4 + "jsr:@civility/store@^1.0.0-beta.2": "1.0.0-beta.2", 6 5 "jsr:@civility/ui@~0.2.2": "0.2.2", 7 6 "jsr:@civility/workers@~0.2.3": "0.2.3", 8 7 "jsr:@inro/simple-tools@~0.5.2": "0.5.2", 9 - "jsr:@paulmillr/qr@~0.5.5": "0.5.5", 10 - "jsr:@std/assert@^1.0.17": "1.0.19", 11 - "jsr:@std/assert@^1.0.19": "1.0.19", 12 - "jsr:@std/async@^1.1.0": "1.2.0", 13 - "jsr:@std/collections@^1.1.0": "1.1.6", 14 - "jsr:@std/data-structures@^1.0.10": "1.0.10", 15 8 "jsr:@std/dotenv@~0.225.6": "0.225.6", 16 - "jsr:@std/fs@^1.0.17": "1.0.23", 17 - "jsr:@std/fs@^1.0.22": "1.0.23", 18 - "jsr:@std/fs@^1.0.23": "1.0.23", 19 9 "jsr:@std/html@^1.0.5": "1.0.5", 20 - "jsr:@std/internal@^1.0.12": "1.0.12", 21 - "jsr:@std/path@^1.1.4": "1.1.4", 22 10 "jsr:@std/semver@^1.0.8": "1.0.8", 23 - "jsr:@std/testing@^1.0.17": "1.0.17", 11 + "jsr:@std/ulid@1": "1.0.0", 24 12 "jsr:@zod/zod@^4.3.6": "4.3.6", 25 - "npm:@tauri-apps/plugin-store@^2.2.0": "2.4.2", 13 + "npm:fast-json-patch@^3.1.1": "3.1.1", 26 14 "npm:hammerjs@^2.0.8": "2.0.8", 27 - "npm:lit@^3.3.2": "3.3.2", 28 - "npm:native-file-system-adapter@^3.0.1": "3.0.1", 29 - "npm:ts-fsrs@5": "5.2.3" 15 + "npm:lit@^3.3.2": "3.3.2" 30 16 }, 31 17 "jsr": { 32 - "@civility/store@0.3.0": { 33 - "integrity": "56c1cd6124a29450f35921734fb306d2410026a5d7ffdc031ba5a5ac6f74c9d8", 18 + "@civility/store@1.0.0-beta.2": { 19 + "integrity": "8c112db7c5e1e4d52d19a0a3a4f90eab2e3c6ffe3a11ae173bc8ed637d11df05", 34 20 "dependencies": [ 35 - "jsr:@std/fs@^1.0.23", 36 - "jsr:@std/semver" 37 - ] 38 - }, 39 - "@civility/sync@0.1.0": { 40 - "integrity": "1cfbde030fd0866a91aa85acaf5d23619bbc0588ed899a17d764f84ba7b9ca9d", 41 - "dependencies": [ 42 - "jsr:@civility/store", 43 - "jsr:@paulmillr/qr", 44 - "npm:native-file-system-adapter" 21 + "jsr:@std/semver", 22 + "jsr:@std/ulid", 23 + "npm:fast-json-patch" 45 24 ] 46 25 }, 47 26 "@civility/ui@0.2.2": { ··· 55 34 "integrity": "84130ff9b3c5d0ee133d8ed076dd86d5ea4a3bb8f49c06c114959eb4e0c66602" 56 35 }, 57 36 "@inro/simple-tools@0.5.2": { 58 - "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5", 59 - "dependencies": [ 60 - "jsr:@std/collections", 61 - "jsr:@std/fs@^1.0.17", 62 - "npm:@tauri-apps/plugin-store", 63 - "npm:ts-fsrs" 64 - ] 65 - }, 66 - "@paulmillr/qr@0.5.5": { 67 - "integrity": "2f8ff22c8d2194f2147eac1b3093f5e85f648c0a8005d5635a617fb72bf5ae38" 68 - }, 69 - "@std/assert@1.0.19": { 70 - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", 71 - "dependencies": [ 72 - "jsr:@std/internal" 73 - ] 74 - }, 75 - "@std/async@1.2.0": { 76 - "integrity": "c059c6f6d95ca7cc012ae8e8d7164d1697113d54b0b679e4372b354b11c2dee5" 77 - }, 78 - "@std/collections@1.1.6": { 79 - "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" 80 - }, 81 - "@std/data-structures@1.0.10": { 82 - "integrity": "f574f86b0e07c69b9edc555fcc814b57d29258bad39fd5a34ba8a80ecf033cfe" 37 + "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5" 83 38 }, 84 39 "@std/dotenv@0.225.6": { 85 40 "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 86 41 }, 87 - "@std/fs@1.0.23": { 88 - "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 89 - "dependencies": [ 90 - "jsr:@std/path" 91 - ] 92 - }, 93 42 "@std/html@1.0.5": { 94 43 "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 95 44 }, 96 - "@std/internal@1.0.12": { 97 - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 98 - }, 99 - "@std/path@1.1.4": { 100 - "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 101 - "dependencies": [ 102 - "jsr:@std/internal" 103 - ] 104 - }, 105 45 "@std/semver@1.0.8": { 106 46 "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" 107 47 }, 108 - "@std/testing@1.0.17": { 109 - "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", 110 - "dependencies": [ 111 - "jsr:@std/assert@^1.0.17", 112 - "jsr:@std/async", 113 - "jsr:@std/data-structures", 114 - "jsr:@std/fs@^1.0.22", 115 - "jsr:@std/internal", 116 - "jsr:@std/path" 117 - ] 48 + "@std/ulid@1.0.0": { 49 + "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" 118 50 }, 119 51 "@zod/zod@4.3.6": { 120 52 "integrity": "7144e5e11f8ffc3cf6e2fca624f6597a8762898aac9868cc8938e9398b96ffe4" ··· 130 62 "@lit-labs/ssr-dom-shim" 131 63 ] 132 64 }, 133 - "@tauri-apps/api@2.10.1": { 134 - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==" 135 - }, 136 - "@tauri-apps/plugin-store@2.4.2": { 137 - "integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==", 138 - "dependencies": [ 139 - "@tauri-apps/api" 140 - ] 141 - }, 142 65 "@types/trusted-types@2.0.7": { 143 66 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 144 67 }, 145 - "fetch-blob@3.2.0": { 146 - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 147 - "dependencies": [ 148 - "node-domexception", 149 - "web-streams-polyfill" 150 - ] 68 + "fast-json-patch@3.1.1": { 69 + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" 151 70 }, 152 71 "hammerjs@2.0.8": { 153 72 "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==" ··· 173 92 "lit-element", 174 93 "lit-html" 175 94 ] 176 - }, 177 - "native-file-system-adapter@3.0.1": { 178 - "integrity": "sha512-ocuhsYk2SY0906LPc3QIMW+rCV3MdhqGiy7wV5Bf0e8/5TsMjDdyIwhNiVPiKxzTJLDrLT6h8BoV9ERfJscKhw==", 179 - "optionalDependencies": [ 180 - "fetch-blob" 181 - ] 182 - }, 183 - "node-domexception@1.0.0": { 184 - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 185 - "deprecated": true 186 - }, 187 - "ts-fsrs@5.2.3": { 188 - "integrity": "sha512-R3IjceC9WfnvUin6Nx+DwqEzh3Qil6Gg2yEHqvocUcC7Nbi+xDrFg/1fKaYBT0tJedDnDAguXMSX0hijhi859w==" 189 - }, 190 - "web-streams-polyfill@3.3.3": { 191 - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" 192 95 } 193 96 }, 194 97 "workspace": { 195 98 "dependencies": [ 196 - "jsr:@civility/store@0.3", 197 - "jsr:@civility/sync@0.1", 99 + "jsr:@civility/store@^1.0.0-beta.2", 100 + "jsr:@civility/sync@^1.0.0-beta.2", 198 101 "jsr:@civility/ui@~0.2.2", 199 102 "jsr:@civility/workers@~0.2.3", 200 103 "jsr:@inro/simple-tools@~0.5.2",
+65 -32
www/models/app.ts
··· 1 - import State from '@civility/store/state' 2 - import useJSON from '@civility/sync/json' 3 1 import { 4 - AppSettings, 5 - AppState, 2 + type AppSettings, 3 + type AppState, 6 4 ClimbLogEntry, 7 5 ClimbSession, 8 - settingsMigrationConfig, 6 + defaultAppSettings, 9 7 } from './schema.ts' 10 - import { Store } from './store.ts' 8 + import { LogbookStore } from './store.ts' 11 9 import type { Benchmark } from '../utils/benchmarks.ts' 12 10 13 11 export interface NavState { ··· 19 17 backRoute: string 20 18 } 21 19 22 - const storage = useJSON<AppSettings>('mb-settings', AppSettings.parse({}), { 23 - migrations: settingsMigrationConfig, 24 - }) 20 + class Settings { 21 + #state: AppSettings 22 + #key: string 23 + #listeners = new Set<() => void>() 24 + 25 + constructor(key: string) { 26 + this.#key = key 27 + const saved = localStorage.getItem(key) 28 + this.#state = saved 29 + ? { ...defaultAppSettings, ...JSON.parse(saved) } 30 + : { ...defaultAppSettings } 31 + } 32 + 33 + get state(): AppSettings { 34 + return this.#state 35 + } 25 36 26 - export class App extends State<AppState> { 27 - store: Store 28 - settings: State<AppSettings> 37 + update(partial: Partial<AppSettings>): void { 38 + Object.assign(this.#state, partial) 39 + localStorage.setItem(this.#key, JSON.stringify(this.#state)) 40 + for (const fn of this.#listeners) fn() 41 + } 42 + 43 + addEventListener(fn: () => void): void { 44 + this.#listeners.add(fn) 45 + } 46 + 47 + removeEventListener(fn: () => void): void { 48 + this.#listeners.delete(fn) 49 + } 50 + } 51 + 52 + export class App { 53 + store: LogbookStore 54 + settings: Settings 29 55 navState: NavState | null = null 56 + #appState: AppState = { error: null } 57 + #listeners = new Set<() => void>() 30 58 31 59 constructor() { 32 - super(AppState.parse({})) 33 - this.store = new Store('mb-data') 34 - this.settings = new State(AppSettings.parse({}), { storage }) 35 - this.settings.waitUntilReady().then(() => this.connectStore()) 60 + this.store = new LogbookStore() 61 + this.settings = new Settings('mb-settings') 62 + this.store.addEventListener(() => this.notify()) 36 63 this.settings.addEventListener(() => this.notify()) 37 64 } 38 65 66 + get state(): AppState { 67 + return this.#appState 68 + } 69 + 70 + // Notify all listeners (routes call requestUpdate) 71 + notify(): void { 72 + for (const fn of this.#listeners) fn() 73 + } 74 + 75 + addEventListener(fn: () => void): void { 76 + this.#listeners.add(fn) 77 + } 78 + 79 + removeEventListener(fn: () => void): void { 80 + this.#listeners.delete(fn) 81 + } 82 + 39 83 setNav(s: NavState): void { 40 84 this.navState = s 41 85 } ··· 44 88 return this.navState 45 89 } 46 90 47 - // ====== SYNCLINK CONNECTION ====== 48 - 49 - async connectStore(): Promise<void> { 50 - if (this.settings.state.syncLinkUrl?.trim()) { 51 - await this.store.connect(this.settings.state.syncLinkUrl) 52 - } else { 53 - this.store.disconnect() 54 - } 55 - } 56 - 57 91 // ====== LOGBOOK ACCESSORS ====== 58 92 59 93 get logbook(): Record<string, ClimbLogEntry> { ··· 116 150 // ====== SETTINGS ====== 117 151 118 152 updateSettings(partial: Partial<AppSettings>): void { 119 - Object.assign(this.settings.state, partial) 120 - this.settings.notify() 153 + this.settings.update(partial) 121 154 } 122 155 123 156 setStopwatch(startedAt: string | null): void { ··· 127 160 // ====== UTILITY ====== 128 161 129 162 clearError(): void { 130 - this.state.error = null 163 + this.#appState.error = null 131 164 this.notify() 132 165 } 133 166 ··· 174 207 }> { 175 208 try { 176 209 await this.store.clearAllData() 177 - this.state.error = null 210 + this.#appState.error = null 178 211 this.notify() 179 212 return { success: true, path: '' } 180 213 } catch (error) { ··· 186 219 } 187 220 } 188 221 189 - dispose(): void { 190 - this.store.dispose() 222 + async dispose(): Promise<void> { 223 + await this.store.dispose() 191 224 } 192 225 } 193 226
+18 -35
www/models/schema.ts
··· 1 - export { AppState, ClimbLogEntry, ClimbSession } from './schema/v0.ts' 2 - import { AppSettings, StoreState } from './schema/v0.ts' 1 + export { 2 + AppSettings, 3 + type AppState, 4 + ClimbLogEntry, 5 + ClimbLogEntryJsonSchema, 6 + ClimbSession, 7 + defaultAppSettings, 8 + StoreState, 9 + } from './schema/v0.ts' 3 10 4 - const currentVersion = globalThis.__APP_VERSION__ 11 + import type { SchemaConfig } from '@civility/store' 12 + import { ClimbLogEntryJsonSchema } from './schema/v0.ts' 5 13 6 - export const storeMigrationConfig = { 7 - currentVersion, 8 - extractVersion: (data: unknown): string | undefined => 9 - data && typeof data === 'object' && 'version' in data 10 - ? (data as { version: string }).version 11 - : undefined, 12 - migrations: [{ 13 - fromVersion: '<1.0.0', 14 - toVersion: currentVersion, 15 - migrate: (data: unknown) => ({ 16 - ...data as StoreState, 17 - version: globalThis.__APP_VERSION__, 18 - }), 19 - }], 14 + export const logbookSchema: SchemaConfig = { 15 + versions: [ 16 + { 17 + version: '1.0.0', 18 + schema: ClimbLogEntryJsonSchema, 19 + }, 20 + ], 20 21 } 21 - 22 - export const settingsMigrationConfig = { 23 - currentVersion, 24 - extractVersion: (data: unknown): string | undefined => 25 - data && typeof data === 'object' && 'version' in data 26 - ? (data as { version: string }).version 27 - : undefined, 28 - migrations: [{ 29 - fromVersion: '<1.0.0', 30 - toVersion: currentVersion, 31 - migrate: (data: unknown) => ({ 32 - ...data as AppSettings, 33 - version: globalThis.__APP_VERSION__, 34 - }), 35 - }], 36 - } 37 - 38 - export { AppSettings, StoreState }
+11 -4
www/models/schema/v0.ts
··· 1 1 import { z } from '@zod/zod' 2 + import type { JsonSchema } from '@civility/store' 2 3 3 4 export const ClimbSession = z.object({ 4 5 date: z.string(), ··· 22 23 }) 23 24 export type ClimbLogEntry = z.infer<typeof ClimbLogEntry> 24 25 26 + export const ClimbLogEntryJsonSchema = z.toJSONSchema( 27 + ClimbLogEntry, 28 + ) as JsonSchema 29 + 30 + // Old StoreState shape — used only for importing legacy exports 25 31 export const StoreState = z.object({ 26 32 version: z.string().optional(), 27 33 logbook: z.record(z.string(), ClimbLogEntry).default({}), ··· 40 46 }) 41 47 export type AppSettings = z.infer<typeof AppSettings> 42 48 43 - export const AppState = z.object({ 44 - error: z.string().nullable().default(null), 45 - }) 46 - export type AppState = z.infer<typeof AppState> 49 + export const defaultAppSettings: AppSettings = AppSettings.parse({}) 50 + 51 + export interface AppState { 52 + error: string | null 53 + }
+126 -67
www/models/store.ts
··· 1 - import SyncLink from '@civility/sync' 2 - import useJSON from '@civility/sync/json' 3 - import { ClimbLogEntry, storeMigrationConfig, StoreState } from './schema.ts' 1 + import { Store, type StoreExport } from '@civility/store' 2 + import { IDBStorage } from '@civility/store/idb' 3 + import { type ClimbLogEntry, logbookSchema, type StoreState } from './schema.ts' 4 4 5 - export class Store { 6 - #sync: SyncLink<StoreState> 5 + const backend = new IDBStorage<ClimbLogEntry>({ dbName: 'moonboard' }) 7 6 8 - constructor(name: string) { 9 - this.#sync = new SyncLink( 10 - useJSON<StoreState>(name, StoreState.parse({}), { 11 - migrations: storeMigrationConfig, 12 - }), 13 - ) 14 - } 7 + export class LogbookStore { 8 + #store: Store<ClimbLogEntry> 9 + #cache: Record<string, ClimbLogEntry> = {} 10 + #ready: Promise<void> 11 + #listeners = new Set<() => void>() 15 12 16 - // ====== SYNCLINK CONNECTION ====== 13 + constructor() { 14 + this.#store = new Store<ClimbLogEntry>(backend, { 15 + name: 'logbook', 16 + schema: logbookSchema, 17 + }) 17 18 18 - async connect(url: string): Promise<void> { 19 - if (!url.trim()) { 20 - throw new Error('SyncLink URL is required') 21 - } 22 - const { baseUrl, appId, token } = SyncLink.parseURL(url) 23 - await this.#sync.connect(baseUrl, appId, token) 24 - } 19 + this.#store.subscribeAll((id, data) => { 20 + if (data) this.#cache[id] = data 21 + else delete this.#cache[id] 22 + for (const fn of this.#listeners) fn() 23 + }) 25 24 26 - disconnect(): void { 27 - this.#sync.disconnect() 25 + this.#ready = this.#loadCache() 28 26 } 29 27 30 - get isConnected(): boolean { 31 - return this.#sync.isConnected 28 + async #loadCache(): Promise<void> { 29 + const all = await this.#store.getAll() 30 + for (const [id, data] of all) { 31 + this.#cache[id] = data 32 + } 33 + // Notify listeners that were attached while IDB was still loading 34 + for (const fn of this.#listeners) fn() 32 35 } 33 36 34 - addEventListener(fn: () => void): void { 35 - this.#sync.addEventListener(fn) 37 + waitUntilReady(): Promise<void> { 38 + return this.#ready 36 39 } 37 40 38 - removeEventListener(fn: () => void): void { 39 - this.#sync.removeEventListener(fn) 41 + // Synchronous cache access for routes 42 + get logbook(): Record<string, ClimbLogEntry> { 43 + return this.#cache 40 44 } 41 45 42 - // ====== STATE ACCESS ====== 43 - 44 - get state() { 45 - return this.#sync.state 46 + // Async CRUD 47 + async getEntry(climbId: number): Promise<ClimbLogEntry | null> { 48 + return await this.#store.get(climbId.toString()) ?? null 46 49 } 47 50 48 - get logbook(): Record<string, ClimbLogEntry> { 49 - return this.#sync.state.data.logbook 51 + async saveEntry(entry: ClimbLogEntry): Promise<void> { 52 + await this.#store.set(entry.climbId.toString(), entry) 50 53 } 51 54 52 - // ====== LOGBOOK CRUD ====== 53 - 54 - async getEntry(climbId: number): Promise<ClimbLogEntry | null> { 55 - const data = await this.#sync.get() 56 - return data.logbook[climbId.toString()] ?? null 55 + async deleteEntry(climbId: number): Promise<void> { 56 + await this.#store.delete(climbId.toString()) 57 57 } 58 58 59 - async saveEntry(entry: ClimbLogEntry): Promise<boolean> { 60 - const data = await this.#sync.get() 61 - data.logbook[entry.climbId.toString()] = entry 62 - return await this.#sync.set(data) 59 + async clearAllData(): Promise<void> { 60 + const ids = await this.#store.list() 61 + for (const id of ids) { 62 + await this.#store.delete(id) 63 + } 64 + this.#cache = {} 65 + for (const fn of this.#listeners) fn() 63 66 } 64 67 65 - async deleteEntry(climbId: number): Promise<boolean> { 66 - const data = await this.#sync.get() 67 - const key = climbId.toString() 68 - if (!(key in data.logbook)) return false 69 - delete data.logbook[key] 70 - return await this.#sync.set(data) 68 + // Reactivity 69 + addEventListener(fn: () => void): void { 70 + this.#listeners.add(fn) 71 71 } 72 72 73 - async getAllEntries(): Promise<ClimbLogEntry[]> { 74 - const data = await this.#sync.get() 75 - return Object.values(data.logbook) 73 + removeEventListener(fn: () => void): void { 74 + this.#listeners.delete(fn) 76 75 } 77 76 78 - // ====== UTILITY ====== 79 - 80 - async clearAllData(): Promise<void> { 81 - await this.#sync.set(StoreState.parse({})) 82 - } 77 + // Export/Import 83 78 84 - exportToFile(filename?: string): Promise<{ 79 + async exportToFile(filename?: string): Promise<{ 85 80 success: boolean 86 81 path: string 87 82 error?: string 88 83 }> { 89 84 try { 90 - return this.#sync.exportToFile(filename || 'moonboard-data') 85 + const data = await this.#store.export() 86 + const json = JSON.stringify(data, null, 2) 87 + const blob = new Blob([json], { type: 'application/json' }) 88 + const url = URL.createObjectURL(blob) 89 + const name = filename || `moonboard-export_${Date.now()}` 90 + 91 + const a = document.createElement('a') 92 + a.href = url 93 + a.download = `${name}.json` 94 + a.click() 95 + URL.revokeObjectURL(url) 96 + 97 + return { success: true, path: a.download } 91 98 } catch (error) { 92 - return Promise.resolve({ 99 + return { 93 100 success: false, 94 101 path: '', 95 102 error: error instanceof Error ? error.message : 'Export failed', 96 - }) 103 + } 97 104 } 98 105 } 99 106 100 - importFromFile(): Promise<{ 107 + async importFromFile(): Promise<{ 101 108 success: boolean 102 109 path: string 103 110 error?: string 104 111 }> { 105 112 try { 106 - return this.#sync.importFromFile() 113 + const text = await pickFile() 114 + const raw = JSON.parse(text) 115 + 116 + if (raw.documents) { 117 + // New StoreExport format 118 + await this.#store.import(raw as StoreExport<ClimbLogEntry>) 119 + } else { 120 + // Legacy format: logbook may be at root or nested under `data` 121 + const logbook = raw.logbook ?? raw.data?.logbook 122 + if (logbook && typeof logbook === 'object') { 123 + await this.#importLegacy({ logbook } as StoreState) 124 + } else { 125 + return { 126 + success: false, 127 + path: '', 128 + error: 'Unrecognized export format', 129 + } 130 + } 131 + } 132 + 133 + // Rebuild cache 134 + this.#cache = {} 135 + const all = await this.#store.getAll() 136 + for (const [id, data] of all) { 137 + this.#cache[id] = data 138 + } 139 + for (const fn of this.#listeners) fn() 140 + 141 + return { success: true, path: '' } 107 142 } catch (error) { 108 - return Promise.resolve({ 143 + return { 109 144 success: false, 110 145 path: '', 111 146 error: error instanceof Error ? error.message : 'Import failed', 112 - }) 147 + } 113 148 } 114 149 } 115 150 116 - dispose(): void { 117 - this.#sync.dispose() 151 + async #importLegacy(data: StoreState): Promise<void> { 152 + for (const [id, entry] of Object.entries(data.logbook)) { 153 + await this.#store.set(id, entry) 154 + } 118 155 } 156 + 157 + async dispose(): Promise<void> { 158 + this.#listeners.clear() 159 + await this.#store.dispose() 160 + } 161 + } 162 + 163 + function pickFile(): Promise<string> { 164 + return new Promise((resolve, reject) => { 165 + const input = document.createElement('input') 166 + input.type = 'file' 167 + input.accept = '.json' 168 + input.onchange = () => { 169 + const file = input.files?.[0] 170 + if (!file) return reject(new Error('No file selected')) 171 + const reader = new FileReader() 172 + reader.onload = () => resolve(reader.result as string) 173 + reader.onerror = () => reject(reader.error) 174 + reader.readAsText(file) 175 + } 176 + input.click() 177 + }) 119 178 }