···11+/**
22+ * Moonboard → @civility/store 1.0.0 migration bookmarklet
33+ *
44+ * Reads `moonboard-data` from localStorage (old @civility/store / SyncLink format),
55+ * runs the migration chain inline, and downloads a StoreExport JSON file
66+ * that can be imported into the new app via Store.import().
77+ *
88+ * Migration chain:
99+ * v0 (no version field, logbook record) → stamp TARGET_VERSION
1010+ *
1111+ * StoreExport shape produced:
1212+ * {
1313+ * version, exportedAt,
1414+ * documents: {},
1515+ * collections: { logbook },
1616+ * }
1717+ *
1818+ * Run in the browser console on either the old or new moonboard app version.
1919+ * Then use the app's Import function to load the downloaded JSON.
2020+ */
2121+; (() => {
2222+ // ---------------------------------------------------------------------------
2323+ // Config — adjust TARGET_VERSION to match the new app's declared version.
2424+ // ---------------------------------------------------------------------------
2525+ const LS_KEY = 'mb-data'
2626+ const TARGET_VERSION = '0.1.4'
2727+ const ORIGIN = 'migrate'
2828+ const NOW = new Date().toISOString()
2929+ const BASE_MS = Date.now()
3030+3131+ // ---------------------------------------------------------------------------
3232+ // Minimal helpers
3333+ // ---------------------------------------------------------------------------
3434+3535+ const ENC = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
3636+ function ulid() {
3737+ let id = '', t = BASE_MS
3838+ for (let i = 9; i >= 0; i--) {
3939+ id = ENC[t % 32] + id
4040+ t = Math.floor(t / 32)
4141+ }
4242+ const bytes = crypto.getRandomValues(new Uint8Array(10))
4343+ for (const b of bytes) id += ENC[b % 32]
4444+ return id
4545+ }
4646+4747+ let _counter = 0
4848+ function makeHLC() {
4949+ return `${String(BASE_MS).padStart(15, '0')}-${String(_counter++).padStart(5, '0')
5050+ }-${ORIGIN}`
5151+ }
5252+5353+ function makeDocumentRecord(scopedId, data, hlc) {
5454+ return {
5555+ id: scopedId,
5656+ data,
5757+ version: TARGET_VERSION,
5858+ hlc,
5959+ lastOrigin: ORIGIN,
6060+ createdAt: NOW,
6161+ updatedAt: NOW,
6262+ }
6363+ }
6464+6565+ function makeChangeEntry(scopedId, data, hlc) {
6666+ return {
6767+ id: ulid(),
6868+ documentId: scopedId,
6969+ patch: [{ op: 'replace', path: '/', value: data }],
7070+ inversePatch: [],
7171+ hlc,
7272+ origin: ORIGIN,
7373+ createdAt: NOW,
7474+ synced: false,
7575+ }
7676+ }
7777+7878+ /**
7979+ * Build a CollectionExport from an array of { id, data } items.
8080+ * `id` is the unscoped key; it will be stored as `${name}/${id}` in IDB.
8181+ */
8282+ function buildCollectionExport(name, items) {
8383+ const documents = {}
8484+ const changes = {}
8585+ for (const { id, data } of items) {
8686+ const scopedId = `${name}/${id}`
8787+ const hlc = makeHLC()
8888+ documents[id] = makeDocumentRecord(scopedId, data, hlc)
8989+ changes[id] = [makeChangeEntry(scopedId, data, hlc)]
9090+ }
9191+ return {
9292+ name,
9393+ version: TARGET_VERSION,
9494+ exportedAt: NOW,
9595+ documents,
9696+ changes,
9797+ }
9898+ }
9999+100100+ function downloadJSON(filename, payload) {
101101+ const blob = new Blob([JSON.stringify(payload, null, 2)], {
102102+ type: 'application/json',
103103+ })
104104+ const url = URL.createObjectURL(blob)
105105+ Object.assign(document.createElement('a'), {
106106+ href: url,
107107+ download: filename,
108108+ }).click()
109109+ URL.revokeObjectURL(url)
110110+ }
111111+112112+ // ---------------------------------------------------------------------------
113113+ // 1. Read localStorage
114114+ // ---------------------------------------------------------------------------
115115+116116+ const raw = localStorage.getItem(LS_KEY)
117117+ if (!raw) {
118118+ console.error(
119119+ `[moonboard-migrate] No data found at localStorage key "${LS_KEY}".`,
120120+ )
121121+ console.info('Available keys:', Object.keys(localStorage))
122122+ return
123123+ }
124124+125125+ let data
126126+ try {
127127+ data = JSON.parse(JSON.parse(raw).data)
128128+ } catch (e) {
129129+ console.error('[moonboard-migrate] Failed to parse localStorage JSON:', e)
130130+ return
131131+ }
132132+133133+ console.log('[moonboard-migrate] Raw data version:', data.version ?? '(none)')
134134+135135+ // ---------------------------------------------------------------------------
136136+ // 2. Migration chain
137137+ // ---------------------------------------------------------------------------
138138+ // The moonboard schema has only one version (v0). No structural changes.
139139+ // Stamp the target version and done.
140140+141141+ data = { ...data, version: TARGET_VERSION }
142142+143143+ // ---------------------------------------------------------------------------
144144+ // 3. Summary
145145+ // ---------------------------------------------------------------------------
146146+147147+ const logbook = data.logbook ?? {}
148148+ const entryCount = Object.keys(logbook).length
149149+ const sentCount = Object.values(logbook).filter((e) => e.sent).length
150150+ const totalAttempts = Object.values(logbook).reduce(
151151+ (n, e) => n + (e.totalAttempts ?? 0),
152152+ 0,
153153+ )
154154+155155+ console.group('[moonboard-migrate] Migrated data summary')
156156+ console.log('version:', data.version)
157157+ console.log('logbook entries:', entryCount)
158158+ console.log('problems sent:', sentCount)
159159+ console.log('total attempts:', totalAttempts)
160160+ console.groupEnd()
161161+162162+ if (entryCount === 0) {
163163+ console.warn(
164164+ '[moonboard-migrate] No logbook entries found — nothing to export.',
165165+ )
166166+ return
167167+ }
168168+169169+ // ---------------------------------------------------------------------------
170170+ // 4. Build StoreExport
171171+ // ---------------------------------------------------------------------------
172172+173173+ // logbook: one doc per climbId (keys are already strings in the old record)
174174+ const logbookItems = Object.entries(logbook).map(([climbId, entry]) => ({
175175+ id: String(climbId),
176176+ data: entry,
177177+ }))
178178+179179+ const storeExport = {
180180+ version: TARGET_VERSION,
181181+ exportedAt: NOW,
182182+ documents: {},
183183+ collections: {
184184+ logbook: buildCollectionExport('logbook', logbookItems),
185185+ },
186186+ }
187187+188188+ // ---------------------------------------------------------------------------
189189+ // 5. Download
190190+ // ---------------------------------------------------------------------------
191191+192192+ const filename = `moonboard-export_${BASE_MS}.json`
193193+ downloadJSON(filename, storeExport)
194194+195195+ console.log(`[moonboard-migrate] ✅ Downloaded "${filename}"`)
196196+ console.log(` logbook entries: ${entryCount}`)
197197+ console.log(` problems sent: ${sentCount}`)
198198+ console.info(
199199+ "[moonboard-migrate] Use the app's Import function to load this file.",
200200+ )
201201+})()
-1
www/models/store.ts
···117117 // New StoreExport format
118118 await this.#store.import(raw as StoreExport<ClimbLogEntry>)
119119 } else {
120120- // Legacy format: logbook may be at root or nested under `data`
121120 const logbook = raw.logbook ?? raw.data?.logbook
122121 if (logbook && typeof logbook === 'object') {
123122 await this.#importLegacy({ logbook } as StoreState)