this repo has no description
0
fork

Configure Feed

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

feat: add migration script

+424 -26
+1 -1
deno.json
··· 1 1 { 2 - "version": "3.9.1", 2 + "version": "4.0.0", 3 3 "workspace": ["./data"], 4 4 "compilerOptions": { 5 5 "lib": [
+314
www/models/migration/pre-4.0.0.ts
··· 1 + const LS_KEY = 'hanzi-data' 2 + const TARGET_VERSION = '1.0.0' 3 + const ORIGIN = 'migrate' 4 + const NOW = new Date().toISOString() 5 + const BASE_MS = Date.now() 6 + 7 + const ENC = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' 8 + function ulid() { 9 + let id = '', t = BASE_MS 10 + for (let i = 9; i >= 0; i--) { 11 + id = ENC[t % 32] + id 12 + t = Math.floor(t / 32) 13 + } 14 + const bytes = crypto.getRandomValues(new Uint8Array(10)) 15 + for (const b of bytes) id += ENC[b % 32] 16 + return id 17 + } 18 + 19 + let _counter = 0 20 + function makeHLC() { 21 + return `${String(BASE_MS).padStart(15, '0')}-${ 22 + String(_counter++).padStart(5, '0') 23 + }-${ORIGIN}` 24 + } 25 + 26 + function makeDocumentRecord(scopedId: string, data: unknown, hlc: string) { 27 + return { 28 + id: scopedId, 29 + data, 30 + version: TARGET_VERSION, 31 + hlc, 32 + lastOrigin: ORIGIN, 33 + createdAt: NOW, 34 + updatedAt: NOW, 35 + } 36 + } 37 + 38 + function makeChangeEntry(scopedId: string, data: unknown, hlc: string) { 39 + return { 40 + id: ulid(), 41 + documentId: scopedId, 42 + patch: [{ op: 'replace' as const, path: '/', value: data }], 43 + inversePatch: [], 44 + hlc, 45 + origin: ORIGIN, 46 + createdAt: NOW, 47 + synced: false, 48 + } 49 + } 50 + 51 + function buildCollectionExport( 52 + name: string, 53 + items: { id: string; data: unknown }[], 54 + ) { 55 + const documents: Record<string, unknown> = {} 56 + const changes: Record<string, unknown[]> = {} 57 + for (const { id, data } of items) { 58 + const scopedId = `${name}/${id}` 59 + const hlc = makeHLC() 60 + documents[id] = makeDocumentRecord(scopedId, data, hlc) 61 + changes[id] = [makeChangeEntry(scopedId, data, hlc)] 62 + } 63 + return { 64 + name, 65 + version: TARGET_VERSION, 66 + exportedAt: NOW, 67 + documents, 68 + changes, 69 + } 70 + } 71 + 72 + function buildDocumentExport(name: string, data: unknown) { 73 + return buildCollectionExport(name, [{ id: '_doc', data }]) 74 + } 75 + 76 + function downloadJSON(filename: string, payload: unknown) { 77 + const blob = new Blob([JSON.stringify(payload, null, 2)], { 78 + type: 'application/json', 79 + }) 80 + const url = URL.createObjectURL(blob) 81 + Object.assign(document.createElement('a'), { 82 + href: url, 83 + download: filename, 84 + }).click() 85 + URL.revokeObjectURL(url) 86 + } 87 + 88 + function migrateAssignmentToV1(a: Record<string, unknown>) { 89 + let efactor = 0 90 + if (a.startedAt) efactor = 1 91 + if (a.passedAt) efactor = 5 92 + if (a.completedAt) efactor = 9 93 + return { ...a, efactor: a.efactor ?? efactor } 94 + } 95 + 96 + export function hasLegacyData(): boolean { 97 + return localStorage.getItem(LS_KEY) !== null 98 + } 99 + 100 + export function deleteLegacyData() { 101 + localStorage.removeItem(LS_KEY) 102 + } 103 + 104 + export async function migrateLegacyData() { 105 + const raw = localStorage.getItem(LS_KEY) 106 + if (!raw) { 107 + console.error( 108 + `[hanzi-migrate] No data found at localStorage key "${LS_KEY}".`, 109 + ) 110 + return 111 + } 112 + 113 + let data: Record<string, unknown> 114 + try { 115 + data = JSON.parse(JSON.parse(raw).data) 116 + } catch (e) { 117 + console.error('[hanzi-migrate] Failed to parse localStorage JSON:', e) 118 + return 119 + } 120 + 121 + console.log( 122 + '[hanzi-migrate] Raw data version:', 123 + data.version ?? '(none, treating as v0)', 124 + ) 125 + 126 + if (!data.version || data.version === '0.0.0') { 127 + console.log('[hanzi-migrate] v0 → v1...') 128 + const newAssignments: Record<string, Record<string, unknown>> = {} 129 + for (const [locale, localeData] of Object.entries(data.assignments ?? {})) { 130 + newAssignments[locale] = {} 131 + if (localeData && typeof localeData === 'object') { 132 + const entries = Array.isArray(localeData) 133 + ? (localeData as unknown[]).map(( 134 + a, 135 + ) => [(a as Record<string, unknown>).subjectId, a]) 136 + : Object.entries(localeData) 137 + for (const [subjectId, assignment] of entries) { 138 + if ( 139 + assignment && typeof assignment === 'object' && 140 + (assignment as Record<string, unknown>).subjectId 141 + ) { 142 + newAssignments[locale][subjectId as string] = migrateAssignmentToV1( 143 + assignment as Record<string, unknown>, 144 + ) 145 + } 146 + } 147 + } 148 + } 149 + data = { 150 + version: '1.0.0', 151 + assignments: newAssignments, 152 + settings: data.settings ?? {}, 153 + flags: data.flags ?? {}, 154 + overrides: data.overrides ?? {}, 155 + userLevels: data.userLevels ?? {}, 156 + } 157 + } 158 + 159 + if (typeof data.version === 'string' && data.version.startsWith('1.')) { 160 + console.log('[hanzi-migrate] v1 → v2...') 161 + data = { ...data, version: '2.0.0' } 162 + } 163 + 164 + if (typeof data.version === 'string' && data.version.startsWith('2.')) { 165 + console.log('[hanzi-migrate] v2 → v3 (subject ID remap)...') 166 + let idMap: Record<string, string> = {} 167 + try { 168 + const res = await fetch('/static/legacy_id_map.json') 169 + if (res.ok) { 170 + idMap = await res.json() 171 + console.log( 172 + `[hanzi-migrate] Loaded idMap from /static/legacy_id_map.json (${ 173 + Object.keys(idMap).length 174 + } entries)`, 175 + ) 176 + } else { 177 + console.warn( 178 + `[hanzi-migrate] Could not load idMap — server responded ${res.status}. Subject IDs will NOT be remapped.`, 179 + ) 180 + } 181 + } catch (e) { 182 + console.warn( 183 + '[hanzi-migrate] Could not load idMap — subject IDs will NOT be remapped.', 184 + e, 185 + ) 186 + } 187 + 188 + const LOCALES = ['zh-CN', 'zh-HK', 'zh-TW', 'ja'] 189 + const newAssignments: Record<string, Record<string, unknown>> = {} 190 + for (const locale of LOCALES) { 191 + const old = (data.assignments as Record<string, Record<string, unknown>>) 192 + ?.[locale] ?? {} 193 + newAssignments[locale] = {} 194 + for (const [oldId, assignment] of Object.entries(old)) { 195 + const newId = idMap[oldId] ?? oldId 196 + newAssignments[locale][newId] = { 197 + ...assignment as object, 198 + subjectId: newId, 199 + } 200 + } 201 + } 202 + 203 + const newOverrides: Record<string, unknown> = {} 204 + for (const [oldId, override] of Object.entries(data.overrides ?? {})) { 205 + const newId = idMap[oldId] ?? oldId 206 + newOverrides[newId] = { ...override as object, subjectId: newId } 207 + } 208 + 209 + data = { 210 + ...data as object, 211 + assignments: newAssignments, 212 + overrides: newOverrides, 213 + version: '3.0.0', 214 + } 215 + } 216 + 217 + data = { ...data as object, version: TARGET_VERSION } 218 + 219 + const locales = Object.keys(data.assignments ?? {}) 220 + const totalAssignments = locales.reduce( 221 + (n, l) => 222 + n + 223 + Object.keys( 224 + (data.assignments as Record<string, Record<string, unknown>>)[l] ?? {}, 225 + ).length, 226 + 0, 227 + ) 228 + 229 + console.group('[hanzi-migrate] Migrated data summary') 230 + console.log('version:', data.version) 231 + console.log('locales:', locales) 232 + console.log('total assignments:', totalAssignments) 233 + console.log('overrides:', Object.keys(data.overrides ?? {}).length) 234 + console.log( 235 + 'journal entries:', 236 + (data.games as Record<string, unknown[]>)?.journal?.length ?? 0, 237 + ) 238 + console.groupEnd() 239 + 240 + if ( 241 + totalAssignments === 0 && Object.keys(data.overrides ?? {}).length === 0 242 + ) { 243 + console.warn( 244 + '[hanzi-migrate] No meaningful data found — nothing to export.', 245 + ) 246 + return 247 + } 248 + 249 + const documents: Record<string, unknown> = { 250 + settings: buildDocumentExport('settings', data.settings ?? {}), 251 + flags: buildDocumentExport('flags', data.flags ?? {}), 252 + userLevels: buildDocumentExport('userLevels', data.userLevels ?? {}), 253 + } 254 + if (data.stats && Object.keys(data.stats).length > 0) { 255 + documents.stats = buildDocumentExport('stats', data.stats) 256 + } 257 + if ((data.searchHistory as unknown[])?.length) { 258 + documents.searchHistory = buildDocumentExport( 259 + 'searchHistory', 260 + data.searchHistory, 261 + ) 262 + } 263 + 264 + const assignmentItems: { id: string; data: unknown }[] = [] 265 + for (const [locale, localeMap] of Object.entries(data.assignments ?? {})) { 266 + for (const [subjectId, assignment] of Object.entries(localeMap ?? {})) { 267 + assignmentItems.push({ 268 + id: `${locale}__${subjectId}`, 269 + data: { ...assignment as object, locale, subjectId }, 270 + }) 271 + } 272 + } 273 + 274 + const overrideItems = Object.entries(data.overrides ?? {}).map(( 275 + [subjectId, override], 276 + ) => ({ 277 + id: subjectId, 278 + data: override, 279 + })) 280 + 281 + const journalItems = 282 + ((data.games as Record<string, unknown[]>)?.journal ?? []).map(( 283 + entry: unknown, 284 + i: number, 285 + ) => ({ 286 + id: (entry as { date?: string }).date ?? String(i), 287 + data: entry, 288 + })) 289 + 290 + const collections = { 291 + assignments: buildCollectionExport('assignments', assignmentItems), 292 + overrides: buildCollectionExport('overrides', overrideItems), 293 + journal: buildCollectionExport('journal', journalItems), 294 + } 295 + 296 + const storeExport = { 297 + version: TARGET_VERSION, 298 + exportedAt: NOW, 299 + documents, 300 + collections, 301 + } 302 + 303 + const filename = `hanzi-export_${BASE_MS}.json` 304 + downloadJSON(filename, storeExport) 305 + 306 + console.log(`[hanzi-migrate] ✅ Downloaded "${filename}"`) 307 + console.log(` documents: ${Object.keys(documents).join(', ')}`) 308 + console.log(` assignments: ${assignmentItems.length}`) 309 + console.log(` overrides: ${overrideItems.length}`) 310 + console.log(` journal: ${journalItems.length}`) 311 + console.info( 312 + "[hanzi-migrate] Use the app's Import function to load this file.", 313 + ) 314 + }
+70 -24
www/routes/main.ts
··· 4 4 import { deck, subjects } from '$/models/subjects.ts' 5 5 import getString from '$/utils/get_string.ts' 6 6 import { resolveLocale } from '$/utils/custom_sets.ts' 7 + import { 8 + deleteLegacyData, 9 + hasLegacyData, 10 + migrateLegacyData, 11 + } from '$/models/migration/pre-4.0.0.ts' 7 12 8 - /** 9 - * Home/main page: first-load dialog, study buttons, stats. 10 - * 11 - * @element r-main 12 - */ 13 13 const PRELOADED_PROGRESS_KEY = 'precached-progress' 14 14 15 15 function preloadProgress() { ··· 34 34 } 35 35 36 36 export class MainRoute extends LitElement { 37 + static override properties = { 38 + migrationDownloadClicked: { type: Boolean, state: true }, 39 + } 40 + 37 41 #onUpdate = () => this.requestUpdate() 42 + 43 + migrationDownloadClicked = false 38 44 39 45 override createRenderRoot() { 40 46 return this ··· 57 63 subjects.removeEventListener(this.#onUpdate) 58 64 } 59 65 60 - #renderFirstLoad() { 66 + #renderWelcome() { 67 + const hasLegacy = hasLegacyData() 61 68 const isFirstLoad = app.storeReady && app.flags?.isFirstLoad 62 - if (!isFirstLoad) return null 63 - const dismiss = async () => { 64 - await app.updateFlags({ isFirstLoad: false }) 69 + 70 + if (hasLegacy && isFirstLoad) { 71 + const dismiss = async () => { 72 + await app.updateFlags({ isFirstLoad: false }) 73 + } 74 + const handleDownload = () => { 75 + this.migrationDownloadClicked = true 76 + this.requestUpdate() 77 + migrateLegacyData() 78 + } 79 + const deleteAndDismiss = async () => { 80 + deleteLegacyData() 81 + await app.updateFlags({ isFirstLoad: false }) 82 + } 83 + return html` 84 + <ui-dialog open @dismiss="${dismiss}"> 85 + <dialog> 86 + <article> 87 + <h1>${getString('migration_title')}</h1> 88 + <p>${getString('migration_desc')}</p> 89 + <button @click="${handleDownload}"> 90 + ${getString('migration_download')} 91 + </button> 92 + <br><br> 93 + <button 94 + ?disabled="${!this.migrationDownloadClicked}" 95 + @click="${deleteAndDismiss}" 96 + > 97 + ${getString('migration_delete_dismiss')} 98 + </button> 99 + </article> 100 + </dialog> 101 + </ui-dialog> 102 + ` 65 103 } 66 - return html` 67 - <ui-dialog ?open="${isFirstLoad}" @dismiss="${dismiss}"> 68 - <dialog> 69 - <article> 70 - <h1>${getString('welcome')}</h1> 71 - <p>${getString('welcome_desc')}</p> 72 - <p>${getString('welcome_lang_prompt')}</p> 73 - <h-locale-select locale="${app.locale}"></h-locale-select> 74 - <br> 75 - <button @click="${dismiss}">${getString('lets_go')}</button> 76 - </article> 77 - </dialog> 78 - </ui-dialog> 79 - ` 104 + 105 + if (isFirstLoad) { 106 + const dismiss = async () => { 107 + await app.updateFlags({ isFirstLoad: false }) 108 + } 109 + return html` 110 + <ui-dialog ?open="${isFirstLoad}" @dismiss="${dismiss}"> 111 + <dialog> 112 + <article> 113 + <h1>${getString('welcome')}</h1> 114 + <p>${getString('welcome_desc')}</p> 115 + <p>${getString('welcome_lang_prompt')}</p> 116 + <h-locale-select locale="${app.locale}"></h-locale-select> 117 + <br> 118 + <button @click="${dismiss}">${getString('lets_go')}</button> 119 + </article> 120 + </dialog> 121 + </ui-dialog> 122 + ` 123 + } 124 + 125 + return null 80 126 } 81 127 82 128 #renderStudyButton(type: SessionType, num: number) { ··· 101 147 override render() { 102 148 const loading = !app.storeReady || !subjects.initialized 103 149 return html` 104 - ${this.#renderFirstLoad()} 150 + ${this.#renderWelcome()} 105 151 <div class="study-buttons"> 106 152 ${loading 107 153 ? html`
+7
www/static/strings/en.json
··· 85 85 "reset_help_dialogs": "Reset Help Dialogs", 86 86 "reset_progress": "Reset Progress", 87 87 "reviews": "Reviews", 88 + "skip": "Skip", 89 + "dismiss": "Dismiss", 90 + "migration_delete": "Delete Old Data", 91 + "migration_delete_dismiss": "Delete Old Data and Dismiss", 88 92 "transliteration": "Transliteration", 89 93 "save": "Save", 90 94 "search": "Search", ··· 116 120 "welcome": "Welcome to HanziApp!", 117 121 "welcome_desc": "This is an app for learning Chinese characters!", 118 122 "welcome_lang_prompt": "Which language are you looking to learn?", 123 + "migration_title": "IMPORTANT", 124 + "migration_desc": "HanziApp has made a major update. Please export your data in order to keep your progress.", 125 + "migration_download": "Download Progress", 119 126 "writing": "Writing", 120 127 "zh_CN": "China (Mandarin, Simplified)", 121 128 "zh_HK": "Hong Kong (Cantonese, Traditional)",
+7
www/static/strings/es.json
··· 85 85 "reset_progress": "Restablecer progreso", 86 86 "reset_help_dialogs": "Restablecer diálogos de ayuda", 87 87 "reviews": "Repasos", 88 + "skip": "Omitir", 89 + "dismiss": "Descartar", 90 + "migration_delete": "Eliminar datos antiguos", 91 + "migration_delete_dismiss": "Eliminar datos antiguos y descartar", 88 92 "transliteration": "Transliteración", 89 93 "save": "Guardar", 90 94 "search": "Buscar", ··· 116 120 "welcome": "¡Bienvenido a HanziApp!", 117 121 "welcome_desc": "¡Esta es una aplicación para aprender caracteres chinos!", 118 122 "welcome_lang_prompt": "¿Qué idioma quieres aprender?", 123 + "migration_title": "IMPORTANTE", 124 + "migration_desc": "HanziApp ha realizado una actualización importante. Por favor, exporta tus datos para conservar tu progreso.", 125 + "migration_download": "Descargar Progreso", 119 126 "writing": "Escritura", 120 127 "zh_CN": "China (mandarín, simplificado)", 121 128 "zh_HK": "Hong Kong (cantonés, tradicional)",
+4 -1
www/static/strings/ja.json
··· 50 50 "reading_first": "読み方を先に", 51 51 "reset_progress": "進捗をリセット", 52 52 "reviews": "復習", 53 - "transliteration": "翻字", 54 53 "save": "保存", 55 54 "settings": "設定", 55 + "skip": "スキップ", 56 + "dismiss": "閉じる", 57 + "migration_delete": "古いデータを削除", 58 + "migration_delete_dismiss": "古いデータを削除して閉じる", 56 59 "sound": "サウンド", 57 60 "study": "学習", 58 61 "study_group_size": "学習グループサイズ",
+7
www/static/strings/zh_CN.json
··· 28 28 "level": "等级", 29 29 "locale": "语言区域", 30 30 "lvl": "等级", 31 + "migration_title": "重要提示", 32 + "migration_desc": "HanziApp已进行重大更新。请导出您的数据以保留学习进度。", 33 + "migration_download": "下载进度", 31 34 "meaning": "含义", 32 35 "meaning_first": "先看含义", 33 36 "mnemonic": "记忆法", ··· 48 51 "reviews": "复习", 49 52 "save": "保存", 50 53 "settings": "设置", 54 + "skip": "跳过", 55 + "dismiss": "关闭", 56 + "migration_delete": "删除旧数据", 57 + "migration_delete_dismiss": "删除旧数据并关闭", 51 58 "sound": "声音", 52 59 "study": "学习", 53 60 "study_group_size": "学习组大小",
+7
www/static/strings/zh_HK.json
··· 28 28 "level": "等級", 29 29 "locale": "語言地區", 30 30 "lvl": "等級", 31 + "migration_title": "重要提示", 32 + "migration_desc": "HanziApp已進行重大更新。請匯出您的數據以保留學習進度。", 33 + "migration_download": "下載進度", 31 34 "meaning": "意思", 32 35 "meaning_first": "先睇意思", 33 36 "mnemonic": "記憶法", ··· 48 51 "reviews": "複習", 49 52 "save": "儲存", 50 53 "settings": "設定", 54 + "skip": "跳過", 55 + "dismiss": "關閉", 56 + "migration_delete": "刪除舊數據", 57 + "migration_delete_dismiss": "刪除舊數據並關閉", 51 58 "sound": "聲音", 52 59 "study": "學習", 53 60 "study_group_size": "學習組大小",
+7
www/static/strings/zh_TW.json
··· 28 28 "level": "等級", 29 29 "locale": "語言地區", 30 30 "lvl": "等級", 31 + "migration_title": "重要提示", 32 + "migration_desc": "HanziApp 已進行重大更新。請匯出您的資料以保留學習進度。", 33 + "migration_download": "下載進度", 31 34 "meaning": "意思", 32 35 "meaning_first": "先顯示意思", 33 36 "mnemonic": "記憶法", ··· 48 51 "reviews": "複習", 49 52 "save": "儲存", 50 53 "settings": "設定", 54 + "skip": "跳過", 55 + "dismiss": "關閉", 56 + "migration_delete": "刪除舊資料", 57 + "migration_delete_dismiss": "刪除舊資料並關閉", 51 58 "sound": "聲音", 52 59 "study": "學習", 53 60 "study_group_size": "學習組大小",