this repo has no description
0
fork

Configure Feed

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

feat: cleanup update process

+252 -62
+2 -2
deno.json
··· 43 43 }, 44 44 "imports": { 45 45 "$/": "./www/", 46 - "@bpev/sync-link": "jsr:@bpev/sync-link@^0.0.13", 46 + "@bpev/sync-link": "jsr:@bpev/sync-link@0.0.17", 47 47 "@byojs/storage": "npm:@byojs/storage@^0.12.1", 48 - "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.0", 48 + "@inro/simple-tools": "jsr:@inro/simple-tools@0.5.2", 49 49 "@leeoniya/ufuzzy": "npm:@leeoniya/ufuzzy@^1.0.19", 50 50 "@std/assert": "jsr:@std/assert@^1.0.15", 51 51 "@std/async": "jsr:@std/async@^1.0.15",
+25 -20
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@bpev/sync-link@^0.0.13": "0.0.13", 5 - "jsr:@inro/simple-tools@0.5": "0.5.0", 6 - "jsr:@std/assert@^1.0.15": "1.0.15", 4 + "jsr:@bpev/sync-link@0.0.17": "0.0.17", 5 + "jsr:@inro/simple-tools@0.5.2": "0.5.2", 6 + "jsr:@paulmillr/qr@~0.5.2": "0.5.2", 7 + "jsr:@std/assert@^1.0.15": "1.0.16", 7 8 "jsr:@std/async@^1.0.15": "1.0.15", 8 9 "jsr:@std/bytes@^1.0.5": "1.0.6", 9 10 "jsr:@std/bytes@^1.0.6": "1.0.6", ··· 11 12 "jsr:@std/collections@^1.1.3": "1.1.3", 12 13 "jsr:@std/csv@^1.0.6": "1.0.6", 13 14 "jsr:@std/dotenv@~0.225.5": "0.225.5", 14 - "jsr:@std/fs@^1.0.17": "1.0.20", 15 - "jsr:@std/fs@^1.0.19": "1.0.20", 15 + "jsr:@std/fs@^1.0.17": "1.0.21", 16 + "jsr:@std/fs@^1.0.19": "1.0.21", 16 17 "jsr:@std/internal@^1.0.12": "1.0.12", 17 18 "jsr:@std/io@~0.225.2": "0.225.2", 18 - "jsr:@std/path@^1.1.2": "1.1.3", 19 - "jsr:@std/path@^1.1.3": "1.1.3", 19 + "jsr:@std/path@^1.1.2": "1.1.4", 20 + "jsr:@std/path@^1.1.4": "1.1.4", 20 21 "jsr:@std/semver@^1.0.6": "1.0.6", 21 22 "jsr:@std/streams@1.0.13": "1.0.13", 22 23 "jsr:@std/streams@^1.0.9": "1.0.13", ··· 38 39 "npm:zod@^4.1.12": "4.1.12" 39 40 }, 40 41 "jsr": { 41 - "@bpev/sync-link@0.0.13": { 42 - "integrity": "2bf6852b54f0dc9557cce2b4dd847fba26021f1320f16729fbb186fc3e170406", 42 + "@bpev/sync-link@0.0.17": { 43 + "integrity": "a21a2994482b64a90da7061b113703e07f5af90498474103e3ce073118871ee6", 43 44 "dependencies": [ 44 45 "jsr:@inro/simple-tools", 46 + "jsr:@paulmillr/qr", 45 47 "npm:@hono/zod-openapi", 46 48 "npm:native-file-system-adapter" 47 49 ] 48 50 }, 49 - "@inro/simple-tools@0.5.0": { 50 - "integrity": "83f425210282f3227bb7bb0b401d7f3b1b38427ed8ad49388d04e21b5f82af28", 51 + "@inro/simple-tools@0.5.2": { 52 + "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5", 51 53 "dependencies": [ 52 54 "jsr:@std/collections@^1.1.0", 53 55 "jsr:@std/fs@^1.0.17", ··· 55 57 "npm:ts-fsrs" 56 58 ] 57 59 }, 58 - "@std/assert@1.0.15": { 59 - "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", 60 + "@paulmillr/qr@0.5.2": { 61 + "integrity": "dcaabde6e5125cabecd82f7f0044062dfc0439493c2945bd14c9368e5a3982f2" 62 + }, 63 + "@std/assert@1.0.16": { 64 + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", 60 65 "dependencies": [ 61 66 "jsr:@std/internal" 62 67 ] ··· 79 84 "@std/dotenv@0.225.5": { 80 85 "integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f" 81 86 }, 82 - "@std/fs@1.0.20": { 83 - "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", 87 + "@std/fs@1.0.21": { 88 + "integrity": "d720fe1056d78d43065a4d6e0eeb2b19f34adb8a0bc7caf3a4dbf1d4178252cd", 84 89 "dependencies": [ 85 90 "jsr:@std/internal", 86 - "jsr:@std/path@^1.1.3" 91 + "jsr:@std/path@^1.1.4" 87 92 ] 88 93 }, 89 94 "@std/internal@1.0.12": { ··· 95 100 "jsr:@std/bytes@^1.0.5" 96 101 ] 97 102 }, 98 - "@std/path@1.1.3": { 99 - "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", 103 + "@std/path@1.1.4": { 104 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 100 105 "dependencies": [ 101 106 "jsr:@std/internal" 102 107 ] ··· 242 247 }, 243 248 "workspace": { 244 249 "dependencies": [ 245 - "jsr:@bpev/sync-link@^0.0.13", 246 - "jsr:@inro/simple-tools@0.5", 250 + "jsr:@bpev/sync-link@0.0.17", 251 + "jsr:@inro/simple-tools@0.5.2", 247 252 "jsr:@std/assert@^1.0.15", 248 253 "jsr:@std/async@^1.0.15", 249 254 "jsr:@std/collections@^1.1.3",
+2 -2
www/components/locale_select.ts
··· 4 4 import app from '$/models/app.ts' 5 5 6 6 const { zh_CN, zh_HK, zh_TW } = Locale 7 - const { Zhuyin, Pinyin, Jyutping } = Transliteration 7 + const { Pinyin, Jyutping } = Transliteration 8 8 9 9 export const LocaleSelect: Component = { 10 10 view: () => ··· 16 16 app.state.settings.locale = locale 17 17 if (locale === zh_CN) app.state.settings.transliteration = Pinyin 18 18 if (locale === zh_HK) app.state.settings.transliteration = Jyutping 19 - if (locale === zh_TW) app.state.settings.transliteration = Zhuyin 19 + if (locale === zh_TW) app.state.settings.transliteration = Pinyin 20 20 21 21 await app.persist() 22 22 app.notify()
-1
www/components/text_reading.ts
··· 1 1 import m, { Vnode } from 'mithril' 2 2 import { show, SoundIcon } from '$/components/mod.ts' 3 - import { transliterationMap } from '$/enums.ts' 4 3 import app from '$/models/app.ts' 5 4 import { Audio, Reading } from '$/models/subjects.ts' 6 5 import { playAudio } from '$/utils/audio.ts'
+11 -6
www/components/transliteration_select.ts
··· 1 1 import m, { Component } from 'mithril' 2 2 import { str } from '$/components/mod.ts' 3 - import { Transliteration } from '$/enums.ts' 3 + import { Locale, Transliteration } from '$/enums.ts' 4 4 import app from '$/models/app.ts' 5 5 6 6 const { Zhuyin, Pinyin, Jyutping } = Transliteration 7 7 8 8 export const TransliterationSelect: Component = { 9 - view: () => 10 - m( 9 + view: () => { 10 + let options = [Pinyin, Zhuyin] 11 + 12 + if (app.state.settings.locale === Locale.zh_HK) options = [Jyutping] 13 + 14 + return m( 11 15 'select.ba.pa2.db.purple-shadow[name="transliteration"]', 12 16 { 13 17 onchange: async (e: InputEvent) => { 14 18 app.state.settings.transliteration = (e.target as HTMLSelectElement) 15 - .value as Locale 19 + .value as Transliteration 16 20 await app.persist() 17 21 app.notify() 18 22 }, 19 23 }, 20 - [Jyutping, Pinyin, Zhuyin].map((value) => 24 + options.map((value) => 21 25 str('option', { value, selected: app.transliteration === value }, value) 22 26 ), 23 - ), 27 + ) 28 + }, 24 29 }
+1 -1
www/index.ts
··· 1 - import './models/schema/idb_migration.ts' 1 + import './models/schema/idb_migration.js' 2 2 3 3 import m from 'mithril' 4 4 import Main from './routes/main.ts'
+14 -4
www/manifest.json
··· 9 9 { 10 10 "src": "/dist/icons/128x128.png", 11 11 "sizes": "128x128", 12 - "type": "image/png" 12 + "type": "image/png", 13 + "purpose": "any" 14 + }, 15 + { 16 + "src": "/dist/icons/192x192.png", 17 + "sizes": "192x192", 18 + "type": "image/png", 19 + "purpose": "any maskable" 13 20 }, 14 21 { 15 22 "src": "/dist/icons/256x256.png", 16 23 "sizes": "256x256", 17 - "type": "image/png" 24 + "type": "image/png", 25 + "purpose": "any" 18 26 }, 19 27 { 20 28 "src": "/dist/icons/512x512.png", 21 29 "sizes": "512x512", 22 - "type": "image/png" 30 + "type": "image/png", 31 + "purpose": "any maskable" 23 32 }, 24 33 { 25 34 "src": "/dist/icons/icon.png", 26 35 "sizes": "1024x1024", 27 - "type": "image/png" 36 + "type": "image/png", 37 + "purpose": "any" 28 38 } 29 39 ], 30 40 "start_url": ".",
+148 -10
www/models/schema.ts
··· 10 10 import { migrateFromV0 } from './schema/v1.ts' 11 11 import type { StoreState as V0 } from './schema/v0.ts' 12 12 import type { StoreState as V1 } from './schema/v1.ts' 13 + import { 14 + MigrationConfig, 15 + MigrationMismatchAction, 16 + } from '@inro/simple-tools/storage' 13 17 14 - const currentVersion = globalThis.__APP_VERSION__ || '2.0.0' 18 + const { Continue, UseDefault } = MigrationMismatchAction 15 19 16 - const extractVersion = (data: unknown) => 17 - (data && typeof data === 'object' && 'version' in data) 18 - ? (data as { version: string }).version 19 - : undefined 20 + type VersionPart = number | 'X' 21 + 22 + const currentVersion = globalThis.__APP_VERSION__ 20 23 21 24 /** Migration configuration for StoreState (all synced data) */ 22 - export const storeMigrationConfig = { 25 + export const storeMigrationConfig: MigrationConfig<StoreState> = { 23 26 currentVersion, 24 - extractVersion, 27 + extractVersion: (data: unknown) => 28 + (data && typeof data === 'object' && 'version' in data) 29 + ? (data as { version: string }).version 30 + : undefined, 31 + compareVersions( 32 + v1: string | number | undefined, 33 + v2: string | number | undefined, 34 + ): number { 35 + if (v1 === undefined && v2 === undefined) return 0 36 + if (v1 === undefined) return -1 37 + if (v2 === undefined) return 1 38 + 39 + const ver1 = String(v1) 40 + const ver2 = String(v2) 41 + 42 + if (ver1 === ver2) return 0 43 + 44 + const vA = parseVersion(ver1) 45 + const vB = parseVersion(ver2) 46 + 47 + // Our migrations between minor/patch versions should be parsable via zod 48 + return compareVersionPart(vA.major, vB.major) 49 + }, 50 + onMigrationComplete: (data: unknown, _wasMigrated: boolean) => { 51 + const state = StoreState.parse(data) 52 + state.version = currentVersion 53 + return state 54 + }, 25 55 migrations: [ 26 56 { 27 57 fromVersion: undefined, 28 - toVersion: '1.0.0', 58 + toVersion: '1.X.X', 29 59 migrate: (data: unknown) => migrateFromV0(data as V0), 30 60 }, 31 61 { 32 - fromVersion: '1.0.0', 33 - toVersion: '2.0.0', 62 + fromVersion: '1.X.X', 63 + toVersion: '2.X.X', 34 64 migrate: (data: unknown) => migrateFromV1(data as V1), 35 65 }, 36 66 ], 67 + 68 + /** 69 + * Handle version mismatches with fallback strategy 70 + * If there's no specific migration for a newer patch/minor version, 71 + * treat it as compatible with the latest known migration in the same major version 72 + */ 73 + onVersionMismatch( 74 + dataVersion: string | number | undefined, 75 + currentVersion: string | number, 76 + data: unknown, 77 + ) { 78 + console.log( 79 + `Version mismatch detected - data: ${dataVersion}, current: ${currentVersion}`, 80 + ) 81 + 82 + if (!dataVersion) return Continue // No version in data 83 + 84 + const dataVer = String(dataVersion) 85 + const currentVer = String(currentVersion) 86 + 87 + const dataParsed = parseVersion(dataVer) 88 + const currentParsed = parseVersion(currentVer) 89 + 90 + // If data is from a newer app version (higher major version), 91 + if ( 92 + typeof dataParsed.major === 'number' && 93 + typeof currentParsed.major === 'number' 94 + ) { 95 + if (dataParsed.major > currentParsed.major) { 96 + console.warn( 97 + `Newer major version detected (${dataVer} > ${currentVer})`, 98 + ) 99 + 100 + if (typeof globalThis !== 'undefined' && 'location' in globalThis) { 101 + console.log('Triggering app refresh to handle newer data version') 102 + setTimeout(() => { 103 + globalThis.location.reload() 104 + }, 1000) 105 + } 106 + 107 + // Try to extract compatible data, falling back to schema defaults 108 + try { 109 + const futureData = data as Partial<StoreState> 110 + 111 + // Use schema's default state as base 112 + const compatibleData: StoreState = { 113 + ...defaultStoreState, 114 + version: currentVer, 115 + } 116 + 117 + // Safely extract compatible fields if they exist and are valid 118 + if (futureData && typeof futureData === 'object') { 119 + if ( 120 + futureData.assignments && 121 + typeof futureData.assignments === 'object' 122 + ) { 123 + compatibleData.assignments = futureData.assignments 124 + } 125 + if (futureData.flags && typeof futureData.flags === 'object') { 126 + compatibleData.flags = futureData.flags 127 + } 128 + if ( 129 + futureData.settings && typeof futureData.settings === 'object' 130 + ) { 131 + compatibleData.settings = futureData.settings 132 + } 133 + if ( 134 + futureData.overrides && typeof futureData.overrides === 'object' 135 + ) { 136 + compatibleData.overrides = futureData.overrides 137 + } 138 + if ( 139 + futureData.userLevels && typeof futureData.userLevels === 'object' 140 + ) { 141 + compatibleData.userLevels = futureData.userLevels 142 + } 143 + } 144 + 145 + return compatibleData 146 + } catch (error) { 147 + console.error('Failed to extract compatible data:', error) 148 + return UseDefault 149 + } 150 + } 151 + } 152 + 153 + return Continue 154 + }, 155 + } 156 + 157 + function parseVersion(version: string): { 158 + major: VersionPart 159 + minor: VersionPart 160 + patch: VersionPart 161 + raw: string 162 + } { 163 + const parts = version.split('.') 164 + return { 165 + major: parts[0] === 'X' ? 'X' : parseInt(parts[0] || '0', 10), 166 + minor: parts[1] === 'X' ? 'X' : parseInt(parts[1] || '0', 10), 167 + patch: parts[2] === 'X' ? 'X' : parseInt(parts[2] || '0', 10), 168 + raw: version, 169 + } 170 + } 171 + 172 + function compareVersionPart(part1: VersionPart, part2: VersionPart): number { 173 + if (part1 === 'X' || part2 === 'X') return 0 174 + return part1 - part2 37 175 } 38 176 39 177 export {
+4 -4
www/models/schema/idb_migration.ts www/models/schema/idb_migration.js
··· 40 40 return confirm(`Did you successfully download the file "${filename}"?`) 41 41 } 42 42 43 - async function getIDBData(dbName, keyName) { 44 - return new Promise((resolve, reject) => { 43 + function getIDBData(dbName, keyName) { 44 + return new Promise((resolve) => { 45 45 const request = indexedDB.open(dbName) 46 46 47 47 request.onerror = () => { ··· 89 89 }) 90 90 } 91 91 92 - async function clearIDBData(dbName, keyName) { 93 - return new Promise((resolve, reject) => { 92 + function clearIDBData(dbName, keyName) { 93 + return new Promise((resolve) => { 94 94 const request = indexedDB.open(dbName) 95 95 96 96 request.onerror = () => {
+4 -2
www/models/schema/v2.ts
··· 57 57 learnLimit: z.number().nullable().default(10), 58 58 learnSessionSize: z.number().nullable().default(10), 59 59 reviewSessionSize: z.number().nullable().default(20), 60 - transliteration: z.enum(Transliteration).default(Locale.Pinyin), 60 + transliteration: z.enum(Transliteration).default(Transliteration.Pinyin), 61 61 cardSortMethod: z.enum(CardSortMethod).default(CardSortMethod.Paired), 62 62 cardSortOrder: z.array(z.string()).nullable().default(null), 63 63 }) ··· 87 87 [Locale.zh_TW]: {}, 88 88 }) 89 89 90 + const appVersion: string = globalThis.__APP_VERSION__ 91 + 90 92 export const StoreState = z.object({ 91 - version: z.string().default(globalThis.__APP_VERSION__), 93 + version: z.string().default(appVersion), 92 94 assignments: z.record(z.enum(Locale), z.record(z.string(), Assignment)) 93 95 .default(defaultAssignments), 94 96 flags: Flags.default(() => defaultFlags),
+2 -2
www/models/store.ts
··· 22 22 #sync: SyncLink<StoreState> 23 23 24 24 constructor(name: string, syncLinkUrl?: string) { 25 - // Use custom LocalStorage config that parses through Zod schema 26 - // This ensures dates are properly converted from strings to Date objects 25 + // Create LocalStorage with custom deserialize that parses through Zod 26 + // This ensures date strings are converted to Date objects 27 27 const storage = new LocalStorage<StoreState>({ 28 28 name, 29 29 defaultValue: defaultStoreState,
+2
www/routes/settings/about.ts
··· 11 11 ]), 12 12 m('main.settings', [ 13 13 m('article', [ 14 + m('p', `version ${globalThis.__APP_VERSION__}`), 14 15 m( 15 16 'p', 16 17 str( ··· 19 20 ), 20 21 ), 21 22 m('section', [ 23 + m('h2', 'Acknowledgements'), 22 24 m('p', 'Word lists were compiled from:'), 23 25 m('ul', [ 24 26 m('li', [
+2
www/static/strings/en.json
··· 46 46 "reading_first": "Reading First", 47 47 "reset_progress": "Reset Progress", 48 48 "reviews": "Reviews", 49 + "transliteration": "Transliteration", 49 50 "save": "Save", 50 51 "settings": "Settings", 51 52 "sound": "Sound", ··· 56 57 "card_order": "Card Order", 57 58 "card_sort_method": "Sort Method", 58 59 "card_sort_hint": "How the different meaning and reading flashcards are ordered in a study session.", 60 + "test_connection": "Connect", 59 61 "tocfl": "TOCFL", 60 62 "todays_words": "Today's Words", 61 63 "total_learned": "Total Learned",
+1 -1
www/utils/check_answer.ts
··· 45 45 .map(({ value }) => value) 46 46 if (whitelist.includes(answer)) return true 47 47 48 - const chars = getCharsByLocale(app.locale, subject).toLowerCase() 48 + const chars = (getCharsByLocale(app.locale, subject) || '').toLowerCase() 49 49 if (answer === chars) return true // Accept direct strings 50 50 51 51 if (isPinyin(app.locale, answer)) {
+34 -7
www/worker.ts
··· 5 5 '/static/theme.css', 6 6 '/dist/index.js', 7 7 '/dist/icons/128x128.png', 8 + '/dist/icons/192x192.png', 8 9 '/dist/icons/256x256.png', 9 10 '/dist/icons/512x512.png', 10 11 '/dist/icons/icon.png', ··· 22 23 }) 23 24 24 25 self.addEventListener('fetch', (event) => { 25 - event.respondWith( 26 - caches.match(event.request) 27 - .then((response) => { 28 - // Return cached version or fetch from network 29 - return response || fetch(event.request) 30 - }), 31 - ) 26 + const url = new URL(event.request.url) 27 + 28 + // For critical JS files, use stale-while-revalidate 29 + if (url.pathname === '/dist/index.js') { 30 + event.respondWith( 31 + caches.match(event.request) 32 + .then((cachedResponse) => { 33 + const fetchPromise = fetch(event.request) 34 + .then((networkResponse) => { 35 + // Update cache in background 36 + if (networkResponse.ok) { 37 + const responseClone = networkResponse.clone() 38 + caches.open(CACHE_NAME).then((cache) => { 39 + cache.put(event.request, responseClone) 40 + }) 41 + } 42 + return networkResponse 43 + }) 44 + .catch(() => null) // Ignore network errors 45 + 46 + // Return cached version immediately, or network if no cache 47 + return cachedResponse || fetchPromise 48 + }), 49 + ) 50 + } else { 51 + // Cache-first for other resources 52 + event.respondWith( 53 + caches.match(event.request) 54 + .then((response) => { 55 + return response || fetch(event.request) 56 + }), 57 + ) 58 + } 32 59 }) 33 60 34 61 self.addEventListener('activate', (event) => {