this repo has no description
0
fork

Configure Feed

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

fix: issues with custom-sets

- Issue where custom sets don't correctly log progress
- Issue where user could load same custom set with different ids

+103 -105
+12 -3
www/models/app.ts
··· 165 165 const map = this.#assignmentsColl.value 166 166 if (!map) return {} 167 167 const result: Record<string, Assignment> = {} 168 - for (const [_key, assignment] of map) { 169 - const assignmentLocale = assignment.locale ?? locale 168 + for (const [key, assignment] of map) { 169 + // Storage key format is `${locale}__${subjectId}`. Fall back to parsing 170 + // the key so legacy entries without `locale` don't leak across locales. 171 + let assignmentLocale = assignment.locale 172 + if (!assignmentLocale) { 173 + const sep = key.indexOf('__') 174 + if (sep >= 0) assignmentLocale = key.slice(0, sep) 175 + } 176 + assignmentLocale ??= locale 170 177 if (assignmentLocale === locale) { 171 178 result[assignment.subjectId] = { 172 179 ...assignment, ··· 238 245 } 239 246 240 247 async saveAssignment(assignment: Assignment): Promise<boolean> { 241 - const key = `${assignment.locale ?? this.locale}__${assignment.subjectId}` 248 + const locale = assignment.locale ?? this.locale 249 + const key = `${locale}__${assignment.subjectId}` 242 250 try { 243 251 const stored: StoredAssignment = { 244 252 ...assignment, 253 + locale, 245 254 lastStudiedAt: assignment.lastStudiedAt?.toISOString(), 246 255 unlockedAt: assignment.unlockedAt?.toISOString(), 247 256 availableAt: assignment.availableAt?.toISOString(),
+91 -85
www/routes/settings/custom-sets.ts
··· 4 4 deleteCustomSet, 5 5 getCustomSets, 6 6 saveCustomSet, 7 - toLocaleId, 8 - updateCustomSetLocale, 9 7 } from '$/utils/custom_sets.ts' 10 8 import { Locale } from '$/enums.ts' 11 9 12 - const LOCALE_OPTIONS = [ 13 - { value: '', label: 'None (detect from data)' }, 14 - { value: 'ja', label: 'Japanese (ja)' }, 15 - { value: 'zh_CN', label: 'Chinese Simplified (zh_CN)' }, 16 - { value: 'zh_TW', label: 'Chinese Traditional (zh_TW)' }, 17 - { value: 'zh_HK', label: 'Chinese Hong Kong (zh_HK)' }, 18 - ] 10 + interface ParsedCustomSet { 11 + id: string 12 + name: string 13 + locale: string 14 + subjects: unknown[] 15 + } 16 + 17 + function parseCustomSetFile(text: string): ParsedCustomSet { 18 + const parsed = JSON.parse(text) 19 + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { 20 + throw new Error( 21 + 'File must be a JSON object with `id`, `name`, `locale`, and `subjects` fields.', 22 + ) 23 + } 24 + const { id, name, locale, subjects } = parsed as Record<string, unknown> 25 + if (typeof id !== 'string' || !id.trim()) { 26 + throw new Error('File must include a non-empty string `id` field.') 27 + } 28 + if (typeof name !== 'string' || !name.trim()) { 29 + throw new Error('File must include a non-empty string `name` field.') 30 + } 31 + if (typeof locale !== 'string' || !locale.trim()) { 32 + throw new Error('File must include a non-empty string `locale` field.') 33 + } 34 + if (!Array.isArray(subjects)) { 35 + throw new Error('File must include a `subjects` array.') 36 + } 37 + const fullId = id.startsWith('custom-') ? id : `custom-${id}` 38 + return { id: fullId, name: name.trim(), locale: locale.trim(), subjects } 39 + } 19 40 20 41 export class SettingsCustomSetsRoutes extends LitElement { 21 42 #name = '' 22 43 #file: File | null = null 44 + #parsed: ParsedCustomSet | null = null 23 45 #error: string | null = null 24 46 25 47 #onUpdate = () => this.requestUpdate() ··· 39 61 app.removeEventListener(this.#onUpdate) 40 62 } 41 63 42 - #selectedLocale = '' 64 + async #onFileChange(file: File | null) { 65 + this.#file = file 66 + this.#parsed = null 67 + this.#error = null 68 + if (!file) { 69 + this.requestUpdate() 70 + return 71 + } 72 + try { 73 + const text = await file.text() 74 + this.#parsed = parseCustomSetFile(text) 75 + this.#name = this.#parsed.name 76 + } catch (e) { 77 + this.#error = `Invalid file: ${e instanceof Error ? e.message : e}` 78 + } 79 + this.requestUpdate() 80 + } 43 81 44 82 async #onSubmit() { 45 - const name = this.#name.trim() 46 - if (!name || !this.#file) { 47 - this.#error = 'Please provide a name and a JSON file.' 83 + if (!this.#parsed) { 84 + this.#error = 'Please choose a valid custom-set JSON file.' 48 85 this.requestUpdate() 49 86 return 50 87 } 88 + const name = this.#name.trim() || this.#parsed.name 51 89 try { 52 - const text = await this.#file.text() 53 - const parsed = JSON.parse(text) 54 - const subjects = Array.isArray(parsed) ? parsed : parsed?.subjects 55 - if (!Array.isArray(subjects)) { 56 - throw new Error( 57 - 'File must contain a JSON array or an object with a subjects array.', 58 - ) 59 - } 60 - const locale = this.#selectedLocale || 61 - (Array.isArray(parsed) ? undefined : parsed?.locale) 62 - const id = toLocaleId(name) 63 - await saveCustomSet({ id, name, locale: locale || undefined }, subjects) 90 + await saveCustomSet( 91 + { id: this.#parsed.id, name, locale: this.#parsed.locale }, 92 + this.#parsed.subjects, 93 + ) 64 94 this.#name = '' 65 95 this.#file = null 96 + this.#parsed = null 66 97 this.#error = null 67 - this.#selectedLocale = '' 68 98 // Dispatch a storage event so h-locale-select updates 69 99 globalThis.dispatchEvent(new Event('hanzi-custom-sets-changed')) 70 100 this.requestUpdate() 71 101 } catch (e) { 72 - this.#error = `Failed to import: ${e}` 102 + this.#error = `Failed to import: ${e instanceof Error ? e.message : e}` 73 103 this.requestUpdate() 74 104 } 75 105 } ··· 83 113 this.requestUpdate() 84 114 } 85 115 86 - async #onUpdateLocale(id: string, locale: string) { 87 - await updateCustomSetLocale(id, locale || undefined) 88 - globalThis.dispatchEvent(new Event('hanzi-custom-sets-changed')) 89 - this.requestUpdate() 90 - } 91 - 92 116 override render() { 93 117 const customSets = getCustomSets() 94 118 ··· 104 128 (set) => 105 129 html` 106 130 <li class="item flex items-center justify-between"> 107 - <span><b>${set.name}</b> <small>(${set.id})</small></span> 108 - <select 109 - class="blue-shadow" 110 - @change="${(e: Event) => { 111 - this.#onUpdateLocale( 112 - set.id, 113 - (e.target as HTMLSelectElement).value, 114 - ) 115 - }}" 116 - > 117 - ${LOCALE_OPTIONS.map((opt) => 118 - html` 119 - <option 120 - value="${opt.value}" 121 - ?selected="${set.locale === opt.value}" 122 - > 123 - ${opt.label} 124 - </option> 125 - ` 126 - )} 127 - </select> 131 + <span> 132 + <b>${set.name}</b> 133 + <small>(${set.id}${set.locale 134 + ? ` · ${set.locale}` 135 + : ''})</small> 136 + </span> 128 137 <button 129 138 class="bg-light-red" 130 139 @click="${() => this.#onDelete(set.id)}" ··· 152 161 : ''} 153 162 154 163 <div class="item"> 155 - <label for="customSetName">Name</label> 156 - <input 157 - class="blue-shadow" 158 - id="customSetName" 159 - type="text" 160 - placeholder="My Custom Set" 161 - .value="${this.#name}" 162 - @input="${(e: InputEvent) => { 163 - this.#name = (e.target as HTMLInputElement).value 164 - }}" 165 - /> 166 - </div> 167 - <div class="item"> 168 - <label for="customSetLocale">Locale (optional)</label> 169 - <select 170 - class="blue-shadow" 171 - id="customSetLocale" 172 - @change="${(e: Event) => { 173 - this.#selectedLocale = (e.target as HTMLSelectElement).value 174 - }}" 175 - > 176 - ${LOCALE_OPTIONS.map((opt) => 177 - html` 178 - <option value="${opt.value}">${opt.label}</option> 179 - ` 180 - )} 181 - </select> 182 - </div> 183 - <div class="item"> 184 164 <label for="customSetFile">File (.json)</label> 185 165 <input 186 166 id="customSetFile" 187 167 type="file" 188 168 accept=".json,application/json" 189 169 @change="${(e: Event) => { 190 - this.#file = (e.target as HTMLInputElement).files?.[0] ?? null 170 + const file = (e.target as HTMLInputElement).files?.[0] ?? null 171 + this.#onFileChange(file) 191 172 }}" 192 173 /> 193 174 </div> 175 + ${this.#parsed 176 + ? html` 177 + <div class="item"> 178 + <label for="customSetName">Name</label> 179 + <input 180 + class="blue-shadow" 181 + id="customSetName" 182 + type="text" 183 + .value="${this.#name}" 184 + @input="${(e: InputEvent) => { 185 + this.#name = (e.target as HTMLInputElement).value 186 + }}" 187 + /> 188 + </div> 189 + <div class="item"> 190 + <small> 191 + <b>ID:</b> ${this.#parsed.id} · <b>Locale:</b> 192 + ${this.#parsed.locale} 193 + </small> 194 + </div> 195 + ` 196 + : ''} 194 197 <div class="full-button"> 195 - <button @click="${() => this.#onSubmit()}"> 198 + <button 199 + ?disabled="${!this.#parsed}" 200 + @click="${() => this.#onSubmit()}" 201 + > 196 202 Add Set 197 203 </button> 198 204 </div>
-17
www/utils/custom_sets.ts
··· 55 55 await customSets.delete(id) 56 56 } 57 57 58 - export async function updateCustomSetLocale( 59 - id: string, 60 - locale: string | undefined, 61 - ): Promise<void> { 62 - const data = await customSets.get(id) 63 - if (!data) return 64 - await customSets.set(id, { ...data, locale }) 65 - } 66 - 67 58 export async function getCustomSetData(id: string): Promise<unknown[] | null> { 68 59 const data = await customSets.get(id) 69 60 return data?.subjects ?? null ··· 89 80 } 90 81 return locale 91 82 } 92 - 93 - export function toLocaleId(name: string): string { 94 - const slug = name 95 - .toLowerCase() 96 - .replace(/[^a-z0-9]+/g, '-') 97 - .replace(/^-|-$/g, '') 98 - return `custom-${slug}` 99 - }