diff --git a/js/bundles/practice-page-enhancer.bundle.js b/js/bundles/practice-page-enhancer.bundle.js
index 91f8fcab..bb3293df 100644
--- a/js/bundles/practice-page-enhancer.bundle.js
+++ b/js/bundles/practice-page-enhancer.bundle.js
@@ -1,5 +1,3524 @@
/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */
+/* ===== js/data/practiceRecordSource.js ===== */
+/**
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
+ */
+(function initPracticeRecordSource(global) {
+ 'use strict';
+
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
+ }
+
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
+
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
+
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
+
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
+
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
+ }
+
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ }
+
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
+ }
+
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
+ }
+
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
+ }
+
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
+
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
+
+ return true;
+ }
+
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
+
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
+
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
+
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
+
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
+ }
+
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
+ }
+ return previewRecordIds.delete(normalizeId(recordId));
+ }
+
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
+ }
+
+ /**
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
+ */
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
+
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
+
+ global.PracticeRecordSource = api;
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataCatalog.js ===== */
+(function installDataCatalog(global) {
+ 'use strict';
+
+ const V2_SCHEMA_VERSION = 2;
+
+ function clone(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function objectDefault() { return {}; }
+ function arrayDefault() { return []; }
+ function nullableDefault() { return null; }
+ function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; }
+ function normalizeObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
+ }
+ function normalizeNullableString(value) {
+ return value === null || value === undefined || value === '' ? null : String(value);
+ }
+ function isArray(value) { return Array.isArray(value); }
+ function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
+ function isNullableString(value) { return value === null || typeof value === 'string'; }
+
+ const CATALOG_OWNERS = new Set([
+ 'settings', 'library', 'recovery', 'backups', 'vocab',
+ 'preferences', 'goals', 'achievements', 'system', 'practice'
+ ]);
+ const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']);
+ const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']);
+
+ function isNonEmptyString(value) {
+ return typeof value === 'string' && Boolean(value.trim());
+ }
+
+ function ownerFromKey(logicalKey) {
+ const dot = String(logicalKey || '').indexOf('.');
+ return dot > 0 ? logicalKey.slice(0, dot) : '';
+ }
+
+ function freezeEntry(entry) {
+ const logicalKey = String(entry.logicalKey || '');
+ const owner = ownerFromKey(logicalKey);
+ const next = Object.assign({}, entry, {
+ logicalKey,
+ owner,
+ schemaVersion: V2_SCHEMA_VERSION,
+ export: entry.export === true,
+ import: entry.import || 'ignore'
+ });
+ return Object.freeze(next);
+ }
+
+ // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations),
+ // not as document keys. import merge identity is resolved in AppData, not here.
+ const definitions = [
+ {
+ logicalKey: 'settings.values', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.configurations', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'library.importedIndexes', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.activeConfigurationId', classification: 'authoritative',
+ defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString,
+ export: true, import: 'replace'
+ },
+ {
+ logicalKey: 'recovery.activeSessions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.drafts', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.interrupted', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.windowSession', classification: 'session',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.entries', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'backups.settings', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'backups.exportHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.importHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'vocab.words', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'vocab.userConfig', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'vocab.lists', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'preferences.values', classification: 'preference',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'goals.items', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'achievements.manual', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'achievements.progress', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'system.migrations', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'system.operationJournal', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ }
+ ].map(freezeEntry);
+
+ function validateCatalog(entries) {
+ if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry');
+ const logicalKeys = new Set();
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('DataCatalog entry must be an object');
+ }
+ if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) {
+ throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`);
+ }
+ logicalKeys.add(entry.logicalKey);
+ }
+ for (const entry of entries) {
+ if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) {
+ throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`);
+ }
+ if (!CATALOG_CLASSIFICATIONS.has(entry.classification)
+ || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION
+ || typeof entry.defaultValue !== 'function'
+ || typeof entry.normalize !== 'function'
+ || typeof entry.validate !== 'function'
+ || typeof entry.export !== 'boolean'
+ || !IMPORT_POLICIES.has(entry.import)) {
+ throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`);
+ }
+ try {
+ const defaultValue = entry.defaultValue();
+ if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) {
+ throw new Error('invalid default');
+ }
+ } catch (_) {
+ throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`);
+ }
+ }
+ return true;
+ }
+
+ validateCatalog(definitions);
+ const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry]));
+ const DataCatalog = Object.freeze({
+ version: V2_SCHEMA_VERSION,
+ list() { return definitions.slice(); },
+ get(logicalKey) {
+ const entry = byKey.get(String(logicalKey || ''));
+ if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`);
+ return entry;
+ },
+ has(logicalKey) { return byKey.has(String(logicalKey || '')); },
+ validate: validateCatalog,
+ clone
+ });
+
+ Object.defineProperty(global, '__AppDataV2Catalog', {
+ value: DataCatalog,
+ enumerable: false,
+ configurable: true,
+ writable: false
+ });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataKernel.js ===== */
+(function installDataKernel(global) {
+ 'use strict';
+
+ if (global.AppData) return;
+
+ const catalog = global.__AppDataV2Catalog;
+ if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel');
+
+ const DATABASE_NAME = 'IELTSAtlasDataV2';
+ // Version 2 uses a new schema, but initialization must still import the durable
+ // ExamSystemDB data owned by releases which predate AppData v2.
+ const DATABASE_VERSION = 2;
+ const DOCUMENT_STORE = 'documents';
+ const SYSTEM_STORE = 'system';
+ const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+ const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES));
+ const OPERATION_JOURNAL_WINDOW = 500;
+ const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`;
+ const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000;
+ const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000;
+ const MAX_TIMER_DELAY_MS = 2147483647;
+ const LEGACY_DATABASE_NAME = 'ExamSystemDB';
+ const LEGACY_STORE_NAME = 'keyValueStore';
+ const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup';
+ const LEGACY_EXTERNAL_STORE_NAME = 'handles';
+ const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory';
+ const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json';
+ const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([
+ 'practice_records',
+ 'vocab_user_config',
+ 'user_achievements'
+ ]);
+
+ function clone(value) { return catalog.clone(value); }
+ function nowIso() { return new Date().toISOString(); }
+ function randomId(prefix) {
+ const random = global.crypto && typeof global.crypto.randomUUID === 'function'
+ ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ return `${prefix || 'op'}_${random}`;
+ }
+
+ class AppDataError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'AppDataError';
+ this.code = code;
+ this.committed = false;
+ this.details = details;
+ }
+ }
+ function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); }
+ function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); }
+
+ function normalizeTimeoutMs(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback;
+ }
+ function scheduleTimeout(handler, delayMs) {
+ if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable');
+ const handle = global.setTimeout.call(global, handler, delayMs);
+ if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle');
+ return handle;
+ }
+ function cancelTimeout(handle) {
+ if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') {
+ try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ }
+ }
+ }
+ function withDeadline(handle, timeoutMs, description, resolve, reject) {
+ let settled = false;
+ let timer = null;
+ const settle = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ cancelTimeout(timer);
+ callback(value);
+ };
+ const expire = (error) => {
+ if (settled) return;
+ settled = true;
+ try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ }
+ reject(error);
+ };
+ try {
+ timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, {
+ operation: description, timeoutMs, reason: 'timeout'
+ })), timeoutMs);
+ } catch (error) {
+ expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, {
+ operation: description, reason: 'watchdog-unavailable', cause: error && error.message
+ }));
+ }
+ return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } };
+ }
+
+ function canonicalizeJson(value, path = '$', ancestors = new Set()) {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path });
+ return Object.is(value, -0) ? 0 : value;
+ }
+ if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') {
+ throw validation(`Non-JSON value at ${path}`, { path, type: typeof value });
+ }
+ if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path });
+ const prototype = Object.getPrototypeOf(value);
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path });
+ if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
+ && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) {
+ throw validation(`Symbol-keyed property at ${path}`, { path });
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => {
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path });
+ return canonicalizeJson(item, `${path}[${index}]`, ancestors);
+ });
+ }
+ const result = {};
+ for (const key of Object.keys(value).sort()) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path });
+ result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
+ }
+ return result;
+ } finally { ancestors.delete(value); }
+ }
+ function stableStringifyCanonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`;
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`;
+ }
+ function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); }
+ function checksum(value) {
+ const input = stableStringify(value);
+ let hash = 2166136261;
+ for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+ function legacyTimestamp(value) {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity;
+ if (Number.isFinite(Number(value))) return Number(value);
+ const parsed = Date.parse(value == null ? '' : String(value));
+ return Number.isFinite(parsed) ? parsed : -Infinity;
+ }
+ function parseLegacyCandidate(value, outerTimestamp) {
+ let parsed = value;
+ let timestamp = legacyTimestamp(outerTimestamp);
+ const hasOuterTimestamp = timestamp !== -Infinity;
+ for (let depth = 0; depth < 3; depth += 1) {
+ if (typeof parsed === 'string') {
+ try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; }
+ } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data')
+ && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) {
+ const innerTimestamp = legacyTimestamp(parsed.timestamp);
+ if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp;
+ parsed = parsed.data;
+ } else break;
+ }
+ return { value: clone(parsed), timestamp };
+ }
+ function parseLegacyValue(value) {
+ const candidate = parseLegacyCandidate(value);
+ return candidate ? candidate.value : clone(value);
+ }
+ async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) {
+ const values = {};
+ const candidates = {};
+ let readComplete = true;
+ const consider = (alias, rawValue, timestamp, sourceRank) => {
+ const candidate = parseLegacyCandidate(rawValue, timestamp);
+ if (!candidate) return;
+ const previous = candidates[alias];
+ if (!previous || candidate.timestamp > previous.timestamp
+ || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) {
+ candidates[alias] = Object.assign(candidate, { sourceRank });
+ }
+ };
+ if (indexedDBApi && typeof indexedDBApi.open === 'function') {
+ await new Promise((resolve) => {
+ let request;
+ let createdEmptyDatabase = false;
+ try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; }
+ request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); };
+ request.onupgradeneeded = () => {
+ createdEmptyDatabase = true;
+ try { request.transaction.abort(); } catch (_) {}
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; }
+ const tx = db.transaction(LEGACY_STORE_NAME, 'readonly');
+ const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys();
+ const rows = tx.objectStore(LEGACY_STORE_NAME).getAll();
+ tx.oncomplete = () => {
+ (keys.result || []).forEach((key, index) => {
+ const row = (rows.result || [])[index];
+ // v1's keyValueStore persisted { key, value, timestamp } rows.
+ const validRow = row && typeof row === 'object'
+ && Object.prototype.hasOwnProperty.call(row, 'key')
+ && String(row.key) === String(key)
+ && Object.prototype.hasOwnProperty.call(row, 'value');
+ if (!validRow) {
+ readComplete = false;
+ return;
+ }
+ consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0);
+ });
+ db.close(); resolve();
+ };
+ tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); };
+ };
+ });
+ }
+ for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) {
+ if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue;
+ for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) {
+ const key = fallbackStorage.key(index);
+ if (!key) continue;
+ const alias = key.startsWith('exam_system_')
+ ? key.slice('exam_system_'.length)
+ : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null);
+ if (!alias) continue;
+ try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ }
+ }
+ }
+ for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value;
+ Object.defineProperty(values, '__legacyReadComplete', {
+ value: readComplete,
+ enumerable: false,
+ configurable: false,
+ writable: false
+ });
+ return values;
+ }
+ async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) {
+ if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null;
+ const directoryHandle = await new Promise((resolve) => {
+ let request;
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value || null);
+ };
+ try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; }
+ request.onerror = () => finish(null);
+ request.onupgradeneeded = () => {
+ try { request.transaction.abort(); } catch (_) {}
+ finish(null);
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) {
+ db.close(); finish(null); return;
+ }
+ const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly')
+ .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY);
+ get.onerror = () => { db.close(); finish(null); };
+ get.onsuccess = () => { db.close(); finish(get.result); };
+ };
+ });
+ if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null;
+ if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null;
+ const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false });
+ const parsed = JSON.parse(await (await fileHandle.getFile()).text());
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined
+ ? parsed.data : parsed;
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null;
+ }
+ function lookupEntry(logicalKey) {
+ if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey });
+ return catalog.get(logicalKey);
+ }
+ function storeFor(logicalKey) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey });
+ return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE;
+ }
+ function makeEnvelope(entry, data, options = {}) {
+ const state = options.state === 'cleared' ? 'cleared' : 'present';
+ if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`);
+ let normalized = null;
+ if (state === 'present') {
+ try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) {
+ throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message });
+ }
+ normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`);
+ if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey });
+ }
+ const revision = options.revision === undefined ? 1 : Number(options.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`);
+ const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')),
+ updatedAt: options.updatedAt || nowIso(), state, data: normalized };
+ payload.checksum = checksum(payload.data);
+ return Object.freeze(payload);
+ }
+ function validateEnvelope(entry, envelope) {
+ try {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
+ || Number(envelope.schemaVersion) !== Number(entry.schemaVersion)
+ || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1
+ || typeof envelope.operationId !== 'string' || !envelope.operationId
+ || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt
+ || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false;
+ const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`);
+ return (envelope.state !== 'cleared' || data === null)
+ && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data);
+ } catch (_) { return false; }
+ }
+ function operationId(value) {
+ if (value === undefined || value === null || value === '') return randomId('mutation');
+ if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string');
+ return value;
+ }
+ function expectedRevision(value, label) {
+ if (value === undefined || value === null) return null;
+ const revision = Number(value);
+ if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`);
+ return revision;
+ }
+ function compactJournal(journal) {
+ const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence));
+ for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]];
+ }
+ function readJournal(row) {
+ const envelope = row && row.envelope;
+ return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
+ ? clone(envelope.data) : {};
+ }
+ function journalResult(journal, spec) {
+ const existing = journal[spec.operationId];
+ if (!existing) return null;
+ if (existing.fingerprint !== spec.fingerprint || !existing.receipt) {
+ throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId });
+ }
+ return clone(existing.receipt);
+ }
+ function writeJournal(journal, spec, receipt) {
+ const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1;
+ journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() };
+ compactJournal(journal);
+ return journal;
+ }
+ function putJournal(tx, currentRow, journal, spec, receipt) {
+ const current = currentRow && currentRow.envelope;
+ const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), {
+ revision: current ? Number(current.revision) + 1 : 1,
+ operationId: spec.operationId,
+ normalized: true
+ });
+ tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) });
+ }
+
+ class IndexedDBDriver {
+ constructor(indexedDBApi, options) {
+ this.indexedDB = indexedDBApi;
+ this.db = null;
+ this.mutationTimeoutMs = options.mutationTimeoutMs;
+ this.requestTimeoutMs = options.requestTimeoutMs;
+ }
+ async initialize() {
+ if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable');
+ this.db = await new Promise((resolve, reject) => {
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ let abandoned = false;
+ let settle;
+ request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); };
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ if (event.oldVersion < 2) {
+ for (const name of ['authoritative', 'derived']) {
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
+ }
+ }
+ for (const name of STORE_NAMES) {
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' });
+ }
+ };
+ settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject);
+ request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB'));
+ request.onblocked = () => {
+ abandoned = true;
+ settle.reject(new Error('IndexedDB upgrade blocked'));
+ };
+ });
+ this.db.onversionchange = () => this.close();
+ return this;
+ }
+ close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} }
+ _open() { if (!this.db) throw new Error('IndexedDB connection closed'); }
+ _transaction(stores, mode, description, work, mutation = false) {
+ this._open();
+ return new Promise((resolve, reject) => {
+ let failure = null;
+ let value;
+ let tx;
+ try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; }
+ const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject);
+ tx.oncomplete = () => settle.resolve(clone(value));
+ tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); };
+ tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`));
+ const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} };
+ try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); }
+ });
+ }
+ readEnvelope(logicalKey) {
+ const store = storeFor(logicalKey);
+ return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(logicalKey);
+ request.onsuccess = () => done(request.result ? request.result.envelope : null);
+ request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`));
+ });
+ }
+ readEntity(store, recordId) {
+ return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(recordId);
+ request.onsuccess = () => done(request.result || null);
+ request.onerror = () => fail(request.error || new Error('Entity read failed'));
+ });
+ }
+ readPracticeSnapshot(recordIds = null, options = {}) {
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES.slice();
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean));
+ return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => {
+ const result = Object.fromEntries(stores.map((store) => [store, []]));
+ let remaining = stores.length;
+ const finishStore = (store, rows) => {
+ result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || '')));
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ for (const store of stores) {
+ const objectStore = tx.objectStore(store);
+ const request = requested && requested.size === 1
+ ? objectStore.get(Array.from(requested)[0])
+ : objectStore.getAll();
+ request.onsuccess = () => {
+ const rows = requested && requested.size === 1
+ ? (request.result ? [request.result] : [])
+ : request.result;
+ finishStore(store, rows);
+ };
+ request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`));
+ }
+ });
+ }
+ listEntities(store) {
+ return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).getAll();
+ request.onsuccess = () => done(request.result || []);
+ request.onerror = () => fail(request.error || new Error('Entity list failed'));
+ });
+ }
+ atomic(spec) {
+ return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => {
+ const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal');
+ journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed'));
+ journalRequest.onsuccess = () => {
+ try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); }
+ };
+ }, true);
+ }
+ exportSnapshot(envelopeKeys) {
+ return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => {
+ const result = { envelopes: {}, entities: {} };
+ let remaining = STORE_NAMES.length;
+ for (const store of STORE_NAMES) {
+ const request = tx.objectStore(store).getAll();
+ request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`));
+ request.onsuccess = () => {
+ if (store === DOCUMENT_STORE || store === SYSTEM_STORE) {
+ for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope;
+ } else result.entities[store] = request.result || [];
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ }
+ });
+ }
+ }
+
+ function entityStore(store) {
+ const value = String(store || '');
+ if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value });
+ return value;
+ }
+ function validateEntityRow(store, row) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)
+ || typeof row.recordId !== 'string' || !row.recordId
+ || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1
+ || typeof row.operationId !== 'string' || !row.operationId
+ || typeof row.updatedAt !== 'string' || !row.updatedAt) {
+ throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null });
+ }
+ const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`);
+ if (row.checksum !== checksum(data)) {
+ throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId });
+ }
+ return row;
+ }
+ function normalizeEntityOperation(operation, index) {
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`);
+ const type = String(operation.type || '');
+ const store = entityStore(operation.store);
+ if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`);
+ const recordId = type === 'clear' ? null : String(operation.recordId || '');
+ if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`);
+ const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null;
+ return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) };
+ }
+ function receiptFor(operationIdValue, revisions, warnings, pending) {
+ const receipt = { committed: true, revisions, operationId: operationIdValue,
+ derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() };
+ const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]];
+ return receipt;
+ }
+
+ class DataKernel {
+ constructor(options = {}) {
+ this.driver = null;
+ this.backend = null;
+ this.state = 'created';
+ this.failure = null;
+ this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB;
+ this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS);
+ this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS);
+ this.committedListeners = new Set();
+ this.commitChannel = null;
+ this.instanceId = randomId('kernel');
+ this.ready = null;
+ }
+ _initializeCommitChannel() {
+ if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return;
+ try {
+ const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME);
+ channel.onmessage = (message) => {
+ const data = message && message.data;
+ if (!data || data.sourceInstanceId === this.instanceId
+ || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return;
+ this._dispatchCommitted({
+ operationId: data.operationId,
+ targets: clone(data.targets),
+ receipt: data.receipt ? clone(data.receipt) : null,
+ remote: true
+ });
+ };
+ this.commitChannel = channel;
+ } catch (_) {
+ this.commitChannel = null;
+ }
+ }
+ _closeCommitChannel() {
+ const channel = this.commitChannel;
+ this.commitChannel = null;
+ try { if (channel) channel.close(); } catch (_) {}
+ }
+ initialize() {
+ if (this.ready) return this.ready;
+ this.state = 'initializing';
+ this.ready = new IndexedDBDriver(this.indexedDB, {
+ mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs
+ }).initialize().then((driver) => {
+ this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this;
+ }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null;
+ throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); });
+ return this.ready;
+ }
+ close() {
+ if (this.driver) this.driver.close();
+ this.driver = null; this.backend = null;
+ this._closeCommitChannel();
+ if (this.state !== 'failed') this.state = 'closed';
+ }
+ _assertReady() {
+ if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message });
+ if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized');
+ }
+ _latch(error) {
+ if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message });
+ this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel();
+ return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message });
+ }
+ onCommitted(listener) {
+ if (typeof listener !== 'function') throw validation('Committed listener must be a function');
+ this.committedListeners.add(listener); return () => this.committedListeners.delete(listener);
+ }
+ _dispatchCommitted(event) {
+ if (!event || !this.committedListeners.size) return;
+ const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback);
+ schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} }));
+ }
+ _notifyCommitted(targets, receipt) {
+ if (!targets.length) return;
+ const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false };
+ this._dispatchCommitted(event);
+ if (this.commitChannel) {
+ try {
+ this.commitChannel.postMessage({
+ sourceInstanceId: this.instanceId,
+ operationId: event.operationId,
+ targets: event.targets,
+ receipt: event.receipt
+ });
+ } catch (_) { /* cross-realm notification is best effort */ }
+ }
+ }
+ async getEnvelope(logicalKey) {
+ this._assertReady(); const entry = lookupEntry(logicalKey);
+ try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async read(logicalKey, options = {}) {
+ const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey);
+ const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data;
+ return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data);
+ }
+ _documentSpec(changes, options) {
+ if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes');
+ const opId = operationId(options.operationId); const seen = new Set();
+ const prepared = changes.map((change, index) => {
+ if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`);
+ const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey);
+ if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel');
+ if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey);
+ const state = change.state === 'cleared' ? 'cleared' : 'present';
+ if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`);
+ if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`);
+ let data = null;
+ if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); }
+ return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) };
+ });
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings });
+ return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) };
+ }
+ async mutate(changes, options = {}) {
+ this._assertReady(); const spec = this._documentSpec(changes, options);
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) }));
+ let remaining = reads.length;
+ const finish = () => {
+ const revisions = {};
+ for (const item of reads) {
+ const current = item.request.result ? item.request.result.envelope : null;
+ if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey });
+ const revision = current ? Number(current.revision) : 0;
+ if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision });
+ const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true });
+ tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision;
+ }
+ const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []);
+ putJournal(tx, journalRow, journal, spec, receipt);
+ done(receipt);
+ };
+ if (!remaining) { finish(); return; }
+ for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification }));
+ this._notifyCommitted(targets, receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); }
+ async readEntity(store, recordId, options = {}) {
+ this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId');
+ try {
+ const row = await this.driver.readEntity(store, id);
+ if (!row) return null;
+ validateEntityRow(store, row);
+ return options.withMeta ? clone(row) : clone(row.data);
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ this._assertReady();
+ const ids = recordIds === null || recordIds === undefined
+ ? null
+ : (Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean);
+ try {
+ const snapshot = await this.driver.readPracticeSnapshot(ids, options);
+ const result = {};
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES;
+ for (const store of stores) {
+ const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : [])
+ .filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ result[store] = options.withMeta
+ ? clone(validRows)
+ : validRows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async listEntities(store, options = {}) {
+ this._assertReady(); store = entityStore(store);
+ if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId');
+ try {
+ const rows = await this.driver.listEntities(store);
+ const validRows = rows.filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data));
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async mutateEntities(operations, options = {}) {
+ this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations');
+ const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set();
+ for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); }
+ for (const store of ENTITY_STORES) {
+ const scoped = items.filter((item) => item.store === store);
+ if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) {
+ throw validation(`Entity clear cannot be combined with other operations for ${store}`);
+ }
+ }
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length;
+ const finish = () => { const revisions = {};
+ for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0;
+ if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`);
+ const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; }
+ }
+ for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; }
+ const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); };
+ if (!remaining) { try { finish(); } catch (error) { fail(error); } return; }
+ for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async exportSnapshot(options = {}) {
+ this._assertReady();
+ try {
+ const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null;
+ const shouldExport = (logicalKey) => {
+ if (!catalog.has(logicalKey)) return false;
+ const entry = lookupEntry(logicalKey);
+ if (selected && !selected.has(logicalKey)) return false;
+ if (entry.export === true) return true;
+ return options.includeSystem === true && entry.classification === 'system';
+ };
+ const data = await this.driver.exportSnapshot(shouldExport);
+ // Full/partial snapshots must be dense for their declared catalog
+ // range. An absent physical row means the catalog default, not an
+ // instruction that future importers should guess about.
+ for (const entry of catalog.list()) {
+ if (!shouldExport(entry.logicalKey)
+ || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue;
+ data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: 'snapshot-default'
+ });
+ }
+ if (Array.isArray(options.entityStores)) {
+ const selectedStores = new Set(options.entityStores.map(entityStore));
+ for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store];
+ }
+ const payload = { envelopes: data.envelopes, entities: data.entities };
+ return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) };
+ } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async installSnapshot(snapshot, options = {}) {
+ this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} };
+ const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities');
+ if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid');
+ if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch');
+ const changes = [];
+ for (const [logicalKey, envelope] of Object.entries(envelopes)) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue;
+ if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`);
+ changes.push({ logicalKey, entry, envelope });
+ }
+ const entityRows = {};
+ for (const store of ENTITY_STORES) {
+ if (!Object.prototype.hasOwnProperty.call(entities, store)) continue;
+ const rows = entities[store];
+ if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`);
+ const ids = new Set();
+ entityRows[store] = rows.map((row) => {
+ if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`);
+ const recordId = String(row.recordId);
+ if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`);
+ ids.add(recordId);
+ const data = canonicalizeJson(row.data);
+ if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`);
+ const revision = row.revision === undefined ? 1 : Number(row.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`);
+ return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) };
+ });
+ }
+ if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data');
+ const resetJournal = options.resetJournal === true;
+ const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object'
+ ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken')
+ : null;
+ const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {};
+ const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {};
+ const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({
+ kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey)
+ })).concat(Object.entries(entityChecks).map(([store, expected]) => ({
+ kind: 'entities', store, expected, request: tx.objectStore(store).getAll()
+ })));
+ const finish = () => {
+ for (const read of reads) {
+ if (read.kind === 'document') {
+ const current = read.request.result ? read.request.result.envelope : null;
+ const actualRevision = current ? Number(current.revision) : 0;
+ const expectedRevision = Number(read.expected) || 0;
+ if (actualRevision !== expectedRevision) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision });
+ }
+ } else {
+ const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0]));
+ const expected = read.expected && typeof read.expected === 'object' ? read.expected : {};
+ const ids = new Set(Object.keys(actual).concat(Object.keys(expected)));
+ for (const recordId of ids) {
+ const current = actual[recordId] || 0;
+ const wanted = Number(expected[recordId]) || 0;
+ if (current !== wanted) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId });
+ }
+ }
+ }
+ }
+ const revisions = {};
+ for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); }
+ for (const [store, rows] of Object.entries(entityRows)) {
+ tx.objectStore(store).clear();
+ for (const row of rows) tx.objectStore(store).put(row);
+ }
+ const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt);
+ };
+ if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; }
+ let remaining = reads.length;
+ for (const read of reads) {
+ read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed'));
+ read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } };
+ }
+ } }));
+ const targets = changes
+ .filter((item) => item.entry.owner !== 'backups')
+ .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification }))
+ .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' })));
+ this._notifyCommitted(targets, receipt);
+ return receipt;
+ }
+ catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); }
+ }
+
+ Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/appData.js ===== */
+(function installAppData(global) {
+ 'use strict';
+
+ const internals = global.__AppDataV2Internals;
+ if (!internals || typeof internals.DataKernel !== 'function') {
+ throw new Error('AppData v2 requires DataKernel');
+ }
+ const {
+ DataKernel,
+ AppDataError,
+ catalog,
+ clone,
+ randomId,
+ nowIso,
+ checksum
+ } = internals;
+ const kernel = new DataKernel();
+ const importPlans = new Map();
+ const RECOVERY_KEYS = Object.freeze({
+ activeSession: 'recovery.activeSessions',
+ draft: 'recovery.drafts',
+ interrupted: 'recovery.interrupted',
+ rejectedCompletion: 'recovery.rejectedCompletions'
+ });
+ const PREFERENCE_FIELDS = Object.freeze({
+ theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode',
+ resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay',
+ threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget',
+ consent: 'consent', logConfig: 'logConfig'
+ });
+ const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+
+ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+ function idOf(value, fields) {
+ for (const field of fields) {
+ if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]);
+ }
+ return '';
+ }
+
+ function importedLibraryId(value, options = {}) {
+ const id = value === null || value === undefined ? '' : String(value).trim();
+ if (!id && options.nullable) return null;
+ if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required');
+ if (/^exam_index(?:_|$)/.test(id)) {
+ throw new AppDataError('VALIDATION', 'Unsupported library configuration id');
+ }
+ return id;
+ }
+ function assertObject(value, message) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function assertArray(value, message) {
+ if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function jsonValue(value, label = 'value') {
+ try {
+ const serialized = JSON.stringify(value, (_key, current) => {
+ if (typeof current === 'bigint') return String(current);
+ if (typeof current === 'number' && !Number.isFinite(current)) return null;
+ return current;
+ });
+ if (serialized === undefined) return null;
+ return JSON.parse(serialized);
+ } catch (error) {
+ throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message });
+ }
+ }
+ function operationId(command, prefix, semanticPayload = command) {
+ const id = command && command.operationId ? String(command.operationId) : randomId(prefix);
+ jsonValue(semanticPayload, `${prefix} payload`);
+ return id;
+ }
+ function mutationOptions(command, prefix, semanticPayload, extra = {}) {
+ const source = asObject(command);
+ const payload = jsonValue(semanticPayload, `${prefix} payload`);
+ const intent = { command: prefix, payload };
+ if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) {
+ intent.expectedRevision = source.expectedRevision;
+ }
+ return Object.assign({}, extra, {
+ operationId: operationId(source, prefix, payload),
+ intent
+ });
+ }
+ function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) {
+ return mutationOptions(asObject(options), prefix, semanticPayload, extra);
+ }
+ function deterministicEntityId(prefix, operation) {
+ return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`;
+ }
+ function normalizeAccuracyRatio(value, label = 'accuracy') {
+ if (value === undefined || value === null || value === '') return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`);
+ }
+ return numeric > 1 ? numeric / 100 : numeric;
+ }
+ function defaultStats() {
+ return {
+ totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0,
+ reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ lastUpdated: nowIso()
+ };
+ }
+
+ function firstNonNegative(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === '' || typeof value === 'object') continue;
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return null;
+ }
+
+ function normalizePracticeScore(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo);
+ const overloadedAnswers = record.correctAnswers;
+ if (overloadedAnswers && typeof overloadedAnswers === 'object') {
+ record.correctAnswerMap = Object.assign(
+ {},
+ clone(asObject(overloadedAnswers)),
+ clone(asObject(record.correctAnswerMap))
+ );
+ }
+ const correct = firstNonNegative(
+ overloadedAnswers,
+ record.correctAnswersCount,
+ scoreInfo.correctAnswers,
+ scoreInfo.correct,
+ legacyScoreInfo.correctAnswers,
+ legacyScoreInfo.correct
+ );
+ if (correct !== null) record.correctAnswers = correct;
+ else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0;
+ const total = firstNonNegative(
+ record.totalQuestions,
+ record.questionCount,
+ scoreInfo.totalQuestions,
+ scoreInfo.total,
+ legacyScoreInfo.totalQuestions,
+ legacyScoreInfo.total
+ );
+ if (total !== null) record.totalQuestions = total;
+ }
+
+ function mergeAnswers(target, source) {
+ if (Array.isArray(source)) {
+ source.forEach((item, index) => {
+ if (!item || typeof item !== 'object') return;
+ const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1);
+ const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer;
+ if (answer !== undefined) target[questionId] = clone(answer);
+ });
+ return;
+ }
+ for (const [questionId, answer] of Object.entries(asObject(source))) {
+ target[String(questionId)] = clone(answer);
+ }
+ }
+
+ function normalizePracticeAnswers(record) {
+ const answers = {};
+ const raw = asObject(record.rawData);
+ const rawReal = asObject(raw.realData);
+ const real = asObject(record.realData);
+ for (const source of [
+ rawReal.answerMap, rawReal.answerList, rawReal.answers,
+ raw.answerMap, raw.answerList, raw.answers,
+ real.answerMap, real.answerList, real.answers,
+ record.answerMap, record.answerList, record.answers
+ ]) mergeAnswers(answers, source);
+ if (Object.keys(answers).length) record.answers = answers;
+ }
+
+ function questionTypeErrorCounts(source) {
+ const counts = {};
+ const add = (type, count = 1) => {
+ const key = String(type || '').trim();
+ if (key && count > 0) counts[key] = (counts[key] || 0) + count;
+ };
+ for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) {
+ const metrics = asObject(value);
+ const total = firstNonNegative(metrics.totalQuestions, metrics.total);
+ const correct = firstNonNegative(metrics.correctAnswers, metrics.correct);
+ if (total !== null && correct !== null) add(type, Math.max(0, total - correct));
+ }
+ for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) {
+ if (detail && detail.isCorrect === false) add(detail.questionType || detail.type);
+ }
+ return counts;
+ }
+
+ function canonicalizeRecord(input) {
+ assertObject(input, 'practice record must be an object');
+ const record = jsonValue(input, 'practice record');
+ record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record');
+ record.sessionId = idOf(record, ['sessionId']) || record.id;
+ record.timestamp = record.timestamp || record.completedAt || record.date || nowIso();
+ record.completedAt = record.completedAt || record.timestamp;
+ record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice';
+ record.metadata = asObject(record.metadata);
+ if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId;
+ if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId;
+ normalizePracticeAnswers(record);
+ normalizePracticeScore(record);
+ for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) {
+ if (record[field] === undefined || record[field] === null || record[field] === '') continue;
+ const numeric = Number(record[field]);
+ if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`);
+ record[field] = numeric;
+ }
+ if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy');
+ return jsonValue(record, 'canonical practice record');
+ }
+
+ function lightSuiteEntry(source, fallbackType = null) {
+ const entry = asObject(source);
+ const scoreInfo = asObject(entry.scoreInfo);
+ const realScoreInfo = asObject(asObject(entry.realData).scoreInfo);
+ const metadata = asObject(entry.metadata);
+ const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0;
+ const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0;
+ const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'suite entry accuracy'
+ ) || 0;
+ const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0;
+ return jsonValue({
+ id: entry.id || null,
+ sessionId: entry.sessionId || null,
+ examId: entry.examId || metadata.examId || null,
+ title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '',
+ type: entry.type || metadata.type || fallbackType,
+ date: entry.date || entry.completedAt || entry.timestamp || null,
+ duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage,
+ questionTypeErrorCounts: questionTypeErrorCounts(entry)
+ }, 'suite entry light projection');
+ }
+
+ function lightFromCanonical(source) {
+ const scoreInfo = asObject(source.scoreInfo);
+ const realScoreInfo = asObject(asObject(source.realData).scoreInfo);
+ const metadata = asObject(source.metadata);
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
+ const dataSource = hasOwn(source, 'dataSource')
+ ? source.dataSource
+ : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined);
+ const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0;
+ const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0;
+ const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'practice light accuracy'
+ ) || 0;
+ return jsonValue({
+ id: source.id,
+ sessionId: source.sessionId,
+ examId: source.examId || source.metadata.examId || null,
+ title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '',
+ type: source.type,
+ mode: source.mode || source.practiceMode || null,
+ timestamp: source.timestamp,
+ completedAt: source.completedAt,
+ date: source.date || source.completedAt || source.timestamp || null,
+ startTime: source.startTime || null,
+ endTime: source.endTime || null,
+ duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0,
+ score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null,
+ questionTypeErrorCounts: questionTypeErrorCounts(source),
+ // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined`
+ // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。
+ // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。
+ dataSource,
+ // Summaries are list indexes. Keep only the metadata needed to filter, show a
+ // source label, or locate the originating library; details stay in their entity.
+ metadata: Object.fromEntries([
+ // `source` must stay: PracticeRecordSource uses metadata.source demo markers
+ // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full.
+ 'examId', 'examTitle', 'title', 'type', 'category', 'frequency',
+ 'dataSource', 'source', 'libraryConfigurationId'
+ ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])),
+ suite: source.suite == null ? null : clone(asObject(source.suite)),
+ suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(
+ entry,
+ String(source.type || '').replace(/-suite$/, '') || null
+ ))
+ }, 'practice light projection');
+ }
+
+ function projectLight(record) {
+ if (!record) return null;
+ return lightFromCanonical(canonicalizeRecord(record));
+ }
+
+ function firstNonEmpty(...values) {
+ let first;
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ if (first === undefined) first = value;
+ if (Array.isArray(value) && value.length) return clone(value);
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value);
+ if (typeof value !== 'object') return clone(value);
+ }
+ return first === undefined ? {} : clone(first);
+ }
+
+ const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']);
+ const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']);
+
+ function withoutRawData(value) {
+ if (Array.isArray(value)) return value.map(withoutRawData);
+ if (!value || typeof value !== 'object') return clone(value);
+ const clean = {};
+ for (const [key, item] of Object.entries(value)) {
+ if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item);
+ }
+ return clean;
+ }
+
+ function splitPracticeRecord(input) {
+ const source = canonicalizeRecord(input);
+ const summary = lightFromCanonical(source);
+ const detail = { recordId: source.id };
+ const annotations = { recordId: source.id };
+ for (const [key, value] of Object.entries(source)) {
+ if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue;
+ if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value);
+ else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => {
+ const next = Object.assign({}, asObject(entry));
+ const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData));
+ for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]);
+ }
+ const annotation = {};
+ for (const annotationKey of ANNOTATION_FIELDS) {
+ if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; }
+ if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey];
+ if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey];
+ }
+ delete next.realData; delete next.rawData;
+ if (Object.keys(annotation).length) {
+ if (!annotations.suiteEntries) annotations.suiteEntries = {};
+ annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation;
+ }
+ return withoutRawData(next);
+ });
+ else detail[key] = withoutRawData(value);
+ }
+ // Accept the old mirror only as an input normalization boundary; it is never persisted.
+ const realData = asObject(source.realData); const rawData = asObject(source.rawData);
+ for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]);
+ }
+ for (const key of ANNOTATION_FIELDS) {
+ if (hasOwn(annotations, key)) continue;
+ if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]);
+ else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]);
+ }
+ return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') };
+ }
+
+ function joinPracticeRecord(summary, detail, annotations, projection = 'full') {
+ if (!summary) return null;
+ const mode = String(projection || 'full').toLowerCase();
+ const light = clone(summary);
+ if (mode === 'light' || mode === 'summary') return light;
+ const joined = Object.assign({}, light, clone(asObject(detail)));
+ delete joined.recordId;
+ if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection');
+ const annotationData = asObject(annotations);
+ for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value);
+ if (Array.isArray(joined.suiteEntries)) {
+ const suiteAnnotations = asObject(annotationData.suiteEntries);
+ joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {})));
+ }
+ return jsonValue(joined, 'practice full projection');
+ }
+
+ function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); }
+
+ // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。
+ // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定,
+ // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败,
+ // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。
+ const practiceRecordSource = global.PracticeRecordSource;
+ if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') {
+ throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)');
+ }
+ const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord;
+
+ function computeStats(records) {
+ const stats = defaultStats();
+ for (const record of asArray(records).filter(isRealPracticeRecord)) {
+ const summary = projectLight(record);
+ const type = String(summary.type || '').toLowerCase();
+ const target = type.includes('listen') ? stats.listening : stats.reading;
+ stats.totalPractices += 1;
+ stats.totalQuestions += summary.totalQuestions;
+ stats.correctAnswers += summary.correctAnswers;
+ target.practices += 1;
+ target.questions += summary.totalQuestions;
+ target.correct += summary.correctAnswers;
+ }
+ stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0;
+ for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0;
+ stats.lastUpdated = nowIso();
+ return stats;
+ }
+
+ function validIso(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function practiceType(record) {
+ const metadata = asObject(record.metadata);
+ const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType,
+ record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase();
+ if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening';
+ if (hints.includes('read')) return 'reading';
+ return null;
+ }
+
+ function accuracyRatio(record) {
+ const summary = lightFromCanonical(record);
+ const value = Number(summary.accuracy);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value > 1 ? value / 100 : value));
+ }
+
+ function durationSeconds(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const realData = asObject(record.realData);
+ const realScoreInfo = asObject(realData.scoreInfo);
+ for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return 0;
+ }
+
+ function earlierUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function laterUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function computeAchievementProgress(records, manual, existing) {
+ const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord)
+ .map((record, index) => ({
+ record,
+ index,
+ unlockedAt: validIso(record.completedAt || record.timestamp),
+ time: new Date(record.completedAt || record.timestamp).getTime()
+ }))
+ .sort((left, right) => {
+ const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER;
+ const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER;
+ return leftTime - rightTime || left.index - right.index;
+ });
+ const candidates = {};
+ const setThreshold = (id, list, count) => {
+ if (list.length >= count) candidates[id] = list[count - 1].unlockedAt;
+ };
+ setThreshold('first_step', items, 1);
+ setThreshold('practice_bronze', items, 10);
+ setThreshold('practice_silver', items, 50);
+ setThreshold('practice_gold', items, 100);
+ setThreshold('practice_platinum', items, 200);
+
+ const reading = items.filter((item) => practiceType(item.record) === 'reading');
+ const listening = items.filter((item) => practiceType(item.record) === 'listening');
+ setThreshold('reading_first', reading, 1);
+ setThreshold('reading_bronze', reading, 10);
+ setThreshold('reading_silver', reading, 50);
+ setThreshold('reading_gold', reading, 100);
+ setThreshold('listening_first', listening, 1);
+ setThreshold('listening_bronze', listening, 10);
+ setThreshold('listening_silver', listening, 50);
+ setThreshold('listening_gold', listening, 100);
+ if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt);
+ if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt);
+
+ let cumulativeDuration = 0;
+ let cumulativeAccuracy = 0;
+ let perfectCount = 0;
+ let speedCount = 0;
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ const accuracy = accuracyRatio(item.record);
+ const duration = durationSeconds(item.record);
+ cumulativeDuration += duration;
+ cumulativeAccuracy += accuracy;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt;
+ if (accuracy >= 1) {
+ perfectCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt;
+ if (perfectCount === 3) candidates.perfect_three = item.unlockedAt;
+ if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt;
+ }
+ if (duration > 0 && duration <= 300 && accuracy > 0.8) {
+ speedCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt;
+ if (speedCount === 3) candidates.speed_three = item.unlockedAt;
+ if (speedCount === 10) candidates.speed_ten = item.unlockedAt;
+ }
+ }
+
+ const dayItems = new Map();
+ for (const item of items) {
+ if (!item.unlockedAt) continue;
+ const day = item.unlockedAt.slice(0, 10);
+ if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt);
+ }
+ const days = Array.from(dayItems.keys()).sort();
+ let streak = 0;
+ let previousDay = null;
+ for (const day of days) {
+ const currentDay = new Date(`${day}T00:00:00.000Z`).getTime();
+ streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1;
+ previousDay = currentDay;
+ if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day);
+ if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day);
+ if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day);
+ if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day);
+ }
+
+ const progress = {};
+ const mergeUnlocked = (source) => {
+ for (const [rawId, value] of Object.entries(asObject(source))) {
+ if (!value || rawId === 'updatedAt') continue;
+ const id = rawId;
+ const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null;
+ if (!progress[id]) progress[id] = { unlockedAt };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ };
+ mergeUnlocked(existing);
+ mergeUnlocked(manual);
+ for (const [id, unlockedAt] of Object.entries(candidates)) {
+ if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ return jsonValue(progress, 'achievement progress');
+ }
+
+ // Entity records are authoritative. Projections are assembled on reads, never cached or
+ // scheduled as follow-up work; this keeps a successful write immediately observable.
+ async function retryMergeConflict(options, task, maxAttempts = 3) {
+ const explicitRevision = hasOwn(options, 'expectedRevision');
+ let lastError;
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await task();
+ } catch (error) {
+ lastError = error;
+ if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) {
+ throw error;
+ }
+ }
+ }
+ throw lastError;
+ }
+
+ async function readCollectionMeta(logicalKey) {
+ const meta = await kernel.read(logicalKey, { withMeta: true });
+ return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 };
+ }
+
+ function retainBackupEntries(items, limit = 20, preserveIds = []) {
+ const cap = Math.max(1, Number(limit) || 20);
+ const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
+ const entries = asArray(items).filter(Boolean).sort(newestFirst);
+ const retained = [];
+ const retainedIds = new Set();
+ const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean));
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap) break;
+ if (retainedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ return retained;
+ }
+
+ function hasOwn(value, key) {
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
+ }
+
+ function normalizeLibraryConfigurationId(value) {
+ return importedLibraryId(value, { nullable: true });
+ }
+
+ async function practiceRecordWithLibraryProvenance(source, command, options = {}) {
+ assertObject(source, 'practice record must be an object');
+ const record = jsonValue(source, 'practice record');
+ const metadata = asObject(record.metadata);
+ let configurationId;
+
+ if (hasOwn(command, 'libraryConfigurationId')) {
+ configurationId = command.libraryConfigurationId;
+ } else if (hasOwn(metadata, 'libraryConfigurationId')) {
+ configurationId = metadata.libraryConfigurationId;
+ } else if (hasOwn(record, 'libraryConfigurationId')) {
+ configurationId = record.libraryConfigurationId;
+ } else {
+ configurationId = await kernel.read('library.activeConfigurationId');
+ }
+
+ const normalizedId = normalizeLibraryConfigurationId(configurationId);
+ record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId });
+
+ if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) {
+ record.suiteEntries = record.suiteEntries.map((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
+ const next = jsonValue(entry, 'practice suite entry');
+ const entryMetadata = asObject(next.metadata);
+ const entryId = hasOwn(entryMetadata, 'libraryConfigurationId')
+ ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId)
+ : normalizedId;
+ next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId });
+ return next;
+ });
+ }
+
+ return record;
+ }
+
+ function practiceRecordMatches(record, identities) {
+ const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean));
+ if (!expected.size || !record || typeof record !== 'object') return false;
+ return ['id', 'recordId', 'sessionId'].some((field) => {
+ const value = record[field];
+ return value !== undefined && value !== null && expected.has(String(value));
+ });
+ }
+
+ function practiceLayerId(row) {
+ return String(row && (row.recordId || row.id || row.sessionId) || '');
+ }
+ async function practiceLayers(recordId, withMeta = false) {
+ const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta });
+ const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') };
+ }
+ function entityRevision(row) { return row ? Number(row.revision) : 0; }
+ function practiceUpserts(recordId, layers, existing = {}) {
+ return [
+ { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) },
+ { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) },
+ { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) }
+ ];
+ }
+ async function joinedPractice(recordId, projection, snapshot = null) {
+ const mode = String(projection || 'full').toLowerCase();
+ const stores = mode === 'light' || mode === 'summary'
+ ? ['practiceSummaries']
+ : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined);
+ const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores });
+ const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ const summary = find('practiceSummaries');
+ if (!summary) return null;
+ if (mode === 'light' || mode === 'summary') return clone(summary);
+ const detail = find('practiceDetails');
+ if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode);
+ return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode);
+ }
+ const practice = Object.freeze({
+ async list(options = {}) {
+ await ready;
+ const projection = String(options.projection || 'full').toLowerCase();
+ const summaries = await kernel.listEntities('practiceSummaries');
+ if (projection === 'light' || projection === 'summary') return summaries;
+ const stores = projection === 'detail' || projection === 'medium'
+ ? ['practiceSummaries', 'practiceDetails']
+ : undefined;
+ const snapshot = await kernel.readPracticeSnapshot(null, { stores });
+ return (await Promise.all(asArray(snapshot.practiceSummaries)
+ .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean);
+ },
+ async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); },
+ async completeAttempt(command) {
+ await ready;
+ const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command;
+ const mutation = mutationOptions(command, 'practice-complete', source);
+ const recordInput = await practiceRecordWithLibraryProvenance(source, command);
+ if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId);
+ const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id;
+ const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities(
+ practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation));
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async finalizeSuite(command) {
+ await ready; assertObject(command, 'finalizeSuite command is required');
+ const mutation = mutationOptions(command, 'practice-suite', command);
+ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true });
+ if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId);
+ const layers = splitPracticeRecord(input); const recordId = layers.summary.id;
+ const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String);
+ const children = new Set((await kernel.listEntities('practiceSummaries'))
+ .filter((summary) => practiceRecordMatches(summary, childIdentities))
+ .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId'])));
+ children.delete(recordId);
+ const receipt = await retryMergeConflict(command, async () => {
+ const existing = await practiceLayers(recordId, true);
+ const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id })));
+ return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation);
+ });
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async updateAnnotations(command) {
+ await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || '');
+ return retryMergeConflict(command, async () => {
+ const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`);
+ if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`);
+ const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data)));
+ const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default');
+ if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) {
+ if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`);
+ annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) });
+ } else {
+ if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`);
+ annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) });
+ Object.assign(annotations, clone(asObject(command.patch)));
+ }
+ return kernel.mutateEntities([{
+ type: 'upsert',
+ store: 'practiceAnnotations',
+ recordId,
+ data: annotations,
+ expectedRevision: entityRevision(current.annotations)
+ }], mutationOptions(command, 'practice-annotations', command));
+ });
+ },
+ async delete(command) {
+ await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required');
+ const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId }));
+ return Object.assign({}, receipt, { deletedCount: 1 });
+ },
+ async deleteMany(command) {
+ await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean)));
+ if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id])));
+ if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length });
+ },
+ async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); },
+ async listInsights(options = {}) {
+ await ready;
+ const limit = Math.max(1, Math.min(50, Number(options.limit) || 10));
+ const summaries = (await kernel.listEntities('practiceSummaries'))
+ .slice()
+ .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '')
+ .localeCompare(String(left.date || left.completedAt || left.timestamp || '')))
+ .slice(0, limit);
+ return Promise.all(summaries.map(async (summary) => {
+ if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary);
+ const detail = await kernel.readEntity('practiceDetails', summary.id);
+ return jsonValue(Object.assign({}, clone(summary), {
+ questionTypeErrorCounts: questionTypeErrorCounts(detail)
+ }), 'practice insight');
+ }));
+ },
+ async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); },
+ projectLight,
+ projectDetail
+ });
+
+ const settings = Object.freeze({
+ async getAll() { await ready; return kernel.read('settings.values'); },
+ async patch(values, options = {}) {
+ await ready; assertObject(values, 'settings.patch requires an object');
+ const mutation = optionsMutationOptions(options, 'settings-patch', values);
+ return retryMergeConflict(options, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ });
+ },
+ async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); }
+ });
+
+ const library = Object.freeze({
+ async listConfigurations() { await ready; return kernel.read('library.configurations'); },
+ async getActive() { await ready; return kernel.read('library.activeConfigurationId'); },
+ async getIndex(configurationId) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ if (id === null) return [];
+ const indexes = await kernel.read('library.importedIndexes');
+ return asArray(indexes[id]);
+ },
+ async updateConfiguration(configuration, options = {}) {
+ await ready; assertObject(configuration, 'library.updateConfiguration requires an object');
+ const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId']));
+ const current = await kernel.read('library.configurations', { withMeta: true });
+ const configs = asArray(current.data);
+ const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id);
+ const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id });
+ if (index >= 0) configs[index] = next; else configs.push(next);
+ return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration));
+ },
+ async activate(configurationId, options = {}) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ const current = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id }));
+ },
+ async import(command) {
+ await ready; assertObject(command, 'library.import requires a command');
+ const id = importedLibraryId(command.id || command.configurationId || randomId('library'));
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id);
+ configs.push(Object.assign({}, asObject(command.configuration), { id, key: id }));
+ const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) });
+ return kernel.mutate([
+ { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ], mutationOptions(command, 'library-import', command));
+ },
+ async remove(configurationId, options = {}) {
+ await ready; const id = importedLibraryId(configurationId);
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id];
+ const changes = [
+ { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ];
+ if (String(activeMeta.data || '') === id) {
+ changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id }));
+ },
+ async resolveIndex() {
+ await ready;
+ const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]);
+ return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]);
+ }
+ });
+
+ function recoveryKey(kind) {
+ const key = RECOVERY_KEYS[String(kind || '')];
+ if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`);
+ return key;
+ }
+ // Recovery document TTL is an AppData domain rule, not a catalog policy field.
+ const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ function recoveryTimestamp(item) {
+ for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) {
+ const parsed = Date.parse(item && item[field]);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return null;
+ }
+ async function pruneRecoveryKey(logicalKey) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const items = asArray(current.data);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const retained = items.filter((item) => {
+ const timestamp = recoveryTimestamp(item);
+ return timestamp === null || timestamp > cutoff;
+ });
+ if (retained.length === items.length) return items;
+ try {
+ await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], {
+ operationId: randomId('recovery-ttl')
+ });
+ return retained;
+ } catch (error) {
+ if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error;
+ }
+ }
+ return kernel.read(logicalKey);
+ }
+ async function cleanupExpiredRecovery() {
+ for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey);
+ }
+ const windowSession = Object.freeze({
+ save(name, value) {
+ if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable');
+ const logicalName = String(name || 'default');
+ const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) };
+ global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload));
+ return true;
+ },
+ get(name) {
+ if (!global.sessionStorage) return null;
+ const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null;
+ },
+ discard(name) {
+ if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ return true;
+ }
+ });
+
+ const recoveryMutationTails = new Map();
+ function enqueueRecoveryMutation(logicalKey, task) {
+ const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve();
+ const result = previous.then(task, task);
+ recoveryMutationTails.set(logicalKey, result.catch(() => undefined));
+ return result;
+ }
+
+ async function readRecovery(kind, id) {
+ await ready;
+ const items = await pruneRecoveryKey(recoveryKey(kind));
+ return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null;
+ }
+ async function saveRecovery(kind, value, options = {}) {
+ await ready; assertObject(value, `recovery ${kind} value must be an object`);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value);
+ const key = recoveryKey(kind);
+ const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId);
+ const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() });
+ const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ if (index >= 0) current.items[index] = item; else current.items.push(item);
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation);
+ }));
+ const committedItem = (await kernel.read(key))
+ .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ return Object.assign({}, receipt, { item: clone(committedItem || item) });
+ }
+ async function discardRecovery(kind, id, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id));
+ return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearRecovery(kind, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) {
+ throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision });
+ }
+ return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearAllRecovery(options = {}) {
+ const results = {};
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ results[kind] = await clearRecovery(kind, options);
+ }
+ return results;
+ }
+ const recovery = Object.freeze({
+ windowSession,
+ async clear(options = {}) { return clearAllRecovery(options); },
+ async listActiveSessions() { return readRecovery('activeSession'); },
+ async getActiveSession(id) { return readRecovery('activeSession', id); },
+ async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); },
+ async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async listDrafts() { return readRecovery('draft'); },
+ async getDraft(id) { return readRecovery('draft', id); },
+ async saveDraft(value, options) { return saveRecovery('draft', value, options); },
+ async discardDraft(id, options) { return discardRecovery('draft', id, options); },
+ async listInterrupted() { return readRecovery('interrupted'); },
+ async getInterrupted(id) { return readRecovery('interrupted', id); },
+ async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); },
+ async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); },
+ async listRejectedCompletions() { return readRecovery('rejectedCompletion'); },
+ async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); },
+ async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); },
+ async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); }
+ });
+
+ function isImportableEntry(entry) {
+ return entry
+ && entry.classification !== 'system'
+ && entry.classification !== 'session'
+ && entry.import !== 'ignore';
+ }
+
+ function isPlainImportObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
+ }
+
+ function isV2SnapshotShape(parsed) {
+ return isPlainImportObject(parsed)
+ && parsed.format === 'ielts-atlas-data-v2'
+ && isPlainImportObject(parsed.envelopes)
+ && isPlainImportObject(parsed.entities);
+ }
+
+ const POISONED_V2_WRAPPER_ALIASES = Object.freeze({
+ 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']),
+ 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']),
+ 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state'])
+ });
+ const LIBRARY_IMPORT_KEYS = Object.freeze([
+ 'library.configurations',
+ 'library.importedIndexes',
+ 'library.activeConfigurationId'
+ ]);
+
+ function canonicalizeV2Import(parsed) {
+ const warnings = [];
+ const repairedKeys = [];
+ const ignoredKeys = [];
+ const envelopes = {};
+ for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const envelope = clone(rawEnvelope);
+ const data = envelope && envelope.state === 'present' ? envelope.data : null;
+ if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') {
+ ignoredKeys.push(logicalKey);
+ warnings.push('Skipped poisoned active library id');
+ continue;
+ }
+ if (isPlainImportObject(data)
+ && Object.prototype.hasOwnProperty.call(data, 'key')
+ && Object.prototype.hasOwnProperty.call(data, 'value')
+ && String(data.key || '').startsWith('exam_system_')) {
+ const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || [];
+ const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null;
+ if (!isPlainImportObject(decoded)) {
+ ignoredKeys.push(logicalKey);
+ warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`);
+ continue;
+ }
+ const overlay = Object.fromEntries(Object.entries(data)
+ .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp'));
+ envelope.data = Object.assign({}, decoded, overlay);
+ envelope.checksum = checksum(envelope.data);
+ repairedKeys.push(logicalKey);
+ warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`);
+ }
+ envelopes[logicalKey] = envelope;
+ }
+
+ if (parsed.scope === 'full') {
+ const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key));
+ if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) {
+ for (const key of presentLibraryKeys) {
+ delete envelopes[key];
+ ignoredKeys.push(key);
+ }
+ warnings.push('Skipped incomplete library data');
+ }
+ }
+ const exportableKeys = catalog.list()
+ .filter((entry) => entry.export === true && isImportableEntry(entry))
+ .map((entry) => entry.logicalKey);
+ const missingKeys = parsed.scope === 'full'
+ ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key))
+ : [];
+ const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length);
+ return {
+ envelopes,
+ warnings,
+ repairedKeys,
+ ignoredKeys,
+ missingKeys,
+ declaredScope: parsed.scope,
+ effectiveScope: degraded ? 'partial' : parsed.scope,
+ trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial')
+ };
+ }
+
+ function resolveImportReplaceFlags(options = {}) {
+ const source = asObject(options);
+ const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase();
+ const replaceAll = source.replace === true;
+ return {
+ replaceDocuments: replaceAll,
+ // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge.
+ replacePractice: replaceAll || practiceMode === 'replace'
+ };
+ }
+
+ function pickFirstRecordArray(candidates) {
+ for (const candidate of asArray(candidates)) {
+ if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) {
+ return { source: candidate.source, records: candidate.records };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Historical v1 export shapes (opensource / pre-AppData-v2):
+ * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats }
+ * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? }
+ * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*)
+ * - bare array of records, or { records: [...] }
+ * Recognition only — no dual backend and no local store migration.
+ */
+ function extractLegacyPracticeRecords(payload) {
+ const sources = [];
+ const add = (source, records) => {
+ if (Array.isArray(records) && records.some(isPlainImportObject)) {
+ sources.push({ source, records });
+ }
+ };
+
+ if (Array.isArray(payload)) {
+ add('(root array)', payload);
+ } else if (isPlainImportObject(payload)) {
+ const preferred = pickFirstRecordArray([
+ { source: 'practice_records', records: payload.practice_records },
+ { source: 'practiceRecords', records: payload.practiceRecords },
+ { source: 'records', records: payload.records }
+ ]);
+ if (preferred) add(preferred.source, preferred.records);
+
+ const data = isPlainImportObject(payload.data) ? payload.data : null;
+ if (data) {
+ const nested = pickFirstRecordArray([
+ { source: 'data.practice_records', records: data.practice_records },
+ { source: 'data.practiceRecords', records: data.practiceRecords }
+ ]);
+ if (nested) add(nested.source, nested.records);
+ else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data);
+ else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data);
+ if (isPlainImportObject(data.exam_system_practice_records)) {
+ add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
+ }
+ }
+ if (isPlainImportObject(payload.exam_system_practice_records)) {
+ add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
+ }
+ }
+
+ const seen = new Set();
+ const records = [];
+ for (const entry of sources) {
+ for (const item of asArray(entry.records)) {
+ if (!isPlainImportObject(item)) continue;
+ const identity = idOf(item, ['id', 'recordId', 'sessionId']);
+ if (identity) {
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ }
+ records.push(item);
+ }
+ }
+ return {
+ records,
+ sources: sources.map((entry) => entry.source)
+ };
+ }
+
+ function entityRowFromLayer(recordId, data, operationId) {
+ const payload = jsonValue(data, 'import practice entity');
+ return {
+ recordId: String(recordId),
+ revision: 1,
+ operationId: String(operationId || `import-${recordId}`),
+ updatedAt: nowIso(),
+ data: payload,
+ checksum: checksum(payload)
+ };
+ }
+
+ function convertLegacyPracticeImport(payload) {
+ const extracted = extractLegacyPracticeRecords(payload);
+ if (!extracted.records.length) {
+ throw new AppDataError(
+ 'VALIDATION',
+ 'Import file is neither a v2 snapshot nor a recognizable v1 practice export'
+ );
+ }
+
+ const entities = {
+ practiceSummaries: [],
+ practiceDetails: [],
+ practiceAnnotations: []
+ };
+ const warnings = [];
+ let skipped = 0;
+
+ for (const raw of extracted.records) {
+ try {
+ const layers = splitPracticeRecord(raw);
+ const recordId = layers.summary.id;
+ const operationId = `import-v1-${recordId}`;
+ entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId));
+ entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId));
+ entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId));
+ } catch (error) {
+ skipped += 1;
+ warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`);
+ }
+ }
+
+ if (!entities.practiceSummaries.length) {
+ throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized');
+ }
+
+ const accepted = entities.practiceSummaries.length;
+ return {
+ format: 'v1',
+ scope: 'partial',
+ envelopes: {},
+ entities,
+ checksum: null,
+ warnings,
+ practiceSummary: {
+ accepted,
+ importedCount: accepted,
+ skippedCount: skipped,
+ sources: extracted.sources.slice()
+ }
+ };
+ }
+
+ function parseImportPayload(payload) {
+ let parsed;
+ try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); }
+ catch (error) {
+ if (error instanceof AppDataError) throw error;
+ throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message });
+ }
+
+ // Bare record arrays are a historical import convenience (UI file pickers).
+ if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed);
+ if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object');
+
+ if (isV2SnapshotShape(parsed)) {
+ if (Number(parsed.schemaVersion) !== Number(catalog.version)) {
+ throw new AppDataError('VALIDATION', 'Import schema version mismatch');
+ }
+ if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) {
+ throw new AppDataError('VALIDATION', 'Import checksum mismatch');
+ }
+ if (parsed.scope !== 'full' && parsed.scope !== 'partial') {
+ throw new AppDataError('VALIDATION', 'Import scope must be full or partial');
+ }
+ const scope = parsed.scope;
+ for (const [store, rows] of Object.entries(parsed.entities)) {
+ if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) {
+ throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`);
+ }
+ for (const row of rows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) {
+ throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ }
+ }
+ }
+ if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) {
+ throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer');
+ }
+ const canonical = canonicalizeV2Import(parsed);
+ return {
+ format: 'v2',
+ scope: canonical.effectiveScope,
+ declaredScope: canonical.declaredScope,
+ envelopes: canonical.envelopes,
+ entities: parsed.entities,
+ checksum: parsed.checksum,
+ warnings: canonical.warnings,
+ practiceSummary: null,
+ repairedKeys: canonical.repairedKeys,
+ ignoredKeys: canonical.ignoredKeys,
+ missingKeys: canonical.missingKeys,
+ trust: canonical.trust
+ };
+ }
+
+ // Explicit but malformed v2 claims must not fall through to legacy parsers.
+ if (parsed.format === 'ielts-atlas-data-v2') {
+ throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported');
+ }
+
+ return convertLegacyPracticeImport(parsed);
+ }
+
+ function collectionIdentityFields(logicalKey) {
+ if (logicalKey === 'library.configurations') return ['id', 'key', 'configId'];
+ if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId'];
+ if (logicalKey === 'backups.entries') return ['id'];
+ if (logicalKey === 'vocab.words') return ['id', 'word', 'key'];
+ if (logicalKey === 'goals.items') return ['id', 'goalId'];
+ return ['id', 'sessionId', 'recordId'];
+ }
+
+ function collectionIdentity(logicalKey, value) {
+ const identity = idOf(value, collectionIdentityFields(logicalKey));
+ return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity;
+ }
+
+ function mergeCollection(existing, incoming, logicalKey) {
+ const result = asArray(existing).map((item) => clone(item));
+ const positions = new Map();
+ result.forEach((item, index) => {
+ const identity = collectionIdentity(logicalKey, item);
+ if (identity) positions.set(identity, index);
+ });
+ for (const rawItem of asArray(incoming)) {
+ const item = jsonValue(rawItem, `${logicalKey} item`);
+ const identity = collectionIdentity(logicalKey, item);
+ if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`);
+ if (positions.has(identity)) result[positions.get(identity)] = item;
+ else {
+ positions.set(identity, result.length);
+ result.push(item);
+ }
+ }
+ return result;
+ }
+
+ function mergeImportValue(entry, existing, incoming) {
+ const policy = entry.import;
+ if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey);
+ if (policy === 'patch') {
+ if (Array.isArray(existing) || Array.isArray(incoming)) {
+ // Array-shaped keys should use merge-by-id; treat accidental patch as replace.
+ return clone(incoming);
+ }
+ return Object.assign({}, asObject(existing), asObject(incoming));
+ }
+ if (policy === 'replace') return clone(incoming);
+ throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`);
+ }
+
+ async function currentEntitySnapshot() {
+ const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true });
+ const result = {};
+ for (const store of PRACTICE_ENTITY_STORES) {
+ if (store === 'practiceSummaries') result[store] = summaries;
+ else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean);
+ }
+ return result;
+ }
+ function practiceEntityIds(rows) {
+ return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean));
+ }
+ function assertPracticeEntitySetsMatch(entities, message) {
+ const expected = practiceEntityIds(entities.practiceSummaries);
+ for (const store of PRACTICE_ENTITY_STORES.slice(1)) {
+ const actual = practiceEntityIds(entities[store]);
+ if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) {
+ throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', {
+ counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size]))
+ });
+ }
+ }
+ }
+ async function createImportPlan(parsed, options = {}) {
+ const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options);
+ const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} };
+ const revisionToken = { documents: {}, entities: {} };
+ const keys = []; const clearedKeys = [];
+ const warnings = asArray(parsed.warnings).map(String);
+ for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue;
+ if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`);
+ if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) {
+ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`);
+ continue;
+ }
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0;
+ let next = envelope;
+ if (!replaceDocuments && envelope.state === 'present') {
+ next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') });
+ }
+ snapshot.envelopes[logicalKey] = next;
+ keys.push(logicalKey);
+ if (next.state === 'cleared') clearedKeys.push(logicalKey);
+ }
+
+ // A full replace mirrors all exportable user data. Missing physical
+ // envelopes mean catalog defaults, represented here as explicit clears.
+ if (replaceDocuments && parsed.scope === 'full') {
+ for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue;
+ snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: randomId('import-clear')
+ });
+ keys.push(entry.logicalKey);
+ clearedKeys.push(entry.logicalKey);
+ }
+ }
+
+ // Any successful practice import installs all three stores together. Merge
+ // may update a subset only when the final recordId sets remain identical.
+ const sourceStores = Object.keys(asObject(parsed.entities));
+ let practiceExistingCount = null;
+ let practiceIncomingCount = null;
+ if (sourceStores.length) {
+ if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) {
+ throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations');
+ }
+ const current = await currentEntitySnapshot();
+ revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries(
+ asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0])
+ )]));
+ practiceExistingCount = asArray(current.practiceSummaries).length;
+ practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length;
+ const existing = replacePractice
+ ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []]))
+ : current;
+ for (const store of PRACTICE_ENTITY_STORES) {
+ const rows = asArray(existing[store]).map(clone);
+ const positions = new Map(rows.map((row, index) => [String(row.recordId), index]));
+ for (const row of asArray(parsed.entities[store])) {
+ if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ const index = positions.get(String(row.recordId));
+ if (index === undefined) {
+ positions.set(String(row.recordId), rows.length);
+ rows.push(clone(row));
+ } else rows[index] = clone(row);
+ }
+ snapshot.entities[store] = rows;
+ }
+ assertPracticeEntitySetsMatch(snapshot.entities);
+ }
+
+ snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities });
+ const practiceSummary = parsed.practiceSummary
+ ? clone(parsed.practiceSummary)
+ : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries')
+ ? {
+ accepted: Number(practiceIncomingCount) || 0,
+ importedCount: Number(practiceIncomingCount) || 0,
+ skippedCount: 0,
+ existingCount: Number(practiceExistingCount) || 0,
+ incomingCount: Number(practiceIncomingCount) || 0,
+ finalCount: asArray(snapshot.entities.practiceSummaries).length,
+ removedCount: Math.max(0, (Number(practiceExistingCount) || 0)
+ - asArray(snapshot.entities.practiceSummaries).length)
+ }
+ : null);
+ if (practiceSummary && practiceSummary.existingCount === undefined) {
+ practiceSummary.existingCount = Number(practiceExistingCount) || 0;
+ practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0;
+ practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length;
+ practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount);
+ }
+ const destructive = clearedKeys.length > 0
+ || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0);
+ return {
+ snapshot,
+ keys,
+ clearedKeys,
+ warnings,
+ practiceSummary,
+ destructive,
+ resetJournal: replaceDocuments && replacePractice,
+ revisionToken,
+ diagnostics: {
+ format: parsed.format,
+ replaceDocuments,
+ replacePractice,
+ declaredScope: parsed.declaredScope || parsed.scope,
+ effectiveScope: parsed.scope,
+ trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'),
+ missingKeys: clone(parsed.missingKeys || []),
+ repairedKeys: clone(parsed.repairedKeys || []),
+ ignoredKeys: clone(parsed.ignoredKeys || [])
+ }
+ };
+ }
+ async function createRestoreSnapshot(backup) {
+ const parsed = parseImportPayload(asObject(backup && backup.data));
+ if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups');
+ if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch');
+ return (await createImportPlan(parsed, { replace: true })).snapshot;
+ }
+
+ const backups = Object.freeze({
+ onDataCommitted(listener) { return kernel.onCommitted(listener); },
+ async getSettings() { await ready; return kernel.read('backups.settings'); },
+ async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); },
+ async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); },
+ async getImportHistory() { await ready; return kernel.read('backups.importHistory'); },
+ async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); },
+ async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); },
+ async create(options = {}) {
+ await ready; const current = await readCollectionMeta('backups.entries');
+ const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' });
+ const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup'));
+ const existing = current.items.find((item) => String(item.id) === String(backupId));
+ if (existing) {
+ if (String(existing.operationId || '') === String(mutation.operationId)
+ && String(existing.type || 'manual') === String(options.type || 'manual')) {
+ return clone(existing);
+ }
+ throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, {
+ backupId: String(backupId)
+ });
+ }
+ const snapshot = await kernel.exportSnapshot();
+ const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum };
+ current.items.unshift(backup);
+ current.items = retainBackupEntries(current.items, 20, options.preserveIds);
+ await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation);
+ const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId));
+ return clone(committed || backup);
+ },
+ async list() { await ready; return kernel.read('backups.entries'); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); },
+ async export(options = {}) {
+ await ready;
+ if (options.backupId !== undefined && options.backupId !== null) {
+ const backupId = String(options.backupId);
+ const stored = asArray(await kernel.read('backups.entries'))
+ .find((item) => String(item && item.id) === backupId);
+ if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`);
+ const portable = jsonValue(stored, 'stored backup export');
+ if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) {
+ throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`);
+ }
+ return portable;
+ }
+ const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null;
+ const logicalKeys = domains
+ ? catalog.list()
+ .filter((entry) => domains.has(entry.owner) && entry.export === true)
+ .map((entry) => entry.logicalKey)
+ : null;
+ const entityStores = !domains || domains.has('practice')
+ ? undefined
+ : [];
+ return kernel.exportSnapshot(Object.assign(
+ logicalKeys ? { logicalKeys } : {},
+ entityStores ? { entityStores } : {}
+ ));
+ },
+ async previewImport(payload, options = {}) {
+ await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan');
+ const cutoff = Date.now() - (30 * 60 * 1000);
+ for (const [id, existing] of importPlans) {
+ if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id);
+ }
+ const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) };
+ importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive };
+ },
+ async commitImport(planId, options = {}) {
+ await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`);
+ if (plan.destructive && options.confirmDestructive !== true) {
+ throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation');
+ }
+ const mutation = optionsMutationOptions(options, 'import-commit', {
+ planId: plan.id,
+ signature: plan.signature
+ }, { warnings: plan.warnings });
+ const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, {
+ resetJournal: plan.resetJournal === true,
+ expectedRevisionToken: plan.revisionToken
+ }));
+ importPlans.delete(String(planId));
+ return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) });
+ },
+ async restore(id, options = {}) {
+ await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id));
+ if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`);
+ const snapshot = await createRestoreSnapshot(backup);
+ const restoreMutation = optionsMutationOptions(options, 'backup-restore', {
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }, { resetJournal: true });
+ const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`;
+ const preRestoreBackupId = `pre_restore_${checksum({
+ operationId: restoreMutation.operationId,
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }).replace(/[^a-z0-9]/gi, '')}`;
+ const preRestoreBackup = await backups.create({
+ id: preRestoreBackupId,
+ operationId: preRestoreOperationId,
+ type: 'pre-restore',
+ preserveIds: [String(id)]
+ });
+ const receipt = await kernel.installSnapshot(snapshot, restoreMutation);
+ return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id });
+ }
+ });
+
+ let vocabMutationTail = Promise.resolve();
+ function enqueueVocabMutation(task) {
+ const result = vocabMutationTail.then(task, task);
+ vocabMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ function retryVocabMutation(options, task) {
+ return enqueueVocabMutation(() => retryMergeConflict(options, task));
+ }
+
+ const vocab = Object.freeze({
+ async listWords() { await ready; return kernel.read('vocab.words'); },
+ async saveWords(words, options = {}) {
+ await ready; assertArray(words, 'vocab.saveWords requires an array');
+ const mutation = optionsMutationOptions(options, 'vocab-words', words);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.words', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.words',
+ data: words,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async getConfig() { await ready; return kernel.read('vocab.userConfig'); },
+ async setConfig(config, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'vocab-config', config);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: asObject(config),
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async patchConfig(patch, options = {}) {
+ await ready; assertObject(patch, 'vocab.patchConfig requires an object');
+ const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), clone(patch));
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); },
+ async listCollections() { await ready; return kernel.read('vocab.lists'); },
+ async saveCollection(id, value, options = {}) {
+ await ready;
+ const collectionId = String(id);
+ const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async saveCollections(values, options = {}) {
+ await ready;
+ assertObject(values, 'vocab.saveCollections requires an object');
+ const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)]));
+ const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), upserts);
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async upsertCollectionWord(collectionId, word, options = {}) {
+ await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word');
+ const id = String(collectionId || '');
+ if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required');
+ const identity = String(word.word || word.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ const existing = collections[id];
+ const list = existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? Object.assign({}, clone(existing), { words: asArray(existing.words) })
+ : { id, words: asArray(existing) };
+ const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity);
+ const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() });
+ if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt;
+ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord);
+ list.updatedAt = nowIso();
+ collections[id] = list;
+ const receipt = await kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(nextWord) });
+ });
+ },
+ async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; },
+ async replaceListWords(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceListWords requires a command');
+ const id = String(command.listId || 'default'); const words = asArray(command.words);
+ if (id === 'default') return this.saveWords(words, options);
+ const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async mergeListWords(command, options = {}) {
+ await ready;
+ assertObject(command, 'vocab.mergeListWords requires a command');
+ const listId = String(command.listId || 'default');
+ const incoming = asArray(command.words);
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(options, 'vocab-words-merge', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data));
+ const storedList = listId === 'default'
+ ? asArray(current.data)
+ : (function readStoredCollection() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? asArray(collection.words)
+ : asArray(collection);
+ }());
+ const merged = storedList.map((word) => clone(word));
+ const positions = new Map();
+ merged.forEach((word, index) => {
+ const identity = String(word && (word.word || word.id) || '').trim().toLowerCase();
+ if (identity) positions.set(identity, index);
+ });
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const rawWord of incoming) {
+ assertObject(rawWord, 'vocab.mergeListWords entries must be objects');
+ const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ if (!positions.has(identity)) {
+ positions.set(identity, merged.length);
+ merged.push(clone(rawWord));
+ addedCount += 1;
+ continue;
+ }
+ const index = positions.get(identity);
+ const existing = asObject(merged[index]);
+ const patch = {};
+ if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim();
+ if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim();
+ if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq;
+ merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() });
+ updatedCount += 1;
+ }
+ const data = listId === 'default'
+ ? merged
+ : Object.assign({}, collections, {
+ [listId]: Object.assign(
+ {},
+ (function collectionBaseForWrite() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? clone(collection)
+ : {};
+ }()),
+ { id: listId, words: merged, updatedAt: nowIso() }
+ )
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount });
+ });
+ },
+ async patchWord(command, options = {}) {
+ await ready; assertObject(command, 'vocab.patchWord requires a command');
+ const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || '');
+ if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required');
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(
+ Object.assign({}, options, { operationId: command.operationId || options.operationId }),
+ 'vocab-word-patch',
+ command
+ );
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : asObject(current.data);
+ const list = listId === 'default'
+ ? asArray(current.data)
+ : asArray(asObject(collections[listId]).words);
+ const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId);
+ if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`);
+ const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() });
+ const next = list.slice(); next[index] = updated;
+ const data = listId === 'default'
+ ? next
+ : Object.assign({}, collections, {
+ [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() })
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(updated) });
+ });
+ },
+ async replaceProgress(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceProgress requires a command');
+ const listId = String(command.listId || 'default'); const words = asArray(command.words);
+ const mutation = optionsMutationOptions(options, 'vocab-progress', command);
+ return retryVocabMutation(options, async () => {
+ const configMeta = await kernel.read('vocab.userConfig', { withMeta: true });
+ const changes = [{
+ logicalKey: 'vocab.userConfig',
+ data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }),
+ expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0
+ }];
+ if (listId === 'default') {
+ const wordsMeta = await kernel.read('vocab.words', { withMeta: true });
+ changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 });
+ } else {
+ const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data));
+ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words });
+ changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, mutation);
+ });
+ }
+ });
+
+ async function readPreferences() { await ready; return kernel.read('preferences.values'); }
+ let preferenceMutationTail = Promise.resolve();
+ function enqueuePreferenceMutation(task) {
+ const result = preferenceMutationTail.then(task, task);
+ preferenceMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ async function writePreference(field, value, options = {}) {
+ const mutation = optionsMutationOptions(options, 'preference-set', { field, value });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [field]: clone(value) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ async function patchPreference(field, patch, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const values = asObject(current.data);
+ const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ const preferences = Object.freeze({
+ async getAll() { return readPreferences(); },
+ async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); },
+ async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); },
+ async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); },
+ async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); },
+ async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); }
+ ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); },
+ async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); },
+ async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); },
+ async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); },
+ async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); },
+ async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); },
+ async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); },
+ async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); }
+ });
+
+ const goals = Object.freeze({
+ async list() { await ready; return kernel.read('goals.items'); },
+ async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); }
+ });
+
+ function deliveryTimestamp(value) {
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN;
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function mergeDeliveryAcknowledgements(current, incoming) {
+ const merged = Object.assign({}, asObject(current));
+ for (const [id, value] of Object.entries(asObject(incoming))) {
+ const key = String(id).trim();
+ if (!key) continue;
+ const previous = deliveryTimestamp(merged[key]);
+ const next = deliveryTimestamp(value);
+ if (!hasOwn(merged, key) || (next && (!previous || next < previous))) {
+ merged[key] = next;
+ } else if (previous) {
+ merged[key] = previous;
+ } else {
+ merged[key] = null;
+ }
+ }
+ return merged;
+ }
+
+ const achievements = Object.freeze({
+ async getAll() {
+ await ready;
+ const progress = await retryMergeConflict({}, async () => {
+ const [summaries, manual, current] = await Promise.all([
+ kernel.listEntities('practiceSummaries'),
+ kernel.read('achievements.manual'),
+ kernel.read('achievements.progress', { withMeta: true })
+ ]);
+ const projected = asObject(computeAchievementProgress(summaries, manual, current.data));
+ if (checksum(projected) !== checksum(asObject(current.data))) {
+ await kernel.mutate([{
+ logicalKey: 'achievements.progress',
+ data: projected,
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], {
+ operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}`
+ });
+ }
+ return projected;
+ }, 5);
+ if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh;
+ Object.defineProperty(progress, 'fresh', { value: true, enumerable: false });
+ return progress;
+ },
+ async retryPending() { return achievements.getAll(); },
+ async acknowledgeDelivery(unlocked, options = {}) {
+ await ready;
+ assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object');
+ const requested = clone(unlocked);
+ const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested);
+ return retryMergeConflict({}, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ const settingsValue = asObject(current.data);
+ const delivery = asObject(settingsValue.achievementDelivery);
+ const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested);
+ return kernel.mutate([{
+ logicalKey: 'settings.values',
+ data: Object.assign({}, settingsValue, {
+ achievementDelivery: { version: 1, acknowledged }
+ }),
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], mutation);
+ }, 5);
+ },
+ async getManualState() { await ready; return kernel.read('achievements.manual'); }
+ });
+
+ const LEGACY_DOCUMENT_ALIASES = Object.freeze({
+ 'settings.values': ['user_settings', 'settings', 'system_settings'],
+ 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'],
+ 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'],
+ 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'],
+ 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'],
+ 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'],
+ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'],
+ 'achievements.manual': ['achievement_manual_state', 'user_achievements']
+ });
+ const LEGACY_PREFERENCE_ALIASES = Object.freeze({
+ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse',
+ practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode',
+ ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed'
+ });
+
+ function mergeLegacySources(indexedDbValue, externalValue) {
+ const indexedDb = asObject(indexedDbValue);
+ const external = asObject(externalValue);
+ const merged = Object.assign({}, external, indexedDb);
+ const records = new Map();
+ const addRecords = (value) => {
+ const list = Array.isArray(value) ? value : asArray(asObject(value).data);
+ list.forEach((record) => {
+ const id = idOf(record, ['id', 'recordId', 'sessionId']);
+ records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record));
+ });
+ };
+ addRecords(external.practice_records || external.practiceRecords);
+ addRecords(indexedDb.practice_records);
+ if (records.size) merged.practice_records = Array.from(records.values());
+ return merged;
+ }
+
+ function legacyLibraryBundle(legacy) {
+ const idMap = new Map();
+ const indexes = {};
+ for (const [oldId, value] of Object.entries(asObject(legacy))) {
+ if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue;
+ const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`;
+ idMap.set(oldId, id);
+ indexes[id] = clone(value);
+ }
+ if (!idMap.size) return null;
+ const configurations = new Map();
+ asArray(legacy.exam_index_configurations).forEach((configuration) => {
+ const oldId = idOf(configuration, ['id', 'key', 'configId']);
+ const id = idMap.get(oldId);
+ if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length }));
+ });
+ for (const [oldId, id] of idMap) {
+ if (!configurations.has(id)) configurations.set(id, {
+ id,
+ key: id,
+ name: `迁移的自定义题库 (${oldId})`,
+ examCount: indexes[id].length,
+ sourceType: 'legacy-import'
+ });
+ }
+ return {
+ configurations: Array.from(configurations.values()),
+ indexes,
+ activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null
+ };
+ }
+
+ async function migrateLegacyData() {
+ // Unit embedders may provide a deliberately minimal kernel bootstrap.
+ if (typeof internals.readLegacyValues !== 'function') return;
+ const migrationMeta = await kernel.read('system.migrations', { withMeta: true });
+ const migrationState = asObject(migrationMeta.data);
+ const v1Complete = asObject(migrationState.v1ToV2).status === 'complete';
+ const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed';
+ let externalBackup = null;
+ if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') {
+ try { externalBackup = await internals.readLegacyExternalBackup(); }
+ catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message);
+ }
+ }
+ if (v1Complete && !externalBackup) return;
+
+ const indexedDb = await internals.readLegacyValues();
+ if (indexedDb && indexedDb.__legacyReadComplete === false) {
+ throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup');
+ }
+ const legacy = mergeLegacySources(indexedDb, externalBackup);
+ const changes = [];
+ for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) {
+ const current = await kernel.getEnvelope(logicalKey);
+ if (current) continue;
+ const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key));
+ if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 });
+ }
+ const libraryBundle = legacyLibraryBundle(legacy);
+ if (libraryBundle) {
+ if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) {
+ const preferences = {};
+ for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) {
+ if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue;
+ const path = target.split('.'); let cursor = preferences;
+ path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; });
+ cursor[path[path.length - 1]] = clone(legacy[alias]);
+ }
+ if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) {
+ changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 });
+ }
+ if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` });
+ const recordsValue = legacy.practice_records;
+ const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data);
+ const operations = [];
+ for (const [index, record] of records.entries()) {
+ try {
+ const candidate = clone(record);
+ if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`;
+ const canonical = canonicalizeRecord(candidate);
+ const parts = splitPracticeRecord(canonical);
+ for (const [store, data] of [
+ ['practiceSummaries', parts.summary],
+ ['practiceDetails', parts.detail],
+ ['practiceAnnotations', parts.annotations]
+ ]) {
+ if (!await kernel.readEntity(store, canonical.id)) {
+ operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 });
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message);
+ }
+ }
+ if (operations.length) {
+ await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` });
+ }
+
+ const nextMigrationState = Object.assign({}, migrationState);
+ if (!v1Complete) nextMigrationState.v1ToV2 = {
+ version: 1,
+ status: 'complete',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(indexedDb),
+ sourceRecordCount: asArray(indexedDb.practice_records).length
+ };
+ if (externalBackup) nextMigrationState.externalBackupV1 = {
+ version: 1,
+ status: 'consumed',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(externalBackup)
+ };
+ await kernel.mutate([{
+ logicalKey: 'system.migrations',
+ data: nextMigrationState,
+ expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0
+ }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` });
+ }
+
+ const ready = kernel.initialize()
+ .then(async () => {
+ // Legacy migration and recovery cleanup are best-effort: a failure here
+ // (e.g. one malformed v1 record) must not brick the data layer for every
+ // read that awaits `ready`. Only a genuine backend init failure below is fatal.
+ try {
+ await migrateLegacyData();
+ } catch (error) {
+ if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error);
+ }
+ try {
+ await cleanupExpiredRecovery();
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error);
+ }
+ return true;
+ })
+ .catch((error) => {
+ if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error);
+ throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed');
+ });
+
+ const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements };
+ Object.defineProperties(AppData, {
+ ready: { value: ready, enumerable: false },
+ status: { value: () => kernel.status(), enumerable: false }
+ });
+ Object.freeze(AppData);
+ Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false });
+ if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) {
+ throw new Error('AppData v2 failed to close its internal bootstrap channel');
+ }
+ if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) {
+ throw new Error('AppData v2 failed to close its catalog bootstrap channel');
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
/* ===== js/utils/suiteBackGuard.js ===== */
(function initSuiteBackGuard(global) {
'use strict';
@@ -296,7 +3815,20 @@
function compareAnswers(userAnswer, correctAnswer) {
const expected = splitAnswerTokens(correctAnswer);
- const actual = splitAnswerTokens(userAnswer);
+ let actual = splitAnswerTokens(userAnswer);
+
+ if (
+ expected.length === 1
+ && /^[A-Z]$/.test(expected[0])
+ && actual.length === 1
+ && !/^[A-Z]$/.test(actual[0])
+ && typeof userAnswer === 'string'
+ ) {
+ const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/);
+ if (labeledOption) {
+ actual = [labeledOption[1]];
+ }
+ }
if (expected.length === 0 && actual.length === 0) {
return null;
@@ -442,12 +3974,11 @@
// 错误缓存,用于临时存储检测到的错误
this.errorCache = new Map();
- // 词表存储键配置
- this.storageKeys = {
- p1: 'vocab_list_p1_errors',
- p4: 'vocab_list_p4_errors',
- master: 'vocab_list_master_errors',
- custom: 'vocab_list_custom'
+ this.collectionIds = {
+ p1: 'spelling-errors-p1',
+ p4: 'spelling-errors-p4',
+ master: 'spelling-errors-master',
+ custom: 'custom'
};
this.lexiconCache = null;
@@ -465,17 +3996,8 @@
*/
async init() {
try {
- // 等待存储系统就绪
- if (window.storage && window.storage.ready) {
- await window.storage.ready;
- }
-
- // 设置命名空间
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[SpellingErrorCollector] 存储命名空间已设置');
- }
-
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
this.initialized = true;
console.log('[SpellingErrorCollector] 初始化完成');
} catch (error) {
@@ -843,14 +4365,9 @@
try {
await this.ensureInitialized();
- const storageKey = this.storageKeys[listId] || listId;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return null;
- }
-
- const list = await window.storage.get(storageKey);
+ const collectionId = this.collectionIds[listId] || listId;
+ const collections = await window.AppData.vocab.listCollections();
+ const list = collections[collectionId];
const normalizedList = this.normalizeVocabListShape(list, listId, listId);
if (normalizedList) {
@@ -862,7 +4379,7 @@
return null;
} catch (error) {
console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error);
- return null;
+ throw error;
}
}
@@ -874,31 +4391,10 @@
async saveVocabList(vocabList) {
try {
await this.ensureInitialized();
-
- if (!vocabList || !vocabList.id) {
- console.error('[SpellingErrorCollector] 无效的词表对象');
- return false;
- }
-
- if (!Array.isArray(vocabList.words)) {
- vocabList.words = [];
- }
-
- vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
-
- // 更新统计信息
- vocabList.stats = vocabList.stats || {};
- vocabList.stats.totalWords = vocabList.words.length;
- vocabList.updatedAt = Date.now();
-
- const storageKey = this.storageKeys[vocabList.id] || vocabList.id;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return false;
- }
-
- await window.storage.set(storageKey, vocabList);
+ vocabList = this.prepareVocabList(vocabList);
+ if (!vocabList) return false;
+ const collectionId = this.collectionIds[vocabList.id] || vocabList.id;
+ await window.AppData.vocab.saveCollection(collectionId, vocabList);
console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`);
return true;
@@ -908,6 +4404,19 @@
}
}
+ prepareVocabList(vocabList) {
+ if (!vocabList || !vocabList.id) {
+ console.error('[SpellingErrorCollector] 无效的词表对象');
+ return null;
+ }
+ if (!Array.isArray(vocabList.words)) vocabList.words = [];
+ const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
+ normalized.stats = normalized.stats || {};
+ normalized.stats.totalWords = normalized.words.length;
+ normalized.updatedAt = Date.now();
+ return normalized;
+ }
+
/**
* 获取词表单词数量
* @param {string} listId - 词表ID
@@ -919,7 +4428,7 @@
return list ? list.words.length : 0;
} catch (error) {
console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error);
- return 0;
+ throw error;
}
}
@@ -1489,17 +4998,25 @@
try {
await this.ensureInitialized();
await this.ensureCoreLexicon();
-
- // 按来源分组错误
const errorsBySource = this.groupErrorsBySource(errors);
-
- // 保存到各个来源的词表
+ const pendingCollections = {};
for (const [source, sourceErrors] of Object.entries(errorsBySource)) {
- await this.saveErrorsToList(source, sourceErrors);
+ let vocabList = await this.loadVocabList(source);
+ if (!vocabList) vocabList = this.createEmptyList(source, source);
+ this.mergeErrorsToList(vocabList, sourceErrors);
+ const prepared = this.prepareVocabList(vocabList);
+ if (!prepared) throw new Error(`生成 ${source} 错词词表失败`);
+ pendingCollections[this.collectionIds[source] || source] = prepared;
}
- // 同步到综合词表
- await this.syncToMasterList(errors);
+ let masterList = await this.loadVocabList('master');
+ if (!masterList) masterList = this.createEmptyList('master', 'all');
+ this.mergeErrorsToList(masterList, errors);
+ const preparedMaster = this.prepareVocabList(masterList);
+ if (!preparedMaster) throw new Error('生成综合错词词表失败');
+ pendingCollections[this.collectionIds.master] = preparedMaster;
+
+ await window.AppData.vocab.saveCollections(pendingCollections);
console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`);
return true;
@@ -1650,7 +5167,9 @@
);
if (vocabList.words.length < originalLength) {
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`);
return true;
} else {
@@ -1680,7 +5199,9 @@
vocabList.words = [];
vocabList.updatedAt = Date.now();
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 清空词表: ${listId}`);
return true;
@@ -1720,6 +5241,20 @@
}
console.log('[PracticeEnhancer] 初始化增强器');
+ const HOST_MESSAGE_SOURCE = 'exam_host';
+
+ function deriveParentOriginFromReferrer() {
+ try {
+ if (!document.referrer) return '';
+ const parsed = new URL(document.referrer, window.location.href);
+ // Chromium: file URL.origin is "file://", postMessage event.origin is "null".
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (_) {
+ return '';
+ }
+ }
const DEFAULT_ENHANCER_CONFIG = {
autoInitialize: true,
@@ -2472,6 +6007,10 @@
sessionId: null,
examId: null, // 新增:存储唯一的examId
parentWindow: null,
+ expectedParentOrigin: deriveParentOriginFromReferrer(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
answers: {},
correctAnswers: {},
interactions: [],
@@ -2588,9 +6127,7 @@
}
this.enhancerBaseUrl = this.getEnhancerBaseUrl();
- await this.ensureStorageAvailable();
await this.ensureSpellingErrorCollector();
- await this.prepareStorageNamespace();
// 检测多套题结构
this.isMultiSuite = this.detectMultiSuiteStructure();
@@ -2787,95 +6324,6 @@
}).filter(Boolean);
},
- ensureStorageAvailable: async function () {
- try {
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- if (window.storage.ready && typeof window.storage.ready.then === 'function') {
- await window.storage.ready;
- }
- return true;
- }
-
- const tryLoad = async (urls) => {
- for (const url of urls) {
- if (!url) continue;
- try {
- console.log('[PracticeEnhancer] 尝试加载存储管理器:', url);
- await dependencyLoader.loadScript(url);
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- if (window.storage.ready && typeof window.storage.ready.then === 'function') {
- await window.storage.ready;
- }
- return true;
- }
- } catch (error) {
- console.warn('[PracticeEnhancer] 存储管理器加载失败:', error);
- }
- }
- return false;
- };
-
- const baseUrl = this.getEnhancerBaseUrl();
- const baseCandidate = new URL('utils/storage.js', baseUrl).href;
- const fallbackUrls = this.buildFallbackUrls([
- '../../../../js/utils/storage.js',
- '../../../js/utils/storage.js',
- '../../js/utils/storage.js',
- '../js/utils/storage.js',
- './js/utils/storage.js'
- ]);
-
- const loaded = await tryLoad([baseCandidate, ...fallbackUrls]);
- if (loaded) return true;
- } catch (error) {
- console.warn('[PracticeEnhancer] 加载存储管理器失败:', error);
- }
-
- // 创建简易回退存储,确保流程不中断
- console.warn('[PracticeEnhancer] 使用简易回退存储');
- const fallbackPrefix = 'exam_system_';
- const safeStore = (() => {
- try {
- return window.localStorage;
- } catch (_) {
- return null;
- }
- })();
-
- const stubStorage = {
- namespace: '',
- ready: Promise.resolve(),
- setNamespace(ns) { this.namespace = ns ? `${ns}_` : ''; },
- async set(key, value) {
- if (!safeStore) return false;
- const k = fallbackPrefix + this.namespace + key;
- safeStore.setItem(k, JSON.stringify({ value }));
- return true;
- },
- async get(key) {
- if (!safeStore) return null;
- const k = fallbackPrefix + this.namespace + key;
- const raw = safeStore.getItem(k);
- if (!raw) return null;
- try {
- const parsed = JSON.parse(raw);
- return parsed && parsed.value !== undefined ? parsed.value : parsed;
- } catch (_) {
- return null;
- }
- },
- async remove(key) {
- if (!safeStore) return false;
- const k = fallbackPrefix + this.namespace + key;
- safeStore.removeItem(k);
- return true;
- }
- };
-
- window.storage = stubStorage;
- return true;
- },
-
ensureSpellingErrorCollector: async function () {
if (window.spellingErrorCollector) {
return true;
@@ -2917,42 +6365,6 @@
return loaded;
},
- prepareStorageNamespace: async function () {
- // 设置共享命名空间
- try {
- if (window.storage?.ready) {
- await window.storage.ready;
- }
-
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[PracticeEnhancer] 已设置共享命名空间: exam_system');
-
- // 验证命名空间设置是否生效
- setTimeout(async () => {
- const testKey = 'namespace_test_enhancer';
- const testValue = 'test_value_enhancer_' + Date.now();
- try {
- await window.storage.set(testKey, testValue);
- const retrievedValue = await window.storage.get(testKey);
- if (retrievedValue === testValue) {
- console.log('✅ 增强器命名空间设置验证成功: 存储和读取正常');
- } else {
- console.warn('❌ 增强器命名空间设置验证失败: 读取值不匹配');
- }
- await window.storage.remove(testKey);
- } catch (error) {
- console.error('❌ 增强器命名空间设置验证失败', error);
- }
- }, 1000);
- } else {
- console.warn('[PracticeEnhancer] 存储管理器未加载或setNamespace方法不可用');
- }
- } catch (error) {
- console.error('[PracticeEnhancer] 存储初始化失败,跳过命名空间设置', error);
- }
- },
-
cleanup: function () {
console.log('[PracticeEnhancer] 清理资源');
if (this.answerCollectionInterval) {
@@ -3563,9 +6975,49 @@
}
const messageType = String(payload.type).toUpperCase();
const payloadData = payload.data || {};
+ if (!event || event.source !== this.parentWindow || payload.source !== HOST_MESSAGE_SOURCE) {
+ return;
+ }
if (messageType === 'INIT_SESSION' || messageType === 'INIT_EXAM_SESSION') {
const initData = payloadData;
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ const declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : '';
+ const incomingToken = typeof initData.windowSessionToken === 'string'
+ ? initData.windowSessionToken.trim()
+ : '';
+ if (!incomingToken) return;
+ const expectedParentOrigin = this.expectedParentOrigin
+ && this.expectedParentOrigin !== 'file://'
+ && !String(this.expectedParentOrigin).startsWith('file:')
+ ? this.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) {
+ return;
+ }
+ this.parentOrigin = expectedParentOrigin;
+ this.parentOriginIsOpaque = false;
+ } else if (window.location.protocol === 'file:') {
+ const trustedFileOrigin = incomingOrigin === 'null'
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) {
+ return;
+ }
+ this.parentOrigin = 'null';
+ this.parentOriginIsOpaque = true;
+ } else {
+ const trustedWebOrigin = Boolean(incomingOrigin)
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) {
+ return;
+ }
+ this.parentOrigin = incomingOrigin;
+ this.parentOriginIsOpaque = false;
+ }
+ this.windowSessionToken = incomingToken;
this.sessionId = initData.sessionId;
this.examId = initData.examId; // 存储 examId
if (initData.reviewSessionId) {
@@ -3598,6 +7050,17 @@
return;
}
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ const incomingToken = typeof payloadData.windowSessionToken === 'string'
+ ? payloadData.windowSessionToken.trim()
+ : '';
+ const originMatches = this.parentOriginIsOpaque
+ ? incomingOrigin === 'null'
+ : Boolean(this.parentOrigin && incomingOrigin === this.parentOrigin);
+ if (!originMatches || !this.windowSessionToken || incomingToken !== this.windowSessionToken) {
+ return;
+ }
+
if (messageType === 'REPLAY_PRACTICE_RECORD') {
this.applyReplayRecord(payloadData || {});
return;
@@ -5086,6 +8549,7 @@
// Requirement 9.1: 必须包含的基本字段
examId: `${this.examId}_${suiteId}`, // Requirement 9.2: examId包含套题标识
sessionId: this.sessionId,
+ suiteSessionId: this.suiteSessionId || null,
answers: suiteAnswers, // Requirement 9.3: 答案键使用"套题ID::问题ID"格式
correctAnswers: suiteCorrectAnswers,
@@ -6236,29 +9700,57 @@
return null;
},
+ createSubmissionId: function () {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return `practice-submit-${window.crypto.randomUUID()}`;
+ }
+ } catch (_) {
+ // Fall through to the session-bound fallback.
+ }
+ return `practice-submit-${this.sessionId || this.examId || 'session'}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ },
+
sendMessage: function (type, data) {
if (!this.parentWindow) {
console.warn('[PracticeEnhancer] 无父窗口,无法发送消息');
- return;
+ return false;
}
if (this.readOnly && type === 'PRACTICE_COMPLETE') {
console.info('[PracticeEnhancer] 回顾模式阻止 PRACTICE_COMPLETE 上报');
- return;
+ return false;
}
- this.runHooks('beforeSendMessage', type, data);
+ const payload = data && typeof data === 'object' ? data : {};
+ if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') {
+ payload.sessionId = payload.sessionId || this.sessionId || null;
+ payload.submissionId = payload.submissionId || this.createSubmissionId();
+ }
+ this.runHooks('beforeSendMessage', type, payload);
+ const secureData = Object.assign({}, payload, {
+ windowSessionToken: this.windowSessionToken || null
+ });
const message = {
type: type,
- data: data,
+ data: secureData,
source: 'practice_page',
timestamp: Date.now()
};
try {
- this.parentWindow.postMessage(message, '*');
+ const targetOrigin = this.parentOrigin && this.parentOrigin !== 'null'
+ ? this.parentOrigin
+ : (this.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) {
+ console.warn('[PracticeEnhancer] 缺少可信父窗口 origin,消息未发送:', type);
+ return false;
+ }
+ this.parentWindow.postMessage(message, targetOrigin);
console.log('[PracticeEnhancer] 消息已发送:', type);
+ return true;
} catch (error) {
console.error('[PracticeEnhancer] 发送消息失败:', error);
+ return false;
}
},
@@ -6326,6 +9818,10 @@
(function markBundleProvided(global) {
if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
global.AppLazyLoader.markProvided([
+ "js/data/practiceRecordSource.js",
+ "js/data/v2/dataCatalog.js",
+ "js/data/v2/dataKernel.js",
+ "js/data/v2/appData.js",
"js/utils/suiteBackGuard.js",
"js/utils/answerMatchCore.js",
"js/app/spellingErrorCollector.js",
diff --git a/js/bundles/practice.bundle.js b/js/bundles/practice.bundle.js
index 142c31d4..74ddaaa4 100644
--- a/js/bundles/practice.bundle.js
+++ b/js/bundles/practice.bundle.js
@@ -64,12 +64,11 @@
// 错误缓存,用于临时存储检测到的错误
this.errorCache = new Map();
- // 词表存储键配置
- this.storageKeys = {
- p1: 'vocab_list_p1_errors',
- p4: 'vocab_list_p4_errors',
- master: 'vocab_list_master_errors',
- custom: 'vocab_list_custom'
+ this.collectionIds = {
+ p1: 'spelling-errors-p1',
+ p4: 'spelling-errors-p4',
+ master: 'spelling-errors-master',
+ custom: 'custom'
};
this.lexiconCache = null;
@@ -87,17 +86,8 @@
*/
async init() {
try {
- // 等待存储系统就绪
- if (window.storage && window.storage.ready) {
- await window.storage.ready;
- }
-
- // 设置命名空间
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[SpellingErrorCollector] 存储命名空间已设置');
- }
-
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
this.initialized = true;
console.log('[SpellingErrorCollector] 初始化完成');
} catch (error) {
@@ -465,14 +455,9 @@
try {
await this.ensureInitialized();
- const storageKey = this.storageKeys[listId] || listId;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return null;
- }
-
- const list = await window.storage.get(storageKey);
+ const collectionId = this.collectionIds[listId] || listId;
+ const collections = await window.AppData.vocab.listCollections();
+ const list = collections[collectionId];
const normalizedList = this.normalizeVocabListShape(list, listId, listId);
if (normalizedList) {
@@ -484,7 +469,7 @@
return null;
} catch (error) {
console.error(`[SpellingErrorCollector] 加载词表失败: ${listId}`, error);
- return null;
+ throw error;
}
}
@@ -496,31 +481,10 @@
async saveVocabList(vocabList) {
try {
await this.ensureInitialized();
-
- if (!vocabList || !vocabList.id) {
- console.error('[SpellingErrorCollector] 无效的词表对象');
- return false;
- }
-
- if (!Array.isArray(vocabList.words)) {
- vocabList.words = [];
- }
-
- vocabList = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
-
- // 更新统计信息
- vocabList.stats = vocabList.stats || {};
- vocabList.stats.totalWords = vocabList.words.length;
- vocabList.updatedAt = Date.now();
-
- const storageKey = this.storageKeys[vocabList.id] || vocabList.id;
-
- if (!window.storage) {
- console.warn('[SpellingErrorCollector] 存储系统不可用');
- return false;
- }
-
- await window.storage.set(storageKey, vocabList);
+ vocabList = this.prepareVocabList(vocabList);
+ if (!vocabList) return false;
+ const collectionId = this.collectionIds[vocabList.id] || vocabList.id;
+ await window.AppData.vocab.saveCollection(collectionId, vocabList);
console.log(`[SpellingErrorCollector] 保存词表成功: ${vocabList.id}, 单词数: ${vocabList.words.length}`);
return true;
@@ -530,6 +494,19 @@
}
}
+ prepareVocabList(vocabList) {
+ if (!vocabList || !vocabList.id) {
+ console.error('[SpellingErrorCollector] 无效的词表对象');
+ return null;
+ }
+ if (!Array.isArray(vocabList.words)) vocabList.words = [];
+ const normalized = this.normalizeVocabListShape(vocabList, vocabList.id, vocabList.source) || vocabList;
+ normalized.stats = normalized.stats || {};
+ normalized.stats.totalWords = normalized.words.length;
+ normalized.updatedAt = Date.now();
+ return normalized;
+ }
+
/**
* 获取词表单词数量
* @param {string} listId - 词表ID
@@ -541,7 +518,7 @@
return list ? list.words.length : 0;
} catch (error) {
console.error(`[SpellingErrorCollector] 获取词表单词数失败: ${listId}`, error);
- return 0;
+ throw error;
}
}
@@ -1111,17 +1088,25 @@
try {
await this.ensureInitialized();
await this.ensureCoreLexicon();
-
- // 按来源分组错误
const errorsBySource = this.groupErrorsBySource(errors);
-
- // 保存到各个来源的词表
+ const pendingCollections = {};
for (const [source, sourceErrors] of Object.entries(errorsBySource)) {
- await this.saveErrorsToList(source, sourceErrors);
+ let vocabList = await this.loadVocabList(source);
+ if (!vocabList) vocabList = this.createEmptyList(source, source);
+ this.mergeErrorsToList(vocabList, sourceErrors);
+ const prepared = this.prepareVocabList(vocabList);
+ if (!prepared) throw new Error(`生成 ${source} 错词词表失败`);
+ pendingCollections[this.collectionIds[source] || source] = prepared;
}
- // 同步到综合词表
- await this.syncToMasterList(errors);
+ let masterList = await this.loadVocabList('master');
+ if (!masterList) masterList = this.createEmptyList('master', 'all');
+ this.mergeErrorsToList(masterList, errors);
+ const preparedMaster = this.prepareVocabList(masterList);
+ if (!preparedMaster) throw new Error('生成综合错词词表失败');
+ pendingCollections[this.collectionIds.master] = preparedMaster;
+
+ await window.AppData.vocab.saveCollections(pendingCollections);
console.log(`[SpellingErrorCollector] 保存完成,共保存 ${errors.length} 个错误`);
return true;
@@ -1272,7 +1257,9 @@
);
if (vocabList.words.length < originalLength) {
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 从词表 ${listId} 移除单词: ${word}`);
return true;
} else {
@@ -1302,7 +1289,9 @@
vocabList.words = [];
vocabList.updatedAt = Date.now();
- await this.saveVocabList(vocabList);
+ if (!await this.saveVocabList(vocabList)) {
+ return false;
+ }
console.log(`[SpellingErrorCollector] 清空词表: ${listId}`);
return true;
@@ -1426,22 +1415,11 @@ class MarkdownExporter {
}
return comparison;
}
- constructor() {
- this.storage = window.storage;
- }
-
async getPracticeRecordsUnified() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- try {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- } catch (error) {
- console.warn('[MarkdownExporter] 从 PracticeRecordAPI 获取练习记录失败:', error);
- return [];
- }
- }
-
- return [];
+ if (!window.AppData || !window.AppData.practice) throw new Error('AppData.practice is unavailable');
+ await window.AppData.ready;
+ const records = await window.AppData.practice.list({ projection: 'full' });
+ return Array.isArray(records) ? records : [];
}
/**
@@ -1494,31 +1472,16 @@ class MarkdownExporter {
*/
async performExport() {
try {
- // 尝试从不同的数据源获取记录
let practiceRecords = [];
- let examIndex = [];
this.updateProgress('正在加载数据...');
// 让出控制权
await new Promise(resolve => setTimeout(resolve, 10));
- // 只使用统一 PracticeRecordAPI 数据
+ // 只使用统一 practice domain 数据
practiceRecords = await this.getPracticeRecordsUnified();
- // examIndex 仍从存储/全局读取
- if (this.storage && typeof this.storage.get === 'function') {
- try {
- const idx = await this.storage.get('exam_index', []);
- examIndex = Array.isArray(idx) ? idx : [];
- } catch (_) {
- examIndex = [];
- }
- }
- if ((!Array.isArray(examIndex) || examIndex.length === 0) && window.examIndex) {
- examIndex = Array.isArray(window.examIndex) ? window.examIndex : [];
- }
-
if (practiceRecords.length === 0) {
throw new Error('没有练习记录可导出');
}
@@ -1552,7 +1515,7 @@ class MarkdownExporter {
await new Promise(resolve => setTimeout(resolve, 10));
// 按日期分组记录
- const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords, examIndex);
+ const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords);
// 生成 Markdown 内容
const markdownContent = await this.generateMarkdownContentAsync(recordsByDate);
@@ -1608,7 +1571,27 @@ class MarkdownExporter {
/**
* 异步按日期分组记录
*/
- async groupRecordsByDateAsync(practiceRecords, examIndex) {
+ async resolveExamForRecord(record) {
+ if (typeof window.resolveExamForPracticeRecord !== 'function') {
+ return null;
+ }
+ return window.resolveExamForPracticeRecord(record);
+ }
+
+ enhanceRecordForExport(record, exam = null) {
+ const metadata = record && record.metadata && typeof record.metadata === 'object'
+ ? record.metadata
+ : {};
+ return {
+ ...record,
+ examInfo: exam || {},
+ title: record.title || metadata.examTitle || exam?.title || '未知题目',
+ category: record.category || metadata.category || exam?.category || 'Unknown',
+ frequency: record.frequency || metadata.frequency || exam?.frequency || 'unknown'
+ };
+ }
+
+ async groupRecordsByDateAsync(practiceRecords) {
const grouped = {};
for (let i = 0; i < practiceRecords.length; i++) {
@@ -1621,15 +1604,8 @@ class MarkdownExporter {
grouped[date] = [];
}
- // 获取考试信息
- const exam = examIndex.find(e => e.id === record.examId);
- const enhancedRecord = {
- ...record,
- examInfo: exam || {},
- title: exam?.title || record.title || '未知题目',
- category: exam?.category || record.category || 'Unknown',
- frequency: exam?.frequency || record.frequency || 'unknown'
- };
+ const exam = await this.resolveExamForRecord(record);
+ const enhancedRecord = this.enhanceRecordForExport(record, exam);
grouped[date].push(enhancedRecord);
@@ -1645,7 +1621,7 @@ class MarkdownExporter {
/**
* 按日期分组记录(同步版本,保持兼容性)
*/
- groupRecordsByDate(practiceRecords, examIndex) {
+ groupRecordsByDate(practiceRecords) {
const grouped = {};
practiceRecords.forEach(record => {
@@ -1656,15 +1632,7 @@ class MarkdownExporter {
grouped[date] = [];
}
- // 获取考试信息
- const exam = examIndex.find(e => e.id === record.examId);
- const enhancedRecord = {
- ...record,
- examInfo: exam || {},
- title: exam?.title || record.title || '未知题目',
- category: exam?.category || record.category || 'Unknown',
- frequency: exam?.frequency || record.frequency || 'unknown'
- };
+ const enhancedRecord = this.enhanceRecordForExport(record);
grouped[date].push(enhancedRecord);
});
@@ -2224,7 +2192,8 @@ class PracticeRecordModal {
show(record) {
try {
- const replayRecord = this.cloneRecord(record);
+ // 详情展示用 medium;回顾时再按 id 拉 full,避免把注解灌进 modal 缓存。
+ const displayRecord = record;
let processedRecord = record;
if (window.DataConsistencyManager) {
@@ -2238,7 +2207,8 @@ class PracticeRecordModal {
const modalHtml = this.createModalHtml(processedRecord);
this.hide();
- this.currentRecord = replayRecord;
+ this.currentRecord = this.cloneRecord(displayRecord);
+ this.currentRecordId = (displayRecord && (displayRecord.id || displayRecord.sessionId)) || null;
document.body.insertAdjacentHTML('beforeend', modalHtml);
this.modalElement = document.getElementById(this.modalId);
@@ -2279,6 +2249,7 @@ class PracticeRecordModal {
this.modalElement = null;
this.currentRecord = null;
this.isVisible = false;
+ this.currentRecordId = null;
}
teardownEventListeners() {
@@ -2333,8 +2304,10 @@ class PracticeRecordModal {
if (replayTrigger) {
this.replayTriggerElement = replayTrigger;
const launchReplay = async () => {
- const replayRecord = this.currentRecord;
- if (!replayRecord) {
+ const recordId = this.currentRecordId
+ || (this.currentRecord && (this.currentRecord.id || this.currentRecord.sessionId))
+ || null;
+ if (!recordId && !this.currentRecord) {
if (typeof window.showMessage === 'function') {
window.showMessage('未找到可回放记录', 'error');
}
@@ -2349,6 +2322,23 @@ class PracticeRecordModal {
closeModal();
try {
+ // 回顾必须 full:重新按 id 拉取含 highlights/notes 的完整记录。
+ // 当前详情多为 medium,full 失败时不得回退 detail(缺注解)。
+ let replayRecord = null;
+ if (window.AppData && recordId) {
+ replayRecord = await window.AppData.practice.get(recordId, { projection: 'full' });
+ } else if (this.currentRecord && (
+ Array.isArray(this.currentRecord.highlights)
+ || Array.isArray(this.currentRecord.notes)
+ || this.currentRecord.realData
+ || this.currentRecord.rawData
+ )) {
+ // 无 API 时仅允许已是 full 形态的 currentRecord。
+ replayRecord = this.currentRecord;
+ }
+ if (!replayRecord) {
+ throw new Error('无法加载完整记录用于回顾');
+ }
await window.app.openPracticeRecordReplay(replayRecord);
} catch (error) {
console.error('[PracticeRecordModal] 启动回放失败:', error);
@@ -2457,12 +2447,12 @@ class PracticeRecordModal {
`;
}
- prepareRecordForDisplay(record) {
+ prepareRecordForDisplay(record, examDefinition = null) {
if (!record) {
return record;
}
if (window.AnswerComparisonUtils && typeof window.AnswerComparisonUtils.withEnrichedMetadata === 'function') {
- return window.AnswerComparisonUtils.withEnrichedMetadata(record);
+ return window.AnswerComparisonUtils.withEnrichedMetadata(record, examDefinition);
}
return record;
}
@@ -2620,7 +2610,10 @@ class PracticeRecordModal {
if (record.multiSuite === true && entry.scoreInfo) {
const correct = entry.scoreInfo.correct || 0;
const total = entry.scoreInfo.total || 0;
- const percentage = entry.scoreInfo.percentage || 0;
+ const rawPercentage = Number(entry.scoreInfo.percentage);
+ const percentage = Number.isFinite(rawPercentage)
+ ? (Math.round(rawPercentage * 10) / 10).toFixed(1)
+ : '0.0';
scoreInfo = `
得分: ${correct}/${total} (${percentage}%)
`;
}
@@ -3342,36 +3335,26 @@ class PracticeRecordModal {
try {
const normalise = (value) => (value == null ? '' : String(value));
const targetId = normalise(recordId);
- const api = window.PracticeRecordAPI || null;
let record = null;
- if (api && typeof api.getById === 'function') {
- record = await api.getById(targetId);
- }
-
- if (!record && api && typeof api.list === 'function') {
- const records = await api.list();
- if (Array.isArray(records)) {
- record = records.find(r => normalise(r.id) === targetId) ||
- records.find(r => normalise(r.sessionId) === targetId);
- }
- }
+ record = await window.AppData.practice.get(targetId, { projection: 'full' });
if (!record) {
throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728');
}
const exporter = new MarkdownExporter();
- const examIndex = await window.storage.get('exam_index', []);
- const exam = Array.isArray(examIndex) ? examIndex.find(e => e.id === record.examId) : null;
+ const exam = typeof window.resolveExamForPracticeRecord === 'function'
+ ? await window.resolveExamForPracticeRecord(record)
+ : null;
const enrichedRecord = this.prepareRecordForDisplay({
...record,
examInfo: exam || {},
- title: exam?.title || record.title || record.examId || '\u672a\u77e5\u9898\u76ee',
- category: exam?.category || record.category || '\u672a\u77e5\u5206\u7c7b',
- frequency: exam?.frequency || record.frequency || '\u672a\u77e5\u9891\u7387'
- });
+ title: record.title || record.metadata?.examTitle || exam?.title || record.examId || '\u672a\u77e5\u9898\u76ee',
+ category: record.category || record.metadata?.category || exam?.category || '\u672a\u77e5\u5206\u7c7b',
+ frequency: record.frequency || record.metadata?.frequency || exam?.frequency || '\u672a\u77e5\u9891\u7387'
+ }, exam);
const markdown = exporter.generateRecordMarkdown(enrichedRecord);
@@ -3404,20 +3387,9 @@ if (!window.practiceRecordModal.showById) {
try {
const normalise = (value) => (value == null ? '' : String(value));
const targetId = normalise(recordId);
- const api = window.PracticeRecordAPI || null;
let record = null;
- if (api && typeof api.getById === 'function') {
- record = await api.getById(targetId);
- }
-
- if (!record && api && typeof api.list === 'function') {
- const records = await api.list();
- if (Array.isArray(records)) {
- record = records.find(r => normalise(r.id) === targetId) ||
- records.find(r => normalise(r.sessionId) === targetId);
- }
- }
+ record = await window.AppData.practice.get(targetId, { projection: 'detail' });
if (!record) {
throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728');
@@ -3513,7 +3485,7 @@ class PracticeHistoryEnhancer {
const hasStandardComponent = window.app?.components?.practiceHistory;
const hasBasicStructure = document.querySelector('.practice-history') ||
document.querySelector('#practice-records') ||
- window.PracticeRecordAPI;
+ window.AppData;
if (hasStandardComponent || hasBasicStructure) {
clearInterval(checkInterval);
@@ -3702,30 +3674,11 @@ class PracticeHistoryEnhancer {
*/
async exportAsJSON() {
try {
- let practiceRecords = [];
- let practiceStats = {};
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const records = await window.PracticeRecordAPI.list();
- practiceRecords = Array.isArray(records) ? records : [];
- }
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- practiceStats = await window.PracticeRecordAPI.readStats();
- }
-
- if (practiceRecords.length === 0) {
+ const practiceRecords = await window.AppData.practice.list({ projection: 'light' });
+ if (!Array.isArray(practiceRecords) || practiceRecords.length === 0) {
throw new Error('没有练习记录可导出');
}
-
- const data = {
- exportDate: new Date().toISOString(),
- stats: practiceStats,
- user_stats: practiceStats,
- userStats: practiceStats,
- records: practiceRecords,
- practice_records: practiceRecords
- };
+ const data = await window.AppData.backups.export({ domains: ['practice'] });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -3748,33 +3701,16 @@ class PracticeHistoryEnhancer {
}
/**
- * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌
+ * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌。
+ * 默认 medium 投影:详情答案层,不含 highlights/notes。
+ * 回顾模式请用 fetchRecordById(id, { projection: 'full' })。
*/
- async fetchRecordById(recordId) {
+ async fetchRecordById(recordId, options = {}) {
const toIdStr = (v) => v == null ? '' : String(v);
const targetIdStr = toIdStr(recordId);
+ const projection = (options && options.projection) || 'detail';
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getById === 'function') {
- try {
- const hit = await window.PracticeRecordAPI.getById(targetIdStr);
- if (hit) return hit;
- } catch (err) {
- console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 获取记录失败:', err);
- }
- }
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- try {
- const records = await window.PracticeRecordAPI.list();
- if (!Array.isArray(records)) return null;
- const hit = records.find(r => toIdStr(r.id) === targetIdStr || toIdStr(r.sessionId) === targetIdStr);
- if (hit) return hit;
- } catch (err) {
- console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 列表查找记录失败:', err);
- }
- }
-
- return null;
+ return window.AppData.practice.get(targetIdStr, { projection });
}
/**
@@ -3822,1991 +3758,112 @@ if (document.readyState === 'loading') {
}
-/* ===== js/core/scoreStorage.js ===== */
-/**
- * ScoreStorage — façade over PracticeRecordAPI / PracticeCore.
- * No independent practice write path: saves must go through PracticeRecordAPI.
- * Kept for PracticeRecorder UI helpers, stats/list adapters, and backup helpers
- * during the post-data-layer transition (see Sprint B/C thinning).
- */
-class ScoreStorage {
- constructor(options = {}) {
- this.repositories = options.repositories || window.dataRepositories;
- if (!this.repositories) {
- throw new Error('数据仓库未初始化,无法构建 ScoreStorage');
- }
-
- this.initializationError = null;
- this.initializing = true;
-
- this.storageKeys = {
- practiceRecords: 'practice_records',
- userStats: 'user_stats',
- storageVersion: 'storage_version',
- backupData: 'manual_backups'
- };
+/* ===== js/utils/answerSanitizer.js ===== */
+(function (global) {
+ 'use strict';
- this.currentVersion = '0.6.2-fix';
- this.maxRecords = 1000;
- this.storage = this.createStorageAdapter();
- if (typeof window !== 'undefined') {
- window.scoreStorage = this;
+ function toStringSafe(value) {
+ if (value === null || value === undefined) {
+ return '';
}
-
- this.ready = this.initialize()
- .catch((error) => {
- this.initializationError = error;
- return Promise.reject(error);
- })
- .finally(() => {
- this.initializing = false;
- });
+ return String(value);
}
- async ensureReady(options = {}) {
- const { allowDuringInit = false } = options;
- if (this.initializationError) {
- throw this.initializationError;
- }
- if (this.initializing && allowDuringInit) {
- return;
- }
- if (this.ready) {
- await this.ready;
+ function normalizeFromObject(object) {
+ if (!object || typeof object !== 'object') {
+ return '';
}
- }
-
- getPracticeRecordAPI(requiredMethods = []) {
- const api = window.PracticeRecordAPI;
- if (!api || typeof api !== 'object') {
- throw new Error('ScoreStorage: PracticeRecordAPI not ready');
+ const preferKeys = [
+ 'value',
+ 'answerValue',
+ 'key',
+ 'option',
+ 'heading',
+ 'word',
+ 'label',
+ 'answerLabel',
+ 'text',
+ 'answer',
+ 'content'
+ ];
+ for (var i = 0; i < preferKeys.length; i += 1) {
+ var key = preferKeys[i];
+ if (typeof object[key] === 'string' && object[key].trim()) {
+ return object[key].trim();
+ }
}
- (Array.isArray(requiredMethods) ? requiredMethods : [requiredMethods])
- .filter(Boolean)
- .forEach((methodName) => {
- if (typeof api[methodName] !== 'function') {
- throw new Error(`ScoreStorage: PracticeRecordAPI.${methodName} not ready`);
- }
- });
- return api;
- }
-
- async listPracticeRecordsCanonical() {
- const api = this.getPracticeRecordAPI(['list']);
- const records = await api.list();
- return Array.isArray(records) ? records : [];
- }
-
- async replacePracticeRecordsCanonical(records, options = {}) {
- const finalRecords = Array.isArray(records) ? records : [];
- const api = this.getPracticeRecordAPI(['replace']);
- await api.replace(finalRecords, Object.assign({
- currentVersion: this.currentVersion,
- maxRecords: this.maxRecords
- }, options || {}));
- return true;
- }
-
- normalizePracticeType(rawType) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.normalizePracticeType === 'function') {
- return coreContracts.normalizePracticeType(rawType);
+ if (typeof object.innerText === 'string' && object.innerText.trim()) {
+ return object.innerText.trim();
}
- if (!rawType) return null;
- const normalized = String(rawType).toLowerCase();
- if (normalized.includes('listen')) return 'listening';
- if (normalized.includes('read')) return 'reading';
- return null;
- }
-
- inferPracticeType(recordData = {}) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.inferPracticeType === 'function') {
- return coreContracts.inferPracticeType(recordData);
+ if (typeof object.textContent === 'string' && object.textContent.trim()) {
+ return object.textContent.trim();
}
- const metadata = recordData.metadata || {};
- const normalized = this.normalizePracticeType(
- recordData.type
- || metadata.type
- || metadata.examType
- || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null)
- );
- return normalized || 'reading';
- }
-
- resolveRecordDate(recordData = {}, now = new Date().toISOString()) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.resolveRecordDate === 'function') {
- return coreContracts.resolveRecordDate(recordData, now);
- }
- const candidates = [
- recordData.metadata?.date,
- recordData.date,
- recordData.endTime,
- recordData.completedAt,
- recordData.startTime,
- recordData.timestamp,
- now
- ];
- for (const value of candidates) {
- if (!value) continue;
- const parsed = new Date(value);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
+ try {
+ var serialized = JSON.stringify(object);
+ if (serialized && serialized !== '{}' && serialized !== '[]') {
+ return serialized;
}
- }
- return now;
+ } catch (_) {}
+ return toStringSafe(object);
}
- inferExamId(recordData = {}) {
- if (!recordData || typeof recordData !== 'object') {
- return null;
+ function normalizeValue(value) {
+ if (value === null || value === undefined) {
+ return '';
}
- if (recordData.examId) {
- return recordData.examId;
+ if (typeof value === 'string') {
+ var trimmed = value.trim();
+ if (/^\[object\s/i.test(trimmed)) {
+ return '';
+ }
+ return trimmed;
}
- if (recordData.metadata?.examId) {
- return recordData.metadata.examId;
+ if (typeof value === 'boolean') {
+ return value ? 'True' : 'False';
}
- if (Array.isArray(recordData.suiteEntries)) {
- const suiteExam = recordData.suiteEntries.find(entry => entry && entry.examId);
- if (suiteExam) {
- return suiteExam.examId;
- }
+ if (typeof value === 'number') {
+ return toStringSafe(value).trim();
}
- const recordId = recordData.id;
- if (typeof recordId === 'string') {
- const match = recordId.match(/^record_([^_]+)_/);
- if (match && match[1]) {
- return match[1];
- }
+ if (Array.isArray(value)) {
+ var normalizedArray = value
+ .map(function (item) { return normalizeValue(item); })
+ .filter(function (item) { return item !== null && item !== undefined && item !== ''; })
+ .join(', ');
+ return normalizedArray.trim();
}
- return null;
- }
-
- buildMetadata(recordData = {}, type) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.buildMetadata === 'function') {
- return coreContracts.buildMetadata(recordData, type);
- }
- const metadata = { ...(recordData.metadata || {}) };
- const examId = recordData.examId;
- const fallbackTitle = recordData.title || recordData.examTitle || examId || 'Unknown Exam';
- const fallbackCategory = recordData.category || 'Unknown';
- const fallbackFrequency = recordData.frequency || 'unknown';
-
- metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle;
- metadata.category = metadata.category || fallbackCategory;
- metadata.frequency = metadata.frequency || fallbackFrequency;
- metadata.type = type;
- metadata.examType = metadata.examType || type;
-
- return metadata;
- }
-
- ensureNumber(value, fallback = 0) {
- const num = Number(value);
- return Number.isFinite(num) ? num : fallback;
+ return normalizeFromObject(value).replace(/^\[object\s[^\]]+\]$/i, '').trim();
}
- deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) {
- const candidates = [
- recordData.totalQuestions,
- recordData.questionCount,
- recordData.scoreInfo?.total,
- recordData.scoreInfo?.totalQuestions,
- recordData.realData?.scoreInfo?.totalQuestions,
- recordData.realData?.scoreInfo?.total
- ];
- for (const candidate of candidates) {
- const num = Number(candidate);
- if (Number.isFinite(num) && num >= 0) {
- return num;
- }
- }
-
- if (Array.isArray(recordData.answers)) {
- return recordData.answers.length;
+ function hasMeaningfulValue(value) {
+ var normalized = normalizeValue(value);
+ if (!normalized) {
+ return false;
}
- if (Array.isArray(recordData.answerList)) {
- return recordData.answerList.length;
+ var lowered = normalized.toLowerCase();
+ if (lowered === 'n/a' || lowered === 'no answer' || lowered === '未作答' || lowered === '无' || lowered === 'none') {
+ return false;
}
+ return true;
+ }
- const detailSources = [
- recordData.answerDetails,
- recordData.scoreInfo?.details,
- recordData.realData?.scoreInfo?.details
- ];
- for (const details of detailSources) {
- if (details && typeof details === 'object') {
- return Object.keys(details).length;
+ function normalizeValueList(value) {
+ var values = Array.isArray(value) ? value : (value === null || value === undefined ? [] : [value]);
+ var normalized = [];
+ values.forEach(function (item) {
+ var text = normalizeValue(item);
+ if (!hasMeaningfulValue(text)) {
+ return;
}
- }
- return fallbackLength || 0;
+ if (!normalized.some(function (existing) { return existing.toLowerCase() === text.toLowerCase(); })) {
+ normalized.push(text);
+ }
+ });
+ return normalized;
}
- deriveCorrectAnswerCount(recordData = {}, answers = []) {
- const numericCandidates = [
- recordData.correctAnswers,
- recordData.correct,
- recordData.score,
- recordData.scoreInfo?.correct,
- recordData.scoreInfo?.score,
- recordData.realData?.scoreInfo?.correct,
- recordData.realData?.scoreInfo?.score
- ];
- for (const candidate of numericCandidates) {
- const num = Number(candidate);
- if (Number.isFinite(num) && num >= 0) {
- return num;
- }
- }
-
- if (
- recordData.correctAnswers &&
- typeof recordData.correctAnswers === 'object' &&
- !Array.isArray(recordData.correctAnswers)
- ) {
- let hasBooleanFlag = false;
- const correctCount = Object.values(recordData.correctAnswers).reduce((count, value) => {
- if (typeof value === 'boolean') {
- hasBooleanFlag = true;
- return value ? count + 1 : count;
- }
- if (value && typeof value === 'object') {
- const flag = value.isCorrect ?? value.correct;
- if (typeof flag === 'boolean') {
- hasBooleanFlag = true;
- return flag ? count + 1 : count;
- }
- }
- return count;
- }, 0);
- if (hasBooleanFlag) {
- return correctCount;
- }
- }
-
- if (Array.isArray(answers) && answers.length > 0) {
- const computed = answers.reduce((sum, answer) => {
- if (!answer || typeof answer !== 'object') {
- return sum;
- }
- if (answer.correct === true || answer.isCorrect === true) {
- return sum + 1;
- }
- return sum;
- }, 0);
- if (computed > 0) {
- return computed;
- }
- }
-
- const detailSources = [
- recordData.answerDetails,
- recordData.scoreInfo?.details,
- recordData.realData?.scoreInfo?.details
- ];
- for (const details of detailSources) {
- if (!details || typeof details !== 'object') {
- continue;
- }
- let hasFlag = false;
- let correct = 0;
- Object.values(details).forEach(detail => {
- if (!detail || typeof detail !== 'object') {
- return;
- }
- if (detail.isCorrect === true || detail.correct === true) {
- correct += 1;
- }
- hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean';
- });
- if (hasFlag) {
- return correct;
- }
- }
- const answerMap = {};
- if (Array.isArray(answers)) {
- answers.forEach((answer) => {
- if (!answer || typeof answer !== 'object') {
- return;
- }
- const key = answer.questionId || answer.id || answer.key;
- if (key && answer.answer != null) {
- answerMap[this.normalizeAnswerMapKey(key)] = answer.answer;
- }
- });
- } else if (this.isPlainObject(answers)) {
- Object.entries(answers).forEach(([key, value]) => {
- const normalizedKey = this.normalizeAnswerMapKey(key);
- if (normalizedKey && value != null) {
- answerMap[normalizedKey] = value;
- }
- });
- }
- const correctMap = this.resolveCorrectAnswerMap(recordData);
- if (Object.keys(answerMap).length > 0 && Object.keys(correctMap).length > 0) {
- return Object.keys(answerMap).reduce((count, key) => {
- if (!Object.prototype.hasOwnProperty.call(correctMap, key)) {
- return count;
- }
- return this.compareAnswerValues(answerMap[key], correctMap[key]) ? count + 1 : count;
- }, 0);
- }
- return 0;
- }
-
- compareAnswerValues(userAnswer, correctAnswer) {
- if (userAnswer == null || correctAnswer == null) {
- return false;
- }
- const matchCore = window.AnswerMatchCore;
- if (matchCore && typeof matchCore.compareAnswers === 'function') {
- return matchCore.compareAnswers(userAnswer, correctAnswer) === true;
- }
- return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase();
- }
-
- getDateOnlyIso(value) {
- if (!value) return null;
- if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
- return value;
- }
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) {
- return null;
- }
- const year = parsed.getFullYear();
- const month = String(parsed.getMonth() + 1).padStart(2, '0');
- const day = String(parsed.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- }
-
- getLocalDayStart(value) {
- if (!value) return null;
- if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
- const [year, month, day] = value.split('-').map(part => Number(part));
- if ([year, month, day].some(num => Number.isNaN(num))) {
- return null;
- }
- return new Date(year, month - 1, day).getTime();
- }
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) {
- return null;
- }
- return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()).getTime();
- }
-
- createStorageAdapter() {
- const metaRepo = this.repositories.meta;
- const backupRepo = this.repositories.backups;
- const keys = this.storageKeys;
- const self = this;
-
- return {
- async get(key, defaultValue = null) {
- switch (key) {
- case keys.practiceRecords: {
- const api = self.getPracticeRecordAPI(['list']);
- const records = await api.list();
- return Array.isArray(records) ? records : [];
- }
- case keys.userStats: {
- const fallback = defaultValue !== null && defaultValue !== undefined ? defaultValue : self.getDefaultUserStats();
- const api = self.getPracticeRecordAPI(['readStats']);
- return await api.readStats({ fallback });
- }
- case keys.storageVersion:
- return await metaRepo.get('storage_version', defaultValue);
- case keys.backupData:
- case 'manual_backups':
- return await backupRepo.list();
- default:
- return await metaRepo.get(key, defaultValue);
- }
- },
- async set(key, value) {
- switch (key) {
- case keys.practiceRecords: {
- throw new Error('ScoreStorage.storage.set(practice_records) is disabled; use PracticeRecordAPI.replace');
- }
- case keys.userStats: {
- throw new Error('ScoreStorage.storage.set(user_stats) is disabled; use PracticeRecordAPI.writeStats');
- }
- case keys.storageVersion:
- await metaRepo.set('storage_version', value);
- return true;
- case keys.backupData:
- case 'manual_backups':
- await backupRepo.saveAll(Array.isArray(value) ? value : []);
- return true;
- default:
- await metaRepo.set(key, value);
- return true;
- }
- },
- async remove(key) {
- switch (key) {
- case keys.practiceRecords: {
- throw new Error('ScoreStorage.storage.remove(practice_records) is disabled; use PracticeRecordAPI.clear');
- }
- case keys.userStats: {
- throw new Error('ScoreStorage.storage.remove(user_stats) is disabled; use PracticeRecordAPI.resetStats');
- }
- case keys.storageVersion:
- await metaRepo.remove('storage_version');
- return true;
- case keys.backupData:
- case 'manual_backups':
- await backupRepo.clear();
- return true;
- default:
- await metaRepo.remove(key);
- return true;
- }
- }
- };
- }
-
- /**
- * 初始化存储系统
- */
- async initialize() {
- try {
- console.log('ScoreStorage initialized');
-
- // 检查存储版本并迁移数据
- await this.checkStorageVersion();
-
- // 初始化数据结构
- await this.initializeDataStructures();
-
- // Legacy migration happens at PersistentStore bootstrap, not in runtime services.
-
- // 暂时禁用清理过期数据,避免误删新记录
- // await this.cleanupExpiredData();
- } catch (error) {
- this.initializationError = error;
- console.error('[ScoreStorage] 初始化失败', error);
- throw error;
- }
- }
-
- /**
- * 检查存储版本
- */
- async checkStorageVersion() {
- const normalizeVersion = v => {
- if (v === undefined || v === null) return '';
- const s = String(v).trim();
- return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
- };
- const storedVersionRaw = await this.storage.get(this.storageKeys.storageVersion);
- const storedVersion = normalizeVersion(storedVersionRaw);
- const current = normalizeVersion(this.currentVersion);
- if (!storedVersion) {
- await this.storage.set(this.storageKeys.storageVersion, current);
- console.log('Storage version initialized:', current);
- return;
- }
- if (storedVersion !== current) {
- await this.migrateData(storedVersion, current);
- } else {
- console.log('[ScoreStorage] 版本匹配,跳过迁移');
- }
- }
-
- /**
- * 数据迁移
- */
- async migrateData(fromVersion, toVersion) {
- if (String(fromVersion) === String(toVersion)) {
- console.log('[ScoreStorage] migrateData skipped: same version');
- return;
- }
- console.log(`Migrating data from ${fromVersion} to ${toVersion}`);
-
- try {
- // 备份当前数据
- await this.createBackup('migration_backup', { allowDuringInit: true });
-
- // 根据版本执行相应的迁移逻辑
- if (fromVersion < '1.0.0') {
- await this.migrateToV1();
- }
-
- // 更新版本号
- await this.storage.set(this.storageKeys.storageVersion, toVersion);
- console.log('Data migration completed successfully');
-
- } catch (error) {
- console.error('Data migration failed:', error);
- // 恢复备份数据
- try {
- await this.restoreBackup('migration_backup', { allowDuringInit: true });
- } catch (restoreError) {
- console.error('Failed to restore backup:', restoreError);
- }
- }
- }
-
- /**
- * 迁移到版本1.0.0
- */
- async migrateToV1() {
- // 标准化练习记录格式
- const records = await this.listPracticeRecordsCanonical();
- const standardizedRecords = records.map(record => this.standardizeRecord(record));
- await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: true });
- }
-
- /**
- * 初始化数据结构
- */
- async initializeDataStructures() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() });
- console.log('[ScoreStorage] 用户统计由 PracticeRecordAPI 管理');
- return;
- }
- console.warn('[ScoreStorage] PracticeRecordAPI.readStats unavailable, skip stats initialization');
- }
-
- /**
- * 获取默认用户统计
- */
- getDefaultUserStats() {
- if (window.ExamData && typeof window.ExamData.createDefaultUserStats === 'function') {
- return window.ExamData.createDefaultUserStats();
- }
- const now = new Date().toISOString();
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- practiceDays: [],
- lastPracticeDate: null,
- achievements: [],
- createdAt: now,
- updatedAt: now
- };
- }
-
- /**
- * 保存练习记录
- */
- async savePracticeRecord(recordData) {
- try {
- await this.ensureReady();
- // 标准化记录格式
- const standardizedRecord = this.standardizeRecord(recordData);
-
- // 验证记录数据
- this.validateRecord(standardizedRecord);
-
- const practiceRecordApi = window.PracticeRecordAPI;
- if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') {
- try {
- const savedRecord = await practiceRecordApi.saveRecord(standardizedRecord, {
- currentVersion: this.currentVersion,
- maxRecords: this.maxRecords,
- updateStats: true
- });
- console.log('Practice record saved:', savedRecord.id);
- return savedRecord;
- } catch (apiError) {
- console.warn('[ScoreStorage] PracticeRecordAPI 保存失败:', apiError);
- throw apiError;
- }
- }
-
- throw new Error('ScoreStorage.savePracticeRecord: unified store not ready');
-
- } catch (error) {
- console.error('Failed to save practice record:', error);
- throw error;
- }
- }
-
- normalizeLegacyRecord(record) {
- if (!record || typeof record !== 'object') {
- return record;
- }
- const patched = Object.assign({}, record);
- if (Array.isArray(record.suiteEntries)) {
- patched.suiteEntries = record.suiteEntries.map(entry => this.clonePlainObject(entry)).filter(Boolean);
- }
- if (record.suiteMode != null) {
- patched.suiteMode = Boolean(record.suiteMode);
- }
- if (record.suiteSessionId) {
- patched.suiteSessionId = record.suiteSessionId;
- }
- if (record.frequency) {
- patched.frequency = record.frequency;
- }
- const inferredType = this.inferPracticeType(patched);
- if (!patched.type) {
- patched.type = inferredType;
- }
- const normalizedMetadata = this.buildMetadata(
- Object.assign({}, patched, { metadata: patched.metadata || {} }),
- patched.type
- );
- patched.metadata = normalizedMetadata;
- const normalizedAnswers = this.standardizeAnswers(patched.answers || patched.answerList || []);
- patched.answers = normalizedAnswers;
- patched.answerList = normalizedAnswers;
- const answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- const comparisonSource = patched.answerComparison || patched.realData?.answerComparison || null;
- const detailSource = patched.scoreInfo?.details
- || patched.realData?.scoreInfo?.details
- || patched.answerDetails
- || null;
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(patched, comparisonSource, detailSource);
- patched.correctAnswerMap = normalizedCorrectMap || {};
- if (!patched.answerDetails || typeof patched.answerDetails !== 'object') {
- patched.answerDetails = this.buildAnswerDetailsFromMaps(answerMap, patched.correctAnswerMap);
- }
- const derivedTotals = this.deriveTotalQuestionCount(patched, normalizedAnswers.length);
- const derivedCorrect = this.deriveCorrectAnswerCount(patched, normalizedAnswers);
- patched.totalQuestions = this.ensureNumber(patched.totalQuestions, derivedTotals);
- patched.correctAnswers = this.ensureNumber(patched.correctAnswers, derivedCorrect);
- patched.score = this.ensureNumber(patched.score, patched.correctAnswers);
- patched.accuracy = this.ensureNumber(
- patched.accuracy,
- patched.totalQuestions > 0 ? patched.correctAnswers / patched.totalQuestions : 0
- );
- if (!patched.startTime) {
- patched.startTime = patched.date || patched.endTime || new Date().toISOString();
- }
- if (!patched.endTime) {
- patched.endTime = patched.date || patched.startTime;
- }
- if (!patched.status) {
- patched.status = 'completed';
- }
- if (!patched.scoreInfo) {
- patched.scoreInfo = {};
- }
- if (!patched.scoreInfo.details && patched.answerDetails) {
- patched.scoreInfo.details = patched.answerDetails;
- }
- if (patched.realData) {
- patched.realData = Object.assign({}, patched.realData, {
- answers: patched.realData.answers || answerMap,
- correctAnswers: patched.correctAnswerMap,
- correctAnswerMap: patched.correctAnswerMap,
- scoreInfo: Object.assign({}, patched.realData.scoreInfo || {}, {
- details: patched.realData.scoreInfo?.details || patched.answerDetails || null
- })
- });
- }
- return patched;
- }
-
- needsRecordSanitization(record) {
- if (!record || typeof record !== 'object') {
- return true;
- }
- if (!record.type || !record.metadata || !record.metadata.type) {
- return true;
- }
- const numericFields = ['score', 'totalQuestions', 'correctAnswers', 'accuracy', 'duration'];
- return numericFields.some((field) => {
- if (!Object.prototype.hasOwnProperty.call(record, field)) {
- return false;
- }
- return typeof record[field] !== 'number' || Number.isNaN(record[field]);
- });
- }
-
- /**
- * 标准化记录格式
- */
- standardizeRecord(recordData) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.standardizeRecord === 'function') {
- return coreContracts.standardizeRecord(recordData, {
- currentVersion: this.currentVersion,
- generateRecordId: () => this.generateRecordId()
- });
- }
- const now = new Date().toISOString();
- const type = this.inferPracticeType(recordData);
- const recordDate = this.resolveRecordDate(recordData, now);
- const resolvedExamId = this.inferExamId(recordData);
- const metadata = this.buildMetadata(
- Object.assign({}, recordData, { examId: resolvedExamId }),
- type
- );
- const comparisonSource = recordData.answerComparison
- || recordData.realData?.answerComparison
- || null;
- const normalizedAnswers = this.standardizeAnswers(recordData.answers || recordData.answerList || []);
- let answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- // 如果 answers 为空,尝试从 answerComparison 补齐 userAnswer
- if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) {
- const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer');
- if (Object.keys(fromComparison).length > 0) {
- answerMap = fromComparison;
- }
- }
- const suiteSessionId = recordData.suiteSessionId
- || recordData.metadata?.suiteSessionId
- || null;
- if (suiteSessionId && !metadata.suiteSessionId) {
- metadata.suiteSessionId = suiteSessionId;
- }
- const frequency = recordData.frequency || metadata.frequency || null;
- if (frequency && !metadata.frequency) {
- metadata.frequency = frequency;
- }
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(recordData, comparisonSource);
- const derivedTotalQuestions = this.deriveTotalQuestionCount(recordData, normalizedAnswers.length);
- const derivedCorrectAnswers = this.deriveCorrectAnswerCount(recordData, normalizedAnswers);
- const totalQuestions = this.ensureNumber(recordData.totalQuestions, derivedTotalQuestions);
- const correctAnswers = this.ensureNumber(recordData.correctAnswers, derivedCorrectAnswers);
- let accuracy = this.ensureNumber(
- recordData.accuracy,
- totalQuestions > 0 ? correctAnswers / totalQuestions : 0
- );
- if (accuracy > 1 && accuracy <= 100) {
- accuracy = accuracy / 100; // 容错百分比形式
- }
- if (!Number.isFinite(accuracy) || accuracy < 0) {
- accuracy = 0;
- } else if (accuracy > 1) {
- accuracy = 1;
- }
- const detailSource = recordData.answerDetails
- || recordData.scoreInfo?.details
- || recordData.realData?.scoreInfo?.details
- || (comparisonSource ? this.convertComparisonToDetails(comparisonSource) : null)
- || this.buildAnswerDetailsFromMaps(answerMap, normalizedCorrectMap);
-
- const startTime = recordData.startTime && !Number.isNaN(new Date(recordData.startTime).getTime())
- ? new Date(recordData.startTime).toISOString()
- : recordDate;
- const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime())
- ? new Date(recordData.endTime).toISOString()
- : recordDate;
- const resolvedTitle = recordData.title
- || metadata.examTitle
- || metadata.title
- || recordData.examTitle
- || recordData.examId
- || '未命名练习';
- const normalizedSuiteEntries = this.standardizeSuiteEntries(recordData.suiteEntries || []);
- const normalizedComparison = comparisonSource && typeof comparisonSource === 'object'
- ? this.clonePlainObject(comparisonSource)
- : null;
-
- return {
- // 基础信息
- id: recordData.id || this.generateRecordId(),
- examId: resolvedExamId,
- sessionId: recordData.sessionId,
- title: resolvedTitle,
- type,
-
- // 时间信息
- startTime,
- endTime,
- duration: this.ensureNumber(recordData.duration, 0),
- date: recordDate,
-
- // 成绩信息
- status: recordData.status || 'completed',
- score: this.ensureNumber(recordData.score, correctAnswers),
- totalQuestions,
- correctAnswers,
- accuracy,
-
- // 答题详情
- answers: normalizedAnswers,
- answerDetails: detailSource || null,
- correctAnswerMap: normalizedCorrectMap || {},
- questionTypePerformance: recordData.questionTypePerformance || {},
-
- // 元数据
- metadata,
- frequency: frequency || metadata.frequency || null,
- suiteMode: Boolean(recordData.suiteMode || (frequency && frequency.toLowerCase() === 'suite')),
- suiteSessionId,
- suiteEntries: normalizedSuiteEntries,
- scoreInfo: recordData.scoreInfo
- ? Object.assign({}, recordData.scoreInfo, {
- details: recordData.scoreInfo.details || detailSource || null
- })
- : (detailSource ? { details: detailSource } : null),
- realData: recordData.realData
- ? Object.assign({}, recordData.realData, {
- answers: recordData.realData.answers || answerMap,
- correctAnswers: normalizedCorrectMap,
- correctAnswerMap: normalizedCorrectMap,
- scoreInfo: Object.assign({}, recordData.realData.scoreInfo || {}, {
- details: recordData.realData.scoreInfo?.details || detailSource || null
- }),
- answerComparison: recordData.realData.answerComparison
- ? this.clonePlainObject(recordData.realData.answerComparison)
- : (normalizedComparison || null)
- })
- : (normalizedComparison ? { answerComparison: normalizedComparison } : null),
- answerComparison: normalizedComparison,
-
- // 系统信息
- version: this.currentVersion,
- createdAt: recordData.createdAt || now,
- updatedAt: now
- };
- }
-
- /**
- * 标准化答案格式
- */
- standardizeAnswers(answers) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.buildAnswerArray === 'function') {
- return coreContracts.buildAnswerArray(answers);
- }
- if (!Array.isArray(answers)) {
- if (answers && typeof answers === 'object') {
- answers = Object.entries(answers).map(([questionId, value]) => ({
- questionId,
- answer: value
- }));
- } else {
- answers = [];
- }
- }
- return answers.map((answer, index) => ({
- questionId: answer.questionId || `q${index + 1}`,
- answer: answer.answer || '',
- correctAnswer: answer.correctAnswer || '',
- correct: Boolean(answer.correct),
- timeSpent: answer.timeSpent || 0,
- questionType: answer.questionType || 'unknown',
- timestamp: answer.timestamp || new Date().toISOString()
- }));
- }
-
- clonePlainObject(value) {
- if (value == null || typeof value !== 'object') {
- return value ?? null;
- }
- if (Array.isArray(value)) {
- return value.map(item => this.clonePlainObject(item)).filter(item => item !== undefined);
- }
- const clone = {};
- Object.keys(value).forEach((key) => {
- const entry = value[key];
- clone[key] = (entry && typeof entry === 'object')
- ? this.clonePlainObject(entry)
- : entry;
- });
- return clone;
- }
-
- isPlainObject(value) {
- return value !== null && typeof value === 'object' && !Array.isArray(value);
- }
-
- normalizeAnswerMapKey(key) {
- if (key == null) {
- return '';
- }
- let normalizedKey = String(key).trim();
- if (!normalizedKey) {
- return '';
- }
- if (/^\d+$/.test(normalizedKey)) {
- normalizedKey = `q${normalizedKey}`;
- } else if (normalizedKey.startsWith('question')) {
- normalizedKey = normalizedKey.replace('question', 'q');
- }
- return normalizedKey;
- }
-
- mergeAnswerMaps(...sources) {
- const merged = {};
- sources.forEach((source) => {
- if (!this.isPlainObject(source)) {
- return;
- }
- Object.entries(source).forEach(([key, value]) => {
- const normalizedKey = this.normalizeAnswerMapKey(key);
- if (!normalizedKey || Object.prototype.hasOwnProperty.call(merged, normalizedKey)) {
- return;
- }
- if (value == null || String(value).trim() === '') {
- return;
- }
- merged[normalizedKey] = value;
- });
- });
- return merged;
- }
-
- convertComparisonToMap(comparison, key = 'correctAnswer') {
- if (!comparison || typeof comparison !== 'object') {
- return {};
- }
- const map = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (!entry || typeof entry !== 'object') return;
- const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.user);
- if (value != null && String(value).trim() !== '') {
- map[questionId] = value;
- }
- });
- return map;
- }
-
- resolveCorrectAnswerMap(recordData = {}, comparisonSource = null, detailSource = null) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.resolveRecordCorrectAnswerMap === 'function') {
- return coreContracts.resolveRecordCorrectAnswerMap(recordData, {
- comparison: comparisonSource,
- detailSources: detailSource ? [detailSource] : []
- });
- }
- const realData = this.isPlainObject(recordData.realData) ? recordData.realData : {};
- const effectiveComparison = comparisonSource || recordData.answerComparison || realData.answerComparison || null;
- return this.mergeAnswerMaps(
- recordData.correctAnswerMap,
- realData.correctAnswerMap,
- recordData.correctAnswers,
- realData.correctAnswers,
- effectiveComparison ? this.convertComparisonToMap(effectiveComparison, 'correctAnswer') : null,
- recordData.answerDetails ? this.deriveCorrectMapFromDetails(recordData.answerDetails) : null,
- detailSource ? this.deriveCorrectMapFromDetails(detailSource) : null,
- recordData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(recordData.scoreInfo.details) : null,
- realData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(realData.scoreInfo.details) : null
- );
- }
-
- convertComparisonToDetails(comparison) {
- if (!comparison || typeof comparison !== 'object') {
- return null;
- }
- const details = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (!entry || typeof entry !== 'object') return;
- details[questionId] = {
- userAnswer: entry.userAnswer ?? entry.user ?? '',
- correctAnswer: entry.correctAnswer ?? entry.correct ?? '',
- isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null
- };
- });
- return details;
- }
-
- standardizeSuiteEntries(entries) {
- if (!Array.isArray(entries)) {
- return [];
- }
- return entries.map((entry, index) => {
- if (!entry || typeof entry !== 'object') {
- return null;
- }
- const normalizedAnswers = this.standardizeAnswers(entry.answers || entry.answerList || []);
- const answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- const normalizedScoreInfo = entry.scoreInfo
- ? Object.assign({}, entry.scoreInfo, {
- details: entry.scoreInfo?.details
- ? this.clonePlainObject(entry.scoreInfo.details)
- : null
- })
- : null;
- const answerComparisonSource = entry.answerComparison
- || normalizedScoreInfo?.details
- || entry.rawData?.answerComparison
- || null;
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(
- entry,
- answerComparisonSource,
- normalizedScoreInfo?.details || entry.rawData?.scoreInfo?.details || null
- );
- const highlights = Array.isArray(entry.highlights)
- ? entry.highlights.slice()
- : (Array.isArray(entry.rawData?.highlights) ? entry.rawData.highlights.slice() : []);
- const scrollY = Number.isFinite(Number(entry.scrollY))
- ? Number(entry.scrollY)
- : (Number.isFinite(Number(entry.rawData?.scrollY)) ? Number(entry.rawData.scrollY) : 0);
- return {
- examId: entry.examId || null,
- title: entry.title || entry.examTitle || `套题第${index + 1}篇`,
- category: entry.category || entry.metadata?.category || '套题',
- duration: this.ensureNumber(entry.duration, 0),
- scoreInfo: normalizedScoreInfo,
- answers: answerMap,
- correctAnswerMap: normalizedCorrectMap,
- answerComparison: this.clonePlainObject(answerComparisonSource) || null,
- metadata: entry.metadata ? Object.assign({}, entry.metadata) : {},
- highlights,
- scrollY,
- rawData: entry.rawData ? this.clonePlainObject(entry.rawData) : null
- };
- }).filter(Boolean);
- }
-
- deriveCorrectMapFromDetails(details) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.deriveCorrectMapFromDetails === 'function') {
- return coreContracts.deriveCorrectMapFromDetails(details);
- }
- if (!details || typeof details !== 'object') {
- return {};
- }
- const map = {};
- Object.entries(details).forEach(([questionId, info]) => {
- if (!info) {
- return;
- }
- const correctAnswer = info.correctAnswer || info.answer || info.value;
- if (correctAnswer != null) {
- map[questionId] = (typeof correctAnswer === 'string')
- ? correctAnswer.trim()
- : String(correctAnswer);
- }
- });
- return map;
- }
-
- buildAnswerDetailsFromMaps(answerMap = {}, correctMap = {}) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.buildAnswerDetails === 'function') {
- return coreContracts.buildAnswerDetails(answerMap, correctMap);
- }
- const details = {};
- const keys = new Set([
- ...Object.keys(answerMap || {}),
- ...Object.keys(correctMap || {})
- ]);
- keys.forEach((questionId) => {
- const userAnswer = answerMap && answerMap[questionId] ? String(answerMap[questionId]) : '-';
- const correctAnswer = correctMap && correctMap[questionId] ? String(correctMap[questionId]) : '-';
- let isCorrect = null;
- if (correctAnswer !== '-') {
- const matchCore = window.AnswerMatchCore;
- isCorrect = matchCore && typeof matchCore.compareAnswers === 'function'
- ? matchCore.compareAnswers(userAnswer, correctAnswer) === true
- : userAnswer.toLowerCase() === correctAnswer.toLowerCase();
- }
- details[questionId] = {
- userAnswer,
- correctAnswer,
- isCorrect
- };
- });
- return details;
- }
-
- /**
- * 验证记录数据
- */
- validateRecord(record) {
- const requiredFields = ['id', 'examId', 'startTime', 'endTime'];
-
- for (const field of requiredFields) {
- if (!record[field]) {
- throw new Error(`Missing required field: ${field}`);
- }
- }
-
- // 验证时间格式
- if (new Date(record.startTime).toString() === 'Invalid Date') {
- throw new Error('Invalid startTime format');
- }
-
- if (new Date(record.endTime).toString() === 'Invalid Date') {
- throw new Error('Invalid endTime format');
- }
-
- // 验证数值范围
- record.accuracy = Math.max(0, Math.min(1, Number(record.accuracy) || 0));
-
- record.duration = Number.isFinite(record.duration) && record.duration >= 0
- ? record.duration
- : 0;
- }
-
- /**
- * 更新用户统计
- */
- async updateUserStats(practiceRecord, options = {}) {
- const { allowDuringInit = false } = options;
- await this.recalculateUserStats({ allowDuringInit });
- }
-
- applyRecordToStats(stats, practiceRecord) {
- if (!stats || typeof stats !== 'object') {
- return;
- }
-
- const duration = Number(practiceRecord.duration) || 0;
- const accuracy = Number(practiceRecord.accuracy) || 0;
- const normalizedRecord = { ...practiceRecord, duration, accuracy };
-
- stats.categoryStats = stats.categoryStats && typeof stats.categoryStats === 'object' ? stats.categoryStats : {};
- stats.questionTypeStats = stats.questionTypeStats && typeof stats.questionTypeStats === 'object' ? stats.questionTypeStats : {};
-
- stats.totalPractices += 1;
- stats.totalTimeSpent += duration;
-
- const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy;
- stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0;
-
- this.updateCategoryStats(stats, normalizedRecord);
- this.updateQuestionTypeStats(stats, normalizedRecord);
- this.updateStreakDays(stats, normalizedRecord);
- this.checkAchievements(stats, normalizedRecord);
-
- stats.updatedAt = new Date().toISOString();
- }
-
- /**
- * 更新分类统计
- */
- updateCategoryStats(stats, practiceRecord) {
- const category = practiceRecord?.metadata?.category;
- if (!category) return;
-
- if (!stats.categoryStats[category]) {
- stats.categoryStats[category] = {
- practices: 0,
- avgScore: 0,
- timeSpent: 0,
- bestScore: 0,
- totalQuestions: 0,
- correctAnswers: 0
- };
- }
-
- const catStats = stats.categoryStats[category];
- catStats.practices += 1;
- catStats.timeSpent += practiceRecord.duration;
- catStats.totalQuestions += practiceRecord.totalQuestions;
- catStats.correctAnswers += practiceRecord.correctAnswers;
- catStats.bestScore = Math.max(catStats.bestScore, practiceRecord.accuracy);
-
- // 重新计算平均分数
- const catTotalScore = (catStats.avgScore * (catStats.practices - 1)) + practiceRecord.accuracy;
- catStats.avgScore = catTotalScore / catStats.practices;
- }
-
- /**
- * 更新题型统计
- */
- updateQuestionTypeStats(stats, practiceRecord) {
- if (!practiceRecord.questionTypePerformance) return;
-
- Object.entries(practiceRecord.questionTypePerformance).forEach(([type, performance]) => {
- if (!stats.questionTypeStats[type]) {
- stats.questionTypeStats[type] = {
- practices: 0,
- accuracy: 0,
- totalQuestions: 0,
- correctAnswers: 0,
- avgTimePerQuestion: 0
- };
- }
-
- const typeStats = stats.questionTypeStats[type];
- typeStats.practices += 1;
- typeStats.totalQuestions += performance.total || 0;
- typeStats.correctAnswers += performance.correct || 0;
-
- // 重新计算准确率
- typeStats.accuracy = typeStats.totalQuestions > 0
- ? typeStats.correctAnswers / typeStats.totalQuestions
- : 0;
-
- // 计算平均每题用时
- if (performance.timeSpent && performance.total) {
- const newAvgTime = performance.timeSpent / performance.total;
- typeStats.avgTimePerQuestion = (typeStats.avgTimePerQuestion * (typeStats.practices - 1) + newAvgTime) / typeStats.practices;
- }
- });
- }
-
- /**
- * 更新连续学习天数
- */
- updateStreakDays(stats, practiceRecord) {
- const recordSource = practiceRecord.date || practiceRecord.endTime || practiceRecord.startTime;
- const recordDay = this.getDateOnlyIso(recordSource);
- if (!recordDay) return;
-
- const dayMs = 24 * 60 * 60 * 1000;
- let practiceDays = Array.isArray(stats.practiceDays) ? stats.practiceDays.slice() : [];
-
- if (practiceDays.length === 0) {
- const historicalStreak = Math.max(0, Math.round(this.ensureNumber(stats.streakDays, 0)));
- const lastPracticeIso = this.getDateOnlyIso(stats.lastPracticeDate);
- const lastPracticeStart = this.getLocalDayStart(lastPracticeIso);
-
- if (historicalStreak > 0 && lastPracticeIso && Number.isFinite(lastPracticeStart)) {
- const migratedDays = [];
- for (let offset = historicalStreak - 1; offset >= 0; offset -= 1) {
- const timestamp = lastPracticeStart - (offset * dayMs);
- const dayIso = this.getDateOnlyIso(timestamp);
- if (dayIso) {
- migratedDays.push(dayIso);
- }
- }
- practiceDays = migratedDays;
- }
- }
-
- const uniqueDays = new Set(practiceDays);
- uniqueDays.add(recordDay);
- practiceDays = Array.from(uniqueDays);
-
- const validDays = practiceDays
- .map(day => ({ day, start: this.getLocalDayStart(day) }))
- .filter(item => item.start !== null)
- .sort((a, b) => a.start - b.start);
-
- if (validDays.length === 0) {
- stats.practiceDays = [];
- stats.streakDays = 0;
- stats.lastPracticeDate = null;
- return;
- }
-
- let currentStreak = 1;
-
- for (let index = 1; index < validDays.length; index += 1) {
- const previous = validDays[index - 1];
- const current = validDays[index];
- const diff = Math.round((current.start - previous.start) / (1000 * 60 * 60 * 24));
-
- if (diff === 1) {
- currentStreak += 1;
- } else if (diff > 1) {
- currentStreak = 1;
- }
- }
-
- stats.practiceDays = validDays.map(item => item.day);
- stats.streakDays = currentStreak;
- stats.lastPracticeDate = validDays[validDays.length - 1].day;
- }
-
- /**
- * 检查成就
- */
- checkAchievements(stats, practiceRecord) {
- const achievements = stats.achievements || [];
-
- // 首次练习成就
- if (stats.totalPractices === 1 && !achievements.includes('first-practice')) {
- achievements.push('first-practice');
- }
-
- // 连续学习成就
- if (stats.streakDays >= 7 && !achievements.includes('week-streak')) {
- achievements.push('week-streak');
- }
-
- if (stats.streakDays >= 30 && !achievements.includes('month-streak')) {
- achievements.push('month-streak');
- }
-
- // 高分成就
- if (practiceRecord.accuracy >= 0.9 && !achievements.includes('high-scorer')) {
- achievements.push('high-scorer');
- }
-
- // 分类掌握成就
- const category = practiceRecord.metadata.category;
- if (category && stats.categoryStats[category]) {
- const catStats = stats.categoryStats[category];
- if (catStats.practices >= 10 && catStats.avgScore >= 0.8) {
- const achievementKey = `${category.toLowerCase()}-master`;
- if (!achievements.includes(achievementKey)) {
- achievements.push(achievementKey);
- }
- }
- }
-
- stats.achievements = achievements;
- }
-
- /**
- * 获取练习记录
- */
- async getPracticeRecords(filters = {}) {
- await this.ensureReady();
- const raw = await this.listPracticeRecordsCanonical();
- const base = Array.isArray(raw) ? raw : [];
- // Normalize each record to ensure UI can rely on a stable shape
- const records = base.map(r => this.normalizeRecordFields(r));
-
- if (Object.keys(filters).length === 0) {
- return records.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
- }
-
- return records.filter(record => {
- // 按考试ID筛选
- if (filters.examId && record.examId !== filters.examId) return false;
-
- // 按分类筛选
- if (filters.category && record.metadata.category !== filters.category) return false;
-
- // 按时间范围筛选
- if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false;
- if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false;
-
- // 按准确率筛选
- if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false;
- if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false;
-
- // 按状态筛选
- if (filters.status && record.status !== filters.status) return false;
-
- return true;
- }).sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
- }
-
- /**
- * 获取用户统计
- */
- async getUserStats(options = {}) {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
- const api = this.getPracticeRecordAPI(['readStats']);
- return await api.readStats({ fallback: this.getDefaultUserStats() });
- }
-
- /**
- * 重新计算用户统计
- */
- async recalculateUserStats(options = {}) {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
- const api = this.getPracticeRecordAPI(['recalculateStats']);
- const stats = await api.recalculateStats();
- console.log('User stats recalculated through PracticeRecordAPI');
- return stats;
- }
-
- /**
- * 将不同来源/版本的记录统一为稳定字段,以便 UI/统计可靠工作
- * 不修改存储中的原始对象,仅在返回路径做兼容填充
- */
- normalizeRecordFields(record) {
- try {
- const r = { ...(record || {}) };
-
- // metadata 兜底
- r.metadata = {
- examTitle: (r.metadata && r.metadata.examTitle) || r.title || r.examTitle || r.examId || '',
- category: (r.metadata && r.metadata.category) || r.category || '',
- frequency: (r.metadata && r.metadata.frequency) || r.frequency || '',
- ...(r.metadata || {})
- };
-
- // 时间字段归一
- const rd = r.realData || {};
- if (!r.startTime) {
- if (typeof rd.startTime === 'number') {
- r.startTime = new Date(rd.startTime).toISOString();
- } else if (rd.startTime) {
- r.startTime = new Date(rd.startTime).toISOString();
- } else if (r.date) {
- r.startTime = new Date(r.date).toISOString();
- }
- }
- if (!r.endTime) {
- if (typeof rd.endTime === 'number') {
- r.endTime = new Date(rd.endTime).toISOString();
- } else if (rd.endTime) {
- r.endTime = new Date(rd.endTime).toISOString();
- } else if (r.startTime && (r.duration || rd.duration)) {
- const base = new Date(r.startTime).getTime();
- const seconds = (Number(r.duration || rd.duration) || 0);
- r.endTime = new Date(base + seconds * 1000).toISOString();
- }
- }
-
- // 用时归一(秒): consider multiple possible fields; prefer positive seconds
- if (!(typeof r.duration === 'number' && isFinite(r.duration) && r.duration > 0)) {
- const sInfo = r.scoreInfo || rd.scoreInfo || {};
- const candidates = [
- r.duration, rd.duration, r.durationSeconds, r.duration_seconds,
- r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent,
- rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent,
- sInfo.duration, sInfo.timeSpent
- ];
- let picked;
- for (const v of candidates) {
- const n = Number(v);
- if (Number.isFinite(n) && n > 0) { picked = n; break; }
- }
- if (picked !== undefined) {
- r.duration = Math.floor(picked);
- } else if (r.startTime && r.endTime) {
- r.duration = Math.max(0, Math.floor((new Date(r.endTime) - new Date(r.startTime)) / 1000));
- } else if (Array.isArray(rd.interactions) && rd.interactions.length) {
- // Derive from interactions timestamp span
- try {
- const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n));
- if (ts.length) {
- const span = Math.max(...ts) - Math.min(...ts);
- if (Number.isFinite(span) && span > 0) r.duration = Math.floor(span / 1000);
- }
- } catch(_) {}
- } else {
- r.duration = 0;
- }
- }
-
- // scoreInfo 归一
- const sInfo = r.scoreInfo || rd.scoreInfo || {};
- if (!r.scoreInfo && (rd.scoreInfo || r.answerComparison)) {
- r.scoreInfo = sInfo;
- }
-
- // answers 归一
- if (!r.answers && rd.answers) {
- r.answers = rd.answers;
- }
- if (Array.isArray(r.answers)) {
- const map = {};
- r.answers.forEach((entry, idx) => {
- if (!entry) return;
- const key = entry.questionId || `q${idx + 1}`;
- map[key] = entry.answer || entry.userAnswer || '';
- });
- r.answerList = r.answers.slice();
- r.answers = map;
- }
- if (Array.isArray(rd.answers)) {
- const rdMap = {};
- rd.answers.forEach((entry, idx) => {
- if (!entry) return;
- const key = entry.questionId || `q${idx + 1}`;
- rdMap[key] = entry.answer || entry.userAnswer || '';
- });
- rd.answers = rdMap;
- }
- const comparisonSource = r.answerComparison || rd.answerComparison || null;
- if ((!r.answers || Object.keys(r.answers).length === 0) && comparisonSource) {
- const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer');
- if (Object.keys(fromComparison).length > 0) {
- r.answers = fromComparison;
- }
- }
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(
- r,
- comparisonSource,
- r.answerDetails || r.scoreInfo?.details || rd.scoreInfo?.details || null
- );
- if (Object.keys(normalizedCorrectMap).length > 0) {
- r.correctAnswerMap = normalizedCorrectMap;
- }
- if (!r.answerDetails) {
- if (comparisonSource) {
- r.answerDetails = this.convertComparisonToDetails(comparisonSource);
- }
- if (!r.answerDetails) {
- r.answerDetails = r.scoreInfo?.details || this.buildAnswerDetailsFromMaps(r.answers, r.correctAnswerMap);
- }
- }
-
- // 正确/总题数归一
- const derivedCorrect = (typeof r.correctAnswers === 'number') ? r.correctAnswers
- : (typeof r.score === 'number' ? r.score
- : (typeof sInfo.correct === 'number'
- ? sInfo.correct
- : this.deriveCorrectAnswerCount(r, r.answers || [])));
-
- const derivedTotal = (typeof r.totalQuestions === 'number') ? r.totalQuestions
- : (typeof sInfo.total === 'number' ? sInfo.total
- : (r.realData && typeof r.realData.totalQuestions === 'number' ? r.realData.totalQuestions
- : (r.answers ? Object.keys(r.answers).length
- : (rd.answers ? Object.keys(rd.answers || {}).length : null))));
-
- if (typeof r.correctAnswers !== 'number' && derivedCorrect != null) {
- r.correctAnswers = derivedCorrect;
- }
- if (typeof r.totalQuestions !== 'number' && derivedTotal != null) {
- r.totalQuestions = derivedTotal;
- }
- if (r.realData && typeof r.realData === 'object') {
- r.realData.correctAnswers = r.correctAnswerMap || {};
- r.realData.correctAnswerMap = r.correctAnswerMap || {};
- }
-
- // 准确率/百分比归一
- let acc = (typeof r.accuracy === 'number') ? r.accuracy
- : (typeof sInfo.accuracy === 'number' ? sInfo.accuracy : null);
- if (acc == null) {
- if (typeof r.correctAnswers === 'number' && typeof r.totalQuestions === 'number' && r.totalQuestions > 0) {
- acc = r.correctAnswers / r.totalQuestions;
- } else {
- acc = 0;
- }
- }
- r.accuracy = acc;
-
- if (typeof r.percentage !== 'number' || isNaN(r.percentage)) {
- if (typeof sInfo.percentage === 'number') {
- r.percentage = sInfo.percentage;
- } else {
- r.percentage = Math.round(acc * 100);
- }
- }
-
- // 状态兜底
- if (!r.status) r.status = 'completed';
-
- return r;
- } catch (e) {
- try { console.warn('[ScoreStorage] normalizeRecordFields failed:', e); } catch(_) {}
- return record;
- }
- }
-
- /**
- * 创建数据备份 - 统一走 BackupAPI → BackupRepository
- */
- async createBackup(backupName = null, options = {}) {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
-
- if (window.BackupAPI && typeof window.BackupAPI.create === 'function') {
- const practiceRecords = await this.listPracticeRecordsCanonical();
- const userStats = await this.getUserStats({ allowDuringInit });
- const storageVersion = await this.storage.get(this.storageKeys.storageVersion);
- const examIndex = await this.storage.get('exam_index', []);
- const backupId = await window.BackupAPI.create({
- id: backupName || undefined,
- type: 'score_storage',
- data: {
- practice_records: practiceRecords,
- user_stats: userStats,
- exam_index: Array.isArray(examIndex) ? examIndex : [],
- storage_version: storageVersion
- }
- });
- console.log('[ScoreStorage] Backup created via BackupAPI:', backupId);
- return backupId;
- }
-
- // Fallback: DataBackupManager path (still ends at BackupAPI if loaded)
- if (window.DataBackupManager) {
- const backupManager = new DataBackupManager();
- const backupId = await backupManager.createBackup(
- backupName || `score_backup_${Date.now()}`,
- 'score_storage'
- );
- console.log('[ScoreStorage] Backup created via DataBackupManager:', backupId);
- return backupId;
- }
-
- console.warn('[ScoreStorage] BackupAPI not available, skipping backup');
- return null;
- }
-
- /**
- * 恢复数据备份 - 统一走 BackupAPI
- */
- async restoreBackup(backupId, options = {}) {
- try {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
-
- if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') {
- const result = await window.BackupAPI.restore(backupId);
- console.log('[ScoreStorage] Backup restored via BackupAPI:', backupId);
- return result.backup;
- }
-
- // Fallback dual-schema restore when BackupAPI missing
- const backups = await this.storage.get('manual_backups', []);
- const backup = backups.find(b => b.id === backupId);
-
- if (!backup) {
- throw new Error(`Backup not found: ${backupId}`);
- }
-
- if (backup.data) {
- const data = backup.data;
- const records = Array.isArray(data.practiceRecords)
- ? data.practiceRecords
- : (Array.isArray(data.practice_records) ? data.practice_records : []);
- const stats = (data.userStats && typeof data.userStats === 'object')
- ? data.userStats
- : ((data.user_stats && typeof data.user_stats === 'object') ? data.user_stats : null);
- const hasStats = Boolean(stats);
- await this.replacePracticeRecordsCanonical(records, { updateStats: !hasStats });
- if (hasStats) {
- const api = this.getPracticeRecordAPI(['resetStats']);
- await api.resetStats(stats);
- }
- if (data.storageVersion || data.storage_version) {
- await this.storage.set(this.storageKeys.storageVersion, data.storageVersion || data.storage_version);
- }
- const examIndex = Array.isArray(data.exam_index)
- ? data.exam_index
- : (Array.isArray(data.examIndex) ? data.examIndex : null);
- if (examIndex) {
- await this.storage.set('exam_index', examIndex);
- }
- }
-
- console.log('[ScoreStorage] Backup restored:', backupId);
- return backup;
- } catch (error) {
- console.error('[ScoreStorage] Failed to restore backup:', error);
- throw error;
- }
- }
-
- /**
- * 获取备份列表 - 统一走 BackupAPI
- */
- async getBackups() {
- try {
- await this.ensureReady();
- if (window.BackupAPI && typeof window.BackupAPI.list === 'function') {
- const backups = await window.BackupAPI.list();
- return (Array.isArray(backups) ? backups : [])
- .slice()
- .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- }
- const backups = await this.storage.get('manual_backups', []);
- return (Array.isArray(backups) ? backups : [])
- .slice()
- .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- } catch (error) {
- console.error('[ScoreStorage] Failed to get backups:', error);
- return [];
- }
- }
-
- /**
- * 导出数据
- */
- async exportData(format = 'json') {
- await this.ensureReady();
- const exportData = {
- exportDate: new Date().toISOString(),
- version: this.currentVersion,
- practiceRecords: await this.listPracticeRecordsCanonical(),
- userStats: await this.getUserStats(),
- backups: await this.storage.get(this.storageKeys.backupData, [])
- };
-
- switch (format.toLowerCase()) {
- case 'json':
- return JSON.stringify(exportData, null, 2);
- case 'csv':
- return this.convertToCSV(exportData.practiceRecords);
- default:
- throw new Error(`Unsupported export format: ${format}`);
- }
- }
-
- /**
- * 转换为CSV格式
- */
- convertToCSV(records) {
- if (records.length === 0) return '';
-
- const headers = [
- 'ID', '考试ID', '开始时间', '结束时间', '用时(秒)',
- '状态', '分数', '总题数', '正确数', '准确率',
- '分类', '频率', '题目标题'
- ];
-
- const rows = records.map(record => [
- record.id,
- record.examId,
- record.startTime,
- record.endTime,
- record.duration,
- record.status,
- record.score,
- record.totalQuestions,
- record.correctAnswers,
- Math.round(record.accuracy * 100) + '%',
- record.metadata.category || '',
- record.metadata.frequency || '',
- record.metadata.examTitle || ''
- ]);
-
- return [headers, ...rows]
- .map(row => row.map(cell => `"${cell}"`).join(','))
- .join('\n');
- }
-
- /**
- * 导入数据
- */
- async importData(importData, options = {}) {
- try {
- await this.ensureReady();
- const payload = typeof importData === 'string' ? JSON.parse(importData) : importData;
-
- const records = this.extractPracticeRecordsFromPayload(payload);
- const stats = this.extractUserStatsFromPayload(payload);
-
- if (!Array.isArray(records) || records.length === 0) {
- throw new Error('Invalid import data format: no practice records found');
- }
-
- // 标准化记录,避免字段缺失
- const standardizedRecords = records.map((r) => {
- try {
- return this.standardizeRecord(r);
- } catch (e) {
- console.warn('[ScoreStorage] 标准化导入记录失败,跳过:', r && r.id, e);
- return null;
- }
- }).filter(Boolean);
-
- // 创建备份
- await this.createBackup('pre_import_backup');
-
- if (options.merge) {
- // 合并模式:按 id 去重,保留导入集中的最新(后出现的覆盖)
- const existingRecords = await this.listPracticeRecordsCanonical();
- const mergedMap = new Map();
- existingRecords.forEach((rec) => {
- if (rec && rec.id) mergedMap.set(rec.id, rec);
- });
- standardizedRecords.forEach((rec) => {
- if (rec && rec.id) mergedMap.set(rec.id, rec);
- });
- const mergedRecords = Array.from(mergedMap.values());
- await this.replacePracticeRecordsCanonical(mergedRecords, { updateStats: true });
- console.log(`Imported ${standardizedRecords.length} records (merge mode), total ${mergedRecords.length}`);
-
- } else {
- // 替换模式:完全替换数据
- await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: !stats });
-
- if (stats) {
- const api = this.getPracticeRecordAPI(['writeStats']);
- await api.writeStats(stats);
- }
-
- console.log(`Imported ${standardizedRecords.length} records (replace mode)`);
- }
-
- return true;
-
- } catch (error) {
- console.error('Failed to import data:', error);
- throw error;
- }
- }
-
- extractPracticeRecordsFromPayload(payload) {
- if (!payload) return [];
- if (Array.isArray(payload)) return payload;
- if (Array.isArray(payload.practiceRecords)) return payload.practiceRecords;
- if (Array.isArray(payload.practice_records)) return payload.practice_records;
- if (Array.isArray(payload.data?.practice_records)) return payload.data.practice_records;
- if (Array.isArray(payload.data?.practiceRecords)) return payload.data.practiceRecords;
- if (payload.data?.exam_system_practice_records && Array.isArray(payload.data.exam_system_practice_records.data)) {
- return payload.data.exam_system_practice_records.data;
- }
- if (payload.exam_system_practice_records && Array.isArray(payload.exam_system_practice_records.data)) {
- return payload.exam_system_practice_records.data;
- }
- return [];
- }
-
- extractUserStatsFromPayload(payload) {
- if (!payload || typeof payload !== 'object') return null;
- return payload.userStats
- || payload.user_stats
- || payload.data?.userStats
- || payload.data?.user_stats
- || null;
- }
-
- // Note: 备份相关方法已移除,现在使用DataBackupManager
-
- /**
- * 生成记录ID
- */
- generateRecordId() {
- return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- }
-
- /**
- * 获取存储统计信息
- */
- async getStorageStats() {
- await this.ensureReady();
- const records = await this.listPracticeRecordsCanonical();
- const backups = await this.storage.get(this.storageKeys.backupData, []);
-
- return {
- totalRecords: records.length,
- totalBackups: backups.length,
- oldestRecord: records.length > 0 ? records[0].startTime : null,
- newestRecord: records.length > 0 ? records[records.length - 1].startTime : null,
- storageVersion: await this.storage.get(this.storageKeys.storageVersion),
- estimatedSize: await this.estimateStorageSize()
- };
- }
-
- /**
- * 估算存储大小
- */
- async estimateStorageSize() {
- await this.ensureReady();
- const data = {
- practiceRecords: await this.listPracticeRecordsCanonical(),
- userStats: await this.getUserStats(),
- backupData: await this.storage.get(this.storageKeys.backupData, [])
- };
-
- const jsonString = JSON.stringify(data);
- return jsonString.length; // 字节数的近似值
- }
-
- // Note: destroy方法已移除,因为备份功能现在由DataBackupManager处理
-}
-
-// 确保全局可用
-window.ScoreStorage = ScoreStorage;
-
-
-/* ===== js/utils/answerSanitizer.js ===== */
-(function (global) {
- 'use strict';
-
- function toStringSafe(value) {
- if (value === null || value === undefined) {
- return '';
- }
- return String(value);
- }
-
- function normalizeFromObject(object) {
- if (!object || typeof object !== 'object') {
- return '';
- }
- const preferKeys = [
- 'value',
- 'answerValue',
- 'key',
- 'option',
- 'heading',
- 'word',
- 'label',
- 'answerLabel',
- 'text',
- 'answer',
- 'content'
- ];
- for (var i = 0; i < preferKeys.length; i += 1) {
- var key = preferKeys[i];
- if (typeof object[key] === 'string' && object[key].trim()) {
- return object[key].trim();
- }
- }
- if (typeof object.innerText === 'string' && object.innerText.trim()) {
- return object.innerText.trim();
- }
- if (typeof object.textContent === 'string' && object.textContent.trim()) {
- return object.textContent.trim();
- }
- try {
- var serialized = JSON.stringify(object);
- if (serialized && serialized !== '{}' && serialized !== '[]') {
- return serialized;
- }
- } catch (_) {}
- return toStringSafe(object);
- }
-
- function normalizeValue(value) {
- if (value === null || value === undefined) {
- return '';
- }
- if (typeof value === 'string') {
- var trimmed = value.trim();
- if (/^\[object\s/i.test(trimmed)) {
- return '';
- }
- return trimmed;
- }
- if (typeof value === 'boolean') {
- return value ? 'True' : 'False';
- }
- if (typeof value === 'number') {
- return toStringSafe(value).trim();
- }
- if (Array.isArray(value)) {
- var normalizedArray = value
- .map(function (item) { return normalizeValue(item); })
- .filter(function (item) { return item !== null && item !== undefined && item !== ''; })
- .join(', ');
- return normalizedArray.trim();
- }
- return normalizeFromObject(value).replace(/^\[object\s[^\]]+\]$/i, '').trim();
- }
-
- function hasMeaningfulValue(value) {
- var normalized = normalizeValue(value);
- if (!normalized) {
- return false;
- }
- var lowered = normalized.toLowerCase();
- if (lowered === 'n/a' || lowered === 'no answer' || lowered === '未作答' || lowered === '无' || lowered === 'none') {
- return false;
- }
- return true;
- }
-
- function normalizeValueList(value) {
- var values = Array.isArray(value) ? value : (value === null || value === undefined ? [] : [value]);
- var normalized = [];
- values.forEach(function (item) {
- var text = normalizeValue(item);
- if (!hasMeaningfulValue(text)) {
- return;
- }
- if (!normalized.some(function (existing) { return existing.toLowerCase() === text.toLowerCase(); })) {
- normalized.push(text);
- }
- });
- return normalized;
- }
-
- function sanitizeComparisonMap(comparisonMap) {
- if (!comparisonMap || typeof comparisonMap !== 'object') {
- return {};
+ function sanitizeComparisonMap(comparisonMap) {
+ if (!comparisonMap || typeof comparisonMap !== 'object') {
+ return {};
}
var sanitized = {};
Object.keys(comparisonMap).forEach(function (key) {
@@ -5855,8 +3912,6 @@ window.ScoreStorage = ScoreStorage;
/* ===== js/core/practiceRecorder.js ===== */
-const PRACTICE_RECORDER_EXPORT_VERSION = '0.6.2-fix';
-
/**
* 练习记录管理器
* 负责练习会话管理、成绩记录和数据持久化
@@ -5868,19 +3923,11 @@ class PracticeRecorder {
this.autoSaveInterval = 30000; // 30秒自动保存
this.autoSaveTimer = null;
- // 初始化存储系统
- this.scoreStorage = new ScoreStorage();
- this.repositories = window.dataRepositories;
- if (!this.repositories) {
- throw new Error('数据仓库未初始化,PracticeRecorder 无法构建');
- }
- this.metaRepo = this.repositories.meta;
-
this.practiceTypeCache = new Map();
// 异步初始化
this.ready = (async () => {
- await this.scoreStorage.ready;
+ await window.AppData.ready;
await this.initialize();
})();
@@ -5915,6 +3962,72 @@ class PracticeRecorder {
throw new Error(`PracticeRecorder requires PracticeCore.contracts.${name}`);
}
+ clonePlainObject(value) {
+ const coreContracts = this.getCoreContracts();
+ if (coreContracts && typeof coreContracts.clonePlainObject === 'function') {
+ return coreContracts.clonePlainObject(value);
+ }
+ if (value == null || typeof value !== 'object') {
+ return value ?? null;
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => this.clonePlainObject(item));
+ }
+ const clone = {};
+ Object.keys(value).forEach((key) => {
+ clone[key] = this.clonePlainObject(value[key]);
+ });
+ return clone;
+ }
+
+ activeSessionEntityId(sessionOrId) {
+ const rawId = sessionOrId && typeof sessionOrId === 'object'
+ ? (sessionOrId.id || sessionOrId.sessionId)
+ : sessionOrId;
+ const normalized = String(rawId || '').trim();
+ if (!normalized) {
+ throw new Error('Active practice session requires a stable session id');
+ }
+ return normalized.startsWith('active-session:') ? normalized : `active-session:${normalized}`;
+ }
+
+ async persistActiveSession(session, previousEntityId = null) {
+ const entity = Object.assign({}, session, { id: this.activeSessionEntityId(session) });
+ const receipt = await window.AppData.recovery.saveActiveSession(entity);
+ if (previousEntityId && previousEntityId !== entity.id) {
+ await window.AppData.recovery.discardActiveSession(previousEntityId);
+ }
+ return receipt;
+ }
+
+ resolveAnnotationState(recordData = {}, fallbackSources = []) {
+ const coreContracts = this.getCoreContracts();
+ if (coreContracts && typeof coreContracts.resolveAnnotationState === 'function') {
+ return coreContracts.resolveAnnotationState(recordData, fallbackSources);
+ }
+ const root = recordData && typeof recordData === 'object' ? recordData : {};
+ const sources = [root, root.rawData, root.realData, root.rawData?.realData]
+ .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources])
+ .filter((source) => source && typeof source === 'object' && !Array.isArray(source));
+ const pickArray = (field) => {
+ const source = sources.find((candidate) => Array.isArray(candidate[field]));
+ return source ? this.clonePlainObject(source[field]) : [];
+ };
+ const pickString = (field) => {
+ const source = sources.find((candidate) => typeof candidate[field] === 'string');
+ return source ? source[field] : '';
+ };
+ const scrollSource = sources.find((candidate) => candidate.scrollY != null && Number.isFinite(Number(candidate.scrollY)));
+ return {
+ highlights: pickArray('highlights'),
+ markedQuestions: pickArray('markedQuestions'),
+ noteText: pickString('noteText'),
+ notes: pickArray('notes'),
+ noteOutlines: pickArray('noteOutlines'),
+ scrollY: scrollSource ? Number(scrollSource.scrollY) : 0
+ };
+ }
+
firstFiniteNumber(fallback, ...values) {
for (const value of values) {
if (value === undefined || value === null) {
@@ -5965,8 +4078,6 @@ class PracticeRecorder {
async recordRejectedCompletionPayload(payload, context = {}) {
try {
- const existing = await this.metaRepo.get('rejected_completion_payloads', []);
- const list = Array.isArray(existing) ? existing : [];
const snapshot = {
id: `rejected_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
@@ -5983,54 +4094,33 @@ class PracticeRecorder {
}
: null
};
- list.unshift(snapshot);
- if (list.length > 50) {
- list.splice(50);
+ await window.AppData.recovery.saveRejectedCompletion(snapshot);
+ const existing = await window.AppData.recovery.listRejectedCompletions();
+ const list = (Array.isArray(existing) ? existing : [])
+ .slice()
+ .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0));
+ for (const stale of list.slice(50)) {
+ await window.AppData.recovery.discardRejectedCompletion(stale.id || stale.sessionId || stale.recordId);
}
- await this.metaRepo.set('rejected_completion_payloads', list);
} catch (error) {
console.warn('[PracticeRecorder] 记录拒绝的完成负载失败:', error);
}
}
- lookupExamIndexEntry(examId) {
+ lookupExamIndexEntry(examId, examIndex = []) {
if (!examId) return null;
- if (this.practiceTypeCache.has(examId)) {
- return this.practiceTypeCache.get(examId);
- }
-
- const sources = [
- () => Array.isArray(window.examIndex) ? window.examIndex : null,
- () => typeof window.getReadingExamIndex === 'function'
- ? window.getReadingExamIndex().map(exam => ({ ...exam, type: exam.type || 'reading' }))
- : null,
- () => Array.isArray(window.__READING_EXAM_INDEX__)
- ? window.__READING_EXAM_INDEX__.map(exam => ({ ...exam, type: exam.type || 'reading' }))
- : null,
- () => Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : null
- ];
-
- for (const getSource of sources) {
- const list = getSource();
- if (Array.isArray(list)) {
- const entry = list.find(item => item && item.id === examId);
- if (entry) {
- this.practiceTypeCache.set(examId, entry);
- return entry;
- }
- }
- }
-
- this.practiceTypeCache.set(examId, null);
- return null;
+ const entry = (Array.isArray(examIndex) ? examIndex : [])
+ .find(item => item && item.id === examId) || null;
+ if (entry) this.practiceTypeCache.set(examId, entry);
+ return entry;
}
resolvePracticeType(session = {}, examEntry = null) {
const examId = session.examId;
const metadata = session.metadata || {};
const cachedEntry = this.practiceTypeCache.get(examId);
- const entry = examEntry || cachedEntry || this.lookupExamIndexEntry(examId);
+ const entry = examEntry || cachedEntry || null;
const normalized = this.normalizePracticeType(
metadata.type
@@ -6186,12 +4276,16 @@ class PracticeRecorder {
* 恢复活动会话
*/
async restoreActiveSessions() {
- const raw = await this.metaRepo.get('active_sessions', []);
+ const raw = await window.AppData.recovery.listActiveSessions();
const storedSessions = Array.isArray(raw) ? raw : [];
- storedSessions.forEach(sessionData => {
+ storedSessions
+ .slice()
+ .sort((left, right) => Date.parse(left.updatedAt || left.lastActivity || 0) - Date.parse(right.updatedAt || right.lastActivity || 0))
+ .forEach(sessionData => {
this.activeSessions.set(sessionData.examId, {
...sessionData,
+ id: this.activeSessionEntityId(sessionData),
status: 'restored',
lastActivity: new Date().toISOString()
});
@@ -6227,6 +4321,13 @@ class PracticeRecorder {
}
const { type, data } = normalized;
+ // Completion persistence belongs exclusively to the exam host protocol. The
+ // recorder is invoked there only after source/origin/token validation, so a
+ // second global listener must never race it into a duplicate save.
+ if (type === 'session_completed') {
+ return;
+ }
+
switch (type) {
case 'session_started':
this.handleSessionStarted(data);
@@ -6234,11 +4335,6 @@ class PracticeRecorder {
case 'session_progress':
this.handleSessionProgress(data);
break;
- case 'session_completed':
- this.handleSessionCompleted(data).catch(error => {
- console.error('[PracticeRecorder] 会话完成处理失败:', error);
- });
- break;
case 'session_paused':
this.handleSessionPaused(data);
break;
@@ -6340,18 +4436,7 @@ class PracticeRecorder {
normalizedComparison
);
const answerList = this.convertAnswerMapToArray(answerMap, correctAnswerMap);
- const highlights = Array.isArray(payload.highlights)
- ? payload.highlights.slice()
- : (Array.isArray(payload.realData?.highlights) ? payload.realData.highlights.slice() : []);
- const markedQuestions = Array.isArray(payload.markedQuestions)
- ? payload.markedQuestions.slice()
- : (Array.isArray(payload.realData?.markedQuestions) ? payload.realData.markedQuestions.slice() : []);
- const scrollY = Number.isFinite(Number(payload.scrollY))
- ? Number(payload.scrollY)
- : (Number.isFinite(Number(payload.realData?.scrollY)) ? Number(payload.realData.scrollY) : 0);
- const noteText = typeof payload.noteText === 'string'
- ? payload.noteText
- : (typeof payload.realData?.noteText === 'string' ? payload.realData.noteText : '');
+ const annotations = this.resolveAnnotationState(payload);
const questionTypeMap = payload.questionTypeMap && typeof payload.questionTypeMap === 'object'
? { ...payload.questionTypeMap }
: (payload.realData?.questionTypeMap && typeof payload.realData.questionTypeMap === 'object'
@@ -6409,15 +4494,12 @@ class PracticeRecorder {
answerComparison: normalizedComparison,
questionTypePerformance: payload.questionTypePerformance || {},
interactions: payload.interactions || [],
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...annotations,
questionTypeMap,
startTime: payload.startTime || null,
endTime: payload.endTime || null,
metadata: Object.assign({}, payload.metadata || {}, {
- markedQuestions: markedQuestions.slice()
+ markedQuestions: this.clonePlainObject(annotations.markedQuestions)
}),
source: scoreInfo.source || payload.pageType || 'practice_page',
realData: Object.assign({}, payload.realData || {}, {
@@ -6425,10 +4507,7 @@ class PracticeRecorder {
correctAnswers: correctAnswerMap,
correctAnswerMap,
answerComparison: normalizedComparison,
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...this.clonePlainObject(annotations),
questionTypeMap,
scoreInfo: Object.assign({}, scoreInfo, { details: answerDetails })
})
@@ -6546,35 +4625,61 @@ class PracticeRecorder {
* 开始练习会话
*/
startPracticeSession(examId, examData = {}) {
- const sessionId = this.generateSessionId();
- const startTime = new Date().toISOString();
+ const requestedSessionId = examData && examData.sessionId != null
+ ? String(examData.sessionId).trim()
+ : '';
+ const existing = this.activeSessions.has(examId)
+ ? this.activeSessions.get(examId)
+ : null;
+ // Prefer an explicit host session id so INIT/COMPLETE and the recorder share one
+ // identity. Reuse an existing active session when the host rebinds the same exam.
+ const sessionId = requestedSessionId
+ || (existing && existing.sessionId)
+ || this.generateSessionId(examId);
+ const startTime = (existing && existing.startTime)
+ || new Date().toISOString();
+ const previousEntityId = existing
+ ? this.activeSessionEntityId(existing)
+ : null;
const sessionData = {
+ id: this.activeSessionEntityId(sessionId),
sessionId,
examId,
startTime,
- lastActivity: startTime,
- status: 'started',
- progress: {
+ lastActivity: new Date().toISOString(),
+ status: existing ? (existing.status || 'started') : 'started',
+ progress: Object.assign({
currentQuestion: 0,
totalQuestions: examData.totalQuestions || 0,
answeredQuestions: 0,
timeSpent: 0
- },
- answers: [],
- metadata: {
+ }, existing && existing.progress ? existing.progress : {}),
+ answers: existing && existing.answers ? existing.answers : [],
+ metadata: Object.assign({
examTitle: examData.title || '',
category: examData.category || '',
frequency: examData.frequency || '',
userAgent: navigator.userAgent,
screenResolution: `${screen.width}x${screen.height}`,
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
- }
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ // 启动时捕获的题库配置 ID:mixin/调用方传入则写进会话 metadata,后续经
+ // handleSessionCompleted 的 buildRecordMetadata 透传到记录 metadata。
+ libraryConfigurationId: (examData && examData.libraryConfigurationId != null)
+ ? examData.libraryConfigurationId
+ : null
+ }, existing && existing.metadata ? existing.metadata : {})
};
+ if (examData && examData.libraryConfigurationId != null) {
+ sessionData.metadata.libraryConfigurationId = examData.libraryConfigurationId;
+ }
+ if (examData && examData.title) {
+ sessionData.metadata.examTitle = examData.title;
+ }
// 存储会话
this.activeSessions.set(examId, sessionData);
- this.saveActiveSessions().catch(error => {
+ this.persistActiveSession(sessionData, previousEntityId).catch(error => {
console.error('[PracticeRecorder] 保存活动会话失败:', error);
});
@@ -6593,25 +4698,55 @@ class PracticeRecorder {
* 处理会话开始
*/
handleSessionStarted(data) {
- const { examId, sessionId, metadata } = data;
+ const examId = data && data.examId != null ? String(data.examId).trim() : '';
+ const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : '';
+ const metadata = data && data.metadata && typeof data.metadata === 'object'
+ ? data.metadata
+ : null;
- if (this.activeSessions.has(examId)) {
- let session = this.activeSessions.get(examId);
- session.sessionId = sessionId;
- session.status = 'active';
- session.lastActivity = new Date().toISOString();
+ if (!examId || !sessionId) {
+ return;
+ }
- if (metadata) {
- session.metadata = { ...session.metadata, ...metadata };
+ // Host handshake (SESSION_READY / INIT rebind) must create the active session when
+ // the full PracticeRecorder was hot-upgraded after a fallback start, or when the
+ // early startPracticeSession raced ahead of the host expectedSessionId.
+ if (!this.activeSessions.has(examId)) {
+ this.startPracticeSession(examId, Object.assign({}, metadata || {}, {
+ sessionId,
+ title: metadata && (metadata.title || metadata.examTitle) || '',
+ category: metadata && metadata.category || '',
+ frequency: metadata && metadata.frequency || '',
+ libraryConfigurationId: metadata && metadata.libraryConfigurationId != null
+ ? metadata.libraryConfigurationId
+ : null
+ }));
+ const created = this.activeSessions.get(examId);
+ if (created) {
+ created.status = 'active';
+ this.activeSessions.set(examId, created);
}
+ console.log(`Session created on host confirm: ${examId}`);
+ return;
+ }
- this.activeSessions.set(examId, session);
- this.saveActiveSessions().catch(error => {
- console.error('[PracticeRecorder] 保存活动会话失败:', error);
- });
+ let session = this.activeSessions.get(examId);
+ const previousEntityId = this.activeSessionEntityId(session);
+ session.sessionId = sessionId;
+ session.id = this.activeSessionEntityId(sessionId);
+ session.status = 'active';
+ session.lastActivity = new Date().toISOString();
- console.log(`Session confirmed started: ${examId}`);
+ if (metadata) {
+ session.metadata = { ...session.metadata, ...metadata };
}
+
+ this.activeSessions.set(examId, session);
+ this.persistActiveSession(session, previousEntityId).catch(error => {
+ console.error('[PracticeRecorder] 保存活动会话失败:', error);
+ });
+
+ console.log(`Session confirmed started: ${examId}`);
}
/**
@@ -6649,6 +4784,7 @@ class PracticeRecorder {
}
const { results } = payload;
+ const examIndex = await window.resolveActiveLibraryIndex();
const candidateExamIds = [
payload.examId,
payload.originalExamId,
@@ -6723,9 +4859,9 @@ class PracticeRecorder {
session.startTime = resolvedStartTime;
- const examEntry = this.lookupExamIndexEntry(resolvedExamId)
- || this.lookupExamIndexEntry(payload.originalExamId)
- || this.lookupExamIndexEntry(payload.derivedExamId);
+ const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex)
+ || this.lookupExamIndexEntry(payload.originalExamId, examIndex)
+ || this.lookupExamIndexEntry(payload.derivedExamId, examIndex);
const type = this.resolvePracticeType({ ...session, examId: resolvedExamId }, examEntry);
const recordDate = this.resolveRecordDate({ ...session, endTime: resolvedEndTime }, resolvedEndTime);
let metadata = this.buildRecordMetadata(
@@ -6805,6 +4941,8 @@ class PracticeRecorder {
results?.accuracy,
scoreInfo.accuracy
);
+ const annotations = this.resolveAnnotationState(results || {}, [session || {}]);
+ metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions);
const practiceRecord = {
id: `record_${session.sessionId || this.generateSessionId(resolvedExamId)}`,
@@ -6826,6 +4964,7 @@ class PracticeRecorder {
correctAnswerMap,
scoreInfo,
questionTypePerformance: results?.questionTypePerformance || {},
+ ...annotations,
metadata,
suiteSessionId,
createdAt: resolvedEndTime,
@@ -6836,7 +4975,8 @@ class PracticeRecorder {
scoreInfo,
interactions: results?.interactions || [],
isRealData: true,
- source: results?.source || 'practice_page'
+ source: results?.source || 'practice_page',
+ ...this.clonePlainObject(annotations)
})
};
@@ -6863,7 +5003,7 @@ class PracticeRecorder {
}
try {
- const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord;
+ const savedRecord = await this.savePracticeRecord(practiceRecord);
if (!syntheticSession && this.activeSessions.has(resolvedExamId)) {
this.endPracticeSession(resolvedExamId);
@@ -6876,11 +5016,13 @@ class PracticeRecorder {
return savedRecord;
} catch (error) {
console.error('[PracticeRecorder] 处理完成会话时出错:', error);
- await this.saveToTemporaryStorage(practiceRecord);
- if (!syntheticSession && this.activeSessions.has(resolvedExamId)) {
- this.endPracticeSession(resolvedExamId, 'save_failed');
+ try {
+ await this.saveToTemporaryStorage(practiceRecord);
+ } catch (recoveryError) {
+ console.error('[PracticeRecorder] canonical 与 recovery 提交均失败:', recoveryError);
+ error.recoveryError = recoveryError;
}
- return practiceRecord;
+ throw error;
}
}
@@ -6990,6 +5132,7 @@ class PracticeRecorder {
if (!this.activeSessions.has(examId)) return;
let session = this.activeSessions.get(examId);
+ const sessionEntityId = this.activeSessionEntityId(session);
// 如果会话未完成,创建中断记录
if (reason !== 'completed' && session.status !== 'completed') {
@@ -7019,8 +5162,8 @@ class PracticeRecorder {
// 清理会话
this.activeSessions.delete(examId);
this.cleanupSessionListener(examId);
- this.saveActiveSessions().catch(error => {
- console.error('[PracticeRecorder] 保存活动会话失败:', error);
+ window.AppData.recovery.discardActiveSession(sessionEntityId).catch(error => {
+ console.error('[PracticeRecorder] 清理活动会话失败:', error);
});
console.log(`Practice session ended: ${examId} (${reason})`);
@@ -7097,59 +5240,49 @@ class PracticeRecorder {
* 保存所有会话
*/
async saveAllSessions() {
- try {
- await this.saveActiveSessions();
- console.log('Auto-saved all active sessions');
- } catch (error) {
- console.error('[PracticeRecorder] 保存活动会话失败:', error);
- }
+ await this.saveActiveSessions();
+ console.log('Auto-saved all active sessions');
}
/**
* 保存活动会话到存储
*/
async saveActiveSessions() {
- const sessionsArray = Array.from(this.activeSessions.values());
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') {
- await practiceCoreStore.writeMeta('active_sessions', sessionsArray);
- return;
+ for (const session of this.activeSessions.values()) {
+ await this.persistActiveSession(session);
}
- await this.metaRepo.set('active_sessions', sessionsArray);
}
/**
* 保存练习记录
*/
- async savePracticeRecord(record) {
+ async savePracticeRecord(record, options = {}) {
const maxRetries = 3;
const storageReadyRecord = this.prepareRecordForStorage(record);
+ const saveOperationId = storageReadyRecord.operationId || this.generateOperationId('practice-complete');
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`[PracticeRecorder] 开始保存练习记录(尝试 ${attempt}/${maxRetries}):`, record.id);
- const practiceRecordApi = window.PracticeRecordAPI;
- if (!practiceRecordApi || typeof practiceRecordApi.saveRecord !== 'function') {
- throw new Error('PracticeRecordAPI not available');
- }
-
- const savedRawRecord = await practiceRecordApi.saveRecord(storageReadyRecord, {
- updateStats: true
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: storageReadyRecord,
+ operationId: saveOperationId
});
+ const savedRawRecord = receipt.record;
const savedRecord = this.restoreRecordAnswerState(savedRawRecord, record);
- console.log(`[PracticeRecorder] PracticeRecordAPI 保存成功: ${savedRecord.id}`);
+ console.log(`[PracticeRecorder] AppData.practice 保存成功: ${savedRecord.id}`);
const verified = await this.verifyRecordSaved(savedRecord.id);
if (!verified) {
- console.warn('[PracticeRecorder] PracticeRecordAPI 保存后未立即检出,稍后将由同步任务纠正');
+ console.warn('[PracticeRecorder] AppData.practice 保存后未立即检出,稍后将由同步任务纠正');
} else {
console.log('[PracticeRecorder] 记录保存验证成功');
}
return savedRecord;
} catch (error) {
console.error(
- `[PracticeRecorder] PracticeRecordAPI 保存失败 (尝试 ${attempt}):`,
+ `[PracticeRecorder] AppData.practice 保存失败 (尝试 ${attempt}):`,
{
error: error?.message,
validationErrors: error?.validationErrors || null,
@@ -7159,7 +5292,7 @@ class PracticeRecorder {
);
if (attempt === maxRetries || this.isCriticalError(error)) {
- return await this.retrySaveWithStandardizedRecord(record);
+ return await this.retrySaveWithStandardizedRecord(record, saveOperationId);
}
const delay = attempt * 100;
@@ -7168,46 +5301,43 @@ class PracticeRecorder {
}
}
- return await this.retrySaveWithStandardizedRecord(record);
+ return await this.retrySaveWithStandardizedRecord(record, saveOperationId);
}
/**
* 用标准化后的 payload 再走统一 API 保存。
*/
- async retrySaveWithStandardizedRecord(record) {
+ async retrySaveWithStandardizedRecord(record, operationId = null) {
try {
console.log('[PracticeRecorder] 使用标准化记录重试保存');
- const standardizedRecord = this.normalizeRecordForPracticeRecordApi(record);
- const practiceRecordApi = window.PracticeRecordAPI;
- if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') {
- return await practiceRecordApi.saveRecord(standardizedRecord, {
- updateStats: true
- });
- }
-
- throw new Error('PracticeRecordAPI unavailable');
+ const examIndex = await window.resolveActiveLibraryIndex();
+ const standardizedRecord = this.normalizeRecordForAppData(record, examIndex);
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: standardizedRecord,
+ operationId: operationId || standardizedRecord.operationId || this.generateOperationId('practice-complete')
+ });
+ return receipt.record;
} catch (error) {
console.error('[PracticeRecorder] 标准化重试保存失败:', {
error: error?.message,
validationErrors: error?.validationErrors || null,
recordSummary: this.buildRecordLogSummary(record)
}, error);
- await this.saveToTemporaryStorage(record);
- throw new Error(`All save methods failed: ${error.message}`);
+ throw error;
}
}
/**
* 标准化记录格式(用于统一 API 重试保存)。
*/
- normalizeRecordForPracticeRecordApi(recordData) {
+ normalizeRecordForAppData(recordData, examIndex = []) {
const now = new Date().toISOString();
const resolvedExamId = this.inferExamId(recordData);
const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime())
? new Date(recordData.endTime).toISOString()
: now;
- const examEntry = this.lookupExamIndexEntry(resolvedExamId);
+ const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex);
const inferredType = this.normalizePracticeType(
recordData.type
|| recordData.metadata?.type
@@ -7267,6 +5397,8 @@ class PracticeRecorder {
recordData.realData?.scoreInfo?.score,
recordData.score
);
+ const annotations = this.resolveAnnotationState(recordData, [recordData.metadata || {}]);
+ metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions);
return {
// 基础信息
@@ -7296,11 +5428,13 @@ class PracticeRecorder {
correctAnswerMap,
scoreInfo: Object.assign({}, recordData.scoreInfo || {}, { details: answerDetails }),
questionTypePerformance: recordData.questionTypePerformance || {},
+ ...annotations,
realData: Object.assign({}, recordData.realData || {}, {
answers: answerMap,
correctAnswers: correctAnswerMap,
correctAnswerMap,
- scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails })
+ scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails }),
+ ...this.clonePlainObject(annotations)
}),
// 元数据
@@ -7318,17 +5452,7 @@ class PracticeRecorder {
*/
async verifyRecordSaved(recordId) {
try {
- const practiceRecordApi = window.PracticeRecordAPI;
- if (practiceRecordApi && typeof practiceRecordApi.getById === 'function') {
- const record = await practiceRecordApi.getById(recordId);
- return !!record;
- }
- if (practiceRecordApi && typeof practiceRecordApi.list === 'function') {
- const records = await practiceRecordApi.list();
- const list = Array.isArray(records) ? records : [];
- return list.some(r => r && (r.id === recordId || r.sessionId === recordId));
- }
- return false;
+ return Boolean(await window.AppData.practice.get(recordId, { projection: 'light' }));
} catch (error) {
console.error('[PracticeRecorder] 验证记录保存时出错', error);
return false;
@@ -7390,11 +5514,23 @@ class PracticeRecorder {
this.convertComparisonToAnswerMap(record.answerComparison || record.realData?.answerComparison, 'userAnswer')
);
const correctMap = this.resolveRecordCorrectAnswerMap(record);
+ const annotations = this.resolveAnnotationState(record, [record.metadata || {}]);
const answerList = this.convertAnswerMapToArray(answerMap, correctMap);
clone.answerList = answerList;
- clone.answers = answerList;
+ // AppData v2 stores canonical answer maps in the detail entity. Converting
+ // `answers` to the legacy array shape here makes persisted review records
+ // unreadable to consumers that intentionally accept maps only.
+ clone.answers = answerMap;
clone.correctAnswerMap = correctMap;
+ clone.questionTypeMap = this.clonePlainObject(
+ record.questionTypeMap || record.realData?.questionTypeMap || {}
+ );
+ clone.interactions = this.clonePlainObject(
+ Array.isArray(record.interactions)
+ ? record.interactions
+ : (Array.isArray(record.realData?.interactions) ? record.realData.interactions : [])
+ );
clone.answerDetails = this.buildCanonicalAnswerDetails(
answerMap,
correctMap,
@@ -7404,6 +5540,10 @@ class PracticeRecorder {
record.answerComparison || record.realData?.answerComparison
);
clone.scoreInfo = Object.assign({}, clone.scoreInfo || {}, { details: clone.answerDetails });
+ Object.assign(clone, this.clonePlainObject(annotations));
+ clone.metadata = Object.assign({}, clone.metadata || {}, {
+ markedQuestions: this.clonePlainObject(annotations.markedQuestions)
+ });
if (clone.answerComparison) {
clone.answerComparison = this.normalizeAnswerComparison(clone.answerComparison);
@@ -7413,7 +5553,8 @@ class PracticeRecorder {
answers: answerMap,
correctAnswers: correctMap,
correctAnswerMap: correctMap,
- scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails })
+ scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails }),
+ ...this.clonePlainObject(annotations)
});
if (clone.realData.answerComparison) {
clone.realData.answerComparison = this.normalizeAnswerComparison(clone.realData.answerComparison);
@@ -7460,6 +5601,9 @@ class PracticeRecorder {
correctAnswerMap: clone.correctAnswerMap,
scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details })
});
+ const annotations = this.resolveAnnotationState(clone, [sourceRecord || {}]);
+ Object.assign(clone, this.clonePlainObject(annotations));
+ clone.realData = Object.assign({}, clone.realData, this.clonePlainObject(annotations));
return clone;
}
@@ -7480,42 +5624,43 @@ class PracticeRecorder {
* 保存到临时存储
*/
async saveToTemporaryStorage(record) {
- try {
- const existing = await this.metaRepo.get('temp_practice_records', []);
- const tempRecords = Array.isArray(existing) ? [...existing] : [];
- tempRecords.push({
- ...record,
- tempSavedAt: new Date().toISOString(),
- needsRecovery: true
- });
-
- // 限制临时记录数量
- const finalTempRecords = tempRecords.length > 50 ? tempRecords.slice(-50) : tempRecords;
+ const recordId = String(record && (record.id || record.sessionId) || `record-${Date.now()}`);
+ const receipt = await window.AppData.recovery.saveDraft({
+ id: `practice-record:${recordId}`,
+ recordId,
+ kind: 'practice_record_recovery',
+ record: this.clonePlainObject(record),
+ tempSavedAt: new Date().toISOString(),
+ needsRecovery: true
+ });
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') {
- await practiceCoreStore.writeMeta('temp_practice_records', finalTempRecords);
- } else {
- await this.metaRepo.set('temp_practice_records', finalTempRecords);
+ try {
+ const drafts = await window.AppData.recovery.listDrafts();
+ const recoveryDrafts = (Array.isArray(drafts) ? drafts : [])
+ .filter((draft) => draft && draft.kind === 'practice_record_recovery')
+ .sort((left, right) => Date.parse(left.updatedAt || left.tempSavedAt || 0) - Date.parse(right.updatedAt || right.tempSavedAt || 0));
+ for (const stale of recoveryDrafts.slice(0, Math.max(0, recoveryDrafts.length - 50))) {
+ await window.AppData.recovery.discardDraft(stale.id);
}
- console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id);
-
} catch (error) {
- console.error('[PracticeRecorder] 临时存储也失败', error);
+ console.warn('[PracticeRecorder] recovery 草稿清理失败,不影响已提交草稿:', error);
}
+ console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id);
+ return receipt;
}
/**
* 保存中断记录
*/
async saveInterruptedRecord(record) {
- const existing = await this.metaRepo.get('interrupted_records', []);
- const records = Array.isArray(existing) ? [...existing] : [];
- records.push(record);
-
- const finalRecords = records.length > 100 ? records.slice(-100) : records;
-
- await this.metaRepo.set('interrupted_records', finalRecords);
+ await window.AppData.recovery.saveInterrupted(record);
+ const existing = await window.AppData.recovery.listInterrupted();
+ const records = (Array.isArray(existing) ? existing : [])
+ .slice()
+ .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0));
+ for (const stale of records.slice(100)) {
+ await window.AppData.recovery.discardInterrupted(stale.id || stale.sessionId || stale.recordId);
+ }
console.log(`Interrupted record saved: ${record.id}`);
}
@@ -7523,33 +5668,12 @@ class PracticeRecorder {
* 更新用户统计
*/
async updateUserStats(practiceRecord) {
- if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.recalculateStats !== 'function') {
- throw new Error('PracticeRecordAPI.recalculateStats unavailable');
- }
- await window.PracticeRecordAPI.recalculateStats();
- console.log('User stats recalculated through PracticeRecordAPI');
+ await window.AppData.practice.getStats();
}
async listPracticeRecordsForStats() {
- // 统计读取只需元数据字段,使用轻量 listSummary 避免反序列化+克隆完整记录
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') {
- try {
- const records = await window.PracticeRecordAPI.listSummary();
- return Array.isArray(records) ? records : [];
- } catch (error) {
- console.warn('[PracticeRecorder] PracticeRecordAPI.listSummary 统计读取失败:', error);
- }
- }
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- try {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- } catch (error) {
- console.warn('[PracticeRecorder] PracticeRecordAPI.list 统计读取失败:', error);
- }
- }
-
- return [];
+ const records = await window.AppData.practice.list({ projection: 'light' });
+ return Array.isArray(records) ? records : [];
}
/**
@@ -7564,11 +5688,10 @@ class PracticeRecorder {
*/
async getPracticeRecords(filters = {}) {
try {
- const practiceRecordApi = window.PracticeRecordAPI;
- if (!practiceRecordApi || typeof practiceRecordApi.list !== 'function') {
- return [];
- }
- const records = await practiceRecordApi.list();
+ // 过滤条件(examId/metadata.category/startTime/date/accuracy)与唯一内部消费者
+ // getDataIntegrityReport -> validateRecordIntegrity(id/examId/startTime/endTime/accuracy/duration)
+ // 都在 light 投影覆盖范围内,不需要拉取答题详情。
+ const records = await window.AppData.practice.list({ projection: 'light' });
const list = Array.isArray(records) ? records : [];
if (Object.keys(filters).length === 0) {
return list;
@@ -7584,15 +5707,12 @@ class PracticeRecorder {
return true;
});
} catch (error) {
- console.error('Failed to get practice records from PracticeRecordAPI:', error);
+ console.error('Failed to get practice records from AppData.practice:', error);
return [];
}
}
getDefaultUserStats() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getDefaultStats === 'function') {
- return window.PracticeRecordAPI.getDefaultStats();
- }
return {
totalPractices: 0,
totalTimeSpent: 0,
@@ -7606,13 +5726,6 @@ class PracticeRecorder {
};
}
- getUnifiedBackupManager() {
- if (window.DataBackupManager) {
- return new window.DataBackupManager();
- }
- throw new Error('DataBackupManager unavailable');
- }
-
convertRecordsToCSV(records) {
const list = Array.isArray(records) ? records : [];
if (list.length === 0) return '';
@@ -7648,10 +5761,7 @@ class PracticeRecorder {
* 获取用户统计
*/
async getUserStats() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- return await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() });
- }
- return this.getDefaultUserStats();
+ return Object.assign(this.getDefaultUserStats(), await window.AppData.practice.getStats());
}
/**
@@ -7659,47 +5769,53 @@ class PracticeRecorder {
*/
async exportData(format = 'json') {
const normalizedFormat = String(format || 'json').toLowerCase();
- // CSV 导出只需元数据字段,使用轻量 listSummary 避免加载完整记录
- const records = normalizedFormat === 'csv'
- ? await this.listPracticeRecordsForStats()
- : await this.getPracticeRecords();
if (normalizedFormat === 'csv') {
+ const records = await this.listPracticeRecordsForStats();
return this.convertRecordsToCSV(records);
}
if (normalizedFormat !== 'json') {
throw new Error(`Unsupported export format: ${format}`);
}
- return JSON.stringify({
- exportDate: new Date().toISOString(),
- version: PRACTICE_RECORDER_EXPORT_VERSION,
- practiceRecords: records,
- userStats: await this.getUserStats()
- }, null, 2);
+ const snapshot = await window.AppData.backups.export({ domains: ['practice'] });
+ return JSON.stringify(snapshot, null, 2);
}
/**
* 导入练习数据
*/
- importData(data, options = {}) {
- const manager = this.getUnifiedBackupManager();
+ async importData(data, options = {}) {
const mergeMode = options.merge === false || options.mergeMode === 'replace'
? 'replace'
: (options.mergeMode || 'merge');
- return manager.importPracticeData(data, Object.assign({}, options, { mergeMode }));
+ const backup = options.createBackup === false
+ ? null
+ : await window.AppData.backups.create({ type: 'pre-import' });
+ const payload = Array.isArray(data) ? { records: data } : data;
+ const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mergeMode });
+ const receipt = await window.AppData.backups.commitImport(preview.id, {
+ operationId: options.operationId,
+ confirmDestructive: mergeMode === 'replace'
+ });
+ try {
+ await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup && backup.id, practice: preview.practice });
+ } catch (historyError) {
+ console.warn('[PracticeRecorder] 导入已提交,但历史记录写入失败:', historyError);
+ }
+ return Object.assign({}, receipt, { backupId: backup && backup.id });
}
/**
* 创建数据备份
*/
createBackup(backupName = null) {
- return this.getUnifiedBackupManager().createBackup(backupName, 'practice_recorder');
+ return window.AppData.backups.create({ id: backupName || undefined, type: 'practice-recorder' });
}
/**
* 恢复数据备份
*/
restoreBackup(backupId) {
- return this.getUnifiedBackupManager().restoreBackup(backupId);
+ return window.AppData.backups.restore(backupId);
}
/**
@@ -7707,10 +5823,7 @@ class PracticeRecorder {
*/
getBackups() {
try {
- if (window.BackupAPI && typeof window.BackupAPI.list === 'function') {
- return window.BackupAPI.list();
- }
- return this.scoreStorage.getBackups();
+ return window.AppData.backups.list();
} catch (error) {
console.error('Failed to get backups:', error);
return [];
@@ -7722,7 +5835,7 @@ class PracticeRecorder {
*/
getStorageStats() {
try {
- return this.scoreStorage.getStorageStats();
+ return window.AppData.status();
} catch (error) {
console.error('Failed to get storage stats:', error);
return null;
@@ -7730,17 +5843,32 @@ class PracticeRecorder {
}
generateRecordId() {
- if (this.scoreStorage && typeof this.scoreStorage.generateRecordId === 'function') {
- return this.scoreStorage.generateRecordId();
- }
return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
+ generateOperationId(prefix = 'operation') {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return `${prefix}_${window.crypto.randomUUID()}`;
+ }
+ } catch (_) {
+ // fall through to timestamp entropy
+ }
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 12)}`;
+ }
+
/**
- * 生成会话ID
+ * 生成会话ID(可选带 examId 前缀,便于与宿主 expectedSessionId 对齐)
*/
- generateSessionId() {
- return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ generateSessionId(examId) {
+ const suffix = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ const normalizedExamId = typeof examId === 'string'
+ ? examId.trim().replace(/\s+/g, '-')
+ : (examId != null ? String(examId).trim().replace(/\s+/g, '-') : '');
+ if (normalizedExamId) {
+ return `${normalizedExamId}_${suffix}`;
+ }
+ return `session_${suffix}`;
}
extractExamIdFromRecordId(recordId) {
@@ -7790,8 +5918,8 @@ class PracticeRecorder {
}
// 获取题目信息
- const examIndex = await this.metaRepo.get('exam_index', []);
- const examList = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ const examIndex = await window.resolveActiveLibraryIndex();
+ const examList = Array.isArray(examIndex) ? examIndex : [];
const exam = examList.find(e => e.id === examId);
if (!exam) {
@@ -7802,12 +5930,11 @@ class PracticeRecorder {
// 构造增强的练习记录
const practiceRecord = this.createRealPracticeRecord(exam, validatedData);
- // 保存记录 - 这里ScoreStorage会自动更新用户统计
- const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord;
+ // AppData 在权威提交后调度统计投影。
+ const savedRecord = await this.savePracticeRecord(practiceRecord);
// 清理活动会话
- this.activeSessions.delete(examId);
- await this.saveActiveSessions();
+ this.endPracticeSession(examId);
// 触发完成事件
this.dispatchSessionEvent('realDataProcessed', {
@@ -7912,13 +6039,10 @@ class PracticeRecorder {
);
const totalQuestions = scoreInfo.total || Object.keys(correctAnswerMap).length || Object.keys(answerMap).length;
const accuracy = scoreInfo.accuracy || (totalQuestions > 0 ? score / totalQuestions : 0);
- const highlights = Array.isArray(realData.highlights) ? realData.highlights.slice() : [];
- const markedQuestions = Array.isArray(realData.markedQuestions) ? realData.markedQuestions.slice() : [];
- const scrollY = Number.isFinite(Number(realData.scrollY)) ? Number(realData.scrollY) : 0;
- const noteText = typeof realData.noteText === 'string' ? realData.noteText : '';
+ const annotations = this.resolveAnnotationState(realData);
const practiceRecord = {
- // 基础信息 - 与ScoreStorage兼容
+ // 基础信息
id: recordId,
examId: exam.id,
sessionId: realData.sessionId,
@@ -7936,30 +6060,40 @@ class PracticeRecorder {
correctAnswers: score, // 正确答案数等于分数
accuracy: accuracy,
- // 答题详情 - 转换为ScoreStorage期望的格式
+ // 答题详情
answers: answerList,
correctAnswerMap,
answerComparison,
questionTypeMap,
questionTypePerformance: this.extractQuestionTypePerformance(realData),
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...annotations,
- // 元数据 - 与ScoreStorage兼容
+ // 元数据
metadata: {
examTitle: exam.title || '',
category: exam.category || '',
frequency: exam.frequency || '',
- markedQuestions: markedQuestions.slice(),
+ markedQuestions: this.clonePlainObject(annotations.markedQuestions),
collectionMethod: 'automatic',
dataQuality: this.assessDataQuality(realData),
- processingTime: Date.now()
+ processingTime: Date.now(),
+ // 启动时捕获的题库配置 ID:优先取 realData 与其 metadata 显式透传的值;
+ // 若上游未透传则显式写入 null(保留 key),让 AppData 记录 provenance
+ // 不再回退读取当前激活题库,避免记录来源在提交时被切换题库影响。
+ libraryConfigurationId: (realData
+ && realData.libraryConfigurationId !== undefined
+ && realData.libraryConfigurationId !== null)
+ ? realData.libraryConfigurationId
+ : (realData
+ && realData.metadata
+ && realData.metadata.libraryConfigurationId !== undefined
+ && realData.metadata.libraryConfigurationId !== null)
+ ? realData.metadata.libraryConfigurationId
+ : null
},
// 额外的真实数据信息
- realData: {
+ realData: Object.assign({}, realData, {
sessionId: realData.sessionId,
answers: answerMap,
correctAnswers: correctAnswerMap,
@@ -7968,15 +6102,12 @@ class PracticeRecorder {
questionTypeMap,
answerHistory: realData.answerHistory || {},
interactions: realData.interactions || [],
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...this.clonePlainObject(annotations),
scoreInfo: scoreInfo,
pageType: realData.pageType,
url: realData.url,
source: scoreInfo.source || 'data_collector'
- },
+ }),
// 系统信息
dataSource: 'real',
@@ -7988,7 +6119,7 @@ class PracticeRecorder {
}
/**
- * 转换答案格式为ScoreStorage兼容格式
+ * 转换答案格式为 canonical record 格式
*/
convertAnswersFormat(answers, correctAnswerMap = {}, answerComparison = {}, questionTypeMap = {}) {
if (!answers || typeof answers !== 'object') {
@@ -8183,7 +6314,7 @@ class PracticeRecorder {
sessionId: sessionId,
timestamp: Date.now()
}
- }, '*');
+ }, window.location.protocol === 'file:' ? '*' : window.location.origin);
}
}
@@ -8192,8 +6323,11 @@ class PracticeRecorder {
*/
async recoverTemporaryRecords() {
try {
- const tempRecords = await this.metaRepo.get('temp_practice_records', []);
- const list = Array.isArray(tempRecords) ? tempRecords : [];
+ const tempRecords = await window.AppData.recovery.listDrafts();
+ const list = (Array.isArray(tempRecords) ? tempRecords : []).filter((draft) => (
+ draft
+ && (draft.kind === 'practice_record_recovery' || draft.needsRecovery === true)
+ ));
if (list.length === 0) {
console.log('[PracticeRecorder] 没有需要恢复的临时记录');
@@ -8203,12 +6337,12 @@ class PracticeRecorder {
console.log(`[PracticeRecorder] 发现 ${list.length} 条临时记录,开始恢复`);
let recoveredCount = 0;
- const failedRecords = [];
-
for (const tempRecord of list) {
try {
- // 移除临时标识
- const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord;
+ const sourceRecord = tempRecord.record && typeof tempRecord.record === 'object'
+ ? tempRecord.record
+ : tempRecord;
+ const { tempSavedAt, needsRecovery, kind, ...cleanRecord } = sourceRecord;
const sanitized = this.sanitizeRecoveredRecord(cleanRecord);
if (!sanitized) {
console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord?.id);
@@ -8217,34 +6351,16 @@ class PracticeRecorder {
// 尝试正常保存
await this.savePracticeRecord(sanitized);
+ await window.AppData.recovery.discardDraft(tempRecord.id);
recoveredCount++;
console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized.id}`);
} catch (error) {
console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord.id}`, error);
- failedRecords.push(tempRecord);
- }
- }
-
- // 清理已恢复的临时记录
- if (failedRecords.length === 0) {
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.removeMeta === 'function') {
- await practiceCoreStore.removeMeta('temp_practice_records');
- } else {
- await this.metaRepo.remove('temp_practice_records');
- }
- console.log(`[PracticeRecorder] 所有${recoveredCount} 条临时记录恢复成功`);
- } else {
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') {
- await practiceCoreStore.writeMeta('temp_practice_records', failedRecords);
- } else {
- await this.metaRepo.set('temp_practice_records', failedRecords);
}
- console.log(`[PracticeRecorder] 恢复了${recoveredCount} 条记录,${failedRecords.length} 条失败`);
}
+ console.log(`[PracticeRecorder] 已恢复 ${recoveredCount} 条临时记录`);
} catch (error) {
console.error('[PracticeRecorder] 恢复临时记录时出错', error);
@@ -8317,7 +6433,7 @@ class PracticeRecorder {
});
// 检查临时记录
- const tempRecords = await this.metaRepo.get('temp_practice_records', []);
+ const tempRecords = await window.AppData.recovery.listDrafts();
const tempList = Array.isArray(tempRecords) ? tempRecords : [];
report.temporaryRecords.total = tempList.length;
report.temporaryRecords.needsRecovery = tempList.filter(r => r && r.needsRecovery).length;
@@ -8337,9 +6453,7 @@ class PracticeRecorder {
// 检查存储状态
try {
- const storageInfo = window.storage && typeof window.storage.getStorageInfo === 'function'
- ? await window.storage.getStorageInfo()
- : null;
+ const storageInfo = window.AppData.status();
report.storage.quota = storageInfo;
} catch (error) {
report.storage.available = false;
@@ -8411,6 +6525,12 @@ class PracticeRecorder {
// 确保全局可用
window.PracticeRecorder = PracticeRecorder;
+// The practice bundle is loaded on demand and may arrive after the bootstrap
+// fallback's bounded polling window. Upgrade immediately when the real class
+// becomes available so suite submissions never remain on the light recorder.
+if (window.app && typeof window.app.instantiatePracticeRecorder === 'function') {
+ window.app.instantiatePracticeRecorder();
+}
/* ===== bundle provided script markers ===== */
@@ -8421,7 +6541,6 @@ window.PracticeRecorder = PracticeRecorder;
"js/utils/markdownExporter.js",
"js/components/practiceRecordModal.js",
"js/components/practiceHistoryEnhancer.js",
- "js/core/scoreStorage.js",
"js/utils/answerSanitizer.js",
"js/core/practiceRecorder.js"
]);
diff --git a/js/bundles/reading-page.bundle.js b/js/bundles/reading-page.bundle.js
index ed5d31c0..b27021f6 100644
--- a/js/bundles/reading-page.bundle.js
+++ b/js/bundles/reading-page.bundle.js
@@ -1,5 +1,3524 @@
/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */
+/* ===== js/data/practiceRecordSource.js ===== */
+/**
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
+ */
+(function initPracticeRecordSource(global) {
+ 'use strict';
+
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
+ }
+
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
+
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
+
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
+
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
+
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
+ }
+
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ }
+
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
+ }
+
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
+ }
+
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
+ }
+
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
+
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
+
+ return true;
+ }
+
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
+
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
+
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
+
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
+
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
+ }
+
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
+ }
+ return previewRecordIds.delete(normalizeId(recordId));
+ }
+
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
+ }
+
+ /**
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
+ */
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
+
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
+
+ global.PracticeRecordSource = api;
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataCatalog.js ===== */
+(function installDataCatalog(global) {
+ 'use strict';
+
+ const V2_SCHEMA_VERSION = 2;
+
+ function clone(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function objectDefault() { return {}; }
+ function arrayDefault() { return []; }
+ function nullableDefault() { return null; }
+ function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; }
+ function normalizeObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
+ }
+ function normalizeNullableString(value) {
+ return value === null || value === undefined || value === '' ? null : String(value);
+ }
+ function isArray(value) { return Array.isArray(value); }
+ function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
+ function isNullableString(value) { return value === null || typeof value === 'string'; }
+
+ const CATALOG_OWNERS = new Set([
+ 'settings', 'library', 'recovery', 'backups', 'vocab',
+ 'preferences', 'goals', 'achievements', 'system', 'practice'
+ ]);
+ const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']);
+ const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']);
+
+ function isNonEmptyString(value) {
+ return typeof value === 'string' && Boolean(value.trim());
+ }
+
+ function ownerFromKey(logicalKey) {
+ const dot = String(logicalKey || '').indexOf('.');
+ return dot > 0 ? logicalKey.slice(0, dot) : '';
+ }
+
+ function freezeEntry(entry) {
+ const logicalKey = String(entry.logicalKey || '');
+ const owner = ownerFromKey(logicalKey);
+ const next = Object.assign({}, entry, {
+ logicalKey,
+ owner,
+ schemaVersion: V2_SCHEMA_VERSION,
+ export: entry.export === true,
+ import: entry.import || 'ignore'
+ });
+ return Object.freeze(next);
+ }
+
+ // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations),
+ // not as document keys. import merge identity is resolved in AppData, not here.
+ const definitions = [
+ {
+ logicalKey: 'settings.values', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.configurations', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'library.importedIndexes', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.activeConfigurationId', classification: 'authoritative',
+ defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString,
+ export: true, import: 'replace'
+ },
+ {
+ logicalKey: 'recovery.activeSessions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.drafts', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.interrupted', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.windowSession', classification: 'session',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.entries', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'backups.settings', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'backups.exportHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.importHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'vocab.words', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'vocab.userConfig', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'vocab.lists', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'preferences.values', classification: 'preference',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'goals.items', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'achievements.manual', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'achievements.progress', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'system.migrations', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'system.operationJournal', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ }
+ ].map(freezeEntry);
+
+ function validateCatalog(entries) {
+ if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry');
+ const logicalKeys = new Set();
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('DataCatalog entry must be an object');
+ }
+ if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) {
+ throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`);
+ }
+ logicalKeys.add(entry.logicalKey);
+ }
+ for (const entry of entries) {
+ if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) {
+ throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`);
+ }
+ if (!CATALOG_CLASSIFICATIONS.has(entry.classification)
+ || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION
+ || typeof entry.defaultValue !== 'function'
+ || typeof entry.normalize !== 'function'
+ || typeof entry.validate !== 'function'
+ || typeof entry.export !== 'boolean'
+ || !IMPORT_POLICIES.has(entry.import)) {
+ throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`);
+ }
+ try {
+ const defaultValue = entry.defaultValue();
+ if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) {
+ throw new Error('invalid default');
+ }
+ } catch (_) {
+ throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`);
+ }
+ }
+ return true;
+ }
+
+ validateCatalog(definitions);
+ const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry]));
+ const DataCatalog = Object.freeze({
+ version: V2_SCHEMA_VERSION,
+ list() { return definitions.slice(); },
+ get(logicalKey) {
+ const entry = byKey.get(String(logicalKey || ''));
+ if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`);
+ return entry;
+ },
+ has(logicalKey) { return byKey.has(String(logicalKey || '')); },
+ validate: validateCatalog,
+ clone
+ });
+
+ Object.defineProperty(global, '__AppDataV2Catalog', {
+ value: DataCatalog,
+ enumerable: false,
+ configurable: true,
+ writable: false
+ });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/dataKernel.js ===== */
+(function installDataKernel(global) {
+ 'use strict';
+
+ if (global.AppData) return;
+
+ const catalog = global.__AppDataV2Catalog;
+ if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel');
+
+ const DATABASE_NAME = 'IELTSAtlasDataV2';
+ // Version 2 uses a new schema, but initialization must still import the durable
+ // ExamSystemDB data owned by releases which predate AppData v2.
+ const DATABASE_VERSION = 2;
+ const DOCUMENT_STORE = 'documents';
+ const SYSTEM_STORE = 'system';
+ const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+ const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES));
+ const OPERATION_JOURNAL_WINDOW = 500;
+ const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`;
+ const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000;
+ const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000;
+ const MAX_TIMER_DELAY_MS = 2147483647;
+ const LEGACY_DATABASE_NAME = 'ExamSystemDB';
+ const LEGACY_STORE_NAME = 'keyValueStore';
+ const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup';
+ const LEGACY_EXTERNAL_STORE_NAME = 'handles';
+ const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory';
+ const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json';
+ const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([
+ 'practice_records',
+ 'vocab_user_config',
+ 'user_achievements'
+ ]);
+
+ function clone(value) { return catalog.clone(value); }
+ function nowIso() { return new Date().toISOString(); }
+ function randomId(prefix) {
+ const random = global.crypto && typeof global.crypto.randomUUID === 'function'
+ ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ return `${prefix || 'op'}_${random}`;
+ }
+
+ class AppDataError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'AppDataError';
+ this.code = code;
+ this.committed = false;
+ this.details = details;
+ }
+ }
+ function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); }
+ function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); }
+
+ function normalizeTimeoutMs(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback;
+ }
+ function scheduleTimeout(handler, delayMs) {
+ if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable');
+ const handle = global.setTimeout.call(global, handler, delayMs);
+ if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle');
+ return handle;
+ }
+ function cancelTimeout(handle) {
+ if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') {
+ try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ }
+ }
+ }
+ function withDeadline(handle, timeoutMs, description, resolve, reject) {
+ let settled = false;
+ let timer = null;
+ const settle = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ cancelTimeout(timer);
+ callback(value);
+ };
+ const expire = (error) => {
+ if (settled) return;
+ settled = true;
+ try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ }
+ reject(error);
+ };
+ try {
+ timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, {
+ operation: description, timeoutMs, reason: 'timeout'
+ })), timeoutMs);
+ } catch (error) {
+ expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, {
+ operation: description, reason: 'watchdog-unavailable', cause: error && error.message
+ }));
+ }
+ return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } };
+ }
+
+ function canonicalizeJson(value, path = '$', ancestors = new Set()) {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path });
+ return Object.is(value, -0) ? 0 : value;
+ }
+ if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') {
+ throw validation(`Non-JSON value at ${path}`, { path, type: typeof value });
+ }
+ if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path });
+ const prototype = Object.getPrototypeOf(value);
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path });
+ if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
+ && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) {
+ throw validation(`Symbol-keyed property at ${path}`, { path });
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => {
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path });
+ return canonicalizeJson(item, `${path}[${index}]`, ancestors);
+ });
+ }
+ const result = {};
+ for (const key of Object.keys(value).sort()) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path });
+ result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
+ }
+ return result;
+ } finally { ancestors.delete(value); }
+ }
+ function stableStringifyCanonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`;
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`;
+ }
+ function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); }
+ function checksum(value) {
+ const input = stableStringify(value);
+ let hash = 2166136261;
+ for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+ function legacyTimestamp(value) {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity;
+ if (Number.isFinite(Number(value))) return Number(value);
+ const parsed = Date.parse(value == null ? '' : String(value));
+ return Number.isFinite(parsed) ? parsed : -Infinity;
+ }
+ function parseLegacyCandidate(value, outerTimestamp) {
+ let parsed = value;
+ let timestamp = legacyTimestamp(outerTimestamp);
+ const hasOuterTimestamp = timestamp !== -Infinity;
+ for (let depth = 0; depth < 3; depth += 1) {
+ if (typeof parsed === 'string') {
+ try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; }
+ } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data')
+ && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) {
+ const innerTimestamp = legacyTimestamp(parsed.timestamp);
+ if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp;
+ parsed = parsed.data;
+ } else break;
+ }
+ return { value: clone(parsed), timestamp };
+ }
+ function parseLegacyValue(value) {
+ const candidate = parseLegacyCandidate(value);
+ return candidate ? candidate.value : clone(value);
+ }
+ async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) {
+ const values = {};
+ const candidates = {};
+ let readComplete = true;
+ const consider = (alias, rawValue, timestamp, sourceRank) => {
+ const candidate = parseLegacyCandidate(rawValue, timestamp);
+ if (!candidate) return;
+ const previous = candidates[alias];
+ if (!previous || candidate.timestamp > previous.timestamp
+ || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) {
+ candidates[alias] = Object.assign(candidate, { sourceRank });
+ }
+ };
+ if (indexedDBApi && typeof indexedDBApi.open === 'function') {
+ await new Promise((resolve) => {
+ let request;
+ let createdEmptyDatabase = false;
+ try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; }
+ request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); };
+ request.onupgradeneeded = () => {
+ createdEmptyDatabase = true;
+ try { request.transaction.abort(); } catch (_) {}
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; }
+ const tx = db.transaction(LEGACY_STORE_NAME, 'readonly');
+ const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys();
+ const rows = tx.objectStore(LEGACY_STORE_NAME).getAll();
+ tx.oncomplete = () => {
+ (keys.result || []).forEach((key, index) => {
+ const row = (rows.result || [])[index];
+ // v1's keyValueStore persisted { key, value, timestamp } rows.
+ const validRow = row && typeof row === 'object'
+ && Object.prototype.hasOwnProperty.call(row, 'key')
+ && String(row.key) === String(key)
+ && Object.prototype.hasOwnProperty.call(row, 'value');
+ if (!validRow) {
+ readComplete = false;
+ return;
+ }
+ consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0);
+ });
+ db.close(); resolve();
+ };
+ tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); };
+ };
+ });
+ }
+ for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) {
+ if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue;
+ for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) {
+ const key = fallbackStorage.key(index);
+ if (!key) continue;
+ const alias = key.startsWith('exam_system_')
+ ? key.slice('exam_system_'.length)
+ : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null);
+ if (!alias) continue;
+ try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ }
+ }
+ }
+ for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value;
+ Object.defineProperty(values, '__legacyReadComplete', {
+ value: readComplete,
+ enumerable: false,
+ configurable: false,
+ writable: false
+ });
+ return values;
+ }
+ async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) {
+ if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null;
+ const directoryHandle = await new Promise((resolve) => {
+ let request;
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value || null);
+ };
+ try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; }
+ request.onerror = () => finish(null);
+ request.onupgradeneeded = () => {
+ try { request.transaction.abort(); } catch (_) {}
+ finish(null);
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) {
+ db.close(); finish(null); return;
+ }
+ const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly')
+ .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY);
+ get.onerror = () => { db.close(); finish(null); };
+ get.onsuccess = () => { db.close(); finish(get.result); };
+ };
+ });
+ if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null;
+ if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null;
+ const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false });
+ const parsed = JSON.parse(await (await fileHandle.getFile()).text());
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined
+ ? parsed.data : parsed;
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null;
+ }
+ function lookupEntry(logicalKey) {
+ if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey });
+ return catalog.get(logicalKey);
+ }
+ function storeFor(logicalKey) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey });
+ return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE;
+ }
+ function makeEnvelope(entry, data, options = {}) {
+ const state = options.state === 'cleared' ? 'cleared' : 'present';
+ if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`);
+ let normalized = null;
+ if (state === 'present') {
+ try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) {
+ throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message });
+ }
+ normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`);
+ if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey });
+ }
+ const revision = options.revision === undefined ? 1 : Number(options.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`);
+ const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')),
+ updatedAt: options.updatedAt || nowIso(), state, data: normalized };
+ payload.checksum = checksum(payload.data);
+ return Object.freeze(payload);
+ }
+ function validateEnvelope(entry, envelope) {
+ try {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
+ || Number(envelope.schemaVersion) !== Number(entry.schemaVersion)
+ || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1
+ || typeof envelope.operationId !== 'string' || !envelope.operationId
+ || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt
+ || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false;
+ const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`);
+ return (envelope.state !== 'cleared' || data === null)
+ && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data);
+ } catch (_) { return false; }
+ }
+ function operationId(value) {
+ if (value === undefined || value === null || value === '') return randomId('mutation');
+ if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string');
+ return value;
+ }
+ function expectedRevision(value, label) {
+ if (value === undefined || value === null) return null;
+ const revision = Number(value);
+ if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`);
+ return revision;
+ }
+ function compactJournal(journal) {
+ const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence));
+ for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]];
+ }
+ function readJournal(row) {
+ const envelope = row && row.envelope;
+ return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
+ ? clone(envelope.data) : {};
+ }
+ function journalResult(journal, spec) {
+ const existing = journal[spec.operationId];
+ if (!existing) return null;
+ if (existing.fingerprint !== spec.fingerprint || !existing.receipt) {
+ throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId });
+ }
+ return clone(existing.receipt);
+ }
+ function writeJournal(journal, spec, receipt) {
+ const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1;
+ journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() };
+ compactJournal(journal);
+ return journal;
+ }
+ function putJournal(tx, currentRow, journal, spec, receipt) {
+ const current = currentRow && currentRow.envelope;
+ const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), {
+ revision: current ? Number(current.revision) + 1 : 1,
+ operationId: spec.operationId,
+ normalized: true
+ });
+ tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) });
+ }
+
+ class IndexedDBDriver {
+ constructor(indexedDBApi, options) {
+ this.indexedDB = indexedDBApi;
+ this.db = null;
+ this.mutationTimeoutMs = options.mutationTimeoutMs;
+ this.requestTimeoutMs = options.requestTimeoutMs;
+ }
+ async initialize() {
+ if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable');
+ this.db = await new Promise((resolve, reject) => {
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ let abandoned = false;
+ let settle;
+ request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); };
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ if (event.oldVersion < 2) {
+ for (const name of ['authoritative', 'derived']) {
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
+ }
+ }
+ for (const name of STORE_NAMES) {
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' });
+ }
+ };
+ settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject);
+ request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB'));
+ request.onblocked = () => {
+ abandoned = true;
+ settle.reject(new Error('IndexedDB upgrade blocked'));
+ };
+ });
+ this.db.onversionchange = () => this.close();
+ return this;
+ }
+ close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} }
+ _open() { if (!this.db) throw new Error('IndexedDB connection closed'); }
+ _transaction(stores, mode, description, work, mutation = false) {
+ this._open();
+ return new Promise((resolve, reject) => {
+ let failure = null;
+ let value;
+ let tx;
+ try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; }
+ const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject);
+ tx.oncomplete = () => settle.resolve(clone(value));
+ tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); };
+ tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`));
+ const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} };
+ try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); }
+ });
+ }
+ readEnvelope(logicalKey) {
+ const store = storeFor(logicalKey);
+ return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(logicalKey);
+ request.onsuccess = () => done(request.result ? request.result.envelope : null);
+ request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`));
+ });
+ }
+ readEntity(store, recordId) {
+ return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(recordId);
+ request.onsuccess = () => done(request.result || null);
+ request.onerror = () => fail(request.error || new Error('Entity read failed'));
+ });
+ }
+ readPracticeSnapshot(recordIds = null, options = {}) {
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES.slice();
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean));
+ return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => {
+ const result = Object.fromEntries(stores.map((store) => [store, []]));
+ let remaining = stores.length;
+ const finishStore = (store, rows) => {
+ result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || '')));
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ for (const store of stores) {
+ const objectStore = tx.objectStore(store);
+ const request = requested && requested.size === 1
+ ? objectStore.get(Array.from(requested)[0])
+ : objectStore.getAll();
+ request.onsuccess = () => {
+ const rows = requested && requested.size === 1
+ ? (request.result ? [request.result] : [])
+ : request.result;
+ finishStore(store, rows);
+ };
+ request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`));
+ }
+ });
+ }
+ listEntities(store) {
+ return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).getAll();
+ request.onsuccess = () => done(request.result || []);
+ request.onerror = () => fail(request.error || new Error('Entity list failed'));
+ });
+ }
+ atomic(spec) {
+ return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => {
+ const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal');
+ journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed'));
+ journalRequest.onsuccess = () => {
+ try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); }
+ };
+ }, true);
+ }
+ exportSnapshot(envelopeKeys) {
+ return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => {
+ const result = { envelopes: {}, entities: {} };
+ let remaining = STORE_NAMES.length;
+ for (const store of STORE_NAMES) {
+ const request = tx.objectStore(store).getAll();
+ request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`));
+ request.onsuccess = () => {
+ if (store === DOCUMENT_STORE || store === SYSTEM_STORE) {
+ for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope;
+ } else result.entities[store] = request.result || [];
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ }
+ });
+ }
+ }
+
+ function entityStore(store) {
+ const value = String(store || '');
+ if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value });
+ return value;
+ }
+ function validateEntityRow(store, row) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)
+ || typeof row.recordId !== 'string' || !row.recordId
+ || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1
+ || typeof row.operationId !== 'string' || !row.operationId
+ || typeof row.updatedAt !== 'string' || !row.updatedAt) {
+ throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null });
+ }
+ const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`);
+ if (row.checksum !== checksum(data)) {
+ throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId });
+ }
+ return row;
+ }
+ function normalizeEntityOperation(operation, index) {
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`);
+ const type = String(operation.type || '');
+ const store = entityStore(operation.store);
+ if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`);
+ const recordId = type === 'clear' ? null : String(operation.recordId || '');
+ if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`);
+ const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null;
+ return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) };
+ }
+ function receiptFor(operationIdValue, revisions, warnings, pending) {
+ const receipt = { committed: true, revisions, operationId: operationIdValue,
+ derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() };
+ const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]];
+ return receipt;
+ }
+
+ class DataKernel {
+ constructor(options = {}) {
+ this.driver = null;
+ this.backend = null;
+ this.state = 'created';
+ this.failure = null;
+ this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB;
+ this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS);
+ this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS);
+ this.committedListeners = new Set();
+ this.commitChannel = null;
+ this.instanceId = randomId('kernel');
+ this.ready = null;
+ }
+ _initializeCommitChannel() {
+ if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return;
+ try {
+ const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME);
+ channel.onmessage = (message) => {
+ const data = message && message.data;
+ if (!data || data.sourceInstanceId === this.instanceId
+ || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return;
+ this._dispatchCommitted({
+ operationId: data.operationId,
+ targets: clone(data.targets),
+ receipt: data.receipt ? clone(data.receipt) : null,
+ remote: true
+ });
+ };
+ this.commitChannel = channel;
+ } catch (_) {
+ this.commitChannel = null;
+ }
+ }
+ _closeCommitChannel() {
+ const channel = this.commitChannel;
+ this.commitChannel = null;
+ try { if (channel) channel.close(); } catch (_) {}
+ }
+ initialize() {
+ if (this.ready) return this.ready;
+ this.state = 'initializing';
+ this.ready = new IndexedDBDriver(this.indexedDB, {
+ mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs
+ }).initialize().then((driver) => {
+ this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this;
+ }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null;
+ throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); });
+ return this.ready;
+ }
+ close() {
+ if (this.driver) this.driver.close();
+ this.driver = null; this.backend = null;
+ this._closeCommitChannel();
+ if (this.state !== 'failed') this.state = 'closed';
+ }
+ _assertReady() {
+ if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message });
+ if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized');
+ }
+ _latch(error) {
+ if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message });
+ this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel();
+ return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message });
+ }
+ onCommitted(listener) {
+ if (typeof listener !== 'function') throw validation('Committed listener must be a function');
+ this.committedListeners.add(listener); return () => this.committedListeners.delete(listener);
+ }
+ _dispatchCommitted(event) {
+ if (!event || !this.committedListeners.size) return;
+ const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback);
+ schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} }));
+ }
+ _notifyCommitted(targets, receipt) {
+ if (!targets.length) return;
+ const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false };
+ this._dispatchCommitted(event);
+ if (this.commitChannel) {
+ try {
+ this.commitChannel.postMessage({
+ sourceInstanceId: this.instanceId,
+ operationId: event.operationId,
+ targets: event.targets,
+ receipt: event.receipt
+ });
+ } catch (_) { /* cross-realm notification is best effort */ }
+ }
+ }
+ async getEnvelope(logicalKey) {
+ this._assertReady(); const entry = lookupEntry(logicalKey);
+ try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async read(logicalKey, options = {}) {
+ const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey);
+ const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data;
+ return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data);
+ }
+ _documentSpec(changes, options) {
+ if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes');
+ const opId = operationId(options.operationId); const seen = new Set();
+ const prepared = changes.map((change, index) => {
+ if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`);
+ const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey);
+ if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel');
+ if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey);
+ const state = change.state === 'cleared' ? 'cleared' : 'present';
+ if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`);
+ if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`);
+ let data = null;
+ if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); }
+ return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) };
+ });
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings });
+ return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) };
+ }
+ async mutate(changes, options = {}) {
+ this._assertReady(); const spec = this._documentSpec(changes, options);
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) }));
+ let remaining = reads.length;
+ const finish = () => {
+ const revisions = {};
+ for (const item of reads) {
+ const current = item.request.result ? item.request.result.envelope : null;
+ if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey });
+ const revision = current ? Number(current.revision) : 0;
+ if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision });
+ const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true });
+ tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision;
+ }
+ const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []);
+ putJournal(tx, journalRow, journal, spec, receipt);
+ done(receipt);
+ };
+ if (!remaining) { finish(); return; }
+ for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification }));
+ this._notifyCommitted(targets, receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); }
+ async readEntity(store, recordId, options = {}) {
+ this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId');
+ try {
+ const row = await this.driver.readEntity(store, id);
+ if (!row) return null;
+ validateEntityRow(store, row);
+ return options.withMeta ? clone(row) : clone(row.data);
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ this._assertReady();
+ const ids = recordIds === null || recordIds === undefined
+ ? null
+ : (Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean);
+ try {
+ const snapshot = await this.driver.readPracticeSnapshot(ids, options);
+ const result = {};
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES;
+ for (const store of stores) {
+ const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : [])
+ .filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ result[store] = options.withMeta
+ ? clone(validRows)
+ : validRows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async listEntities(store, options = {}) {
+ this._assertReady(); store = entityStore(store);
+ if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId');
+ try {
+ const rows = await this.driver.listEntities(store);
+ const validRows = rows.filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data));
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async mutateEntities(operations, options = {}) {
+ this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations');
+ const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set();
+ for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); }
+ for (const store of ENTITY_STORES) {
+ const scoped = items.filter((item) => item.store === store);
+ if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) {
+ throw validation(`Entity clear cannot be combined with other operations for ${store}`);
+ }
+ }
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length;
+ const finish = () => { const revisions = {};
+ for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0;
+ if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`);
+ const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; }
+ }
+ for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; }
+ const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); };
+ if (!remaining) { try { finish(); } catch (error) { fail(error); } return; }
+ for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async exportSnapshot(options = {}) {
+ this._assertReady();
+ try {
+ const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null;
+ const shouldExport = (logicalKey) => {
+ if (!catalog.has(logicalKey)) return false;
+ const entry = lookupEntry(logicalKey);
+ if (selected && !selected.has(logicalKey)) return false;
+ if (entry.export === true) return true;
+ return options.includeSystem === true && entry.classification === 'system';
+ };
+ const data = await this.driver.exportSnapshot(shouldExport);
+ // Full/partial snapshots must be dense for their declared catalog
+ // range. An absent physical row means the catalog default, not an
+ // instruction that future importers should guess about.
+ for (const entry of catalog.list()) {
+ if (!shouldExport(entry.logicalKey)
+ || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue;
+ data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: 'snapshot-default'
+ });
+ }
+ if (Array.isArray(options.entityStores)) {
+ const selectedStores = new Set(options.entityStores.map(entityStore));
+ for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store];
+ }
+ const payload = { envelopes: data.envelopes, entities: data.entities };
+ return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) };
+ } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async installSnapshot(snapshot, options = {}) {
+ this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} };
+ const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities');
+ if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid');
+ if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch');
+ const changes = [];
+ for (const [logicalKey, envelope] of Object.entries(envelopes)) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue;
+ if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`);
+ changes.push({ logicalKey, entry, envelope });
+ }
+ const entityRows = {};
+ for (const store of ENTITY_STORES) {
+ if (!Object.prototype.hasOwnProperty.call(entities, store)) continue;
+ const rows = entities[store];
+ if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`);
+ const ids = new Set();
+ entityRows[store] = rows.map((row) => {
+ if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`);
+ const recordId = String(row.recordId);
+ if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`);
+ ids.add(recordId);
+ const data = canonicalizeJson(row.data);
+ if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`);
+ const revision = row.revision === undefined ? 1 : Number(row.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`);
+ return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) };
+ });
+ }
+ if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data');
+ const resetJournal = options.resetJournal === true;
+ const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object'
+ ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken')
+ : null;
+ const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {};
+ const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {};
+ const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({
+ kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey)
+ })).concat(Object.entries(entityChecks).map(([store, expected]) => ({
+ kind: 'entities', store, expected, request: tx.objectStore(store).getAll()
+ })));
+ const finish = () => {
+ for (const read of reads) {
+ if (read.kind === 'document') {
+ const current = read.request.result ? read.request.result.envelope : null;
+ const actualRevision = current ? Number(current.revision) : 0;
+ const expectedRevision = Number(read.expected) || 0;
+ if (actualRevision !== expectedRevision) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision });
+ }
+ } else {
+ const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0]));
+ const expected = read.expected && typeof read.expected === 'object' ? read.expected : {};
+ const ids = new Set(Object.keys(actual).concat(Object.keys(expected)));
+ for (const recordId of ids) {
+ const current = actual[recordId] || 0;
+ const wanted = Number(expected[recordId]) || 0;
+ if (current !== wanted) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId });
+ }
+ }
+ }
+ }
+ const revisions = {};
+ for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); }
+ for (const [store, rows] of Object.entries(entityRows)) {
+ tx.objectStore(store).clear();
+ for (const row of rows) tx.objectStore(store).put(row);
+ }
+ const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt);
+ };
+ if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; }
+ let remaining = reads.length;
+ for (const read of reads) {
+ read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed'));
+ read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } };
+ }
+ } }));
+ const targets = changes
+ .filter((item) => item.entry.owner !== 'backups')
+ .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification }))
+ .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' })));
+ this._notifyCommitted(targets, receipt);
+ return receipt;
+ }
+ catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); }
+ }
+
+ Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false });
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
+/* ===== js/data/v2/appData.js ===== */
+(function installAppData(global) {
+ 'use strict';
+
+ const internals = global.__AppDataV2Internals;
+ if (!internals || typeof internals.DataKernel !== 'function') {
+ throw new Error('AppData v2 requires DataKernel');
+ }
+ const {
+ DataKernel,
+ AppDataError,
+ catalog,
+ clone,
+ randomId,
+ nowIso,
+ checksum
+ } = internals;
+ const kernel = new DataKernel();
+ const importPlans = new Map();
+ const RECOVERY_KEYS = Object.freeze({
+ activeSession: 'recovery.activeSessions',
+ draft: 'recovery.drafts',
+ interrupted: 'recovery.interrupted',
+ rejectedCompletion: 'recovery.rejectedCompletions'
+ });
+ const PREFERENCE_FIELDS = Object.freeze({
+ theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode',
+ resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay',
+ threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget',
+ consent: 'consent', logConfig: 'logConfig'
+ });
+ const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+
+ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+ function idOf(value, fields) {
+ for (const field of fields) {
+ if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]);
+ }
+ return '';
+ }
+
+ function importedLibraryId(value, options = {}) {
+ const id = value === null || value === undefined ? '' : String(value).trim();
+ if (!id && options.nullable) return null;
+ if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required');
+ if (/^exam_index(?:_|$)/.test(id)) {
+ throw new AppDataError('VALIDATION', 'Unsupported library configuration id');
+ }
+ return id;
+ }
+ function assertObject(value, message) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function assertArray(value, message) {
+ if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function jsonValue(value, label = 'value') {
+ try {
+ const serialized = JSON.stringify(value, (_key, current) => {
+ if (typeof current === 'bigint') return String(current);
+ if (typeof current === 'number' && !Number.isFinite(current)) return null;
+ return current;
+ });
+ if (serialized === undefined) return null;
+ return JSON.parse(serialized);
+ } catch (error) {
+ throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message });
+ }
+ }
+ function operationId(command, prefix, semanticPayload = command) {
+ const id = command && command.operationId ? String(command.operationId) : randomId(prefix);
+ jsonValue(semanticPayload, `${prefix} payload`);
+ return id;
+ }
+ function mutationOptions(command, prefix, semanticPayload, extra = {}) {
+ const source = asObject(command);
+ const payload = jsonValue(semanticPayload, `${prefix} payload`);
+ const intent = { command: prefix, payload };
+ if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) {
+ intent.expectedRevision = source.expectedRevision;
+ }
+ return Object.assign({}, extra, {
+ operationId: operationId(source, prefix, payload),
+ intent
+ });
+ }
+ function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) {
+ return mutationOptions(asObject(options), prefix, semanticPayload, extra);
+ }
+ function deterministicEntityId(prefix, operation) {
+ return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`;
+ }
+ function normalizeAccuracyRatio(value, label = 'accuracy') {
+ if (value === undefined || value === null || value === '') return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`);
+ }
+ return numeric > 1 ? numeric / 100 : numeric;
+ }
+ function defaultStats() {
+ return {
+ totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0,
+ reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ lastUpdated: nowIso()
+ };
+ }
+
+ function firstNonNegative(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === '' || typeof value === 'object') continue;
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return null;
+ }
+
+ function normalizePracticeScore(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo);
+ const overloadedAnswers = record.correctAnswers;
+ if (overloadedAnswers && typeof overloadedAnswers === 'object') {
+ record.correctAnswerMap = Object.assign(
+ {},
+ clone(asObject(overloadedAnswers)),
+ clone(asObject(record.correctAnswerMap))
+ );
+ }
+ const correct = firstNonNegative(
+ overloadedAnswers,
+ record.correctAnswersCount,
+ scoreInfo.correctAnswers,
+ scoreInfo.correct,
+ legacyScoreInfo.correctAnswers,
+ legacyScoreInfo.correct
+ );
+ if (correct !== null) record.correctAnswers = correct;
+ else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0;
+ const total = firstNonNegative(
+ record.totalQuestions,
+ record.questionCount,
+ scoreInfo.totalQuestions,
+ scoreInfo.total,
+ legacyScoreInfo.totalQuestions,
+ legacyScoreInfo.total
+ );
+ if (total !== null) record.totalQuestions = total;
+ }
+
+ function mergeAnswers(target, source) {
+ if (Array.isArray(source)) {
+ source.forEach((item, index) => {
+ if (!item || typeof item !== 'object') return;
+ const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1);
+ const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer;
+ if (answer !== undefined) target[questionId] = clone(answer);
+ });
+ return;
+ }
+ for (const [questionId, answer] of Object.entries(asObject(source))) {
+ target[String(questionId)] = clone(answer);
+ }
+ }
+
+ function normalizePracticeAnswers(record) {
+ const answers = {};
+ const raw = asObject(record.rawData);
+ const rawReal = asObject(raw.realData);
+ const real = asObject(record.realData);
+ for (const source of [
+ rawReal.answerMap, rawReal.answerList, rawReal.answers,
+ raw.answerMap, raw.answerList, raw.answers,
+ real.answerMap, real.answerList, real.answers,
+ record.answerMap, record.answerList, record.answers
+ ]) mergeAnswers(answers, source);
+ if (Object.keys(answers).length) record.answers = answers;
+ }
+
+ function questionTypeErrorCounts(source) {
+ const counts = {};
+ const add = (type, count = 1) => {
+ const key = String(type || '').trim();
+ if (key && count > 0) counts[key] = (counts[key] || 0) + count;
+ };
+ for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) {
+ const metrics = asObject(value);
+ const total = firstNonNegative(metrics.totalQuestions, metrics.total);
+ const correct = firstNonNegative(metrics.correctAnswers, metrics.correct);
+ if (total !== null && correct !== null) add(type, Math.max(0, total - correct));
+ }
+ for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) {
+ if (detail && detail.isCorrect === false) add(detail.questionType || detail.type);
+ }
+ return counts;
+ }
+
+ function canonicalizeRecord(input) {
+ assertObject(input, 'practice record must be an object');
+ const record = jsonValue(input, 'practice record');
+ record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record');
+ record.sessionId = idOf(record, ['sessionId']) || record.id;
+ record.timestamp = record.timestamp || record.completedAt || record.date || nowIso();
+ record.completedAt = record.completedAt || record.timestamp;
+ record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice';
+ record.metadata = asObject(record.metadata);
+ if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId;
+ if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId;
+ normalizePracticeAnswers(record);
+ normalizePracticeScore(record);
+ for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) {
+ if (record[field] === undefined || record[field] === null || record[field] === '') continue;
+ const numeric = Number(record[field]);
+ if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`);
+ record[field] = numeric;
+ }
+ if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy');
+ return jsonValue(record, 'canonical practice record');
+ }
+
+ function lightSuiteEntry(source, fallbackType = null) {
+ const entry = asObject(source);
+ const scoreInfo = asObject(entry.scoreInfo);
+ const realScoreInfo = asObject(asObject(entry.realData).scoreInfo);
+ const metadata = asObject(entry.metadata);
+ const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0;
+ const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0;
+ const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'suite entry accuracy'
+ ) || 0;
+ const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0;
+ return jsonValue({
+ id: entry.id || null,
+ sessionId: entry.sessionId || null,
+ examId: entry.examId || metadata.examId || null,
+ title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '',
+ type: entry.type || metadata.type || fallbackType,
+ date: entry.date || entry.completedAt || entry.timestamp || null,
+ duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage,
+ questionTypeErrorCounts: questionTypeErrorCounts(entry)
+ }, 'suite entry light projection');
+ }
+
+ function lightFromCanonical(source) {
+ const scoreInfo = asObject(source.scoreInfo);
+ const realScoreInfo = asObject(asObject(source.realData).scoreInfo);
+ const metadata = asObject(source.metadata);
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
+ const dataSource = hasOwn(source, 'dataSource')
+ ? source.dataSource
+ : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined);
+ const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0;
+ const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0;
+ const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'practice light accuracy'
+ ) || 0;
+ return jsonValue({
+ id: source.id,
+ sessionId: source.sessionId,
+ examId: source.examId || source.metadata.examId || null,
+ title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '',
+ type: source.type,
+ mode: source.mode || source.practiceMode || null,
+ timestamp: source.timestamp,
+ completedAt: source.completedAt,
+ date: source.date || source.completedAt || source.timestamp || null,
+ startTime: source.startTime || null,
+ endTime: source.endTime || null,
+ duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0,
+ score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null,
+ questionTypeErrorCounts: questionTypeErrorCounts(source),
+ // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined`
+ // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。
+ // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。
+ dataSource,
+ // Summaries are list indexes. Keep only the metadata needed to filter, show a
+ // source label, or locate the originating library; details stay in their entity.
+ metadata: Object.fromEntries([
+ // `source` must stay: PracticeRecordSource uses metadata.source demo markers
+ // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full.
+ 'examId', 'examTitle', 'title', 'type', 'category', 'frequency',
+ 'dataSource', 'source', 'libraryConfigurationId'
+ ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])),
+ suite: source.suite == null ? null : clone(asObject(source.suite)),
+ suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(
+ entry,
+ String(source.type || '').replace(/-suite$/, '') || null
+ ))
+ }, 'practice light projection');
+ }
+
+ function projectLight(record) {
+ if (!record) return null;
+ return lightFromCanonical(canonicalizeRecord(record));
+ }
+
+ function firstNonEmpty(...values) {
+ let first;
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ if (first === undefined) first = value;
+ if (Array.isArray(value) && value.length) return clone(value);
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value);
+ if (typeof value !== 'object') return clone(value);
+ }
+ return first === undefined ? {} : clone(first);
+ }
+
+ const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']);
+ const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']);
+
+ function withoutRawData(value) {
+ if (Array.isArray(value)) return value.map(withoutRawData);
+ if (!value || typeof value !== 'object') return clone(value);
+ const clean = {};
+ for (const [key, item] of Object.entries(value)) {
+ if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item);
+ }
+ return clean;
+ }
+
+ function splitPracticeRecord(input) {
+ const source = canonicalizeRecord(input);
+ const summary = lightFromCanonical(source);
+ const detail = { recordId: source.id };
+ const annotations = { recordId: source.id };
+ for (const [key, value] of Object.entries(source)) {
+ if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue;
+ if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value);
+ else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => {
+ const next = Object.assign({}, asObject(entry));
+ const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData));
+ for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]);
+ }
+ const annotation = {};
+ for (const annotationKey of ANNOTATION_FIELDS) {
+ if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; }
+ if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey];
+ if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey];
+ }
+ delete next.realData; delete next.rawData;
+ if (Object.keys(annotation).length) {
+ if (!annotations.suiteEntries) annotations.suiteEntries = {};
+ annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation;
+ }
+ return withoutRawData(next);
+ });
+ else detail[key] = withoutRawData(value);
+ }
+ // Accept the old mirror only as an input normalization boundary; it is never persisted.
+ const realData = asObject(source.realData); const rawData = asObject(source.rawData);
+ for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]);
+ }
+ for (const key of ANNOTATION_FIELDS) {
+ if (hasOwn(annotations, key)) continue;
+ if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]);
+ else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]);
+ }
+ return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') };
+ }
+
+ function joinPracticeRecord(summary, detail, annotations, projection = 'full') {
+ if (!summary) return null;
+ const mode = String(projection || 'full').toLowerCase();
+ const light = clone(summary);
+ if (mode === 'light' || mode === 'summary') return light;
+ const joined = Object.assign({}, light, clone(asObject(detail)));
+ delete joined.recordId;
+ if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection');
+ const annotationData = asObject(annotations);
+ for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value);
+ if (Array.isArray(joined.suiteEntries)) {
+ const suiteAnnotations = asObject(annotationData.suiteEntries);
+ joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {})));
+ }
+ return jsonValue(joined, 'practice full projection');
+ }
+
+ function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); }
+
+ // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。
+ // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定,
+ // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败,
+ // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。
+ const practiceRecordSource = global.PracticeRecordSource;
+ if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') {
+ throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)');
+ }
+ const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord;
+
+ function computeStats(records) {
+ const stats = defaultStats();
+ for (const record of asArray(records).filter(isRealPracticeRecord)) {
+ const summary = projectLight(record);
+ const type = String(summary.type || '').toLowerCase();
+ const target = type.includes('listen') ? stats.listening : stats.reading;
+ stats.totalPractices += 1;
+ stats.totalQuestions += summary.totalQuestions;
+ stats.correctAnswers += summary.correctAnswers;
+ target.practices += 1;
+ target.questions += summary.totalQuestions;
+ target.correct += summary.correctAnswers;
+ }
+ stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0;
+ for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0;
+ stats.lastUpdated = nowIso();
+ return stats;
+ }
+
+ function validIso(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function practiceType(record) {
+ const metadata = asObject(record.metadata);
+ const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType,
+ record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase();
+ if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening';
+ if (hints.includes('read')) return 'reading';
+ return null;
+ }
+
+ function accuracyRatio(record) {
+ const summary = lightFromCanonical(record);
+ const value = Number(summary.accuracy);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value > 1 ? value / 100 : value));
+ }
+
+ function durationSeconds(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const realData = asObject(record.realData);
+ const realScoreInfo = asObject(realData.scoreInfo);
+ for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return 0;
+ }
+
+ function earlierUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function laterUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function computeAchievementProgress(records, manual, existing) {
+ const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord)
+ .map((record, index) => ({
+ record,
+ index,
+ unlockedAt: validIso(record.completedAt || record.timestamp),
+ time: new Date(record.completedAt || record.timestamp).getTime()
+ }))
+ .sort((left, right) => {
+ const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER;
+ const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER;
+ return leftTime - rightTime || left.index - right.index;
+ });
+ const candidates = {};
+ const setThreshold = (id, list, count) => {
+ if (list.length >= count) candidates[id] = list[count - 1].unlockedAt;
+ };
+ setThreshold('first_step', items, 1);
+ setThreshold('practice_bronze', items, 10);
+ setThreshold('practice_silver', items, 50);
+ setThreshold('practice_gold', items, 100);
+ setThreshold('practice_platinum', items, 200);
+
+ const reading = items.filter((item) => practiceType(item.record) === 'reading');
+ const listening = items.filter((item) => practiceType(item.record) === 'listening');
+ setThreshold('reading_first', reading, 1);
+ setThreshold('reading_bronze', reading, 10);
+ setThreshold('reading_silver', reading, 50);
+ setThreshold('reading_gold', reading, 100);
+ setThreshold('listening_first', listening, 1);
+ setThreshold('listening_bronze', listening, 10);
+ setThreshold('listening_silver', listening, 50);
+ setThreshold('listening_gold', listening, 100);
+ if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt);
+ if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt);
+
+ let cumulativeDuration = 0;
+ let cumulativeAccuracy = 0;
+ let perfectCount = 0;
+ let speedCount = 0;
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ const accuracy = accuracyRatio(item.record);
+ const duration = durationSeconds(item.record);
+ cumulativeDuration += duration;
+ cumulativeAccuracy += accuracy;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt;
+ if (accuracy >= 1) {
+ perfectCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt;
+ if (perfectCount === 3) candidates.perfect_three = item.unlockedAt;
+ if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt;
+ }
+ if (duration > 0 && duration <= 300 && accuracy > 0.8) {
+ speedCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt;
+ if (speedCount === 3) candidates.speed_three = item.unlockedAt;
+ if (speedCount === 10) candidates.speed_ten = item.unlockedAt;
+ }
+ }
+
+ const dayItems = new Map();
+ for (const item of items) {
+ if (!item.unlockedAt) continue;
+ const day = item.unlockedAt.slice(0, 10);
+ if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt);
+ }
+ const days = Array.from(dayItems.keys()).sort();
+ let streak = 0;
+ let previousDay = null;
+ for (const day of days) {
+ const currentDay = new Date(`${day}T00:00:00.000Z`).getTime();
+ streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1;
+ previousDay = currentDay;
+ if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day);
+ if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day);
+ if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day);
+ if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day);
+ }
+
+ const progress = {};
+ const mergeUnlocked = (source) => {
+ for (const [rawId, value] of Object.entries(asObject(source))) {
+ if (!value || rawId === 'updatedAt') continue;
+ const id = rawId;
+ const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null;
+ if (!progress[id]) progress[id] = { unlockedAt };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ };
+ mergeUnlocked(existing);
+ mergeUnlocked(manual);
+ for (const [id, unlockedAt] of Object.entries(candidates)) {
+ if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ return jsonValue(progress, 'achievement progress');
+ }
+
+ // Entity records are authoritative. Projections are assembled on reads, never cached or
+ // scheduled as follow-up work; this keeps a successful write immediately observable.
+ async function retryMergeConflict(options, task, maxAttempts = 3) {
+ const explicitRevision = hasOwn(options, 'expectedRevision');
+ let lastError;
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await task();
+ } catch (error) {
+ lastError = error;
+ if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) {
+ throw error;
+ }
+ }
+ }
+ throw lastError;
+ }
+
+ async function readCollectionMeta(logicalKey) {
+ const meta = await kernel.read(logicalKey, { withMeta: true });
+ return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 };
+ }
+
+ function retainBackupEntries(items, limit = 20, preserveIds = []) {
+ const cap = Math.max(1, Number(limit) || 20);
+ const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
+ const entries = asArray(items).filter(Boolean).sort(newestFirst);
+ const retained = [];
+ const retainedIds = new Set();
+ const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean));
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap) break;
+ if (retainedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ return retained;
+ }
+
+ function hasOwn(value, key) {
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
+ }
+
+ function normalizeLibraryConfigurationId(value) {
+ return importedLibraryId(value, { nullable: true });
+ }
+
+ async function practiceRecordWithLibraryProvenance(source, command, options = {}) {
+ assertObject(source, 'practice record must be an object');
+ const record = jsonValue(source, 'practice record');
+ const metadata = asObject(record.metadata);
+ let configurationId;
+
+ if (hasOwn(command, 'libraryConfigurationId')) {
+ configurationId = command.libraryConfigurationId;
+ } else if (hasOwn(metadata, 'libraryConfigurationId')) {
+ configurationId = metadata.libraryConfigurationId;
+ } else if (hasOwn(record, 'libraryConfigurationId')) {
+ configurationId = record.libraryConfigurationId;
+ } else {
+ configurationId = await kernel.read('library.activeConfigurationId');
+ }
+
+ const normalizedId = normalizeLibraryConfigurationId(configurationId);
+ record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId });
+
+ if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) {
+ record.suiteEntries = record.suiteEntries.map((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
+ const next = jsonValue(entry, 'practice suite entry');
+ const entryMetadata = asObject(next.metadata);
+ const entryId = hasOwn(entryMetadata, 'libraryConfigurationId')
+ ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId)
+ : normalizedId;
+ next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId });
+ return next;
+ });
+ }
+
+ return record;
+ }
+
+ function practiceRecordMatches(record, identities) {
+ const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean));
+ if (!expected.size || !record || typeof record !== 'object') return false;
+ return ['id', 'recordId', 'sessionId'].some((field) => {
+ const value = record[field];
+ return value !== undefined && value !== null && expected.has(String(value));
+ });
+ }
+
+ function practiceLayerId(row) {
+ return String(row && (row.recordId || row.id || row.sessionId) || '');
+ }
+ async function practiceLayers(recordId, withMeta = false) {
+ const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta });
+ const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') };
+ }
+ function entityRevision(row) { return row ? Number(row.revision) : 0; }
+ function practiceUpserts(recordId, layers, existing = {}) {
+ return [
+ { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) },
+ { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) },
+ { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) }
+ ];
+ }
+ async function joinedPractice(recordId, projection, snapshot = null) {
+ const mode = String(projection || 'full').toLowerCase();
+ const stores = mode === 'light' || mode === 'summary'
+ ? ['practiceSummaries']
+ : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined);
+ const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores });
+ const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ const summary = find('practiceSummaries');
+ if (!summary) return null;
+ if (mode === 'light' || mode === 'summary') return clone(summary);
+ const detail = find('practiceDetails');
+ if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode);
+ return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode);
+ }
+ const practice = Object.freeze({
+ async list(options = {}) {
+ await ready;
+ const projection = String(options.projection || 'full').toLowerCase();
+ const summaries = await kernel.listEntities('practiceSummaries');
+ if (projection === 'light' || projection === 'summary') return summaries;
+ const stores = projection === 'detail' || projection === 'medium'
+ ? ['practiceSummaries', 'practiceDetails']
+ : undefined;
+ const snapshot = await kernel.readPracticeSnapshot(null, { stores });
+ return (await Promise.all(asArray(snapshot.practiceSummaries)
+ .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean);
+ },
+ async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); },
+ async completeAttempt(command) {
+ await ready;
+ const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command;
+ const mutation = mutationOptions(command, 'practice-complete', source);
+ const recordInput = await practiceRecordWithLibraryProvenance(source, command);
+ if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId);
+ const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id;
+ const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities(
+ practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation));
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async finalizeSuite(command) {
+ await ready; assertObject(command, 'finalizeSuite command is required');
+ const mutation = mutationOptions(command, 'practice-suite', command);
+ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true });
+ if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId);
+ const layers = splitPracticeRecord(input); const recordId = layers.summary.id;
+ const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String);
+ const children = new Set((await kernel.listEntities('practiceSummaries'))
+ .filter((summary) => practiceRecordMatches(summary, childIdentities))
+ .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId'])));
+ children.delete(recordId);
+ const receipt = await retryMergeConflict(command, async () => {
+ const existing = await practiceLayers(recordId, true);
+ const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id })));
+ return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation);
+ });
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async updateAnnotations(command) {
+ await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || '');
+ return retryMergeConflict(command, async () => {
+ const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`);
+ if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`);
+ const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data)));
+ const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default');
+ if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) {
+ if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`);
+ annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) });
+ } else {
+ if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`);
+ annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) });
+ Object.assign(annotations, clone(asObject(command.patch)));
+ }
+ return kernel.mutateEntities([{
+ type: 'upsert',
+ store: 'practiceAnnotations',
+ recordId,
+ data: annotations,
+ expectedRevision: entityRevision(current.annotations)
+ }], mutationOptions(command, 'practice-annotations', command));
+ });
+ },
+ async delete(command) {
+ await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required');
+ const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId }));
+ return Object.assign({}, receipt, { deletedCount: 1 });
+ },
+ async deleteMany(command) {
+ await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean)));
+ if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id])));
+ if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length });
+ },
+ async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); },
+ async listInsights(options = {}) {
+ await ready;
+ const limit = Math.max(1, Math.min(50, Number(options.limit) || 10));
+ const summaries = (await kernel.listEntities('practiceSummaries'))
+ .slice()
+ .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '')
+ .localeCompare(String(left.date || left.completedAt || left.timestamp || '')))
+ .slice(0, limit);
+ return Promise.all(summaries.map(async (summary) => {
+ if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary);
+ const detail = await kernel.readEntity('practiceDetails', summary.id);
+ return jsonValue(Object.assign({}, clone(summary), {
+ questionTypeErrorCounts: questionTypeErrorCounts(detail)
+ }), 'practice insight');
+ }));
+ },
+ async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); },
+ projectLight,
+ projectDetail
+ });
+
+ const settings = Object.freeze({
+ async getAll() { await ready; return kernel.read('settings.values'); },
+ async patch(values, options = {}) {
+ await ready; assertObject(values, 'settings.patch requires an object');
+ const mutation = optionsMutationOptions(options, 'settings-patch', values);
+ return retryMergeConflict(options, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ });
+ },
+ async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); }
+ });
+
+ const library = Object.freeze({
+ async listConfigurations() { await ready; return kernel.read('library.configurations'); },
+ async getActive() { await ready; return kernel.read('library.activeConfigurationId'); },
+ async getIndex(configurationId) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ if (id === null) return [];
+ const indexes = await kernel.read('library.importedIndexes');
+ return asArray(indexes[id]);
+ },
+ async updateConfiguration(configuration, options = {}) {
+ await ready; assertObject(configuration, 'library.updateConfiguration requires an object');
+ const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId']));
+ const current = await kernel.read('library.configurations', { withMeta: true });
+ const configs = asArray(current.data);
+ const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id);
+ const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id });
+ if (index >= 0) configs[index] = next; else configs.push(next);
+ return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration));
+ },
+ async activate(configurationId, options = {}) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ const current = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id }));
+ },
+ async import(command) {
+ await ready; assertObject(command, 'library.import requires a command');
+ const id = importedLibraryId(command.id || command.configurationId || randomId('library'));
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id);
+ configs.push(Object.assign({}, asObject(command.configuration), { id, key: id }));
+ const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) });
+ return kernel.mutate([
+ { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ], mutationOptions(command, 'library-import', command));
+ },
+ async remove(configurationId, options = {}) {
+ await ready; const id = importedLibraryId(configurationId);
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id];
+ const changes = [
+ { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ];
+ if (String(activeMeta.data || '') === id) {
+ changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id }));
+ },
+ async resolveIndex() {
+ await ready;
+ const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]);
+ return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]);
+ }
+ });
+
+ function recoveryKey(kind) {
+ const key = RECOVERY_KEYS[String(kind || '')];
+ if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`);
+ return key;
+ }
+ // Recovery document TTL is an AppData domain rule, not a catalog policy field.
+ const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ function recoveryTimestamp(item) {
+ for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) {
+ const parsed = Date.parse(item && item[field]);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return null;
+ }
+ async function pruneRecoveryKey(logicalKey) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const items = asArray(current.data);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const retained = items.filter((item) => {
+ const timestamp = recoveryTimestamp(item);
+ return timestamp === null || timestamp > cutoff;
+ });
+ if (retained.length === items.length) return items;
+ try {
+ await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], {
+ operationId: randomId('recovery-ttl')
+ });
+ return retained;
+ } catch (error) {
+ if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error;
+ }
+ }
+ return kernel.read(logicalKey);
+ }
+ async function cleanupExpiredRecovery() {
+ for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey);
+ }
+ const windowSession = Object.freeze({
+ save(name, value) {
+ if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable');
+ const logicalName = String(name || 'default');
+ const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) };
+ global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload));
+ return true;
+ },
+ get(name) {
+ if (!global.sessionStorage) return null;
+ const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null;
+ },
+ discard(name) {
+ if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ return true;
+ }
+ });
+
+ const recoveryMutationTails = new Map();
+ function enqueueRecoveryMutation(logicalKey, task) {
+ const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve();
+ const result = previous.then(task, task);
+ recoveryMutationTails.set(logicalKey, result.catch(() => undefined));
+ return result;
+ }
+
+ async function readRecovery(kind, id) {
+ await ready;
+ const items = await pruneRecoveryKey(recoveryKey(kind));
+ return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null;
+ }
+ async function saveRecovery(kind, value, options = {}) {
+ await ready; assertObject(value, `recovery ${kind} value must be an object`);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value);
+ const key = recoveryKey(kind);
+ const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId);
+ const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() });
+ const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ if (index >= 0) current.items[index] = item; else current.items.push(item);
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation);
+ }));
+ const committedItem = (await kernel.read(key))
+ .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ return Object.assign({}, receipt, { item: clone(committedItem || item) });
+ }
+ async function discardRecovery(kind, id, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id));
+ return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearRecovery(kind, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) {
+ throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision });
+ }
+ return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearAllRecovery(options = {}) {
+ const results = {};
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ results[kind] = await clearRecovery(kind, options);
+ }
+ return results;
+ }
+ const recovery = Object.freeze({
+ windowSession,
+ async clear(options = {}) { return clearAllRecovery(options); },
+ async listActiveSessions() { return readRecovery('activeSession'); },
+ async getActiveSession(id) { return readRecovery('activeSession', id); },
+ async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); },
+ async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async listDrafts() { return readRecovery('draft'); },
+ async getDraft(id) { return readRecovery('draft', id); },
+ async saveDraft(value, options) { return saveRecovery('draft', value, options); },
+ async discardDraft(id, options) { return discardRecovery('draft', id, options); },
+ async listInterrupted() { return readRecovery('interrupted'); },
+ async getInterrupted(id) { return readRecovery('interrupted', id); },
+ async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); },
+ async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); },
+ async listRejectedCompletions() { return readRecovery('rejectedCompletion'); },
+ async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); },
+ async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); },
+ async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); }
+ });
+
+ function isImportableEntry(entry) {
+ return entry
+ && entry.classification !== 'system'
+ && entry.classification !== 'session'
+ && entry.import !== 'ignore';
+ }
+
+ function isPlainImportObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
+ }
+
+ function isV2SnapshotShape(parsed) {
+ return isPlainImportObject(parsed)
+ && parsed.format === 'ielts-atlas-data-v2'
+ && isPlainImportObject(parsed.envelopes)
+ && isPlainImportObject(parsed.entities);
+ }
+
+ const POISONED_V2_WRAPPER_ALIASES = Object.freeze({
+ 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']),
+ 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']),
+ 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state'])
+ });
+ const LIBRARY_IMPORT_KEYS = Object.freeze([
+ 'library.configurations',
+ 'library.importedIndexes',
+ 'library.activeConfigurationId'
+ ]);
+
+ function canonicalizeV2Import(parsed) {
+ const warnings = [];
+ const repairedKeys = [];
+ const ignoredKeys = [];
+ const envelopes = {};
+ for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const envelope = clone(rawEnvelope);
+ const data = envelope && envelope.state === 'present' ? envelope.data : null;
+ if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') {
+ ignoredKeys.push(logicalKey);
+ warnings.push('Skipped poisoned active library id');
+ continue;
+ }
+ if (isPlainImportObject(data)
+ && Object.prototype.hasOwnProperty.call(data, 'key')
+ && Object.prototype.hasOwnProperty.call(data, 'value')
+ && String(data.key || '').startsWith('exam_system_')) {
+ const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || [];
+ const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null;
+ if (!isPlainImportObject(decoded)) {
+ ignoredKeys.push(logicalKey);
+ warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`);
+ continue;
+ }
+ const overlay = Object.fromEntries(Object.entries(data)
+ .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp'));
+ envelope.data = Object.assign({}, decoded, overlay);
+ envelope.checksum = checksum(envelope.data);
+ repairedKeys.push(logicalKey);
+ warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`);
+ }
+ envelopes[logicalKey] = envelope;
+ }
+
+ if (parsed.scope === 'full') {
+ const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key));
+ if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) {
+ for (const key of presentLibraryKeys) {
+ delete envelopes[key];
+ ignoredKeys.push(key);
+ }
+ warnings.push('Skipped incomplete library data');
+ }
+ }
+ const exportableKeys = catalog.list()
+ .filter((entry) => entry.export === true && isImportableEntry(entry))
+ .map((entry) => entry.logicalKey);
+ const missingKeys = parsed.scope === 'full'
+ ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key))
+ : [];
+ const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length);
+ return {
+ envelopes,
+ warnings,
+ repairedKeys,
+ ignoredKeys,
+ missingKeys,
+ declaredScope: parsed.scope,
+ effectiveScope: degraded ? 'partial' : parsed.scope,
+ trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial')
+ };
+ }
+
+ function resolveImportReplaceFlags(options = {}) {
+ const source = asObject(options);
+ const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase();
+ const replaceAll = source.replace === true;
+ return {
+ replaceDocuments: replaceAll,
+ // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge.
+ replacePractice: replaceAll || practiceMode === 'replace'
+ };
+ }
+
+ function pickFirstRecordArray(candidates) {
+ for (const candidate of asArray(candidates)) {
+ if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) {
+ return { source: candidate.source, records: candidate.records };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Historical v1 export shapes (opensource / pre-AppData-v2):
+ * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats }
+ * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? }
+ * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*)
+ * - bare array of records, or { records: [...] }
+ * Recognition only — no dual backend and no local store migration.
+ */
+ function extractLegacyPracticeRecords(payload) {
+ const sources = [];
+ const add = (source, records) => {
+ if (Array.isArray(records) && records.some(isPlainImportObject)) {
+ sources.push({ source, records });
+ }
+ };
+
+ if (Array.isArray(payload)) {
+ add('(root array)', payload);
+ } else if (isPlainImportObject(payload)) {
+ const preferred = pickFirstRecordArray([
+ { source: 'practice_records', records: payload.practice_records },
+ { source: 'practiceRecords', records: payload.practiceRecords },
+ { source: 'records', records: payload.records }
+ ]);
+ if (preferred) add(preferred.source, preferred.records);
+
+ const data = isPlainImportObject(payload.data) ? payload.data : null;
+ if (data) {
+ const nested = pickFirstRecordArray([
+ { source: 'data.practice_records', records: data.practice_records },
+ { source: 'data.practiceRecords', records: data.practiceRecords }
+ ]);
+ if (nested) add(nested.source, nested.records);
+ else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data);
+ else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data);
+ if (isPlainImportObject(data.exam_system_practice_records)) {
+ add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
+ }
+ }
+ if (isPlainImportObject(payload.exam_system_practice_records)) {
+ add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
+ }
+ }
+
+ const seen = new Set();
+ const records = [];
+ for (const entry of sources) {
+ for (const item of asArray(entry.records)) {
+ if (!isPlainImportObject(item)) continue;
+ const identity = idOf(item, ['id', 'recordId', 'sessionId']);
+ if (identity) {
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ }
+ records.push(item);
+ }
+ }
+ return {
+ records,
+ sources: sources.map((entry) => entry.source)
+ };
+ }
+
+ function entityRowFromLayer(recordId, data, operationId) {
+ const payload = jsonValue(data, 'import practice entity');
+ return {
+ recordId: String(recordId),
+ revision: 1,
+ operationId: String(operationId || `import-${recordId}`),
+ updatedAt: nowIso(),
+ data: payload,
+ checksum: checksum(payload)
+ };
+ }
+
+ function convertLegacyPracticeImport(payload) {
+ const extracted = extractLegacyPracticeRecords(payload);
+ if (!extracted.records.length) {
+ throw new AppDataError(
+ 'VALIDATION',
+ 'Import file is neither a v2 snapshot nor a recognizable v1 practice export'
+ );
+ }
+
+ const entities = {
+ practiceSummaries: [],
+ practiceDetails: [],
+ practiceAnnotations: []
+ };
+ const warnings = [];
+ let skipped = 0;
+
+ for (const raw of extracted.records) {
+ try {
+ const layers = splitPracticeRecord(raw);
+ const recordId = layers.summary.id;
+ const operationId = `import-v1-${recordId}`;
+ entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId));
+ entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId));
+ entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId));
+ } catch (error) {
+ skipped += 1;
+ warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`);
+ }
+ }
+
+ if (!entities.practiceSummaries.length) {
+ throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized');
+ }
+
+ const accepted = entities.practiceSummaries.length;
+ return {
+ format: 'v1',
+ scope: 'partial',
+ envelopes: {},
+ entities,
+ checksum: null,
+ warnings,
+ practiceSummary: {
+ accepted,
+ importedCount: accepted,
+ skippedCount: skipped,
+ sources: extracted.sources.slice()
+ }
+ };
+ }
+
+ function parseImportPayload(payload) {
+ let parsed;
+ try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); }
+ catch (error) {
+ if (error instanceof AppDataError) throw error;
+ throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message });
+ }
+
+ // Bare record arrays are a historical import convenience (UI file pickers).
+ if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed);
+ if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object');
+
+ if (isV2SnapshotShape(parsed)) {
+ if (Number(parsed.schemaVersion) !== Number(catalog.version)) {
+ throw new AppDataError('VALIDATION', 'Import schema version mismatch');
+ }
+ if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) {
+ throw new AppDataError('VALIDATION', 'Import checksum mismatch');
+ }
+ if (parsed.scope !== 'full' && parsed.scope !== 'partial') {
+ throw new AppDataError('VALIDATION', 'Import scope must be full or partial');
+ }
+ const scope = parsed.scope;
+ for (const [store, rows] of Object.entries(parsed.entities)) {
+ if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) {
+ throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`);
+ }
+ for (const row of rows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) {
+ throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ }
+ }
+ }
+ if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) {
+ throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer');
+ }
+ const canonical = canonicalizeV2Import(parsed);
+ return {
+ format: 'v2',
+ scope: canonical.effectiveScope,
+ declaredScope: canonical.declaredScope,
+ envelopes: canonical.envelopes,
+ entities: parsed.entities,
+ checksum: parsed.checksum,
+ warnings: canonical.warnings,
+ practiceSummary: null,
+ repairedKeys: canonical.repairedKeys,
+ ignoredKeys: canonical.ignoredKeys,
+ missingKeys: canonical.missingKeys,
+ trust: canonical.trust
+ };
+ }
+
+ // Explicit but malformed v2 claims must not fall through to legacy parsers.
+ if (parsed.format === 'ielts-atlas-data-v2') {
+ throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported');
+ }
+
+ return convertLegacyPracticeImport(parsed);
+ }
+
+ function collectionIdentityFields(logicalKey) {
+ if (logicalKey === 'library.configurations') return ['id', 'key', 'configId'];
+ if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId'];
+ if (logicalKey === 'backups.entries') return ['id'];
+ if (logicalKey === 'vocab.words') return ['id', 'word', 'key'];
+ if (logicalKey === 'goals.items') return ['id', 'goalId'];
+ return ['id', 'sessionId', 'recordId'];
+ }
+
+ function collectionIdentity(logicalKey, value) {
+ const identity = idOf(value, collectionIdentityFields(logicalKey));
+ return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity;
+ }
+
+ function mergeCollection(existing, incoming, logicalKey) {
+ const result = asArray(existing).map((item) => clone(item));
+ const positions = new Map();
+ result.forEach((item, index) => {
+ const identity = collectionIdentity(logicalKey, item);
+ if (identity) positions.set(identity, index);
+ });
+ for (const rawItem of asArray(incoming)) {
+ const item = jsonValue(rawItem, `${logicalKey} item`);
+ const identity = collectionIdentity(logicalKey, item);
+ if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`);
+ if (positions.has(identity)) result[positions.get(identity)] = item;
+ else {
+ positions.set(identity, result.length);
+ result.push(item);
+ }
+ }
+ return result;
+ }
+
+ function mergeImportValue(entry, existing, incoming) {
+ const policy = entry.import;
+ if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey);
+ if (policy === 'patch') {
+ if (Array.isArray(existing) || Array.isArray(incoming)) {
+ // Array-shaped keys should use merge-by-id; treat accidental patch as replace.
+ return clone(incoming);
+ }
+ return Object.assign({}, asObject(existing), asObject(incoming));
+ }
+ if (policy === 'replace') return clone(incoming);
+ throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`);
+ }
+
+ async function currentEntitySnapshot() {
+ const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true });
+ const result = {};
+ for (const store of PRACTICE_ENTITY_STORES) {
+ if (store === 'practiceSummaries') result[store] = summaries;
+ else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean);
+ }
+ return result;
+ }
+ function practiceEntityIds(rows) {
+ return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean));
+ }
+ function assertPracticeEntitySetsMatch(entities, message) {
+ const expected = practiceEntityIds(entities.practiceSummaries);
+ for (const store of PRACTICE_ENTITY_STORES.slice(1)) {
+ const actual = practiceEntityIds(entities[store]);
+ if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) {
+ throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', {
+ counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size]))
+ });
+ }
+ }
+ }
+ async function createImportPlan(parsed, options = {}) {
+ const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options);
+ const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} };
+ const revisionToken = { documents: {}, entities: {} };
+ const keys = []; const clearedKeys = [];
+ const warnings = asArray(parsed.warnings).map(String);
+ for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue;
+ if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`);
+ if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) {
+ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`);
+ continue;
+ }
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0;
+ let next = envelope;
+ if (!replaceDocuments && envelope.state === 'present') {
+ next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') });
+ }
+ snapshot.envelopes[logicalKey] = next;
+ keys.push(logicalKey);
+ if (next.state === 'cleared') clearedKeys.push(logicalKey);
+ }
+
+ // A full replace mirrors all exportable user data. Missing physical
+ // envelopes mean catalog defaults, represented here as explicit clears.
+ if (replaceDocuments && parsed.scope === 'full') {
+ for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue;
+ snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: randomId('import-clear')
+ });
+ keys.push(entry.logicalKey);
+ clearedKeys.push(entry.logicalKey);
+ }
+ }
+
+ // Any successful practice import installs all three stores together. Merge
+ // may update a subset only when the final recordId sets remain identical.
+ const sourceStores = Object.keys(asObject(parsed.entities));
+ let practiceExistingCount = null;
+ let practiceIncomingCount = null;
+ if (sourceStores.length) {
+ if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) {
+ throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations');
+ }
+ const current = await currentEntitySnapshot();
+ revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries(
+ asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0])
+ )]));
+ practiceExistingCount = asArray(current.practiceSummaries).length;
+ practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length;
+ const existing = replacePractice
+ ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []]))
+ : current;
+ for (const store of PRACTICE_ENTITY_STORES) {
+ const rows = asArray(existing[store]).map(clone);
+ const positions = new Map(rows.map((row, index) => [String(row.recordId), index]));
+ for (const row of asArray(parsed.entities[store])) {
+ if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ const index = positions.get(String(row.recordId));
+ if (index === undefined) {
+ positions.set(String(row.recordId), rows.length);
+ rows.push(clone(row));
+ } else rows[index] = clone(row);
+ }
+ snapshot.entities[store] = rows;
+ }
+ assertPracticeEntitySetsMatch(snapshot.entities);
+ }
+
+ snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities });
+ const practiceSummary = parsed.practiceSummary
+ ? clone(parsed.practiceSummary)
+ : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries')
+ ? {
+ accepted: Number(practiceIncomingCount) || 0,
+ importedCount: Number(practiceIncomingCount) || 0,
+ skippedCount: 0,
+ existingCount: Number(practiceExistingCount) || 0,
+ incomingCount: Number(practiceIncomingCount) || 0,
+ finalCount: asArray(snapshot.entities.practiceSummaries).length,
+ removedCount: Math.max(0, (Number(practiceExistingCount) || 0)
+ - asArray(snapshot.entities.practiceSummaries).length)
+ }
+ : null);
+ if (practiceSummary && practiceSummary.existingCount === undefined) {
+ practiceSummary.existingCount = Number(practiceExistingCount) || 0;
+ practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0;
+ practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length;
+ practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount);
+ }
+ const destructive = clearedKeys.length > 0
+ || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0);
+ return {
+ snapshot,
+ keys,
+ clearedKeys,
+ warnings,
+ practiceSummary,
+ destructive,
+ resetJournal: replaceDocuments && replacePractice,
+ revisionToken,
+ diagnostics: {
+ format: parsed.format,
+ replaceDocuments,
+ replacePractice,
+ declaredScope: parsed.declaredScope || parsed.scope,
+ effectiveScope: parsed.scope,
+ trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'),
+ missingKeys: clone(parsed.missingKeys || []),
+ repairedKeys: clone(parsed.repairedKeys || []),
+ ignoredKeys: clone(parsed.ignoredKeys || [])
+ }
+ };
+ }
+ async function createRestoreSnapshot(backup) {
+ const parsed = parseImportPayload(asObject(backup && backup.data));
+ if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups');
+ if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch');
+ return (await createImportPlan(parsed, { replace: true })).snapshot;
+ }
+
+ const backups = Object.freeze({
+ onDataCommitted(listener) { return kernel.onCommitted(listener); },
+ async getSettings() { await ready; return kernel.read('backups.settings'); },
+ async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); },
+ async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); },
+ async getImportHistory() { await ready; return kernel.read('backups.importHistory'); },
+ async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); },
+ async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); },
+ async create(options = {}) {
+ await ready; const current = await readCollectionMeta('backups.entries');
+ const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' });
+ const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup'));
+ const existing = current.items.find((item) => String(item.id) === String(backupId));
+ if (existing) {
+ if (String(existing.operationId || '') === String(mutation.operationId)
+ && String(existing.type || 'manual') === String(options.type || 'manual')) {
+ return clone(existing);
+ }
+ throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, {
+ backupId: String(backupId)
+ });
+ }
+ const snapshot = await kernel.exportSnapshot();
+ const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum };
+ current.items.unshift(backup);
+ current.items = retainBackupEntries(current.items, 20, options.preserveIds);
+ await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation);
+ const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId));
+ return clone(committed || backup);
+ },
+ async list() { await ready; return kernel.read('backups.entries'); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); },
+ async export(options = {}) {
+ await ready;
+ if (options.backupId !== undefined && options.backupId !== null) {
+ const backupId = String(options.backupId);
+ const stored = asArray(await kernel.read('backups.entries'))
+ .find((item) => String(item && item.id) === backupId);
+ if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`);
+ const portable = jsonValue(stored, 'stored backup export');
+ if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) {
+ throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`);
+ }
+ return portable;
+ }
+ const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null;
+ const logicalKeys = domains
+ ? catalog.list()
+ .filter((entry) => domains.has(entry.owner) && entry.export === true)
+ .map((entry) => entry.logicalKey)
+ : null;
+ const entityStores = !domains || domains.has('practice')
+ ? undefined
+ : [];
+ return kernel.exportSnapshot(Object.assign(
+ logicalKeys ? { logicalKeys } : {},
+ entityStores ? { entityStores } : {}
+ ));
+ },
+ async previewImport(payload, options = {}) {
+ await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan');
+ const cutoff = Date.now() - (30 * 60 * 1000);
+ for (const [id, existing] of importPlans) {
+ if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id);
+ }
+ const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) };
+ importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive };
+ },
+ async commitImport(planId, options = {}) {
+ await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`);
+ if (plan.destructive && options.confirmDestructive !== true) {
+ throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation');
+ }
+ const mutation = optionsMutationOptions(options, 'import-commit', {
+ planId: plan.id,
+ signature: plan.signature
+ }, { warnings: plan.warnings });
+ const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, {
+ resetJournal: plan.resetJournal === true,
+ expectedRevisionToken: plan.revisionToken
+ }));
+ importPlans.delete(String(planId));
+ return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) });
+ },
+ async restore(id, options = {}) {
+ await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id));
+ if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`);
+ const snapshot = await createRestoreSnapshot(backup);
+ const restoreMutation = optionsMutationOptions(options, 'backup-restore', {
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }, { resetJournal: true });
+ const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`;
+ const preRestoreBackupId = `pre_restore_${checksum({
+ operationId: restoreMutation.operationId,
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }).replace(/[^a-z0-9]/gi, '')}`;
+ const preRestoreBackup = await backups.create({
+ id: preRestoreBackupId,
+ operationId: preRestoreOperationId,
+ type: 'pre-restore',
+ preserveIds: [String(id)]
+ });
+ const receipt = await kernel.installSnapshot(snapshot, restoreMutation);
+ return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id });
+ }
+ });
+
+ let vocabMutationTail = Promise.resolve();
+ function enqueueVocabMutation(task) {
+ const result = vocabMutationTail.then(task, task);
+ vocabMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ function retryVocabMutation(options, task) {
+ return enqueueVocabMutation(() => retryMergeConflict(options, task));
+ }
+
+ const vocab = Object.freeze({
+ async listWords() { await ready; return kernel.read('vocab.words'); },
+ async saveWords(words, options = {}) {
+ await ready; assertArray(words, 'vocab.saveWords requires an array');
+ const mutation = optionsMutationOptions(options, 'vocab-words', words);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.words', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.words',
+ data: words,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async getConfig() { await ready; return kernel.read('vocab.userConfig'); },
+ async setConfig(config, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'vocab-config', config);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: asObject(config),
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async patchConfig(patch, options = {}) {
+ await ready; assertObject(patch, 'vocab.patchConfig requires an object');
+ const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), clone(patch));
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); },
+ async listCollections() { await ready; return kernel.read('vocab.lists'); },
+ async saveCollection(id, value, options = {}) {
+ await ready;
+ const collectionId = String(id);
+ const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async saveCollections(values, options = {}) {
+ await ready;
+ assertObject(values, 'vocab.saveCollections requires an object');
+ const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)]));
+ const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), upserts);
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async upsertCollectionWord(collectionId, word, options = {}) {
+ await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word');
+ const id = String(collectionId || '');
+ if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required');
+ const identity = String(word.word || word.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ const existing = collections[id];
+ const list = existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? Object.assign({}, clone(existing), { words: asArray(existing.words) })
+ : { id, words: asArray(existing) };
+ const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity);
+ const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() });
+ if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt;
+ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord);
+ list.updatedAt = nowIso();
+ collections[id] = list;
+ const receipt = await kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(nextWord) });
+ });
+ },
+ async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; },
+ async replaceListWords(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceListWords requires a command');
+ const id = String(command.listId || 'default'); const words = asArray(command.words);
+ if (id === 'default') return this.saveWords(words, options);
+ const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async mergeListWords(command, options = {}) {
+ await ready;
+ assertObject(command, 'vocab.mergeListWords requires a command');
+ const listId = String(command.listId || 'default');
+ const incoming = asArray(command.words);
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(options, 'vocab-words-merge', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data));
+ const storedList = listId === 'default'
+ ? asArray(current.data)
+ : (function readStoredCollection() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? asArray(collection.words)
+ : asArray(collection);
+ }());
+ const merged = storedList.map((word) => clone(word));
+ const positions = new Map();
+ merged.forEach((word, index) => {
+ const identity = String(word && (word.word || word.id) || '').trim().toLowerCase();
+ if (identity) positions.set(identity, index);
+ });
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const rawWord of incoming) {
+ assertObject(rawWord, 'vocab.mergeListWords entries must be objects');
+ const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ if (!positions.has(identity)) {
+ positions.set(identity, merged.length);
+ merged.push(clone(rawWord));
+ addedCount += 1;
+ continue;
+ }
+ const index = positions.get(identity);
+ const existing = asObject(merged[index]);
+ const patch = {};
+ if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim();
+ if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim();
+ if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq;
+ merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() });
+ updatedCount += 1;
+ }
+ const data = listId === 'default'
+ ? merged
+ : Object.assign({}, collections, {
+ [listId]: Object.assign(
+ {},
+ (function collectionBaseForWrite() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? clone(collection)
+ : {};
+ }()),
+ { id: listId, words: merged, updatedAt: nowIso() }
+ )
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount });
+ });
+ },
+ async patchWord(command, options = {}) {
+ await ready; assertObject(command, 'vocab.patchWord requires a command');
+ const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || '');
+ if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required');
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(
+ Object.assign({}, options, { operationId: command.operationId || options.operationId }),
+ 'vocab-word-patch',
+ command
+ );
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : asObject(current.data);
+ const list = listId === 'default'
+ ? asArray(current.data)
+ : asArray(asObject(collections[listId]).words);
+ const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId);
+ if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`);
+ const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() });
+ const next = list.slice(); next[index] = updated;
+ const data = listId === 'default'
+ ? next
+ : Object.assign({}, collections, {
+ [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() })
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(updated) });
+ });
+ },
+ async replaceProgress(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceProgress requires a command');
+ const listId = String(command.listId || 'default'); const words = asArray(command.words);
+ const mutation = optionsMutationOptions(options, 'vocab-progress', command);
+ return retryVocabMutation(options, async () => {
+ const configMeta = await kernel.read('vocab.userConfig', { withMeta: true });
+ const changes = [{
+ logicalKey: 'vocab.userConfig',
+ data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }),
+ expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0
+ }];
+ if (listId === 'default') {
+ const wordsMeta = await kernel.read('vocab.words', { withMeta: true });
+ changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 });
+ } else {
+ const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data));
+ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words });
+ changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, mutation);
+ });
+ }
+ });
+
+ async function readPreferences() { await ready; return kernel.read('preferences.values'); }
+ let preferenceMutationTail = Promise.resolve();
+ function enqueuePreferenceMutation(task) {
+ const result = preferenceMutationTail.then(task, task);
+ preferenceMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ async function writePreference(field, value, options = {}) {
+ const mutation = optionsMutationOptions(options, 'preference-set', { field, value });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [field]: clone(value) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ async function patchPreference(field, patch, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const values = asObject(current.data);
+ const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ const preferences = Object.freeze({
+ async getAll() { return readPreferences(); },
+ async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); },
+ async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); },
+ async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); },
+ async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); },
+ async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); }
+ ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); },
+ async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); },
+ async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); },
+ async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); },
+ async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); },
+ async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); },
+ async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); },
+ async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); }
+ });
+
+ const goals = Object.freeze({
+ async list() { await ready; return kernel.read('goals.items'); },
+ async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); }
+ });
+
+ function deliveryTimestamp(value) {
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN;
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function mergeDeliveryAcknowledgements(current, incoming) {
+ const merged = Object.assign({}, asObject(current));
+ for (const [id, value] of Object.entries(asObject(incoming))) {
+ const key = String(id).trim();
+ if (!key) continue;
+ const previous = deliveryTimestamp(merged[key]);
+ const next = deliveryTimestamp(value);
+ if (!hasOwn(merged, key) || (next && (!previous || next < previous))) {
+ merged[key] = next;
+ } else if (previous) {
+ merged[key] = previous;
+ } else {
+ merged[key] = null;
+ }
+ }
+ return merged;
+ }
+
+ const achievements = Object.freeze({
+ async getAll() {
+ await ready;
+ const progress = await retryMergeConflict({}, async () => {
+ const [summaries, manual, current] = await Promise.all([
+ kernel.listEntities('practiceSummaries'),
+ kernel.read('achievements.manual'),
+ kernel.read('achievements.progress', { withMeta: true })
+ ]);
+ const projected = asObject(computeAchievementProgress(summaries, manual, current.data));
+ if (checksum(projected) !== checksum(asObject(current.data))) {
+ await kernel.mutate([{
+ logicalKey: 'achievements.progress',
+ data: projected,
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], {
+ operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}`
+ });
+ }
+ return projected;
+ }, 5);
+ if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh;
+ Object.defineProperty(progress, 'fresh', { value: true, enumerable: false });
+ return progress;
+ },
+ async retryPending() { return achievements.getAll(); },
+ async acknowledgeDelivery(unlocked, options = {}) {
+ await ready;
+ assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object');
+ const requested = clone(unlocked);
+ const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested);
+ return retryMergeConflict({}, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ const settingsValue = asObject(current.data);
+ const delivery = asObject(settingsValue.achievementDelivery);
+ const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested);
+ return kernel.mutate([{
+ logicalKey: 'settings.values',
+ data: Object.assign({}, settingsValue, {
+ achievementDelivery: { version: 1, acknowledged }
+ }),
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], mutation);
+ }, 5);
+ },
+ async getManualState() { await ready; return kernel.read('achievements.manual'); }
+ });
+
+ const LEGACY_DOCUMENT_ALIASES = Object.freeze({
+ 'settings.values': ['user_settings', 'settings', 'system_settings'],
+ 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'],
+ 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'],
+ 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'],
+ 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'],
+ 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'],
+ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'],
+ 'achievements.manual': ['achievement_manual_state', 'user_achievements']
+ });
+ const LEGACY_PREFERENCE_ALIASES = Object.freeze({
+ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse',
+ practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode',
+ ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed'
+ });
+
+ function mergeLegacySources(indexedDbValue, externalValue) {
+ const indexedDb = asObject(indexedDbValue);
+ const external = asObject(externalValue);
+ const merged = Object.assign({}, external, indexedDb);
+ const records = new Map();
+ const addRecords = (value) => {
+ const list = Array.isArray(value) ? value : asArray(asObject(value).data);
+ list.forEach((record) => {
+ const id = idOf(record, ['id', 'recordId', 'sessionId']);
+ records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record));
+ });
+ };
+ addRecords(external.practice_records || external.practiceRecords);
+ addRecords(indexedDb.practice_records);
+ if (records.size) merged.practice_records = Array.from(records.values());
+ return merged;
+ }
+
+ function legacyLibraryBundle(legacy) {
+ const idMap = new Map();
+ const indexes = {};
+ for (const [oldId, value] of Object.entries(asObject(legacy))) {
+ if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue;
+ const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`;
+ idMap.set(oldId, id);
+ indexes[id] = clone(value);
+ }
+ if (!idMap.size) return null;
+ const configurations = new Map();
+ asArray(legacy.exam_index_configurations).forEach((configuration) => {
+ const oldId = idOf(configuration, ['id', 'key', 'configId']);
+ const id = idMap.get(oldId);
+ if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length }));
+ });
+ for (const [oldId, id] of idMap) {
+ if (!configurations.has(id)) configurations.set(id, {
+ id,
+ key: id,
+ name: `迁移的自定义题库 (${oldId})`,
+ examCount: indexes[id].length,
+ sourceType: 'legacy-import'
+ });
+ }
+ return {
+ configurations: Array.from(configurations.values()),
+ indexes,
+ activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null
+ };
+ }
+
+ async function migrateLegacyData() {
+ // Unit embedders may provide a deliberately minimal kernel bootstrap.
+ if (typeof internals.readLegacyValues !== 'function') return;
+ const migrationMeta = await kernel.read('system.migrations', { withMeta: true });
+ const migrationState = asObject(migrationMeta.data);
+ const v1Complete = asObject(migrationState.v1ToV2).status === 'complete';
+ const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed';
+ let externalBackup = null;
+ if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') {
+ try { externalBackup = await internals.readLegacyExternalBackup(); }
+ catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message);
+ }
+ }
+ if (v1Complete && !externalBackup) return;
+
+ const indexedDb = await internals.readLegacyValues();
+ if (indexedDb && indexedDb.__legacyReadComplete === false) {
+ throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup');
+ }
+ const legacy = mergeLegacySources(indexedDb, externalBackup);
+ const changes = [];
+ for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) {
+ const current = await kernel.getEnvelope(logicalKey);
+ if (current) continue;
+ const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key));
+ if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 });
+ }
+ const libraryBundle = legacyLibraryBundle(legacy);
+ if (libraryBundle) {
+ if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) {
+ const preferences = {};
+ for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) {
+ if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue;
+ const path = target.split('.'); let cursor = preferences;
+ path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; });
+ cursor[path[path.length - 1]] = clone(legacy[alias]);
+ }
+ if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) {
+ changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 });
+ }
+ if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` });
+ const recordsValue = legacy.practice_records;
+ const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data);
+ const operations = [];
+ for (const [index, record] of records.entries()) {
+ try {
+ const candidate = clone(record);
+ if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`;
+ const canonical = canonicalizeRecord(candidate);
+ const parts = splitPracticeRecord(canonical);
+ for (const [store, data] of [
+ ['practiceSummaries', parts.summary],
+ ['practiceDetails', parts.detail],
+ ['practiceAnnotations', parts.annotations]
+ ]) {
+ if (!await kernel.readEntity(store, canonical.id)) {
+ operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 });
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message);
+ }
+ }
+ if (operations.length) {
+ await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` });
+ }
+
+ const nextMigrationState = Object.assign({}, migrationState);
+ if (!v1Complete) nextMigrationState.v1ToV2 = {
+ version: 1,
+ status: 'complete',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(indexedDb),
+ sourceRecordCount: asArray(indexedDb.practice_records).length
+ };
+ if (externalBackup) nextMigrationState.externalBackupV1 = {
+ version: 1,
+ status: 'consumed',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(externalBackup)
+ };
+ await kernel.mutate([{
+ logicalKey: 'system.migrations',
+ data: nextMigrationState,
+ expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0
+ }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` });
+ }
+
+ const ready = kernel.initialize()
+ .then(async () => {
+ // Legacy migration and recovery cleanup are best-effort: a failure here
+ // (e.g. one malformed v1 record) must not brick the data layer for every
+ // read that awaits `ready`. Only a genuine backend init failure below is fatal.
+ try {
+ await migrateLegacyData();
+ } catch (error) {
+ if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error);
+ }
+ try {
+ await cleanupExpiredRecovery();
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error);
+ }
+ return true;
+ })
+ .catch((error) => {
+ if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error);
+ throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed');
+ });
+
+ const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements };
+ Object.defineProperties(AppData, {
+ ready: { value: ready, enumerable: false },
+ status: { value: () => kernel.status(), enumerable: false }
+ });
+ Object.freeze(AppData);
+ Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false });
+ if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) {
+ throw new Error('AppData v2 failed to close its internal bootstrap channel');
+ }
+ if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) {
+ throw new Error('AppData v2 failed to close its catalog bootstrap channel');
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
+
+
/* ===== js/runtime/readingExamRegistry.js ===== */
(function initReadingExamRegistry(global) {
'use strict';
@@ -260,6 +3779,7 @@
scope,
text,
kind: resolveHighlightKind(node),
+ noteId: node.dataset && node.dataset.noteId ? String(node.dataset.noteId) : '',
occurrence: seen,
start: startOffset,
end: endOffset,
@@ -333,6 +3853,9 @@
if (offsetRange && !offsetRange.collapsed) {
const offsetSpan = document.createElement('span');
applyHighlightKind(offsetSpan, highlightKind);
+ if (record.noteId) {
+ offsetSpan.dataset.noteId = String(record.noteId);
+ }
try {
offsetRange.surroundContents(offsetSpan);
return true;
@@ -381,6 +3904,9 @@
}
const span = document.createElement('span');
applyHighlightKind(span, highlightKind);
+ if (record.noteId) {
+ span.dataset.noteId = String(record.noteId);
+ }
try {
range.surroundContents(span);
return true;
@@ -782,7 +4308,20 @@
function compareAnswers(userAnswer, correctAnswer) {
const expected = splitAnswerTokens(correctAnswer);
- const actual = splitAnswerTokens(userAnswer);
+ let actual = splitAnswerTokens(userAnswer);
+
+ if (
+ expected.length === 1
+ && /^[A-Z]$/.test(expected[0])
+ && actual.length === 1
+ && !/^[A-Z]$/.test(actual[0])
+ && typeof userAnswer === 'string'
+ ) {
+ const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/);
+ if (labeledOption) {
+ actual = [labeledOption[1]];
+ }
+ }
if (expected.length === 0 && actual.length === 0) {
return null;
@@ -1233,12 +4772,12 @@
const BUBBLE_ID = 'review-highlight-dictionary-bubble';
const INTERACTIVE_CLASS = 'review-dictionary-highlight';
const VOCAB_MESSAGE_TYPE = 'VOCAB_HIGHLIGHT_SAVE';
- const FALLBACK_STORAGE_KEY = 'exam_system_vocab_list_reading_highlights';
let currentOptions = {};
let activeHighlight = null;
let activeLookup = null;
let outsideHandlerAttached = false;
+ const pendingSaveRequests = new Map();
function cleanText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
@@ -1663,58 +5202,22 @@
meaning: cleanText(lookup.zh || ''),
definition: cleanText(lookup.en || ''),
phonetic: cleanText(lookup.phonetic || ''),
- partOfSpeech: cleanText(lookup.pos || ''),
- source: lookup.source || 'local',
- sourceLabel: lookup.sourceLabel || '本地词典',
- license: lookup.license || '',
- example: cleanText(lookup.example || ''),
- tags: Array.isArray(lookup.tags) ? lookup.tags.slice() : [],
- context: context && typeof context === 'object' ? context : {}
- };
- }
-
- function createStorageEnvelope(data) {
- return JSON.stringify({
- data,
- timestamp: Date.now(),
- version: '0.6.2-fix',
- compressed: false
- });
- }
-
- function readFallbackList() {
- try {
- const raw = global.localStorage && global.localStorage.getItem(FALLBACK_STORAGE_KEY);
- if (!raw) {
- return null;
- }
- const parsed = JSON.parse(raw);
- const data = parsed && Object.prototype.hasOwnProperty.call(parsed, 'data')
- ? parsed.data
- : parsed;
- return data && typeof data === 'object' && Array.isArray(data.words) ? data : null;
- } catch (_) {
- return null;
- }
+ partOfSpeech: cleanText(lookup.pos || ''),
+ source: lookup.source || 'local',
+ sourceLabel: lookup.sourceLabel || '本地词典',
+ license: lookup.license || '',
+ example: cleanText(lookup.example || ''),
+ tags: Array.isArray(lookup.tags) ? lookup.tags.slice() : [],
+ context: context && typeof context === 'object' ? context : {}
+ };
}
- function writeFallbackVocab(payload) {
- if (!global.localStorage || !payload || !payload.word) {
- return false;
- }
+ async function writeAppDataVocab(payload) {
+ if (!payload || !payload.word || !global.AppData || !global.AppData.vocab) return false;
+ const key = String(payload.word).trim().toLowerCase();
const now = new Date().toISOString();
- const list = readFallbackList() || {
- id: 'reading-highlights',
- name: '阅读高亮生词',
- icon: '📖',
- source: 'reading-highlight',
- words: [],
- createdAt: now,
- updatedAt: now
- };
- const key = payload.word.toLowerCase();
- const existingIndex = list.words.findIndex((item) => String(item.word || '').trim().toLowerCase() === key);
- const wordRecord = {
+ await global.AppData.ready;
+ await global.AppData.vocab.upsertCollectionWord('reading-highlights', {
id: `reading-highlight-${key.replace(/[^a-z0-9]+/g, '-')}`,
word: payload.word,
meaning: payload.meaning || payload.definition || '待补充释义',
@@ -1725,7 +5228,6 @@
payload.selectedText && payload.selectedText !== payload.word ? `原高亮: ${payload.selectedText}` : '',
payload.sourceLabel ? `来源: ${payload.sourceLabel}` : ''
].filter(Boolean).join(';'),
- timestamp: Date.now(),
source: 'reading-highlight',
easeFactor: null,
interval: 1,
@@ -1734,59 +5236,74 @@
correctCount: 0,
lastReviewed: null,
nextReview: null,
- createdAt: existingIndex >= 0 ? (list.words[existingIndex].createdAt || now) : now,
updatedAt: now
- };
- if (existingIndex >= 0) {
- list.words.splice(existingIndex, 1, { ...list.words[existingIndex], ...wordRecord });
- } else {
- list.words.push(wordRecord);
+ });
+ return true;
+ }
+
+ function createRequestId() {
+ try {
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') {
+ return `vocab-highlight-${global.crypto.randomUUID()}`;
+ }
+ } catch (_) {
+ // use timestamp fallback
}
- list.updatedAt = now;
- list.stats = {
- totalWords: list.words.length,
- masteredWords: list.words.filter((word) => (Number(word.correctCount) || 0) >= 4).length,
- reviewingWords: list.words.filter((word) => word.lastReviewed && !word.nextReview).length
- };
- global.localStorage.setItem(FALLBACK_STORAGE_KEY, createStorageEnvelope(list));
+ return `vocab-highlight-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ }
+
+ function settleSaveRequest(requestId, succeeded) {
+ const id = String(requestId || '').trim();
+ const pending = pendingSaveRequests.get(id);
+ if (!id || !pending) return false;
+ pendingSaveRequests.delete(id);
+ clearTimeout(pending.timer);
+ pending.resolve(Boolean(succeeded));
return true;
}
+ function handleSaveOutcome(payload, succeeded) {
+ const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : '';
+ return settleSaveRequest(requestId, succeeded);
+ }
+
function postVocabPayload(payload) {
- if (currentOptions && typeof currentOptions.postMessage === 'function') {
- currentOptions.postMessage(VOCAB_MESSAGE_TYPE, payload);
- return true;
+ if (!currentOptions || typeof currentOptions.postMessage !== 'function') return null;
+ const requestId = createRequestId();
+ const requestPayload = { ...payload, requestId };
+ const outcome = new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ pendingSaveRequests.delete(requestId);
+ resolve(false);
+ }, 5000);
+ pendingSaveRequests.set(requestId, { resolve, timer });
+ });
+ let delivered = false;
+ try {
+ delivered = currentOptions.postMessage(VOCAB_MESSAGE_TYPE, requestPayload) !== false;
+ } catch (_) {
+ delivered = false;
}
- const candidates = [global.opener, global.parent];
- for (let index = 0; index < candidates.length; index += 1) {
- const target = candidates[index];
- if (!target || target === global) {
- continue;
- }
- try {
- target.postMessage({
- type: VOCAB_MESSAGE_TYPE,
- source: 'practice_page',
- data: payload
- }, '*');
- return true;
- } catch (_) {
- // try next target
- }
+ if (!delivered) {
+ settleSaveRequest(requestId, false);
+ return null;
}
- return false;
+ return outcome;
}
- function saveActiveLookup(button) {
+ async function saveActiveLookup(button) {
const payload = buildVocabPayload();
if (!payload.word) {
return;
}
- const posted = postVocabPayload(payload);
- const fallbackSaved = writeFallbackVocab(payload);
+ const hostOutcome = postVocabPayload(payload);
+ let persisted = hostOutcome ? await hostOutcome : false;
+ if (!persisted) {
+ try { persisted = await writeAppDataVocab(payload); } catch (_) { persisted = false; }
+ }
if (button instanceof HTMLButtonElement) {
- button.textContent = posted || fallbackSaved ? '已加入' : '保存失败';
- button.disabled = true;
+ button.textContent = persisted ? '已加入' : '保存失败';
+ button.disabled = persisted;
}
}
@@ -1838,7 +5355,7 @@
attach,
enhance,
close: closeBubble,
- storageKey: FALLBACK_STORAGE_KEY,
+ handleSaveOutcome,
messageType: VOCAB_MESSAGE_TYPE
};
@@ -1854,8 +5371,6 @@
(function initPracticeTimerPreferences(global) {
'use strict';
- var READING_KEY = 'ielts_reading_timer_preferences_v2';
- var LISTENING_KEY = 'ielts_listening_timer_preferences_v1';
var VERSION = 1;
var DEFAULTS = {
version: VERSION,
@@ -1892,26 +5407,38 @@
};
}
- function keyFor(scope) {
- return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY;
+ var cache = Object.create(null);
+ var hydrationPromise = null;
+ function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; }
+ function hydrateTimerPreferences() {
+ if (cache.reading && cache.listening) return Promise.resolve(true);
+ if (hydrationPromise) return hydrationPromise;
+ if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false);
+ hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getTimer();
+ cache.reading = normalize(stored && stored.reading);
+ cache.listening = normalize(stored && stored.listening);
+ return true;
+ }).catch(function onTimerPreferenceLoadError(error) {
+ hydrationPromise = null;
+ console.warn('[PracticeTimerPreferences] 加载失败:', error);
+ return false;
+ });
+ return hydrationPromise;
}
function read(scope) {
- try {
- var raw = global.localStorage && global.localStorage.getItem(keyFor(scope));
- return normalize(raw ? JSON.parse(raw) : null);
- } catch (_) {
- return normalize(null);
- }
+ return normalize(cache[normalizeScope(scope)]);
}
- function save(scope, preferences) {
+ async function save(scope, preferences) {
+ await hydrateTimerPreferences();
+ if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable');
+ var normalizedScope = normalizeScope(scope);
var next = normalize(preferences);
- try {
- if (global.localStorage) {
- global.localStorage.setItem(keyFor(scope), JSON.stringify(next));
- }
- } catch (_) { }
+ await global.AppData.preferences.setTimer(normalizedScope, next);
+ cache[normalizedScope] = next;
return next;
}
@@ -1919,17 +5446,16 @@
return clampMinutes(value, DEFAULTS.countdownMinutes) * 60;
}
- global.PracticeTimerPreferences = {
+ var api = {
VERSION: VERSION,
- READING_KEY: READING_KEY,
- LISTENING_KEY: LISTENING_KEY,
DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)),
normalize: normalize,
read: read,
save: save,
- keyFor: keyFor,
minutesToSeconds: minutesToSeconds
};
+ Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences });
+ global.PracticeTimerPreferences = api;
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1940,12 +5466,33 @@
const MESSAGE_SOURCE = 'practice_page';
const INIT_RETRY_MS = 1500;
const SIMULATION_DRAFT_SYNC_MS = 1200;
+ const READING_DRAFT_SYNC_MS = 1500;
+ const SUBMIT_ACK_TIMEOUT_MS = 10000;
+ const NOTE_EDITOR_SAVE_DEBOUNCE_MS = 450;
+ const NOTE_ROW_LONG_PRESS_MS = 100;
const EXPLANATION_STYLE_ID = 'reading-explanation-style';
const MEMORIZE_STYLE_ID = 'reading-memorize-style';
+ const READING_NOTE_STYLE_ID = 'reading-note-style';
+ const READING_DISPLAY_CONTROL_STYLE_ID = 'reading-display-control-style';
const PRACTICE_TIMER_BRIDGE_KEY = '__IELTS_PRACTICE_TIMER__';
const PRACTICE_TIMER_EVENT = 'practiceTimerStateChange';
- const READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1';
const READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/;
+ const HOST_MESSAGE_SOURCE = 'exam_host';
+ let readingCandidateCodeCache = { mode: 'auto', customCode: '' };
+
+ function deriveReferrerOrigin() {
+ try {
+ if (!document.referrer) return '';
+ const parsed = new URL(document.referrer, global.location.href);
+ // File-page refs do not provide a usable web origin, so bind them through
+ // the opaque/file message-origin handling below instead of pinning file://.
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (_) {
+ return '';
+ }
+ }
const EXPLANATION_NODE_SELECTOR = [
'.reading-explanation-card',
'.reading-group-explanation',
@@ -1962,6 +5509,7 @@
const navStatus = new Map();
const scriptCache = new Map();
const LOCATOR_HIGHLIGHT_SELECTOR = '.reading-locator-highlight, .reading-locator-block';
+ const LOCATOR_OVERLAP_SELECTOR = '.reading-locator-overlap';
function getAnswerMatchCore() {
const core = global.AnswerMatchCore;
if (!core || typeof core !== 'object') {
@@ -2015,6 +5563,10 @@
timerLocked: false,
ready: false,
submitted: false,
+ submissionStatus: 'draft',
+ submissionId: '',
+ submissionAckTimer: null,
+ pendingSubmissionPresentation: null,
initTimer: null,
manifestLoaded: false,
dataset: null,
@@ -2036,10 +5588,37 @@
},
simulationDraftSyncTimer: null,
simulationDraftFingerprint: '',
+ readingDraftSyncTimer: null,
+ readingDraftFingerprint: '',
+ notes: [],
+ noteOutlines: [],
+ markedQuestions: [],
+ activeNoteId: '',
+ noteEditorPosition: null,
+ noteUiInitialized: false,
+ noteEditorSaveTimer: null,
+ noteDrawerDirty: true,
+ noteHighlightMetaDirty: true,
+ noteEditorPendingSync: false,
+ reviewRecordId: '',
+ // 单篇阅读 final-submit 成功后,宿主通过 PRACTICE_RECORD_SAVED 回传的已存档
+ // practice record id。持有该 id 时,笔记编辑在只读提交页仍然可写,并且
+ // syncReadingAnnotation 会以该 recordId 发送 READING_ANNOTATION_SYNC,把
+ // 结果页上的笔记改动持久化回已存档的练习记录。
+ submittedRecordId: '',
+ highlightVisibility: {
+ locators: true,
+ notes: true,
+ highlights: true
+ },
+ questionNavCollapsed: false,
lastInitSignature: '',
lastReplaySignature: '',
sessionReadySent: false,
parentWindow: global.opener || global.parent || null,
+ expectedParentOrigin: deriveReferrerOrigin(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
windowSessionToken: '',
windowSessionIssuedAtMs: 0
};
@@ -2064,7 +5643,10 @@
timerInterval: null,
lastRange: null,
currentHighlightNode: null,
- keepToolbar: false
+ keepToolbar: false,
+ noteDragFrame: null,
+ noteListDragging: false,
+ noteSuppressClickUntil: 0
};
const testOverrides = {
renderExplanations: null
@@ -2233,6 +5815,10 @@
control.disabled = locked || state.readOnly;
}
});
+ if (dom.resetBtn) dom.resetBtn.disabled = locked || state.readOnly;
+ document.querySelectorAll('#reading-note-drawer [data-note-outline-add], #reading-note-drawer [data-note-outline-toggle], #reading-note-drawer [data-note-outline-title], #reading-note-drawer [data-note-outline-delete], #reading-note-drawer [data-note-drag-handle], #reading-note-drawer [data-note-delete]').forEach((control) => {
+ if ('disabled' in control) control.disabled = locked;
+ });
disableDragInteractions();
}
@@ -2269,20 +5855,15 @@
}
function readReadingCandidateCodePreferences() {
- try {
- const raw = global.localStorage?.getItem(READING_CANDIDATE_CODE_PREF_KEY);
- const parsed = raw ? JSON.parse(raw) : null;
- const mode = parsed?.mode === 'custom' ? 'custom' : 'auto';
- const customCode = typeof parsed?.customCode === 'string'
- ? parsed.customCode.replace(/\D/g, '').slice(0, 6)
- : '';
- return {
- mode,
- customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : ''
- };
- } catch (_) {
- return { mode: 'auto', customCode: '' };
- }
+ return { ...readingCandidateCodeCache };
+ }
+
+ async function loadReadingCandidateCodePreferences() {
+ await global.AppData.ready;
+ const stored = await global.AppData.preferences.getCandidateCode();
+ const mode = stored?.mode === 'custom' ? 'custom' : 'auto';
+ const customCode = typeof stored?.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : '';
+ readingCandidateCodeCache = { mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' };
}
function resolveReadingCandidateCode() {
@@ -2303,6 +5884,8 @@
const rawLimitSeconds = Number(state.suiteTimerLimitSeconds);
if (Number.isFinite(rawLimitSeconds) && rawLimitSeconds > 0) {
limitSeconds = Math.floor(rawLimitSeconds);
+ } else if (state.suiteSessionId && state.suiteTimerMode === 'countdown') {
+ limitSeconds = minutesToSeconds(60, 60);
} else if (preferences.limitEnabled) {
limitSeconds = minutesToSeconds(preferences.limitMinutes, 60);
} else {
@@ -2340,8 +5923,10 @@
}
timer.classList.toggle('paused', !interaction.timerRunning && !hasEndlessCountdown);
timer.classList.toggle('timer-expired', expired);
- timer.dataset.timerMode = preferences.mode;
- timer.dataset.expiryAction = preferences.expiryAction;
+ if (timer.dataset) {
+ timer.dataset.timerMode = preferences.mode;
+ timer.dataset.expiryAction = preferences.expiryAction;
+ }
timer.style.opacity = (interaction.timerRunning || hasEndlessCountdown) ? '1' : '0.5';
var _warnRemaining = !hasEndlessCountdown
&& (preferences.mode === 'countdown' || (Number.isFinite(Number(limitSeconds)) && Number(limitSeconds) > 0))
@@ -2469,6 +6054,10 @@
function updateSelectionToolbar() {
const toolbar = document.getElementById('selbar');
if (!toolbar) return;
+ if (!canEditReadingNotes()) {
+ toolbar.style.display = 'none';
+ return;
+ }
const selection = global.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
if (!interaction.keepToolbar && !interaction.currentHighlightNode) {
@@ -2530,6 +6119,10 @@
function applySelectionHighlight(kind = 'highlight') {
const toolbar = document.getElementById('selbar');
+ if (!canEditReadingNotes()) {
+ if (toolbar) toolbar.style.display = 'none';
+ return;
+ }
const selection = global.getSelection();
if (!interaction.lastRange || interaction.lastRange.collapsed || interaction.currentHighlightNode) {
return;
@@ -2548,11 +6141,19 @@
if (toolbar) toolbar.style.display = 'none';
interaction.lastRange = null;
interaction.currentHighlightNode = null;
- syncSimulationDraftSnapshot('highlight');
+ if (kind === 'note') {
+ const note = ensureNoteForHighlight(span, normalizeNoteText(span.textContent), { sync: false });
+ if (note) openNoteEditor(note.id, { anchorNode: span, focusBody: true });
+ }
+ syncReadingAnnotation('highlight');
}
function removeSelectionHighlight() {
const toolbar = document.getElementById('selbar');
+ if (!canEditReadingNotes()) {
+ if (toolbar) toolbar.style.display = 'none';
+ return;
+ }
const selection = global.getSelection();
let target = interaction.currentHighlightNode;
if (!target && interaction.lastRange) {
@@ -2561,6 +6162,7 @@
? ancestor.parentElement?.closest('.hl')
: ancestor.closest?.('.hl');
}
+ const removedNoteId = target instanceof HTMLElement ? String(target.dataset.noteId || '') : '';
if (target && target.parentNode) {
const parent = target.parentNode;
while (target.firstChild) {
@@ -2573,7 +6175,8 @@
if (toolbar) toolbar.style.display = 'none';
interaction.lastRange = null;
interaction.currentHighlightNode = null;
- syncSimulationDraftSnapshot('unhighlight');
+ if (removedNoteId) deleteNote(removedNoteId, { sync: false });
+ syncReadingAnnotation('unhighlight');
}
function attachSelectionHighlightToolbar() {
@@ -2590,13 +6193,13 @@
});
document.getElementById('btnHL')?.addEventListener('click', () => applySelectionHighlight('highlight'));
document.getElementById('btnNote')?.addEventListener('click', () => {
+ if (!canEditReadingNotes()) return;
let targetNode = interaction.currentHighlightNode;
let text = '';
if (targetNode) {
if (targetNode.dataset.hlType !== 'note') {
targetNode.dataset.hlType = 'note';
- syncSimulationDraftSnapshot('highlight');
}
text = (targetNode.textContent || '').trim();
} else if (interaction.lastRange && !interaction.lastRange.collapsed) {
@@ -2620,18 +6223,10 @@
interaction.lastRange = null;
interaction.currentHighlightNode = null;
- if (text) {
- const noteArea = document.querySelector('#notes-panel textarea');
- if (noteArea) {
- noteArea.value += (noteArea.value ? '\n\n' : '') + '> ' + text + '\n';
- noteArea.scrollTop = noteArea.scrollHeight;
- noteArea.focus();
- }
+ if (targetNode && text) {
+ const note = ensureNoteForHighlight(targetNode, text);
closeFloatingPanels();
- const notesPanel = document.getElementById('notes-panel');
- const overlay = document.querySelector('.overlay');
- if (notesPanel) notesPanel.style.display = 'flex';
- if (overlay) overlay.style.display = 'block';
+ if (note) openNoteEditor(note.id, { anchorNode: targetNode, focusBody: true });
}
});
document.getElementById('btnUH')?.addEventListener('click', removeSelectionHighlight);
@@ -2919,6 +6514,9 @@
}
function getNotesText() {
+ if (state.noteUiInitialized) {
+ return formatNotesForLegacyText(state.notes);
+ }
const noteArea = document.querySelector('#notes-panel textarea');
return noteArea ? String(noteArea.value || '') : '';
}
@@ -2930,11 +6528,164 @@
}
}
+ function generateNoteId() {
+ return `note_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function generateNoteOutlineId() {
+ return `outline_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function normalizeNoteText(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function buildDefaultNoteTitle(quote = '') {
+ const text = normalizeNoteText(quote);
+ if (!text) return 'Untitled note';
+ return text.length > 36 ? `${text.slice(0, 36)}...` : text;
+ }
+
+ function compareNoteOrder(a, b) {
+ const orderA = Number.isFinite(Number(a?.order)) ? Number(a.order) : 0;
+ const orderB = Number.isFinite(Number(b?.order)) ? Number(b.order) : 0;
+ if (orderA !== orderB) return orderA - orderB;
+ return Number(a?.createdAt || 0) - Number(b?.createdAt || 0);
+ }
+
+ function normalizeNotes(rawNotes) {
+ const seen = new Set();
+ return (Array.isArray(rawNotes) ? rawNotes : []).map((entry, index) => {
+ if (!entry || typeof entry !== 'object') return null;
+ let id = entry.id != null ? String(entry.id).trim() : '';
+ if (!id || seen.has(id)) id = generateNoteId();
+ seen.add(id);
+ const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now();
+ return {
+ id,
+ title: entry.title != null ? String(entry.title) : '',
+ body: entry.body != null ? String(entry.body) : '',
+ quote: entry.quote != null ? String(entry.quote) : '',
+ outlineId: entry.outlineId != null ? String(entry.outlineId).trim() : '',
+ order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index,
+ createdAt,
+ updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt
+ };
+ }).filter(Boolean);
+ }
+
+ function normalizeNoteOutlines(rawOutlines) {
+ const seen = new Set();
+ return (Array.isArray(rawOutlines) ? rawOutlines : []).map((entry, index) => {
+ if (!entry || typeof entry !== 'object') return null;
+ let id = entry.id != null ? String(entry.id).trim() : '';
+ if (!id || seen.has(id)) id = generateNoteOutlineId();
+ seen.add(id);
+ const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now();
+ return {
+ id,
+ title: String(entry.title || '').trim() || 'New outline',
+ order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index,
+ collapsed: Boolean(entry.collapsed),
+ createdAt,
+ updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt
+ };
+ }).filter(Boolean).sort(compareNoteOrder);
+ }
+
+ function sanitizeNotesWithOutlines(rawNotes, rawOutlines) {
+ const noteOutlines = normalizeNoteOutlines(rawOutlines);
+ const validIds = new Set(noteOutlines.map((outline) => outline.id));
+ const notes = normalizeNotes(rawNotes).map((note, index) => ({
+ ...note,
+ outlineId: validIds.has(note.outlineId) ? note.outlineId : '',
+ order: Number.isFinite(Number(note.order)) ? Number(note.order) : index
+ }));
+ return { notes, noteOutlines };
+ }
+
+ function collectNotes() {
+ return normalizeNotes(state.notes);
+ }
+
+ function collectNoteOutlines() {
+ return normalizeNoteOutlines(state.noteOutlines);
+ }
+
+ function getNoteById(noteId) {
+ const id = String(noteId || '').trim();
+ return id ? state.notes.find((note) => note && note.id === id) || null : null;
+ }
+
+ function getValidNoteOutlineId(outlineId) {
+ const id = String(outlineId || '').trim();
+ return id && state.noteOutlines.some((outline) => outline.id === id) ? id : '';
+ }
+
+ function sortNotesForDrawer(notes = state.notes) {
+ return (Array.isArray(notes) ? notes : []).filter(Boolean).slice().sort(compareNoteOrder);
+ }
+
+ function getNextNoteOrder(outlineId = '') {
+ const id = getValidNoteOutlineId(outlineId);
+ const matching = state.notes.filter((note) => (note?.outlineId || '') === id);
+ return matching.length
+ ? Math.max(...matching.map((note) => Number.isFinite(Number(note.order)) ? Number(note.order) : 0)) + 1
+ : 0;
+ }
+
+ function formatNotesForLegacyText(notes = state.notes) {
+ return normalizeNotes(notes).map((note) => {
+ const parts = [`# ${String(note.title || '').trim() || 'Untitled note'}`];
+ if (note.quote) parts.push(`> ${normalizeNoteText(note.quote)}`);
+ if (note.body) parts.push(note.body);
+ return parts.join('\n');
+ }).join('\n\n');
+ }
+
+ function syncNotesToLegacyText() {
+ setNotesText(formatNotesForLegacyText(state.notes));
+ }
+
+ function normalizeMarkedQuestions(rawQuestions) {
+ const seen = new Set();
+ return (Array.isArray(rawQuestions) ? rawQuestions : []).map((entry) => (
+ normalizeQuestionId(entry) || String(entry || '').trim().toLowerCase()
+ )).filter(Boolean).filter((entry) => {
+ if (seen.has(entry)) return false;
+ seen.add(entry);
+ return true;
+ });
+ }
+
+ function getCurrentMarkedQuestions() {
+ let marks = [];
+ let hostResolved = false;
+ if (typeof global.getPracticeMarkedQuestions === 'function') {
+ try {
+ const raw = global.getPracticeMarkedQuestions();
+ hostResolved = raw != null;
+ marks = normalizeMarkedQuestions(raw);
+ } catch (_) { marks = []; }
+ }
+ // 只有当 host 没有 give 出结果时(函数不存在或抛错)才回退到缓存;
+ // 用户清空最后一个标记时 host 会返回 [],这是有效空集,不能再被 state.markedQuestions 复活,
+ // 否则清空无法持久,并会在后续 draft/annotation sync 中重新写入旧标记。
+ if (!hostResolved && !marks.length) {
+ marks = normalizeMarkedQuestions(state.markedQuestions);
+ }
+ state.markedQuestions = marks.slice();
+ return marks;
+ }
+
function buildEmptyDraft() {
return {
answers: {},
highlights: [],
noteText: '',
+ notes: [],
+ noteOutlines: [],
+ markedQuestions: [],
scrollY: 0,
updatedAt: Date.now()
};
@@ -2952,6 +6703,9 @@
noteText: typeof source.noteText === 'string'
? source.noteText
: '',
+ notes: normalizeNotes(source.notes),
+ noteOutlines: normalizeNoteOutlines(source.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(source.markedQuestions),
scrollY: Number.isFinite(Number(source.scrollY))
? Number(source.scrollY)
: 0,
@@ -2980,7 +6734,7 @@
const mergedUpdatedAt = Number.isFinite(Number(next.updatedAt))
? Number(next.updatedAt)
: (Number.isFinite(Number(base.updatedAt)) ? Number(base.updatedAt) : Date.now());
- return Object.assign(buildEmptyDraft(), base, next, {
+ const merged = Object.assign(buildEmptyDraft(), base, next, {
answers: next.answers && typeof next.answers === 'object'
? { ...next.answers }
: { ...base.answers },
@@ -2990,11 +6744,22 @@
noteText: typeof next.noteText === 'string'
? next.noteText
: base.noteText,
+ notes: Array.isArray(nextDraft?.notes) ? normalizeNotes(next.notes) : normalizeNotes(base.notes),
+ noteOutlines: Array.isArray(nextDraft?.noteOutlines)
+ ? normalizeNoteOutlines(next.noteOutlines)
+ : normalizeNoteOutlines(base.noteOutlines),
+ markedQuestions: Array.isArray(nextDraft?.markedQuestions)
+ ? normalizeMarkedQuestions(next.markedQuestions)
+ : normalizeMarkedQuestions(base.markedQuestions),
scrollY: Number.isFinite(Number(next.scrollY))
? Number(next.scrollY)
: base.scrollY,
updatedAt: mergedUpdatedAt
});
+ const sanitized = sanitizeNotesWithOutlines(merged.notes, merged.noteOutlines);
+ merged.notes = sanitized.notes;
+ merged.noteOutlines = sanitized.noteOutlines;
+ return merged;
}
function mergeSuiteDraftPayload(data = {}) {
@@ -3093,6 +6858,9 @@
answers: collectAnswers(),
highlights: collectHighlights(),
noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
scrollY: global.scrollY || 0,
updatedAt: Date.now()
});
@@ -3256,7 +7024,6 @@
refreshDynamicQuestionEnhancements();
clearCurrentAnswers();
applyDraftToDom(slot.draft || buildEmptyDraft());
- setNotesText(slot.draft?.noteText || '');
syncSimulationCtxForActiveSlot();
syncInlineSuiteIdentity();
state.simulationMode = true;
@@ -3267,25 +7034,886 @@
if (Number.isFinite(Number(slot.draft?.scrollY))) {
global.scrollTo(0, Number(slot.draft.scrollY) || 0);
}
- if (!options.skipDraftSync) {
- syncSimulationDraftSnapshot('activate');
+ if (!options.skipDraftSync) {
+ syncSimulationDraftSnapshot('activate');
+ }
+ if (!options.silent) {
+ postMessage('SIMULATION_ACTIVE_EXAM_CHANGE', {
+ examId: targetExamId,
+ currentIndex: state.suite.currentIndex,
+ suiteSequence: state.suite.sequence.map((entry) => ({ ...entry }))
+ });
+ }
+ return true;
+ }
+
+ async function ensureExplanationManifest() {
+ if (global.__READING_EXPLANATION_MANIFEST__) {
+ return global.__READING_EXPLANATION_MANIFEST__;
+ }
+ await loadScript('../reading-explanations/manifest.js');
+ return global.__READING_EXPLANATION_MANIFEST__ || {};
+ }
+
+ function ensureReadingDisplayControlStyles() {
+ if (document.getElementById(READING_DISPLAY_CONTROL_STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = READING_DISPLAY_CONTROL_STYLE_ID;
+ style.textContent = `
+ .reading-display-toggle-group{display:inline-flex;align-items:center;gap:4px;padding:2px;border:1px solid #dbe4ef;border-radius:8px;background:#f8fafc}
+ .reading-display-toggle{border:0;border-radius:6px;min-width:30px;height:28px;padding:0 8px;cursor:pointer;color:#64748b;background:transparent;font-size:12px;font-weight:700}
+ .reading-display-toggle:hover{background:#eef2f7;color:#0f172a}.reading-display-toggle.is-on{background:#dbeafe;color:#1d4ed8}
+ body.hide-reading-locators .reading-locator-highlight{background:transparent!important;box-shadow:none!important;outline:none!important}
+ body.hide-reading-locators .reading-locator-overlap{text-decoration:none!important;outline:none!important}
+ body.hide-reading-locators .reading-passage-locator-target.is-review-jump-target{background:transparent!important;outline:none!important}
+ body.hide-reading-notes .hl[data-hl-type="note"],body.hide-reading-notes .hl[data-note-id]{background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important;pointer-events:none}
+ body.hide-reading-highlights .hl:not([data-hl-type="note"]):not([data-note-id]){background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important}
+ body.reading-question-nav-collapsed .practice-nav{display:none}
+ body.dark-mode .reading-display-toggle-group{background:#1e293b;border-color:#475569;color:#cbd5e1}
+ `;
+ document.head.appendChild(style);
+ }
+
+ function saveReadingDisplayPreferences() {
+ global.AppData.preferences.setReadingDisplay({
+ highlightVisibility: state.highlightVisibility,
+ questionNavCollapsed: state.questionNavCollapsed
+ }).catch((error) => console.warn('[ReadingDisplay] 保存失败:', error));
+ }
+
+ async function loadReadingDisplayPreferences() {
+ try {
+ const saved = await global.AppData.preferences.getReadingDisplay();
+ if (saved?.highlightVisibility) {
+ state.highlightVisibility = {
+ locators: saved.highlightVisibility.locators !== false,
+ notes: saved.highlightVisibility.notes !== false,
+ highlights: saved.highlightVisibility.highlights !== false
+ };
+ }
+ state.questionNavCollapsed = Boolean(saved?.questionNavCollapsed);
+ } catch (_) { /* Ignore invalid preference payloads. */ }
+ applyReadingDisplayState();
+ }
+
+ function applyReadingDisplayState() {
+ if (!document.body) return;
+ document.body.classList.toggle('hide-reading-locators', state.highlightVisibility.locators === false);
+ document.body.classList.toggle('hide-reading-notes', state.highlightVisibility.notes === false);
+ document.body.classList.toggle('hide-reading-highlights', state.highlightVisibility.highlights === false);
+ document.body.classList.toggle('reading-question-nav-collapsed', state.questionNavCollapsed);
+ document.querySelectorAll('[data-highlight-toggle]').forEach((button) => {
+ const key = button.getAttribute('data-highlight-toggle');
+ const enabled = state.highlightVisibility[key] !== false;
+ button.classList.toggle('is-on', enabled);
+ button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
+ });
+ const navToggle = document.getElementById('reading-question-nav-toggle');
+ if (navToggle) {
+ const collapsed = state.questionNavCollapsed;
+ // is-on means the question card bar is currently visible.
+ navToggle.classList.toggle('is-on', !collapsed);
+ navToggle.setAttribute('aria-pressed', collapsed ? 'false' : 'true');
+ navToggle.title = collapsed ? '显示题卡' : '隐藏题卡';
+ navToggle.textContent = 'Q';
+ }
+ }
+
+ function ensureReadingDisplayControls() {
+ ensureReadingDisplayControlStyles();
+ // Remove the legacy floating bottom-right nav toggle if an older session left one behind.
+ document.querySelectorAll('body > #reading-question-nav-toggle, body > .reading-question-nav-toggle').forEach((node) => {
+ if (node.closest?.('.reading-display-toggle-group')) return;
+ node.remove();
+ });
+ const headerRight = document.querySelector('.header-right');
+ if (headerRight && !document.getElementById('reading-display-toggle-group')) {
+ const group = document.createElement('div');
+ group.id = 'reading-display-toggle-group';
+ group.className = 'reading-display-toggle-group';
+ group.setAttribute('aria-label', '阅读显示控制');
+ group.innerHTML = [
+ '
A ',
+ '
N ',
+ '
H ',
+ '
Q '
+ ].join('');
+ const settingsButton = document.getElementById('settings-btn');
+ headerRight.insertBefore(group, settingsButton?.parentNode === headerRight ? settingsButton : null);
+ group.addEventListener('click', (event) => {
+ const target = event.target instanceof HTMLElement ? event.target : null;
+ if (!target) return;
+ const navButton = target.closest('[data-question-nav-toggle]');
+ if (navButton) {
+ state.questionNavCollapsed = !state.questionNavCollapsed;
+ applyReadingDisplayState();
+ saveReadingDisplayPreferences();
+ return;
+ }
+ const button = target.closest('[data-highlight-toggle]');
+ if (!button) return;
+ const key = button.getAttribute('data-highlight-toggle');
+ if (!Object.prototype.hasOwnProperty.call(state.highlightVisibility, key)) return;
+ state.highlightVisibility[key] = state.highlightVisibility[key] === false;
+ applyReadingDisplayState();
+ saveReadingDisplayPreferences();
+ });
+ } else {
+ // If the group already exists without the nav toggle (hot reload / partial DOM), attach it.
+ const group = document.getElementById('reading-display-toggle-group');
+ if (group && !document.getElementById('reading-question-nav-toggle')) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'reading-display-toggle';
+ button.id = 'reading-question-nav-toggle';
+ button.setAttribute('data-question-nav-toggle', '');
+ button.title = '隐藏题卡';
+ button.setAttribute('aria-pressed', 'true');
+ button.textContent = 'Q';
+ button.addEventListener('click', (event) => {
+ event.stopPropagation();
+ state.questionNavCollapsed = !state.questionNavCollapsed;
+ applyReadingDisplayState();
+ saveReadingDisplayPreferences();
+ });
+ group.appendChild(button);
+ }
+ }
+ applyReadingDisplayState();
+ }
+
+ function ensureReadingNoteStyles() {
+ if (document.getElementById(READING_NOTE_STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = READING_NOTE_STYLE_ID;
+ style.textContent = `
+ .hl[data-note-id]{position:relative;cursor:pointer;background:rgba(191,219,254,.78)!important;box-shadow:inset 0 -.52em rgba(147,197,253,.34)}
+ .hl[data-note-id].reading-note-flash{outline:2px solid #60a5fa;outline-offset:2px}.reading-notes-btn{position:relative}
+ .reading-note-count{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:99px;background:#16a34a;color:#fff;font-size:10px;line-height:16px;text-align:center;font-weight:700;display:none}
+ #reading-note-drawer{position:fixed;inset:0 0 0 auto;width:min(360px,92vw);background:#fff;border-left:1px solid #dbe4ef;box-shadow:-18px 0 36px rgba(15,23,42,.16);z-index:3600;transform:translateX(105%);transition:transform 180ms ease;display:flex;flex-direction:column}
+ #reading-note-drawer.open{transform:translateX(0)}.reading-note-drawer-head,.reading-note-editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid #e2e8f0}
+ .reading-note-drawer-title{display:flex;align-items:center;gap:8px}.reading-note-drawer-head h3,.reading-note-editor-head h3{margin:0;font-size:16px}.reading-note-list{padding:10px;overflow:auto;flex:1}
+ .reading-note-outline{border:1px solid #dbeafe;border-radius:8px;margin-bottom:10px;overflow:hidden;background:#f8fbff}.reading-note-outline-head{display:grid;grid-template-columns:30px 1fr 30px;align-items:center;padding:5px;background:#eff6ff}.reading-note-outline.collapsed .reading-note-outline-body{display:none}
+ .reading-note-outline-body,.reading-note-loose-list{min-height:26px;padding:4px 8px}.reading-note-row{display:grid;grid-template-columns:1fr 28px 30px;align-items:center;gap:4px;border-bottom:1px solid #edf2f7}.reading-note-row.dragging{opacity:.45}.reading-note-row.drag-over{box-shadow:inset 0 2px #2563eb}
+ .reading-note-open,.reading-note-outline-title{border:0;background:transparent;color:#0f172a;text-align:left;padding:9px 6px;border-radius:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}.reading-note-open:hover{background:#eff6ff;color:#1d4ed8}
+ .reading-note-close,.reading-note-delete,.reading-note-outline-toggle,.reading-note-outline-delete,.reading-note-drag-handle,.reading-note-outline-add{border:0;background:transparent;color:#64748b;cursor:pointer;width:30px;height:30px;border-radius:6px}.reading-note-outline-add{background:#eff6ff;color:#1d4ed8;font-size:18px}.reading-note-outline-title-input{min-width:0;border:1px solid #93c5fd;border-radius:5px;padding:6px}
+ #reading-note-editor{position:fixed;z-index:3700;width:min(620px,calc(100vw - 24px));height:min(520px,calc(100vh - 24px));min-width:320px;min-height:320px;background:#fff;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 22px 50px rgba(15,23,42,.22);display:none;flex-direction:column;overflow:hidden;resize:both}
+ .reading-note-editor-head{cursor:move;background:#f8fafc;user-select:none}.reading-note-editor-body{display:flex;flex-direction:column;gap:10px;padding:14px;flex:1;min-height:0}.reading-note-quote{margin:0;color:#475569;background:#eff6ff;border-left:3px solid #60a5fa;padding:8px 10px;max-height:74px;overflow:auto}
+ .reading-note-title,.reading-note-body{width:100%;border:1px solid #cbd5e1;border-radius:6px;padding:9px 10px;box-sizing:border-box}.reading-note-title{font-weight:700}.reading-note-body{min-height:190px;resize:vertical;flex:1}
+ body.dark-mode #reading-note-drawer,body.dark-mode #reading-note-editor{background:#1e293b;border-color:#475569;color:#e2e8f0}body.dark-mode .reading-note-open,body.dark-mode .reading-note-outline-title{color:#f8fafc}
+ @media(max-width:520px){#reading-note-editor{inset:12px!important;width:calc(100vw - 24px);height:calc(100vh - 24px);min-width:0;min-height:0;resize:none}}
+ `;
+ document.head.appendChild(style);
+ }
+
+ function ensureReadingNotesButton() {
+ let button = document.getElementById('notes-drawer-btn');
+ if (button) return button;
+ const headerRight = document.querySelector('.header-right');
+ if (!headerRight) return null;
+ button = document.createElement('button');
+ button.id = 'notes-drawer-btn';
+ button.type = 'button';
+ button.className = 'header-btn reading-notes-btn';
+ button.title = 'Notes';
+ button.innerHTML = 'Notes
0 ';
+ headerRight.insertBefore(button, headerRight.firstChild);
+ button.addEventListener('click', (event) => { event.stopPropagation(); toggleNotesDrawer(); });
+ return button;
+ }
+
+ function ensureReadingNotesUi() {
+ ensureReadingNoteStyles();
+ ensureReadingNotesButton();
+ const legacyPanel = document.getElementById('notes-panel');
+ const legacyButton = document.getElementById('note-btn');
+ if (legacyPanel) { legacyPanel.style.display = 'none'; legacyPanel.setAttribute('aria-hidden', 'true'); }
+ if (legacyButton) { legacyButton.style.display = 'none'; legacyButton.setAttribute('aria-hidden', 'true'); }
+ let drawer = document.getElementById('reading-note-drawer');
+ if (!drawer) {
+ drawer = document.createElement('aside');
+ drawer.id = 'reading-note-drawer';
+ drawer.setAttribute('aria-hidden', 'true');
+ drawer.innerHTML = '
';
+ document.body.appendChild(drawer);
+ drawer.addEventListener('click', handleNoteDrawerClick);
+ drawer.addEventListener('keydown', handleNoteDrawerKeydown);
+ drawer.addEventListener('focusout', handleNoteDrawerFocusOut);
+ drawer.addEventListener('dragstart', handleNoteDragStart);
+ drawer.addEventListener('dragover', handleNoteDragOver);
+ drawer.addEventListener('drop', handleNoteDrop);
+ drawer.addEventListener('dragend', clearNoteDragIndicators);
+ }
+ let editor = document.getElementById('reading-note-editor');
+ if (!editor) {
+ editor = document.createElement('section');
+ editor.id = 'reading-note-editor';
+ editor.setAttribute('aria-hidden', 'true');
+ editor.innerHTML = '
Note × ';
+ document.body.appendChild(editor);
+ editor.addEventListener('click', (event) => { if (event.target.closest?.('[data-note-editor-close]')) closeNoteEditor(); });
+ editor.querySelector('[data-note-title]')?.addEventListener('input', saveActiveNoteFromEditor);
+ editor.querySelector('[data-note-body]')?.addEventListener('input', saveActiveNoteFromEditor);
+ editor.querySelector('[data-note-title]')?.addEventListener('change', flushActiveNoteFromEditor);
+ editor.querySelector('[data-note-body]')?.addEventListener('change', flushActiveNoteFromEditor);
+ attachNoteEditorDrag(editor);
+ }
+ if (!state.noteUiInitialized) {
+ state.noteUiInitialized = true;
+ document.addEventListener('click', handleNoteHighlightClick, true);
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') { closeNoteEditor(); closeNotesDrawer(); }
+ });
+ }
+ syncNotesToLegacyText();
+ renderNotesDrawer();
+ refreshNoteHighlightAttributes();
+ return drawer;
+ }
+
+ function toggleNotesDrawer() {
+ const drawer = ensureReadingNotesUi();
+ if (drawer?.classList.contains('open')) closeNotesDrawer();
+ else openNotesDrawer();
+ }
+
+ function openNotesDrawer() {
+ const drawer = ensureReadingNotesUi();
+ if (!drawer) return;
+ state.noteDrawerDirty = true;
+ drawer.classList.add('open');
+ drawer.setAttribute('aria-hidden', 'false');
+ renderNotesDrawer();
+ }
+
+ function closeNotesDrawer() {
+ const drawer = document.getElementById('reading-note-drawer');
+ drawer?.classList.remove('open');
+ drawer?.setAttribute('aria-hidden', 'true');
+ }
+
+ function renderNoteRow(note) {
+ const title = String(note.title || '').trim() || 'Untitled note';
+ const editable = canEditReadingNotes();
+ const disabled = editable ? '' : ' disabled';
+ return `
${escapeHtml(title)} ⋮⋮ ×
`;
+ }
+
+ function renderNotesDrawer() {
+ const count = state.notes.length;
+ const badge = document.querySelector('#notes-drawer-btn .reading-note-count');
+ if (badge) { badge.textContent = String(count); badge.style.display = count ? 'block' : 'none'; }
+ const list = document.querySelector('#reading-note-drawer [data-note-list]');
+ if (!list || !state.noteDrawerDirty) return;
+ const disabled = canEditReadingNotes() ? '' : ' disabled';
+ const notesByOutline = new Map();
+ sortNotesForDrawer().forEach((note) => {
+ const outlineId = getValidNoteOutlineId(note.outlineId);
+ const group = notesByOutline.get(outlineId) || [];
+ group.push(note);
+ notesByOutline.set(outlineId, group);
+ });
+ const outlinesHtml = collectNoteOutlines().map((outline) => {
+ const notes = notesByOutline.get(outline.id) || [];
+ return `
${outline.collapsed ? '›' : '⌄'} ${escapeHtml(outline.title)} ×
${notes.map(renderNoteRow).join('')}
`;
+ }).join('');
+ const loose = (notesByOutline.get('') || []).map(renderNoteRow).join('');
+ list.innerHTML = count || state.noteOutlines.length
+ ? `${outlinesHtml}
${loose}
`
+ : '
No notes yet.
';
+ const add = document.querySelector('#reading-note-drawer [data-note-outline-add]');
+ if (add) add.disabled = !canEditReadingNotes();
+ state.noteDrawerDirty = false;
+ }
+
+ function handleNoteDrawerClick(event) {
+ const target = event.target instanceof HTMLElement ? event.target : null;
+ if (!target) return;
+ if (target.closest('[data-note-drawer-close]')) return closeNotesDrawer();
+ if (target.closest('[data-note-outline-add]')) return createNoteOutline();
+ const toggle = target.closest('[data-note-outline-toggle]');
+ if (toggle) return toggleNoteOutline(toggle.getAttribute('data-note-outline-toggle'));
+ const outlineDelete = target.closest('[data-note-outline-delete]');
+ if (outlineDelete) return deleteNoteOutline(outlineDelete.getAttribute('data-note-outline-delete'));
+ const outlineTitle = target.closest('[data-note-outline-title]');
+ if (outlineTitle) return startRenameNoteOutline(outlineTitle.getAttribute('data-note-outline-title'));
+ const noteDelete = target.closest('[data-note-delete]');
+ if (noteDelete) return deleteNote(noteDelete.getAttribute('data-note-delete'));
+ const noteOpen = target.closest('[data-note-open]');
+ if (noteOpen) {
+ const noteId = noteOpen.getAttribute('data-note-open');
+ const anchor = findOrRestoreNoteHighlight(noteId);
+ if (anchor) scrollNoteHighlightIntoView(anchor);
+ openNoteEditor(noteId, { anchorNode: anchor });
+ }
+ }
+
+ function upsertNote(rawNote, options = {}) {
+ if (!canEditReadingNotes()) return null;
+ const normalized = normalizeNotes([rawNote])[0];
+ if (!normalized) return null;
+ normalized.outlineId = getValidNoteOutlineId(normalized.outlineId);
+ const index = state.notes.findIndex((note) => note.id === normalized.id);
+ if (index >= 0) state.notes.splice(index, 1, { ...state.notes[index], ...normalized });
+ else {
+ if (!Number.isFinite(Number(rawNote?.order))) normalized.order = getNextNoteOrder(normalized.outlineId);
+ state.notes.push(normalized);
+ }
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ syncNotesToLegacyText();
+ if (options.forceUi !== false) { renderNotesDrawer(); refreshNoteHighlightAttributes(normalized.id); }
+ if (options.sync !== false) syncReadingAnnotation(options.reason || 'note');
+ return getNoteById(normalized.id);
+ }
+
+ function setNotes(rawNotes, rawOutlines = [], options = {}) {
+ const sanitized = sanitizeNotesWithOutlines(rawNotes, rawOutlines);
+ state.notes = sanitized.notes;
+ state.noteOutlines = sanitized.noteOutlines;
+ if (!state.notes.length && options.legacyText) {
+ const legacyText = String(options.legacyText || '');
+ if (legacyText.trim()) {
+ state.notes = normalizeNotes([{ id: generateNoteId(), title: 'Notes', body: legacyText, quote: '' }]);
+ }
+ }
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ ensureReadingNotesUi();
+ syncNotesToLegacyText();
+ renderNotesDrawer();
+ refreshNoteHighlightAttributes();
+ restoreMissingNoteAnchors();
+ }
+
+ function createNoteOutline() {
+ if (!canEditReadingNotes()) return;
+ const now = Date.now();
+ state.noteOutlines.push({ id: generateNoteOutlineId(), title: 'New outline', order: state.noteOutlines.length, collapsed: false, createdAt: now, updatedAt: now });
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ startRenameNoteOutline(state.noteOutlines[state.noteOutlines.length - 1].id);
+ syncReadingAnnotation('note-outline-add');
+ }
+
+ function getNoteOutlineById(id) { return state.noteOutlines.find((outline) => outline.id === String(id || '')) || null; }
+
+ function toggleNoteOutline(id) {
+ if (!canEditReadingNotes()) return;
+ const outline = getNoteOutlineById(id);
+ if (!outline) return;
+ outline.collapsed = !outline.collapsed;
+ outline.updatedAt = Date.now();
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ syncReadingAnnotation('note-outline-toggle');
+ }
+
+ function deleteNoteOutline(id) {
+ if (!canEditReadingNotes()) return;
+ const outlineId = String(id || '');
+ state.noteOutlines = state.noteOutlines.filter((outline) => outline.id !== outlineId);
+ state.notes.forEach((note) => { if (note.outlineId === outlineId) note.outlineId = ''; });
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ syncReadingAnnotation('note-outline-delete');
+ }
+
+ function startRenameNoteOutline(id) {
+ if (!canEditReadingNotes()) return;
+ const outline = getNoteOutlineById(id);
+ const button = document.querySelector(`[data-note-outline-title="${escapeSelector(id)}"]`);
+ if (!outline || !button) return;
+ const input = document.createElement('input');
+ input.className = 'reading-note-outline-title-input';
+ input.value = outline.title;
+ input.setAttribute('data-note-outline-title-input', outline.id);
+ button.replaceWith(input);
+ input.focus(); input.select();
+ }
+
+ function commitRenameNoteOutline(input, cancel = false) {
+ if (!(input instanceof HTMLInputElement) || input.dataset.committed === 'true') return;
+ if (!canEditReadingNotes() && !cancel) cancel = true;
+ input.dataset.committed = 'true';
+ const outline = getNoteOutlineById(input.getAttribute('data-note-outline-title-input'));
+ if (outline && !cancel) { outline.title = String(input.value || '').trim() || 'New outline'; outline.updatedAt = Date.now(); }
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ if (!cancel) syncReadingAnnotation('note-outline-rename');
+ }
+
+ function handleNoteDrawerKeydown(event) {
+ const input = event.target instanceof HTMLElement ? event.target.closest('[data-note-outline-title-input]') : null;
+ if (input) {
+ if (!canEditReadingNotes() && event.key !== 'Escape') return;
+ if (event.key === 'Enter') { event.preventDefault(); commitRenameNoteOutline(input); }
+ else if (event.key === 'Escape') { event.preventDefault(); commitRenameNoteOutline(input, true); }
+ return;
+ }
+ const handle = event.target instanceof HTMLElement ? event.target.closest('[data-note-drag-handle]') : null;
+ if (!handle || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
+ if (!canEditReadingNotes()) return;
+ event.preventDefault();
+ const note = getNoteById(handle.getAttribute('data-note-drag-handle'));
+ if (!note) return;
+ if (event.key === 'ArrowLeft') note.outlineId = '';
+ else if (event.key === 'ArrowRight' && state.noteOutlines[0]) note.outlineId = state.noteOutlines[0].id;
+ else {
+ const siblings = sortNotesForDrawer().filter((item) => item.outlineId === note.outlineId);
+ const index = siblings.findIndex((item) => item.id === note.id);
+ const targetIndex = event.key === 'ArrowUp' ? index - 1 : index + 1;
+ if (targetIndex >= 0 && targetIndex < siblings.length) {
+ const targetOrder = siblings[targetIndex].order;
+ siblings[targetIndex].order = note.order;
+ note.order = targetOrder;
+ }
+ }
+ note.updatedAt = Date.now();
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ syncReadingAnnotation('note-reorder');
+ }
+
+ function handleNoteDrawerFocusOut(event) {
+ const input = event.target instanceof HTMLInputElement ? event.target.closest('[data-note-outline-title-input]') : null;
+ if (input) commitRenameNoteOutline(input);
+ }
+
+ let draggedNoteId = '';
+ function handleNoteDragStart(event) {
+ if (!canEditReadingNotes()) return;
+ const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null;
+ if (!row) return;
+ draggedNoteId = row.getAttribute('data-note-row') || '';
+ row.classList.add('dragging');
+ event.dataTransfer?.setData('text/plain', draggedNoteId);
+ }
+
+ function handleNoteDragOver(event) {
+ if (!canEditReadingNotes()) return;
+ const target = event.target instanceof HTMLElement ? event.target.closest('[data-note-row], [data-note-drop-list]') : null;
+ if (!target) return;
+ event.preventDefault();
+ clearNoteDragIndicators();
+ document.querySelector(`[data-note-row="${escapeSelector(draggedNoteId)}"]`)?.classList.add('dragging');
+ target.classList.add('drag-over');
+ }
+
+ function handleNoteDrop(event) {
+ if (!canEditReadingNotes()) return clearNoteDragIndicators();
+ event.preventDefault();
+ const note = getNoteById(draggedNoteId || event.dataTransfer?.getData('text/plain'));
+ const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null;
+ const list = event.target instanceof HTMLElement ? event.target.closest('[data-note-drop-list]') : null;
+ if (!note || (!row && !list)) return clearNoteDragIndicators();
+ const outlineId = getValidNoteOutlineId((list || row.closest('[data-note-drop-list]'))?.getAttribute('data-note-drop-list'));
+ const siblings = sortNotesForDrawer().filter((item) => item.id !== note.id && (item.outlineId || '') === outlineId);
+ const index = row ? Math.max(0, siblings.findIndex((item) => item.id === row.getAttribute('data-note-row'))) : siblings.length;
+ siblings.splice(index < 0 ? siblings.length : index, 0, note);
+ siblings.forEach((item, order) => { item.outlineId = outlineId; item.order = order; item.updatedAt = Date.now(); });
+ state.noteDrawerDirty = true;
+ clearNoteDragIndicators();
+ renderNotesDrawer();
+ syncReadingAnnotation('note-reorder');
+ }
+
+ function clearNoteDragIndicators() {
+ document.querySelectorAll('.reading-note-row.dragging,.reading-note-row.drag-over,[data-note-drop-list].drag-over').forEach((node) => node.classList.remove('dragging', 'drag-over'));
+ draggedNoteId = '';
+ }
+
+ function clampNoteEditorPosition(left, top) {
+ const editor = document.getElementById('reading-note-editor');
+ const margin = 12;
+ const width = editor?.offsetWidth || 430;
+ const height = editor?.offsetHeight || 330;
+ return {
+ left: Math.min(Math.max(margin, left), Math.max(margin, global.innerWidth - width - margin)),
+ top: Math.min(Math.max(margin, top), Math.max(margin, global.innerHeight - height - margin))
+ };
+ }
+
+ function positionNoteEditor(anchorNode = null) {
+ const editor = document.getElementById('reading-note-editor');
+ if (!editor) return;
+ let left = Number(state.noteEditorPosition?.left);
+ let top = Number(state.noteEditorPosition?.top);
+ if (!Number.isFinite(left) || !Number.isFinite(top)) {
+ const rect = anchorNode?.getBoundingClientRect?.();
+ left = rect ? rect.left + Math.min(24, rect.width / 2) : (global.innerWidth - (editor.offsetWidth || 430)) / 2;
+ top = rect ? rect.bottom + 10 : (global.innerHeight - (editor.offsetHeight || 330)) / 2;
+ }
+ const position = clampNoteEditorPosition(left, top);
+ editor.style.left = `${Math.round(position.left)}px`;
+ editor.style.top = `${Math.round(position.top)}px`;
+ state.noteEditorPosition = position;
+ }
+
+ function canEditReadingNotes() {
+ if (state.timerLocked) return false;
+ const activePracticeCanEdit = Boolean(
+ !state.readOnly
+ && !state.memorizeMode
+ && !state.submitted
+ );
+ const submittedRecordCanEdit = Boolean(
+ state.submitted
+ && state.submittedRecordId
+ && !state.memorizeMode
+ );
+ return Boolean(state.reviewMode || activePracticeCanEdit || submittedRecordCanEdit);
+ }
+
+ function openNoteEditor(noteId, options = {}) {
+ ensureReadingNotesUi();
+ if (state.activeNoteId && state.activeNoteId !== noteId) flushActiveNoteFromEditor();
+ const note = getNoteById(noteId);
+ if (!note) return;
+ state.activeNoteId = note.id;
+ const editor = document.getElementById('reading-note-editor');
+ const title = editor?.querySelector('[data-note-title]');
+ const body = editor?.querySelector('[data-note-body]');
+ const quote = editor?.querySelector('[data-note-quote]');
+ if (!editor) return;
+ const canEditNotes = canEditReadingNotes();
+ if (title) { title.value = note.title || ''; title.disabled = !canEditNotes; }
+ if (body) { body.value = note.body || ''; body.disabled = !canEditNotes; }
+ if (quote) { quote.textContent = note.quote || ''; quote.style.display = note.quote ? '' : 'none'; }
+ editor.style.display = 'flex';
+ editor.setAttribute('aria-hidden', 'false');
+ global.requestAnimationFrame(() => {
+ positionNoteEditor(options.anchorNode || findNoteHighlight(note.id));
+ (options.focusBody ? body : title)?.focus();
+ });
+ }
+
+ function closeNoteEditor() {
+ flushActiveNoteFromEditor();
+ const editor = document.getElementById('reading-note-editor');
+ if (editor) { editor.style.display = 'none'; editor.setAttribute('aria-hidden', 'true'); }
+ state.activeNoteId = '';
+ }
+
+ function attachNoteEditorDrag(editor) {
+ const handle = editor.querySelector('[data-note-drag-handle]');
+ if (!handle) return;
+ let drag = null;
+ const move = (event) => {
+ if (!drag) return;
+ const next = clampNoteEditorPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y);
+ editor.style.left = `${Math.round(next.left)}px`;
+ editor.style.top = `${Math.round(next.top)}px`;
+ state.noteEditorPosition = next;
+ };
+ const stop = () => {
+ drag = null;
+ document.removeEventListener('pointermove', move);
+ document.removeEventListener('pointerup', stop);
+ document.removeEventListener('pointercancel', stop);
+ };
+ handle.addEventListener('pointerdown', (event) => {
+ if (event.target.closest?.('button')) return;
+ const rect = editor.getBoundingClientRect();
+ drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top };
+ document.addEventListener('pointermove', move);
+ document.addEventListener('pointerup', stop);
+ document.addEventListener('pointercancel', stop);
+ event.preventDefault();
+ });
+ }
+
+ function clearNoteEditorSaveTimer() {
+ if (state.noteEditorSaveTimer) global.clearTimeout(state.noteEditorSaveTimer);
+ state.noteEditorSaveTimer = null;
+ }
+
+ function saveActiveNoteFromEditor() {
+ if (!canEditReadingNotes()) return;
+ const note = getNoteById(state.activeNoteId);
+ if (!note) return;
+ const editor = document.getElementById('reading-note-editor');
+ const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim();
+ const body = String(editor?.querySelector('[data-note-body]')?.value || '');
+ if (title === note.title && body === note.body) return;
+ Object.assign(note, { title, body, updatedAt: Date.now() });
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ state.noteEditorPendingSync = true;
+ syncNotesToLegacyText();
+ clearNoteEditorSaveTimer();
+ state.noteEditorSaveTimer = global.setTimeout(flushActiveNoteFromEditor, NOTE_EDITOR_SAVE_DEBOUNCE_MS);
+ }
+
+ function flushActiveNoteFromEditor() {
+ if (!canEditReadingNotes()) return;
+ const note = getNoteById(state.activeNoteId);
+ if (!note) return;
+ const editor = document.getElementById('reading-note-editor');
+ const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim();
+ const body = String(editor?.querySelector('[data-note-body]')?.value || '');
+ if (title === note.title && body === note.body && !state.noteEditorPendingSync) return;
+ clearNoteEditorSaveTimer();
+ state.noteEditorPendingSync = false;
+ upsertNote({ ...note, title, body, updatedAt: Date.now() }, { forceUi: true, reason: 'note-edit' });
+ }
+
+ function createNoteAnchorSpan(note) {
+ const span = document.createElement('span');
+ span.className = 'hl';
+ span.dataset.hlType = 'note';
+ span.dataset.noteId = note.id;
+ return span;
+ }
+
+ function shouldSkipNoteAnchorTextNode(node) {
+ if (!node?.nodeValue?.trim()) return true;
+ const element = node.parentElement;
+ return Boolean(element?.closest?.('.hl') || getHighlightShared()?.isInsideExplanation?.(node));
+ }
+
+ function wrapNoteTextInRoot(root, note, quote) {
+ const nodes = getHighlightShared()?.getTextNodes?.(root) || [];
+ // 先统计整段里命中次数;saved highlight 缺失才会走到这条兜底路径,若同一引文
+ // 多次出现,按“首次命中”绑定会静默定位到错误位置。这里要求全局唯一匹配才绑定,
+ // 否则放弃恢复该笔记的锚点,而不是盲目绑到第一个重复位置。
+ let matchNode = null;
+ let matchIndex = -1;
+ let totalMatches = 0;
+ for (const node of nodes) {
+ if (shouldSkipNoteAnchorTextNode(node)) continue;
+ const value = String(node.nodeValue || '');
+ let from = 0;
+ let idx = value.indexOf(quote, from);
+ while (idx >= 0) {
+ totalMatches += 1;
+ if (!matchNode) {
+ matchNode = node;
+ matchIndex = idx;
+ }
+ from = idx + quote.length;
+ idx = value.indexOf(quote, from);
+ }
+ }
+ if (totalMatches === 0 || totalMatches > 1 || !matchNode) {
+ return null;
+ }
+ const range = document.createRange();
+ range.setStart(matchNode, matchIndex); range.setEnd(matchNode, matchIndex + quote.length);
+ const span = createNoteAnchorSpan(note);
+ try { range.surroundContents(span); return span; } catch (_) { return null; }
+ }
+
+ function findRestorableNoteAnchor(note) {
+ const quote = normalizeNoteText(note?.quote);
+ if (!quote || quote.length < 2) return null;
+ // 唯一性的判定需要在整篇 passage 范围内完成;逐 root 绑定会让跨 root
+ // 的重复引文被误判为“当前 root 内唯一”。先聚合所有命中,再决定绑定。
+ const roots = [dom.left, dom.groups].filter(Boolean);
+ let totalMatches = 0;
+ let matchRoot = null;
+ for (const root of roots) {
+ const nodes = getHighlightShared()?.getTextNodes?.(root) || [];
+ for (const node of nodes) {
+ if (shouldSkipNoteAnchorTextNode(node)) continue;
+ const value = String(node.nodeValue || '');
+ let from = 0;
+ let idx = value.indexOf(quote, from);
+ while (idx >= 0) {
+ totalMatches += 1;
+ if (!matchRoot) matchRoot = root;
+ from = idx + quote.length;
+ idx = value.indexOf(quote, from);
+ }
+ }
+ }
+ if (totalMatches !== 1 || !matchRoot) return null;
+ return wrapNoteTextInRoot(matchRoot, note, quote);
+ }
+
+ function restoreMissingNoteAnchors() {
+ let count = 0;
+ state.notes.forEach((note) => {
+ if (!findNoteHighlight(note.id) && findRestorableNoteAnchor(note)) count += 1;
+ });
+ if (count) { state.noteHighlightMetaDirty = true; refreshNoteHighlightAttributes(); }
+ return count;
+ }
+
+ function ensureNoteForHighlight(highlightNode, quote = '', options = {}) {
+ if (!(highlightNode instanceof HTMLElement)) return null;
+ let note = getNoteById(highlightNode.dataset.noteId);
+ if (!note && !canEditReadingNotes()) return null;
+ if (!note) {
+ const now = Date.now();
+ note = upsertNote({
+ id: highlightNode.dataset.noteId || generateNoteId(),
+ title: '', body: '', quote: quote || normalizeNoteText(highlightNode.textContent),
+ createdAt: now, updatedAt: now
+ }, { sync: false });
+ }
+ if (note) {
+ highlightNode.dataset.noteId = note.id;
+ highlightNode.dataset.hlType = 'note';
+ state.noteHighlightMetaDirty = true;
+ refreshNoteHighlightAttributes(note.id);
+ if (options.sync !== false) syncReadingAnnotation('note-anchor');
+ }
+ return note;
+ }
+
+ function ensureNoteAnchorsBeforeSnapshot() {
+ document.querySelectorAll('.hl[data-hl-type="note"]').forEach((node) => {
+ if (node instanceof HTMLElement && !node.dataset.noteId) {
+ ensureNoteForHighlight(node, normalizeNoteText(node.textContent), { sync: false });
+ }
+ });
+ }
+
+ function findNoteHighlight(noteId) {
+ const id = String(noteId || '').trim();
+ return id ? document.querySelector(`.hl[data-note-id="${escapeSelector(id)}"]`) : null;
+ }
+
+ function findOrRestoreNoteHighlight(noteId) {
+ const existing = findNoteHighlight(noteId);
+ if (existing) return existing;
+ const note = getNoteById(noteId);
+ return note ? findRestorableNoteAnchor(note) : null;
+ }
+
+ function scrollNoteHighlightIntoView(node) {
+ node?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
+ node?.classList.add('reading-note-flash');
+ global.setTimeout(() => node?.classList.remove('reading-note-flash'), 900);
+ }
+
+ function deleteNote(noteId, options = {}) {
+ if (!canEditReadingNotes()) return;
+ const id = String(noteId || '').trim();
+ if (!id) return;
+ state.notes = state.notes.filter((note) => note.id !== id);
+ document.querySelectorAll(`.hl[data-note-id="${escapeSelector(id)}"]`).forEach((node) => {
+ const parent = node.parentNode;
+ if (!parent) return;
+ while (node.firstChild) parent.insertBefore(node.firstChild, node);
+ node.remove(); parent.normalize();
+ });
+ if (state.activeNoteId === id) { state.activeNoteId = ''; closeNoteEditor(); }
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ syncNotesToLegacyText();
+ renderNotesDrawer();
+ if (options.sync !== false) syncReadingAnnotation('note-delete');
+ }
+
+ function clearStructuredNotesForReset() {
+ if (!canEditReadingNotes()) return;
+ clearNoteEditorSaveTimer();
+ state.noteEditorPendingSync = false;
+ state.activeNoteId = '';
+ state.notes = [];
+ state.noteOutlines = [];
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ document.querySelectorAll('.hl[data-note-id], .hl[data-hl-type="note"]').forEach((node) => {
+ const parent = node.parentNode;
+ if (!parent) return;
+ while (node.firstChild) parent.insertBefore(node.firstChild, node);
+ node.remove();
+ parent.normalize();
+ });
+ setNotesText('');
+ const editor = document.getElementById('reading-note-editor');
+ if (editor) {
+ editor.querySelectorAll('input, textarea').forEach((field) => { field.value = ''; });
+ editor.style.display = 'none';
+ editor.setAttribute('aria-hidden', 'true');
+ }
+ closeNotesDrawer();
+ renderNotesDrawer();
+ }
+
+ function refreshNoteHighlightAttributes(noteId = '') {
+ if (!state.noteHighlightMetaDirty && !noteId) return;
+ const selector = noteId ? `.hl[data-note-id="${escapeSelector(noteId)}"]` : '.hl[data-note-id]';
+ document.querySelectorAll(selector).forEach((node) => {
+ if (!(node instanceof HTMLElement)) return;
+ const note = getNoteById(node.dataset.noteId);
+ const title = String(note?.title || '').trim() || buildDefaultNoteTitle(node.textContent);
+ node.dataset.hlType = 'note';
+ node.title = `Note: ${title}`;
+ node.setAttribute('role', 'button');
+ node.tabIndex = 0;
+ node.setAttribute('aria-label', `Open note: ${title}`);
+ });
+ state.noteHighlightMetaDirty = false;
+ }
+
+ function handleNoteHighlightClick(event) {
+ const highlight = event.target instanceof HTMLElement ? event.target.closest('.hl[data-note-id]') : null;
+ if (!highlight) return;
+ event.preventDefault(); event.stopPropagation();
+ openNoteEditor(highlight.dataset.noteId, { anchorNode: highlight });
+ }
+
+ function syncReadingAnnotation(reason = 'note') {
+ if (!canEditReadingNotes()) return;
+ const isSuiteReviewAnnotation = Boolean(
+ state.simulationMode
+ && state.suiteReviewMode
+ && state.reviewMode
+ && state.suiteSessionId
+ );
+ if (state.simulationMode && (!state.readOnly || isSuiteReviewAnnotation)) {
+ syncSimulationDraftSnapshot(reason);
+ return;
+ }
+ if (state.reviewMode) {
+ postMessage('READING_ANNOTATION_SYNC', {
+ examId: state.examId,
+ recordId: state.reviewRecordId || null,
+ reviewSessionId: state.reviewSessionId || null,
+ sessionId: state.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null,
+ annotations: {
+ highlights: collectHighlights(),
+ noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
+ scrollY: global.scrollY || 0
+ },
+ reason
+ });
+ return;
}
- if (!options.silent) {
- postMessage('SIMULATION_ACTIVE_EXAM_CHANGE', {
- examId: targetExamId,
- currentIndex: state.suite.currentIndex,
- suiteSequence: state.suite.sequence.map((entry) => ({ ...entry }))
+ // 单篇 final-submit 后(submitted=true,reviewMode=false),宿主在保存练习
+ // 记录后通过 PRACTICE_RECORD_SAVED 回传 recordId。持有该 id 时,结果页笔记
+ // 改动需要以 READING_ANNOTATION_SYNC 直接写回已存档的练习记录,而非走草稿
+ // 同步(草稿在提交时已被清除,且 draft 分支在此状态下会被跳过)。
+ if (state.submitted && state.submittedRecordId && !state.memorizeMode) {
+ postMessage('READING_ANNOTATION_SYNC', {
+ examId: state.examId,
+ recordId: state.submittedRecordId,
+ reviewSessionId: null,
+ sessionId: state.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null,
+ annotations: {
+ highlights: collectHighlights(),
+ noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
+ scrollY: global.scrollY || 0
+ },
+ reason
});
+ return;
}
- return true;
- }
-
- async function ensureExplanationManifest() {
- if (global.__READING_EXPLANATION_MANIFEST__) {
- return global.__READING_EXPLANATION_MANIFEST__;
+ if (!state.readOnly && !state.submitted && !state.memorizeMode) {
+ syncReadingDraftSnapshot(reason);
}
- await loadScript('../reading-explanations/manifest.js');
- return global.__READING_EXPLANATION_MANIFEST__ || {};
}
async function ensureExplanationDataset() {
@@ -3405,6 +8033,11 @@
.reading-locator-highlight:hover {
background: rgba(250, 204, 21, 0.62);
}
+ .reading-locator-overlap { cursor:pointer; text-decoration:underline #dc2626 2px; text-underline-offset:3px; }
+ .reading-locator-highlight.is-review-jump-target,.reading-locator-overlap.is-review-jump-target { outline:2px solid rgba(37,99,235,.45); outline-offset:2px; }
+ .reading-locator-block { display:inline-block;width:1px;height:1em;overflow:hidden;opacity:0;pointer-events:none;vertical-align:baseline; }
+ .reading-passage-locator-target.is-review-jump-target { border-radius:4px;outline:2px solid rgba(37,99,235,.38);background:rgba(96,165,250,.12); }
+ .results-table .question-jump-btn { border:0;padding:0;background:transparent;color:#2563eb;font:inherit;font-weight:700;cursor:pointer;text-decoration:underline;text-underline-offset:2px; }
`;
document.head.appendChild(style);
}
@@ -3423,6 +8056,11 @@
return;
}
shared.unwrapMatchingHighlights(dom.left, LOCATOR_HIGHLIGHT_SELECTOR);
+ dom.left?.querySelectorAll('.reading-passage-locator-target').forEach((node) => node.classList.remove('reading-passage-locator-target', 'is-review-jump-target'));
+ dom.left?.querySelectorAll(LOCATOR_OVERLAP_SELECTOR).forEach((node) => {
+ node.classList.remove('reading-locator-overlap', 'is-review-jump-target');
+ delete node.dataset.locatorOverlap;
+ });
}
function getHighlightShared() {
@@ -3725,17 +8363,14 @@
let draftsByExam = {};
let resultsByExam = {};
try {
- const raw = global.sessionStorage?.getItem('ielts_sim_session');
- if (raw) {
- const parsed = JSON.parse(raw);
- if (parsed) {
- if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence;
- if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam;
- if (Array.isArray(parsed.results)) {
- parsed.results.forEach(res => {
- if (res && res.examId) resultsByExam[res.examId] = res;
- });
- }
+ const parsed = global.AppData?.recovery?.windowSession?.get('simulation');
+ if (parsed) {
+ if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence;
+ if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam;
+ if (Array.isArray(parsed.results)) {
+ parsed.results.forEach(res => {
+ if (res && res.examId) resultsByExam[res.examId] = res;
+ });
}
}
} catch (_) {}
@@ -4430,7 +9065,7 @@
function attachMemorizeLocatorListeners() {
document.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement
- ? event.target.closest('.reading-locator-highlight[data-question-id]')
+ ? event.target.closest('.reading-locator-highlight[data-question-id],.reading-locator-overlap[data-question-id],.reading-locator-block[data-question-id]')
: null;
if (!target) {
return;
@@ -4724,6 +9359,61 @@
return snippets;
}
+ function buildLocatorSnippetVariants(text) {
+ const source = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!source) return [];
+ return Array.from(new Set([
+ source,
+ source.replace(/[‘’]/g, "'").replace(/[“”]/g, '"'),
+ source.replace(/[‐‑‒–—―]/g, '-'),
+ source.replace(/\s+-\s+/g, ' — '),
+ source.replace(/\s+-\s+/g, ' – ')
+ ])).filter(Boolean);
+ }
+
+ function normalizeLocatorComparableText(text) {
+ return String(text || '').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/[‐‑‒–—―]/g, '-').replace(/\s+/g, ' ').trim().toLowerCase();
+ }
+
+ function findPassageBlockForLocatorSnippet(snippet) {
+ if (!dom.left || !snippet) return null;
+ const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText);
+ return Array.from(dom.left.querySelectorAll('p, li, td, th, div')).filter((node) => {
+ if (node.closest(EXPLANATION_NODE_SELECTOR) || node.classList.contains('reading-locator-highlight')) return false;
+ if (node.tagName === 'DIV' && node.querySelector('p, li, td, th')) return false;
+ const text = normalizeLocatorComparableText(node.textContent);
+ return text.length >= 10 && variants.some((variant) => text.includes(variant));
+ }).sort((a, b) => String(a.textContent || '').length - String(b.textContent || '').length)[0] || null;
+ }
+
+ function markOverlappingLocatorHighlight(questionId, snippet) {
+ const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText);
+ const target = Array.from(dom.left?.querySelectorAll('.hl') || []).find((node) => {
+ const text = normalizeLocatorComparableText(node.textContent);
+ return text.length >= 12 && variants.some((variant) => text.includes(variant) || variant.includes(text));
+ });
+ if (!target) return null;
+ target.classList.add('reading-locator-overlap');
+ target.dataset.questionId = questionId;
+ target.dataset.locatorOverlap = 'true';
+ target.title = `Q${displayLabel(questionId)} 定位`;
+ return target;
+ }
+
+ function createLocatorBlock(questionId, snippet) {
+ const target = findPassageBlockForLocatorSnippet(snippet);
+ if (!target) return null;
+ const existing = target.querySelector(`.reading-locator-block[data-question-id="${escapeSelector(questionId)}"]`);
+ if (existing) return existing;
+ target.classList.add('reading-passage-locator-target');
+ const marker = document.createElement('span');
+ marker.className = 'reading-locator-block';
+ marker.dataset.questionId = questionId;
+ marker.setAttribute('aria-hidden', 'true');
+ target.insertBefore(marker, target.firstChild);
+ return marker;
+ }
+
function buildMemorizeLocatorSnippets() {
const snippetsByQuestionId = new Map();
const sections = Array.isArray(state.explanation?.questionExplanations)
@@ -4767,7 +9457,7 @@
function applyMemorizeLocatorHighlights() {
clearMemorizeLocatorHighlights();
- if (!state.memorizeMode || !dom.left) {
+ if ((!state.memorizeMode && !state.reviewMode && !state.submitted) || !dom.left) {
return 0;
}
const shared = getHighlightShared();
@@ -4779,21 +9469,68 @@
let applied = 0;
snippetsByQuestionId.forEach((snippets, questionId) => {
snippets.slice(0, 4).forEach((snippet) => {
- const matches = shared.wrapTextMatches(dom.left, snippet, {
- className: 'reading-locator-highlight',
- attrs: {
- 'data-question-id': questionId,
- title: `Q${displayLabel(questionId)} 定位`
- },
- limit: 2,
- skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block'
- });
+ let matches = [];
+ for (const variant of buildLocatorSnippetVariants(snippet)) {
+ if (matches.length) break;
+ matches = shared.wrapTextMatches(dom.left, variant, {
+ className: 'reading-locator-highlight',
+ attrs: { 'data-question-id': questionId, title: `Q${displayLabel(questionId)} 定位` },
+ limit: 2,
+ skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block'
+ });
+ }
+ if (!matches.length) {
+ const overlap = markOverlappingLocatorHighlight(questionId, snippet);
+ if (overlap) matches = [overlap];
+ }
+ if (!matches.length) {
+ const marker = createLocatorBlock(questionId, snippet);
+ if (marker) matches = [marker];
+ }
applied += matches.length;
});
});
return applied;
}
+ function findLocatorAnchor(questionId) {
+ const normalized = normalizeQuestionId(questionId);
+ return Array.from(document.querySelectorAll('.reading-locator-highlight[data-question-id],.reading-locator-block[data-question-id],.reading-locator-overlap[data-question-id]'))
+ .find((node) => normalizeQuestionId(node.dataset.questionId) === normalized) || null;
+ }
+
+ function applyLocatorHighlightsForQuestion(questionId) {
+ const normalized = normalizeQuestionId(questionId);
+ const snippets = buildMemorizeLocatorSnippets().get(normalized) || [];
+ if (!normalized || !dom.left) return 0;
+ const shared = getHighlightShared();
+ for (const snippet of snippets) {
+ for (const variant of buildLocatorSnippetVariants(snippet)) {
+ const matches = shared?.wrapTextMatches?.(dom.left, variant, {
+ className: 'reading-locator-highlight',
+ attrs: { 'data-question-id': normalized, title: `Q${displayLabel(normalized)} 定位` },
+ limit: 1,
+ skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block'
+ }) || [];
+ if (matches.length) return matches.length;
+ }
+ if (markOverlappingLocatorHighlight(normalized, snippet) || createLocatorBlock(normalized, snippet)) return 1;
+ }
+ return 0;
+ }
+
+ function jumpToQuestionEvidence(questionId) {
+ if (!findLocatorAnchor(questionId)) applyLocatorHighlightsForQuestion(questionId);
+ const locator = findLocatorAnchor(questionId);
+ const target = locator || findQuestionAnchor(questionId);
+ if (!target) return false;
+ target.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
+ const highlightTarget = locator?.classList.contains('reading-locator-block') ? locator.closest('.reading-passage-locator-target') : locator;
+ highlightTarget?.classList.add('is-review-jump-target');
+ global.setTimeout(() => highlightTarget?.classList.remove('is-review-jump-target'), 1800);
+ return true;
+ }
+
async function renderMemorizeStudyLayer() {
if (!state.memorizeMode) {
return;
@@ -4862,7 +9599,7 @@
if (!item) return null;
const sourceDropzone = item.closest('.paragraph-dropzone, .match-dropzone, .drop-target-summary');
return {
- value: item.dataset.heading || item.dataset.option || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(),
+ value: item.dataset.heading || item.dataset.option || item.dataset.key || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(),
label: item.dataset.answerLabel || item.dataset.word || item.dataset.value || item.textContent.trim(),
sourceDropzoneId: sourceDropzone?.dataset?.dropzoneId || ''
};
@@ -5320,7 +10057,7 @@
const checkboxGroups = getCheckboxAnswers();
checkboxGroups.forEach((values, name) => {
- const questionIds = expandQuestionSequence(name);
+ const questionIds = resolveCheckboxQuestionIds(name);
if (!questionIds.length) {
return;
}
@@ -5355,6 +10092,26 @@
return answers;
}
+ function resolveCheckboxQuestionIds(name) {
+ const questionIds = expandQuestionSequence(name);
+ if (questionIds.length <= 1) {
+ return questionIds;
+ }
+ const firstQuestionId = questionIds[0];
+ const answerKey = state.dataset?.answerKey || {};
+ const questionGroup = buildQuestionGroupLookup(state.dataset).get(firstQuestionId) || null;
+ if (
+ questionGroup
+ && questionGroup.kind === 'multi_choice'
+ && Array.isArray(questionGroup.questionIds)
+ && questionGroup.questionIds.length === 1
+ && Array.isArray(answerKey[firstQuestionId])
+ ) {
+ return [firstQuestionId];
+ }
+ return questionIds;
+ }
+
function normalizeAnswerValue(value) {
if (Array.isArray(value)) {
return splitAnswerTokens(value);
@@ -5490,10 +10247,16 @@
: splitAnswerTokens(value);
const normalized = [];
rawTokens.forEach((entry) => {
- const token = canonicalizeAnswerToken(entry);
+ const rawChoiceToken = String(entry ?? '').trim().toUpperCase();
+ const token = /^[A-Z]$/.test(rawChoiceToken)
+ ? rawChoiceToken
+ : canonicalizeAnswerToken(entry);
if (!token) {
return;
}
+ if (!/^[A-Z]$/.test(token)) {
+ return;
+ }
if (!normalized.some((existing) => areAnswerTokensEquivalent(existing, token))) {
normalized.push(token);
}
@@ -5513,6 +10276,50 @@
return tokens.sort((left, right) => left.localeCompare(right, 'en'));
}
+ function resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, targetQuestionId) {
+ const questionIds = Array.isArray(questionGroup?.questionIds)
+ ? questionGroup.questionIds.map((entry) => normalizeQuestionId(entry)).filter(Boolean)
+ : [];
+ const selectedTokens = collectGroupChoiceTokens(answers, questionIds);
+ const remainingTokens = selectedTokens.slice();
+ const assignments = new Map();
+
+ questionIds.forEach((questionId) => {
+ const expectedToken = canonicalizeAnswerToken(answerKey[questionId]);
+ if (!expectedToken) {
+ return;
+ }
+ const matchedIndex = remainingTokens.findIndex((token) => areAnswerTokensEquivalent(token, expectedToken));
+ if (matchedIndex >= 0) {
+ assignments.set(questionId, remainingTokens[matchedIndex]);
+ remainingTokens.splice(matchedIndex, 1);
+ }
+ });
+
+ questionIds.forEach((questionId) => {
+ if (assignments.has(questionId)) {
+ return;
+ }
+ const fallbackToken = remainingTokens.shift();
+ if (fallbackToken) {
+ assignments.set(questionId, fallbackToken);
+ }
+ });
+
+ const normalizedTargetId = normalizeQuestionId(targetQuestionId) || targetQuestionId;
+ const expectedToken = canonicalizeAnswerToken(answerKey[normalizedTargetId]);
+ const assignedToken = assignments.get(normalizedTargetId) || '';
+ return {
+ // Review rows for split-key multi-choice still show the full selected set
+ // so partial credit remains inspectable even though scoring is per expected token.
+ displayUserAnswer: selectedTokens.length
+ ? selectedTokens.slice()
+ : (assignedToken || answers[normalizedTargetId] || ''),
+ expectedToken,
+ isCorrect: Boolean(assignedToken && expectedToken && areAnswerTokensEquivalent(assignedToken, expectedToken))
+ };
+ }
+
function questionWeight(correctAnswer, questionGroup = null) {
if (Array.isArray(correctAnswer)) {
const normalized = normalizeAnswerValue(correctAnswer);
@@ -5573,14 +10380,13 @@
let partialCorrectCount = isCorrect ? weight : 0;
if (isSplitMultiChoiceGroup) {
- const selectedTokens = collectGroupChoiceTokens(answers, questionGroup.questionIds);
- const expectedToken = canonicalizeAnswerToken(correctAnswer);
- displayUserAnswer = selectedTokens.length ? selectedTokens : userAnswer;
- if (!expectedToken) {
+ const splitSelection = resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, normalizedQuestionId);
+ displayUserAnswer = splitSelection.displayUserAnswer || userAnswer;
+ if (!splitSelection.expectedToken) {
isCorrect = null;
partialCorrectCount = 0;
} else {
- isCorrect = selectedTokens.some((token) => areAnswerTokensEquivalent(token, expectedToken));
+ isCorrect = splitSelection.isCorrect;
partialCorrectCount = isCorrect ? 1 : 0;
}
weight = 1;
@@ -5665,13 +10471,17 @@
const label = escapeHtml(displayLabel(entry.questionId));
const userAnswer = escapeHtml(displayAnswerValue(entry.userAnswer));
const correctAnswer = escapeHtml(displayAnswerValue(entry.correctAnswer, ''));
- const status = entry.isCorrect ? '✓' : '✗';
+ const partial = Number(entry.partialCorrectCount) || 0;
+ const weight = Number(entry.weight) || 1;
+ const isPartial = !entry.isCorrect && partial > 0 && weight > 1;
+ const status = entry.isCorrect ? '✓' : (isPartial ? `${partial}/${weight}` : '✗');
+ const statusClass = entry.isCorrect ? 'result-correct' : (isPartial ? 'result-partial' : 'result-incorrect');
return `
- ${label}
+ ${label}
${userAnswer}
${correctAnswer || ''}
- ${status}
+ ${status}
`;
}).join('');
@@ -5691,6 +10501,9 @@
`;
dom.results.style.display = 'block';
+ dom.results.querySelectorAll?.('[data-result-question-id]').forEach((button) => {
+ button.addEventListener('click', () => jumpToQuestionEvidence(button.dataset.resultQuestionId || ''));
+ });
}
function escapeSelector(value) {
@@ -5875,9 +10688,21 @@
const controls = document.querySelectorAll('input, textarea, select');
controls.forEach((control) => {
if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement) {
+ // review、普通进行中练习、以及已回传 recordId 的结果页允许编辑笔记;
+ // 只读/计时锁定/背诵模式仍保持禁用,避免改动无法保存或破坏答题流程。
+ const canEditNotes = canEditReadingNotes();
+ if (
+ canEditNotes
+ && typeof control.closest === 'function'
+ && control.closest('#reading-note-editor, #reading-note-drawer')
+ ) {
+ control.disabled = false;
+ return;
+ }
control.disabled = state.readOnly || state.timerLocked;
}
});
+ renderNotesDrawer();
syncPrimaryActionButtons();
refreshSimulationDraftSyncLifecycle();
enhanceReviewHighlights();
@@ -5898,6 +10723,8 @@
}
function enterSubmittedReadOnlyState(reason = 'submit') {
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'submitted';
state.submitted = true;
setReadOnlyMode(true, reason);
disableDragInteractions();
@@ -5910,22 +10737,136 @@
syncPrimaryActionButtons();
}
+ function clearSubmissionAckTimer() {
+ if (state.submissionAckTimer) {
+ clearTimeout(state.submissionAckTimer);
+ state.submissionAckTimer = null;
+ }
+ }
+
+ function createSubmissionId() {
+ try {
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') {
+ return global.crypto.randomUUID();
+ }
+ } catch (_) {
+ // Fall through to a session-bound identifier.
+ }
+ return [state.sessionId || 'session', state.examId || 'exam', Date.now(), Math.random().toString(36).slice(2)].join(':');
+ }
+
+ function restoreDraftSubmissionState(submissionId = '') {
+ if (state.submissionStatus === 'submitted') {
+ return false;
+ }
+ if (submissionId && state.submissionId && submissionId !== state.submissionId) {
+ return false;
+ }
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'draft';
+ state.submitted = false;
+ syncPrimaryActionButtons();
+ return true;
+ }
+
+ function expirePendingSubmission(submissionId = '') {
+ if (state.submissionStatus !== 'submitting') {
+ return false;
+ }
+ return restoreDraftSubmissionState(submissionId || state.submissionId);
+ }
+
+ function beginSubmission(messageType, payload, presentation = null) {
+ if (state.submissionStatus === 'submitting' || state.submissionStatus === 'submitted') {
+ return false;
+ }
+ if (!state.submissionId) {
+ state.submissionId = createSubmissionId();
+ }
+ state.submissionStatus = 'submitting';
+ state.pendingSubmissionPresentation = presentation;
+ syncPrimaryActionButtons();
+ const delivered = postMessage(messageType, Object.assign({}, payload || {}, {
+ submissionId: state.submissionId
+ }));
+ if (!delivered) {
+ restoreDraftSubmissionState(state.submissionId);
+ return false;
+ }
+ clearSubmissionAckTimer();
+ state.submissionAckTimer = setTimeout(() => {
+ expirePendingSubmission(state.submissionId);
+ }, SUBMIT_ACK_TIMEOUT_MS);
+ return true;
+ }
+
+ function matchesPendingSubmission(data = {}) {
+ if (state.submissionStatus !== 'submitting') return false;
+ const submissionId = data && data.submissionId != null ? String(data.submissionId).trim() : '';
+ const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : '';
+ const examId = data && data.examId != null ? String(data.examId).trim() : '';
+ const suiteSessionId = data && data.suiteSessionId != null ? String(data.suiteSessionId).trim() : '';
+ if (!submissionId || submissionId !== state.submissionId) return false;
+ if (!sessionId || !state.sessionId || sessionId !== String(state.sessionId)) return false;
+ if (!examId || !state.examId || examId !== String(state.examId)) return false;
+ if (state.suiteSessionId && suiteSessionId !== String(state.suiteSessionId)) return false;
+ if (!state.suiteSessionId && suiteSessionId) return false;
+ return true;
+ }
+
+ async function acceptSubmissionAcknowledgement(data = {}) {
+ if (!matchesPendingSubmission(data)) {
+ return false;
+ }
+ const presentation = state.pendingSubmissionPresentation;
+ clearSubmissionAckTimer();
+ enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit');
+ if (presentation && presentation.results) {
+ state.lastResults = presentation.results;
+ renderResults(presentation.results);
+ await renderExplanations();
+ applyHighlights(Array.isArray(presentation.highlights) ? presentation.highlights : []);
+ refreshNoteHighlightAttributes();
+ restoreMissingNoteAnchors();
+ applyMemorizeLocatorHighlights();
+ enhanceReviewHighlights();
+ updateNavStatuses(presentation.results);
+ }
+ state.pendingSubmissionPresentation = null;
+ if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) {
+ stopSimulationDraftSync();
+ clearSimulationDraftMirror();
+ state.simulationDraftFingerprint = '';
+ }
+ return true;
+ }
+
if (global.__IELTS_READING_PAGE_TEST_HOOKS__ === true) {
global.__IELTS_UNIFIED_READING_PAGE_TEST__ = Object.assign(
global.__IELTS_UNIFIED_READING_PAGE_TEST__ || {},
{
buildReplayResults,
mergeDraft,
+ normalizeNotes,
+ normalizeNoteOutlines,
+ syncReadingAnnotation,
mergeSuiteDraftPayload,
captureInlineSuiteDraftBeforeReinit,
shouldIgnoreInlineSuiteEnvelope,
shouldAcceptWindowSessionMessage,
adoptWindowSessionMessage,
+ buildInitSignature,
handleIncoming,
initializeInlineSimulationSuite,
buildResultsFromAnswers,
renderTimer,
handleSubmit,
+ beginSubmission,
+ acceptSubmissionAcknowledgement,
+ expirePendingSubmission,
+ restoreDraftSubmissionState,
+ stopReadingDraftSync,
+ stopSimulationDraftSync,
getTestState() {
return {
examId: state.examId,
@@ -5945,6 +10886,19 @@
currentIndex: state.suite?.currentIndex || 0,
suiteInline: Boolean(state.suite?.inline),
suiteTimerLimitSeconds: state.suiteTimerLimitSeconds,
+ reviewRecordId: state.reviewRecordId,
+ submittedRecordId: state.submittedRecordId,
+ submitted: state.submitted,
+ readOnly: state.readOnly,
+ submissionStatus: state.submissionStatus,
+ submissionId: state.submissionId,
+ parentOrigin: state.parentOrigin,
+ parentOriginIsOpaque: state.parentOriginIsOpaque,
+ expectedParentOrigin: state.expectedParentOrigin,
+ windowSessionToken: state.windowSessionToken,
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: normalizeMarkedQuestions(state.markedQuestions),
suiteSequence: Array.isArray(state.suite?.sequence)
? state.suite.sequence.map((entry) => ({ ...entry }))
: [],
@@ -6071,7 +11025,7 @@
if (!state.readOnly || canResetSubmittedSingle) {
setSubmitLabel(dom.submitBtn.dataset.defaultLabel || 'Submit');
}
- dom.submitBtn.disabled = state.readOnly;
+ dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting';
}
if (dom.resetBtn) {
dom.resetBtn.style.display = '';
@@ -6092,7 +11046,7 @@
dom.submitBtn.style.display = ctx.isLast ? '' : 'none';
dom.submitBtn.setAttribute('type', 'button');
setSubmitLabel('Submit');
- dom.submitBtn.disabled = state.readOnly;
+ dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting';
}
}
@@ -6165,8 +11119,13 @@
}
function resetToAnsweringPresentation() {
+ clearSubmissionAckTimer();
state.lastResults = null;
state.submitted = false;
+ state.submissionStatus = 'draft';
+ state.submissionId = '';
+ state.pendingSubmissionPresentation = null;
+ state.submittedRecordId = '';
state.readOnly = false;
state.timerLocked = false;
state.timerExpired = false;
@@ -6228,6 +11187,8 @@
syncPrimaryActionButtons();
} else {
state.reviewMode = true;
+ // 进入 review 视图后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。
+ state.submittedRecordId = '';
if (data.readOnly !== false) {
enterSubmittedReadOnlyState('stationary-review');
} else {
@@ -6238,6 +11199,7 @@
async function applyReplayRecord(data = {}) {
const entry = data.entry && typeof data.entry === 'object' ? data.entry : data;
+ const replayData = entry.realData && typeof entry.realData === 'object' ? entry.realData : {};
const entryExamId = entry && entry.examId != null ? String(entry.examId).trim() : '';
const currentExamId = state.examId != null ? String(state.examId).trim() : '';
if (entryExamId && currentExamId && entryExamId !== currentExamId) {
@@ -6250,7 +11212,10 @@
? entry.markedQuestions
: (Array.isArray(entry.metadata && entry.metadata.markedQuestions)
? entry.metadata.markedQuestions
- : []));
+ : (Array.isArray(replayData.markedQuestions) ? replayData.markedQuestions : [])));
+ state.reviewRecordId = String(data.recordId || entry.id || '').trim();
+ // 进入 review 回放后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。
+ state.submittedRecordId = '';
if (data.reviewSessionId) {
state.reviewSessionId = data.reviewSessionId;
}
@@ -6260,8 +11225,16 @@
state.reviewMode = true;
state.reviewViewMode = 'review';
applyReplayAnswersToDom(replayResults.answers || {});
- const replayHighlights = Array.isArray(entry.highlights) ? entry.highlights : [];
+ const replayHighlights = Array.isArray(entry.highlights)
+ ? entry.highlights
+ : (Array.isArray(replayData.highlights) ? replayData.highlights : []);
applyHighlights(replayHighlights);
+ setNotes(
+ Array.isArray(entry.notes) ? entry.notes : replayData.notes,
+ Array.isArray(entry.noteOutlines) ? entry.noteOutlines : replayData.noteOutlines,
+ { legacyText: typeof entry.noteText === 'string' ? entry.noteText : replayData.noteText }
+ );
+ state.markedQuestions = normalizeMarkedQuestions(replayMarks);
enhanceReviewHighlights();
if (Number.isFinite(Number(entry.scrollY))) {
global.scrollTo(0, Number(entry.scrollY));
@@ -6270,6 +11243,9 @@
renderResults(replayResults);
await renderExplanations();
applyHighlights(replayHighlights);
+ refreshNoteHighlightAttributes();
+ restoreMissingNoteAnchors();
+ applyMemorizeLocatorHighlights();
enhanceReviewHighlights();
updateNavStatuses(replayResults);
if (data.readOnly !== false) {
@@ -6368,9 +11344,6 @@
function adoptWindowSessionMessage(data = {}, sourceWindow = null) {
const incomingToken = normalizeWindowSessionToken(data && data.windowSessionToken);
const incomingIssuedAtMs = readMessageIssuedAtMs(data);
- if (sourceWindow) {
- state.parentWindow = sourceWindow;
- }
if (incomingToken) {
state.windowSessionToken = incomingToken;
}
@@ -6381,25 +11354,77 @@
}
}
- function postMessage(type, payload) {
- const envelope = buildEnvelope(type, payload);
- const candidates = [global.opener, state.parentWindow, global.parent];
- const visited = new Set();
- for (let index = 0; index < candidates.length; index += 1) {
- const target = candidates[index];
- if (!target || target === global || visited.has(target)) {
- continue;
+ function acceptHostInitMessage(event, envelope, data = {}) {
+ if (!state.parentWindow || !event || event.source !== state.parentWindow) return false;
+ if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false;
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ const declaredOrigin = typeof data.parentOrigin === 'string' ? data.parentOrigin : '';
+ const incomingToken = normalizeWindowSessionToken(data.windowSessionToken);
+ if (!incomingToken) return false;
+ // "file://" is not a usable postMessage target/origin pin. Treat it the same
+ // as an unbound referrer so file:// hosts can bind via opaque "null".
+ const expectedParentOrigin = state.expectedParentOrigin
+ && state.expectedParentOrigin !== 'file://'
+ && !String(state.expectedParentOrigin).startsWith('file:')
+ ? state.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) {
+ return false;
}
- visited.add(target);
- try {
- target.postMessage(envelope, '*');
- state.parentWindow = target;
- return true;
- } catch (_) {
- // try next candidate
+ state.parentOrigin = expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else if (global.location.protocol === 'file:') {
+ // File pages can report either opaque "null" or "file://" for iframe
+ // messages across Chromium platforms; never accept a web origin here.
+ const trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://')
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) {
+ return false;
+ }
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ } else {
+ const trustedWebOrigin = Boolean(incomingOrigin)
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) {
+ return false;
}
+ state.parentOrigin = incomingOrigin;
+ state.parentOriginIsOpaque = false;
+ }
+ return true;
+ }
+
+ function isTrustedHostMessage(event, envelope, data = {}) {
+ if (!state.parentWindow || !event || event.source !== state.parentWindow) return false;
+ if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false;
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ if (state.parentOriginIsOpaque) {
+ if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') return false;
+ } else if (!state.parentOrigin || incomingOrigin !== state.parentOrigin) {
+ return false;
+ }
+ const expectedToken = normalizeWindowSessionToken(state.windowSessionToken);
+ const incomingToken = normalizeWindowSessionToken(data.windowSessionToken);
+ return Boolean(expectedToken && incomingToken && expectedToken === incomingToken);
+ }
+
+ function postMessage(type, payload) {
+ const envelope = buildEnvelope(type, payload);
+ const target = state.parentWindow;
+ if (!target || target === global || typeof target.postMessage !== 'function') return false;
+ const targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) return false;
+ try {
+ return target.postMessage(envelope, targetOrigin) !== false;
+ } catch (_) {
+ return false;
}
- return false;
}
function stopInitLoop() {
@@ -6441,7 +11466,8 @@
suiteTimerAnchorMs: Number.isFinite(Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs))) ? Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs)) : null,
suiteTimerMode: data && typeof data.suiteTimerMode === 'string' ? data.suiteTimerMode.trim().toLowerCase() : '',
suiteTimerLimitSeconds: parseOptionalNonNegativeInteger(data && data.suiteTimerLimitSeconds),
- globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null
+ globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null,
+ draftFingerprint: buildDraftFingerprint(data && data.draft)
});
}
@@ -6467,6 +11493,10 @@
}
function restartInitHandshake() {
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'draft';
+ state.submissionId = '';
+ state.pendingSubmissionPresentation = null;
state.sessionId = null;
state.sessionReadySent = false;
state.lastInitSignature = '';
@@ -6518,13 +11548,13 @@
}, 500);
}
- function getSimulationDraftStorageKey() {
+ function getSimulationDraftSessionName() {
const suiteSessionId = state.suiteSessionId ? String(state.suiteSessionId).trim() : '';
const examId = state.examId ? String(state.examId).trim() : '';
if (!suiteSessionId || !examId) {
return '';
}
- return `ielts_sim_draft::${suiteSessionId}::${examId}`;
+ return `simulation-draft:${suiteSessionId}:${examId}`;
}
function cloneDraftSafely(draft) {
@@ -6538,6 +11568,9 @@
answers: draft.answers && typeof draft.answers === 'object' ? { ...draft.answers } : {},
highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [],
noteText: typeof draft.noteText === 'string' ? draft.noteText : '',
+ notes: normalizeNotes(draft.notes),
+ noteOutlines: normalizeNoteOutlines(draft.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(draft.markedQuestions),
scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0
};
}
@@ -6548,6 +11581,11 @@
return '';
}
try {
+ // updatedAt 每次调用都会刷新(Date.now()),若纳入指纹会让周期性比对永远不相等,
+ // 导致空闲时每 1.5s 都会重复 POST/持久化草稿。只用稳定内容计算指纹。
+ if ('updatedAt' in draft) {
+ return JSON.stringify(Object.assign({}, draft, { updatedAt: null }));
+ }
return JSON.stringify(draft);
} catch (_) {
return '';
@@ -6555,29 +11593,27 @@
}
function persistSimulationDraftMirror(draft) {
- const key = getSimulationDraftStorageKey();
- if (!key || !global.sessionStorage || !draft) {
+ const name = getSimulationDraftSessionName();
+ if (!name || !global.AppData?.recovery?.windowSession || !draft) {
return;
}
try {
- global.sessionStorage.setItem(key, JSON.stringify({
+ global.AppData.recovery.windowSession.save(name, {
draft,
updatedAt: Date.now()
- }));
+ });
} catch (_) {
// ignore sessionStorage failures in restricted environments
}
}
function restoreSimulationDraftMirror() {
- const key = getSimulationDraftStorageKey();
- if (!key || !global.sessionStorage) {
+ const name = getSimulationDraftSessionName();
+ if (!name || !global.AppData?.recovery?.windowSession) {
return null;
}
try {
- const raw = global.sessionStorage.getItem(key);
- if (!raw) return null;
- const parsed = JSON.parse(raw);
+ const parsed = global.AppData.recovery.windowSession.get(name);
if (!parsed || typeof parsed !== 'object') {
return null;
}
@@ -6590,12 +11626,12 @@
}
function clearSimulationDraftMirror() {
- const key = getSimulationDraftStorageKey();
- if (!key || !global.sessionStorage) {
+ const name = getSimulationDraftSessionName();
+ if (!name || !global.AppData?.recovery?.windowSession) {
return;
}
try {
- global.sessionStorage.removeItem(key);
+ global.AppData.recovery.windowSession.discard(name);
} catch (_) {
// ignore sessionStorage failures in restricted environments
}
@@ -6615,13 +11651,94 @@
answers,
highlights: collectHighlights(),
noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
scrollY: global.scrollY || 0,
updatedAt
};
}
+ function canSyncReadingDraft() {
+ return Boolean(
+ !state.simulationMode
+ && !state.reviewMode
+ && !state.readOnly
+ && !state.timerLocked
+ && !state.submitted
+ && !state.memorizeMode
+ && state.examId
+ && state.sessionId
+ && state.windowSessionToken
+ );
+ }
+
+ function syncReadingDraftSnapshot(reason = 'periodic') {
+ if (!canSyncReadingDraft()) {
+ return;
+ }
+ const draft = collectCurrentDraft();
+ const fingerprint = buildDraftFingerprint(draft);
+ if (reason === 'periodic' && fingerprint && fingerprint === state.readingDraftFingerprint) {
+ return;
+ }
+ state.readingDraftFingerprint = fingerprint;
+ const mirroredDraft = cloneDraftSafely(draft);
+ if (!mirroredDraft) {
+ return;
+ }
+ postMessage('READING_DRAFT_SYNC', {
+ examId: state.examId,
+ sessionId: state.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null,
+ draft: mirroredDraft,
+ draftUpdatedAt: Number.isFinite(Number(mirroredDraft.updatedAt)) ? Number(mirroredDraft.updatedAt) : Date.now(),
+ elapsed: getPageElapsedSeconds(),
+ reason
+ });
+ }
+
+ function stopReadingDraftSync() {
+ if (state.readingDraftSyncTimer) {
+ clearInterval(state.readingDraftSyncTimer);
+ state.readingDraftSyncTimer = null;
+ }
+ }
+
+ function refreshReadingDraftSyncLifecycle() {
+ if (!canSyncReadingDraft()) {
+ stopReadingDraftSync();
+ return;
+ }
+ if (!state.readingDraftSyncTimer) {
+ state.readingDraftSyncTimer = setInterval(() => {
+ syncReadingDraftSnapshot('periodic');
+ }, READING_DRAFT_SYNC_MS);
+ }
+ syncReadingDraftSnapshot('activate');
+ }
+
+ function flushReadingDraftOnLifecycle(reason = 'pagehide') {
+ if (canSyncReadingDraft()) {
+ syncReadingDraftSnapshot(reason);
+ return;
+ }
+ // 草稿同步在 submitted/只读态被跳过;但单篇 final-submit 后若宿主已回传
+ // submittedRecordId,结果页笔记改动仍需要落库——这里同步触发一次标注同步,
+ // 防止页面在 450ms 防抖触发前关闭/隐藏而丢失 READING_ANNOTATION_SYNC。
+ if (state.submitted && state.submittedRecordId && !state.memorizeMode && !state.reviewMode) {
+ syncReadingAnnotation(reason);
+ }
+ }
+
function syncSimulationDraftSnapshot(reason = 'periodic') {
- if (!state.simulationMode || state.readOnly || !state.suiteSessionId) {
+ if (state.timerLocked) return;
+ const isSuiteReviewAnnotation = Boolean(
+ state.suiteReviewMode
+ && state.reviewMode
+ && state.suiteSessionId
+ );
+ if (!state.simulationMode || (state.readOnly && !isSuiteReviewAnnotation) || !state.suiteSessionId) {
return;
}
const draft = state.suite?.inline
@@ -6779,8 +11896,10 @@
if (Array.isArray(draft.highlights)) {
applyHighlights(draft.highlights);
}
- if (typeof draft.noteText === 'string') {
- setNotesText(draft.noteText);
+ setNotes(draft.notes, draft.noteOutlines, { legacyText: draft.noteText });
+ state.markedQuestions = normalizeMarkedQuestions(draft.markedQuestions);
+ if (typeof global.setPracticeMarkedQuestions === 'function') {
+ try { global.setPracticeMarkedQuestions(state.markedQuestions); } catch (_) { /* ignore */ }
}
if (typeof draft.scrollY === 'number') {
global.scrollTo(0, draft.scrollY);
@@ -6797,6 +11916,7 @@
if (!shared) {
return [];
}
+ ensureNoteAnchorsBeforeSnapshot();
return shared.snapshotHighlights({
left: dom.left,
groups: dom.groups
@@ -6835,6 +11955,9 @@
answers: results.answers || {},
highlights: collectHighlights(),
noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
scrollY: global.scrollY || 0,
elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0),
timerSnapshot,
@@ -6912,6 +12035,9 @@
questionTypePerformance: results.questionTypePerformance || {},
highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [],
noteText: typeof draft.noteText === 'string' ? draft.noteText : '',
+ notes: normalizeNotes(draft.notes),
+ noteOutlines: normalizeNoteOutlines(draft.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(draft.markedQuestions),
scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0,
updatedAt: Number.isFinite(Number(draft.updatedAt)) ? Number(draft.updatedAt) : Date.now()
});
@@ -6945,6 +12071,9 @@
scoreInfo,
highlights: [],
noteText: '',
+ notes: [],
+ noteOutlines: [],
+ markedQuestions: [],
scrollY: global.scrollY || 0,
elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0),
timerSnapshot,
@@ -6957,6 +12086,7 @@
input.checked = false;
});
document.querySelectorAll('input[type="text"], textarea').forEach((input) => {
+ if (input.closest('#notes-panel, #reading-note-editor, #reading-note-drawer')) return;
input.value = '';
});
document.querySelectorAll('select').forEach((select) => {
@@ -6996,6 +12126,9 @@
answers: snapshot.answers || {},
highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [],
noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '',
+ notes: normalizeNotes(snapshot.notes),
+ noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions),
scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0,
updatedAt: Number.isFinite(Number(snapshot.updatedAt)) ? Number(snapshot.updatedAt) : Date.now()
},
@@ -7004,6 +12137,9 @@
answers: snapshot.answers || {},
highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [],
noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '',
+ notes: normalizeNotes(snapshot.notes),
+ noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions),
scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0,
elapsed: Number.isFinite(Number(snapshot.elapsed)) ? Number(snapshot.elapsed) : getPageElapsedSeconds(),
timerSnapshot: snapshot.timerSnapshot || getPracticeTimerSnapshot()
@@ -7021,7 +12157,7 @@
handleExitClick();
return;
}
- if (state.readOnly) {
+ if (state.readOnly || state.submissionStatus !== 'draft') {
return;
}
const submissionSnapshot = state.suite?.inline
@@ -7036,15 +12172,12 @@
? (Array.isArray(activeSlot?.draft?.highlights) ? activeSlot.draft.highlights : [])
: (Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : []);
const postedResults = submissionSnapshot.results || results;
- state.lastResults = results;
if (activeSlot) {
activeSlot.lastResults = results;
}
- renderResults(results);
- enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit');
const messageType = state.simulationMode ? 'SIMULATION_SUBMIT' : 'PRACTICE_COMPLETE';
const timing = resolvePracticeTiming(1, submissionSnapshot.timerSnapshot);
- postMessage(messageType, Object.assign({
+ beginSubmission(messageType, Object.assign({
duration: timing.duration,
startTime: new Date(timing.startTimeMs).toISOString(),
endTime: new Date(timing.endTimeMs).toISOString(),
@@ -7064,25 +12197,22 @@
dataKey: state.dataKey,
markedQuestions: (typeof global.getPracticeMarkedQuestions === 'function')
? global.getPracticeMarkedQuestions()
- : []
+ : normalizeMarkedQuestions(submissionSnapshot.markedQuestions)
},
answers: submissionSnapshot.answers || {},
highlights: Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : [],
noteText: typeof submissionSnapshot.noteText === 'string' ? submissionSnapshot.noteText : '',
+ notes: normalizeNotes(submissionSnapshot.notes),
+ noteOutlines: normalizeNoteOutlines(submissionSnapshot.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(submissionSnapshot.markedQuestions),
scrollY: Number.isFinite(Number(submissionSnapshot.scrollY)) ? Number(submissionSnapshot.scrollY) : 0
}, state.suite?.inline ? {
suiteSubmission: true,
suiteEntries: Array.isArray(submissionSnapshot.suiteEntries) ? submissionSnapshot.suiteEntries : []
- } : {}, postedResults));
- await renderExplanations();
- applyHighlights(highlightSnapshot);
- enhanceReviewHighlights();
- updateNavStatuses(results);
- if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) {
- stopSimulationDraftSync();
- clearSimulationDraftMirror();
- state.simulationDraftFingerprint = '';
- }
+ } : {}, postedResults), {
+ results,
+ highlights: highlightSnapshot
+ });
}
function handleReset() {
@@ -7093,6 +12223,7 @@
if (state.submitted && state.readOnlyReason === 'final-submit' && !state.suiteSessionId && !state.reviewMode) {
resetToAnsweringPresentation();
clearCurrentAnswers();
+ clearStructuredNotesForReset();
requestNormalPracticeRestart('retake-after-submit');
return;
}
@@ -7101,6 +12232,7 @@
}
closeReviewHighlightDictionary();
clearCurrentAnswers();
+ clearStructuredNotesForReset();
if (dom.results) {
dom.results.style.display = 'none';
dom.results.innerHTML = '';
@@ -7118,7 +12250,7 @@
const opener = global.opener && !global.opener.closed ? global.opener : null;
if (hasEndlessMarker && opener) {
try {
- opener.postMessage({ type: 'ENDLESS_USER_EXIT' }, '*');
+ postMessage('ENDLESS_USER_EXIT', {});
if (typeof opener.stopEndlessPractice === 'function') {
opener.stopEndlessPractice();
} else if (opener.AppActions && typeof opener.AppActions.stopEndlessPractice === 'function') {
@@ -7219,6 +12351,9 @@
const data = payload.data || {};
const sourceWindow = event && typeof event === 'object' ? (event.source || null) : null;
if (type === 'INIT_SESSION' || type === 'INIT_EXAM_SESSION') {
+ if (!acceptHostInitMessage(event, payload, data)) {
+ return;
+ }
if (!shouldAcceptWindowSessionMessage(data, sourceWindow)) {
return;
}
@@ -7245,6 +12380,12 @@
if (incomingExamId && !currentExamId) {
state.examId = incomingExamId;
}
+ if (data.sessionId && state.sessionId && String(data.sessionId) !== String(state.sessionId)) {
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'draft';
+ state.submissionId = '';
+ state.pendingSubmissionPresentation = null;
+ }
if (data.sessionId) {
state.sessionId = data.sessionId;
}
@@ -7322,14 +12463,28 @@
}
if (data.reviewMode) {
state.reviewMode = true;
+ // init 中的 review 模式同样不应沿用单篇 submitted 回传的 recordId。
+ state.submittedRecordId = '';
if (data.readOnly !== false) {
enterSubmittedReadOnlyState('stationary-review');
} else {
setReadOnlyMode(false);
}
}
+ const singleDraft = !state.simulationMode
+ && !state.reviewMode
+ && data
+ && data.draft
+ && typeof data.draft === 'object'
+ ? data.draft
+ : null;
+ if (singleDraft) {
+ applyDraftToDom(singleDraft);
+ state.readingDraftFingerprint = buildDraftFingerprint(singleDraft);
+ }
syncPrimaryActionButtons();
refreshSimulationDraftSyncLifecycle();
+ refreshReadingDraftSyncLifecycle();
syncSuiteModeState();
stopInitLoop();
state.lastInitSignature = initSignature;
@@ -7339,6 +12494,9 @@
sendSessionReady();
return;
}
+ if (!isTrustedHostMessage(event, payload, data)) {
+ return;
+ }
if (type === 'REPLAY_PRACTICE_RECORD') {
const replaySignature = buildReplaySignature(data || {});
if (replaySignature && replaySignature === state.lastReplaySignature) {
@@ -7352,6 +12510,40 @@
applyReviewContext(data || {});
return;
}
+ if (type === 'PRACTICE_SUBMIT_ACK') {
+ await acceptSubmissionAcknowledgement(data || {});
+ return;
+ }
+ if (type === 'PRACTICE_SUBMIT_FAILED') {
+ if (matchesPendingSubmission(data || {})) {
+ restoreDraftSubmissionState(String(data.submissionId || ''));
+ }
+ return;
+ }
+ if (type === 'VOCAB_HIGHLIGHT_SAVE_ACK' || type === 'VOCAB_HIGHLIGHT_SAVE_FAILED') {
+ const dictionary = getReviewHighlightDictionary();
+ if (dictionary && typeof dictionary.handleSaveOutcome === 'function') {
+ dictionary.handleSaveOutcome(data || {}, type === 'VOCAB_HIGHLIGHT_SAVE_ACK');
+ }
+ return;
+ }
+ if (type === 'PRACTICE_RECORD_SAVED') {
+ // 宿主在单篇阅读 final-submit 落库成功后回传已存档 recordId,
+ // 用于支持结果页笔记改动的持久化(syncReadingAnnotation 的 submitted 分支)。
+ const payloadExamId = data && data.examId != null ? String(data.examId).trim() : '';
+ const currentExamId = state.examId != null ? String(state.examId).trim() : '';
+ if (payloadExamId && currentExamId && payloadExamId !== currentExamId && !state.suite?.inline) {
+ return;
+ }
+ const payloadSessionId = data && data.sessionId != null ? String(data.sessionId).trim() : '';
+ const currentSessionId = state.sessionId != null ? String(state.sessionId).trim() : '';
+ if (!payloadSessionId || !currentSessionId || payloadSessionId !== currentSessionId) {
+ return;
+ }
+ const recordId = data && data.recordId != null ? String(data.recordId).trim() : '';
+ state.submittedRecordId = recordId;
+ return;
+ }
if (type === 'SUITE_NAVIGATE' && data.url) {
const targetSuiteSessionId = typeof data.suiteSessionId === 'string' ? data.suiteSessionId.trim() : '';
const currentSuiteSessionId = typeof state.suiteSessionId === 'string' ? state.suiteSessionId.trim() : '';
@@ -7508,6 +12700,30 @@
global.addEventListener('message', handleIncoming);
}
+ function attachReadingDraftLifecycleHooks() {
+ const flush = (reason) => {
+ try {
+ // 先把编辑器里未提交的笔记立刻刷出:review 页面 flushReadingDraftOnLifecycle
+ // 会因 canSyncReadingDraft 直接 no-op,笔记只能靠 450ms 防抖提交,页面在
+ // 防抖触发前关闭/隐藏就会丢失 READING_ANNOTATION_SYNC。这里同步触发一次,
+ // review 路径在同步里发出最新的 note,正常阅读路径则继续走 draft 快照。
+ if (typeof flushActiveNoteFromEditor === 'function') {
+ flushActiveNoteFromEditor();
+ }
+ flushReadingDraftOnLifecycle(reason);
+ } catch (_) {
+ // ignore draft flush failures during teardown
+ }
+ };
+ global.addEventListener('pagehide', () => flush('pagehide'));
+ global.addEventListener('beforeunload', () => flush('beforeunload'));
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') {
+ flush('visibilitychange');
+ }
+ });
+ }
+
function attachPracticeTimerBridge() {
global.addEventListener(PRACTICE_TIMER_EVENT, (event) => {
const detail = event && event.detail && typeof event.detail === 'object'
@@ -7521,6 +12737,8 @@
}
async function bootstrap() {
+ await loadReadingCandidateCodePreferences();
+ if (global.PracticeTimerPreferences?.ready) await global.PracticeTimerPreferences.ready;
parseQuery();
captureDom();
const dataset = await ensureDataset();
@@ -7533,31 +12751,19 @@
attachDragDrop();
attachPaneResizer();
- // Ensure drag items can return home when replaced or discarded
- function initDragPools() {
- document.querySelectorAll('.pool-items').forEach((pool, index) => {
- if (!pool.id) {
- pool.id = `practice-pool-${index}`;
- }
- });
- document.querySelectorAll('.pool-items .drag-item').forEach((item) => {
- if (!item.dataset.originPool) {
- const pool = item.closest('.pool-items');
- if (pool?.id) {
- item.dataset.originPool = pool.id;
- }
- }
- });
- }
initDragPools();
attachUnifiedTimer();
attachUnifiedPanels();
+ ensureReadingNotesUi();
+ ensureReadingDisplayControls();
+ await loadReadingDisplayPreferences();
attachSelectionHighlightToolbar();
attachReviewHighlightDictionary();
attachActionListeners();
attachMessageBridge();
attachPracticeTimerBridge();
+ attachReadingDraftLifecycleHooks();
syncSuiteModeState();
setExitButtonVisible(false);
if (state.memorizeMode) {
@@ -7565,6 +12771,7 @@
}
updateNavStatuses();
refreshSimulationDraftSyncLifecycle();
+ refreshReadingDraftSyncLifecycle();
startInitLoop();
}
@@ -7583,6 +12790,10 @@
(function markBundleProvided(global) {
if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
global.AppLazyLoader.markProvided([
+ "js/data/practiceRecordSource.js",
+ "js/data/v2/dataCatalog.js",
+ "js/data/v2/dataKernel.js",
+ "js/data/v2/appData.js",
"js/runtime/readingExamRegistry.js",
"js/runtime/readingExplanationRegistry.js",
"js/runtime/readingHighlightShared.js",
diff --git a/js/bundles/runtime-entry.bundle.js b/js/bundles/runtime-entry.bundle.js
index 2090ee82..728925dc 100644
--- a/js/bundles/runtime-entry.bundle.js
+++ b/js/bundles/runtime-entry.bundle.js
@@ -536,11 +536,7 @@
function start(themeName = null) {
if (!themeName) {
- try {
- themeName = localStorage.getItem('three_bg_theme') || 'floral-bloom';
- } catch(e) {
- themeName = 'floral-bloom';
- }
+ themeName = 'floral-bloom';
}
try {
@@ -583,14 +579,20 @@
}
global.switchBgTheme = function(themeName) {
- try {
- localStorage.setItem('three_bg_theme', themeName);
- } catch(e){}
+ if (global.AppData && global.AppData.preferences) {
+ global.AppData.preferences.setThreeBackground(themeName).catch((error) => console.warn('[SHUI Three Background] preference save failed:', error));
+ }
start(themeName);
};
- function init() {
- start();
+ async function init() {
+ try {
+ await global.AppData.ready;
+ const saved = await global.AppData.preferences.getThreeBackground();
+ start(saved || 'floral-bloom');
+ } catch (_) {
+ start('floral-bloom');
+ }
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
@@ -753,9 +755,7 @@
'js/bundles/theme.bundle.js'
];
- manifest['settings-tools'] = [
- 'js/bundles/settings.bundle.js'
- ];
+ manifest['settings-tools'] = [];
manifest['diagnostics-tools'] = [
'js/bundles/diagnostics.bundle.js'
@@ -764,13 +764,16 @@
dependencies['state-core'] = [];
dependencies['exam-data'] = [];
dependencies['practice-suite'] = ['state-core'];
- dependencies['browse-runtime'] = ['state-core'];
- dependencies['browse-view'] = ['state-core'];
+ // Browsing is also the entry point for starting a practice session.
+ // Keep the real recorder ready before a user can open an exam; the
+ // bootstrap fallback cannot own the full submit/persist round trip.
+ dependencies['browse-runtime'] = ['state-core', 'practice-suite'];
+ dependencies['browse-view'] = ['state-core', 'practice-suite'];
dependencies['session-suite'] = ['browse-runtime', 'practice-suite'];
dependencies['settings-tools'] = ['state-core'];
- dependencies['more-tools'] = ['state-core', 'settings-tools'];
+ dependencies['more-tools'] = ['state-core'];
dependencies['theme-tools'] = [];
- dependencies['diagnostics-tools'] = ['state-core', 'settings-tools'];
+ dependencies['diagnostics-tools'] = ['state-core'];
}
function setBuiltInListeningAvailability(available, reason) {
@@ -1072,10 +1075,6 @@
(function initSuitePreferenceUtils(global) {
'use strict';
- const FLOW_MODE_STORAGE_KEY = 'suite_flow_mode';
- const FREQUENCY_SCOPE_STORAGE_KEY = 'suite_frequency_scope';
- const AUTO_ADVANCE_STORAGE_KEY = 'suite_auto_advance_after_submit';
-
const FLOW_MODES = ['classic', 'simulation', 'stationary'];
const FREQUENCY_SCOPES = ['high', 'high_medium', 'all', 'custom'];
@@ -1190,50 +1189,54 @@
return null;
}
- function readStorageValue(key) {
- try {
- if (global.localStorage && typeof global.localStorage.getItem === 'function') {
- return global.localStorage.getItem(key);
- }
- } catch (_) {
- // ignore read failures
- }
- return null;
- }
-
- function writeStorageValue(key, value) {
- try {
- if (global.localStorage && typeof global.localStorage.setItem === 'function') {
- global.localStorage.setItem(key, String(value));
- }
- } catch (_) {
- // ignore write failures
+ let hydrationPromise = null;
+ function hydrateSuitePreference() {
+ if (hydrationPromise) return hydrationPromise;
+ // runtime-entry.bundle.js is intentionally loaded before the data
+ // foundation. Do not memoize that early miss: a cached `false` would
+ // make every later resolver skip the persisted AppData preference.
+ if (!global.AppData || !global.AppData.preferences) {
+ return Promise.resolve(false);
}
+ hydrationPromise = Promise.resolve().then(async () => {
+ await global.AppData.ready;
+ const stored = await global.AppData.preferences.getSuite();
+ if (stored && typeof stored === 'object') Object.assign(ensurePracticeConfig().suite, stored);
+ return true;
+ }).catch((error) => {
+ console.warn('[SuitePreference] 加载失败:', error);
+ return false;
+ });
+ // A transient AppData initialization failure should be retryable on the
+ // next read, just like the pre-foundation early miss above.
+ hydrationPromise = hydrationPromise.then((hydrated) => {
+ if (!hydrated) hydrationPromise = null;
+ return hydrated;
+ });
+ return hydrationPromise;
}
- function resolveSuitePreference(overrides = {}) {
+ async function resolveSuitePreference(overrides = {}) {
+ await hydrateSuitePreference();
const config = ensurePracticeConfig();
const suiteConfig = config.suite || {};
const flowMode = normalizeFlowMode(overrides.flowMode)
|| normalizeFlowMode(suiteConfig.flowMode)
- || normalizeFlowMode(readStorageValue(FLOW_MODE_STORAGE_KEY))
|| 'classic';
const frequencyScope = normalizeFrequencyScope(overrides.frequencyScope)
|| normalizeFrequencyScope(suiteConfig.frequencyScope)
- || normalizeFrequencyScope(readStorageValue(FREQUENCY_SCOPE_STORAGE_KEY))
|| 'all';
const overrideAutoAdvance = parseBoolean(overrides.autoAdvanceAfterSubmit);
const configAutoAdvance = parseBoolean(suiteConfig.autoAdvanceAfterSubmit);
- const storedAutoAdvance = parseBoolean(readStorageValue(AUTO_ADVANCE_STORAGE_KEY));
const fallbackAutoAdvance = flowMode !== 'stationary';
const autoAdvanceAfterSubmit = overrideAutoAdvance != null
? overrideAutoAdvance
: (configAutoAdvance != null
? configAutoAdvance
- : (storedAutoAdvance != null ? storedAutoAdvance : fallbackAutoAdvance));
+ : fallbackAutoAdvance);
config.suite.flowMode = flowMode;
config.suite.frequencyScope = frequencyScope;
@@ -1247,24 +1250,34 @@
}
function persistSuitePreference(partial = {}) {
- const current = resolveSuitePreference();
+ const config = ensurePracticeConfig();
+ const suiteConfig = config.suite || {};
+ const fallbackCurrent = {
+ flowMode: normalizeFlowMode(suiteConfig.flowMode) || 'classic',
+ frequencyScope: normalizeFrequencyScope(suiteConfig.frequencyScope) || 'all',
+ autoAdvanceAfterSubmit: parseBoolean(suiteConfig.autoAdvanceAfterSubmit)
+ };
- const flowMode = normalizeFlowMode(partial.flowMode) || current.flowMode;
- const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || current.frequencyScope;
+ const flowMode = normalizeFlowMode(partial.flowMode) || fallbackCurrent.flowMode;
+ const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || fallbackCurrent.frequencyScope;
const partialAutoAdvance = parseBoolean(partial.autoAdvanceAfterSubmit);
const autoAdvanceAfterSubmit = partialAutoAdvance != null
? partialAutoAdvance
: (flowMode === 'stationary' ? false : true);
- const config = ensurePracticeConfig();
config.suite.flowMode = flowMode;
config.suite.frequencyScope = frequencyScope;
config.suite.autoAdvanceAfterSubmit = autoAdvanceAfterSubmit;
- writeStorageValue(FLOW_MODE_STORAGE_KEY, flowMode);
- writeStorageValue(FREQUENCY_SCOPE_STORAGE_KEY, frequencyScope);
- writeStorageValue(AUTO_ADVANCE_STORAGE_KEY, autoAdvanceAfterSubmit ? 'true' : 'false');
+ hydrateSuitePreference().then((hydrated) => {
+ if (!hydrated || !global.AppData || !global.AppData.preferences) return;
+ return global.AppData.preferences.patchSuite({
+ flowMode,
+ frequencyScope,
+ autoAdvanceAfterSubmit
+ });
+ }).catch((error) => console.warn('[SuitePreference] 保存失败:', error));
return {
flowMode,
@@ -1281,12 +1294,19 @@
normalizeFrequencyScope,
normalizeFrequency,
isFrequencyIncluded,
+ ready: hydrateSuitePreference,
resolveSuitePreference,
persistSuitePreference
};
global.SuitePreferenceUtils = api;
+ // Kick hydration off eagerly so any later resolver (including the
+ // synchronous readers inside suitePracticeMixin) does not race the very
+ // first AppData.preferences.getSuite() lookup. If the data foundation is
+ // not installed yet, hydrateSuitePreference deliberately retries later.
+ hydrateSuitePreference();
+
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
@@ -1430,11 +1450,11 @@
if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') {
frequencyScope = 'all';
}
- return {
+ return Promise.resolve({
flowMode: flowMode,
frequencyScope: frequencyScope,
autoAdvanceAfterSubmit: flowMode !== 'stationary'
- };
+ });
}
function persistSuitePreference(partial) {
@@ -1442,7 +1462,22 @@
if (suitePreferenceUtils && typeof suitePreferenceUtils.persistSuitePreference === 'function') {
return suitePreferenceUtils.persistSuitePreference(partial || {});
}
- return resolveSuitePreference(partial || {});
+ // Fallback persists locally; resolveSuitePreference() above is async,
+ // but persistSuitePreference itself must remain synchronous so callers
+ // can read .flowMode/.frequencyScope immediately. Compute inline.
+ var flowMode = String(partial && partial.flowMode || '').trim().toLowerCase();
+ if (flowMode !== 'classic' && flowMode !== 'simulation' && flowMode !== 'stationary') {
+ flowMode = 'classic';
+ }
+ var frequencyScope = String(partial && partial.frequencyScope || '').trim().toLowerCase();
+ if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') {
+ frequencyScope = 'all';
+ }
+ return {
+ flowMode: flowMode,
+ frequencyScope: frequencyScope,
+ autoAdvanceAfterSubmit: flowMode !== 'stationary'
+ };
}
function persistSuiteFlowMode(mode) {
@@ -1457,9 +1492,9 @@
function promptSuiteModeSelection() {
return new Promise(function resolveSelection(resolve) {
- var preselectedPreference = resolveSuitePreference();
- var preselected = preselectedPreference.flowMode || 'classic';
- var preselectedScope = preselectedPreference.frequencyScope || 'all';
+ resolveSuitePreference().then(function applyPreselection(preselectedPreference) {
+ var preselected = (preselectedPreference && preselectedPreference.flowMode) || 'classic';
+ var preselectedScope = (preselectedPreference && preselectedPreference.frequencyScope) || 'all';
var search = '';
try {
search = String(global.location && global.location.search || '').toLowerCase();
@@ -1570,6 +1605,7 @@
}
});
global.document.body.appendChild(host);
+ });
});
}
@@ -1675,34 +1711,6 @@
}
}
- function getExamIndexSnapshot() {
- if (typeof global.getExamIndexState === 'function') {
- try {
- var snapshot = global.getExamIndexState();
- if (Array.isArray(snapshot) && snapshot.length) {
- return snapshot.slice();
- }
- } catch (_) { }
- }
- if (Array.isArray(global.examIndex) && global.examIndex.length) {
- return global.examIndex.slice();
- }
- if (typeof global.getReadingExamIndex === 'function') {
- var readingIndex = global.getReadingExamIndex();
- if (Array.isArray(readingIndex) && readingIndex.length) {
- return readingIndex.map(function (exam) {
- return Object.assign({}, exam, { type: exam.type || 'reading' });
- });
- }
- }
- if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) {
- return global.__READING_EXAM_INDEX__.map(function (exam) {
- return Object.assign({}, exam, { type: exam.type || 'reading' });
- });
- }
- return [];
- }
-
function isReadingMemorizeCandidate(exam) {
if (!exam || !exam.id) {
return false;
@@ -1892,12 +1900,8 @@
});
}
- function startRandomPractice(category, type, filterMode, path) {
- var getExamIndexState = global.getExamIndexState || function () {
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- };
-
- var list = getExamIndexState();
+ async function startRandomPractice(category, type, filterMode, path) {
+ var list = await global.resolveActiveLibraryIndex();
var normalizedType = (!type || type === 'all') ? null : type;
var normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null;
@@ -1998,11 +2002,8 @@
}, 1000);
}
- function pickRandomExam() {
- var getExamIndexState = global.getExamIndexState || function () {
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- };
- var list = getExamIndexState().filter(function (e) {
+ function pickRandomExam(examIndex) {
+ var list = (Array.isArray(examIndex) ? examIndex : []).filter(function (e) {
return e && e.hasHtml && e.type === 'reading';
});
if (!list.length) return null;
@@ -2024,6 +2025,9 @@
}
// resolve to absolute
url = new URL(url, window.location.href).href;
+ var parsedUrl = new URL(url);
+ parsedUrl.searchParams.set('endless', '1');
+ url = parsedUrl.href;
} catch (_) { }
if (!url) return null;
@@ -2048,14 +2052,21 @@
if (!endlessState || !endlessState.active) return;
var countdown = ENDLESS_COUNTDOWN_SEC;
+ var postEndlessControl = function (type, data) {
+ if (!endlessState || !endlessState.currentExamId || !global.app
+ || typeof global.app._postExamMessage !== 'function') return false;
+ return global.app._postExamMessage(
+ endlessState.currentExamId,
+ sourceWindow,
+ type,
+ data || {}
+ );
+ };
// 通知练习页开始倒计时
try {
if (sourceWindow && !sourceWindow.closed) {
- sourceWindow.postMessage({
- type: 'ENDLESS_COUNTDOWN',
- data: { seconds: countdown }
- }, '*');
+ postEndlessControl('ENDLESS_COUNTDOWN', { seconds: countdown });
}
} catch (_) { }
@@ -2076,10 +2087,7 @@
// 持续更新倒计时
try {
if (sourceWindow && !sourceWindow.closed) {
- sourceWindow.postMessage({
- type: 'ENDLESS_COUNTDOWN_TICK',
- data: { seconds: countdown }
- }, '*');
+ postEndlessControl('ENDLESS_COUNTDOWN_TICK', { seconds: countdown });
}
} catch (_) { }
@@ -2089,16 +2097,13 @@
try {
if (sourceWindow && !sourceWindow.closed) {
- sourceWindow.postMessage({
- type: 'ENDLESS_COUNTDOWN_END',
- data: {}
- }, '*');
+ postEndlessControl('ENDLESS_COUNTDOWN_END', {});
}
} catch (_) { }
if (!endlessState || !endlessState.active) return;
- var nextExam = pickRandomExam();
+ var nextExam = pickRandomExam(endlessState.examIndex);
if (!nextExam) {
if (typeof global.showMessage === 'function') {
global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a', 'warning');
@@ -2112,21 +2117,32 @@
}
var reuseWin = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null;
- var newWin = openEndlessExam(nextExam, reuseWin);
- if (newWin) {
- endlessState.currentWindow = newWin;
- if (global.app && typeof global.app.setupExamWindowManagement === 'function') {
- global.app.setupExamWindowManagement(newWin, nextExam.id, nextExam, {});
+ var openNext = global.app && typeof global.app.openExam === 'function'
+ ? global.app.openExam(nextExam.id, {
+ target: 'tab',
+ windowName: ENDLESS_WINDOW_NAME,
+ reuseWindow: reuseWin,
+ endlessMode: true
+ })
+ : openEndlessExam(nextExam, reuseWin);
+ Promise.resolve(openNext).then(function (newWin) {
+ if (!newWin || !endlessState || !endlessState.active) {
+ throw new Error('无法打开下一题');
}
- if (global.app && typeof global.app.startPracticeSession === 'function') {
- try { global.app.startPracticeSession(nextExam.id); } catch (_) { }
+ endlessState.currentWindow = newWin;
+ endlessState.currentExamId = nextExam.id;
+ }).catch(function (error) {
+ if (global.console && console.error) console.error('[EndlessMode] 打开下一题失败:', error);
+ if (typeof global.showMessage === 'function') {
+ global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u4e0b\u4e00\u9898', 'error');
}
- }
+ stopEndlessPractice({ silent: true });
+ });
}
}, 1000);
}
- function startEndlessPractice() {
+ async function startEndlessPractice() {
// 如果已激活,不再走“父页按钮二次点击退出”的伪交互
if (endlessState && endlessState.active) {
if (typeof global.showMessage === 'function') {
@@ -2135,7 +2151,8 @@
return;
}
- var firstExam = pickRandomExam();
+ var examIndex = await global.resolveActiveLibraryIndex();
+ var firstExam = pickRandomExam(examIndex);
if (!firstExam) {
if (typeof global.showMessage === 'function') {
global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a\uff0c\u8bf7\u5148\u52a0\u8f7d\u9898\u5e93', 'error');
@@ -2146,8 +2163,10 @@
// 标记状态
endlessState = {
active: true,
+ examIndex: examIndex,
countdownTimer: null,
currentWindow: null,
+ currentExamId: firstExam.id,
messageHandler: null,
windowMonitor: null
};
@@ -2157,6 +2176,25 @@
if (!endlessState || !endlessState.active) return;
var msg = event && event.data;
if (!msg || typeof msg.type !== 'string') return;
+ var currentWindow = endlessState.currentWindow;
+ if (!currentWindow || event.source !== currentWindow) return;
+ var info = global.app && global.app.examWindows && endlessState.currentExamId
+ ? global.app.examWindows.get(endlessState.currentExamId)
+ : null;
+ if (info && info.expectedOrigin && info.expectedOrigin !== 'null') {
+ if (event.origin !== info.expectedOrigin) return;
+ } else if (info && info.allowOpaqueOrigin) {
+ if (event.origin !== 'null') return;
+ } else {
+ return;
+ }
+ var messageData = msg.data || {};
+ var permitsPreInit = msg.type === 'REQUEST_INIT';
+ if (!permitsPreInit && (
+ msg.source !== 'practice_page'
+ || !info.windowSessionToken
+ || messageData.windowSessionToken !== info.windowSessionToken
+ )) return;
if (msg.type === 'ENDLESS_USER_EXIT') {
stopEndlessPractice();
return;
@@ -2190,19 +2228,27 @@
// 优先用 app.openExam 保证注入
if (global.app && typeof global.app.openExam === 'function') {
try {
- Promise.resolve(global.app.openExam(firstExam.id, {
+ win = await global.app.openExam(firstExam.id, {
target: 'tab',
- windowName: ENDLESS_WINDOW_NAME
- })).then(function (w) {
- if (w && endlessState) endlessState.currentWindow = w;
- startEndlessWindowMonitor();
- }).catch(function () { });
- } catch (_) { }
+ windowName: ENDLESS_WINDOW_NAME,
+ endlessMode: true
+ });
+ } catch (error) {
+ if (global.console && console.error) console.error('[EndlessMode] 打开首题失败:', error);
+ }
} else {
win = openEndlessExam(firstExam, null);
- if (win && endlessState) endlessState.currentWindow = win;
- startEndlessWindowMonitor();
}
+ if (!win || !endlessState) {
+ stopEndlessPractice({ silent: true });
+ if (typeof global.showMessage === 'function') {
+ global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u7ec3\u4e60\u7a97\u53e3', 'error');
+ }
+ return;
+ }
+ endlessState.currentWindow = win;
+ endlessState.currentExamId = firstExam.id;
+ startEndlessWindowMonitor();
}
global.AppActions = Object.assign({}, global.AppActions, {
diff --git a/js/bundles/session.bundle.js b/js/bundles/session.bundle.js
index 30590546..164caa8c 100644
--- a/js/bundles/session.bundle.js
+++ b/js/bundles/session.bundle.js
@@ -11,7 +11,7 @@
function resolveSuitePreferenceForMixin(options = {}) {
const suitePreferenceUtils = getSuitePreferenceUtils();
if (suitePreferenceUtils && typeof suitePreferenceUtils.resolveSuitePreference === 'function') {
- return suitePreferenceUtils.resolveSuitePreference(options);
+ return suitePreferenceUtils.ensurePracticeConfig().suite || {};
}
let flowMode = String(options && options.flowMode || '').trim().toLowerCase();
if (!['classic', 'simulation', 'stationary'].includes(flowMode)) {
@@ -174,16 +174,26 @@
}
},
async handleSuitePracticeComplete(examId, data, sourceWindow = null) {
+ const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => (
+ data && data.submissionId
+ ? Object.assign({
+ handled: Boolean(handled),
+ committed: Boolean(committed),
+ errorCode: errorCode || null
+ }, extra || {})
+ : Boolean(handled)
+ );
// First check whether this is multi-suite mode (detected via suiteId).
if (data && data.suiteId) {
- return await this.handleMultiSuitePracticeComplete(examId, data);
+ const committed = await this.handleMultiSuitePracticeComplete(examId, data);
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed');
}
if (data && data.suiteSubmission === true && typeof this._handleInlineSimulationSuiteSubmit === 'function') {
return await this._handleInlineSimulationSuiteSubmit(examId, data, sourceWindow);
}
const session = this.currentSuiteSession;
- if (!session || session.status !== 'active') {
+ if (!session) {
return false;
}
@@ -193,6 +203,14 @@
if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) {
return false;
}
+ if (session.status === 'completed') {
+ return withSubmitOutcome(true, true, '', data && data.submissionId ? {
+ teardownSession: session
+ } : null);
+ }
+ if (session.status !== 'active') {
+ return false;
+ }
const mappingMissing = !this.suiteExamMap || !this.suiteExamMap.has(examId);
if (mappingMissing && typeof this._registerSuiteSequence === 'function') {
@@ -222,7 +240,7 @@
submittedExamId: examId,
sessionId: session.id
});
- return true;
+ return withSubmitOutcome(true, false, 'inactive_suite_exam');
}
const derivedDuration = this._deriveSuiteExamElapsedSeconds(session, examId, data && data.duration);
@@ -260,7 +278,7 @@
if (replayWindow) {
await this._sendSuiteReviewState(session, examId, replayWindow);
}
- return true;
+ return withSubmitOutcome(true, true);
}
session.currentIndex = currentIndex + 1;
@@ -269,8 +287,11 @@
// Last passage -> finalize the entire simulation
if (session.currentIndex >= session.sequence.length) {
- await this.finalizeSuiteRecord(session);
- return true;
+ const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed);
+ const committed = await this.finalizeSuiteRecord(session, { deferTeardown });
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? {
+ teardownSession: session
+ } : null);
}
// Not last -> advance to next passage
@@ -282,7 +303,8 @@
}
}
- return this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId);
+ const advanced = await this._advanceSuiteToNext(session, sequenceEntry.exam.title, examId);
+ return withSubmitOutcome(advanced, advanced, advanced ? '' : 'suite_advance_failed');
},
async continueSuitePractice() {
@@ -298,8 +320,17 @@
},
async _handleInlineSimulationSuiteSubmit(examId, data, sourceWindow = null) {
+ const withSubmitOutcome = (handled, committed = handled, errorCode = '', extra = null) => (
+ data && data.submissionId
+ ? Object.assign({
+ handled: Boolean(handled),
+ committed: Boolean(committed),
+ errorCode: errorCode || null
+ }, extra || {})
+ : Boolean(handled)
+ );
const session = this.currentSuiteSession;
- if (!session || session.status !== 'active' || session.flowMode !== 'simulation') {
+ if (!session || session.flowMode !== 'simulation') {
return false;
}
const payloadSuiteSessionId = data && typeof data.suiteSessionId === 'string'
@@ -308,9 +339,17 @@
if (payloadSuiteSessionId && payloadSuiteSessionId !== session.id) {
return false;
}
+ if (session.status === 'completed') {
+ return withSubmitOutcome(true, true, '', data && data.submissionId ? {
+ teardownSession: session
+ } : null);
+ }
+ if (session.status !== 'active') {
+ return false;
+ }
const suiteEntries = Array.isArray(data && data.suiteEntries) ? data.suiteEntries : [];
if (!suiteEntries.length) {
- return false;
+ return withSubmitOutcome(true, false, 'suite_entries_missing');
}
const entriesByExam = new Map();
suiteEntries.forEach((entry) => {
@@ -320,7 +359,7 @@
}
});
if (!entriesByExam.size) {
- return false;
+ return withSubmitOutcome(true, false, 'suite_entries_missing');
}
const hasEverySequenceEntry = Array.isArray(session.sequence)
&& session.sequence.length > 0
@@ -334,7 +373,7 @@
expected: session.sequence.map(item => item && item.examId).filter(Boolean),
received: Array.from(entriesByExam.keys())
});
- return false;
+ return withSubmitOutcome(true, false, 'suite_entries_incomplete');
}
session.results = [];
@@ -358,6 +397,8 @@
answers: entryPayload.answers || {},
highlights: Array.isArray(entryPayload.highlights) ? entryPayload.highlights.slice() : [],
noteText: typeof entryPayload.noteText === 'string' ? entryPayload.noteText : '',
+ notes: Array.isArray(entryPayload.notes) ? entryPayload.notes.slice() : [],
+ noteOutlines: Array.isArray(entryPayload.noteOutlines) ? entryPayload.noteOutlines.slice() : [],
scrollY: Number.isFinite(Number(entryPayload.scrollY)) ? Number(entryPayload.scrollY) : 0,
markedQuestions: Array.isArray(entryPayload.markedQuestions) ? entryPayload.markedQuestions.slice() : []
},
@@ -381,8 +422,11 @@
session.windowRef = sourceWindow;
}
this._mirrorSessionToStorage(session);
- await this.finalizeSuiteRecord(session);
- return true;
+ const deferTeardown = Boolean(data && data.submissionId && sourceWindow && !sourceWindow.closed);
+ const committed = await this.finalizeSuiteRecord(session, { deferTeardown });
+ return withSubmitOutcome(true, committed, committed ? '' : 'suite_save_failed', deferTeardown ? {
+ teardownSession: session
+ } : null);
},
_resolveSuitePreference(options = {}) {
@@ -438,7 +482,7 @@
if (data.draft && typeof data.draft === 'object' && !Array.isArray(data.draft)) {
return true;
}
- return ['answers', 'highlights', 'noteText', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => (
+ return ['answers', 'highlights', 'noteText', 'notes', 'noteOutlines', 'scrollY', 'markedQuestions', 'draftUpdatedAt'].some((key) => (
Object.prototype.hasOwnProperty.call(data, key)
));
},
@@ -470,6 +514,12 @@
const noteTextSource = typeof draftSource.noteText === 'string'
? draftSource.noteText
: (data && typeof data.noteText === 'string' ? data.noteText : '');
+ const notesSource = Array.isArray(draftSource.notes)
+ ? draftSource.notes
+ : (Array.isArray(data && data.notes) ? data.notes : []);
+ const noteOutlinesSource = Array.isArray(draftSource.noteOutlines)
+ ? draftSource.noteOutlines
+ : (Array.isArray(data && data.noteOutlines) ? data.noteOutlines : []);
const scrollSource = Number.isFinite(Number(draftSource.scrollY))
? Number(draftSource.scrollY)
: (Number.isFinite(Number(data && data.scrollY)) ? Number(data.scrollY) : 0);
@@ -483,6 +533,8 @@
answers: this._cloneSuiteDraftPlainObject(answerSource),
highlights: highlightSource.slice(),
noteText: noteTextSource,
+ notes: this._cloneSuitePlainObject(notesSource),
+ noteOutlines: this._cloneSuitePlainObject(noteOutlinesSource),
scrollY: scrollSource,
markedQuestions: markedQuestionsSource.slice(),
updatedAt: Number.isFinite(updatedAt) ? updatedAt : Date.now()
@@ -558,6 +610,8 @@
delete cloned.highlights;
delete cloned.scrollY;
delete cloned.noteText;
+ delete cloned.notes;
+ delete cloned.noteOutlines;
return cloned;
},
@@ -576,6 +630,14 @@
if (noteText) {
rawData.noteText = noteText;
}
+ const notes = this._resolveSuiteEntryNotes(entry, draft);
+ if (notes.length > 0) {
+ rawData.notes = notes;
+ }
+ const noteOutlines = this._resolveSuiteEntryNoteOutlines(entry, draft);
+ if (noteOutlines.length > 0) {
+ rawData.noteOutlines = noteOutlines;
+ }
return rawData;
},
@@ -585,8 +647,11 @@
entry && entry.highlights,
entry && entry.rawData && entry.rawData.highlights
];
+ // 把“存在数组”视为权威来源(即使为空也可代表用户已清空高亮),
+ // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 highlights: []
+ // 被跳过而回落到旧 entry.rawData.highlights,复活已删除的高亮。
for (const source of sources) {
- if (Array.isArray(source) && source.length > 0) {
+ if (source != null && Array.isArray(source)) {
return source.slice();
}
}
@@ -634,6 +699,40 @@
return '';
},
+ _resolveSuiteEntryNotes(entry, draft = null) {
+ const sources = [
+ draft && draft.notes,
+ entry && entry.notes,
+ entry && entry.rawData && entry.rawData.notes
+ ];
+ // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条结构笔记),
+ // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 notes: []
+ // 被跳过而回落到旧 entry.rawData.notes,复活已删除的笔记。
+ for (const source of sources) {
+ if (source != null && Array.isArray(source)) {
+ return this._cloneSuitePlainObject(source);
+ }
+ }
+ return [];
+ },
+
+ _resolveSuiteEntryNoteOutlines(entry, draft = null) {
+ const sources = [
+ draft && draft.noteOutlines,
+ entry && entry.noteOutlines,
+ entry && entry.rawData && entry.rawData.noteOutlines
+ ];
+ // 把“存在数组”视为权威来源(即使为空也可代表用户已删除最后一条笔记大纲),
+ // 与 _buildSuiteDraftSnapshot 的写路径语义保持一致;避免显式 noteOutlines: []
+ // 被跳过而回落到旧 entry.rawData.noteOutlines,复活已删除的大纲。
+ for (const source of sources) {
+ if (source != null && Array.isArray(source)) {
+ return this._cloneSuitePlainObject(source);
+ }
+ }
+ return [];
+ },
+
_buildSuiteReplayEntry(session, examId) {
if (!session || !Array.isArray(session.results)) {
return null;
@@ -698,6 +797,8 @@
const highlights = this._resolveSuiteEntryHighlights(result, draft);
const noteText = this._resolveSuiteEntryNoteText(result, draft);
+ const notes = this._resolveSuiteEntryNotes(result, draft);
+ const noteOutlines = this._resolveSuiteEntryNoteOutlines(result, draft);
const scrollY = this._resolveSuiteEntryScrollY(result, draft);
const markedQuestions = result && Array.isArray(result.markedQuestions)
? result.markedQuestions.slice()
@@ -708,6 +809,8 @@
|| markedQuestions.length
|| highlights.length
|| noteText
+ || notes.length
+ || noteOutlines.length
|| (Number.isFinite(Number(scrollY)) && Number(scrollY) > 0)
);
if (!hasReplayData) {
@@ -722,6 +825,8 @@
markedQuestions,
highlights,
noteText,
+ notes,
+ noteOutlines,
scrollY
};
},
@@ -783,18 +888,15 @@
}
try {
if (replayEntry) {
- resolvedWindow.postMessage({
- type: 'REPLAY_PRACTICE_RECORD',
- data: {
- suiteSessionId: session.id,
- reviewEntryIndex: contextPayload.currentIndex,
- readOnly: contextPayload.readOnly !== false,
- markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [],
- entry: replayEntry
- }
- }, '*');
+ this._postExamMessage(examId, resolvedWindow, 'REPLAY_PRACTICE_RECORD', {
+ suiteSessionId: session.id,
+ reviewEntryIndex: contextPayload.currentIndex,
+ readOnly: contextPayload.readOnly !== false,
+ markedQuestions: Array.isArray(replayEntry.markedQuestions) ? replayEntry.markedQuestions : [],
+ entry: replayEntry
+ });
}
- resolvedWindow.postMessage({ type: 'REVIEW_CONTEXT', data: contextPayload }, '*');
+ this._postExamMessage(examId, resolvedWindow, 'REVIEW_CONTEXT', contextPayload);
return true;
} catch (error) {
console.warn('[SuitePractice] 发送套题回看上下文失败:', error);
@@ -829,7 +931,7 @@
&& Number(windowInfo.lastMessageAt) >= startedAt
&& (!windowInfo.suiteSessionId || windowInfo.suiteSessionId === session.id)
&& (!windowInfo.windowSessionToken || !windowInfo.lastWindowSessionToken || windowInfo.windowSessionToken === windowInfo.lastWindowSessionToken)
- && (!windowInfo.pageType || /unified-reading|suite-placeholder/i.test(String(windowInfo.pageType)))
+ && (!windowInfo.pageType || /unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(String(windowInfo.pageType)))
);
if (readyMatches) {
return true;
@@ -873,7 +975,7 @@
const pageType = windowInfo && typeof windowInfo.pageType === 'string'
? windowInfo.pageType.toLowerCase()
: '';
- if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) {
+ if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) {
return false;
}
return true;
@@ -1009,6 +1111,7 @@
}
if (isCrossExamNavigation || !targetWindow) {
targetWindow = await this.openExam(targetEntry.examId, {
+ examDefinition: targetEntry.exam,
target: 'tab',
windowName: session.windowName || 'ielts-suite-mode-tab',
suiteSessionId: session.id,
@@ -1086,7 +1189,10 @@
}
try {
- const opened = await this.openExam(nextEntry.examId, options);
+ const opened = await this.openExam(nextEntry.examId, {
+ ...options,
+ examDefinition: nextEntry.exam
+ });
if (opened && !opened.closed) {
return opened;
}
@@ -1172,18 +1278,13 @@
startTime: session.startTime,
activeExamId: session.activeExamId
};
- if (global.sessionStorage) {
- global.sessionStorage.setItem('ielts_sim_session', JSON.stringify(snapshot));
- }
+ global.AppData.recovery.windowSession.save('simulation', snapshot);
} catch (_) { /* file:// may not support */ }
},
_restoreSessionFromStorage() {
try {
- if (!global.sessionStorage) return null;
- const raw = global.sessionStorage.getItem('ielts_sim_session');
- if (!raw) return null;
- const snapshot = JSON.parse(raw);
+ const snapshot = global.AppData.recovery.windowSession.get('simulation');
if (!snapshot || !snapshot.id || !Array.isArray(snapshot.sequence)) return null;
return snapshot;
} catch (_) { return null; }
@@ -1191,9 +1292,7 @@
_clearSessionStorage() {
try {
- if (global.sessionStorage) {
- global.sessionStorage.removeItem('ielts_sim_session');
- }
+ global.AppData.recovery.windowSession.discard('simulation');
} catch (_) { /* ignore */ }
},
@@ -1303,8 +1402,6 @@
const pausedAtMs = Number.isFinite(Number(session.suiteTimerPausedAtMs)) ? Number(session.suiteTimerPausedAtMs) : null;
const suiteTimerRunning = session.suiteTimerRunning !== false;
const payload = {
- type: 'SIMULATION_CONTEXT',
- data: {
suiteSessionId: session.id,
flowMode: session.flowMode || 'simulation',
examId,
@@ -1333,10 +1430,9 @@
pausedAtMs,
running: suiteTimerRunning
}
- }
};
try {
- targetWindow.postMessage(payload, '*');
+ this._postExamMessage(examId, targetWindow, 'SIMULATION_CONTEXT', payload);
return true;
} catch (e) {
console.warn('[SuitePractice] 发送模拟上下文失败:', e);
@@ -1348,7 +1444,16 @@
const session = this.currentSuiteSession;
if (!session || session.status !== 'active') return false;
if (session.flowMode !== 'simulation') return false;
- if (session.simulationNavigateLocked === true) return false;
+ if (session.simulationNavigateLocked === true) {
+ const inFlight = this._simulationNavigateInFlight;
+ if (!inFlight || typeof inFlight.then !== 'function') return false;
+ try {
+ await inFlight;
+ } catch (_) {
+ // The queued request still gets its own validation and error path.
+ }
+ return this._handleSimulationNavigate(examId, data, sourceWindow);
+ }
const normalizedExamId = examId != null ? String(examId).trim() : '';
const activeExamId = session.activeExamId != null ? String(session.activeExamId).trim() : '';
if (!normalizedExamId) return false;
@@ -1362,6 +1467,11 @@
}
session.activeExamId = normalizedExamId;
}
+ let releaseNavigation;
+ const navigationInFlight = new Promise((resolve) => {
+ releaseNavigation = resolve;
+ });
+ this._simulationNavigateInFlight = navigationInFlight;
session.simulationNavigateLocked = true;
try {
@@ -1405,6 +1515,7 @@
session.activeExamId = targetEntry.examId;
const targetWindow = await this.openExam(targetEntry.examId, {
+ examDefinition: targetEntry.exam,
target: 'tab',
windowName: session.windowName || 'ielts-suite-mode-tab',
suiteSessionId: session.id,
@@ -1444,6 +1555,10 @@
return true;
} finally {
session.simulationNavigateLocked = false;
+ if (this._simulationNavigateInFlight === navigationInFlight) {
+ this._simulationNavigateInFlight = null;
+ }
+ releaseNavigation();
}
},
@@ -1464,7 +1579,7 @@
if (!session || (session.status !== 'active' && session.status !== 'initializing') || !examId) {
return false;
}
- if (session.flowMode !== 'simulation') {
+ if (session.flowMode !== 'simulation' && session.flowMode !== 'stationary') {
return false;
}
if (!Array.isArray(session.sequence) || !session.sequence.length) {
@@ -1486,7 +1601,7 @@
const pageType = windowInfo && typeof windowInfo.pageType === 'string'
? windowInfo.pageType.toLowerCase()
: '';
- if (pageType && !pageType.includes('unified-reading') && !pageType.includes('suite-placeholder')) {
+ if (pageType && !/unified-reading|suite-placeholder|^p[1-4]$|^practice$/i.test(pageType)) {
return false;
}
let targetWindow = session.windowRef && !session.windowRef.closed ? session.windowRef : null;
@@ -1531,11 +1646,14 @@
session.activeExamId = examId;
session.windowRef = targetWindow;
this._mirrorSessionToStorage(session);
- if (session._contextSentExamId === examId
+ if (session.flowMode === 'simulation' && session._contextSentExamId === examId
&& Number.isFinite(Number(session._contextSentAt))
&& Date.now() - session._contextSentAt < 3000) {
return true;
}
+ if (session.flowMode === 'stationary') {
+ return this._sendSuiteReviewState(session, examId, targetWindow);
+ }
return this._sendSimulationContext(session, examId, targetWindow);
},
@@ -1563,6 +1681,9 @@
if (alreadyRecorded) {
console.warn('[MultiSuite] 套题已记录,跳过:', suiteData.suiteId);
+ if (session.status !== 'completed' && this.isMultiSuiteComplete(session)) {
+ return await this.finalizeMultiSuiteRecord(session);
+ }
return true;
}
@@ -1605,8 +1726,7 @@
// 检查是否所有套题都已完成
if (this.isMultiSuiteComplete(session)) {
console.log('[MultiSuite] all suite entries completed, finalizing consolidated record.');
- await this.finalizeMultiSuiteRecord(session);
- return true;
+ return await this.finalizeMultiSuiteRecord(session);
}
// 还有套题未完成,保存当前进度
@@ -1653,12 +1773,13 @@
async finalizeMultiSuiteRecord(session) {
if (!session || !Array.isArray(session.suiteResults) || session.suiteResults.length === 0) {
console.warn('[MultiSuite] 无效的会话或无结果,跳过聚合');
- return;
+ return false;
}
session.status = 'finalizing';
console.log('[MultiSuite] 开始聚合多套题记录:', session.id);
+ let record = null;
try {
const completionTime = Date.now();
const startTime = session.startTime || completionTime;
@@ -1688,7 +1809,7 @@
const displayTitle = dateLabel + ' ' + sourceLabel + ' multi-suite practice';
// 构建聚合记录
- const record = {
+ record = {
id: session.id,
examId: session.baseExamId,
title: displayTitle,
@@ -1753,36 +1874,49 @@
// 保存聚合记录
await this._saveSuitePracticeRecord(record);
-
- // 保存拼写错误到词表
- if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) {
- try {
- await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors);
- console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length);
- } catch (error) {
- console.warn('[MultiSuite] 保存拼写错误失败:', error);
- }
+ session.status = 'completed';
+ } catch (error) {
+ console.error('[MultiSuite] 聚合记录失败:', error);
+ session.status = 'error';
+ try {
+ window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error');
+ } catch (notificationError) {
+ console.warn('[MultiSuite] 显示聚合保存失败通知时出错:', notificationError);
}
+ return false;
+ }
- // 更新状态
- await this._updatePracticeRecordsState();
+ // From here on the aggregate record is authoritative. Every remaining action is best-effort
+ // and must not turn the committed submission into a NACK or another persistence attempt.
+ const aggregatedSpellingErrors = Array.isArray(record.spellingErrors) ? record.spellingErrors : [];
+ if (aggregatedSpellingErrors.length > 0 && window.spellingErrorCollector) {
+ await this._runSuitePostCommitStep('保存多套题拼写错误', async () => {
+ await window.spellingErrorCollector.saveErrors(aggregatedSpellingErrors);
+ console.log('[MultiSuite] 已保存拼写错误到词表:', aggregatedSpellingErrors.length);
+ });
+ }
+ await this._runSuitePostCommitStep('同步多套题练习记录', () => this._updatePracticeRecordsState());
+ await this._runSuitePostCommitStep('刷新多套题总览', () => {
this.refreshOverviewData && this.refreshOverviewData();
-
- // 清理会话
+ });
+ await this._runSuitePostCommitStep('清理多套题会话', () => {
this.multiSuiteSessionsMap.delete(session.baseExamId);
- session.status = 'completed';
-
+ });
+ await this._runSuitePostCommitStep('显示多套题完成通知', () => {
window.showMessage && window.showMessage('多套题练习已完成,已保存 ' + session.suiteResults.length + ' 条套题记录。', 'success');
+ });
+ console.log('[MultiSuite] consolidated record saved:', record.id);
+ return true;
+ },
-
-
- console.log('[MultiSuite] consolidated record saved:', record.id);
-
+ async _runSuitePostCommitStep(label, callback) {
+ try {
+ await callback();
+ return true;
} catch (error) {
- console.error('[MultiSuite] 聚合记录失败:', error);
- session.status = 'error';
- window.showMessage && window.showMessage('多套题记录保存失败,请稍后重试。', 'error');
+ console.warn(`[SuitePractice] ${label}失败(聚合记录已保存):`, error);
+ return false;
}
},
@@ -1956,14 +2090,15 @@
return aggregated;
},
- async finalizeSuiteRecord(session) {
+ async finalizeSuiteRecord(session, options = {}) {
if (!session || !session.results || !session.results.length) {
await this._teardownSuiteSession(session);
- return;
+ return false;
}
session.status = 'finalizing';
+ let committed = false;
try {
const completionTime = Date.now();
const suiteEntries = session.results.map(entry => {
@@ -1979,6 +2114,8 @@
markedQuestions: Array.isArray(entry.markedQuestions) ? entry.markedQuestions.slice() : [],
highlights: this._resolveSuiteEntryHighlights(entry, draft),
noteText: this._resolveSuiteEntryNoteText(entry, draft),
+ notes: this._resolveSuiteEntryNotes(entry, draft),
+ noteOutlines: this._resolveSuiteEntryNoteOutlines(entry, draft),
scrollY: this._resolveSuiteEntryScrollY(entry, draft),
rawData: this._sanitizeSuiteRawData(entry.rawData)
};
@@ -2077,55 +2214,55 @@
};
await this._saveSuitePracticeRecord(record);
- await this._updatePracticeRecordsState();
- this.refreshOverviewData && this.refreshOverviewData();
- window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success');
+ committed = true;
session.status = 'completed';
} catch (error) {
console.error('[SuitePractice] 保存套题记录失败:', error);
- window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error');
- await this._savePartialSuiteAsIndividual(session);
- session.status = 'error';
- } finally {
- await this._teardownSuiteSession(session);
- }
- },
-
- async _fetchSuiteExamIndex() {
- let list = this.getState ? this.getState('exam.index') : null;
- if (!Array.isArray(list) || !list.length) {
try {
- const activeKey = await storage.get('active_exam_index_key', 'exam_index');
- list = await storage.get(activeKey, []);
- if (!Array.isArray(list) || !list.length) {
- list = await storage.get('exam_index', []);
- }
- } catch (error) {
- console.warn('[SuitePractice] Failed to load exam index, falling back to the default bank.', error);
- list = await storage.get('exam_index', []);
+ window.showMessage && window.showMessage('套题记录保存失败,系统将尝试恢复到普通模式。', 'error');
+ } catch (notificationError) {
+ console.warn('[SuitePractice] 显示套题保存失败通知时出错:', notificationError);
}
+ try {
+ await this._savePartialSuiteAsIndividual(session);
+ } catch (fallbackError) {
+ console.warn('[SuitePractice] 聚合记录未保存,单篇恢复也失败:', fallbackError);
+ }
+ session.status = options.deferTeardown ? 'active' : 'error';
+ }
+
+ if (committed) {
+ await this._runSuitePostCommitStep('同步套题练习记录', () => this._updatePracticeRecordsState());
+ await this._runSuitePostCommitStep('刷新套题总览', () => {
+ this.refreshOverviewData && this.refreshOverviewData();
+ });
+ await this._runSuitePostCommitStep('显示套题完成通知', () => {
+ window.showMessage && window.showMessage('套题练习已完成,记录已保存。', 'success');
+ });
+ }
+
+ if (!options.deferTeardown) {
+ await this._runSuitePostCommitStep('清理套题会话窗口', () => this._teardownSuiteSession(session));
}
+ return committed;
+ },
+ async _fetchSuiteExamIndex() {
+ const list = await window.resolveActiveLibraryIndex();
return Array.isArray(list) ? list.filter(Boolean) : [];
},
async _listPracticeRecordsViaAPI() {
const normalizeList = (list) => (Array.isArray(list) ? list : []);
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return normalizeList(await window.PracticeRecordAPI.list());
- }
-
- return [];
+ // Filtering needs suiteEntries and suite markers, but never highlights or notes.
+ // The detail projection contains those fields without loading the annotation layer.
+ return normalizeList(await window.AppData.practice.list({ projection: 'detail' }));
},
async _recalculatePracticeStatsFromRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.recalculateStats === 'function') {
- await window.PracticeRecordAPI.recalculateStats();
- return true;
- }
- console.warn('[SuitePractice] 统一练习统计 API 未就绪');
- return false;
+ await window.AppData.practice.getStats();
+ return true;
},
async _loadSuitePracticeRecordsForFiltering() {
@@ -2458,6 +2595,7 @@
let examWindow = null;
try {
examWindow = await this.openExam(firstEntry.examId, {
+ examDefinition: firstEntry.exam,
target: 'tab',
windowName: suiteWindowName,
suiteSessionId,
@@ -2586,13 +2724,26 @@
},
async _saveSuitePracticeRecord(record) {
- if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.saveRecord !== 'function') {
- throw new Error('统一练习记录存储未就绪');
- }
- await window.PracticeRecordAPI.saveRecord(record, { updateStats: true });
- await this._cleanupSuiteEntryRecords(record).catch(error => {
- console.warn('[SuitePractice] 清理套题子记录失败:', error);
+ const childSessionIds = [];
+ (Array.isArray(record && record.suiteEntries) ? record.suiteEntries : []).forEach((entry) => {
+ const raw = entry && entry.rawData || {};
+ const sessionId = raw.sessionId || (entry && (entry.sessionId || entry.suiteEntrySessionId));
+ if (sessionId && String(sessionId) !== String(record.sessionId || '')) childSessionIds.push(String(sessionId));
});
+ const receipt = await window.AppData.practice.finalizeSuite({
+ record,
+ childSessionIds,
+ operationId: record.operationId
+ || (record.submissionId
+ ? `practice-suite:${String(record.sessionId || 'session')}:${String(record.submissionId)}`
+ : undefined)
+ });
+ if (!receipt || receipt.committed !== true) {
+ const error = new Error('Suite aggregate commit was not confirmed');
+ error.code = 'SUITE_COMMIT_NOT_CONFIRMED';
+ throw error;
+ }
+ return receipt.record || record;
},
async _cleanupSuiteEntryRecords(record) {
@@ -2625,14 +2776,7 @@
return;
}
- if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.deleteMany !== 'function') {
- throw new Error('统一练习记录删除 API 未就绪');
- }
- const result = await window.PracticeRecordAPI.deleteMany(Array.from(entrySessionIds), { updateStats: true, matchBy: 'sessionId' });
- const deletedCount = Number(result && result.deletedCount) || 0;
- if (deletedCount > 0) {
- console.log('[SuitePractice] cleared ' + deletedCount + ' suite child records');
- }
+ // Child cleanup is committed atomically by practice.finalizeSuite.
},
async _updatePracticeRecordsState() {
@@ -2641,22 +2785,20 @@
await window.syncPracticeRecords({ forceRender: true });
return;
} else {
- const latest = await this._listPracticeRecordsViaAPI();
- if (this.setState) {
- this.setState('practice.records', Array.isArray(latest) ? latest : []);
+ const [latest, index] = await Promise.all([
+ window.AppData.practice.list({ projection: 'light' }),
+ window.resolveActiveLibraryIndex()
+ ]);
+ if (typeof window.refreshBrowseProgressFromRecords === 'function') {
+ window.refreshBrowseProgressFromRecords(latest, index);
+ }
+ if (typeof window.updatePracticeView === 'function') {
+ window.updatePracticeView(latest, index);
}
}
} catch (error) {
console.warn('[SuitePractice] 同步练习记录失败:', error);
}
-
- try {
- if (typeof window.updatePracticeView === 'function') {
- window.updatePracticeView();
- }
- } catch (error) {
- console.warn('[SuitePractice] 刷新练习视图失败:', error);
- }
},
_formatSuiteDateLabel(timestamp) {
@@ -2778,16 +2920,21 @@
return;
}
+ if (session.submitReceiptTeardownTimer) {
+ clearTimeout(session.submitReceiptTeardownTimer);
+ session.submitReceiptTeardownTimer = null;
+ }
+
this._clearSuiteHandshakes();
if (session.windowRef && !session.windowRef.closed && typeof session.windowRef.postMessage === 'function') {
try {
- session.windowRef.postMessage({
- type: 'SUITE_FORCE_CLOSE',
- data: {
- suiteSessionId: session.id || null
- }
- }, '*');
+ const activeExamId = session.activeExamId
+ || (session.sequence && session.sequence[session.currentIndex || 0] && session.sequence[session.currentIndex || 0].examId)
+ || '';
+ this._postExamMessage(activeExamId, session.windowRef, 'SUITE_FORCE_CLOSE', {
+ suiteSessionId: session.id || null
+ });
} catch (forceCloseError) {
console.warn('[SuitePractice] 无法通知套题窗口关闭:', forceCloseError);
}
diff --git a/js/bundles/settings.bundle.js b/js/bundles/settings.bundle.js
deleted file mode 100644
index fff3760a..00000000
--- a/js/bundles/settings.bundle.js
+++ /dev/null
@@ -1,1554 +0,0 @@
-/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */
-
-/* ===== js/components/DataIntegrityManager.js ===== */
-/**
- * 数据完整性管理器 (仓库驱动版)
- * 负责数据备份、验证、修复和导入导出功能
- * 基于统一的数据仓库接口执行原子操作
- */
-class DataIntegrityManager {
- constructor(options = {}) {
- this.backupInterval = 600000; // 10分钟自动备份
- this.maxBackups = 5; // 最多保留5个备份(减少占用)
- this.dataVersion = '0.6.2-fix';
- this.backupTimer = null;
- this.validationRules = new Map();
- this.repositories = null;
- this.consistencyReport = null;
- this.isInitialized = false;
- this.registry = options.registry || window.StorageProviderRegistry || null;
- this._unsubscribe = null;
-
- this.registerDefaultValidationRules();
- this.connectToProviders();
-
- console.log('[DataIntegrityManager] 数据完整性管理器已创建');
- }
-
- connectToProviders() {
- const registry = this.registry;
- if (registry && typeof registry.onProvidersReady === 'function') {
- this._unsubscribe = registry.onProvidersReady(({ repositories }) => {
- this.attachRepositories(repositories);
- });
- const current = registry.getCurrentProviders && registry.getCurrentProviders();
- if (current && current.repositories) {
- this.attachRepositories(current.repositories);
- }
- return;
- }
-
- if (window.dataRepositories) {
- this.attachRepositories(window.dataRepositories);
- return;
- }
-
- console.warn('[DataIntegrityManager] 未检测到数据仓库注册表,等待外部注入');
- }
-
- async attachRepositories(repositories) {
- if (!repositories) {
- return;
- }
- if (this.repositories === repositories && this.isInitialized) {
- return;
- }
-
- this.repositories = repositories;
- console.log('[DataIntegrityManager] 已绑定数据仓库接口');
-
- try {
- await this.initializeWithRepositories();
- } catch (error) {
- console.error('[DataIntegrityManager] 初始化失败:', error);
- this.startAutoBackup();
- this.isInitialized = true;
- }
- }
-
- async initializeWithRepositories() {
- try {
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
-
- try {
- this.consistencyReport = await this.repositories.runConsistencyChecks();
- console.log('[DataIntegrityManager] 初始一致性检查完成', this.consistencyReport);
- } catch (reportError) {
- console.warn('[DataIntegrityManager] 初始一致性检查失败:', reportError);
- }
-
- this.startAutoBackup();
- try { await this.cleanupOldBackups(); } catch (_) {}
-
- this.isInitialized = true;
- console.log('[DataIntegrityManager] 数据完整性管理器已初始化');
- } catch (error) {
- throw error;
- }
- }
-
- _ensureInitialized() {
- if (!this.isInitialized) {
- console.warn('[DataIntegrityManager] 尚未完全初始化,使用降级模式');
- }
- }
-
- async cleanupOldBackups() {
- try {
- if (!this.repositories) return;
- const backups = await this.repositories.backups.list();
- if (backups.length <= this.maxBackups) return;
- await this.repositories.backups.prune(this.maxBackups);
- console.log('[DataIntegrityManager] 已执行备份裁剪');
- } catch (error) {
- console.error('[DataIntegrityManager] 清理旧备份失败:', error);
- }
- }
-
- async createBackup(providedData, type = 'manual') {
- let data = null;
- try {
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
- data = providedData || await this.getCriticalData();
- if (Object.keys(data).length === 0) {
- throw new Error('无数据可备份');
- }
-
- // 统一经 BackupAPI(内部仍落 BackupRepository),保证 schema 与裁剪一致
- if (window.BackupAPI && typeof window.BackupAPI.create === 'function') {
- const backupId = await window.BackupAPI.create({
- type,
- data,
- version: this.dataVersion
- });
- const backupObj = await window.BackupAPI.getById(backupId);
- console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${backupId}`);
- return backupObj || { id: backupId, type, data, version: this.dataVersion };
- }
-
- const id = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- const timestamp = new Date().toISOString();
- const backupObj = {
- id,
- timestamp,
- data,
- version: this.dataVersion,
- type,
- size: JSON.stringify(data).length
- };
- await this.repositories.backups.add(backupObj);
- console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${id}`);
- return backupObj;
- } catch (error) {
- console.error('[DataIntegrityManager] 创建备份失败:', error);
- if (error.name === 'QuotaExceededError' && data) {
- this.exportDataAsFallback(data);
- }
- throw error;
- }
- }
-
- exportDataAsFallback(exportData) {
- try {
- const exportObj = {
- exportDate: new Date().toISOString(),
- version: this.dataVersion,
- data: exportData,
- note: 'Storage quota exceeded - manual backup'
- };
- const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `ielts-data-backup-quota-${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- console.log('[DataIntegrityManager] 配额溢出备份已下载');
- } catch (fallbackError) {
- console.error('[DataIntegrityManager] fallback 导出失败:', fallbackError);
- }
- }
-
- registerDefaultValidationRules() {
- this.validationRules.set('practice_records', {
- required: ['id', 'startTime'],
- types: {
- id: 'string',
- startTime: 'string',
- endTime: 'string',
- date: 'string',
- duration: 'number',
- examId: 'string',
- examTitle: 'string',
- scoreInfo: 'object'
- },
- validators: {
- startTime: (value) => !isNaN(new Date(value).getTime()),
- date: (value) => !value || !isNaN(new Date(value).getTime()),
- endTime: (value) => !value || !isNaN(new Date(value).getTime()),
- duration: (value) => typeof value === 'number' && value >= 0,
- id: (value) => typeof value === 'string' && value.length > 0
- }
- });
-
- this.validationRules.set('system_settings', {
- types: {
- theme: 'string',
- language: 'string',
- autoSave: 'boolean',
- notifications: 'boolean'
- }
- });
- }
-
- startAutoBackup() {
- if (this.backupTimer) {
- clearInterval(this.backupTimer);
- }
- this.backupTimer = setInterval(() => {
- this.performAutoBackup();
- }, this.backupInterval);
- console.log(`[DataIntegrityManager] 自动备份已启动 (${this.backupInterval / 1000}秒间隔)`);
- }
-
- stopAutoBackup() {
- if (this.backupTimer) {
- clearInterval(this.backupTimer);
- this.backupTimer = null;
- console.log('[DataIntegrityManager] 自动备份已停止');
- }
- }
-
- async performAutoBackup() {
- try {
- const criticalData = await this.getCriticalData();
- if (Object.keys(criticalData).length > 0) {
- await this.createBackup(criticalData, 'auto');
- console.log('[DataIntegrityManager] 自动备份完成');
- } else {
- console.log('[DataIntegrityManager] 无关键数据需要备份');
- }
- } catch (error) {
- console.error('[DataIntegrityManager] 自动备份失败:', error);
- }
- }
-
- async getBackupList() {
- try {
- if (!this.repositories) return [];
- const backups = await this.repositories.backups.list();
- return backups.map(b => ({
- id: b.id,
- timestamp: b.timestamp,
- type: b.type,
- version: b.version,
- size: b.size
- }));
- } catch (error) {
- console.error('[DataIntegrityManager] 获取备份列表失败:', error);
- return [];
- }
- }
-
- async restoreBackup(backupId) {
- let currentSnapshot = null;
- try {
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
-
- // 优先走 BackupAPI:统一还原 records/stats/exam_index/settings
- if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') {
- try {
- currentSnapshot = await this.getCriticalData();
- } catch (snapshotError) {
- console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError);
- }
- await window.BackupAPI.restore(backupId);
- console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功 (BackupAPI)`);
- return;
- }
-
- const backup = await this.repositories.backups.getById(backupId);
- if (!backup) {
- throw new Error('备份不存在');
- }
- try {
- currentSnapshot = await this.getCriticalData();
- } catch (snapshotError) {
- console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError);
- }
- const data = backup.data || {};
- // 缺失 practice_records 时不清空现有记录(settings-only 备份恢复不应删练习数据)。
- // 仅当备份显式包含 practice_records 数组时才恢复。
- const records = Array.isArray(data.practice_records)
- ? data.practice_records
- : (Array.isArray(data.practiceRecords) ? data.practiceRecords : null);
- const stats = data.user_stats || data.userStats || null;
- if (records != null) {
- await this._restorePracticeRecords(records, stats);
- } else if (stats) {
- await this._writeUserStats(stats);
- }
-
- if (data.system_settings && typeof data.system_settings === 'object') {
- const currentSettings = await this.repositories.settings.getAll();
- const restoredSettings = { ...currentSettings, ...data.system_settings };
- await this.repositories.settings.saveAll(restoredSettings);
- }
- console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功`);
- } catch (error) {
- console.error('[DataIntegrityManager] 恢复备份失败:', error);
- if (currentSnapshot) {
- try {
- await this._restoreFromBackup({ data: currentSnapshot });
- console.warn('[DataIntegrityManager] 恢复失败后已回滚到恢复前快照');
- } catch (restoreError) {
- console.error('[DataIntegrityManager] 恢复失败后的回滚也失败:', restoreError);
- }
- }
- throw error;
- }
- }
-
- async exportData() {
- try {
- const data = await this.getCriticalData();
- const exportObj = {
- exportDate: new Date().toISOString(),
- version: this.dataVersion,
- data
- };
- const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `ielts-data-backup-${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- console.log('[DataIntegrityManager] 数据导出成功');
- } catch (error) {
- console.error('[DataIntegrityManager] 导出数据失败:', error);
- throw error;
- }
- }
-
- async importData(source, options = {}) {
- this._ensureInitialized();
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
-
- let payload;
- let backup = null;
- try {
- payload = await this._normalizeImportPayload(source);
- } catch (error) {
- console.error('[DataIntegrityManager] 解析导入源失败:', error);
- throw new Error(error?.message || '导入文件格式无效');
- }
-
- const hasPracticeSection = Array.isArray(payload.practice_records);
- const hasSettingsSection = payload.system_settings && typeof payload.system_settings === 'object';
- const hasUserStatsSection = payload.user_stats && typeof payload.user_stats === 'object';
- const practiceRecords = hasPracticeSection ? this._preparePracticeRecords(payload.practice_records) : null;
- const systemSettings = hasSettingsSection ? this._prepareSystemSettings(payload.system_settings) : {};
- const userStats = hasUserStatsSection ? payload.user_stats : null;
-
- if (!hasPracticeSection && !hasSettingsSection && !hasUserStatsSection) {
- throw new Error('导入文件缺少可用的数据');
- }
-
- try {
- backup = await this.createBackup(null, 'pre_import');
- } catch (error) {
- console.warn('[DataIntegrityManager] 导入前创建备份失败:', error);
- }
-
- try {
- if (hasPracticeSection) {
- await this._restorePracticeRecords(practiceRecords || [], userStats);
- } else if (userStats) {
- await this._writeUserStats(userStats);
- }
-
- if (hasSettingsSection && Object.keys(systemSettings).length > 0) {
- const current = await this.repositories.settings.getAll();
- const next = { ...current, ...systemSettings };
- await this.repositories.settings.saveAll(next);
- }
- } catch (error) {
- console.error('[DataIntegrityManager] 导入数据失败:', error);
- // 导入已部分写入:尝试从 pre_import 备份恢复,避免半导入状态损坏数据。
- if (backup && backup.id) {
- try {
- await this._restoreFromBackup(backup);
- console.warn('[DataIntegrityManager] 导入失败后已从备份恢复:', backup.id);
- } catch (restoreError) {
- console.error('[DataIntegrityManager] 导入失败后恢复备份也失败:', restoreError);
- }
- }
- throw new Error(error?.message || '导入数据失败');
- }
-
- return {
- importedCount: practiceRecords ? practiceRecords.length : 0,
- backupId: backup?.id || null,
- version: payload.version || this.dataVersion
- };
- }
-
- async getCriticalData() {
- this._ensureInitialized();
- try {
- if (!this.repositories) {
- return {};
- }
- const data = {};
- try {
- const practiceRecords = await this._listPracticeRecords();
- // 读取失败时用 null 而非 [],区分"读取失败"与"确实无记录"。
- // rollback 时 null 表示不恢复 records,避免用空备份清空好数据。
- data.practice_records = practiceRecords != null ? practiceRecords : null;
- } catch (recordsError) {
- console.warn('[DataIntegrityManager] 获取练习记录失败:', recordsError);
- data.practice_records = null;
- }
-
- try {
- const allSettings = await this.repositories.settings.getAll();
- const systemSettings = {
- theme: allSettings.theme,
- language: allSettings.language,
- autoSave: allSettings.autoSave,
- notifications: allSettings.notifications
- };
- data.system_settings = systemSettings;
- } catch (settingsError) {
- console.warn('[DataIntegrityManager] 获取系统设置失败:', settingsError);
- data.system_settings = {};
- }
-
- try {
- const metaRepo = this.repositories.meta;
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- data.user_stats = await window.PracticeRecordAPI.readStats();
- }
- if (metaRepo && typeof metaRepo.get === 'function') {
- data.vocab_words = await metaRepo.get('vocab_words', []);
- data.vocab_user_config = await metaRepo.get('vocab_user_config', null);
- data.vocab_review_queue = await metaRepo.get('vocab_review_queue', []);
- data.vocab_list_reading_highlights = await metaRepo.get('vocab_list_reading_highlights', []);
- }
- } catch (vocabError) {
- console.warn('[DataIntegrityManager] 获取词汇数据失败:', vocabError);
- }
-
- return data;
- } catch (error) {
- console.error('[DataIntegrityManager] 获取关键数据失败:', error);
- return {};
- }
- }
-
- async _listPracticeRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- }
- if (this.repositories && this.repositories.practice && typeof this.repositories.practice.list === 'function') {
- const records = await this.repositories.practice.list();
- return Array.isArray(records) ? records : [];
- }
-
- return [];
- }
-
- async _restorePracticeRecords(records, userStats = null) {
- const finalRecords = Array.isArray(records) ? records : [];
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') {
- await window.PracticeRecordAPI.restoreRecords(finalRecords, {
- stats: userStats && typeof userStats === 'object' ? userStats : null,
- updateStats: true
- });
- return true;
- }
- if (this.repositories && this.repositories.practice && typeof this.repositories.practice.overwrite === 'function') {
- await this.repositories.practice.overwrite(finalRecords);
- return true;
- }
-
- throw new Error('统一练习记录恢复 API 未就绪');
- }
-
- async _writeUserStats(stats) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.writeStats === 'function') {
- await window.PracticeRecordAPI.writeStats(stats);
- return true;
- }
- if (this.repositories && this.repositories.meta && typeof this.repositories.meta.set === 'function') {
- await this.repositories.meta.set('user_stats', stats);
- return true;
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- // 导入失败时从 pre_import 备份恢复,避免数据停留在半导入状态。
- // 恢复失败仅记录,不掩盖原始导入错误。
- // 注意:practice_records 为 null/undefined 时不恢复 records(读取失败时的占位),
- // 只有非 null 的数组才视为有效备份进行恢复;空数组也需恢复(表示备份时确实无记录)。
- async _restoreFromBackup(backup) {
- if (!backup || !backup.data) {
- return false;
- }
- const snapshot = backup.data;
- const hasRecordsBackup = snapshot.practice_records != null;
- const restoredRecords = hasRecordsBackup && Array.isArray(snapshot.practice_records)
- ? this._preparePracticeRecords(snapshot.practice_records)
- : null;
- const restoredStats = snapshot.user_stats && typeof snapshot.user_stats === 'object'
- ? snapshot.user_stats
- : null;
- if (restoredRecords) {
- await this._restorePracticeRecords(restoredRecords, restoredStats);
- } else if (restoredStats) {
- await this._writeUserStats(restoredStats);
- }
- if (snapshot.system_settings && typeof snapshot.system_settings === 'object'
- && Object.keys(snapshot.system_settings).length > 0) {
- const current = await this.repositories.settings.getAll();
- const next = { ...current, ...snapshot.system_settings };
- await this.repositories.settings.saveAll(next);
- }
- return true;
- }
-
- async _normalizeImportPayload(source) {
- const raw = await this._resolveImportSource(source);
- const container = this._unwrapDataSection(raw);
- return {
- practice_records: this._extractField(container, ['practice_records', 'practiceRecords', 'practice']),
- system_settings: this._extractField(container, ['system_settings', 'systemSettings', 'settings']),
- user_stats: this._extractField(container, ['user_stats', 'userStats']),
- version: typeof raw?.version === 'string' ? raw.version : null
- };
- }
-
- async _resolveImportSource(source) {
- if (!source) {
- throw new Error('未提供导入数据源');
- }
- if (typeof source === 'string') {
- return JSON.parse(source);
- }
- if (typeof Blob !== 'undefined' && source instanceof Blob && typeof source.text === 'function') {
- const text = await source.text();
- return JSON.parse(text);
- }
- if (typeof File !== 'undefined' && source instanceof File) {
- const text = await source.text();
- return JSON.parse(text);
- }
- if (source instanceof ArrayBuffer) {
- const text = new TextDecoder('utf-8').decode(source);
- return JSON.parse(text);
- }
- if (typeof source === 'object') {
- return source;
- }
- throw new Error('不支持的导入数据类型');
- }
-
- _unwrapDataSection(raw) {
- if (!raw || typeof raw !== 'object') {
- throw new Error('导入文件格式无效');
- }
- if (raw.data && typeof raw.data === 'object') {
- return raw.data;
- }
- return raw;
- }
-
- _extractField(container, variants) {
- if (!container || typeof container !== 'object') {
- return undefined;
- }
- const lookup = this._buildKeyLookup(container);
- for (const variant of variants) {
- if (lookup.has(variant.toLowerCase())) {
- return lookup.get(variant.toLowerCase());
- }
- }
- return undefined;
- }
-
- _buildKeyLookup(container) {
- const map = new Map();
- Object.keys(container).forEach((key) => {
- map.set(key.toLowerCase(), container[key]);
- });
- return map;
- }
-
- _preparePracticeRecords(list) {
- if (!Array.isArray(list)) {
- return [];
- }
- return list.filter(entry => entry && typeof entry === 'object');
- }
-
- _prepareSystemSettings(settings) {
- if (!settings || typeof settings !== 'object') {
- return {};
- }
- const allowed = ['theme', 'language', 'autoSave', 'notifications'];
- const prepared = {};
- for (const key of allowed) {
- if (settings[key] !== undefined) {
- prepared[key] = settings[key];
- }
- }
- return prepared;
- }
-
-}
-
-let dataIntegrityManagerInstance = null;
-
-function getDataIntegrityManager() {
- if (!dataIntegrityManagerInstance) {
- dataIntegrityManagerInstance = new DataIntegrityManager();
- }
- return dataIntegrityManagerInstance;
-}
-
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = { DataIntegrityManager, getDataIntegrityManager };
-} else {
- window.DataIntegrityManager = DataIntegrityManager;
- window.getDataIntegrityManager = getDataIntegrityManager;
-}
-
-
-/* ===== js/utils/dataBackupManager.js ===== */
-/**
- * Data backup and recovery manager.
- * Provides export/import/cleanup functionality for the shared storage layer.
- */
-class DataBackupManager {
- constructor() {
- this.storageKeys = {
- backupSettings: 'backup_settings',
- exportHistory: 'export_history',
- importHistory: 'import_history',
- manualBackups: 'manual_backups'
- };
-
- this.supportedFormats = ['json', 'csv'];
- this.maxBackupHistory = 20;
- this.maxExportHistory = 50;
-
- this.initialize();
- }
-
- sanitizeExamTitle(title) {
- if (!title) return '';
- const str = String(title).trim();
- if (!str) return '';
- const pattern = /ielts\s+listening\s+practice\s*-\s*part\s*\d+\s*[:\-]?\s*(.+)$/i;
- const match = str.match(pattern);
- if (match && match[1]) {
- return match[1].trim();
- }
- if (str.includes(' - ')) {
- const segments = str.split(' - ').map((s) => s.trim()).filter(Boolean);
- if (segments.length > 1) {
- return segments[segments.length - 1];
- }
- }
- return str;
- }
-
- sanitizeRecord(record) {
- if (!record || typeof record !== 'object') {
- return record;
- }
- const clone = { ...record };
- const metadata = (clone.metadata && typeof clone.metadata === 'object') ? { ...clone.metadata } : {};
- const baseTitle = metadata.examTitle || metadata.title || clone.title || clone.examTitle;
- const cleanedTitle = this.sanitizeExamTitle(baseTitle);
- if (cleanedTitle) {
- metadata.examTitle = cleanedTitle;
- metadata.title = metadata.title || cleanedTitle;
- clone.title = cleanedTitle;
- if (!clone.examTitle) {
- clone.examTitle = cleanedTitle;
- }
- clone.metadata = metadata;
- }
- return clone;
- }
-
- async initialize() {
- try {
- await this.initializeSettings();
- } catch (error) {
- console.error('[DataBackupManager] failed to initialize settings', error);
- }
-
- this.setupPeriodicCleanup();
- }
-
- async initializeSettings() {
- const defaults = {
- autoBackup: true,
- backupInterval: 24,
- maxBackups: 10,
- compressionEnabled: false,
- encryptionEnabled: false,
- lastAutoBackup: null
- };
-
- try {
- const stored = await storage.get(this.storageKeys.backupSettings, defaults);
- await storage.set(this.storageKeys.backupSettings, { ...defaults, ...stored });
- } catch (error) {
- console.error('[DataBackupManager] unable to persist settings', error);
- }
- }
-
- async listPracticeRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return await window.PracticeRecordAPI.list();
- }
-
- throw new Error('统一练习记录存储未就绪');
- }
-
- async replacePracticeRecords(records, options = {}) {
- const normalizedRecords = Array.isArray(records) ? records : [];
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') {
- await window.PracticeRecordAPI.replace(normalizedRecords, options);
- return true;
- }
-
- throw new Error('统一练习记录存储未就绪');
- }
-
- async restorePracticeRecords(records, stats = null) {
- const normalizedRecords = Array.isArray(records) ? records : [];
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') {
- return await window.PracticeRecordAPI.restoreRecords(normalizedRecords, {
- stats: this.isPlainObject(stats) ? stats : null,
- updateStats: true
- });
- }
-
- throw new Error('统一练习记录恢复 API 未就绪');
- }
-
- async readUserStats() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- return await window.PracticeRecordAPI.readStats();
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- async mergeUserStats(stats, mergeMode = 'merge') {
- if (!this.isPlainObject(stats)) {
- return await this.readUserStats();
- }
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeStats === 'function') {
- return await window.PracticeRecordAPI.mergeStats(stats, { mergeMode });
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- async resetUserStats(stats = null) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.resetStats === 'function') {
- return await window.PracticeRecordAPI.resetStats(stats);
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- async createBackup(backupName = null, type = 'manual') {
- if (window.BackupAPI && typeof window.BackupAPI.create === 'function') {
- return await window.BackupAPI.create({
- id: backupName || undefined,
- type
- });
- }
-
- // Fallback when BackupAPI not loaded yet (early boot / isolated tests)
- const practiceRecords = await this.listPracticeRecords();
- const userStats = await this.readUserStats();
- const examIndex = await storage.get('exam_index', []);
- const backup = {
- id: backupName || `backup_${Date.now()}`,
- timestamp: new Date().toISOString(),
- type,
- data: {
- practice_records: practiceRecords,
- practiceRecords,
- user_stats: userStats,
- userStats,
- exam_index: examIndex,
- examIndex
- }
- };
-
- const backups = await storage.get(this.storageKeys.manualBackups, []);
- backups.unshift(backup);
- while (backups.length > this.maxBackupHistory) {
- backups.pop();
- }
- await storage.set(this.storageKeys.manualBackups, backups);
- return backup.id;
- }
-
- async exportPracticeRecords(options = {}) {
- const {
- format = 'json',
- includeStats = true,
- includeBackups = false,
- dateRange = null,
- categories = null,
- compression = false
- } = options;
-
- const normalizedFormat = String(format).toLowerCase();
- if (!this.supportedFormats.includes(normalizedFormat)) {
- throw new Error(`Unsupported export format: ${format}`);
- }
-
- let practiceRecords = await this.listPracticeRecords();
- practiceRecords = Array.isArray(practiceRecords) ? practiceRecords : [];
-
- if (dateRange) {
- practiceRecords = this.filterByDateRange(practiceRecords, dateRange);
- }
-
- if (Array.isArray(categories) && categories.length) {
- practiceRecords = practiceRecords.filter(record => categories.includes(record?.metadata?.category));
- }
-
- const exportPayload = {
- exportInfo: {
- timestamp: new Date().toISOString(),
- version: '0.6.2-fix',
- format: normalizedFormat,
- recordCount: practiceRecords.length,
- options: { format, includeStats, includeBackups, dateRange, categories }
- },
- practiceRecords
- };
-
- if (includeStats) {
- exportPayload.userStats = await this.readUserStats();
- }
-
- if (includeBackups) {
- try {
- // 统一经 BackupAPI 读全量列表;不再经 scoreStorage 的类型过滤旁路
- if (window.BackupAPI && typeof window.BackupAPI.list === 'function') {
- exportPayload.backups = await window.BackupAPI.list();
- } else {
- exportPayload.backups = await storage.get(this.storageKeys.manualBackups, []);
- }
- if (!Array.isArray(exportPayload.backups)) {
- exportPayload.backups = [];
- }
- } catch (error) {
- console.warn('[DataBackupManager] failed to include backups in export', error);
- exportPayload.backups = [];
- }
- }
-
- await this.recordExportHistory(exportPayload.exportInfo);
-
- switch (normalizedFormat) {
- case 'json':
- return this.exportAsJSON(exportPayload, compression);
- case 'csv':
- return this.exportAsCSV(exportPayload);
- default:
- throw new Error(`Format ${format} not implemented`);
- }
- }
-
- exportAsJSON(data, compressionEnabled = false) {
- const raw = JSON.stringify(data, null, 2);
- const payload = compressionEnabled ? this.compressData(raw) : raw;
-
- return {
- data: payload,
- filename: `practice_records_${this.getTimestamp()}.json`,
- mimeType: 'application/json',
- size: payload.length,
- compressed: compressionEnabled
- };
- }
-
- exportAsCSV(data) {
- const records = Array.isArray(data.practiceRecords) ? data.practiceRecords : [];
- const headers = [
- 'record_id',
- 'exam_id',
- 'title',
- 'status',
- 'score',
- 'accuracy',
- 'duration_seconds',
- 'start_time',
- 'end_time',
- 'category',
- 'frequency',
- 'created_at'
- ];
-
- const rows = records.map(record => {
- const metadata = record?.metadata || {};
- return [
- record?.id ?? '',
- record?.examId ?? '',
- record?.title ?? '',
- record?.status ?? '',
- record?.score ?? '',
- record?.accuracy ?? '',
- record?.duration ?? '',
- record?.startTime ?? '',
- record?.endTime ?? '',
- metadata.category ?? '',
- metadata.frequency ?? '',
- record?.createdAt ?? ''
- ];
- });
-
- const csvContent = [headers, ...rows]
- .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
- .join('\n');
-
- return {
- data: csvContent,
- filename: `practice_records_${this.getTimestamp()}.csv`,
- mimeType: 'text/csv',
- size: csvContent.length
- };
- }
- /**
- * Legacy-friendly wrapper.
- */
- async importPracticeRecords(source, options = {}) {
- return this.importPracticeData(source, options);
- }
-
- async importPracticeData(source, options = {}) {
- console.log('[DataBackupManager] importPracticeData called, source type:', typeof source, 'length:', Array.isArray(source) ? source.length : source.practiceRecords?.length);
- const {
- mergeMode = 'merge',
- createBackup = true,
- preserveIds = true
- } = options;
-
- let payload;
- try {
- payload = await this.parseImportSource(source, { allowFetch: true });
- } catch (error) {
- throw new Error(`Failed to read import source: ${error.message}`);
- }
-
- const normalized = this.normalizeImportPayload(payload, { preserveIds });
- console.log('[DataBackupManager] Normalized records:', normalized.practiceRecords.length);
-
- let practiceRecords = Array.isArray(normalized.practiceRecords) ? normalized.practiceRecords : [];
-
- if (!practiceRecords.length) {
- throw new Error('Import file does not contain any practice records.');
- }
-
- practiceRecords = practiceRecords.map((r) => this.sanitizeRecord(r));
- normalized.practiceRecords = practiceRecords;
- console.log('[DataBackupManager] After sanitize, records:', normalized.practiceRecords.length);
-
- let backupId = null;
- if (createBackup) {
- backupId = await this.createPreImportBackup();
- console.log('[DataBackupManager] Pre-import backup created:', backupId);
- }
-
- let mergeResult;
- try {
- // 若备份同时携带 user_stats,导入 records 时禁止并发 recalculateStats,
- // 否则会与后续 mergeUserStats 竞态,覆盖备份中的 practiceDays/streakDays 等字段。
- const hasImportedStats = Boolean(normalized.userStats);
- mergeResult = await this.mergePracticeRecords(
- normalized.practiceRecords,
- mergeMode,
- { updateStats: !hasImportedStats }
- );
- console.log('[DataBackupManager] Practice records imported through PracticeRecordAPI');
-
- if (normalized.userStats) {
- await this.mergeUserStats(normalized.userStats, mergeMode);
- }
- } catch (error) {
- if (backupId) {
- try {
- await this.restoreBackup(backupId);
- } catch (restoreError) {
- console.error('[DataBackupManager] failed to restore backup after import error', restoreError);
- }
- }
-
- await this.recordImportHistory({
- timestamp: new Date().toISOString(),
- mergeMode,
- backupId,
- success: false,
- error: error.message
- });
- throw error;
- }
-
- await this.recordImportHistory({
- timestamp: new Date().toISOString(),
- recordCount: mergeResult.importedCount,
- mergeMode,
- backupId,
- sources: normalized.sources,
- success: true
- });
-
- return {
- success: true,
- ...mergeResult,
- backupId,
- statsImported: Boolean(normalized.userStats),
- sources: normalized.sources
- };
- }
-
- async parseImportSource(source, { allowFetch = false } = {}) {
- if (source === undefined || source === null) {
- throw new Error('Import source is empty.');
- }
-
- if (typeof File !== 'undefined' && source instanceof File) {
- return this.parseImportSource(await source.text(), { allowFetch });
- }
-
- if (typeof Blob !== 'undefined' && source instanceof Blob) {
- return this.parseImportSource(await source.text(), { allowFetch });
- }
-
- if (typeof source === 'string') {
- const trimmed = source.trim();
- if (!trimmed) {
- throw new Error('Import source string is empty.');
- }
-
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
- try {
- return JSON.parse(trimmed);
- } catch (error) {
- throw new Error('Import string is not valid JSON.');
- }
- }
-
- if (!allowFetch) {
- throw new Error('Import string is neither JSON nor a fetchable path.');
- }
-
- const response = await fetch(trimmed);
- if (!response.ok) {
- throw new Error(`Failed to fetch import file: ${response.status}`);
- }
- return await response.json();
- }
-
- if (Array.isArray(source) || this.isPlainObject(source)) {
- return source;
- }
-
- throw new Error('Unsupported import source type.');
- }
-
- normalizeImportPayload(payload, { preserveIds = true } = {}) {
- if (payload === undefined || payload === null) {
- throw new Error('Import data is empty.');
- }
-
- const practiceRecords = [];
- const sources = [];
- let userStats = null;
-
- if (this.isPlainObject(payload)) {
- const directStats = payload.user_stats
- ?? payload.userStats
- ?? payload.stats
- ?? payload.data?.user_stats
- ?? payload.data?.userStats
- ?? payload.data?.stats;
- if (this.isPlainObject(directStats)) {
- userStats = this.prepareUserStats(directStats);
- }
- }
-
- this.extractRecordSources(payload).forEach(({ records, source }) => {
- const normalizedRecords = records
- .map((record, index) => this.normalizeRecord(record, {
- preserveIds,
- fallbackIdPrefix: source || 'record',
- index
- }))
- .filter(Boolean);
-
- if (normalizedRecords.length) {
- practiceRecords.push(...normalizedRecords);
- sources.push({ path: source || '(root array)', count: normalizedRecords.length });
- }
- });
-
- // Dual-schema payloads and multi-path recovery can surface the same id twice;
- // keep first occurrence so replace-mode import does not invent duplicates.
- const seenIds = new Set();
- const dedupedPracticeRecords = [];
- practiceRecords.forEach((record) => {
- if (!record || typeof record !== 'object') {
- return;
- }
- const id = record.id != null ? String(record.id) : null;
- if (id) {
- if (seenIds.has(id)) {
- return;
- }
- seenIds.add(id);
- }
- dedupedPracticeRecords.push(record);
- });
-
- return {
- practiceRecords: dedupedPracticeRecords,
- userStats,
- sources
- };
- }
-
- extractRecordSources(payload) {
- const sources = [];
- const add = (source, records) => {
- if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) {
- sources.push({ source, records });
- }
- };
- // App backups write dual aliases (practice_records + practiceRecords) for the same list.
- // Prefer the first non-empty array so replace-mode import does not double-append.
- const addPreferred = (candidates) => {
- for (const { source, records } of candidates) {
- if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) {
- add(source, records);
- return true;
- }
- }
- return false;
- };
-
- if (Array.isArray(payload)) {
- add('(root array)', payload);
- return sources;
- }
- if (!this.isPlainObject(payload)) {
- return sources;
- }
-
- addPreferred([
- { source: 'practice_records', records: payload.practice_records },
- { source: 'practiceRecords', records: payload.practiceRecords }
- ]);
-
- const data = this.isPlainObject(payload.data) ? payload.data : {};
- const dataArrayPicked = addPreferred([
- { source: 'data.practice_records', records: data.practice_records },
- { source: 'data.practiceRecords', records: data.practiceRecords }
- ]);
- // Envelope form only when the preferred alias was not already a plain array source.
- if (!dataArrayPicked && this.isPlainObject(data.practice_records)) {
- add('data.practice_records.data', data.practice_records.data);
- } else if (!dataArrayPicked && this.isPlainObject(data.practiceRecords)) {
- add('data.practiceRecords.data', data.practiceRecords.data);
- }
- if (this.isPlainObject(data.exam_system_practice_records)) {
- add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
- }
- if (this.isPlainObject(payload.exam_system_practice_records)) {
- add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
- }
-
- return sources;
- }
-
- async mergePracticeRecords(newRecords, mergeMode = 'merge', options = {}) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeRecords === 'function') {
- return await window.PracticeRecordAPI.mergeRecords(
- Array.isArray(newRecords) ? newRecords : [],
- {
- mergeMode,
- updateStats: options.updateStats !== false
- }
- );
- }
-
- throw new Error('统一练习记录导入 API 未就绪');
- }
-
- prepareUserStats(candidate) {
- if (!this.isPlainObject(candidate)) {
- return null;
- }
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.prepareStats === 'function') {
- return window.PracticeRecordAPI.prepareStats(candidate);
- }
- throw new Error('统一练习统计 API 未就绪');
- }
-
- normalizeRecord(record, options = {}) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.normalizeRecord === 'function') {
- return window.PracticeRecordAPI.normalizeRecord(record, options);
- }
- throw new Error('统一练习记录标准化 API 未就绪');
- }
- normalizeDateValue(value) {
- if (!value) {
- return null;
- }
-
- if (value instanceof Date && !Number.isNaN(value.getTime())) {
- return value.toISOString();
- }
-
- if (typeof value === 'number' && Number.isFinite(value)) {
- return new Date(value).toISOString();
- }
-
- if (typeof value === 'string') {
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
-
- if (/^\d+$/.test(trimmed)) {
- const numeric = Number(trimmed);
- if (Number.isFinite(numeric)) {
- const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000;
- return new Date(milliseconds).toISOString();
- }
- }
-
- const parsed = new Date(trimmed);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
-
- return null;
- }
-
- getRecordTimestamp(record) {
- if (!record) {
- return 0;
- }
-
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.timestamp,
- record.date
- ];
-
- for (const candidate of candidates) {
- const iso = this.normalizeDateValue(candidate);
- if (iso) {
- const time = new Date(iso).getTime();
- if (Number.isFinite(time)) {
- return time;
- }
- }
- }
-
- return 0;
- }
-
- filterByDateRange(records, dateRange) {
- const { startDate, endDate } = dateRange;
- return (records || []).filter(record => {
- const value = this.normalizeDateValue(record?.startTime ?? record?.createdAt ?? record?.timestamp);
- if (!value) {
- return false;
- }
-
- const recordDate = new Date(value);
- if (startDate && recordDate < new Date(startDate)) {
- return false;
- }
- if (endDate && recordDate > new Date(endDate)) {
- return false;
- }
- return true;
- });
- }
-
- compressData(data) {
- try {
- if (window.pako && typeof window.pako.gzip === 'function') {
- return window.pako.gzip(data, { to: 'string' });
- }
- } catch (error) {
- console.warn('[DataBackupManager] compression failed', error);
- }
- return data;
- }
- async createPreImportBackup() {
- try {
- // 与 createBackup 共用 unshift + pop 裁剪,避免 push+shift 误删最新用户备份
- return await this.createBackup(`pre_import_${Date.now()}`, 'pre_import');
- } catch (error) {
- console.error('[DataBackupManager] failed to create backup', error);
- return null;
- }
- }
-
- async restoreBackup(backupId) {
- if (!backupId) {
- throw new Error('Invalid backup id.');
- }
-
- try {
- if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') {
- const result = await window.BackupAPI.restore(backupId);
- return result.backup;
- }
-
- const backups = await storage.get(this.storageKeys.manualBackups, []);
- const backup = backups.find(item => item.id === backupId);
- if (!backup) {
- throw new Error(`Backup ${backupId} not found.`);
- }
-
- const data = backup.data || {};
- const records = Array.isArray(data.practice_records)
- ? data.practice_records
- : (Array.isArray(data.practiceRecords) ? data.practiceRecords : []);
- const stats = this.isPlainObject(data.user_stats)
- ? data.user_stats
- : (this.isPlainObject(data.userStats) ? data.userStats : null);
-
- await this.restorePracticeRecords(records, stats);
-
- const examIndex = Array.isArray(data.exam_index)
- ? data.exam_index
- : (Array.isArray(data.examIndex) ? data.examIndex : null);
- if (examIndex) {
- await storage.set('exam_index', examIndex);
- }
-
- return backup;
- } catch (error) {
- console.error('[DataBackupManager] backup restore failed', error);
- throw error;
- }
- }
-
- async clearData(options = {}) {
- const {
- clearPracticeRecords = false,
- clearUserStats = false,
- clearBackups = false,
- clearSettings = false,
- createBackup = true
- } = options;
-
- let backupId = null;
- if (createBackup) {
- backupId = await this.createPreImportBackup();
- }
-
- const clearedItems = [];
-
- if (clearPracticeRecords) {
- await this.replacePracticeRecords([], { updateStats: !clearUserStats });
- clearedItems.push('practice_records');
- if (!clearUserStats) {
- clearedItems.push('user_stats');
- }
- }
-
- if (clearUserStats) {
- await this.resetUserStats();
- clearedItems.push('user_stats');
- }
-
- if (clearBackups) {
- if (window.BackupAPI && typeof window.BackupAPI.clear === 'function') {
- await window.BackupAPI.clear();
- } else {
- await storage.set(this.storageKeys.manualBackups, []);
- }
- if (typeof storage.remove === 'function') {
- await storage.remove('backup_data');
- }
- clearedItems.push('backups');
- }
-
- if (clearSettings) {
- await storage.remove('settings');
- await storage.remove(this.storageKeys.backupSettings);
- clearedItems.push('settings');
- }
-
- return {
- success: true,
- clearedItems,
- backupId
- };
- }
-
- async recordExportHistory(info) {
- const history = await storage.get(this.storageKeys.exportHistory, []);
- history.push({ ...info, id: `export_${Date.now()}` });
- while (history.length > this.maxExportHistory) {
- history.shift();
- }
- await storage.set(this.storageKeys.exportHistory, history);
- }
-
- async recordImportHistory(info) {
- const history = await storage.get(this.storageKeys.importHistory, []);
- history.push({ ...info, id: `import_${Date.now()}` });
- while (history.length > this.maxExportHistory) {
- history.shift();
- }
- await storage.set(this.storageKeys.importHistory, history);
- }
-
- async getExportHistory() {
- const history = await storage.get(this.storageKeys.exportHistory, []);
- return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- }
-
- async getImportHistory() {
- const history = await storage.get(this.storageKeys.importHistory, []);
- return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- }
- async getDataStats() {
- try {
- const practiceRecords = await this.listPracticeRecords();
- const userStats = await this.readUserStats();
- const exportHistory = await this.getExportHistory();
- const importHistory = await this.getImportHistory();
- const storageInfo = typeof storage.getStorageInfo === 'function' ? await storage.getStorageInfo() : null;
-
- const recordsArray = Array.isArray(practiceRecords) ? practiceRecords : [];
-
- return {
- practiceRecords: {
- count: recordsArray.length,
- oldestRecord: recordsArray.length ? recordsArray[0]?.startTime : null,
- newestRecord: recordsArray.length ? recordsArray[recordsArray.length - 1]?.startTime : null
- },
- userStats: {
- totalPractices: userStats?.totalPractices ?? 0,
- totalTimeSpent: userStats?.totalTimeSpent ?? 0,
- averageScore: userStats?.averageScore ?? 0
- },
- exportHistory: {
- count: exportHistory.length,
- lastExport: exportHistory.length ? exportHistory[0].timestamp : null
- },
- importHistory: {
- count: importHistory.length,
- lastImport: importHistory.length ? importHistory[0].timestamp : null
- },
- storage: storageInfo
- };
- } catch (error) {
- console.error('[DataBackupManager] failed to collect stats', error);
- return null;
- }
- }
-
- setupPeriodicCleanup() {
- if (this.cleanupTimer) {
- clearInterval(this.cleanupTimer);
- }
-
- this.cleanupTimer = setInterval(() => {
- this.cleanupExpiredData().catch(error => console.error('[DataBackupManager] cleanup failed', error));
- }, 24 * 60 * 60 * 1000);
- }
-
- async cleanupExpiredData() {
- try {
- const limit = 30 * 24 * 60 * 60 * 1000;
- const now = Date.now();
-
- const exportHistory = await storage.get(this.storageKeys.exportHistory, []);
- const freshExports = exportHistory.filter(item => now - new Date(item.timestamp).getTime() < limit);
- if (freshExports.length !== exportHistory.length) {
- await storage.set(this.storageKeys.exportHistory, freshExports);
- }
-
- const importHistory = await storage.get(this.storageKeys.importHistory, []);
- const freshImports = importHistory.filter(item => now - new Date(item.timestamp).getTime() < limit);
- if (freshImports.length !== importHistory.length) {
- await storage.set(this.storageKeys.importHistory, freshImports);
- }
- } catch (error) {
- console.error('[DataBackupManager] cleanup error', error);
- }
- }
-
- getTimestamp() {
- return new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
- }
-
- toCamelCaseKey(key) {
- return String(key)
- .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase())
- .replace(/^[A-Z]/, match => match.toLowerCase());
- }
-
- isPlainObject(value) {
- return Object.prototype.toString.call(value) === '[object Object]';
- }
-}
-
-window.DataBackupManager = DataBackupManager;
-
-
-/* ===== bundle provided script markers ===== */
-(function markBundleProvided(global) {
- if (global.AppLazyLoader && typeof global.AppLazyLoader.markProvided === "function") {
- global.AppLazyLoader.markProvided([
- "js/components/DataIntegrityManager.js",
- "js/utils/dataBackupManager.js"
-]);
- }
-})(typeof window !== "undefined" ? window : this);
diff --git a/js/bundles/theme.bundle.js b/js/bundles/theme.bundle.js
index b914c8e4..7ae4f007 100644
--- a/js/bundles/theme.bundle.js
+++ b/js/bundles/theme.bundle.js
@@ -1,53 +1,36 @@
/* Generated by scripts/build-bundles.mjs. Do not edit by hand. */
/* ===== js/theme-switcher.js ===== */
-const THEME_PORTAL_STORAGE_KEY = 'preferred_theme_portal';
-const THEME_PORTAL_SESSION_SKIP_KEY = 'preferred_theme_skip_session';
-
-function safeParse(json) {
- if (!json) {
- return null;
- }
- try {
- const value = JSON.parse(json);
- return value && typeof value === 'object' ? value : null;
- } catch (error) {
- console.warn('[Theme] 无法解析主题首选项:', error);
- return null;
- }
-}
-
const themePreferenceController = {
- STORAGE_KEY: THEME_PORTAL_STORAGE_KEY,
- SESSION_KEY: THEME_PORTAL_SESSION_SKIP_KEY,
+ cache: null,
+ ready: null,
load() {
- try {
- return safeParse(localStorage.getItem(this.STORAGE_KEY));
- } catch (error) {
- console.warn('[Theme] 读取主题首选项失败:', error);
- return null;
+ return this.cache;
+ },
+
+ hydrate() {
+ if (!this.ready) {
+ this.ready = window.AppData.ready.then(() => window.AppData.preferences.getThemePortal()).then((value) => {
+ this.cache = value;
+ return value;
+ });
}
+ return this.ready;
},
- save(payload) {
+ async save(payload) {
if (!payload || typeof payload !== 'object') {
- this.clear();
- return;
- }
- try {
- localStorage.setItem(this.STORAGE_KEY, JSON.stringify(payload));
- } catch (error) {
- console.warn('[Theme] 保存主题首选项失败:', error);
+ return this.clear();
}
+ await window.AppData.preferences.setThemePortal(payload);
+ this.cache = payload;
+ return payload;
},
- clear() {
- try {
- localStorage.removeItem(this.STORAGE_KEY);
- } catch (_) {
- // no-op
- }
+ async clear() {
+ await window.AppData.preferences.setThemePortal(null);
+ this.cache = null;
},
recordInternalTheme(themeId = 'default') {
@@ -56,8 +39,8 @@ const themePreferenceController = {
theme: themeId,
updatedAt: Date.now()
};
- this.save(snapshot);
- return this.load();
+ this.save(snapshot).catch((error) => console.warn('[Theme] 保存主题首选项失败:', error));
+ return snapshot;
}
};
@@ -71,7 +54,7 @@ function applyTheme(theme) {
if (!theme) return;
try {
root.setAttribute('data-theme', theme);
- localStorage.setItem('theme', theme);
+ window.AppData.preferences.setTheme(theme).catch((error) => console.warn('[Theme] 保存主题失败:', error));
themePreferenceController.recordInternalTheme(theme);
} catch (e) {}
}
@@ -80,7 +63,7 @@ function applyDefaultTheme() {
const root = document.documentElement;
try {
root.removeAttribute('data-theme');
- localStorage.removeItem('theme');
+ window.AppData.preferences.setTheme('default').catch((error) => console.warn('[Theme] 保存主题失败:', error));
themePreferenceController.recordInternalTheme('default');
} catch (e) {}
}
@@ -148,7 +131,7 @@ function initializeThemeScrollerControls() {
syncThemeScrollerButtons();
}
-function initializeThemeSwitcher() {
+async function initializeThemeSwitcher() {
if (typeof window !== 'undefined' && window.__themeSwitcherInitialized) {
return;
}
@@ -158,10 +141,12 @@ function initializeThemeSwitcher() {
window.__syncThemeScrollerButtons = syncThemeScrollerButtons;
}
- // Restore general theme
try {
- const savedTheme = localStorage.getItem('theme');
- if (savedTheme) applyTheme(savedTheme);
+ await window.AppData.ready;
+ await themePreferenceController.hydrate();
+ const savedTheme = await window.AppData.preferences.getTheme();
+ if (savedTheme && savedTheme !== 'default') document.documentElement.setAttribute('data-theme', savedTheme);
+ else document.documentElement.removeAttribute('data-theme');
} catch (e) {}
// Close modal when clicking outside
diff --git a/js/bundles/ui-shell.bundle.js b/js/bundles/ui-shell.bundle.js
index 650a0d8e..1da88d56 100644
--- a/js/bundles/ui-shell.bundle.js
+++ b/js/bundles/ui-shell.bundle.js
@@ -1052,8 +1052,6 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
(function initPracticeTimerPreferences(global) {
'use strict';
- var READING_KEY = 'ielts_reading_timer_preferences_v2';
- var LISTENING_KEY = 'ielts_listening_timer_preferences_v1';
var VERSION = 1;
var DEFAULTS = {
version: VERSION,
@@ -1090,26 +1088,38 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
};
}
- function keyFor(scope) {
- return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY;
+ var cache = Object.create(null);
+ var hydrationPromise = null;
+ function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; }
+ function hydrateTimerPreferences() {
+ if (cache.reading && cache.listening) return Promise.resolve(true);
+ if (hydrationPromise) return hydrationPromise;
+ if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false);
+ hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getTimer();
+ cache.reading = normalize(stored && stored.reading);
+ cache.listening = normalize(stored && stored.listening);
+ return true;
+ }).catch(function onTimerPreferenceLoadError(error) {
+ hydrationPromise = null;
+ console.warn('[PracticeTimerPreferences] 加载失败:', error);
+ return false;
+ });
+ return hydrationPromise;
}
function read(scope) {
- try {
- var raw = global.localStorage && global.localStorage.getItem(keyFor(scope));
- return normalize(raw ? JSON.parse(raw) : null);
- } catch (_) {
- return normalize(null);
- }
+ return normalize(cache[normalizeScope(scope)]);
}
- function save(scope, preferences) {
+ async function save(scope, preferences) {
+ await hydrateTimerPreferences();
+ if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable');
+ var normalizedScope = normalizeScope(scope);
var next = normalize(preferences);
- try {
- if (global.localStorage) {
- global.localStorage.setItem(keyFor(scope), JSON.stringify(next));
- }
- } catch (_) { }
+ await global.AppData.preferences.setTimer(normalizedScope, next);
+ cache[normalizedScope] = next;
return next;
}
@@ -1117,17 +1127,16 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
return clampMinutes(value, DEFAULTS.countdownMinutes) * 60;
}
- global.PracticeTimerPreferences = {
+ var api = {
VERSION: VERSION,
- READING_KEY: READING_KEY,
- LISTENING_KEY: LISTENING_KEY,
DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)),
normalize: normalize,
read: read,
save: save,
- keyFor: keyFor,
minutesToSeconds: minutesToSeconds
};
+ Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences });
+ global.PracticeTimerPreferences = api;
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1455,8 +1464,9 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
var SESSION_GROUP = 'session-suite';
var STATE_CORE_GROUP = 'state-core';
var SETTINGS_GROUP = 'settings-tools';
- var READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1';
var READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/;
+ var readingCandidateCodeCache = { mode: 'auto', customCode: '' };
+ var readingCandidateCodeReady = null;
function ensureLazyGroup(name) {
if (!name || !global.AppLazyLoader || typeof global.AppLazyLoader.ensureGroup !== 'function') {
@@ -1494,34 +1504,32 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
}
function readReadingCandidateCodePreferences() {
- try {
- var raw = global.localStorage && global.localStorage.getItem(READING_CANDIDATE_CODE_PREF_KEY);
- var parsed = raw ? JSON.parse(raw) : null;
- var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto';
- var customCode = parsed && typeof parsed.customCode === 'string'
- ? parsed.customCode.replace(/\D/g, '').slice(0, 6)
- : '';
- return {
- mode: mode,
- customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : ''
- };
- } catch (_) {
- return { mode: 'auto', customCode: '' };
- }
+ return Object.assign({}, readingCandidateCodeCache);
+ }
+
+ function loadReadingCandidateCodePreferences() {
+ if (readingCandidateCodeReady) return readingCandidateCodeReady;
+ readingCandidateCodeReady = Promise.resolve().then(async function loadCandidateCode() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getCandidateCode();
+ var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto';
+ var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : '';
+ readingCandidateCodeCache = { mode: mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' };
+ return readingCandidateCodeCache;
+ });
+ return readingCandidateCodeReady;
}
- function saveReadingCandidateCodePreferences(preferences) {
+ async function saveReadingCandidateCodePreferences(preferences) {
+ await loadReadingCandidateCodePreferences();
var next = {
mode: preferences && preferences.mode === 'custom' ? 'custom' : 'auto',
customCode: preferences && typeof preferences.customCode === 'string'
? preferences.customCode.replace(/\D/g, '').slice(0, 6)
: ''
};
- try {
- if (global.localStorage) {
- global.localStorage.setItem(READING_CANDIDATE_CODE_PREF_KEY, JSON.stringify(next));
- }
- } catch (_) { }
+ await global.AppData.preferences.setCandidateCode(next);
+ readingCandidateCodeCache = next;
return next;
}
@@ -1537,7 +1545,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
}
}
- function setupReadingCandidateCodeSettings() {
+ async function setupReadingCandidateCodeSettings() {
+ await loadReadingCandidateCodePreferences();
var input = document.getElementById('reading-candidate-code-input');
var saveButton = document.getElementById('reading-candidate-code-save-btn');
var randomButton = document.getElementById('reading-candidate-code-random-btn');
@@ -1592,7 +1601,7 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
setReadingCandidateCodeStatus(status, '', '');
});
- saveButton.addEventListener('click', function saveCandidateCodeSettings() {
+ saveButton.addEventListener('click', async function saveCandidateCodeSettings() {
var mode = getSelectedMode();
var code = input.value.replace(/\D/g, '').slice(0, 6);
if (mode === 'custom' && !READING_CANDIDATE_CODE_PATTERN.test(code)) {
@@ -1600,7 +1609,7 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
input.focus();
return;
}
- saveReadingCandidateCodePreferences({ mode: mode, customCode: code });
+ await saveReadingCandidateCodePreferences({ mode: mode, customCode: code });
setReadingCandidateCodeStatus(
status,
mode === 'custom' ? '已保存自定义编码:' + code : '已保存:自动生成。',
@@ -1608,11 +1617,11 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
);
});
- randomButton.addEventListener('click', function generateCandidateCode() {
+ randomButton.addEventListener('click', async function generateCandidateCode() {
var code = hashReadingCandidateCode(createReadingCandidateCodeSeed());
setSelectedMode('custom');
input.value = code;
- saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code });
+ await saveReadingCandidateCodePreferences({ mode: 'custom', customCode: code });
setReadingCandidateCodeStatus(status, '已随机生成并保存:' + code, 'success');
});
@@ -1631,12 +1640,13 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
}
}
- function setupPracticeTimerSettings() {
+ async function setupPracticeTimerSettings() {
var manager = global.PracticeTimerPreferences;
if (!manager || typeof manager.read !== 'function' || typeof manager.save !== 'function') {
return;
}
+ if (manager.ready) await manager.ready;
Array.prototype.slice.call(document.querySelectorAll('.practice-timer-card[data-timer-scope]'))
.forEach(function bindTimerCard(card) {
var scope = String(card.dataset.timerScope || '').toLowerCase() === 'listening'
@@ -1692,10 +1702,14 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
setPracticeTimerStatus(status, '', '');
});
});
- saveButton.addEventListener('click', function saveTimerPreferences() {
- var saved = manager.save(scope, collect());
- apply(saved);
- setPracticeTimerStatus(status, '已保存', 'success');
+ saveButton.addEventListener('click', async function saveTimerPreferences() {
+ try {
+ var saved = await manager.save(scope, collect());
+ apply(saved);
+ setPracticeTimerStatus(status, '已保存', 'success');
+ } catch (error) {
+ setPracticeTimerStatus(status, '保存失败', 'error');
+ }
});
apply(manager.read(scope));
@@ -1791,20 +1805,6 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
return ensureLazyGroup(SETTINGS_GROUP);
}
- function setStorageNamespace() {
- if (!global.storage || !global.storage.ready || typeof global.storage.setNamespace !== 'function') {
- return;
- }
- global.storage.ready.then(function applyNamespace() {
- global.storage.setNamespace('exam_system');
- try {
- console.log('[MainEntry] 已设置存储命名空间: exam_system');
- } catch (_) { }
- }).catch(function handleNamespaceError(error) {
- console.error('[MainEntry] 设置命名空间失败', error);
- });
- }
-
function initializeNavigationShell() {
try {
if (global.NavigationController && typeof global.NavigationController.ensure === 'function') {
@@ -2042,35 +2042,29 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
return active.id.replace(/-view$/, '');
}
- function syncOverviewAfterIndexLoad() {
- if (!global.app || typeof global.app.setState !== 'function') {
- return;
- }
- if (typeof global.getExamIndexState !== 'function') {
- return;
- }
- var list = global.getExamIndexState();
+ function syncOverviewAfterIndexLoad(index) {
+ var list = Array.isArray(index) ? index : [];
if (!Array.isArray(list)) {
return;
}
try {
- global.app.setState('exam.index', list.slice());
- if (typeof global.app.refreshOverviewData === 'function') {
- global.app.refreshOverviewData();
+ if (typeof global.updateOverview === 'function') {
+ global.updateOverview(list);
}
} catch (error) {
console.warn('[MainEntry] 同步总览数据失败:', error);
}
}
- function handleExamIndexLoaded() {
- syncOverviewAfterIndexLoad();
+ function handleExamIndexLoaded(index) {
+ var snapshot = Array.isArray(index) ? index : [];
+ syncOverviewAfterIndexLoad(snapshot);
var activeView = getActiveViewName();
if (activeView === 'browse') {
ensureBrowseGroup().then(function afterBrowseReady() {
if (typeof global.loadExamList === 'function') {
- try { global.loadExamList(); } catch (_) { }
+ try { global.loadExamList(snapshot); } catch (_) { }
}
var loading = document.querySelector('#browse-view .loading');
if (loading) {
@@ -2084,8 +2078,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
if (activeView === 'practice') {
Promise.all([ensureBrowseGroup(), ensurePracticeSuiteGroup()]).then(function onPracticeReady() {
- if (typeof global.updatePracticeView === 'function') {
- try { global.updatePracticeView(); } catch (_) { }
+ if (typeof global.startPracticeRecordsSyncInBackground === 'function') {
+ global.startPracticeRecordsSyncInBackground('exam-index-loaded', { forceRender: true });
}
}).catch(function handlePracticeLoadError(error) {
console.error('[MainEntry] practice 视图模块加载失败:', error);
@@ -2093,8 +2087,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
}
}
- global.addEventListener('examIndexLoaded', function onExamIndexLoaded() {
- handleExamIndexLoaded();
+ global.addEventListener('examIndexLoaded', function onExamIndexLoaded(event) {
+ handleExamIndexLoaded(event && event.detail ? event.detail.index : []);
});
global.addEventListener('appCoreReady', function onAppCoreReady() {
@@ -2125,7 +2119,6 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
}
function init() {
- setStorageNamespace();
initializeNavigationShell();
setupReadingCandidateCodeSettings();
setupPracticeTimerSettings();
@@ -2191,7 +2184,8 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
var settingsPrefetchPromise = null;
var indexInteractionsInitialized = false;
var licenseModalInitialized = false;
- var LICENSE_STORAGE_KEY = 'hasSeenGplLicense';
+ var licenseModalInitializationPromise = null;
+ var licenseModalRenderToken = 0;
function ensureBrowse() {
if (browsePrefetched) {
@@ -2226,22 +2220,14 @@ console.log('[DOM] DOM工具库已加载,统一事件委托、DOM创建和样
}
function ensureSettings() {
- if (settingsPrefetched) {
- return (settingsPrefetchPromise || Promise.resolve()).then(function () {
- if (global.ExternalBackupService && typeof global.ExternalBackupService.refreshPanel === 'function') {
- try { global.ExternalBackupService.refreshPanel(); } catch (_) { /* ignore */ }
- }
- });
+ if (settingsPrefetched) {
+ return settingsPrefetchPromise || Promise.resolve();
}
settingsPrefetched = true;
var loader = global.AppEntry && typeof global.AppEntry.ensureSettingsToolsGroup === 'function'
? global.AppEntry.ensureSettingsToolsGroup
: function fallback() { return Promise.resolve(); };
- settingsPrefetchPromise = loader().then(function () {
- if (global.ExternalBackupService && typeof global.ExternalBackupService.refreshPanel === 'function') {
- try { global.ExternalBackupService.refreshPanel(); } catch (_) { /* ignore */ }
- }
- }).catch(function swallow(error) {
+ settingsPrefetchPromise = loader().catch(function swallow(error) {
settingsPrefetched = false;
settingsPrefetchPromise = null;
console.warn('[IndexInteractions] 预加载 settings 失败:', error);
@@ -2249,8 +2235,8 @@ function ensureSettings() {
return settingsPrefetchPromise;
}
- function startListeningSprint() {
- var list = typeof global.getExamIndexState === 'function' ? global.getExamIndexState() : [];
+ async function startListeningSprint() {
+ var list = await global.resolveActiveLibraryIndex();
var listeningExams = Array.isArray(list) ? list.filter(function (exam) { return exam && exam.type === 'listening'; }) : [];
if (!listeningExams.length) {
if (typeof global.showMessage === 'function') {
@@ -2269,8 +2255,8 @@ function ensureSettings() {
}
}
- function startInstantLaunch() {
- var list = typeof global.getExamIndexState === 'function' ? global.getExamIndexState() : [];
+ async function startInstantLaunch() {
+ var list = await global.resolveActiveLibraryIndex();
if (!Array.isArray(list) || !list.length) {
if (typeof global.showMessage === 'function') {
global.showMessage('题库尚未加载', 'error');
@@ -2435,6 +2421,13 @@ function ensureSettings() {
? global.OnboardingTour.start(true)
: undefined;
}],
+ ['external-backup-entry-btn', function () {
+ return ensureSettings().then(function () {
+ return global.ExternalBackupService && typeof global.ExternalBackupService.openModal === 'function'
+ ? global.ExternalBackupService.openModal()
+ : undefined;
+ });
+ }],
['create-backup-btn', function () {
return ensureSettings().then(function () {
return typeof global.createManualBackup === 'function' && global.createManualBackup();
@@ -2854,12 +2847,20 @@ function ensureSettings() {
return global.document ? global.document.getElementById('license-modal') : null;
}
- function hasAcceptedLicense() {
- try {
- return global.localStorage && global.localStorage.getItem(LICENSE_STORAGE_KEY) === 'true';
- } catch (_) {
- return true;
+ function getConsentPreferences() {
+ var preferences = global.AppData && global.AppData.preferences;
+ if (!preferences || typeof preferences.getConsent !== 'function' || typeof preferences.setConsent !== 'function') {
+ throw new Error('AppData preferences consent API is unavailable');
}
+ return preferences;
+ }
+
+ function hasAcceptedLicense() {
+ return Promise.resolve().then(function () {
+ return getConsentPreferences().getConsent();
+ }).then(function (consent) {
+ return !!(consent && consent.hasSeenGplLicense === true);
+ });
}
function showLicenseModal() {
@@ -2867,14 +2868,19 @@ function ensureSettings() {
if (!modal) {
return;
}
+ var renderToken = ++licenseModalRenderToken;
global.requestAnimationFrame(function () {
global.requestAnimationFrame(function () {
+ if (renderToken !== licenseModalRenderToken) {
+ return;
+ }
modal.classList.add('show');
});
});
}
function hideLicenseModal() {
+ licenseModalRenderToken += 1;
var modal = getLicenseModal();
if (modal) {
modal.classList.remove('show');
@@ -2882,24 +2888,37 @@ function ensureSettings() {
}
function acceptGplLicense() {
- try {
- if (global.localStorage) {
- global.localStorage.setItem(LICENSE_STORAGE_KEY, 'true');
- }
- } catch (error) {
- console.warn('LocalStorage error:', error);
- }
- hideLicenseModal();
+ var preferences;
+ return Promise.resolve().then(function () {
+ preferences = getConsentPreferences();
+ return preferences.getConsent();
+ }).then(function (consent) {
+ return preferences.setConsent(Object.assign({}, consent || {}, {
+ hasSeenGplLicense: true
+ }));
+ }).then(function () {
+ hideLicenseModal();
+ return true;
+ }).catch(function (error) {
+ console.error('[LicenseModal] Failed to save GPL license consent:', error);
+ return false;
+ });
}
function initLicenseModal() {
if (licenseModalInitialized) {
- return;
+ return licenseModalInitializationPromise || Promise.resolve();
}
licenseModalInitialized = true;
- if (!hasAcceptedLicense()) {
+ licenseModalInitializationPromise = hasAcceptedLicense().then(function (accepted) {
+ if (!accepted) {
+ showLicenseModal();
+ }
+ }).catch(function (error) {
+ console.error('[LicenseModal] Failed to load GPL license consent:', error);
showLicenseModal();
- }
+ });
+ return licenseModalInitializationPromise;
}
global.LicenseModal = Object.assign({}, global.LicenseModal || {}, {
diff --git a/js/components/BrowseStateManager.js b/js/components/BrowseStateManager.js
index 2c66ea3b..150614cb 100644
--- a/js/components/BrowseStateManager.js
+++ b/js/components/BrowseStateManager.js
@@ -43,15 +43,9 @@ class BrowseStateManager {
*/
initialize() {
console.log('[BrowseStateManager] 初始化浏览状态管理器');
-
- // 恢复保存的状态
- this.restorePersistentState();
-
// 设置事件监听器
this.setupEventListeners();
-
- // 初始化完成后通知订阅者
- this.notifySubscribers();
+ this.ready = this.restorePersistentState().finally(() => this.notifySubscribers());
}
/**
@@ -206,7 +200,7 @@ class BrowseStateManager {
/**
* 持久化状态
*/
- persistState() {
+ async persistState() {
try {
const dataToSave = {
currentFilter: this.currentFilter,
@@ -216,7 +210,7 @@ class BrowseStateManager {
timestamp: Date.now()
};
- localStorage.setItem('browse_state', JSON.stringify(dataToSave));
+ await window.AppData.preferences.patchBrowse({ stateManager: dataToSave });
console.log('[BrowseStateManager] 状态已持久化');
} catch (error) {
console.error('[BrowseStateManager] 持久化状态失败:', error);
@@ -226,11 +220,13 @@ class BrowseStateManager {
/**
* 恢复持久化的状态
*/
- restorePersistentState() {
+ async restorePersistentState() {
try {
- const savedData = localStorage.getItem('browse_state');
+ await window.AppData.ready;
+ const browse = await window.AppData.preferences.getBrowse();
+ const savedData = browse && browse.stateManager;
if (savedData) {
- const data = JSON.parse(savedData);
+ const data = savedData;
// 恢复基本状态
this.previousFilter = data.previousFilter || null;
@@ -557,4 +553,4 @@ class BrowseStateManager {
}
// 导出类
-window.BrowseStateManager = BrowseStateManager;
\ No newline at end of file
+window.BrowseStateManager = BrowseStateManager;
diff --git a/js/components/DataIntegrityManager.js b/js/components/DataIntegrityManager.js
deleted file mode 100644
index 949d142c..00000000
--- a/js/components/DataIntegrityManager.js
+++ /dev/null
@@ -1,637 +0,0 @@
-/**
- * 数据完整性管理器 (仓库驱动版)
- * 负责数据备份、验证、修复和导入导出功能
- * 基于统一的数据仓库接口执行原子操作
- */
-class DataIntegrityManager {
- constructor(options = {}) {
- this.backupInterval = 600000; // 10分钟自动备份
- this.maxBackups = 5; // 最多保留5个备份(减少占用)
- this.dataVersion = '0.6.2-fix';
- this.backupTimer = null;
- this.validationRules = new Map();
- this.repositories = null;
- this.consistencyReport = null;
- this.isInitialized = false;
- this.registry = options.registry || window.StorageProviderRegistry || null;
- this._unsubscribe = null;
-
- this.registerDefaultValidationRules();
- this.connectToProviders();
-
- console.log('[DataIntegrityManager] 数据完整性管理器已创建');
- }
-
- connectToProviders() {
- const registry = this.registry;
- if (registry && typeof registry.onProvidersReady === 'function') {
- this._unsubscribe = registry.onProvidersReady(({ repositories }) => {
- this.attachRepositories(repositories);
- });
- const current = registry.getCurrentProviders && registry.getCurrentProviders();
- if (current && current.repositories) {
- this.attachRepositories(current.repositories);
- }
- return;
- }
-
- if (window.dataRepositories) {
- this.attachRepositories(window.dataRepositories);
- return;
- }
-
- console.warn('[DataIntegrityManager] 未检测到数据仓库注册表,等待外部注入');
- }
-
- async attachRepositories(repositories) {
- if (!repositories) {
- return;
- }
- if (this.repositories === repositories && this.isInitialized) {
- return;
- }
-
- this.repositories = repositories;
- console.log('[DataIntegrityManager] 已绑定数据仓库接口');
-
- try {
- await this.initializeWithRepositories();
- } catch (error) {
- console.error('[DataIntegrityManager] 初始化失败:', error);
- this.startAutoBackup();
- this.isInitialized = true;
- }
- }
-
- async initializeWithRepositories() {
- try {
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
-
- try {
- this.consistencyReport = await this.repositories.runConsistencyChecks();
- console.log('[DataIntegrityManager] 初始一致性检查完成', this.consistencyReport);
- } catch (reportError) {
- console.warn('[DataIntegrityManager] 初始一致性检查失败:', reportError);
- }
-
- this.startAutoBackup();
- try { await this.cleanupOldBackups(); } catch (_) {}
-
- this.isInitialized = true;
- console.log('[DataIntegrityManager] 数据完整性管理器已初始化');
- } catch (error) {
- throw error;
- }
- }
-
- _ensureInitialized() {
- if (!this.isInitialized) {
- console.warn('[DataIntegrityManager] 尚未完全初始化,使用降级模式');
- }
- }
-
- async cleanupOldBackups() {
- try {
- if (!this.repositories) return;
- const backups = await this.repositories.backups.list();
- if (backups.length <= this.maxBackups) return;
- await this.repositories.backups.prune(this.maxBackups);
- console.log('[DataIntegrityManager] 已执行备份裁剪');
- } catch (error) {
- console.error('[DataIntegrityManager] 清理旧备份失败:', error);
- }
- }
-
- async createBackup(providedData, type = 'manual') {
- let data = null;
- try {
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
- data = providedData || await this.getCriticalData();
- if (Object.keys(data).length === 0) {
- throw new Error('无数据可备份');
- }
-
- // 统一经 BackupAPI(内部仍落 BackupRepository),保证 schema 与裁剪一致
- if (window.BackupAPI && typeof window.BackupAPI.create === 'function') {
- const backupId = await window.BackupAPI.create({
- type,
- data,
- version: this.dataVersion
- });
- const backupObj = await window.BackupAPI.getById(backupId);
- console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${backupId}`);
- return backupObj || { id: backupId, type, data, version: this.dataVersion };
- }
-
- const id = `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- const timestamp = new Date().toISOString();
- const backupObj = {
- id,
- timestamp,
- data,
- version: this.dataVersion,
- type,
- size: JSON.stringify(data).length
- };
- await this.repositories.backups.add(backupObj);
- console.log(`[DataIntegrityManager] ${type} 备份创建成功: ${id}`);
- return backupObj;
- } catch (error) {
- console.error('[DataIntegrityManager] 创建备份失败:', error);
- if (error.name === 'QuotaExceededError' && data) {
- this.exportDataAsFallback(data);
- }
- throw error;
- }
- }
-
- exportDataAsFallback(exportData) {
- try {
- const exportObj = {
- exportDate: new Date().toISOString(),
- version: this.dataVersion,
- data: exportData,
- note: 'Storage quota exceeded - manual backup'
- };
- const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `ielts-data-backup-quota-${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- console.log('[DataIntegrityManager] 配额溢出备份已下载');
- } catch (fallbackError) {
- console.error('[DataIntegrityManager] fallback 导出失败:', fallbackError);
- }
- }
-
- registerDefaultValidationRules() {
- this.validationRules.set('practice_records', {
- required: ['id', 'startTime'],
- types: {
- id: 'string',
- startTime: 'string',
- endTime: 'string',
- date: 'string',
- duration: 'number',
- examId: 'string',
- examTitle: 'string',
- scoreInfo: 'object'
- },
- validators: {
- startTime: (value) => !isNaN(new Date(value).getTime()),
- date: (value) => !value || !isNaN(new Date(value).getTime()),
- endTime: (value) => !value || !isNaN(new Date(value).getTime()),
- duration: (value) => typeof value === 'number' && value >= 0,
- id: (value) => typeof value === 'string' && value.length > 0
- }
- });
-
- this.validationRules.set('system_settings', {
- types: {
- theme: 'string',
- language: 'string',
- autoSave: 'boolean',
- notifications: 'boolean'
- }
- });
- }
-
- startAutoBackup() {
- if (this.backupTimer) {
- clearInterval(this.backupTimer);
- }
- this.backupTimer = setInterval(() => {
- this.performAutoBackup();
- }, this.backupInterval);
- console.log(`[DataIntegrityManager] 自动备份已启动 (${this.backupInterval / 1000}秒间隔)`);
- }
-
- stopAutoBackup() {
- if (this.backupTimer) {
- clearInterval(this.backupTimer);
- this.backupTimer = null;
- console.log('[DataIntegrityManager] 自动备份已停止');
- }
- }
-
- async performAutoBackup() {
- try {
- const criticalData = await this.getCriticalData();
- if (Object.keys(criticalData).length > 0) {
- await this.createBackup(criticalData, 'auto');
- console.log('[DataIntegrityManager] 自动备份完成');
- } else {
- console.log('[DataIntegrityManager] 无关键数据需要备份');
- }
- } catch (error) {
- console.error('[DataIntegrityManager] 自动备份失败:', error);
- }
- }
-
- async getBackupList() {
- try {
- if (!this.repositories) return [];
- const backups = await this.repositories.backups.list();
- return backups.map(b => ({
- id: b.id,
- timestamp: b.timestamp,
- type: b.type,
- version: b.version,
- size: b.size
- }));
- } catch (error) {
- console.error('[DataIntegrityManager] 获取备份列表失败:', error);
- return [];
- }
- }
-
- async restoreBackup(backupId) {
- let currentSnapshot = null;
- try {
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
-
- // 优先走 BackupAPI:统一还原 records/stats/exam_index/settings
- if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') {
- try {
- currentSnapshot = await this.getCriticalData();
- } catch (snapshotError) {
- console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError);
- }
- await window.BackupAPI.restore(backupId);
- console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功 (BackupAPI)`);
- return;
- }
-
- const backup = await this.repositories.backups.getById(backupId);
- if (!backup) {
- throw new Error('备份不存在');
- }
- try {
- currentSnapshot = await this.getCriticalData();
- } catch (snapshotError) {
- console.warn('[DataIntegrityManager] 恢复前快照采集失败:', snapshotError);
- }
- const data = backup.data || {};
- // 缺失 practice_records 时不清空现有记录(settings-only 备份恢复不应删练习数据)。
- // 仅当备份显式包含 practice_records 数组时才恢复。
- const records = Array.isArray(data.practice_records)
- ? data.practice_records
- : (Array.isArray(data.practiceRecords) ? data.practiceRecords : null);
- const stats = data.user_stats || data.userStats || null;
- if (records != null) {
- await this._restorePracticeRecords(records, stats);
- } else if (stats) {
- await this._writeUserStats(stats);
- }
-
- if (data.system_settings && typeof data.system_settings === 'object') {
- const currentSettings = await this.repositories.settings.getAll();
- const restoredSettings = { ...currentSettings, ...data.system_settings };
- await this.repositories.settings.saveAll(restoredSettings);
- }
- console.log(`[DataIntegrityManager] 备份 ${backupId} 恢复成功`);
- } catch (error) {
- console.error('[DataIntegrityManager] 恢复备份失败:', error);
- if (currentSnapshot) {
- try {
- await this._restoreFromBackup({ data: currentSnapshot });
- console.warn('[DataIntegrityManager] 恢复失败后已回滚到恢复前快照');
- } catch (restoreError) {
- console.error('[DataIntegrityManager] 恢复失败后的回滚也失败:', restoreError);
- }
- }
- throw error;
- }
- }
-
- async exportData() {
- try {
- const data = await this.getCriticalData();
- const exportObj = {
- exportDate: new Date().toISOString(),
- version: this.dataVersion,
- data
- };
- const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `ielts-data-backup-${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- console.log('[DataIntegrityManager] 数据导出成功');
- } catch (error) {
- console.error('[DataIntegrityManager] 导出数据失败:', error);
- throw error;
- }
- }
-
- async importData(source, options = {}) {
- this._ensureInitialized();
- if (!this.repositories) {
- throw new Error('数据仓库不可用');
- }
-
- let payload;
- let backup = null;
- try {
- payload = await this._normalizeImportPayload(source);
- } catch (error) {
- console.error('[DataIntegrityManager] 解析导入源失败:', error);
- throw new Error(error?.message || '导入文件格式无效');
- }
-
- const hasPracticeSection = Array.isArray(payload.practice_records);
- const hasSettingsSection = payload.system_settings && typeof payload.system_settings === 'object';
- const hasUserStatsSection = payload.user_stats && typeof payload.user_stats === 'object';
- const practiceRecords = hasPracticeSection ? this._preparePracticeRecords(payload.practice_records) : null;
- const systemSettings = hasSettingsSection ? this._prepareSystemSettings(payload.system_settings) : {};
- const userStats = hasUserStatsSection ? payload.user_stats : null;
-
- if (!hasPracticeSection && !hasSettingsSection && !hasUserStatsSection) {
- throw new Error('导入文件缺少可用的数据');
- }
-
- try {
- backup = await this.createBackup(null, 'pre_import');
- } catch (error) {
- console.warn('[DataIntegrityManager] 导入前创建备份失败:', error);
- }
-
- try {
- if (hasPracticeSection) {
- await this._restorePracticeRecords(practiceRecords || [], userStats);
- } else if (userStats) {
- await this._writeUserStats(userStats);
- }
-
- if (hasSettingsSection && Object.keys(systemSettings).length > 0) {
- const current = await this.repositories.settings.getAll();
- const next = { ...current, ...systemSettings };
- await this.repositories.settings.saveAll(next);
- }
- } catch (error) {
- console.error('[DataIntegrityManager] 导入数据失败:', error);
- // 导入已部分写入:尝试从 pre_import 备份恢复,避免半导入状态损坏数据。
- if (backup && backup.id) {
- try {
- await this._restoreFromBackup(backup);
- console.warn('[DataIntegrityManager] 导入失败后已从备份恢复:', backup.id);
- } catch (restoreError) {
- console.error('[DataIntegrityManager] 导入失败后恢复备份也失败:', restoreError);
- }
- }
- throw new Error(error?.message || '导入数据失败');
- }
-
- return {
- importedCount: practiceRecords ? practiceRecords.length : 0,
- backupId: backup?.id || null,
- version: payload.version || this.dataVersion
- };
- }
-
- async getCriticalData() {
- this._ensureInitialized();
- try {
- if (!this.repositories) {
- return {};
- }
- const data = {};
- try {
- const practiceRecords = await this._listPracticeRecords();
- // 读取失败时用 null 而非 [],区分"读取失败"与"确实无记录"。
- // rollback 时 null 表示不恢复 records,避免用空备份清空好数据。
- data.practice_records = practiceRecords != null ? practiceRecords : null;
- } catch (recordsError) {
- console.warn('[DataIntegrityManager] 获取练习记录失败:', recordsError);
- data.practice_records = null;
- }
-
- try {
- const allSettings = await this.repositories.settings.getAll();
- const systemSettings = {
- theme: allSettings.theme,
- language: allSettings.language,
- autoSave: allSettings.autoSave,
- notifications: allSettings.notifications
- };
- data.system_settings = systemSettings;
- } catch (settingsError) {
- console.warn('[DataIntegrityManager] 获取系统设置失败:', settingsError);
- data.system_settings = {};
- }
-
- try {
- const metaRepo = this.repositories.meta;
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- data.user_stats = await window.PracticeRecordAPI.readStats();
- }
- if (metaRepo && typeof metaRepo.get === 'function') {
- data.vocab_words = await metaRepo.get('vocab_words', []);
- data.vocab_user_config = await metaRepo.get('vocab_user_config', null);
- data.vocab_review_queue = await metaRepo.get('vocab_review_queue', []);
- data.vocab_list_reading_highlights = await metaRepo.get('vocab_list_reading_highlights', []);
- }
- } catch (vocabError) {
- console.warn('[DataIntegrityManager] 获取词汇数据失败:', vocabError);
- }
-
- return data;
- } catch (error) {
- console.error('[DataIntegrityManager] 获取关键数据失败:', error);
- return {};
- }
- }
-
- async _listPracticeRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- }
- if (this.repositories && this.repositories.practice && typeof this.repositories.practice.list === 'function') {
- const records = await this.repositories.practice.list();
- return Array.isArray(records) ? records : [];
- }
-
- return [];
- }
-
- async _restorePracticeRecords(records, userStats = null) {
- const finalRecords = Array.isArray(records) ? records : [];
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') {
- await window.PracticeRecordAPI.restoreRecords(finalRecords, {
- stats: userStats && typeof userStats === 'object' ? userStats : null,
- updateStats: true
- });
- return true;
- }
- if (this.repositories && this.repositories.practice && typeof this.repositories.practice.overwrite === 'function') {
- await this.repositories.practice.overwrite(finalRecords);
- return true;
- }
-
- throw new Error('统一练习记录恢复 API 未就绪');
- }
-
- async _writeUserStats(stats) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.writeStats === 'function') {
- await window.PracticeRecordAPI.writeStats(stats);
- return true;
- }
- if (this.repositories && this.repositories.meta && typeof this.repositories.meta.set === 'function') {
- await this.repositories.meta.set('user_stats', stats);
- return true;
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- // 导入失败时从 pre_import 备份恢复,避免数据停留在半导入状态。
- // 恢复失败仅记录,不掩盖原始导入错误。
- // 注意:practice_records 为 null/undefined 时不恢复 records(读取失败时的占位),
- // 只有非 null 的数组才视为有效备份进行恢复;空数组也需恢复(表示备份时确实无记录)。
- async _restoreFromBackup(backup) {
- if (!backup || !backup.data) {
- return false;
- }
- const snapshot = backup.data;
- const hasRecordsBackup = snapshot.practice_records != null;
- const restoredRecords = hasRecordsBackup && Array.isArray(snapshot.practice_records)
- ? this._preparePracticeRecords(snapshot.practice_records)
- : null;
- const restoredStats = snapshot.user_stats && typeof snapshot.user_stats === 'object'
- ? snapshot.user_stats
- : null;
- if (restoredRecords) {
- await this._restorePracticeRecords(restoredRecords, restoredStats);
- } else if (restoredStats) {
- await this._writeUserStats(restoredStats);
- }
- if (snapshot.system_settings && typeof snapshot.system_settings === 'object'
- && Object.keys(snapshot.system_settings).length > 0) {
- const current = await this.repositories.settings.getAll();
- const next = { ...current, ...snapshot.system_settings };
- await this.repositories.settings.saveAll(next);
- }
- return true;
- }
-
- async _normalizeImportPayload(source) {
- const raw = await this._resolveImportSource(source);
- const container = this._unwrapDataSection(raw);
- return {
- practice_records: this._extractField(container, ['practice_records', 'practiceRecords', 'practice']),
- system_settings: this._extractField(container, ['system_settings', 'systemSettings', 'settings']),
- user_stats: this._extractField(container, ['user_stats', 'userStats']),
- version: typeof raw?.version === 'string' ? raw.version : null
- };
- }
-
- async _resolveImportSource(source) {
- if (!source) {
- throw new Error('未提供导入数据源');
- }
- if (typeof source === 'string') {
- return JSON.parse(source);
- }
- if (typeof Blob !== 'undefined' && source instanceof Blob && typeof source.text === 'function') {
- const text = await source.text();
- return JSON.parse(text);
- }
- if (typeof File !== 'undefined' && source instanceof File) {
- const text = await source.text();
- return JSON.parse(text);
- }
- if (source instanceof ArrayBuffer) {
- const text = new TextDecoder('utf-8').decode(source);
- return JSON.parse(text);
- }
- if (typeof source === 'object') {
- return source;
- }
- throw new Error('不支持的导入数据类型');
- }
-
- _unwrapDataSection(raw) {
- if (!raw || typeof raw !== 'object') {
- throw new Error('导入文件格式无效');
- }
- if (raw.data && typeof raw.data === 'object') {
- return raw.data;
- }
- return raw;
- }
-
- _extractField(container, variants) {
- if (!container || typeof container !== 'object') {
- return undefined;
- }
- const lookup = this._buildKeyLookup(container);
- for (const variant of variants) {
- if (lookup.has(variant.toLowerCase())) {
- return lookup.get(variant.toLowerCase());
- }
- }
- return undefined;
- }
-
- _buildKeyLookup(container) {
- const map = new Map();
- Object.keys(container).forEach((key) => {
- map.set(key.toLowerCase(), container[key]);
- });
- return map;
- }
-
- _preparePracticeRecords(list) {
- if (!Array.isArray(list)) {
- return [];
- }
- return list.filter(entry => entry && typeof entry === 'object');
- }
-
- _prepareSystemSettings(settings) {
- if (!settings || typeof settings !== 'object') {
- return {};
- }
- const allowed = ['theme', 'language', 'autoSave', 'notifications'];
- const prepared = {};
- for (const key of allowed) {
- if (settings[key] !== undefined) {
- prepared[key] = settings[key];
- }
- }
- return prepared;
- }
-
-}
-
-let dataIntegrityManagerInstance = null;
-
-function getDataIntegrityManager() {
- if (!dataIntegrityManagerInstance) {
- dataIntegrityManagerInstance = new DataIntegrityManager();
- }
- return dataIntegrityManagerInstance;
-}
-
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = { DataIntegrityManager, getDataIntegrityManager };
-} else {
- window.DataIntegrityManager = DataIntegrityManager;
- window.getDataIntegrityManager = getDataIntegrityManager;
-}
diff --git a/js/components/PerformanceOptimizer.js b/js/components/PerformanceOptimizer.js
index 3af73344..a2292b8a 100644
--- a/js/components/PerformanceOptimizer.js
+++ b/js/components/PerformanceOptimizer.js
@@ -440,6 +440,7 @@ class PerformanceOptimizer {
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
+ timeout = null;
func(...args);
};
clearTimeout(timeout);
diff --git a/js/components/SystemDiagnostics.js b/js/components/SystemDiagnostics.js
index 8044e3ad..d6b88800 100644
--- a/js/components/SystemDiagnostics.js
+++ b/js/components/SystemDiagnostics.js
@@ -122,8 +122,11 @@ class SystemDiagnostics {
/**
* 测试单个题目的通信功能
*/
- async testExamCommunication(examId, timeout = 10000) {
- const exam = window.examIndex?.find(e => e.id === examId);
+ async testExamCommunication(examId, timeout = 10000, examIndex = null) {
+ const index = Array.isArray(examIndex)
+ ? examIndex
+ : await window.resolveActiveLibraryIndex();
+ const exam = index.find(e => e.id === examId);
if (!exam) {
return {
examId,
@@ -176,7 +179,10 @@ class SystemDiagnostics {
}
};
- examWindow.postMessage(testMessage, '*');
+ examWindow.postMessage(
+ testMessage,
+ window.location.protocol === 'file:' ? '*' : window.location.origin
+ );
// 等待响应
const result = await new Promise((resolve) => {
@@ -234,14 +240,17 @@ class SystemDiagnostics {
/**
* 批量测试通信功能
*/
- async testMultipleExams(examIds, concurrency = 3) {
+ async testMultipleExams(examIds, concurrency = 3, examIndex = null) {
console.log(`[SystemDiagnostics] 开始批量测试 ${examIds.length} 个题目的通信功能`);
+ const index = Array.isArray(examIndex)
+ ? examIndex
+ : await window.resolveActiveLibraryIndex();
const results = [];
for (let i = 0; i < examIds.length; i += concurrency) {
const batch = examIds.slice(i, i + concurrency);
const batchResults = await Promise.all(
- batch.map(examId => this.testExamCommunication(examId))
+ batch.map(examId => this.testExamCommunication(examId, 10000, index))
);
results.push(...batchResults);
}
@@ -295,7 +304,7 @@ class SystemDiagnostics {
connection.window.postMessage({
type: 'HEARTBEAT',
timestamp: Date.now()
- }, '*');
+ }, window.location.protocol === 'file:' ? '*' : window.location.origin);
}
} catch (error) {
this.handleConnectionLost(examId, 'connection_error');
@@ -523,7 +532,7 @@ class SystemDiagnostics {
async fullSystemDiagnostics() {
console.log('[SystemDiagnostics] 开始完整系统诊断...');
- const examIndex = window.examIndex || [];
+ const examIndex = await window.resolveActiveLibraryIndex();
const diagnosticReport = {
timestamp: Date.now(),
indexValidation: null,
@@ -540,7 +549,7 @@ class SystemDiagnostics {
// 如果有失败的题目,进行通信测试
if (diagnosticReport.indexValidation.failedExams.length > 0) {
const failedExamIds = diagnosticReport.indexValidation.failedExams.map(exam => exam.id);
- diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5)); // 限制测试数量
+ diagnosticReport.communicationTest = await this.testMultipleExams(failedExamIds.slice(0, 5), 3, examIndex); // 限制测试数量
}
} catch (error) {
console.error('[SystemDiagnostics] 索引验证失败:', error);
@@ -693,4 +702,4 @@ class SystemDiagnostics {
}
// 导出到全局
-window.SystemDiagnostics = SystemDiagnostics;
\ No newline at end of file
+window.SystemDiagnostics = SystemDiagnostics;
diff --git a/js/components/dataManagementPanel.js b/js/components/dataManagementPanel.js
deleted file mode 100644
index 0d598ca6..00000000
--- a/js/components/dataManagementPanel.js
+++ /dev/null
@@ -1,1106 +0,0 @@
-/**
- * 数据管理面板组件
- * 提供数据导入导出、备份恢复的用户界面
- */
-function createElement(tagName, options = {}) {
- const el = document.createElement(tagName);
- if (options.className) {
- el.className = options.className;
- }
- if (typeof options.text === 'string') {
- el.textContent = options.text;
- }
- if (options.attrs) {
- Object.keys(options.attrs).forEach((key) => {
- el.setAttribute(key, options.attrs[key]);
- });
- }
- if (options.dataset) {
- Object.keys(options.dataset).forEach((key) => {
- el.dataset[key] = options.dataset[key];
- });
- }
- return el;
-}
-
-class DataManagementPanel {
- constructor(container) {
- this.container = container;
- this.backupManager = new DataBackupManager();
- this.isVisible = false;
- this.selectedFileContent = null;
- this.pendingImportMode = null;
-
- this.initialize();
- }
-
- /**
- * 初始化组件
- */
- async initialize() {
- this.createPanelStructure();
- this.bindEvents();
- this.loadDataStats();
- await this.loadHistory();
-
- console.log('DataManagementPanel initialized');
- }
-
- /**
- * 创建面板结构
- */
- createPanelStructure() {
- const panel = createElement('div', { className: 'data-management-panel' });
- panel.appendChild(this.createHeader());
- panel.appendChild(this.createContent());
- panel.appendChild(this.createProgressOverlay());
- this.container.replaceChildren(panel);
- }
-
- createHeader() {
- const header = createElement('div', { className: 'panel-header' });
- const title = createElement('h3');
- const icon = createElement('i', { className: 'fas fa-database' });
- title.appendChild(icon);
- title.appendChild(document.createTextNode(' 数据管理'));
-
- const closeBtn = createElement('button', {
- className: 'close-btn',
- attrs: { 'data-action': 'close', type: 'button' }
- });
- closeBtn.appendChild(createElement('i', { className: 'fas fa-times' }));
-
- header.appendChild(title);
- header.appendChild(closeBtn);
- return header;
- }
-
- createContent() {
- const content = createElement('div', { className: 'panel-content' });
- content.append(
- this.createStatsSection(),
- this.createExportSection(),
- this.createImportSection(),
- this.createCleanupSection(),
- this.createHistorySection()
- );
- return content;
- }
-
- createStatsSection() {
- const section = createElement('div', { className: 'stats-section' });
- section.appendChild(createElement('h4', { text: '数据统计' }));
-
- const grid = createElement('div', { className: 'stats-grid' });
- const stats = [
- { label: '练习记录', id: 'recordCount' },
- { label: '总练习时间', id: 'totalTime' },
- { label: '平均分数', id: 'avgScore' },
- { label: '存储使用', id: 'storageUsage' }
- ];
-
- stats.forEach(({ label, id }) => {
- const item = createElement('div', { className: 'stat-item' });
- item.appendChild(createElement('span', { className: 'stat-label', text: label }));
- const value = createElement('span', { className: 'stat-value', text: '-' });
- value.id = id;
- item.appendChild(value);
- grid.appendChild(item);
- });
-
- section.appendChild(grid);
- return section;
- }
-
- createExportSection() {
- const section = createElement('div', { className: 'export-section' });
- section.appendChild(createElement('h4', { text: '数据导出' }));
-
- const options = createElement('div', { className: 'export-options' });
-
- options.appendChild(this.createSelectGroup('导出格式:', 'exportFormat', [
- { value: 'json', text: 'JSON格式' },
- { value: 'csv', text: 'CSV格式' }
- ]));
-
- options.appendChild(this.createCheckboxGroup({
- id: 'includeStats',
- label: '包含用户统计',
- checked: true
- }));
-
- options.appendChild(this.createCheckboxGroup({
- id: 'includeBackups',
- label: '包含备份数据'
- }));
-
- const dateRange = createElement('div', { className: 'date-range-group' });
- dateRange.appendChild(createElement('label', { text: '时间范围 (可选):' }));
- const dateInputs = createElement('div', { className: 'date-inputs' });
- dateInputs.appendChild(createElement('input', {
- attrs: { type: 'date', id: 'exportStartDate', placeholder: '开始日期' }
- }));
- dateInputs.appendChild(createElement('input', {
- attrs: { type: 'date', id: 'exportEndDate', placeholder: '结束日期' }
- }));
- dateRange.appendChild(dateInputs);
- options.appendChild(dateRange);
-
- const exportButton = createElement('button', {
- className: 'export-btn',
- attrs: { 'data-action': 'export', type: 'button' }
- });
- exportButton.appendChild(createElement('i', { className: 'fas fa-download' }));
- exportButton.appendChild(document.createTextNode(' 导出数据'));
- options.appendChild(exportButton);
-
- section.appendChild(options);
- return section;
- }
-
- createImportSection() {
- const section = createElement('div', { className: 'import-section' });
- section.appendChild(createElement('h4', { text: '数据导入' }));
-
- const options = createElement('div', { className: 'import-options' });
- const fileGroup = createElement('div', { className: 'file-input-group' });
-
- const fileInput = createElement('input', {
- attrs: {
- type: 'file',
- id: 'importFile',
- accept: '.json,.csv'
- }
- });
- fileInput.style.display = 'none';
-
- const fileButton = createElement('button', {
- className: 'file-select-btn',
- attrs: { 'data-action': 'selectFile', type: 'button' }
- });
- fileButton.appendChild(createElement('i', { className: 'fas fa-file-upload' }));
- fileButton.appendChild(document.createTextNode(' 选择文件'));
-
- const fileName = createElement('span', {
- className: 'file-name',
- text: '未选择文件'
- });
- fileName.id = 'selectedFileName';
-
- fileGroup.append(fileInput, fileButton, fileName);
- options.appendChild(fileGroup);
-
- options.appendChild(this.createSelectGroup('导入模式:', 'importMode', [
- { value: 'merge', text: '合并 (保留现有数据)' },
- { value: 'replace', text: '替换 (清空现有数据)' },
- { value: 'skip', text: '跳过 (仅导入新数据)' }
- ]));
-
- options.appendChild(this.createCheckboxGroup({
- id: 'createBackupBeforeImport',
- label: '导入前创建备份',
- checked: true
- }));
-
- const importButton = createElement('button', {
- className: 'import-btn',
- attrs: { 'data-action': 'import', type: 'button', disabled: 'disabled' }
- });
- importButton.appendChild(createElement('i', { className: 'fas fa-upload' }));
- importButton.appendChild(document.createTextNode(' 导入数据'));
- options.appendChild(importButton);
-
- section.appendChild(options);
- return section;
- }
-
- createCleanupSection() {
- const section = createElement('div', { className: 'cleanup-section' });
- section.appendChild(createElement('h4', { text: '数据清理' }));
-
- const options = createElement('div', { className: 'cleanup-options' });
- const warning = createElement('div', { className: 'warning-box' });
- warning.appendChild(createElement('i', { className: 'fas fa-exclamation-triangle' }));
- warning.appendChild(document.createTextNode(' 数据清理操作不可逆,请谨慎操作!'));
- options.appendChild(warning);
-
- const checkboxContainer = createElement('div', { className: 'cleanup-checkboxes' });
- [
- { id: 'clearRecords', label: '清理练习记录' },
- { id: 'clearStats', label: '清理用户统计' },
- { id: 'clearBackups', label: '清理备份数据' },
- { id: 'clearSettings', label: '清理系统设置' }
- ].forEach(({ id, label }) => {
- const wrapper = createElement('label');
- const checkbox = createElement('input', {
- attrs: { type: 'checkbox', id },
- className: 'cleanup-checkbox'
- });
- wrapper.appendChild(checkbox);
- wrapper.appendChild(document.createTextNode(` ${label}`));
- checkboxContainer.appendChild(wrapper);
- });
- options.appendChild(checkboxContainer);
-
- options.appendChild(this.createCheckboxGroup({
- id: 'createBackupBeforeClean',
- label: '清理前创建备份',
- checked: true
- }));
-
- const cleanupButton = createElement('button', {
- className: 'cleanup-btn danger',
- attrs: { 'data-action': 'cleanup', type: 'button' }
- });
- cleanupButton.appendChild(createElement('i', { className: 'fas fa-trash-alt' }));
- cleanupButton.appendChild(document.createTextNode(' 执行清理'));
- options.appendChild(cleanupButton);
-
- section.appendChild(options);
- return section;
- }
-
- createHistorySection() {
- const section = createElement('div', { className: 'history-section' });
- section.appendChild(createElement('h4', { text: '操作历史' }));
-
- const tabs = createElement('div', { className: 'history-tabs' });
- const exportTab = createElement('button', {
- className: 'tab-btn active',
- dataset: { tab: 'export' },
- attrs: { type: 'button' }
- });
- exportTab.textContent = '导出历史';
- const importTab = createElement('button', {
- className: 'tab-btn',
- dataset: { tab: 'import' },
- attrs: { type: 'button' }
- });
- importTab.textContent = '导入历史';
- tabs.append(exportTab, importTab);
-
- const content = createElement('div', { className: 'history-content' });
- const exportList = createElement('div', { className: 'history-list' });
- exportList.id = 'exportHistory';
- const importList = createElement('div', { className: 'history-list' });
- importList.id = 'importHistory';
- importList.style.display = 'none';
- content.append(exportList, importList);
-
- section.append(tabs, content);
- return section;
- }
-
- createProgressOverlay() {
- const overlay = createElement('div', {
- className: 'progress-overlay',
- attrs: { id: 'progressOverlay' }
- });
- overlay.style.display = 'none';
-
- const wrapper = createElement('div', { className: 'progress-content' });
- wrapper.appendChild(createElement('div', { className: 'spinner' }));
- const text = createElement('div', {
- className: 'progress-text',
- text: '处理中...'
- });
- text.id = 'progressText';
- wrapper.appendChild(text);
- overlay.appendChild(wrapper);
- return overlay;
- }
-
- createSelectGroup(labelText, selectId, options) {
- const group = createElement('div', { className: 'option-group' });
- const label = createElement('label', { text: labelText });
- const select = createElement('select', { attrs: { id: selectId } });
- options.forEach(({ value, text }) => {
- const option = createElement('option', { text });
- option.value = value;
- select.appendChild(option);
- });
- group.append(label, select);
- return group;
- }
-
- createCheckboxGroup({ id, label, checked }) {
- const group = createElement('div', { className: 'option-group' });
- const wrapper = createElement('label');
- const input = createElement('input', {
- attrs: { type: 'checkbox', id }
- });
- if (checked) {
- input.checked = true;
- }
- wrapper.appendChild(input);
- wrapper.appendChild(document.createTextNode(` ${label}`));
- group.appendChild(wrapper);
- return group;
- }
-
- /**
- * 绑定事件
- */
- bindEvents() {
- const panel = this.container.querySelector('.data-management-panel');
-
- // 使用统一的事件委托处理所有按钮
- panel.addEventListener('click', (e) => {
- const button = e.target.closest('[data-action]');
- if (!button) return;
-
- const action = button.dataset.action;
-
- switch (action) {
- case 'close':
- this.hide();
- break;
- case 'export':
- this.handleExport();
- break;
- case 'selectFile':
- panel.querySelector('#importFile').click();
- break;
- case 'import':
- this.beginImportFlow();
- break;
- case 'cleanup':
- this.handleCleanup();
- break;
- }
- });
-
- // 文件选择change事件仍然需要单独绑定
- panel.querySelector('#importFile').addEventListener('change', (e) => {
- this.handleFileSelect(e);
- });
- console.log('[DataManagementPanel] 使用统一事件委托处理按钮');
-
- // 历史标签切换 - 使用事件委托
- panel.addEventListener('click', (e) => {
- const tabBtn = e.target.closest('.tab-btn');
- if (tabBtn) {
- this.switchHistoryTab(tabBtn.dataset.tab);
- }
- });
-
- // 清理选项变化监听
- panel.addEventListener('change', (e) => {
- if (e.target.classList && e.target.classList.contains('cleanup-checkbox') && e.target.type === 'checkbox') {
- this.updateCleanupButton();
- }
- });
-
- this.updateCleanupButton();
-
- // 直接把设置页的“导入数据”按钮也绑到本面板,绕过全局 importData 覆盖混乱
- const settingsImportBtn = document.getElementById('import-data-btn');
- if (settingsImportBtn) {
- settingsImportBtn.addEventListener('click', (event) => {
- event.preventDefault();
- this.show();
- this.beginImportFlow({ forceModePicker: true });
- });
- }
- }
-
- hasImportSource() {
- if (this.selectedFileContent != null) {
- return true;
- }
- const fileInput = document.getElementById('importFile');
- return Boolean(fileInput && fileInput.files && fileInput.files[0]);
- }
-
- resolveImportMode(selectedMode = null) {
- const modeSelect = document.getElementById('importMode');
- return selectedMode || this.pendingImportMode || (modeSelect ? modeSelect.value : null) || null;
- }
-
- /**
- * 统一导入入口:先确保模式,再在有文件时真正执行导入。
- * 旧实现点击「导入数据」只会打开模式弹窗,永远不调用 handleImport。
- */
- beginImportFlow(options = {}) {
- const forceModePicker = options.forceModePicker === true;
- const mode = this.resolveImportMode();
-
- if (forceModePicker || !mode) {
- this.showImportModeModal({ autoImportWhenReady: true });
- return;
- }
-
- if (!this.hasImportSource()) {
- this.showMessage('请先选择要导入的文件', 'warning');
- return;
- }
-
- this.handleImport(mode);
- }
-
- showImportModeModal(options = {}) {
- const autoImportWhenReady = options.autoImportWhenReady !== false;
- this.pendingImportMode = null;
- if (!this.importModeModal) {
- const overlay = createElement('div', { className: 'import-mode-overlay' });
- Object.assign(overlay.style, {
- position: 'fixed',
- inset: '0',
- background: 'rgba(15,23,42,0.45)',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- padding: '16px',
- boxSizing: 'border-box',
- zIndex: '9999'
- });
-
- const modal = createElement('div', { className: 'import-mode-modal' });
- Object.assign(modal.style, {
- position: 'relative'
- });
-
- const closeBtn = createElement('button', { className: 'close-btn', text: '×' });
- closeBtn.addEventListener('click', () => this.hideImportModeModal());
- modal.appendChild(closeBtn);
-
- const title = createElement('h4', { text: '选择导入模式' });
- title.style.marginTop = '0';
- modal.appendChild(title);
-
- const desc = createElement('p', { text: '选择导入策略。若已选择文件,确认后将立即开始导入。' });
- modal.appendChild(desc);
-
- const options = createElement('div', { className: 'import-mode-options' });
- const defs = [
- {
- mode: 'merge',
- icon: '➕',
- title: '增量导入',
- description: '保留现有记录,仅合并新增或较新记录。'
- },
- {
- mode: 'replace',
- icon: '⚠️',
- title: '覆盖导入',
- description: '彻底替换现有记录,谨慎操作。'
- }
- ];
- defs.forEach((def) => {
- const card = createElement('div', { className: 'import-mode-option' });
- const icon = createElement('div', { className: 'mode-icon', text: def.icon });
- const titleEl = createElement('h5', { text: def.title });
- const text = createElement('p', { text: def.description });
- const button = createElement('button', { className: 'mode-select-btn', text: '选择' });
- button.addEventListener('click', () => {
- this.pendingImportMode = def.mode;
- const select = document.getElementById('importMode');
- if (select) {
- select.value = def.mode;
- }
- this.hideImportModeModal();
-
- if (autoImportWhenReady && this.hasImportSource()) {
- this.handleImport(def.mode);
- return;
- }
-
- this.showMessage(`已选择“${def.title}”,请选择文件后再次点击导入。`, 'info');
- });
- card.append(icon, titleEl, text, button);
- options.appendChild(card);
- });
- modal.appendChild(options);
-
- const actions = createElement('div', { className: 'import-mode-actions' });
- const cancelBtn = createElement('button', { className: 'btn-cancel', text: '关闭' });
- cancelBtn.addEventListener('click', () => this.hideImportModeModal());
- actions.append(cancelBtn);
- modal.appendChild(actions);
-
- overlay.appendChild(modal);
- overlay.addEventListener('click', (e) => {
- if (e.target === overlay) {
- this.hideImportModeModal();
- }
- });
-
- document.body.appendChild(overlay);
- this.importModeModal = overlay;
- }
-
- this.importModeModal.style.display = 'flex';
- }
-
- hideImportModeModal() {
- if (this.importModeModal) {
- this.importModeModal.style.display = 'none';
- }
- }
-
- /**
- * 显示面板
- */
- async show() {
- this.container.style.display = 'block';
- this.isVisible = true;
- await this.loadDataStats();
- await this.loadHistory();
- }
-
- /**
- * 隐藏面板
- */
- hide() {
- this.container.style.display = 'none';
- this.isVisible = false;
- }
-
- /**
- * 加载数据统计
- */
- async loadDataStats() {
- try {
- const stats = await this.backupManager.getDataStats();
-
- if (stats) {
- document.getElementById('recordCount').textContent = stats.practiceRecords.count;
- document.getElementById('totalTime').textContent = this.formatTime(stats.userStats.totalTimeSpent);
- document.getElementById('avgScore').textContent = Math.round(stats.userStats.averageScore * 100) + '%';
-
- if (stats.storage) {
- const usageKB = Math.round(stats.storage.used / 1024);
- document.getElementById('storageUsage').textContent = `${usageKB} KB`;
- }
- }
- } catch (error) {
- console.error('Failed to load data stats:', error);
- }
- }
-
- /**
- * 处理数据导出
- */
- async handleExport() {
- try {
- this.showProgress('准备导出数据...');
-
- const format = document.getElementById('exportFormat').value;
- const includeStats = document.getElementById('includeStats').checked;
- const includeBackups = document.getElementById('includeBackups').checked;
-
- const startDate = document.getElementById('exportStartDate').value;
- const endDate = document.getElementById('exportEndDate').value;
-
- const options = {
- format,
- includeStats,
- includeBackups
- };
-
- if (startDate || endDate) {
- options.dateRange = { startDate, endDate };
- }
-
- const exportResult = await this.backupManager.exportPracticeRecords(options);
-
- // 下载文件
- this.downloadFile(exportResult.data, exportResult.filename, exportResult.mimeType);
-
- this.hideProgress();
- this.showMessage('数据导出成功!', 'success');
- this.loadHistory();
-
- } catch (error) {
- this.hideProgress();
- this.showMessage(`导出失败: ${error.message}`, 'error');
- }
- }
-
- /**
- * 处理文件选择
- */
- handleFileSelect(event) {
- console.log('[DataManagementPanel] handleFileSelect called');
- const file = event.target.files[0];
- const fileNameSpan = document.getElementById('selectedFileName');
- const importBtn = this.container
- ? this.container.querySelector('[data-action="import"]')
- : document.querySelector('[data-action="import"]');
-
- if (file) {
- fileNameSpan.textContent = file.name;
- if (importBtn) {
- importBtn.disabled = false;
- }
-
- // 异步读取文件内容
- this.readFile(file).then(content => {
- try {
- this.selectedFileContent = JSON.parse(content);
- } catch (_) {
- this.selectedFileContent = content;
- }
- console.log('[DataManagementPanel] File content loaded');
-
- // 若模式已通过弹窗选定,选完文件后可直接导入
- if (this.pendingImportMode) {
- this.handleImport(this.pendingImportMode);
- }
- }).catch(error => {
- console.error('[DataManagementPanel] Failed to read file:', error);
- this.showMessage('文件读取失败', 'error');
- });
- } else {
- fileNameSpan.textContent = '未选择文件';
- if (importBtn) {
- importBtn.disabled = true;
- }
- this.selectedFileContent = null;
- }
- }
-
- /**
- * 处理数据导入
- */
- async handleImport(selectedMode = null) {
- console.log('[DataManagementPanel] handleImport called');
- try {
- let fileContent;
- const fileInput = document.getElementById('importFile');
- const file = fileInput && fileInput.files ? fileInput.files[0] : null;
-
- if (this.selectedFileContent != null) {
- console.log('[DataManagementPanel] using cached file content');
- fileContent = this.selectedFileContent;
- } else if (file) {
- // 添加文件大小检查
- if (file.size > 5 * 1024 * 1024) {
- this.showMessage('文件过大 (>5MB),请分批导入或使用小文件测试。');
- return;
- }
- this.showProgress('读取文件...');
- // 直接使用FileReader添加详细日志
- fileContent = await new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onerror = (e) => {
- console.error('[DataManagementPanel] File read error:', e);
- reject(new Error('文件读取失败'));
- };
- reader.onload = (e) => {
- console.log('[DataManagementPanel] File loaded, size:', file.size);
- try {
- const data = JSON.parse(e.target.result);
- console.log('[DataManagementPanel] JSON parsed, type:', Array.isArray(data) ? 'array' : typeof data, 'length:', data.length || data.practiceRecords?.length || data.practice_records?.length || data.data?.practice_records?.length);
- resolve(data);
- } catch (err) {
- console.error('[DataManagementPanel] JSON parse error:', err);
- reject(err);
- }
- };
- reader.readAsText(file);
- });
- } else {
- this.showMessage('请先选择要导入的文件', 'warning');
- return;
- }
-
- this.updateProgress('验证数据格式...');
-
- const resolvedMode = this.resolveImportMode(selectedMode);
- if (!resolvedMode) {
- this.hideProgress();
- this.showImportModeModal({ autoImportWhenReady: true });
- return;
- }
-
- const createBackupEl = document.getElementById('createBackupBeforeImport');
- const createBackup = createBackupEl ? createBackupEl.checked : true;
-
- const options = {
- mergeMode: resolvedMode,
- createBackup: createBackup,
- validateData: true
- };
-
- this.updateProgress('导入数据...');
-
- const result = await this.backupManager.importPracticeData(fileContent, options);
- console.log('[DataManagementPanel] importPracticeData returned:', result);
-
- this.hideProgress();
-
- if (result.success) {
- console.log('[DataManagementPanel] Import successful');
- console.log(
- 'Import completed: importedCount=',
- result.importedCount,
- 'total=',
- result.recordCount || result.finalCount || result.importedCount || 0
- );
- this.showMessage(
- `导入成功!导入 ${result.importedCount || result.recordCount || 0} 条记录,跳过 ${result.skippedCount || 0} 条重复记录。`,
- 'success'
- );
- this.loadDataStats();
- this.loadHistory();
-
- // 清空文件选择
- if (fileInput) {
- fileInput.value = '';
- }
- const fileNameEl = document.getElementById('selectedFileName');
- if (fileNameEl) {
- fileNameEl.textContent = '未选择文件';
- }
- const importBtn = this.container
- ? this.container.querySelector('[data-action="import"]')
- : document.querySelector('[data-action="import"]');
- if (importBtn) {
- importBtn.disabled = true;
- }
- this.selectedFileContent = null;
- this.pendingImportMode = null;
- }
-
- } catch (error) {
- this.hideProgress();
- console.error('[DataManagementPanel] Import failed:', error);
- this.showMessage(`导入失败: ${error.message}`, 'error');
- }
- }
-
- /**
- * 处理数据清理
- */
- async handleCleanup() {
- const clearRecords = document.getElementById('clearRecords').checked;
- const clearStats = document.getElementById('clearStats').checked;
- const clearBackups = document.getElementById('clearBackups').checked;
- const clearSettings = document.getElementById('clearSettings').checked;
- const createBackup = document.getElementById('createBackupBeforeClean').checked;
-
- if (!clearRecords && !clearStats && !clearBackups && !clearSettings) {
- this.showMessage('请选择要清理的数据类型', 'warning');
- return;
- }
-
- // 确认对话框
- const confirmMessage = `确定要清理以下数据吗?\n${
- [
- clearRecords && '• 练习记录',
- clearStats && '• 用户统计',
- clearBackups && '• 备份数据',
- clearSettings && '• 系统设置'
- ].filter(Boolean).join('\n')
- }\n\n此操作不可撤销!`;
-
- if (!confirm(confirmMessage)) {
- return;
- }
-
- try {
- this.showProgress('清理数据...');
-
- const options = {
- clearPracticeRecords: clearRecords,
- clearUserStats: clearStats,
- clearBackups: clearBackups,
- clearSettings: clearSettings,
- createBackup: createBackup
- };
-
- const result = await this.backupManager.clearData(options);
-
- this.hideProgress();
-
- if (result.success) {
- this.showMessage(
- `数据清理完成!已清理: ${result.clearedItems.join(', ')}`,
- 'success'
- );
- this.loadDataStats();
- this.loadHistory();
-
- // 重置清理选项
- document.querySelectorAll('.cleanup-checkboxes input[type="checkbox"]').forEach(cb => {
- cb.checked = false;
- });
- this.updateCleanupButton();
- }
-
- } catch (error) {
- this.hideProgress();
- this.showMessage(`清理失败: ${error.message}`, 'error');
- }
- }
-
- /**
- * 切换历史标签
- */
- switchHistoryTab(tab) {
- // 更新标签状态
- document.querySelectorAll('.tab-btn').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.tab === tab);
- });
-
- // 显示对应内容
- document.getElementById('exportHistory').style.display = tab === 'export' ? 'block' : 'none';
- document.getElementById('importHistory').style.display = tab === 'import' ? 'block' : 'none';
- }
-
- /**
- * 加载操作历史
- */
- async loadHistory() {
- await Promise.all([
- this.loadExportHistory(),
- this.loadImportHistory()
- ]);
- }
-
- /**
- * 加载导出历史
- */
- async loadExportHistory() {
- const container = document.getElementById('exportHistory');
- if (!container) {
- return;
- }
-
- try {
- const exportHistory = await this.backupManager.getExportHistory();
- const historyItems = Array.isArray(exportHistory) ? exportHistory : [];
-
- if (!historyItems.length) {
- this.renderNoHistory(container, '暂无导出记录');
- return;
- }
-
- const fragment = document.createDocumentFragment();
- historyItems.forEach((item) => {
- fragment.appendChild(this.createHistoryItem({
- icon: 'fas fa-download',
- title: `${item.format?.toUpperCase() || 'JSON'} 导出`,
- details: [
- `记录数: ${item.recordCount ?? 0}`,
- `时间: ${this.formatDateTime(item.timestamp)}`
- ]
- }));
- });
-
- container.replaceChildren(fragment);
- } catch (error) {
- console.error('[DataManagementPanel] Failed to load export history:', error);
- this.renderNoHistory(container, '导出历史加载失败');
- }
- }
-
- /**
- * 加载导入历史
- */
- async loadImportHistory() {
- const container = document.getElementById('importHistory');
- if (!container) {
- return;
- }
-
- try {
- const importHistory = await this.backupManager.getImportHistory();
- const historyItems = Array.isArray(importHistory) ? importHistory : [];
-
- if (!historyItems.length) {
- this.renderNoHistory(container, '暂无导入记录');
- return;
- }
-
- const fragment = document.createDocumentFragment();
- historyItems.forEach((item) => {
- fragment.appendChild(this.createHistoryItem({
- icon: 'fas fa-upload',
- title: '导入操作',
- details: [
- `新增记录: ${item.recordCount ?? item.importedCount ?? 0}`,
- `合并模式: ${item.mergeMode || 'merge'}`,
- `时间: ${this.formatDateTime(item.timestamp)}`
- ]
- }));
- });
-
- container.replaceChildren(fragment);
- } catch (error) {
- console.error('[DataManagementPanel] Failed to load import history:', error);
- this.renderNoHistory(container, '导入历史加载失败');
- }
- }
-
- renderNoHistory(container, message) {
- const empty = createElement('div', { className: 'no-history', text: message });
- container.replaceChildren(empty);
- }
-
- createHistoryItem({ icon, title, details }) {
- const item = createElement('div', { className: 'history-item' });
- const info = createElement('div', { className: 'history-info' });
- const titleEl = createElement('div', { className: 'history-title' });
- titleEl.appendChild(createElement('i', { className: icon }));
- titleEl.appendChild(document.createTextNode(` ${title}`));
-
- const detailsEl = createElement('div', { className: 'history-details' });
- details.forEach((detail) => {
- detailsEl.appendChild(createElement('span', { text: detail }));
- });
-
- info.append(titleEl, detailsEl);
- item.appendChild(info);
- return item;
- }
-
- /**
- * 更新清理按钮状态
- */
- updateCleanupButton() {
- const checkboxes = document.querySelectorAll('.cleanup-checkboxes input[type="checkbox"]');
- const cleanupBtn = document.querySelector('[data-action="cleanup"]');
-
- const hasSelection = Array.from(checkboxes).some(cb => cb.checked);
- cleanupBtn.disabled = !hasSelection;
- }
-
- /**
- * 显示进度
- */
- showProgress(text) {
- const overlay = document.getElementById('progressOverlay');
- const progressText = document.getElementById('progressText');
-
- progressText.textContent = text;
- overlay.style.display = 'flex';
- }
-
- /**
- * 更新进度文本
- */
- updateProgress(text) {
- const progressText = document.getElementById('progressText');
- progressText.textContent = text;
- }
-
- /**
- * 隐藏进度
- */
- hideProgress() {
- const overlay = document.getElementById('progressOverlay');
- overlay.style.display = 'none';
- }
-
- /**
- * 显示消息
- */
- showMessage(message, type = 'info') {
- // 创建消息元素
- const messageEl = createElement('div', { className: `message-toast ${type}` });
- const icon = createElement('i', { className: `fas fa-${this.getMessageIcon(type)}` });
- const text = createElement('span', { text: message });
- messageEl.append(icon, text);
-
- // 添加到页面
- document.body.appendChild(messageEl);
-
- // 自动移除
- setTimeout(() => {
- messageEl.remove();
- }, 5000);
- }
-
- /**
- * 获取消息图标
- */
- getMessageIcon(type) {
- const icons = {
- success: 'check-circle',
- error: 'exclamation-circle',
- warning: 'exclamation-triangle',
- info: 'info-circle'
- };
- return icons[type] || 'info-circle';
- }
-
- /**
- * 读取文件内容
- */
- readFile(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
-
- reader.onload = (e) => {
- resolve(e.target.result);
- };
-
- reader.onerror = () => {
- reject(new Error('文件读取失败'));
- };
-
- reader.readAsText(file);
- });
- }
-
- /**
- * 下载文件
- */
- downloadFile(content, filename, mimeType) {
- // 对于文本类型的内容,添加UTF-8编码支持
- const isTextType = mimeType.includes('text/') ||
- mimeType.includes('application/json') ||
- mimeType.includes('application/javascript') ||
- mimeType.includes('application/xml');
-
- const blobOptions = isTextType ? { type: mimeType + '; charset=utf-8' } : { type: mimeType };
- const blob = new Blob([content], blobOptions);
- const url = URL.createObjectURL(blob);
-
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- a.style.display = 'none';
-
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-
- URL.revokeObjectURL(url);
- }
-
- /**
- * 格式化时间
- */
- formatTime(seconds) {
- if (!seconds) return '0分钟';
-
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
-
- if (hours > 0) {
- return `${hours}小时${minutes}分钟`;
- } else {
- return `${minutes}分钟`;
- }
- }
-
- /**
- * 格式化日期时间
- */
- formatDateTime(dateString) {
- const date = new Date(dateString);
- return date.toLocaleString('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- });
- }
-}
-
-// 确保全局可用
-window.DataManagementPanel = DataManagementPanel;
diff --git a/js/components/goalSettingsPanel.js b/js/components/goalSettingsPanel.js
deleted file mode 100644
index bb697ad1..00000000
--- a/js/components/goalSettingsPanel.js
+++ /dev/null
@@ -1,253 +0,0 @@
-(function (window) {
- 'use strict';
-
- var TYPE_LABELS = {
- practice_count: '练习次数',
- study_time: '学习时长 (分钟)',
- accuracy: '正确率 (%)'
- };
-
- var PERIOD_LABELS = {
- daily: '每日',
- weekly: '每周',
- monthly: '每月'
- };
-
- var TYPE_ICONS = {
- practice_count: '📝',
- study_time: '⏱️',
- accuracy: '🎯'
- };
-
- function GoalSettingsPanel(options) {
- this.container = options.container || null;
- this.goalManager = options.goalManager || null;
- this.dom = options.domBuilder || (window.DOM && window.DOM.builder);
- this.events = options.events || (window.DOM && window.DOM.events);
- this._boundRender = this._onGoalUpdate.bind(this);
- }
-
- GoalSettingsPanel.prototype.init = function () {
- if (this.goalManager) {
- this.goalManager.on('goalUpdated', this._boundRender);
- }
- };
-
- GoalSettingsPanel.prototype.destroy = function () {
- if (this.goalManager) {
- this.goalManager.off('goalUpdated', this._boundRender);
- }
- };
-
- GoalSettingsPanel.prototype._onGoalUpdate = function () {
- this.render();
- };
-
- GoalSettingsPanel.prototype.render = function () {
- var container = this.container;
- if (!container) return;
-
- if (!this.goalManager || !this.goalManager.ready) {
- container.innerHTML = '
加载中...
';
- return;
- }
-
- var goals = this.goalManager.getGoals();
- var allProgress = this.goalManager.getAllProgress();
- var streak = this.goalManager.getStreak();
-
- var html = '';
-
- // Streak display
- html += '
';
- html += '🔥 ';
- html += '连续学习 ' + streak.current + ' 天 ';
- if (streak.best > 0) {
- html += '最佳 ' + streak.best + ' 天 ';
- }
- html += '
';
-
- // Goal list
- if (allProgress.length > 0) {
- html += '
';
- for (var i = 0; i < allProgress.length; i++) {
- html += this._renderGoalCard(allProgress[i]);
- }
- html += '
';
- } else {
- html += '
暂无学习目标,点击下方按钮创建
';
- }
-
- // Add button
- html += '
';
- html += '+ 添加目标 ';
- html += '
';
-
- container.innerHTML = html;
- this._bindActions(container);
- };
-
- GoalSettingsPanel.prototype._renderGoalCard = function (progress) {
- var goal = progress.goal;
- var icon = TYPE_ICONS[goal.type] || '📌';
- var typeLabel = TYPE_LABELS[goal.type] || goal.type;
- var periodLabel = PERIOD_LABELS[goal.period] || goal.period;
- var percent = progress.percent;
- var completed = progress.completed;
- var display = goal.type === 'accuracy'
- ? Math.round(progress.current * 100) + '%'
- : String(progress.current);
-
- var cls = 'goal-card' + (completed ? ' goal-card-completed' : '');
- var html = '
';
- html += '';
- html += '
';
- html += '
';
- html += '
';
- html += '
';
- html += '
' + display + ' / ' + goal.target + ' ' + this._unitLabel(goal.type) + '
';
- html += '
';
- html += '
';
- return html;
- };
-
- GoalSettingsPanel.prototype._unitLabel = function (type) {
- if (type === 'practice_count') return '次';
- if (type === 'study_time') return '分钟';
- if (type === 'accuracy') return '%';
- return '';
- };
-
- GoalSettingsPanel.prototype._bindActions = function (container) {
- var self = this;
- var addBtn = container.querySelector('[data-action="add-goal"]');
- if (addBtn) {
- addBtn.addEventListener('click', function () {
- self._showCreateDialog();
- });
- }
-
- var deleteBtns = container.querySelectorAll('[data-action="delete-goal"]');
- for (var i = 0; i < deleteBtns.length; i++) {
- deleteBtns[i].addEventListener('click', function () {
- var gid = this.getAttribute('data-goal-id');
- if (gid && self.goalManager) {
- self.goalManager.deleteGoal(gid);
- }
- });
- }
- };
-
- GoalSettingsPanel.prototype._showCreateDialog = function () {
- var self = this;
- var overlay = document.createElement('div');
- overlay.className = 'goal-dialog-overlay';
-
- var dialog = document.createElement('div');
- dialog.className = 'goal-dialog';
- dialog.innerHTML = this._renderCreateForm();
-
- overlay.appendChild(dialog);
- document.body.appendChild(overlay);
-
- overlay.addEventListener('click', function (e) {
- if (e.target === overlay) {
- document.body.removeChild(overlay);
- }
- });
-
- var cancelBtn = dialog.querySelector('[data-action="cancel"]');
- if (cancelBtn) {
- cancelBtn.addEventListener('click', function () {
- document.body.removeChild(overlay);
- });
- }
-
- var saveBtn = dialog.querySelector('[data-action="save"]');
- if (saveBtn) {
- saveBtn.addEventListener('click', function () {
- var type = dialog.querySelector('#goal-type').value;
- var period = dialog.querySelector('#goal-period').value;
- var target = Number(dialog.querySelector('#goal-target').value);
- var title = dialog.querySelector('#goal-title').value.trim();
-
- if (!type || !period || !Number.isFinite(target) || target <= 0) {
- if (window.showMessage) {
- window.showMessage('请填写完整的目标信息', 'warning');
- }
- return;
- }
-
- self.goalManager.createGoal({
- type: type,
- period: period,
- target: target,
- title: title
- });
-
- document.body.removeChild(overlay);
- });
- }
-
- // Update target placeholder on type change
- var typeSelect = dialog.querySelector('#goal-type');
- if (typeSelect) {
- typeSelect.addEventListener('change', function () {
- var ph = dialog.querySelector('#goal-target');
- if (this.value === 'practice_count') ph.placeholder = '例:3';
- else if (this.value === 'study_time') ph.placeholder = '例:60';
- else if (this.value === 'accuracy') ph.placeholder = '例:80';
- });
- }
- };
-
- GoalSettingsPanel.prototype._renderCreateForm = function () {
- var html = '
';
- html += '
创建学习目标 ';
-
- html += '
';
- html += '目标类型 ';
- html += '';
- html += '练习次数 ';
- html += '学习时长 (分钟) ';
- html += '正确率 (%) ';
- html += ' ';
- html += '
';
-
- html += '
';
- html += '目标周期 ';
- html += '';
- html += '每日 ';
- html += '每周 ';
- html += '每月 ';
- html += ' ';
- html += '
';
-
- html += '
';
- html += '目标值 ';
- html += ' ';
- html += '
';
-
- html += '
';
- html += '标题 (可选) ';
- html += ' ';
- html += '
';
-
- html += '
';
- html += '取消 ';
- html += '保存 ';
- html += '
';
-
- html += '
';
- return html;
- };
-
- window.GoalSettingsPanel = GoalSettingsPanel;
-})(typeof window !== 'undefined' ? window : this);
diff --git a/js/components/onboardingTour.js b/js/components/onboardingTour.js
index 36e60bc2..b1b3ba0a 100644
--- a/js/components/onboardingTour.js
+++ b/js/components/onboardingTour.js
@@ -4,7 +4,7 @@
* 兼容 file:// 协议
*
* 数据层约定(0.6.2-fix 之后):
- * - 示例记录必须经 PracticeRecordAPI.saveRecord,且具备 canonical examId
+ * - 示例记录必须经 AppData.practice.completeAttempt,且具备 canonical examId
* - 回放依赖 realData.answers(object map)+ correctAnswerMap
* - 引导状态键使用 exam_system_ 前缀,并兼容迁移旧键
*/
@@ -39,19 +39,6 @@
type: 'reading'
});
- // 存储键名(带前缀;读取时兼容旧键)
- const STORAGE_KEYS = {
- COMPLETED: 'exam_system_onboarding_completed',
- CURRENT_STEP: 'exam_system_onboarding_step',
- LAST_SHOWN: 'exam_system_onboarding_last_shown'
- };
-
- const LEGACY_STORAGE_KEYS = {
- COMPLETED: 'onboardingCompleted',
- CURRENT_STEP: 'onboardingStep',
- LAST_SHOWN: 'onboardingLastShown'
- };
-
const HISTORY_ITEM_SELECTOR =
`#history-list .history-item.history-record-item[data-record-id="${DEMO_RECORD_ID}"]`;
const HISTORY_TITLE_SELECTOR =
@@ -243,16 +230,6 @@
nextText: '下一步',
lockScroll: true,
disableHighlightPointer: true
- },
- {
- id: 'local-backup',
- target: '#external-backup-entry-btn',
- title: '💾 本地磁盘备份',
- content: '若浏览器支持,可绑定本地文件夹做磁盘备份,与导出 JSON 互为补充。',
- position: 'top',
- nextText: '下一步',
- lockScroll: true,
- disableHighlightPointer: true
}
]
},
@@ -302,71 +279,51 @@
// 状态管理器
class TourStateManager {
constructor() {
- this._storage = this._getStorage();
- this._migrateLegacyKeys();
+ this._state = { completed: false, currentStep: 0, lastShown: null };
+ this.ready = this._load();
}
- _getStorage() {
- try {
- localStorage.setItem('__test__', '1');
- localStorage.removeItem('__test__');
- return localStorage;
- } catch (e) {
- const mem = {};
- return {
- getItem: (k) => (Object.prototype.hasOwnProperty.call(mem, k) ? mem[k] : null),
- setItem: (k, v) => { mem[k] = String(v); },
- removeItem: (k) => { delete mem[k]; }
- };
- }
+ async _load() {
+ if (!global.AppData || !global.AppData.preferences) return;
+ await global.AppData.ready;
+ const stored = await global.AppData.preferences.getOnboarding();
+ this._state = {
+ completed: stored.completed === true || stored.completed === 'true',
+ currentStep: Number.isFinite(Number(stored.currentStep)) ? Number(stored.currentStep) : 0,
+ lastShown: stored.lastShown || null
+ };
}
- _migrateLegacyKeys() {
- Object.keys(STORAGE_KEYS).forEach((name) => {
- const nextKey = STORAGE_KEYS[name];
- const legacyKey = LEGACY_STORAGE_KEYS[name];
- if (!legacyKey) return;
- try {
- const current = this._storage.getItem(nextKey);
- if (current !== null && current !== undefined && current !== '') return;
- const legacy = this._storage.getItem(legacyKey);
- if (legacy === null || legacy === undefined || legacy === '') return;
- this._storage.setItem(nextKey, legacy);
- this._storage.removeItem(legacyKey);
- } catch (_) {
- // ignore migration failures
- }
+ _persist() {
+ if (!global.AppData || !global.AppData.preferences) return;
+ global.AppData.preferences.setOnboarding(this._state).catch((error) => {
+ console.warn('[Onboarding] 保存引导状态失败:', error);
});
}
isCompleted() {
- return this._storage.getItem(STORAGE_KEYS.COMPLETED) === 'true';
+ return this._state.completed === true;
}
getCurrentStep() {
- const step = this._storage.getItem(STORAGE_KEYS.CURRENT_STEP);
- return step ? parseInt(step, 10) : 0;
+ return this._state.currentStep || 0;
}
setStep(step) {
- this._storage.setItem(STORAGE_KEYS.CURRENT_STEP, String(step));
- this._storage.setItem(STORAGE_KEYS.LAST_SHOWN, String(Date.now()));
+ this._state.currentStep = Number(step) || 0;
+ this._state.lastShown = Date.now();
+ this._persist();
}
markCompleted() {
- this._storage.setItem(STORAGE_KEYS.COMPLETED, 'true');
- this._storage.removeItem(STORAGE_KEYS.CURRENT_STEP);
+ this._state.completed = true;
+ this._state.currentStep = 0;
+ this._persist();
}
reset() {
- this._storage.removeItem(STORAGE_KEYS.COMPLETED);
- this._storage.removeItem(STORAGE_KEYS.CURRENT_STEP);
- this._storage.removeItem(STORAGE_KEYS.LAST_SHOWN);
- try {
- this._storage.removeItem(LEGACY_STORAGE_KEYS.COMPLETED);
- this._storage.removeItem(LEGACY_STORAGE_KEYS.CURRENT_STEP);
- this._storage.removeItem(LEGACY_STORAGE_KEYS.LAST_SHOWN);
- } catch (_) {}
+ this._state = { completed: false, currentStep: 0, lastShown: null };
+ this._persist();
}
}
@@ -587,8 +544,9 @@
destroy() {
this.clearHighlight();
if (this._overlay) {
- this._overlay.classList.remove('is-active');
- setTimeout(() => this._overlay?.remove(), 300);
+ const overlay = this._overlay;
+ overlay.classList.remove('is-active');
+ setTimeout(() => overlay.remove(), 300);
this._overlay = null;
}
if (this._holeEl) {
@@ -596,8 +554,9 @@
this._holeEl = null;
}
if (this._tooltip) {
- this._tooltip.classList.remove('is-visible');
- setTimeout(() => this._tooltip?.remove(), 300);
+ const tooltip = this._tooltip;
+ tooltip.classList.remove('is-visible');
+ setTimeout(() => tooltip.remove(), 300);
this._tooltip = null;
}
}
@@ -615,7 +574,11 @@
this._boundKeyHandler = null;
this._currentSubStep = 0;
this._inSubSteps = false;
- this._demoInjectPromise = null;
+ this._demoInjectTask = null;
+ this._demoCleanupPromise = null;
+ this._lifecycleToken = 0;
+ this._startTimer = null;
+ this._selectorWaiters = new Set();
this._lastDemoInjectResult = null;
this._clickWaitCleanup = null;
this._scrollBlocked = false;
@@ -625,12 +588,14 @@
this._savedScrollTop = 0;
}
- init() {
+ async init() {
+ await this._stateManager.ready;
if (this._stateManager.isCompleted()) {
return;
}
- setTimeout(() => {
+ this._startTimer = setTimeout(() => {
+ this._startTimer = null;
this.start();
}, 1500);
}
@@ -641,6 +606,7 @@
// 每次启动使用步骤副本,避免限级回放补丁污染默认配置
this._steps = cloneSteps(this._baseSteps);
this._currentStep = fromBeginning ? 0 : this._stateManager.getCurrentStep();
+ this._lifecycleToken += 1;
this._isActive = true;
this._inSubSteps = false;
this._currentSubStep = 0;
@@ -664,6 +630,14 @@
stop() {
this._isActive = false;
+ this._lifecycleToken += 1;
+ if (this._startTimer !== null) {
+ clearTimeout(this._startTimer);
+ this._startTimer = null;
+ }
+ this._cancelSelectorWaits();
+ this._clearDemoRecordPreview();
+ void this._cleanupDemoRecord();
this._clearClickWait();
this._unlockScroll();
this._unlockPointer();
@@ -966,8 +940,10 @@
};
if (subStep.action === 'injectDemoRecord') {
+ const lifecycleToken = this._lifecycleToken;
Promise.resolve(this._injectDemoRecord())
.then((result) => {
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) return;
this._lastDemoInjectResult = result;
if (!result || !result.ok) {
this._showInjectFailureSubStep(parentStep, result);
@@ -980,6 +956,7 @@
proceed();
})
.catch((err) => {
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) return;
console.error('[Onboarding] 注入示例记录失败:', err);
this._lastDemoInjectResult = { ok: false, reason: 'exception', error: err };
this._showInjectFailureSubStep(parentStep, this._lastDemoInjectResult);
@@ -1167,28 +1144,8 @@
let list = [];
try {
- if (typeof global.getExamIndexState === 'function') {
- list = global.getExamIndexState();
- } else if (Array.isArray(global.examIndex)) {
- list = global.examIndex;
- }
- } catch (_) {}
-
- if (!Array.isArray(list) || list.length === 0) {
- try {
- const storage = global.persistentStore || global.storage;
- if (storage && typeof storage.get === 'function') {
- let activeKey = 'exam_index';
- try {
- activeKey = await storage.get('active_exam_index_key', 'exam_index') || 'exam_index';
- } catch (_) {}
- list = await storage.get(activeKey, []) || [];
- if ((!Array.isArray(list) || list.length === 0) && activeKey !== 'exam_index') {
- list = await storage.get('exam_index', []) || [];
- }
- }
- } catch (_) {}
- }
+ list = await global.resolveActiveLibraryIndex();
+ } catch (_) { }
if (!Array.isArray(list)) list = [];
@@ -1357,44 +1314,82 @@
}));
}
- _waitForSelector(selector, maxWait = 4000) {
+ _waitForSelector(selector, maxWait = 4000, lifecycleToken = this._lifecycleToken) {
return new Promise((resolve) => {
const startTime = Date.now();
+ const waiter = { timer: null, settle: null };
+ const settle = (value) => {
+ if (!this._selectorWaiters.has(waiter)) return;
+ if (waiter.timer !== null) clearTimeout(waiter.timer);
+ this._selectorWaiters.delete(waiter);
+ resolve(value);
+ };
const check = () => {
+ waiter.timer = null;
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ settle(null);
+ return;
+ }
const el = document.querySelector(selector);
if (el) {
- resolve(el);
+ settle(el);
return;
}
if (Date.now() - startTime > maxWait) {
- resolve(null);
+ settle(null);
return;
}
- setTimeout(check, 120);
+ waiter.timer = setTimeout(check, 120);
};
+ waiter.settle = settle;
+ this._selectorWaiters.add(waiter);
check();
});
}
+ _cancelSelectorWaits() {
+ for (const waiter of Array.from(this._selectorWaiters)) {
+ waiter.settle(null);
+ }
+ }
+
+ _isDemoLifecycleCurrent(token) {
+ return this._isActive && token === this._lifecycleToken;
+ }
+
async _injectDemoRecord() {
- if (this._demoInjectPromise) {
- return this._demoInjectPromise;
+ const lifecycleToken = this._lifecycleToken;
+ if (this._demoInjectTask && this._demoInjectTask.token === lifecycleToken) {
+ return this._demoInjectTask.promise;
}
- this._demoInjectPromise = (async () => {
- const api = global.PracticeRecordAPI;
- if (!api || typeof api.saveRecord !== 'function') {
- return { ok: false, reason: 'PracticeRecordAPI unavailable' };
+ const injectPromise = (async () => {
+ const api = global.AppData && global.AppData.practice;
+ if (!api || typeof api.completeAttempt !== 'function') {
+ return { ok: false, reason: 'AppData.practice unavailable' };
}
const examContext = await this._resolveDemoExamContext();
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ return { ok: false, reason: 'cancelled' };
+ }
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ return { ok: false, reason: 'cancelled' };
+ }
const demoRecordObj = this._buildDemoRecord(examContext);
+ // 演示记录带 metadata.source = 'onboarding-demo',会被统一的来源判定
+ // (js/data/practiceRecordSource.js)排除在练习记录列表、成绩统计与成就之外。
+ // 引导需要用户看见这一行,所以显式为这一个 id 申请"视图层预览"许可:
+ // 只放行渲染,投影器读不到该白名单,统计与成就仍然不会被演示数据污染。
+ this._allowDemoRecordPreview();
+
try {
- // 避免污染 user_stats
- await api.saveRecord(demoRecordObj, { updateStats: false });
+ await api.completeAttempt({ record: demoRecordObj });
} catch (err) {
console.error('[Onboarding] 注入示例记录失败:', err);
+ this._clearDemoRecordPreview();
return {
ok: false,
reason: err && err.message ? err.message : 'saveRecord failed',
@@ -1402,8 +1397,24 @@
};
}
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ await this._cleanupDemoRecord({ refresh: false });
+ return { ok: false, reason: 'cancelled' };
+ }
+
await this._refreshPracticeHistory();
- const row = await this._waitForSelector(HISTORY_ITEM_SELECTOR, 5000);
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ await this._cleanupDemoRecord({ refresh: false });
+ return { ok: false, reason: 'cancelled' };
+ }
+ const row = await this._waitForSelector(HISTORY_ITEM_SELECTOR, 5000, lifecycleToken);
+ if (!this._isDemoLifecycleCurrent(lifecycleToken)) {
+ if (this._demoCleanupPromise) await this._demoCleanupPromise;
+ await this._cleanupDemoRecord({ refresh: false });
+ return { ok: false, reason: 'cancelled' };
+ }
if (!row) {
return {
ok: false,
@@ -1422,33 +1433,70 @@
recordId: DEMO_RECORD_ID
};
})();
+ this._demoInjectTask = { token: lifecycleToken, promise: injectPromise };
try {
- return await this._demoInjectPromise;
+ return await injectPromise;
} finally {
- this._demoInjectPromise = null;
+ if (this._demoInjectTask && this._demoInjectTask.promise === injectPromise) {
+ this._demoInjectTask = null;
+ }
+ }
+ }
+
+ /**
+ * 申请/撤销演示记录的"视图层预览"许可。
+ * 见 js/data/practiceRecordSource.js 的引导预览白名单说明:许可只影响练习记录列表渲染,
+ * practice.stats 与 achievements.progress 投影器永远按"演示数据"排除这条记录。
+ */
+ _allowDemoRecordPreview() {
+ const classifier = global.PracticeRecordSource;
+ if (classifier && typeof classifier.allowPreviewRecordId === 'function') {
+ classifier.allowPreviewRecordId(DEMO_RECORD_ID);
}
}
- async _cleanupDemoRecord() {
- const api = global.PracticeRecordAPI;
- if (!api || typeof api.deleteById !== 'function') {
+ _clearDemoRecordPreview() {
+ const classifier = global.PracticeRecordSource;
+ if (classifier && typeof classifier.clearPreviewRecordId === 'function') {
+ classifier.clearPreviewRecordId(DEMO_RECORD_ID);
+ }
+ }
+
+ async _cleanupDemoRecord(options = {}) {
+ // 先撤销预览许可再删除并重渲染:即使删除失败,这条演示记录也不会继续留在列表里。
+ this._clearDemoRecordPreview();
+
+ if (this._demoCleanupPromise) return this._demoCleanupPromise;
+
+ const api = global.AppData && global.AppData.practice;
+ if (!api || typeof api.delete !== 'function') {
return;
}
- try {
- await api.deleteById(DEMO_RECORD_ID, { updateStats: false });
- if (typeof global.syncPracticeRecords === 'function') {
- await Promise.resolve(global.syncPracticeRecords({ forceRender: true }));
- } else if (global.app && typeof global.app.renderPracticeHistory === 'function') {
- await Promise.resolve(global.app.renderPracticeHistory());
- } else {
- global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', {
- detail: { source: 'onboarding-cleanup' }
- }));
+ const refresh = options.refresh !== false;
+ const cleanupPromise = (async () => {
+ try {
+ await api.delete({ recordId: DEMO_RECORD_ID });
+ if (!refresh) return;
+ if (typeof global.syncPracticeRecords === 'function') {
+ await Promise.resolve(global.syncPracticeRecords({ forceRender: true }));
+ } else if (global.app && typeof global.app.renderPracticeHistory === 'function') {
+ await Promise.resolve(global.app.renderPracticeHistory());
+ } else {
+ global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', {
+ detail: { source: 'onboarding-cleanup' }
+ }));
+ }
+ } catch (err) {
+ console.warn('[Onboarding] 清理示例记录失败:', err);
}
- } catch (err) {
- console.warn('[Onboarding] 清理示例记录失败:', err);
+ })();
+ this._demoCleanupPromise = cleanupPromise;
+ try {
+ await cleanupPromise;
+ } finally {
+ if (this._demoCleanupPromise === cleanupPromise) this._demoCleanupPromise = null;
}
}
@@ -1578,7 +1626,6 @@
}
_complete() {
- this._cleanupDemoRecord();
this._stateManager.markCompleted();
this.stop();
}
diff --git a/js/components/practiceHistoryEnhancer.js b/js/components/practiceHistoryEnhancer.js
index a9793db1..3385cca5 100644
--- a/js/components/practiceHistoryEnhancer.js
+++ b/js/components/practiceHistoryEnhancer.js
@@ -76,7 +76,7 @@ class PracticeHistoryEnhancer {
const hasStandardComponent = window.app?.components?.practiceHistory;
const hasBasicStructure = document.querySelector('.practice-history') ||
document.querySelector('#practice-records') ||
- window.PracticeRecordAPI;
+ window.AppData;
if (hasStandardComponent || hasBasicStructure) {
clearInterval(checkInterval);
@@ -265,30 +265,11 @@ class PracticeHistoryEnhancer {
*/
async exportAsJSON() {
try {
- let practiceRecords = [];
- let practiceStats = {};
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const records = await window.PracticeRecordAPI.list();
- practiceRecords = Array.isArray(records) ? records : [];
- }
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- practiceStats = await window.PracticeRecordAPI.readStats();
- }
-
- if (practiceRecords.length === 0) {
+ const practiceRecords = await window.AppData.practice.list({ projection: 'light' });
+ if (!Array.isArray(practiceRecords) || practiceRecords.length === 0) {
throw new Error('没有练习记录可导出');
}
-
- const data = {
- exportDate: new Date().toISOString(),
- stats: practiceStats,
- user_stats: practiceStats,
- userStats: practiceStats,
- records: practiceRecords,
- practice_records: practiceRecords
- };
+ const data = await window.AppData.backups.export({ domains: ['practice'] });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -311,33 +292,16 @@ class PracticeHistoryEnhancer {
}
/**
- * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌
+ * 从统一练习记录 API 获取练习记录,避免 legacy storage 影子键回灌。
+ * 默认 medium 投影:详情答案层,不含 highlights/notes。
+ * 回顾模式请用 fetchRecordById(id, { projection: 'full' })。
*/
- async fetchRecordById(recordId) {
+ async fetchRecordById(recordId, options = {}) {
const toIdStr = (v) => v == null ? '' : String(v);
const targetIdStr = toIdStr(recordId);
+ const projection = (options && options.projection) || 'detail';
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getById === 'function') {
- try {
- const hit = await window.PracticeRecordAPI.getById(targetIdStr);
- if (hit) return hit;
- } catch (err) {
- console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 获取记录失败:', err);
- }
- }
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- try {
- const records = await window.PracticeRecordAPI.list();
- if (!Array.isArray(records)) return null;
- const hit = records.find(r => toIdStr(r.id) === targetIdStr || toIdStr(r.sessionId) === targetIdStr);
- if (hit) return hit;
- } catch (err) {
- console.warn('[PracticeHistoryEnhancer] 从 PracticeRecordAPI 列表查找记录失败:', err);
- }
- }
-
- return null;
+ return window.AppData.practice.get(targetIdStr, { projection });
}
/**
diff --git a/js/components/practiceRecordModal.js b/js/components/practiceRecordModal.js
index 69e236d1..bcab9afa 100644
--- a/js/components/practiceRecordModal.js
+++ b/js/components/practiceRecordModal.js
@@ -15,7 +15,8 @@ class PracticeRecordModal {
show(record) {
try {
- const replayRecord = this.cloneRecord(record);
+ // 详情展示用 medium;回顾时再按 id 拉 full,避免把注解灌进 modal 缓存。
+ const displayRecord = record;
let processedRecord = record;
if (window.DataConsistencyManager) {
@@ -29,7 +30,8 @@ class PracticeRecordModal {
const modalHtml = this.createModalHtml(processedRecord);
this.hide();
- this.currentRecord = replayRecord;
+ this.currentRecord = this.cloneRecord(displayRecord);
+ this.currentRecordId = (displayRecord && (displayRecord.id || displayRecord.sessionId)) || null;
document.body.insertAdjacentHTML('beforeend', modalHtml);
this.modalElement = document.getElementById(this.modalId);
@@ -70,6 +72,7 @@ class PracticeRecordModal {
this.modalElement = null;
this.currentRecord = null;
this.isVisible = false;
+ this.currentRecordId = null;
}
teardownEventListeners() {
@@ -124,8 +127,10 @@ class PracticeRecordModal {
if (replayTrigger) {
this.replayTriggerElement = replayTrigger;
const launchReplay = async () => {
- const replayRecord = this.currentRecord;
- if (!replayRecord) {
+ const recordId = this.currentRecordId
+ || (this.currentRecord && (this.currentRecord.id || this.currentRecord.sessionId))
+ || null;
+ if (!recordId && !this.currentRecord) {
if (typeof window.showMessage === 'function') {
window.showMessage('未找到可回放记录', 'error');
}
@@ -140,6 +145,23 @@ class PracticeRecordModal {
closeModal();
try {
+ // 回顾必须 full:重新按 id 拉取含 highlights/notes 的完整记录。
+ // 当前详情多为 medium,full 失败时不得回退 detail(缺注解)。
+ let replayRecord = null;
+ if (window.AppData && recordId) {
+ replayRecord = await window.AppData.practice.get(recordId, { projection: 'full' });
+ } else if (this.currentRecord && (
+ Array.isArray(this.currentRecord.highlights)
+ || Array.isArray(this.currentRecord.notes)
+ || this.currentRecord.realData
+ || this.currentRecord.rawData
+ )) {
+ // 无 API 时仅允许已是 full 形态的 currentRecord。
+ replayRecord = this.currentRecord;
+ }
+ if (!replayRecord) {
+ throw new Error('无法加载完整记录用于回顾');
+ }
await window.app.openPracticeRecordReplay(replayRecord);
} catch (error) {
console.error('[PracticeRecordModal] 启动回放失败:', error);
@@ -248,12 +270,12 @@ class PracticeRecordModal {
`;
}
- prepareRecordForDisplay(record) {
+ prepareRecordForDisplay(record, examDefinition = null) {
if (!record) {
return record;
}
if (window.AnswerComparisonUtils && typeof window.AnswerComparisonUtils.withEnrichedMetadata === 'function') {
- return window.AnswerComparisonUtils.withEnrichedMetadata(record);
+ return window.AnswerComparisonUtils.withEnrichedMetadata(record, examDefinition);
}
return record;
}
@@ -411,7 +433,10 @@ class PracticeRecordModal {
if (record.multiSuite === true && entry.scoreInfo) {
const correct = entry.scoreInfo.correct || 0;
const total = entry.scoreInfo.total || 0;
- const percentage = entry.scoreInfo.percentage || 0;
+ const rawPercentage = Number(entry.scoreInfo.percentage);
+ const percentage = Number.isFinite(rawPercentage)
+ ? (Math.round(rawPercentage * 10) / 10).toFixed(1)
+ : '0.0';
scoreInfo = `
得分: ${correct}/${total} (${percentage}%)
`;
}
@@ -1133,36 +1158,26 @@ class PracticeRecordModal {
try {
const normalise = (value) => (value == null ? '' : String(value));
const targetId = normalise(recordId);
- const api = window.PracticeRecordAPI || null;
let record = null;
- if (api && typeof api.getById === 'function') {
- record = await api.getById(targetId);
- }
-
- if (!record && api && typeof api.list === 'function') {
- const records = await api.list();
- if (Array.isArray(records)) {
- record = records.find(r => normalise(r.id) === targetId) ||
- records.find(r => normalise(r.sessionId) === targetId);
- }
- }
+ record = await window.AppData.practice.get(targetId, { projection: 'full' });
if (!record) {
throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728');
}
const exporter = new MarkdownExporter();
- const examIndex = await window.storage.get('exam_index', []);
- const exam = Array.isArray(examIndex) ? examIndex.find(e => e.id === record.examId) : null;
+ const exam = typeof window.resolveExamForPracticeRecord === 'function'
+ ? await window.resolveExamForPracticeRecord(record)
+ : null;
const enrichedRecord = this.prepareRecordForDisplay({
...record,
examInfo: exam || {},
- title: exam?.title || record.title || record.examId || '\u672a\u77e5\u9898\u76ee',
- category: exam?.category || record.category || '\u672a\u77e5\u5206\u7c7b',
- frequency: exam?.frequency || record.frequency || '\u672a\u77e5\u9891\u7387'
- });
+ title: record.title || record.metadata?.examTitle || exam?.title || record.examId || '\u672a\u77e5\u9898\u76ee',
+ category: record.category || record.metadata?.category || exam?.category || '\u672a\u77e5\u5206\u7c7b',
+ frequency: record.frequency || record.metadata?.frequency || exam?.frequency || '\u672a\u77e5\u9891\u7387'
+ }, exam);
const markdown = exporter.generateRecordMarkdown(enrichedRecord);
@@ -1195,20 +1210,9 @@ if (!window.practiceRecordModal.showById) {
try {
const normalise = (value) => (value == null ? '' : String(value));
const targetId = normalise(recordId);
- const api = window.PracticeRecordAPI || null;
let record = null;
- if (api && typeof api.getById === 'function') {
- record = await api.getById(targetId);
- }
-
- if (!record && api && typeof api.list === 'function') {
- const records = await api.list();
- if (Array.isArray(records)) {
- record = records.find(r => normalise(r.id) === targetId) ||
- records.find(r => normalise(r.sessionId) === targetId);
- }
- }
+ record = await window.AppData.practice.get(targetId, { projection: 'detail' });
if (!record) {
throw new Error('\u8bb0\u5f55\u4e0d\u5b58\u5728');
diff --git a/js/components/vocabSessionView.js b/js/components/vocabSessionView.js
index 69dbea90..4add767f 100644
--- a/js/components/vocabSessionView.js
+++ b/js/components/vocabSessionView.js
@@ -769,17 +769,15 @@
? meta.name.trim()
: (typeof meta.source === 'string' && meta.source.trim() ? meta.source.trim() : '');
if (result.type === 'progress') {
- await state.store.setWords(entries);
- if (meta.config && typeof meta.config === 'object') {
- await state.store.setConfig(meta.config);
- const latestConfig = state.store.getConfig();
- const limit = Number(latestConfig?.reviewLimit);
- if (Number.isFinite(limit) && limit > 0) {
- state.session.batchSize = Math.max(1, Math.min(limit, DEFAULT_BATCH_SIZE));
- }
- }
- if (Array.isArray(meta.reviewQueue)) {
- await state.store.setReviewQueue(meta.reviewQueue);
+ await state.store.replaceProgress(
+ entries,
+ meta.config && typeof meta.config === 'object' ? meta.config : {},
+ typeof meta.listId === 'string' ? meta.listId : null
+ );
+ const latestConfig = state.store.getConfig();
+ const limit = Number(latestConfig?.reviewLimit);
+ if (Number.isFinite(limit) && limit > 0) {
+ state.session.batchSize = Math.max(1, Math.min(limit, DEFAULT_BATCH_SIZE));
}
resetSessionState();
prepareSessionQueue();
@@ -794,47 +792,13 @@
showFeedbackMessage(`${categoryLabel}${suffix}导入完成,已同步 ${entries.length} 条词汇`, 'success');
return;
}
- const existing = state.store.getWords();
- const merged = existing.slice();
- const indexByWord = new Map();
- existing.forEach((word, index) => {
- if (word && typeof word.word === 'string') {
- indexByWord.set(word.word.trim().toLowerCase(), index);
- }
- });
- let updatedCount = 0;
- let insertedCount = 0;
- entries.forEach((entry) => {
- const key = String(entry.word || '').trim().toLowerCase();
- if (!key) {
- return;
- }
- if (indexByWord.has(key)) {
- const idx = indexByWord.get(key);
- const base = merged[idx];
- merged[idx] = {
- ...base,
- meaning: entry.meaning || base.meaning,
- example: entry.example || base.example,
- freq: typeof entry.freq === 'number' ? entry.freq : base.freq
- };
- updatedCount += 1;
- return;
- }
- merged.push({
- word: entry.word,
- meaning: entry.meaning,
- example: entry.example || '',
- freq: typeof entry.freq === 'number' ? entry.freq : undefined
- });
- indexByWord.set(key, merged.length - 1);
- insertedCount += 1;
- });
+ const mergeResult = await state.store.mergeWords(entries);
+ const insertedCount = Number(mergeResult && mergeResult.addedCount) || 0;
+ const updatedCount = Number(mergeResult && mergeResult.updatedCount) || 0;
if (!insertedCount && !updatedCount) {
showFeedbackMessage('所有词条均已存在,无需更新', 'info');
return;
}
- await state.store.setWords(merged);
const categoryLabel = meta.category === 'user' ? '自设词表' : '外部词表';
const suffix = sourceLabel ? `「${sourceLabel}」` : '';
showFeedbackMessage(`${categoryLabel}${suffix}导入完成:新增 ${insertedCount} 条,更新 ${updatedCount} 条`, 'success');
@@ -1531,7 +1495,7 @@
state.session.activeQueue.push(clone);
}
- function rateAndContinue(quality) {
+ async function rateAndContinue(quality) {
const session = state.session;
const word = session.currentWord;
if (!word || session.stage !== 'feedback') {
@@ -1542,9 +1506,17 @@
if (session.lastAnswer && session.lastAnswer.quality !== quality) {
const now = new Date();
const patch = state.scheduler.scheduleAfterResult(word, quality, now);
- state.store.updateWord(word.id, patch);
- session.currentWord = { ...word, ...patch };
- session.lastAnswer.quality = quality;
+ try {
+ const committedWord = await state.store.updateWord(word.id, patch);
+ if (!committedWord) {
+ throw new Error('词汇记录不存在');
+ }
+ session.currentWord = committedWord;
+ session.lastAnswer.quality = quality;
+ } catch (error) {
+ showFeedbackMessage(`评分保存失败:${error.message || error}`, 'error');
+ return;
+ }
}
moveToNextWord();
@@ -1576,17 +1548,26 @@
render();
}
- function saveCurrentNote() {
+ async function saveCurrentNote() {
const word = state.session.currentWord;
if (!word || !state.store || !state.elements.noteInput) {
return;
}
const note = state.elements.noteInput.value.trim();
- state.store.updateWord(word.id, { note });
- state.session.currentWord = {
- ...state.session.currentWord,
- note
- };
+ let committedWord;
+ try {
+ committedWord = await state.store.updateWord(word.id, { note });
+ if (!committedWord) {
+ throw new Error('词汇记录不存在');
+ }
+ } catch (error) {
+ if (state.elements.noteStatus) {
+ state.elements.noteStatus.textContent = '保存失败';
+ }
+ showFeedbackMessage(`笔记保存失败:${error.message || error}`, 'error');
+ return false;
+ }
+ state.session.currentWord = committedWord;
if (state.elements.noteStatus) {
state.elements.noteStatus.textContent = '已保存';
setTimeout(() => {
@@ -1595,6 +1576,7 @@
}
}, 1500);
}
+ return true;
}
function startBatch(force) {
diff --git a/js/core/backupAPI.js b/js/core/backupAPI.js
deleted file mode 100644
index 300782f4..00000000
--- a/js/core/backupAPI.js
+++ /dev/null
@@ -1,392 +0,0 @@
-(function initBackupAPI(global) {
- 'use strict';
-
- if (global.BackupAPI && global.BackupAPI.__stable === true) {
- return;
- }
-
- const DEFAULT_VERSION = '0.6.2-form';
- const DEFAULT_MAX_BACKUPS = 20;
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
- }
-
- function cloneJson(value) {
- if (value == null) return value;
- try {
- return JSON.parse(JSON.stringify(value));
- } catch (_) {
- return value;
- }
- }
-
- function getStorageFacade() {
- if (global.storage && typeof global.storage.get === 'function') {
- return global.storage;
- }
- // Some boot paths / VM tests expose bare global storage without attaching to window
- try {
- if (typeof storage !== 'undefined' && storage && typeof storage.get === 'function') {
- return storage;
- }
- } catch (_) { /* ignore ReferenceError in strict scopes */ }
- return null;
- }
-
- function getRepositories() {
- if (global.dataRepositories && global.dataRepositories.backups) {
- return global.dataRepositories;
- }
- const registry = global.StorageProviderRegistry;
- if (registry && typeof registry.getCurrentProviders === 'function') {
- const current = registry.getCurrentProviders();
- if (current && current.repositories && current.repositories.backups) {
- return current.repositories;
- }
- }
- if (global.simpleStorageWrapper && global.simpleStorageWrapper.backupRepo) {
- return {
- backups: global.simpleStorageWrapper.backupRepo,
- meta: global.simpleStorageWrapper.metaRepo || null,
- settings: global.simpleStorageWrapper.settingsRepo || null
- };
- }
- return null;
- }
-
- function getBackupRepo() {
- const repos = getRepositories();
- return repos && repos.backups ? repos.backups : null;
- }
-
- function getMetaRepo() {
- const repos = getRepositories();
- return repos && repos.meta ? repos.meta : null;
- }
-
- async function readMeta(key, fallback = null) {
- const meta = getMetaRepo();
- if (meta && typeof meta.get === 'function') {
- return await meta.get(key, fallback);
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- return await storageFacade.get(key, fallback);
- }
- return fallback;
- }
-
- async function writeMeta(key, value) {
- const meta = getMetaRepo();
- if (meta && typeof meta.set === 'function') {
- await meta.set(key, value);
- return true;
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set(key, value);
- return true;
- }
- throw new Error('BackupAPI: meta store not ready');
- }
-
- function resolvePracticeRecords(data) {
- if (!data || typeof data !== 'object') return null;
- if (Array.isArray(data.practice_records)) return data.practice_records;
- if (Array.isArray(data.practiceRecords)) return data.practiceRecords;
- return null;
- }
-
- function resolveUserStats(data) {
- if (!data || typeof data !== 'object') return null;
- if (isPlainObject(data.user_stats)) return data.user_stats;
- if (isPlainObject(data.userStats)) return data.userStats;
- return null;
- }
-
- function resolveExamIndex(data) {
- if (!data || typeof data !== 'object') return null;
- if (Array.isArray(data.exam_index)) return data.exam_index;
- if (Array.isArray(data.examIndex)) return data.examIndex;
- return null;
- }
-
- function resolveStorageVersion(data) {
- if (!data || typeof data !== 'object') return null;
- if (data.storage_version != null) return data.storage_version;
- if (data.storageVersion != null) return data.storageVersion;
- return null;
- }
-
- /**
- * Canonical dual-schema payload so any legacy restore path can read snake or camel keys.
- */
- function normalizePayload(data = {}) {
- const source = isPlainObject(data) ? data : {};
- const records = resolvePracticeRecords(source);
- const stats = resolveUserStats(source);
- const examIndex = resolveExamIndex(source);
- const storageVersion = resolveStorageVersion(source);
- const payload = { ...source };
-
- if (records) {
- payload.practice_records = records;
- payload.practiceRecords = records;
- }
- if (stats) {
- payload.user_stats = stats;
- payload.userStats = stats;
- }
- if (examIndex) {
- payload.exam_index = examIndex;
- payload.examIndex = examIndex;
- }
- if (storageVersion != null) {
- payload.storage_version = storageVersion;
- payload.storageVersion = storageVersion;
- }
- return payload;
- }
-
- async function captureSnapshot(extra = {}) {
- let practiceRecords = [];
- let userStats = null;
-
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') {
- const listed = await global.PracticeRecordAPI.list();
- practiceRecords = Array.isArray(listed) ? listed : [];
- }
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') {
- userStats = await global.PracticeRecordAPI.readStats();
- }
-
- const examIndex = await readMeta('exam_index', []);
- const storageVersion = await readMeta('storage_version', null);
-
- return normalizePayload({
- practice_records: practiceRecords,
- user_stats: userStats,
- exam_index: Array.isArray(examIndex) ? examIndex : [],
- storage_version: storageVersion,
- ...(isPlainObject(extra) ? extra : {})
- });
- }
-
- async function list(options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.list === 'function') {
- const backups = await repo.list(options);
- return Array.isArray(backups) ? backups : [];
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- const backups = await storageFacade.get('manual_backups', []);
- return Array.isArray(backups) ? backups : [];
- }
- throw new Error('BackupAPI.list: backup repository not ready');
- }
-
- async function getById(id, options = {}) {
- if (!id) return null;
- const repo = getBackupRepo();
- if (repo && typeof repo.getById === 'function') {
- return await repo.getById(id, options);
- }
- const backups = await list(options);
- return backups.find((item) => item && String(item.id) === String(id)) || null;
- }
-
- async function add(backup, options = {}) {
- const repo = getBackupRepo();
- const normalizedData = normalizePayload(backup && backup.data ? backup.data : {});
- const entry = {
- ...(backup && typeof backup === 'object' ? backup : {}),
- id: (backup && backup.id) || `backup_${Date.now()}`,
- timestamp: (backup && backup.timestamp) || new Date().toISOString(),
- type: (backup && backup.type) || 'manual',
- version: (backup && backup.version) || DEFAULT_VERSION,
- data: normalizedData
- };
- entry.size = entry.size || JSON.stringify(entry.data).length;
-
- if (repo && typeof repo.add === 'function') {
- return await repo.add(entry, options);
- }
-
- // Fallback: raw storage (tests / early boot)
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- const backups = await storageFacade.get('manual_backups', []);
- const list = Array.isArray(backups) ? backups.slice() : [];
- list.unshift(entry);
- const max = options.maxBackups || DEFAULT_MAX_BACKUPS;
- while (list.length > max) {
- list.pop();
- }
- await storageFacade.set('manual_backups', list);
- return entry;
- }
-
- throw new Error('BackupAPI.add: backup repository not ready');
- }
-
- async function create(options = {}) {
- const {
- id = null,
- type = 'manual',
- data = null,
- extra = null,
- version = DEFAULT_VERSION
- } = options;
-
- const snapshot = data != null
- ? normalizePayload(data)
- : await captureSnapshot(extra || {});
-
- const backupId = id || `backup_${Date.now()}`;
- const entry = await add({
- id: backupId,
- timestamp: new Date().toISOString(),
- type,
- version,
- data: snapshot
- });
-
- return entry && entry.id ? entry.id : backupId;
- }
-
- async function restorePayload(data, options = {}) {
- const payload = normalizePayload(data || {});
- const records = resolvePracticeRecords(payload);
- const stats = resolveUserStats(payload);
- const examIndex = resolveExamIndex(payload);
- const storageVersion = resolveStorageVersion(payload);
- const restoreRecords = options.restoreRecords !== false;
- const restoreExamIndex = options.restoreExamIndex !== false;
- const restoreStorageVersion = options.restoreStorageVersion !== false;
-
- if (restoreRecords && records != null) {
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.restoreRecords === 'function') {
- await global.PracticeRecordAPI.restoreRecords(records, {
- stats: isPlainObject(stats) ? stats : null,
- updateStats: true
- });
- } else {
- throw new Error('BackupAPI.restore: PracticeRecordAPI.restoreRecords not ready');
- }
- } else if (isPlainObject(stats) && global.PracticeRecordAPI && typeof global.PracticeRecordAPI.resetStats === 'function') {
- await global.PracticeRecordAPI.resetStats(stats);
- }
-
- if (restoreExamIndex && examIndex) {
- await writeMeta('exam_index', examIndex);
- }
-
- if (restoreStorageVersion && storageVersion != null) {
- await writeMeta('storage_version', storageVersion);
- }
-
- // Optional system settings (DataIntegrityManager snapshots)
- if (isPlainObject(payload.system_settings)) {
- const repos = getRepositories();
- if (repos && repos.settings && typeof repos.settings.getAll === 'function') {
- const current = await repos.settings.getAll();
- await repos.settings.saveAll({ ...current, ...payload.system_settings });
- }
- }
-
- return {
- restoredRecords: records != null,
- restoredStats: isPlainObject(stats),
- restoredExamIndex: Boolean(restoreExamIndex && examIndex),
- restoredStorageVersion: Boolean(restoreStorageVersion && storageVersion != null)
- };
- }
-
- async function restore(backupId, options = {}) {
- if (!backupId) {
- throw new Error('BackupAPI.restore: invalid backup id');
- }
- const backup = await getById(backupId);
- if (!backup) {
- throw new Error(`BackupAPI.restore: backup ${backupId} not found`);
- }
- const result = await restorePayload(backup.data || {}, options);
- return { backup, ...result };
- }
-
- async function clear(options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.clear === 'function') {
- await repo.clear(options);
- return true;
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set('manual_backups', []);
- return true;
- }
- throw new Error('BackupAPI.clear: backup repository not ready');
- }
-
- async function remove(id, options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.delete === 'function') {
- return await repo.delete(id, options);
- }
- const backups = await list();
- const next = backups.filter((item) => item && String(item.id) !== String(id));
- if (next.length === backups.length) return false;
- if (repo && typeof repo.saveAll === 'function') {
- await repo.saveAll(next, options);
- return true;
- }
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set('manual_backups', next);
- return true;
- }
- return false;
- }
-
- async function prune(limit, options = {}) {
- const repo = getBackupRepo();
- if (repo && typeof repo.prune === 'function') {
- return await repo.prune(limit, options);
- }
- const max = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_MAX_BACKUPS;
- const backups = await list();
- if (backups.length <= max) return backups.length;
- const next = backups.slice(0, max);
- if (repo && typeof repo.saveAll === 'function') {
- await repo.saveAll(next, options);
- } else {
- const storageFacade = getStorageFacade();
- if (storageFacade) {
- await storageFacade.set('manual_backups', next);
- }
- }
- return next.length;
- }
-
- global.BackupAPI = {
- __stable: true,
- version: DEFAULT_VERSION,
- list,
- getById,
- add,
- create,
- captureSnapshot,
- normalizePayload,
- restore,
- restorePayload,
- clear,
- remove,
- prune,
- resolvePracticeRecords,
- resolveUserStats,
- resolveExamIndex,
- resolveStorageVersion
- };
-})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/core/externalBackupService.js b/js/core/externalBackupService.js
index e6c0cfb9..2611b963 100644
--- a/js/core/externalBackupService.js
+++ b/js/core/externalBackupService.js
@@ -1,40 +1,46 @@
/**
- * External disk backup via File System Access API.
- * Browser-internal backups (manual_backups) cannot survive site-data clears;
- * this service writes JSON into a user-chosen local folder.
+ * V2 external disk backup adapter.
*
- * Policy:
- * - Silent write only when a directory handle already has granted permission.
- * - Daily reminder at most once per calendar day (permission / bind / stale write).
- * - Download export is never auto-triggered; only after explicit user click.
+ * The selected directory is not a DataKernel backend. Durable application
+ * commits stay authoritative in AppData; this adapter writes portable v2
+ * snapshots after the commit and isolates every filesystem failure.
*/
(function initExternalBackupService(global) {
'use strict';
- if (global.ExternalBackupService && global.ExternalBackupService.__stable === true) {
- return;
- }
+ if (global.ExternalBackupService && global.ExternalBackupService.__v2 === true) return;
- var META_KEY = 'exam_system_external_backup_meta';
- var DB_NAME = 'ExamSystemExternalBackup';
+ var DB_NAME = 'IELTSAtlasExternalBackupV2';
var DB_VERSION = 1;
- var STORE_NAME = 'handles';
- var HANDLE_KEY = 'backup_directory';
- var LATEST_FILENAME = 'practice-backup-latest.json';
- var DAY_MS = 24 * 60 * 60 * 1000;
- var STALE_WRITE_MS = DAY_MS;
- var REMIND_BANNER_ID = 'external-backup-remind-banner';
- var VERSION = '0.6.2-fix';
+ var STORE_NAME = 'binding';
+ var HANDLE_KEY = 'directory-handle';
+ var META_KEY = 'metadata';
+ var LATEST_FILENAME = 'ielts-atlas-backup-latest.json';
+ var WRITE_DELAY_MS = 8000;
+ var ENTRY_ID = 'external-backup-entry-btn';
+ var MODAL_ID = 'external-backup-modal';
var state = {
ready: false,
readyPromise: null,
+ initialized: false,
+ suspended: false,
directoryHandle: null,
- meta: null,
+ permission: 'prompt',
dirty: false,
+ dirtyGeneration: 0,
writing: false,
- lastSnapshotHash: null,
- silentFlushTimer: null
+ writeQueue: Promise.resolve(),
+ silentFlushTimer: null,
+ unsubscribeCommitted: null,
+ visibilityHandler: null,
+ meta: {
+ directoryName: null,
+ lastWriteAt: null,
+ lastChecksum: null,
+ lastWriteError: null,
+ awaitingRestore: false
+ }
};
function nowIso() {
@@ -42,106 +48,56 @@
}
function dayKey(date) {
- var d = date instanceof Date ? date : new Date(date || Date.now());
- if (Number.isNaN(d.getTime())) {
- d = new Date();
- }
- var y = d.getFullYear();
- var m = String(d.getMonth() + 1).padStart(2, '0');
- var day = String(d.getDate()).padStart(2, '0');
- return y + '-' + m + '-' + day;
- }
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
- }
-
- function notify(message, type) {
- if (typeof global.showMessage === 'function') {
- global.showMessage(message, type || 'info');
- }
+ var year = date.getFullYear();
+ var month = String(date.getMonth() + 1).padStart(2, '0');
+ var day = String(date.getDate()).padStart(2, '0');
+ return year + '-' + month + '-' + day;
}
- function defaultMeta() {
+ function cloneMeta(value) {
+ var source = value && typeof value === 'object' ? value : {};
return {
- enabled: false,
- directoryName: null,
- lastWriteAt: null,
- lastWriteOk: false,
- lastWriteError: null,
- lastRemindDay: null,
- lastPermissionOk: false,
- lastRestorePromptDay: null,
- recordCountAtLastWrite: 0,
- createdAt: nowIso(),
- updatedAt: nowIso()
+ directoryName: source.directoryName ? String(source.directoryName) : null,
+ lastWriteAt: source.lastWriteAt ? String(source.lastWriteAt) : null,
+ lastChecksum: source.lastChecksum ? String(source.lastChecksum) : null,
+ lastWriteError: source.lastWriteError ? String(source.lastWriteError) : null,
+ awaitingRestore: source.awaitingRestore === true
};
}
- function readMeta() {
+ function getIndexedDB() {
try {
- var raw = global.localStorage && global.localStorage.getItem(META_KEY);
- if (!raw) {
- return defaultMeta();
- }
- var parsed = JSON.parse(raw);
- return Object.assign(defaultMeta(), isPlainObject(parsed) ? parsed : {});
+ return global.indexedDB || null;
} catch (_) {
- return defaultMeta();
- }
- }
-
- function writeMeta(patch) {
- var next = Object.assign({}, state.meta || readMeta(), isPlainObject(patch) ? patch : {}, {
- updatedAt: nowIso()
- });
- state.meta = next;
- try {
- if (global.localStorage) {
- global.localStorage.setItem(META_KEY, JSON.stringify(next));
- }
- } catch (error) {
- console.warn('[ExternalBackup] meta write failed:', error);
+ return null;
}
- dispatchStatus();
- return next;
- }
-
- function dispatchStatus() {
- try {
- global.dispatchEvent(new CustomEvent('external-backup-status', {
- detail: getStatus()
- }));
- } catch (_) { /* ignore */ }
}
function supportsFileSystemAccess() {
- return !!(
- global.showDirectoryPicker &&
- typeof global.showDirectoryPicker === 'function' &&
- global.isSecureContext !== false
- );
- }
-
- function supportsFilePickerRead() {
- return !!(global.showOpenFilePicker && typeof global.showOpenFilePicker === 'function');
+ return typeof global.showDirectoryPicker === 'function'
+ && global.isSecureContext !== false;
}
- function openHandleDb() {
+ function openBindingDb() {
return new Promise(function (resolve, reject) {
- if (!global.indexedDB) {
- reject(new Error('IndexedDB unavailable'));
+ var indexedDb = getIndexedDB();
+ if (!indexedDb) {
+ reject(new Error('IndexedDB unavailable for directory binding'));
+ return;
+ }
+ var request;
+ try {
+ request = indexedDb.open(DB_NAME, DB_VERSION);
+ } catch (error) {
+ reject(error);
return;
}
- var request = global.indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = function () {
- reject(request.error || new Error('Failed to open external backup DB'));
+ reject(request.error || new Error('Failed to open external backup binding database'));
};
request.onupgradeneeded = function (event) {
var db = event.target.result;
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.createObjectStore(STORE_NAME);
- }
+ if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
};
request.onsuccess = function () {
resolve(request.result);
@@ -149,50 +105,73 @@
});
}
- function idbRequest(request) {
- return new Promise(function (resolve, reject) {
- request.onsuccess = function () { resolve(request.result); };
- request.onerror = function () { reject(request.error); };
- });
- }
-
- async function saveDirectoryHandle(handle) {
- var db = await openHandleDb();
+ async function readStoredValue(key) {
+ var db = await openBindingDb();
try {
- var tx = db.transaction(STORE_NAME, 'readwrite');
- var store = tx.objectStore(STORE_NAME);
- await idbRequest(store.put(handle, HANDLE_KEY));
+ return await new Promise(function (resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readonly');
+ var request = tx.objectStore(STORE_NAME).get(key);
+ request.onsuccess = function () { resolve(request.result); };
+ request.onerror = function () { reject(request.error || tx.error); };
+ tx.onabort = function () { reject(tx.error || new Error('Binding read transaction aborted')); };
+ });
} finally {
try { db.close(); } catch (_) { /* ignore */ }
}
}
- async function loadDirectoryHandle() {
- var db = await openHandleDb();
+ async function writeStoredValues(values) {
+ var db = await openBindingDb();
try {
- var tx = db.transaction(STORE_NAME, 'readonly');
- var store = tx.objectStore(STORE_NAME);
- return await idbRequest(store.get(HANDLE_KEY));
+ await new Promise(function (resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ var store = tx.objectStore(STORE_NAME);
+ Object.keys(values).forEach(function (key) {
+ store.put(values[key], key);
+ });
+ tx.oncomplete = function () { resolve(); };
+ tx.onerror = function () { reject(tx.error || new Error('Binding write transaction failed')); };
+ tx.onabort = function () { reject(tx.error || new Error('Binding write transaction aborted')); };
+ });
} finally {
try { db.close(); } catch (_) { /* ignore */ }
}
}
- async function clearDirectoryHandle() {
- var db = await openHandleDb();
+ async function clearStoredBinding() {
+ var db = await openBindingDb();
try {
- var tx = db.transaction(STORE_NAME, 'readwrite');
- var store = tx.objectStore(STORE_NAME);
- await idbRequest(store.delete(HANDLE_KEY));
+ await new Promise(function (resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ var store = tx.objectStore(STORE_NAME);
+ store.delete(HANDLE_KEY);
+ store.delete(META_KEY);
+ tx.oncomplete = function () { resolve(); };
+ tx.onerror = function () { reject(tx.error || new Error('Binding clear transaction failed')); };
+ tx.onabort = function () { reject(tx.error || new Error('Binding clear transaction aborted')); };
+ });
} finally {
try { db.close(); } catch (_) { /* ignore */ }
}
}
- async function queryHandlePermission(handle, mode) {
- if (!handle) {
- return 'denied';
+ async function persistMeta(patch) {
+ if (state.suspended) return false;
+ state.meta = Object.assign({}, state.meta, cloneMeta(Object.assign({}, state.meta, patch || {})));
+ try {
+ await writeStoredValues((function () {
+ var values = {};
+ values[META_KEY] = state.meta;
+ return values;
+ })());
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] metadata persistence failed:', error);
}
+ return true;
+ }
+
+ async function queryPermission(handle, mode) {
+ if (!handle) return 'denied';
try {
if (typeof handle.queryPermission === 'function') {
return await handle.queryPermission({ mode: mode || 'readwrite' });
@@ -201,120 +180,39 @@
return 'prompt';
}
- async function requestHandlePermission(handle, mode) {
- if (!handle) {
- return 'denied';
- }
- try {
- if (typeof handle.requestPermission === 'function') {
- return await handle.requestPermission({ mode: mode || 'readwrite' });
- }
- } catch (_) { /* ignore */ }
- // Some Chromium builds treat existing handles as usable without requestPermission.
- return await queryHandlePermission(handle, mode);
- }
-
async function ensurePermission(handle, interactive) {
- if (!handle) {
- return false;
- }
- var current = await queryHandlePermission(handle, 'readwrite');
- if (current === 'granted') {
- writeMeta({ lastPermissionOk: true });
+ var permission = await queryPermission(handle, 'readwrite');
+ if (permission === 'granted') {
+ state.permission = permission;
return true;
}
if (!interactive) {
- writeMeta({ lastPermissionOk: false });
+ state.permission = permission;
return false;
}
- var next = await requestHandlePermission(handle, 'readwrite');
- var ok = next === 'granted';
- writeMeta({ lastPermissionOk: ok });
- return ok;
- }
-
- async function requestPersistentStorage() {
try {
- if (!global.navigator || !global.navigator.storage || typeof global.navigator.storage.persist !== 'function') {
- return false;
- }
- var already = typeof global.navigator.storage.persisted === 'function'
- ? await global.navigator.storage.persisted()
- : false;
- if (already) {
- return true;
+ if (typeof handle.requestPermission === 'function') {
+ permission = await handle.requestPermission({ mode: 'readwrite' });
}
- return await global.navigator.storage.persist();
- } catch (error) {
- console.warn('[ExternalBackup] persist() failed:', error);
- return false;
- }
- }
-
- async function captureSnapshot() {
- if (global.BackupAPI && typeof global.BackupAPI.captureSnapshot === 'function') {
- var snapshot = await global.BackupAPI.captureSnapshot();
- return global.BackupAPI.normalizePayload
- ? global.BackupAPI.normalizePayload(snapshot)
- : snapshot;
- }
-
- var practiceRecords = [];
- var userStats = null;
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') {
- var listed = await global.PracticeRecordAPI.list();
- practiceRecords = Array.isArray(listed) ? listed : [];
- }
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.readStats === 'function') {
- userStats = await global.PracticeRecordAPI.readStats();
+ } catch (_) {
+ permission = 'denied';
}
-
- var examIndex = [];
- var storageVersion = null;
- try {
- if (global.storage && typeof global.storage.get === 'function') {
- examIndex = await global.storage.get('exam_index', []);
- storageVersion = await global.storage.get('storage_version', null);
- }
- } catch (_) { /* ignore */ }
-
- return {
- practice_records: practiceRecords,
- practiceRecords: practiceRecords,
- user_stats: userStats,
- userStats: userStats,
- exam_index: Array.isArray(examIndex) ? examIndex : [],
- examIndex: Array.isArray(examIndex) ? examIndex : [],
- storage_version: storageVersion,
- storageVersion: storageVersion
- };
+ state.permission = permission;
+ return permission === 'granted';
}
- function buildExportDocument(snapshot) {
- return {
- exportDate: nowIso(),
- version: VERSION,
- source: 'external-backup-service',
- note: 'Disk backup for IELTS Atlas. Survives browser cache clears. Import via 设置 → 导入数据.',
- data: snapshot
- };
- }
-
- function stableHash(payload) {
+ async function requestPersistentStorage() {
try {
- var text = JSON.stringify(payload);
- var hash = 0;
- for (var i = 0; i < text.length; i += 1) {
- hash = ((hash << 5) - hash) + text.charCodeAt(i);
- hash |= 0;
- }
- return String(hash);
+ var storage = global.navigator && global.navigator.storage;
+ if (!storage || typeof storage.persist !== 'function') return false;
+ if (typeof storage.persisted === 'function' && await storage.persisted()) return true;
+ return await storage.persist();
} catch (_) {
- return String(Date.now());
+ return false;
}
}
- async function writeTextFile(directoryHandle, filename, text) {
+ async function writeAndVerify(directoryHandle, filename, text, snapshot) {
var fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
var writable = await fileHandle.createWritable();
try {
@@ -324,697 +222,479 @@
try { await writable.abort(); } catch (_) { /* ignore */ }
throw error;
}
- }
- async function readTextFile(directoryHandle, filename) {
- var fileHandle = await directoryHandle.getFileHandle(filename, { create: false });
var file = await fileHandle.getFile();
- return await file.text();
+ var storedText = await file.text();
+ var stored;
+ try {
+ stored = JSON.parse(storedText);
+ } catch (error) {
+ throw new Error('Backup verification failed: written file is not valid JSON');
+ }
+ if (!stored || stored.format !== 'ielts-atlas-data-v2'
+ || stored.schemaVersion !== snapshot.schemaVersion
+ || stored.checksum !== snapshot.checksum) {
+ throw new Error('Backup verification failed: snapshot metadata mismatch');
+ }
+ return storedText.length;
}
- async function writeToBoundDirectory(options) {
- var opts = options || {};
- if (state.writing) {
- return { success: false, reason: 'busy' };
+ async function fileExists(directoryHandle, filename) {
+ try {
+ await directoryHandle.getFileHandle(filename, { create: false });
+ return true;
+ } catch (error) {
+ if (error && error.name === 'NotFoundError') return false;
+ throw error;
}
- if (!state.directoryHandle) {
- return { success: false, reason: 'unbound' };
+ }
+
+ function uniqueGenerationFilename(date) {
+ var time = [
+ String(date.getHours()).padStart(2, '0'),
+ String(date.getMinutes()).padStart(2, '0'),
+ String(date.getSeconds()).padStart(2, '0'),
+ String(date.getMilliseconds()).padStart(3, '0')
+ ].join('');
+ return 'ielts-atlas-backup-' + dayKey(date) + '-' + time + '.json';
+ }
+
+ function requireBackupApi() {
+ var backups = global.AppData && global.AppData.backups;
+ if (!backups || typeof backups.export !== 'function'
+ || typeof backups.previewImport !== 'function'
+ || typeof backups.commitImport !== 'function') {
+ throw new Error('AppData v2 backup API is unavailable');
}
+ return backups;
+ }
- state.writing = true;
+ async function withDiskWriteLock(callback) {
+ var previous = state.writeQueue.catch(function () {});
+ var releaseCurrent;
+ state.writeQueue = new Promise(function (resolve) {
+ releaseCurrent = resolve;
+ });
+ await previous;
try {
- var interactive = opts.interactive === true;
- var permitted = await ensurePermission(state.directoryHandle, interactive);
- if (!permitted) {
- writeMeta({ lastWriteOk: false, lastWriteError: 'permission_denied' });
- return { success: false, reason: 'permission_denied' };
+ var locks = global.navigator && global.navigator.locks;
+ if (locks && typeof locks.request === 'function') {
+ return await locks.request('ielts-atlas-external-backup-write', { mode: 'exclusive' }, callback);
}
+ return await callback();
+ } finally {
+ releaseCurrent();
+ }
+ }
- var snapshot = await captureSnapshot();
- var doc = buildExportDocument(snapshot);
- var text = JSON.stringify(doc, null, 2);
- var hash = stableHash(doc.data);
+ async function refreshStoredBindingForWrite() {
+ if (state.suspended) return;
+ var stored = await Promise.all([
+ readStoredValue(HANDLE_KEY),
+ readStoredValue(META_KEY)
+ ]);
+ state.directoryHandle = stored[0] || null;
+ if (stored[1]) state.meta = cloneMeta(stored[1]);
+ if (state.directoryHandle && !state.meta.directoryName) {
+ state.meta.directoryName = state.directoryHandle.name || 'backup';
+ }
+ }
- if (!opts.force && hash === state.lastSnapshotHash && state.meta && state.meta.lastWriteOk) {
- return { success: true, reason: 'unchanged', skipped: true };
+ async function writeToBoundDirectory(options) {
+ var opts = options || {};
+ await ensureReady();
+ if (state.suspended) return { success: false, reason: 'suspended' };
+ return withDiskWriteLock(async function () {
+ if (state.suspended) return { success: false, reason: 'suspended' };
+ if (state.writing) return { success: false, reason: 'busy' };
+ try {
+ await refreshStoredBindingForWrite();
+ } catch (error) {
+ return { success: false, reason: 'binding_unavailable', error: error };
}
-
- await writeTextFile(state.directoryHandle, LATEST_FILENAME, text);
-
- if (opts.datedCopy !== false) {
- try {
- var dated = 'practice-backup-' + dayKey(new Date()) + '.json';
- await writeTextFile(state.directoryHandle, dated, text);
- } catch (datedError) {
- console.warn('[ExternalBackup] dated copy failed:', datedError);
- }
+ if (!state.directoryHandle) return { success: false, reason: 'unbound' };
+ if (state.meta.awaitingRestore && opts.allowOverwriteExisting !== true) {
+ return { success: false, reason: 'restore_required' };
}
- var recordCount = Array.isArray(snapshot.practice_records)
- ? snapshot.practice_records.length
- : (Array.isArray(snapshot.practiceRecords) ? snapshot.practiceRecords.length : 0);
+ var startedGeneration = state.dirtyGeneration;
+ var followupNeeded = false;
+ state.writing = true;
+ refreshPanel();
+ try {
+ if (!await ensurePermission(state.directoryHandle, opts.interactive === true)) {
+ await persistMeta({ lastWriteError: 'permission_denied' });
+ return { success: false, reason: 'permission_denied' };
+ }
- state.lastSnapshotHash = hash;
- state.dirty = false;
- writeMeta({
- enabled: true,
- lastWriteAt: nowIso(),
- lastWriteOk: true,
- lastWriteError: null,
- lastPermissionOk: true,
- recordCountAtLastWrite: recordCount
- });
+ var backups = requireBackupApi();
+ var snapshot = await backups.export();
+ if (!snapshot || snapshot.format !== 'ielts-atlas-data-v2' || !snapshot.checksum) {
+ throw new Error('AppData returned an invalid v2 backup snapshot');
+ }
+ if (!opts.force && snapshot.checksum === state.meta.lastChecksum) {
+ state.dirty = state.dirtyGeneration !== startedGeneration;
+ followupNeeded = state.dirty;
+ return { success: true, reason: 'unchanged', skipped: true, checksum: snapshot.checksum };
+ }
- return {
- success: true,
- reason: 'written',
- filename: LATEST_FILENAME,
- recordCount: recordCount,
- bytes: text.length
- };
- } catch (error) {
- console.error('[ExternalBackup] write failed:', error);
- writeMeta({
- lastWriteOk: false,
- lastWriteError: error && error.message ? error.message : String(error)
- });
- return {
- success: false,
- reason: 'write_error',
- error: error
- };
- } finally {
- state.writing = false;
- }
+ var text = JSON.stringify(snapshot, null, 2);
+ var writeDate = new Date();
+ var datedFilename = 'ielts-atlas-backup-' + dayKey(writeDate) + '.json';
+ if (await fileExists(state.directoryHandle, datedFilename)) {
+ datedFilename = uniqueGenerationFilename(writeDate);
+ }
+ await writeAndVerify(state.directoryHandle, datedFilename, text, snapshot);
+ var bytes = await writeAndVerify(state.directoryHandle, LATEST_FILENAME, text, snapshot);
+
+ var latestSnapshot = await backups.export();
+ var changedDuringWrite = state.dirtyGeneration !== startedGeneration
+ || !latestSnapshot || latestSnapshot.checksum !== snapshot.checksum;
+ if (changedDuringWrite && state.dirtyGeneration === startedGeneration) {
+ state.dirtyGeneration += 1;
+ }
+ state.dirty = changedDuringWrite;
+ followupNeeded = changedDuringWrite;
+ await persistMeta({
+ directoryName: state.directoryHandle.name || state.meta.directoryName || 'backup',
+ lastWriteAt: nowIso(),
+ lastChecksum: snapshot.checksum,
+ lastWriteError: null
+ });
+ return {
+ success: true,
+ reason: 'written',
+ filename: LATEST_FILENAME,
+ generationFilename: datedFilename,
+ checksum: snapshot.checksum,
+ bytes: bytes,
+ followupPending: followupNeeded
+ };
+ } catch (error) {
+ followupNeeded = state.dirtyGeneration !== startedGeneration;
+ await persistMeta({ lastWriteError: error && error.message ? error.message : String(error) });
+ if (global.console && console.error) console.error('[ExternalBackup v2] write failed:', error);
+ return { success: false, reason: 'write_error', error: error };
+ } finally {
+ state.writing = false;
+ if (followupNeeded && state.directoryHandle) scheduleSilentFlush();
+ refreshPanel();
+ }
+ });
}
async function bindDirectory(options) {
+ if (state.suspended) throw new Error('本地备份服务正在重置');
if (!supportsFileSystemAccess()) {
- throw new Error('当前浏览器不支持绑定本地文件夹(需要 Chrome/Edge,且非 file:// 打开)');
+ throw new Error('当前浏览器不支持绑定本地文件夹(请使用 Chrome/Edge 并通过 http(s) 或 localhost 打开)');
}
-
var handle = await global.showDirectoryPicker({
id: 'ielts-atlas-external-backup',
mode: 'readwrite',
startIn: 'documents'
});
+ if (!handle) throw new Error('未选择文件夹');
+ if (!await ensurePermission(handle, true)) throw new Error('未获得文件夹读写权限');
- if (!handle) {
- throw new Error('未选择文件夹');
- }
-
- var permitted = await ensurePermission(handle, true);
- if (!permitted) {
- throw new Error('未获得文件夹读写权限');
- }
-
- await saveDirectoryHandle(handle);
- state.directoryHandle = handle;
- writeMeta({
- enabled: true,
+ var existingBackupFound = await fileExists(handle, LATEST_FILENAME);
+ var meta = cloneMeta({
directoryName: handle.name || 'backup',
- lastPermissionOk: true,
- lastWriteError: null
+ lastWriteAt: null,
+ lastChecksum: null,
+ lastWriteError: null,
+ awaitingRestore: existingBackupFound
});
+ var values = {};
+ values[HANDLE_KEY] = handle;
+ values[META_KEY] = meta;
+ await writeStoredValues(values);
+ state.directoryHandle = handle;
+ state.meta = meta;
+ state.dirty = !existingBackupFound;
+ state.dirtyGeneration += 1;
+ await requestPersistentStorage();
- var writeNow = !options || options.writeNow !== false;
var writeResult = null;
- if (writeNow) {
+ if (!existingBackupFound && (!options || options.writeNow !== false)) {
writeResult = await writeToBoundDirectory({ interactive: true, force: true });
}
-
- await requestPersistentStorage();
+ refreshPanel();
return {
- directoryName: handle.name || 'backup',
+ directoryName: meta.directoryName,
+ existingBackupFound: existingBackupFound,
writeResult: writeResult
};
}
- async function unbindDirectory() {
- state.directoryHandle = null;
- state.lastSnapshotHash = null;
- try {
- await clearDirectoryHandle();
- } catch (error) {
- console.warn('[ExternalBackup] clear handle failed:', error);
+ function cancelSilentFlush() {
+ if (state.silentFlushTimer) {
+ global.clearTimeout(state.silentFlushTimer);
+ state.silentFlushTimer = null;
}
- writeMeta({
- enabled: false,
- directoryName: null,
- lastPermissionOk: false,
- lastWriteError: null
- });
- return true;
}
- async function restoreFromLatest(options) {
- var opts = options || {};
- if (!state.directoryHandle) {
- throw new Error('尚未绑定备份文件夹');
- }
- var permitted = await ensurePermission(state.directoryHandle, opts.interactive !== false);
- if (!permitted) {
- throw new Error('需要文件夹读取权限才能恢复');
- }
-
- var text = await readTextFile(state.directoryHandle, LATEST_FILENAME);
- var payload = JSON.parse(text);
- var data = payload && payload.data ? payload.data : payload;
-
- if (global.BackupAPI && typeof global.BackupAPI.restorePayload === 'function') {
- await global.BackupAPI.restorePayload(data, opts);
- } else if (global.DataBackupManager || global.dataBackupManager) {
- throw new Error('请使用设置页「导入数据」选择备份文件完成恢复');
- } else {
- throw new Error('恢复 API 未就绪');
- }
+ function clearBindingState() {
+ state.directoryHandle = null;
+ state.permission = 'prompt';
+ state.dirty = false;
+ state.dirtyGeneration += 1;
+ state.meta = cloneMeta({});
+ refreshPanel();
+ }
- writeMeta({ lastRestorePromptDay: dayKey(new Date()) });
+ async function unbindDirectory() {
+ cancelSilentFlush();
+ await withDiskWriteLock(async function () {
+ await clearStoredBinding();
+ clearBindingState();
+ });
return true;
}
- async function pickAndRestoreFile() {
- if (supportsFilePickerRead()) {
- var handles = await global.showOpenFilePicker({
- multiple: false,
- types: [{
- description: 'IELTS Atlas backup JSON',
- accept: { 'application/json': ['.json'] }
- }]
- });
- var fileHandle = handles && handles[0];
- if (!fileHandle) {
- throw new Error('未选择文件');
- }
- var file = await fileHandle.getFile();
- var text = await file.text();
- var payload = JSON.parse(text);
- var data = payload && payload.data ? payload.data : payload;
- if (global.BackupAPI && typeof global.BackupAPI.restorePayload === 'function') {
- await global.BackupAPI.restorePayload(data);
- return true;
- }
- throw new Error('恢复 API 未就绪');
+ async function prepareForFullReset() {
+ state.suspended = true;
+ cancelSilentFlush();
+ if (typeof state.unsubscribeCommitted === 'function') {
+ try { state.unsubscribeCommitted(); } catch (_) { /* ignore */ }
}
-
- // Fallback: reuse existing import flow
- if (typeof global.importData === 'function') {
- global.importData();
- return false;
+ state.unsubscribeCommitted = null;
+ if (global.document && state.visibilityHandler) {
+ try { global.document.removeEventListener('visibilitychange', state.visibilityHandler); } catch (_) { /* ignore */ }
}
- throw new Error('当前环境不支持文件选择器,请使用「导入数据」');
- }
+ state.visibilityHandler = null;
+ state.initialized = false;
- async function countPracticeRecords() {
- try {
- if (global.PracticeRecordAPI && typeof global.PracticeRecordAPI.list === 'function') {
- var list = await global.PracticeRecordAPI.list();
- return Array.isArray(list) ? list.length : 0;
- }
- } catch (_) { /* ignore */ }
- return 0;
+ await withDiskWriteLock(async function () {
+ await clearStoredBinding();
+ clearBindingState();
+ });
+ return {
+ success: true,
+ diskFilesPreserved: true,
+ bindingCleared: true
+ };
}
- async function hasReadableLatestBackup() {
- if (!state.directoryHandle) {
- return false;
+ async function readLatestPayload(interactive) {
+ await ensureReady();
+ if (!state.directoryHandle) throw new Error('请先绑定备份文件夹');
+ if (!await ensurePermission(state.directoryHandle, interactive === true)) {
+ throw new Error('需要允许文件夹访问权限');
}
+ var fileHandle;
try {
- var permitted = await ensurePermission(state.directoryHandle, false);
- if (!permitted) {
- return false;
- }
- await state.directoryHandle.getFileHandle(LATEST_FILENAME, { create: false });
- return true;
- } catch (_) {
- return false;
- }
- }
-
- function buildReminder(status) {
- if (!status) {
- return null;
- }
-
- if (!status.supported) {
- return null;
- }
-
- if (!status.bound) {
- return {
- level: 'info',
- code: 'bind',
- title: '建议绑定本地备份文件夹',
- message: '练习数据只存在浏览器内,清缓存会丢失。绑定文件夹后可一键写入磁盘备份。',
- primaryAction: 'bind',
- primaryLabel: '绑定文件夹',
- secondaryAction: null,
- secondaryLabel: null
- };
- }
-
- if (!status.permissionGranted) {
- return {
- level: 'warning',
- code: 'permission',
- title: '本地备份需要重新授权',
- message: '已绑定「' + (status.directoryName || '备份文件夹') + '」,但当前没有写入权限。',
- primaryAction: 'reauth',
- primaryLabel: '重新授权并写入',
- secondaryAction: 'unbind',
- secondaryLabel: '解除绑定'
- };
- }
-
- if (status.staleWrite || status.dirty) {
- return {
- level: 'info',
- code: 'write',
- title: '本地备份可更新',
- message: status.lastWriteAt
- ? ('距上次写入已超过一天或有新练习数据(上次:' + formatTime(status.lastWriteAt) + ')。')
- : '尚未写入磁盘备份,建议现在写入。',
- primaryAction: 'write',
- primaryLabel: '立即写入备份',
- secondaryAction: null,
- secondaryLabel: null
- };
+ fileHandle = await state.directoryHandle.getFileHandle(LATEST_FILENAME, { create: false });
+ } catch (error) {
+ throw new Error('未找到 ' + LATEST_FILENAME);
}
-
- return null;
- }
-
- function formatTime(iso) {
- if (!iso) return '—';
+ var file = await fileHandle.getFile();
+ var text = await file.text();
try {
- return new Date(iso).toLocaleString();
+ return JSON.parse(text);
} catch (_) {
- return String(iso);
+ throw new Error('本地备份文件不是有效的 JSON');
}
}
- function shouldShowDailyReminder(reminder) {
- if (!reminder) {
- return false;
+ function summarizePreview(preview) {
+ var keys = Array.isArray(preview.keys) ? preview.keys : [];
+ var cleared = Array.isArray(preview.clearedKeys) ? preview.clearedKeys : [];
+ var practice = preview.practice || {};
+ var lines = [
+ '将从本地磁盘备份覆盖恢复当前数据。',
+ '格式:' + (preview.format || 'unknown') + (preview.scope ? ' / ' + preview.scope : ''),
+ '数据域:' + (keys.length ? keys.join('、') : '无')
+ ];
+ if (cleared.length) lines.push('将清空:' + cleared.join('、'));
+ if (practice && Number.isFinite(Number(practice.finalCount))) {
+ lines.push('练习记录:现有 ' + (Number(practice.existingCount) || 0)
+ + ' 条 → 恢复后 ' + Number(practice.finalCount) + ' 条'
+ + '(删除 ' + (Number(practice.removedCount) || 0) + ' 条)');
}
- var meta = state.meta || readMeta();
- var today = dayKey(new Date());
- if (meta.lastRemindDay === today) {
- return false;
+ var diagnostics = preview.diagnostics || {};
+ if (Array.isArray(diagnostics.missingKeys) && diagnostics.missingKeys.length) {
+ lines.push('备份缺失且将保留现状:' + diagnostics.missingKeys.join('、'));
}
- return true;
- }
-
- function markReminded() {
- writeMeta({ lastRemindDay: dayKey(new Date()) });
- }
-
- function removeRemindBanner() {
- var el = global.document && global.document.getElementById(REMIND_BANNER_ID);
- if (el && el.parentNode) {
- el.parentNode.removeChild(el);
+ if (Array.isArray(diagnostics.repairedKeys) && diagnostics.repairedKeys.length) {
+ lines.push('已修复旧格式数据:' + diagnostics.repairedKeys.join('、'));
}
- }
-
- function renderRemindBanner(reminder) {
- if (!global.document || !global.document.body || !reminder) {
- return;
+ if (Array.isArray(diagnostics.ignoredKeys) && diagnostics.ignoredKeys.length) {
+ lines.push('已隔离不安全数据:' + diagnostics.ignoredKeys.join('、'));
}
-
- removeRemindBanner();
-
- var banner = global.document.createElement('div');
- banner.id = REMIND_BANNER_ID;
- banner.className = 'external-backup-banner external-backup-banner--' + (reminder.level || 'info');
- banner.setAttribute('role', 'status');
-
- var glass = global.document.createElement('div');
- glass.className = 'external-backup-banner__glass';
-
- var text = global.document.createElement('div');
- text.className = 'external-backup-banner__text';
- var title = global.document.createElement('strong');
- title.textContent = reminder.title;
- var msg = global.document.createElement('span');
- msg.textContent = reminder.message;
- text.appendChild(title);
- text.appendChild(msg);
-
- var actions = global.document.createElement('div');
- actions.className = 'external-backup-banner__actions';
-
- function makeBtn(label, action, primary) {
- var btn = global.document.createElement('button');
- btn.type = 'button';
- btn.className = primary
- ? 'btn external-backup-banner__btn external-backup-banner__btn--primary'
- : 'btn external-backup-banner__btn external-backup-banner__btn--ghost';
- btn.textContent = label;
- btn.addEventListener('click', function () {
- handleReminderAction(action);
- });
- return btn;
- }
-
- if (reminder.primaryAction) {
- actions.appendChild(makeBtn(reminder.primaryLabel || '确定', reminder.primaryAction, true));
- }
- if (reminder.secondaryAction) {
- actions.appendChild(makeBtn(reminder.secondaryLabel || '取消', reminder.secondaryAction, false));
+ if (Array.isArray(preview.warnings) && preview.warnings.length) {
+ lines.push('警告:' + preview.warnings.join(';'));
}
-
- var dismiss = global.document.createElement('button');
- dismiss.type = 'button';
- dismiss.className = 'external-backup-banner__dismiss';
- dismiss.setAttribute('aria-label', '关闭提醒');
- dismiss.textContent = '×';
- dismiss.addEventListener('click', function () {
- markReminded();
- removeRemindBanner();
- });
-
- glass.appendChild(text);
- glass.appendChild(actions);
- glass.appendChild(dismiss);
- banner.appendChild(glass);
- global.document.body.appendChild(banner);
- markReminded();
+ lines.push('', '恢复前会创建一个应用内安全快照。是否继续?');
+ return lines.join('\n');
}
- async function handleReminderAction(action) {
+ function createOperationId(prefix) {
try {
- if (action === 'bind') {
- var bound = await bindDirectory({ writeNow: true });
- removeRemindBanner();
- if (bound.writeResult && bound.writeResult.success) {
- notify('已绑定并写入本地备份:' + (bound.directoryName || ''), 'success');
- } else {
- notify('已绑定文件夹:' + (bound.directoryName || '') + ',请点击「立即写入备份」', 'info');
- }
- refreshUi();
- return;
- }
- if (action === 'reauth' || action === 'write') {
- var result = await writeToBoundDirectory({ interactive: true, force: true });
- removeRemindBanner();
- if (result.success) {
- notify(result.skipped ? '备份已是最新' : '本地备份已写入', 'success');
- } else if (result.reason === 'permission_denied') {
- notify('仍未获得文件夹权限,请在浏览器弹窗中允许访问', 'warning');
- } else if (result.reason === 'unbound') {
- notify('尚未绑定备份文件夹', 'warning');
- } else {
- notify('写入失败:' + (result.error && result.error.message ? result.error.message : result.reason), 'error');
- }
- refreshUi();
- return;
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') {
+ return prefix + '-' + global.crypto.randomUUID();
}
- if (action === 'unbind') {
- await unbindDirectory();
- removeRemindBanner();
- notify('已解除本地备份文件夹绑定', 'info');
- refreshUi();
- }
- } catch (error) {
- if (error && error.name === 'AbortError') {
- notify('已取消', 'info');
- return;
- }
- console.error('[ExternalBackup] reminder action failed:', error);
- notify(error && error.message ? error.message : '操作失败', 'error');
- }
- }
-
- function getStatus() {
- var meta = state.meta || readMeta();
- var lastWriteAt = meta.lastWriteAt || null;
- var lastWriteAge = lastWriteAt ? (Date.now() - new Date(lastWriteAt).getTime()) : Infinity;
- var staleWrite = !lastWriteAt || !Number.isFinite(lastWriteAge) || lastWriteAge >= STALE_WRITE_MS;
- var permissionGranted = !!(state.directoryHandle && meta.lastPermissionOk);
-
- return {
- supported: supportsFileSystemAccess(),
- secureContext: global.isSecureContext !== false,
- bound: !!(state.directoryHandle && meta.enabled),
- directoryName: meta.directoryName || null,
- permissionGranted: permissionGranted,
- lastWriteAt: lastWriteAt,
- lastWriteOk: !!meta.lastWriteOk,
- lastWriteError: meta.lastWriteError || null,
- lastWriteAgeMs: Number.isFinite(lastWriteAge) ? lastWriteAge : null,
- staleWrite: staleWrite,
- dirty: !!state.dirty,
- writing: !!state.writing,
- recordCountAtLastWrite: meta.recordCountAtLastWrite || 0,
- latestFilename: LATEST_FILENAME,
- lastRemindDay: meta.lastRemindDay || null
- };
- }
-
- async function refreshPermissionFlag() {
- if (!state.directoryHandle) {
- writeMeta({ lastPermissionOk: false });
- return false;
- }
- var ok = await ensurePermission(state.directoryHandle, false);
- return ok;
+ } catch (_) { /* ignore */ }
+ return prefix + '-' + Date.now() + '-' + Math.random().toString(16).slice(2);
}
- async function maybeShowDailyReminder(options) {
+ async function restorePayload(payload, options) {
var opts = options || {};
- await ensureReady();
- await refreshPermissionFlag();
- var status = getStatus();
- var reminder = buildReminder(status);
- if (!reminder) {
- if (opts.force) {
- removeRemindBanner();
- }
- return null;
- }
- if (opts.force || shouldShowDailyReminder(reminder)) {
- if (opts.render !== false) {
- renderRemindBanner(reminder);
+ var backups = requireBackupApi();
+ var preview = await backups.previewImport(payload, {
+ replace: true,
+ practiceMode: 'replace',
+ applyClears: true,
+ fullRestore: true
+ });
+ var confirmed = opts.confirmed === true;
+ if (!confirmed) {
+ try {
+ confirmed = global.confirm(summarizePreview(preview));
+ } catch (_) {
+ confirmed = false;
}
- return reminder;
}
- return null;
- }
+ if (!confirmed) return { success: false, reason: 'cancelled', preview: preview };
- async function maybePromptEmptyStoreRecovery() {
- await ensureReady();
- var count = await countPracticeRecords();
- if (count > 0) {
- return false;
- }
- var readable = await hasReadableLatestBackup();
- if (!readable) {
- return false;
- }
-
- var meta = state.meta || readMeta();
- var today = dayKey(new Date());
- if (meta.lastRestorePromptDay === today) {
- return false;
- }
- writeMeta({ lastRestorePromptDay: today });
-
- var dirName = (state.meta && state.meta.directoryName) || '备份文件夹';
- var ok = false;
- try {
- ok = global.confirm(
- '检测到浏览器内练习记录为空,但本地备份文件夹「' + dirName +
- '」中有 ' + LATEST_FILENAME + '。是否立即恢复?'
- );
- } catch (_) {
- ok = false;
- }
- if (!ok) {
- return false;
- }
+ await backups.create({
+ type: 'pre-external-restore',
+ operationId: createOperationId('pre-external-restore')
+ });
+ var result = await backups.commitImport(preview.id, {
+ operationId: opts.operationId || createOperationId('external-restore'),
+ confirmDestructive: preview.destructive === true
+ });
try {
- await restoreFromLatest({ interactive: true });
- notify('已从本地备份文件夹恢复数据', 'success');
- try {
- if (typeof global.updateOverview === 'function') {
- global.updateOverview();
- }
- } catch (_) { /* ignore */ }
- try {
- global.dispatchEvent(new CustomEvent('practiceRecordsUpdated', {
- detail: { source: 'external-backup-restore' }
- }));
- } catch (_) { /* ignore */ }
- return true;
- } catch (error) {
- console.error('[ExternalBackup] restore failed:', error);
- notify('恢复失败:' + (error && error.message ? error.message : error), 'error');
- return false;
+ if (typeof backups.recordImport === 'function') {
+ await backups.recordImport({
+ source: 'external-backup',
+ format: preview.format,
+ keys: preview.keys,
+ clearedKeys: preview.clearedKeys,
+ practice: preview.practice || null
+ });
+ }
+ } catch (historyError) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] import history failed:', historyError);
}
+ return { success: true, preview: preview, result: result };
}
- function markDirty() {
- state.dirty = true;
- dispatchStatus();
- scheduleSilentFlush();
+ async function restoreFromLatest(options) {
+ var payload = await readLatestPayload(true);
+ var result = await restorePayload(payload, options);
+ if (result && result.success) {
+ state.dirty = false;
+ await persistMeta({
+ lastChecksum: payload && payload.checksum ? payload.checksum : state.meta.lastChecksum,
+ lastWriteError: null,
+ awaitingRestore: false
+ });
+ }
+ return result;
}
- /**
- * When folder permission is already granted, write silently after data changes.
- * Never auto-downloads; never prompts for permission here.
- */
function scheduleSilentFlush() {
- if (state.silentFlushTimer) {
- global.clearTimeout(state.silentFlushTimer);
- }
+ if (state.suspended || state.meta.awaitingRestore) return;
+ if (state.silentFlushTimer) global.clearTimeout(state.silentFlushTimer);
state.silentFlushTimer = global.setTimeout(function () {
state.silentFlushTimer = null;
- flushSilentlyIfPermitted().catch(function (error) {
- console.warn('[ExternalBackup] silent flush failed:', error);
+ return flushSilentlyIfPermitted().catch(function (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] silent flush failed:', error);
});
- }, 8000);
+ }, WRITE_DELAY_MS);
+ }
+
+ function markDirty() {
+ if (state.suspended) return;
+ state.dirty = true;
+ state.dirtyGeneration += 1;
+ refreshPanel();
+ scheduleSilentFlush();
}
async function flushSilentlyIfPermitted() {
await ensureReady();
- if (!state.directoryHandle || !state.dirty || state.writing) {
+ if (state.suspended) return { success: false, reason: 'suspended' };
+ if (state.meta.awaitingRestore) return { success: false, reason: 'restore_required' };
+ if (!state.directoryHandle || !state.dirty) {
return { success: false, reason: 'skip' };
}
- var permitted = await ensurePermission(state.directoryHandle, false);
- if (!permitted) {
- // Permission missing: daily banner handles re-auth; do not prompt here.
+ if (state.writing) {
+ scheduleSilentFlush();
+ return { success: false, reason: 'busy' };
+ }
+ if (!await ensurePermission(state.directoryHandle, false)) {
+ refreshPanel();
return { success: false, reason: 'permission_denied' };
}
return writeToBoundDirectory({ interactive: false, force: false });
}
- function refreshUi() {
- try {
- if (typeof global.refreshExternalBackupPanel === 'function') {
- global.refreshExternalBackupPanel();
- }
- } catch (_) { /* ignore */ }
- dispatchStatus();
+ function getStatus() {
+ return {
+ supported: supportsFileSystemAccess(),
+ bound: !!state.directoryHandle,
+ directoryName: state.meta.directoryName,
+ permission: state.permission,
+ permissionGranted: state.permission === 'granted',
+ dirty: state.dirty,
+ writing: state.writing,
+ suspended: state.suspended,
+ lastWriteAt: state.meta.lastWriteAt,
+ lastChecksum: state.meta.lastChecksum,
+ lastWriteError: state.meta.lastWriteError,
+ awaitingRestore: state.meta.awaitingRestore
+ };
+ }
+
+ function formatTime(value) {
+ if (!value) return '';
+ var parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
}
function formatStatusText(status) {
- if (!status.supported) {
- return '当前环境不支持文件夹绑定(请用 Chrome/Edge 通过 http(s) 打开;file:// 下请用「导出到下载」)。';
- }
- if (!status.bound) {
- return '未绑定本地备份文件夹。绑定后可一键写入磁盘,避免清缓存丢数据。';
- }
- var parts = [];
- parts.push('已绑定:' + (status.directoryName || '文件夹'));
- if (!status.permissionGranted) {
- parts.push('权限失效,需重新授权');
- } else if (status.lastWriteAt) {
- parts.push('上次写入 ' + formatTime(status.lastWriteAt));
- if (status.lastWriteOk === false) {
- parts.push('最近一次写入失败');
- }
- } else {
- parts.push('尚未写入');
- }
- if (status.dirty) {
- parts.push('有未备份的新数据');
- }
+ if (!status.supported) return '当前环境不支持文件夹绑定,请使用「导出到下载」和「导入数据」。';
+ if (!status.bound) return '未绑定本地备份文件夹。';
+ var parts = ['已绑定:' + (status.directoryName || '文件夹')];
+ if (status.awaitingRestore) parts.push('检测到已有备份,请先恢复');
+ if (!status.permissionGranted) parts.push('需要重新授权');
+ if (status.writing) parts.push('正在写入');
+ else if (status.lastWriteAt) parts.push('上次写入 ' + formatTime(status.lastWriteAt));
+ else parts.push('尚未写入');
+ if (status.dirty) parts.push('有未备份的新数据');
+ if (status.lastWriteError) parts.push('最近错误:' + status.lastWriteError);
return parts.join(' · ');
}
- function formatEntryLabel(status) {
- if (!status.supported) {
- return '📁 本地磁盘备份';
- }
- if (!status.bound) {
- return '📁 本地磁盘备份';
- }
- if (!status.permissionGranted) {
- return '📁 本地备份 · 需授权';
- }
- if (status.staleWrite || status.dirty) {
- return '📁 本地备份 · 待更新';
+ function notify(message, type) {
+ if (typeof global.showMessage === 'function') {
+ global.showMessage(message, type || 'info');
+ } else if (global.console && console.log) {
+ console.log('[ExternalBackup v2] ' + message);
}
- return '📁 本地备份 · 已就绪';
}
- var ENTRY_ID = 'external-backup-entry-btn';
- var MODAL_ID = 'external-backup-modal';
- var modalBound = false;
+ function makeButton(id, label) {
+ var button = global.document.createElement('button');
+ button.type = 'button';
+ button.id = id;
+ button.className = 'btn data-mgmt-btn';
+ button.textContent = label;
+ return button;
+ }
function getModal() {
return global.document ? global.document.getElementById(MODAL_ID) : null;
}
- function openModal() {
- ensureModalDom();
- var modal = getModal();
- if (modal) {
- modal.classList.add('show');
- refreshExternalBackupPanel();
- }
- }
-
- function closeModal() {
- var modal = getModal();
- if (modal) {
- modal.classList.remove('show');
- }
- }
-
- function makeActionButton(id, label) {
- var btn = global.document.createElement('button');
- btn.type = 'button';
- btn.className = 'btn data-mgmt-btn';
- btn.id = id;
- btn.textContent = label;
- return btn;
- }
-
- function ensureEntryButton() {
- var panel = global.document && global.document.querySelector('#settings-view .data-management-panel');
- if (!panel) {
- return null;
- }
- var entry = global.document.getElementById(ENTRY_ID);
- if (entry) {
- return entry;
- }
-
- var actions = panel.querySelector('.hero-settings-actions');
- if (!actions) {
- return null;
- }
-
- entry = global.document.createElement('button');
- entry.type = 'button';
- entry.className = 'btn data-mgmt-btn';
- entry.id = ENTRY_ID;
- entry.textContent = '📁 本地磁盘备份';
-
- // Prefer leading position so the recommended action is easy to find.
- if (actions.firstChild) {
- actions.insertBefore(entry, actions.firstChild);
- } else {
- actions.appendChild(entry);
- }
- return entry;
- }
-
function ensureModalDom() {
- if (!global.document || !global.document.body) {
- return null;
- }
+ if (!global.document || !global.document.body) return null;
+ var existing = getModal();
+ if (existing) return existing;
- var modal = getModal();
- if (modal) {
- if (!modalBound) {
- bindModalEvents(modal);
- }
- return modal;
- }
-
- modal = global.document.createElement('div');
+ var modal = global.document.createElement('div');
modal.id = MODAL_ID;
modal.className = 'theme-modal external-backup-modal shui-secondary-modal shui-secondary-modal--sm';
modal.setAttribute('role', 'dialog');
@@ -1023,308 +703,221 @@
var content = global.document.createElement('div');
content.className = 'theme-modal-content external-backup-modal__content shui-secondary-modal__content';
-
var header = global.document.createElement('div');
header.className = 'theme-modal-header external-backup-modal__header shui-secondary-modal__header';
-
- var titleGroup = global.document.createElement('div');
- titleGroup.className = 'external-backup-modal__title-group shui-secondary-modal__title-group';
-
- var eyebrow = global.document.createElement('div');
- eyebrow.className = 'external-backup-modal__eyebrow shui-secondary-modal__eyebrow';
- eyebrow.textContent = 'DISK BACKUP';
-
var title = global.document.createElement('h3');
title.id = 'external-backup-title';
title.textContent = '本地磁盘备份';
-
- titleGroup.appendChild(eyebrow);
- titleGroup.appendChild(title);
-
- var closeBtn = global.document.createElement('button');
- closeBtn.type = 'button';
- closeBtn.className = 'theme-modal-close';
- closeBtn.setAttribute('aria-label', '关闭');
- closeBtn.innerHTML = '×';
-
- header.appendChild(titleGroup);
- header.appendChild(closeBtn);
+ var closeButton = global.document.createElement('button');
+ closeButton.type = 'button';
+ closeButton.className = 'theme-modal-close';
+ closeButton.setAttribute('aria-label', '关闭');
+ closeButton.innerHTML = '×';
+ header.appendChild(title);
+ header.appendChild(closeButton);
var body = global.document.createElement('div');
body.className = 'theme-modal-body external-backup-modal__body shui-secondary-modal__body';
-
- var host = global.document.createElement('div');
- host.id = 'external-backup-panel';
- host.className = 'external-backup-panel external-backup-panel--modal';
-
- var desc = global.document.createElement('p');
- desc.className = 'external-backup-panel__desc';
- desc.textContent = '绑定本地文件夹后,可把练习数据写入磁盘 JSON。清浏览器缓存不会删除该文件夹中的文件;已授权时可在后台静默更新;每天最多提醒一次,且不会自动下载。';
-
+ var panel = global.document.createElement('div');
+ panel.id = 'external-backup-panel';
+ panel.className = 'external-backup-panel external-backup-panel--modal';
+ var description = global.document.createElement('p');
+ description.className = 'external-backup-panel__desc';
+ description.textContent = '绑定本地文件夹后,IELTS Atlas 会写入完整的 v2 数据快照。磁盘文件不会因清理浏览器站点数据而删除;后台写入不会主动请求权限。';
var statusCard = global.document.createElement('div');
statusCard.className = 'external-backup-status-card';
-
var statusLabel = global.document.createElement('div');
statusLabel.className = 'external-backup-status-card__label';
statusLabel.textContent = '当前状态';
-
- var status = global.document.createElement('div');
- status.id = 'external-backup-status';
- status.className = 'external-backup-panel__status';
- status.textContent = '状态加载中…';
-
+ var statusText = global.document.createElement('div');
+ statusText.id = 'external-backup-status';
+ statusText.className = 'external-backup-panel__status';
+ statusText.textContent = '状态加载中…';
statusCard.appendChild(statusLabel);
- statusCard.appendChild(status);
+ statusCard.appendChild(statusText);
var tips = global.document.createElement('ul');
tips.className = 'external-backup-panel__tips';
[
- '推荐使用 Chrome / Edge,通过 http(s) 或 localhost 打开',
- 'file:// 环境通常无法绑定文件夹,请改用「导出到下载」',
- '应用内备份只防导入误操作,防不了清缓存'
- ].forEach(function (line) {
- var li = global.document.createElement('li');
- li.textContent = line;
- tips.appendChild(li);
+ '支持 Chrome / Edge 的安全上下文;其他环境继续使用手动导出',
+ '备份文件包含练习、设置、词汇、题库配置等可迁移数据',
+ '磁盘 JSON 为明文文件,请妥善保管'
+ ].forEach(function (text) {
+ var item = global.document.createElement('li');
+ item.textContent = text;
+ tips.appendChild(item);
});
var actions = global.document.createElement('div');
actions.className = 'external-backup-panel__actions';
-
- var bindBtn = makeActionButton('external-backup-bind-btn', '📁 绑定备份文件夹');
- var writeBtn = makeActionButton('external-backup-write-btn', '💾 立即写入备份');
- var restoreBtn = makeActionButton('external-backup-restore-btn', '♻️ 从文件夹恢复');
- var unbindBtn = makeActionButton('external-backup-unbind-btn', '🔓 解除绑定');
- unbindBtn.classList.add('external-backup-btn--ghost');
-
- actions.appendChild(bindBtn);
- actions.appendChild(writeBtn);
- actions.appendChild(restoreBtn);
- actions.appendChild(unbindBtn);
-
- host.appendChild(desc);
- host.appendChild(statusCard);
- host.appendChild(tips);
- host.appendChild(actions);
- body.appendChild(host);
-
+ var bindButton = makeButton('external-backup-bind-btn', '📁 绑定备份文件夹');
+ var writeButton = makeButton('external-backup-write-btn', '💾 立即写入备份');
+ var restoreButton = makeButton('external-backup-restore-btn', '♻️ 从文件夹恢复');
+ var unbindButton = makeButton('external-backup-unbind-btn', '🔓 解除绑定');
+ unbindButton.classList.add('external-backup-btn--ghost');
+ actions.appendChild(bindButton);
+ actions.appendChild(writeButton);
+ actions.appendChild(restoreButton);
+ actions.appendChild(unbindButton);
+
+ panel.appendChild(description);
+ panel.appendChild(statusCard);
+ panel.appendChild(tips);
+ panel.appendChild(actions);
+ body.appendChild(panel);
content.appendChild(header);
content.appendChild(body);
modal.appendChild(content);
global.document.body.appendChild(modal);
- bindBtn.addEventListener('click', async function () {
- try {
- await ensureReady();
- var result = await bindDirectory({ writeNow: true });
- if (result.writeResult && result.writeResult.success) {
- notify('已绑定并写入:' + result.directoryName, 'success');
- } else {
- notify('已绑定:' + result.directoryName, 'success');
- }
- } catch (error) {
- if (error && error.name === 'AbortError') {
- notify('已取消选择文件夹', 'info');
- } else {
- notify(error && error.message ? error.message : '绑定失败', 'error');
- }
- } finally {
- refreshExternalBackupPanel();
- }
+ closeButton.addEventListener('click', closeModal);
+ modal.addEventListener('click', function (event) {
+ if (event.target === modal) closeModal();
});
-
- writeBtn.addEventListener('click', async function () {
+ bindButton.addEventListener('click', async function () {
try {
- await ensureReady();
- var result = await writeToBoundDirectory({ interactive: true, force: true });
- if (result.success) {
- notify(result.skipped ? '备份内容无变化' : ('已写入 ' + (result.filename || LATEST_FILENAME)), 'success');
- } else if (result.reason === 'unbound') {
- notify('请先绑定备份文件夹', 'warning');
- } else if (result.reason === 'permission_denied') {
- notify('需要允许文件夹访问权限', 'warning');
+ var bound = await bindDirectory({ writeNow: true });
+ if (bound.existingBackupFound) {
+ notify('已绑定并检测到现有备份;为防止覆盖,请先从文件夹恢复', 'warning');
+ } else if (bound.writeResult && !bound.writeResult.success) {
+ notify('文件夹已绑定,但首次写入失败', 'warning');
} else {
- notify('写入失败:' + (result.error && result.error.message ? result.error.message : result.reason), 'error');
+ notify('已绑定并写入:' + bound.directoryName, 'success');
}
} catch (error) {
- notify(error && error.message ? error.message : '写入失败', 'error');
- } finally {
- refreshExternalBackupPanel();
+ notify(error && error.name === 'AbortError' ? '已取消选择文件夹' : (error.message || '绑定失败'), error && error.name === 'AbortError' ? 'info' : 'error');
}
+ refreshPanel();
});
-
- restoreBtn.addEventListener('click', async function () {
+ writeButton.addEventListener('click', async function () {
+ var result = await writeToBoundDirectory({ interactive: true, force: true });
+ if (result.success) notify(result.skipped ? '备份内容无变化' : '已写入 ' + result.filename, 'success');
+ else if (result.reason === 'unbound') notify('请先绑定备份文件夹', 'warning');
+ else if (result.reason === 'restore_required') notify('检测到现有备份,请先从文件夹恢复,避免覆盖', 'warning');
+ else if (result.reason === 'permission_denied') notify('需要允许文件夹访问权限', 'warning');
+ else notify('写入失败:' + (result.error && result.error.message || result.reason), 'error');
+ });
+ restoreButton.addEventListener('click', async function () {
try {
- await ensureReady();
- var statusNow = getStatus();
- if (!statusNow.bound) {
- await pickAndRestoreFile();
- notify('已从文件恢复(或已打开导入流程)', 'success');
- return;
- }
- var ok = true;
- try {
- ok = global.confirm('将用文件夹中的 ' + LATEST_FILENAME + ' 覆盖/恢复练习数据,是否继续?');
- } catch (_) { /* ignore */ }
- if (!ok) {
- return;
- }
- await restoreFromLatest({ interactive: true });
- notify('已从本地备份文件夹恢复', 'success');
- try {
- if (typeof global.updateOverview === 'function') {
- global.updateOverview();
+ var restored = await restoreFromLatest();
+ if (restored.success) {
+ notify('已从本地磁盘备份恢复', 'success');
+ if (typeof global.syncPracticeRecords === 'function') {
+ Promise.resolve(global.syncPracticeRecords({ forceRender: true })).catch(function () {});
}
- } catch (_) { /* ignore */ }
- } catch (error) {
- if (error && error.name === 'AbortError') {
- notify('已取消', 'info');
- } else {
- notify(error && error.message ? error.message : '恢复失败', 'error');
+ } else if (restored.reason === 'cancelled') {
+ notify('已取消恢复', 'info');
}
- } finally {
- refreshExternalBackupPanel();
+ } catch (error) {
+ notify(error && error.message ? error.message : '恢复失败', 'error');
}
});
-
- unbindBtn.addEventListener('click', async function () {
+ unbindButton.addEventListener('click', async function () {
+ var confirmed = true;
+ try {
+ confirmed = global.confirm('解除绑定后将停止自动写入;磁盘上的 JSON 文件不会删除。确定?');
+ } catch (_) { /* ignore */ }
+ if (!confirmed) return;
try {
- await ensureReady();
- var ok = true;
- try {
- ok = global.confirm('解除绑定后将不再写入该文件夹(磁盘上的备份文件仍保留)。确定?');
- } catch (_) { /* ignore */ }
- if (!ok) {
- return;
- }
await unbindDirectory();
- notify('已解除绑定', 'info');
+ notify('已解除本地备份文件夹绑定', 'info');
} catch (error) {
notify(error && error.message ? error.message : '解除绑定失败', 'error');
- } finally {
- refreshExternalBackupPanel();
}
});
-
- bindModalEvents(modal);
return modal;
}
- function bindModalEvents(modal) {
- if (!modal || modalBound) {
- return;
- }
- modalBound = true;
-
- var closeBtn = modal.querySelector('.theme-modal-close');
- if (closeBtn) {
- closeBtn.addEventListener('click', closeModal);
+ function refreshPanel() {
+ if (!global.document) return;
+ var status = getStatus();
+ var statusElement = global.document.getElementById('external-backup-status');
+ if (statusElement) {
+ statusElement.textContent = formatStatusText(status);
+ statusElement.dataset.state = !status.supported ? 'unsupported'
+ : !status.bound ? 'unbound'
+ : !status.permissionGranted ? 'need-auth'
+ : status.dirty ? 'stale' : 'ok';
}
- modal.addEventListener('click', function (event) {
- if (event.target === modal) {
- closeModal();
- }
- });
- global.document.addEventListener('keydown', function (event) {
- if (event.key === 'Escape' && modal.classList.contains('show')) {
- closeModal();
- }
- });
-
- var entry = ensureEntryButton();
- if (entry && !entry.__externalBackupBound) {
- entry.__externalBackupBound = true;
- entry.addEventListener('click', function (event) {
- event.preventDefault();
- openModal();
- });
+ var entry = global.document.getElementById(ENTRY_ID);
+ if (entry) {
+ entry.textContent = !status.bound ? '📁 本地磁盘备份'
+ : !status.permissionGranted ? '📁 本地备份 · 需授权'
+ : status.dirty ? '📁 本地备份 · 待更新' : '📁 本地备份 · 已就绪';
+ entry.dataset.state = statusElement && statusElement.dataset.state || 'unbound';
}
+ var bindButton = global.document.getElementById('external-backup-bind-btn');
+ var writeButton = global.document.getElementById('external-backup-write-btn');
+ var restoreButton = global.document.getElementById('external-backup-restore-btn');
+ var unbindButton = global.document.getElementById('external-backup-unbind-btn');
+ if (bindButton) bindButton.disabled = !status.supported || status.writing;
+ if (writeButton) writeButton.disabled = !status.bound || status.writing;
+ if (restoreButton) restoreButton.disabled = !status.bound || status.writing;
+ if (unbindButton) unbindButton.disabled = !status.bound || status.writing;
}
- function ensurePanelDom() {
- // Compact entry on settings page + secondary modal body.
- ensureEntryButton();
+ function openModal() {
var modal = ensureModalDom();
- return modal ? modal.querySelector('#external-backup-panel') : null;
- }
-
- function refreshExternalBackupPanel() {
- var host = ensurePanelDom();
- var status = getStatus();
-
- var entry = global.document && global.document.getElementById(ENTRY_ID);
- if (entry) {
- entry.textContent = formatEntryLabel(status);
- entry.dataset.state = status.bound
- ? (status.permissionGranted ? (status.staleWrite || status.dirty ? 'stale' : 'ok') : 'need-auth')
- : (status.supported ? 'unbound' : 'unsupported');
- entry.title = formatStatusText(status);
- }
-
- if (!host) {
- return;
- }
-
- var statusEl = host.querySelector('#external-backup-status');
- if (statusEl) {
- statusEl.textContent = formatStatusText(status);
- statusEl.dataset.state = status.bound
- ? (status.permissionGranted ? (status.staleWrite || status.dirty ? 'stale' : 'ok') : 'need-auth')
- : (status.supported ? 'unbound' : 'unsupported');
- }
-
- var writeBtn = host.querySelector('#external-backup-write-btn');
- var unbindBtn = host.querySelector('#external-backup-unbind-btn');
- var restoreBtn = host.querySelector('#external-backup-restore-btn');
- var bindBtn = host.querySelector('#external-backup-bind-btn');
-
- if (bindBtn) {
- bindBtn.disabled = !status.supported;
- bindBtn.textContent = status.bound ? '📁 更换备份文件夹' : '📁 绑定备份文件夹';
- }
- if (writeBtn) {
- writeBtn.disabled = !status.bound || status.writing;
- }
- if (unbindBtn) {
- unbindBtn.disabled = !status.bound;
- }
- if (restoreBtn) {
- restoreBtn.disabled = false;
- }
+ if (modal) modal.classList.add('show');
+ ensureReady().then(async function () {
+ if (state.directoryHandle) state.permission = await queryPermission(state.directoryHandle, 'readwrite');
+ refreshPanel();
+ }).catch(function (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] initialization failed:', error);
+ refreshPanel();
+ });
}
- function onStorageSync(event) {
- var key = event && event.detail ? event.detail.key : null;
- if (!key || key === '*' || key === 'practice_records' || key === 'user_stats' ||
- String(key).indexOf('practice') !== -1 || String(key).indexOf('vocab') !== -1) {
- markDirty();
- }
+ function closeModal() {
+ var modal = getModal();
+ if (modal) modal.classList.remove('show');
}
async function ensureReady() {
- if (state.ready) {
- return true;
- }
- if (state.readyPromise) {
- return state.readyPromise;
- }
+ if (state.ready) return true;
+ if (state.readyPromise) return state.readyPromise;
state.readyPromise = (async function () {
- state.meta = readMeta();
- try {
- var handle = await loadDirectoryHandle();
- if (handle) {
- state.directoryHandle = handle;
- var ok = await ensurePermission(handle, false);
- writeMeta({
- enabled: true,
- directoryName: handle.name || state.meta.directoryName || 'backup',
- lastPermissionOk: ok
- });
+ if (global.AppData && global.AppData.ready) await global.AppData.ready;
+ if (state.suspended) {
+ state.ready = true;
+ return false;
+ }
+ if (supportsFileSystemAccess()) {
+ try {
+ var stored = await Promise.all([
+ readStoredValue(HANDLE_KEY),
+ readStoredValue(META_KEY)
+ ]);
+ state.directoryHandle = stored[0] || null;
+ state.meta = cloneMeta(stored[1]);
+ if (state.directoryHandle) {
+ state.permission = await queryPermission(state.directoryHandle, 'readwrite');
+ if (!state.meta.directoryName) {
+ state.meta.directoryName = state.directoryHandle.name || 'backup';
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] binding load failed:', error);
+ }
+ }
+ var backups = global.AppData && global.AppData.backups;
+ if (backups && typeof backups.onDataCommitted === 'function' && !state.unsubscribeCommitted) {
+ state.unsubscribeCommitted = backups.onDataCommitted(markDirty);
+ }
+ if (state.directoryHandle && backups && typeof backups.export === 'function') {
+ try {
+ var currentSnapshot = await backups.export();
+ if (!currentSnapshot || currentSnapshot.checksum !== state.meta.lastChecksum) {
+ state.dirty = true;
+ state.dirtyGeneration += 1;
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] freshness check failed:', error);
}
- } catch (error) {
- console.warn('[ExternalBackup] load handle failed:', error);
}
state.ready = true;
+ if (state.dirty && state.permission === 'granted') scheduleSilentFlush();
+ refreshPanel();
return true;
})();
return state.readyPromise;
@@ -1332,42 +925,30 @@
async function init() {
await ensureReady();
- ensurePanelDom();
- refreshExternalBackupPanel();
- await requestPersistentStorage();
-
- // Daily reminder + empty-store recovery (deferred so PracticeRecordAPI can boot)
- global.setTimeout(function () {
- maybeShowDailyReminder({ render: true }).catch(function (error) {
- console.warn('[ExternalBackup] daily reminder failed:', error);
- });
- maybePromptEmptyStoreRecovery().catch(function (error) {
- console.warn('[ExternalBackup] recovery prompt failed:', error);
- });
- }, 1800);
-
- // Re-check when user returns to the tab (still at most once/day)
- if (global.document) {
- global.document.addEventListener('visibilitychange', function () {
- if (global.document.visibilityState === 'visible') {
- maybeShowDailyReminder({ render: true }).catch(function () { /* ignore */ });
- refreshExternalBackupPanel();
- } else if (global.document.visibilityState === 'hidden') {
- // Best-effort silent write when leaving the tab (no permission prompt)
- flushSilentlyIfPermitted().catch(function () { /* ignore */ });
+ if (state.suspended) return false;
+ ensureModalDom();
+ refreshPanel();
+ if (global.document && !state.initialized) {
+ state.visibilityHandler = function () {
+ if (state.suspended) return;
+ if (global.document.visibilityState === 'hidden') {
+ flushSilentlyIfPermitted().catch(function () {});
+ } else if (state.directoryHandle) {
+ queryPermission(state.directoryHandle, 'readwrite').then(function (permission) {
+ state.permission = permission;
+ if (permission === 'granted' && state.dirty) scheduleSilentFlush();
+ refreshPanel();
+ });
}
- });
+ };
+ global.document.addEventListener('visibilitychange', state.visibilityHandler);
}
+ state.initialized = true;
+ return true;
}
- // Listen for data changes early
- try {
- global.addEventListener('storage-sync', onStorageSync);
- global.addEventListener('practiceRecordsUpdated', markDirty);
- } catch (_) { /* ignore */ }
-
- global.ExternalBackupService = {
- __stable: true,
+ global.ExternalBackupService = Object.freeze({
+ __v2: true,
LATEST_FILENAME: LATEST_FILENAME,
supportsFileSystemAccess: supportsFileSystemAccess,
ensureReady: ensureReady,
@@ -1376,26 +957,22 @@
closeModal: closeModal,
bindDirectory: bindDirectory,
unbindDirectory: unbindDirectory,
+ prepareForFullReset: prepareForFullReset,
writeNow: function (options) {
return writeToBoundDirectory(Object.assign({ interactive: true, force: true }, options || {}));
},
restoreFromLatest: restoreFromLatest,
- pickAndRestoreFile: pickAndRestoreFile,
+ restorePayload: restorePayload,
getStatus: getStatus,
- formatStatusText: formatStatusText,
- maybeShowDailyReminder: maybeShowDailyReminder,
- maybePromptEmptyStoreRecovery: maybePromptEmptyStoreRecovery,
markDirty: markDirty,
flushSilentlyIfPermitted: flushSilentlyIfPermitted,
- refreshPanel: refreshExternalBackupPanel,
+ refreshPanel: refreshPanel,
requestPersistentStorage: requestPersistentStorage
- };
-
- global.refreshExternalBackupPanel = refreshExternalBackupPanel;
+ });
function boot() {
init().catch(function (error) {
- console.warn('[ExternalBackup] init failed:', error);
+ if (global.console && console.warn) console.warn('[ExternalBackup v2] boot failed:', error);
});
}
diff --git a/js/core/goalManager.js b/js/core/goalManager.js
deleted file mode 100644
index f6857a6c..00000000
--- a/js/core/goalManager.js
+++ /dev/null
@@ -1,363 +0,0 @@
-(function (window) {
- 'use strict';
-
- var STORAGE_KEY = 'learning_goals';
- var PROGRESS_KEY = 'goal_progress';
-
- var GOAL_TYPES = Object.freeze({
- PRACTICE_COUNT: 'practice_count',
- STUDY_TIME: 'study_time',
- ACCURACY: 'accuracy'
- });
-
- var GOAL_PERIODS = Object.freeze({
- DAILY: 'daily',
- WEEKLY: 'weekly',
- MONTHLY: 'monthly'
- });
-
- var PERIOD_MS = Object.freeze({
- daily: 24 * 60 * 60 * 1000,
- weekly: 7 * 24 * 60 * 60 * 1000,
- monthly: 30 * 24 * 60 * 60 * 1000
- });
-
- function getNow() {
- return new Date().toISOString();
- }
-
- function todayKey() {
- return new Date().toISOString().slice(0, 10);
- }
-
- function weekKey() {
- var d = new Date();
- var jan1 = new Date(d.getFullYear(), 0, 1);
- var week = Math.ceil(((d - jan1) / 86400000 + jan1.getDay() + 1) / 7);
- return d.getFullYear() + '-W' + String(week).padStart(2, '0');
- }
-
- function monthKey() {
- return new Date().toISOString().slice(0, 7);
- }
-
- function periodKey(period) {
- if (period === GOAL_PERIODS.DAILY) return todayKey();
- if (period === GOAL_PERIODS.WEEKLY) return weekKey();
- if (period === GOAL_PERIODS.MONTHLY) return monthKey();
- return todayKey();
- }
-
- function generateId() {
- return 'goal_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
- }
-
- function normalizeGoal(input) {
- if (!input || typeof input !== 'object') return null;
- var type = input.type;
- var period = input.period;
- if (!GOAL_TYPES[type]) return null;
- if (!GOAL_PERIODS[period]) return null;
-
- var target = Number(input.target);
- if (!Number.isFinite(target) || target <= 0) return null;
-
- return {
- id: typeof input.id === 'string' && input.id ? input.id : generateId(),
- type: type,
- period: period,
- target: Math.floor(target),
- title: typeof input.title === 'string' ? input.title.trim() : '',
- createdAt: input.createdAt || getNow(),
- updatedAt: getNow()
- };
- }
-
- function GoalManager() {
- this.goals = [];
- this.progress = {};
- this.streak = { current: 0, best: 0, lastDate: null };
- this.ready = false;
- this._readyPromise = this._init();
- this._listeners = [];
- }
-
- GoalManager.prototype._init = async function () {
- try {
- if (window.storage) {
- await window.storage.waitForInitialization();
- this.goals = await window.storage.get(STORAGE_KEY, []);
- var saved = await window.storage.get(PROGRESS_KEY, null);
- if (saved && typeof saved === 'object') {
- this.progress = saved.progress || {};
- this.streak = saved.streak || { current: 0, best: 0, lastDate: null };
- }
- } else {
- try {
- var raw = localStorage.getItem(STORAGE_KEY);
- this.goals = raw ? JSON.parse(raw) : [];
- var rawP = localStorage.getItem(PROGRESS_KEY);
- if (rawP) {
- var parsed = JSON.parse(rawP);
- this.progress = parsed.progress || {};
- this.streak = parsed.streak || { current: 0, best: 0, lastDate: null };
- }
- } catch (e) {
- this.goals = [];
- this.progress = {};
- }
- }
- this.ready = true;
- this._bindEvents();
- } catch (e) {
- console.error('[GoalManager] Init failed:', e);
- this.ready = true;
- }
- };
-
- GoalManager.prototype._bindEvents = function () {
- var self = this;
- window.addEventListener('practiceSessionCompleted', function (e) {
- self._onPracticeCompleted(e.detail);
- });
- };
-
- GoalManager.prototype._onPracticeCompleted = async function (detail) {
- if (!detail) return;
- await this._readyPromise;
-
- var record = detail.record || detail;
- var accuracy = this._extractAccuracy(record);
- var duration = this._extractDuration(record);
- var pk = todayKey();
-
- this._incrementProgress(pk, GOAL_TYPES.PRACTICE_COUNT, 1);
- this._updateStreak();
- this._incrementProgress(pk, GOAL_TYPES.STUDY_TIME, Math.round(duration / 60));
-
- var weekPk = weekKey();
- this._incrementProgress(weekPk, GOAL_TYPES.STUDY_TIME, Math.round(duration / 60));
-
- var monthPk = monthKey();
- this._incrementProgress(monthPk, GOAL_TYPES.STUDY_TIME, Math.round(duration / 60));
-
- if (accuracy > 0) {
- this._updateAccuracyProgress(GOAL_PERIODS.DAILY, accuracy);
- this._updateAccuracyProgress(GOAL_PERIODS.WEEKLY, accuracy);
- this._updateAccuracyProgress(GOAL_PERIODS.MONTHLY, accuracy);
- }
-
- await this._save();
- this._checkCompletions();
- this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak });
- };
-
- GoalManager.prototype._extractAccuracy = function (record) {
- if (!record) return 0;
- var candidates = [
- record.accuracy,
- record.scoreInfo && record.scoreInfo.accuracy,
- record.realData && record.realData.accuracy,
- record.realData && record.realData.scoreInfo && record.realData.scoreInfo.accuracy
- ];
- for (var i = 0; i < candidates.length; i++) {
- var v = Number(candidates[i]);
- if (Number.isFinite(v) && v >= 0) {
- return v > 1 && v <= 100 ? v / 100 : v;
- }
- }
- return 0;
- };
-
- GoalManager.prototype._extractDuration = function (record) {
- if (!record) return 0;
- var candidates = [
- record.duration,
- record.scoreInfo && record.scoreInfo.duration,
- record.scoreInfo && record.scoreInfo.timeSpent,
- record.realData && record.realData.duration,
- record.realData && record.realData.scoreInfo && record.realData.scoreInfo.duration
- ];
- for (var i = 0; i < candidates.length; i++) {
- var v = Number(candidates[i]);
- if (Number.isFinite(v) && v >= 0) return v;
- }
- return 0;
- };
-
- GoalManager.prototype._incrementProgress = function (pk, type, amount) {
- if (!this.progress[pk]) this.progress[pk] = {};
- var current = Number(this.progress[pk][type]) || 0;
- this.progress[pk][type] = current + (Number(amount) || 0);
- };
-
- GoalManager.prototype._updateAccuracyProgress = function (period, accuracy) {
- var pk = periodKey(period);
- if (!this.progress[pk]) this.progress[pk] = {};
- var acc = this.progress[pk];
- if (!acc[GOAL_TYPES.ACCURACY + '_sum']) {
- acc[GOAL_TYPES.ACCURACY + '_sum'] = 0;
- acc[GOAL_TYPES.ACCURACY + '_count'] = 0;
- }
- acc[GOAL_TYPES.ACCURACY + '_sum'] += accuracy;
- acc[GOAL_TYPES.ACCURACY + '_count'] += 1;
- acc[GOAL_TYPES.ACCURACY] = acc[GOAL_TYPES.ACCURACY + '_sum'] / acc[GOAL_TYPES.ACCURACY + '_count'];
- };
-
- GoalManager.prototype._updateStreak = function () {
- var today = todayKey();
- if (this.streak.lastDate === today) return;
-
- var yesterday = new Date();
- yesterday.setDate(yesterday.getDate() - 1);
- var yesterdayKey = yesterday.toISOString().slice(0, 10);
-
- if (this.streak.lastDate === yesterdayKey) {
- this.streak.current += 1;
- } else if (this.streak.lastDate !== today) {
- this.streak.current = 1;
- }
- this.streak.lastDate = today;
- if (this.streak.current > this.streak.best) {
- this.streak.best = this.streak.current;
- }
- };
-
- GoalManager.prototype._checkCompletions = function () {
- var self = this;
- this.goals.forEach(function (goal) {
- var pk = periodKey(goal.period);
- var current = self._getGoalCurrent(goal, pk);
- if (current >= goal.target) {
- self._emit('goalCompleted', { goal: goal, current: current });
- }
- });
- };
-
- GoalManager.prototype._getGoalCurrent = function (goal, pk) {
- if (!this.progress[pk]) return 0;
- return Number(this.progress[pk][goal.type]) || 0;
- };
-
- GoalManager.prototype._save = async function () {
- try {
- if (window.storage) {
- await window.storage.set(STORAGE_KEY, this.goals);
- await window.storage.set(PROGRESS_KEY, { progress: this.progress, streak: this.streak });
- } else {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(this.goals));
- localStorage.setItem(PROGRESS_KEY, JSON.stringify({ progress: this.progress, streak: this.streak }));
- }
- } catch (e) {
- console.error('[GoalManager] Save failed:', e);
- }
- };
-
- GoalManager.prototype._emit = function (name, detail) {
- window.dispatchEvent(new CustomEvent(name, { detail: detail }));
- };
-
- // Public API
-
- GoalManager.prototype.createGoal = async function (input) {
- await this._readyPromise;
- var goal = normalizeGoal(input);
- if (!goal) return null;
- this.goals.push(goal);
- await this._save();
- this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak });
- return goal;
- };
-
- GoalManager.prototype.updateGoal = async function (id, updates) {
- await this._readyPromise;
- var idx = this.goals.findIndex(function (g) { return g.id === id; });
- if (idx < 0) return null;
- var existing = this.goals[idx];
- var merged = {
- id: existing.id,
- type: updates.type || existing.type,
- period: updates.period || existing.period,
- target: updates.target !== undefined ? Number(updates.target) : existing.target,
- title: updates.title !== undefined ? updates.title : existing.title,
- createdAt: existing.createdAt,
- updatedAt: getNow()
- };
- var normalized = normalizeGoal(merged);
- if (!normalized) return null;
- this.goals[idx] = normalized;
- await this._save();
- this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak });
- return normalized;
- };
-
- GoalManager.prototype.deleteGoal = async function (id) {
- await this._readyPromise;
- var before = this.goals.length;
- this.goals = this.goals.filter(function (g) { return g.id !== id; });
- if (this.goals.length < before) {
- await this._save();
- this._emit('goalUpdated', { goals: this.goals, progress: this.progress, streak: this.streak });
- return true;
- }
- return false;
- };
-
- GoalManager.prototype.getGoals = function () {
- return this.goals.slice();
- };
-
- GoalManager.prototype.getGoalProgress = function (goalId) {
- var goal = this.goals.find(function (g) { return g.id === goalId; });
- if (!goal) return null;
- var pk = periodKey(goal.period);
- var current = this._getGoalCurrent(goal, pk);
- return {
- goal: goal,
- current: current,
- target: goal.target,
- percent: goal.target > 0 ? Math.min(100, Math.round(current / goal.target * 100)) : 0,
- completed: current >= goal.target,
- periodKey: pk
- };
- };
-
- GoalManager.prototype.getAllProgress = function () {
- var self = this;
- return this.goals.map(function (goal) {
- return self.getGoalProgress(goal.id);
- }).filter(Boolean);
- };
-
- GoalManager.prototype.getStreak = function () {
- return {
- current: this.streak.current,
- best: this.streak.best,
- lastDate: this.streak.lastDate
- };
- };
-
- GoalManager.prototype.on = function (eventName, callback) {
- this._listeners.push({ event: eventName, callback: callback });
- window.addEventListener(eventName, callback);
- };
-
- GoalManager.prototype.off = function (eventName, callback) {
- this._listeners = this._listeners.filter(function (l) {
- return !(l.event === eventName && l.callback === callback);
- });
- window.removeEventListener(eventName, callback);
- };
-
- GoalManager.prototype.destroy = function () {
- this._listeners.forEach(function (l) {
- window.removeEventListener(l.event, l.callback);
- });
- this._listeners = [];
- };
-
- GoalManager.TYPES = GOAL_TYPES;
- GoalManager.PERIODS = GOAL_PERIODS;
-
- window.GoalManager = GoalManager;
-})(typeof window !== 'undefined' ? window : this);
diff --git a/js/core/practiceCore.js b/js/core/practiceCore.js
index f95b7491..b2f015ce 100644
--- a/js/core/practiceCore.js
+++ b/js/core/practiceCore.js
@@ -50,17 +50,6 @@
'WORKOUT_COMPLETE'
]);
- const STORAGE_KEYS = Object.freeze({
- practiceRecords: 'practice_records',
- userStats: 'user_stats',
- activeSessions: 'active_sessions',
- tempPracticeRecords: 'temp_practice_records'
- });
- let internalRepositories = null;
- // 由 data/index.js 在仓库注入时通过 __installInternalRepositories 第二参数传入,
- // 使仓库未注入前的 fallback 路径也能拿到 storage internal token,避免被新保护层拒绝。
- let internalStorageAccess = null;
-
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
@@ -90,6 +79,47 @@
return clone;
}
+ /**
+ * Resolve the complete reading-annotation snapshot from canonical and legacy
+ * locations. Explicit root values win so review edits can replace an older
+ * realData mirror; the returned object is deep-cloned and safe to persist.
+ */
+ function resolveAnnotationState(recordData = {}, fallbackSources = [], options = {}) {
+ const root = isPlainObject(recordData) ? recordData : {};
+ const rawData = isPlainObject(root.rawData) ? root.rawData : {};
+ const realData = isPlainObject(root.realData) ? root.realData : {};
+ const rawRealData = isPlainObject(rawData.realData) ? rawData.realData : {};
+ const sources = [root, rawData, realData, rawRealData]
+ .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources])
+ .filter((source) => isPlainObject(source));
+
+ const pickArray = (field) => {
+ const source = sources.find((candidate) => (
+ Array.isArray(candidate[field])
+ && (!options.preferNonEmptyArrays || candidate[field].length)
+ )) || sources.find((candidate) => Array.isArray(candidate[field]));
+ return source ? clonePlainObject(source[field]) : [];
+ };
+ const pickString = (field) => {
+ const source = sources.find((candidate) => typeof candidate[field] === 'string');
+ return source ? source[field] : '';
+ };
+ const scrollSource = sources.find((candidate) => (
+ candidate.scrollY !== undefined
+ && candidate.scrollY !== null
+ && Number.isFinite(Number(candidate.scrollY))
+ ));
+
+ return {
+ highlights: pickArray('highlights'),
+ markedQuestions: pickArray('markedQuestions'),
+ noteText: pickString('noteText'),
+ notes: pickArray('notes'),
+ noteOutlines: pickArray('noteOutlines'),
+ scrollY: scrollSource ? Number(scrollSource.scrollY) : 0
+ };
+ }
+
function ensureNumber(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
@@ -672,12 +702,15 @@
: Number(scoreInfo.percentage);
scoreInfo.answerKeyComplete = hasCompleteCanonicalCorrectAnswers;
+ const annotations = resolveAnnotationState(entry);
+
return {
answers,
correctAnswers: correctAnswerMap,
correctAnswerMap,
answerComparison,
- scoreInfo
+ scoreInfo,
+ ...annotations
};
}
@@ -895,12 +928,13 @@
}
return map;
}, {});
- const highlights = Array.isArray(entry.highlights)
- ? entry.highlights.slice()
- : (Array.isArray(entry.rawData && entry.rawData.highlights) ? entry.rawData.highlights.slice() : []);
- const scrollY = Number.isFinite(Number(entry.scrollY))
- ? Number(entry.scrollY)
- : (Number.isFinite(Number(entry.rawData && entry.rawData.scrollY)) ? Number(entry.rawData.scrollY) : 0);
+ // 旧/导入的套题条目可能只在 entry.metadata.markedQuestions 保留标记题,
+ // 与顶层 standardizeRecord(见下方 resolveAnnotationState(recordData, [recordData.metadata]))
+ // 保持一致,将 entry.metadata 作为兜底来源传入,避免根级 markedQuestions: [] 被回放
+ // 逻辑视作权威而丢弃已保存的标记题。
+ const metadata = entry.metadata ? Object.assign({}, entry.metadata) : {};
+ const annotations = resolveAnnotationState(entry, [entry.metadata], { preferNonEmptyArrays: true });
+ metadata.markedQuestions = clonePlainObject(annotations.markedQuestions);
return {
examId: entry.examId || null,
title: entry.title || entry.examTitle || `套题第${index + 1}篇`,
@@ -910,9 +944,8 @@
answers: answerMap,
correctAnswerMap: entryCorrectMap,
answerComparison: clonePlainObject(answerComparisonSource) || null,
- metadata: entry.metadata ? Object.assign({}, entry.metadata) : {},
- highlights,
- scrollY,
+ metadata,
+ ...annotations,
rawData: entry.rawData ? clonePlainObject(entry.rawData) : null
};
}).filter(Boolean);
@@ -1076,6 +1109,8 @@
? clonePlainObject(comparisonSource)
: null;
const realDataCorrectAnswers = clonePlainObject(normalizedCorrectMap || {});
+ const annotations = resolveAnnotationState(recordData, [recordData.metadata]);
+ metadata.markedQuestions = clonePlainObject(annotations.markedQuestions);
const generateRecordId = typeof options.generateRecordId === 'function'
? options.generateRecordId
: defaultGenerateRecordId;
@@ -1104,13 +1139,13 @@
suiteMode: Boolean(recordData.suiteMode || ((recordData.frequency || metadata.frequency || '').toLowerCase() === 'suite')),
suiteSessionId: recordData.suiteSessionId || (metadata && metadata.suiteSessionId) || null,
suiteEntries: normalizedSuiteEntries,
+ ...annotations,
scoreInfo: recordData.scoreInfo
? Object.assign({}, recordData.scoreInfo, {
details: recordData.scoreInfo.details || detailSource || null
})
: (detailSource ? { details: detailSource } : null),
- realData: recordData.realData
- ? Object.assign({}, recordData.realData, {
+ realData: Object.assign({}, recordData.realData || {}, {
answers: (recordData.realData && recordData.realData.answers) || answerMap,
correctAnswers: realDataCorrectAnswers,
correctAnswerMap: clonePlainObject(normalizedCorrectMap || {}),
@@ -1119,9 +1154,9 @@
}),
answerComparison: (recordData.realData && recordData.realData.answerComparison)
? clonePlainObject(recordData.realData.answerComparison)
- : (normalizedComparison || null)
- })
- : (normalizedComparison ? { answerComparison: normalizedComparison } : null),
+ : (normalizedComparison || null),
+ ...clonePlainObject(annotations)
+ }),
answerComparison: normalizedComparison,
version: options.currentVersion || recordData.version || '0.6.2-fix',
createdAt: firstDateCandidate(recordData.createdAt, recordData.startTime, recordData.start_time, recordDate) || now,
@@ -1336,26 +1371,7 @@
|| (examEntry && examEntry.title)
|| resolvedExamId
|| '未命名练习';
- const resolvedHighlights = Array.isArray(rawPayload.highlights)
- ? rawPayload.highlights.slice()
- : (Array.isArray(rawPayload.realData && rawPayload.realData.highlights)
- ? rawPayload.realData.highlights.slice()
- : (Array.isArray(sessionContext.highlights) ? sessionContext.highlights.slice() : []));
- const resolvedMarkedQuestions = Array.isArray(rawPayload.markedQuestions)
- ? rawPayload.markedQuestions.slice()
- : (Array.isArray(rawPayload.realData && rawPayload.realData.markedQuestions)
- ? rawPayload.realData.markedQuestions.slice()
- : (Array.isArray(sessionContext.markedQuestions) ? sessionContext.markedQuestions.slice() : []));
- const resolvedScrollY = Number.isFinite(Number(rawPayload.scrollY))
- ? Number(rawPayload.scrollY)
- : (Number.isFinite(Number(rawPayload.realData && rawPayload.realData.scrollY))
- ? Number(rawPayload.realData.scrollY)
- : (Number.isFinite(Number(sessionContext.scrollY)) ? Number(sessionContext.scrollY) : 0));
- const resolvedNoteText = typeof rawPayload.noteText === 'string'
- ? rawPayload.noteText
- : (typeof rawPayload.realData?.noteText === 'string'
- ? rawPayload.realData.noteText
- : (typeof sessionContext.noteText === 'string' ? sessionContext.noteText : ''));
+ const annotations = resolveAnnotationState(rawPayload, [sessionContext]);
const resolvedQuestionTypeMap = isPlainObject(rawPayload.questionTypeMap)
? clonePlainObject(rawPayload.questionTypeMap)
: (isPlainObject(rawPayload.realData && rawPayload.realData.questionTypeMap)
@@ -1389,16 +1405,13 @@
examTitle: title,
category,
frequency,
- markedQuestions: resolvedMarkedQuestions.slice()
+ markedQuestions: clonePlainObject(annotations.markedQuestions)
}),
frequency,
suiteMode: Boolean(rawPayload.suiteMode || (String(rawPayload.practiceMode || metadata.practiceMode || '').toLowerCase() === 'suite')),
suiteSessionId,
suiteEntries,
- highlights: resolvedHighlights.slice(),
- scrollY: resolvedScrollY,
- markedQuestions: resolvedMarkedQuestions.slice(),
- noteText: resolvedNoteText,
+ ...annotations,
questionTypeMap: resolvedQuestionTypeMap,
scoreInfo: Object.assign({}, scoreInfo, {
correct: correctAnswers,
@@ -1413,10 +1426,7 @@
correctAnswers: correctAnswerMap,
answerComparison,
correctAnswerMap,
- highlights: resolvedHighlights.slice(),
- scrollY: resolvedScrollY,
- markedQuestions: resolvedMarkedQuestions.slice(),
- noteText: resolvedNoteText,
+ ...clonePlainObject(annotations),
questionTypeMap: resolvedQuestionTypeMap,
scoreInfo: Object.assign({}, (rawPayload.realData && rawPayload.realData.scoreInfo) || scoreInfo, {
correct: correctAnswers,
@@ -1434,367 +1444,6 @@
}, options);
}
- function getRepositories() {
- return internalRepositories;
- }
-
- function getStorageManager(storageManager) {
- return storageManager || global.persistentStore || global.storage || null;
- }
-
- function getStorageInternalOptions(storage) {
- // 仓库注入后用 token 化选项,确保 fallback 读写能通过 storage 的 internal-only 保护。
- if (internalStorageAccess && typeof internalStorageAccess.createInternalOptions === 'function') {
- try {
- return internalStorageAccess.createInternalOptions({});
- } catch (_) {
- // fallthrough 到旧行为
- }
- }
- return { skipPracticeCoreRedirect: true };
- }
-
- function syncPracticeRecordState(records) {
- const syncAppState = (nextRecords) => {
- try {
- if (global.app && global.app.state && global.app.state.practice) {
- global.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : [];
- }
- } catch (_) {}
- };
-
- if (typeof global.setPracticeRecordsState === 'function') {
- try {
- const finalRecords = global.setPracticeRecordsState(records);
- syncAppState(finalRecords);
- return;
- } catch (error) {
- console.warn('[PracticeCore] 同步 practice records 状态失败:', error);
- }
- }
- syncAppState(records);
- }
-
- async function readPracticeRecords(storageManager) {
- const repos = getRepositories();
- if (repos && repos.practice && typeof repos.practice.list === 'function') {
- return await repos.practice.list();
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.readPersistentValue === 'function') {
- return await storage.readPersistentValue(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage));
- }
- if (storage && typeof storage.get === 'function') {
- return await storage.get(STORAGE_KEYS.practiceRecords, [], getStorageInternalOptions(storage));
- }
- return [];
- }
-
- /**
- * 轻量投影:读取原始数组(clone:false 跳过 structuredClone),映射为精简 summary 对象。
- * 排除 answers/answerDetails/correctAnswerMap/suiteEntries[]/realData/answerComparison 等重字段,
- * 供练习历史列表、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用,
- * 避免大数据量下反序列化+克隆全部记录导致的前端渲染卡顿和内存溢出。
- */
- function projectRecordSummary(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
- const scoreInfo = record.scoreInfo || {};
- const metadata = record.metadata || {};
- // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData
- const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
- const suiteEntries = rawSuiteEntries.map(function (entry) {
- if (!entry || typeof entry !== 'object') { return null; }
- const entryMeta = entry.metadata || {};
- const entryScore = entry.scoreInfo || {};
- return {
- id: entry.id || '',
- examId: entry.examId || entryMeta.examId || '',
- title: entry.title || entryMeta.examTitle || '',
- percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0,
- duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0
- };
- }).filter(Boolean);
- return {
- id: record.id || record.sessionId || '',
- sessionId: record.sessionId || null,
- examId: record.examId || metadata.examId || null,
- title: record.title || metadata.examTitle || '',
- type: record.type || metadata.type || 'reading',
- practiceType: record.practiceType || metadata.practiceType || metadata.examType || null,
- url: record.url || metadata.url || null,
- startTime: record.startTime || null,
- endTime: record.endTime || null,
- date: record.date || null,
- duration: Number(record.duration ?? scoreInfo.duration ?? scoreInfo.timeSpent) || 0,
- percentage: Number(record.percentage ?? scoreInfo.percentage) || 0,
- accuracy: Number(record.accuracy ?? scoreInfo.accuracy) || 0,
- score: Number(record.score ?? scoreInfo.score) || 0,
- totalQuestions: Number(record.totalQuestions ?? scoreInfo.total) || 0,
- correctAnswers: Number(record.correctAnswers ?? scoreInfo.correct) || 0,
- status: record.status || 'completed',
- suiteMode: Boolean(record.suiteMode),
- suiteEntryCount: rawSuiteEntries.length,
- suiteEntries: suiteEntries,
- suiteSessionId: record.suiteSessionId || (metadata.suiteSessionId) || null,
- // questionTypePerformance 是小对象(每题型 {total,correct}),不是重字段,保留供 recalculateStats 使用
- questionTypePerformance: record.questionTypePerformance || null,
- // 轻量 scoreInfo 子集:供 accuracy/duration 等 fallback 读取
- scoreInfo: {
- accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null,
- duration: scoreInfo.duration != null ? scoreInfo.duration : null,
- timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null,
- percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null,
- score: scoreInfo.score != null ? scoreInfo.score : null,
- total: scoreInfo.total != null ? scoreInfo.total : null,
- correct: scoreInfo.correct != null ? scoreInfo.correct : null
- },
- metadata: {
- category: metadata.category || record.category || null,
- examTitle: metadata.examTitle || record.title || '',
- frequency: metadata.frequency || record.frequency || 'unknown',
- type: metadata.type || record.type || null,
- examType: metadata.examType || null,
- practiceType: metadata.practiceType || null,
- examId: metadata.examId || null,
- title: metadata.title || null,
- url: metadata.url || null
- },
- updatedAt: record.updatedAt || null,
- createdAt: record.createdAt || null
- };
- }
-
- async function readPracticeRecordSummaries(storageManager) {
- const repos = getRepositories();
- let records;
- if (repos && repos.practice && typeof repos.practice.read === 'function') {
- // clone:false 跳过 structuredClone,在投影后原始重字段不会进入返回值
- records = await repos.practice.read({ clone: false });
- } else {
- records = await readPracticeRecords(storageManager);
- }
- if (!Array.isArray(records)) {
- return [];
- }
- return records
- .map(projectRecordSummary)
- .filter(Boolean);
- }
-
- /**
- * 轻量计数:使用 repository.count()(clone:false + .length),不构造 summary 数组。
- */
- async function countPracticeRecords(storageManager) {
- const repos = getRepositories();
- if (repos && repos.practice && typeof repos.practice.count === 'function') {
- return await repos.practice.count();
- }
- const records = await readPracticeRecords(storageManager);
- return Array.isArray(records) ? records.length : 0;
- }
-
- async function writePracticeRecords(records, storageManager) {
- const finalRecords = Array.isArray(records) ? records : [];
- const repos = getRepositories();
- if (repos && repos.practice && typeof repos.practice.overwrite === 'function') {
- await repos.practice.overwrite(finalRecords);
- syncPracticeRecordState(finalRecords);
- return true;
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.writePersistentValue === 'function') {
- const result = await storage.writePersistentValue(STORAGE_KEYS.practiceRecords, finalRecords, getStorageInternalOptions(storage));
- syncPracticeRecordState(finalRecords);
- return result;
- }
- return false;
- }
-
- async function readMeta(key, defaultValue, storageManager) {
- const repos = getRepositories();
- if (repos && repos.meta && typeof repos.meta.get === 'function') {
- return await repos.meta.get(key, defaultValue);
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.readPersistentValue === 'function') {
- return await storage.readPersistentValue(key, defaultValue, getStorageInternalOptions(storage));
- }
- if (storage && typeof storage.get === 'function') {
- return await storage.get(key, defaultValue, getStorageInternalOptions(storage));
- }
- return defaultValue;
- }
-
- async function writeMeta(key, value, storageManager) {
- const repos = getRepositories();
- if (repos && repos.meta && typeof repos.meta.set === 'function') {
- await repos.meta.set(key, value);
- return true;
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.writePersistentValue === 'function') {
- return await storage.writePersistentValue(key, value, getStorageInternalOptions(storage));
- }
- return false;
- }
-
- async function removeMeta(key, storageManager) {
- const repos = getRepositories();
- if (repos && repos.meta && typeof repos.meta.remove === 'function') {
- await repos.meta.remove(key);
- return true;
- }
- const storage = getStorageManager(storageManager);
- if (storage && typeof storage.removePersistentValue === 'function') {
- return await storage.removePersistentValue(key, getStorageInternalOptions(storage));
- }
- return false;
- }
-
- function extractSessionId(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
- const rawId = record.sessionId
- || (record.realData && record.realData.sessionId)
- || (record.metadata && record.metadata.sessionId)
- || null;
- if (!rawId) return null;
- return String(rawId).trim() || null;
- }
-
- function dedupePracticeRecords(records) {
- // 仅按 record.id 去重,不按 sessionId 全局去重。
- // sessionId 在套题场景中是容器标识,不是 attempt 唯一键;
- // 多条不同 id 的记录可能共享同一 sessionId(如同一套题的不同 passage),
- // 按 sessionId 去重会永久丢弃合法记录。
- const seenIds = new Set();
- const deduped = [];
-
- (Array.isArray(records) ? records : []).forEach((record) => {
- if (!record || typeof record !== 'object') {
- return;
- }
- const recordId = record.id != null ? String(record.id) : null;
-
- if (recordId && seenIds.has(recordId)) {
- return;
- }
-
- if (recordId) seenIds.add(recordId);
- deduped.push(record);
- });
-
- return deduped;
- }
-
- function getRecordTimestamp(record) {
- if (!record || typeof record !== 'object') {
- return 0;
- }
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.date,
- record.timestamp
- ];
- for (let index = 0; index < candidates.length; index += 1) {
- const value = candidates[index];
- if (!value) {
- continue;
- }
- const time = new Date(value).getTime();
- if (Number.isFinite(time)) {
- return time;
- }
- }
- return 0;
- }
-
- function handlesStorageKey(key) {
- return key === STORAGE_KEYS.practiceRecords
- || key === STORAGE_KEYS.userStats
- || key === STORAGE_KEYS.activeSessions
- || key === STORAGE_KEYS.tempPracticeRecords;
- }
-
- async function replacePracticeRecords(records, options = {}) {
- const canonical = dedupePracticeRecords(
- (Array.isArray(records) ? records : []).map((record) => standardizeRecord(record, options))
- );
- canonical.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a));
- if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && canonical.length > options.maxRecords) {
- canonical.splice(options.maxRecords);
- }
- return await writePracticeRecords(canonical, options.storageManager);
- }
-
- async function savePracticeRecord(record, options = {}) {
- const standardizedRecord = standardizeRecord(record, options);
- let records = await readPracticeRecords(options.storageManager);
- records = Array.isArray(records) ? records.slice() : [];
-
- const existingIndex = records.findIndex((entry) => entry && String(entry.id) === String(standardizedRecord.id));
- if (existingIndex >= 0) {
- records[existingIndex] = standardizedRecord;
- } else {
- records.unshift(standardizedRecord);
- }
-
- // 仅当同一 sessionId 且同一 examId 时才移除旧记录(同一篇练习的重复提交覆盖)。
- // 不同 examId 但共享 sessionId 的记录(如套题不同 passage)必须保留。
- const standardizedSessionId = extractSessionId(standardizedRecord);
- const standardizedExamId = standardizedRecord.examId || null;
- if (standardizedSessionId) {
- records = records.filter((entry, index) => {
- if (index === 0) {
- return true;
- }
- const sessionId = extractSessionId(entry);
- const examId = entry && entry.examId || null;
- const sameSession = sessionId && sessionId === standardizedSessionId;
- const sameExam = standardizedExamId && examId && examId === standardizedExamId;
- return !(sameSession && sameExam && String(entry.id) !== String(standardizedRecord.id));
- });
- }
-
- records = dedupePracticeRecords(records);
- records.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a));
- if (Number.isFinite(options.maxRecords) && options.maxRecords > 0 && records.length > options.maxRecords) {
- records.splice(options.maxRecords);
- }
- await writePracticeRecords(records, options.storageManager);
- return standardizedRecord;
- }
-
- async function routeStorageSet(storageManager, key, value, options = {}) {
- if (key === STORAGE_KEYS.practiceRecords) {
- return await replacePracticeRecords(value, {
- currentVersion: options.currentVersion || '0.6.2-fix',
- maxRecords: options.maxRecords || 1000,
- storageManager
- });
- }
- if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) {
- return await writeMeta(key, value, storageManager);
- }
- return null;
- }
-
- async function routeStorageRemove(storageManager, key) {
- if (key === STORAGE_KEYS.practiceRecords) {
- return await writePracticeRecords([], storageManager);
- }
- if (key === STORAGE_KEYS.userStats || key === STORAGE_KEYS.activeSessions || key === STORAGE_KEYS.tempPracticeRecords) {
- return await removeMeta(key, storageManager);
- }
- return null;
- }
-
const contracts = Object.freeze({
ensureNumber,
normalizePracticeType,
@@ -1823,6 +1472,7 @@
buildMetadata,
standardizeRecord,
standardizeSuiteEntries,
+ resolveAnnotationState,
clonePlainObject
});
@@ -1839,71 +1489,12 @@
fromCompletion
});
- const internalStore = Object.freeze({
- STORAGE_KEYS,
- handlesStorageKey,
- listPracticeRecords: readPracticeRecords,
- listPracticeRecordSummaries: readPracticeRecordSummaries,
- countPracticeRecords,
- replacePracticeRecords,
- savePracticeRecord,
- routeStorageSet,
- routeStorageRemove,
- readMeta,
- writeMeta,
- removeMeta,
- syncPracticeRecordState
- });
-
- const publicStore = Object.freeze({
- STORAGE_KEYS,
- handlesStorageKey,
- listPracticeRecords: readPracticeRecords,
- listPracticeRecordSummaries: readPracticeRecordSummaries,
- countPracticeRecords,
- readMeta,
- syncPracticeRecordState
- });
-
- const practiceCore = {
+ const practiceCore = Object.freeze({
__stable: true,
version: '0.6.2-fix',
contracts,
protocol,
- ingestor,
- store: publicStore
- };
- Object.defineProperty(practiceCore, '__installRecordAPI', {
- value: function(install) {
- if (typeof install !== 'function') {
- throw new Error('PracticeCore.__installRecordAPI requires an installer function');
- }
- return install(internalStore);
- },
- enumerable: false,
- configurable: true,
- writable: false
- });
- Object.defineProperty(practiceCore, '__installInternalRepositories', {
- value: function(repositories, installers) {
- if (!repositories || typeof repositories !== 'object') {
- throw new Error('PracticeCore.__installInternalRepositories requires repositories');
- }
- internalRepositories = repositories;
- // 接收 storage internal token factory,供 fallback 路径使用。
- if (installers && typeof installers.createInternalOptions === 'function') {
- internalStorageAccess = { createInternalOptions: installers.createInternalOptions };
- }
- try {
- delete practiceCore.__installInternalRepositories;
- } catch (_) {
- practiceCore.__installInternalRepositories = undefined;
- }
- return true;
- },
- enumerable: false,
- configurable: true,
- writable: false
+ ingestor
});
global.PracticeCore = practiceCore;
})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/core/practiceRecordAPI.js b/js/core/practiceRecordAPI.js
deleted file mode 100644
index 18d91ca9..00000000
--- a/js/core/practiceRecordAPI.js
+++ /dev/null
@@ -1,883 +0,0 @@
-(function initPracticeRecordAPI(global) {
- 'use strict';
-
- const DEFAULT_VERSION = '0.6.2-fix';
- const DEFAULT_MAX_RECORDS = 1000;
-
- if (global.PracticeRecordAPI && global.PracticeRecordAPI.__stable === true) {
- return;
- }
-
- let recordStore = null;
-
- function getPracticeCore() {
- return global.PracticeCore || null;
- }
-
- function installRecordStore() {
- if (recordStore) {
- return recordStore;
- }
- const core = getPracticeCore();
- if (!core || typeof core.__installRecordAPI !== 'function') {
- return null;
- }
- recordStore = core.__installRecordAPI((store) => store || null);
- try {
- delete core.__installRecordAPI;
- } catch (_) {
- core.__installRecordAPI = undefined;
- }
- return recordStore;
- }
-
- function getRecordStore() {
- return recordStore || installRecordStore();
- }
-
- installRecordStore();
-
- function getDefaultSaveOptions(options = {}) {
- const source = options && typeof options === 'object' ? options : {};
- const normalized = {
- currentVersion: source.currentVersion || DEFAULT_VERSION,
- maxRecords: DEFAULT_MAX_RECORDS
- };
- const maxRecords = Number(source.maxRecords);
- if (Number.isFinite(maxRecords) && maxRecords > 0) {
- normalized.maxRecords = maxRecords;
- }
- Object.keys(source).forEach((key) => {
- if (source[key] !== undefined) {
- normalized[key] = source[key];
- }
- });
- normalized.currentVersion = normalized.currentVersion || DEFAULT_VERSION;
- normalized.maxRecords = Number.isFinite(Number(normalized.maxRecords)) && Number(normalized.maxRecords) > 0
- ? Number(normalized.maxRecords)
- : DEFAULT_MAX_RECORDS;
- return normalized;
- }
-
- function toIdString(value) {
- return value == null ? '' : String(value);
- }
-
- function isPlainObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value);
- }
-
- function clonePlainObject(value) {
- if (value == null || typeof value !== 'object') {
- return value ?? null;
- }
- if (Array.isArray(value)) {
- return value.map((item) => clonePlainObject(item));
- }
- const clone = {};
- Object.keys(value).forEach((key) => {
- clone[key] = clonePlainObject(value[key]);
- });
- return clone;
- }
-
- function getDefaultStats() {
- if (global.ExamData && typeof global.ExamData.createDefaultUserStats === 'function') {
- return clonePlainObject(global.ExamData.createDefaultUserStats());
- }
- const now = new Date().toISOString();
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- practiceDays: [],
- lastPracticeDate: null,
- achievements: [],
- createdAt: now,
- updatedAt: now
- };
- }
-
- function toCamelCaseKey(key) {
- return String(key)
- .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase())
- .replace(/^[A-Z]/, match => match.toLowerCase());
- }
-
- function normalizeStatsAliases(stats) {
- if (!isPlainObject(stats)) {
- return {};
- }
- const normalized = {};
- Object.entries(stats).forEach(([key, value]) => {
- normalized[toCamelCaseKey(key)] = value;
- });
- return normalized;
- }
-
- function prepareStats(stats) {
- const source = normalizeStatsAliases(stats);
- const prepared = Object.assign({}, getDefaultStats(), clonePlainObject(source));
- prepared.categoryStats = isPlainObject(source.categoryStats) ? clonePlainObject(source.categoryStats) : {};
- prepared.questionTypeStats = isPlainObject(source.questionTypeStats) ? clonePlainObject(source.questionTypeStats) : {};
- prepared.practiceDays = Array.isArray(source.practiceDays) ? source.practiceDays.slice() : [];
- prepared.achievements = Array.isArray(source.achievements) ? source.achievements.slice() : [];
- prepared.updatedAt = source.updatedAt || new Date().toISOString();
- return prepared;
- }
-
- function getCoreContracts() {
- const core = getPracticeCore();
- return core && core.contracts ? core.contracts : null;
- }
-
- function normalizeRecord(record, options = {}) {
- if (!isPlainObject(record)) {
- return null;
- }
-
- const contracts = getCoreContracts();
- if (!contracts || typeof contracts.standardizeRecord !== 'function') {
- throw new Error('PracticeRecordAPI.normalizeRecord: PracticeCore.contracts.standardizeRecord not ready');
- }
-
- const preserveIds = options.preserveIds !== false;
- const safePrefix = options.fallbackIdPrefix || 'record';
- const sourceId = record.id
- ?? record.recordId
- ?? record.record_id
- ?? record.practiceId
- ?? record.practice_id
- ?? record.sessionId
- ?? record.sessionID
- ?? record.timestamp
- ?? record.uuid;
- const candidate = clonePlainObject(record) || {};
-
- let id = preserveIds && sourceId ? String(sourceId).trim() : '';
- if (!id) {
- const index = Number.isFinite(Number(options.index)) ? Number(options.index) : 0;
- id = `${safePrefix}_${Date.now()}_${index}_${Math.random().toString(36).slice(2, 8)}`;
- }
- candidate.id = id;
-
- if (record.recordStatus !== undefined && candidate.status === undefined) {
- candidate.status = record.recordStatus;
- }
-
- const generateRecordId = typeof options.generateRecordId === 'function'
- ? options.generateRecordId
- : () => id;
- const standardized = contracts.standardizeRecord(candidate, Object.assign({}, options, {
- currentVersion: options.currentVersion || DEFAULT_VERSION,
- generateRecordId
- }));
- return standardized && standardized.examId ? standardized : null;
- }
-
- function normalizeDateValue(value) {
- if (!value) {
- return null;
- }
- if (value instanceof Date && !Number.isNaN(value.getTime())) {
- return value.toISOString();
- }
- if (typeof value === 'number' && Number.isFinite(value)) {
- return new Date(value).toISOString();
- }
- if (typeof value === 'string') {
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
- if (/^\d+$/.test(trimmed)) {
- const numeric = Number(trimmed);
- if (Number.isFinite(numeric)) {
- const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000;
- return new Date(milliseconds).toISOString();
- }
- }
- const parsed = new Date(trimmed);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
- return null;
- }
-
- function getRecordTimestamp(record) {
- if (!record || typeof record !== 'object') {
- return 0;
- }
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.timestamp,
- record.date
- ];
- for (let index = 0; index < candidates.length; index += 1) {
- const iso = normalizeDateValue(candidates[index]);
- if (iso) {
- const time = new Date(iso).getTime();
- if (Number.isFinite(time)) {
- return time;
- }
- }
- }
- return 0;
- }
-
- function mergeRecordDetails(existing, incoming, options = {}) {
- const merged = Object.assign({}, existing || {}, incoming || {});
- if (isPlainObject(existing && existing.metadata) || isPlainObject(incoming && incoming.metadata)) {
- merged.metadata = Object.assign(
- {},
- isPlainObject(existing && existing.metadata) ? existing.metadata : {},
- isPlainObject(incoming && incoming.metadata) ? incoming.metadata : {}
- );
- }
- if (isPlainObject(existing && existing.realData) || isPlainObject(incoming && incoming.realData)) {
- merged.realData = Object.assign(
- {},
- isPlainObject(existing && existing.realData) ? existing.realData : {},
- isPlainObject(incoming && incoming.realData) ? incoming.realData : {}
- );
- }
- return normalizeRecord(merged, Object.assign({}, options, {
- generateRecordId: () => String(merged.id || (incoming && incoming.id) || (existing && existing.id) || `record_${Date.now()}`)
- }));
- }
-
- async function readStats(options = {}) {
- const fallback = Object.prototype.hasOwnProperty.call(options, 'fallback')
- ? options.fallback
- : getDefaultStats();
-
- const store = getRecordStore();
- if (!store || typeof store.readMeta !== 'function') {
- throw new Error('PracticeRecordAPI.readStats: unified meta store not ready');
- }
-
- return prepareStats(await store.readMeta('user_stats', fallback));
- }
-
- async function writeStats(stats) {
- const finalStats = prepareStats(stats);
- const store = getRecordStore();
-
- if (store && typeof store.writeMeta === 'function') {
- await store.writeMeta('user_stats', finalStats);
- return finalStats;
- }
-
- throw new Error('PracticeRecordAPI.writeStats: unified meta store not ready');
- }
-
- function normalizeDay(value) {
- if (!value) return null;
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return null;
- return date.toISOString().slice(0, 10);
- }
-
- function calculateStreakDays(days) {
- const sorted = Array.isArray(days) ? days.slice().sort() : [];
- if (sorted.length === 0) return 0;
- let streak = 1;
- for (let index = sorted.length - 1; index > 0; index -= 1) {
- const current = new Date(sorted[index]);
- const previous = new Date(sorted[index - 1]);
- const diffDays = Math.round((current - previous) / 86400000);
- if (diffDays === 1) {
- streak += 1;
- continue;
- }
- if (diffDays > 1) break;
- }
- return streak;
- }
-
- function normalizeAccuracyForStats(record) {
- const values = [
- record && record.accuracy,
- record && record.scoreInfo && record.scoreInfo.accuracy,
- record && record.realData && record.realData.scoreInfo && record.realData.scoreInfo.accuracy
- ];
- for (let index = 0; index < values.length; index += 1) {
- const numeric = Number(values[index]);
- if (Number.isFinite(numeric)) {
- if (numeric > 1 && numeric <= 100) {
- return numeric / 100;
- }
- return Math.max(0, Math.min(1, numeric));
- }
- }
- const correct = Number(record && (record.correctAnswers ?? record.scoreInfo?.correct ?? record.score));
- const total = Number(record && (record.totalQuestions ?? record.scoreInfo?.total));
- return Number.isFinite(correct) && Number.isFinite(total) && total > 0
- ? Math.max(0, Math.min(1, correct / total))
- : 0;
- }
-
- function applyRecordToStats(stats, record) {
- if (!stats || !record || typeof record !== 'object') {
- return;
- }
-
- const duration = Math.max(0, Number(record.duration) || 0);
- const accuracy = normalizeAccuracyForStats(record);
- const category = String((record.metadata && record.metadata.category) || record.category || record.type || '').trim();
- const day = normalizeDay(record.date || record.endTime || record.startTime || record.createdAt);
-
- stats.totalPractices += 1;
- stats.totalTimeSpent += duration;
- const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy;
- stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0;
-
- stats.categoryStats = isPlainObject(stats.categoryStats) ? stats.categoryStats : {};
- if (category) {
- if (!stats.categoryStats[category]) {
- stats.categoryStats[category] = {
- practices: 0,
- avgScore: 0,
- timeSpent: 0,
- bestScore: 0,
- totalQuestions: 0,
- correctAnswers: 0
- };
- }
- const categoryStats = stats.categoryStats[category];
- categoryStats.practices += 1;
- categoryStats.timeSpent += duration;
- categoryStats.bestScore = Math.max(categoryStats.bestScore || 0, accuracy);
- categoryStats.totalQuestions += Number(record.totalQuestions) || 0;
- categoryStats.correctAnswers += Number(record.correctAnswers) || 0;
- categoryStats.avgScore = ((categoryStats.avgScore || 0) * (categoryStats.practices - 1) + accuracy) / categoryStats.practices;
- }
-
- stats.questionTypeStats = isPlainObject(stats.questionTypeStats) ? stats.questionTypeStats : {};
- if (isPlainObject(record.questionTypePerformance)) {
- Object.entries(record.questionTypePerformance).forEach(([type, performance]) => {
- if (!stats.questionTypeStats[type]) {
- stats.questionTypeStats[type] = {
- practices: 0,
- accuracy: 0,
- totalQuestions: 0,
- correctAnswers: 0
- };
- }
- const typeStats = stats.questionTypeStats[type];
- typeStats.practices += 1;
- typeStats.totalQuestions += Number(performance && performance.total) || 0;
- typeStats.correctAnswers += Number(performance && performance.correct) || 0;
- typeStats.accuracy = typeStats.totalQuestions > 0
- ? typeStats.correctAnswers / typeStats.totalQuestions
- : 0;
- });
- }
-
- if (day) {
- const days = new Set(Array.isArray(stats.practiceDays) ? stats.practiceDays : []);
- days.add(day);
- stats.practiceDays = Array.from(days).sort();
- stats.lastPracticeDate = stats.practiceDays[stats.practiceDays.length - 1] || null;
- stats.streakDays = calculateStreakDays(stats.practiceDays);
- }
- stats.updatedAt = new Date().toISOString();
- }
-
- async function recalculateStats() {
- // 使用轻量 listSummary 避免反序列化+克隆完整记录(answers/suiteEntries/realData 等重字段)。
- // summary 已包含 applyRecordToStats 所需的全部字段:duration, accuracy, metadata.category,
- // date/endTime/startTime/createdAt, totalQuestions, correctAnswers, questionTypePerformance。
- const records = await listSummary();
- const stats = getDefaultStats();
- (Array.isArray(records) ? records : []).forEach((record) => applyRecordToStats(stats, record));
- return await writeStats(stats);
- }
-
- async function resetStats(stats = null) {
- return await writeStats(isPlainObject(stats) ? stats : getDefaultStats());
- }
-
- async function mergeStats(stats, options = {}) {
- if (!isPlainObject(stats)) {
- return await readStats();
- }
-
- const mergeMode = options.mergeMode || options.mode || 'merge';
- if (mergeMode === 'replace') {
- return await writeStats(stats);
- }
-
- const existing = await readStats({ fallback: {} });
- const merged = Object.assign({}, existing);
- Object.entries(stats).forEach(([key, value]) => {
- if (value === undefined || value === null) {
- return;
- }
- const current = existing[key];
- if (typeof value === 'number' && typeof current === 'number') {
- merged[key] = Math.max(value, current);
- return;
- }
- if (isPlainObject(value) && isPlainObject(current)) {
- merged[key] = Object.assign({}, current, value);
- return;
- }
- merged[key] = clonePlainObject(value);
- });
-
- return await writeStats(merged);
- }
-
- async function updateStatsForSavedRecord(record, options = {}) {
- if (!record || options.updateStats === false) {
- return false;
- }
-
- await recalculateStats();
- return true;
- }
-
- async function list() {
- const store = getRecordStore();
- if (!store || typeof store.listPracticeRecords !== 'function') {
- throw new Error('PracticeRecordAPI.list: unified store not ready');
- }
-
- const records = await store.listPracticeRecords();
- return Array.isArray(records) ? records : [];
- }
-
- /**
- * 轻量投影查询:返回每条记录的元数据摘要,不含 answers/correctAnswerMap/
- * suiteEntries[]/realData 等重字段。底层以 clone:false 读取原始数组后即时映射,
- * 避免大数据量下 structuredClone 全部记录导致内存溢出和渲染卡顿。
- * 供练习历史列表签名、趋势图、热力图、成就统计等只需时间戳和元数据的消费者使用。
- */
- async function listSummary(options = {}) {
- const store = getRecordStore();
- if (!store || typeof store.listPracticeRecordSummaries !== 'function') {
- // 回退:store 尚未支持 summary 时从完整记录投影
- const records = await list();
- return records.map(_projectSummary).filter(Boolean);
- }
- const summaries = await store.listPracticeRecordSummaries();
- return Array.isArray(summaries) ? summaries : [];
- }
-
- /** 返回记录总数,不加载记录数组到内存 */
- async function count(options = {}) {
- const store = getRecordStore();
- if (store && typeof store.countPracticeRecords === 'function') {
- return await store.countPracticeRecords();
- }
- // 回退:store 不支持 count 时从 summary 长度获取
- if (store && typeof store.listPracticeRecordSummaries === 'function') {
- const summaries = await store.listPracticeRecordSummaries();
- return Array.isArray(summaries) ? summaries.length : 0;
- }
- const records = await list();
- return Array.isArray(records) ? records.length : 0;
- }
-
- /** 返回去重后的 examId 列表,供 overview 统计使用 */
- async function distinctExamIds(options = {}) {
- const summaries = await listSummary(options);
- const seen = new Set();
- const result = [];
- for (let i = 0; i < summaries.length; i += 1) {
- const examId = summaries[i] && summaries[i].examId;
- if (examId && !seen.has(examId)) {
- seen.add(examId);
- result.push(examId);
- }
- }
- return result;
- }
-
- /** 纯函数投影:从单条完整记录提取轻量 summary */
- function _projectSummary(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
- const scoreInfo = record.scoreInfo || {};
- const metadata = record.metadata || {};
- // 轻量 suiteEntries 投影:仅保留签名字段,不含 answers/correctAnswerMap/realData
- const rawSuiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
- const suiteEntries = rawSuiteEntries.map(function (entry) {
- if (!entry || typeof entry !== 'object') { return null; }
- const entryMeta = entry.metadata || {};
- const entryScore = entry.scoreInfo || {};
- return {
- id: entry.id || '',
- examId: entry.examId || entryMeta.examId || '',
- title: entry.title || entryMeta.examTitle || '',
- percentage: Number(entry.percentage != null ? entry.percentage : entryScore.percentage) || 0,
- duration: Number(entry.duration != null ? entry.duration : (entry.rawData && entry.rawData.duration)) || 0
- };
- }).filter(Boolean);
- return {
- id: record.id || record.sessionId || '',
- sessionId: record.sessionId || null,
- examId: record.examId || metadata.examId || null,
- title: record.title || metadata.examTitle || '',
- type: record.type || metadata.type || 'reading',
- practiceType: record.practiceType || metadata.practiceType || metadata.examType || null,
- url: record.url || metadata.url || null,
- startTime: record.startTime || null,
- endTime: record.endTime || null,
- date: record.date || null,
- duration: Number(record.duration != null ? record.duration : (scoreInfo.duration != null ? scoreInfo.duration : scoreInfo.timeSpent)) || 0,
- percentage: Number(record.percentage != null ? record.percentage : scoreInfo.percentage) || 0,
- accuracy: Number(record.accuracy != null ? record.accuracy : scoreInfo.accuracy) || 0,
- score: Number(record.score != null ? record.score : scoreInfo.score) || 0,
- totalQuestions: Number(record.totalQuestions != null ? record.totalQuestions : scoreInfo.total) || 0,
- correctAnswers: Number(record.correctAnswers != null ? record.correctAnswers : scoreInfo.correct) || 0,
- status: record.status || 'completed',
- suiteMode: Boolean(record.suiteMode),
- suiteEntryCount: rawSuiteEntries.length,
- suiteEntries: suiteEntries,
- suiteSessionId: record.suiteSessionId || metadata.suiteSessionId || null,
- questionTypePerformance: record.questionTypePerformance || null,
- scoreInfo: {
- accuracy: scoreInfo.accuracy != null ? scoreInfo.accuracy : null,
- duration: scoreInfo.duration != null ? scoreInfo.duration : null,
- timeSpent: scoreInfo.timeSpent != null ? scoreInfo.timeSpent : null,
- percentage: scoreInfo.percentage != null ? scoreInfo.percentage : null,
- score: scoreInfo.score != null ? scoreInfo.score : null,
- total: scoreInfo.total != null ? scoreInfo.total : null,
- correct: scoreInfo.correct != null ? scoreInfo.correct : null
- },
- metadata: {
- category: metadata.category || record.category || null,
- examTitle: metadata.examTitle || record.title || '',
- frequency: metadata.frequency || record.frequency || 'unknown',
- type: metadata.type || record.type || null,
- examType: metadata.examType || null,
- practiceType: metadata.practiceType || null,
- examId: metadata.examId || null,
- title: metadata.title || null,
- url: metadata.url || null
- },
- updatedAt: record.updatedAt || null,
- createdAt: record.createdAt || null
- };
- }
-
- async function getById(recordId) {
- const targetId = toIdString(recordId);
- if (!targetId) {
- return null;
- }
- const records = await list();
- return records.find((record) => {
- if (!record || typeof record !== 'object') {
- return false;
- }
- return toIdString(record.id) === targetId || toIdString(record.sessionId) === targetId;
- }) || null;
- }
-
- async function replace(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('PracticeRecordAPI.replace requires an array of records');
- }
- const finalRecords = records;
- const saveOptions = getDefaultSaveOptions(options);
- const store = getRecordStore();
- if (store && typeof store.replacePracticeRecords === 'function') {
- await store.replacePracticeRecords(finalRecords, saveOptions);
- if (options.updateStats !== false) {
- await recalculateStats();
- }
- return finalRecords;
- }
-
- throw new Error('PracticeRecordAPI.replace: unified store not ready');
- }
-
- async function mergeRecords(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('PracticeRecordAPI.mergeRecords requires an array of records');
- }
-
- const mergeMode = options.mergeMode || options.mode || 'merge';
- const normalizeOptions = getDefaultSaveOptions(options);
- const incomingRecords = records
- .map((record, index) => normalizeRecord(record, Object.assign({}, normalizeOptions, {
- preserveIds: options.preserveIds !== false,
- fallbackIdPrefix: options.fallbackIdPrefix || 'record',
- index
- })))
- .filter(Boolean);
- const existingRecords = await list();
-
- if (mergeMode === 'replace') {
- await replace(incomingRecords, Object.assign({}, options, { updateStats: options.updateStats !== false }));
- return {
- importedCount: incomingRecords.length,
- updatedCount: existingRecords.length,
- skippedCount: 0,
- finalCount: incomingRecords.length,
- records: incomingRecords
- };
- }
-
- const indexMap = new Map();
- existingRecords.forEach((record, index) => {
- if (record && record.id !== undefined && record.id !== null) {
- indexMap.set(String(record.id), { record, index });
- }
- });
-
- const mergedRecords = existingRecords.slice();
- let importedCount = 0;
- let updatedCount = 0;
- let skippedCount = 0;
-
- incomingRecords.forEach((record) => {
- if (!record || record.id === undefined || record.id === null) {
- return;
- }
-
- const key = String(record.id);
- const existing = indexMap.get(key);
-
- if (!existing) {
- mergedRecords.push(record);
- indexMap.set(key, { record, index: mergedRecords.length - 1 });
- importedCount += 1;
- return;
- }
-
- if (mergeMode === 'skip') {
- skippedCount += 1;
- return;
- }
-
- const existingTimestamp = getRecordTimestamp(existing.record);
- const incomingTimestamp = getRecordTimestamp(record);
- if (incomingTimestamp >= existingTimestamp) {
- const merged = mergeRecordDetails(existing.record, record, normalizeOptions);
- mergedRecords[existing.index] = merged;
- indexMap.set(key, { record: merged, index: existing.index });
- updatedCount += 1;
- return;
- }
-
- skippedCount += 1;
- });
-
- mergedRecords.sort((a, b) => getRecordTimestamp(b) - getRecordTimestamp(a));
- await replace(mergedRecords, Object.assign({}, options, { updateStats: options.updateStats !== false }));
-
- return {
- importedCount,
- updatedCount,
- skippedCount,
- finalCount: mergedRecords.length,
- records: mergedRecords
- };
- }
-
- async function restoreRecords(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('PracticeRecordAPI.restoreRecords requires an array of records');
- }
-
- await replace(records, Object.assign({}, options, { updateStats: false }));
- if (isPlainObject(options.stats)) {
- await writeStats(options.stats);
- } else if (options.updateStats !== false) {
- await recalculateStats();
- }
- return {
- restoredCount: records.length,
- statsRestored: isPlainObject(options.stats)
- };
- }
-
- async function clear(options = {}) {
- await replace([], Object.assign({}, options, { updateStats: false }));
- if (options.updateStats === true) {
- await resetStats();
- }
- return true;
- }
-
- async function deleteMany(recordIds, options = {}) {
- const ids = Array.isArray(recordIds) ? recordIds.map(toIdString).filter(Boolean) : [];
- if (ids.length === 0) {
- return { deletedCount: 0, deletedRecords: [], records: await list() };
- }
-
- const idSet = new Set(ids);
- // 默认仅按 record.id 删除,避免共享 sessionId 的不同记录被误删。
- // matchBy: 'sessionId' 时才按 sessionId 匹配(用于 suite 子记录清理等显式场景)。
- const matchBySessionId = options.matchBy === 'sessionId';
- const records = await list();
- const deletedRecords = [];
- const remainingRecords = [];
-
- (Array.isArray(records) ? records : []).forEach((record) => {
- const recordId = toIdString(record && record.id);
- const sessionId = toIdString(record && record.sessionId);
- const idMatch = recordId && idSet.has(recordId);
- const sessionMatch = matchBySessionId && sessionId && idSet.has(sessionId);
- if (idMatch || sessionMatch) {
- deletedRecords.push(record);
- return;
- }
- remainingRecords.push(record);
- });
-
- if (deletedRecords.length > 0) {
- await replace(remainingRecords, options);
- }
-
- return {
- deletedCount: deletedRecords.length,
- deletedRecords,
- records: remainingRecords
- };
- }
-
- async function deleteById(recordId, options = {}) {
- const result = await deleteMany([recordId], options);
- return {
- deleted: result.deletedCount > 0,
- record: result.deletedRecords[0] || null,
- records: result.records
- };
- }
-
- async function saveRecord(record, options = {}) {
- if (!record || typeof record !== 'object') {
- throw new Error('PracticeRecordAPI.saveRecord requires a record object');
- }
-
- const saveOptions = getDefaultSaveOptions(options);
- const store = getRecordStore();
- if (!store || typeof store.savePracticeRecord !== 'function') {
- throw new Error('PracticeRecordAPI.saveRecord: PracticeCore store not ready');
- }
- const normalizedRecord = normalizeRecord(record, saveOptions);
- if (!normalizedRecord || !normalizedRecord.examId) {
- throw new Error('PracticeRecordAPI.saveRecord requires a canonical examId');
- }
-
- const savedRecord = await store.savePracticeRecord(normalizedRecord, saveOptions);
-
- if (options.updateStats !== false) {
- await updateStatsForSavedRecord(savedRecord, options);
- }
-
- return savedRecord;
- }
-
- function fromCompletion(payload, context = {}, examEntry = null, options = {}) {
- const core = getPracticeCore();
- if (!core || !core.ingestor || typeof core.ingestor.fromCompletion !== 'function') {
- return null;
- }
- return core.ingestor.fromCompletion(payload, context || {}, examEntry || null, getDefaultSaveOptions(options));
- }
-
- async function saveCompletion(payload, context = {}, examEntry = null, options = {}) {
- const record = fromCompletion(payload, context, examEntry, options);
- if (!record) {
- throw new Error('PracticeRecordAPI.saveCompletion could not build canonical record');
- }
- return await saveRecord(record, options);
- }
-
- function normalizeAccuracy(value) {
- const numeric = Number(value);
- if (!Number.isFinite(numeric) || numeric < 0) {
- return 0;
- }
- if (numeric > 1 && numeric <= 100) {
- return numeric / 100;
- }
- return Math.min(numeric, 1);
- }
-
- function toSummaryMetrics(record = {}) {
- const total = Number(record.totalQuestions ?? record.scoreInfo?.total ?? record.scoreInfo?.totalQuestions ?? record.realData?.scoreInfo?.total ?? record.realData?.totalQuestions);
- const correct = Number(record.correctAnswers ?? record.score ?? record.scoreInfo?.correct ?? record.scoreInfo?.score ?? record.realData?.scoreInfo?.correct ?? record.realData?.score);
- const safeTotal = Number.isFinite(total) && total >= 0 ? total : 0;
- const safeCorrect = Number.isFinite(correct) && correct >= 0 ? correct : 0;
-
- let accuracy = normalizeAccuracy(record.accuracy ?? record.scoreInfo?.accuracy ?? record.realData?.scoreInfo?.accuracy ?? (safeTotal > 0 ? safeCorrect / safeTotal : 0));
- const percentageCandidate = Number(record.percentage ?? record.scoreInfo?.percentage ?? record.realData?.scoreInfo?.percentage);
- const percentage = Number.isFinite(percentageCandidate) && percentageCandidate >= 0 && percentageCandidate <= 100
- ? percentageCandidate
- : Math.round(accuracy * 100);
- const hasExplicitAccuracy = record.accuracy != null
- || record.scoreInfo?.accuracy != null
- || record.realData?.scoreInfo?.accuracy != null;
- accuracy = percentage > 1 && !hasExplicitAccuracy
- ? percentage / 100
- : accuracy;
-
- return {
- totalQuestions: safeTotal,
- correctAnswers: safeCorrect,
- accuracy,
- percentage,
- duration: Number(record.duration ?? record.realData?.duration) || 0
- };
- }
-
- function toReplayEntries(record, projector) {
- if (typeof projector === 'function') {
- return projector(record);
- }
- return [];
- }
-
- global.PracticeRecordAPI = {
- __stable: true,
- version: '0.6.2-fix',
- list,
- listSummary,
- count,
- distinctExamIds,
- getById,
- replace,
- mergeRecords,
- restoreRecords,
- clear,
- deleteById,
- deleteMany,
- saveRecord,
- normalizeRecord,
- fromCompletion,
- saveCompletion,
- toSummaryMetrics,
- toReplayEntries,
- getDefaultStats,
- prepareStats,
- readStats,
- writeStats,
- mergeStats,
- resetStats,
- recalculateStats,
- updateStatsForSavedRecord
- };
-
- if (global.persistentStore && typeof global.persistentStore.migrateLegacyData === 'function') {
- Promise.resolve()
- .then(() => global.persistentStore.migrateLegacyData({ skipReady: true }))
- .catch((error) => {
- console.warn('[PracticeRecordAPI] 延后练习记录迁移失败:', error);
- });
- }
-})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/core/practiceRecorder.js b/js/core/practiceRecorder.js
index 6cdfb31b..49433082 100644
--- a/js/core/practiceRecorder.js
+++ b/js/core/practiceRecorder.js
@@ -1,5 +1,3 @@
-const PRACTICE_RECORDER_EXPORT_VERSION = '0.6.2-fix';
-
/**
* 练习记录管理器
* 负责练习会话管理、成绩记录和数据持久化
@@ -11,19 +9,11 @@ class PracticeRecorder {
this.autoSaveInterval = 30000; // 30秒自动保存
this.autoSaveTimer = null;
- // 初始化存储系统
- this.scoreStorage = new ScoreStorage();
- this.repositories = window.dataRepositories;
- if (!this.repositories) {
- throw new Error('数据仓库未初始化,PracticeRecorder 无法构建');
- }
- this.metaRepo = this.repositories.meta;
-
this.practiceTypeCache = new Map();
// 异步初始化
this.ready = (async () => {
- await this.scoreStorage.ready;
+ await window.AppData.ready;
await this.initialize();
})();
@@ -58,6 +48,72 @@ class PracticeRecorder {
throw new Error(`PracticeRecorder requires PracticeCore.contracts.${name}`);
}
+ clonePlainObject(value) {
+ const coreContracts = this.getCoreContracts();
+ if (coreContracts && typeof coreContracts.clonePlainObject === 'function') {
+ return coreContracts.clonePlainObject(value);
+ }
+ if (value == null || typeof value !== 'object') {
+ return value ?? null;
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => this.clonePlainObject(item));
+ }
+ const clone = {};
+ Object.keys(value).forEach((key) => {
+ clone[key] = this.clonePlainObject(value[key]);
+ });
+ return clone;
+ }
+
+ activeSessionEntityId(sessionOrId) {
+ const rawId = sessionOrId && typeof sessionOrId === 'object'
+ ? (sessionOrId.id || sessionOrId.sessionId)
+ : sessionOrId;
+ const normalized = String(rawId || '').trim();
+ if (!normalized) {
+ throw new Error('Active practice session requires a stable session id');
+ }
+ return normalized.startsWith('active-session:') ? normalized : `active-session:${normalized}`;
+ }
+
+ async persistActiveSession(session, previousEntityId = null) {
+ const entity = Object.assign({}, session, { id: this.activeSessionEntityId(session) });
+ const receipt = await window.AppData.recovery.saveActiveSession(entity);
+ if (previousEntityId && previousEntityId !== entity.id) {
+ await window.AppData.recovery.discardActiveSession(previousEntityId);
+ }
+ return receipt;
+ }
+
+ resolveAnnotationState(recordData = {}, fallbackSources = []) {
+ const coreContracts = this.getCoreContracts();
+ if (coreContracts && typeof coreContracts.resolveAnnotationState === 'function') {
+ return coreContracts.resolveAnnotationState(recordData, fallbackSources);
+ }
+ const root = recordData && typeof recordData === 'object' ? recordData : {};
+ const sources = [root, root.rawData, root.realData, root.rawData?.realData]
+ .concat(Array.isArray(fallbackSources) ? fallbackSources : [fallbackSources])
+ .filter((source) => source && typeof source === 'object' && !Array.isArray(source));
+ const pickArray = (field) => {
+ const source = sources.find((candidate) => Array.isArray(candidate[field]));
+ return source ? this.clonePlainObject(source[field]) : [];
+ };
+ const pickString = (field) => {
+ const source = sources.find((candidate) => typeof candidate[field] === 'string');
+ return source ? source[field] : '';
+ };
+ const scrollSource = sources.find((candidate) => candidate.scrollY != null && Number.isFinite(Number(candidate.scrollY)));
+ return {
+ highlights: pickArray('highlights'),
+ markedQuestions: pickArray('markedQuestions'),
+ noteText: pickString('noteText'),
+ notes: pickArray('notes'),
+ noteOutlines: pickArray('noteOutlines'),
+ scrollY: scrollSource ? Number(scrollSource.scrollY) : 0
+ };
+ }
+
firstFiniteNumber(fallback, ...values) {
for (const value of values) {
if (value === undefined || value === null) {
@@ -108,8 +164,6 @@ class PracticeRecorder {
async recordRejectedCompletionPayload(payload, context = {}) {
try {
- const existing = await this.metaRepo.get('rejected_completion_payloads', []);
- const list = Array.isArray(existing) ? existing : [];
const snapshot = {
id: `rejected_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
@@ -126,54 +180,33 @@ class PracticeRecorder {
}
: null
};
- list.unshift(snapshot);
- if (list.length > 50) {
- list.splice(50);
+ await window.AppData.recovery.saveRejectedCompletion(snapshot);
+ const existing = await window.AppData.recovery.listRejectedCompletions();
+ const list = (Array.isArray(existing) ? existing : [])
+ .slice()
+ .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0));
+ for (const stale of list.slice(50)) {
+ await window.AppData.recovery.discardRejectedCompletion(stale.id || stale.sessionId || stale.recordId);
}
- await this.metaRepo.set('rejected_completion_payloads', list);
} catch (error) {
console.warn('[PracticeRecorder] 记录拒绝的完成负载失败:', error);
}
}
- lookupExamIndexEntry(examId) {
+ lookupExamIndexEntry(examId, examIndex = []) {
if (!examId) return null;
- if (this.practiceTypeCache.has(examId)) {
- return this.practiceTypeCache.get(examId);
- }
-
- const sources = [
- () => Array.isArray(window.examIndex) ? window.examIndex : null,
- () => typeof window.getReadingExamIndex === 'function'
- ? window.getReadingExamIndex().map(exam => ({ ...exam, type: exam.type || 'reading' }))
- : null,
- () => Array.isArray(window.__READING_EXAM_INDEX__)
- ? window.__READING_EXAM_INDEX__.map(exam => ({ ...exam, type: exam.type || 'reading' }))
- : null,
- () => Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : null
- ];
-
- for (const getSource of sources) {
- const list = getSource();
- if (Array.isArray(list)) {
- const entry = list.find(item => item && item.id === examId);
- if (entry) {
- this.practiceTypeCache.set(examId, entry);
- return entry;
- }
- }
- }
-
- this.practiceTypeCache.set(examId, null);
- return null;
+ const entry = (Array.isArray(examIndex) ? examIndex : [])
+ .find(item => item && item.id === examId) || null;
+ if (entry) this.practiceTypeCache.set(examId, entry);
+ return entry;
}
resolvePracticeType(session = {}, examEntry = null) {
const examId = session.examId;
const metadata = session.metadata || {};
const cachedEntry = this.practiceTypeCache.get(examId);
- const entry = examEntry || cachedEntry || this.lookupExamIndexEntry(examId);
+ const entry = examEntry || cachedEntry || null;
const normalized = this.normalizePracticeType(
metadata.type
@@ -329,12 +362,16 @@ class PracticeRecorder {
* 恢复活动会话
*/
async restoreActiveSessions() {
- const raw = await this.metaRepo.get('active_sessions', []);
+ const raw = await window.AppData.recovery.listActiveSessions();
const storedSessions = Array.isArray(raw) ? raw : [];
- storedSessions.forEach(sessionData => {
+ storedSessions
+ .slice()
+ .sort((left, right) => Date.parse(left.updatedAt || left.lastActivity || 0) - Date.parse(right.updatedAt || right.lastActivity || 0))
+ .forEach(sessionData => {
this.activeSessions.set(sessionData.examId, {
...sessionData,
+ id: this.activeSessionEntityId(sessionData),
status: 'restored',
lastActivity: new Date().toISOString()
});
@@ -370,6 +407,13 @@ class PracticeRecorder {
}
const { type, data } = normalized;
+ // Completion persistence belongs exclusively to the exam host protocol. The
+ // recorder is invoked there only after source/origin/token validation, so a
+ // second global listener must never race it into a duplicate save.
+ if (type === 'session_completed') {
+ return;
+ }
+
switch (type) {
case 'session_started':
this.handleSessionStarted(data);
@@ -377,11 +421,6 @@ class PracticeRecorder {
case 'session_progress':
this.handleSessionProgress(data);
break;
- case 'session_completed':
- this.handleSessionCompleted(data).catch(error => {
- console.error('[PracticeRecorder] 会话完成处理失败:', error);
- });
- break;
case 'session_paused':
this.handleSessionPaused(data);
break;
@@ -483,18 +522,7 @@ class PracticeRecorder {
normalizedComparison
);
const answerList = this.convertAnswerMapToArray(answerMap, correctAnswerMap);
- const highlights = Array.isArray(payload.highlights)
- ? payload.highlights.slice()
- : (Array.isArray(payload.realData?.highlights) ? payload.realData.highlights.slice() : []);
- const markedQuestions = Array.isArray(payload.markedQuestions)
- ? payload.markedQuestions.slice()
- : (Array.isArray(payload.realData?.markedQuestions) ? payload.realData.markedQuestions.slice() : []);
- const scrollY = Number.isFinite(Number(payload.scrollY))
- ? Number(payload.scrollY)
- : (Number.isFinite(Number(payload.realData?.scrollY)) ? Number(payload.realData.scrollY) : 0);
- const noteText = typeof payload.noteText === 'string'
- ? payload.noteText
- : (typeof payload.realData?.noteText === 'string' ? payload.realData.noteText : '');
+ const annotations = this.resolveAnnotationState(payload);
const questionTypeMap = payload.questionTypeMap && typeof payload.questionTypeMap === 'object'
? { ...payload.questionTypeMap }
: (payload.realData?.questionTypeMap && typeof payload.realData.questionTypeMap === 'object'
@@ -552,15 +580,12 @@ class PracticeRecorder {
answerComparison: normalizedComparison,
questionTypePerformance: payload.questionTypePerformance || {},
interactions: payload.interactions || [],
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...annotations,
questionTypeMap,
startTime: payload.startTime || null,
endTime: payload.endTime || null,
metadata: Object.assign({}, payload.metadata || {}, {
- markedQuestions: markedQuestions.slice()
+ markedQuestions: this.clonePlainObject(annotations.markedQuestions)
}),
source: scoreInfo.source || payload.pageType || 'practice_page',
realData: Object.assign({}, payload.realData || {}, {
@@ -568,10 +593,7 @@ class PracticeRecorder {
correctAnswers: correctAnswerMap,
correctAnswerMap,
answerComparison: normalizedComparison,
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...this.clonePlainObject(annotations),
questionTypeMap,
scoreInfo: Object.assign({}, scoreInfo, { details: answerDetails })
})
@@ -689,35 +711,61 @@ class PracticeRecorder {
* 开始练习会话
*/
startPracticeSession(examId, examData = {}) {
- const sessionId = this.generateSessionId();
- const startTime = new Date().toISOString();
+ const requestedSessionId = examData && examData.sessionId != null
+ ? String(examData.sessionId).trim()
+ : '';
+ const existing = this.activeSessions.has(examId)
+ ? this.activeSessions.get(examId)
+ : null;
+ // Prefer an explicit host session id so INIT/COMPLETE and the recorder share one
+ // identity. Reuse an existing active session when the host rebinds the same exam.
+ const sessionId = requestedSessionId
+ || (existing && existing.sessionId)
+ || this.generateSessionId(examId);
+ const startTime = (existing && existing.startTime)
+ || new Date().toISOString();
+ const previousEntityId = existing
+ ? this.activeSessionEntityId(existing)
+ : null;
const sessionData = {
+ id: this.activeSessionEntityId(sessionId),
sessionId,
examId,
startTime,
- lastActivity: startTime,
- status: 'started',
- progress: {
+ lastActivity: new Date().toISOString(),
+ status: existing ? (existing.status || 'started') : 'started',
+ progress: Object.assign({
currentQuestion: 0,
totalQuestions: examData.totalQuestions || 0,
answeredQuestions: 0,
timeSpent: 0
- },
- answers: [],
- metadata: {
+ }, existing && existing.progress ? existing.progress : {}),
+ answers: existing && existing.answers ? existing.answers : [],
+ metadata: Object.assign({
examTitle: examData.title || '',
category: examData.category || '',
frequency: examData.frequency || '',
userAgent: navigator.userAgent,
screenResolution: `${screen.width}x${screen.height}`,
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
- }
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ // 启动时捕获的题库配置 ID:mixin/调用方传入则写进会话 metadata,后续经
+ // handleSessionCompleted 的 buildRecordMetadata 透传到记录 metadata。
+ libraryConfigurationId: (examData && examData.libraryConfigurationId != null)
+ ? examData.libraryConfigurationId
+ : null
+ }, existing && existing.metadata ? existing.metadata : {})
};
+ if (examData && examData.libraryConfigurationId != null) {
+ sessionData.metadata.libraryConfigurationId = examData.libraryConfigurationId;
+ }
+ if (examData && examData.title) {
+ sessionData.metadata.examTitle = examData.title;
+ }
// 存储会话
this.activeSessions.set(examId, sessionData);
- this.saveActiveSessions().catch(error => {
+ this.persistActiveSession(sessionData, previousEntityId).catch(error => {
console.error('[PracticeRecorder] 保存活动会话失败:', error);
});
@@ -736,25 +784,55 @@ class PracticeRecorder {
* 处理会话开始
*/
handleSessionStarted(data) {
- const { examId, sessionId, metadata } = data;
+ const examId = data && data.examId != null ? String(data.examId).trim() : '';
+ const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : '';
+ const metadata = data && data.metadata && typeof data.metadata === 'object'
+ ? data.metadata
+ : null;
- if (this.activeSessions.has(examId)) {
- let session = this.activeSessions.get(examId);
- session.sessionId = sessionId;
- session.status = 'active';
- session.lastActivity = new Date().toISOString();
+ if (!examId || !sessionId) {
+ return;
+ }
- if (metadata) {
- session.metadata = { ...session.metadata, ...metadata };
+ // Host handshake (SESSION_READY / INIT rebind) must create the active session when
+ // the full PracticeRecorder was hot-upgraded after a fallback start, or when the
+ // early startPracticeSession raced ahead of the host expectedSessionId.
+ if (!this.activeSessions.has(examId)) {
+ this.startPracticeSession(examId, Object.assign({}, metadata || {}, {
+ sessionId,
+ title: metadata && (metadata.title || metadata.examTitle) || '',
+ category: metadata && metadata.category || '',
+ frequency: metadata && metadata.frequency || '',
+ libraryConfigurationId: metadata && metadata.libraryConfigurationId != null
+ ? metadata.libraryConfigurationId
+ : null
+ }));
+ const created = this.activeSessions.get(examId);
+ if (created) {
+ created.status = 'active';
+ this.activeSessions.set(examId, created);
}
+ console.log(`Session created on host confirm: ${examId}`);
+ return;
+ }
- this.activeSessions.set(examId, session);
- this.saveActiveSessions().catch(error => {
- console.error('[PracticeRecorder] 保存活动会话失败:', error);
- });
+ let session = this.activeSessions.get(examId);
+ const previousEntityId = this.activeSessionEntityId(session);
+ session.sessionId = sessionId;
+ session.id = this.activeSessionEntityId(sessionId);
+ session.status = 'active';
+ session.lastActivity = new Date().toISOString();
- console.log(`Session confirmed started: ${examId}`);
+ if (metadata) {
+ session.metadata = { ...session.metadata, ...metadata };
}
+
+ this.activeSessions.set(examId, session);
+ this.persistActiveSession(session, previousEntityId).catch(error => {
+ console.error('[PracticeRecorder] 保存活动会话失败:', error);
+ });
+
+ console.log(`Session confirmed started: ${examId}`);
}
/**
@@ -792,6 +870,7 @@ class PracticeRecorder {
}
const { results } = payload;
+ const examIndex = await window.resolveActiveLibraryIndex();
const candidateExamIds = [
payload.examId,
payload.originalExamId,
@@ -866,9 +945,9 @@ class PracticeRecorder {
session.startTime = resolvedStartTime;
- const examEntry = this.lookupExamIndexEntry(resolvedExamId)
- || this.lookupExamIndexEntry(payload.originalExamId)
- || this.lookupExamIndexEntry(payload.derivedExamId);
+ const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex)
+ || this.lookupExamIndexEntry(payload.originalExamId, examIndex)
+ || this.lookupExamIndexEntry(payload.derivedExamId, examIndex);
const type = this.resolvePracticeType({ ...session, examId: resolvedExamId }, examEntry);
const recordDate = this.resolveRecordDate({ ...session, endTime: resolvedEndTime }, resolvedEndTime);
let metadata = this.buildRecordMetadata(
@@ -948,6 +1027,8 @@ class PracticeRecorder {
results?.accuracy,
scoreInfo.accuracy
);
+ const annotations = this.resolveAnnotationState(results || {}, [session || {}]);
+ metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions);
const practiceRecord = {
id: `record_${session.sessionId || this.generateSessionId(resolvedExamId)}`,
@@ -969,6 +1050,7 @@ class PracticeRecorder {
correctAnswerMap,
scoreInfo,
questionTypePerformance: results?.questionTypePerformance || {},
+ ...annotations,
metadata,
suiteSessionId,
createdAt: resolvedEndTime,
@@ -979,7 +1061,8 @@ class PracticeRecorder {
scoreInfo,
interactions: results?.interactions || [],
isRealData: true,
- source: results?.source || 'practice_page'
+ source: results?.source || 'practice_page',
+ ...this.clonePlainObject(annotations)
})
};
@@ -1006,7 +1089,7 @@ class PracticeRecorder {
}
try {
- const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord;
+ const savedRecord = await this.savePracticeRecord(practiceRecord);
if (!syntheticSession && this.activeSessions.has(resolvedExamId)) {
this.endPracticeSession(resolvedExamId);
@@ -1019,11 +1102,13 @@ class PracticeRecorder {
return savedRecord;
} catch (error) {
console.error('[PracticeRecorder] 处理完成会话时出错:', error);
- await this.saveToTemporaryStorage(practiceRecord);
- if (!syntheticSession && this.activeSessions.has(resolvedExamId)) {
- this.endPracticeSession(resolvedExamId, 'save_failed');
+ try {
+ await this.saveToTemporaryStorage(practiceRecord);
+ } catch (recoveryError) {
+ console.error('[PracticeRecorder] canonical 与 recovery 提交均失败:', recoveryError);
+ error.recoveryError = recoveryError;
}
- return practiceRecord;
+ throw error;
}
}
@@ -1133,6 +1218,7 @@ class PracticeRecorder {
if (!this.activeSessions.has(examId)) return;
let session = this.activeSessions.get(examId);
+ const sessionEntityId = this.activeSessionEntityId(session);
// 如果会话未完成,创建中断记录
if (reason !== 'completed' && session.status !== 'completed') {
@@ -1162,8 +1248,8 @@ class PracticeRecorder {
// 清理会话
this.activeSessions.delete(examId);
this.cleanupSessionListener(examId);
- this.saveActiveSessions().catch(error => {
- console.error('[PracticeRecorder] 保存活动会话失败:', error);
+ window.AppData.recovery.discardActiveSession(sessionEntityId).catch(error => {
+ console.error('[PracticeRecorder] 清理活动会话失败:', error);
});
console.log(`Practice session ended: ${examId} (${reason})`);
@@ -1240,59 +1326,49 @@ class PracticeRecorder {
* 保存所有会话
*/
async saveAllSessions() {
- try {
- await this.saveActiveSessions();
- console.log('Auto-saved all active sessions');
- } catch (error) {
- console.error('[PracticeRecorder] 保存活动会话失败:', error);
- }
+ await this.saveActiveSessions();
+ console.log('Auto-saved all active sessions');
}
/**
* 保存活动会话到存储
*/
async saveActiveSessions() {
- const sessionsArray = Array.from(this.activeSessions.values());
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') {
- await practiceCoreStore.writeMeta('active_sessions', sessionsArray);
- return;
+ for (const session of this.activeSessions.values()) {
+ await this.persistActiveSession(session);
}
- await this.metaRepo.set('active_sessions', sessionsArray);
}
/**
* 保存练习记录
*/
- async savePracticeRecord(record) {
+ async savePracticeRecord(record, options = {}) {
const maxRetries = 3;
const storageReadyRecord = this.prepareRecordForStorage(record);
+ const saveOperationId = storageReadyRecord.operationId || this.generateOperationId('practice-complete');
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(`[PracticeRecorder] 开始保存练习记录(尝试 ${attempt}/${maxRetries}):`, record.id);
- const practiceRecordApi = window.PracticeRecordAPI;
- if (!practiceRecordApi || typeof practiceRecordApi.saveRecord !== 'function') {
- throw new Error('PracticeRecordAPI not available');
- }
-
- const savedRawRecord = await practiceRecordApi.saveRecord(storageReadyRecord, {
- updateStats: true
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: storageReadyRecord,
+ operationId: saveOperationId
});
+ const savedRawRecord = receipt.record;
const savedRecord = this.restoreRecordAnswerState(savedRawRecord, record);
- console.log(`[PracticeRecorder] PracticeRecordAPI 保存成功: ${savedRecord.id}`);
+ console.log(`[PracticeRecorder] AppData.practice 保存成功: ${savedRecord.id}`);
const verified = await this.verifyRecordSaved(savedRecord.id);
if (!verified) {
- console.warn('[PracticeRecorder] PracticeRecordAPI 保存后未立即检出,稍后将由同步任务纠正');
+ console.warn('[PracticeRecorder] AppData.practice 保存后未立即检出,稍后将由同步任务纠正');
} else {
console.log('[PracticeRecorder] 记录保存验证成功');
}
return savedRecord;
} catch (error) {
console.error(
- `[PracticeRecorder] PracticeRecordAPI 保存失败 (尝试 ${attempt}):`,
+ `[PracticeRecorder] AppData.practice 保存失败 (尝试 ${attempt}):`,
{
error: error?.message,
validationErrors: error?.validationErrors || null,
@@ -1302,7 +1378,7 @@ class PracticeRecorder {
);
if (attempt === maxRetries || this.isCriticalError(error)) {
- return await this.retrySaveWithStandardizedRecord(record);
+ return await this.retrySaveWithStandardizedRecord(record, saveOperationId);
}
const delay = attempt * 100;
@@ -1311,46 +1387,43 @@ class PracticeRecorder {
}
}
- return await this.retrySaveWithStandardizedRecord(record);
+ return await this.retrySaveWithStandardizedRecord(record, saveOperationId);
}
/**
* 用标准化后的 payload 再走统一 API 保存。
*/
- async retrySaveWithStandardizedRecord(record) {
+ async retrySaveWithStandardizedRecord(record, operationId = null) {
try {
console.log('[PracticeRecorder] 使用标准化记录重试保存');
- const standardizedRecord = this.normalizeRecordForPracticeRecordApi(record);
- const practiceRecordApi = window.PracticeRecordAPI;
- if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') {
- return await practiceRecordApi.saveRecord(standardizedRecord, {
- updateStats: true
- });
- }
-
- throw new Error('PracticeRecordAPI unavailable');
+ const examIndex = await window.resolveActiveLibraryIndex();
+ const standardizedRecord = this.normalizeRecordForAppData(record, examIndex);
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: standardizedRecord,
+ operationId: operationId || standardizedRecord.operationId || this.generateOperationId('practice-complete')
+ });
+ return receipt.record;
} catch (error) {
console.error('[PracticeRecorder] 标准化重试保存失败:', {
error: error?.message,
validationErrors: error?.validationErrors || null,
recordSummary: this.buildRecordLogSummary(record)
}, error);
- await this.saveToTemporaryStorage(record);
- throw new Error(`All save methods failed: ${error.message}`);
+ throw error;
}
}
/**
* 标准化记录格式(用于统一 API 重试保存)。
*/
- normalizeRecordForPracticeRecordApi(recordData) {
+ normalizeRecordForAppData(recordData, examIndex = []) {
const now = new Date().toISOString();
const resolvedExamId = this.inferExamId(recordData);
const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime())
? new Date(recordData.endTime).toISOString()
: now;
- const examEntry = this.lookupExamIndexEntry(resolvedExamId);
+ const examEntry = this.lookupExamIndexEntry(resolvedExamId, examIndex);
const inferredType = this.normalizePracticeType(
recordData.type
|| recordData.metadata?.type
@@ -1410,6 +1483,8 @@ class PracticeRecorder {
recordData.realData?.scoreInfo?.score,
recordData.score
);
+ const annotations = this.resolveAnnotationState(recordData, [recordData.metadata || {}]);
+ metadata.markedQuestions = this.clonePlainObject(annotations.markedQuestions);
return {
// 基础信息
@@ -1439,11 +1514,13 @@ class PracticeRecorder {
correctAnswerMap,
scoreInfo: Object.assign({}, recordData.scoreInfo || {}, { details: answerDetails }),
questionTypePerformance: recordData.questionTypePerformance || {},
+ ...annotations,
realData: Object.assign({}, recordData.realData || {}, {
answers: answerMap,
correctAnswers: correctAnswerMap,
correctAnswerMap,
- scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails })
+ scoreInfo: Object.assign({}, recordData.realData?.scoreInfo || {}, { details: answerDetails }),
+ ...this.clonePlainObject(annotations)
}),
// 元数据
@@ -1461,17 +1538,7 @@ class PracticeRecorder {
*/
async verifyRecordSaved(recordId) {
try {
- const practiceRecordApi = window.PracticeRecordAPI;
- if (practiceRecordApi && typeof practiceRecordApi.getById === 'function') {
- const record = await practiceRecordApi.getById(recordId);
- return !!record;
- }
- if (practiceRecordApi && typeof practiceRecordApi.list === 'function') {
- const records = await practiceRecordApi.list();
- const list = Array.isArray(records) ? records : [];
- return list.some(r => r && (r.id === recordId || r.sessionId === recordId));
- }
- return false;
+ return Boolean(await window.AppData.practice.get(recordId, { projection: 'light' }));
} catch (error) {
console.error('[PracticeRecorder] 验证记录保存时出错', error);
return false;
@@ -1533,11 +1600,23 @@ class PracticeRecorder {
this.convertComparisonToAnswerMap(record.answerComparison || record.realData?.answerComparison, 'userAnswer')
);
const correctMap = this.resolveRecordCorrectAnswerMap(record);
+ const annotations = this.resolveAnnotationState(record, [record.metadata || {}]);
const answerList = this.convertAnswerMapToArray(answerMap, correctMap);
clone.answerList = answerList;
- clone.answers = answerList;
+ // AppData v2 stores canonical answer maps in the detail entity. Converting
+ // `answers` to the legacy array shape here makes persisted review records
+ // unreadable to consumers that intentionally accept maps only.
+ clone.answers = answerMap;
clone.correctAnswerMap = correctMap;
+ clone.questionTypeMap = this.clonePlainObject(
+ record.questionTypeMap || record.realData?.questionTypeMap || {}
+ );
+ clone.interactions = this.clonePlainObject(
+ Array.isArray(record.interactions)
+ ? record.interactions
+ : (Array.isArray(record.realData?.interactions) ? record.realData.interactions : [])
+ );
clone.answerDetails = this.buildCanonicalAnswerDetails(
answerMap,
correctMap,
@@ -1547,6 +1626,10 @@ class PracticeRecorder {
record.answerComparison || record.realData?.answerComparison
);
clone.scoreInfo = Object.assign({}, clone.scoreInfo || {}, { details: clone.answerDetails });
+ Object.assign(clone, this.clonePlainObject(annotations));
+ clone.metadata = Object.assign({}, clone.metadata || {}, {
+ markedQuestions: this.clonePlainObject(annotations.markedQuestions)
+ });
if (clone.answerComparison) {
clone.answerComparison = this.normalizeAnswerComparison(clone.answerComparison);
@@ -1556,7 +1639,8 @@ class PracticeRecorder {
answers: answerMap,
correctAnswers: correctMap,
correctAnswerMap: correctMap,
- scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails })
+ scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details: clone.answerDetails }),
+ ...this.clonePlainObject(annotations)
});
if (clone.realData.answerComparison) {
clone.realData.answerComparison = this.normalizeAnswerComparison(clone.realData.answerComparison);
@@ -1603,6 +1687,9 @@ class PracticeRecorder {
correctAnswerMap: clone.correctAnswerMap,
scoreInfo: Object.assign({}, clone.realData?.scoreInfo || {}, { details })
});
+ const annotations = this.resolveAnnotationState(clone, [sourceRecord || {}]);
+ Object.assign(clone, this.clonePlainObject(annotations));
+ clone.realData = Object.assign({}, clone.realData, this.clonePlainObject(annotations));
return clone;
}
@@ -1623,42 +1710,43 @@ class PracticeRecorder {
* 保存到临时存储
*/
async saveToTemporaryStorage(record) {
- try {
- const existing = await this.metaRepo.get('temp_practice_records', []);
- const tempRecords = Array.isArray(existing) ? [...existing] : [];
- tempRecords.push({
- ...record,
- tempSavedAt: new Date().toISOString(),
- needsRecovery: true
- });
-
- // 限制临时记录数量
- const finalTempRecords = tempRecords.length > 50 ? tempRecords.slice(-50) : tempRecords;
+ const recordId = String(record && (record.id || record.sessionId) || `record-${Date.now()}`);
+ const receipt = await window.AppData.recovery.saveDraft({
+ id: `practice-record:${recordId}`,
+ recordId,
+ kind: 'practice_record_recovery',
+ record: this.clonePlainObject(record),
+ tempSavedAt: new Date().toISOString(),
+ needsRecovery: true
+ });
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') {
- await practiceCoreStore.writeMeta('temp_practice_records', finalTempRecords);
- } else {
- await this.metaRepo.set('temp_practice_records', finalTempRecords);
+ try {
+ const drafts = await window.AppData.recovery.listDrafts();
+ const recoveryDrafts = (Array.isArray(drafts) ? drafts : [])
+ .filter((draft) => draft && draft.kind === 'practice_record_recovery')
+ .sort((left, right) => Date.parse(left.updatedAt || left.tempSavedAt || 0) - Date.parse(right.updatedAt || right.tempSavedAt || 0));
+ for (const stale of recoveryDrafts.slice(0, Math.max(0, recoveryDrafts.length - 50))) {
+ await window.AppData.recovery.discardDraft(stale.id);
}
- console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id);
-
} catch (error) {
- console.error('[PracticeRecorder] 临时存储也失败', error);
+ console.warn('[PracticeRecorder] recovery 草稿清理失败,不影响已提交草稿:', error);
}
+ console.log('[PracticeRecorder] 记录已保存到临时存储:', record.id);
+ return receipt;
}
/**
* 保存中断记录
*/
async saveInterruptedRecord(record) {
- const existing = await this.metaRepo.get('interrupted_records', []);
- const records = Array.isArray(existing) ? [...existing] : [];
- records.push(record);
-
- const finalRecords = records.length > 100 ? records.slice(-100) : records;
-
- await this.metaRepo.set('interrupted_records', finalRecords);
+ await window.AppData.recovery.saveInterrupted(record);
+ const existing = await window.AppData.recovery.listInterrupted();
+ const records = (Array.isArray(existing) ? existing : [])
+ .slice()
+ .sort((left, right) => Date.parse(right.updatedAt || right.createdAt || 0) - Date.parse(left.updatedAt || left.createdAt || 0));
+ for (const stale of records.slice(100)) {
+ await window.AppData.recovery.discardInterrupted(stale.id || stale.sessionId || stale.recordId);
+ }
console.log(`Interrupted record saved: ${record.id}`);
}
@@ -1666,33 +1754,12 @@ class PracticeRecorder {
* 更新用户统计
*/
async updateUserStats(practiceRecord) {
- if (!window.PracticeRecordAPI || typeof window.PracticeRecordAPI.recalculateStats !== 'function') {
- throw new Error('PracticeRecordAPI.recalculateStats unavailable');
- }
- await window.PracticeRecordAPI.recalculateStats();
- console.log('User stats recalculated through PracticeRecordAPI');
+ await window.AppData.practice.getStats();
}
async listPracticeRecordsForStats() {
- // 统计读取只需元数据字段,使用轻量 listSummary 避免反序列化+克隆完整记录
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') {
- try {
- const records = await window.PracticeRecordAPI.listSummary();
- return Array.isArray(records) ? records : [];
- } catch (error) {
- console.warn('[PracticeRecorder] PracticeRecordAPI.listSummary 统计读取失败:', error);
- }
- }
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- try {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- } catch (error) {
- console.warn('[PracticeRecorder] PracticeRecordAPI.list 统计读取失败:', error);
- }
- }
-
- return [];
+ const records = await window.AppData.practice.list({ projection: 'light' });
+ return Array.isArray(records) ? records : [];
}
/**
@@ -1707,11 +1774,10 @@ class PracticeRecorder {
*/
async getPracticeRecords(filters = {}) {
try {
- const practiceRecordApi = window.PracticeRecordAPI;
- if (!practiceRecordApi || typeof practiceRecordApi.list !== 'function') {
- return [];
- }
- const records = await practiceRecordApi.list();
+ // 过滤条件(examId/metadata.category/startTime/date/accuracy)与唯一内部消费者
+ // getDataIntegrityReport -> validateRecordIntegrity(id/examId/startTime/endTime/accuracy/duration)
+ // 都在 light 投影覆盖范围内,不需要拉取答题详情。
+ const records = await window.AppData.practice.list({ projection: 'light' });
const list = Array.isArray(records) ? records : [];
if (Object.keys(filters).length === 0) {
return list;
@@ -1727,15 +1793,12 @@ class PracticeRecorder {
return true;
});
} catch (error) {
- console.error('Failed to get practice records from PracticeRecordAPI:', error);
+ console.error('Failed to get practice records from AppData.practice:', error);
return [];
}
}
getDefaultUserStats() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.getDefaultStats === 'function') {
- return window.PracticeRecordAPI.getDefaultStats();
- }
return {
totalPractices: 0,
totalTimeSpent: 0,
@@ -1749,13 +1812,6 @@ class PracticeRecorder {
};
}
- getUnifiedBackupManager() {
- if (window.DataBackupManager) {
- return new window.DataBackupManager();
- }
- throw new Error('DataBackupManager unavailable');
- }
-
convertRecordsToCSV(records) {
const list = Array.isArray(records) ? records : [];
if (list.length === 0) return '';
@@ -1791,10 +1847,7 @@ class PracticeRecorder {
* 获取用户统计
*/
async getUserStats() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- return await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() });
- }
- return this.getDefaultUserStats();
+ return Object.assign(this.getDefaultUserStats(), await window.AppData.practice.getStats());
}
/**
@@ -1802,47 +1855,53 @@ class PracticeRecorder {
*/
async exportData(format = 'json') {
const normalizedFormat = String(format || 'json').toLowerCase();
- // CSV 导出只需元数据字段,使用轻量 listSummary 避免加载完整记录
- const records = normalizedFormat === 'csv'
- ? await this.listPracticeRecordsForStats()
- : await this.getPracticeRecords();
if (normalizedFormat === 'csv') {
+ const records = await this.listPracticeRecordsForStats();
return this.convertRecordsToCSV(records);
}
if (normalizedFormat !== 'json') {
throw new Error(`Unsupported export format: ${format}`);
}
- return JSON.stringify({
- exportDate: new Date().toISOString(),
- version: PRACTICE_RECORDER_EXPORT_VERSION,
- practiceRecords: records,
- userStats: await this.getUserStats()
- }, null, 2);
+ const snapshot = await window.AppData.backups.export({ domains: ['practice'] });
+ return JSON.stringify(snapshot, null, 2);
}
/**
* 导入练习数据
*/
- importData(data, options = {}) {
- const manager = this.getUnifiedBackupManager();
+ async importData(data, options = {}) {
const mergeMode = options.merge === false || options.mergeMode === 'replace'
? 'replace'
: (options.mergeMode || 'merge');
- return manager.importPracticeData(data, Object.assign({}, options, { mergeMode }));
+ const backup = options.createBackup === false
+ ? null
+ : await window.AppData.backups.create({ type: 'pre-import' });
+ const payload = Array.isArray(data) ? { records: data } : data;
+ const preview = await window.AppData.backups.previewImport(payload, { practiceMode: mergeMode });
+ const receipt = await window.AppData.backups.commitImport(preview.id, {
+ operationId: options.operationId,
+ confirmDestructive: mergeMode === 'replace'
+ });
+ try {
+ await window.AppData.backups.recordImport({ type: preview.format, keys: preview.keys, backupId: backup && backup.id, practice: preview.practice });
+ } catch (historyError) {
+ console.warn('[PracticeRecorder] 导入已提交,但历史记录写入失败:', historyError);
+ }
+ return Object.assign({}, receipt, { backupId: backup && backup.id });
}
/**
* 创建数据备份
*/
createBackup(backupName = null) {
- return this.getUnifiedBackupManager().createBackup(backupName, 'practice_recorder');
+ return window.AppData.backups.create({ id: backupName || undefined, type: 'practice-recorder' });
}
/**
* 恢复数据备份
*/
restoreBackup(backupId) {
- return this.getUnifiedBackupManager().restoreBackup(backupId);
+ return window.AppData.backups.restore(backupId);
}
/**
@@ -1850,10 +1909,7 @@ class PracticeRecorder {
*/
getBackups() {
try {
- if (window.BackupAPI && typeof window.BackupAPI.list === 'function') {
- return window.BackupAPI.list();
- }
- return this.scoreStorage.getBackups();
+ return window.AppData.backups.list();
} catch (error) {
console.error('Failed to get backups:', error);
return [];
@@ -1865,7 +1921,7 @@ class PracticeRecorder {
*/
getStorageStats() {
try {
- return this.scoreStorage.getStorageStats();
+ return window.AppData.status();
} catch (error) {
console.error('Failed to get storage stats:', error);
return null;
@@ -1873,17 +1929,32 @@ class PracticeRecorder {
}
generateRecordId() {
- if (this.scoreStorage && typeof this.scoreStorage.generateRecordId === 'function') {
- return this.scoreStorage.generateRecordId();
- }
return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
+ generateOperationId(prefix = 'operation') {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return `${prefix}_${window.crypto.randomUUID()}`;
+ }
+ } catch (_) {
+ // fall through to timestamp entropy
+ }
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 12)}`;
+ }
+
/**
- * 生成会话ID
+ * 生成会话ID(可选带 examId 前缀,便于与宿主 expectedSessionId 对齐)
*/
- generateSessionId() {
- return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ generateSessionId(examId) {
+ const suffix = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ const normalizedExamId = typeof examId === 'string'
+ ? examId.trim().replace(/\s+/g, '-')
+ : (examId != null ? String(examId).trim().replace(/\s+/g, '-') : '');
+ if (normalizedExamId) {
+ return `${normalizedExamId}_${suffix}`;
+ }
+ return `session_${suffix}`;
}
extractExamIdFromRecordId(recordId) {
@@ -1933,8 +2004,8 @@ class PracticeRecorder {
}
// 获取题目信息
- const examIndex = await this.metaRepo.get('exam_index', []);
- const examList = Array.isArray(examIndex) ? examIndex : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ const examIndex = await window.resolveActiveLibraryIndex();
+ const examList = Array.isArray(examIndex) ? examIndex : [];
const exam = examList.find(e => e.id === examId);
if (!exam) {
@@ -1945,12 +2016,11 @@ class PracticeRecorder {
// 构造增强的练习记录
const practiceRecord = this.createRealPracticeRecord(exam, validatedData);
- // 保存记录 - 这里ScoreStorage会自动更新用户统计
- const savedRecord = await this.savePracticeRecord(practiceRecord) || practiceRecord;
+ // AppData 在权威提交后调度统计投影。
+ const savedRecord = await this.savePracticeRecord(practiceRecord);
// 清理活动会话
- this.activeSessions.delete(examId);
- await this.saveActiveSessions();
+ this.endPracticeSession(examId);
// 触发完成事件
this.dispatchSessionEvent('realDataProcessed', {
@@ -2055,13 +2125,10 @@ class PracticeRecorder {
);
const totalQuestions = scoreInfo.total || Object.keys(correctAnswerMap).length || Object.keys(answerMap).length;
const accuracy = scoreInfo.accuracy || (totalQuestions > 0 ? score / totalQuestions : 0);
- const highlights = Array.isArray(realData.highlights) ? realData.highlights.slice() : [];
- const markedQuestions = Array.isArray(realData.markedQuestions) ? realData.markedQuestions.slice() : [];
- const scrollY = Number.isFinite(Number(realData.scrollY)) ? Number(realData.scrollY) : 0;
- const noteText = typeof realData.noteText === 'string' ? realData.noteText : '';
+ const annotations = this.resolveAnnotationState(realData);
const practiceRecord = {
- // 基础信息 - 与ScoreStorage兼容
+ // 基础信息
id: recordId,
examId: exam.id,
sessionId: realData.sessionId,
@@ -2079,30 +2146,40 @@ class PracticeRecorder {
correctAnswers: score, // 正确答案数等于分数
accuracy: accuracy,
- // 答题详情 - 转换为ScoreStorage期望的格式
+ // 答题详情
answers: answerList,
correctAnswerMap,
answerComparison,
questionTypeMap,
questionTypePerformance: this.extractQuestionTypePerformance(realData),
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...annotations,
- // 元数据 - 与ScoreStorage兼容
+ // 元数据
metadata: {
examTitle: exam.title || '',
category: exam.category || '',
frequency: exam.frequency || '',
- markedQuestions: markedQuestions.slice(),
+ markedQuestions: this.clonePlainObject(annotations.markedQuestions),
collectionMethod: 'automatic',
dataQuality: this.assessDataQuality(realData),
- processingTime: Date.now()
+ processingTime: Date.now(),
+ // 启动时捕获的题库配置 ID:优先取 realData 与其 metadata 显式透传的值;
+ // 若上游未透传则显式写入 null(保留 key),让 AppData 记录 provenance
+ // 不再回退读取当前激活题库,避免记录来源在提交时被切换题库影响。
+ libraryConfigurationId: (realData
+ && realData.libraryConfigurationId !== undefined
+ && realData.libraryConfigurationId !== null)
+ ? realData.libraryConfigurationId
+ : (realData
+ && realData.metadata
+ && realData.metadata.libraryConfigurationId !== undefined
+ && realData.metadata.libraryConfigurationId !== null)
+ ? realData.metadata.libraryConfigurationId
+ : null
},
// 额外的真实数据信息
- realData: {
+ realData: Object.assign({}, realData, {
sessionId: realData.sessionId,
answers: answerMap,
correctAnswers: correctAnswerMap,
@@ -2111,15 +2188,12 @@ class PracticeRecorder {
questionTypeMap,
answerHistory: realData.answerHistory || {},
interactions: realData.interactions || [],
- highlights,
- scrollY,
- markedQuestions,
- noteText,
+ ...this.clonePlainObject(annotations),
scoreInfo: scoreInfo,
pageType: realData.pageType,
url: realData.url,
source: scoreInfo.source || 'data_collector'
- },
+ }),
// 系统信息
dataSource: 'real',
@@ -2131,7 +2205,7 @@ class PracticeRecorder {
}
/**
- * 转换答案格式为ScoreStorage兼容格式
+ * 转换答案格式为 canonical record 格式
*/
convertAnswersFormat(answers, correctAnswerMap = {}, answerComparison = {}, questionTypeMap = {}) {
if (!answers || typeof answers !== 'object') {
@@ -2326,7 +2400,7 @@ class PracticeRecorder {
sessionId: sessionId,
timestamp: Date.now()
}
- }, '*');
+ }, window.location.protocol === 'file:' ? '*' : window.location.origin);
}
}
@@ -2335,8 +2409,11 @@ class PracticeRecorder {
*/
async recoverTemporaryRecords() {
try {
- const tempRecords = await this.metaRepo.get('temp_practice_records', []);
- const list = Array.isArray(tempRecords) ? tempRecords : [];
+ const tempRecords = await window.AppData.recovery.listDrafts();
+ const list = (Array.isArray(tempRecords) ? tempRecords : []).filter((draft) => (
+ draft
+ && (draft.kind === 'practice_record_recovery' || draft.needsRecovery === true)
+ ));
if (list.length === 0) {
console.log('[PracticeRecorder] 没有需要恢复的临时记录');
@@ -2346,12 +2423,12 @@ class PracticeRecorder {
console.log(`[PracticeRecorder] 发现 ${list.length} 条临时记录,开始恢复`);
let recoveredCount = 0;
- const failedRecords = [];
-
for (const tempRecord of list) {
try {
- // 移除临时标识
- const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord;
+ const sourceRecord = tempRecord.record && typeof tempRecord.record === 'object'
+ ? tempRecord.record
+ : tempRecord;
+ const { tempSavedAt, needsRecovery, kind, ...cleanRecord } = sourceRecord;
const sanitized = this.sanitizeRecoveredRecord(cleanRecord);
if (!sanitized) {
console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord?.id);
@@ -2360,34 +2437,16 @@ class PracticeRecorder {
// 尝试正常保存
await this.savePracticeRecord(sanitized);
+ await window.AppData.recovery.discardDraft(tempRecord.id);
recoveredCount++;
console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized.id}`);
} catch (error) {
console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord.id}`, error);
- failedRecords.push(tempRecord);
}
}
-
- // 清理已恢复的临时记录
- if (failedRecords.length === 0) {
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.removeMeta === 'function') {
- await practiceCoreStore.removeMeta('temp_practice_records');
- } else {
- await this.metaRepo.remove('temp_practice_records');
- }
- console.log(`[PracticeRecorder] 所有${recoveredCount} 条临时记录恢复成功`);
- } else {
- const practiceCoreStore = window.PracticeCore && window.PracticeCore.store;
- if (practiceCoreStore && typeof practiceCoreStore.writeMeta === 'function') {
- await practiceCoreStore.writeMeta('temp_practice_records', failedRecords);
- } else {
- await this.metaRepo.set('temp_practice_records', failedRecords);
- }
- console.log(`[PracticeRecorder] 恢复了${recoveredCount} 条记录,${failedRecords.length} 条失败`);
- }
+ console.log(`[PracticeRecorder] 已恢复 ${recoveredCount} 条临时记录`);
} catch (error) {
console.error('[PracticeRecorder] 恢复临时记录时出错', error);
@@ -2460,7 +2519,7 @@ class PracticeRecorder {
});
// 检查临时记录
- const tempRecords = await this.metaRepo.get('temp_practice_records', []);
+ const tempRecords = await window.AppData.recovery.listDrafts();
const tempList = Array.isArray(tempRecords) ? tempRecords : [];
report.temporaryRecords.total = tempList.length;
report.temporaryRecords.needsRecovery = tempList.filter(r => r && r.needsRecovery).length;
@@ -2480,9 +2539,7 @@ class PracticeRecorder {
// 检查存储状态
try {
- const storageInfo = window.storage && typeof window.storage.getStorageInfo === 'function'
- ? await window.storage.getStorageInfo()
- : null;
+ const storageInfo = window.AppData.status();
report.storage.quota = storageInfo;
} catch (error) {
report.storage.available = false;
@@ -2554,3 +2611,9 @@ class PracticeRecorder {
// 确保全局可用
window.PracticeRecorder = PracticeRecorder;
+// The practice bundle is loaded on demand and may arrive after the bootstrap
+// fallback's bounded polling window. Upgrade immediately when the real class
+// becomes available so suite submissions never remain on the light recorder.
+if (window.app && typeof window.app.instantiatePracticeRecorder === 'function') {
+ window.app.instantiatePracticeRecorder();
+}
diff --git a/js/core/practiceStore.js b/js/core/practiceStore.js
deleted file mode 100644
index 2f3ed5fb..00000000
--- a/js/core/practiceStore.js
+++ /dev/null
@@ -1,53 +0,0 @@
-(function initPracticeStore(global) {
- 'use strict';
-
- function getPracticeRecordAPI() {
- if (!global.PracticeRecordAPI) {
- throw new Error('PracticeStore: PracticeRecordAPI not ready');
- }
- return global.PracticeRecordAPI;
- }
-
- async function list() {
- var api = getPracticeRecordAPI();
- if (typeof api.list !== 'function') {
- throw new Error('PracticeStore.list: PracticeRecordAPI.list not ready');
- }
- var records = await api.list();
- return Array.isArray(records) ? records : [];
- }
-
- async function replace(records, options) {
- var finalRecords = Array.isArray(records) ? records : [];
- var api = getPracticeRecordAPI();
- if (typeof api.replace !== 'function') {
- throw new Error('PracticeStore.replace: PracticeRecordAPI.replace not ready');
- }
- await api.replace(finalRecords, Object.assign({ updateStats: true }, options || {}));
- return true;
- }
-
- async function save(record, options) {
- var api = getPracticeRecordAPI();
- if (typeof api.saveRecord !== 'function') {
- throw new Error('PracticeStore.save: PracticeRecordAPI.saveRecord not ready');
- }
- return api.saveRecord(record, Object.assign({ updateStats: true }, options || {}));
- }
-
- async function clear(options) {
- var api = getPracticeRecordAPI();
- if (typeof api.clear === 'function') {
- await api.clear(Object.assign({ updateStats: true }, options || {}));
- return true;
- }
- return replace([], options || {});
- }
-
- global.PracticeStore = Object.assign({}, global.PracticeStore || {}, {
- list: list,
- replace: replace,
- save: save,
- clear: clear
- });
-})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/core/resourceCore.js b/js/core/resourceCore.js
index f5ccbf0a..faef27c0 100644
--- a/js/core/resourceCore.js
+++ b/js/core/resourceCore.js
@@ -3,8 +3,6 @@
const PATH_PROTOCOL_RE = /^(?:[a-z]+:)?\/\//i;
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
- const PATH_MAP_STORAGE_PREFIX = 'exam_path_map__';
- const BASE_PREFIX_STORAGE_KEY = 'resource.basePrefix';
const PATH_FALLBACK_ORDER = ['map', 'fallback', 'raw', 'relative-up', 'relative-design'];
const RAW_DEFAULT_PATH_MAP = {
reading: {
@@ -171,10 +169,6 @@
return result;
}
- function getPathMapStorageKey(key) {
- return PATH_MAP_STORAGE_PREFIX + key;
- }
-
function setActivePathMap(map) {
const normalized = normalizePathMap(map);
try { global.__activeLibraryPathMap = normalized; } catch (_) { }
@@ -193,14 +187,10 @@
}
async function loadPathMapForConfiguration(key) {
- if (!key || !global.storage || typeof global.storage.get !== 'function') {
- return clonePathMap(DEFAULT_PATH_MAP);
- }
+ if (!key || !global.AppData || !global.AppData.library) return clonePathMap(DEFAULT_PATH_MAP);
try {
- const stored = await global.storage.get(getPathMapStorageKey(key));
- if (stored && typeof stored === 'object') {
- return normalizePathMap(stored, DEFAULT_PATH_MAP);
- }
+ const index = await global.AppData.library.getIndex(key);
+ return index.length ? derivePathMapFromIndex(index, DEFAULT_PATH_MAP) : clonePathMap(DEFAULT_PATH_MAP);
} catch (error) {
console.warn('[ResourceCore] 读取路径映射失败:', error);
}
@@ -217,14 +207,6 @@
? normalizePathMap(overrideMap, fallback)
: derivePathMapFromIndex(exams, fallback);
- if (global.storage && typeof global.storage.set === 'function') {
- try {
- await global.storage.set(getPathMapStorageKey(key), derived);
- } catch (error) {
- console.warn('[ResourceCore] 写入路径映射失败:', error);
- }
- }
-
if (options.setActive) {
setActivePathMap(derived);
}
@@ -232,25 +214,16 @@
}
async function deletePathMapForConfiguration(key) {
- if (!key || !global.storage || typeof global.storage.remove !== 'function') {
- return false;
- }
- try {
- await global.storage.remove(getPathMapStorageKey(key));
- return true;
- } catch (error) {
- console.warn('[ResourceCore] 删除路径映射失败:', error);
- return false;
- }
+ return Boolean(key);
}
async function refreshPathMap() {
- if (!global.storage || typeof global.storage.get !== 'function') {
+ if (!global.AppData || !global.AppData.library) {
return setActivePathMap(getPathMap());
}
try {
- const key = await global.storage.get('active_exam_index_key', 'exam_index');
- const next = await loadPathMapForConfiguration(key || 'exam_index');
+ const key = await global.AppData.library.getActive();
+ const next = await loadPathMapForConfiguration(key);
return setActivePathMap(next);
} catch (error) {
console.warn('[ResourceCore] 刷新路径映射失败:', error);
@@ -376,22 +349,13 @@
return null;
}
- function loadStoredBasePrefix() {
- try {
- return localStorage.getItem(BASE_PREFIX_STORAGE_KEY) || '';
- } catch (_) {
- return '';
- }
- }
+ let storedBasePrefix = '';
function storeBasePrefix(value) {
- try {
- if (value) {
- localStorage.setItem(BASE_PREFIX_STORAGE_KEY, value);
- } else {
- localStorage.removeItem(BASE_PREFIX_STORAGE_KEY);
- }
- } catch (_) { }
+ storedBasePrefix = value || '';
+ if (global.AppData && global.AppData.preferences) {
+ global.AppData.preferences.setResourceBasePrefix(storedBasePrefix).catch(() => {});
+ }
}
function getBasePrefix() {
@@ -400,7 +364,7 @@
return direct;
}
- const stored = normalizeBasePrefix(loadStoredBasePrefix());
+ const stored = normalizeBasePrefix(storedBasePrefix);
if (stored && stored !== './') {
global.RESOURCE_BASE_PREFIX = stored;
return stored;
@@ -422,6 +386,13 @@
return normalized;
}
+ if (global.AppData && global.AppData.preferences) {
+ global.AppData.preferences.getResourceBasePrefix().then((value) => {
+ storedBasePrefix = value || '';
+ if (!global.RESOURCE_BASE_PREFIX && storedBasePrefix) global.RESOURCE_BASE_PREFIX = normalizeBasePrefix(storedBasePrefix);
+ }).catch(() => {});
+ }
+
function resolveGeneratedReadingRuntimeUrl(exam, kind = 'html') {
if (!exam || kind === 'pdf') {
return '';
@@ -656,14 +627,12 @@
version: '0.6.2-fix',
RAW_DEFAULT_PATH_MAP,
DEFAULT_PATH_MAP,
- PATH_MAP_STORAGE_PREFIX,
PATH_FALLBACK_ORDER,
clonePathMap,
normalizePathRoot,
mergeRootWithFallback,
buildOverridePathMap,
derivePathMapFromIndex,
- getPathMapStorageKey,
getPathMap,
setActivePathMap,
loadPathMapForConfiguration,
diff --git a/js/core/scoreStorage.js b/js/core/scoreStorage.js
deleted file mode 100644
index 314c9e5f..00000000
--- a/js/core/scoreStorage.js
+++ /dev/null
@@ -1,1876 +0,0 @@
-/**
- * ScoreStorage — façade over PracticeRecordAPI / PracticeCore.
- * No independent practice write path: saves must go through PracticeRecordAPI.
- * Kept for PracticeRecorder UI helpers, stats/list adapters, and backup helpers
- * during the post-data-layer transition (see Sprint B/C thinning).
- */
-class ScoreStorage {
- constructor(options = {}) {
- this.repositories = options.repositories || window.dataRepositories;
- if (!this.repositories) {
- throw new Error('数据仓库未初始化,无法构建 ScoreStorage');
- }
-
- this.initializationError = null;
- this.initializing = true;
-
- this.storageKeys = {
- practiceRecords: 'practice_records',
- userStats: 'user_stats',
- storageVersion: 'storage_version',
- backupData: 'manual_backups'
- };
-
- this.currentVersion = '0.6.2-fix';
- this.maxRecords = 1000;
- this.storage = this.createStorageAdapter();
- if (typeof window !== 'undefined') {
- window.scoreStorage = this;
- }
-
- this.ready = this.initialize()
- .catch((error) => {
- this.initializationError = error;
- return Promise.reject(error);
- })
- .finally(() => {
- this.initializing = false;
- });
- }
-
- async ensureReady(options = {}) {
- const { allowDuringInit = false } = options;
- if (this.initializationError) {
- throw this.initializationError;
- }
- if (this.initializing && allowDuringInit) {
- return;
- }
- if (this.ready) {
- await this.ready;
- }
- }
-
- getPracticeRecordAPI(requiredMethods = []) {
- const api = window.PracticeRecordAPI;
- if (!api || typeof api !== 'object') {
- throw new Error('ScoreStorage: PracticeRecordAPI not ready');
- }
- (Array.isArray(requiredMethods) ? requiredMethods : [requiredMethods])
- .filter(Boolean)
- .forEach((methodName) => {
- if (typeof api[methodName] !== 'function') {
- throw new Error(`ScoreStorage: PracticeRecordAPI.${methodName} not ready`);
- }
- });
- return api;
- }
-
- async listPracticeRecordsCanonical() {
- const api = this.getPracticeRecordAPI(['list']);
- const records = await api.list();
- return Array.isArray(records) ? records : [];
- }
-
- async replacePracticeRecordsCanonical(records, options = {}) {
- const finalRecords = Array.isArray(records) ? records : [];
- const api = this.getPracticeRecordAPI(['replace']);
- await api.replace(finalRecords, Object.assign({
- currentVersion: this.currentVersion,
- maxRecords: this.maxRecords
- }, options || {}));
- return true;
- }
-
- normalizePracticeType(rawType) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.normalizePracticeType === 'function') {
- return coreContracts.normalizePracticeType(rawType);
- }
- if (!rawType) return null;
- const normalized = String(rawType).toLowerCase();
- if (normalized.includes('listen')) return 'listening';
- if (normalized.includes('read')) return 'reading';
- return null;
- }
-
- inferPracticeType(recordData = {}) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.inferPracticeType === 'function') {
- return coreContracts.inferPracticeType(recordData);
- }
- const metadata = recordData.metadata || {};
- const normalized = this.normalizePracticeType(
- recordData.type
- || metadata.type
- || metadata.examType
- || (recordData.examId && String(recordData.examId).toLowerCase().includes('listening') ? 'listening' : null)
- );
- return normalized || 'reading';
- }
-
- resolveRecordDate(recordData = {}, now = new Date().toISOString()) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.resolveRecordDate === 'function') {
- return coreContracts.resolveRecordDate(recordData, now);
- }
- const candidates = [
- recordData.metadata?.date,
- recordData.date,
- recordData.endTime,
- recordData.completedAt,
- recordData.startTime,
- recordData.timestamp,
- now
- ];
- for (const value of candidates) {
- if (!value) continue;
- const parsed = new Date(value);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
- return now;
- }
-
- inferExamId(recordData = {}) {
- if (!recordData || typeof recordData !== 'object') {
- return null;
- }
- if (recordData.examId) {
- return recordData.examId;
- }
- if (recordData.metadata?.examId) {
- return recordData.metadata.examId;
- }
- if (Array.isArray(recordData.suiteEntries)) {
- const suiteExam = recordData.suiteEntries.find(entry => entry && entry.examId);
- if (suiteExam) {
- return suiteExam.examId;
- }
- }
- const recordId = recordData.id;
- if (typeof recordId === 'string') {
- const match = recordId.match(/^record_([^_]+)_/);
- if (match && match[1]) {
- return match[1];
- }
- }
- return null;
- }
-
- buildMetadata(recordData = {}, type) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.buildMetadata === 'function') {
- return coreContracts.buildMetadata(recordData, type);
- }
- const metadata = { ...(recordData.metadata || {}) };
- const examId = recordData.examId;
- const fallbackTitle = recordData.title || recordData.examTitle || examId || 'Unknown Exam';
- const fallbackCategory = recordData.category || 'Unknown';
- const fallbackFrequency = recordData.frequency || 'unknown';
-
- metadata.examTitle = metadata.examTitle || metadata.title || fallbackTitle;
- metadata.category = metadata.category || fallbackCategory;
- metadata.frequency = metadata.frequency || fallbackFrequency;
- metadata.type = type;
- metadata.examType = metadata.examType || type;
-
- return metadata;
- }
-
- ensureNumber(value, fallback = 0) {
- const num = Number(value);
- return Number.isFinite(num) ? num : fallback;
- }
-
- deriveTotalQuestionCount(recordData = {}, fallbackLength = 0) {
- const candidates = [
- recordData.totalQuestions,
- recordData.questionCount,
- recordData.scoreInfo?.total,
- recordData.scoreInfo?.totalQuestions,
- recordData.realData?.scoreInfo?.totalQuestions,
- recordData.realData?.scoreInfo?.total
- ];
- for (const candidate of candidates) {
- const num = Number(candidate);
- if (Number.isFinite(num) && num >= 0) {
- return num;
- }
- }
-
- if (Array.isArray(recordData.answers)) {
- return recordData.answers.length;
- }
- if (Array.isArray(recordData.answerList)) {
- return recordData.answerList.length;
- }
-
- const detailSources = [
- recordData.answerDetails,
- recordData.scoreInfo?.details,
- recordData.realData?.scoreInfo?.details
- ];
- for (const details of detailSources) {
- if (details && typeof details === 'object') {
- return Object.keys(details).length;
- }
- }
- return fallbackLength || 0;
- }
-
- deriveCorrectAnswerCount(recordData = {}, answers = []) {
- const numericCandidates = [
- recordData.correctAnswers,
- recordData.correct,
- recordData.score,
- recordData.scoreInfo?.correct,
- recordData.scoreInfo?.score,
- recordData.realData?.scoreInfo?.correct,
- recordData.realData?.scoreInfo?.score
- ];
- for (const candidate of numericCandidates) {
- const num = Number(candidate);
- if (Number.isFinite(num) && num >= 0) {
- return num;
- }
- }
-
- if (
- recordData.correctAnswers &&
- typeof recordData.correctAnswers === 'object' &&
- !Array.isArray(recordData.correctAnswers)
- ) {
- let hasBooleanFlag = false;
- const correctCount = Object.values(recordData.correctAnswers).reduce((count, value) => {
- if (typeof value === 'boolean') {
- hasBooleanFlag = true;
- return value ? count + 1 : count;
- }
- if (value && typeof value === 'object') {
- const flag = value.isCorrect ?? value.correct;
- if (typeof flag === 'boolean') {
- hasBooleanFlag = true;
- return flag ? count + 1 : count;
- }
- }
- return count;
- }, 0);
- if (hasBooleanFlag) {
- return correctCount;
- }
- }
-
- if (Array.isArray(answers) && answers.length > 0) {
- const computed = answers.reduce((sum, answer) => {
- if (!answer || typeof answer !== 'object') {
- return sum;
- }
- if (answer.correct === true || answer.isCorrect === true) {
- return sum + 1;
- }
- return sum;
- }, 0);
- if (computed > 0) {
- return computed;
- }
- }
-
- const detailSources = [
- recordData.answerDetails,
- recordData.scoreInfo?.details,
- recordData.realData?.scoreInfo?.details
- ];
- for (const details of detailSources) {
- if (!details || typeof details !== 'object') {
- continue;
- }
- let hasFlag = false;
- let correct = 0;
- Object.values(details).forEach(detail => {
- if (!detail || typeof detail !== 'object') {
- return;
- }
- if (detail.isCorrect === true || detail.correct === true) {
- correct += 1;
- }
- hasFlag = hasFlag || typeof detail.isCorrect === 'boolean' || typeof detail.correct === 'boolean';
- });
- if (hasFlag) {
- return correct;
- }
- }
- const answerMap = {};
- if (Array.isArray(answers)) {
- answers.forEach((answer) => {
- if (!answer || typeof answer !== 'object') {
- return;
- }
- const key = answer.questionId || answer.id || answer.key;
- if (key && answer.answer != null) {
- answerMap[this.normalizeAnswerMapKey(key)] = answer.answer;
- }
- });
- } else if (this.isPlainObject(answers)) {
- Object.entries(answers).forEach(([key, value]) => {
- const normalizedKey = this.normalizeAnswerMapKey(key);
- if (normalizedKey && value != null) {
- answerMap[normalizedKey] = value;
- }
- });
- }
- const correctMap = this.resolveCorrectAnswerMap(recordData);
- if (Object.keys(answerMap).length > 0 && Object.keys(correctMap).length > 0) {
- return Object.keys(answerMap).reduce((count, key) => {
- if (!Object.prototype.hasOwnProperty.call(correctMap, key)) {
- return count;
- }
- return this.compareAnswerValues(answerMap[key], correctMap[key]) ? count + 1 : count;
- }, 0);
- }
- return 0;
- }
-
- compareAnswerValues(userAnswer, correctAnswer) {
- if (userAnswer == null || correctAnswer == null) {
- return false;
- }
- const matchCore = window.AnswerMatchCore;
- if (matchCore && typeof matchCore.compareAnswers === 'function') {
- return matchCore.compareAnswers(userAnswer, correctAnswer) === true;
- }
- return String(userAnswer).trim().toLowerCase() === String(correctAnswer).trim().toLowerCase();
- }
-
- getDateOnlyIso(value) {
- if (!value) return null;
- if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
- return value;
- }
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) {
- return null;
- }
- const year = parsed.getFullYear();
- const month = String(parsed.getMonth() + 1).padStart(2, '0');
- const day = String(parsed.getDate()).padStart(2, '0');
- return `${year}-${month}-${day}`;
- }
-
- getLocalDayStart(value) {
- if (!value) return null;
- if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
- const [year, month, day] = value.split('-').map(part => Number(part));
- if ([year, month, day].some(num => Number.isNaN(num))) {
- return null;
- }
- return new Date(year, month - 1, day).getTime();
- }
- const parsed = new Date(value);
- if (Number.isNaN(parsed.getTime())) {
- return null;
- }
- return new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()).getTime();
- }
-
- createStorageAdapter() {
- const metaRepo = this.repositories.meta;
- const backupRepo = this.repositories.backups;
- const keys = this.storageKeys;
- const self = this;
-
- return {
- async get(key, defaultValue = null) {
- switch (key) {
- case keys.practiceRecords: {
- const api = self.getPracticeRecordAPI(['list']);
- const records = await api.list();
- return Array.isArray(records) ? records : [];
- }
- case keys.userStats: {
- const fallback = defaultValue !== null && defaultValue !== undefined ? defaultValue : self.getDefaultUserStats();
- const api = self.getPracticeRecordAPI(['readStats']);
- return await api.readStats({ fallback });
- }
- case keys.storageVersion:
- return await metaRepo.get('storage_version', defaultValue);
- case keys.backupData:
- case 'manual_backups':
- return await backupRepo.list();
- default:
- return await metaRepo.get(key, defaultValue);
- }
- },
- async set(key, value) {
- switch (key) {
- case keys.practiceRecords: {
- throw new Error('ScoreStorage.storage.set(practice_records) is disabled; use PracticeRecordAPI.replace');
- }
- case keys.userStats: {
- throw new Error('ScoreStorage.storage.set(user_stats) is disabled; use PracticeRecordAPI.writeStats');
- }
- case keys.storageVersion:
- await metaRepo.set('storage_version', value);
- return true;
- case keys.backupData:
- case 'manual_backups':
- await backupRepo.saveAll(Array.isArray(value) ? value : []);
- return true;
- default:
- await metaRepo.set(key, value);
- return true;
- }
- },
- async remove(key) {
- switch (key) {
- case keys.practiceRecords: {
- throw new Error('ScoreStorage.storage.remove(practice_records) is disabled; use PracticeRecordAPI.clear');
- }
- case keys.userStats: {
- throw new Error('ScoreStorage.storage.remove(user_stats) is disabled; use PracticeRecordAPI.resetStats');
- }
- case keys.storageVersion:
- await metaRepo.remove('storage_version');
- return true;
- case keys.backupData:
- case 'manual_backups':
- await backupRepo.clear();
- return true;
- default:
- await metaRepo.remove(key);
- return true;
- }
- }
- };
- }
-
- /**
- * 初始化存储系统
- */
- async initialize() {
- try {
- console.log('ScoreStorage initialized');
-
- // 检查存储版本并迁移数据
- await this.checkStorageVersion();
-
- // 初始化数据结构
- await this.initializeDataStructures();
-
- // Legacy migration happens at PersistentStore bootstrap, not in runtime services.
-
- // 暂时禁用清理过期数据,避免误删新记录
- // await this.cleanupExpiredData();
- } catch (error) {
- this.initializationError = error;
- console.error('[ScoreStorage] 初始化失败', error);
- throw error;
- }
- }
-
- /**
- * 检查存储版本
- */
- async checkStorageVersion() {
- const normalizeVersion = v => {
- if (v === undefined || v === null) return '';
- const s = String(v).trim();
- return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
- };
- const storedVersionRaw = await this.storage.get(this.storageKeys.storageVersion);
- const storedVersion = normalizeVersion(storedVersionRaw);
- const current = normalizeVersion(this.currentVersion);
- if (!storedVersion) {
- await this.storage.set(this.storageKeys.storageVersion, current);
- console.log('Storage version initialized:', current);
- return;
- }
- if (storedVersion !== current) {
- await this.migrateData(storedVersion, current);
- } else {
- console.log('[ScoreStorage] 版本匹配,跳过迁移');
- }
- }
-
- /**
- * 数据迁移
- */
- async migrateData(fromVersion, toVersion) {
- if (String(fromVersion) === String(toVersion)) {
- console.log('[ScoreStorage] migrateData skipped: same version');
- return;
- }
- console.log(`Migrating data from ${fromVersion} to ${toVersion}`);
-
- try {
- // 备份当前数据
- await this.createBackup('migration_backup', { allowDuringInit: true });
-
- // 根据版本执行相应的迁移逻辑
- if (fromVersion < '1.0.0') {
- await this.migrateToV1();
- }
-
- // 更新版本号
- await this.storage.set(this.storageKeys.storageVersion, toVersion);
- console.log('Data migration completed successfully');
-
- } catch (error) {
- console.error('Data migration failed:', error);
- // 恢复备份数据
- try {
- await this.restoreBackup('migration_backup', { allowDuringInit: true });
- } catch (restoreError) {
- console.error('Failed to restore backup:', restoreError);
- }
- }
- }
-
- /**
- * 迁移到版本1.0.0
- */
- async migrateToV1() {
- // 标准化练习记录格式
- const records = await this.listPracticeRecordsCanonical();
- const standardizedRecords = records.map(record => this.standardizeRecord(record));
- await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: true });
- }
-
- /**
- * 初始化数据结构
- */
- async initializeDataStructures() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- await window.PracticeRecordAPI.readStats({ fallback: this.getDefaultUserStats() });
- console.log('[ScoreStorage] 用户统计由 PracticeRecordAPI 管理');
- return;
- }
- console.warn('[ScoreStorage] PracticeRecordAPI.readStats unavailable, skip stats initialization');
- }
-
- /**
- * 获取默认用户统计
- */
- getDefaultUserStats() {
- if (window.ExamData && typeof window.ExamData.createDefaultUserStats === 'function') {
- return window.ExamData.createDefaultUserStats();
- }
- const now = new Date().toISOString();
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- practiceDays: [],
- lastPracticeDate: null,
- achievements: [],
- createdAt: now,
- updatedAt: now
- };
- }
-
- /**
- * 保存练习记录
- */
- async savePracticeRecord(recordData) {
- try {
- await this.ensureReady();
- // 标准化记录格式
- const standardizedRecord = this.standardizeRecord(recordData);
-
- // 验证记录数据
- this.validateRecord(standardizedRecord);
-
- const practiceRecordApi = window.PracticeRecordAPI;
- if (practiceRecordApi && typeof practiceRecordApi.saveRecord === 'function') {
- try {
- const savedRecord = await practiceRecordApi.saveRecord(standardizedRecord, {
- currentVersion: this.currentVersion,
- maxRecords: this.maxRecords,
- updateStats: true
- });
- console.log('Practice record saved:', savedRecord.id);
- return savedRecord;
- } catch (apiError) {
- console.warn('[ScoreStorage] PracticeRecordAPI 保存失败:', apiError);
- throw apiError;
- }
- }
-
- throw new Error('ScoreStorage.savePracticeRecord: unified store not ready');
-
- } catch (error) {
- console.error('Failed to save practice record:', error);
- throw error;
- }
- }
-
- normalizeLegacyRecord(record) {
- if (!record || typeof record !== 'object') {
- return record;
- }
- const patched = Object.assign({}, record);
- if (Array.isArray(record.suiteEntries)) {
- patched.suiteEntries = record.suiteEntries.map(entry => this.clonePlainObject(entry)).filter(Boolean);
- }
- if (record.suiteMode != null) {
- patched.suiteMode = Boolean(record.suiteMode);
- }
- if (record.suiteSessionId) {
- patched.suiteSessionId = record.suiteSessionId;
- }
- if (record.frequency) {
- patched.frequency = record.frequency;
- }
- const inferredType = this.inferPracticeType(patched);
- if (!patched.type) {
- patched.type = inferredType;
- }
- const normalizedMetadata = this.buildMetadata(
- Object.assign({}, patched, { metadata: patched.metadata || {} }),
- patched.type
- );
- patched.metadata = normalizedMetadata;
- const normalizedAnswers = this.standardizeAnswers(patched.answers || patched.answerList || []);
- patched.answers = normalizedAnswers;
- patched.answerList = normalizedAnswers;
- const answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- const comparisonSource = patched.answerComparison || patched.realData?.answerComparison || null;
- const detailSource = patched.scoreInfo?.details
- || patched.realData?.scoreInfo?.details
- || patched.answerDetails
- || null;
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(patched, comparisonSource, detailSource);
- patched.correctAnswerMap = normalizedCorrectMap || {};
- if (!patched.answerDetails || typeof patched.answerDetails !== 'object') {
- patched.answerDetails = this.buildAnswerDetailsFromMaps(answerMap, patched.correctAnswerMap);
- }
- const derivedTotals = this.deriveTotalQuestionCount(patched, normalizedAnswers.length);
- const derivedCorrect = this.deriveCorrectAnswerCount(patched, normalizedAnswers);
- patched.totalQuestions = this.ensureNumber(patched.totalQuestions, derivedTotals);
- patched.correctAnswers = this.ensureNumber(patched.correctAnswers, derivedCorrect);
- patched.score = this.ensureNumber(patched.score, patched.correctAnswers);
- patched.accuracy = this.ensureNumber(
- patched.accuracy,
- patched.totalQuestions > 0 ? patched.correctAnswers / patched.totalQuestions : 0
- );
- if (!patched.startTime) {
- patched.startTime = patched.date || patched.endTime || new Date().toISOString();
- }
- if (!patched.endTime) {
- patched.endTime = patched.date || patched.startTime;
- }
- if (!patched.status) {
- patched.status = 'completed';
- }
- if (!patched.scoreInfo) {
- patched.scoreInfo = {};
- }
- if (!patched.scoreInfo.details && patched.answerDetails) {
- patched.scoreInfo.details = patched.answerDetails;
- }
- if (patched.realData) {
- patched.realData = Object.assign({}, patched.realData, {
- answers: patched.realData.answers || answerMap,
- correctAnswers: patched.correctAnswerMap,
- correctAnswerMap: patched.correctAnswerMap,
- scoreInfo: Object.assign({}, patched.realData.scoreInfo || {}, {
- details: patched.realData.scoreInfo?.details || patched.answerDetails || null
- })
- });
- }
- return patched;
- }
-
- needsRecordSanitization(record) {
- if (!record || typeof record !== 'object') {
- return true;
- }
- if (!record.type || !record.metadata || !record.metadata.type) {
- return true;
- }
- const numericFields = ['score', 'totalQuestions', 'correctAnswers', 'accuracy', 'duration'];
- return numericFields.some((field) => {
- if (!Object.prototype.hasOwnProperty.call(record, field)) {
- return false;
- }
- return typeof record[field] !== 'number' || Number.isNaN(record[field]);
- });
- }
-
- /**
- * 标准化记录格式
- */
- standardizeRecord(recordData) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.standardizeRecord === 'function') {
- return coreContracts.standardizeRecord(recordData, {
- currentVersion: this.currentVersion,
- generateRecordId: () => this.generateRecordId()
- });
- }
- const now = new Date().toISOString();
- const type = this.inferPracticeType(recordData);
- const recordDate = this.resolveRecordDate(recordData, now);
- const resolvedExamId = this.inferExamId(recordData);
- const metadata = this.buildMetadata(
- Object.assign({}, recordData, { examId: resolvedExamId }),
- type
- );
- const comparisonSource = recordData.answerComparison
- || recordData.realData?.answerComparison
- || null;
- const normalizedAnswers = this.standardizeAnswers(recordData.answers || recordData.answerList || []);
- let answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- // 如果 answers 为空,尝试从 answerComparison 补齐 userAnswer
- if ((!answerMap || Object.keys(answerMap).length === 0) && comparisonSource) {
- const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer');
- if (Object.keys(fromComparison).length > 0) {
- answerMap = fromComparison;
- }
- }
- const suiteSessionId = recordData.suiteSessionId
- || recordData.metadata?.suiteSessionId
- || null;
- if (suiteSessionId && !metadata.suiteSessionId) {
- metadata.suiteSessionId = suiteSessionId;
- }
- const frequency = recordData.frequency || metadata.frequency || null;
- if (frequency && !metadata.frequency) {
- metadata.frequency = frequency;
- }
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(recordData, comparisonSource);
- const derivedTotalQuestions = this.deriveTotalQuestionCount(recordData, normalizedAnswers.length);
- const derivedCorrectAnswers = this.deriveCorrectAnswerCount(recordData, normalizedAnswers);
- const totalQuestions = this.ensureNumber(recordData.totalQuestions, derivedTotalQuestions);
- const correctAnswers = this.ensureNumber(recordData.correctAnswers, derivedCorrectAnswers);
- let accuracy = this.ensureNumber(
- recordData.accuracy,
- totalQuestions > 0 ? correctAnswers / totalQuestions : 0
- );
- if (accuracy > 1 && accuracy <= 100) {
- accuracy = accuracy / 100; // 容错百分比形式
- }
- if (!Number.isFinite(accuracy) || accuracy < 0) {
- accuracy = 0;
- } else if (accuracy > 1) {
- accuracy = 1;
- }
- const detailSource = recordData.answerDetails
- || recordData.scoreInfo?.details
- || recordData.realData?.scoreInfo?.details
- || (comparisonSource ? this.convertComparisonToDetails(comparisonSource) : null)
- || this.buildAnswerDetailsFromMaps(answerMap, normalizedCorrectMap);
-
- const startTime = recordData.startTime && !Number.isNaN(new Date(recordData.startTime).getTime())
- ? new Date(recordData.startTime).toISOString()
- : recordDate;
- const endTime = recordData.endTime && !Number.isNaN(new Date(recordData.endTime).getTime())
- ? new Date(recordData.endTime).toISOString()
- : recordDate;
- const resolvedTitle = recordData.title
- || metadata.examTitle
- || metadata.title
- || recordData.examTitle
- || recordData.examId
- || '未命名练习';
- const normalizedSuiteEntries = this.standardizeSuiteEntries(recordData.suiteEntries || []);
- const normalizedComparison = comparisonSource && typeof comparisonSource === 'object'
- ? this.clonePlainObject(comparisonSource)
- : null;
-
- return {
- // 基础信息
- id: recordData.id || this.generateRecordId(),
- examId: resolvedExamId,
- sessionId: recordData.sessionId,
- title: resolvedTitle,
- type,
-
- // 时间信息
- startTime,
- endTime,
- duration: this.ensureNumber(recordData.duration, 0),
- date: recordDate,
-
- // 成绩信息
- status: recordData.status || 'completed',
- score: this.ensureNumber(recordData.score, correctAnswers),
- totalQuestions,
- correctAnswers,
- accuracy,
-
- // 答题详情
- answers: normalizedAnswers,
- answerDetails: detailSource || null,
- correctAnswerMap: normalizedCorrectMap || {},
- questionTypePerformance: recordData.questionTypePerformance || {},
-
- // 元数据
- metadata,
- frequency: frequency || metadata.frequency || null,
- suiteMode: Boolean(recordData.suiteMode || (frequency && frequency.toLowerCase() === 'suite')),
- suiteSessionId,
- suiteEntries: normalizedSuiteEntries,
- scoreInfo: recordData.scoreInfo
- ? Object.assign({}, recordData.scoreInfo, {
- details: recordData.scoreInfo.details || detailSource || null
- })
- : (detailSource ? { details: detailSource } : null),
- realData: recordData.realData
- ? Object.assign({}, recordData.realData, {
- answers: recordData.realData.answers || answerMap,
- correctAnswers: normalizedCorrectMap,
- correctAnswerMap: normalizedCorrectMap,
- scoreInfo: Object.assign({}, recordData.realData.scoreInfo || {}, {
- details: recordData.realData.scoreInfo?.details || detailSource || null
- }),
- answerComparison: recordData.realData.answerComparison
- ? this.clonePlainObject(recordData.realData.answerComparison)
- : (normalizedComparison || null)
- })
- : (normalizedComparison ? { answerComparison: normalizedComparison } : null),
- answerComparison: normalizedComparison,
-
- // 系统信息
- version: this.currentVersion,
- createdAt: recordData.createdAt || now,
- updatedAt: now
- };
- }
-
- /**
- * 标准化答案格式
- */
- standardizeAnswers(answers) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.buildAnswerArray === 'function') {
- return coreContracts.buildAnswerArray(answers);
- }
- if (!Array.isArray(answers)) {
- if (answers && typeof answers === 'object') {
- answers = Object.entries(answers).map(([questionId, value]) => ({
- questionId,
- answer: value
- }));
- } else {
- answers = [];
- }
- }
- return answers.map((answer, index) => ({
- questionId: answer.questionId || `q${index + 1}`,
- answer: answer.answer || '',
- correctAnswer: answer.correctAnswer || '',
- correct: Boolean(answer.correct),
- timeSpent: answer.timeSpent || 0,
- questionType: answer.questionType || 'unknown',
- timestamp: answer.timestamp || new Date().toISOString()
- }));
- }
-
- clonePlainObject(value) {
- if (value == null || typeof value !== 'object') {
- return value ?? null;
- }
- if (Array.isArray(value)) {
- return value.map(item => this.clonePlainObject(item)).filter(item => item !== undefined);
- }
- const clone = {};
- Object.keys(value).forEach((key) => {
- const entry = value[key];
- clone[key] = (entry && typeof entry === 'object')
- ? this.clonePlainObject(entry)
- : entry;
- });
- return clone;
- }
-
- isPlainObject(value) {
- return value !== null && typeof value === 'object' && !Array.isArray(value);
- }
-
- normalizeAnswerMapKey(key) {
- if (key == null) {
- return '';
- }
- let normalizedKey = String(key).trim();
- if (!normalizedKey) {
- return '';
- }
- if (/^\d+$/.test(normalizedKey)) {
- normalizedKey = `q${normalizedKey}`;
- } else if (normalizedKey.startsWith('question')) {
- normalizedKey = normalizedKey.replace('question', 'q');
- }
- return normalizedKey;
- }
-
- mergeAnswerMaps(...sources) {
- const merged = {};
- sources.forEach((source) => {
- if (!this.isPlainObject(source)) {
- return;
- }
- Object.entries(source).forEach(([key, value]) => {
- const normalizedKey = this.normalizeAnswerMapKey(key);
- if (!normalizedKey || Object.prototype.hasOwnProperty.call(merged, normalizedKey)) {
- return;
- }
- if (value == null || String(value).trim() === '') {
- return;
- }
- merged[normalizedKey] = value;
- });
- });
- return merged;
- }
-
- convertComparisonToMap(comparison, key = 'correctAnswer') {
- if (!comparison || typeof comparison !== 'object') {
- return {};
- }
- const map = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (!entry || typeof entry !== 'object') return;
- const value = entry[key] ?? (key === 'correctAnswer' ? entry.correct : entry.user);
- if (value != null && String(value).trim() !== '') {
- map[questionId] = value;
- }
- });
- return map;
- }
-
- resolveCorrectAnswerMap(recordData = {}, comparisonSource = null, detailSource = null) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.resolveRecordCorrectAnswerMap === 'function') {
- return coreContracts.resolveRecordCorrectAnswerMap(recordData, {
- comparison: comparisonSource,
- detailSources: detailSource ? [detailSource] : []
- });
- }
- const realData = this.isPlainObject(recordData.realData) ? recordData.realData : {};
- const effectiveComparison = comparisonSource || recordData.answerComparison || realData.answerComparison || null;
- return this.mergeAnswerMaps(
- recordData.correctAnswerMap,
- realData.correctAnswerMap,
- recordData.correctAnswers,
- realData.correctAnswers,
- effectiveComparison ? this.convertComparisonToMap(effectiveComparison, 'correctAnswer') : null,
- recordData.answerDetails ? this.deriveCorrectMapFromDetails(recordData.answerDetails) : null,
- detailSource ? this.deriveCorrectMapFromDetails(detailSource) : null,
- recordData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(recordData.scoreInfo.details) : null,
- realData.scoreInfo?.details ? this.deriveCorrectMapFromDetails(realData.scoreInfo.details) : null
- );
- }
-
- convertComparisonToDetails(comparison) {
- if (!comparison || typeof comparison !== 'object') {
- return null;
- }
- const details = {};
- Object.entries(comparison).forEach(([questionId, entry]) => {
- if (!entry || typeof entry !== 'object') return;
- details[questionId] = {
- userAnswer: entry.userAnswer ?? entry.user ?? '',
- correctAnswer: entry.correctAnswer ?? entry.correct ?? '',
- isCorrect: typeof entry.isCorrect === 'boolean' ? entry.isCorrect : null
- };
- });
- return details;
- }
-
- standardizeSuiteEntries(entries) {
- if (!Array.isArray(entries)) {
- return [];
- }
- return entries.map((entry, index) => {
- if (!entry || typeof entry !== 'object') {
- return null;
- }
- const normalizedAnswers = this.standardizeAnswers(entry.answers || entry.answerList || []);
- const answerMap = normalizedAnswers.reduce((map, item) => {
- if (item && item.questionId) {
- map[item.questionId] = item.answer || '';
- }
- return map;
- }, {});
- const normalizedScoreInfo = entry.scoreInfo
- ? Object.assign({}, entry.scoreInfo, {
- details: entry.scoreInfo?.details
- ? this.clonePlainObject(entry.scoreInfo.details)
- : null
- })
- : null;
- const answerComparisonSource = entry.answerComparison
- || normalizedScoreInfo?.details
- || entry.rawData?.answerComparison
- || null;
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(
- entry,
- answerComparisonSource,
- normalizedScoreInfo?.details || entry.rawData?.scoreInfo?.details || null
- );
- const highlights = Array.isArray(entry.highlights)
- ? entry.highlights.slice()
- : (Array.isArray(entry.rawData?.highlights) ? entry.rawData.highlights.slice() : []);
- const scrollY = Number.isFinite(Number(entry.scrollY))
- ? Number(entry.scrollY)
- : (Number.isFinite(Number(entry.rawData?.scrollY)) ? Number(entry.rawData.scrollY) : 0);
- return {
- examId: entry.examId || null,
- title: entry.title || entry.examTitle || `套题第${index + 1}篇`,
- category: entry.category || entry.metadata?.category || '套题',
- duration: this.ensureNumber(entry.duration, 0),
- scoreInfo: normalizedScoreInfo,
- answers: answerMap,
- correctAnswerMap: normalizedCorrectMap,
- answerComparison: this.clonePlainObject(answerComparisonSource) || null,
- metadata: entry.metadata ? Object.assign({}, entry.metadata) : {},
- highlights,
- scrollY,
- rawData: entry.rawData ? this.clonePlainObject(entry.rawData) : null
- };
- }).filter(Boolean);
- }
-
- deriveCorrectMapFromDetails(details) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.deriveCorrectMapFromDetails === 'function') {
- return coreContracts.deriveCorrectMapFromDetails(details);
- }
- if (!details || typeof details !== 'object') {
- return {};
- }
- const map = {};
- Object.entries(details).forEach(([questionId, info]) => {
- if (!info) {
- return;
- }
- const correctAnswer = info.correctAnswer || info.answer || info.value;
- if (correctAnswer != null) {
- map[questionId] = (typeof correctAnswer === 'string')
- ? correctAnswer.trim()
- : String(correctAnswer);
- }
- });
- return map;
- }
-
- buildAnswerDetailsFromMaps(answerMap = {}, correctMap = {}) {
- const coreContracts = window.PracticeCore && window.PracticeCore.contracts;
- if (coreContracts && typeof coreContracts.buildAnswerDetails === 'function') {
- return coreContracts.buildAnswerDetails(answerMap, correctMap);
- }
- const details = {};
- const keys = new Set([
- ...Object.keys(answerMap || {}),
- ...Object.keys(correctMap || {})
- ]);
- keys.forEach((questionId) => {
- const userAnswer = answerMap && answerMap[questionId] ? String(answerMap[questionId]) : '-';
- const correctAnswer = correctMap && correctMap[questionId] ? String(correctMap[questionId]) : '-';
- let isCorrect = null;
- if (correctAnswer !== '-') {
- const matchCore = window.AnswerMatchCore;
- isCorrect = matchCore && typeof matchCore.compareAnswers === 'function'
- ? matchCore.compareAnswers(userAnswer, correctAnswer) === true
- : userAnswer.toLowerCase() === correctAnswer.toLowerCase();
- }
- details[questionId] = {
- userAnswer,
- correctAnswer,
- isCorrect
- };
- });
- return details;
- }
-
- /**
- * 验证记录数据
- */
- validateRecord(record) {
- const requiredFields = ['id', 'examId', 'startTime', 'endTime'];
-
- for (const field of requiredFields) {
- if (!record[field]) {
- throw new Error(`Missing required field: ${field}`);
- }
- }
-
- // 验证时间格式
- if (new Date(record.startTime).toString() === 'Invalid Date') {
- throw new Error('Invalid startTime format');
- }
-
- if (new Date(record.endTime).toString() === 'Invalid Date') {
- throw new Error('Invalid endTime format');
- }
-
- // 验证数值范围
- record.accuracy = Math.max(0, Math.min(1, Number(record.accuracy) || 0));
-
- record.duration = Number.isFinite(record.duration) && record.duration >= 0
- ? record.duration
- : 0;
- }
-
- /**
- * 更新用户统计
- */
- async updateUserStats(practiceRecord, options = {}) {
- const { allowDuringInit = false } = options;
- await this.recalculateUserStats({ allowDuringInit });
- }
-
- applyRecordToStats(stats, practiceRecord) {
- if (!stats || typeof stats !== 'object') {
- return;
- }
-
- const duration = Number(practiceRecord.duration) || 0;
- const accuracy = Number(practiceRecord.accuracy) || 0;
- const normalizedRecord = { ...practiceRecord, duration, accuracy };
-
- stats.categoryStats = stats.categoryStats && typeof stats.categoryStats === 'object' ? stats.categoryStats : {};
- stats.questionTypeStats = stats.questionTypeStats && typeof stats.questionTypeStats === 'object' ? stats.questionTypeStats : {};
-
- stats.totalPractices += 1;
- stats.totalTimeSpent += duration;
-
- const totalScore = (stats.averageScore * (stats.totalPractices - 1)) + accuracy;
- stats.averageScore = stats.totalPractices > 0 ? totalScore / stats.totalPractices : 0;
-
- this.updateCategoryStats(stats, normalizedRecord);
- this.updateQuestionTypeStats(stats, normalizedRecord);
- this.updateStreakDays(stats, normalizedRecord);
- this.checkAchievements(stats, normalizedRecord);
-
- stats.updatedAt = new Date().toISOString();
- }
-
- /**
- * 更新分类统计
- */
- updateCategoryStats(stats, practiceRecord) {
- const category = practiceRecord?.metadata?.category;
- if (!category) return;
-
- if (!stats.categoryStats[category]) {
- stats.categoryStats[category] = {
- practices: 0,
- avgScore: 0,
- timeSpent: 0,
- bestScore: 0,
- totalQuestions: 0,
- correctAnswers: 0
- };
- }
-
- const catStats = stats.categoryStats[category];
- catStats.practices += 1;
- catStats.timeSpent += practiceRecord.duration;
- catStats.totalQuestions += practiceRecord.totalQuestions;
- catStats.correctAnswers += practiceRecord.correctAnswers;
- catStats.bestScore = Math.max(catStats.bestScore, practiceRecord.accuracy);
-
- // 重新计算平均分数
- const catTotalScore = (catStats.avgScore * (catStats.practices - 1)) + practiceRecord.accuracy;
- catStats.avgScore = catTotalScore / catStats.practices;
- }
-
- /**
- * 更新题型统计
- */
- updateQuestionTypeStats(stats, practiceRecord) {
- if (!practiceRecord.questionTypePerformance) return;
-
- Object.entries(practiceRecord.questionTypePerformance).forEach(([type, performance]) => {
- if (!stats.questionTypeStats[type]) {
- stats.questionTypeStats[type] = {
- practices: 0,
- accuracy: 0,
- totalQuestions: 0,
- correctAnswers: 0,
- avgTimePerQuestion: 0
- };
- }
-
- const typeStats = stats.questionTypeStats[type];
- typeStats.practices += 1;
- typeStats.totalQuestions += performance.total || 0;
- typeStats.correctAnswers += performance.correct || 0;
-
- // 重新计算准确率
- typeStats.accuracy = typeStats.totalQuestions > 0
- ? typeStats.correctAnswers / typeStats.totalQuestions
- : 0;
-
- // 计算平均每题用时
- if (performance.timeSpent && performance.total) {
- const newAvgTime = performance.timeSpent / performance.total;
- typeStats.avgTimePerQuestion = (typeStats.avgTimePerQuestion * (typeStats.practices - 1) + newAvgTime) / typeStats.practices;
- }
- });
- }
-
- /**
- * 更新连续学习天数
- */
- updateStreakDays(stats, practiceRecord) {
- const recordSource = practiceRecord.date || practiceRecord.endTime || practiceRecord.startTime;
- const recordDay = this.getDateOnlyIso(recordSource);
- if (!recordDay) return;
-
- const dayMs = 24 * 60 * 60 * 1000;
- let practiceDays = Array.isArray(stats.practiceDays) ? stats.practiceDays.slice() : [];
-
- if (practiceDays.length === 0) {
- const historicalStreak = Math.max(0, Math.round(this.ensureNumber(stats.streakDays, 0)));
- const lastPracticeIso = this.getDateOnlyIso(stats.lastPracticeDate);
- const lastPracticeStart = this.getLocalDayStart(lastPracticeIso);
-
- if (historicalStreak > 0 && lastPracticeIso && Number.isFinite(lastPracticeStart)) {
- const migratedDays = [];
- for (let offset = historicalStreak - 1; offset >= 0; offset -= 1) {
- const timestamp = lastPracticeStart - (offset * dayMs);
- const dayIso = this.getDateOnlyIso(timestamp);
- if (dayIso) {
- migratedDays.push(dayIso);
- }
- }
- practiceDays = migratedDays;
- }
- }
-
- const uniqueDays = new Set(practiceDays);
- uniqueDays.add(recordDay);
- practiceDays = Array.from(uniqueDays);
-
- const validDays = practiceDays
- .map(day => ({ day, start: this.getLocalDayStart(day) }))
- .filter(item => item.start !== null)
- .sort((a, b) => a.start - b.start);
-
- if (validDays.length === 0) {
- stats.practiceDays = [];
- stats.streakDays = 0;
- stats.lastPracticeDate = null;
- return;
- }
-
- let currentStreak = 1;
-
- for (let index = 1; index < validDays.length; index += 1) {
- const previous = validDays[index - 1];
- const current = validDays[index];
- const diff = Math.round((current.start - previous.start) / (1000 * 60 * 60 * 24));
-
- if (diff === 1) {
- currentStreak += 1;
- } else if (diff > 1) {
- currentStreak = 1;
- }
- }
-
- stats.practiceDays = validDays.map(item => item.day);
- stats.streakDays = currentStreak;
- stats.lastPracticeDate = validDays[validDays.length - 1].day;
- }
-
- /**
- * 检查成就
- */
- checkAchievements(stats, practiceRecord) {
- const achievements = stats.achievements || [];
-
- // 首次练习成就
- if (stats.totalPractices === 1 && !achievements.includes('first-practice')) {
- achievements.push('first-practice');
- }
-
- // 连续学习成就
- if (stats.streakDays >= 7 && !achievements.includes('week-streak')) {
- achievements.push('week-streak');
- }
-
- if (stats.streakDays >= 30 && !achievements.includes('month-streak')) {
- achievements.push('month-streak');
- }
-
- // 高分成就
- if (practiceRecord.accuracy >= 0.9 && !achievements.includes('high-scorer')) {
- achievements.push('high-scorer');
- }
-
- // 分类掌握成就
- const category = practiceRecord.metadata.category;
- if (category && stats.categoryStats[category]) {
- const catStats = stats.categoryStats[category];
- if (catStats.practices >= 10 && catStats.avgScore >= 0.8) {
- const achievementKey = `${category.toLowerCase()}-master`;
- if (!achievements.includes(achievementKey)) {
- achievements.push(achievementKey);
- }
- }
- }
-
- stats.achievements = achievements;
- }
-
- /**
- * 获取练习记录
- */
- async getPracticeRecords(filters = {}) {
- await this.ensureReady();
- const raw = await this.listPracticeRecordsCanonical();
- const base = Array.isArray(raw) ? raw : [];
- // Normalize each record to ensure UI can rely on a stable shape
- const records = base.map(r => this.normalizeRecordFields(r));
-
- if (Object.keys(filters).length === 0) {
- return records.sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
- }
-
- return records.filter(record => {
- // 按考试ID筛选
- if (filters.examId && record.examId !== filters.examId) return false;
-
- // 按分类筛选
- if (filters.category && record.metadata.category !== filters.category) return false;
-
- // 按时间范围筛选
- if (filters.startDate && new Date(record.startTime) < new Date(filters.startDate)) return false;
- if (filters.endDate && new Date(record.startTime) > new Date(filters.endDate)) return false;
-
- // 按准确率筛选
- if (filters.minAccuracy && record.accuracy < filters.minAccuracy) return false;
- if (filters.maxAccuracy && record.accuracy > filters.maxAccuracy) return false;
-
- // 按状态筛选
- if (filters.status && record.status !== filters.status) return false;
-
- return true;
- }).sort((a, b) => new Date(b.startTime) - new Date(a.startTime));
- }
-
- /**
- * 获取用户统计
- */
- async getUserStats(options = {}) {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
- const api = this.getPracticeRecordAPI(['readStats']);
- return await api.readStats({ fallback: this.getDefaultUserStats() });
- }
-
- /**
- * 重新计算用户统计
- */
- async recalculateUserStats(options = {}) {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
- const api = this.getPracticeRecordAPI(['recalculateStats']);
- const stats = await api.recalculateStats();
- console.log('User stats recalculated through PracticeRecordAPI');
- return stats;
- }
-
- /**
- * 将不同来源/版本的记录统一为稳定字段,以便 UI/统计可靠工作
- * 不修改存储中的原始对象,仅在返回路径做兼容填充
- */
- normalizeRecordFields(record) {
- try {
- const r = { ...(record || {}) };
-
- // metadata 兜底
- r.metadata = {
- examTitle: (r.metadata && r.metadata.examTitle) || r.title || r.examTitle || r.examId || '',
- category: (r.metadata && r.metadata.category) || r.category || '',
- frequency: (r.metadata && r.metadata.frequency) || r.frequency || '',
- ...(r.metadata || {})
- };
-
- // 时间字段归一
- const rd = r.realData || {};
- if (!r.startTime) {
- if (typeof rd.startTime === 'number') {
- r.startTime = new Date(rd.startTime).toISOString();
- } else if (rd.startTime) {
- r.startTime = new Date(rd.startTime).toISOString();
- } else if (r.date) {
- r.startTime = new Date(r.date).toISOString();
- }
- }
- if (!r.endTime) {
- if (typeof rd.endTime === 'number') {
- r.endTime = new Date(rd.endTime).toISOString();
- } else if (rd.endTime) {
- r.endTime = new Date(rd.endTime).toISOString();
- } else if (r.startTime && (r.duration || rd.duration)) {
- const base = new Date(r.startTime).getTime();
- const seconds = (Number(r.duration || rd.duration) || 0);
- r.endTime = new Date(base + seconds * 1000).toISOString();
- }
- }
-
- // 用时归一(秒): consider multiple possible fields; prefer positive seconds
- if (!(typeof r.duration === 'number' && isFinite(r.duration) && r.duration > 0)) {
- const sInfo = r.scoreInfo || rd.scoreInfo || {};
- const candidates = [
- r.duration, rd.duration, r.durationSeconds, r.duration_seconds,
- r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent,
- rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent,
- sInfo.duration, sInfo.timeSpent
- ];
- let picked;
- for (const v of candidates) {
- const n = Number(v);
- if (Number.isFinite(n) && n > 0) { picked = n; break; }
- }
- if (picked !== undefined) {
- r.duration = Math.floor(picked);
- } else if (r.startTime && r.endTime) {
- r.duration = Math.max(0, Math.floor((new Date(r.endTime) - new Date(r.startTime)) / 1000));
- } else if (Array.isArray(rd.interactions) && rd.interactions.length) {
- // Derive from interactions timestamp span
- try {
- const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n));
- if (ts.length) {
- const span = Math.max(...ts) - Math.min(...ts);
- if (Number.isFinite(span) && span > 0) r.duration = Math.floor(span / 1000);
- }
- } catch(_) {}
- } else {
- r.duration = 0;
- }
- }
-
- // scoreInfo 归一
- const sInfo = r.scoreInfo || rd.scoreInfo || {};
- if (!r.scoreInfo && (rd.scoreInfo || r.answerComparison)) {
- r.scoreInfo = sInfo;
- }
-
- // answers 归一
- if (!r.answers && rd.answers) {
- r.answers = rd.answers;
- }
- if (Array.isArray(r.answers)) {
- const map = {};
- r.answers.forEach((entry, idx) => {
- if (!entry) return;
- const key = entry.questionId || `q${idx + 1}`;
- map[key] = entry.answer || entry.userAnswer || '';
- });
- r.answerList = r.answers.slice();
- r.answers = map;
- }
- if (Array.isArray(rd.answers)) {
- const rdMap = {};
- rd.answers.forEach((entry, idx) => {
- if (!entry) return;
- const key = entry.questionId || `q${idx + 1}`;
- rdMap[key] = entry.answer || entry.userAnswer || '';
- });
- rd.answers = rdMap;
- }
- const comparisonSource = r.answerComparison || rd.answerComparison || null;
- if ((!r.answers || Object.keys(r.answers).length === 0) && comparisonSource) {
- const fromComparison = this.convertComparisonToMap(comparisonSource, 'userAnswer');
- if (Object.keys(fromComparison).length > 0) {
- r.answers = fromComparison;
- }
- }
- const normalizedCorrectMap = this.resolveCorrectAnswerMap(
- r,
- comparisonSource,
- r.answerDetails || r.scoreInfo?.details || rd.scoreInfo?.details || null
- );
- if (Object.keys(normalizedCorrectMap).length > 0) {
- r.correctAnswerMap = normalizedCorrectMap;
- }
- if (!r.answerDetails) {
- if (comparisonSource) {
- r.answerDetails = this.convertComparisonToDetails(comparisonSource);
- }
- if (!r.answerDetails) {
- r.answerDetails = r.scoreInfo?.details || this.buildAnswerDetailsFromMaps(r.answers, r.correctAnswerMap);
- }
- }
-
- // 正确/总题数归一
- const derivedCorrect = (typeof r.correctAnswers === 'number') ? r.correctAnswers
- : (typeof r.score === 'number' ? r.score
- : (typeof sInfo.correct === 'number'
- ? sInfo.correct
- : this.deriveCorrectAnswerCount(r, r.answers || [])));
-
- const derivedTotal = (typeof r.totalQuestions === 'number') ? r.totalQuestions
- : (typeof sInfo.total === 'number' ? sInfo.total
- : (r.realData && typeof r.realData.totalQuestions === 'number' ? r.realData.totalQuestions
- : (r.answers ? Object.keys(r.answers).length
- : (rd.answers ? Object.keys(rd.answers || {}).length : null))));
-
- if (typeof r.correctAnswers !== 'number' && derivedCorrect != null) {
- r.correctAnswers = derivedCorrect;
- }
- if (typeof r.totalQuestions !== 'number' && derivedTotal != null) {
- r.totalQuestions = derivedTotal;
- }
- if (r.realData && typeof r.realData === 'object') {
- r.realData.correctAnswers = r.correctAnswerMap || {};
- r.realData.correctAnswerMap = r.correctAnswerMap || {};
- }
-
- // 准确率/百分比归一
- let acc = (typeof r.accuracy === 'number') ? r.accuracy
- : (typeof sInfo.accuracy === 'number' ? sInfo.accuracy : null);
- if (acc == null) {
- if (typeof r.correctAnswers === 'number' && typeof r.totalQuestions === 'number' && r.totalQuestions > 0) {
- acc = r.correctAnswers / r.totalQuestions;
- } else {
- acc = 0;
- }
- }
- r.accuracy = acc;
-
- if (typeof r.percentage !== 'number' || isNaN(r.percentage)) {
- if (typeof sInfo.percentage === 'number') {
- r.percentage = sInfo.percentage;
- } else {
- r.percentage = Math.round(acc * 100);
- }
- }
-
- // 状态兜底
- if (!r.status) r.status = 'completed';
-
- return r;
- } catch (e) {
- try { console.warn('[ScoreStorage] normalizeRecordFields failed:', e); } catch(_) {}
- return record;
- }
- }
-
- /**
- * 创建数据备份 - 统一走 BackupAPI → BackupRepository
- */
- async createBackup(backupName = null, options = {}) {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
-
- if (window.BackupAPI && typeof window.BackupAPI.create === 'function') {
- const practiceRecords = await this.listPracticeRecordsCanonical();
- const userStats = await this.getUserStats({ allowDuringInit });
- const storageVersion = await this.storage.get(this.storageKeys.storageVersion);
- const examIndex = await this.storage.get('exam_index', []);
- const backupId = await window.BackupAPI.create({
- id: backupName || undefined,
- type: 'score_storage',
- data: {
- practice_records: practiceRecords,
- user_stats: userStats,
- exam_index: Array.isArray(examIndex) ? examIndex : [],
- storage_version: storageVersion
- }
- });
- console.log('[ScoreStorage] Backup created via BackupAPI:', backupId);
- return backupId;
- }
-
- // Fallback: DataBackupManager path (still ends at BackupAPI if loaded)
- if (window.DataBackupManager) {
- const backupManager = new DataBackupManager();
- const backupId = await backupManager.createBackup(
- backupName || `score_backup_${Date.now()}`,
- 'score_storage'
- );
- console.log('[ScoreStorage] Backup created via DataBackupManager:', backupId);
- return backupId;
- }
-
- console.warn('[ScoreStorage] BackupAPI not available, skipping backup');
- return null;
- }
-
- /**
- * 恢复数据备份 - 统一走 BackupAPI
- */
- async restoreBackup(backupId, options = {}) {
- try {
- const { allowDuringInit = false } = options;
- await this.ensureReady({ allowDuringInit });
-
- if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') {
- const result = await window.BackupAPI.restore(backupId);
- console.log('[ScoreStorage] Backup restored via BackupAPI:', backupId);
- return result.backup;
- }
-
- // Fallback dual-schema restore when BackupAPI missing
- const backups = await this.storage.get('manual_backups', []);
- const backup = backups.find(b => b.id === backupId);
-
- if (!backup) {
- throw new Error(`Backup not found: ${backupId}`);
- }
-
- if (backup.data) {
- const data = backup.data;
- const records = Array.isArray(data.practiceRecords)
- ? data.practiceRecords
- : (Array.isArray(data.practice_records) ? data.practice_records : []);
- const stats = (data.userStats && typeof data.userStats === 'object')
- ? data.userStats
- : ((data.user_stats && typeof data.user_stats === 'object') ? data.user_stats : null);
- const hasStats = Boolean(stats);
- await this.replacePracticeRecordsCanonical(records, { updateStats: !hasStats });
- if (hasStats) {
- const api = this.getPracticeRecordAPI(['resetStats']);
- await api.resetStats(stats);
- }
- if (data.storageVersion || data.storage_version) {
- await this.storage.set(this.storageKeys.storageVersion, data.storageVersion || data.storage_version);
- }
- const examIndex = Array.isArray(data.exam_index)
- ? data.exam_index
- : (Array.isArray(data.examIndex) ? data.examIndex : null);
- if (examIndex) {
- await this.storage.set('exam_index', examIndex);
- }
- }
-
- console.log('[ScoreStorage] Backup restored:', backupId);
- return backup;
- } catch (error) {
- console.error('[ScoreStorage] Failed to restore backup:', error);
- throw error;
- }
- }
-
- /**
- * 获取备份列表 - 统一走 BackupAPI
- */
- async getBackups() {
- try {
- await this.ensureReady();
- if (window.BackupAPI && typeof window.BackupAPI.list === 'function') {
- const backups = await window.BackupAPI.list();
- return (Array.isArray(backups) ? backups : [])
- .slice()
- .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- }
- const backups = await this.storage.get('manual_backups', []);
- return (Array.isArray(backups) ? backups : [])
- .slice()
- .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- } catch (error) {
- console.error('[ScoreStorage] Failed to get backups:', error);
- return [];
- }
- }
-
- /**
- * 导出数据
- */
- async exportData(format = 'json') {
- await this.ensureReady();
- const exportData = {
- exportDate: new Date().toISOString(),
- version: this.currentVersion,
- practiceRecords: await this.listPracticeRecordsCanonical(),
- userStats: await this.getUserStats(),
- backups: await this.storage.get(this.storageKeys.backupData, [])
- };
-
- switch (format.toLowerCase()) {
- case 'json':
- return JSON.stringify(exportData, null, 2);
- case 'csv':
- return this.convertToCSV(exportData.practiceRecords);
- default:
- throw new Error(`Unsupported export format: ${format}`);
- }
- }
-
- /**
- * 转换为CSV格式
- */
- convertToCSV(records) {
- if (records.length === 0) return '';
-
- const headers = [
- 'ID', '考试ID', '开始时间', '结束时间', '用时(秒)',
- '状态', '分数', '总题数', '正确数', '准确率',
- '分类', '频率', '题目标题'
- ];
-
- const rows = records.map(record => [
- record.id,
- record.examId,
- record.startTime,
- record.endTime,
- record.duration,
- record.status,
- record.score,
- record.totalQuestions,
- record.correctAnswers,
- Math.round(record.accuracy * 100) + '%',
- record.metadata.category || '',
- record.metadata.frequency || '',
- record.metadata.examTitle || ''
- ]);
-
- return [headers, ...rows]
- .map(row => row.map(cell => `"${cell}"`).join(','))
- .join('\n');
- }
-
- /**
- * 导入数据
- */
- async importData(importData, options = {}) {
- try {
- await this.ensureReady();
- const payload = typeof importData === 'string' ? JSON.parse(importData) : importData;
-
- const records = this.extractPracticeRecordsFromPayload(payload);
- const stats = this.extractUserStatsFromPayload(payload);
-
- if (!Array.isArray(records) || records.length === 0) {
- throw new Error('Invalid import data format: no practice records found');
- }
-
- // 标准化记录,避免字段缺失
- const standardizedRecords = records.map((r) => {
- try {
- return this.standardizeRecord(r);
- } catch (e) {
- console.warn('[ScoreStorage] 标准化导入记录失败,跳过:', r && r.id, e);
- return null;
- }
- }).filter(Boolean);
-
- // 创建备份
- await this.createBackup('pre_import_backup');
-
- if (options.merge) {
- // 合并模式:按 id 去重,保留导入集中的最新(后出现的覆盖)
- const existingRecords = await this.listPracticeRecordsCanonical();
- const mergedMap = new Map();
- existingRecords.forEach((rec) => {
- if (rec && rec.id) mergedMap.set(rec.id, rec);
- });
- standardizedRecords.forEach((rec) => {
- if (rec && rec.id) mergedMap.set(rec.id, rec);
- });
- const mergedRecords = Array.from(mergedMap.values());
- await this.replacePracticeRecordsCanonical(mergedRecords, { updateStats: true });
- console.log(`Imported ${standardizedRecords.length} records (merge mode), total ${mergedRecords.length}`);
-
- } else {
- // 替换模式:完全替换数据
- await this.replacePracticeRecordsCanonical(standardizedRecords, { updateStats: !stats });
-
- if (stats) {
- const api = this.getPracticeRecordAPI(['writeStats']);
- await api.writeStats(stats);
- }
-
- console.log(`Imported ${standardizedRecords.length} records (replace mode)`);
- }
-
- return true;
-
- } catch (error) {
- console.error('Failed to import data:', error);
- throw error;
- }
- }
-
- extractPracticeRecordsFromPayload(payload) {
- if (!payload) return [];
- if (Array.isArray(payload)) return payload;
- if (Array.isArray(payload.practiceRecords)) return payload.practiceRecords;
- if (Array.isArray(payload.practice_records)) return payload.practice_records;
- if (Array.isArray(payload.data?.practice_records)) return payload.data.practice_records;
- if (Array.isArray(payload.data?.practiceRecords)) return payload.data.practiceRecords;
- if (payload.data?.exam_system_practice_records && Array.isArray(payload.data.exam_system_practice_records.data)) {
- return payload.data.exam_system_practice_records.data;
- }
- if (payload.exam_system_practice_records && Array.isArray(payload.exam_system_practice_records.data)) {
- return payload.exam_system_practice_records.data;
- }
- return [];
- }
-
- extractUserStatsFromPayload(payload) {
- if (!payload || typeof payload !== 'object') return null;
- return payload.userStats
- || payload.user_stats
- || payload.data?.userStats
- || payload.data?.user_stats
- || null;
- }
-
- // Note: 备份相关方法已移除,现在使用DataBackupManager
-
- /**
- * 生成记录ID
- */
- generateRecordId() {
- return `record_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
- }
-
- /**
- * 获取存储统计信息
- */
- async getStorageStats() {
- await this.ensureReady();
- const records = await this.listPracticeRecordsCanonical();
- const backups = await this.storage.get(this.storageKeys.backupData, []);
-
- return {
- totalRecords: records.length,
- totalBackups: backups.length,
- oldestRecord: records.length > 0 ? records[0].startTime : null,
- newestRecord: records.length > 0 ? records[records.length - 1].startTime : null,
- storageVersion: await this.storage.get(this.storageKeys.storageVersion),
- estimatedSize: await this.estimateStorageSize()
- };
- }
-
- /**
- * 估算存储大小
- */
- async estimateStorageSize() {
- await this.ensureReady();
- const data = {
- practiceRecords: await this.listPracticeRecordsCanonical(),
- userStats: await this.getUserStats(),
- backupData: await this.storage.get(this.storageKeys.backupData, [])
- };
-
- const jsonString = JSON.stringify(data);
- return jsonString.length; // 字节数的近似值
- }
-
- // Note: destroy方法已移除,因为备份功能现在由DataBackupManager处理
-}
-
-// 确保全局可用
-window.ScoreStorage = ScoreStorage;
diff --git a/js/core/siteDataReset.js b/js/core/siteDataReset.js
new file mode 100644
index 00000000..ae197aa1
--- /dev/null
+++ b/js/core/siteDataReset.js
@@ -0,0 +1,147 @@
+/** Clear all browser-local IELTS Atlas data while preserving external JSON files. */
+(function initSiteDataReset(global) {
+ 'use strict';
+
+ if (global.SiteDataReset && global.SiteDataReset.__v2 === true) {
+ global.clearCache = global.SiteDataReset.request;
+ return;
+ }
+
+ const DATABASE_NAMES = Object.freeze([
+ 'IELTSAtlasDataV2',
+ 'ExamSystemDB',
+ 'IELTSAtlasExternalBackupV2'
+ ]);
+ let resetPromise = null;
+
+ function notify(message, type = 'info') {
+ if (typeof global.showMessage === 'function') global.showMessage(message, type);
+ else if (global.console && typeof global.console.log === 'function') {
+ global.console.log(`[SiteDataReset] ${message}`);
+ }
+ }
+
+ function deleteDatabase(name) {
+ return new Promise((resolve, reject) => {
+ const indexedDB = global.indexedDB;
+ if (!indexedDB || typeof indexedDB.deleteDatabase !== 'function') {
+ resolve({ name, skipped: true });
+ return;
+ }
+
+ let request;
+ try { request = indexedDB.deleteDatabase(name); }
+ catch (error) { reject(error); return; }
+
+ request.onsuccess = () => resolve({ name, deleted: true });
+ request.onerror = () => reject(request.error || new Error(`删除数据库失败:${name}`));
+ request.onblocked = () => notify(
+ `数据库 ${name} 正被其他 IELTS Atlas 标签页占用。请关闭其他标签页,清理会自动继续。`,
+ 'warning'
+ );
+ });
+ }
+
+ async function stopExternalBackup() {
+ const service = global.ExternalBackupService;
+ if (!service) return;
+ if (typeof service.prepareForFullReset === 'function') {
+ await service.prepareForFullReset();
+ } else if (typeof service.unbindDirectory === 'function') {
+ await service.unbindDirectory();
+ }
+ }
+
+ function clearWebStorage() {
+ const errors = [];
+ for (const name of ['localStorage', 'sessionStorage']) {
+ try {
+ const storage = global[name];
+ if (storage && typeof storage.clear === 'function') storage.clear();
+ } catch (error) {
+ errors.push({ stage: 'clear-web-storage', storage: name, error });
+ }
+ }
+ return errors;
+ }
+
+ function reload(options) {
+ if (options.reload === false) return false;
+ if (!global.location || typeof global.location.reload !== 'function') return false;
+ global.location.reload();
+ return true;
+ }
+
+ async function perform(options = {}) {
+ if (resetPromise) return resetPromise;
+ resetPromise = (async () => {
+ try {
+ await stopExternalBackup();
+ } catch (error) {
+ notify('外部备份仍在写入,本次清理已取消。', 'error');
+ return {
+ success: false,
+ reason: 'external_backup_busy',
+ terminal: false,
+ error,
+ databases: DATABASE_NAMES.slice(),
+ externalBackupFilesPreserved: true
+ };
+ }
+
+ const results = await Promise.allSettled(DATABASE_NAMES.map(deleteDatabase));
+ const errors = results.flatMap((result, index) => result.status === 'rejected'
+ ? [{ stage: 'delete-database', database: DATABASE_NAMES[index], error: result.reason }]
+ : []);
+ errors.push(...clearWebStorage());
+ if (errors.length) {
+ notify('本地数据仅部分清除,请关闭其他标签页后重试。', 'error');
+ return {
+ success: false,
+ reason: 'partial_reset',
+ terminal: false,
+ errors,
+ databases: DATABASE_NAMES.slice(),
+ externalBackupFilesPreserved: true
+ };
+ }
+
+ return {
+ success: true,
+ terminal: reload(options),
+ databases: DATABASE_NAMES.slice(),
+ externalBackupFilesPreserved: true
+ };
+ })();
+
+ try { return await resetPromise; }
+ finally { resetPromise = null; }
+ }
+
+ async function request(options = {}) {
+ let confirmed = options.confirmed === true;
+ if (!confirmed) {
+ try {
+ confirmed = global.confirm(
+ '确定要清除全部浏览器本地数据并返回首次启动状态吗?\n\n'
+ + '练习记录、题库、词汇、设置、应用内备份和本地文件夹绑定都会清除;'
+ + '外部文件夹中的 JSON 备份不会删除。'
+ );
+ } catch (_) { confirmed = false; }
+ }
+ if (!confirmed) return { success: false, reason: 'cancelled', terminal: false };
+
+ notify('正在清除全部本地数据...', 'info');
+ try { return await perform(options); }
+ catch (error) {
+ if (global.console && typeof global.console.error === 'function') {
+ global.console.error('[SiteDataReset] full reset failed:', error);
+ }
+ notify(`清除失败:${error && error.message ? error.message : '浏览器存储不可用'}`, 'error');
+ return { success: false, reason: 'reset_failed', terminal: false, error };
+ }
+ }
+
+ global.SiteDataReset = Object.freeze({ __v2: true, DATABASE_NAMES, perform, request });
+ global.clearCache = request;
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/core/storageProviderRegistry.js b/js/core/storageProviderRegistry.js
deleted file mode 100644
index dc220e5b..00000000
--- a/js/core/storageProviderRegistry.js
+++ /dev/null
@@ -1,83 +0,0 @@
-(function(window) {
- const listeners = new Set();
- let providers = null;
-
- function normalizeProviders(input) {
- if (!input || typeof input !== 'object') {
- return null;
- }
- const normalized = {
- storageManager: input.storageManager || window.storage || null,
- persistentStore: input.persistentStore || window.persistentStore || null,
- preferenceStore: input.preferenceStore || window.preferenceStore || null,
- repositories: input.repositories || null,
- simpleStorageWrapper: input.simpleStorageWrapper || null
- };
- if (!normalized.repositories) {
- return null;
- }
- return normalized;
- }
-
- function notifyListeners(payload) {
- listeners.forEach((listener) => {
- try {
- listener(payload);
- } catch (error) {
- console.error('[StorageProviderRegistry] listener failed:', error);
- }
- });
- }
-
- function registerStorageProviders(input) {
- const normalized = normalizeProviders(input);
- if (!normalized) {
- throw new Error('registerStorageProviders requires repositories');
- }
- providers = normalized;
-
- if (!window.dataRepositories) {
- window.dataRepositories = normalized.repositories;
- }
- if (!window.storage && normalized.storageManager) {
- window.storage = normalized.storageManager;
- }
- if (!window.persistentStore && normalized.persistentStore) {
- window.persistentStore = normalized.persistentStore;
- }
- if (!window.preferenceStore && normalized.preferenceStore) {
- window.preferenceStore = normalized.preferenceStore;
- }
- if (normalized.simpleStorageWrapper && !window.simpleStorageWrapper) {
- window.simpleStorageWrapper = normalized.simpleStorageWrapper;
- }
-
- notifyListeners(Object.assign({}, providers));
- return providers;
- }
-
- function onProvidersReady(callback) {
- if (typeof callback !== 'function') {
- return () => {};
- }
- listeners.add(callback);
- if (providers) {
- try {
- callback(Object.assign({}, providers));
- } catch (error) {
- console.error('[StorageProviderRegistry] immediate callback failed:', error);
- }
- }
- return () => listeners.delete(callback);
- }
-
- function getCurrentProviders() {
- return providers ? Object.assign({}, providers) : null;
- }
-
- window.StorageProviderRegistry = {
- registerStorageProviders,
- onProvidersReady,
- getCurrentProviders
- };
-})(window);
diff --git a/js/core/vocabStore.js b/js/core/vocabStore.js
index bda51c18..17f4645b 100644
--- a/js/core/vocabStore.js
+++ b/js/core/vocabStore.js
@@ -5,53 +5,40 @@
id: 'default',
name: 'IELTS 核心词表',
icon: '📚',
- source: 'builtin',
- storageKey: 'vocab_words'
+ source: 'builtin'
},
'spelling-errors-p1': {
id: 'spelling-errors-p1',
name: 'P1 拼写错误',
icon: '📝',
- source: 'p1',
- storageKey: 'vocab_list_p1_errors'
+ source: 'p1'
},
'spelling-errors-p4': {
id: 'spelling-errors-p4',
name: 'P4 拼写错误',
icon: '📝',
- source: 'p4',
- storageKey: 'vocab_list_p4_errors'
+ source: 'p4'
},
'spelling-errors-master': {
id: 'spelling-errors-master',
name: '综合错误词表',
icon: '📚',
- source: 'all',
- storageKey: 'vocab_list_master_errors'
+ source: 'all'
},
'custom': {
id: 'custom',
name: '自定义词表',
icon: '✏️',
- source: 'user',
- storageKey: 'vocab_list_custom'
+ source: 'user'
},
'reading-highlights': {
id: 'reading-highlights',
name: '阅读高亮生词',
icon: '📖',
- source: 'reading-highlight',
- storageKey: 'vocab_list_reading_highlights'
+ source: 'reading-highlight'
}
});
- const STORAGE_KEYS = Object.freeze({
- WORDS: 'vocab_words',
- CONFIG: 'vocab_user_config',
- REVIEW_QUEUE: 'vocab_review_queue',
- ACTIVE_LIST: 'vocab_active_list_id'
- });
-
const DEFAULT_CONFIG = Object.freeze({
dailyNew: 20,
reviewLimit: 100,
@@ -60,29 +47,31 @@
notify: true
});
- const DEFAULT_REVIEW_QUEUE = Object.freeze([]);
const DEFAULT_LIST_ID = 'default';
const DEFAULT_LEXICON_URL = 'assets/wordlists/ielts_core.json';
const SPELLING_ERROR_LIST_IDS = new Set(['spelling-errors-p1', 'spelling-errors-p4', 'spelling-errors-master']);
const state = {
- repositories: null,
- metaRepo: null,
- storageManager: null,
words: [],
wordIndex: new Map(),
config: { ...DEFAULT_CONFIG },
- reviewQueue: DEFAULT_REVIEW_QUEUE.slice(),
ready: false,
readyPromise: null,
readyResolvers: [],
loadingPromise: null,
- registryUnsubscribe: null,
lastLoadSource: 'init',
activeListId: DEFAULT_LIST_ID,
listCache: new Map()
};
+ function cloneValue(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
function emitReady(value) {
if (state.ready) {
return;
@@ -281,59 +270,30 @@
});
}
- async function persist(key, value) {
- try {
- if (state.metaRepo && typeof state.metaRepo.set === 'function') {
- await state.metaRepo.set(key, value, { clone: true });
- return true;
- }
- if (state.storageManager && typeof state.storageManager.set === 'function') {
- await state.storageManager.set(key, value);
- return true;
- }
- if (typeof localStorage !== 'undefined') {
- localStorage.setItem(key, JSON.stringify(value));
- return true;
- }
- } catch (error) {
- console.error('[VocabStore] persist error:', error);
- }
- return false;
+ async function requireVocabData() {
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab is unavailable');
+ await window.AppData.ready;
+ return window.AppData.vocab;
}
- async function read(key, defaultValue) {
- if (state.metaRepo && typeof state.metaRepo.get === 'function') {
- try {
- const value = await state.metaRepo.get(key, defaultValue);
- if (value !== undefined) {
- return value;
- }
- } catch (error) {
- console.warn('[VocabStore] metaRepo读取失败:', error);
- }
- }
- if (state.storageManager && typeof state.storageManager.get === 'function') {
- try {
- const value = await state.storageManager.get(key, defaultValue);
- if (value !== undefined) {
- return value;
- }
- } catch (error) {
- console.warn('[VocabStore] storageManager读取失败:', error);
- }
- }
- if (typeof localStorage !== 'undefined') {
- try {
- const raw = localStorage.getItem(key);
- if (!raw) {
- return defaultValue;
- }
- return JSON.parse(raw);
- } catch (error) {
- console.warn('[VocabStore] localStorage解析失败:', error);
- }
- }
- return defaultValue;
+ async function readListData(listId) {
+ const vocab = await requireVocabData();
+ if (listId === DEFAULT_LIST_ID) return vocab.listWords();
+ const collections = await vocab.listCollections();
+ return Object.prototype.hasOwnProperty.call(collections, listId) ? collections[listId] : null;
+ }
+
+ async function saveListData(listId, value) {
+ const vocab = await requireVocabData();
+ const words = value && typeof value === 'object' && Array.isArray(value.words) ? value.words : value;
+ await vocab.replaceListWords({ listId, words: Array.isArray(words) ? words : [] });
+ return true;
+ }
+
+ async function saveConfigData(configPatch = state.config) {
+ const vocab = await requireVocabData();
+ await vocab.patchConfig(Object.assign({}, configPatch, { activeListId: state.activeListId }));
+ return true;
}
function mergeConfig(config) {
@@ -353,15 +313,6 @@
rebuildIndex();
}
- function getStorageKeyForListId(listId) {
- const targetId = typeof listId === 'string' && VOCAB_LISTS[listId] ? listId : DEFAULT_LIST_ID;
- return VOCAB_LISTS[targetId].storageKey;
- }
-
- function getActiveStorageKey() {
- return getStorageKeyForListId(state.activeListId);
- }
-
function isSpellingErrorList(listId) {
return SPELLING_ERROR_LIST_IDS.has(listId);
}
@@ -475,29 +426,26 @@
return state.loadingPromise;
}
state.loadingPromise = (async () => {
- const [storedConfig, storedQueue, storedActiveList] = await Promise.all([
- read(STORAGE_KEYS.CONFIG, { ...DEFAULT_CONFIG }),
- read(STORAGE_KEYS.REVIEW_QUEUE, DEFAULT_REVIEW_QUEUE.slice()),
- read(STORAGE_KEYS.ACTIVE_LIST, DEFAULT_LIST_ID)
- ]);
+ const vocab = await requireVocabData();
+ const storedConfig = await vocab.getConfig();
+ const storedActiveList = storedConfig && storedConfig.activeListId;
state.activeListId = typeof storedActiveList === 'string' && VOCAB_LISTS[storedActiveList]
? storedActiveList
: DEFAULT_LIST_ID;
- const activeStorageKey = getStorageKeyForListId(state.activeListId);
- const storedWords = await read(activeStorageKey, []);
+ const storedWords = await readListData(state.activeListId);
const normalizedWords = normalizeStoredListWords(storedWords, state.activeListId);
if (normalizedWords.length) {
setWordsInternal(normalizedWords);
- state.lastLoadSource = state.metaRepo ? 'meta' : (state.storageManager ? 'storage' : 'localStorage');
+ state.lastLoadSource = 'appData-v2';
}
state.config = mergeConfig(storedConfig);
- state.reviewQueue = Array.isArray(storedQueue) ? storedQueue.map((id) => String(id)) : [];
})()
.catch((error) => {
console.error('[VocabStore] 初始化加载失败:', error);
+ throw error;
})
.finally(() => {
state.loadingPromise = null;
@@ -507,8 +455,7 @@
async function ensureDefaultLexicon() {
try {
- const defaultStorageKey = getStorageKeyForListId(DEFAULT_LIST_ID);
- const storedDefault = await read(defaultStorageKey, []);
+ const storedDefault = await readListData(DEFAULT_LIST_ID);
const normalizedStored = normalizeStoredListWords(storedDefault, DEFAULT_LIST_ID);
const pollutedBySpellingList = isLikelySpellingErrorSnapshot(normalizedStored);
if (normalizedStored.length && !pollutedBySpellingList) {
@@ -526,7 +473,7 @@
console.warn('[VocabStore] 默认词库为空');
return [];
}
- await persist(defaultStorageKey, normalized);
+ await saveListData(DEFAULT_LIST_ID, normalized);
if (state.activeListId === DEFAULT_LIST_ID) {
setWordsInternal(normalized);
state.lastLoadSource = 'default';
@@ -548,8 +495,8 @@
});
return normalized;
} catch (error) {
- console.warn('[VocabStore] 默认词库加载失败:', error);
- return [];
+ console.error('[VocabStore] 默认词库加载失败:', error);
+ throw error;
}
}
@@ -559,87 +506,39 @@
emitReady(true);
}
- function connectToProviders() {
- if (state.registryUnsubscribe || state.repositories || state.storageManager) {
- return;
- }
- const registry = window.StorageProviderRegistry;
- if (registry && typeof registry.onProvidersReady === 'function') {
- state.registryUnsubscribe = registry.onProvidersReady((payload) => {
- if (payload && payload.repositories) {
- attachRepositories(payload.repositories);
- }
- if (payload && payload.storageManager) {
- state.storageManager = payload.storageManager;
- }
- });
- const current = typeof registry.getCurrentProviders === 'function' ? registry.getCurrentProviders() : null;
- if (current) {
- if (current.repositories) {
- attachRepositories(current.repositories);
- }
- if (current.storageManager) {
- state.storageManager = current.storageManager;
- }
- }
- return;
- }
- if (window.dataRepositories) {
- attachRepositories(window.dataRepositories);
- }
- if (window.storage) {
- state.storageManager = window.storage;
- }
- }
-
- async function attachRepositories(repositories) {
- if (!repositories || state.repositories === repositories) {
- return;
- }
- state.repositories = repositories;
- state.metaRepo = repositories.meta || null;
- await loadState();
- if (!state.words.length) {
- await ensureDefaultLexicon();
- }
- await persist(getActiveStorageKey(), state.words);
- await persist(STORAGE_KEYS.CONFIG, state.config);
- await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue);
- emitReady(true);
- }
-
function getWords() {
- return state.words.map((word) => ({ ...word }));
+ return cloneValue(state.words);
}
- async function setWords(words) {
+ async function mergeWords(words) {
const normalized = Array.isArray(words)
? words.map((word) => normalizeWordRecord(word)).filter(Boolean)
: [];
- setWordsInternal(normalized);
- await persist(getActiveStorageKey(), normalized);
+ const vocab = await requireVocabData();
+ const receipt = await vocab.mergeListWords({ listId: state.activeListId, words: normalized });
+ const committedWords = Array.isArray(receipt.words) ? receipt.words : [];
+ setWordsInternal(committedWords.map((word) => normalizeWordRecord(word)).filter(Boolean));
state.listCache.delete(state.activeListId);
- return getWords();
+ return {
+ words: getWords(),
+ addedCount: Number(receipt.addedCount) || 0,
+ updatedCount: Number(receipt.updatedCount) || 0
+ };
}
async function updateWord(id, patch = {}) {
if (!id || !state.wordIndex.has(id)) {
return null;
}
- const original = state.wordIndex.get(id);
- const updated = normalizeWordRecord({
- ...original,
- ...patch,
- id,
- updatedAt: getNow()
- });
+ const vocab = await requireVocabData();
+ const receipt = await vocab.patchWord({ listId: state.activeListId, wordId: id, patch });
+ const updated = normalizeWordRecord(receipt.word);
const index = state.words.findIndex((word) => word.id === id);
if (index >= 0 && updated) {
state.words.splice(index, 1, updated);
state.wordIndex.set(id, updated);
- await persist(getActiveStorageKey(), state.words);
state.listCache.delete(state.activeListId);
- return { ...updated };
+ return cloneValue(updated);
}
return null;
}
@@ -649,19 +548,29 @@
}
async function setConfig(config) {
- state.config = mergeConfig(config);
- await persist(STORAGE_KEYS.CONFIG, state.config);
+ const next = mergeConfig(config);
+ await saveConfigData(next);
+ state.config = next;
return getConfig();
}
- function getReviewQueue() {
- return state.reviewQueue.slice();
- }
-
- async function setReviewQueue(queue) {
- state.reviewQueue = Array.isArray(queue) ? queue.map((id) => String(id)) : [];
- await persist(STORAGE_KEYS.REVIEW_QUEUE, state.reviewQueue);
- return getReviewQueue();
+ async function replaceProgress(words, config = {}, listId = null) {
+ const normalized = Array.isArray(words)
+ ? words.map((word) => normalizeWordRecord(word)).filter(Boolean)
+ : [];
+ const requestedListId = typeof listId === 'string' && listId.trim()
+ ? listId.trim()
+ : (typeof config.activeListId === 'string' && config.activeListId.trim()
+ ? config.activeListId.trim()
+ : state.activeListId);
+ const nextConfig = mergeConfig({ ...config, activeListId: requestedListId });
+ const vocab = await requireVocabData();
+ await vocab.replaceProgress({ listId: requestedListId, words: normalized, config: nextConfig });
+ state.config = nextConfig;
+ state.activeListId = requestedListId;
+ setWordsInternal(normalized);
+ state.listCache.delete(requestedListId);
+ return { words: getWords(), config: getConfig() };
}
function getDueWords(referenceTime = new Date()) {
@@ -800,8 +709,7 @@
}
try {
- const storageKey = listConfig.storageKey;
- let storedData = await read(storageKey, null);
+ let storedData = await readListData(listId);
if (listId === DEFAULT_LIST_ID && (!storedData || (Array.isArray(storedData) && storedData.length === 0))) {
const ensured = await ensureDefaultLexicon();
storedData = ensured;
@@ -836,7 +744,7 @@
return listData;
} catch (error) {
console.error('[VocabStore] loadList 失败:', error);
- return null;
+ throw error;
}
}
@@ -861,25 +769,11 @@
}
try {
- // 保存当前词表到存储(如果有修改)
- if (state.activeListId && state.words.length > 0) {
- const currentConfig = VOCAB_LISTS[state.activeListId];
- if (currentConfig) {
- await persist(currentConfig.storageKey, state.words);
- }
- }
-
- // 切换到新词表
+ const vocab = await requireVocabData();
+ await vocab.activateList(listId);
state.activeListId = listId;
setWordsInternal(listData.words || []);
state.listCache.delete(listId);
-
- // 保存激活的词表 ID
- await persist(STORAGE_KEYS.ACTIVE_LIST, listId);
-
- // 清空复习队列(新词表需要重新生成队列)
- state.reviewQueue = [];
- await persist(STORAGE_KEYS.REVIEW_QUEUE, []);
return true;
} catch (error) {
@@ -907,8 +801,7 @@
// 从存储读取
try {
- const listConfig = VOCAB_LISTS[listId];
- const storedData = await read(listConfig.storageKey, null);
+ const storedData = await readListData(listId);
// 检查是否为拼写错误词表格式
if (storedData && typeof storedData === 'object' && Array.isArray(storedData.words)) {
@@ -920,7 +813,7 @@
return 0;
} catch (error) {
console.error('[VocabStore] getListWordCount 失败:', error);
- return 0;
+ throw error;
}
}
@@ -988,8 +881,7 @@
}
await init();
const listId = 'reading-highlights';
- const listConfig = VOCAB_LISTS[listId];
- const storedData = await read(listConfig.storageKey, []);
+ const storedData = await readListData(listId);
const words = normalizeStoredListWords(storedData, listId);
const key = normalized.word.toLowerCase();
const existingIndex = words.findIndex((entry) => String(entry.word || '').trim().toLowerCase() === key);
@@ -1005,7 +897,7 @@
} else {
words.push(normalized);
}
- await persist(listConfig.storageKey, words.filter(Boolean));
+ await saveListData(listId, words.filter(Boolean));
state.listCache.delete(listId);
if (state.activeListId === listId) {
setWordsInternal(words.filter(Boolean));
@@ -1015,7 +907,6 @@
async function init() {
ensureReadyPromise();
- connectToProviders();
if (!state.ready) {
await bootstrap();
}
@@ -1025,12 +916,11 @@
const api = {
init,
getWords,
- setWords,
+ mergeWords,
updateWord,
getConfig,
setConfig,
- getReviewQueue,
- setReviewQueue,
+ replaceProgress,
getDueWords,
getNewWords,
loadList,
diff --git a/js/data/dataSources/storageDataSource.js b/js/data/dataSources/storageDataSource.js
deleted file mode 100644
index 4294fd8d..00000000
--- a/js/data/dataSources/storageDataSource.js
+++ /dev/null
@@ -1,137 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- function isProtectedPracticeDataKey(key) {
- return key === 'practice_records' || key === 'user_stats';
- }
-
- class StorageTransactionContext {
- constructor(storageManager, options = {}) {
- this.storage = storageManager;
- this.createInternalOptions = typeof options.createInternalOptions === 'function'
- ? options.createInternalOptions
- : null;
- this.operations = [];
- this.cache = new Map();
- }
-
- _internalOptions(key) {
- if (this.createInternalOptions) {
- return this.createInternalOptions();
- }
- if (isProtectedPracticeDataKey(key)) {
- throw new Error(`StorageTransactionContext cannot access protected key ${key} without internal storage access`);
- }
- return { skipPracticeCoreRedirect: true };
- }
-
- async get(key, defaultValue) {
- if (this.cache.has(key)) {
- return this.cache.get(key);
- }
- const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
- const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key));
- const finalValue = value === undefined ? resolvedDefault : value;
- this.cache.set(key, finalValue);
- return finalValue;
- }
-
- set(key, value) {
- this.cache.set(key, value);
- this.operations.push({ type: 'set', key, value });
- }
-
- remove(key) {
- this.cache.delete(key);
- this.operations.push({ type: 'remove', key });
- }
-
- async commit() {
- for (const op of this.operations) {
- if (op.type === 'set') {
- await this.storage.set(op.key, op.value, this._internalOptions(op.key));
- } else if (op.type === 'remove') {
- await this.storage.remove(op.key, this._internalOptions(op.key));
- }
- }
- this.operations = [];
- }
-
- async rollback() {
- this.operations = [];
- }
- }
-
- class StorageDataSource {
- constructor(storageManager, options = {}) {
- if (!storageManager) {
- throw new Error('StorageDataSource requires a StorageManager instance');
- }
- this.storage = storageManager;
- this.createInternalOptions = typeof options.createInternalOptions === 'function'
- ? options.createInternalOptions
- : null;
- this._queue = Promise.resolve();
- }
-
- _internalOptions(key) {
- if (this.createInternalOptions) {
- return this.createInternalOptions();
- }
- if (isProtectedPracticeDataKey(key)) {
- throw new Error(`StorageDataSource cannot access protected key ${key} without internal storage access`);
- }
- return { skipPracticeCoreRedirect: true };
- }
-
- async read(key, defaultValue) {
- const resolvedDefault = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
- const value = await this.storage.get(key, resolvedDefault, this._internalOptions(key));
- return value === undefined ? resolvedDefault : value;
- }
-
- async write(key, value) {
- return this._enqueue(async () => {
- await this.storage.set(key, value, this._internalOptions(key));
- return true;
- });
- }
-
- async remove(key) {
- return this._enqueue(async () => {
- await this.storage.remove(key, this._internalOptions(key));
- return true;
- });
- }
-
- async runTransaction(handler, options = {}) {
- if (typeof handler !== 'function') {
- throw new Error('StorageDataSource.runTransaction requires a handler function');
- }
- const label = options.label || 'storage-transaction';
- return this._enqueue(async () => {
- const context = new StorageTransactionContext(this.storage, {
- createInternalOptions: this.createInternalOptions
- });
- try {
- const result = await handler(context);
- await context.commit();
- return result;
- } catch (error) {
- await context.rollback();
- console.error(`[StorageDataSource] Transaction failed (${label}):`, error);
- throw error;
- }
- });
- }
-
- _enqueue(task) {
- const next = this._queue.then(task);
- this._queue = next.catch(() => {});
- return next;
- }
- }
-
- ExamData.StorageTransactionContext = StorageTransactionContext;
- ExamData.StorageDataSource = StorageDataSource;
-})(window);
diff --git a/js/data/index.js b/js/data/index.js
deleted file mode 100644
index 69cfa385..00000000
--- a/js/data/index.js
+++ /dev/null
@@ -1,241 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- function createDefaultUserStats() {
- const now = new Date().toISOString();
- return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- lastPracticeDate: null,
- achievements: [],
- createdAt: now,
- updatedAt: now
- };
- }
-
- function createDefaultVocabConfig() {
- return {
- dailyNew: 20,
- reviewLimit: 100,
- masteryCount: 4,
- theme: 'auto',
- notify: true
- };
- }
-
- function createMetaFacade(metaRepo) {
- function assertAllowedKey(key) {
- if (key === 'user_stats') {
- throw new Error('user_stats must go through PracticeRecordAPI');
- }
- }
-
- return Object.freeze({
- async get(key, defaultValue, options = {}) {
- assertAllowedKey(key);
- return await metaRepo.get(key, defaultValue, options);
- },
- async set(key, value, options = {}) {
- assertAllowedKey(key);
- return await metaRepo.set(key, value, options);
- },
- async remove(key, options = {}) {
- assertAllowedKey(key);
- return await metaRepo.remove(key, options);
- },
- async runConsistencyCheck(keys) {
- const targetKeys = Array.isArray(keys)
- ? keys.filter((key) => key !== 'user_stats')
- : undefined;
- return await metaRepo.runConsistencyCheck(targetKeys);
- }
- });
- }
-
- function bootstrap() {
- if (!window.persistentStore) {
- console.warn('[data/index] StorageManager 未就绪,延迟初始化数据仓库');
- setTimeout(bootstrap, 100);
- return;
- }
-
- if (window.dataRepositories) {
- return;
- }
-
- if (!window.PracticeCore || typeof window.PracticeCore.__installInternalRepositories !== 'function') {
- console.warn('[data/index] PracticeCore internal installer 未就绪,延迟初始化数据仓库');
- setTimeout(bootstrap, 100);
- return;
- }
-
- let createInternalOptions = null;
- if (typeof window.__installStorageInternalAccess === 'function') {
- window.__installStorageInternalAccess((factory) => {
- createInternalOptions = typeof factory === 'function' ? factory : null;
- return Boolean(createInternalOptions);
- });
- }
- if (!createInternalOptions) {
- console.warn('[data/index] Storage internal access 未就绪,延迟初始化数据仓库');
- setTimeout(bootstrap, 100);
- return;
- }
-
- const dataSource = new ExamData.StorageDataSource(window.persistentStore, {
- createInternalOptions
- });
- const registry = new ExamData.DataRepositoryRegistry(dataSource);
-
- const practiceRepo = new ExamData.PracticeRepository(dataSource, { maxRecords: 1000 });
- const settingsRepo = new ExamData.SettingsRepository(dataSource);
- const backupRepo = new ExamData.BackupRepository(dataSource, { maxBackups: 20 });
- const metaRepo = new ExamData.MetaRepository(dataSource, {
- user_stats: {
- defaultValue: createDefaultUserStats,
- validators: [
- (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_stats 必须为对象'
- ]
- },
- storage_version: {
- defaultValue: () => null,
- validators: [
- (value) => value === null || typeof value === 'string' || 'storage_version 必须是字符串或 null'
- ],
- cloneOnRead: false
- },
- data_restored: {
- defaultValue: () => false,
- validators: [
- (value) => typeof value === 'boolean' || 'data_restored 必须是布尔值'
- ],
- cloneOnRead: false
- },
- active_sessions: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'active_sessions 必须为数组'
- ]
- },
- temp_practice_records: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'temp_practice_records 必须为数组'
- ]
- },
- interrupted_records: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'interrupted_records 必须为数组'
- ]
- },
- exam_index: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'exam_index 必须为数组'
- ]
- },
- vocab_words: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'vocab_words 必须为数组'
- ]
- },
- vocab_user_config: {
- defaultValue: createDefaultVocabConfig,
- validators: [
- (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'vocab_user_config 必须为对象'
- ]
- },
- vocab_review_queue: {
- defaultValue: () => [],
- validators: [
- (value) => Array.isArray(value) || 'vocab_review_queue 必须为数组'
- ]
- },
- vocab_list_reading_highlights: {
- defaultValue: () => [],
- validators: [
- (value) => (
- Array.isArray(value)
- || (value && typeof value === 'object' && Array.isArray(value.words))
- ) || 'vocab_list_reading_highlights 必须为数组或词表对象'
- ]
- },
- legacy_practice_records_migrated: {
- defaultValue: () => false,
- validators: [
- (value) => typeof value === 'boolean' || 'legacy_practice_records_migrated 必须为布尔值'
- ],
- cloneOnRead: false
- }
- });
-
- registry.register('practice', practiceRepo);
- registry.register('settings', settingsRepo);
- registry.register('backups', backupRepo);
- registry.register('meta', metaRepo);
-
- const internalApi = {
- get practice() { return practiceRepo; },
- get settings() { return settingsRepo; },
- get backups() { return backupRepo; },
- get meta() { return metaRepo; },
- transaction(names, handler) {
- return registry.transaction(names, handler);
- },
- runConsistencyChecks(names) {
- return registry.runConsistencyChecks(names);
- }
- };
- window.PracticeCore.__installInternalRepositories(internalApi, { createInternalOptions });
- if (window.__installStorageInternalAccess) {
- try {
- delete window.__installStorageInternalAccess;
- } catch (_) {
- window.__installStorageInternalAccess = undefined;
- }
- }
- const metaFacade = createMetaFacade(metaRepo);
- const api = {
- get settings() { return settingsRepo; },
- get backups() { return backupRepo; },
- get meta() { return metaFacade; },
- transaction(names, handler) {
- const targetNames = Array.isArray(names) ? names : [];
- if (targetNames.includes('practice')) {
- throw new Error('practice_records transactions must go through PracticeRecordAPI');
- }
- return registry.transaction(names, handler);
- },
- runConsistencyChecks(names) {
- const targetNames = Array.isArray(names)
- ? names.filter((name) => name !== 'practice')
- : undefined;
- return registry.runConsistencyChecks(targetNames);
- }
- };
- const registryApi = window.StorageProviderRegistry;
- if (registryApi && typeof registryApi.registerStorageProviders === 'function') {
- registryApi.registerStorageProviders({
- repositories: api,
- storageManager: window.storage || null,
- persistentStore: window.persistentStore || null,
- preferenceStore: window.preferenceStore || null
- });
- } else {
- window.dataRepositories = api;
- }
-
- ExamData.registry = registry;
- ExamData.createDefaultUserStats = createDefaultUserStats;
- ExamData.createDefaultVocabConfig = createDefaultVocabConfig;
- console.log('[data/index] 数据仓库初始化完成');
- }
-
- bootstrap();
-})(window);
diff --git a/js/data/practiceRecordSource.js b/js/data/practiceRecordSource.js
new file mode 100644
index 00000000..157980b4
--- /dev/null
+++ b/js/data/practiceRecordSource.js
@@ -0,0 +1,199 @@
+/**
+ * 练习记录来源判定 —— “什么算真实练习记录”的唯一权威定义。
+ *
+ * 背景(本文件存在的理由):
+ * 这条规则历史上被复制成了两套互不相通的实现,语义还不一样:
+ * - UI 侧 js/main.js `updatePracticeView` 只看顶层 `dataSource`;
+ * - 投影器侧 js/data/v2/appData.js `computeStats` / `computeAchievementProgress`
+ * 只看 `metadata.source === 'onboarding-demo'`。
+ * 结果是 `demo` / `e2e-seed` 这类记录“在练习记录页看不见,却计入成绩统计和成就解锁”,
+ * 用户会看到自己没做过的题影响了正确率与成就。
+ *
+ * 因此判定必须只有一份实现,并被所有消费方共享。本文件同时被打进
+ * core-foundation / reading-page / practice-page-enhancer / listening-record-bridge /
+ * listening-wrapper(供 appData.js 的投影器使用)和 browse(供 js/main.js 的渲染过滤使用)
+ * 等 bundle;appData.js 在启动时硬性要求本模块存在,缺失即抛错,杜绝“再退回本地副本”。
+ *
+ * ---------------------------------------------------------------------------
+ * 语义(两个维度,任一命中即判为非真实)
+ *
+ * 1) dataSource(顶层,回退 metadata.dataSource)
+ * - 缺失 / null / 空串 => **真实记录**
+ * - 'real' => 真实记录
+ * - 其它任何显式值 => 非真实(演示 / 种子 / 占位)
+ *
+ * “缺失即真实”是硬性约束,不得收窄:生产代码只在 practiceRecorder / examSessionMixin
+ * 三处写过该字段且都写 'real',套题聚合、听力桥接、legacy 迁移记录从来不写。
+ * 曾经有一版把“没标注”当成“非真实”,直接导致练习记录页整页空白(线上 P0)。
+ *
+ * 2) metadata.source
+ * 只精确匹配已知的演示/种子标记,**绝不做包含匹配**。
+ * 这个字段是被复用的:套题记录会写 'listening' / 'reading'(内容类型标签,见
+ * js/app/suitePracticeMixin.js),消息通道会写 'practice_page' / 'inline_collector'
+ * / 'suite_placeholder' / 'listening_record_bridge' / 'data_collector'(采集方式标签)。
+ * 任何模糊匹配都可能把真实记录判成演示数据,属于同一类 P0。
+ *
+ * 注意:`record.source` 与 `realData.source` 是采集方式标签而非来源标注,故不参与判定。
+ */
+(function initPracticeRecordSource(global) {
+ 'use strict';
+
+ // 同一份源码会被多个 bundle 内联(浏览器里 core-foundation 与 browse 都会执行一次),
+ // 重复赋值本身无害,但仍按仓库惯例做幂等保护,避免任何形态的静默覆盖。
+ if (global.PracticeRecordSource && global.PracticeRecordSource.__stable === true) {
+ return;
+ }
+
+ /** 被认可为“真实用户练习”的显式 dataSource 取值。 */
+ const REAL_DATA_SOURCES = Object.freeze(['real']);
+
+ /**
+ * 被认定为“演示 / 种子 / 夹具数据”的 metadata.source 取值(精确匹配,大小写与首尾空白无关)。
+ * 目前生产代码只会写出 'onboarding-demo'(js/components/onboardingTour.js);
+ * 其余是历史与测试夹具里出现过的等价写法,一并显式列出而不是靠模糊匹配推断。
+ */
+ const DEMO_SOURCE_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo',
+ 'demo',
+ 'e2e-seed',
+ 'e2e_seed'
+ ]);
+
+ /** 只有新手引导自己的 marker 才有资格申请临时历史列表预览。 */
+ const ONBOARDING_PREVIEW_MARKERS = Object.freeze([
+ 'onboarding-demo',
+ 'onboarding_demo',
+ 'onboardingdemo'
+ ]);
+
+ const realDataSourceSet = new Set(REAL_DATA_SOURCES);
+ const demoSourceSet = new Set(DEMO_SOURCE_MARKERS);
+ const onboardingPreviewMarkerSet = new Set(ONBOARDING_PREVIEW_MARKERS);
+
+ function normalize(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim().toLowerCase();
+ }
+
+ function asObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ }
+
+ function hasOwn(object, field) {
+ return Object.prototype.hasOwnProperty.call(object, field);
+ }
+
+ /** 读取记录的来源标注:顶层优先,回退 metadata(light 投影同样走这条回退链)。 */
+ function readDataSource(record) {
+ if (hasOwn(record, 'dataSource')) return normalize(record.dataSource);
+ const metadata = asObject(record.metadata);
+ return hasOwn(metadata, 'dataSource') ? normalize(metadata.dataSource) : '';
+ }
+
+ function readMetadataSource(record) {
+ return normalize(asObject(record.metadata).source);
+ }
+
+ /**
+ * 唯一判定入口:该记录是否算作用户的真实练习。
+ * 练习记录列表渲染、practice.stats 投影、achievements.progress 投影三者必须都用它,
+ * 三处结论一致是本模块的核心契约。
+ */
+ function isRealPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+
+ const dataSource = readDataSource(record);
+ // 缺失/空值一律按真实记录对待(见文件头“缺失即真实”)。
+ if (dataSource !== '' && !realDataSourceSet.has(dataSource)) return false;
+
+ if (demoSourceSet.has(readMetadataSource(record))) return false;
+
+ return true;
+ }
+
+ /** isRealPracticeRecord 的补集,仅对合法记录对象成立(非对象既不真也不演示)。 */
+ function isDemoPracticeRecord(record) {
+ if (!record || typeof record !== 'object') return false;
+ return !isRealPracticeRecord(record);
+ }
+
+ function filterRealPracticeRecords(records) {
+ return (Array.isArray(records) ? records : []).filter(isRealPracticeRecord);
+ }
+
+ // -----------------------------------------------------------------------
+ // 引导预览白名单(仅影响渲染,永不影响统计与成就)
+ //
+ // 新手引导的"回顾模式"步骤会先把一条演示记录写进权威 practice records,
+ // 再等待它在练习记录列表里出现(js/components/onboardingTour.js
+ // `_injectDemoRecord` -> `_waitForSelector`),演示完成后立即删除。
+ //
+ // 这条记录按上面的判定确实是演示数据(metadata.source = 'onboarding-demo'),
+ // 所以它必须继续被 practice.stats / achievements.progress 排除。但引导要教用户
+ // 认识这一行 UI,因此需要一个**显式、按 id 限定、临时**的渲染例外。
+ //
+ // 关键设计:例外只存在于视图层白名单,投影器根本读不到它——
+ // 于是"是否真实"仍然只有一份判定,不会退回"UI 与统计各写一套"的老 bug。
+ // 历史上引导记录之所以能显示,只是因为没人给它写 dataSource(巧合而非设计)。
+ // -----------------------------------------------------------------------
+ const previewRecordIds = new Set();
+
+ function normalizeId(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+ }
+
+ /** 登记一条允许在练习记录列表中预览的演示记录 id(引导步骤开始时调用)。 */
+ function allowPreviewRecordId(recordId) {
+ const id = normalizeId(recordId);
+ if (id) previewRecordIds.add(id);
+ return id !== '';
+ }
+
+ /** 撤销预览许可(引导结束/跳过/清理演示记录时调用)。 */
+ function clearPreviewRecordId(recordId) {
+ if (recordId === undefined) {
+ previewRecordIds.clear();
+ return true;
+ }
+ return previewRecordIds.delete(normalizeId(recordId));
+ }
+
+ function isPreviewRecord(record) {
+ if (!previewRecordIds.size || !record || typeof record !== 'object') return false;
+ if (!onboardingPreviewMarkerSet.has(readMetadataSource(record))) return false;
+ const id = normalizeId(record.id || record.recordId);
+ return Boolean(id && previewRecordIds.has(id));
+ }
+
+ /**
+ * 练习记录列表的渲染过滤:真实记录 + 已显式登记的引导预览记录。
+ * 统计/成就一律用 filterRealPracticeRecords,绝不用这个函数。
+ */
+ function filterRecordsForHistoryView(records) {
+ return (Array.isArray(records) ? records : [])
+ .filter((record) => isRealPracticeRecord(record) || isPreviewRecord(record));
+ }
+
+ const api = Object.freeze({
+ __stable: true,
+ REAL_DATA_SOURCES,
+ DEMO_SOURCE_MARKERS,
+ ONBOARDING_PREVIEW_MARKERS,
+ isRealPracticeRecord,
+ isDemoPracticeRecord,
+ filterRealPracticeRecords,
+ allowPreviewRecordId,
+ clearPreviewRecordId,
+ isPreviewRecord,
+ filterRecordsForHistoryView
+ });
+
+ global.PracticeRecordSource = api;
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = api;
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/data/repositories/backupRepository.js b/js/data/repositories/backupRepository.js
deleted file mode 100644
index 185de712..00000000
--- a/js/data/repositories/backupRepository.js
+++ /dev/null
@@ -1,142 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- function ensureArray(value) {
- return Array.isArray(value) ? value : [];
- }
-
- class BackupRepository extends BaseRepository {
- constructor(dataSource, options = {}) {
- super({
- dataSource,
- key: options.key || 'manual_backups',
- name: options.name || 'manual_backups',
- defaultValue: () => [],
- migrations: [
- (value) => ensureArray(value),
- ...(options.migrations || [])
- ],
- validators: [
- (value) => Array.isArray(value) || 'manual_backups 必须是数组',
- ...(options.validators || [])
- ],
- cloneOnRead: options.cloneOnRead !== false
- });
- this.maxBackups = options.maxBackups || 20;
- }
-
- normalizeBackup(backup) {
- if (!backup || typeof backup !== 'object') {
- throw new Error('备份数据必须是对象');
- }
- const normalized = { ...backup };
- normalized.id = normalized.id ? String(normalized.id) : `backup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
- normalized.timestamp = normalized.timestamp || new Date().toISOString();
- normalized.type = normalized.type || 'manual';
- normalized.version = normalized.version || '0.6.2-fix';
- normalized.data = normalized.data || {};
- normalized.size = normalized.size || JSON.stringify(normalized.data).length;
- return normalized;
- }
-
- validateBackup(backup) {
- const errors = [];
- if (!backup || typeof backup !== 'object') {
- errors.push('备份必须是对象');
- } else {
- if (!backup.id) {
- errors.push('备份缺少 id');
- }
- if (!backup.timestamp) {
- errors.push('备份缺少 timestamp');
- }
- if (!backup.data || typeof backup.data !== 'object') {
- errors.push('备份缺少 data 对象');
- }
- }
- return {
- isValid: errors.length === 0,
- errors
- };
- }
-
- _assertBackup(backup) {
- const validation = this.validateBackup(backup);
- if (!validation.isValid) {
- const error = new Error(`[manual_backups] 备份无效: ${validation.errors.join(', ')}`);
- error.validationErrors = validation.errors;
- throw error;
- }
- return true;
- }
-
- async list(options = {}) {
- return await this.read({ ...options, clone: options.clone !== false });
- }
-
- async add(backup, options = {}) {
- const normalized = this.normalizeBackup(backup);
- this._assertBackup(normalized);
- return this.dataSource.runTransaction(async (tx) => {
- let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- backups.unshift(normalized);
- if (this.maxBackups && backups.length > this.maxBackups) {
- backups = backups.slice(0, this.maxBackups);
- }
- await this.write(backups, { transaction: tx, skipValidation: true, clone: false });
- return normalized;
- }, { label: 'backup-add' });
- }
-
- async saveAll(backups, options = {}) {
- const prepared = ensureArray(backups).map((item) => {
- const normalized = this.normalizeBackup(item);
- this._assertBackup(normalized);
- return normalized;
- });
- await this.write(prepared, { ...options, skipValidation: true });
- return true;
- }
-
- async delete(id, options = {}) {
- if (!id) return false;
- const targetId = String(id);
- return this.dataSource.runTransaction(async (tx) => {
- let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- const next = backups.filter(backup => backup.id !== targetId);
- const deleted = next.length !== backups.length;
- if (deleted) {
- await this.write(next, { transaction: tx, skipValidation: true, clone: false });
- }
- return deleted;
- }, { label: 'backup-delete' });
- }
-
- async getById(id, options = {}) {
- if (!id) return null;
- const backups = await this.read({ ...options, clone: true });
- return backups.find(backup => backup.id === String(id)) || null;
- }
-
- async clear(options = {}) {
- await this.write([], { ...options, skipValidation: true });
- return true;
- }
-
- async prune(limit, options = {}) {
- const max = typeof limit === 'number' && limit > 0 ? limit : this.maxBackups;
- return this.dataSource.runTransaction(async (tx) => {
- let backups = ensureArray(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- if (backups.length <= max) {
- return backups.length;
- }
- const next = backups.slice(0, max);
- await this.write(next, { transaction: tx, skipValidation: true, clone: false });
- return next.length;
- }, { label: 'backup-prune' });
- }
- }
-
- ExamData.BackupRepository = BackupRepository;
-})(window);
diff --git a/js/data/repositories/baseRepository.js b/js/data/repositories/baseRepository.js
deleted file mode 100644
index 222530a1..00000000
--- a/js/data/repositories/baseRepository.js
+++ /dev/null
@@ -1,163 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- function cloneValue(value) {
- if (value === null || value === undefined) {
- return value;
- }
- if (typeof structuredClone === 'function') {
- try {
- return structuredClone(value);
- } catch (_) {
- // Fallback to JSON serialization below
- }
- }
- try {
- return JSON.parse(JSON.stringify(value));
- } catch (_) {
- return value;
- }
- }
-
- class BaseRepository {
- constructor(options) {
- const {
- dataSource,
- key,
- name,
- defaultValue = null,
- migrations = [],
- validators = [],
- cloneOnRead = true
- } = options || {};
-
- if (!dataSource) {
- throw new Error('BaseRepository requires a dataSource instance');
- }
- if (!key) {
- throw new Error('BaseRepository requires a storage key');
- }
-
- this.dataSource = dataSource;
- this.key = key;
- this.name = name || key;
- this.defaultValue = defaultValue;
- this.migrations = Array.isArray(migrations) ? migrations.slice() : [migrations];
- this.validators = Array.isArray(validators) ? validators.slice() : [validators];
- this.cloneOnRead = cloneOnRead;
- }
-
- _resolveDefaultValue(override) {
- const candidate = override !== undefined ? override : this.defaultValue;
- return typeof candidate === 'function' ? candidate() : candidate;
- }
-
- async read(options = {}) {
- const { transaction, defaultValue, skipValidation = false, clone = undefined } = options;
- const resolvedDefault = this._resolveDefaultValue(defaultValue);
- const sourceValue = transaction
- ? await transaction.get(this.key, resolvedDefault)
- : await this.dataSource.read(this.key, resolvedDefault);
-
- let value = sourceValue === undefined ? resolvedDefault : sourceValue;
- value = await this.applyMigrations(value, { transaction });
-
- if (!skipValidation) {
- this.validate(value);
- }
-
- if (clone === false || (!this.cloneOnRead && clone === undefined)) {
- return value;
- }
- return cloneValue(value);
- }
-
- async write(value, options = {}) {
- const { transaction, skipValidation = false, clone = true } = options;
- if (!skipValidation) {
- this.validate(value);
- }
- const dataToPersist = clone ? cloneValue(value) : value;
- if (transaction) {
- transaction.set(this.key, dataToPersist);
- return true;
- }
- await this.dataSource.write(this.key, dataToPersist);
- return true;
- }
-
- async remove(options = {}) {
- const { transaction } = options;
- if (transaction) {
- transaction.remove(this.key);
- return true;
- }
- await this.dataSource.remove(this.key);
- return true;
- }
-
- async applyMigrations(value, context = {}) {
- let current = value;
- for (const migration of this.migrations) {
- if (typeof migration === 'function') {
- current = await migration(current, { key: this.key, name: this.name, ...context });
- }
- }
- return current;
- }
-
- validate(value) {
- const errors = [];
- for (const validator of this.validators) {
- if (typeof validator !== 'function') {
- continue;
- }
- try {
- const result = validator(value);
- if (result === false) {
- errors.push(`${this.name} 数据验证失败`);
- } else if (typeof result === 'string') {
- errors.push(result);
- } else if (result && typeof result === 'object') {
- if (result.valid === false || result.isValid === false) {
- errors.push(result.message || result.error || `${this.name} 数据验证失败`);
- }
- }
- } catch (error) {
- errors.push(error.message || String(error));
- }
- }
- if (errors.length > 0) {
- const err = new Error(`[${this.name}] 数据验证失败: ${errors.join('; ')}`);
- err.validationErrors = errors;
- throw err;
- }
- return true;
- }
-
- async runConsistencyCheck(options = {}) {
- try {
- const data = await this.read({ ...options, skipValidation: false });
- return { valid: true, data, errors: [] };
- } catch (error) {
- const errors = error.validationErrors || [error.message || String(error)];
- return { valid: false, errors };
- }
- }
-
- registerMigration(fn) {
- if (typeof fn === 'function') {
- this.migrations.push(fn);
- }
- }
-
- registerValidator(fn) {
- if (typeof fn === 'function') {
- this.validators.push(fn);
- }
- }
- }
-
- ExamData.cloneValue = cloneValue;
- ExamData.BaseRepository = BaseRepository;
-})(window);
diff --git a/js/data/repositories/dataRepositoryRegistry.js b/js/data/repositories/dataRepositoryRegistry.js
deleted file mode 100644
index 2b24a1ad..00000000
--- a/js/data/repositories/dataRepositoryRegistry.js
+++ /dev/null
@@ -1,68 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
-
- class DataRepositoryRegistry {
- constructor(dataSource) {
- if (!dataSource) {
- throw new Error('DataRepositoryRegistry requires a dataSource instance');
- }
- this.dataSource = dataSource;
- this._repositories = new Map();
- }
-
- register(name, repository) {
- if (!name) {
- throw new Error('Repository name is required');
- }
- if (!repository) {
- throw new Error(`Repository instance missing for ${name}`);
- }
- this._repositories.set(name, repository);
- }
-
- get(name) {
- return this._repositories.get(name);
- }
-
- listNames() {
- return Array.from(this._repositories.keys());
- }
-
- async transaction(names, handler) {
- if (typeof handler !== 'function') {
- throw new Error('transaction handler must be a function');
- }
- const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames();
- return this.dataSource.runTransaction(async (tx) => {
- const scope = {};
- for (const name of targetNames) {
- if (this._repositories.has(name)) {
- scope[name] = this._repositories.get(name);
- }
- }
- return handler(scope, tx);
- }, { label: `registry:${targetNames.join(',')}` });
- }
-
- async runConsistencyChecks(names) {
- const targetNames = Array.isArray(names) && names.length > 0 ? names : this.listNames();
- const report = {};
- for (const name of targetNames) {
- const repo = this._repositories.get(name);
- if (repo && typeof repo.runConsistencyCheck === 'function') {
- try {
- report[name] = await repo.runConsistencyCheck();
- } catch (error) {
- report[name] = {
- valid: false,
- errors: [error.message || String(error)]
- };
- }
- }
- }
- return report;
- }
- }
-
- ExamData.DataRepositoryRegistry = DataRepositoryRegistry;
-})(window);
diff --git a/js/data/repositories/metaRepository.js b/js/data/repositories/metaRepository.js
deleted file mode 100644
index af3d41ab..00000000
--- a/js/data/repositories/metaRepository.js
+++ /dev/null
@@ -1,70 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- class MetaRepository {
- constructor(dataSource, definitions = {}) {
- if (!dataSource) {
- throw new Error('MetaRepository requires a dataSource instance');
- }
- this.dataSource = dataSource;
- this.repositories = new Map();
- Object.entries(definitions).forEach(([key, config]) => {
- this.registerKey(key, config);
- });
- }
-
- registerKey(key, config = {}) {
- const repository = new BaseRepository({
- dataSource: this.dataSource,
- key,
- name: config.name || `meta:${key}`,
- defaultValue: config.defaultValue !== undefined ? config.defaultValue : null,
- migrations: config.migrations || [],
- validators: config.validators || [],
- cloneOnRead: config.cloneOnRead !== false
- });
- this.repositories.set(key, repository);
- return repository;
- }
-
- _getRepo(key) {
- const repo = this.repositories.get(key);
- if (!repo) {
- throw new Error(`MetaRepository 未注册键: ${key}`);
- }
- return repo;
- }
-
- async get(key, defaultValue, options = {}) {
- const repo = this._getRepo(key);
- const resolvedDefault = defaultValue !== undefined ? defaultValue : undefined;
- return repo.read({ ...options, defaultValue: resolvedDefault, clone: options.clone !== false });
- }
-
- async set(key, value, options = {}) {
- const repo = this._getRepo(key);
- await repo.write(value, { ...options, skipValidation: false, clone: options.clone !== false });
- return true;
- }
-
- async remove(key, options = {}) {
- const repo = this._getRepo(key);
- await repo.remove(options);
- return true;
- }
-
- async runConsistencyCheck(keys) {
- const targetKeys = Array.isArray(keys) && keys.length ? keys : Array.from(this.repositories.keys());
- const report = {};
- for (const key of targetKeys) {
- const repo = this.repositories.get(key);
- if (!repo) continue;
- report[key] = await repo.runConsistencyCheck();
- }
- return report;
- }
- }
-
- ExamData.MetaRepository = MetaRepository;
-})(window);
diff --git a/js/data/repositories/practiceRepository.js b/js/data/repositories/practiceRepository.js
deleted file mode 100644
index 10dece85..00000000
--- a/js/data/repositories/practiceRepository.js
+++ /dev/null
@@ -1,206 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- function ensureArray(value) {
- return Array.isArray(value) ? value : [];
- }
-
- class PracticeRepository extends BaseRepository {
- constructor(dataSource, options = {}) {
- super({
- dataSource,
- key: options.key || 'practice_records',
- name: options.name || 'practice_records',
- defaultValue: () => [],
- migrations: [
- (value) => ensureArray(value),
- ...(options.migrations || [])
- ],
- validators: [
- (value) => Array.isArray(value) || 'practice_records 必须为数组',
- ...(options.validators || [])
- ],
- cloneOnRead: options.cloneOnRead !== false
- });
- this.maxRecords = options.maxRecords || 1000;
- }
-
- normalizeRecord(record) {
- if (!record || typeof record !== 'object') {
- throw new Error('practice record 必须是对象');
- }
- const normalized = { ...record };
- if (!normalized.id) {
- normalized.id = `record_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
- } else {
- normalized.id = String(normalized.id);
- }
- return normalized;
- }
-
- validatePracticeRecord(record) {
- const errors = [];
- if (!record || typeof record !== 'object') {
- errors.push('记录必须是对象');
- } else {
- if (!record.id || typeof record.id !== 'string') {
- errors.push('记录缺少有效的 id');
- }
- if (!record.type || typeof record.type !== 'string') {
- errors.push('记录缺少有效的 type');
- }
- if (record.score === undefined || record.score === null || typeof record.score !== 'number') {
- errors.push('记录缺少有效的 score');
- }
- if (record.score !== undefined && typeof record.score !== 'number') {
- errors.push('score 必须是数字');
- }
- if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') {
- errors.push('totalQuestions 必须是数字');
- }
- if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') {
- errors.push('correctAnswers 必须是数字');
- }
- if (record.duration !== undefined && typeof record.duration !== 'number') {
- errors.push('duration 必须是数字');
- }
- if (!record.date) {
- errors.push('记录缺少有效的 date');
- } else if (Number.isNaN(new Date(record.date).getTime())) {
- errors.push('date 格式无效');
- }
- }
- return {
- isValid: errors.length === 0,
- errors
- };
- }
-
- _assertRecord(record) {
- const validation = this.validatePracticeRecord(record);
- if (!validation.isValid) {
- const error = new Error(`[practice_records] 记录无效: ${validation.errors.join(', ')}`);
- error.validationErrors = validation.errors;
- throw error;
- }
- return true;
- }
-
- async list(options = {}) {
- return await this.read({ ...options, clone: options.clone !== false });
- }
-
- async getById(id, options = {}) {
- const records = await this.read({ ...options, clone: true });
- return records.find(r => r.id === id) || null;
- }
-
- async overwrite(records, options = {}) {
- const list = ensureArray(records).map((record) => {
- const normalized = this.normalizeRecord(record);
- this._assertRecord(normalized);
- return normalized;
- });
- await this.write(list, { ...options, skipValidation: true });
- return true;
- }
-
- async upsert(record, options = {}) {
- const normalized = this.normalizeRecord(record);
- this._assertRecord(normalized);
- const merge = options.merge === true;
- return this.dataSource.runTransaction(async (tx) => {
- let records = await this.read({ transaction: tx, skipValidation: true, clone: true });
- records = ensureArray(records);
- const index = records.findIndex(r => r.id === normalized.id);
- if (index >= 0) {
- records[index] = merge ? { ...records[index], ...normalized } : normalized;
- } else {
- records.unshift(normalized);
- }
- if (this.maxRecords && records.length > this.maxRecords) {
- records = records.slice(0, this.maxRecords);
- }
- await this.write(records, { transaction: tx, skipValidation: true, clone: false });
- return normalized;
- }, { label: 'practice-upsert' });
- }
-
- async removeById(id, options = {}) {
- if (!id) return 0;
- const removed = await this.removeByIds([id], options);
- return removed;
- }
-
- async removeByIds(ids, options = {}) {
- const idSet = new Set((ids || []).filter(Boolean).map(String));
- if (idSet.size === 0) {
- return 0;
- }
- return this.dataSource.runTransaction(async (tx) => {
- let records = await this.read({ transaction: tx, skipValidation: true, clone: true });
- records = ensureArray(records);
- const next = records.filter(record => !idSet.has(record.id));
- const removed = records.length - next.length;
- if (removed > 0) {
- await this.write(next, { transaction: tx, skipValidation: true, clone: false });
- }
- return removed;
- }, { label: 'practice-remove' });
- }
-
- async update(id, updates = {}, options = {}) {
- if (!id) {
- throw new Error('update 需要记录 id');
- }
- if (!updates || typeof updates !== 'object') {
- throw new Error('updates 必须是对象');
- }
- return this.dataSource.runTransaction(async (tx) => {
- let records = await this.read({ transaction: tx, skipValidation: true, clone: true });
- records = ensureArray(records);
- const index = records.findIndex(record => record.id === String(id));
- if (index === -1) {
- return null;
- }
- const updated = { ...records[index], ...updates };
- this._assertRecord(updated);
- records[index] = updated;
- await this.write(records, { transaction: tx, skipValidation: true, clone: false });
- return updated;
- }, { label: 'practice-update' });
- }
-
- async count(options = {}) {
- const records = await this.read({ ...options, clone: false, skipValidation: false });
- return Array.isArray(records) ? records.length : 0;
- }
-
- async clear(options = {}) {
- await this.write([], { ...options, skipValidation: true });
- return true;
- }
-
- async runConsistencyCheck(options = {}) {
- const report = await super.runConsistencyCheck(options);
- if (!report.valid) {
- return report;
- }
- const errors = [];
- const records = ensureArray(report.data);
- for (const record of records) {
- const validation = this.validatePracticeRecord(record);
- if (!validation.isValid) {
- errors.push(`记录 ${record && record.id ? record.id : 'unknown'}: ${validation.errors.join(', ')}`);
- }
- }
- if (errors.length > 0) {
- return { valid: false, errors };
- }
- return { valid: true, data: records, errors: [] };
- }
- }
-
- ExamData.PracticeRepository = PracticeRepository;
-})(window);
diff --git a/js/data/repositories/settingsRepository.js b/js/data/repositories/settingsRepository.js
deleted file mode 100644
index 4dc23201..00000000
--- a/js/data/repositories/settingsRepository.js
+++ /dev/null
@@ -1,81 +0,0 @@
-(function(window) {
- const ExamData = window.ExamData = window.ExamData || {};
- const BaseRepository = ExamData.BaseRepository;
-
- function ensureObject(value) {
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
- }
-
- class SettingsRepository extends BaseRepository {
- constructor(dataSource, options = {}) {
- super({
- dataSource,
- key: options.key || 'user_settings',
- name: options.name || 'user_settings',
- defaultValue: () => ({}),
- migrations: [
- (value) => ensureObject(value),
- ...(options.migrations || [])
- ],
- validators: [
- (value) => (value && typeof value === 'object' && !Array.isArray(value)) || 'user_settings 必须是对象',
- ...(options.validators || [])
- ],
- cloneOnRead: options.cloneOnRead !== false
- });
- }
-
- async getAll(options = {}) {
- return await this.read({ ...options, clone: options.clone !== false });
- }
-
- async saveAll(settings, options = {}) {
- const prepared = ensureObject(settings);
- await this.write(prepared, { ...options, skipValidation: false });
- return true;
- }
-
- async get(key, defaultValue = null, options = {}) {
- const settings = await this.read({ ...options, clone: true });
- if (Object.prototype.hasOwnProperty.call(settings, key)) {
- return settings[key];
- }
- return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
- }
-
- async set(key, value, options = {}) {
- return this.merge({ [key]: value }, options);
- }
-
- async merge(patch, options = {}) {
- if (!patch || typeof patch !== 'object') {
- throw new Error('merge 需要对象参数');
- }
- return this.dataSource.runTransaction(async (tx) => {
- const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- const next = { ...current, ...patch };
- await this.write(next, { transaction: tx, skipValidation: false, clone: false });
- return next;
- }, { label: 'settings-merge' });
- }
-
- async removeKey(key, options = {}) {
- return this.dataSource.runTransaction(async (tx) => {
- const current = ensureObject(await this.read({ transaction: tx, skipValidation: true, clone: true }));
- if (!Object.prototype.hasOwnProperty.call(current, key)) {
- return current;
- }
- delete current[key];
- await this.write(current, { transaction: tx, skipValidation: false, clone: false });
- return current;
- }, { label: 'settings-remove-key' });
- }
-
- async clear(options = {}) {
- await this.write({}, { ...options, skipValidation: true });
- return true;
- }
- }
-
- ExamData.SettingsRepository = SettingsRepository;
-})(window);
diff --git a/js/data/v2/appData.js b/js/data/v2/appData.js
new file mode 100644
index 00000000..8523c645
--- /dev/null
+++ b/js/data/v2/appData.js
@@ -0,0 +1,2190 @@
+(function installAppData(global) {
+ 'use strict';
+
+ const internals = global.__AppDataV2Internals;
+ if (!internals || typeof internals.DataKernel !== 'function') {
+ throw new Error('AppData v2 requires DataKernel');
+ }
+ const {
+ DataKernel,
+ AppDataError,
+ catalog,
+ clone,
+ randomId,
+ nowIso,
+ checksum
+ } = internals;
+ const kernel = new DataKernel();
+ const importPlans = new Map();
+ const RECOVERY_KEYS = Object.freeze({
+ activeSession: 'recovery.activeSessions',
+ draft: 'recovery.drafts',
+ interrupted: 'recovery.interrupted',
+ rejectedCompletion: 'recovery.rejectedCompletions'
+ });
+ const PREFERENCE_FIELDS = Object.freeze({
+ theme: 'theme', browse: 'browse', timer: 'timer', suite: 'suite', candidateCode: 'candidateCode',
+ resourceBasePrefix: 'resourceBasePrefix', onboarding: 'onboarding', readingDisplay: 'readingDisplay',
+ threeBackground: 'threeBackground', themePortal: 'themePortal', practiceWidget: 'practiceWidget',
+ consent: 'consent', logConfig: 'logConfig'
+ });
+ const PRACTICE_ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+
+ function asObject(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+ function asArray(value) { return Array.isArray(value) ? value : []; }
+ function idOf(value, fields) {
+ for (const field of fields) {
+ if (value && value[field] !== undefined && value[field] !== null && value[field] !== '') return String(value[field]);
+ }
+ return '';
+ }
+
+ function importedLibraryId(value, options = {}) {
+ const id = value === null || value === undefined ? '' : String(value).trim();
+ if (!id && options.nullable) return null;
+ if (!id) throw new AppDataError('VALIDATION', 'Imported library configuration id is required');
+ if (/^exam_index(?:_|$)/.test(id)) {
+ throw new AppDataError('VALIDATION', 'Unsupported library configuration id');
+ }
+ return id;
+ }
+ function assertObject(value, message) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function assertArray(value, message) {
+ if (!Array.isArray(value)) throw new AppDataError('VALIDATION', message);
+ }
+ function jsonValue(value, label = 'value') {
+ try {
+ const serialized = JSON.stringify(value, (_key, current) => {
+ if (typeof current === 'bigint') return String(current);
+ if (typeof current === 'number' && !Number.isFinite(current)) return null;
+ return current;
+ });
+ if (serialized === undefined) return null;
+ return JSON.parse(serialized);
+ } catch (error) {
+ throw new AppDataError('VALIDATION', `${label} must be JSON-serializable`, { cause: error && error.message });
+ }
+ }
+ function operationId(command, prefix, semanticPayload = command) {
+ const id = command && command.operationId ? String(command.operationId) : randomId(prefix);
+ jsonValue(semanticPayload, `${prefix} payload`);
+ return id;
+ }
+ function mutationOptions(command, prefix, semanticPayload, extra = {}) {
+ const source = asObject(command);
+ const payload = jsonValue(semanticPayload, `${prefix} payload`);
+ const intent = { command: prefix, payload };
+ if (Object.prototype.hasOwnProperty.call(source, 'expectedRevision')) {
+ intent.expectedRevision = source.expectedRevision;
+ }
+ return Object.assign({}, extra, {
+ operationId: operationId(source, prefix, payload),
+ intent
+ });
+ }
+ function optionsMutationOptions(options, prefix, semanticPayload, extra = {}) {
+ return mutationOptions(asObject(options), prefix, semanticPayload, extra);
+ }
+ function deterministicEntityId(prefix, operation) {
+ return `${prefix}_${checksum({ operationId: String(operation) }).replace(/[^a-z0-9]+/gi, '')}`;
+ }
+ function normalizeAccuracyRatio(value, label = 'accuracy') {
+ if (value === undefined || value === null || value === '') return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0 || numeric > 100) {
+ throw new AppDataError('VALIDATION', `${label} must be between 0 and 100`);
+ }
+ return numeric > 1 ? numeric / 100 : numeric;
+ }
+ function defaultStats() {
+ return {
+ totalPractices: 0, totalQuestions: 0, correctAnswers: 0, averageAccuracy: 0,
+ reading: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ listening: { practices: 0, questions: 0, correct: 0, accuracy: 0 },
+ lastUpdated: nowIso()
+ };
+ }
+
+ function firstNonNegative(...values) {
+ for (const value of values) {
+ if (value === null || value === undefined || value === '' || typeof value === 'object') continue;
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return null;
+ }
+
+ function normalizePracticeScore(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const legacyScoreInfo = asObject(asObject(record.realData).scoreInfo);
+ const overloadedAnswers = record.correctAnswers;
+ if (overloadedAnswers && typeof overloadedAnswers === 'object') {
+ record.correctAnswerMap = Object.assign(
+ {},
+ clone(asObject(overloadedAnswers)),
+ clone(asObject(record.correctAnswerMap))
+ );
+ }
+ const correct = firstNonNegative(
+ overloadedAnswers,
+ record.correctAnswersCount,
+ scoreInfo.correctAnswers,
+ scoreInfo.correct,
+ legacyScoreInfo.correctAnswers,
+ legacyScoreInfo.correct
+ );
+ if (correct !== null) record.correctAnswers = correct;
+ else if (overloadedAnswers && typeof overloadedAnswers === 'object') record.correctAnswers = 0;
+ const total = firstNonNegative(
+ record.totalQuestions,
+ record.questionCount,
+ scoreInfo.totalQuestions,
+ scoreInfo.total,
+ legacyScoreInfo.totalQuestions,
+ legacyScoreInfo.total
+ );
+ if (total !== null) record.totalQuestions = total;
+ }
+
+ function mergeAnswers(target, source) {
+ if (Array.isArray(source)) {
+ source.forEach((item, index) => {
+ if (!item || typeof item !== 'object') return;
+ const questionId = idOf(item, ['questionId', 'questionNumber', 'id', 'number']) || String(index + 1);
+ const answer = item.answer ?? item.value ?? item.userAnswer ?? item.selectedAnswer;
+ if (answer !== undefined) target[questionId] = clone(answer);
+ });
+ return;
+ }
+ for (const [questionId, answer] of Object.entries(asObject(source))) {
+ target[String(questionId)] = clone(answer);
+ }
+ }
+
+ function normalizePracticeAnswers(record) {
+ const answers = {};
+ const raw = asObject(record.rawData);
+ const rawReal = asObject(raw.realData);
+ const real = asObject(record.realData);
+ for (const source of [
+ rawReal.answerMap, rawReal.answerList, rawReal.answers,
+ raw.answerMap, raw.answerList, raw.answers,
+ real.answerMap, real.answerList, real.answers,
+ record.answerMap, record.answerList, record.answers
+ ]) mergeAnswers(answers, source);
+ if (Object.keys(answers).length) record.answers = answers;
+ }
+
+ function questionTypeErrorCounts(source) {
+ const counts = {};
+ const add = (type, count = 1) => {
+ const key = String(type || '').trim();
+ if (key && count > 0) counts[key] = (counts[key] || 0) + count;
+ };
+ for (const [type, value] of Object.entries(asObject(source && source.questionTypePerformance))) {
+ const metrics = asObject(value);
+ const total = firstNonNegative(metrics.totalQuestions, metrics.total);
+ const correct = firstNonNegative(metrics.correctAnswers, metrics.correct);
+ if (total !== null && correct !== null) add(type, Math.max(0, total - correct));
+ }
+ for (const detail of Object.values(asObject(asObject(source && source.scoreInfo).details))) {
+ if (detail && detail.isCorrect === false) add(detail.questionType || detail.type);
+ }
+ return counts;
+ }
+
+ function canonicalizeRecord(input) {
+ assertObject(input, 'practice record must be an object');
+ const record = jsonValue(input, 'practice record');
+ record.id = idOf(record, ['id', 'recordId', 'sessionId']) || randomId('record');
+ record.sessionId = idOf(record, ['sessionId']) || record.id;
+ record.timestamp = record.timestamp || record.completedAt || record.date || nowIso();
+ record.completedAt = record.completedAt || record.timestamp;
+ record.type = record.type || record.examType || (record.metadata && record.metadata.type) || 'practice';
+ record.metadata = asObject(record.metadata);
+ if (!record.metadata.examId && record.examId) record.metadata.examId = record.examId;
+ if (!record.examId && record.metadata.examId) record.examId = record.metadata.examId;
+ normalizePracticeAnswers(record);
+ normalizePracticeScore(record);
+ for (const field of ['duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'totalScore']) {
+ if (record[field] === undefined || record[field] === null || record[field] === '') continue;
+ const numeric = Number(record[field]);
+ if (!Number.isFinite(numeric) || numeric < 0) throw new AppDataError('VALIDATION', `practice record ${field} must be a non-negative number`);
+ record[field] = numeric;
+ }
+ if (record.accuracy !== undefined) record.accuracy = normalizeAccuracyRatio(record.accuracy, 'practice record accuracy');
+ return jsonValue(record, 'canonical practice record');
+ }
+
+ function lightSuiteEntry(source, fallbackType = null) {
+ const entry = asObject(source);
+ const scoreInfo = asObject(entry.scoreInfo);
+ const realScoreInfo = asObject(asObject(entry.realData).scoreInfo);
+ const metadata = asObject(entry.metadata);
+ const totalQuestions = firstNonNegative(entry.totalQuestions, scoreInfo.totalQuestions, scoreInfo.total, realScoreInfo.totalQuestions, realScoreInfo.total) ?? 0;
+ const correctAnswers = firstNonNegative(entry.correctAnswers, scoreInfo.correctAnswers, scoreInfo.correct, realScoreInfo.correctAnswers, realScoreInfo.correct) ?? 0;
+ const explicitAccuracy = entry.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'suite entry accuracy'
+ ) || 0;
+ const percentage = Number(entry.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0;
+ return jsonValue({
+ id: entry.id || null,
+ sessionId: entry.sessionId || null,
+ examId: entry.examId || metadata.examId || null,
+ title: entry.title || entry.examTitle || metadata.examTitle || metadata.title || '',
+ type: entry.type || metadata.type || fallbackType,
+ date: entry.date || entry.completedAt || entry.timestamp || null,
+ duration: Number(entry.duration ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage,
+ questionTypeErrorCounts: questionTypeErrorCounts(entry)
+ }, 'suite entry light projection');
+ }
+
+ function lightFromCanonical(source) {
+ const scoreInfo = asObject(source.scoreInfo);
+ const realScoreInfo = asObject(asObject(source.realData).scoreInfo);
+ const metadata = asObject(source.metadata);
+ const hasOwn = (object, field) => Object.prototype.hasOwnProperty.call(object, field);
+ const dataSource = hasOwn(source, 'dataSource')
+ ? source.dataSource
+ : (hasOwn(metadata, 'dataSource') ? metadata.dataSource : undefined);
+ const totalQuestions = Number(source.totalQuestions ?? scoreInfo.totalQuestions ?? scoreInfo.total ?? realScoreInfo.totalQuestions ?? realScoreInfo.total ?? 0) || 0;
+ const correctAnswers = Number(source.correctAnswers ?? scoreInfo.correctAnswers ?? scoreInfo.correct ?? realScoreInfo.correctAnswers ?? realScoreInfo.correct ?? 0) || 0;
+ const explicitAccuracy = source.accuracy ?? scoreInfo.accuracy ?? realScoreInfo.accuracy;
+ const accuracy = normalizeAccuracyRatio(
+ explicitAccuracy === undefined && totalQuestions > 0 ? correctAnswers / totalQuestions : (explicitAccuracy ?? 0),
+ 'practice light accuracy'
+ ) || 0;
+ return jsonValue({
+ id: source.id,
+ sessionId: source.sessionId,
+ examId: source.examId || source.metadata.examId || null,
+ title: source.title || source.examTitle || (source.metadata && source.metadata.examTitle) || source.metadata.title || '',
+ type: source.type,
+ mode: source.mode || source.practiceMode || null,
+ timestamp: source.timestamp,
+ completedAt: source.completedAt,
+ date: source.date || source.completedAt || source.timestamp || null,
+ startTime: source.startTime || null,
+ endTime: source.endTime || null,
+ duration: Number(source.duration ?? source.durationSeconds ?? scoreInfo.duration ?? realScoreInfo.duration ?? 0) || 0,
+ totalQuestions,
+ correctAnswers,
+ accuracy,
+ percentage: Number(source.percentage ?? scoreInfo.percentage ?? realScoreInfo.percentage ?? (accuracy * 100)) || 0,
+ score: source.score ?? scoreInfo.score ?? realScoreInfo.score ?? null,
+ questionTypeErrorCounts: questionTypeErrorCounts(source),
+ // 缺失时必须留空而不是写 null:消费方按 `dataSource === 'real' || === undefined`
+ // 过滤记录(js/main.js updatePracticeView),null 两者都不匹配会让记录整条消失。
+ // jsonValue 走 JSON.stringify,undefined 字段会被丢弃,读取时即为 undefined。
+ dataSource,
+ // Summaries are list indexes. Keep only the metadata needed to filter, show a
+ // source label, or locate the originating library; details stay in their entity.
+ metadata: Object.fromEntries([
+ // `source` must stay: PracticeRecordSource uses metadata.source demo markers
+ // (e.g. onboarding-demo) so light/stats/achievements stay consistent with full.
+ 'examId', 'examTitle', 'title', 'type', 'category', 'frequency',
+ 'dataSource', 'source', 'libraryConfigurationId'
+ ].filter((field) => hasOwn(metadata, field)).map((field) => [field, clone(metadata[field])])),
+ suite: source.suite == null ? null : clone(asObject(source.suite)),
+ suiteEntrySummaries: asArray(source.suiteEntries).map((entry) => lightSuiteEntry(
+ entry,
+ String(source.type || '').replace(/-suite$/, '') || null
+ ))
+ }, 'practice light projection');
+ }
+
+ function projectLight(record) {
+ if (!record) return null;
+ return lightFromCanonical(canonicalizeRecord(record));
+ }
+
+ function firstNonEmpty(...values) {
+ let first;
+ for (const value of values) {
+ if (value === undefined || value === null) continue;
+ if (first === undefined) first = value;
+ if (Array.isArray(value) && value.length) return clone(value);
+ if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length) return clone(value);
+ if (typeof value !== 'object') return clone(value);
+ }
+ return first === undefined ? {} : clone(first);
+ }
+
+ const SUMMARY_FIELDS = new Set(['id', 'sessionId', 'examId', 'title', 'type', 'mode', 'timestamp', 'completedAt', 'date', 'startTime', 'endTime', 'duration', 'totalQuestions', 'correctAnswers', 'accuracy', 'percentage', 'score', 'questionTypeErrorCounts', 'dataSource', 'metadata', 'suite', 'suiteEntrySummaries']);
+ const ANNOTATION_FIELDS = new Set(['markedQuestions', 'highlights', 'notes', 'noteOutlines', 'noteText', 'scrollY', 'interactions', 'annotations']);
+
+ function withoutRawData(value) {
+ if (Array.isArray(value)) return value.map(withoutRawData);
+ if (!value || typeof value !== 'object') return clone(value);
+ const clean = {};
+ for (const [key, item] of Object.entries(value)) {
+ if (key !== 'realData' && key !== 'rawData') clean[key] = withoutRawData(item);
+ }
+ return clean;
+ }
+
+ function splitPracticeRecord(input) {
+ const source = canonicalizeRecord(input);
+ const summary = lightFromCanonical(source);
+ const detail = { recordId: source.id };
+ const annotations = { recordId: source.id };
+ for (const [key, value] of Object.entries(source)) {
+ if (key === 'realData' || key === 'rawData' || key === 'answerMap' || key === 'answerList' || SUMMARY_FIELDS.has(key)) continue;
+ if (ANNOTATION_FIELDS.has(key)) annotations[key] = withoutRawData(value);
+ else if (key === 'suiteEntries') detail.suiteEntries = asArray(value).map((entry) => {
+ const next = Object.assign({}, asObject(entry));
+ const replaySource = Object.assign({}, asObject(next.rawData), asObject(next.realData));
+ for (const replayKey of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(next, replayKey) && hasOwn(replaySource, replayKey)) next[replayKey] = clone(replaySource[replayKey]);
+ }
+ const annotation = {};
+ for (const annotationKey of ANNOTATION_FIELDS) {
+ if (hasOwn(next, annotationKey)) { annotation[annotationKey] = next[annotationKey]; delete next[annotationKey]; }
+ if (next.realData && hasOwn(next.realData, annotationKey)) delete next.realData[annotationKey];
+ if (next.rawData && hasOwn(next.rawData, annotationKey)) delete next.rawData[annotationKey];
+ }
+ delete next.realData; delete next.rawData;
+ if (Object.keys(annotation).length) {
+ if (!annotations.suiteEntries) annotations.suiteEntries = {};
+ annotations.suiteEntries[String(next.examId || asObject(next.metadata).examId || next.id || Object.keys(annotations.suiteEntries).length)] = annotation;
+ }
+ return withoutRawData(next);
+ });
+ else detail[key] = withoutRawData(value);
+ }
+ // Accept the old mirror only as an input normalization boundary; it is never persisted.
+ const realData = asObject(source.realData); const rawData = asObject(source.rawData);
+ for (const key of ['answers', 'correctAnswerMap', 'answerComparison', 'answerDetails', 'scoreInfo', 'questionTypePerformance']) {
+ if (!hasOwn(detail, key)) detail[key] = firstNonEmpty(source[key], realData[key], rawData[key]);
+ }
+ for (const key of ANNOTATION_FIELDS) {
+ if (hasOwn(annotations, key)) continue;
+ if (hasOwn(realData, key)) annotations[key] = withoutRawData(realData[key]);
+ else if (hasOwn(rawData, key)) annotations[key] = withoutRawData(rawData[key]);
+ }
+ return { summary: jsonValue(summary, 'practice summary'), detail: jsonValue(detail, 'practice detail'), annotations: jsonValue(annotations, 'practice annotations') };
+ }
+
+ function joinPracticeRecord(summary, detail, annotations, projection = 'full') {
+ if (!summary) return null;
+ const mode = String(projection || 'full').toLowerCase();
+ const light = clone(summary);
+ if (mode === 'light' || mode === 'summary') return light;
+ const joined = Object.assign({}, light, clone(asObject(detail)));
+ delete joined.recordId;
+ if (mode === 'detail' || mode === 'medium') return jsonValue(joined, 'practice detail projection');
+ const annotationData = asObject(annotations);
+ for (const [key, value] of Object.entries(annotationData)) if (key !== 'recordId' && key !== 'suiteEntries') joined[key] = clone(value);
+ if (Array.isArray(joined.suiteEntries)) {
+ const suiteAnnotations = asObject(annotationData.suiteEntries);
+ joined.suiteEntries = joined.suiteEntries.map((entry) => Object.assign({}, entry, clone(suiteAnnotations[String(entry.examId || asObject(entry.metadata).examId || entry.id)] || {})));
+ }
+ return jsonValue(joined, 'practice full projection');
+ }
+
+ function projectDetail(record) { return joinPracticeRecord(splitPracticeRecord(record).summary, splitPracticeRecord(record).detail, null, 'detail'); }
+
+ // “什么算真实练习记录”只有一份定义(js/data/practiceRecordSource.js)。
+ // 这里必须硬性依赖而不是本地兜底:曾经投影器与 js/main.js 各写一套判定,
+ // 导致演示/种子记录在列表里看不见却计入统计与成就。缺失即启动失败,
+ // 让漏配 bundle 在开发期就暴露,而不是运行时静默退回旧语义。
+ const practiceRecordSource = global.PracticeRecordSource;
+ if (!practiceRecordSource || typeof practiceRecordSource.isRealPracticeRecord !== 'function') {
+ throw new Error('AppData v2 requires PracticeRecordSource (js/data/practiceRecordSource.js)');
+ }
+ const isRealPracticeRecord = practiceRecordSource.isRealPracticeRecord;
+
+ function computeStats(records) {
+ const stats = defaultStats();
+ for (const record of asArray(records).filter(isRealPracticeRecord)) {
+ const summary = projectLight(record);
+ const type = String(summary.type || '').toLowerCase();
+ const target = type.includes('listen') ? stats.listening : stats.reading;
+ stats.totalPractices += 1;
+ stats.totalQuestions += summary.totalQuestions;
+ stats.correctAnswers += summary.correctAnswers;
+ target.practices += 1;
+ target.questions += summary.totalQuestions;
+ target.correct += summary.correctAnswers;
+ }
+ stats.averageAccuracy = stats.totalQuestions ? (stats.correctAnswers / stats.totalQuestions) * 100 : 0;
+ for (const target of [stats.reading, stats.listening]) target.accuracy = target.questions ? (target.correct / target.questions) * 100 : 0;
+ stats.lastUpdated = nowIso();
+ return stats;
+ }
+
+ function validIso(value) {
+ if (value === null || value === undefined || value === '') return null;
+ const time = new Date(value).getTime();
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function practiceType(record) {
+ const metadata = asObject(record.metadata);
+ const hints = [record.type, record.practiceType, metadata.type, metadata.examType, metadata.practiceType,
+ record.examId, record.title, metadata.examId, metadata.title].filter(Boolean).join(' ').toLowerCase();
+ if (hints.includes('listen') || hints.includes('audio') || hints.includes('hearing')) return 'listening';
+ if (hints.includes('read')) return 'reading';
+ return null;
+ }
+
+ function accuracyRatio(record) {
+ const summary = lightFromCanonical(record);
+ const value = Number(summary.accuracy);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(1, value > 1 ? value / 100 : value));
+ }
+
+ function durationSeconds(record) {
+ const scoreInfo = asObject(record.scoreInfo);
+ const realData = asObject(record.realData);
+ const realScoreInfo = asObject(realData.scoreInfo);
+ for (const value of [record.duration, realData.duration, scoreInfo.duration, scoreInfo.timeSpent, realScoreInfo.duration, realScoreInfo.timeSpent]) {
+ const numeric = Number(value);
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric;
+ }
+ return 0;
+ }
+
+ function earlierUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() <= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function laterUnlock(left, right) {
+ const leftIso = validIso(left);
+ const rightIso = validIso(right);
+ if (!leftIso) return rightIso;
+ if (!rightIso) return leftIso;
+ return new Date(leftIso).getTime() >= new Date(rightIso).getTime() ? leftIso : rightIso;
+ }
+
+ function computeAchievementProgress(records, manual, existing) {
+ const items = asArray(records).filter(isRealPracticeRecord).map(canonicalizeRecord)
+ .map((record, index) => ({
+ record,
+ index,
+ unlockedAt: validIso(record.completedAt || record.timestamp),
+ time: new Date(record.completedAt || record.timestamp).getTime()
+ }))
+ .sort((left, right) => {
+ const leftTime = Number.isFinite(left.time) ? left.time : Number.MAX_SAFE_INTEGER;
+ const rightTime = Number.isFinite(right.time) ? right.time : Number.MAX_SAFE_INTEGER;
+ return leftTime - rightTime || left.index - right.index;
+ });
+ const candidates = {};
+ const setThreshold = (id, list, count) => {
+ if (list.length >= count) candidates[id] = list[count - 1].unlockedAt;
+ };
+ setThreshold('first_step', items, 1);
+ setThreshold('practice_bronze', items, 10);
+ setThreshold('practice_silver', items, 50);
+ setThreshold('practice_gold', items, 100);
+ setThreshold('practice_platinum', items, 200);
+
+ const reading = items.filter((item) => practiceType(item.record) === 'reading');
+ const listening = items.filter((item) => practiceType(item.record) === 'listening');
+ setThreshold('reading_first', reading, 1);
+ setThreshold('reading_bronze', reading, 10);
+ setThreshold('reading_silver', reading, 50);
+ setThreshold('reading_gold', reading, 100);
+ setThreshold('listening_first', listening, 1);
+ setThreshold('listening_bronze', listening, 10);
+ setThreshold('listening_silver', listening, 50);
+ setThreshold('listening_gold', listening, 100);
+ if (reading.length >= 10 && listening.length >= 10) candidates.balanced_foundation = laterUnlock(reading[9].unlockedAt, listening[9].unlockedAt);
+ if (reading.length >= 30 && listening.length >= 30) candidates.balanced_advanced = laterUnlock(reading[29].unlockedAt, listening[29].unlockedAt);
+
+ let cumulativeDuration = 0;
+ let cumulativeAccuracy = 0;
+ let perfectCount = 0;
+ let speedCount = 0;
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ const accuracy = accuracyRatio(item.record);
+ const duration = durationSeconds(item.record);
+ cumulativeDuration += duration;
+ cumulativeAccuracy += accuracy;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_60') && cumulativeDuration >= 3600) candidates.time_focus_60 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_300') && cumulativeDuration >= 18000) candidates.time_focus_300 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'time_focus_1000') && cumulativeDuration >= 60000) candidates.time_focus_1000 = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_stable') && index + 1 >= 10 && cumulativeAccuracy / (index + 1) >= 0.7) candidates.accuracy_stable = item.unlockedAt;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_elite') && index + 1 >= 20 && cumulativeAccuracy / (index + 1) >= 0.85) candidates.accuracy_elite = item.unlockedAt;
+ if (accuracy >= 1) {
+ perfectCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'accuracy_perfect')) candidates.accuracy_perfect = item.unlockedAt;
+ if (perfectCount === 3) candidates.perfect_three = item.unlockedAt;
+ if (perfectCount === 10) candidates.perfect_ten = item.unlockedAt;
+ }
+ if (duration > 0 && duration <= 300 && accuracy > 0.8) {
+ speedCount += 1;
+ if (!Object.prototype.hasOwnProperty.call(candidates, 'speed_demon')) candidates.speed_demon = item.unlockedAt;
+ if (speedCount === 3) candidates.speed_three = item.unlockedAt;
+ if (speedCount === 10) candidates.speed_ten = item.unlockedAt;
+ }
+ }
+
+ const dayItems = new Map();
+ for (const item of items) {
+ if (!item.unlockedAt) continue;
+ const day = item.unlockedAt.slice(0, 10);
+ if (!dayItems.has(day)) dayItems.set(day, item.unlockedAt);
+ }
+ const days = Array.from(dayItems.keys()).sort();
+ let streak = 0;
+ let previousDay = null;
+ for (const day of days) {
+ const currentDay = new Date(`${day}T00:00:00.000Z`).getTime();
+ streak = previousDay !== null && currentDay - previousDay === 86400000 ? streak + 1 : 1;
+ previousDay = currentDay;
+ if (streak === 3 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_bronze')) candidates.streak_bronze = dayItems.get(day);
+ if (streak === 7 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_silver')) candidates.streak_silver = dayItems.get(day);
+ if (streak === 30 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_gold')) candidates.streak_gold = dayItems.get(day);
+ if (streak === 60 && !Object.prototype.hasOwnProperty.call(candidates, 'streak_platinum')) candidates.streak_platinum = dayItems.get(day);
+ }
+
+ const progress = {};
+ const mergeUnlocked = (source) => {
+ for (const [rawId, value] of Object.entries(asObject(source))) {
+ if (!value || rawId === 'updatedAt') continue;
+ const id = rawId;
+ const unlockedAt = value && typeof value === 'object' ? validIso(value.unlockedAt) : null;
+ if (!progress[id]) progress[id] = { unlockedAt };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ };
+ mergeUnlocked(existing);
+ mergeUnlocked(manual);
+ for (const [id, unlockedAt] of Object.entries(candidates)) {
+ if (!progress[id]) progress[id] = { unlockedAt: validIso(unlockedAt) };
+ else progress[id].unlockedAt = earlierUnlock(progress[id].unlockedAt, unlockedAt);
+ }
+ return jsonValue(progress, 'achievement progress');
+ }
+
+ // Entity records are authoritative. Projections are assembled on reads, never cached or
+ // scheduled as follow-up work; this keeps a successful write immediately observable.
+ async function retryMergeConflict(options, task, maxAttempts = 3) {
+ const explicitRevision = hasOwn(options, 'expectedRevision');
+ let lastError;
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ try {
+ return await task();
+ } catch (error) {
+ lastError = error;
+ if (explicitRevision || !error || error.code !== 'CONFLICT' || attempt + 1 >= maxAttempts) {
+ throw error;
+ }
+ }
+ }
+ throw lastError;
+ }
+
+ async function readCollectionMeta(logicalKey) {
+ const meta = await kernel.read(logicalKey, { withMeta: true });
+ return { items: asArray(meta.data), revision: meta.envelope ? Number(meta.envelope.revision) : 0 };
+ }
+
+ function retainBackupEntries(items, limit = 20, preserveIds = []) {
+ const cap = Math.max(1, Number(limit) || 20);
+ const newestFirst = (left, right) => String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
+ const entries = asArray(items).filter(Boolean).sort(newestFirst);
+ const retained = [];
+ const retainedIds = new Set();
+ const requestedIds = new Set(asArray(preserveIds).map(String).filter(Boolean));
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap || retainedIds.has(id) || !requestedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ for (const item of entries) {
+ const id = String(item.id);
+ if (retained.length >= cap) break;
+ if (retainedIds.has(id)) continue;
+ retained.push(item);
+ retainedIds.add(id);
+ }
+ return retained;
+ }
+
+ function hasOwn(value, key) {
+ return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
+ }
+
+ function normalizeLibraryConfigurationId(value) {
+ return importedLibraryId(value, { nullable: true });
+ }
+
+ async function practiceRecordWithLibraryProvenance(source, command, options = {}) {
+ assertObject(source, 'practice record must be an object');
+ const record = jsonValue(source, 'practice record');
+ const metadata = asObject(record.metadata);
+ let configurationId;
+
+ if (hasOwn(command, 'libraryConfigurationId')) {
+ configurationId = command.libraryConfigurationId;
+ } else if (hasOwn(metadata, 'libraryConfigurationId')) {
+ configurationId = metadata.libraryConfigurationId;
+ } else if (hasOwn(record, 'libraryConfigurationId')) {
+ configurationId = record.libraryConfigurationId;
+ } else {
+ configurationId = await kernel.read('library.activeConfigurationId');
+ }
+
+ const normalizedId = normalizeLibraryConfigurationId(configurationId);
+ record.metadata = Object.assign({}, metadata, { libraryConfigurationId: normalizedId });
+
+ if (options.includeSuiteEntries && Array.isArray(record.suiteEntries)) {
+ record.suiteEntries = record.suiteEntries.map((entry) => {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry;
+ const next = jsonValue(entry, 'practice suite entry');
+ const entryMetadata = asObject(next.metadata);
+ const entryId = hasOwn(entryMetadata, 'libraryConfigurationId')
+ ? normalizeLibraryConfigurationId(entryMetadata.libraryConfigurationId)
+ : normalizedId;
+ next.metadata = Object.assign({}, entryMetadata, { libraryConfigurationId: entryId });
+ return next;
+ });
+ }
+
+ return record;
+ }
+
+ function practiceRecordMatches(record, identities) {
+ const expected = new Set(asArray(identities).map((value) => String(value || '')).filter(Boolean));
+ if (!expected.size || !record || typeof record !== 'object') return false;
+ return ['id', 'recordId', 'sessionId'].some((field) => {
+ const value = record[field];
+ return value !== undefined && value !== null && expected.has(String(value));
+ });
+ }
+
+ function practiceLayerId(row) {
+ return String(row && (row.recordId || row.id || row.sessionId) || '');
+ }
+ async function practiceLayers(recordId, withMeta = false) {
+ const snapshot = await kernel.readPracticeSnapshot([recordId], { withMeta });
+ const find = (store) => asArray(snapshot[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ return { summary: find('practiceSummaries'), detail: find('practiceDetails'), annotations: find('practiceAnnotations') };
+ }
+ function entityRevision(row) { return row ? Number(row.revision) : 0; }
+ function practiceUpserts(recordId, layers, existing = {}) {
+ return [
+ { type: 'upsert', store: 'practiceSummaries', recordId, data: layers.summary, expectedRevision: entityRevision(existing.summary) },
+ { type: 'upsert', store: 'practiceDetails', recordId, data: layers.detail, expectedRevision: entityRevision(existing.detail) },
+ { type: 'upsert', store: 'practiceAnnotations', recordId, data: layers.annotations, expectedRevision: entityRevision(existing.annotations) }
+ ];
+ }
+ async function joinedPractice(recordId, projection, snapshot = null) {
+ const mode = String(projection || 'full').toLowerCase();
+ const stores = mode === 'light' || mode === 'summary'
+ ? ['practiceSummaries']
+ : (mode === 'detail' || mode === 'medium' ? ['practiceSummaries', 'practiceDetails'] : undefined);
+ const layers = snapshot || await kernel.readPracticeSnapshot([recordId], { stores });
+ const find = (store) => asArray(layers[store]).find((row) => practiceLayerId(row) === String(recordId)) || null;
+ const summary = find('practiceSummaries');
+ if (!summary) return null;
+ if (mode === 'light' || mode === 'summary') return clone(summary);
+ const detail = find('practiceDetails');
+ if (mode === 'detail' || mode === 'medium') return joinPracticeRecord(summary, detail, null, mode);
+ return joinPracticeRecord(summary, detail, find('practiceAnnotations'), mode);
+ }
+ const practice = Object.freeze({
+ async list(options = {}) {
+ await ready;
+ const projection = String(options.projection || 'full').toLowerCase();
+ const summaries = await kernel.listEntities('practiceSummaries');
+ if (projection === 'light' || projection === 'summary') return summaries;
+ const stores = projection === 'detail' || projection === 'medium'
+ ? ['practiceSummaries', 'practiceDetails']
+ : undefined;
+ const snapshot = await kernel.readPracticeSnapshot(null, { stores });
+ return (await Promise.all(asArray(snapshot.practiceSummaries)
+ .map((summary) => joinedPractice(practiceLayerId(summary), projection, snapshot)))).filter(Boolean);
+ },
+ async get(recordId, options = {}) { await ready; return joinedPractice(String(recordId || ''), options.projection || 'full'); },
+ async completeAttempt(command) {
+ await ready;
+ const source = command && (command.record || command.attempt) ? (command.record || command.attempt) : command;
+ const mutation = mutationOptions(command, 'practice-complete', source);
+ const recordInput = await practiceRecordWithLibraryProvenance(source, command);
+ if (!idOf(recordInput, ['id', 'recordId', 'sessionId'])) recordInput.id = deterministicEntityId('record', mutation.operationId);
+ const layers = splitPracticeRecord(recordInput); const recordId = layers.summary.id;
+ const receipt = await retryMergeConflict(command || {}, async () => kernel.mutateEntities(
+ practiceUpserts(recordId, layers, await practiceLayers(recordId, true)), mutation));
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async finalizeSuite(command) {
+ await ready; assertObject(command, 'finalizeSuite command is required');
+ const mutation = mutationOptions(command, 'practice-suite', command);
+ const input = await practiceRecordWithLibraryProvenance(command.record || command.aggregate || command, command, { includeSuiteEntries: true });
+ if (!idOf(input, ['id', 'recordId', 'sessionId'])) input.id = deterministicEntityId('suite', mutation.operationId);
+ const layers = splitPracticeRecord(input); const recordId = layers.summary.id;
+ const childIdentities = asArray(command.childRecordIds || command.childSessionIds).map(String);
+ const children = new Set((await kernel.listEntities('practiceSummaries'))
+ .filter((summary) => practiceRecordMatches(summary, childIdentities))
+ .map((summary) => idOf(summary, ['id', 'recordId', 'sessionId'])));
+ children.delete(recordId);
+ const receipt = await retryMergeConflict(command, async () => {
+ const existing = await practiceLayers(recordId, true);
+ const deletes = Array.from(children).flatMap((id) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId: id })));
+ return kernel.mutateEntities(deletes.concat(practiceUpserts(recordId, layers, existing)), mutation);
+ });
+ return Object.assign({}, receipt, { record: await joinedPractice(recordId, 'full') });
+ },
+ async updateAnnotations(command) {
+ await ready; assertObject(command, 'updateAnnotations command is required'); const recordId = String(command.recordId || '');
+ return retryMergeConflict(command, async () => {
+ const current = await practiceLayers(recordId, true); if (!current.summary) throw new AppDataError('VALIDATION', `Unknown practice record: ${recordId}`);
+ if (command.expectedRevision !== undefined && Number(command.expectedRevision) !== entityRevision(current.annotations)) throw new AppDataError('CONFLICT', `Revision conflict for practice annotations ${recordId}`);
+ const annotations = Object.assign({ recordId }, clone(asObject(current.annotations && current.annotations.data)));
+ const detail = clone(asObject(current.detail && current.detail.data)); const examId = String(command.examId || current.summary.data.examId || 'default');
+ if (Array.isArray(detail.suiteEntries) && detail.suiteEntries.length) {
+ if (!detail.suiteEntries.some((entry) => String(entry.examId || asObject(entry.metadata).examId || '') === examId)) throw new AppDataError('VALIDATION', `Suite record ${recordId} does not contain exam ${examId}`);
+ annotations.suiteEntries = Object.assign({}, asObject(annotations.suiteEntries), { [examId]: Object.assign({}, asObject(annotations.suiteEntries)[examId], clone(asObject(command.patch))) });
+ } else {
+ if (current.summary.data.examId && String(current.summary.data.examId) !== examId) throw new AppDataError('VALIDATION', `Record ${recordId} does not match exam ${examId}`);
+ annotations.annotations = Object.assign({}, asObject(annotations.annotations), { [examId]: Object.assign({}, asObject(annotations.annotations)[examId], clone(asObject(command.patch))) });
+ Object.assign(annotations, clone(asObject(command.patch)));
+ }
+ return kernel.mutateEntities([{
+ type: 'upsert',
+ store: 'practiceAnnotations',
+ recordId,
+ data: annotations,
+ expectedRevision: entityRevision(current.annotations)
+ }], mutationOptions(command, 'practice-annotations', command));
+ });
+ },
+ async delete(command) {
+ await ready; const recordId = String(command && (command.recordId || command.id) || command || ''); if (!recordId) throw new AppDataError('VALIDATION', 'practice record id is required');
+ const found = await kernel.readEntity('practiceSummaries', recordId); if (!found) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete', { recordId })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId })), mutationOptions(command, 'practice-delete', { recordId }));
+ return Object.assign({}, receipt, { deletedCount: 1 });
+ },
+ async deleteMany(command) {
+ await ready; assertObject(command, 'practice.deleteMany command is required'); const recordIds = Array.from(new Set(asArray(command.recordIds).map(String).filter(Boolean)));
+ if (!recordIds.length) throw new AppDataError('VALIDATION', 'practice.deleteMany requires recordIds'); const summaries = await kernel.listEntities('practiceSummaries'); const ids = recordIds.filter((id) => summaries.some((item) => practiceRecordMatches(item, [id])));
+ if (!ids.length) return Object.assign(await kernel.journalNoop(mutationOptions(command, 'practice-delete-many', { recordIds })), { deletedCount: 0, noop: true });
+ const receipt = await kernel.mutateEntities(ids.flatMap((recordId) => ['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'delete', store, recordId }))), mutationOptions(command, 'practice-delete-many', { recordIds })); return Object.assign({}, receipt, { deletedCount: ids.length });
+ },
+ async clear(command = {}) { await ready; return kernel.mutateEntities(['practiceSummaries', 'practiceDetails', 'practiceAnnotations'].map((store) => ({ type: 'clear', store })), mutationOptions(command, 'practice-clear', { all: true })); },
+ async listInsights(options = {}) {
+ await ready;
+ const limit = Math.max(1, Math.min(50, Number(options.limit) || 10));
+ const summaries = (await kernel.listEntities('practiceSummaries'))
+ .slice()
+ .sort((left, right) => String(right.date || right.completedAt || right.timestamp || '')
+ .localeCompare(String(left.date || left.completedAt || left.timestamp || '')))
+ .slice(0, limit);
+ return Promise.all(summaries.map(async (summary) => {
+ if (Object.keys(asObject(summary.questionTypeErrorCounts)).length) return clone(summary);
+ const detail = await kernel.readEntity('practiceDetails', summary.id);
+ return jsonValue(Object.assign({}, clone(summary), {
+ questionTypeErrorCounts: questionTypeErrorCounts(detail)
+ }), 'practice insight');
+ }));
+ },
+ async getStats() { await ready; return computeStats(await kernel.listEntities('practiceSummaries')); },
+ projectLight,
+ projectDetail
+ });
+
+ const settings = Object.freeze({
+ async getAll() { await ready; return kernel.read('settings.values'); },
+ async patch(values, options = {}) {
+ await ready; assertObject(values, 'settings.patch requires an object');
+ const mutation = optionsMutationOptions(options, 'settings-patch', values);
+ return retryMergeConflict(options, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'settings.values', data: Object.assign({}, asObject(current.data), clone(values)), expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ });
+ },
+ async reset(options = {}) { await ready; const current = await kernel.read('settings.values', { withMeta: true }); return kernel.mutate([{ logicalKey: 'settings.values', state: 'cleared', expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], optionsMutationOptions(options, 'settings-reset', { reset: true })); }
+ });
+
+ const library = Object.freeze({
+ async listConfigurations() { await ready; return kernel.read('library.configurations'); },
+ async getActive() { await ready; return kernel.read('library.activeConfigurationId'); },
+ async getIndex(configurationId) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ if (id === null) return [];
+ const indexes = await kernel.read('library.importedIndexes');
+ return asArray(indexes[id]);
+ },
+ async updateConfiguration(configuration, options = {}) {
+ await ready; assertObject(configuration, 'library.updateConfiguration requires an object');
+ const id = importedLibraryId(idOf(configuration, ['id', 'key', 'configId']));
+ const current = await kernel.read('library.configurations', { withMeta: true });
+ const configs = asArray(current.data);
+ const index = configs.findIndex((item) => idOf(item, ['id', 'key', 'configId']) === id);
+ const next = Object.assign({}, index >= 0 ? configs[index] : {}, clone(configuration), { id, key: id });
+ if (index >= 0) configs[index] = next; else configs.push(next);
+ return kernel.mutate([{ logicalKey: 'library.configurations', data: configs, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-config', configuration));
+ },
+ async activate(configurationId, options = {}) {
+ await ready;
+ const id = importedLibraryId(configurationId, { nullable: true });
+ const current = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ return kernel.mutate([{ logicalKey: 'library.activeConfigurationId', data: id, expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'library-activate', { configurationId: id }));
+ },
+ async import(command) {
+ await ready; assertObject(command, 'library.import requires a command');
+ const id = importedLibraryId(command.id || command.configurationId || randomId('library'));
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const configs = asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id);
+ configs.push(Object.assign({}, asObject(command.configuration), { id, key: id }));
+ const indexes = Object.assign({}, asObject(indexesMeta.data), { [id]: asArray(command.index) });
+ return kernel.mutate([
+ { logicalKey: 'library.configurations', data: configs, expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ], mutationOptions(command, 'library-import', command));
+ },
+ async remove(configurationId, options = {}) {
+ await ready; const id = importedLibraryId(configurationId);
+ const configsMeta = await kernel.read('library.configurations', { withMeta: true });
+ const indexesMeta = await kernel.read('library.importedIndexes', { withMeta: true });
+ const activeMeta = await kernel.read('library.activeConfigurationId', { withMeta: true });
+ const indexes = Object.assign({}, asObject(indexesMeta.data)); delete indexes[id];
+ const changes = [
+ { logicalKey: 'library.configurations', data: asArray(configsMeta.data).filter((item) => idOf(item, ['id', 'key', 'configId']) !== id), expectedRevision: configsMeta.envelope ? configsMeta.envelope.revision : 0 },
+ { logicalKey: 'library.importedIndexes', data: indexes, expectedRevision: indexesMeta.envelope ? indexesMeta.envelope.revision : 0 }
+ ];
+ if (String(activeMeta.data || '') === id) {
+ changes.push({ logicalKey: 'library.activeConfigurationId', data: null, expectedRevision: activeMeta.envelope ? activeMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, optionsMutationOptions(options, 'library-remove', { configurationId: id }));
+ },
+ async resolveIndex() {
+ await ready;
+ const [activeId, indexes] = await Promise.all([kernel.read('library.activeConfigurationId'), kernel.read('library.importedIndexes')]);
+ return activeId && Array.isArray(asObject(indexes)[activeId]) ? clone(indexes[activeId]) : clone([]);
+ }
+ });
+
+ function recoveryKey(kind) {
+ const key = RECOVERY_KEYS[String(kind || '')];
+ if (!key) throw new AppDataError('VALIDATION', `Unknown recovery kind: ${kind}`);
+ return key;
+ }
+ // Recovery document TTL is an AppData domain rule, not a catalog policy field.
+ const RECOVERY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+ function recoveryTimestamp(item) {
+ for (const field of ['updatedAt', 'lastActivity', 'tempSavedAt', 'timestamp', 'createdAt']) {
+ const parsed = Date.parse(item && item[field]);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return null;
+ }
+ async function pruneRecoveryKey(logicalKey) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const items = asArray(current.data);
+ const cutoff = Date.now() - RECOVERY_TTL_MS;
+ const retained = items.filter((item) => {
+ const timestamp = recoveryTimestamp(item);
+ return timestamp === null || timestamp > cutoff;
+ });
+ if (retained.length === items.length) return items;
+ try {
+ await kernel.mutate([{ logicalKey, data: retained, expectedRevision: current.envelope ? current.envelope.revision : 0 }], {
+ operationId: randomId('recovery-ttl')
+ });
+ return retained;
+ } catch (error) {
+ if (!(error instanceof AppDataError) || error.code !== 'CONFLICT' || attempt === 2) throw error;
+ }
+ }
+ return kernel.read(logicalKey);
+ }
+ async function cleanupExpiredRecovery() {
+ for (const logicalKey of Object.values(RECOVERY_KEYS)) await pruneRecoveryKey(logicalKey);
+ }
+ const windowSession = Object.freeze({
+ save(name, value) {
+ if (!global.sessionStorage) throw new AppDataError('BACKEND_UNAVAILABLE', 'sessionStorage unavailable');
+ const logicalName = String(name || 'default');
+ const payload = { schemaVersion: catalog.version, updatedAt: nowIso(), data: clone(value) };
+ global.sessionStorage.setItem(`ielts_atlas:v2:session:${logicalName}`, JSON.stringify(payload));
+ return true;
+ },
+ get(name) {
+ if (!global.sessionStorage) return null;
+ const raw = global.sessionStorage.getItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ if (!raw) return null;
+ const payload = JSON.parse(raw);
+ return payload && payload.schemaVersion === catalog.version ? clone(payload.data) : null;
+ },
+ discard(name) {
+ if (global.sessionStorage) global.sessionStorage.removeItem(`ielts_atlas:v2:session:${String(name || 'default')}`);
+ return true;
+ }
+ });
+
+ const recoveryMutationTails = new Map();
+ function enqueueRecoveryMutation(logicalKey, task) {
+ const previous = recoveryMutationTails.get(logicalKey) || Promise.resolve();
+ const result = previous.then(task, task);
+ recoveryMutationTails.set(logicalKey, result.catch(() => undefined));
+ return result;
+ }
+
+ async function readRecovery(kind, id) {
+ await ready;
+ const items = await pruneRecoveryKey(recoveryKey(kind));
+ return id == null ? items : items.find((item) => idOf(item, ['id', 'sessionId', 'recordId']) === String(id)) || null;
+ }
+ async function saveRecovery(kind, value, options = {}) {
+ await ready; assertObject(value, `recovery ${kind} value must be an object`);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-save`, value);
+ const key = recoveryKey(kind);
+ const id = idOf(value, ['id', 'sessionId', 'recordId']) || deterministicEntityId('recovery', mutation.operationId);
+ const item = Object.assign({}, clone(value), { id: value.id || id, updatedAt: nowIso() });
+ const receipt = await enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const index = current.items.findIndex((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ if (index >= 0) current.items[index] = item; else current.items.push(item);
+ return kernel.mutate([{ logicalKey: key, data: current.items, expectedRevision: current.revision }], mutation);
+ }));
+ const committedItem = (await kernel.read(key))
+ .find((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) === id);
+ return Object.assign({}, receipt, { item: clone(committedItem || item) });
+ }
+ async function discardRecovery(kind, id, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-discard`, { id: String(id) });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ const next = current.items.filter((entry) => idOf(entry, ['id', 'sessionId', 'recordId']) !== String(id));
+ return kernel.mutate([{ logicalKey: key, data: next, expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearRecovery(kind, options = {}) {
+ await ready;
+ const key = recoveryKey(kind);
+ const mutation = optionsMutationOptions(options, `recovery-${kind}-clear`, { kind });
+ return enqueueRecoveryMutation(key, () => retryMergeConflict(options, async () => {
+ const current = await readCollectionMeta(key);
+ if (options.expectedRevision !== undefined && Number(options.expectedRevision) !== current.revision) {
+ throw new AppDataError('CONFLICT', `Revision conflict while clearing recovery ${kind}`, { expectedRevision: options.expectedRevision, actualRevision: current.revision });
+ }
+ return kernel.mutate([{ logicalKey: key, state: 'cleared', expectedRevision: current.revision }], mutation);
+ }));
+ }
+ async function clearAllRecovery(options = {}) {
+ const results = {};
+ for (const kind of Object.keys(RECOVERY_KEYS)) {
+ results[kind] = await clearRecovery(kind, options);
+ }
+ return results;
+ }
+ const recovery = Object.freeze({
+ windowSession,
+ async clear(options = {}) { return clearAllRecovery(options); },
+ async listActiveSessions() { return readRecovery('activeSession'); },
+ async getActiveSession(id) { return readRecovery('activeSession', id); },
+ async saveActiveSession(value, options) { return saveRecovery('activeSession', value, options); },
+ async completeActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async discardActiveSession(id, options) { return discardRecovery('activeSession', id, options); },
+ async listDrafts() { return readRecovery('draft'); },
+ async getDraft(id) { return readRecovery('draft', id); },
+ async saveDraft(value, options) { return saveRecovery('draft', value, options); },
+ async discardDraft(id, options) { return discardRecovery('draft', id, options); },
+ async listInterrupted() { return readRecovery('interrupted'); },
+ async getInterrupted(id) { return readRecovery('interrupted', id); },
+ async saveInterrupted(value, options) { return saveRecovery('interrupted', value, options); },
+ async discardInterrupted(id, options) { return discardRecovery('interrupted', id, options); },
+ async listRejectedCompletions() { return readRecovery('rejectedCompletion'); },
+ async getRejectedCompletion(id) { return readRecovery('rejectedCompletion', id); },
+ async saveRejectedCompletion(value, options) { return saveRecovery('rejectedCompletion', value, options); },
+ async discardRejectedCompletion(id, options) { return discardRecovery('rejectedCompletion', id, options); }
+ });
+
+ function isImportableEntry(entry) {
+ return entry
+ && entry.classification !== 'system'
+ && entry.classification !== 'session'
+ && entry.import !== 'ignore';
+ }
+
+ function isPlainImportObject(value) {
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
+ }
+
+ function isV2SnapshotShape(parsed) {
+ return isPlainImportObject(parsed)
+ && parsed.format === 'ielts-atlas-data-v2'
+ && isPlainImportObject(parsed.envelopes)
+ && isPlainImportObject(parsed.entities);
+ }
+
+ const POISONED_V2_WRAPPER_ALIASES = Object.freeze({
+ 'settings.values': Object.freeze(['exam_system_settings', 'exam_system_user_settings', 'exam_system_system_settings']),
+ 'vocab.userConfig': Object.freeze(['exam_system_vocab_user_config']),
+ 'achievements.manual': Object.freeze(['exam_system_user_achievements', 'exam_system_achievement_manual_state'])
+ });
+ const LIBRARY_IMPORT_KEYS = Object.freeze([
+ 'library.configurations',
+ 'library.importedIndexes',
+ 'library.activeConfigurationId'
+ ]);
+
+ function canonicalizeV2Import(parsed) {
+ const warnings = [];
+ const repairedKeys = [];
+ const ignoredKeys = [];
+ const envelopes = {};
+ for (const [logicalKey, rawEnvelope] of Object.entries(parsed.envelopes)) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const envelope = clone(rawEnvelope);
+ const data = envelope && envelope.state === 'present' ? envelope.data : null;
+ if (logicalKey === 'library.activeConfigurationId' && String(data) === '[object Object]') {
+ ignoredKeys.push(logicalKey);
+ warnings.push('Skipped poisoned active library id');
+ continue;
+ }
+ if (isPlainImportObject(data)
+ && Object.prototype.hasOwnProperty.call(data, 'key')
+ && Object.prototype.hasOwnProperty.call(data, 'value')
+ && String(data.key || '').startsWith('exam_system_')) {
+ const aliases = POISONED_V2_WRAPPER_ALIASES[logicalKey] || [];
+ const decoded = aliases.includes(String(data.key)) ? internals.parseLegacyValue(data.value) : null;
+ if (!isPlainImportObject(decoded)) {
+ ignoredKeys.push(logicalKey);
+ warnings.push(`Skipped mismatched legacy storage wrapper: ${logicalKey}`);
+ continue;
+ }
+ const overlay = Object.fromEntries(Object.entries(data)
+ .filter(([key]) => key !== 'key' && key !== 'value' && key !== 'timestamp'));
+ envelope.data = Object.assign({}, decoded, overlay);
+ envelope.checksum = checksum(envelope.data);
+ repairedKeys.push(logicalKey);
+ warnings.push(`Repaired legacy storage wrapper: ${logicalKey}`);
+ }
+ envelopes[logicalKey] = envelope;
+ }
+
+ if (parsed.scope === 'full') {
+ const presentLibraryKeys = LIBRARY_IMPORT_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(envelopes, key));
+ if (presentLibraryKeys.length && presentLibraryKeys.length !== LIBRARY_IMPORT_KEYS.length) {
+ for (const key of presentLibraryKeys) {
+ delete envelopes[key];
+ ignoredKeys.push(key);
+ }
+ warnings.push('Skipped incomplete library data');
+ }
+ }
+ const exportableKeys = catalog.list()
+ .filter((entry) => entry.export === true && isImportableEntry(entry))
+ .map((entry) => entry.logicalKey);
+ const missingKeys = parsed.scope === 'full'
+ ? exportableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(envelopes, key))
+ : [];
+ const degraded = parsed.scope === 'full' && (missingKeys.length || ignoredKeys.length);
+ return {
+ envelopes,
+ warnings,
+ repairedKeys,
+ ignoredKeys,
+ missingKeys,
+ declaredScope: parsed.scope,
+ effectiveScope: degraded ? 'partial' : parsed.scope,
+ trust: degraded ? 'degraded-partial' : (parsed.scope === 'full' ? 'trusted-full' : 'partial')
+ };
+ }
+
+ function resolveImportReplaceFlags(options = {}) {
+ const source = asObject(options);
+ const practiceMode = String(source.practiceMode || source.mergeMode || '').toLowerCase();
+ const replaceAll = source.replace === true;
+ return {
+ replaceDocuments: replaceAll,
+ // Call sites (practiceRecorder / boot-fallbacks) pass practiceMode replace|merge.
+ replacePractice: replaceAll || practiceMode === 'replace'
+ };
+ }
+
+ function pickFirstRecordArray(candidates) {
+ for (const candidate of asArray(candidates)) {
+ if (Array.isArray(candidate.records) && candidate.records.some(isPlainImportObject)) {
+ return { source: candidate.source, records: candidate.records };
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Historical v1 export shapes (opensource / pre-AppData-v2):
+ * - practiceRecorder.exportData: { exportDate, version, practiceRecords, userStats }
+ * - DataBackupManager: { exportInfo, practiceRecords, userStats?, backups? }
+ * - BackupAPI dual schema: practice_records / practiceRecords (+ nested data.*)
+ * - bare array of records, or { records: [...] }
+ * Recognition only — no dual backend and no local store migration.
+ */
+ function extractLegacyPracticeRecords(payload) {
+ const sources = [];
+ const add = (source, records) => {
+ if (Array.isArray(records) && records.some(isPlainImportObject)) {
+ sources.push({ source, records });
+ }
+ };
+
+ if (Array.isArray(payload)) {
+ add('(root array)', payload);
+ } else if (isPlainImportObject(payload)) {
+ const preferred = pickFirstRecordArray([
+ { source: 'practice_records', records: payload.practice_records },
+ { source: 'practiceRecords', records: payload.practiceRecords },
+ { source: 'records', records: payload.records }
+ ]);
+ if (preferred) add(preferred.source, preferred.records);
+
+ const data = isPlainImportObject(payload.data) ? payload.data : null;
+ if (data) {
+ const nested = pickFirstRecordArray([
+ { source: 'data.practice_records', records: data.practice_records },
+ { source: 'data.practiceRecords', records: data.practiceRecords }
+ ]);
+ if (nested) add(nested.source, nested.records);
+ else if (isPlainImportObject(data.practice_records)) add('data.practice_records.data', data.practice_records.data);
+ else if (isPlainImportObject(data.practiceRecords)) add('data.practiceRecords.data', data.practiceRecords.data);
+ if (isPlainImportObject(data.exam_system_practice_records)) {
+ add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
+ }
+ }
+ if (isPlainImportObject(payload.exam_system_practice_records)) {
+ add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
+ }
+ }
+
+ const seen = new Set();
+ const records = [];
+ for (const entry of sources) {
+ for (const item of asArray(entry.records)) {
+ if (!isPlainImportObject(item)) continue;
+ const identity = idOf(item, ['id', 'recordId', 'sessionId']);
+ if (identity) {
+ if (seen.has(identity)) continue;
+ seen.add(identity);
+ }
+ records.push(item);
+ }
+ }
+ return {
+ records,
+ sources: sources.map((entry) => entry.source)
+ };
+ }
+
+ function entityRowFromLayer(recordId, data, operationId) {
+ const payload = jsonValue(data, 'import practice entity');
+ return {
+ recordId: String(recordId),
+ revision: 1,
+ operationId: String(operationId || `import-${recordId}`),
+ updatedAt: nowIso(),
+ data: payload,
+ checksum: checksum(payload)
+ };
+ }
+
+ function convertLegacyPracticeImport(payload) {
+ const extracted = extractLegacyPracticeRecords(payload);
+ if (!extracted.records.length) {
+ throw new AppDataError(
+ 'VALIDATION',
+ 'Import file is neither a v2 snapshot nor a recognizable v1 practice export'
+ );
+ }
+
+ const entities = {
+ practiceSummaries: [],
+ practiceDetails: [],
+ practiceAnnotations: []
+ };
+ const warnings = [];
+ let skipped = 0;
+
+ for (const raw of extracted.records) {
+ try {
+ const layers = splitPracticeRecord(raw);
+ const recordId = layers.summary.id;
+ const operationId = `import-v1-${recordId}`;
+ entities.practiceSummaries.push(entityRowFromLayer(recordId, layers.summary, operationId));
+ entities.practiceDetails.push(entityRowFromLayer(recordId, layers.detail, operationId));
+ entities.practiceAnnotations.push(entityRowFromLayer(recordId, layers.annotations, operationId));
+ } catch (error) {
+ skipped += 1;
+ warnings.push(`Skipped invalid practice record: ${error && error.message ? error.message : error}`);
+ }
+ }
+
+ if (!entities.practiceSummaries.length) {
+ throw new AppDataError('VALIDATION', 'Import file practice records could not be normalized');
+ }
+
+ const accepted = entities.practiceSummaries.length;
+ return {
+ format: 'v1',
+ scope: 'partial',
+ envelopes: {},
+ entities,
+ checksum: null,
+ warnings,
+ practiceSummary: {
+ accepted,
+ importedCount: accepted,
+ skippedCount: skipped,
+ sources: extracted.sources.slice()
+ }
+ };
+ }
+
+ function parseImportPayload(payload) {
+ let parsed;
+ try { parsed = typeof payload === 'string' ? JSON.parse(payload) : jsonValue(payload, 'import payload'); }
+ catch (error) {
+ if (error instanceof AppDataError) throw error;
+ throw new AppDataError('VALIDATION', 'Import payload is not valid JSON', { cause: error && error.message });
+ }
+
+ // Bare record arrays are a historical import convenience (UI file pickers).
+ if (Array.isArray(parsed)) return convertLegacyPracticeImport(parsed);
+ if (!parsed || typeof parsed !== 'object') throw new AppDataError('VALIDATION', 'Import payload must be an object');
+
+ if (isV2SnapshotShape(parsed)) {
+ if (Number(parsed.schemaVersion) !== Number(catalog.version)) {
+ throw new AppDataError('VALIDATION', 'Import schema version mismatch');
+ }
+ if (!parsed.checksum || parsed.checksum !== checksum({ envelopes: parsed.envelopes, entities: parsed.entities })) {
+ throw new AppDataError('VALIDATION', 'Import checksum mismatch');
+ }
+ if (parsed.scope !== 'full' && parsed.scope !== 'partial') {
+ throw new AppDataError('VALIDATION', 'Import scope must be full or partial');
+ }
+ const scope = parsed.scope;
+ for (const [store, rows] of Object.entries(parsed.entities)) {
+ if (!PRACTICE_ENTITY_STORES.includes(store) || !Array.isArray(rows)) {
+ throw new AppDataError('VALIDATION', `Invalid import entity store: ${store}`);
+ }
+ for (const row of rows) {
+ if (!row || typeof row !== 'object' || Array.isArray(row) || !String(row.recordId || '')) {
+ throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ }
+ }
+ }
+ if (scope === 'full' && PRACTICE_ENTITY_STORES.some((store) => !Object.prototype.hasOwnProperty.call(parsed.entities, store))) {
+ throw new AppDataError('VALIDATION', 'Full import is missing a practice entity layer');
+ }
+ const canonical = canonicalizeV2Import(parsed);
+ return {
+ format: 'v2',
+ scope: canonical.effectiveScope,
+ declaredScope: canonical.declaredScope,
+ envelopes: canonical.envelopes,
+ entities: parsed.entities,
+ checksum: parsed.checksum,
+ warnings: canonical.warnings,
+ practiceSummary: null,
+ repairedKeys: canonical.repairedKeys,
+ ignoredKeys: canonical.ignoredKeys,
+ missingKeys: canonical.missingKeys,
+ trust: canonical.trust
+ };
+ }
+
+ // Explicit but malformed v2 claims must not fall through to legacy parsers.
+ if (parsed.format === 'ielts-atlas-data-v2') {
+ throw new AppDataError('VALIDATION', 'Only valid v2 snapshots can be imported');
+ }
+
+ return convertLegacyPracticeImport(parsed);
+ }
+
+ function collectionIdentityFields(logicalKey) {
+ if (logicalKey === 'library.configurations') return ['id', 'key', 'configId'];
+ if (logicalKey.startsWith('recovery.')) return ['id', 'sessionId', 'recordId'];
+ if (logicalKey === 'backups.entries') return ['id'];
+ if (logicalKey === 'vocab.words') return ['id', 'word', 'key'];
+ if (logicalKey === 'goals.items') return ['id', 'goalId'];
+ return ['id', 'sessionId', 'recordId'];
+ }
+
+ function collectionIdentity(logicalKey, value) {
+ const identity = idOf(value, collectionIdentityFields(logicalKey));
+ return logicalKey === 'vocab.words' ? identity.trim().toLowerCase() : identity;
+ }
+
+ function mergeCollection(existing, incoming, logicalKey) {
+ const result = asArray(existing).map((item) => clone(item));
+ const positions = new Map();
+ result.forEach((item, index) => {
+ const identity = collectionIdentity(logicalKey, item);
+ if (identity) positions.set(identity, index);
+ });
+ for (const rawItem of asArray(incoming)) {
+ const item = jsonValue(rawItem, `${logicalKey} item`);
+ const identity = collectionIdentity(logicalKey, item);
+ if (!identity) throw new AppDataError('VALIDATION', `${logicalKey} import item has no stable identity`);
+ if (positions.has(identity)) result[positions.get(identity)] = item;
+ else {
+ positions.set(identity, result.length);
+ result.push(item);
+ }
+ }
+ return result;
+ }
+
+ function mergeImportValue(entry, existing, incoming) {
+ const policy = entry.import;
+ if (policy === 'merge-by-id') return mergeCollection(existing, incoming, entry.logicalKey);
+ if (policy === 'patch') {
+ if (Array.isArray(existing) || Array.isArray(incoming)) {
+ // Array-shaped keys should use merge-by-id; treat accidental patch as replace.
+ return clone(incoming);
+ }
+ return Object.assign({}, asObject(existing), asObject(incoming));
+ }
+ if (policy === 'replace') return clone(incoming);
+ throw new AppDataError('VALIDATION', `Unsupported import policy for ${entry.logicalKey}: ${policy}`);
+ }
+
+ async function currentEntitySnapshot() {
+ const summaries = await kernel.listEntities('practiceSummaries', { withMeta: true });
+ const result = {};
+ for (const store of PRACTICE_ENTITY_STORES) {
+ if (store === 'practiceSummaries') result[store] = summaries;
+ else result[store] = (await Promise.all(summaries.map((summary) => kernel.readEntity(store, summary.recordId, { withMeta: true })))).filter(Boolean);
+ }
+ return result;
+ }
+ function practiceEntityIds(rows) {
+ return new Set(asArray(rows).map((row) => String(row && row.recordId || '')).filter(Boolean));
+ }
+ function assertPracticeEntitySetsMatch(entities, message) {
+ const expected = practiceEntityIds(entities.practiceSummaries);
+ for (const store of PRACTICE_ENTITY_STORES.slice(1)) {
+ const actual = practiceEntityIds(entities[store]);
+ if (actual.size !== expected.size || Array.from(expected).some((recordId) => !actual.has(recordId))) {
+ throw new AppDataError('VALIDATION', message || 'Practice import entity layers must contain the same recordIds', {
+ counts: Object.fromEntries(PRACTICE_ENTITY_STORES.map((name) => [name, practiceEntityIds(entities[name]).size]))
+ });
+ }
+ }
+ }
+ async function createImportPlan(parsed, options = {}) {
+ const { replaceDocuments, replacePractice } = resolveImportReplaceFlags(options);
+ const snapshot = { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: parsed.scope, envelopes: {}, entities: {} };
+ const revisionToken = { documents: {}, entities: {} };
+ const keys = []; const clearedKeys = [];
+ const warnings = asArray(parsed.warnings).map(String);
+ for (const [logicalKey, envelope] of Object.entries(asObject(parsed.envelopes))) {
+ if (!catalog.has(logicalKey)) throw new AppDataError('VALIDATION', `Unknown import key: ${logicalKey}`);
+ const entry = catalog.get(logicalKey); if (!isImportableEntry(entry)) continue;
+ if (!internals.validateEnvelope(entry, envelope)) throw new AppDataError('VALIDATION', `Invalid import envelope: ${logicalKey}`);
+ if (envelope.state === 'cleared' && !replaceDocuments && options.applyClears !== true) {
+ warnings.push(`Skipped cleared import key in merge mode: ${logicalKey}`);
+ continue;
+ }
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ revisionToken.documents[logicalKey] = current.envelope ? Number(current.envelope.revision) || 0 : 0;
+ let next = envelope;
+ if (!replaceDocuments && envelope.state === 'present') {
+ next = internals.makeEnvelope(entry, mergeImportValue(entry, current.data, envelope.data), { operationId: randomId('import-merge') });
+ }
+ snapshot.envelopes[logicalKey] = next;
+ keys.push(logicalKey);
+ if (next.state === 'cleared') clearedKeys.push(logicalKey);
+ }
+
+ // A full replace mirrors all exportable user data. Missing physical
+ // envelopes mean catalog defaults, represented here as explicit clears.
+ if (replaceDocuments && parsed.scope === 'full') {
+ for (const entry of catalog.list().filter((candidate) => candidate.export === true && isImportableEntry(candidate))) {
+ if (Object.prototype.hasOwnProperty.call(snapshot.envelopes, entry.logicalKey)) continue;
+ snapshot.envelopes[entry.logicalKey] = internals.makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: randomId('import-clear')
+ });
+ keys.push(entry.logicalKey);
+ clearedKeys.push(entry.logicalKey);
+ }
+ }
+
+ // Any successful practice import installs all three stores together. Merge
+ // may update a subset only when the final recordId sets remain identical.
+ const sourceStores = Object.keys(asObject(parsed.entities));
+ let practiceExistingCount = null;
+ let practiceIncomingCount = null;
+ if (sourceStores.length) {
+ if (replacePractice && PRACTICE_ENTITY_STORES.some((store) => !sourceStores.includes(store))) {
+ throw new AppDataError('VALIDATION', 'Practice replace requires summaries, details, and annotations');
+ }
+ const current = await currentEntitySnapshot();
+ revisionToken.entities = Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, Object.fromEntries(
+ asArray(current[store]).map((row) => [String(row.recordId), Number(row.revision) || 0])
+ )]));
+ practiceExistingCount = asArray(current.practiceSummaries).length;
+ practiceIncomingCount = asArray(parsed.entities.practiceSummaries).length;
+ const existing = replacePractice
+ ? Object.fromEntries(PRACTICE_ENTITY_STORES.map((store) => [store, []]))
+ : current;
+ for (const store of PRACTICE_ENTITY_STORES) {
+ const rows = asArray(existing[store]).map(clone);
+ const positions = new Map(rows.map((row, index) => [String(row.recordId), index]));
+ for (const row of asArray(parsed.entities[store])) {
+ if (!row || !String(row.recordId || '')) throw new AppDataError('VALIDATION', `Invalid import entity: ${store}`);
+ const index = positions.get(String(row.recordId));
+ if (index === undefined) {
+ positions.set(String(row.recordId), rows.length);
+ rows.push(clone(row));
+ } else rows[index] = clone(row);
+ }
+ snapshot.entities[store] = rows;
+ }
+ assertPracticeEntitySetsMatch(snapshot.entities);
+ }
+
+ snapshot.checksum = checksum({ envelopes: snapshot.envelopes, entities: snapshot.entities });
+ const practiceSummary = parsed.practiceSummary
+ ? clone(parsed.practiceSummary)
+ : (Object.prototype.hasOwnProperty.call(snapshot.entities, 'practiceSummaries')
+ ? {
+ accepted: Number(practiceIncomingCount) || 0,
+ importedCount: Number(practiceIncomingCount) || 0,
+ skippedCount: 0,
+ existingCount: Number(practiceExistingCount) || 0,
+ incomingCount: Number(practiceIncomingCount) || 0,
+ finalCount: asArray(snapshot.entities.practiceSummaries).length,
+ removedCount: Math.max(0, (Number(practiceExistingCount) || 0)
+ - asArray(snapshot.entities.practiceSummaries).length)
+ }
+ : null);
+ if (practiceSummary && practiceSummary.existingCount === undefined) {
+ practiceSummary.existingCount = Number(practiceExistingCount) || 0;
+ practiceSummary.incomingCount = Number(practiceIncomingCount) || Number(practiceSummary.importedCount) || 0;
+ practiceSummary.finalCount = asArray(snapshot.entities.practiceSummaries).length;
+ practiceSummary.removedCount = Math.max(0, practiceSummary.existingCount - practiceSummary.finalCount);
+ }
+ const destructive = clearedKeys.length > 0
+ || Boolean(practiceSummary && Number(practiceSummary.removedCount) > 0);
+ return {
+ snapshot,
+ keys,
+ clearedKeys,
+ warnings,
+ practiceSummary,
+ destructive,
+ resetJournal: replaceDocuments && replacePractice,
+ revisionToken,
+ diagnostics: {
+ format: parsed.format,
+ replaceDocuments,
+ replacePractice,
+ declaredScope: parsed.declaredScope || parsed.scope,
+ effectiveScope: parsed.scope,
+ trust: parsed.trust || (parsed.format === 'v2' ? 'trusted-full' : 'degraded-partial'),
+ missingKeys: clone(parsed.missingKeys || []),
+ repairedKeys: clone(parsed.repairedKeys || []),
+ ignoredKeys: clone(parsed.ignoredKeys || [])
+ }
+ };
+ }
+ async function createRestoreSnapshot(backup) {
+ const parsed = parseImportPayload(asObject(backup && backup.data));
+ if (parsed.format !== 'v2') throw new AppDataError('VALIDATION', 'Only v2 snapshots can be restored from local backups');
+ if (backup.checksum && backup.checksum !== parsed.checksum) throw new AppDataError('VALIDATION', 'Backup checksum mismatch');
+ return (await createImportPlan(parsed, { replace: true })).snapshot;
+ }
+
+ const backups = Object.freeze({
+ onDataCommitted(listener) { return kernel.onCommitted(listener); },
+ async getSettings() { await ready; return kernel.read('backups.settings'); },
+ async setSettings(values, options = {}) { await ready; const current = await kernel.read('backups.settings', { withMeta: true }); return kernel.mutate([{ logicalKey: 'backups.settings', data: asObject(values), expectedRevision: current.envelope ? current.envelope.revision : 0 }], optionsMutationOptions(options, 'backup-settings', values)); },
+ async getExportHistory() { await ready; return kernel.read('backups.exportHistory'); },
+ async getImportHistory() { await ready; return kernel.read('backups.importHistory'); },
+ async recordExport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.exportHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup export history entry'))); return kernel.mutate([{ logicalKey: 'backups.exportHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-export-history', entry)); },
+ async recordImport(entry, options = {}) { await ready; const current = await readCollectionMeta('backups.importHistory'); current.items.unshift(Object.assign({ timestamp: nowIso() }, jsonValue(entry, 'backup import history entry'))); return kernel.mutate([{ logicalKey: 'backups.importHistory', data: current.items.slice(0, 100), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-import-history', entry)); },
+ async create(options = {}) {
+ await ready; const current = await readCollectionMeta('backups.entries');
+ const mutation = optionsMutationOptions(options, 'backup-create', { id: options.id || null, type: options.type || 'manual' });
+ const backupId = options.id || (options.operationId ? `backup_${checksum({ operationId: String(options.operationId) }).replace(/[^a-z0-9]/gi, '')}` : randomId('backup'));
+ const existing = current.items.find((item) => String(item.id) === String(backupId));
+ if (existing) {
+ if (String(existing.operationId || '') === String(mutation.operationId)
+ && String(existing.type || 'manual') === String(options.type || 'manual')) {
+ return clone(existing);
+ }
+ throw new AppDataError('CONFLICT', `Backup id already exists: ${backupId}`, {
+ backupId: String(backupId)
+ });
+ }
+ const snapshot = await kernel.exportSnapshot();
+ const backup = { id: backupId, operationId: mutation.operationId, timestamp: nowIso(), type: options.type || 'manual', version: 2, data: snapshot, size: JSON.stringify(snapshot).length, checksum: snapshot.checksum };
+ current.items.unshift(backup);
+ current.items = retainBackupEntries(current.items, 20, options.preserveIds);
+ await kernel.mutate([{ logicalKey: 'backups.entries', data: current.items, expectedRevision: current.revision }], mutation);
+ const committed = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(backupId));
+ return clone(committed || backup);
+ },
+ async list() { await ready; return kernel.read('backups.entries'); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('backups.entries'); return kernel.mutate([{ logicalKey: 'backups.entries', data: current.items.filter((item) => String(item.id) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'backup-delete', { id: String(id) })); },
+ async export(options = {}) {
+ await ready;
+ if (options.backupId !== undefined && options.backupId !== null) {
+ const backupId = String(options.backupId);
+ const stored = asArray(await kernel.read('backups.entries'))
+ .find((item) => String(item && item.id) === backupId);
+ if (!stored) throw new AppDataError('VALIDATION', `Unknown backup: ${backupId}`);
+ const portable = jsonValue(stored, 'stored backup export');
+ if (!portable.data || !portable.checksum || portable.checksum !== portable.data.checksum) {
+ throw new AppDataError('VALIDATION', `Backup checksum mismatch: ${backupId}`);
+ }
+ return portable;
+ }
+ const domains = Array.isArray(options.domains) ? new Set(options.domains.map(String)) : null;
+ const logicalKeys = domains
+ ? catalog.list()
+ .filter((entry) => domains.has(entry.owner) && entry.export === true)
+ .map((entry) => entry.logicalKey)
+ : null;
+ const entityStores = !domains || domains.has('practice')
+ ? undefined
+ : [];
+ return kernel.exportSnapshot(Object.assign(
+ logicalKeys ? { logicalKeys } : {},
+ entityStores ? { entityStores } : {}
+ ));
+ },
+ async previewImport(payload, options = {}) {
+ await ready; const parsed = parseImportPayload(payload); const prepared = await createImportPlan(parsed, options); const planId = randomId('import-plan');
+ const cutoff = Date.now() - (30 * 60 * 1000);
+ for (const [id, existing] of importPlans) {
+ if (Date.parse(existing.createdAt) < cutoff || importPlans.size >= 20) importPlans.delete(id);
+ }
+ const plan = { id: planId, format: parsed.format, scope: parsed.scope, keys: prepared.keys, clearedKeys: prepared.clearedKeys, warnings: prepared.warnings, createdAt: nowIso(), snapshot: prepared.snapshot, practiceSummary: prepared.practiceSummary, diagnostics: prepared.diagnostics, destructive: prepared.destructive, resetJournal: prepared.resetJournal, revisionToken: prepared.revisionToken, signature: checksum(prepared.snapshot) };
+ importPlans.set(planId, plan); return { id: planId, format: plan.format, scope: plan.scope, keys: plan.keys, clearedKeys: clone(plan.clearedKeys), warnings: clone(plan.warnings), createdAt: plan.createdAt, practice: clone(plan.practiceSummary), diagnostics: clone(plan.diagnostics), destructive: plan.destructive };
+ },
+ async commitImport(planId, options = {}) {
+ await ready; const plan = importPlans.get(String(planId)); if (!plan) throw new AppDataError('VALIDATION', `Unknown import plan: ${planId}`);
+ if (plan.destructive && options.confirmDestructive !== true) {
+ throw new AppDataError('VALIDATION', 'Destructive import requires explicit confirmation');
+ }
+ const mutation = optionsMutationOptions(options, 'import-commit', {
+ planId: plan.id,
+ signature: plan.signature
+ }, { warnings: plan.warnings });
+ const receipt = await kernel.installSnapshot(plan.snapshot, Object.assign({}, mutation, {
+ resetJournal: plan.resetJournal === true,
+ expectedRevisionToken: plan.revisionToken
+ }));
+ importPlans.delete(String(planId));
+ return Object.assign({}, receipt, plan.practiceSummary || {}, { practice: clone(plan.practiceSummary) });
+ },
+ async restore(id, options = {}) {
+ await ready; const backup = (await kernel.read('backups.entries')).find((item) => String(item.id) === String(id));
+ if (!backup) throw new AppDataError('VALIDATION', `Unknown backup: ${id}`);
+ const snapshot = await createRestoreSnapshot(backup);
+ const restoreMutation = optionsMutationOptions(options, 'backup-restore', {
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }, { resetJournal: true });
+ const preRestoreOperationId = `${restoreMutation.operationId}:pre-restore`;
+ const preRestoreBackupId = `pre_restore_${checksum({
+ operationId: restoreMutation.operationId,
+ backupId: String(id),
+ checksum: backup.checksum || checksum(backup.data)
+ }).replace(/[^a-z0-9]/gi, '')}`;
+ const preRestoreBackup = await backups.create({
+ id: preRestoreBackupId,
+ operationId: preRestoreOperationId,
+ type: 'pre-restore',
+ preserveIds: [String(id)]
+ });
+ const receipt = await kernel.installSnapshot(snapshot, restoreMutation);
+ return Object.assign({}, receipt, { preRestoreBackupId: preRestoreBackup.id });
+ }
+ });
+
+ let vocabMutationTail = Promise.resolve();
+ function enqueueVocabMutation(task) {
+ const result = vocabMutationTail.then(task, task);
+ vocabMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ function retryVocabMutation(options, task) {
+ return enqueueVocabMutation(() => retryMergeConflict(options, task));
+ }
+
+ const vocab = Object.freeze({
+ async listWords() { await ready; return kernel.read('vocab.words'); },
+ async saveWords(words, options = {}) {
+ await ready; assertArray(words, 'vocab.saveWords requires an array');
+ const mutation = optionsMutationOptions(options, 'vocab-words', words);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.words', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.words',
+ data: words,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async getConfig() { await ready; return kernel.read('vocab.userConfig'); },
+ async setConfig(config, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'vocab-config', config);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: asObject(config),
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async patchConfig(patch, options = {}) {
+ await ready; assertObject(patch, 'vocab.patchConfig requires an object');
+ const mutation = optionsMutationOptions(options, 'vocab-config-patch', patch);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.userConfig', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), clone(patch));
+ return kernel.mutate([{
+ logicalKey: 'vocab.userConfig',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async activateList(listId, options = {}) { return this.patchConfig({ activeListId: String(listId || 'default') }, options); },
+ async listCollections() { await ready; return kernel.read('vocab.lists'); },
+ async saveCollection(id, value, options = {}) {
+ await ready;
+ const collectionId = String(id);
+ const mutation = optionsMutationOptions(options, 'vocab-list', { id: collectionId, value });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [collectionId]: clone(value) });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async saveCollections(values, options = {}) {
+ await ready;
+ assertObject(values, 'vocab.saveCollections requires an object');
+ const upserts = Object.fromEntries(Object.entries(values).map(([id, value]) => [String(id), clone(value)]));
+ const mutation = optionsMutationOptions(options, 'vocab-lists-batch', upserts);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), upserts);
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: next,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async upsertCollectionWord(collectionId, word, options = {}) {
+ await ready; assertObject(word, 'vocab.upsertCollectionWord requires a word');
+ const id = String(collectionId || '');
+ if (!id) throw new AppDataError('VALIDATION', 'vocab collection id is required');
+ const identity = String(word.word || word.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ const mutation = optionsMutationOptions(options, 'vocab-word', { collectionId: id, word });
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ const existing = collections[id];
+ const list = existing && typeof existing === 'object' && !Array.isArray(existing)
+ ? Object.assign({}, clone(existing), { words: asArray(existing.words) })
+ : { id, words: asArray(existing) };
+ const index = list.words.findIndex((item) => String(item && (item.word || item.id) || '').trim().toLowerCase() === identity);
+ const nextWord = Object.assign({}, index >= 0 ? list.words[index] : {}, clone(word), { updatedAt: word.updatedAt || nowIso() });
+ if (!nextWord.createdAt) nextWord.createdAt = nextWord.updatedAt;
+ if (index >= 0) list.words[index] = nextWord; else list.words.push(nextWord);
+ list.updatedAt = nowIso();
+ collections[id] = list;
+ const receipt = await kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(nextWord) });
+ });
+ },
+ async readList(listId) { await ready; const id = String(listId || 'default'); if (id === 'default') return kernel.read('vocab.words'); const collections = await kernel.read('vocab.lists'); return Object.prototype.hasOwnProperty.call(collections, id) ? clone(collections[id]) : null; },
+ async replaceListWords(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceListWords requires a command');
+ const id = String(command.listId || 'default'); const words = asArray(command.words);
+ if (id === 'default') return this.saveWords(words, options);
+ const mutation = optionsMutationOptions(options, 'vocab-list-words-replace', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read('vocab.lists', { withMeta: true });
+ const collections = Object.assign({}, asObject(current.data));
+ collections[id] = Object.assign({}, asObject(collections[id]), { id, words, updatedAt: nowIso() });
+ return kernel.mutate([{
+ logicalKey: 'vocab.lists',
+ data: collections,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ });
+ },
+ async mergeListWords(command, options = {}) {
+ await ready;
+ assertObject(command, 'vocab.mergeListWords requires a command');
+ const listId = String(command.listId || 'default');
+ const incoming = asArray(command.words);
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(options, 'vocab-words-merge', command);
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : Object.assign({}, asObject(current.data));
+ const storedList = listId === 'default'
+ ? asArray(current.data)
+ : (function readStoredCollection() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? asArray(collection.words)
+ : asArray(collection);
+ }());
+ const merged = storedList.map((word) => clone(word));
+ const positions = new Map();
+ merged.forEach((word, index) => {
+ const identity = String(word && (word.word || word.id) || '').trim().toLowerCase();
+ if (identity) positions.set(identity, index);
+ });
+ let addedCount = 0;
+ let updatedCount = 0;
+ for (const rawWord of incoming) {
+ assertObject(rawWord, 'vocab.mergeListWords entries must be objects');
+ const identity = String(rawWord.word || rawWord.id || '').trim().toLowerCase();
+ if (!identity) throw new AppDataError('VALIDATION', 'vocab word identity is required');
+ if (!positions.has(identity)) {
+ positions.set(identity, merged.length);
+ merged.push(clone(rawWord));
+ addedCount += 1;
+ continue;
+ }
+ const index = positions.get(identity);
+ const existing = asObject(merged[index]);
+ const patch = {};
+ if (typeof rawWord.meaning === 'string' && rawWord.meaning.trim()) patch.meaning = rawWord.meaning.trim();
+ if (typeof rawWord.example === 'string' && rawWord.example.trim()) patch.example = rawWord.example.trim();
+ if (typeof rawWord.freq === 'number' && Number.isFinite(rawWord.freq)) patch.freq = rawWord.freq;
+ merged[index] = Object.assign({}, existing, patch, { updatedAt: nowIso() });
+ updatedCount += 1;
+ }
+ const data = listId === 'default'
+ ? merged
+ : Object.assign({}, collections, {
+ [listId]: Object.assign(
+ {},
+ (function collectionBaseForWrite() {
+ const collection = collections[listId];
+ return collection && typeof collection === 'object' && !Array.isArray(collection)
+ ? clone(collection)
+ : {};
+ }()),
+ { id: listId, words: merged, updatedAt: nowIso() }
+ )
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { listId, words: clone(merged), addedCount, updatedCount });
+ });
+ },
+ async patchWord(command, options = {}) {
+ await ready; assertObject(command, 'vocab.patchWord requires a command');
+ const listId = String(command.listId || 'default'); const wordId = String(command.wordId || command.id || '');
+ if (!wordId) throw new AppDataError('VALIDATION', 'vocab word id is required');
+ const logicalKey = listId === 'default' ? 'vocab.words' : 'vocab.lists';
+ const mutation = optionsMutationOptions(
+ Object.assign({}, options, { operationId: command.operationId || options.operationId }),
+ 'vocab-word-patch',
+ command
+ );
+ return retryVocabMutation(options, async () => {
+ const current = await kernel.read(logicalKey, { withMeta: true });
+ const collections = listId === 'default' ? null : asObject(current.data);
+ const list = listId === 'default'
+ ? asArray(current.data)
+ : asArray(asObject(collections[listId]).words);
+ const index = list.findIndex((word) => idOf(word, ['id', 'word', 'key']) === wordId);
+ if (index < 0) throw new AppDataError('VALIDATION', `Unknown vocab word: ${wordId}`);
+ const updated = Object.assign({}, list[index], clone(asObject(command.patch)), { id: list[index].id || wordId, updatedAt: nowIso() });
+ const next = list.slice(); next[index] = updated;
+ const data = listId === 'default'
+ ? next
+ : Object.assign({}, collections, {
+ [listId]: Object.assign({}, asObject(collections[listId]), { id: listId, words: next, updatedAt: nowIso() })
+ });
+ const receipt = await kernel.mutate([{
+ logicalKey,
+ data,
+ expectedRevision: options.expectedRevision ?? (current.envelope ? Number(current.envelope.revision) : 0)
+ }], mutation);
+ return Object.assign({}, receipt, { word: clone(updated) });
+ });
+ },
+ async replaceProgress(command, options = {}) {
+ await ready; assertObject(command, 'vocab.replaceProgress requires a command');
+ const listId = String(command.listId || 'default'); const words = asArray(command.words);
+ const mutation = optionsMutationOptions(options, 'vocab-progress', command);
+ return retryVocabMutation(options, async () => {
+ const configMeta = await kernel.read('vocab.userConfig', { withMeta: true });
+ const changes = [{
+ logicalKey: 'vocab.userConfig',
+ data: Object.assign({}, asObject(configMeta.data), asObject(command.config), { activeListId: listId }),
+ expectedRevision: configMeta.envelope ? configMeta.envelope.revision : 0
+ }];
+ if (listId === 'default') {
+ const wordsMeta = await kernel.read('vocab.words', { withMeta: true });
+ changes.push({ logicalKey: 'vocab.words', data: words, expectedRevision: wordsMeta.envelope ? wordsMeta.envelope.revision : 0 });
+ } else {
+ const listsMeta = await kernel.read('vocab.lists', { withMeta: true }); const lists = Object.assign({}, asObject(listsMeta.data));
+ lists[listId] = Object.assign({}, asObject(lists[listId]), { id: listId, words });
+ changes.push({ logicalKey: 'vocab.lists', data: lists, expectedRevision: listsMeta.envelope ? listsMeta.envelope.revision : 0 });
+ }
+ return kernel.mutate(changes, mutation);
+ });
+ }
+ });
+
+ async function readPreferences() { await ready; return kernel.read('preferences.values'); }
+ let preferenceMutationTail = Promise.resolve();
+ function enqueuePreferenceMutation(task) {
+ const result = preferenceMutationTail.then(task, task);
+ preferenceMutationTail = result.catch(() => undefined);
+ return result;
+ }
+ async function writePreference(field, value, options = {}) {
+ const mutation = optionsMutationOptions(options, 'preference-set', { field, value });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const next = Object.assign({}, asObject(current.data), { [field]: clone(value) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ async function patchPreference(field, patch, options = {}) {
+ await ready;
+ const mutation = optionsMutationOptions(options, 'preference-patch', { field, patch });
+ return enqueuePreferenceMutation(() => retryMergeConflict(options, async () => {
+ const current = await kernel.read('preferences.values', { withMeta: true });
+ const values = asObject(current.data);
+ const next = Object.assign({}, values, { [field]: Object.assign({}, asObject(values[field]), asObject(patch)) });
+ return kernel.mutate([{ logicalKey: 'preferences.values', data: next, expectedRevision: options.expectedRevision ?? (current.envelope ? current.envelope.revision : 0) }], mutation);
+ }));
+ }
+ const preferences = Object.freeze({
+ async getAll() { return readPreferences(); },
+ async getTheme() { return (await readPreferences())[PREFERENCE_FIELDS.theme] ?? null; }, async setTheme(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.theme, value, options); },
+ async getBrowse() { return clone((await readPreferences())[PREFERENCE_FIELDS.browse] ?? null); }, async setBrowse(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.browse, value, options); }, async patchBrowse(value, options) { return patchPreference(PREFERENCE_FIELDS.browse, value, options); },
+ async getTimer(scope) { const timer = clone((await readPreferences())[PREFERENCE_FIELDS.timer] ?? {}); return scope ? clone(timer[String(scope)] ?? null) : timer; }, async setTimer(scope, value, options) { return patchPreference(PREFERENCE_FIELDS.timer, { [String(scope)]: clone(value) }, options); },
+ async getSuite() { return clone((await readPreferences())[PREFERENCE_FIELDS.suite] ?? null); }, async setSuite(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.suite, value, options); }, async patchSuite(value, options) { return patchPreference(PREFERENCE_FIELDS.suite, value, options); },
+ async getCandidateCode() { return (await readPreferences())[PREFERENCE_FIELDS.candidateCode] ?? null; }, async setCandidateCode(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.candidateCode, value, options); }
+ ,async getResourceBasePrefix() { return (await readPreferences())[PREFERENCE_FIELDS.resourceBasePrefix] ?? null; }, async setResourceBasePrefix(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.resourceBasePrefix, value, options); },
+ async getOnboarding() { return clone((await readPreferences())[PREFERENCE_FIELDS.onboarding] ?? {}); }, async setOnboarding(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.onboarding, asObject(value), options); },
+ async getReadingDisplay() { return clone((await readPreferences())[PREFERENCE_FIELDS.readingDisplay] ?? null); }, async setReadingDisplay(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.readingDisplay, value, options); },
+ async getThreeBackground() { return (await readPreferences())[PREFERENCE_FIELDS.threeBackground] ?? null; }, async setThreeBackground(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.threeBackground, value, options); },
+ async getThemePortal() { return clone((await readPreferences())[PREFERENCE_FIELDS.themePortal] ?? null); }, async setThemePortal(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.themePortal, value, options); },
+ async getPracticeWidget() { return (await readPreferences())[PREFERENCE_FIELDS.practiceWidget] ?? null; }, async setPracticeWidget(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.practiceWidget, value, options); },
+ async getConsent() { return clone((await readPreferences())[PREFERENCE_FIELDS.consent] ?? {}); }, async setConsent(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.consent, asObject(value), options); },
+ async getLogConfig() { return clone((await readPreferences())[PREFERENCE_FIELDS.logConfig] ?? null); }, async setLogConfig(value, options) { await ready; return writePreference(PREFERENCE_FIELDS.logConfig, asObject(value), options); }
+ });
+
+ const goals = Object.freeze({
+ async list() { await ready; return kernel.read('goals.items'); },
+ async save(goal, options = {}) { await ready; assertObject(goal, 'goals.save requires an object'); const mutation = optionsMutationOptions(options, 'goal-save', goal); const current = await readCollectionMeta('goals.items'); const id = idOf(goal, ['id', 'goalId']) || deterministicEntityId('goal', mutation.operationId); const item = Object.assign({}, clone(goal), { id }); const index = current.items.findIndex((entry) => idOf(entry, ['id', 'goalId']) === id); if (index >= 0) current.items[index] = item; else current.items.push(item); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items, expectedRevision: current.revision }], mutation); },
+ async delete(id, options = {}) { await ready; const current = await readCollectionMeta('goals.items'); return kernel.mutate([{ logicalKey: 'goals.items', data: current.items.filter((item) => idOf(item, ['id', 'goalId']) !== String(id)), expectedRevision: current.revision }], optionsMutationOptions(options, 'goal-delete', { id: String(id) })); }
+ });
+
+ function deliveryTimestamp(value) {
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const time = typeof candidate === 'string' && candidate.trim() ? Date.parse(candidate) : NaN;
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
+ }
+
+ function mergeDeliveryAcknowledgements(current, incoming) {
+ const merged = Object.assign({}, asObject(current));
+ for (const [id, value] of Object.entries(asObject(incoming))) {
+ const key = String(id).trim();
+ if (!key) continue;
+ const previous = deliveryTimestamp(merged[key]);
+ const next = deliveryTimestamp(value);
+ if (!hasOwn(merged, key) || (next && (!previous || next < previous))) {
+ merged[key] = next;
+ } else if (previous) {
+ merged[key] = previous;
+ } else {
+ merged[key] = null;
+ }
+ }
+ return merged;
+ }
+
+ const achievements = Object.freeze({
+ async getAll() {
+ await ready;
+ const progress = await retryMergeConflict({}, async () => {
+ const [summaries, manual, current] = await Promise.all([
+ kernel.listEntities('practiceSummaries'),
+ kernel.read('achievements.manual'),
+ kernel.read('achievements.progress', { withMeta: true })
+ ]);
+ const projected = asObject(computeAchievementProgress(summaries, manual, current.data));
+ if (checksum(projected) !== checksum(asObject(current.data))) {
+ await kernel.mutate([{
+ logicalKey: 'achievements.progress',
+ data: projected,
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], {
+ operationId: `achievement-progress-${current.envelope ? Number(current.envelope.revision) : 0}-${checksum(projected)}`
+ });
+ }
+ return projected;
+ }, 5);
+ if (Object.prototype.hasOwnProperty.call(progress, 'fresh')) delete progress.fresh;
+ Object.defineProperty(progress, 'fresh', { value: true, enumerable: false });
+ return progress;
+ },
+ async retryPending() { return achievements.getAll(); },
+ async acknowledgeDelivery(unlocked, options = {}) {
+ await ready;
+ assertObject(unlocked, 'achievements.acknowledgeDelivery requires an object');
+ const requested = clone(unlocked);
+ const mutation = optionsMutationOptions(options, 'achievement-delivery-acknowledge', requested);
+ return retryMergeConflict({}, async () => {
+ const current = await kernel.read('settings.values', { withMeta: true });
+ const settingsValue = asObject(current.data);
+ const delivery = asObject(settingsValue.achievementDelivery);
+ const acknowledged = mergeDeliveryAcknowledgements(delivery.acknowledged, requested);
+ return kernel.mutate([{
+ logicalKey: 'settings.values',
+ data: Object.assign({}, settingsValue, {
+ achievementDelivery: { version: 1, acknowledged }
+ }),
+ expectedRevision: current.envelope ? Number(current.envelope.revision) : 0
+ }], mutation);
+ }, 5);
+ },
+ async getManualState() { await ready; return kernel.read('achievements.manual'); }
+ });
+
+ const LEGACY_DOCUMENT_ALIASES = Object.freeze({
+ 'settings.values': ['user_settings', 'settings', 'system_settings'],
+ 'recovery.activeSessions': ['active_sessions'], 'recovery.drafts': ['temp_practice_records'],
+ 'recovery.interrupted': ['interrupted_records'], 'recovery.rejectedCompletions': ['rejected_completion_payloads'],
+ 'backups.entries': ['manual_backups'], 'backups.settings': ['backup_settings'],
+ 'backups.exportHistory': ['export_history'], 'backups.importHistory': ['import_history'],
+ 'vocab.words': ['vocab_words'], 'vocab.userConfig': ['vocab_user_config'], 'vocab.lists': ['vocab_lists'],
+ 'preferences.values': ['ui_preferences'], 'goals.items': ['learning_goals'],
+ 'achievements.manual': ['achievement_manual_state', 'user_achievements']
+ });
+ const LEGACY_PREFERENCE_ALIASES = Object.freeze({
+ theme: 'theme', preferred_theme: 'theme', browse_state: 'browse', browse_preferences: 'browse',
+ practice_timer_preferences: 'timer', suite_preference: 'suite', candidate_code: 'candidateCode',
+ ielts_reading_display_preferences_v1: 'readingDisplay', onboarding_completed: 'onboarding.completed'
+ });
+
+ function mergeLegacySources(indexedDbValue, externalValue) {
+ const indexedDb = asObject(indexedDbValue);
+ const external = asObject(externalValue);
+ const merged = Object.assign({}, external, indexedDb);
+ const records = new Map();
+ const addRecords = (value) => {
+ const list = Array.isArray(value) ? value : asArray(asObject(value).data);
+ list.forEach((record) => {
+ const id = idOf(record, ['id', 'recordId', 'sessionId']);
+ records.set(id ? `id:${id}` : `content:${checksum(record)}`, clone(record));
+ });
+ };
+ addRecords(external.practice_records || external.practiceRecords);
+ addRecords(indexedDb.practice_records);
+ if (records.size) merged.practice_records = Array.from(records.values());
+ return merged;
+ }
+
+ function legacyLibraryBundle(legacy) {
+ const idMap = new Map();
+ const indexes = {};
+ for (const [oldId, value] of Object.entries(asObject(legacy))) {
+ if (!/^exam_index_/.test(oldId) || oldId === 'exam_index_configurations' || !asArray(value).length) continue;
+ const id = `legacy-library-${checksum(oldId).replace(/^fnv1a-/, '')}`;
+ idMap.set(oldId, id);
+ indexes[id] = clone(value);
+ }
+ if (!idMap.size) return null;
+ const configurations = new Map();
+ asArray(legacy.exam_index_configurations).forEach((configuration) => {
+ const oldId = idOf(configuration, ['id', 'key', 'configId']);
+ const id = idMap.get(oldId);
+ if (id) configurations.set(id, Object.assign({}, clone(configuration), { id, key: id, examCount: indexes[id].length }));
+ });
+ for (const [oldId, id] of idMap) {
+ if (!configurations.has(id)) configurations.set(id, {
+ id,
+ key: id,
+ name: `迁移的自定义题库 (${oldId})`,
+ examCount: indexes[id].length,
+ sourceType: 'legacy-import'
+ });
+ }
+ return {
+ configurations: Array.from(configurations.values()),
+ indexes,
+ activeId: idMap.get(String(legacy.active_exam_index_key || '')) || null
+ };
+ }
+
+ async function migrateLegacyData() {
+ // Unit embedders may provide a deliberately minimal kernel bootstrap.
+ if (typeof internals.readLegacyValues !== 'function') return;
+ const migrationMeta = await kernel.read('system.migrations', { withMeta: true });
+ const migrationState = asObject(migrationMeta.data);
+ const v1Complete = asObject(migrationState.v1ToV2).status === 'complete';
+ const externalConsumed = asObject(migrationState.externalBackupV1).status === 'consumed';
+ let externalBackup = null;
+ if (!externalConsumed && typeof internals.readLegacyExternalBackup === 'function') {
+ try { externalBackup = await internals.readLegacyExternalBackup(); }
+ catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] legacy external backup skipped:', error && error.message);
+ }
+ }
+ if (v1Complete && !externalBackup) return;
+
+ const indexedDb = await internals.readLegacyValues();
+ if (indexedDb && indexedDb.__legacyReadComplete === false) {
+ throw new AppDataError('BACKEND_UNAVAILABLE', 'Legacy IndexedDB could not be read completely; migration will retry on next startup');
+ }
+ const legacy = mergeLegacySources(indexedDb, externalBackup);
+ const changes = [];
+ for (const [logicalKey, aliases] of Object.entries(LEGACY_DOCUMENT_ALIASES)) {
+ const current = await kernel.getEnvelope(logicalKey);
+ if (current) continue;
+ const alias = aliases.find((key) => Object.prototype.hasOwnProperty.call(legacy, key));
+ if (alias) changes.push({ logicalKey, data: legacy[alias], expectedRevision: 0 });
+ }
+ const libraryBundle = legacyLibraryBundle(legacy);
+ if (libraryBundle) {
+ if (!(await kernel.getEnvelope('library.configurations'))) changes.push({ logicalKey: 'library.configurations', data: libraryBundle.configurations, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.importedIndexes'))) changes.push({ logicalKey: 'library.importedIndexes', data: libraryBundle.indexes, expectedRevision: 0 });
+ if (!(await kernel.getEnvelope('library.activeConfigurationId'))) changes.push({ logicalKey: 'library.activeConfigurationId', data: libraryBundle.activeId, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('preferences.values')) && !changes.some((change) => change.logicalKey === 'preferences.values')) {
+ const preferences = {};
+ for (const [alias, target] of Object.entries(LEGACY_PREFERENCE_ALIASES)) {
+ if (!Object.prototype.hasOwnProperty.call(legacy, alias)) continue;
+ const path = target.split('.'); let cursor = preferences;
+ path.slice(0, -1).forEach((part) => { cursor[part] = asObject(cursor[part]); cursor = cursor[part]; });
+ cursor[path[path.length - 1]] = clone(legacy[alias]);
+ }
+ if (Object.keys(preferences).length) changes.push({ logicalKey: 'preferences.values', data: preferences, expectedRevision: 0 });
+ }
+ if (!(await kernel.getEnvelope('vocab.userConfig')) && !changes.some((change) => change.logicalKey === 'vocab.userConfig') && Object.prototype.hasOwnProperty.call(legacy, 'vocab_active_list_id')) {
+ changes.push({ logicalKey: 'vocab.userConfig', data: { activeListId: legacy.vocab_active_list_id }, expectedRevision: 0 });
+ }
+ if (changes.length) await kernel.mutate(changes, { operationId: `legacy-documents-${internals.checksum(changes)}` });
+ const recordsValue = legacy.practice_records;
+ const records = Array.isArray(recordsValue) ? recordsValue : asArray(asObject(recordsValue).data);
+ const operations = [];
+ for (const [index, record] of records.entries()) {
+ try {
+ const candidate = clone(record);
+ if (!idOf(candidate, ['id', 'recordId', 'sessionId'])) candidate.id = `legacy_${index}_${internals.checksum(record)}`;
+ const canonical = canonicalizeRecord(candidate);
+ const parts = splitPracticeRecord(canonical);
+ for (const [store, data] of [
+ ['practiceSummaries', parts.summary],
+ ['practiceDetails', parts.detail],
+ ['practiceAnnotations', parts.annotations]
+ ]) {
+ if (!await kernel.readEntity(store, canonical.id)) {
+ operations.push({ type: 'upsert', store, recordId: canonical.id, data, expectedRevision: 0 });
+ }
+ }
+ } catch (error) {
+ if (global.console && console.warn) console.warn(`[AppData v2] skipping malformed legacy practice record #${index}:`, error && error.message);
+ }
+ }
+ if (operations.length) {
+ await kernel.mutateEntities(operations, { operationId: `legacy-practice-${internals.checksum(records)}` });
+ }
+
+ const nextMigrationState = Object.assign({}, migrationState);
+ if (!v1Complete) nextMigrationState.v1ToV2 = {
+ version: 1,
+ status: 'complete',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(indexedDb),
+ sourceRecordCount: asArray(indexedDb.practice_records).length
+ };
+ if (externalBackup) nextMigrationState.externalBackupV1 = {
+ version: 1,
+ status: 'consumed',
+ completedAt: nowIso(),
+ sourceChecksum: checksum(externalBackup)
+ };
+ await kernel.mutate([{
+ logicalKey: 'system.migrations',
+ data: nextMigrationState,
+ expectedRevision: migrationMeta.envelope ? Number(migrationMeta.envelope.revision) : 0
+ }], { operationId: `legacy-migration-${checksum(nextMigrationState)}` });
+ }
+
+ const ready = kernel.initialize()
+ .then(async () => {
+ // Legacy migration and recovery cleanup are best-effort: a failure here
+ // (e.g. one malformed v1 record) must not brick the data layer for every
+ // read that awaits `ready`. Only a genuine backend init failure below is fatal.
+ try {
+ await migrateLegacyData();
+ } catch (error) {
+ if (global.console && console.error) console.error('[AppData v2] legacy migration skipped:', error);
+ }
+ try {
+ await cleanupExpiredRecovery();
+ } catch (error) {
+ if (global.console && console.warn) console.warn('[AppData v2] recovery cleanup skipped:', error);
+ }
+ return true;
+ })
+ .catch((error) => {
+ if (global.console && console.error) console.error('[AppData v2] initialization blocked:', error);
+ throw error instanceof AppDataError ? error : new AppDataError('INITIALIZATION_BLOCKED', error && error.message || 'AppData v2 initialization failed');
+ });
+
+ const AppData = { practice, settings, library, recovery, backups, vocab, preferences, goals, achievements };
+ Object.defineProperties(AppData, {
+ ready: { value: ready, enumerable: false },
+ status: { value: () => kernel.status(), enumerable: false }
+ });
+ Object.freeze(AppData);
+ Object.defineProperty(global, 'AppData', { value: AppData, enumerable: true, configurable: false, writable: false });
+ if (!Reflect.deleteProperty(global, '__AppDataV2Internals')) {
+ throw new Error('AppData v2 failed to close its internal bootstrap channel');
+ }
+ if (!Reflect.deleteProperty(global, '__AppDataV2Catalog')) {
+ throw new Error('AppData v2 failed to close its catalog bootstrap channel');
+ }
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/data/v2/dataCatalog.js b/js/data/v2/dataCatalog.js
new file mode 100644
index 00000000..648ccad3
--- /dev/null
+++ b/js/data/v2/dataCatalog.js
@@ -0,0 +1,230 @@
+(function installDataCatalog(global) {
+ 'use strict';
+
+ const V2_SCHEMA_VERSION = 2;
+
+ function clone(value) {
+ if (value === undefined) return undefined;
+ if (typeof structuredClone === 'function') {
+ try { return structuredClone(value); } catch (_) { /* fall through */ }
+ }
+ return JSON.parse(JSON.stringify(value));
+ }
+
+ function objectDefault() { return {}; }
+ function arrayDefault() { return []; }
+ function nullableDefault() { return null; }
+ function normalizeArray(value) { return Array.isArray(value) ? clone(value) : []; }
+ function normalizeObject(value) {
+ return value && typeof value === 'object' && !Array.isArray(value) ? clone(value) : {};
+ }
+ function normalizeNullableString(value) {
+ return value === null || value === undefined || value === '' ? null : String(value);
+ }
+ function isArray(value) { return Array.isArray(value); }
+ function isObject(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
+ function isNullableString(value) { return value === null || typeof value === 'string'; }
+
+ const CATALOG_OWNERS = new Set([
+ 'settings', 'library', 'recovery', 'backups', 'vocab',
+ 'preferences', 'goals', 'achievements', 'system', 'practice'
+ ]);
+ const CATALOG_CLASSIFICATIONS = new Set(['authoritative', 'preference', 'session', 'system']);
+ const IMPORT_POLICIES = new Set(['replace', 'patch', 'merge-by-id', 'ignore']);
+
+ function isNonEmptyString(value) {
+ return typeof value === 'string' && Boolean(value.trim());
+ }
+
+ function ownerFromKey(logicalKey) {
+ const dot = String(logicalKey || '').indexOf('.');
+ return dot > 0 ? logicalKey.slice(0, dot) : '';
+ }
+
+ function freezeEntry(entry) {
+ const logicalKey = String(entry.logicalKey || '');
+ const owner = ownerFromKey(logicalKey);
+ const next = Object.assign({}, entry, {
+ logicalKey,
+ owner,
+ schemaVersion: V2_SCHEMA_VERSION,
+ export: entry.export === true,
+ import: entry.import || 'ignore'
+ });
+ return Object.freeze(next);
+ }
+
+ // Minimal document catalog. Practice lives in entity stores (summaries/details/annotations),
+ // not as document keys. import merge identity is resolved in AppData, not here.
+ const definitions = [
+ {
+ logicalKey: 'settings.values', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.configurations', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'library.importedIndexes', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'library.activeConfigurationId', classification: 'authoritative',
+ defaultValue: nullableDefault, normalize: normalizeNullableString, validate: isNullableString,
+ export: true, import: 'replace'
+ },
+ {
+ logicalKey: 'recovery.activeSessions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.drafts', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.interrupted', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.rejectedCompletions', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'recovery.windowSession', classification: 'session',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.entries', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'backups.settings', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'backups.exportHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'backups.importHistory', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'vocab.words', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'vocab.userConfig', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'vocab.lists', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'preferences.values', classification: 'preference',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'goals.items', classification: 'authoritative',
+ defaultValue: arrayDefault, normalize: normalizeArray, validate: isArray,
+ export: true, import: 'merge-by-id'
+ },
+ {
+ logicalKey: 'achievements.manual', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'achievements.progress', classification: 'authoritative',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: true, import: 'patch'
+ },
+ {
+ logicalKey: 'system.migrations', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ },
+ {
+ logicalKey: 'system.operationJournal', classification: 'system',
+ defaultValue: objectDefault, normalize: normalizeObject, validate: isObject,
+ export: false, import: 'ignore'
+ }
+ ].map(freezeEntry);
+
+ function validateCatalog(entries) {
+ if (!Array.isArray(entries) || !entries.length) throw new Error('DataCatalog requires at least one entry');
+ const logicalKeys = new Set();
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ throw new Error('DataCatalog entry must be an object');
+ }
+ if (!isNonEmptyString(entry.logicalKey) || logicalKeys.has(entry.logicalKey)) {
+ throw new Error(`DataCatalog duplicate/invalid logical key: ${entry.logicalKey}`);
+ }
+ logicalKeys.add(entry.logicalKey);
+ }
+ for (const entry of entries) {
+ if (!CATALOG_OWNERS.has(entry.owner) || !entry.logicalKey.startsWith(`${entry.owner}.`)) {
+ throw new Error(`DataCatalog owner conflict for ${entry.logicalKey}: ${entry.owner}`);
+ }
+ if (!CATALOG_CLASSIFICATIONS.has(entry.classification)
+ || !Number.isInteger(entry.schemaVersion) || entry.schemaVersion !== V2_SCHEMA_VERSION
+ || typeof entry.defaultValue !== 'function'
+ || typeof entry.normalize !== 'function'
+ || typeof entry.validate !== 'function'
+ || typeof entry.export !== 'boolean'
+ || !IMPORT_POLICIES.has(entry.import)) {
+ throw new Error(`DataCatalog incomplete contract: ${entry.logicalKey}`);
+ }
+ try {
+ const defaultValue = entry.defaultValue();
+ if (!entry.validate(defaultValue) || !entry.validate(entry.normalize(defaultValue))) {
+ throw new Error('invalid default');
+ }
+ } catch (_) {
+ throw new Error(`DataCatalog invalid default contract: ${entry.logicalKey}`);
+ }
+ }
+ return true;
+ }
+
+ validateCatalog(definitions);
+ const byKey = new Map(definitions.map((entry) => [entry.logicalKey, entry]));
+ const DataCatalog = Object.freeze({
+ version: V2_SCHEMA_VERSION,
+ list() { return definitions.slice(); },
+ get(logicalKey) {
+ const entry = byKey.get(String(logicalKey || ''));
+ if (!entry) throw new Error(`DataCatalog unknown logical key: ${logicalKey}`);
+ return entry;
+ },
+ has(logicalKey) { return byKey.has(String(logicalKey || '')); },
+ validate: validateCatalog,
+ clone
+ });
+
+ Object.defineProperty(global, '__AppDataV2Catalog', {
+ value: DataCatalog,
+ enumerable: false,
+ configurable: true,
+ writable: false
+ });
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/data/v2/dataKernel.js b/js/data/v2/dataKernel.js
new file mode 100644
index 00000000..dc04f861
--- /dev/null
+++ b/js/data/v2/dataKernel.js
@@ -0,0 +1,888 @@
+(function installDataKernel(global) {
+ 'use strict';
+
+ if (global.AppData) return;
+
+ const catalog = global.__AppDataV2Catalog;
+ if (!catalog) throw new Error('AppData v2 requires DataCatalog before DataKernel');
+
+ const DATABASE_NAME = 'IELTSAtlasDataV2';
+ // Version 2 uses a new schema, but initialization must still import the durable
+ // ExamSystemDB data owned by releases which predate AppData v2.
+ const DATABASE_VERSION = 2;
+ const DOCUMENT_STORE = 'documents';
+ const SYSTEM_STORE = 'system';
+ const ENTITY_STORES = Object.freeze(['practiceSummaries', 'practiceDetails', 'practiceAnnotations']);
+ const STORE_NAMES = Object.freeze([DOCUMENT_STORE, SYSTEM_STORE].concat(ENTITY_STORES));
+ const OPERATION_JOURNAL_WINDOW = 500;
+ const COMMIT_CHANNEL_NAME = `${DATABASE_NAME}:committed`;
+ const DEFAULT_IDB_MUTATION_TIMEOUT_MS = 30000;
+ const DEFAULT_IDB_REQUEST_TIMEOUT_MS = 30000;
+ const MAX_TIMER_DELAY_MS = 2147483647;
+ const LEGACY_DATABASE_NAME = 'ExamSystemDB';
+ const LEGACY_STORE_NAME = 'keyValueStore';
+ const LEGACY_EXTERNAL_DATABASE_NAME = 'ExamSystemExternalBackup';
+ const LEGACY_EXTERNAL_STORE_NAME = 'handles';
+ const LEGACY_EXTERNAL_HANDLE_KEY = 'backup_directory';
+ const LEGACY_EXTERNAL_FILENAME = 'practice-backup-latest.json';
+ const LEGACY_UNPREFIXED_WEB_KEYS = Object.freeze([
+ 'practice_records',
+ 'vocab_user_config',
+ 'user_achievements'
+ ]);
+
+ function clone(value) { return catalog.clone(value); }
+ function nowIso() { return new Date().toISOString(); }
+ function randomId(prefix) {
+ const random = global.crypto && typeof global.crypto.randomUUID === 'function'
+ ? global.crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`;
+ return `${prefix || 'op'}_${random}`;
+ }
+
+ class AppDataError extends Error {
+ constructor(code, message, details = {}) {
+ super(message);
+ this.name = 'AppDataError';
+ this.code = code;
+ this.committed = false;
+ this.details = details;
+ }
+ }
+ function validation(message, details) { return new AppDataError('VALIDATION', message, details || {}); }
+ function corruption(message, details) { return new AppDataError('CORRUPT_RECORD', message, details || {}); }
+
+ function normalizeTimeoutMs(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, MAX_TIMER_DELAY_MS) : fallback;
+ }
+ function scheduleTimeout(handler, delayMs) {
+ if (typeof global.setTimeout !== 'function') throw new Error('setTimeout unavailable');
+ const handle = global.setTimeout.call(global, handler, delayMs);
+ if (handle === null || handle === undefined) throw new Error('setTimeout did not return a handle');
+ return handle;
+ }
+ function cancelTimeout(handle) {
+ if (handle !== null && handle !== undefined && typeof global.clearTimeout === 'function') {
+ try { global.clearTimeout.call(global, handle); } catch (_) { /* already gone */ }
+ }
+ }
+ function withDeadline(handle, timeoutMs, description, resolve, reject) {
+ let settled = false;
+ let timer = null;
+ const settle = (callback, value) => {
+ if (settled) return;
+ settled = true;
+ cancelTimeout(timer);
+ callback(value);
+ };
+ const expire = (error) => {
+ if (settled) return;
+ settled = true;
+ try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) { /* best effort */ }
+ reject(error);
+ };
+ try {
+ timer = scheduleTimeout(() => expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} timed out after ${timeoutMs}ms`, {
+ operation: description, timeoutMs, reason: 'timeout'
+ })), timeoutMs);
+ } catch (error) {
+ expire(new AppDataError('BACKEND_UNAVAILABLE', `IndexedDB ${description} watchdog unavailable`, {
+ operation: description, reason: 'watchdog-unavailable', cause: error && error.message
+ }));
+ }
+ return { resolve(value) { settle(resolve, value); }, reject(error) { settle(reject, error); } };
+ }
+
+ function canonicalizeJson(value, path = '$', ancestors = new Set()) {
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) throw validation(`Non-finite number at ${path}`, { path });
+ return Object.is(value, -0) ? 0 : value;
+ }
+ if (typeof value !== 'object' || value === undefined || typeof value === 'bigint' || typeof value === 'function' || typeof value === 'symbol') {
+ throw validation(`Non-JSON value at ${path}`, { path, type: typeof value });
+ }
+ if (ancestors.has(value)) throw validation(`Cyclic data at ${path}`, { path });
+ const prototype = Object.getPrototypeOf(value);
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw validation(`Non-plain object at ${path}`, { path });
+ if (typeof Reflect === 'object' && typeof Reflect.ownKeys === 'function'
+ && Reflect.ownKeys(value).some((key) => typeof key === 'symbol')) {
+ throw validation(`Symbol-keyed property at ${path}`, { path });
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ return value.map((item, index) => {
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw validation(`Sparse array entry at ${path}[${index}]`, { path });
+ return canonicalizeJson(item, `${path}[${index}]`, ancestors);
+ });
+ }
+ const result = {};
+ for (const key of Object.keys(value).sort()) {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || descriptor.get || descriptor.set) throw validation(`Accessor property at ${path}.${key}`, { path });
+ result[key] = canonicalizeJson(descriptor.value, `${path}.${key}`, ancestors);
+ }
+ return result;
+ } finally { ancestors.delete(value); }
+ }
+ function stableStringifyCanonical(value) {
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
+ if (Array.isArray(value)) return `[${value.map(stableStringifyCanonical).join(',')}]`;
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyCanonical(value[key])}`).join(',')}}`;
+ }
+ function stableStringify(value) { return stableStringifyCanonical(canonicalizeJson(value)); }
+ function checksum(value) {
+ const input = stableStringify(value);
+ let hash = 2166136261;
+ for (let index = 0; index < input.length; index += 1) { hash ^= input.charCodeAt(index); hash = Math.imul(hash, 16777619); }
+ return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+ }
+ function legacyTimestamp(value) {
+ if (value === null || value === undefined || (typeof value === 'string' && !value.trim())) return -Infinity;
+ if (Number.isFinite(Number(value))) return Number(value);
+ const parsed = Date.parse(value == null ? '' : String(value));
+ return Number.isFinite(parsed) ? parsed : -Infinity;
+ }
+ function parseLegacyCandidate(value, outerTimestamp) {
+ let parsed = value;
+ let timestamp = legacyTimestamp(outerTimestamp);
+ const hasOuterTimestamp = timestamp !== -Infinity;
+ for (let depth = 0; depth < 3; depth += 1) {
+ if (typeof parsed === 'string') {
+ try { parsed = JSON.parse(parsed); } catch (_) { if (depth === 0) return null; break; }
+ } else if (parsed && typeof parsed === 'object' && Object.prototype.hasOwnProperty.call(parsed, 'data')
+ && (Object.prototype.hasOwnProperty.call(parsed, 'version') || Object.prototype.hasOwnProperty.call(parsed, 'compressed'))) {
+ const innerTimestamp = legacyTimestamp(parsed.timestamp);
+ if (!hasOuterTimestamp && innerTimestamp > timestamp) timestamp = innerTimestamp;
+ parsed = parsed.data;
+ } else break;
+ }
+ return { value: clone(parsed), timestamp };
+ }
+ function parseLegacyValue(value) {
+ const candidate = parseLegacyCandidate(value);
+ return candidate ? candidate.value : clone(value);
+ }
+ async function readLegacyValues(indexedDBApi = global.indexedDB, storage = global.localStorage, sessionStorageApi = global.sessionStorage) {
+ const values = {};
+ const candidates = {};
+ let readComplete = true;
+ const consider = (alias, rawValue, timestamp, sourceRank) => {
+ const candidate = parseLegacyCandidate(rawValue, timestamp);
+ if (!candidate) return;
+ const previous = candidates[alias];
+ if (!previous || candidate.timestamp > previous.timestamp
+ || (candidate.timestamp === previous.timestamp && sourceRank < previous.sourceRank)) {
+ candidates[alias] = Object.assign(candidate, { sourceRank });
+ }
+ };
+ if (indexedDBApi && typeof indexedDBApi.open === 'function') {
+ await new Promise((resolve) => {
+ let request;
+ let createdEmptyDatabase = false;
+ try { request = indexedDBApi.open(LEGACY_DATABASE_NAME); } catch (_) { readComplete = false; resolve(); return; }
+ request.onerror = () => { if (!createdEmptyDatabase) readComplete = false; resolve(); };
+ request.onupgradeneeded = () => {
+ createdEmptyDatabase = true;
+ try { request.transaction.abort(); } catch (_) {}
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { db.close(); resolve(); return; }
+ const tx = db.transaction(LEGACY_STORE_NAME, 'readonly');
+ const keys = tx.objectStore(LEGACY_STORE_NAME).getAllKeys();
+ const rows = tx.objectStore(LEGACY_STORE_NAME).getAll();
+ tx.oncomplete = () => {
+ (keys.result || []).forEach((key, index) => {
+ const row = (rows.result || [])[index];
+ // v1's keyValueStore persisted { key, value, timestamp } rows.
+ const validRow = row && typeof row === 'object'
+ && Object.prototype.hasOwnProperty.call(row, 'key')
+ && String(row.key) === String(key)
+ && Object.prototype.hasOwnProperty.call(row, 'value');
+ if (!validRow) {
+ readComplete = false;
+ return;
+ }
+ consider(String(key).replace(/^exam_system_/, ''), row.value, row.timestamp, 0);
+ });
+ db.close(); resolve();
+ };
+ tx.onerror = tx.onabort = () => { readComplete = false; db.close(); resolve(); };
+ };
+ });
+ }
+ for (const [sourceRank, fallbackStorage] of [storage, sessionStorageApi].entries()) {
+ if (!fallbackStorage || typeof fallbackStorage.key !== 'function') continue;
+ for (let index = 0; index < Number(fallbackStorage.length || 0); index += 1) {
+ const key = fallbackStorage.key(index);
+ if (!key) continue;
+ const alias = key.startsWith('exam_system_')
+ ? key.slice('exam_system_'.length)
+ : (LEGACY_UNPREFIXED_WEB_KEYS.includes(key) ? key : null);
+ if (!alias) continue;
+ try { consider(alias, fallbackStorage.getItem(key), null, sourceRank + 1); } catch (_) { /* inaccessible fallback */ }
+ }
+ }
+ for (const [alias, candidate] of Object.entries(candidates)) values[alias] = candidate.value;
+ Object.defineProperty(values, '__legacyReadComplete', {
+ value: readComplete,
+ enumerable: false,
+ configurable: false,
+ writable: false
+ });
+ return values;
+ }
+ async function readLegacyExternalBackup(indexedDBApi = global.indexedDB) {
+ if (!indexedDBApi || typeof indexedDBApi.open !== 'function') return null;
+ const directoryHandle = await new Promise((resolve) => {
+ let request;
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value || null);
+ };
+ try { request = indexedDBApi.open(LEGACY_EXTERNAL_DATABASE_NAME); } catch (_) { finish(null); return; }
+ request.onerror = () => finish(null);
+ request.onupgradeneeded = () => {
+ try { request.transaction.abort(); } catch (_) {}
+ finish(null);
+ };
+ request.onsuccess = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(LEGACY_EXTERNAL_STORE_NAME)) {
+ db.close(); finish(null); return;
+ }
+ const get = db.transaction(LEGACY_EXTERNAL_STORE_NAME, 'readonly')
+ .objectStore(LEGACY_EXTERNAL_STORE_NAME).get(LEGACY_EXTERNAL_HANDLE_KEY);
+ get.onerror = () => { db.close(); finish(null); };
+ get.onsuccess = () => { db.close(); finish(get.result); };
+ };
+ });
+ if (!directoryHandle || typeof directoryHandle.queryPermission !== 'function') return null;
+ if (await directoryHandle.queryPermission({ mode: 'read' }) !== 'granted') return null;
+ const fileHandle = await directoryHandle.getFileHandle(LEGACY_EXTERNAL_FILENAME, { create: false });
+ const parsed = JSON.parse(await (await fileHandle.getFile()).text());
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.data !== undefined
+ ? parsed.data : parsed;
+ return payload && typeof payload === 'object' && !Array.isArray(payload) ? clone(payload) : null;
+ }
+ function lookupEntry(logicalKey) {
+ if (!catalog.has(logicalKey)) throw validation(`Unknown AppData logical key: ${logicalKey}`, { logicalKey });
+ return catalog.get(logicalKey);
+ }
+ function storeFor(logicalKey) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'session') throw validation(`${logicalKey} is not durable kernel data`, { logicalKey });
+ return entry.classification === 'system' ? SYSTEM_STORE : DOCUMENT_STORE;
+ }
+ function makeEnvelope(entry, data, options = {}) {
+ const state = options.state === 'cleared' ? 'cleared' : 'present';
+ if (options.state !== undefined && state !== options.state) throw validation(`Invalid envelope state for ${entry.logicalKey}`);
+ let normalized = null;
+ if (state === 'present') {
+ try { normalized = options.normalized ? data : entry.normalize(canonicalizeJson(data, `$.${entry.logicalKey}`)); } catch (error) {
+ throw validation(`Unable to normalize ${entry.logicalKey}`, { cause: error && error.message });
+ }
+ normalized = canonicalizeJson(normalized, `$.${entry.logicalKey}`);
+ if (!entry.validate(normalized)) throw validation(`Invalid data for ${entry.logicalKey}`, { logicalKey: entry.logicalKey });
+ }
+ const revision = options.revision === undefined ? 1 : Number(options.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid revision for ${entry.logicalKey}`);
+ const payload = { schemaVersion: entry.schemaVersion, revision, operationId: String(options.operationId || randomId('op')),
+ updatedAt: options.updatedAt || nowIso(), state, data: normalized };
+ payload.checksum = checksum(payload.data);
+ return Object.freeze(payload);
+ }
+ function validateEnvelope(entry, envelope) {
+ try {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)
+ || Number(envelope.schemaVersion) !== Number(entry.schemaVersion)
+ || !Number.isInteger(Number(envelope.revision)) || Number(envelope.revision) < 1
+ || typeof envelope.operationId !== 'string' || !envelope.operationId
+ || typeof envelope.updatedAt !== 'string' || !envelope.updatedAt
+ || (envelope.state !== 'present' && envelope.state !== 'cleared')) return false;
+ const data = canonicalizeJson(envelope.data, `$.${entry.logicalKey}`);
+ return (envelope.state !== 'cleared' || data === null)
+ && (envelope.state !== 'present' || entry.validate(data)) && envelope.checksum === checksum(data);
+ } catch (_) { return false; }
+ }
+ function operationId(value) {
+ if (value === undefined || value === null || value === '') return randomId('mutation');
+ if (typeof value !== 'string' || !value.trim()) throw validation('operationId must be a non-empty string');
+ return value;
+ }
+ function expectedRevision(value, label) {
+ if (value === undefined || value === null) return null;
+ const revision = Number(value);
+ if (!Number.isInteger(revision) || revision < 0) throw validation(`Invalid expectedRevision for ${label}`);
+ return revision;
+ }
+ function compactJournal(journal) {
+ const ranked = Object.entries(journal).sort((left, right) => Number(right[1].sequence) - Number(left[1].sequence));
+ for (let index = OPERATION_JOURNAL_WINDOW; index < ranked.length; index += 1) delete journal[ranked[index][0]];
+ }
+ function readJournal(row) {
+ const envelope = row && row.envelope;
+ return envelope && envelope.state === 'present' && envelope.data && typeof envelope.data === 'object' && !Array.isArray(envelope.data)
+ ? clone(envelope.data) : {};
+ }
+ function journalResult(journal, spec) {
+ const existing = journal[spec.operationId];
+ if (!existing) return null;
+ if (existing.fingerprint !== spec.fingerprint || !existing.receipt) {
+ throw new AppDataError('CONFLICT', `operationId is already bound to another request: ${spec.operationId}`, { operationId: spec.operationId });
+ }
+ return clone(existing.receipt);
+ }
+ function writeJournal(journal, spec, receipt) {
+ const sequence = Object.values(journal).reduce((maximum, item) => Math.max(maximum, Number(item.sequence) || 0), 0) + 1;
+ journal[spec.operationId] = { fingerprint: spec.fingerprint, receipt: clone(receipt), sequence, committedAt: nowIso() };
+ compactJournal(journal);
+ return journal;
+ }
+ function putJournal(tx, currentRow, journal, spec, receipt) {
+ const current = currentRow && currentRow.envelope;
+ const envelope = makeEnvelope(lookupEntry('system.operationJournal'), writeJournal(journal, spec, receipt), {
+ revision: current ? Number(current.revision) + 1 : 1,
+ operationId: spec.operationId,
+ normalized: true
+ });
+ tx.objectStore(SYSTEM_STORE).put({ logicalKey: 'system.operationJournal', envelope: canonicalizeJson(envelope) });
+ }
+
+ class IndexedDBDriver {
+ constructor(indexedDBApi, options) {
+ this.indexedDB = indexedDBApi;
+ this.db = null;
+ this.mutationTimeoutMs = options.mutationTimeoutMs;
+ this.requestTimeoutMs = options.requestTimeoutMs;
+ }
+ async initialize() {
+ if (!this.indexedDB || typeof this.indexedDB.open !== 'function') throw new Error('IndexedDB unavailable');
+ this.db = await new Promise((resolve, reject) => {
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ let abandoned = false;
+ let settle;
+ request.onsuccess = () => { if (abandoned || !settle) { try { request.result.close(); } catch (_) {} } else settle.resolve(request.result); };
+ request.onupgradeneeded = (event) => {
+ const db = request.result;
+ if (event.oldVersion < 2) {
+ for (const name of ['authoritative', 'derived']) {
+ if (db.objectStoreNames.contains(name)) db.deleteObjectStore(name);
+ }
+ }
+ for (const name of STORE_NAMES) {
+ if (!db.objectStoreNames.contains(name)) db.createObjectStore(name, { keyPath: name === DOCUMENT_STORE || name === SYSTEM_STORE ? 'logicalKey' : 'recordId' });
+ }
+ };
+ settle = withDeadline({ abort() { abandoned = true; } }, this.requestTimeoutMs, 'open', resolve, reject);
+ request.onerror = () => settle.reject(request.error || new Error('Unable to open IndexedDB'));
+ request.onblocked = () => {
+ abandoned = true;
+ settle.reject(new Error('IndexedDB upgrade blocked'));
+ };
+ });
+ this.db.onversionchange = () => this.close();
+ return this;
+ }
+ close() { const db = this.db; this.db = null; try { if (db) db.close(); } catch (_) {} }
+ _open() { if (!this.db) throw new Error('IndexedDB connection closed'); }
+ _transaction(stores, mode, description, work, mutation = false) {
+ this._open();
+ return new Promise((resolve, reject) => {
+ let failure = null;
+ let value;
+ let tx;
+ try { tx = this.db.transaction(stores, mode); } catch (error) { reject(error); return; }
+ const settle = withDeadline(tx, mutation ? this.mutationTimeoutMs : this.requestTimeoutMs, description, resolve, reject);
+ tx.oncomplete = () => settle.resolve(clone(value));
+ tx.onerror = () => { failure = failure || tx.error || new Error(`IndexedDB ${description} failed`); };
+ tx.onabort = () => settle.reject(failure || tx.error || new Error(`IndexedDB ${description} aborted`));
+ const fail = (error) => { failure = failure || error; try { tx.abort(); } catch (_) {} };
+ try { work(tx, (result) => { value = result; }, fail); } catch (error) { fail(error); }
+ });
+ }
+ readEnvelope(logicalKey) {
+ const store = storeFor(logicalKey);
+ return this._transaction([store], 'readonly', `read ${logicalKey}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(logicalKey);
+ request.onsuccess = () => done(request.result ? request.result.envelope : null);
+ request.onerror = () => fail(request.error || new Error(`Read failed: ${logicalKey}`));
+ });
+ }
+ readEntity(store, recordId) {
+ return this._transaction([store], 'readonly', `read ${store}/${recordId}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).get(recordId);
+ request.onsuccess = () => done(request.result || null);
+ request.onerror = () => fail(request.error || new Error('Entity read failed'));
+ });
+ }
+ readPracticeSnapshot(recordIds = null, options = {}) {
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES.slice();
+ const requested = recordIds === null || recordIds === undefined
+ ? null
+ : new Set((Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean));
+ return this._transaction(stores, 'readonly', 'read practice snapshot', (tx, done, fail) => {
+ const result = Object.fromEntries(stores.map((store) => [store, []]));
+ let remaining = stores.length;
+ const finishStore = (store, rows) => {
+ result[store] = (rows || []).filter((row) => !requested || requested.has(String(row && row.recordId || '')));
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ for (const store of stores) {
+ const objectStore = tx.objectStore(store);
+ const request = requested && requested.size === 1
+ ? objectStore.get(Array.from(requested)[0])
+ : objectStore.getAll();
+ request.onsuccess = () => {
+ const rows = requested && requested.size === 1
+ ? (request.result ? [request.result] : [])
+ : request.result;
+ finishStore(store, rows);
+ };
+ request.onerror = () => fail(request.error || new Error(`Practice snapshot read failed: ${store}`));
+ }
+ });
+ }
+ listEntities(store) {
+ return this._transaction([store], 'readonly', `list ${store}`, (tx, done, fail) => {
+ const request = tx.objectStore(store).getAll();
+ request.onsuccess = () => done(request.result || []);
+ request.onerror = () => fail(request.error || new Error('Entity list failed'));
+ });
+ }
+ atomic(spec) {
+ return this._transaction(spec.stores, 'readwrite', `mutation ${spec.operationId}`, (tx, done, fail) => {
+ const journalRequest = tx.objectStore(SYSTEM_STORE).get('system.operationJournal');
+ journalRequest.onerror = () => fail(journalRequest.error || new Error('Journal read failed'));
+ journalRequest.onsuccess = () => {
+ try { spec.apply(tx, journalRequest.result || null, readJournal(journalRequest.result), done, fail); } catch (error) { fail(error); }
+ };
+ }, true);
+ }
+ exportSnapshot(envelopeKeys) {
+ return this._transaction(STORE_NAMES, 'readonly', 'snapshot export', (tx, done, fail) => {
+ const result = { envelopes: {}, entities: {} };
+ let remaining = STORE_NAMES.length;
+ for (const store of STORE_NAMES) {
+ const request = tx.objectStore(store).getAll();
+ request.onerror = () => fail(request.error || new Error(`Snapshot read failed: ${store}`));
+ request.onsuccess = () => {
+ if (store === DOCUMENT_STORE || store === SYSTEM_STORE) {
+ for (const row of request.result || []) if (envelopeKeys(row.logicalKey)) result.envelopes[row.logicalKey] = row.envelope;
+ } else result.entities[store] = request.result || [];
+ remaining -= 1;
+ if (!remaining) done(result);
+ };
+ }
+ });
+ }
+ }
+
+ function entityStore(store) {
+ const value = String(store || '');
+ if (!ENTITY_STORES.includes(value)) throw validation(`Unknown entity store: ${value}`, { store: value });
+ return value;
+ }
+ function validateEntityRow(store, row) {
+ if (!row || typeof row !== 'object' || Array.isArray(row)
+ || typeof row.recordId !== 'string' || !row.recordId
+ || !Number.isInteger(Number(row.revision)) || Number(row.revision) < 1
+ || typeof row.operationId !== 'string' || !row.operationId
+ || typeof row.updatedAt !== 'string' || !row.updatedAt) {
+ throw corruption(`Invalid entity row: ${store}`, { store, recordId: row && row.recordId || null });
+ }
+ const data = canonicalizeJson(row.data, `$.${store}.${row.recordId}`);
+ if (row.checksum !== checksum(data)) {
+ throw corruption(`Entity checksum mismatch: ${store}/${row.recordId}`, { store, recordId: row.recordId });
+ }
+ return row;
+ }
+ function normalizeEntityOperation(operation, index) {
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) throw validation(`Invalid entity operation at index ${index}`);
+ const type = String(operation.type || '');
+ const store = entityStore(operation.store);
+ if (!['upsert', 'delete', 'clear'].includes(type)) throw validation(`Invalid entity operation type: ${type}`);
+ const recordId = type === 'clear' ? null : String(operation.recordId || '');
+ if (type !== 'clear' && !recordId.trim()) throw validation(`Entity operation ${type} requires recordId`);
+ const data = type === 'upsert' ? canonicalizeJson(operation.data, `$.operations[${index}].data`) : null;
+ return { type, store, recordId, data, expectedRevision: expectedRevision(operation.expectedRevision, `${store}/${recordId || '*'}`) };
+ }
+ function receiptFor(operationIdValue, revisions, warnings, pending) {
+ const receipt = { committed: true, revisions, operationId: operationIdValue,
+ derived: { status: pending.length ? 'pending' : 'ready', pending: pending.slice() }, warnings: warnings.slice() };
+ const keys = Object.keys(revisions); if (keys.length === 1) receipt.revision = revisions[keys[0]];
+ return receipt;
+ }
+
+ class DataKernel {
+ constructor(options = {}) {
+ this.driver = null;
+ this.backend = null;
+ this.state = 'created';
+ this.failure = null;
+ this.indexedDB = Object.prototype.hasOwnProperty.call(options, 'indexedDB') ? options.indexedDB : global.indexedDB;
+ this.indexedDBMutationTimeoutMs = normalizeTimeoutMs(options.indexedDBMutationTimeoutMs, DEFAULT_IDB_MUTATION_TIMEOUT_MS);
+ this.indexedDBRequestTimeoutMs = normalizeTimeoutMs(options.indexedDBRequestTimeoutMs, DEFAULT_IDB_REQUEST_TIMEOUT_MS);
+ this.committedListeners = new Set();
+ this.commitChannel = null;
+ this.instanceId = randomId('kernel');
+ this.ready = null;
+ }
+ _initializeCommitChannel() {
+ if (this.commitChannel || typeof global.BroadcastChannel !== 'function') return;
+ try {
+ const channel = new global.BroadcastChannel(COMMIT_CHANNEL_NAME);
+ channel.onmessage = (message) => {
+ const data = message && message.data;
+ if (!data || data.sourceInstanceId === this.instanceId
+ || typeof data.operationId !== 'string' || !Array.isArray(data.targets)) return;
+ this._dispatchCommitted({
+ operationId: data.operationId,
+ targets: clone(data.targets),
+ receipt: data.receipt ? clone(data.receipt) : null,
+ remote: true
+ });
+ };
+ this.commitChannel = channel;
+ } catch (_) {
+ this.commitChannel = null;
+ }
+ }
+ _closeCommitChannel() {
+ const channel = this.commitChannel;
+ this.commitChannel = null;
+ try { if (channel) channel.close(); } catch (_) {}
+ }
+ initialize() {
+ if (this.ready) return this.ready;
+ this.state = 'initializing';
+ this.ready = new IndexedDBDriver(this.indexedDB, {
+ mutationTimeoutMs: this.indexedDBMutationTimeoutMs, requestTimeoutMs: this.indexedDBRequestTimeoutMs
+ }).initialize().then((driver) => {
+ this.driver = driver; this.backend = 'indexeddb-v2'; this.state = 'ready'; this._initializeCommitChannel(); return this;
+ }).catch((error) => { this.state = 'failed'; this.failure = error; this.driver = null; this.backend = null;
+ throw error instanceof AppDataError ? error : new AppDataError('BACKEND_UNAVAILABLE', 'IndexedDB is required for AppData v2', { cause: error && error.message }); });
+ return this.ready;
+ }
+ close() {
+ if (this.driver) this.driver.close();
+ this.driver = null; this.backend = null;
+ this._closeCommitChannel();
+ if (this.state !== 'failed') this.state = 'closed';
+ }
+ _assertReady() {
+ if (this.state === 'failed') throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 backend failed', { cause: this.failure && this.failure.message });
+ if (this.state !== 'ready' || !this.driver) throw new AppDataError('BACKEND_UNAVAILABLE', 'AppData v2 is not initialized');
+ }
+ _latch(error) {
+ if (error && (error.name === 'QuotaExceededError' || error.code === 22)) return new AppDataError('QUOTA_EXCEEDED', 'IndexedDB write failed: storage quota exceeded', { cause: error.message });
+ this.state = 'failed'; this.failure = error; if (this.driver) this.driver.close(); this.driver = null; this.backend = null; this._closeCommitChannel();
+ return new AppDataError('BACKEND_UNAVAILABLE', 'Active IndexedDB backend failed; reload is required', { cause: error && error.message });
+ }
+ onCommitted(listener) {
+ if (typeof listener !== 'function') throw validation('Committed listener must be a function');
+ this.committedListeners.add(listener); return () => this.committedListeners.delete(listener);
+ }
+ _dispatchCommitted(event) {
+ if (!event || !this.committedListeners.size) return;
+ const schedule = typeof global.queueMicrotask === 'function' ? global.queueMicrotask.bind(global) : (callback) => Promise.resolve().then(callback);
+ schedule(() => Array.from(this.committedListeners).forEach((listener) => { try { Promise.resolve(listener(clone(event))).catch(() => {}); } catch (_) {} }));
+ }
+ _notifyCommitted(targets, receipt) {
+ if (!targets.length) return;
+ const event = { operationId: receipt.operationId, targets: clone(targets), receipt: clone(receipt), remote: false };
+ this._dispatchCommitted(event);
+ if (this.commitChannel) {
+ try {
+ this.commitChannel.postMessage({
+ sourceInstanceId: this.instanceId,
+ operationId: event.operationId,
+ targets: event.targets,
+ receipt: event.receipt
+ });
+ } catch (_) { /* cross-realm notification is best effort */ }
+ }
+ }
+ async getEnvelope(logicalKey) {
+ this._assertReady(); const entry = lookupEntry(logicalKey);
+ try { const envelope = await this.driver.readEnvelope(logicalKey); if (envelope && !validateEnvelope(entry, envelope)) throw corruption(`Invalid envelope: ${logicalKey}`, { logicalKey }); return envelope; }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async read(logicalKey, options = {}) {
+ const entry = lookupEntry(logicalKey); const envelope = await this.getEnvelope(logicalKey);
+ const data = !envelope || envelope.state === 'cleared' ? entry.defaultValue() : envelope.data;
+ return options.withMeta ? { data: clone(data), envelope: envelope ? clone(envelope) : null } : clone(data);
+ }
+ _documentSpec(changes, options) {
+ if (!Array.isArray(changes) || (!changes.length && !options.allowNoop && !options.noop)) throw validation('DataKernel.mutate requires changes');
+ const opId = operationId(options.operationId); const seen = new Set();
+ const prepared = changes.map((change, index) => {
+ if (!change || typeof change !== 'object' || Array.isArray(change)) throw validation(`Invalid mutation change at index ${index}`);
+ const logicalKey = String(change.logicalKey || ''); const entry = lookupEntry(logicalKey);
+ if (logicalKey === 'system.operationJournal') throw validation('system.operationJournal is managed by DataKernel');
+ if (seen.has(logicalKey)) throw validation(`Duplicate mutation key: ${logicalKey}`); seen.add(logicalKey);
+ const state = change.state === 'cleared' ? 'cleared' : 'present';
+ if (change.state !== undefined && state !== change.state) throw validation(`Invalid mutation state for ${logicalKey}`);
+ if (state === 'cleared' && entry.classification === 'system') throw validation(`${logicalKey} cannot be cleared`);
+ let data = null;
+ if (state === 'present') { try { data = canonicalizeJson(entry.normalize(canonicalizeJson(change.data)), '$.data'); } catch (error) { throw validation(`Unable to normalize ${logicalKey}`, { cause: error && error.message }); } if (!entry.validate(data)) throw validation(`Invalid data for ${logicalKey}`); }
+ return { logicalKey, entry, state, data, expectedRevision: expectedRevision(change.expectedRevision, logicalKey) };
+ });
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const fingerprint = checksum({ changes: prepared.map((item) => ({ logicalKey: item.logicalKey, state: item.state, data: item.data, expectedRevision: item.expectedRevision })), warnings });
+ return { operationId: opId, changes: prepared, pending: [], warnings, fingerprint, stores: Array.from(new Set([SYSTEM_STORE].concat(prepared.map((item) => storeFor(item.logicalKey))))) };
+ }
+ async mutate(changes, options = {}) {
+ this._assertReady(); const spec = this._documentSpec(changes, options);
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = spec.changes.map((change) => ({ change, request: tx.objectStore(storeFor(change.logicalKey)).get(change.logicalKey) }));
+ let remaining = reads.length;
+ const finish = () => {
+ const revisions = {};
+ for (const item of reads) {
+ const current = item.request.result ? item.request.result.envelope : null;
+ if (current && !validateEnvelope(item.change.entry, current)) throw corruption(`Invalid stored envelope: ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey });
+ const revision = current ? Number(current.revision) : 0;
+ if (item.change.expectedRevision !== null && item.change.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${item.change.logicalKey}`, { logicalKey: item.change.logicalKey, expectedRevision: item.change.expectedRevision, actualRevision: revision });
+ const envelope = makeEnvelope(item.change.entry, item.change.data, { state: item.change.state, revision: revision + 1, operationId: spec.operationId, normalized: true });
+ tx.objectStore(storeFor(item.change.logicalKey)).put({ logicalKey: item.change.logicalKey, envelope: canonicalizeJson(envelope) }); revisions[item.change.logicalKey] = envelope.revision;
+ }
+ const receipt = receiptFor(spec.operationId, revisions, spec.warnings, []);
+ putJournal(tx, journalRow, journal, spec, receipt);
+ done(receipt);
+ };
+ if (!remaining) { finish(); return; }
+ for (const item of reads) { item.request.onerror = () => fail(item.request.error || new Error('Mutation read failed')); item.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ const targets = spec.changes.filter((change) => change.entry.owner !== 'backups' && (change.entry.classification === 'authoritative' || change.entry.classification === 'preference')).map((change) => ({ logicalKey: change.logicalKey, state: change.state, owner: change.entry.owner, classification: change.entry.classification }));
+ this._notifyCommitted(targets, receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async journalNoop(options = {}) { return this.mutate([], Object.assign({}, options, { allowNoop: true })); }
+ async readEntity(store, recordId, options = {}) {
+ this._assertReady(); store = entityStore(store); const id = String(recordId || ''); if (!id) throw validation('readEntity requires recordId');
+ try {
+ const row = await this.driver.readEntity(store, id);
+ if (!row) return null;
+ validateEntityRow(store, row);
+ return options.withMeta ? clone(row) : clone(row.data);
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async readPracticeSnapshot(recordIds = null, options = {}) {
+ this._assertReady();
+ const ids = recordIds === null || recordIds === undefined
+ ? null
+ : (Array.isArray(recordIds) ? recordIds : [recordIds])
+ .map((value) => String(value || ''))
+ .filter(Boolean);
+ try {
+ const snapshot = await this.driver.readPracticeSnapshot(ids, options);
+ const result = {};
+ const stores = Array.isArray(options.stores) && options.stores.length
+ ? Array.from(new Set(options.stores.map((store) => entityStore(store))))
+ : ENTITY_STORES;
+ for (const store of stores) {
+ const validRows = (snapshot && Array.isArray(snapshot[store]) ? snapshot[store] : [])
+ .filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ result[store] = options.withMeta
+ ? clone(validRows)
+ : validRows.map((row) => clone(row.data));
+ }
+ return result;
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async listEntities(store, options = {}) {
+ this._assertReady(); store = entityStore(store);
+ if (store !== 'practiceSummaries') throw validation('Only practiceSummaries supports listEntities; load details and annotations by recordId');
+ try {
+ const rows = await this.driver.listEntities(store);
+ const validRows = rows.filter((row) => {
+ try { validateEntityRow(store, row); return true; }
+ catch (error) {
+ if (error instanceof AppDataError && error.code === 'CORRUPT_RECORD') return false;
+ throw error;
+ }
+ });
+ return options.withMeta ? clone(validRows) : validRows.map((row) => clone(row.data));
+ }
+ catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async mutateEntities(operations, options = {}) {
+ this._assertReady(); if (!Array.isArray(operations) || !operations.length) throw validation('mutateEntities requires operations');
+ const opId = operationId(options.operationId); const items = operations.map(normalizeEntityOperation); const seen = new Set();
+ for (const item of items) { const key = `${item.store}/${item.recordId || '*'}`; if (seen.has(key)) throw validation(`Duplicate entity operation: ${key}`); seen.add(key); }
+ for (const store of ENTITY_STORES) {
+ const scoped = items.filter((item) => item.store === store);
+ if (scoped.some((item) => item.type === 'clear') && scoped.length > 1) {
+ throw validation(`Entity clear cannot be combined with other operations for ${store}`);
+ }
+ }
+ const warnings = options.warnings === undefined ? [] : canonicalizeJson(options.warnings, '$.warnings');
+ if (!Array.isArray(warnings) || warnings.some((item) => typeof item !== 'string')) throw validation('warnings must be an array of strings');
+ const spec = { operationId: opId, warnings, pending: [], fingerprint: checksum({ operations: items, warnings }), stores: Array.from(new Set([SYSTEM_STORE].concat(items.map((item) => item.store)))) };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const reads = items.filter((item) => item.type !== 'clear').map((item) => ({ item, request: tx.objectStore(item.store).get(item.recordId) })); let remaining = reads.length;
+ const finish = () => { const revisions = {};
+ for (const read of reads) { const current = read.request.result || null; const revision = current ? Number(current.revision) : 0;
+ if (read.item.expectedRevision !== null && read.item.expectedRevision !== revision) throw new AppDataError('CONFLICT', `Revision conflict for ${read.item.store}/${read.item.recordId}`);
+ const key = `${read.item.store}/${read.item.recordId}`; if (read.item.type === 'delete') { tx.objectStore(read.item.store).delete(read.item.recordId); revisions[key] = revision + 1; } else { const next = { recordId: read.item.recordId, revision: revision + 1, operationId: spec.operationId, updatedAt: nowIso(), data: read.item.data }; next.checksum = checksum(next.data); tx.objectStore(read.item.store).put(next); revisions[key] = next.revision; }
+ }
+ for (const item of items.filter((item) => item.type === 'clear')) { tx.objectStore(item.store).clear(); revisions[`${item.store}/*`] = 0; }
+ const receipt = receiptFor(spec.operationId, revisions, warnings, []); putJournal(tx, journalRow, journal, spec, receipt); done(receipt); };
+ if (!remaining) { try { finish(); } catch (error) { fail(error); } return; }
+ for (const read of reads) { read.request.onerror = () => fail(read.request.error || new Error('Entity mutation read failed')); read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } }; }
+ } }));
+ this._notifyCommitted(items.map((item) => ({ store: item.store, recordId: item.recordId, type: item.type })), receipt); return receipt;
+ } catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ async exportSnapshot(options = {}) {
+ this._assertReady();
+ try {
+ const selected = Array.isArray(options.logicalKeys) ? new Set(options.logicalKeys.map((key) => String(key))) : null;
+ const shouldExport = (logicalKey) => {
+ if (!catalog.has(logicalKey)) return false;
+ const entry = lookupEntry(logicalKey);
+ if (selected && !selected.has(logicalKey)) return false;
+ if (entry.export === true) return true;
+ return options.includeSystem === true && entry.classification === 'system';
+ };
+ const data = await this.driver.exportSnapshot(shouldExport);
+ // Full/partial snapshots must be dense for their declared catalog
+ // range. An absent physical row means the catalog default, not an
+ // instruction that future importers should guess about.
+ for (const entry of catalog.list()) {
+ if (!shouldExport(entry.logicalKey)
+ || Object.prototype.hasOwnProperty.call(data.envelopes, entry.logicalKey)) continue;
+ data.envelopes[entry.logicalKey] = makeEnvelope(entry, null, {
+ state: 'cleared',
+ operationId: 'snapshot-default'
+ });
+ }
+ if (Array.isArray(options.entityStores)) {
+ const selectedStores = new Set(options.entityStores.map(entityStore));
+ for (const store of ENTITY_STORES) if (!selectedStores.has(store)) delete data.entities[store];
+ }
+ const payload = { envelopes: data.envelopes, entities: data.entities };
+ return { format: 'ielts-atlas-data-v2', schemaVersion: catalog.version, scope: selected ? 'partial' : 'full', createdAt: nowIso(), backend: this.backend, envelopes: data.envelopes, entities: data.entities, checksum: checksum(payload) };
+ } catch (error) { if (error instanceof AppDataError) throw error; throw this._latch(error); }
+ }
+ async installSnapshot(snapshot, options = {}) {
+ this._assertReady(); const source = snapshot && snapshot.envelopes ? snapshot : { envelopes: snapshot, entities: {} };
+ const envelopes = canonicalizeJson(source.envelopes, '$.envelopes'); const entities = canonicalizeJson(source.entities || {}, '$.entities');
+ if (!envelopes || typeof envelopes !== 'object' || Array.isArray(envelopes) || !entities || typeof entities !== 'object' || Array.isArray(entities)) throw validation('Snapshot is invalid');
+ if (source.checksum && source.checksum !== checksum({ envelopes, entities })) throw validation('Snapshot checksum mismatch');
+ const changes = [];
+ for (const [logicalKey, envelope] of Object.entries(envelopes)) {
+ const entry = lookupEntry(logicalKey);
+ if (entry.classification === 'system' || entry.classification === 'session' || entry.import === 'ignore') continue;
+ if (!validateEnvelope(entry, envelope)) throw validation(`Invalid snapshot envelope: ${logicalKey}`);
+ changes.push({ logicalKey, entry, envelope });
+ }
+ const entityRows = {};
+ for (const store of ENTITY_STORES) {
+ if (!Object.prototype.hasOwnProperty.call(entities, store)) continue;
+ const rows = entities[store];
+ if (!Array.isArray(rows)) throw validation(`Invalid snapshot entities: ${store}`);
+ const ids = new Set();
+ entityRows[store] = rows.map((row) => {
+ if (!row || typeof row !== 'object' || !String(row.recordId || '')) throw validation(`Invalid snapshot entity: ${store}`);
+ const recordId = String(row.recordId);
+ if (ids.has(recordId)) throw validation(`Duplicate snapshot entity: ${store}/${recordId}`);
+ ids.add(recordId);
+ const data = canonicalizeJson(row.data);
+ if (!row.checksum || row.checksum !== checksum(data)) throw validation(`Invalid snapshot entity checksum: ${store}/${recordId}`);
+ const revision = row.revision === undefined ? 1 : Number(row.revision);
+ if (!Number.isInteger(revision) || revision < 1) throw validation(`Invalid snapshot entity revision: ${store}/${recordId}`);
+ return { recordId, revision, operationId: String(row.operationId || options.operationId || 'snapshot'), updatedAt: String(row.updatedAt || nowIso()), data, checksum: checksum(data) };
+ });
+ }
+ if (!changes.length && !Object.keys(entityRows).length) throw validation('Snapshot contains no importable data');
+ const resetJournal = options.resetJournal === true;
+ const expectedRevisionToken = options.expectedRevisionToken && typeof options.expectedRevisionToken === 'object'
+ ? canonicalizeJson(options.expectedRevisionToken, '$.expectedRevisionToken')
+ : null;
+ const opId = operationId(options.operationId || randomId('restore')); const spec = { operationId: opId, warnings: [], pending: [], fingerprint: checksum({ envelopes: changes.map((item) => [item.logicalKey, item.envelope]), entities: entityRows, resetJournal, expectedRevisionToken }), stores: STORE_NAMES.slice() };
+ try {
+ const receipt = await this.driver.atomic(Object.assign(spec, { apply: (tx, journalRow, journal, done, fail) => {
+ const replay = journalResult(journal, spec); if (replay) { done(replay); return; }
+ const documentChecks = expectedRevisionToken && expectedRevisionToken.documents || {};
+ const entityChecks = expectedRevisionToken && expectedRevisionToken.entities || {};
+ const reads = Object.entries(documentChecks).map(([logicalKey, expected]) => ({
+ kind: 'document', logicalKey, expected, request: tx.objectStore(DOCUMENT_STORE).get(logicalKey)
+ })).concat(Object.entries(entityChecks).map(([store, expected]) => ({
+ kind: 'entities', store, expected, request: tx.objectStore(store).getAll()
+ })));
+ const finish = () => {
+ for (const read of reads) {
+ if (read.kind === 'document') {
+ const current = read.request.result ? read.request.result.envelope : null;
+ const actualRevision = current ? Number(current.revision) : 0;
+ const expectedRevision = Number(read.expected) || 0;
+ if (actualRevision !== expectedRevision) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.logicalKey}`, { logicalKey: read.logicalKey, expectedRevision, actualRevision });
+ }
+ } else {
+ const actual = Object.fromEntries((read.request.result || []).map((row) => [String(row.recordId), Number(row.revision) || 0]));
+ const expected = read.expected && typeof read.expected === 'object' ? read.expected : {};
+ const ids = new Set(Object.keys(actual).concat(Object.keys(expected)));
+ for (const recordId of ids) {
+ const current = actual[recordId] || 0;
+ const wanted = Number(expected[recordId]) || 0;
+ if (current !== wanted) {
+ throw new AppDataError('CONFLICT', `Snapshot revision conflict for ${read.store}/${recordId}`, { store: read.store, recordId });
+ }
+ }
+ }
+ }
+ const revisions = {};
+ for (const item of changes) { tx.objectStore(DOCUMENT_STORE).put({ logicalKey: item.logicalKey, envelope: makeEnvelope(item.entry, item.envelope.data, { state: item.envelope.state, revision: item.envelope.revision, operationId: spec.operationId, normalized: true }) }); revisions[item.logicalKey] = Number(item.envelope.revision); }
+ for (const [store, rows] of Object.entries(entityRows)) {
+ tx.objectStore(store).clear();
+ for (const row of rows) tx.objectStore(store).put(row);
+ }
+ const receipt = receiptFor(spec.operationId, revisions, [], []); putJournal(tx, journalRow, resetJournal ? {} : journal, spec, receipt); done(receipt);
+ };
+ if (!reads.length) { try { finish(); } catch (error) { fail(error); } return; }
+ let remaining = reads.length;
+ for (const read of reads) {
+ read.request.onerror = () => fail(read.request.error || new Error('Snapshot revalidation read failed'));
+ read.request.onsuccess = () => { remaining -= 1; if (!remaining) { try { finish(); } catch (error) { fail(error); } } };
+ }
+ } }));
+ const targets = changes
+ .filter((item) => item.entry.owner !== 'backups')
+ .map((item) => ({ logicalKey: item.logicalKey, state: item.envelope.state, owner: item.entry.owner, classification: item.entry.classification }))
+ .concat(Object.keys(entityRows).map((store) => ({ store, recordId: null, type: 'replace' })));
+ this._notifyCommitted(targets, receipt);
+ return receipt;
+ }
+ catch (error) { if (error instanceof AppDataError && (error.code === 'VALIDATION' || error.code === 'CONFLICT' || error.code === 'CORRUPT_RECORD')) throw error; throw this._latch(error); }
+ }
+ status() { return Object.freeze({ state: this.state, backend: this.backend, failure: this.failure ? this.failure.message : null }); }
+ }
+
+ Object.defineProperty(global, '__AppDataV2Internals', { value: { catalog, DataKernel, AppDataError, makeEnvelope, validateEnvelope, checksum, stableStringify, canonicalizeJson, clone, randomId, nowIso, parseLegacyValue, readLegacyValues, readLegacyExternalBackup, constants: Object.freeze({ DATABASE_NAME, DATABASE_VERSION, DOCUMENT_STORE, SYSTEM_STORE, ENTITY_STORES, OPERATION_JOURNAL_WINDOW }) }, enumerable: false, configurable: true, writable: false });
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/listeningRecordBridge.js b/js/listeningRecordBridge.js
index 5b96b1f7..839826e7 100644
--- a/js/listeningRecordBridge.js
+++ b/js/listeningRecordBridge.js
@@ -2,6 +2,20 @@
'use strict';
var TAG = '[ListeningBridge]';
+ var HOST_MESSAGE_SOURCE = 'exam_host';
+
+ function deriveParentOriginFromReferrer() {
+ try {
+ if (!window.document || !window.document.referrer) return '';
+ var parsed = new URL(window.document.referrer, window.location.href);
+ // Chromium: file URL.origin is "file://", postMessage event.origin is "null".
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (e) {
+ return '';
+ }
+ }
var state = {
sessionId: null,
@@ -11,8 +25,13 @@
initialized: false,
completed: false,
parentWindow: null,
+ expectedParentOrigin: deriveParentOriginFromReferrer(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
initRequestTimer: null,
- initRequestAttempts: 0
+ initRequestAttempts: 0,
+ pendingCompletion: null
};
function log() {
@@ -37,6 +56,22 @@
return null;
}
+ function createSubmissionId() {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return 'listening-submit-' + window.crypto.randomUUID();
+ }
+ if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
+ var bytes = new Uint8Array(16);
+ window.crypto.getRandomValues(bytes);
+ return 'listening-submit-' + Array.prototype.map.call(bytes, function (byte) {
+ return byte.toString(16).padStart(2, '0');
+ }).join('');
+ }
+ } catch (_) {}
+ return 'listening-submit-' + Date.now() + '-' + Math.random().toString(36).slice(2);
+ }
+
function sendMessage(type, data) {
var pw = state.parentWindow || findParentWindow();
if (!pw) {
@@ -44,7 +79,17 @@
return false;
}
try {
- pw.postMessage({ type: type, data: data || {}, source: 'listening_record_bridge', timestamp: Date.now() }, '*');
+ var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) {
+ warn('无法 send message — trusted parent origin is unavailable');
+ return false;
+ }
+ var secureData = Object.assign({}, data || {}, {
+ windowSessionToken: state.windowSessionToken || null
+ });
+ pw.postMessage({ type: type, data: secureData, source: 'listening_record_bridge', timestamp: Date.now() }, targetOrigin);
return true;
} catch (e) {
warn('postMessage failed:', e);
@@ -327,30 +372,11 @@
function parseObjectLiteral(text, startIndex, label) {
label = label || 'inline';
if (startIndex >= text.length) return null;
- var depth = 0;
- var i = startIndex;
- var started = false;
- var objectStart = -1;
- for (; i < text.length; i++) {
- var ch = text.charAt(i);
- if (ch === '{') {
- if (!started) objectStart = i;
- depth++;
- started = true;
- }
- else if (ch === '}') { depth--; if (started && depth === 0) break; }
- else if (ch === '\'' || ch === '"') {
- var quote = ch;
- for (i++; i < text.length; i++) {
- if (text.charAt(i) === '\\' && i + 1 < text.length) { i++; continue; }
- if (text.charAt(i) === quote) break;
- }
- }
- }
- if (!started || depth !== 0) return null;
- var snippet = text.substring(objectStart, i + 1);
try {
- return (new Function('return (' + snippet + ')'))();
+ if (!window.SafeObjectLiteralParser || typeof window.SafeObjectLiteralParser.parseAt !== 'function') {
+ throw new Error('SafeObjectLiteralParser is unavailable');
+ }
+ return window.SafeObjectLiteralParser.parseAt(text, startIndex).value;
} catch (e) {
warn('parseObjectLiteral failed for', label, e);
return null;
@@ -753,13 +779,35 @@
};
}
+ function sendPendingCompletion(reason) {
+ var pending = state.pendingCompletion;
+ if (!pending || state.completed) return false;
+ if (!state.initialized || !state.windowSessionToken) {
+ sendInitRequest(reason || 'complete_before_init');
+ return false;
+ }
+ if (!pending.payload) {
+ pending.payload = buildBridgePayload(pending.details);
+ pending.payload.submissionId = pending.submissionId;
+ }
+ log(
+ 'sending PRACTICE_COMPLETE, submissionId=' + pending.submissionId
+ + ' correct=' + pending.payload.scoreInfo.correct + '/' + pending.payload.scoreInfo.total
+ );
+ return sendMessage('PRACTICE_COMPLETE', pending.payload);
+ }
+
function onComplete(options) {
options = options || {};
if (state.completed) {
log('already completed, skipping');
return true;
}
- state.completed = true;
+ if (state.pendingCompletion) {
+ sendPendingCompletion('completion_retry');
+ scheduleCompletionRetries(state.pendingCompletion.options || options);
+ return true;
+ }
var allowGenerated = !!options.allowGenerated;
var details = extractAttemptDetails(window, { allowGenerated: allowGenerated });
@@ -772,17 +820,17 @@
}
if (!details.length) {
warn('no details extracted, cannot complete');
- state.completed = false;
return false;
}
- var payload = buildBridgePayload(details);
- log('sending PRACTICE_COMPLETE, correct=' + payload.scoreInfo.correct + '/' + payload.scoreInfo.total);
- if (!state.initialized) {
- sendInitRequest('complete_before_init');
- }
- sendMessage('PRACTICE_COMPLETE', payload);
- clearCompletionRetryTimers();
+ state.pendingCompletion = {
+ submissionId: createSubmissionId(),
+ details: details,
+ options: Object.assign({}, options),
+ payload: null
+ };
+ sendPendingCompletion(state.initialized ? 'completion_created' : 'complete_before_init');
+ scheduleCompletionRetries(options);
return true;
}
@@ -802,9 +850,9 @@
for (var i = 0; i < retryDelays.length; i++) {
(function (delay) {
completionRetryTimers.push(setTimeout(function () {
- if (!state.completed) {
- onComplete(options || {});
- }
+ if (state.completed) return;
+ if (state.pendingCompletion) sendPendingCompletion('completion_timeout');
+ else onComplete(options || {});
}, delay));
})(retryDelays[i]);
}
@@ -1023,18 +1071,71 @@
if (type === 'INIT_SESSION' || type === 'init_exam_session') {
var payload = data.data || data;
- if (event.source && event.source !== window && typeof event.source.postMessage === 'function') {
- state.parentWindow = event.source;
+ if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return;
+ var incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : '';
+ var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : '';
+ if (!incomingToken) return;
+ var expectedParentOrigin = state.expectedParentOrigin
+ && state.expectedParentOrigin !== 'file://'
+ && String(state.expectedParentOrigin).indexOf('file:') !== 0
+ ? state.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) return;
+ state.parentOrigin = expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else if (window.location.protocol === 'file:') {
+ var trustedFileOrigin = incomingOrigin === 'null'
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) return;
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ } else {
+ var trustedWebOrigin = !!incomingOrigin
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) return;
+ state.parentOrigin = incomingOrigin;
+ state.parentOriginIsOpaque = false;
}
+ var previousSessionId = state.sessionId;
+ state.windowSessionToken = incomingToken;
state.sessionId = payload.sessionId || state.sessionId || (state.examId + '_' + Date.now());
state.examId = payload.examId || state.examId;
state.suiteSessionId = payload.suiteSessionId || state.suiteSessionId || null;
state.startTime = toTimestampMs(payload.startTime, toTimestampMs(state.startTime, Date.now()));
state.initialized = true;
stopInitRequestLoop();
+ if (state.pendingCompletion && String(previousSessionId || '') !== String(state.sessionId || '')) {
+ state.pendingCompletion.payload = null;
+ }
log('INIT_SESSION received — examId=' + state.examId + ' sessionId=' + state.sessionId);
sendSessionReady('ready');
+ if (state.pendingCompletion) {
+ sendPendingCompletion('init_received');
+ }
+ } else if (type === 'PRACTICE_SUBMIT_ACK' || type === 'PRACTICE_SUBMIT_FAILED') {
+ var outcome = data.data || data;
+ if (!state.parentWindow || event.source !== state.parentWindow || data.source !== HOST_MESSAGE_SOURCE) return;
+ var outcomeOrigin = typeof event.origin === 'string' ? event.origin : '';
+ if (state.parentOriginIsOpaque ? outcomeOrigin !== 'null' : (!state.parentOrigin || outcomeOrigin !== state.parentOrigin)) return;
+ if (!outcome || String(outcome.windowSessionToken || '') !== String(state.windowSessionToken || '')) return;
+ var pending = state.pendingCompletion;
+ if (!pending
+ || String(outcome.submissionId || '') !== String(pending.submissionId || '')
+ || String(outcome.sessionId || '') !== String(state.sessionId || '')) return;
+ if (type === 'PRACTICE_SUBMIT_ACK') {
+ state.completed = true;
+ state.pendingCompletion = null;
+ clearCompletionRetryTimers();
+ log('PRACTICE_COMPLETE persisted, submissionId=' + outcome.submissionId);
+ } else {
+ warn('PRACTICE_COMPLETE persistence failed, retrying submissionId=' + outcome.submissionId);
+ scheduleCompletionRetries(pending.options || {});
+ }
}
});
}
diff --git a/js/listeningUnifiedWrapper.js b/js/listeningUnifiedWrapper.js
index ad3319a7..f5d6b798 100644
--- a/js/listeningUnifiedWrapper.js
+++ b/js/listeningUnifiedWrapper.js
@@ -4,8 +4,8 @@
var BRIDGE_SCRIPT_URL = '/js/bundles/listening-record-bridge.bundle.js';
var ADAPTER_STYLE_ID = 'listening-unified-wrapper-adapter-style';
var TIMER_INTERVAL_MS = 1000;
- var CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1';
var CANDIDATE_CODE_PATTERN = /^\d{6}$/;
+ var candidateCodeCache = { mode: 'auto', customCode: '' };
var state = {
examId: '',
sourceUrl: '',
@@ -19,7 +19,11 @@
bridgeInjected: false,
bridgeReady: false,
pendingMessages: [],
- parentWindow: null,
+ parentWindow: global.opener || (global.parent && global.parent !== global ? global.parent : null),
+ expectedParentOrigin: '',
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
timerInterval: null,
lastTimerText: ''
};
@@ -27,9 +31,18 @@
function sameOrigin() {
return global.location && global.location.origin && global.location.origin !== 'null'
? global.location.origin
- : '*';
+ : (global.location && global.location.protocol === 'file:' ? '*' : '');
}
+ try {
+ if (global.document && global.document.referrer) {
+ var referrerUrl = new URL(global.document.referrer, global.location.href);
+ state.expectedParentOrigin = referrerUrl.origin && referrerUrl.origin !== 'null'
+ ? referrerUrl.origin
+ : '';
+ }
+ } catch (_) { }
+
function normalizeSafeId(value, fallback) {
var text = String(value || '').trim();
return /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,180}$/.test(text) ? text : fallback;
@@ -125,20 +138,15 @@
}
function readCandidateCodePreferences() {
- try {
- var raw = global.localStorage && global.localStorage.getItem(CANDIDATE_CODE_PREF_KEY);
- var parsed = raw ? JSON.parse(raw) : null;
- var mode = parsed && parsed.mode === 'custom' ? 'custom' : 'auto';
- var customCode = parsed && typeof parsed.customCode === 'string'
- ? parsed.customCode.replace(/\D/g, '').slice(0, 6)
- : '';
- return {
- mode: mode,
- customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : ''
- };
- } catch (_) {
- return { mode: 'auto', customCode: '' };
- }
+ return Object.assign({}, candidateCodeCache);
+ }
+
+ async function loadCandidateCodePreferences() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getCandidateCode();
+ var mode = stored && stored.mode === 'custom' ? 'custom' : 'auto';
+ var customCode = stored && typeof stored.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : '';
+ candidateCodeCache = { mode: mode, customCode: CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' };
}
function resolveCandidateCode() {
@@ -830,7 +838,9 @@
return;
}
try {
- win.postMessage(message, sameOrigin());
+ var targetOrigin = sameOrigin();
+ if (!targetOrigin) throw new Error('iframe target origin unavailable');
+ win.postMessage(message, targetOrigin);
} catch (_) {
state.pendingMessages.push(message);
}
@@ -869,35 +879,74 @@
return;
}
try {
- target.postMessage(message, sameOrigin());
+ var targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) return;
+ target.postMessage(message, targetOrigin);
} catch (_) { }
}
- function handleParentMessage(message, source) {
- if (source && source !== global && typeof source.postMessage === 'function') {
- state.parentWindow = source;
- }
+ function handleParentMessage(event) {
+ var message = event && event.data;
+ var source = event && event.source;
var type = message && message.type;
if (type === 'INIT_SESSION' || type === 'init_exam_session') {
var payload = message.data || message;
+ var incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ var declaredOrigin = typeof payload.parentOrigin === 'string' ? payload.parentOrigin : '';
+ var incomingToken = typeof payload.windowSessionToken === 'string' ? payload.windowSessionToken.trim() : '';
+ if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host' || !incomingToken) return;
+ if (state.expectedParentOrigin) {
+ if (incomingOrigin !== state.expectedParentOrigin || declaredOrigin !== state.expectedParentOrigin) return;
+ state.parentOrigin = state.expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else {
+ if (incomingOrigin !== 'null' || declaredOrigin !== 'null' || global.location.protocol !== 'file:') return;
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ }
+ state.windowSessionToken = incomingToken;
state.examId = normalizeSafeId(payload.examId, state.examId || 'listening-unknown');
state.sessionId = normalizeSafeId(payload.sessionId, state.sessionId || (state.examId + '_' + Date.now()));
state.suiteSessionId = normalizeSafeId(payload.suiteSessionId, state.suiteSessionId || '');
state.startTime = Number.isFinite(Number(payload.startTime)) ? Number(payload.startTime) : state.startTime;
+ } else {
+ var messagePayload = message && message.data || {};
+ var messageOrigin = typeof event.origin === 'string' ? event.origin : '';
+ var messageToken = typeof messagePayload.windowSessionToken === 'string' ? messagePayload.windowSessionToken.trim() : '';
+ var originMatches = state.parentOriginIsOpaque
+ ? messageOrigin === 'null'
+ : Boolean(state.parentOrigin && messageOrigin === state.parentOrigin);
+ if (!state.parentWindow || source !== state.parentWindow || message.source !== 'exam_host'
+ || !originMatches || !state.windowSessionToken || messageToken !== state.windowSessionToken) return;
}
forwardToIframe(message);
}
function handleMessage(event) {
- if (!event || !event.data || (event.origin && event.origin !== global.location.origin)) {
+ if (!event || !event.data) {
return;
}
var frameWindow = getFrameWindow();
if (event.source && frameWindow && event.source === frameWindow) {
+ var frameOrigin = sameOrigin();
+ if (frameOrigin === '*') {
+ if (event.origin !== 'null') return;
+ } else if (!frameOrigin || event.origin !== frameOrigin) {
+ return;
+ }
+ var framePayload = event.data && event.data.data || {};
+ var permitsPreInit = event.data.type === 'REQUEST_INIT'
+ || (event.data.type === 'SESSION_READY' && framePayload.initialized !== true);
+ if (!permitsPreInit && (
+ !state.windowSessionToken
+ || framePayload.windowSessionToken !== state.windowSessionToken
+ )) return;
forwardToParent(event.data);
return;
}
- handleParentMessage(event.data, event.source);
+ handleParentMessage(event);
}
function exposeCompatibilityApi() {
@@ -937,7 +986,9 @@
};
}
- function init() {
+ async function init() {
+ await loadCandidateCodePreferences();
+ if (global.PracticeTimerPreferences && global.PracticeTimerPreferences.ready) await global.PracticeTimerPreferences.ready;
var root = getRoot();
var frame = getFrame();
if (!root || !frame) {
diff --git a/js/main.js b/js/main.js
index 5737ca7f..a0a95fdc 100644
--- a/js/main.js
+++ b/js/main.js
@@ -226,7 +226,11 @@ function ensureLegacyNavigation(options) {
syncOnNavigate: true,
onRepeatNavigate: function onRepeatNavigate(viewName) {
if (viewName === 'browse') {
- resetBrowseViewToAll();
+ if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') {
+ window.ExamActions.resetBrowseViewToAll();
+ } else if (typeof window.resetBrowseViewToAll === 'function') {
+ window.resetBrowseViewToAll();
+ }
}
},
onNavigate: function onNavigate(viewName) {
@@ -265,12 +269,6 @@ async function initializeLegacyComponents() {
setupBrowsePreferenceUI();
- // Setup UI Listeners
- const folderPicker = document.getElementById('folder-picker');
- if (folderPicker) {
- folderPicker.addEventListener('change', handleFolderSelection);
- }
-
// Initialize components
if (window.PDFHandler) {
pdfHandler = new PDFHandler();
@@ -280,13 +278,6 @@ async function initializeLegacyComponents() {
browseStateManager = new BrowseStateManager();
console.log('[System] 浏览状态管理器已初始化');
}
- if (window.DataIntegrityManager) {
- window.dataIntegrityManager = new DataIntegrityManager();
- console.log('[System] 数据完整性管理器已初始化');
- } else {
- console.info('[System] DataIntegrityManager 按需加载,跳过启动初始化');
- }
-
// 性能优化器已拆到 diagnostics-tools;浏览页保留无依赖降级路径。
if (window.PerformanceOptimizer) {
window.performanceOptimizer = new PerformanceOptimizer();
@@ -295,93 +286,47 @@ async function initializeLegacyComponents() {
console.info('[System] PerformanceOptimizer 按需加载,跳过启动初始化');
}
- // Clean up old cache and configurations for v1.1.0 upgrade (one-time only)
- let needsCleanup = false;
- try {
- needsCleanup = !localStorage.getItem('upgrade_v1_1_0_cleanup_done');
- } catch (error) {
- console.warn('[System] 检查升级标记失败,将继续执行清理流程', error);
- needsCleanup = true;
- }
-
- if (needsCleanup) {
- console.log('[System] 首次运行,执行升级清理...');
- try {
- await cleanupOldCache();
- } finally {
- try { localStorage.setItem('upgrade_v1_1_0_cleanup_done', '1'); } catch (_) { }
- }
- } else {
- console.log('[System] 升级清理已完成,跳过重复清理');
- }
-
// Load data and setup listeners
await loadLibraryInternal();
- startPracticeRecordsSyncInBackground('boot'); // 后台静默加载练习记录,避免阻塞首页
+ // 首页/题库浏览只使用摘要记录;完整 answers/realData 在进入练习历史页时再加载。
setupMessageListener(); // Listen for updates from child windows
- setupStorageSyncListener(); // Listen for storage changes from other tabs
-}
-
-// Clean up old cache and configurations
-async function cleanupOldCache() {
- try {
- console.log('[System] 正在清理旧缓存与配置...');
- await storage.remove('exam_index');
- await storage.remove('active_exam_index_key');
- await storage.set('exam_index_configurations', []);
- console.log('[System] 旧缓存清理完成');
- } catch (error) {
- console.warn('[System] 清理旧缓存时出错:', error);
- }
}
-
// --- Data Loading and Management ---
-// Phase 3: 练习记录同步 - 保留在 main.js(核心数据流,暂不迁移)
+// Practice history is read from AppData for each refresh. Only its signature is
+// retained as runtime UI state; record arrays never become a second authority.
+let lastPracticeRecordsSignature = null;
async function syncPracticeRecords(options = {}) {
- const { forceRender = false } = options || {};
- console.log('[System] 正在从存储中同步练习记录...');
- const previousRecords = typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []);
- let records = [];
- let loadError = null;
- try {
- records = await listCanonicalPracticeRecords();
- } catch (e) {
- console.warn('[System] 同步记录时发生错误:', e);
- loadError = e;
- records = Array.isArray(previousRecords) ? previousRecords.slice() : [];
- const errorMessage = String(e && e.message ? e.message : e).toLowerCase();
- if (errorMessage.includes('not ready') || errorMessage.includes('未就绪')) {
- setTimeout(() => {
- try {
- startPracticeRecordsSyncInBackground('api-ready-retry');
- } catch (_) { }
- }, 800);
- }
- if (Array.isArray(previousRecords) && previousRecords.length > 0) {
- console.warn('[System] canonical store 暂未就绪,保留当前内存中的练习记录,避免误清空视图。');
- }
- }
-
- if (loadError && (!Array.isArray(records) || records.length === 0) && (!Array.isArray(previousRecords) || previousRecords.length === 0)) {
- console.warn('[System] canonical store 暂未就绪,本次跳过练习记录视图刷新。');
- return;
- }
-
- // Normalize duration and percentages to avoid 0-second artifacts
+ const { forceRender = false, mode = 'summary' } = options || {};
+ const loadMode = mode === 'full' ? 'full' : 'summary';
+ let recordsUnchanged = false;
+ console.log(`[System] 正在从存储中同步练习记录... (mode=${loadMode})`);
+ let [records, insightRecords, examIndex] = await Promise.all([
+ listCanonicalPracticeRecordSummaries(),
+ window.AppData.practice.listInsights({ limit: 10 }),
+ resolveActiveExamIndex()
+ ]);
+ const insightsById = new Map((Array.isArray(insightRecords) ? insightRecords : [])
+ .filter((record) => record && record.id)
+ .map((record) => [String(record.id), record]));
+ records = (Array.isArray(records) ? records : []).map((record) =>
+ record && insightsById.has(String(record.id))
+ ? Object.assign({}, record, insightsById.get(String(record.id)))
+ : record);
+ if (loadMode === 'full') {
+ console.log('[System] mode=full 请求已限定为 light 视图刷新;完整记录请直接调用 AppData.practice.list()');
+ }
+
+ // Normalize duration and percentages to avoid 0-second artifacts(summary 无 realData/interactions)
try {
records = (records || []).map(r => {
- const rd = (r && r.realData) || {};
let duration = (typeof r.duration === 'number') ? r.duration : undefined;
if (!(Number.isFinite(duration) && duration > 0)) {
- const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {};
+ const sInfo = r && r.scoreInfo || {};
const candidates = [
- r.duration, rd.duration, r.durationSeconds, r.duration_seconds,
+ r.duration, r.durationSeconds, r.duration_seconds,
r.elapsedSeconds, r.elapsed_seconds, r.timeSpent, r.time_spent,
- rd.durationSeconds, rd.elapsedSeconds, rd.timeSpent,
sInfo.duration, sInfo.timeSpent
];
for (const v of candidates) {
@@ -395,22 +340,12 @@ async function syncPracticeRecords(options = {}) {
duration = Math.round((e - s) / 1000);
}
}
- if (!(Number.isFinite(duration) && duration > 0) && rd && Array.isArray(rd.interactions) && rd.interactions.length) {
- try {
- const ts = rd.interactions.map(x => x && Number(x.timestamp)).filter(n => Number.isFinite(n));
- if (ts.length) {
- const span = Math.max(...ts) - Math.min(...ts);
- if (Number.isFinite(span) && span > 0) duration = Math.floor(span / 1000);
- }
- } catch (_) { }
- }
}
if (!Number.isFinite(duration)) duration = 0;
- // Coerce percentage/accuracy if only scoreInfo exists
- const sInfo = r && (r.scoreInfo || rd.scoreInfo) || {};
+ const sInfo = r && r.scoreInfo || {};
const correct = (typeof r.correctAnswers === 'number') ? r.correctAnswers : (typeof sInfo.correct === 'number' ? sInfo.correct : (typeof r.score === 'number' ? r.score : undefined));
- const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : (rd.answers ? Object.keys(rd.answers).length : undefined));
+ const total = (typeof r.totalQuestions === 'number') ? r.totalQuestions : (typeof sInfo.total === 'number' ? sInfo.total : undefined);
let accuracy = (typeof r.accuracy === 'number') ? r.accuracy : undefined;
let percentage = (typeof r.percentage === 'number') ? r.percentage : undefined;
if ((accuracy === undefined || percentage === undefined) && Number.isFinite(correct) && Number.isFinite(total) && total > 0) {
@@ -423,138 +358,71 @@ async function syncPracticeRecords(options = {}) {
});
} catch (e) { console.warn('[System] normalize durations failed:', e); }
- // 若数据未变则跳过 UI 刷新,避免无意义的列表重置
- // 使用轻量 listSummary 进行签名比对,无需反序列化+克隆完整记录数组
+ // Avoid resetting the list when the authoritative light projection is unchanged.
try {
- const prev = typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []);
const renderer = window.PracticeHistoryRenderer;
if (renderer && renderer.helpers && typeof renderer.helpers.computeRecordsSignature === 'function') {
- const prevSig = renderer.helpers.computeRecordsSignature(prev);
- // 若 forceRender 则跳过轻量查询,直接走完整加载
- if (!forceRender && window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') {
- const summaries = await window.PracticeRecordAPI.listSummary();
- const nextSig = renderer.helpers.computeRecordsSignature(summaries);
- if (prevSig === nextSig) {
- console.log('[System] 练习记录未变化,跳过UI刷新');
- return;
- }
- } else {
- const nextSig = renderer.helpers.computeRecordsSignature(records);
- if (!forceRender && prevSig === nextSig) {
- console.log('[System] 练习记录未变化,跳过UI刷新');
- return;
- }
+ const nextSignature = renderer.helpers.computeRecordsSignature(records);
+ if (!forceRender && lastPracticeRecordsSignature === nextSignature) {
+ console.log('[System] 练习记录未变化,跳过UI刷新');
+ recordsUnchanged = true;
}
+ lastPracticeRecordsSignature = nextSignature;
}
} catch (_) { /* 保底不中断同步流程 */ }
- // 新增修复3D:确保全局变量和 app.state 都跟 canonical records 保持一致
- setPracticeRecordsState(records);
- try {
- if (window.app && window.app.state && window.app.state.practice) {
- const nextRecords = typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(records) ? records : []);
- window.app.state.practice.records = Array.isArray(nextRecords) ? nextRecords.slice() : [];
- }
- } catch (error) {
- console.warn('[System] 同步练习记录到 App state 失败:', error);
- }
- refreshBrowseProgressFromRecords(records);
+ refreshBrowseProgressFromRecords(records, examIndex);
- console.log(`[System] ${records.length} 条练习记录已加载到内存。`);
- updatePracticeView();
+ console.log(`[System] 已从 AppData 加载 ${records.length} 条练习摘要。`);
+ if (!recordsUnchanged) {
+ updatePracticeView(records, examIndex);
+ }
+ return records;
}
let practiceRecordsLoadPromise = null;
-function ensurePracticeRecordsSync(trigger = 'default') {
+function ensurePracticeRecordsSync(trigger = 'default', options = {}) {
if (practiceRecordsLoadPromise) {
return practiceRecordsLoadPromise;
}
const loadTask = (async () => {
- await syncPracticeRecords();
- return true;
- })().catch((error) => {
- console.warn(`[System] 练习记录同步失败(${trigger}):`, error);
- return false;
- });
+ return syncPracticeRecords(Object.assign({ mode: 'summary' }, options || {}));
+ })();
practiceRecordsLoadPromise = loadTask.finally(() => {
practiceRecordsLoadPromise = null;
});
return practiceRecordsLoadPromise;
}
-function startPracticeRecordsSyncInBackground(trigger = 'default') {
- try {
- ensurePracticeRecordsSync(trigger);
- } catch (error) {
+function startPracticeRecordsSyncInBackground(trigger = 'default', options = {}) {
+ ensurePracticeRecordsSync(trigger, options).catch((error) => {
console.warn(`[System] 后台同步练习记录失败(${trigger}):`, error);
- }
+ });
}
async function listCanonicalPracticeRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- }
-
- throw new Error('统一练习记录 API 未就绪');
+ // 两个调用方(bulkDeleteRecords / deleteRecord)只用 id、title、date 做存在性校验与确认文案,
+ // light 投影已覆盖;删除本身走 AppData.practice.delete/deleteMany,不需要全量答题详情。
+ const records = await window.AppData.practice.list({ projection: 'light' });
+ return Array.isArray(records) ? records : [];
}
-async function replaceCanonicalPracticeRecords(records) {
- const finalRecords = Array.isArray(records) ? records : [];
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') {
- await window.PracticeRecordAPI.replace(finalRecords, {
- maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000
- });
- return true;
- }
-
- throw new Error('统一练习记录 API 未就绪');
+async function listCanonicalPracticeRecordSummaries() {
+ const summaries = await window.AppData.practice.list({ projection: 'light' });
+ return Array.isArray(summaries) ? summaries : [];
}
-function cleanupLegacyPracticeRecordArtifacts() {
- // Unprefixed legacy keys only — never the active backend key.
- const legacyRawKeys = ['practice_records', 'old_prefix_practice_records'];
-
- try {
- legacyRawKeys.forEach((key) => {
- try { localStorage.removeItem(key); } catch (_) { }
- try { sessionStorage.removeItem(key); } catch (_) { }
- });
- } catch (error) {
- console.warn('[System] 清理 legacy 练习记录影子键失败:', error);
- }
-
- const storage = window.storage;
- const shadowKey = storage && typeof storage.getKey === 'function'
- ? storage.getKey('practice_records')
- : null;
- if (!shadowKey) {
- return;
+async function resolveActiveExamIndex() {
+ if (typeof window.resolveActiveLibraryIndex === 'function') {
+ const index = await window.resolveActiveLibraryIndex();
+ return Array.isArray(index) ? index : [];
}
-
- // When IndexedDB is blocked/unavailable, writePersistentValue stores canonical
- // practice_records under exam_system_practice_records in localStorage/sessionStorage.
- // Removing that key after replace/delete would wipe the just-persisted history.
- const mode = storage && storage.mode;
- const usesWebStorageBackend = mode === 'localStorage' || mode === 'sessionStorage';
- if (usesWebStorageBackend || storage.indexedDBBlocked || !storage.indexedDB) {
- return;
+ const manager = await ensureLibraryManagerReady();
+ if (manager && typeof manager.resolveActiveIndex === 'function') {
+ const index = await manager.resolveActiveIndex();
+ return Array.isArray(index) ? index : [];
}
-
- try { localStorage.removeItem(shadowKey); } catch (_) { }
- try { sessionStorage.removeItem(shadowKey); } catch (_) { }
-}
-
-async function persistPracticeRecordsAndRefresh(records, trigger = 'manual-update') {
- const finalRecords = Array.isArray(records) ? records : [];
- await replaceCanonicalPracticeRecords(finalRecords);
- cleanupLegacyPracticeRecordArtifacts();
- await syncPracticeRecords({ forceRender: true });
- return getPracticeRecordsState();
+ throw new Error('LibraryManager.resolveActiveIndex is unavailable');
}
const completionNoticeState = {
@@ -617,6 +485,30 @@ function extractCompletionSessionId(envelope) {
return null;
}
+// fallbackExamSessions 是纯内存 Map(js/app.js:50),主页刷新后会话映射即丢失。
+// 完成消息本身携带 examId(unifiedReadingPage.buildEnvelope / practicePageEnhancer.buildResultsPayload /
+// listeningRecordBridge.buildBridgePayload 都会写入),据此仍可走同一条持久化路径。
+function resolveCompletionExamId(envelope, payload) {
+ const sources = [payload, envelope, envelope && envelope.data];
+ for (const source of sources) {
+ if (!source || typeof source !== 'object') {
+ continue;
+ }
+ const candidates = [
+ source.examId,
+ source.derivedExamId,
+ source.metadata && typeof source.metadata === 'object' ? source.metadata.examId : null
+ ];
+ for (const candidate of candidates) {
+ const normalized = candidate == null ? '' : String(candidate).trim();
+ if (normalized) {
+ return normalized;
+ }
+ }
+ }
+ return null;
+}
+
function shouldAnnounceCompletion(sessionId) {
const now = Date.now();
if (sessionId && completionNoticeState.lastSessionId === sessionId) {
@@ -748,6 +640,17 @@ if (typeof window !== 'undefined') {
}
function setupMessageListener() {
+ const resolveFallbackMessageOrigin = () => {
+ const location = window.location || {};
+ const rawOrigin = typeof location.origin === 'string' ? location.origin : '';
+ const isOpaqueFile = location.protocol === 'file:'
+ || rawOrigin === 'null'
+ || rawOrigin === 'file://'
+ || rawOrigin.startsWith('file:');
+ return isOpaqueFile
+ ? { declaredOrigin: 'null', targetOrigin: '*' }
+ : { declaredOrigin: rawOrigin, targetOrigin: rawOrigin };
+ };
const findFallbackSessionByWindow = (sourceWindow) => {
if (!sourceWindow || !window.fallbackExamSessions || typeof fallbackExamSessions.entries !== 'function') {
return null;
@@ -766,30 +669,123 @@ function setupMessageListener() {
if (!entry || !entry.rec || !entry.rec.win || entry.rec.win.closed) {
return;
}
- const payload = entry.rec.initPayload || {
+ const messageOrigin = resolveFallbackMessageOrigin();
+ const targetOrigin = messageOrigin.targetOrigin;
+ if (!targetOrigin) return;
+ if (!entry.rec.windowSessionToken) {
+ const cryptoApi = window.crypto;
+ if (!cryptoApi || typeof cryptoApi.getRandomValues !== 'function') return;
+ const bytes = new Uint8Array(24);
+ cryptoApi.getRandomValues(bytes);
+ entry.rec.windowSessionToken = Array.from(bytes)
+ .map(byte => byte.toString(16).padStart(2, '0'))
+ .join('');
+ }
+ const payload = Object.assign({}, entry.rec.initPayload || {
examId: entry.rec.examId,
- parentOrigin: window.location.origin,
+ parentOrigin: messageOrigin.declaredOrigin,
sessionId: entry.rec.sessionId || entry.sid
- };
+ }, {
+ parentOrigin: messageOrigin.declaredOrigin,
+ windowSessionToken: entry.rec.windowSessionToken
+ });
+ entry.rec.initPayload = payload;
try {
- entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload }, '*');
- entry.rec.win.postMessage({ type: 'init_exam_session', data: payload }, '*');
+ entry.rec.win.postMessage({ type: 'INIT_SESSION', data: payload, source: 'exam_host' }, targetOrigin);
+ entry.rec.win.postMessage({ type: 'init_exam_session', data: payload, source: 'exam_host' }, targetOrigin);
} catch (_) { }
};
- window.addEventListener('message', (event) => {
- // 更兼容的安全检查:允许同源或file协议下的子窗口
+ const sendFallbackSubmitOutcome = (rec, payload, succeeded, errorCode = '') => {
+ const submissionId = payload && payload.submissionId != null ? String(payload.submissionId).trim() : '';
+ const sessionId = payload && payload.sessionId != null ? String(payload.sessionId).trim() : '';
+ if (!rec || !rec.win || rec.win.closed || !submissionId || !sessionId) return false;
+ const targetOrigin = resolveFallbackMessageOrigin().targetOrigin;
+ if (!targetOrigin || !rec.windowSessionToken) return false;
try {
- if (event.origin && event.origin !== 'null' && event.origin !== window.location.origin) {
- return;
- }
- } catch (_) { }
+ rec.win.postMessage({
+ type: succeeded ? 'PRACTICE_SUBMIT_ACK' : 'PRACTICE_SUBMIT_FAILED',
+ data: {
+ examId: payload.examId || rec.examId || null,
+ sessionId,
+ suiteSessionId: payload.suiteSessionId || null,
+ submissionId,
+ errorCode: succeeded ? null : (errorCode || 'save_failed'),
+ windowSessionToken: rec.windowSessionToken
+ },
+ source: 'exam_host',
+ timestamp: Date.now()
+ }, targetOrigin);
+ return true;
+ } catch (_) {
+ return false;
+ }
+ };
+
+ const sendFallbackVocabOutcome = (rec, payload, succeeded, errorCode = '') => {
+ const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : '';
+ const sessionId = payload && payload.sessionId != null
+ ? String(payload.sessionId).trim()
+ : String(rec && rec.sessionId || '');
+ if (!rec || !rec.win || rec.win.closed || !requestId || !sessionId || !rec.windowSessionToken) return false;
+ const targetOrigin = resolveFallbackMessageOrigin().targetOrigin;
+ if (!targetOrigin) return false;
+ try {
+ rec.win.postMessage({
+ type: succeeded ? 'VOCAB_HIGHLIGHT_SAVE_ACK' : 'VOCAB_HIGHLIGHT_SAVE_FAILED',
+ data: {
+ requestId,
+ examId: payload.examId || rec.examId || null,
+ sessionId,
+ errorCode: succeeded ? null : (errorCode || 'save_failed'),
+ windowSessionToken: rec.windowSessionToken
+ },
+ source: 'exam_host',
+ timestamp: Date.now()
+ }, targetOrigin);
+ return true;
+ } catch (_) {
+ return false;
+ }
+ };
+ const verifyFallbackPracticeCompletionRecord = async (record) => {
+ if (!record || typeof record !== 'object' || !record.id || !record.examId || !record.sessionId) {
+ return null;
+ }
+ if (!window.AppData || !window.AppData.practice || typeof window.AppData.practice.get !== 'function') {
+ return null;
+ }
+ const persisted = await window.AppData.practice.get(String(record.id), { projection: 'light' });
+ if (!persisted || typeof persisted !== 'object') {
+ return null;
+ }
+ return String(persisted.id || '') === String(record.id)
+ && String(persisted.examId || '') === String(record.examId)
+ && String(persisted.sessionId || '') === String(record.sessionId)
+ ? persisted
+ : null;
+ };
+
+ window.addEventListener('message', (event) => {
const data = event.data || {};
const type = data.type;
+ const payload = data && typeof data.data === 'object' ? data.data : data;
+ const matched = findFallbackSessionByWindow(event.source);
+ if (!matched || !matched.rec) return;
+ const isLocalFile = window.location && window.location.protocol === 'file:';
+ if (isLocalFile ? event.origin !== 'null' : event.origin !== window.location.origin) return;
+ const allowedSources = new Set(['practice_page', 'inline_collector', 'listening_record_bridge', 'suite_placeholder']);
+ if (!allowedSources.has(data.source || payload.source)) return;
+ const permitsPreInit = type === 'REQUEST_INIT'
+ || (type === 'SESSION_READY' && payload.initialized !== true);
+ if (!permitsPreInit && (
+ !matched.rec.windowSessionToken
+ || payload.windowSessionToken !== matched.rec.windowSessionToken
+ )) {
+ return;
+ }
if (type === 'SESSION_READY') {
- const payload = data && typeof data.data === 'object' ? data.data : data;
- const matched = findFallbackSessionByWindow(event.source);
if (payload && payload.initialized === false) {
sendFallbackInit(matched);
return;
@@ -803,71 +799,99 @@ function setupMessageListener() {
}
} catch (_) { }
} else if (type === 'REQUEST_INIT') {
- sendFallbackInit(findFallbackSessionByWindow(event.source));
+ sendFallbackInit(matched);
} else if (type === 'VOCAB_HIGHLIGHT_SAVE') {
const payload = data.data && typeof data.data === 'object' ? data.data : data;
- saveReadingHighlightVocab(payload).catch((error) => {
+ const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : '';
+ if (!requestId) return;
+ saveReadingHighlightVocab(payload).then((saved) => {
+ sendFallbackVocabOutcome(matched.rec, payload, Boolean(saved), saved ? '' : 'save_failed');
+ }).catch((error) => {
console.warn('[VocabStore] 阅读高亮生词保存异常:', error);
+ sendFallbackVocabOutcome(matched.rec, payload, false, 'save_failed');
});
} else if (type === 'PRACTICE_COMPLETE' || type === 'practice_completed') {
const payload = extractCompletionPayload(data) || {};
const sessionId = extractCompletionSessionId(data);
- const matchedByWindow = findFallbackSessionByWindow(event.source);
+ const matchedByWindow = matched;
const rec = sessionId ? (fallbackExamSessions.get(sessionId) || (matchedByWindow && matchedByWindow.rec)) : (matchedByWindow && matchedByWindow.rec);
const recSessionId = rec && (rec.sessionId || (matchedByWindow && matchedByWindow.sid) || sessionId);
if (recSessionId && payload && typeof payload === 'object') {
payload.sessionId = recSessionId;
}
+ if (!payload.submissionId || !recSessionId) return;
+ const receiptKey = payload.submissionId && recSessionId
+ ? `${recSessionId}:${String(payload.submissionId)}`
+ : '';
+ if (rec && receiptKey && rec.practiceSubmitReceipt === receiptKey) {
+ sendFallbackSubmitOutcome(rec, payload, true);
+ return;
+ }
const shouldNotify = shouldAnnounceCompletion(recSessionId || sessionId);
- if (rec) {
- console.log('[System] 收到练习完成,保存 canonical 记录');
- const cleanupAfterCompletion = () => {
- try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { }
- try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { }
- };
- savePracticeCompletionRecord(rec.examId, payload).then(
- () => {
- // 保存成功:提示完成、展示摘要、同步记录。
- cleanupAfterCompletion();
- if (shouldNotify) {
- showMessage('练习已完成,正在更新记录...', 'success');
- showCompletionSummary(payload);
- }
- setTimeout(syncPracticeRecords, 300);
- },
- (saveError) => {
- // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要,
- // 避免在记录未落库时误导用户;同步一次以反映真实状态。
- console.error('[System] 练习完成记录保存失败:', saveError);
- cleanupAfterCompletion();
- if (shouldNotify) {
- showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error');
- }
- setTimeout(syncPracticeRecords, 300);
+ const cleanupAfterCompletion = () => {
+ try { if (rec && rec.timer) clearInterval(rec.timer); } catch (_) { }
+ if (rec && receiptKey) {
+ try { if (rec.submitCleanupTimer) clearTimeout(rec.submitCleanupTimer); } catch (_) { }
+ rec.submitCleanupTimer = setTimeout(() => {
+ try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { }
+ }, 120000);
+ if (rec.submitCleanupTimer && typeof rec.submitCleanupTimer.unref === 'function') {
+ rec.submitCleanupTimer.unref();
}
- );
- } else {
- console.log('[System] 收到练习完成消息,正在同步记录...');
+ return;
+ }
+ try { fallbackExamSessions.delete(recSessionId || sessionId); } catch (_) { }
+ };
+ const onCompletionSaved = async (savedRecord) => {
+ const persistedRecord = await verifyFallbackPracticeCompletionRecord(savedRecord);
+ if (!persistedRecord) {
+ throw new Error('canonical_completion_readback_failed');
+ }
+ if (rec && receiptKey) rec.practiceSubmitReceipt = receiptKey;
+ sendFallbackSubmitOutcome(rec, payload, true);
+ // 保存成功:提示完成、展示摘要、同步记录。
+ cleanupAfterCompletion();
if (shouldNotify) {
showMessage('练习已完成,正在更新记录...', 'success');
showCompletionSummary(payload);
}
- setTimeout(syncPracticeRecords, 300);
+ setTimeout(() => ensurePracticeRecordsSync('completion-saved'), 300);
+ };
+ const onCompletionSaveFailed = (saveError) => {
+ sendFallbackSubmitOutcome(rec, payload, false, 'save_failed');
+ // 保存失败:仍清理 timer/session,但不展示“已完成”成功横幅与摘要,
+ // 避免在记录未落库时误导用户;同步一次以反映真实状态。
+ console.error('[System] 练习完成记录保存失败:', saveError);
+ cleanupAfterCompletion();
+ if (shouldNotify) {
+ showMessage('练习已完成,但记录保存失败,请重试或检查数据。', 'error');
+ }
+ setTimeout(() => ensurePracticeRecordsSync('completion-save-failed'), 300);
+ };
+ if (rec) {
+ console.log('[System] 收到练习完成,保存 canonical 记录');
+ savePracticeCompletionRecord(rec.examId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed);
+ } else {
+ // 会话映射缺失(例如主页刷新后 fallbackExamSessions 已被清空)。此前这里只做只读同步,
+ // 记录一个字都不写却提示“练习已完成”。改为用消息自带的 examId 走同一条持久化路径。
+ const payloadExamId = resolveCompletionExamId(data, payload);
+ if (payloadExamId) {
+ console.log('[System] 会话映射缺失,改用消息自带 examId 保存 canonical 记录:', payloadExamId);
+ savePracticeCompletionRecord(payloadExamId, payload).then(onCompletionSaved).catch(onCompletionSaveFailed);
+ } else {
+ // 连 examId 都没有就无法归属到任何题目,必须明确报错,绝不能报成功。
+ console.error('[System] 练习完成消息缺少 examId,无法保存记录');
+ sendFallbackSubmitOutcome(rec, payload, false, 'missing_exam_id');
+ if (shouldNotify) {
+ showMessage('练习已完成,但记录保存失败:缺少题目标识,无法归档本次练习。', 'error');
+ }
+ setTimeout(() => ensurePracticeRecordsSync('completion-missing-exam-id'), 300);
+ }
}
}
});
}
-function setupStorageSyncListener() {
- window.addEventListener('storage-sync', (event) => {
- console.log('[System] 收到存储同步事件,正在更新练习记录...', event.detail);
- //可以选择性地只更新受影响的key,但为了简单起见,我们直接同步所有记录
- // if (event.detail && event.detail.key === 'practice_records') {
- syncPracticeRecords();
- // }
- });
-}
-
function normalizeFallbackAnswerValue(value) {
if (value === null || value === undefined) {
return '';
@@ -1047,9 +1071,9 @@ async function saveFallbackSpellingErrors(examId, realData, exam = {}) {
}
}
-function findExamForCompletion(examId, realData = {}) {
- const list = typeof getExamIndexState === 'function' ? getExamIndexState() : [];
- let exam = Array.isArray(list) ? (list.find(e => e.id === examId) || {}) : {};
+function findExamForCompletion(examId, realData = {}, examIndex = []) {
+ const list = Array.isArray(examIndex) ? examIndex : [];
+ let exam = list.find(e => e.id === examId) || {};
if (exam.id || !realData) {
return exam;
@@ -1137,31 +1161,45 @@ async function savePracticeCompletionRecord(examId, realData) {
return null;
}
- const api = window.PracticeRecordAPI;
- if (!api || typeof api.saveCompletion !== 'function') {
- throw new Error('统一练习记录 API 未就绪');
- }
- const exam = findExamForCompletion(examId, realData);
+ const examIndex = await resolveActiveExamIndex();
+ const exam = findExamForCompletion(examId, realData, examIndex);
const category = resolveCompletionCategory(exam, realData);
- const record = await api.saveCompletion(realData, {
- examId,
- examEntry: exam,
- metadata: {
+ // 启动时捕获的题库配置 ID:优先取 PRACTICE_COMPLETE 消息或 realData 已显式透传的值,
+ // 否则显式写入 null(保留 key),让 AppData provenance 不再回退到当前激活题库,
+ // 避免用户在考试过程中切换题库导致记录来源不一致。
+ const launchLibraryConfigurationId = (realData && realData.libraryConfigurationId != null
+ && realData.libraryConfigurationId !== '')
+ ? realData.libraryConfigurationId
+ : (realData && realData.metadata && realData.metadata.libraryConfigurationId != null
+ && realData.metadata.libraryConfigurationId !== '')
+ ? realData.metadata.libraryConfigurationId
+ : null;
+ const receipt = await window.AppData.practice.completeAttempt({
+ record: Object.assign({}, realData, {
+ examId,
+ title: realData.title || exam.title || '',
+ category,
+ frequency: exam.frequency || realData.frequency || 'unknown',
+ type: exam.type || realData.type || null,
+ metadata: Object.assign({}, realData.metadata || {}, {
examId,
examTitle: exam.title || realData.title || '',
category,
frequency: exam.frequency || realData.frequency || 'unknown',
- type: exam.type || realData.type || null
- }
- }, exam, {
- currentVersion: (window.scoreStorage && window.scoreStorage.currentVersion) || '0.6.2-fix',
- maxRecords: (window.scoreStorage && window.scoreStorage.maxRecords) || 1000,
- updateStats: true
+ type: exam.type || realData.type || null,
+ libraryConfigurationId: launchLibraryConfigurationId
+ })
+ }),
+ operationId: realData.operationId
+ || realData.messageId
+ || (realData.submissionId
+ ? `practice-complete:${examId}:${realData.sessionId || 'session'}:${realData.submissionId}`
+ : undefined)
});
await saveFallbackSpellingErrors(examId, realData, exam);
console.log('[PracticeRecord] 练习完成数据已保存到 canonical store');
- return record;
+ return receipt.record;
} catch (e) {
console.error('[PracticeRecord] 保存练习记录失败:', e);
throw e;
@@ -1230,14 +1268,14 @@ function getOverviewView() {
return overviewViewInstance;
}
-function updateOverview() {
+function updateOverview(examIndex = []) {
const categoryContainer = document.getElementById('category-overview');
if (!categoryContainer) {
console.warn('[Overview] 找不到 category-overview 容器');
return;
}
- const currentExamIndex = getExamIndexState();
+ const currentExamIndex = Array.isArray(examIndex) ? examIndex : [];
const statsService = window.AppServices && window.AppServices.overviewStats;
const stats = statsService ?
statsService.calculate(currentExamIndex) :
@@ -1663,10 +1701,35 @@ function recordMatchesExamType(record, targetType, examIndex) {
return true;
}
+// 练习记录渲染前的来源过滤。判定本身不在这里实现,而是复用
+// js/data/practiceRecordSource.js(与 practice.stats / achievements.progress 投影器同源),
+// 因为“列表看不见但计入统计”的 bug 正是由两处各写一套判定造成的。
+//
+// 用 filterRecordsForHistoryView 而不是 filterRealPracticeRecords:两者对"真实记录"的
+// 判定完全相同,前者额外放行新手引导显式登记的演示记录 id(引导需要用户看见那一行)。
+// 该例外只存在于视图层,投影器读不到,因此统计与成就仍严格排除演示数据。
+function filterRealPracticeRecordsForView(records) {
+ const list = Array.isArray(records) ? records : [];
+ const classifier = window.PracticeRecordSource;
+ if (!classifier || typeof classifier.filterRecordsForHistoryView !== 'function') {
+ // core-foundation 里的 appData.js 缺少该模块会直接抛错、应用根本起不来,
+ // 所以走到这里只能是加载顺序被破坏。此时绝不本地复刻判定:显式报错并保留全部记录,
+ // 宁可多显示演示记录,也不能重演"真实记录被吃掉、练习记录页整页空白"。
+ console.error('[PracticeHistory] PracticeRecordSource 未加载,已跳过演示记录过滤(判定必须与统计/成就同源)');
+ return list;
+ }
+ return classifier.filterRecordsForHistoryView(list);
+}
+
// Phase 3: 练习记录视图更新 - 保留在 main.js(依赖多个组件,暂不迁移)
-function updatePracticeView() {
- const rawRecords = getPracticeRecordsState();
- const records = rawRecords.filter((record) => record && (record.dataSource === 'real' || record.dataSource === undefined));
+function updatePracticeView(recordsSnapshot = [], examIndexSnapshot = []) {
+ const rawRecords = Array.isArray(recordsSnapshot) ? recordsSnapshot : [];
+ const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : [];
+ // 排除演示/种子记录。判定必须与 practice.stats / achievements.progress 两个投影器
+ // 完全一致,否则会重演“演示记录在列表里看不见,却计入成绩统计和成就解锁”。
+ // 唯一权威定义在 js/data/practiceRecordSource.js(含“dataSource 缺失即真实记录”,
+ // 该语义曾因被收窄导致练习记录页整页空白,不得回退)。
+ const records = filterRealPracticeRecordsForView(rawRecords);
const stats = window.PracticeStats;
const summary = stats && typeof stats.calculateSummary === 'function'
@@ -1695,10 +1758,9 @@ function updatePracticeView() {
const examType = getCurrentExamType();
if (examType !== 'all') {
if (stats && typeof stats.filterByExamType === 'function') {
- recordsToShow = stats.filterByExamType(recordsToShow, getExamIndexState(), examType);
+ recordsToShow = stats.filterByExamType(recordsToShow, examIndex, examType);
} else {
- const examIndexSnapshot = getExamIndexState();
- recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndexSnapshot));
+ recordsToShow = recordsToShow.filter((record) => recordMatchesExamType(record, examType, examIndex));
}
}
@@ -1730,7 +1792,7 @@ function updatePracticeView() {
const priorityRenderer = ensurePracticePriorityRenderer();
if (priorityRenderer && typeof priorityRenderer.update === 'function') {
- priorityRenderer.update(recordsForInsights, getExamIndexState(), { examType });
+ priorityRenderer.update(recordsForInsights, examIndex, { examType });
}
// --- 4. Render history list ---
@@ -1762,7 +1824,7 @@ function searchPracticeHistory(query) {
if (clearButton) {
clearButton.hidden = window.__practiceHistoryQuery.length === 0;
}
- updatePracticeView();
+ startPracticeRecordsSyncInBackground('history-search', { forceRender: true });
}
function clearPracticeHistorySearch() {
@@ -1776,20 +1838,20 @@ function clearPracticeHistorySearch() {
searchPracticeHistory('');
}
-function refreshBrowseProgressFromRecords(recordsOverride = null) {
+function refreshBrowseProgressFromRecords(records, examIndex) {
try {
- const records = Array.isArray(recordsOverride)
- ? recordsOverride
- : (typeof getPracticeRecordsState === 'function'
- ? getPracticeRecordsState()
- : (Array.isArray(window.practiceRecords) ? window.practiceRecords : []));
+ const recordSnapshot = Array.isArray(records) ? records : [];
+ const indexSnapshot = Array.isArray(examIndex) ? examIndex : [];
if (typeof updateBrowseAnchorsFromRecords === 'function') {
- updateBrowseAnchorsFromRecords(records);
+ updateBrowseAnchorsFromRecords(recordSnapshot, indexSnapshot);
+ }
+ if (typeof rebuildBrowseCompletionIndex === 'function') {
+ rebuildBrowseCompletionIndex(recordSnapshot);
}
const browseView = document.getElementById('browse-view');
const isBrowseActive = browseView && browseView.classList.contains('active');
if (isBrowseActive && typeof loadExamList === 'function') {
- loadExamList();
+ loadExamList(indexSnapshot);
}
} catch (error) {
console.warn('[Browse] 刷新浏览进度失败:', error);
@@ -1802,28 +1864,11 @@ function ensurePracticeSessionSyncListener() {
return;
}
practiceSessionEventBound = true;
- document.addEventListener('practiceSessionCompleted', (event) => {
- try {
- const detail = event && event.detail ? event.detail : {};
- let record = detail.practiceRecord;
- if (record && typeof record === 'object') {
- record = enrichPracticeRecordForUI(record);
- const current = getPracticeRecordsState();
- const filtered = Array.isArray(current)
- ? current.filter((item) => item && item.id !== record.id)
- : [];
- setPracticeRecordsState([record, ...filtered]);
- updatePracticeView();
- refreshBrowseProgressFromRecords([record, ...filtered]);
- }
- } catch (syncError) {
- console.warn('[PracticeView] practiceSessionCompleted 事件处理失败:', syncError);
- } finally {
- // 仍然执行一次全面同步,确保 ScoreStorage/StorageRepo 状态一致
- setTimeout(() => {
- try { syncPracticeRecords(); } catch (_) { }
- }, 200);
- }
+ document.addEventListener('practiceSessionCompleted', () => {
+ startPracticeRecordsSyncInBackground('session-completed', {
+ mode: 'summary',
+ forceRender: true
+ });
});
}
@@ -1940,10 +1985,6 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu
try {
window.app.browseCategory(category, type, filterMode, path);
console.log('[browseCategory] Called app.browseCategory with filterMode:', filterMode);
- // 常规模式仍需刷新题库;频率模式由 browseController 接管
- if (!filterMode) {
- setTimeout(() => loadExamList(), 100);
- }
return;
} catch (error) {
console.warn('[browseCategory] window.app.browseCategory 调用失败,使用降级路径:', error);
@@ -1981,12 +2022,16 @@ function browseCategory(category, type = 'reading', filterMode = null, path = nu
}
}
-function filterByType(type) {
+async function filterByType(type, examIndexOverride = null) {
const requestedType = type;
+ let examIndex = Array.isArray(examIndexOverride) ? examIndexOverride : [];
try {
+ if (!Array.isArray(examIndexOverride)) {
+ examIndex = await resolveActiveExamIndex();
+ }
const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function'
- ? window.hasActiveListeningLibrary()
- : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening'));
+ ? window.hasActiveListeningLibrary(examIndex)
+ : examIndex.some((exam) => exam && exam.type === 'listening');
if (requestedType === 'listening' && !listeningAvailable) {
type = 'all';
if (typeof window.showMessage === 'function') {
@@ -2013,7 +2058,7 @@ function filterByType(type) {
if (window.browseController &&
window.browseController.currentMode !== 'default' &&
typeof window.browseController.resetToDefault === 'function') {
- window.browseController.resetToDefault();
+ window.browseController.resetToDefault(examIndex);
}
// 更新题库浏览筛选按钮的 active 状态
@@ -2038,12 +2083,13 @@ function filterByType(type) {
}
// 刷新题库列表
- loadExamList();
+ await loadExamList(examIndex);
}
// 应用分类筛选(供 App/总览调用)
-function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) {
+async function applyBrowseFilter(category = 'all', type = null, filterMode = null, path = null) {
try {
+ const indexSnapshot = await resolveActiveExamIndex();
const memorizeSelectionActive = isReadingMemorizeBrowseMode();
if (memorizeSelectionActive) {
category = 'all';
@@ -2065,7 +2111,6 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
// 若未显式给出类型,则根据当前题库推断(同时存在时不限定类型)
if (!type || type === 'all') {
try {
- const indexSnapshot = getExamIndexState();
const hasReading = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'reading');
const hasListening = indexSnapshot.some(e => e.category === normalizedCategory && e.type === 'listening');
if (hasReading && !hasListening) type = 'reading';
@@ -2077,8 +2122,8 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
const normalizedType = normalizeExamType(type);
const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null;
const listeningAvailable = typeof window.hasActiveListeningLibrary === 'function'
- ? window.hasActiveListeningLibrary()
- : (Array.isArray(getExamIndexState()) && getExamIndexState().some((exam) => exam && exam.type === 'listening'));
+ ? window.hasActiveListeningLibrary(indexSnapshot)
+ : indexSnapshot.some((exam) => exam && exam.type === 'listening');
const effectiveFilterMode = listeningAvailable ? filterMode : null;
const effectiveType = (!listeningAvailable && normalizedType === 'listening') ? 'all' : normalizedType;
@@ -2091,9 +2136,9 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
if (window.browseController) {
try {
if (!window.browseController.buttonContainer) {
- window.browseController.initialize('type-filter-buttons');
+ window.browseController.initialize('type-filter-buttons', indexSnapshot);
}
- window.browseController.setMode(effectiveFilterMode);
+ window.browseController.setMode(effectiveFilterMode, indexSnapshot);
} catch (error) {
console.warn('[Browse] 切换浏览模式失败:', error);
}
@@ -2105,7 +2150,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
if (window.browseController &&
window.browseController.currentMode !== 'default' &&
typeof window.browseController.resetToDefault === 'function') {
- window.browseController.resetToDefault();
+ window.browseController.resetToDefault(indexSnapshot);
}
}
@@ -2119,7 +2164,7 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
// 如果是频率模式,setMode 已经处理了刷新,不需要再次调用 loadExamList
// 只有在默认模式下才显式调用
if (!effectiveFilterMode) {
- loadExamList();
+ await loadExamList(indexSnapshot);
}
// 若未在浏览视图,则尽力切换
@@ -2140,16 +2185,21 @@ function applyBrowseFilter(category = 'all', type = null, filterMode = null, pat
}
// Initialize browse view when it's activated
-function initializeBrowseView() {
+async function initializeBrowseView(options = {}) {
console.log('[System] Initializing browse view...');
- startPracticeRecordsSyncInBackground('browse-view');
+ const [examIndex] = await Promise.all([
+ resolveActiveExamIndex(),
+ typeof window.whenBrowseViewPreferencesReady === 'function'
+ ? window.whenBrowseViewPreferencesReady()
+ : Promise.resolve()
+ ]);
// 初始化 browseController
if (window.browseController && !window.browseController.buttonContainer) {
- window.browseController.initialize('type-filter-buttons');
+ window.browseController.initialize('type-filter-buttons', examIndex);
}
if (typeof window.refreshListeningAvailabilityUI === 'function') {
- window.refreshListeningAvailabilityUI();
+ window.refreshListeningAvailabilityUI(examIndex);
}
const persisted = getPersistedBrowseFilter();
@@ -2161,12 +2211,11 @@ function initializeBrowseView() {
setBrowseTitle(formatBrowseTitle('all', 'all'));
}
- ensurePracticeRecordsSync('browse-view').then(() => {
- refreshBrowseProgressFromRecords();
- });
setupBrowseSortControl();
setupBrowseFrequencyFilterControl();
- loadExamList();
+ if (!options.skipLoad) {
+ await loadExamList(examIndex);
+ }
}
function normalizeBrowseFrequencyFilter(value) {
@@ -2188,11 +2237,27 @@ function refreshBrowseResults() {
loadExamList();
}
-function setupBrowseControls() {
+let browseControlsSeeded = false;
+async function setupBrowseControls() {
+ if (!browseControlsSeeded) {
+ try {
+ const browse = await window.AppData.preferences.getBrowse();
+ if (browse) {
+ window.__browseSortMode = browse.sortMode || window.__browseSortMode;
+ window.__browseFrequencyFilter = browse.frequencyFilter || window.__browseFrequencyFilter;
+ }
+ } catch (_) { /* defaults remain active */ }
+ browseControlsSeeded = true;
+ }
setupBrowseSortControl();
setupBrowseFrequencyFilterControl();
}
+async function persistBrowsePreference(patch) {
+ const current = await window.AppData.preferences.getBrowse() || {};
+ await window.AppData.preferences.setBrowse(Object.assign({}, current, patch));
+}
+
function setupBrowseSortControl() {
const sortSelect = document.getElementById('browse-sort-select');
if (!sortSelect || sortSelect.dataset.bound === 'true') {
@@ -2203,22 +2268,12 @@ function setupBrowseSortControl() {
return mode === 'frequency-desc' || mode === 'difficulty-desc' ? mode : 'default';
};
let savedMode = String(window.__browseSortMode || '').trim().toLowerCase();
- if (!savedMode) {
- try {
- savedMode = String(window.localStorage.getItem('browse_sort_mode') || 'default').trim().toLowerCase();
- } catch (_) {
- savedMode = 'default';
- }
- }
+ if (!savedMode) savedMode = 'default';
sortSelect.value = normalizeSortMode(savedMode);
window.__browseSortMode = sortSelect.value;
sortSelect.addEventListener('change', () => {
window.__browseSortMode = normalizeSortMode(sortSelect.value);
- try {
- window.localStorage.setItem('browse_sort_mode', window.__browseSortMode);
- } catch (_) {
- // ignore storage failures
- }
+ persistBrowsePreference({ sortMode: window.__browseSortMode }).catch(console.warn);
refreshBrowseResults();
});
sortSelect.dataset.bound = 'true';
@@ -2243,13 +2298,6 @@ function setupBrowseFrequencyFilterControl() {
return;
}
let savedFilter = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all');
- if (savedFilter === 'all') {
- try {
- savedFilter = normalizeBrowseFrequencyFilter(window.localStorage.getItem('browse_frequency_filter') || 'all');
- } catch (_) {
- savedFilter = 'all';
- }
- }
window.__browseFrequencyFilter = savedFilter;
updateBrowseFrequencyButtons(savedFilter);
}
@@ -2259,11 +2307,7 @@ function filterByFrequency(filter) {
const current = normalizeBrowseFrequencyFilter(window.__browseFrequencyFilter || 'all');
const next = requested !== 'all' && requested === current ? 'all' : requested;
window.__browseFrequencyFilter = next;
- try {
- window.localStorage.setItem('browse_frequency_filter', next);
- } catch (_) {
- // ignore storage failures
- }
+ persistBrowsePreference({ frequencyFilter: next }).catch(console.warn);
updateBrowseFrequencyButtons(next);
refreshBrowseResults();
}
@@ -2308,33 +2352,36 @@ function filterRecordsByType(type) {
setTimeout(window.updateSegmentedIndicators, 10);
}
- updatePracticeView();
+ startPracticeRecordsSyncInBackground('record-type-filter', { forceRender: true });
}
-function loadExamList() {
- setupBrowseControls();
+async function loadExamList(examIndexOverride = null) {
+ await setupBrowseControls();
+ const examIndex = Array.isArray(examIndexOverride)
+ ? examIndexOverride
+ : await resolveActiveExamIndex();
if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') {
- return window.ExamActions.loadExamList();
+ return window.ExamActions.loadExamList(examIndex);
}
console.warn('[main.js] ExamActions.loadExamList 未就绪,尝试加载 browse-view 组');
if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
window.AppLazyLoader.ensureGroup('browse-view').then(function () {
setupBrowseControls();
if (window.ExamActions && typeof window.ExamActions.loadExamList === 'function') {
- window.ExamActions.loadExamList();
+ window.ExamActions.loadExamList(examIndex);
} else {
// 最终降级:直接 DOM 渲染
- loadExamListFallback();
+ loadExamListFallback(examIndex);
}
}).catch(function (err) {
console.error('[main.js] browse-view 组加载失败:', err);
- loadExamListFallback();
+ loadExamListFallback(examIndex);
});
} else {
// 无懒加载器,直接降级
- loadExamListFallback();
+ loadExamListFallback(examIndex);
}
}
@@ -2383,13 +2430,11 @@ function clearReadingMemorizeBrowseMode() {
}
}
-function selectReadingMemorizeExam(examId) {
+async function selectReadingMemorizeExam(examId) {
if (window.ExamActions && typeof window.ExamActions.launchReadingMemorizeExam === 'function') {
return window.ExamActions.launchReadingMemorizeExam(examId);
}
- const list = typeof getExamIndexState === 'function'
- ? getExamIndexState()
- : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ const list = await resolveActiveExamIndex();
const exam = Array.isArray(list)
? list.find(function (item) { return item && String(item.id) === String(examId); })
: null;
@@ -2486,10 +2531,10 @@ function createFallbackExamCard(exam, options = {}) {
return item;
}
-function loadExamListFallback() {
+function loadExamListFallback(examIndexSnapshot = []) {
console.warn('[main.js] 使用降级渲染逻辑');
try {
- let examIndex = typeof getExamIndexState === 'function' ? getExamIndexState() : (Array.isArray(window.examIndex) ? window.examIndex : []);
+ let examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : [];
const container = document.getElementById('exam-list-container');
if (!container) return;
@@ -2590,83 +2635,11 @@ function loadExamListFallback() {
}
}
-function resetBrowseViewToAll() {
- if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') {
- return window.ExamActions.resetBrowseViewToAll();
- }
- console.warn('[main.js] ExamActions.resetBrowseViewToAll 未就绪');
-
- // 清除频率模式状态,确保回到默认列表
- clearReadingMemorizeBrowseMode();
- window.__browseFilterMode = 'default';
- window.__browsePath = null;
-
- if (window.AppLazyLoader && typeof window.AppLazyLoader.ensureGroup === 'function') {
- window.AppLazyLoader.ensureGroup('browse-view').then(function () {
- if (window.ExamActions && typeof window.ExamActions.resetBrowseViewToAll === 'function') {
- window.ExamActions.resetBrowseViewToAll();
- } else {
- // 降级:重置状态并重新加载
- if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all');
- loadExamList();
- }
- }).catch(function () {
- if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all');
- loadExamList();
- });
- } else {
- if (typeof setBrowseFilterState === 'function') setBrowseFilterState('all', 'all');
- loadExamList();
- }
-}
-
-function displayExams(exams) {
- if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') {
- return window.ExamActions.displayExams(exams);
- }
- console.warn('[main.js] ExamActions.displayExams 未就绪,使用降级渲染');
-
- // 立即降级渲染(displayExams 需要同步执行)
- try {
- const container = document.getElementById('exam-list-container');
- if (!container) return;
-
- // 清除 loading 指示器(修复 P2 bug)
- const loadingEl = document.querySelector('#browse-view .loading');
- if (loadingEl) {
- loadingEl.style.display = 'none';
- }
-
- const memorizeSelectionActive = isReadingMemorizeBrowseMode();
- if (typeof window.syncReadingMemorizeBrowseModeUI === 'function') {
- window.syncReadingMemorizeBrowseModeUI();
- }
- const normalizedExams = memorizeSelectionActive
- ? filterReadingMemorizeExamsFallback(exams)
- : (Array.isArray(exams) ? exams : []);
- if (memorizeSelectionActive && typeof setBrowseTitle === 'function') {
- setBrowseTitle('阅读背题选题');
- }
- if (normalizedExams.length === 0) {
- container.innerHTML = '
';
- return;
- }
-
- const list = document.createElement('div');
- list.className = 'exam-list';
- normalizedExams.forEach(function (exam) {
- if (!exam) return;
- list.appendChild(createFallbackExamCard(exam, {
- selectionMode: memorizeSelectionActive ? 'reading-memorize' : '',
- showMeta: true
- }));
- });
- container.innerHTML = '';
- container.appendChild(list);
- } catch (err) {
- console.error('[main.js] displayExams 降级渲染失败:', err);
- }
-}
+// resetBrowseViewToAll / displayExams 的唯一实现在 js/app/examActions.js,
+// 由其 IIFE 导出到 window.ExamActions 与 window 上。此处不再重复定义:
+// 两个文件同处 browse.bundle.js,重名的顶层声明会与 examActions 的全局写入
+// 静默互相覆盖(历史上 loadExamList 就因此渲染空白)。
+// 调用方请走 window.ExamActions.*(未加载时有 main-entry.js 的懒加载代理兜底)。
function getResourceCore() {
return window.ResourceCore || null;
@@ -2742,9 +2715,8 @@ function openExam(examId, options = {}) {
return showMessage('统一练习入口未就绪:app.openExam 不可用,已阻止打开原始题源 HTML。', 'error');
}
-function viewPDF(examId) {
- // 增加数组化防御
- const list = getExamIndexState();
+async function viewPDF(examId) {
+ const list = await resolveActiveExamIndex();
const exam = list.find(e => e.id === examId);
if (!exam || !exam.pdfFilename) return showMessage('未找到PDF文件', 'error');
@@ -2814,8 +2786,8 @@ function getViewName(viewName) {
}
}
-function updateSystemInfo() {
- const examIndexSnapshot = getExamIndexState();
+function updateSystemInfo(examIndex = []) {
+ const examIndexSnapshot = Array.isArray(examIndex) ? examIndex : [];
if (!examIndexSnapshot || examIndexSnapshot.length === 0) return;
const readingExams = examIndexSnapshot.filter(e => e.type === 'reading');
const listeningExams = examIndexSnapshot.filter(e => e.type === 'listening');
@@ -2944,14 +2916,14 @@ async function getActiveLibraryConfigurationKey() {
if (manager && typeof manager.getActiveLibraryConfigurationKey === 'function') {
return await manager.getActiveLibraryConfigurationKey();
}
- return await storage.get('active_exam_index_key', 'exam_index');
+ return window.AppData.library.getActive();
}
async function getLibraryConfigurations() {
const manager = await ensureLibraryManagerReady();
if (manager && typeof manager.getLibraryConfigurations === 'function') {
return await manager.getLibraryConfigurations();
}
- return await storage.get('exam_index_configurations', []);
+ return await window.AppData.library.listConfigurations();
}
async function saveLibraryConfiguration(name, key, examCount) {
const manager = await ensureLibraryManagerReady();
@@ -2965,19 +2937,21 @@ async function setActiveLibraryConfiguration(key) {
return await manager.setActiveLibraryConfiguration(key);
}
}
-function triggerFolderPicker() { document.getElementById('folder-picker').click(); }
-function handleFolderSelection(event) { /* legacy stub - replaced by modal-specific inputs */ }
-
// --- Library Loader Modal and Index Management ---
// ... other utility and management functions can be moved here ...
// --- Functions Restored from Backup ---
+let debouncedExamSearch = null;
+
function searchExams(query) {
toggleSearchClearButton(query);
if (window.performanceOptimizer && typeof window.performanceOptimizer.debounce === 'function') {
- const debouncedSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search');
- debouncedSearch(query);
+ // 跨 input 事件复用同一个 debounce 闭包,避免每个字符都排队一次搜索。
+ if (!debouncedExamSearch) {
+ debouncedExamSearch = window.performanceOptimizer.debounce(performSearch, 300, 'exam_search');
+ }
+ debouncedExamSearch(query);
} else {
// Fallback: direct call if optimizer not available
performSearch(query);
@@ -3008,8 +2982,8 @@ function clearSearch() {
searchExams('');
}
-function getBrowseFilteredExamBase() {
- const examIndex = getExamIndexState();
+function getBrowseFilteredExamBase(examIndexSnapshot = []) {
+ const examIndex = Array.isArray(examIndexSnapshot) ? examIndexSnapshot : [];
const activeCategory = typeof getCurrentCategory === 'function' ? getCurrentCategory() : 'all';
const activeExamType = typeof getCurrentExamType === 'function' ? getCurrentExamType() : 'all';
const isFrequencyMode = window.__browseFilterMode && window.__browseFilterMode !== 'default';
@@ -3045,7 +3019,7 @@ function getBrowseFilteredExamBase() {
return list;
}
-function performSearch(query) {
+async function performSearch(query) {
const normalizedQuery = query.toLowerCase().trim();
if (!normalizedQuery) {
loadExamList();
@@ -3054,7 +3028,7 @@ function performSearch(query) {
// 调试日志
console.log('[Search] 执行搜索,查询词:', normalizedQuery);
- const searchBase = getBrowseFilteredExamBase();
+ const searchBase = getBrowseFilteredExamBase(await resolveActiveExamIndex());
console.log('[Search] 当前筛选后索引数量:', searchBase.length);
const searchResults = searchBase.filter(exam => {
if (exam.searchText) {
@@ -3066,7 +3040,11 @@ function performSearch(query) {
});
console.log('[Search] 搜索结果数量:', searchResults.length);
- displayExams(searchResults);
+ if (window.ExamActions && typeof window.ExamActions.displayExams === 'function') {
+ window.ExamActions.displayExams(searchResults);
+ } else if (typeof window.displayExams === 'function') {
+ window.displayExams(searchResults);
+ }
}
async function toggleBulkDelete() {
@@ -3078,7 +3056,7 @@ async function toggleBulkDelete() {
if (typeof showMessage === 'function') {
showMessage('批量管理模式已开启,点击记录进行选择', 'info');
}
- updatePracticeView();
+ await syncPracticeRecords({ forceRender: true });
return;
}
@@ -3098,7 +3076,7 @@ async function toggleBulkDelete() {
clearSelectedRecordsState();
refreshBulkDeleteButton();
- updatePracticeView();
+ await syncPracticeRecords({ forceRender: true });
}
async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) {
@@ -3110,21 +3088,21 @@ async function bulkDeleteRecords(selectedSnapshot = getSelectedRecordsState()) {
const records = await listCanonicalPracticeRecords();
const baseList = Array.isArray(records) ? records : [];
- const recordsToKeep = baseList.filter(record => !normalizedIds.includes(normalizeRecordId(record && record.id)));
-
- const deletedCount = baseList.length - recordsToKeep.length;
+ const recordIds = new Set(baseList.map((record) => normalizeRecordId(record && record.id)).filter(Boolean));
+ const deletedCount = normalizedIds.filter((id) => recordIds.has(id)).length;
if (deletedCount === 0) {
showMessage('未找到可删除的记录', 'warning');
return;
}
- await persistPracticeRecordsAndRefresh(recordsToKeep, 'bulk-delete');
+ await window.AppData.practice.deleteMany({ recordIds: normalizedIds });
+ await syncPracticeRecords({ forceRender: true, trigger: 'bulk-delete' });
showMessage(`已删除 ${deletedCount} 条记录`, 'success');
console.log(`[System] 批量删除了 ${deletedCount} 条练习记录`);
}
-function toggleRecordSelection(recordId) {
+async function toggleRecordSelection(recordId) {
if (!getBulkDeleteModeState()) return;
const normalizedId = normalizeRecordId(recordId);
@@ -3138,7 +3116,7 @@ function toggleRecordSelection(recordId) {
} else {
addSelectedRecordState(normalizedId);
}
- updatePracticeView(); // Re-render to show selection state
+ await syncPracticeRecords({ forceRender: true });
}
@@ -3160,15 +3138,19 @@ async function deleteRecord(recordId) {
const confirmMessage = `确定要删除这条练习记录吗?\n\n题目: ${record.title}\n时间: ${new Date(record.date).toLocaleString()}\n\n此操作不可恢复。`;
if (confirm(confirmMessage)) {
- const nextRecords = records.filter((record) => String(record.id) !== String(recordId));
- await persistPracticeRecordsAndRefresh(nextRecords, 'single-delete');
+ await window.AppData.practice.delete({ recordId });
+ await syncPracticeRecords({ forceRender: true, trigger: 'single-delete' });
showMessage('记录已删除', 'success');
}
}
async function clearPracticeData() {
if (confirm('确定要清除所有练习记录吗?此操作不可恢复。')) {
- await persistPracticeRecordsAndRefresh([], 'clear-all');
+ await window.AppData.practice.clear();
+ await syncPracticeRecords({ forceRender: true, trigger: 'clear-all' });
+ if (window.AppData && window.AppData.recovery && typeof window.AppData.recovery.clear === 'function') {
+ await window.AppData.recovery.clear();
+ }
processedSessions.clear();
clearSelectedRecordsState();
setBulkDeleteModeState(false);
@@ -3178,44 +3160,11 @@ async function clearPracticeData() {
}
async function clearCache() {
- const confirmMessage = '确定要清除所有缓存数据并清空练习记录吗?';
- if (!confirm(confirmMessage)) {
- return;
- }
-
- const localLegacyKeys = [
- 'exam_system_practice_records',
- 'upgrade_v1_1_0_cleanup_done',
- 'browse_state',
- 'hasSeenGplLicense',
- 'theme',
- 'bloom-theme-mode',
- 'blue-theme-mode'
- ];
-
- try {
- if (window.storage && typeof storage.clear === 'function') {
- await storage.clear();
- } else if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.clear === 'function') {
- await window.PracticeRecordAPI.clear({ updateStats: true });
- } else {
- throw new Error('统一练习记录 API 未就绪');
- }
- } catch (error) {
- console.warn('[clearCache] failed to clear managed storage:', error);
- }
-
- localLegacyKeys.forEach((key) => {
- try { localStorage.removeItem(key); } catch (_) { }
- });
- setPracticeRecordsState([]);
- processedSessions.clear();
- if (window.performanceOptimizer && typeof window.performanceOptimizer.cleanup === 'function') {
- window.performanceOptimizer.cleanup();
+ if (!window.SiteDataReset || typeof window.SiteDataReset.request !== 'function') {
+ showMessage('清除失败:全量重置服务未就绪', 'error');
+ return false;
}
-
- showMessage('缓存与练习记录已清除', 'success');
- setTimeout(() => { location.reload(); }, 1000);
+ return window.SiteDataReset.request();
}
let libraryConfigViewInstance = null;
@@ -3262,7 +3211,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
}
seenKeys.add(key);
normalized.push({
- name: key === 'exam_index' ? '默认题库' : key,
+ name: key,
key,
examCount: 0,
timestamp: now
@@ -3291,15 +3240,6 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
}
}
- if (!key && typeof record.name === 'string') {
- const nameKey = normalizeKey(record.name);
- if (/^exam_index(_\d+)?$/.test(nameKey)) {
- key = nameKey;
- record.key = key;
- mutated = true;
- }
- }
-
if (!key) {
mutated = true;
continue;
@@ -3334,7 +3274,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
seenKeys.add(key);
if (typeof record.name !== 'string' || !record.name.trim()) {
- record.name = key === 'exam_index' ? '默认题库' : key;
+ record.name = key;
mutated = true;
} else {
record.name = record.name.trim();
@@ -3370,6 +3310,7 @@ function normalizeLibraryConfigurationRecords(rawConfigs) {
async function resolveLibraryConfigurations() {
const rawConfigs = await getLibraryConfigurations();
+ const activeIndex = await resolveActiveExamIndex();
let configs = Array.isArray(rawConfigs) ? rawConfigs : [];
let mutated = false;
@@ -3377,28 +3318,22 @@ async function resolveLibraryConfigurations() {
configs = normalizedResult.normalized;
mutated = normalizedResult.mutated;
- if (configs.length === 0) {
- try {
- const count = getExamIndexState().length;
- configs = [{
- name: '默认题库',
- key: 'exam_index',
- examCount: count,
- timestamp: Date.now()
- }];
- mutated = true;
- const activeKey = await storage.get('active_exam_index_key');
- if (!activeKey) {
- await storage.set('active_exam_index_key', 'exam_index');
- }
- } catch (error) {
- console.warn('[LibraryConfig] 无法初始化默认题库配置', error);
- }
+ if (!configs.some(config => config && config.builtIn === true)) {
+ configs.unshift({
+ name: '默认题库',
+ key: '',
+ id: null,
+ builtIn: true,
+ sourceType: 'built-in-manifest',
+ examCount: activeIndex.length
+ });
}
if (mutated) {
try {
- await storage.set('exam_index_configurations', configs);
+ for (const config of configs) {
+ if (config && config.key && config.builtIn !== true) await window.AppData.library.updateConfiguration(config);
+ }
} catch (error) {
console.warn('[LibraryConfig] 无法同步题库配置记录', error);
}
@@ -3454,15 +3389,14 @@ async function deleteLibraryConfiguration(key) {
async function debugCompareActiveIndexWithDefault() {
try {
const activeKey = await getActiveLibraryConfigurationKey();
- const activeIndex = Array.isArray(getExamIndexState()) ? getExamIndexState() : [];
+ const activeIndex = await resolveActiveExamIndex();
const defaultIndex = typeof window.getReadingExamIndex === 'function'
? window.getReadingExamIndex().map((exam) => Object.assign({}, exam, { type: 'reading' }))
: (Array.isArray(window.__READING_EXAM_INDEX__)
? window.__READING_EXAM_INDEX__.map((exam) => Object.assign({}, exam, { type: 'reading' }))
: []);
const defaultListening = Array.isArray(window.listeningExamIndex) ? window.listeningExamIndex : [];
- const storedDefault = await storage.get('exam_index', []);
- const combinedDefault = storedDefault.length ? storedDefault : [...defaultIndex, ...defaultListening];
+ const combinedDefault = [...defaultIndex, ...defaultListening];
const normalizeTail = (path) => {
const p = String(path || '').replace(/\\/g, '/').split('/').filter(Boolean);
@@ -3558,8 +3492,8 @@ function renderLibraryConfigFallback(container, configs, options) {
if (!config) {
return;
}
- const isActive = activeKey === config.key;
- const isDefault = config.key === 'exam_index';
+ const isDefault = config.builtIn === true;
+ const isActive = isDefault ? activeKey == null : activeKey === config.key;
const item = document.createElement('div');
item.className = 'library-config-panel__item' + (activeKey === config.key ? ' library-config-panel__item--active' : '');
@@ -3583,7 +3517,7 @@ function renderLibraryConfigFallback(container, configs, options) {
switchBtn.type = 'button';
switchBtn.className = 'btn btn-secondary';
switchBtn.dataset.configAction = 'switch';
- switchBtn.dataset.configKey = config.key;
+ switchBtn.dataset.configKey = config.key || '';
if (isActive) {
switchBtn.dataset.configActive = '1';
}
@@ -3755,10 +3689,7 @@ async function showLibraryConfigListV2(options) {
// 切换题库配置
async function switchLibraryConfig(configKey) {
- const key = typeof configKey === 'string' ? configKey.trim() : '';
- if (!key) {
- return;
- }
+ const key = typeof configKey === 'string' && configKey.trim() ? configKey.trim() : null;
try {
const activeKey = await getActiveLibraryConfigurationKey();
if (activeKey === key) {
@@ -3786,10 +3717,6 @@ async function deleteLibraryConfig(configKey) {
if (!key) {
return;
}
- if (key === 'exam_index') {
- showMessage('默认题库不可删除', 'warning');
- return;
- }
try {
const activeKey = await getActiveLibraryConfigurationKey();
if (activeKey === key) {
@@ -3924,12 +3851,12 @@ function openExamWithFallback(exam, delay = 600) {
}
// Phase 3: 随机练习 - 已迁移到 app-actions.js
-function startRandomPractice(category, type = 'reading', filterMode = null, path = null) {
+async function startRandomPractice(category, type = 'reading', filterMode = null, path = null) {
if (window.AppActions && typeof window.AppActions.startRandomPractice === 'function') {
return window.AppActions.startRandomPractice(category, type, filterMode, path);
}
// 降级:直接执行
- const list = getExamIndexState();
+ const list = await resolveActiveExamIndex();
const normalizedType = (!type || type === 'all') ? null : type;
const normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null;
diff --git a/js/patches/runtime-fixes.js b/js/patches/runtime-fixes.js
deleted file mode 100644
index 523c07ea..00000000
--- a/js/patches/runtime-fixes.js
+++ /dev/null
@@ -1,109 +0,0 @@
-// Runtime fixes to smooth async storage + recovery under file://
-(function () {
- 'use strict';
-
- function ensureCompatPatch(global) {
- if (!global || (global.CompatPatch && typeof global.CompatPatch.register === 'function')) {
- return global && global.CompatPatch ? global.CompatPatch : null;
- }
- var patches = [];
- var register = function register(name, metadata) {
- if (!name) {
- return null;
- }
- var patch = Object.assign({
- name: String(name),
- owner: 'legacy',
- reason: '',
- removeAfter: ''
- }, metadata || {});
- patches.push(patch);
- return patch;
- };
- var list = function list() {
- return patches.slice();
- };
- global.CompatPatch = Object.assign({}, global.CompatPatch || {}, {
- register: register,
- list: list
- });
- return global.CompatPatch;
- }
-
- ensureCompatPatch(window);
-
- if (window.CompatPatch && typeof window.CompatPatch.register === 'function') {
- window.CompatPatch.register('practice-recorder-temp-recovery-async', {
- owner: 'practice',
- reason: 'file protocol compatible recovery for legacy temporary practice records',
- removeAfter: 'after PracticeRecorder recovery is canonical'
- });
- }
-
- try {
- // Patch PracticeRecorder.recoverTemporaryRecords to a robust async version
- const patchPracticeRecorder = () => {
- const PR = window.PracticeRecorder;
- if (!PR || !PR.prototype) return false;
-
- const original = PR.prototype.recoverTemporaryRecords;
- PR.prototype.recoverTemporaryRecords = async function () {
- try {
- const raw = (window.storage && storage.get)
- ? await storage.get('temp_practice_records', [])
- : [];
- const tempRecords = Array.isArray(raw) ? raw : [];
-
- if (tempRecords.length === 0) {
- console.log('[PracticeRecorder] 没有需要恢复的临时记录');
- return;
- }
-
- console.log(`[PracticeRecorder] 发现 ${tempRecords.length} 条临时记录,开始恢复...`);
-
- let recoveredCount = 0;
- const failed = [];
-
- for (const tempRecord of tempRecords) {
- try {
- const { tempSavedAt, needsRecovery, ...cleanRecord } = tempRecord || {};
- const sanitized = (this && typeof this.sanitizeRecoveredRecord === 'function')
- ? this.sanitizeRecoveredRecord(cleanRecord)
- : cleanRecord;
- if (!sanitized || !sanitized.examId) {
- console.warn('[PracticeRecorder] 跳过无法修正的临时记录(缺少 examId 或字段无效)', cleanRecord && cleanRecord.id);
- continue;
- }
- if (this && typeof this.savePracticeRecord === 'function') {
- await this.savePracticeRecord(sanitized);
- }
- recoveredCount++;
- console.log(`[PracticeRecorder] 恢复记录成功: ${sanitized && sanitized.id}`);
- } catch (e) {
- console.error(`[PracticeRecorder] 恢复记录失败: ${tempRecord && tempRecord.id}`, e);
- failed.push(tempRecord);
- }
- }
-
- if (failed.length === 0) {
- if (window.storage && storage.remove) await storage.remove('temp_practice_records');
- console.log(`[PracticeRecorder] 所有 ${recoveredCount} 条临时记录恢复成功`);
- } else {
- if (window.storage && storage.set) await storage.set('temp_practice_records', failed);
- console.log(`[PracticeRecorder] 恢复了 ${recoveredCount} 条记录,${failed.length} 条失败`);
- }
- } catch (error) {
- console.error('[PracticeRecorder] 恢复临时记录时出错:', error);
- }
- };
-
- console.log('[RuntimeFixes] PracticeRecorder.recoverTemporaryRecords 已替换为异步实现');
- return true;
- };
-
- const tryPatch = () => {
- if (!patchPracticeRecorder()) setTimeout(tryPatch, 100);
- };
- tryPatch();
- } catch (_) {}
-})();
diff --git a/js/practice-page-enhancer.js b/js/practice-page-enhancer.js
index 72433ef2..28ee2a5d 100644
--- a/js/practice-page-enhancer.js
+++ b/js/practice-page-enhancer.js
@@ -14,6 +14,20 @@
}
console.log('[PracticeEnhancer] 初始化增强器');
+ const HOST_MESSAGE_SOURCE = 'exam_host';
+
+ function deriveParentOriginFromReferrer() {
+ try {
+ if (!document.referrer) return '';
+ const parsed = new URL(document.referrer, window.location.href);
+ // Chromium: file URL.origin is "file://", postMessage event.origin is "null".
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (_) {
+ return '';
+ }
+ }
const DEFAULT_ENHANCER_CONFIG = {
autoInitialize: true,
@@ -766,6 +780,10 @@
sessionId: null,
examId: null, // 新增:存储唯一的examId
parentWindow: null,
+ expectedParentOrigin: deriveParentOriginFromReferrer(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
+ windowSessionToken: '',
answers: {},
correctAnswers: {},
interactions: [],
@@ -882,9 +900,7 @@
}
this.enhancerBaseUrl = this.getEnhancerBaseUrl();
- await this.ensureStorageAvailable();
await this.ensureSpellingErrorCollector();
- await this.prepareStorageNamespace();
// 检测多套题结构
this.isMultiSuite = this.detectMultiSuiteStructure();
@@ -1081,95 +1097,6 @@
}).filter(Boolean);
},
- ensureStorageAvailable: async function () {
- try {
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- if (window.storage.ready && typeof window.storage.ready.then === 'function') {
- await window.storage.ready;
- }
- return true;
- }
-
- const tryLoad = async (urls) => {
- for (const url of urls) {
- if (!url) continue;
- try {
- console.log('[PracticeEnhancer] 尝试加载存储管理器:', url);
- await dependencyLoader.loadScript(url);
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- if (window.storage.ready && typeof window.storage.ready.then === 'function') {
- await window.storage.ready;
- }
- return true;
- }
- } catch (error) {
- console.warn('[PracticeEnhancer] 存储管理器加载失败:', error);
- }
- }
- return false;
- };
-
- const baseUrl = this.getEnhancerBaseUrl();
- const baseCandidate = new URL('utils/storage.js', baseUrl).href;
- const fallbackUrls = this.buildFallbackUrls([
- '../../../../js/utils/storage.js',
- '../../../js/utils/storage.js',
- '../../js/utils/storage.js',
- '../js/utils/storage.js',
- './js/utils/storage.js'
- ]);
-
- const loaded = await tryLoad([baseCandidate, ...fallbackUrls]);
- if (loaded) return true;
- } catch (error) {
- console.warn('[PracticeEnhancer] 加载存储管理器失败:', error);
- }
-
- // 创建简易回退存储,确保流程不中断
- console.warn('[PracticeEnhancer] 使用简易回退存储');
- const fallbackPrefix = 'exam_system_';
- const safeStore = (() => {
- try {
- return window.localStorage;
- } catch (_) {
- return null;
- }
- })();
-
- const stubStorage = {
- namespace: '',
- ready: Promise.resolve(),
- setNamespace(ns) { this.namespace = ns ? `${ns}_` : ''; },
- async set(key, value) {
- if (!safeStore) return false;
- const k = fallbackPrefix + this.namespace + key;
- safeStore.setItem(k, JSON.stringify({ value }));
- return true;
- },
- async get(key) {
- if (!safeStore) return null;
- const k = fallbackPrefix + this.namespace + key;
- const raw = safeStore.getItem(k);
- if (!raw) return null;
- try {
- const parsed = JSON.parse(raw);
- return parsed && parsed.value !== undefined ? parsed.value : parsed;
- } catch (_) {
- return null;
- }
- },
- async remove(key) {
- if (!safeStore) return false;
- const k = fallbackPrefix + this.namespace + key;
- safeStore.removeItem(k);
- return true;
- }
- };
-
- window.storage = stubStorage;
- return true;
- },
-
ensureSpellingErrorCollector: async function () {
if (window.spellingErrorCollector) {
return true;
@@ -1211,42 +1138,6 @@
return loaded;
},
- prepareStorageNamespace: async function () {
- // 设置共享命名空间
- try {
- if (window.storage?.ready) {
- await window.storage.ready;
- }
-
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[PracticeEnhancer] 已设置共享命名空间: exam_system');
-
- // 验证命名空间设置是否生效
- setTimeout(async () => {
- const testKey = 'namespace_test_enhancer';
- const testValue = 'test_value_enhancer_' + Date.now();
- try {
- await window.storage.set(testKey, testValue);
- const retrievedValue = await window.storage.get(testKey);
- if (retrievedValue === testValue) {
- console.log('✅ 增强器命名空间设置验证成功: 存储和读取正常');
- } else {
- console.warn('❌ 增强器命名空间设置验证失败: 读取值不匹配');
- }
- await window.storage.remove(testKey);
- } catch (error) {
- console.error('❌ 增强器命名空间设置验证失败', error);
- }
- }, 1000);
- } else {
- console.warn('[PracticeEnhancer] 存储管理器未加载或setNamespace方法不可用');
- }
- } catch (error) {
- console.error('[PracticeEnhancer] 存储初始化失败,跳过命名空间设置', error);
- }
- },
-
cleanup: function () {
console.log('[PracticeEnhancer] 清理资源');
if (this.answerCollectionInterval) {
@@ -1857,9 +1748,49 @@
}
const messageType = String(payload.type).toUpperCase();
const payloadData = payload.data || {};
+ if (!event || event.source !== this.parentWindow || payload.source !== HOST_MESSAGE_SOURCE) {
+ return;
+ }
if (messageType === 'INIT_SESSION' || messageType === 'INIT_EXAM_SESSION') {
const initData = payloadData;
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ const declaredOrigin = typeof initData.parentOrigin === 'string' ? initData.parentOrigin : '';
+ const incomingToken = typeof initData.windowSessionToken === 'string'
+ ? initData.windowSessionToken.trim()
+ : '';
+ if (!incomingToken) return;
+ const expectedParentOrigin = this.expectedParentOrigin
+ && this.expectedParentOrigin !== 'file://'
+ && !String(this.expectedParentOrigin).startsWith('file:')
+ ? this.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) {
+ return;
+ }
+ this.parentOrigin = expectedParentOrigin;
+ this.parentOriginIsOpaque = false;
+ } else if (window.location.protocol === 'file:') {
+ const trustedFileOrigin = incomingOrigin === 'null'
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) {
+ return;
+ }
+ this.parentOrigin = 'null';
+ this.parentOriginIsOpaque = true;
+ } else {
+ const trustedWebOrigin = Boolean(incomingOrigin)
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) {
+ return;
+ }
+ this.parentOrigin = incomingOrigin;
+ this.parentOriginIsOpaque = false;
+ }
+ this.windowSessionToken = incomingToken;
this.sessionId = initData.sessionId;
this.examId = initData.examId; // 存储 examId
if (initData.reviewSessionId) {
@@ -1892,6 +1823,17 @@
return;
}
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ const incomingToken = typeof payloadData.windowSessionToken === 'string'
+ ? payloadData.windowSessionToken.trim()
+ : '';
+ const originMatches = this.parentOriginIsOpaque
+ ? incomingOrigin === 'null'
+ : Boolean(this.parentOrigin && incomingOrigin === this.parentOrigin);
+ if (!originMatches || !this.windowSessionToken || incomingToken !== this.windowSessionToken) {
+ return;
+ }
+
if (messageType === 'REPLAY_PRACTICE_RECORD') {
this.applyReplayRecord(payloadData || {});
return;
@@ -3380,6 +3322,7 @@
// Requirement 9.1: 必须包含的基本字段
examId: `${this.examId}_${suiteId}`, // Requirement 9.2: examId包含套题标识
sessionId: this.sessionId,
+ suiteSessionId: this.suiteSessionId || null,
answers: suiteAnswers, // Requirement 9.3: 答案键使用"套题ID::问题ID"格式
correctAnswers: suiteCorrectAnswers,
@@ -4530,29 +4473,57 @@
return null;
},
+ createSubmissionId: function () {
+ try {
+ if (window.crypto && typeof window.crypto.randomUUID === 'function') {
+ return `practice-submit-${window.crypto.randomUUID()}`;
+ }
+ } catch (_) {
+ // Fall through to the session-bound fallback.
+ }
+ return `practice-submit-${this.sessionId || this.examId || 'session'}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ },
+
sendMessage: function (type, data) {
if (!this.parentWindow) {
console.warn('[PracticeEnhancer] 无父窗口,无法发送消息');
- return;
+ return false;
}
if (this.readOnly && type === 'PRACTICE_COMPLETE') {
console.info('[PracticeEnhancer] 回顾模式阻止 PRACTICE_COMPLETE 上报');
- return;
+ return false;
}
- this.runHooks('beforeSendMessage', type, data);
+ const payload = data && typeof data === 'object' ? data : {};
+ if (type === 'PRACTICE_COMPLETE' || type === 'PRACTICE_RESULT') {
+ payload.sessionId = payload.sessionId || this.sessionId || null;
+ payload.submissionId = payload.submissionId || this.createSubmissionId();
+ }
+ this.runHooks('beforeSendMessage', type, payload);
+ const secureData = Object.assign({}, payload, {
+ windowSessionToken: this.windowSessionToken || null
+ });
const message = {
type: type,
- data: data,
+ data: secureData,
source: 'practice_page',
timestamp: Date.now()
};
try {
- this.parentWindow.postMessage(message, '*');
+ const targetOrigin = this.parentOrigin && this.parentOrigin !== 'null'
+ ? this.parentOrigin
+ : (this.expectedParentOrigin || (window.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) {
+ console.warn('[PracticeEnhancer] 缺少可信父窗口 origin,消息未发送:', type);
+ return false;
+ }
+ this.parentWindow.postMessage(message, targetOrigin);
console.log('[PracticeEnhancer] 消息已发送:', type);
+ return true;
} catch (error) {
console.error('[PracticeEnhancer] 发送消息失败:', error);
+ return false;
}
},
diff --git a/js/presentation/app-actions.js b/js/presentation/app-actions.js
index cd75021f..5480b319 100644
--- a/js/presentation/app-actions.js
+++ b/js/presentation/app-actions.js
@@ -134,11 +134,11 @@
if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') {
frequencyScope = 'all';
}
- return {
+ return Promise.resolve({
flowMode: flowMode,
frequencyScope: frequencyScope,
autoAdvanceAfterSubmit: flowMode !== 'stationary'
- };
+ });
}
function persistSuitePreference(partial) {
@@ -146,7 +146,22 @@
if (suitePreferenceUtils && typeof suitePreferenceUtils.persistSuitePreference === 'function') {
return suitePreferenceUtils.persistSuitePreference(partial || {});
}
- return resolveSuitePreference(partial || {});
+ // Fallback persists locally; resolveSuitePreference() above is async,
+ // but persistSuitePreference itself must remain synchronous so callers
+ // can read .flowMode/.frequencyScope immediately. Compute inline.
+ var flowMode = String(partial && partial.flowMode || '').trim().toLowerCase();
+ if (flowMode !== 'classic' && flowMode !== 'simulation' && flowMode !== 'stationary') {
+ flowMode = 'classic';
+ }
+ var frequencyScope = String(partial && partial.frequencyScope || '').trim().toLowerCase();
+ if (frequencyScope !== 'high' && frequencyScope !== 'high_medium' && frequencyScope !== 'all' && frequencyScope !== 'custom') {
+ frequencyScope = 'all';
+ }
+ return {
+ flowMode: flowMode,
+ frequencyScope: frequencyScope,
+ autoAdvanceAfterSubmit: flowMode !== 'stationary'
+ };
}
function persistSuiteFlowMode(mode) {
@@ -161,9 +176,9 @@
function promptSuiteModeSelection() {
return new Promise(function resolveSelection(resolve) {
- var preselectedPreference = resolveSuitePreference();
- var preselected = preselectedPreference.flowMode || 'classic';
- var preselectedScope = preselectedPreference.frequencyScope || 'all';
+ resolveSuitePreference().then(function applyPreselection(preselectedPreference) {
+ var preselected = (preselectedPreference && preselectedPreference.flowMode) || 'classic';
+ var preselectedScope = (preselectedPreference && preselectedPreference.frequencyScope) || 'all';
var search = '';
try {
search = String(global.location && global.location.search || '').toLowerCase();
@@ -274,6 +289,7 @@
}
});
global.document.body.appendChild(host);
+ });
});
}
@@ -379,34 +395,6 @@
}
}
- function getExamIndexSnapshot() {
- if (typeof global.getExamIndexState === 'function') {
- try {
- var snapshot = global.getExamIndexState();
- if (Array.isArray(snapshot) && snapshot.length) {
- return snapshot.slice();
- }
- } catch (_) { }
- }
- if (Array.isArray(global.examIndex) && global.examIndex.length) {
- return global.examIndex.slice();
- }
- if (typeof global.getReadingExamIndex === 'function') {
- var readingIndex = global.getReadingExamIndex();
- if (Array.isArray(readingIndex) && readingIndex.length) {
- return readingIndex.map(function (exam) {
- return Object.assign({}, exam, { type: exam.type || 'reading' });
- });
- }
- }
- if (Array.isArray(global.__READING_EXAM_INDEX__) && global.__READING_EXAM_INDEX__.length) {
- return global.__READING_EXAM_INDEX__.map(function (exam) {
- return Object.assign({}, exam, { type: exam.type || 'reading' });
- });
- }
- return [];
- }
-
function isReadingMemorizeCandidate(exam) {
if (!exam || !exam.id) {
return false;
@@ -596,12 +584,8 @@
});
}
- function startRandomPractice(category, type, filterMode, path) {
- var getExamIndexState = global.getExamIndexState || function () {
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- };
-
- var list = getExamIndexState();
+ async function startRandomPractice(category, type, filterMode, path) {
+ var list = await global.resolveActiveLibraryIndex();
var normalizedType = (!type || type === 'all') ? null : type;
var normalizedPath = (typeof path === 'string' && path.trim()) ? path.trim() : null;
@@ -702,11 +686,8 @@
}, 1000);
}
- function pickRandomExam() {
- var getExamIndexState = global.getExamIndexState || function () {
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- };
- var list = getExamIndexState().filter(function (e) {
+ function pickRandomExam(examIndex) {
+ var list = (Array.isArray(examIndex) ? examIndex : []).filter(function (e) {
return e && e.hasHtml && e.type === 'reading';
});
if (!list.length) return null;
@@ -728,6 +709,9 @@
}
// resolve to absolute
url = new URL(url, window.location.href).href;
+ var parsedUrl = new URL(url);
+ parsedUrl.searchParams.set('endless', '1');
+ url = parsedUrl.href;
} catch (_) { }
if (!url) return null;
@@ -752,14 +736,21 @@
if (!endlessState || !endlessState.active) return;
var countdown = ENDLESS_COUNTDOWN_SEC;
+ var postEndlessControl = function (type, data) {
+ if (!endlessState || !endlessState.currentExamId || !global.app
+ || typeof global.app._postExamMessage !== 'function') return false;
+ return global.app._postExamMessage(
+ endlessState.currentExamId,
+ sourceWindow,
+ type,
+ data || {}
+ );
+ };
// 通知练习页开始倒计时
try {
if (sourceWindow && !sourceWindow.closed) {
- sourceWindow.postMessage({
- type: 'ENDLESS_COUNTDOWN',
- data: { seconds: countdown }
- }, '*');
+ postEndlessControl('ENDLESS_COUNTDOWN', { seconds: countdown });
}
} catch (_) { }
@@ -780,10 +771,7 @@
// 持续更新倒计时
try {
if (sourceWindow && !sourceWindow.closed) {
- sourceWindow.postMessage({
- type: 'ENDLESS_COUNTDOWN_TICK',
- data: { seconds: countdown }
- }, '*');
+ postEndlessControl('ENDLESS_COUNTDOWN_TICK', { seconds: countdown });
}
} catch (_) { }
@@ -793,16 +781,13 @@
try {
if (sourceWindow && !sourceWindow.closed) {
- sourceWindow.postMessage({
- type: 'ENDLESS_COUNTDOWN_END',
- data: {}
- }, '*');
+ postEndlessControl('ENDLESS_COUNTDOWN_END', {});
}
} catch (_) { }
if (!endlessState || !endlessState.active) return;
- var nextExam = pickRandomExam();
+ var nextExam = pickRandomExam(endlessState.examIndex);
if (!nextExam) {
if (typeof global.showMessage === 'function') {
global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a', 'warning');
@@ -816,21 +801,32 @@
}
var reuseWin = (sourceWindow && !sourceWindow.closed) ? sourceWindow : null;
- var newWin = openEndlessExam(nextExam, reuseWin);
- if (newWin) {
- endlessState.currentWindow = newWin;
- if (global.app && typeof global.app.setupExamWindowManagement === 'function') {
- global.app.setupExamWindowManagement(newWin, nextExam.id, nextExam, {});
+ var openNext = global.app && typeof global.app.openExam === 'function'
+ ? global.app.openExam(nextExam.id, {
+ target: 'tab',
+ windowName: ENDLESS_WINDOW_NAME,
+ reuseWindow: reuseWin,
+ endlessMode: true
+ })
+ : openEndlessExam(nextExam, reuseWin);
+ Promise.resolve(openNext).then(function (newWin) {
+ if (!newWin || !endlessState || !endlessState.active) {
+ throw new Error('无法打开下一题');
}
- if (global.app && typeof global.app.startPracticeSession === 'function') {
- try { global.app.startPracticeSession(nextExam.id); } catch (_) { }
+ endlessState.currentWindow = newWin;
+ endlessState.currentExamId = nextExam.id;
+ }).catch(function (error) {
+ if (global.console && console.error) console.error('[EndlessMode] 打开下一题失败:', error);
+ if (typeof global.showMessage === 'function') {
+ global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u4e0b\u4e00\u9898', 'error');
}
- }
+ stopEndlessPractice({ silent: true });
+ });
}
}, 1000);
}
- function startEndlessPractice() {
+ async function startEndlessPractice() {
// 如果已激活,不再走“父页按钮二次点击退出”的伪交互
if (endlessState && endlessState.active) {
if (typeof global.showMessage === 'function') {
@@ -839,7 +835,8 @@
return;
}
- var firstExam = pickRandomExam();
+ var examIndex = await global.resolveActiveLibraryIndex();
+ var firstExam = pickRandomExam(examIndex);
if (!firstExam) {
if (typeof global.showMessage === 'function') {
global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u9898\u5e93\u4e3a\u7a7a\uff0c\u8bf7\u5148\u52a0\u8f7d\u9898\u5e93', 'error');
@@ -850,8 +847,10 @@
// 标记状态
endlessState = {
active: true,
+ examIndex: examIndex,
countdownTimer: null,
currentWindow: null,
+ currentExamId: firstExam.id,
messageHandler: null,
windowMonitor: null
};
@@ -861,6 +860,25 @@
if (!endlessState || !endlessState.active) return;
var msg = event && event.data;
if (!msg || typeof msg.type !== 'string') return;
+ var currentWindow = endlessState.currentWindow;
+ if (!currentWindow || event.source !== currentWindow) return;
+ var info = global.app && global.app.examWindows && endlessState.currentExamId
+ ? global.app.examWindows.get(endlessState.currentExamId)
+ : null;
+ if (info && info.expectedOrigin && info.expectedOrigin !== 'null') {
+ if (event.origin !== info.expectedOrigin) return;
+ } else if (info && info.allowOpaqueOrigin) {
+ if (event.origin !== 'null') return;
+ } else {
+ return;
+ }
+ var messageData = msg.data || {};
+ var permitsPreInit = msg.type === 'REQUEST_INIT';
+ if (!permitsPreInit && (
+ msg.source !== 'practice_page'
+ || !info.windowSessionToken
+ || messageData.windowSessionToken !== info.windowSessionToken
+ )) return;
if (msg.type === 'ENDLESS_USER_EXIT') {
stopEndlessPractice();
return;
@@ -894,19 +912,27 @@
// 优先用 app.openExam 保证注入
if (global.app && typeof global.app.openExam === 'function') {
try {
- Promise.resolve(global.app.openExam(firstExam.id, {
+ win = await global.app.openExam(firstExam.id, {
target: 'tab',
- windowName: ENDLESS_WINDOW_NAME
- })).then(function (w) {
- if (w && endlessState) endlessState.currentWindow = w;
- startEndlessWindowMonitor();
- }).catch(function () { });
- } catch (_) { }
+ windowName: ENDLESS_WINDOW_NAME,
+ endlessMode: true
+ });
+ } catch (error) {
+ if (global.console && console.error) console.error('[EndlessMode] 打开首题失败:', error);
+ }
} else {
win = openEndlessExam(firstExam, null);
- if (win && endlessState) endlessState.currentWindow = win;
- startEndlessWindowMonitor();
}
+ if (!win || !endlessState) {
+ stopEndlessPractice({ silent: true });
+ if (typeof global.showMessage === 'function') {
+ global.showMessage('\u65e0\u5c3d\u6a21\u5f0f\uff1a\u65e0\u6cd5\u6253\u5f00\u7ec3\u4e60\u7a97\u53e3', 'error');
+ }
+ return;
+ }
+ endlessState.currentWindow = win;
+ endlessState.currentExamId = firstExam.id;
+ startEndlessWindowMonitor();
}
global.AppActions = Object.assign({}, global.AppActions, {
diff --git a/js/presentation/developerTeamModal.js b/js/presentation/developerTeamModal.js
deleted file mode 100644
index fef71d48..00000000
--- a/js/presentation/developerTeamModal.js
+++ /dev/null
@@ -1,58 +0,0 @@
-(function initDeveloperTeamModal(global) {
- 'use strict';
-
- function getModal() {
- return document.getElementById('developer-modal');
- }
-
- if (typeof global.showDeveloperTeam !== 'function') {
- global.showDeveloperTeam = function showDeveloperTeam() {
- var modal = getModal();
- if (modal) {
- modal.classList.add('show');
- }
- };
- }
-
- if (typeof global.hideDeveloperTeam !== 'function') {
- global.hideDeveloperTeam = function hideDeveloperTeam() {
- var modal = getModal();
- if (modal) {
- modal.classList.remove('show');
- }
- };
- }
-
- function setupDismissHandlers() {
- var modal = getModal();
- if (!modal || modal.dataset.dismissBound === '1') {
- return;
- }
-
- modal.addEventListener('click', function onBackdropClick(event) {
- if (event.target === modal) {
- global.hideDeveloperTeam();
- }
- });
-
- modal.dataset.dismissBound = '1';
- }
-
- function handleEscape(event) {
- if (event.key !== 'Escape') {
- return;
- }
- var modal = getModal();
- if (modal && modal.classList.contains('show')) {
- global.hideDeveloperTeam();
- }
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', setupDismissHandlers);
- } else {
- setupDismissHandlers();
- }
-
- document.addEventListener('keydown', handleEscape);
-})(typeof window !== 'undefined' ? window : this);
diff --git a/js/presentation/indexInteractions.js b/js/presentation/indexInteractions.js
index db523c8b..9175a6f9 100644
--- a/js/presentation/indexInteractions.js
+++ b/js/presentation/indexInteractions.js
@@ -9,7 +9,8 @@
var settingsPrefetchPromise = null;
var indexInteractionsInitialized = false;
var licenseModalInitialized = false;
- var LICENSE_STORAGE_KEY = 'hasSeenGplLicense';
+ var licenseModalInitializationPromise = null;
+ var licenseModalRenderToken = 0;
function ensureBrowse() {
if (browsePrefetched) {
@@ -44,22 +45,14 @@
}
function ensureSettings() {
- if (settingsPrefetched) {
- return (settingsPrefetchPromise || Promise.resolve()).then(function () {
- if (global.ExternalBackupService && typeof global.ExternalBackupService.refreshPanel === 'function') {
- try { global.ExternalBackupService.refreshPanel(); } catch (_) { /* ignore */ }
- }
- });
+ if (settingsPrefetched) {
+ return settingsPrefetchPromise || Promise.resolve();
}
settingsPrefetched = true;
var loader = global.AppEntry && typeof global.AppEntry.ensureSettingsToolsGroup === 'function'
? global.AppEntry.ensureSettingsToolsGroup
: function fallback() { return Promise.resolve(); };
- settingsPrefetchPromise = loader().then(function () {
- if (global.ExternalBackupService && typeof global.ExternalBackupService.refreshPanel === 'function') {
- try { global.ExternalBackupService.refreshPanel(); } catch (_) { /* ignore */ }
- }
- }).catch(function swallow(error) {
+ settingsPrefetchPromise = loader().catch(function swallow(error) {
settingsPrefetched = false;
settingsPrefetchPromise = null;
console.warn('[IndexInteractions] 预加载 settings 失败:', error);
@@ -67,8 +60,8 @@ function ensureSettings() {
return settingsPrefetchPromise;
}
- function startListeningSprint() {
- var list = typeof global.getExamIndexState === 'function' ? global.getExamIndexState() : [];
+ async function startListeningSprint() {
+ var list = await global.resolveActiveLibraryIndex();
var listeningExams = Array.isArray(list) ? list.filter(function (exam) { return exam && exam.type === 'listening'; }) : [];
if (!listeningExams.length) {
if (typeof global.showMessage === 'function') {
@@ -87,8 +80,8 @@ function ensureSettings() {
}
}
- function startInstantLaunch() {
- var list = typeof global.getExamIndexState === 'function' ? global.getExamIndexState() : [];
+ async function startInstantLaunch() {
+ var list = await global.resolveActiveLibraryIndex();
if (!Array.isArray(list) || !list.length) {
if (typeof global.showMessage === 'function') {
global.showMessage('题库尚未加载', 'error');
@@ -253,6 +246,13 @@ function ensureSettings() {
? global.OnboardingTour.start(true)
: undefined;
}],
+ ['external-backup-entry-btn', function () {
+ return ensureSettings().then(function () {
+ return global.ExternalBackupService && typeof global.ExternalBackupService.openModal === 'function'
+ ? global.ExternalBackupService.openModal()
+ : undefined;
+ });
+ }],
['create-backup-btn', function () {
return ensureSettings().then(function () {
return typeof global.createManualBackup === 'function' && global.createManualBackup();
@@ -672,12 +672,20 @@ function ensureSettings() {
return global.document ? global.document.getElementById('license-modal') : null;
}
- function hasAcceptedLicense() {
- try {
- return global.localStorage && global.localStorage.getItem(LICENSE_STORAGE_KEY) === 'true';
- } catch (_) {
- return true;
+ function getConsentPreferences() {
+ var preferences = global.AppData && global.AppData.preferences;
+ if (!preferences || typeof preferences.getConsent !== 'function' || typeof preferences.setConsent !== 'function') {
+ throw new Error('AppData preferences consent API is unavailable');
}
+ return preferences;
+ }
+
+ function hasAcceptedLicense() {
+ return Promise.resolve().then(function () {
+ return getConsentPreferences().getConsent();
+ }).then(function (consent) {
+ return !!(consent && consent.hasSeenGplLicense === true);
+ });
}
function showLicenseModal() {
@@ -685,14 +693,19 @@ function ensureSettings() {
if (!modal) {
return;
}
+ var renderToken = ++licenseModalRenderToken;
global.requestAnimationFrame(function () {
global.requestAnimationFrame(function () {
+ if (renderToken !== licenseModalRenderToken) {
+ return;
+ }
modal.classList.add('show');
});
});
}
function hideLicenseModal() {
+ licenseModalRenderToken += 1;
var modal = getLicenseModal();
if (modal) {
modal.classList.remove('show');
@@ -700,24 +713,37 @@ function ensureSettings() {
}
function acceptGplLicense() {
- try {
- if (global.localStorage) {
- global.localStorage.setItem(LICENSE_STORAGE_KEY, 'true');
- }
- } catch (error) {
- console.warn('LocalStorage error:', error);
- }
- hideLicenseModal();
+ var preferences;
+ return Promise.resolve().then(function () {
+ preferences = getConsentPreferences();
+ return preferences.getConsent();
+ }).then(function (consent) {
+ return preferences.setConsent(Object.assign({}, consent || {}, {
+ hasSeenGplLicense: true
+ }));
+ }).then(function () {
+ hideLicenseModal();
+ return true;
+ }).catch(function (error) {
+ console.error('[LicenseModal] Failed to save GPL license consent:', error);
+ return false;
+ });
}
function initLicenseModal() {
if (licenseModalInitialized) {
- return;
+ return licenseModalInitializationPromise || Promise.resolve();
}
licenseModalInitialized = true;
- if (!hasAcceptedLicense()) {
+ licenseModalInitializationPromise = hasAcceptedLicense().then(function (accepted) {
+ if (!accepted) {
+ showLicenseModal();
+ }
+ }).catch(function (error) {
+ console.error('[LicenseModal] Failed to load GPL license consent:', error);
showLicenseModal();
- }
+ });
+ return licenseModalInitializationPromise;
}
global.LicenseModal = Object.assign({}, global.LicenseModal || {}, {
diff --git a/js/presentation/threeBackground.js b/js/presentation/threeBackground.js
index 218958c9..c7dba4f6 100644
--- a/js/presentation/threeBackground.js
+++ b/js/presentation/threeBackground.js
@@ -533,11 +533,7 @@
function start(themeName = null) {
if (!themeName) {
- try {
- themeName = localStorage.getItem('three_bg_theme') || 'floral-bloom';
- } catch(e) {
- themeName = 'floral-bloom';
- }
+ themeName = 'floral-bloom';
}
try {
@@ -580,14 +576,20 @@
}
global.switchBgTheme = function(themeName) {
- try {
- localStorage.setItem('three_bg_theme', themeName);
- } catch(e){}
+ if (global.AppData && global.AppData.preferences) {
+ global.AppData.preferences.setThreeBackground(themeName).catch((error) => console.warn('[SHUI Three Background] preference save failed:', error));
+ }
start(themeName);
};
- function init() {
- start();
+ async function init() {
+ try {
+ await global.AppData.ready;
+ const saved = await global.AppData.preferences.getThreeBackground();
+ start(saved || 'floral-bloom');
+ } catch (_) {
+ start('floral-bloom');
+ }
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
diff --git a/js/runtime/lazyLoader.js b/js/runtime/lazyLoader.js
index 283b9520..953f98a0 100644
--- a/js/runtime/lazyLoader.js
+++ b/js/runtime/lazyLoader.js
@@ -42,9 +42,7 @@
'js/bundles/theme.bundle.js'
];
- manifest['settings-tools'] = [
- 'js/bundles/settings.bundle.js'
- ];
+ manifest['settings-tools'] = [];
manifest['diagnostics-tools'] = [
'js/bundles/diagnostics.bundle.js'
@@ -53,13 +51,16 @@
dependencies['state-core'] = [];
dependencies['exam-data'] = [];
dependencies['practice-suite'] = ['state-core'];
- dependencies['browse-runtime'] = ['state-core'];
- dependencies['browse-view'] = ['state-core'];
+ // Browsing is also the entry point for starting a practice session.
+ // Keep the real recorder ready before a user can open an exam; the
+ // bootstrap fallback cannot own the full submit/persist round trip.
+ dependencies['browse-runtime'] = ['state-core', 'practice-suite'];
+ dependencies['browse-view'] = ['state-core', 'practice-suite'];
dependencies['session-suite'] = ['browse-runtime', 'practice-suite'];
dependencies['settings-tools'] = ['state-core'];
- dependencies['more-tools'] = ['state-core', 'settings-tools'];
+ dependencies['more-tools'] = ['state-core'];
dependencies['theme-tools'] = [];
- dependencies['diagnostics-tools'] = ['state-core', 'settings-tools'];
+ dependencies['diagnostics-tools'] = ['state-core'];
}
function setBuiltInListeningAvailability(available, reason) {
diff --git a/js/runtime/readingHighlightShared.js b/js/runtime/readingHighlightShared.js
index 4008f815..96ec00e3 100644
--- a/js/runtime/readingHighlightShared.js
+++ b/js/runtime/readingHighlightShared.js
@@ -175,6 +175,7 @@
scope,
text,
kind: resolveHighlightKind(node),
+ noteId: node.dataset && node.dataset.noteId ? String(node.dataset.noteId) : '',
occurrence: seen,
start: startOffset,
end: endOffset,
@@ -248,6 +249,9 @@
if (offsetRange && !offsetRange.collapsed) {
const offsetSpan = document.createElement('span');
applyHighlightKind(offsetSpan, highlightKind);
+ if (record.noteId) {
+ offsetSpan.dataset.noteId = String(record.noteId);
+ }
try {
offsetRange.surroundContents(offsetSpan);
return true;
@@ -296,6 +300,9 @@
}
const span = document.createElement('span');
applyHighlightKind(span, highlightKind);
+ if (record.noteId) {
+ span.dataset.noteId = String(record.noteId);
+ }
try {
range.surroundContents(span);
return true;
diff --git a/js/runtime/reviewHighlightDictionary.js b/js/runtime/reviewHighlightDictionary.js
index 3590df22..6aec70c8 100644
--- a/js/runtime/reviewHighlightDictionary.js
+++ b/js/runtime/reviewHighlightDictionary.js
@@ -5,12 +5,12 @@
const BUBBLE_ID = 'review-highlight-dictionary-bubble';
const INTERACTIVE_CLASS = 'review-dictionary-highlight';
const VOCAB_MESSAGE_TYPE = 'VOCAB_HIGHLIGHT_SAVE';
- const FALLBACK_STORAGE_KEY = 'exam_system_vocab_list_reading_highlights';
let currentOptions = {};
let activeHighlight = null;
let activeLookup = null;
let outsideHandlerAttached = false;
+ const pendingSaveRequests = new Map();
function cleanText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
@@ -445,48 +445,12 @@
};
}
- function createStorageEnvelope(data) {
- return JSON.stringify({
- data,
- timestamp: Date.now(),
- version: '0.6.2-fix',
- compressed: false
- });
- }
-
- function readFallbackList() {
- try {
- const raw = global.localStorage && global.localStorage.getItem(FALLBACK_STORAGE_KEY);
- if (!raw) {
- return null;
- }
- const parsed = JSON.parse(raw);
- const data = parsed && Object.prototype.hasOwnProperty.call(parsed, 'data')
- ? parsed.data
- : parsed;
- return data && typeof data === 'object' && Array.isArray(data.words) ? data : null;
- } catch (_) {
- return null;
- }
- }
-
- function writeFallbackVocab(payload) {
- if (!global.localStorage || !payload || !payload.word) {
- return false;
- }
+ async function writeAppDataVocab(payload) {
+ if (!payload || !payload.word || !global.AppData || !global.AppData.vocab) return false;
+ const key = String(payload.word).trim().toLowerCase();
const now = new Date().toISOString();
- const list = readFallbackList() || {
- id: 'reading-highlights',
- name: '阅读高亮生词',
- icon: '📖',
- source: 'reading-highlight',
- words: [],
- createdAt: now,
- updatedAt: now
- };
- const key = payload.word.toLowerCase();
- const existingIndex = list.words.findIndex((item) => String(item.word || '').trim().toLowerCase() === key);
- const wordRecord = {
+ await global.AppData.ready;
+ await global.AppData.vocab.upsertCollectionWord('reading-highlights', {
id: `reading-highlight-${key.replace(/[^a-z0-9]+/g, '-')}`,
word: payload.word,
meaning: payload.meaning || payload.definition || '待补充释义',
@@ -497,7 +461,6 @@
payload.selectedText && payload.selectedText !== payload.word ? `原高亮: ${payload.selectedText}` : '',
payload.sourceLabel ? `来源: ${payload.sourceLabel}` : ''
].filter(Boolean).join(';'),
- timestamp: Date.now(),
source: 'reading-highlight',
easeFactor: null,
interval: 1,
@@ -506,59 +469,74 @@
correctCount: 0,
lastReviewed: null,
nextReview: null,
- createdAt: existingIndex >= 0 ? (list.words[existingIndex].createdAt || now) : now,
updatedAt: now
- };
- if (existingIndex >= 0) {
- list.words.splice(existingIndex, 1, { ...list.words[existingIndex], ...wordRecord });
- } else {
- list.words.push(wordRecord);
- }
- list.updatedAt = now;
- list.stats = {
- totalWords: list.words.length,
- masteredWords: list.words.filter((word) => (Number(word.correctCount) || 0) >= 4).length,
- reviewingWords: list.words.filter((word) => word.lastReviewed && !word.nextReview).length
- };
- global.localStorage.setItem(FALLBACK_STORAGE_KEY, createStorageEnvelope(list));
+ });
return true;
}
+ function createRequestId() {
+ try {
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') {
+ return `vocab-highlight-${global.crypto.randomUUID()}`;
+ }
+ } catch (_) {
+ // use timestamp fallback
+ }
+ return `vocab-highlight-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ }
+
+ function settleSaveRequest(requestId, succeeded) {
+ const id = String(requestId || '').trim();
+ const pending = pendingSaveRequests.get(id);
+ if (!id || !pending) return false;
+ pendingSaveRequests.delete(id);
+ clearTimeout(pending.timer);
+ pending.resolve(Boolean(succeeded));
+ return true;
+ }
+
+ function handleSaveOutcome(payload, succeeded) {
+ const requestId = payload && payload.requestId != null ? String(payload.requestId).trim() : '';
+ return settleSaveRequest(requestId, succeeded);
+ }
+
function postVocabPayload(payload) {
- if (currentOptions && typeof currentOptions.postMessage === 'function') {
- currentOptions.postMessage(VOCAB_MESSAGE_TYPE, payload);
- return true;
+ if (!currentOptions || typeof currentOptions.postMessage !== 'function') return null;
+ const requestId = createRequestId();
+ const requestPayload = { ...payload, requestId };
+ const outcome = new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ pendingSaveRequests.delete(requestId);
+ resolve(false);
+ }, 5000);
+ pendingSaveRequests.set(requestId, { resolve, timer });
+ });
+ let delivered = false;
+ try {
+ delivered = currentOptions.postMessage(VOCAB_MESSAGE_TYPE, requestPayload) !== false;
+ } catch (_) {
+ delivered = false;
}
- const candidates = [global.opener, global.parent];
- for (let index = 0; index < candidates.length; index += 1) {
- const target = candidates[index];
- if (!target || target === global) {
- continue;
- }
- try {
- target.postMessage({
- type: VOCAB_MESSAGE_TYPE,
- source: 'practice_page',
- data: payload
- }, '*');
- return true;
- } catch (_) {
- // try next target
- }
+ if (!delivered) {
+ settleSaveRequest(requestId, false);
+ return null;
}
- return false;
+ return outcome;
}
- function saveActiveLookup(button) {
+ async function saveActiveLookup(button) {
const payload = buildVocabPayload();
if (!payload.word) {
return;
}
- const posted = postVocabPayload(payload);
- const fallbackSaved = writeFallbackVocab(payload);
+ const hostOutcome = postVocabPayload(payload);
+ let persisted = hostOutcome ? await hostOutcome : false;
+ if (!persisted) {
+ try { persisted = await writeAppDataVocab(payload); } catch (_) { persisted = false; }
+ }
if (button instanceof HTMLButtonElement) {
- button.textContent = posted || fallbackSaved ? '已加入' : '保存失败';
- button.disabled = true;
+ button.textContent = persisted ? '已加入' : '保存失败';
+ button.disabled = persisted;
}
}
@@ -610,7 +588,7 @@
attach,
enhance,
close: closeBubble,
- storageKey: FALLBACK_STORAGE_KEY,
+ handleSaveOutcome,
messageType: VOCAB_MESSAGE_TYPE
};
diff --git a/js/runtime/unifiedReadingPage.js b/js/runtime/unifiedReadingPage.js
index 09c7cdfe..62f5d39a 100644
--- a/js/runtime/unifiedReadingPage.js
+++ b/js/runtime/unifiedReadingPage.js
@@ -4,12 +4,33 @@
const MESSAGE_SOURCE = 'practice_page';
const INIT_RETRY_MS = 1500;
const SIMULATION_DRAFT_SYNC_MS = 1200;
+ const READING_DRAFT_SYNC_MS = 1500;
+ const SUBMIT_ACK_TIMEOUT_MS = 10000;
+ const NOTE_EDITOR_SAVE_DEBOUNCE_MS = 450;
+ const NOTE_ROW_LONG_PRESS_MS = 100;
const EXPLANATION_STYLE_ID = 'reading-explanation-style';
const MEMORIZE_STYLE_ID = 'reading-memorize-style';
+ const READING_NOTE_STYLE_ID = 'reading-note-style';
+ const READING_DISPLAY_CONTROL_STYLE_ID = 'reading-display-control-style';
const PRACTICE_TIMER_BRIDGE_KEY = '__IELTS_PRACTICE_TIMER__';
const PRACTICE_TIMER_EVENT = 'practiceTimerStateChange';
- const READING_CANDIDATE_CODE_PREF_KEY = 'ielts_reading_candidate_code_preferences_v1';
const READING_CANDIDATE_CODE_PATTERN = /^\d{6}$/;
+ const HOST_MESSAGE_SOURCE = 'exam_host';
+ let readingCandidateCodeCache = { mode: 'auto', customCode: '' };
+
+ function deriveReferrerOrigin() {
+ try {
+ if (!document.referrer) return '';
+ const parsed = new URL(document.referrer, global.location.href);
+ // File-page refs do not provide a usable web origin, so bind them through
+ // the opaque/file message-origin handling below instead of pinning file://.
+ if (parsed.protocol === 'file:') return '';
+ if (!parsed.origin || parsed.origin === 'null' || parsed.origin === 'file://') return '';
+ return parsed.origin;
+ } catch (_) {
+ return '';
+ }
+ }
const EXPLANATION_NODE_SELECTOR = [
'.reading-explanation-card',
'.reading-group-explanation',
@@ -26,6 +47,7 @@
const navStatus = new Map();
const scriptCache = new Map();
const LOCATOR_HIGHLIGHT_SELECTOR = '.reading-locator-highlight, .reading-locator-block';
+ const LOCATOR_OVERLAP_SELECTOR = '.reading-locator-overlap';
function getAnswerMatchCore() {
const core = global.AnswerMatchCore;
if (!core || typeof core !== 'object') {
@@ -79,6 +101,10 @@
timerLocked: false,
ready: false,
submitted: false,
+ submissionStatus: 'draft',
+ submissionId: '',
+ submissionAckTimer: null,
+ pendingSubmissionPresentation: null,
initTimer: null,
manifestLoaded: false,
dataset: null,
@@ -100,10 +126,37 @@
},
simulationDraftSyncTimer: null,
simulationDraftFingerprint: '',
+ readingDraftSyncTimer: null,
+ readingDraftFingerprint: '',
+ notes: [],
+ noteOutlines: [],
+ markedQuestions: [],
+ activeNoteId: '',
+ noteEditorPosition: null,
+ noteUiInitialized: false,
+ noteEditorSaveTimer: null,
+ noteDrawerDirty: true,
+ noteHighlightMetaDirty: true,
+ noteEditorPendingSync: false,
+ reviewRecordId: '',
+ // 单篇阅读 final-submit 成功后,宿主通过 PRACTICE_RECORD_SAVED 回传的已存档
+ // practice record id。持有该 id 时,笔记编辑在只读提交页仍然可写,并且
+ // syncReadingAnnotation 会以该 recordId 发送 READING_ANNOTATION_SYNC,把
+ // 结果页上的笔记改动持久化回已存档的练习记录。
+ submittedRecordId: '',
+ highlightVisibility: {
+ locators: true,
+ notes: true,
+ highlights: true
+ },
+ questionNavCollapsed: false,
lastInitSignature: '',
lastReplaySignature: '',
sessionReadySent: false,
parentWindow: global.opener || global.parent || null,
+ expectedParentOrigin: deriveReferrerOrigin(),
+ parentOrigin: '',
+ parentOriginIsOpaque: false,
windowSessionToken: '',
windowSessionIssuedAtMs: 0
};
@@ -128,7 +181,10 @@
timerInterval: null,
lastRange: null,
currentHighlightNode: null,
- keepToolbar: false
+ keepToolbar: false,
+ noteDragFrame: null,
+ noteListDragging: false,
+ noteSuppressClickUntil: 0
};
const testOverrides = {
renderExplanations: null
@@ -297,6 +353,10 @@
control.disabled = locked || state.readOnly;
}
});
+ if (dom.resetBtn) dom.resetBtn.disabled = locked || state.readOnly;
+ document.querySelectorAll('#reading-note-drawer [data-note-outline-add], #reading-note-drawer [data-note-outline-toggle], #reading-note-drawer [data-note-outline-title], #reading-note-drawer [data-note-outline-delete], #reading-note-drawer [data-note-drag-handle], #reading-note-drawer [data-note-delete]').forEach((control) => {
+ if ('disabled' in control) control.disabled = locked;
+ });
disableDragInteractions();
}
@@ -333,20 +393,15 @@
}
function readReadingCandidateCodePreferences() {
- try {
- const raw = global.localStorage?.getItem(READING_CANDIDATE_CODE_PREF_KEY);
- const parsed = raw ? JSON.parse(raw) : null;
- const mode = parsed?.mode === 'custom' ? 'custom' : 'auto';
- const customCode = typeof parsed?.customCode === 'string'
- ? parsed.customCode.replace(/\D/g, '').slice(0, 6)
- : '';
- return {
- mode,
- customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : ''
- };
- } catch (_) {
- return { mode: 'auto', customCode: '' };
- }
+ return { ...readingCandidateCodeCache };
+ }
+
+ async function loadReadingCandidateCodePreferences() {
+ await global.AppData.ready;
+ const stored = await global.AppData.preferences.getCandidateCode();
+ const mode = stored?.mode === 'custom' ? 'custom' : 'auto';
+ const customCode = typeof stored?.customCode === 'string' ? stored.customCode.replace(/\D/g, '').slice(0, 6) : '';
+ readingCandidateCodeCache = { mode, customCode: READING_CANDIDATE_CODE_PATTERN.test(customCode) ? customCode : '' };
}
function resolveReadingCandidateCode() {
@@ -367,6 +422,8 @@
const rawLimitSeconds = Number(state.suiteTimerLimitSeconds);
if (Number.isFinite(rawLimitSeconds) && rawLimitSeconds > 0) {
limitSeconds = Math.floor(rawLimitSeconds);
+ } else if (state.suiteSessionId && state.suiteTimerMode === 'countdown') {
+ limitSeconds = minutesToSeconds(60, 60);
} else if (preferences.limitEnabled) {
limitSeconds = minutesToSeconds(preferences.limitMinutes, 60);
} else {
@@ -404,8 +461,10 @@
}
timer.classList.toggle('paused', !interaction.timerRunning && !hasEndlessCountdown);
timer.classList.toggle('timer-expired', expired);
- timer.dataset.timerMode = preferences.mode;
- timer.dataset.expiryAction = preferences.expiryAction;
+ if (timer.dataset) {
+ timer.dataset.timerMode = preferences.mode;
+ timer.dataset.expiryAction = preferences.expiryAction;
+ }
timer.style.opacity = (interaction.timerRunning || hasEndlessCountdown) ? '1' : '0.5';
var _warnRemaining = !hasEndlessCountdown
&& (preferences.mode === 'countdown' || (Number.isFinite(Number(limitSeconds)) && Number(limitSeconds) > 0))
@@ -533,6 +592,10 @@
function updateSelectionToolbar() {
const toolbar = document.getElementById('selbar');
if (!toolbar) return;
+ if (!canEditReadingNotes()) {
+ toolbar.style.display = 'none';
+ return;
+ }
const selection = global.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
if (!interaction.keepToolbar && !interaction.currentHighlightNode) {
@@ -594,6 +657,10 @@
function applySelectionHighlight(kind = 'highlight') {
const toolbar = document.getElementById('selbar');
+ if (!canEditReadingNotes()) {
+ if (toolbar) toolbar.style.display = 'none';
+ return;
+ }
const selection = global.getSelection();
if (!interaction.lastRange || interaction.lastRange.collapsed || interaction.currentHighlightNode) {
return;
@@ -612,11 +679,19 @@
if (toolbar) toolbar.style.display = 'none';
interaction.lastRange = null;
interaction.currentHighlightNode = null;
- syncSimulationDraftSnapshot('highlight');
+ if (kind === 'note') {
+ const note = ensureNoteForHighlight(span, normalizeNoteText(span.textContent), { sync: false });
+ if (note) openNoteEditor(note.id, { anchorNode: span, focusBody: true });
+ }
+ syncReadingAnnotation('highlight');
}
function removeSelectionHighlight() {
const toolbar = document.getElementById('selbar');
+ if (!canEditReadingNotes()) {
+ if (toolbar) toolbar.style.display = 'none';
+ return;
+ }
const selection = global.getSelection();
let target = interaction.currentHighlightNode;
if (!target && interaction.lastRange) {
@@ -625,6 +700,7 @@
? ancestor.parentElement?.closest('.hl')
: ancestor.closest?.('.hl');
}
+ const removedNoteId = target instanceof HTMLElement ? String(target.dataset.noteId || '') : '';
if (target && target.parentNode) {
const parent = target.parentNode;
while (target.firstChild) {
@@ -637,7 +713,8 @@
if (toolbar) toolbar.style.display = 'none';
interaction.lastRange = null;
interaction.currentHighlightNode = null;
- syncSimulationDraftSnapshot('unhighlight');
+ if (removedNoteId) deleteNote(removedNoteId, { sync: false });
+ syncReadingAnnotation('unhighlight');
}
function attachSelectionHighlightToolbar() {
@@ -654,13 +731,13 @@
});
document.getElementById('btnHL')?.addEventListener('click', () => applySelectionHighlight('highlight'));
document.getElementById('btnNote')?.addEventListener('click', () => {
+ if (!canEditReadingNotes()) return;
let targetNode = interaction.currentHighlightNode;
let text = '';
if (targetNode) {
if (targetNode.dataset.hlType !== 'note') {
targetNode.dataset.hlType = 'note';
- syncSimulationDraftSnapshot('highlight');
}
text = (targetNode.textContent || '').trim();
} else if (interaction.lastRange && !interaction.lastRange.collapsed) {
@@ -684,18 +761,10 @@
interaction.lastRange = null;
interaction.currentHighlightNode = null;
- if (text) {
- const noteArea = document.querySelector('#notes-panel textarea');
- if (noteArea) {
- noteArea.value += (noteArea.value ? '\n\n' : '') + '> ' + text + '\n';
- noteArea.scrollTop = noteArea.scrollHeight;
- noteArea.focus();
- }
+ if (targetNode && text) {
+ const note = ensureNoteForHighlight(targetNode, text);
closeFloatingPanels();
- const notesPanel = document.getElementById('notes-panel');
- const overlay = document.querySelector('.overlay');
- if (notesPanel) notesPanel.style.display = 'flex';
- if (overlay) overlay.style.display = 'block';
+ if (note) openNoteEditor(note.id, { anchorNode: targetNode, focusBody: true });
}
});
document.getElementById('btnUH')?.addEventListener('click', removeSelectionHighlight);
@@ -983,6 +1052,9 @@
}
function getNotesText() {
+ if (state.noteUiInitialized) {
+ return formatNotesForLegacyText(state.notes);
+ }
const noteArea = document.querySelector('#notes-panel textarea');
return noteArea ? String(noteArea.value || '') : '';
}
@@ -994,11 +1066,164 @@
}
}
+ function generateNoteId() {
+ return `note_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function generateNoteOutlineId() {
+ return `outline_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function normalizeNoteText(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim();
+ }
+
+ function buildDefaultNoteTitle(quote = '') {
+ const text = normalizeNoteText(quote);
+ if (!text) return 'Untitled note';
+ return text.length > 36 ? `${text.slice(0, 36)}...` : text;
+ }
+
+ function compareNoteOrder(a, b) {
+ const orderA = Number.isFinite(Number(a?.order)) ? Number(a.order) : 0;
+ const orderB = Number.isFinite(Number(b?.order)) ? Number(b.order) : 0;
+ if (orderA !== orderB) return orderA - orderB;
+ return Number(a?.createdAt || 0) - Number(b?.createdAt || 0);
+ }
+
+ function normalizeNotes(rawNotes) {
+ const seen = new Set();
+ return (Array.isArray(rawNotes) ? rawNotes : []).map((entry, index) => {
+ if (!entry || typeof entry !== 'object') return null;
+ let id = entry.id != null ? String(entry.id).trim() : '';
+ if (!id || seen.has(id)) id = generateNoteId();
+ seen.add(id);
+ const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now();
+ return {
+ id,
+ title: entry.title != null ? String(entry.title) : '',
+ body: entry.body != null ? String(entry.body) : '',
+ quote: entry.quote != null ? String(entry.quote) : '',
+ outlineId: entry.outlineId != null ? String(entry.outlineId).trim() : '',
+ order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index,
+ createdAt,
+ updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt
+ };
+ }).filter(Boolean);
+ }
+
+ function normalizeNoteOutlines(rawOutlines) {
+ const seen = new Set();
+ return (Array.isArray(rawOutlines) ? rawOutlines : []).map((entry, index) => {
+ if (!entry || typeof entry !== 'object') return null;
+ let id = entry.id != null ? String(entry.id).trim() : '';
+ if (!id || seen.has(id)) id = generateNoteOutlineId();
+ seen.add(id);
+ const createdAt = Number.isFinite(Number(entry.createdAt)) ? Number(entry.createdAt) : Date.now();
+ return {
+ id,
+ title: String(entry.title || '').trim() || 'New outline',
+ order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : index,
+ collapsed: Boolean(entry.collapsed),
+ createdAt,
+ updatedAt: Number.isFinite(Number(entry.updatedAt)) ? Number(entry.updatedAt) : createdAt
+ };
+ }).filter(Boolean).sort(compareNoteOrder);
+ }
+
+ function sanitizeNotesWithOutlines(rawNotes, rawOutlines) {
+ const noteOutlines = normalizeNoteOutlines(rawOutlines);
+ const validIds = new Set(noteOutlines.map((outline) => outline.id));
+ const notes = normalizeNotes(rawNotes).map((note, index) => ({
+ ...note,
+ outlineId: validIds.has(note.outlineId) ? note.outlineId : '',
+ order: Number.isFinite(Number(note.order)) ? Number(note.order) : index
+ }));
+ return { notes, noteOutlines };
+ }
+
+ function collectNotes() {
+ return normalizeNotes(state.notes);
+ }
+
+ function collectNoteOutlines() {
+ return normalizeNoteOutlines(state.noteOutlines);
+ }
+
+ function getNoteById(noteId) {
+ const id = String(noteId || '').trim();
+ return id ? state.notes.find((note) => note && note.id === id) || null : null;
+ }
+
+ function getValidNoteOutlineId(outlineId) {
+ const id = String(outlineId || '').trim();
+ return id && state.noteOutlines.some((outline) => outline.id === id) ? id : '';
+ }
+
+ function sortNotesForDrawer(notes = state.notes) {
+ return (Array.isArray(notes) ? notes : []).filter(Boolean).slice().sort(compareNoteOrder);
+ }
+
+ function getNextNoteOrder(outlineId = '') {
+ const id = getValidNoteOutlineId(outlineId);
+ const matching = state.notes.filter((note) => (note?.outlineId || '') === id);
+ return matching.length
+ ? Math.max(...matching.map((note) => Number.isFinite(Number(note.order)) ? Number(note.order) : 0)) + 1
+ : 0;
+ }
+
+ function formatNotesForLegacyText(notes = state.notes) {
+ return normalizeNotes(notes).map((note) => {
+ const parts = [`# ${String(note.title || '').trim() || 'Untitled note'}`];
+ if (note.quote) parts.push(`> ${normalizeNoteText(note.quote)}`);
+ if (note.body) parts.push(note.body);
+ return parts.join('\n');
+ }).join('\n\n');
+ }
+
+ function syncNotesToLegacyText() {
+ setNotesText(formatNotesForLegacyText(state.notes));
+ }
+
+ function normalizeMarkedQuestions(rawQuestions) {
+ const seen = new Set();
+ return (Array.isArray(rawQuestions) ? rawQuestions : []).map((entry) => (
+ normalizeQuestionId(entry) || String(entry || '').trim().toLowerCase()
+ )).filter(Boolean).filter((entry) => {
+ if (seen.has(entry)) return false;
+ seen.add(entry);
+ return true;
+ });
+ }
+
+ function getCurrentMarkedQuestions() {
+ let marks = [];
+ let hostResolved = false;
+ if (typeof global.getPracticeMarkedQuestions === 'function') {
+ try {
+ const raw = global.getPracticeMarkedQuestions();
+ hostResolved = raw != null;
+ marks = normalizeMarkedQuestions(raw);
+ } catch (_) { marks = []; }
+ }
+ // 只有当 host 没有 give 出结果时(函数不存在或抛错)才回退到缓存;
+ // 用户清空最后一个标记时 host 会返回 [],这是有效空集,不能再被 state.markedQuestions 复活,
+ // 否则清空无法持久,并会在后续 draft/annotation sync 中重新写入旧标记。
+ if (!hostResolved && !marks.length) {
+ marks = normalizeMarkedQuestions(state.markedQuestions);
+ }
+ state.markedQuestions = marks.slice();
+ return marks;
+ }
+
function buildEmptyDraft() {
return {
answers: {},
highlights: [],
noteText: '',
+ notes: [],
+ noteOutlines: [],
+ markedQuestions: [],
scrollY: 0,
updatedAt: Date.now()
};
@@ -1016,6 +1241,9 @@
noteText: typeof source.noteText === 'string'
? source.noteText
: '',
+ notes: normalizeNotes(source.notes),
+ noteOutlines: normalizeNoteOutlines(source.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(source.markedQuestions),
scrollY: Number.isFinite(Number(source.scrollY))
? Number(source.scrollY)
: 0,
@@ -1044,7 +1272,7 @@
const mergedUpdatedAt = Number.isFinite(Number(next.updatedAt))
? Number(next.updatedAt)
: (Number.isFinite(Number(base.updatedAt)) ? Number(base.updatedAt) : Date.now());
- return Object.assign(buildEmptyDraft(), base, next, {
+ const merged = Object.assign(buildEmptyDraft(), base, next, {
answers: next.answers && typeof next.answers === 'object'
? { ...next.answers }
: { ...base.answers },
@@ -1054,11 +1282,22 @@
noteText: typeof next.noteText === 'string'
? next.noteText
: base.noteText,
+ notes: Array.isArray(nextDraft?.notes) ? normalizeNotes(next.notes) : normalizeNotes(base.notes),
+ noteOutlines: Array.isArray(nextDraft?.noteOutlines)
+ ? normalizeNoteOutlines(next.noteOutlines)
+ : normalizeNoteOutlines(base.noteOutlines),
+ markedQuestions: Array.isArray(nextDraft?.markedQuestions)
+ ? normalizeMarkedQuestions(next.markedQuestions)
+ : normalizeMarkedQuestions(base.markedQuestions),
scrollY: Number.isFinite(Number(next.scrollY))
? Number(next.scrollY)
: base.scrollY,
updatedAt: mergedUpdatedAt
});
+ const sanitized = sanitizeNotesWithOutlines(merged.notes, merged.noteOutlines);
+ merged.notes = sanitized.notes;
+ merged.noteOutlines = sanitized.noteOutlines;
+ return merged;
}
function mergeSuiteDraftPayload(data = {}) {
@@ -1157,6 +1396,9 @@
answers: collectAnswers(),
highlights: collectHighlights(),
noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
scrollY: global.scrollY || 0,
updatedAt: Date.now()
});
@@ -1320,7 +1562,6 @@
refreshDynamicQuestionEnhancements();
clearCurrentAnswers();
applyDraftToDom(slot.draft || buildEmptyDraft());
- setNotesText(slot.draft?.noteText || '');
syncSimulationCtxForActiveSlot();
syncInlineSuiteIdentity();
state.simulationMode = true;
@@ -1352,6 +1593,867 @@
return global.__READING_EXPLANATION_MANIFEST__ || {};
}
+ function ensureReadingDisplayControlStyles() {
+ if (document.getElementById(READING_DISPLAY_CONTROL_STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = READING_DISPLAY_CONTROL_STYLE_ID;
+ style.textContent = `
+ .reading-display-toggle-group{display:inline-flex;align-items:center;gap:4px;padding:2px;border:1px solid #dbe4ef;border-radius:8px;background:#f8fafc}
+ .reading-display-toggle{border:0;border-radius:6px;min-width:30px;height:28px;padding:0 8px;cursor:pointer;color:#64748b;background:transparent;font-size:12px;font-weight:700}
+ .reading-display-toggle:hover{background:#eef2f7;color:#0f172a}.reading-display-toggle.is-on{background:#dbeafe;color:#1d4ed8}
+ body.hide-reading-locators .reading-locator-highlight{background:transparent!important;box-shadow:none!important;outline:none!important}
+ body.hide-reading-locators .reading-locator-overlap{text-decoration:none!important;outline:none!important}
+ body.hide-reading-locators .reading-passage-locator-target.is-review-jump-target{background:transparent!important;outline:none!important}
+ body.hide-reading-notes .hl[data-hl-type="note"],body.hide-reading-notes .hl[data-note-id]{background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important;pointer-events:none}
+ body.hide-reading-highlights .hl:not([data-hl-type="note"]):not([data-note-id]){background:transparent!important;color:inherit!important;box-shadow:none!important;outline:none!important}
+ body.reading-question-nav-collapsed .practice-nav{display:none}
+ body.dark-mode .reading-display-toggle-group{background:#1e293b;border-color:#475569;color:#cbd5e1}
+ `;
+ document.head.appendChild(style);
+ }
+
+ function saveReadingDisplayPreferences() {
+ global.AppData.preferences.setReadingDisplay({
+ highlightVisibility: state.highlightVisibility,
+ questionNavCollapsed: state.questionNavCollapsed
+ }).catch((error) => console.warn('[ReadingDisplay] 保存失败:', error));
+ }
+
+ async function loadReadingDisplayPreferences() {
+ try {
+ const saved = await global.AppData.preferences.getReadingDisplay();
+ if (saved?.highlightVisibility) {
+ state.highlightVisibility = {
+ locators: saved.highlightVisibility.locators !== false,
+ notes: saved.highlightVisibility.notes !== false,
+ highlights: saved.highlightVisibility.highlights !== false
+ };
+ }
+ state.questionNavCollapsed = Boolean(saved?.questionNavCollapsed);
+ } catch (_) { /* Ignore invalid preference payloads. */ }
+ applyReadingDisplayState();
+ }
+
+ function applyReadingDisplayState() {
+ if (!document.body) return;
+ document.body.classList.toggle('hide-reading-locators', state.highlightVisibility.locators === false);
+ document.body.classList.toggle('hide-reading-notes', state.highlightVisibility.notes === false);
+ document.body.classList.toggle('hide-reading-highlights', state.highlightVisibility.highlights === false);
+ document.body.classList.toggle('reading-question-nav-collapsed', state.questionNavCollapsed);
+ document.querySelectorAll('[data-highlight-toggle]').forEach((button) => {
+ const key = button.getAttribute('data-highlight-toggle');
+ const enabled = state.highlightVisibility[key] !== false;
+ button.classList.toggle('is-on', enabled);
+ button.setAttribute('aria-pressed', enabled ? 'true' : 'false');
+ });
+ const navToggle = document.getElementById('reading-question-nav-toggle');
+ if (navToggle) {
+ const collapsed = state.questionNavCollapsed;
+ // is-on means the question card bar is currently visible.
+ navToggle.classList.toggle('is-on', !collapsed);
+ navToggle.setAttribute('aria-pressed', collapsed ? 'false' : 'true');
+ navToggle.title = collapsed ? '显示题卡' : '隐藏题卡';
+ navToggle.textContent = 'Q';
+ }
+ }
+
+ function ensureReadingDisplayControls() {
+ ensureReadingDisplayControlStyles();
+ // Remove the legacy floating bottom-right nav toggle if an older session left one behind.
+ document.querySelectorAll('body > #reading-question-nav-toggle, body > .reading-question-nav-toggle').forEach((node) => {
+ if (node.closest?.('.reading-display-toggle-group')) return;
+ node.remove();
+ });
+ const headerRight = document.querySelector('.header-right');
+ if (headerRight && !document.getElementById('reading-display-toggle-group')) {
+ const group = document.createElement('div');
+ group.id = 'reading-display-toggle-group';
+ group.className = 'reading-display-toggle-group';
+ group.setAttribute('aria-label', '阅读显示控制');
+ group.innerHTML = [
+ '
A ',
+ '
N ',
+ '
H ',
+ '
Q '
+ ].join('');
+ const settingsButton = document.getElementById('settings-btn');
+ headerRight.insertBefore(group, settingsButton?.parentNode === headerRight ? settingsButton : null);
+ group.addEventListener('click', (event) => {
+ const target = event.target instanceof HTMLElement ? event.target : null;
+ if (!target) return;
+ const navButton = target.closest('[data-question-nav-toggle]');
+ if (navButton) {
+ state.questionNavCollapsed = !state.questionNavCollapsed;
+ applyReadingDisplayState();
+ saveReadingDisplayPreferences();
+ return;
+ }
+ const button = target.closest('[data-highlight-toggle]');
+ if (!button) return;
+ const key = button.getAttribute('data-highlight-toggle');
+ if (!Object.prototype.hasOwnProperty.call(state.highlightVisibility, key)) return;
+ state.highlightVisibility[key] = state.highlightVisibility[key] === false;
+ applyReadingDisplayState();
+ saveReadingDisplayPreferences();
+ });
+ } else {
+ // If the group already exists without the nav toggle (hot reload / partial DOM), attach it.
+ const group = document.getElementById('reading-display-toggle-group');
+ if (group && !document.getElementById('reading-question-nav-toggle')) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'reading-display-toggle';
+ button.id = 'reading-question-nav-toggle';
+ button.setAttribute('data-question-nav-toggle', '');
+ button.title = '隐藏题卡';
+ button.setAttribute('aria-pressed', 'true');
+ button.textContent = 'Q';
+ button.addEventListener('click', (event) => {
+ event.stopPropagation();
+ state.questionNavCollapsed = !state.questionNavCollapsed;
+ applyReadingDisplayState();
+ saveReadingDisplayPreferences();
+ });
+ group.appendChild(button);
+ }
+ }
+ applyReadingDisplayState();
+ }
+
+ function ensureReadingNoteStyles() {
+ if (document.getElementById(READING_NOTE_STYLE_ID)) return;
+ const style = document.createElement('style');
+ style.id = READING_NOTE_STYLE_ID;
+ style.textContent = `
+ .hl[data-note-id]{position:relative;cursor:pointer;background:rgba(191,219,254,.78)!important;box-shadow:inset 0 -.52em rgba(147,197,253,.34)}
+ .hl[data-note-id].reading-note-flash{outline:2px solid #60a5fa;outline-offset:2px}.reading-notes-btn{position:relative}
+ .reading-note-count{position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:99px;background:#16a34a;color:#fff;font-size:10px;line-height:16px;text-align:center;font-weight:700;display:none}
+ #reading-note-drawer{position:fixed;inset:0 0 0 auto;width:min(360px,92vw);background:#fff;border-left:1px solid #dbe4ef;box-shadow:-18px 0 36px rgba(15,23,42,.16);z-index:3600;transform:translateX(105%);transition:transform 180ms ease;display:flex;flex-direction:column}
+ #reading-note-drawer.open{transform:translateX(0)}.reading-note-drawer-head,.reading-note-editor-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 14px;border-bottom:1px solid #e2e8f0}
+ .reading-note-drawer-title{display:flex;align-items:center;gap:8px}.reading-note-drawer-head h3,.reading-note-editor-head h3{margin:0;font-size:16px}.reading-note-list{padding:10px;overflow:auto;flex:1}
+ .reading-note-outline{border:1px solid #dbeafe;border-radius:8px;margin-bottom:10px;overflow:hidden;background:#f8fbff}.reading-note-outline-head{display:grid;grid-template-columns:30px 1fr 30px;align-items:center;padding:5px;background:#eff6ff}.reading-note-outline.collapsed .reading-note-outline-body{display:none}
+ .reading-note-outline-body,.reading-note-loose-list{min-height:26px;padding:4px 8px}.reading-note-row{display:grid;grid-template-columns:1fr 28px 30px;align-items:center;gap:4px;border-bottom:1px solid #edf2f7}.reading-note-row.dragging{opacity:.45}.reading-note-row.drag-over{box-shadow:inset 0 2px #2563eb}
+ .reading-note-open,.reading-note-outline-title{border:0;background:transparent;color:#0f172a;text-align:left;padding:9px 6px;border-radius:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}.reading-note-open:hover{background:#eff6ff;color:#1d4ed8}
+ .reading-note-close,.reading-note-delete,.reading-note-outline-toggle,.reading-note-outline-delete,.reading-note-drag-handle,.reading-note-outline-add{border:0;background:transparent;color:#64748b;cursor:pointer;width:30px;height:30px;border-radius:6px}.reading-note-outline-add{background:#eff6ff;color:#1d4ed8;font-size:18px}.reading-note-outline-title-input{min-width:0;border:1px solid #93c5fd;border-radius:5px;padding:6px}
+ #reading-note-editor{position:fixed;z-index:3700;width:min(620px,calc(100vw - 24px));height:min(520px,calc(100vh - 24px));min-width:320px;min-height:320px;background:#fff;border:1px solid #cbd5e1;border-radius:8px;box-shadow:0 22px 50px rgba(15,23,42,.22);display:none;flex-direction:column;overflow:hidden;resize:both}
+ .reading-note-editor-head{cursor:move;background:#f8fafc;user-select:none}.reading-note-editor-body{display:flex;flex-direction:column;gap:10px;padding:14px;flex:1;min-height:0}.reading-note-quote{margin:0;color:#475569;background:#eff6ff;border-left:3px solid #60a5fa;padding:8px 10px;max-height:74px;overflow:auto}
+ .reading-note-title,.reading-note-body{width:100%;border:1px solid #cbd5e1;border-radius:6px;padding:9px 10px;box-sizing:border-box}.reading-note-title{font-weight:700}.reading-note-body{min-height:190px;resize:vertical;flex:1}
+ body.dark-mode #reading-note-drawer,body.dark-mode #reading-note-editor{background:#1e293b;border-color:#475569;color:#e2e8f0}body.dark-mode .reading-note-open,body.dark-mode .reading-note-outline-title{color:#f8fafc}
+ @media(max-width:520px){#reading-note-editor{inset:12px!important;width:calc(100vw - 24px);height:calc(100vh - 24px);min-width:0;min-height:0;resize:none}}
+ `;
+ document.head.appendChild(style);
+ }
+
+ function ensureReadingNotesButton() {
+ let button = document.getElementById('notes-drawer-btn');
+ if (button) return button;
+ const headerRight = document.querySelector('.header-right');
+ if (!headerRight) return null;
+ button = document.createElement('button');
+ button.id = 'notes-drawer-btn';
+ button.type = 'button';
+ button.className = 'header-btn reading-notes-btn';
+ button.title = 'Notes';
+ button.innerHTML = 'Notes
0 ';
+ headerRight.insertBefore(button, headerRight.firstChild);
+ button.addEventListener('click', (event) => { event.stopPropagation(); toggleNotesDrawer(); });
+ return button;
+ }
+
+ function ensureReadingNotesUi() {
+ ensureReadingNoteStyles();
+ ensureReadingNotesButton();
+ const legacyPanel = document.getElementById('notes-panel');
+ const legacyButton = document.getElementById('note-btn');
+ if (legacyPanel) { legacyPanel.style.display = 'none'; legacyPanel.setAttribute('aria-hidden', 'true'); }
+ if (legacyButton) { legacyButton.style.display = 'none'; legacyButton.setAttribute('aria-hidden', 'true'); }
+ let drawer = document.getElementById('reading-note-drawer');
+ if (!drawer) {
+ drawer = document.createElement('aside');
+ drawer.id = 'reading-note-drawer';
+ drawer.setAttribute('aria-hidden', 'true');
+ drawer.innerHTML = '
';
+ document.body.appendChild(drawer);
+ drawer.addEventListener('click', handleNoteDrawerClick);
+ drawer.addEventListener('keydown', handleNoteDrawerKeydown);
+ drawer.addEventListener('focusout', handleNoteDrawerFocusOut);
+ drawer.addEventListener('dragstart', handleNoteDragStart);
+ drawer.addEventListener('dragover', handleNoteDragOver);
+ drawer.addEventListener('drop', handleNoteDrop);
+ drawer.addEventListener('dragend', clearNoteDragIndicators);
+ }
+ let editor = document.getElementById('reading-note-editor');
+ if (!editor) {
+ editor = document.createElement('section');
+ editor.id = 'reading-note-editor';
+ editor.setAttribute('aria-hidden', 'true');
+ editor.innerHTML = '
Note × ';
+ document.body.appendChild(editor);
+ editor.addEventListener('click', (event) => { if (event.target.closest?.('[data-note-editor-close]')) closeNoteEditor(); });
+ editor.querySelector('[data-note-title]')?.addEventListener('input', saveActiveNoteFromEditor);
+ editor.querySelector('[data-note-body]')?.addEventListener('input', saveActiveNoteFromEditor);
+ editor.querySelector('[data-note-title]')?.addEventListener('change', flushActiveNoteFromEditor);
+ editor.querySelector('[data-note-body]')?.addEventListener('change', flushActiveNoteFromEditor);
+ attachNoteEditorDrag(editor);
+ }
+ if (!state.noteUiInitialized) {
+ state.noteUiInitialized = true;
+ document.addEventListener('click', handleNoteHighlightClick, true);
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') { closeNoteEditor(); closeNotesDrawer(); }
+ });
+ }
+ syncNotesToLegacyText();
+ renderNotesDrawer();
+ refreshNoteHighlightAttributes();
+ return drawer;
+ }
+
+ function toggleNotesDrawer() {
+ const drawer = ensureReadingNotesUi();
+ if (drawer?.classList.contains('open')) closeNotesDrawer();
+ else openNotesDrawer();
+ }
+
+ function openNotesDrawer() {
+ const drawer = ensureReadingNotesUi();
+ if (!drawer) return;
+ state.noteDrawerDirty = true;
+ drawer.classList.add('open');
+ drawer.setAttribute('aria-hidden', 'false');
+ renderNotesDrawer();
+ }
+
+ function closeNotesDrawer() {
+ const drawer = document.getElementById('reading-note-drawer');
+ drawer?.classList.remove('open');
+ drawer?.setAttribute('aria-hidden', 'true');
+ }
+
+ function renderNoteRow(note) {
+ const title = String(note.title || '').trim() || 'Untitled note';
+ const editable = canEditReadingNotes();
+ const disabled = editable ? '' : ' disabled';
+ return `
${escapeHtml(title)} ⋮⋮ ×
`;
+ }
+
+ function renderNotesDrawer() {
+ const count = state.notes.length;
+ const badge = document.querySelector('#notes-drawer-btn .reading-note-count');
+ if (badge) { badge.textContent = String(count); badge.style.display = count ? 'block' : 'none'; }
+ const list = document.querySelector('#reading-note-drawer [data-note-list]');
+ if (!list || !state.noteDrawerDirty) return;
+ const disabled = canEditReadingNotes() ? '' : ' disabled';
+ const notesByOutline = new Map();
+ sortNotesForDrawer().forEach((note) => {
+ const outlineId = getValidNoteOutlineId(note.outlineId);
+ const group = notesByOutline.get(outlineId) || [];
+ group.push(note);
+ notesByOutline.set(outlineId, group);
+ });
+ const outlinesHtml = collectNoteOutlines().map((outline) => {
+ const notes = notesByOutline.get(outline.id) || [];
+ return `
${outline.collapsed ? '›' : '⌄'} ${escapeHtml(outline.title)} ×
${notes.map(renderNoteRow).join('')}
`;
+ }).join('');
+ const loose = (notesByOutline.get('') || []).map(renderNoteRow).join('');
+ list.innerHTML = count || state.noteOutlines.length
+ ? `${outlinesHtml}
${loose}
`
+ : '
No notes yet.
';
+ const add = document.querySelector('#reading-note-drawer [data-note-outline-add]');
+ if (add) add.disabled = !canEditReadingNotes();
+ state.noteDrawerDirty = false;
+ }
+
+ function handleNoteDrawerClick(event) {
+ const target = event.target instanceof HTMLElement ? event.target : null;
+ if (!target) return;
+ if (target.closest('[data-note-drawer-close]')) return closeNotesDrawer();
+ if (target.closest('[data-note-outline-add]')) return createNoteOutline();
+ const toggle = target.closest('[data-note-outline-toggle]');
+ if (toggle) return toggleNoteOutline(toggle.getAttribute('data-note-outline-toggle'));
+ const outlineDelete = target.closest('[data-note-outline-delete]');
+ if (outlineDelete) return deleteNoteOutline(outlineDelete.getAttribute('data-note-outline-delete'));
+ const outlineTitle = target.closest('[data-note-outline-title]');
+ if (outlineTitle) return startRenameNoteOutline(outlineTitle.getAttribute('data-note-outline-title'));
+ const noteDelete = target.closest('[data-note-delete]');
+ if (noteDelete) return deleteNote(noteDelete.getAttribute('data-note-delete'));
+ const noteOpen = target.closest('[data-note-open]');
+ if (noteOpen) {
+ const noteId = noteOpen.getAttribute('data-note-open');
+ const anchor = findOrRestoreNoteHighlight(noteId);
+ if (anchor) scrollNoteHighlightIntoView(anchor);
+ openNoteEditor(noteId, { anchorNode: anchor });
+ }
+ }
+
+ function upsertNote(rawNote, options = {}) {
+ if (!canEditReadingNotes()) return null;
+ const normalized = normalizeNotes([rawNote])[0];
+ if (!normalized) return null;
+ normalized.outlineId = getValidNoteOutlineId(normalized.outlineId);
+ const index = state.notes.findIndex((note) => note.id === normalized.id);
+ if (index >= 0) state.notes.splice(index, 1, { ...state.notes[index], ...normalized });
+ else {
+ if (!Number.isFinite(Number(rawNote?.order))) normalized.order = getNextNoteOrder(normalized.outlineId);
+ state.notes.push(normalized);
+ }
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ syncNotesToLegacyText();
+ if (options.forceUi !== false) { renderNotesDrawer(); refreshNoteHighlightAttributes(normalized.id); }
+ if (options.sync !== false) syncReadingAnnotation(options.reason || 'note');
+ return getNoteById(normalized.id);
+ }
+
+ function setNotes(rawNotes, rawOutlines = [], options = {}) {
+ const sanitized = sanitizeNotesWithOutlines(rawNotes, rawOutlines);
+ state.notes = sanitized.notes;
+ state.noteOutlines = sanitized.noteOutlines;
+ if (!state.notes.length && options.legacyText) {
+ const legacyText = String(options.legacyText || '');
+ if (legacyText.trim()) {
+ state.notes = normalizeNotes([{ id: generateNoteId(), title: 'Notes', body: legacyText, quote: '' }]);
+ }
+ }
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ ensureReadingNotesUi();
+ syncNotesToLegacyText();
+ renderNotesDrawer();
+ refreshNoteHighlightAttributes();
+ restoreMissingNoteAnchors();
+ }
+
+ function createNoteOutline() {
+ if (!canEditReadingNotes()) return;
+ const now = Date.now();
+ state.noteOutlines.push({ id: generateNoteOutlineId(), title: 'New outline', order: state.noteOutlines.length, collapsed: false, createdAt: now, updatedAt: now });
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ startRenameNoteOutline(state.noteOutlines[state.noteOutlines.length - 1].id);
+ syncReadingAnnotation('note-outline-add');
+ }
+
+ function getNoteOutlineById(id) { return state.noteOutlines.find((outline) => outline.id === String(id || '')) || null; }
+
+ function toggleNoteOutline(id) {
+ if (!canEditReadingNotes()) return;
+ const outline = getNoteOutlineById(id);
+ if (!outline) return;
+ outline.collapsed = !outline.collapsed;
+ outline.updatedAt = Date.now();
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ syncReadingAnnotation('note-outline-toggle');
+ }
+
+ function deleteNoteOutline(id) {
+ if (!canEditReadingNotes()) return;
+ const outlineId = String(id || '');
+ state.noteOutlines = state.noteOutlines.filter((outline) => outline.id !== outlineId);
+ state.notes.forEach((note) => { if (note.outlineId === outlineId) note.outlineId = ''; });
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ syncReadingAnnotation('note-outline-delete');
+ }
+
+ function startRenameNoteOutline(id) {
+ if (!canEditReadingNotes()) return;
+ const outline = getNoteOutlineById(id);
+ const button = document.querySelector(`[data-note-outline-title="${escapeSelector(id)}"]`);
+ if (!outline || !button) return;
+ const input = document.createElement('input');
+ input.className = 'reading-note-outline-title-input';
+ input.value = outline.title;
+ input.setAttribute('data-note-outline-title-input', outline.id);
+ button.replaceWith(input);
+ input.focus(); input.select();
+ }
+
+ function commitRenameNoteOutline(input, cancel = false) {
+ if (!(input instanceof HTMLInputElement) || input.dataset.committed === 'true') return;
+ if (!canEditReadingNotes() && !cancel) cancel = true;
+ input.dataset.committed = 'true';
+ const outline = getNoteOutlineById(input.getAttribute('data-note-outline-title-input'));
+ if (outline && !cancel) { outline.title = String(input.value || '').trim() || 'New outline'; outline.updatedAt = Date.now(); }
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ if (!cancel) syncReadingAnnotation('note-outline-rename');
+ }
+
+ function handleNoteDrawerKeydown(event) {
+ const input = event.target instanceof HTMLElement ? event.target.closest('[data-note-outline-title-input]') : null;
+ if (input) {
+ if (!canEditReadingNotes() && event.key !== 'Escape') return;
+ if (event.key === 'Enter') { event.preventDefault(); commitRenameNoteOutline(input); }
+ else if (event.key === 'Escape') { event.preventDefault(); commitRenameNoteOutline(input, true); }
+ return;
+ }
+ const handle = event.target instanceof HTMLElement ? event.target.closest('[data-note-drag-handle]') : null;
+ if (!handle || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
+ if (!canEditReadingNotes()) return;
+ event.preventDefault();
+ const note = getNoteById(handle.getAttribute('data-note-drag-handle'));
+ if (!note) return;
+ if (event.key === 'ArrowLeft') note.outlineId = '';
+ else if (event.key === 'ArrowRight' && state.noteOutlines[0]) note.outlineId = state.noteOutlines[0].id;
+ else {
+ const siblings = sortNotesForDrawer().filter((item) => item.outlineId === note.outlineId);
+ const index = siblings.findIndex((item) => item.id === note.id);
+ const targetIndex = event.key === 'ArrowUp' ? index - 1 : index + 1;
+ if (targetIndex >= 0 && targetIndex < siblings.length) {
+ const targetOrder = siblings[targetIndex].order;
+ siblings[targetIndex].order = note.order;
+ note.order = targetOrder;
+ }
+ }
+ note.updatedAt = Date.now();
+ state.noteDrawerDirty = true;
+ renderNotesDrawer();
+ syncReadingAnnotation('note-reorder');
+ }
+
+ function handleNoteDrawerFocusOut(event) {
+ const input = event.target instanceof HTMLInputElement ? event.target.closest('[data-note-outline-title-input]') : null;
+ if (input) commitRenameNoteOutline(input);
+ }
+
+ let draggedNoteId = '';
+ function handleNoteDragStart(event) {
+ if (!canEditReadingNotes()) return;
+ const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null;
+ if (!row) return;
+ draggedNoteId = row.getAttribute('data-note-row') || '';
+ row.classList.add('dragging');
+ event.dataTransfer?.setData('text/plain', draggedNoteId);
+ }
+
+ function handleNoteDragOver(event) {
+ if (!canEditReadingNotes()) return;
+ const target = event.target instanceof HTMLElement ? event.target.closest('[data-note-row], [data-note-drop-list]') : null;
+ if (!target) return;
+ event.preventDefault();
+ clearNoteDragIndicators();
+ document.querySelector(`[data-note-row="${escapeSelector(draggedNoteId)}"]`)?.classList.add('dragging');
+ target.classList.add('drag-over');
+ }
+
+ function handleNoteDrop(event) {
+ if (!canEditReadingNotes()) return clearNoteDragIndicators();
+ event.preventDefault();
+ const note = getNoteById(draggedNoteId || event.dataTransfer?.getData('text/plain'));
+ const row = event.target instanceof HTMLElement ? event.target.closest('[data-note-row]') : null;
+ const list = event.target instanceof HTMLElement ? event.target.closest('[data-note-drop-list]') : null;
+ if (!note || (!row && !list)) return clearNoteDragIndicators();
+ const outlineId = getValidNoteOutlineId((list || row.closest('[data-note-drop-list]'))?.getAttribute('data-note-drop-list'));
+ const siblings = sortNotesForDrawer().filter((item) => item.id !== note.id && (item.outlineId || '') === outlineId);
+ const index = row ? Math.max(0, siblings.findIndex((item) => item.id === row.getAttribute('data-note-row'))) : siblings.length;
+ siblings.splice(index < 0 ? siblings.length : index, 0, note);
+ siblings.forEach((item, order) => { item.outlineId = outlineId; item.order = order; item.updatedAt = Date.now(); });
+ state.noteDrawerDirty = true;
+ clearNoteDragIndicators();
+ renderNotesDrawer();
+ syncReadingAnnotation('note-reorder');
+ }
+
+ function clearNoteDragIndicators() {
+ document.querySelectorAll('.reading-note-row.dragging,.reading-note-row.drag-over,[data-note-drop-list].drag-over').forEach((node) => node.classList.remove('dragging', 'drag-over'));
+ draggedNoteId = '';
+ }
+
+ function clampNoteEditorPosition(left, top) {
+ const editor = document.getElementById('reading-note-editor');
+ const margin = 12;
+ const width = editor?.offsetWidth || 430;
+ const height = editor?.offsetHeight || 330;
+ return {
+ left: Math.min(Math.max(margin, left), Math.max(margin, global.innerWidth - width - margin)),
+ top: Math.min(Math.max(margin, top), Math.max(margin, global.innerHeight - height - margin))
+ };
+ }
+
+ function positionNoteEditor(anchorNode = null) {
+ const editor = document.getElementById('reading-note-editor');
+ if (!editor) return;
+ let left = Number(state.noteEditorPosition?.left);
+ let top = Number(state.noteEditorPosition?.top);
+ if (!Number.isFinite(left) || !Number.isFinite(top)) {
+ const rect = anchorNode?.getBoundingClientRect?.();
+ left = rect ? rect.left + Math.min(24, rect.width / 2) : (global.innerWidth - (editor.offsetWidth || 430)) / 2;
+ top = rect ? rect.bottom + 10 : (global.innerHeight - (editor.offsetHeight || 330)) / 2;
+ }
+ const position = clampNoteEditorPosition(left, top);
+ editor.style.left = `${Math.round(position.left)}px`;
+ editor.style.top = `${Math.round(position.top)}px`;
+ state.noteEditorPosition = position;
+ }
+
+ function canEditReadingNotes() {
+ if (state.timerLocked) return false;
+ const activePracticeCanEdit = Boolean(
+ !state.readOnly
+ && !state.memorizeMode
+ && !state.submitted
+ );
+ const submittedRecordCanEdit = Boolean(
+ state.submitted
+ && state.submittedRecordId
+ && !state.memorizeMode
+ );
+ return Boolean(state.reviewMode || activePracticeCanEdit || submittedRecordCanEdit);
+ }
+
+ function openNoteEditor(noteId, options = {}) {
+ ensureReadingNotesUi();
+ if (state.activeNoteId && state.activeNoteId !== noteId) flushActiveNoteFromEditor();
+ const note = getNoteById(noteId);
+ if (!note) return;
+ state.activeNoteId = note.id;
+ const editor = document.getElementById('reading-note-editor');
+ const title = editor?.querySelector('[data-note-title]');
+ const body = editor?.querySelector('[data-note-body]');
+ const quote = editor?.querySelector('[data-note-quote]');
+ if (!editor) return;
+ const canEditNotes = canEditReadingNotes();
+ if (title) { title.value = note.title || ''; title.disabled = !canEditNotes; }
+ if (body) { body.value = note.body || ''; body.disabled = !canEditNotes; }
+ if (quote) { quote.textContent = note.quote || ''; quote.style.display = note.quote ? '' : 'none'; }
+ editor.style.display = 'flex';
+ editor.setAttribute('aria-hidden', 'false');
+ global.requestAnimationFrame(() => {
+ positionNoteEditor(options.anchorNode || findNoteHighlight(note.id));
+ (options.focusBody ? body : title)?.focus();
+ });
+ }
+
+ function closeNoteEditor() {
+ flushActiveNoteFromEditor();
+ const editor = document.getElementById('reading-note-editor');
+ if (editor) { editor.style.display = 'none'; editor.setAttribute('aria-hidden', 'true'); }
+ state.activeNoteId = '';
+ }
+
+ function attachNoteEditorDrag(editor) {
+ const handle = editor.querySelector('[data-note-drag-handle]');
+ if (!handle) return;
+ let drag = null;
+ const move = (event) => {
+ if (!drag) return;
+ const next = clampNoteEditorPosition(drag.left + event.clientX - drag.x, drag.top + event.clientY - drag.y);
+ editor.style.left = `${Math.round(next.left)}px`;
+ editor.style.top = `${Math.round(next.top)}px`;
+ state.noteEditorPosition = next;
+ };
+ const stop = () => {
+ drag = null;
+ document.removeEventListener('pointermove', move);
+ document.removeEventListener('pointerup', stop);
+ document.removeEventListener('pointercancel', stop);
+ };
+ handle.addEventListener('pointerdown', (event) => {
+ if (event.target.closest?.('button')) return;
+ const rect = editor.getBoundingClientRect();
+ drag = { x: event.clientX, y: event.clientY, left: rect.left, top: rect.top };
+ document.addEventListener('pointermove', move);
+ document.addEventListener('pointerup', stop);
+ document.addEventListener('pointercancel', stop);
+ event.preventDefault();
+ });
+ }
+
+ function clearNoteEditorSaveTimer() {
+ if (state.noteEditorSaveTimer) global.clearTimeout(state.noteEditorSaveTimer);
+ state.noteEditorSaveTimer = null;
+ }
+
+ function saveActiveNoteFromEditor() {
+ if (!canEditReadingNotes()) return;
+ const note = getNoteById(state.activeNoteId);
+ if (!note) return;
+ const editor = document.getElementById('reading-note-editor');
+ const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim();
+ const body = String(editor?.querySelector('[data-note-body]')?.value || '');
+ if (title === note.title && body === note.body) return;
+ Object.assign(note, { title, body, updatedAt: Date.now() });
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ state.noteEditorPendingSync = true;
+ syncNotesToLegacyText();
+ clearNoteEditorSaveTimer();
+ state.noteEditorSaveTimer = global.setTimeout(flushActiveNoteFromEditor, NOTE_EDITOR_SAVE_DEBOUNCE_MS);
+ }
+
+ function flushActiveNoteFromEditor() {
+ if (!canEditReadingNotes()) return;
+ const note = getNoteById(state.activeNoteId);
+ if (!note) return;
+ const editor = document.getElementById('reading-note-editor');
+ const title = String(editor?.querySelector('[data-note-title]')?.value || '').trim();
+ const body = String(editor?.querySelector('[data-note-body]')?.value || '');
+ if (title === note.title && body === note.body && !state.noteEditorPendingSync) return;
+ clearNoteEditorSaveTimer();
+ state.noteEditorPendingSync = false;
+ upsertNote({ ...note, title, body, updatedAt: Date.now() }, { forceUi: true, reason: 'note-edit' });
+ }
+
+ function createNoteAnchorSpan(note) {
+ const span = document.createElement('span');
+ span.className = 'hl';
+ span.dataset.hlType = 'note';
+ span.dataset.noteId = note.id;
+ return span;
+ }
+
+ function shouldSkipNoteAnchorTextNode(node) {
+ if (!node?.nodeValue?.trim()) return true;
+ const element = node.parentElement;
+ return Boolean(element?.closest?.('.hl') || getHighlightShared()?.isInsideExplanation?.(node));
+ }
+
+ function wrapNoteTextInRoot(root, note, quote) {
+ const nodes = getHighlightShared()?.getTextNodes?.(root) || [];
+ // 先统计整段里命中次数;saved highlight 缺失才会走到这条兜底路径,若同一引文
+ // 多次出现,按“首次命中”绑定会静默定位到错误位置。这里要求全局唯一匹配才绑定,
+ // 否则放弃恢复该笔记的锚点,而不是盲目绑到第一个重复位置。
+ let matchNode = null;
+ let matchIndex = -1;
+ let totalMatches = 0;
+ for (const node of nodes) {
+ if (shouldSkipNoteAnchorTextNode(node)) continue;
+ const value = String(node.nodeValue || '');
+ let from = 0;
+ let idx = value.indexOf(quote, from);
+ while (idx >= 0) {
+ totalMatches += 1;
+ if (!matchNode) {
+ matchNode = node;
+ matchIndex = idx;
+ }
+ from = idx + quote.length;
+ idx = value.indexOf(quote, from);
+ }
+ }
+ if (totalMatches === 0 || totalMatches > 1 || !matchNode) {
+ return null;
+ }
+ const range = document.createRange();
+ range.setStart(matchNode, matchIndex); range.setEnd(matchNode, matchIndex + quote.length);
+ const span = createNoteAnchorSpan(note);
+ try { range.surroundContents(span); return span; } catch (_) { return null; }
+ }
+
+ function findRestorableNoteAnchor(note) {
+ const quote = normalizeNoteText(note?.quote);
+ if (!quote || quote.length < 2) return null;
+ // 唯一性的判定需要在整篇 passage 范围内完成;逐 root 绑定会让跨 root
+ // 的重复引文被误判为“当前 root 内唯一”。先聚合所有命中,再决定绑定。
+ const roots = [dom.left, dom.groups].filter(Boolean);
+ let totalMatches = 0;
+ let matchRoot = null;
+ for (const root of roots) {
+ const nodes = getHighlightShared()?.getTextNodes?.(root) || [];
+ for (const node of nodes) {
+ if (shouldSkipNoteAnchorTextNode(node)) continue;
+ const value = String(node.nodeValue || '');
+ let from = 0;
+ let idx = value.indexOf(quote, from);
+ while (idx >= 0) {
+ totalMatches += 1;
+ if (!matchRoot) matchRoot = root;
+ from = idx + quote.length;
+ idx = value.indexOf(quote, from);
+ }
+ }
+ }
+ if (totalMatches !== 1 || !matchRoot) return null;
+ return wrapNoteTextInRoot(matchRoot, note, quote);
+ }
+
+ function restoreMissingNoteAnchors() {
+ let count = 0;
+ state.notes.forEach((note) => {
+ if (!findNoteHighlight(note.id) && findRestorableNoteAnchor(note)) count += 1;
+ });
+ if (count) { state.noteHighlightMetaDirty = true; refreshNoteHighlightAttributes(); }
+ return count;
+ }
+
+ function ensureNoteForHighlight(highlightNode, quote = '', options = {}) {
+ if (!(highlightNode instanceof HTMLElement)) return null;
+ let note = getNoteById(highlightNode.dataset.noteId);
+ if (!note && !canEditReadingNotes()) return null;
+ if (!note) {
+ const now = Date.now();
+ note = upsertNote({
+ id: highlightNode.dataset.noteId || generateNoteId(),
+ title: '', body: '', quote: quote || normalizeNoteText(highlightNode.textContent),
+ createdAt: now, updatedAt: now
+ }, { sync: false });
+ }
+ if (note) {
+ highlightNode.dataset.noteId = note.id;
+ highlightNode.dataset.hlType = 'note';
+ state.noteHighlightMetaDirty = true;
+ refreshNoteHighlightAttributes(note.id);
+ if (options.sync !== false) syncReadingAnnotation('note-anchor');
+ }
+ return note;
+ }
+
+ function ensureNoteAnchorsBeforeSnapshot() {
+ document.querySelectorAll('.hl[data-hl-type="note"]').forEach((node) => {
+ if (node instanceof HTMLElement && !node.dataset.noteId) {
+ ensureNoteForHighlight(node, normalizeNoteText(node.textContent), { sync: false });
+ }
+ });
+ }
+
+ function findNoteHighlight(noteId) {
+ const id = String(noteId || '').trim();
+ return id ? document.querySelector(`.hl[data-note-id="${escapeSelector(id)}"]`) : null;
+ }
+
+ function findOrRestoreNoteHighlight(noteId) {
+ const existing = findNoteHighlight(noteId);
+ if (existing) return existing;
+ const note = getNoteById(noteId);
+ return note ? findRestorableNoteAnchor(note) : null;
+ }
+
+ function scrollNoteHighlightIntoView(node) {
+ node?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
+ node?.classList.add('reading-note-flash');
+ global.setTimeout(() => node?.classList.remove('reading-note-flash'), 900);
+ }
+
+ function deleteNote(noteId, options = {}) {
+ if (!canEditReadingNotes()) return;
+ const id = String(noteId || '').trim();
+ if (!id) return;
+ state.notes = state.notes.filter((note) => note.id !== id);
+ document.querySelectorAll(`.hl[data-note-id="${escapeSelector(id)}"]`).forEach((node) => {
+ const parent = node.parentNode;
+ if (!parent) return;
+ while (node.firstChild) parent.insertBefore(node.firstChild, node);
+ node.remove(); parent.normalize();
+ });
+ if (state.activeNoteId === id) { state.activeNoteId = ''; closeNoteEditor(); }
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ syncNotesToLegacyText();
+ renderNotesDrawer();
+ if (options.sync !== false) syncReadingAnnotation('note-delete');
+ }
+
+ function clearStructuredNotesForReset() {
+ if (!canEditReadingNotes()) return;
+ clearNoteEditorSaveTimer();
+ state.noteEditorPendingSync = false;
+ state.activeNoteId = '';
+ state.notes = [];
+ state.noteOutlines = [];
+ state.noteDrawerDirty = true;
+ state.noteHighlightMetaDirty = true;
+ document.querySelectorAll('.hl[data-note-id], .hl[data-hl-type="note"]').forEach((node) => {
+ const parent = node.parentNode;
+ if (!parent) return;
+ while (node.firstChild) parent.insertBefore(node.firstChild, node);
+ node.remove();
+ parent.normalize();
+ });
+ setNotesText('');
+ const editor = document.getElementById('reading-note-editor');
+ if (editor) {
+ editor.querySelectorAll('input, textarea').forEach((field) => { field.value = ''; });
+ editor.style.display = 'none';
+ editor.setAttribute('aria-hidden', 'true');
+ }
+ closeNotesDrawer();
+ renderNotesDrawer();
+ }
+
+ function refreshNoteHighlightAttributes(noteId = '') {
+ if (!state.noteHighlightMetaDirty && !noteId) return;
+ const selector = noteId ? `.hl[data-note-id="${escapeSelector(noteId)}"]` : '.hl[data-note-id]';
+ document.querySelectorAll(selector).forEach((node) => {
+ if (!(node instanceof HTMLElement)) return;
+ const note = getNoteById(node.dataset.noteId);
+ const title = String(note?.title || '').trim() || buildDefaultNoteTitle(node.textContent);
+ node.dataset.hlType = 'note';
+ node.title = `Note: ${title}`;
+ node.setAttribute('role', 'button');
+ node.tabIndex = 0;
+ node.setAttribute('aria-label', `Open note: ${title}`);
+ });
+ state.noteHighlightMetaDirty = false;
+ }
+
+ function handleNoteHighlightClick(event) {
+ const highlight = event.target instanceof HTMLElement ? event.target.closest('.hl[data-note-id]') : null;
+ if (!highlight) return;
+ event.preventDefault(); event.stopPropagation();
+ openNoteEditor(highlight.dataset.noteId, { anchorNode: highlight });
+ }
+
+ function syncReadingAnnotation(reason = 'note') {
+ if (!canEditReadingNotes()) return;
+ const isSuiteReviewAnnotation = Boolean(
+ state.simulationMode
+ && state.suiteReviewMode
+ && state.reviewMode
+ && state.suiteSessionId
+ );
+ if (state.simulationMode && (!state.readOnly || isSuiteReviewAnnotation)) {
+ syncSimulationDraftSnapshot(reason);
+ return;
+ }
+ if (state.reviewMode) {
+ postMessage('READING_ANNOTATION_SYNC', {
+ examId: state.examId,
+ recordId: state.reviewRecordId || null,
+ reviewSessionId: state.reviewSessionId || null,
+ sessionId: state.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null,
+ annotations: {
+ highlights: collectHighlights(),
+ noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
+ scrollY: global.scrollY || 0
+ },
+ reason
+ });
+ return;
+ }
+ // 单篇 final-submit 后(submitted=true,reviewMode=false),宿主在保存练习
+ // 记录后通过 PRACTICE_RECORD_SAVED 回传 recordId。持有该 id 时,结果页笔记
+ // 改动需要以 READING_ANNOTATION_SYNC 直接写回已存档的练习记录,而非走草稿
+ // 同步(草稿在提交时已被清除,且 draft 分支在此状态下会被跳过)。
+ if (state.submitted && state.submittedRecordId && !state.memorizeMode) {
+ postMessage('READING_ANNOTATION_SYNC', {
+ examId: state.examId,
+ recordId: state.submittedRecordId,
+ reviewSessionId: null,
+ sessionId: state.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null,
+ annotations: {
+ highlights: collectHighlights(),
+ noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
+ scrollY: global.scrollY || 0
+ },
+ reason
+ });
+ return;
+ }
+ if (!state.readOnly && !state.submitted && !state.memorizeMode) {
+ syncReadingDraftSnapshot(reason);
+ }
+ }
+
async function ensureExplanationDataset() {
const registry = global.__READING_EXPLANATION_DATA__;
if (!registry || typeof registry.get !== 'function') {
@@ -1469,6 +2571,11 @@
.reading-locator-highlight:hover {
background: rgba(250, 204, 21, 0.62);
}
+ .reading-locator-overlap { cursor:pointer; text-decoration:underline #dc2626 2px; text-underline-offset:3px; }
+ .reading-locator-highlight.is-review-jump-target,.reading-locator-overlap.is-review-jump-target { outline:2px solid rgba(37,99,235,.45); outline-offset:2px; }
+ .reading-locator-block { display:inline-block;width:1px;height:1em;overflow:hidden;opacity:0;pointer-events:none;vertical-align:baseline; }
+ .reading-passage-locator-target.is-review-jump-target { border-radius:4px;outline:2px solid rgba(37,99,235,.38);background:rgba(96,165,250,.12); }
+ .results-table .question-jump-btn { border:0;padding:0;background:transparent;color:#2563eb;font:inherit;font-weight:700;cursor:pointer;text-decoration:underline;text-underline-offset:2px; }
`;
document.head.appendChild(style);
}
@@ -1487,6 +2594,11 @@
return;
}
shared.unwrapMatchingHighlights(dom.left, LOCATOR_HIGHLIGHT_SELECTOR);
+ dom.left?.querySelectorAll('.reading-passage-locator-target').forEach((node) => node.classList.remove('reading-passage-locator-target', 'is-review-jump-target'));
+ dom.left?.querySelectorAll(LOCATOR_OVERLAP_SELECTOR).forEach((node) => {
+ node.classList.remove('reading-locator-overlap', 'is-review-jump-target');
+ delete node.dataset.locatorOverlap;
+ });
}
function getHighlightShared() {
@@ -1789,17 +2901,14 @@
let draftsByExam = {};
let resultsByExam = {};
try {
- const raw = global.sessionStorage?.getItem('ielts_sim_session');
- if (raw) {
- const parsed = JSON.parse(raw);
- if (parsed) {
- if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence;
- if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam;
- if (Array.isArray(parsed.results)) {
- parsed.results.forEach(res => {
- if (res && res.examId) resultsByExam[res.examId] = res;
- });
- }
+ const parsed = global.AppData?.recovery?.windowSession?.get('simulation');
+ if (parsed) {
+ if (Array.isArray(parsed.sequence)) sequenceExams = parsed.sequence;
+ if (parsed.draftsByExam) draftsByExam = parsed.draftsByExam;
+ if (Array.isArray(parsed.results)) {
+ parsed.results.forEach(res => {
+ if (res && res.examId) resultsByExam[res.examId] = res;
+ });
}
}
} catch (_) {}
@@ -2494,7 +3603,7 @@
function attachMemorizeLocatorListeners() {
document.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement
- ? event.target.closest('.reading-locator-highlight[data-question-id]')
+ ? event.target.closest('.reading-locator-highlight[data-question-id],.reading-locator-overlap[data-question-id],.reading-locator-block[data-question-id]')
: null;
if (!target) {
return;
@@ -2788,6 +3897,61 @@
return snippets;
}
+ function buildLocatorSnippetVariants(text) {
+ const source = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!source) return [];
+ return Array.from(new Set([
+ source,
+ source.replace(/[‘’]/g, "'").replace(/[“”]/g, '"'),
+ source.replace(/[‐‑‒–—―]/g, '-'),
+ source.replace(/\s+-\s+/g, ' — '),
+ source.replace(/\s+-\s+/g, ' – ')
+ ])).filter(Boolean);
+ }
+
+ function normalizeLocatorComparableText(text) {
+ return String(text || '').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/[‐‑‒–—―]/g, '-').replace(/\s+/g, ' ').trim().toLowerCase();
+ }
+
+ function findPassageBlockForLocatorSnippet(snippet) {
+ if (!dom.left || !snippet) return null;
+ const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText);
+ return Array.from(dom.left.querySelectorAll('p, li, td, th, div')).filter((node) => {
+ if (node.closest(EXPLANATION_NODE_SELECTOR) || node.classList.contains('reading-locator-highlight')) return false;
+ if (node.tagName === 'DIV' && node.querySelector('p, li, td, th')) return false;
+ const text = normalizeLocatorComparableText(node.textContent);
+ return text.length >= 10 && variants.some((variant) => text.includes(variant));
+ }).sort((a, b) => String(a.textContent || '').length - String(b.textContent || '').length)[0] || null;
+ }
+
+ function markOverlappingLocatorHighlight(questionId, snippet) {
+ const variants = buildLocatorSnippetVariants(snippet).map(normalizeLocatorComparableText);
+ const target = Array.from(dom.left?.querySelectorAll('.hl') || []).find((node) => {
+ const text = normalizeLocatorComparableText(node.textContent);
+ return text.length >= 12 && variants.some((variant) => text.includes(variant) || variant.includes(text));
+ });
+ if (!target) return null;
+ target.classList.add('reading-locator-overlap');
+ target.dataset.questionId = questionId;
+ target.dataset.locatorOverlap = 'true';
+ target.title = `Q${displayLabel(questionId)} 定位`;
+ return target;
+ }
+
+ function createLocatorBlock(questionId, snippet) {
+ const target = findPassageBlockForLocatorSnippet(snippet);
+ if (!target) return null;
+ const existing = target.querySelector(`.reading-locator-block[data-question-id="${escapeSelector(questionId)}"]`);
+ if (existing) return existing;
+ target.classList.add('reading-passage-locator-target');
+ const marker = document.createElement('span');
+ marker.className = 'reading-locator-block';
+ marker.dataset.questionId = questionId;
+ marker.setAttribute('aria-hidden', 'true');
+ target.insertBefore(marker, target.firstChild);
+ return marker;
+ }
+
function buildMemorizeLocatorSnippets() {
const snippetsByQuestionId = new Map();
const sections = Array.isArray(state.explanation?.questionExplanations)
@@ -2831,7 +3995,7 @@
function applyMemorizeLocatorHighlights() {
clearMemorizeLocatorHighlights();
- if (!state.memorizeMode || !dom.left) {
+ if ((!state.memorizeMode && !state.reviewMode && !state.submitted) || !dom.left) {
return 0;
}
const shared = getHighlightShared();
@@ -2843,21 +4007,68 @@
let applied = 0;
snippetsByQuestionId.forEach((snippets, questionId) => {
snippets.slice(0, 4).forEach((snippet) => {
- const matches = shared.wrapTextMatches(dom.left, snippet, {
- className: 'reading-locator-highlight',
- attrs: {
- 'data-question-id': questionId,
- title: `Q${displayLabel(questionId)} 定位`
- },
- limit: 2,
- skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block'
- });
+ let matches = [];
+ for (const variant of buildLocatorSnippetVariants(snippet)) {
+ if (matches.length) break;
+ matches = shared.wrapTextMatches(dom.left, variant, {
+ className: 'reading-locator-highlight',
+ attrs: { 'data-question-id': questionId, title: `Q${displayLabel(questionId)} 定位` },
+ limit: 2,
+ skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block'
+ });
+ }
+ if (!matches.length) {
+ const overlap = markOverlappingLocatorHighlight(questionId, snippet);
+ if (overlap) matches = [overlap];
+ }
+ if (!matches.length) {
+ const marker = createLocatorBlock(questionId, snippet);
+ if (marker) matches = [marker];
+ }
applied += matches.length;
});
});
return applied;
}
+ function findLocatorAnchor(questionId) {
+ const normalized = normalizeQuestionId(questionId);
+ return Array.from(document.querySelectorAll('.reading-locator-highlight[data-question-id],.reading-locator-block[data-question-id],.reading-locator-overlap[data-question-id]'))
+ .find((node) => normalizeQuestionId(node.dataset.questionId) === normalized) || null;
+ }
+
+ function applyLocatorHighlightsForQuestion(questionId) {
+ const normalized = normalizeQuestionId(questionId);
+ const snippets = buildMemorizeLocatorSnippets().get(normalized) || [];
+ if (!normalized || !dom.left) return 0;
+ const shared = getHighlightShared();
+ for (const snippet of snippets) {
+ for (const variant of buildLocatorSnippetVariants(snippet)) {
+ const matches = shared?.wrapTextMatches?.(dom.left, variant, {
+ className: 'reading-locator-highlight',
+ attrs: { 'data-question-id': normalized, title: `Q${displayLabel(normalized)} 定位` },
+ limit: 1,
+ skipSelector: '.hl, .reading-locator-highlight, .reading-locator-block'
+ }) || [];
+ if (matches.length) return matches.length;
+ }
+ if (markOverlappingLocatorHighlight(normalized, snippet) || createLocatorBlock(normalized, snippet)) return 1;
+ }
+ return 0;
+ }
+
+ function jumpToQuestionEvidence(questionId) {
+ if (!findLocatorAnchor(questionId)) applyLocatorHighlightsForQuestion(questionId);
+ const locator = findLocatorAnchor(questionId);
+ const target = locator || findQuestionAnchor(questionId);
+ if (!target) return false;
+ target.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
+ const highlightTarget = locator?.classList.contains('reading-locator-block') ? locator.closest('.reading-passage-locator-target') : locator;
+ highlightTarget?.classList.add('is-review-jump-target');
+ global.setTimeout(() => highlightTarget?.classList.remove('is-review-jump-target'), 1800);
+ return true;
+ }
+
async function renderMemorizeStudyLayer() {
if (!state.memorizeMode) {
return;
@@ -2926,7 +4137,7 @@
if (!item) return null;
const sourceDropzone = item.closest('.paragraph-dropzone, .match-dropzone, .drop-target-summary');
return {
- value: item.dataset.heading || item.dataset.option || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(),
+ value: item.dataset.heading || item.dataset.option || item.dataset.key || item.dataset.word || item.dataset.value || item.dataset.answerValue || item.textContent.trim(),
label: item.dataset.answerLabel || item.dataset.word || item.dataset.value || item.textContent.trim(),
sourceDropzoneId: sourceDropzone?.dataset?.dropzoneId || ''
};
@@ -3384,7 +4595,7 @@
const checkboxGroups = getCheckboxAnswers();
checkboxGroups.forEach((values, name) => {
- const questionIds = expandQuestionSequence(name);
+ const questionIds = resolveCheckboxQuestionIds(name);
if (!questionIds.length) {
return;
}
@@ -3419,6 +4630,26 @@
return answers;
}
+ function resolveCheckboxQuestionIds(name) {
+ const questionIds = expandQuestionSequence(name);
+ if (questionIds.length <= 1) {
+ return questionIds;
+ }
+ const firstQuestionId = questionIds[0];
+ const answerKey = state.dataset?.answerKey || {};
+ const questionGroup = buildQuestionGroupLookup(state.dataset).get(firstQuestionId) || null;
+ if (
+ questionGroup
+ && questionGroup.kind === 'multi_choice'
+ && Array.isArray(questionGroup.questionIds)
+ && questionGroup.questionIds.length === 1
+ && Array.isArray(answerKey[firstQuestionId])
+ ) {
+ return [firstQuestionId];
+ }
+ return questionIds;
+ }
+
function normalizeAnswerValue(value) {
if (Array.isArray(value)) {
return splitAnswerTokens(value);
@@ -3554,10 +4785,16 @@
: splitAnswerTokens(value);
const normalized = [];
rawTokens.forEach((entry) => {
- const token = canonicalizeAnswerToken(entry);
+ const rawChoiceToken = String(entry ?? '').trim().toUpperCase();
+ const token = /^[A-Z]$/.test(rawChoiceToken)
+ ? rawChoiceToken
+ : canonicalizeAnswerToken(entry);
if (!token) {
return;
}
+ if (!/^[A-Z]$/.test(token)) {
+ return;
+ }
if (!normalized.some((existing) => areAnswerTokensEquivalent(existing, token))) {
normalized.push(token);
}
@@ -3577,6 +4814,50 @@
return tokens.sort((left, right) => left.localeCompare(right, 'en'));
}
+ function resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, targetQuestionId) {
+ const questionIds = Array.isArray(questionGroup?.questionIds)
+ ? questionGroup.questionIds.map((entry) => normalizeQuestionId(entry)).filter(Boolean)
+ : [];
+ const selectedTokens = collectGroupChoiceTokens(answers, questionIds);
+ const remainingTokens = selectedTokens.slice();
+ const assignments = new Map();
+
+ questionIds.forEach((questionId) => {
+ const expectedToken = canonicalizeAnswerToken(answerKey[questionId]);
+ if (!expectedToken) {
+ return;
+ }
+ const matchedIndex = remainingTokens.findIndex((token) => areAnswerTokensEquivalent(token, expectedToken));
+ if (matchedIndex >= 0) {
+ assignments.set(questionId, remainingTokens[matchedIndex]);
+ remainingTokens.splice(matchedIndex, 1);
+ }
+ });
+
+ questionIds.forEach((questionId) => {
+ if (assignments.has(questionId)) {
+ return;
+ }
+ const fallbackToken = remainingTokens.shift();
+ if (fallbackToken) {
+ assignments.set(questionId, fallbackToken);
+ }
+ });
+
+ const normalizedTargetId = normalizeQuestionId(targetQuestionId) || targetQuestionId;
+ const expectedToken = canonicalizeAnswerToken(answerKey[normalizedTargetId]);
+ const assignedToken = assignments.get(normalizedTargetId) || '';
+ return {
+ // Review rows for split-key multi-choice still show the full selected set
+ // so partial credit remains inspectable even though scoring is per expected token.
+ displayUserAnswer: selectedTokens.length
+ ? selectedTokens.slice()
+ : (assignedToken || answers[normalizedTargetId] || ''),
+ expectedToken,
+ isCorrect: Boolean(assignedToken && expectedToken && areAnswerTokensEquivalent(assignedToken, expectedToken))
+ };
+ }
+
function questionWeight(correctAnswer, questionGroup = null) {
if (Array.isArray(correctAnswer)) {
const normalized = normalizeAnswerValue(correctAnswer);
@@ -3637,14 +4918,13 @@
let partialCorrectCount = isCorrect ? weight : 0;
if (isSplitMultiChoiceGroup) {
- const selectedTokens = collectGroupChoiceTokens(answers, questionGroup.questionIds);
- const expectedToken = canonicalizeAnswerToken(correctAnswer);
- displayUserAnswer = selectedTokens.length ? selectedTokens : userAnswer;
- if (!expectedToken) {
+ const splitSelection = resolveSplitMultiChoiceSelection(answers, answerKey, questionGroup, normalizedQuestionId);
+ displayUserAnswer = splitSelection.displayUserAnswer || userAnswer;
+ if (!splitSelection.expectedToken) {
isCorrect = null;
partialCorrectCount = 0;
} else {
- isCorrect = selectedTokens.some((token) => areAnswerTokensEquivalent(token, expectedToken));
+ isCorrect = splitSelection.isCorrect;
partialCorrectCount = isCorrect ? 1 : 0;
}
weight = 1;
@@ -3729,13 +5009,17 @@
const label = escapeHtml(displayLabel(entry.questionId));
const userAnswer = escapeHtml(displayAnswerValue(entry.userAnswer));
const correctAnswer = escapeHtml(displayAnswerValue(entry.correctAnswer, ''));
- const status = entry.isCorrect ? '✓' : '✗';
+ const partial = Number(entry.partialCorrectCount) || 0;
+ const weight = Number(entry.weight) || 1;
+ const isPartial = !entry.isCorrect && partial > 0 && weight > 1;
+ const status = entry.isCorrect ? '✓' : (isPartial ? `${partial}/${weight}` : '✗');
+ const statusClass = entry.isCorrect ? 'result-correct' : (isPartial ? 'result-partial' : 'result-incorrect');
return `
- ${label}
+ ${label}
${userAnswer}
${correctAnswer || ''}
- ${status}
+ ${status}
`;
}).join('');
@@ -3755,6 +5039,9 @@
`;
dom.results.style.display = 'block';
+ dom.results.querySelectorAll?.('[data-result-question-id]').forEach((button) => {
+ button.addEventListener('click', () => jumpToQuestionEvidence(button.dataset.resultQuestionId || ''));
+ });
}
function escapeSelector(value) {
@@ -3939,9 +5226,21 @@
const controls = document.querySelectorAll('input, textarea, select');
controls.forEach((control) => {
if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement) {
+ // review、普通进行中练习、以及已回传 recordId 的结果页允许编辑笔记;
+ // 只读/计时锁定/背诵模式仍保持禁用,避免改动无法保存或破坏答题流程。
+ const canEditNotes = canEditReadingNotes();
+ if (
+ canEditNotes
+ && typeof control.closest === 'function'
+ && control.closest('#reading-note-editor, #reading-note-drawer')
+ ) {
+ control.disabled = false;
+ return;
+ }
control.disabled = state.readOnly || state.timerLocked;
}
});
+ renderNotesDrawer();
syncPrimaryActionButtons();
refreshSimulationDraftSyncLifecycle();
enhanceReviewHighlights();
@@ -3962,6 +5261,8 @@
}
function enterSubmittedReadOnlyState(reason = 'submit') {
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'submitted';
state.submitted = true;
setReadOnlyMode(true, reason);
disableDragInteractions();
@@ -3974,22 +5275,136 @@
syncPrimaryActionButtons();
}
+ function clearSubmissionAckTimer() {
+ if (state.submissionAckTimer) {
+ clearTimeout(state.submissionAckTimer);
+ state.submissionAckTimer = null;
+ }
+ }
+
+ function createSubmissionId() {
+ try {
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') {
+ return global.crypto.randomUUID();
+ }
+ } catch (_) {
+ // Fall through to a session-bound identifier.
+ }
+ return [state.sessionId || 'session', state.examId || 'exam', Date.now(), Math.random().toString(36).slice(2)].join(':');
+ }
+
+ function restoreDraftSubmissionState(submissionId = '') {
+ if (state.submissionStatus === 'submitted') {
+ return false;
+ }
+ if (submissionId && state.submissionId && submissionId !== state.submissionId) {
+ return false;
+ }
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'draft';
+ state.submitted = false;
+ syncPrimaryActionButtons();
+ return true;
+ }
+
+ function expirePendingSubmission(submissionId = '') {
+ if (state.submissionStatus !== 'submitting') {
+ return false;
+ }
+ return restoreDraftSubmissionState(submissionId || state.submissionId);
+ }
+
+ function beginSubmission(messageType, payload, presentation = null) {
+ if (state.submissionStatus === 'submitting' || state.submissionStatus === 'submitted') {
+ return false;
+ }
+ if (!state.submissionId) {
+ state.submissionId = createSubmissionId();
+ }
+ state.submissionStatus = 'submitting';
+ state.pendingSubmissionPresentation = presentation;
+ syncPrimaryActionButtons();
+ const delivered = postMessage(messageType, Object.assign({}, payload || {}, {
+ submissionId: state.submissionId
+ }));
+ if (!delivered) {
+ restoreDraftSubmissionState(state.submissionId);
+ return false;
+ }
+ clearSubmissionAckTimer();
+ state.submissionAckTimer = setTimeout(() => {
+ expirePendingSubmission(state.submissionId);
+ }, SUBMIT_ACK_TIMEOUT_MS);
+ return true;
+ }
+
+ function matchesPendingSubmission(data = {}) {
+ if (state.submissionStatus !== 'submitting') return false;
+ const submissionId = data && data.submissionId != null ? String(data.submissionId).trim() : '';
+ const sessionId = data && data.sessionId != null ? String(data.sessionId).trim() : '';
+ const examId = data && data.examId != null ? String(data.examId).trim() : '';
+ const suiteSessionId = data && data.suiteSessionId != null ? String(data.suiteSessionId).trim() : '';
+ if (!submissionId || submissionId !== state.submissionId) return false;
+ if (!sessionId || !state.sessionId || sessionId !== String(state.sessionId)) return false;
+ if (!examId || !state.examId || examId !== String(state.examId)) return false;
+ if (state.suiteSessionId && suiteSessionId !== String(state.suiteSessionId)) return false;
+ if (!state.suiteSessionId && suiteSessionId) return false;
+ return true;
+ }
+
+ async function acceptSubmissionAcknowledgement(data = {}) {
+ if (!matchesPendingSubmission(data)) {
+ return false;
+ }
+ const presentation = state.pendingSubmissionPresentation;
+ clearSubmissionAckTimer();
+ enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit');
+ if (presentation && presentation.results) {
+ state.lastResults = presentation.results;
+ renderResults(presentation.results);
+ await renderExplanations();
+ applyHighlights(Array.isArray(presentation.highlights) ? presentation.highlights : []);
+ refreshNoteHighlightAttributes();
+ restoreMissingNoteAnchors();
+ applyMemorizeLocatorHighlights();
+ enhanceReviewHighlights();
+ updateNavStatuses(presentation.results);
+ }
+ state.pendingSubmissionPresentation = null;
+ if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) {
+ stopSimulationDraftSync();
+ clearSimulationDraftMirror();
+ state.simulationDraftFingerprint = '';
+ }
+ return true;
+ }
+
if (global.__IELTS_READING_PAGE_TEST_HOOKS__ === true) {
global.__IELTS_UNIFIED_READING_PAGE_TEST__ = Object.assign(
global.__IELTS_UNIFIED_READING_PAGE_TEST__ || {},
{
buildReplayResults,
mergeDraft,
+ normalizeNotes,
+ normalizeNoteOutlines,
+ syncReadingAnnotation,
mergeSuiteDraftPayload,
captureInlineSuiteDraftBeforeReinit,
shouldIgnoreInlineSuiteEnvelope,
shouldAcceptWindowSessionMessage,
adoptWindowSessionMessage,
+ buildInitSignature,
handleIncoming,
initializeInlineSimulationSuite,
buildResultsFromAnswers,
renderTimer,
handleSubmit,
+ beginSubmission,
+ acceptSubmissionAcknowledgement,
+ expirePendingSubmission,
+ restoreDraftSubmissionState,
+ stopReadingDraftSync,
+ stopSimulationDraftSync,
getTestState() {
return {
examId: state.examId,
@@ -4009,6 +5424,19 @@
currentIndex: state.suite?.currentIndex || 0,
suiteInline: Boolean(state.suite?.inline),
suiteTimerLimitSeconds: state.suiteTimerLimitSeconds,
+ reviewRecordId: state.reviewRecordId,
+ submittedRecordId: state.submittedRecordId,
+ submitted: state.submitted,
+ readOnly: state.readOnly,
+ submissionStatus: state.submissionStatus,
+ submissionId: state.submissionId,
+ parentOrigin: state.parentOrigin,
+ parentOriginIsOpaque: state.parentOriginIsOpaque,
+ expectedParentOrigin: state.expectedParentOrigin,
+ windowSessionToken: state.windowSessionToken,
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: normalizeMarkedQuestions(state.markedQuestions),
suiteSequence: Array.isArray(state.suite?.sequence)
? state.suite.sequence.map((entry) => ({ ...entry }))
: [],
@@ -4135,7 +5563,7 @@
if (!state.readOnly || canResetSubmittedSingle) {
setSubmitLabel(dom.submitBtn.dataset.defaultLabel || 'Submit');
}
- dom.submitBtn.disabled = state.readOnly;
+ dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting';
}
if (dom.resetBtn) {
dom.resetBtn.style.display = '';
@@ -4156,7 +5584,7 @@
dom.submitBtn.style.display = ctx.isLast ? '' : 'none';
dom.submitBtn.setAttribute('type', 'button');
setSubmitLabel('Submit');
- dom.submitBtn.disabled = state.readOnly;
+ dom.submitBtn.disabled = state.readOnly || state.submissionStatus === 'submitting';
}
}
@@ -4229,8 +5657,13 @@
}
function resetToAnsweringPresentation() {
+ clearSubmissionAckTimer();
state.lastResults = null;
state.submitted = false;
+ state.submissionStatus = 'draft';
+ state.submissionId = '';
+ state.pendingSubmissionPresentation = null;
+ state.submittedRecordId = '';
state.readOnly = false;
state.timerLocked = false;
state.timerExpired = false;
@@ -4292,6 +5725,8 @@
syncPrimaryActionButtons();
} else {
state.reviewMode = true;
+ // 进入 review 视图后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。
+ state.submittedRecordId = '';
if (data.readOnly !== false) {
enterSubmittedReadOnlyState('stationary-review');
} else {
@@ -4302,6 +5737,7 @@
async function applyReplayRecord(data = {}) {
const entry = data.entry && typeof data.entry === 'object' ? data.entry : data;
+ const replayData = entry.realData && typeof entry.realData === 'object' ? entry.realData : {};
const entryExamId = entry && entry.examId != null ? String(entry.examId).trim() : '';
const currentExamId = state.examId != null ? String(state.examId).trim() : '';
if (entryExamId && currentExamId && entryExamId !== currentExamId) {
@@ -4314,7 +5750,10 @@
? entry.markedQuestions
: (Array.isArray(entry.metadata && entry.metadata.markedQuestions)
? entry.metadata.markedQuestions
- : []));
+ : (Array.isArray(replayData.markedQuestions) ? replayData.markedQuestions : [])));
+ state.reviewRecordId = String(data.recordId || entry.id || '').trim();
+ // 进入 review 回放后,单篇 submitted 回传的 recordId 已不再适用,清空避免误用。
+ state.submittedRecordId = '';
if (data.reviewSessionId) {
state.reviewSessionId = data.reviewSessionId;
}
@@ -4324,8 +5763,16 @@
state.reviewMode = true;
state.reviewViewMode = 'review';
applyReplayAnswersToDom(replayResults.answers || {});
- const replayHighlights = Array.isArray(entry.highlights) ? entry.highlights : [];
+ const replayHighlights = Array.isArray(entry.highlights)
+ ? entry.highlights
+ : (Array.isArray(replayData.highlights) ? replayData.highlights : []);
applyHighlights(replayHighlights);
+ setNotes(
+ Array.isArray(entry.notes) ? entry.notes : replayData.notes,
+ Array.isArray(entry.noteOutlines) ? entry.noteOutlines : replayData.noteOutlines,
+ { legacyText: typeof entry.noteText === 'string' ? entry.noteText : replayData.noteText }
+ );
+ state.markedQuestions = normalizeMarkedQuestions(replayMarks);
enhanceReviewHighlights();
if (Number.isFinite(Number(entry.scrollY))) {
global.scrollTo(0, Number(entry.scrollY));
@@ -4334,6 +5781,9 @@
renderResults(replayResults);
await renderExplanations();
applyHighlights(replayHighlights);
+ refreshNoteHighlightAttributes();
+ restoreMissingNoteAnchors();
+ applyMemorizeLocatorHighlights();
enhanceReviewHighlights();
updateNavStatuses(replayResults);
if (data.readOnly !== false) {
@@ -4432,9 +5882,6 @@
function adoptWindowSessionMessage(data = {}, sourceWindow = null) {
const incomingToken = normalizeWindowSessionToken(data && data.windowSessionToken);
const incomingIssuedAtMs = readMessageIssuedAtMs(data);
- if (sourceWindow) {
- state.parentWindow = sourceWindow;
- }
if (incomingToken) {
state.windowSessionToken = incomingToken;
}
@@ -4445,25 +5892,77 @@
}
}
- function postMessage(type, payload) {
- const envelope = buildEnvelope(type, payload);
- const candidates = [global.opener, state.parentWindow, global.parent];
- const visited = new Set();
- for (let index = 0; index < candidates.length; index += 1) {
- const target = candidates[index];
- if (!target || target === global || visited.has(target)) {
- continue;
+ function acceptHostInitMessage(event, envelope, data = {}) {
+ if (!state.parentWindow || !event || event.source !== state.parentWindow) return false;
+ if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false;
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ const declaredOrigin = typeof data.parentOrigin === 'string' ? data.parentOrigin : '';
+ const incomingToken = normalizeWindowSessionToken(data.windowSessionToken);
+ if (!incomingToken) return false;
+ // "file://" is not a usable postMessage target/origin pin. Treat it the same
+ // as an unbound referrer so file:// hosts can bind via opaque "null".
+ const expectedParentOrigin = state.expectedParentOrigin
+ && state.expectedParentOrigin !== 'file://'
+ && !String(state.expectedParentOrigin).startsWith('file:')
+ ? state.expectedParentOrigin
+ : '';
+ if (expectedParentOrigin) {
+ if (incomingOrigin !== expectedParentOrigin || declaredOrigin !== expectedParentOrigin) {
+ return false;
}
- visited.add(target);
- try {
- target.postMessage(envelope, '*');
- state.parentWindow = target;
- return true;
- } catch (_) {
- // try next candidate
+ state.parentOrigin = expectedParentOrigin;
+ state.parentOriginIsOpaque = false;
+ } else if (global.location.protocol === 'file:') {
+ // File pages can report either opaque "null" or "file://" for iframe
+ // messages across Chromium platforms; never accept a web origin here.
+ const trustedFileOrigin = (incomingOrigin === 'null' || incomingOrigin === 'file://')
+ && (declaredOrigin === 'null' || declaredOrigin === '' || declaredOrigin === 'file://');
+ if (!trustedFileOrigin) {
+ return false;
}
+ state.parentOrigin = 'null';
+ state.parentOriginIsOpaque = true;
+ } else {
+ const trustedWebOrigin = Boolean(incomingOrigin)
+ && incomingOrigin !== 'null'
+ && incomingOrigin !== 'file://'
+ && declaredOrigin === incomingOrigin;
+ if (!trustedWebOrigin) {
+ return false;
+ }
+ state.parentOrigin = incomingOrigin;
+ state.parentOriginIsOpaque = false;
+ }
+ return true;
+ }
+
+ function isTrustedHostMessage(event, envelope, data = {}) {
+ if (!state.parentWindow || !event || event.source !== state.parentWindow) return false;
+ if (!envelope || envelope.source !== HOST_MESSAGE_SOURCE) return false;
+ const incomingOrigin = typeof event.origin === 'string' ? event.origin : '';
+ if (state.parentOriginIsOpaque) {
+ if (incomingOrigin !== 'null' && incomingOrigin !== 'file://') return false;
+ } else if (!state.parentOrigin || incomingOrigin !== state.parentOrigin) {
+ return false;
+ }
+ const expectedToken = normalizeWindowSessionToken(state.windowSessionToken);
+ const incomingToken = normalizeWindowSessionToken(data.windowSessionToken);
+ return Boolean(expectedToken && incomingToken && expectedToken === incomingToken);
+ }
+
+ function postMessage(type, payload) {
+ const envelope = buildEnvelope(type, payload);
+ const target = state.parentWindow;
+ if (!target || target === global || typeof target.postMessage !== 'function') return false;
+ const targetOrigin = state.parentOrigin && state.parentOrigin !== 'null'
+ ? state.parentOrigin
+ : (state.expectedParentOrigin || (global.location.protocol === 'file:' ? '*' : ''));
+ if (!targetOrigin) return false;
+ try {
+ return target.postMessage(envelope, targetOrigin) !== false;
+ } catch (_) {
+ return false;
}
- return false;
}
function stopInitLoop() {
@@ -4505,7 +6004,8 @@
suiteTimerAnchorMs: Number.isFinite(Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs))) ? Number(data && (data.suiteTimerAnchorMs ?? data.globalTimerAnchorMs)) : null,
suiteTimerMode: data && typeof data.suiteTimerMode === 'string' ? data.suiteTimerMode.trim().toLowerCase() : '',
suiteTimerLimitSeconds: parseOptionalNonNegativeInteger(data && data.suiteTimerLimitSeconds),
- globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null
+ globalTimerAnchorMs: Number.isFinite(Number(data && data.globalTimerAnchorMs)) ? Number(data.globalTimerAnchorMs) : null,
+ draftFingerprint: buildDraftFingerprint(data && data.draft)
});
}
@@ -4531,6 +6031,10 @@
}
function restartInitHandshake() {
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'draft';
+ state.submissionId = '';
+ state.pendingSubmissionPresentation = null;
state.sessionId = null;
state.sessionReadySent = false;
state.lastInitSignature = '';
@@ -4582,13 +6086,13 @@
}, 500);
}
- function getSimulationDraftStorageKey() {
+ function getSimulationDraftSessionName() {
const suiteSessionId = state.suiteSessionId ? String(state.suiteSessionId).trim() : '';
const examId = state.examId ? String(state.examId).trim() : '';
if (!suiteSessionId || !examId) {
return '';
}
- return `ielts_sim_draft::${suiteSessionId}::${examId}`;
+ return `simulation-draft:${suiteSessionId}:${examId}`;
}
function cloneDraftSafely(draft) {
@@ -4602,6 +6106,9 @@
answers: draft.answers && typeof draft.answers === 'object' ? { ...draft.answers } : {},
highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [],
noteText: typeof draft.noteText === 'string' ? draft.noteText : '',
+ notes: normalizeNotes(draft.notes),
+ noteOutlines: normalizeNoteOutlines(draft.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(draft.markedQuestions),
scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0
};
}
@@ -4612,6 +6119,11 @@
return '';
}
try {
+ // updatedAt 每次调用都会刷新(Date.now()),若纳入指纹会让周期性比对永远不相等,
+ // 导致空闲时每 1.5s 都会重复 POST/持久化草稿。只用稳定内容计算指纹。
+ if ('updatedAt' in draft) {
+ return JSON.stringify(Object.assign({}, draft, { updatedAt: null }));
+ }
return JSON.stringify(draft);
} catch (_) {
return '';
@@ -4619,29 +6131,27 @@
}
function persistSimulationDraftMirror(draft) {
- const key = getSimulationDraftStorageKey();
- if (!key || !global.sessionStorage || !draft) {
+ const name = getSimulationDraftSessionName();
+ if (!name || !global.AppData?.recovery?.windowSession || !draft) {
return;
}
try {
- global.sessionStorage.setItem(key, JSON.stringify({
+ global.AppData.recovery.windowSession.save(name, {
draft,
updatedAt: Date.now()
- }));
+ });
} catch (_) {
// ignore sessionStorage failures in restricted environments
}
}
function restoreSimulationDraftMirror() {
- const key = getSimulationDraftStorageKey();
- if (!key || !global.sessionStorage) {
+ const name = getSimulationDraftSessionName();
+ if (!name || !global.AppData?.recovery?.windowSession) {
return null;
}
try {
- const raw = global.sessionStorage.getItem(key);
- if (!raw) return null;
- const parsed = JSON.parse(raw);
+ const parsed = global.AppData.recovery.windowSession.get(name);
if (!parsed || typeof parsed !== 'object') {
return null;
}
@@ -4654,12 +6164,12 @@
}
function clearSimulationDraftMirror() {
- const key = getSimulationDraftStorageKey();
- if (!key || !global.sessionStorage) {
+ const name = getSimulationDraftSessionName();
+ if (!name || !global.AppData?.recovery?.windowSession) {
return;
}
try {
- global.sessionStorage.removeItem(key);
+ global.AppData.recovery.windowSession.discard(name);
} catch (_) {
// ignore sessionStorage failures in restricted environments
}
@@ -4679,13 +6189,94 @@
answers,
highlights: collectHighlights(),
noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
scrollY: global.scrollY || 0,
updatedAt
};
}
+ function canSyncReadingDraft() {
+ return Boolean(
+ !state.simulationMode
+ && !state.reviewMode
+ && !state.readOnly
+ && !state.timerLocked
+ && !state.submitted
+ && !state.memorizeMode
+ && state.examId
+ && state.sessionId
+ && state.windowSessionToken
+ );
+ }
+
+ function syncReadingDraftSnapshot(reason = 'periodic') {
+ if (!canSyncReadingDraft()) {
+ return;
+ }
+ const draft = collectCurrentDraft();
+ const fingerprint = buildDraftFingerprint(draft);
+ if (reason === 'periodic' && fingerprint && fingerprint === state.readingDraftFingerprint) {
+ return;
+ }
+ state.readingDraftFingerprint = fingerprint;
+ const mirroredDraft = cloneDraftSafely(draft);
+ if (!mirroredDraft) {
+ return;
+ }
+ postMessage('READING_DRAFT_SYNC', {
+ examId: state.examId,
+ sessionId: state.sessionId || null,
+ windowSessionToken: state.windowSessionToken || null,
+ draft: mirroredDraft,
+ draftUpdatedAt: Number.isFinite(Number(mirroredDraft.updatedAt)) ? Number(mirroredDraft.updatedAt) : Date.now(),
+ elapsed: getPageElapsedSeconds(),
+ reason
+ });
+ }
+
+ function stopReadingDraftSync() {
+ if (state.readingDraftSyncTimer) {
+ clearInterval(state.readingDraftSyncTimer);
+ state.readingDraftSyncTimer = null;
+ }
+ }
+
+ function refreshReadingDraftSyncLifecycle() {
+ if (!canSyncReadingDraft()) {
+ stopReadingDraftSync();
+ return;
+ }
+ if (!state.readingDraftSyncTimer) {
+ state.readingDraftSyncTimer = setInterval(() => {
+ syncReadingDraftSnapshot('periodic');
+ }, READING_DRAFT_SYNC_MS);
+ }
+ syncReadingDraftSnapshot('activate');
+ }
+
+ function flushReadingDraftOnLifecycle(reason = 'pagehide') {
+ if (canSyncReadingDraft()) {
+ syncReadingDraftSnapshot(reason);
+ return;
+ }
+ // 草稿同步在 submitted/只读态被跳过;但单篇 final-submit 后若宿主已回传
+ // submittedRecordId,结果页笔记改动仍需要落库——这里同步触发一次标注同步,
+ // 防止页面在 450ms 防抖触发前关闭/隐藏而丢失 READING_ANNOTATION_SYNC。
+ if (state.submitted && state.submittedRecordId && !state.memorizeMode && !state.reviewMode) {
+ syncReadingAnnotation(reason);
+ }
+ }
+
function syncSimulationDraftSnapshot(reason = 'periodic') {
- if (!state.simulationMode || state.readOnly || !state.suiteSessionId) {
+ if (state.timerLocked) return;
+ const isSuiteReviewAnnotation = Boolean(
+ state.suiteReviewMode
+ && state.reviewMode
+ && state.suiteSessionId
+ );
+ if (!state.simulationMode || (state.readOnly && !isSuiteReviewAnnotation) || !state.suiteSessionId) {
return;
}
const draft = state.suite?.inline
@@ -4843,8 +6434,10 @@
if (Array.isArray(draft.highlights)) {
applyHighlights(draft.highlights);
}
- if (typeof draft.noteText === 'string') {
- setNotesText(draft.noteText);
+ setNotes(draft.notes, draft.noteOutlines, { legacyText: draft.noteText });
+ state.markedQuestions = normalizeMarkedQuestions(draft.markedQuestions);
+ if (typeof global.setPracticeMarkedQuestions === 'function') {
+ try { global.setPracticeMarkedQuestions(state.markedQuestions); } catch (_) { /* ignore */ }
}
if (typeof draft.scrollY === 'number') {
global.scrollTo(0, draft.scrollY);
@@ -4861,6 +6454,7 @@
if (!shared) {
return [];
}
+ ensureNoteAnchorsBeforeSnapshot();
return shared.snapshotHighlights({
left: dom.left,
groups: dom.groups
@@ -4899,6 +6493,9 @@
answers: results.answers || {},
highlights: collectHighlights(),
noteText: getNotesText(),
+ notes: collectNotes(),
+ noteOutlines: collectNoteOutlines(),
+ markedQuestions: getCurrentMarkedQuestions(),
scrollY: global.scrollY || 0,
elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0),
timerSnapshot,
@@ -4976,6 +6573,9 @@
questionTypePerformance: results.questionTypePerformance || {},
highlights: Array.isArray(draft.highlights) ? draft.highlights.slice() : [],
noteText: typeof draft.noteText === 'string' ? draft.noteText : '',
+ notes: normalizeNotes(draft.notes),
+ noteOutlines: normalizeNoteOutlines(draft.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(draft.markedQuestions),
scrollY: Number.isFinite(Number(draft.scrollY)) ? Number(draft.scrollY) : 0,
updatedAt: Number.isFinite(Number(draft.updatedAt)) ? Number(draft.updatedAt) : Date.now()
});
@@ -5009,6 +6609,9 @@
scoreInfo,
highlights: [],
noteText: '',
+ notes: [],
+ noteOutlines: [],
+ markedQuestions: [],
scrollY: global.scrollY || 0,
elapsed: Math.max(0, Number(timerSnapshot.durationSeconds) || 0),
timerSnapshot,
@@ -5021,6 +6624,7 @@
input.checked = false;
});
document.querySelectorAll('input[type="text"], textarea').forEach((input) => {
+ if (input.closest('#notes-panel, #reading-note-editor, #reading-note-drawer')) return;
input.value = '';
});
document.querySelectorAll('select').forEach((select) => {
@@ -5060,6 +6664,9 @@
answers: snapshot.answers || {},
highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [],
noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '',
+ notes: normalizeNotes(snapshot.notes),
+ noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions),
scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0,
updatedAt: Number.isFinite(Number(snapshot.updatedAt)) ? Number(snapshot.updatedAt) : Date.now()
},
@@ -5068,6 +6675,9 @@
answers: snapshot.answers || {},
highlights: Array.isArray(snapshot.highlights) ? snapshot.highlights : [],
noteText: typeof snapshot.noteText === 'string' ? snapshot.noteText : '',
+ notes: normalizeNotes(snapshot.notes),
+ noteOutlines: normalizeNoteOutlines(snapshot.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(snapshot.markedQuestions),
scrollY: Number.isFinite(Number(snapshot.scrollY)) ? Number(snapshot.scrollY) : 0,
elapsed: Number.isFinite(Number(snapshot.elapsed)) ? Number(snapshot.elapsed) : getPageElapsedSeconds(),
timerSnapshot: snapshot.timerSnapshot || getPracticeTimerSnapshot()
@@ -5085,7 +6695,7 @@
handleExitClick();
return;
}
- if (state.readOnly) {
+ if (state.readOnly || state.submissionStatus !== 'draft') {
return;
}
const submissionSnapshot = state.suite?.inline
@@ -5100,15 +6710,12 @@
? (Array.isArray(activeSlot?.draft?.highlights) ? activeSlot.draft.highlights : [])
: (Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : []);
const postedResults = submissionSnapshot.results || results;
- state.lastResults = results;
if (activeSlot) {
activeSlot.lastResults = results;
}
- renderResults(results);
- enterSubmittedReadOnlyState(state.simulationMode ? 'simulation-final-submit' : 'final-submit');
const messageType = state.simulationMode ? 'SIMULATION_SUBMIT' : 'PRACTICE_COMPLETE';
const timing = resolvePracticeTiming(1, submissionSnapshot.timerSnapshot);
- postMessage(messageType, Object.assign({
+ beginSubmission(messageType, Object.assign({
duration: timing.duration,
startTime: new Date(timing.startTimeMs).toISOString(),
endTime: new Date(timing.endTimeMs).toISOString(),
@@ -5128,25 +6735,22 @@
dataKey: state.dataKey,
markedQuestions: (typeof global.getPracticeMarkedQuestions === 'function')
? global.getPracticeMarkedQuestions()
- : []
+ : normalizeMarkedQuestions(submissionSnapshot.markedQuestions)
},
answers: submissionSnapshot.answers || {},
highlights: Array.isArray(submissionSnapshot.highlights) ? submissionSnapshot.highlights : [],
noteText: typeof submissionSnapshot.noteText === 'string' ? submissionSnapshot.noteText : '',
+ notes: normalizeNotes(submissionSnapshot.notes),
+ noteOutlines: normalizeNoteOutlines(submissionSnapshot.noteOutlines),
+ markedQuestions: normalizeMarkedQuestions(submissionSnapshot.markedQuestions),
scrollY: Number.isFinite(Number(submissionSnapshot.scrollY)) ? Number(submissionSnapshot.scrollY) : 0
}, state.suite?.inline ? {
suiteSubmission: true,
suiteEntries: Array.isArray(submissionSnapshot.suiteEntries) ? submissionSnapshot.suiteEntries : []
- } : {}, postedResults));
- await renderExplanations();
- applyHighlights(highlightSnapshot);
- enhanceReviewHighlights();
- updateNavStatuses(results);
- if (state.simulationMode && state.simulationCtx && state.simulationCtx.isLast) {
- stopSimulationDraftSync();
- clearSimulationDraftMirror();
- state.simulationDraftFingerprint = '';
- }
+ } : {}, postedResults), {
+ results,
+ highlights: highlightSnapshot
+ });
}
function handleReset() {
@@ -5157,6 +6761,7 @@
if (state.submitted && state.readOnlyReason === 'final-submit' && !state.suiteSessionId && !state.reviewMode) {
resetToAnsweringPresentation();
clearCurrentAnswers();
+ clearStructuredNotesForReset();
requestNormalPracticeRestart('retake-after-submit');
return;
}
@@ -5165,6 +6770,7 @@
}
closeReviewHighlightDictionary();
clearCurrentAnswers();
+ clearStructuredNotesForReset();
if (dom.results) {
dom.results.style.display = 'none';
dom.results.innerHTML = '';
@@ -5182,7 +6788,7 @@
const opener = global.opener && !global.opener.closed ? global.opener : null;
if (hasEndlessMarker && opener) {
try {
- opener.postMessage({ type: 'ENDLESS_USER_EXIT' }, '*');
+ postMessage('ENDLESS_USER_EXIT', {});
if (typeof opener.stopEndlessPractice === 'function') {
opener.stopEndlessPractice();
} else if (opener.AppActions && typeof opener.AppActions.stopEndlessPractice === 'function') {
@@ -5283,6 +6889,9 @@
const data = payload.data || {};
const sourceWindow = event && typeof event === 'object' ? (event.source || null) : null;
if (type === 'INIT_SESSION' || type === 'INIT_EXAM_SESSION') {
+ if (!acceptHostInitMessage(event, payload, data)) {
+ return;
+ }
if (!shouldAcceptWindowSessionMessage(data, sourceWindow)) {
return;
}
@@ -5309,6 +6918,12 @@
if (incomingExamId && !currentExamId) {
state.examId = incomingExamId;
}
+ if (data.sessionId && state.sessionId && String(data.sessionId) !== String(state.sessionId)) {
+ clearSubmissionAckTimer();
+ state.submissionStatus = 'draft';
+ state.submissionId = '';
+ state.pendingSubmissionPresentation = null;
+ }
if (data.sessionId) {
state.sessionId = data.sessionId;
}
@@ -5386,14 +7001,28 @@
}
if (data.reviewMode) {
state.reviewMode = true;
+ // init 中的 review 模式同样不应沿用单篇 submitted 回传的 recordId。
+ state.submittedRecordId = '';
if (data.readOnly !== false) {
enterSubmittedReadOnlyState('stationary-review');
} else {
setReadOnlyMode(false);
}
}
+ const singleDraft = !state.simulationMode
+ && !state.reviewMode
+ && data
+ && data.draft
+ && typeof data.draft === 'object'
+ ? data.draft
+ : null;
+ if (singleDraft) {
+ applyDraftToDom(singleDraft);
+ state.readingDraftFingerprint = buildDraftFingerprint(singleDraft);
+ }
syncPrimaryActionButtons();
refreshSimulationDraftSyncLifecycle();
+ refreshReadingDraftSyncLifecycle();
syncSuiteModeState();
stopInitLoop();
state.lastInitSignature = initSignature;
@@ -5403,6 +7032,9 @@
sendSessionReady();
return;
}
+ if (!isTrustedHostMessage(event, payload, data)) {
+ return;
+ }
if (type === 'REPLAY_PRACTICE_RECORD') {
const replaySignature = buildReplaySignature(data || {});
if (replaySignature && replaySignature === state.lastReplaySignature) {
@@ -5416,6 +7048,40 @@
applyReviewContext(data || {});
return;
}
+ if (type === 'PRACTICE_SUBMIT_ACK') {
+ await acceptSubmissionAcknowledgement(data || {});
+ return;
+ }
+ if (type === 'PRACTICE_SUBMIT_FAILED') {
+ if (matchesPendingSubmission(data || {})) {
+ restoreDraftSubmissionState(String(data.submissionId || ''));
+ }
+ return;
+ }
+ if (type === 'VOCAB_HIGHLIGHT_SAVE_ACK' || type === 'VOCAB_HIGHLIGHT_SAVE_FAILED') {
+ const dictionary = getReviewHighlightDictionary();
+ if (dictionary && typeof dictionary.handleSaveOutcome === 'function') {
+ dictionary.handleSaveOutcome(data || {}, type === 'VOCAB_HIGHLIGHT_SAVE_ACK');
+ }
+ return;
+ }
+ if (type === 'PRACTICE_RECORD_SAVED') {
+ // 宿主在单篇阅读 final-submit 落库成功后回传已存档 recordId,
+ // 用于支持结果页笔记改动的持久化(syncReadingAnnotation 的 submitted 分支)。
+ const payloadExamId = data && data.examId != null ? String(data.examId).trim() : '';
+ const currentExamId = state.examId != null ? String(state.examId).trim() : '';
+ if (payloadExamId && currentExamId && payloadExamId !== currentExamId && !state.suite?.inline) {
+ return;
+ }
+ const payloadSessionId = data && data.sessionId != null ? String(data.sessionId).trim() : '';
+ const currentSessionId = state.sessionId != null ? String(state.sessionId).trim() : '';
+ if (!payloadSessionId || !currentSessionId || payloadSessionId !== currentSessionId) {
+ return;
+ }
+ const recordId = data && data.recordId != null ? String(data.recordId).trim() : '';
+ state.submittedRecordId = recordId;
+ return;
+ }
if (type === 'SUITE_NAVIGATE' && data.url) {
const targetSuiteSessionId = typeof data.suiteSessionId === 'string' ? data.suiteSessionId.trim() : '';
const currentSuiteSessionId = typeof state.suiteSessionId === 'string' ? state.suiteSessionId.trim() : '';
@@ -5572,6 +7238,30 @@
global.addEventListener('message', handleIncoming);
}
+ function attachReadingDraftLifecycleHooks() {
+ const flush = (reason) => {
+ try {
+ // 先把编辑器里未提交的笔记立刻刷出:review 页面 flushReadingDraftOnLifecycle
+ // 会因 canSyncReadingDraft 直接 no-op,笔记只能靠 450ms 防抖提交,页面在
+ // 防抖触发前关闭/隐藏就会丢失 READING_ANNOTATION_SYNC。这里同步触发一次,
+ // review 路径在同步里发出最新的 note,正常阅读路径则继续走 draft 快照。
+ if (typeof flushActiveNoteFromEditor === 'function') {
+ flushActiveNoteFromEditor();
+ }
+ flushReadingDraftOnLifecycle(reason);
+ } catch (_) {
+ // ignore draft flush failures during teardown
+ }
+ };
+ global.addEventListener('pagehide', () => flush('pagehide'));
+ global.addEventListener('beforeunload', () => flush('beforeunload'));
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') {
+ flush('visibilitychange');
+ }
+ });
+ }
+
function attachPracticeTimerBridge() {
global.addEventListener(PRACTICE_TIMER_EVENT, (event) => {
const detail = event && event.detail && typeof event.detail === 'object'
@@ -5585,6 +7275,8 @@
}
async function bootstrap() {
+ await loadReadingCandidateCodePreferences();
+ if (global.PracticeTimerPreferences?.ready) await global.PracticeTimerPreferences.ready;
parseQuery();
captureDom();
const dataset = await ensureDataset();
@@ -5597,31 +7289,19 @@
attachDragDrop();
attachPaneResizer();
- // Ensure drag items can return home when replaced or discarded
- function initDragPools() {
- document.querySelectorAll('.pool-items').forEach((pool, index) => {
- if (!pool.id) {
- pool.id = `practice-pool-${index}`;
- }
- });
- document.querySelectorAll('.pool-items .drag-item').forEach((item) => {
- if (!item.dataset.originPool) {
- const pool = item.closest('.pool-items');
- if (pool?.id) {
- item.dataset.originPool = pool.id;
- }
- }
- });
- }
initDragPools();
attachUnifiedTimer();
attachUnifiedPanels();
+ ensureReadingNotesUi();
+ ensureReadingDisplayControls();
+ await loadReadingDisplayPreferences();
attachSelectionHighlightToolbar();
attachReviewHighlightDictionary();
attachActionListeners();
attachMessageBridge();
attachPracticeTimerBridge();
+ attachReadingDraftLifecycleHooks();
syncSuiteModeState();
setExitButtonVisible(false);
if (state.memorizeMode) {
@@ -5629,6 +7309,7 @@
}
updateNavStatuses();
refreshSimulationDraftSyncLifecycle();
+ refreshReadingDraftSyncLifecycle();
startInitLoop();
}
diff --git a/js/services/achievementManager.js b/js/services/achievementManager.js
index fb089be2..010a31b0 100644
--- a/js/services/achievementManager.js
+++ b/js/services/achievementManager.js
@@ -1,33 +1,99 @@
(function (window) {
'use strict';
+ /**
+ * Presentation catalog + notifier for achievements.
+ *
+ * Unlock rules and persistence belong entirely to the `achievements.progress`
+ * projector (js/data/v2/appData.js -> computeAchievementProgress). That projector
+ * is declared `derived` in the data catalog, is listed in `derivedPending` for every
+ * practice mutation, and records the historically accurate unlock timestamp for each
+ * achievement id.
+ *
+ * This class therefore owns only display metadata (title / description / icon / tier)
+ * and diffs successive projector reads so that newly unlocked achievements can be
+ * surfaced as notifications. It deliberately does NOT re-derive unlock conditions:
+ * a second rule engine here would drift from the projector (it previously did, which
+ * left every streak achievement permanently locked) and would stamp "unlocked now"
+ * instead of the real unlock time.
+ */
class AchievementManager {
constructor() {
- this.storageKey = 'user_achievements';
this.achievements = this._defineAchievements();
+ this.achievementIds = new Set(this.achievements.map((item) => item.id));
this.listeners = [];
this.initialized = false;
+ // Newest read — what the achievements modal renders.
this.unlocked = {};
+ // Last read whose projector provenance was proven — what the unlock diff measures
+ // against. Deliberately separate from `unlocked`: see syncFromAppData.
+ this.baseline = {};
+ this.baselineFresh = false;
+ this._deliveryInitialized = false;
+ this._pendingDelivery = {};
+ this._initPromise = null;
+ this._syncTail = Promise.resolve();
}
/**
- * Initialize the manager, loading state from storage
+ * Initialize the manager, loading persisted progress from storage.
+ *
+ * The first run seeds a durable delivery baseline so existing users are not greeted with
+ * every historical unlock. Later runs diff against that persisted acknowledgement instead
+ * of the first projector read, which lets a pending unlock survive a page restart.
*/
async init() {
if (this.initialized) return;
+ if (this._initPromise) return this._initPromise;
+ this._initPromise = this._enqueueSync(() => this._initialize()).finally(() => {
+ this._initPromise = null;
+ });
+ return this._initPromise;
+ }
+
+ async _initialize() {
try {
- this.unlocked = await this._loadUnlockedState();
+ let [state, delivery] = await Promise.all([
+ this._loadUnlockedState(),
+ this._loadDeliveryState()
+ ]);
+ state = await this._retryUntilFresh(state);
+ this.unlocked = state.unlocked;
+ if (delivery) {
+ this.baseline = delivery.acknowledged;
+ this.baselineFresh = true;
+ this._deliveryInitialized = true;
+ } else {
+ this.baseline = state.unlocked;
+ this.baselineFresh = state.fresh;
+ // A brand-new store has no projector provenance yet, but its empty snapshot is
+ // still a safe delivery baseline: there is no historical unlock to suppress.
+ if (state.fresh || Object.keys(state.unlocked).length === 0) {
+ await this._persistDeliveryBaseline(state.unlocked);
+ this._deliveryInitialized = true;
+ }
+ }
console.log('[AchievementManager] Initialized. Unlocked:', Object.keys(this.unlocked).length);
this.initialized = true;
+
+ if (delivery) {
+ await this._syncFromAppDataNow({ notify: true, initialState: state });
+ }
} catch (e) {
console.error('[AchievementManager] Init failed', e);
this.unlocked = {};
+ this.baseline = {};
+ this.baselineFresh = false;
+ this._deliveryInitialized = false;
+ this.initialized = false;
+ throw e;
}
}
/**
- * Define the list of available achievements
+ * Display metadata for every achievement the projector can unlock.
+ * Ids must stay in sync with computeAchievementProgress in js/data/v2/appData.js.
*/
_defineAchievements() {
return [
@@ -37,32 +103,28 @@
title: '初出茅庐',
description: '累计完成 10 次练习',
icon: '🥉',
- tier: 1,
- condition: (stats) => stats.totalPracticed >= 10
+ tier: 1
},
{
id: 'practice_silver',
title: '渐入佳境',
description: '累计完成 50 次练习',
icon: '🥈',
- tier: 2,
- condition: (stats) => stats.totalPracticed >= 50
+ tier: 2
},
{
id: 'practice_gold',
title: '百炼成钢',
description: '累计完成 100 次练习',
icon: '🥇',
- tier: 3,
- condition: (stats) => stats.totalPracticed >= 100
+ tier: 3
},
{
id: 'practice_platinum',
title: '千锤百炼',
description: '累计完成 200 次练习',
icon: '🏅',
- tier: 3,
- condition: (stats) => stats.totalPracticed >= 200
+ tier: 3
},
// --- Streak Milestones ---
@@ -71,32 +133,28 @@
title: '持之以恒',
description: '连续学习 3 天',
icon: '🔥',
- tier: 1,
- condition: (stats) => stats.streakDays >= 3
+ tier: 1
},
{
id: 'streak_silver',
title: '习惯养成',
description: '连续学习 7 天',
icon: '🔥',
- tier: 2,
- condition: (stats) => stats.streakDays >= 7
+ tier: 2
},
{
id: 'streak_gold',
title: '意志如铁',
description: '连续学习 30 天',
icon: '🔥',
- tier: 3,
- condition: (stats) => stats.streakDays >= 30
+ tier: 3
},
{
id: 'streak_platinum',
title: '长期主义',
description: '连续学习 60 天',
icon: '🗓️',
- tier: 3,
- condition: (stats) => stats.streakDays >= 60
+ tier: 3
},
// --- Category Mastery: Listening ---
@@ -105,32 +163,28 @@
title: '开耳第一篇',
description: '完成 1 篇听力练习',
icon: '🎧',
- tier: 1,
- condition: (stats) => stats.listeningCount >= 1
+ tier: 1
},
{
id: 'listening_bronze',
title: '顺风耳 (铜)',
description: '累计完成 10 篇听力练习',
icon: '👂',
- tier: 1,
- condition: (stats) => stats.listeningCount >= 10
+ tier: 1
},
{
id: 'listening_silver',
title: '顺风耳 (银)',
description: '累计完成 50 篇听力练习',
icon: '👂',
- tier: 2,
- condition: (stats) => stats.listeningCount >= 50
+ tier: 2
},
{
id: 'listening_gold',
title: '顺风耳 (金)',
description: '累计完成 100 篇听力练习',
icon: '👂',
- tier: 3,
- condition: (stats) => stats.listeningCount >= 100
+ tier: 3
},
// --- Category Mastery: Reading ---
@@ -139,32 +193,28 @@
title: '开卷第一篇',
description: '完成 1 篇阅读练习',
icon: '📖',
- tier: 1,
- condition: (stats) => stats.readingCount >= 1
+ tier: 1
},
{
id: 'reading_bronze',
title: '火眼金睛 (铜)',
description: '累计完成 10 篇阅读练习',
icon: '👁️',
- tier: 1,
- condition: (stats) => stats.readingCount >= 10
+ tier: 1
},
{
id: 'reading_silver',
title: '火眼金睛 (银)',
description: '累计完成 50 篇阅读练习',
icon: '👁️',
- tier: 2,
- condition: (stats) => stats.readingCount >= 50
+ tier: 2
},
{
id: 'reading_gold',
title: '火眼金睛 (金)',
description: '累计完成 100 篇阅读练习',
icon: '👁️',
- tier: 3,
- condition: (stats) => stats.readingCount >= 100
+ tier: 3
},
// --- Balanced Practice ---
@@ -173,16 +223,14 @@
title: '双线推进',
description: '阅读与听力各完成 10 篇',
icon: '⚖️',
- tier: 2,
- condition: (stats) => stats.readingCount >= 10 && stats.listeningCount >= 10
+ tier: 2
},
{
id: 'balanced_advanced',
title: '均衡进阶',
description: '阅读与听力各完成 30 篇',
icon: '🧭',
- tier: 3,
- condition: (stats) => stats.readingCount >= 30 && stats.listeningCount >= 30
+ tier: 3
},
// --- Focus Time ---
@@ -191,24 +239,21 @@
title: '专注一小时',
description: '累计学习 60 分钟',
icon: '⏱️',
- tier: 1,
- condition: (stats) => stats.totalStudyMinutes >= 60
+ tier: 1
},
{
id: 'time_focus_300',
title: '沉浸五小时',
description: '累计学习 300 分钟',
icon: '⏳',
- tier: 2,
- condition: (stats) => stats.totalStudyMinutes >= 300
+ tier: 2
},
{
id: 'time_focus_1000',
title: '深度备考',
description: '累计学习 1000 分钟',
icon: '⌛',
- tier: 3,
- condition: (stats) => stats.totalStudyMinutes >= 1000
+ tier: 3
},
// --- Accuracy Milestones ---
@@ -217,48 +262,42 @@
title: '稳中有进',
description: '10 次练习后平均正确率 70%+',
icon: '📈',
- tier: 2,
- condition: (stats) => stats.totalPracticed >= 10 && stats.averageAccuracy >= 0.7
+ tier: 2
},
{
id: 'accuracy_elite',
title: '高分稳定',
description: '20 次练习后平均正确率 85%+',
icon: '💎',
- tier: 3,
- condition: (stats) => stats.totalPracticed >= 20 && stats.averageAccuracy >= 0.85
+ tier: 3
},
{
id: 'perfect_three',
title: '三次满分',
description: '累计 3 次练习获得满分',
icon: '🎯',
- tier: 2,
- condition: (stats) => stats.perfectCount >= 3
+ tier: 2
},
{
id: 'perfect_ten',
title: '十全十美',
description: '累计 10 次练习获得满分',
icon: '🏆',
- tier: 3,
- condition: (stats) => stats.perfectCount >= 10
+ tier: 3
},
{
id: 'speed_three',
title: '快速稳定',
description: '3 次 5 分钟内完成高分练习',
icon: '⚡',
- tier: 2,
- condition: (stats) => stats.speedHighScoreCount >= 3
+ tier: 2
},
{
id: 'speed_ten',
title: '闪电节奏',
description: '10 次 5 分钟内完成高分练习',
icon: '🌩️',
- tier: 3,
- condition: (stats) => stats.speedHighScoreCount >= 10
+ tier: 3
},
// --- Special Achievements ---
@@ -267,383 +306,208 @@
title: '迈出第一步',
description: '完成第一次练习',
icon: '🌱',
- tier: 1,
- condition: (stats) => stats.totalPracticed >= 1
+ tier: 1
},
{
id: 'accuracy_perfect',
title: '神射手',
description: '单次练习获得 100% 正确率',
icon: '🎯',
- tier: 3,
- condition: (stats) => stats.hasPerfectAccuracy
+ tier: 3
},
{
id: 'speed_demon',
title: '唯快不破',
description: '5分钟内完成高分练习',
icon: '⚡',
- tier: 3,
- condition: (stats) => stats.hasSpeedDemon
+ tier: 3
}
];
}
/**
- * Load unlocked state from storage
+ * Read projector-owned unlock progress from storage.
+ *
+ * `AppData.achievements.getAll()` attaches a non-enumerable `fresh` flag: false means the
+ * projector was still pending and the payload is an inline recompute rather than the proven
+ * cache. That distinction is load-bearing for the unlock diff and delivery retry.
*/
async _loadUnlockedState() {
- if (window.storage) {
- return await window.storage.get(this.storageKey, {});
- }
- const raw = localStorage.getItem(this.storageKey);
- return raw ? JSON.parse(raw) : {};
- }
-
- /**
- * Save unlocked state to storage
- */
- async _saveUnlockedState() {
- if (window.storage) {
- await window.storage.set(this.storageKey, this.unlocked);
- return;
- }
- localStorage.setItem(this.storageKey, JSON.stringify(this.unlocked));
- }
-
- _getDefaultUserStats() {
+ const progress = await window.AppData.achievements.getAll();
return {
- totalPractices: 0,
- totalTimeSpent: 0,
- averageScore: 0,
- categoryStats: {},
- questionTypeStats: {},
- streakDays: 0,
- practiceDays: [],
- lastPracticeDate: null,
- achievements: []
+ unlocked: this._normalizeProgress(progress),
+ fresh: !progress || progress.fresh !== false
};
}
- _getPracticeRecorder() {
- const app = window.app;
- if (app && app.components && app.components.practiceRecorder) {
- return app.components.practiceRecorder;
- }
- return null;
- }
-
- async _getUserStatsFromPracticeRecordAPI() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- return await window.PracticeRecordAPI.readStats();
- }
- const recorder = this._getPracticeRecorder();
- if (recorder && typeof recorder.getUserStats === 'function') {
- return await recorder.getUserStats();
+ async _loadDeliveryState() {
+ const settings = await window.AppData.settings.getAll();
+ const delivery = settings && settings.achievementDelivery;
+ if (!delivery || delivery.version !== 1 || !delivery.acknowledged
+ || typeof delivery.acknowledged !== 'object' || Array.isArray(delivery.acknowledged)) {
+ return null;
}
- return this._getDefaultUserStats();
- }
-
- /** @deprecated Use _getUserStatsFromPracticeRecordAPI */
- async _getUserStatsFromScoreStorage() {
- return this._getUserStatsFromPracticeRecordAPI();
+ return {
+ acknowledged: Object.fromEntries(Object.entries(delivery.acknowledged)
+ .filter(([id]) => this.achievementIds.has(id))
+ .map(([id, unlockedAt]) => [id, { unlockedAt: unlockedAt || null }]))
+ };
}
- async _getPracticeRecordsFromPracticeRecordAPI() {
- // 使用轻量 listSummary:achievementManager 只需 type/accuracy/duration 等元数据,
- // 不需要 answers/correctAnswerMap/suiteEntries 等重字段。listSummary 已从 scoreInfo 投影了
- // accuracy/duration/score 等字段,无需依赖 realData.scoreInfo 后备路径。
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.listSummary === 'function') {
- return await window.PracticeRecordAPI.listSummary();
- }
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return await window.PracticeRecordAPI.list();
- }
- const recorder = this._getPracticeRecorder();
- if (recorder && typeof recorder.getPracticeRecords === 'function') {
- return await recorder.getPracticeRecords();
+ async _persistDeliveryBaseline(unlocked) {
+ if (!window.AppData.achievements
+ || typeof window.AppData.achievements.acknowledgeDelivery !== 'function') {
+ throw new Error('AppData.achievements.acknowledgeDelivery is required');
}
- return [];
+ await window.AppData.achievements.acknowledgeDelivery(unlocked);
}
- /** @deprecated Use _getPracticeRecordsFromPracticeRecordAPI */
- async _getPracticeRecordsFromScoreStorage() {
- return this._getPracticeRecordsFromPracticeRecordAPI();
+ _unionBaseline(...sources) {
+ const merged = {};
+ sources.forEach((source) => {
+ Object.entries(source && typeof source === 'object' ? source : {}).forEach(([id, value]) => {
+ if (!this.achievementIds.has(id)) return;
+ const candidate = value && typeof value === 'object' ? value.unlockedAt : value;
+ const candidateTime = typeof candidate === 'string' ? Date.parse(candidate) : NaN;
+ const prior = merged[id] && merged[id].unlockedAt;
+ const priorTime = typeof prior === 'string' ? Date.parse(prior) : NaN;
+ if (!merged[id] || (Number.isFinite(candidateTime)
+ && (!Number.isFinite(priorTime) || candidateTime < priorTime))) {
+ merged[id] = { unlockedAt: Number.isFinite(candidateTime)
+ ? new Date(candidateTime).toISOString()
+ : null };
+ }
+ });
+ });
+ return merged;
}
- _getCategoryPracticeCount(stats, targetKey) {
- if (!stats || !stats.categoryStats || typeof stats.categoryStats !== 'object') {
- return 0;
+ async _retryUntilFresh(initialState) {
+ let state = initialState;
+ if (state.fresh || !window.AppData.achievements
+ || typeof window.AppData.achievements.retryPending !== 'function') {
+ return state;
}
-
- const normalizedTarget = String(targetKey || '').toLowerCase();
- let count = 0;
-
- Object.entries(stats.categoryStats).forEach(([key, value]) => {
- const normalizedKey = String(key || '').toLowerCase();
- if (normalizedKey !== normalizedTarget) {
- return;
+ for (let attempt = 0; attempt < 3 && !state.fresh; attempt += 1) {
+ try {
+ await window.AppData.achievements.retryPending();
+ state = await this._loadUnlockedState();
+ } catch (err) {
+ console.warn('[AchievementManager] Failed to retry pending achievement projection', err);
}
- const practices = value && Number(value.practices);
- if (Number.isFinite(practices)) {
- count += practices;
+ if (!state.fresh && attempt < 2) {
+ await new Promise((resolve) => {
+ const schedule = window.setTimeout || ((callback) => callback());
+ schedule(resolve, 10 * (2 ** attempt));
+ });
}
- });
-
- return count;
- }
-
- _normalizePracticeType(rawType) {
- if (!rawType) {
- return null;
}
-
- const normalized = String(rawType).toLowerCase();
- if (normalized.includes('listen') || normalized.includes('audio') || normalized.includes('hearing')) {
- return 'listening';
- }
- if (normalized.includes('read')) {
- return 'reading';
- }
- return null;
+ return state;
}
- _inferRecordPracticeType(record) {
- if (!record || typeof record !== 'object') {
- return null;
- }
-
- const metadata = record.metadata && typeof record.metadata === 'object'
- ? record.metadata
+ /**
+ * Reduce the projector payload to `{ [id]: { unlockedAt } }` for ids this
+ * catalog can render. Unknown ids (e.g. manual entries for retired achievements)
+ * are dropped because there is no card to show them on.
+ */
+ _normalizeProgress(progress) {
+ const source = progress && typeof progress === 'object' && !Array.isArray(progress)
+ ? progress
: {};
- const candidates = [
- record.type,
- record.practiceType,
- metadata.type,
- metadata.examType,
- metadata.practiceType
- ];
+ const normalized = {};
- for (const candidate of candidates) {
- const normalized = this._normalizePracticeType(candidate);
- if (normalized) {
- return normalized;
+ Object.entries(source).forEach(([id, value]) => {
+ if (!value || id === 'updatedAt' || !this.achievementIds.has(id)) {
+ return;
}
- }
-
- const contextHints = [
- record.examId,
- record.url,
- record.title,
- metadata.url,
- metadata.examId,
- metadata.examTitle,
- metadata.title
- ]
- .filter(Boolean)
- .map((item) => String(item).toLowerCase())
- .join(' ');
-
- if (/listeningpractice|\/listening\/|listen|audio/.test(contextHints)) {
- return 'listening';
- }
- if (/reading|睡着过项目组/.test(contextHints)) {
- return 'reading';
- }
+ const unlockedAt = value && typeof value === 'object' ? value.unlockedAt : null;
+ normalized[id] = { unlockedAt: unlockedAt || null };
+ });
- return null;
+ return normalized;
}
- _normalizeAccuracy(record) {
- if (!record || typeof record !== 'object') {
- return 0;
- }
-
- const scoreInfo = record.scoreInfo && typeof record.scoreInfo === 'object'
- ? record.scoreInfo
- : (record.realData && record.realData.scoreInfo && typeof record.realData.scoreInfo === 'object'
- ? record.realData.scoreInfo
- : {});
-
- const candidates = [
- record.accuracy,
- scoreInfo.accuracy
- ];
-
- for (const candidate of candidates) {
- const value = Number(candidate);
- if (!Number.isFinite(value)) {
- continue;
- }
- if (value > 1 && value <= 100) {
- return value / 100;
- }
- return Math.max(0, Math.min(1, value));
- }
-
- return 0;
+ /**
+ * Re-read projector progress and report achievements unlocked since the last proven read.
+ *
+ * Freshness gates the baseline, not the display. `this.unlocked` always tracks the newest
+ * read so the achievements modal never renders yesterday's state, while `this.baseline` —
+ * the set the unlock diff is measured against — only advances on a read whose provenance the
+ * projector proved. An unproven read that quietly became the baseline would make the next
+ * read see the unlock as "already known" and drop its notification for good, which is the
+ * one failure mode with no recovery path: there is no later event that re-raises it.
+ *
+ * Consequences of that split: an unproven read never notifies (announcing an unlock the
+ * proven projection has not confirmed risks a toast for something that never happened, e.g.
+ * a source snapshot read mid-import), and it never consumes one either — the very next
+ * proven read still sees the unlock as new and raises it exactly once.
+ *
+ * @param {Object} options
+ * @param {boolean} [options.notify] - surface a toast for each new unlock
+ */
+ syncFromAppData(options = {}) {
+ return this._enqueueSync(() => this._syncFromAppDataNow(options));
}
- _getRecordDuration(record) {
- if (!record || typeof record !== 'object') {
- return 0;
- }
-
- const scoreInfo = record.scoreInfo && typeof record.scoreInfo === 'object'
- ? record.scoreInfo
- : (record.realData && record.realData.scoreInfo && typeof record.realData.scoreInfo === 'object'
- ? record.realData.scoreInfo
- : {});
-
- const candidates = [
- record.duration,
- record.realData && record.realData.duration,
- scoreInfo.duration,
- scoreInfo.timeSpent
- ];
-
- for (const candidate of candidates) {
- const value = Number(candidate);
- if (Number.isFinite(value) && value >= 0) {
- return value;
- }
- }
-
- return 0;
+ _enqueueSync(run) {
+ const result = this._syncTail.then(run, run);
+ this._syncTail = result.catch(() => {});
+ return result;
}
- _applyRecordsToDerivedStats(derived, records) {
- if (!derived || !Array.isArray(records) || records.length === 0) {
- return;
- }
-
- let listeningFromRecords = 0;
- let readingFromRecords = 0;
- let totalFromRecords = 0;
- let totalAccuracyFromRecords = 0;
- let accuracyRecordCount = 0;
- let totalDurationFromRecords = 0;
-
- records.forEach((record) => {
- if (!record || typeof record !== 'object') {
- return;
- }
-
- totalFromRecords += 1;
-
- const practiceType = this._inferRecordPracticeType(record);
- if (practiceType === 'listening') {
- listeningFromRecords += 1;
- } else if (practiceType === 'reading') {
- readingFromRecords += 1;
- }
-
- const accuracy = this._normalizeAccuracy(record);
- const duration = this._getRecordDuration(record);
- totalAccuracyFromRecords += accuracy;
- accuracyRecordCount += 1;
- totalDurationFromRecords += duration;
- this._applyRecordToDerivedStats(derived, { accuracy, duration });
- });
+ async _syncFromAppDataNow(options = {}) {
+ const { notify = false } = options;
+ const baseline = this.baseline && typeof this.baseline === 'object' ? this.baseline : {};
- derived.totalPracticed = Math.max(Number(derived.totalPracticed) || 0, totalFromRecords);
- derived.listeningCount = Math.max(Number(derived.listeningCount) || 0, listeningFromRecords);
- derived.readingCount = Math.max(Number(derived.readingCount) || 0, readingFromRecords);
- derived.totalStudyMinutes = Math.max(
- Number(derived.totalStudyMinutes) || 0,
- totalDurationFromRecords / 60
- );
- if (accuracyRecordCount > 0) {
- derived.averageAccuracy = Math.max(
- Number(derived.averageAccuracy) || 0,
- totalAccuracyFromRecords / accuracyRecordCount
- );
+ let state = options.initialState || null;
+ try {
+ if (!state) state = await this._loadUnlockedState();
+ } catch (err) {
+ console.warn('[AchievementManager] Failed to read achievement progress', err);
+ return [];
}
- }
- _buildDerivedStats(rawStats) {
- const stats = rawStats && typeof rawStats === 'object' ? rawStats : {};
- const averageScore = Number(stats.averageScore) || 0;
- return {
- totalPracticed: Number(stats.totalPractices) || 0,
- streakDays: Number(stats.streakDays) || 0,
- totalStudyMinutes: (Number(stats.totalTimeSpent) || 0) / 60,
- averageAccuracy: averageScore > 1 && averageScore <= 100 ? averageScore / 100 : averageScore,
- listeningCount: this._getCategoryPracticeCount(stats, 'listening'),
- readingCount: this._getCategoryPracticeCount(stats, 'reading'),
- hasPerfectAccuracy: false,
- hasSpeedDemon: false,
- perfectCount: 0,
- speedHighScoreCount: 0
- };
- }
+ state = await this._retryUntilFresh(state);
- _applyRecordToDerivedStats(derived, record) {
- if (!derived || !record) {
- return;
+ const current = state.unlocked;
+ this.unlocked = current;
+ if (!state.fresh) {
+ // Derived cache was unproven (projector pending): display refreshed, baseline held.
+ this.baselineFresh = false;
+ return [];
}
- const accuracy = Number(record.accuracy) || 0;
- const duration = Number(record.duration) || 0;
-
- if (accuracy >= 1) {
- derived.hasPerfectAccuracy = true;
- derived.perfectCount = (Number(derived.perfectCount) || 0) + 1;
- }
- if (duration > 0 && duration <= 300 && accuracy > 0.8) {
- derived.hasSpeedDemon = true;
- derived.speedHighScoreCount = (Number(derived.speedHighScoreCount) || 0) + 1;
+ if (!this._deliveryInitialized) {
+ await this._persistDeliveryBaseline(current);
+ this.baseline = this._unionBaseline(baseline, current);
+ this.baselineFresh = true;
+ this._deliveryInitialized = true;
+ return [];
}
- }
-
- async syncFromPracticeRecordAPI(options = {}) {
- const {
- includeRecords = false,
- latestRecord = null,
- notify = false
- } = options;
- const rawStats = await this._getUserStatsFromPracticeRecordAPI();
- const derivedStats = this._buildDerivedStats(rawStats);
+ const newUnlocks = this.achievements.filter((achievement) => (
+ current[achievement.id] && !baseline[achievement.id]
+ ));
- const records = await this._getPracticeRecordsFromPracticeRecordAPI();
- this._applyRecordsToDerivedStats(derivedStats, records);
+ this.baselineFresh = true;
- if (!includeRecords) {
- this._applyRecordToDerivedStats(derivedStats, latestRecord);
+ if (newUnlocks.length > 0 && notify) {
+ this._notify(newUnlocks);
+ // Notification delivery is at-least-once across crashes. Within this session,
+ // advance first so a failed persistence retry cannot repeatedly toast the user.
+ this.baseline = this._unionBaseline(baseline, current);
+ this._pendingDelivery = this._unionBaseline(this._pendingDelivery, current);
+ } else if (newUnlocks.length === 0) {
+ this.baseline = this._unionBaseline(baseline, current);
}
- return this._unlockByStats(derivedStats, { notify });
- }
-
- /** @deprecated Use syncFromPracticeRecordAPI */
- async syncFromScoreStorage(options = {}) {
- return this.syncFromPracticeRecordAPI(options);
- }
-
- async _unlockByStats(stats, options = {}) {
- const { notify = false } = options;
- const newUnlocks = [];
-
- for (const achievement of this.achievements) {
- if (this.unlocked[achievement.id]) continue;
-
+ if (Object.keys(this._pendingDelivery).length > 0) {
+ const pending = this._pendingDelivery;
try {
- if (achievement.condition(stats, null)) {
- this.unlocked[achievement.id] = {
- unlockedAt: new Date().toISOString()
- };
- newUnlocks.push(achievement);
- }
+ await this._persistDeliveryBaseline(pending);
+ this._pendingDelivery = {};
} catch (err) {
- console.error(`[AchievementManager] Error checking ${achievement.id}`, err);
- }
- }
-
- if (newUnlocks.length > 0) {
- await this._saveUnlockedState();
- if (notify) {
- this._notify(newUnlocks);
+ console.warn('[AchievementManager] Failed to persist delivery acknowledgement', err);
}
}
@@ -651,12 +515,12 @@
}
/**
- * Check for new achievements based on latest activity
- * @param {Object} latestRecord - The practice record just completed
+ * Check for newly unlocked achievements after a practice completes.
+ * The projector has already recomputed progress by this point; we only diff it.
*/
- async check(latestRecord) {
+ async check() {
if (!this.initialized) await this.init();
- return this.syncFromPracticeRecordAPI({ includeRecords: true, latestRecord, notify: true });
+ return this.syncFromAppData({ notify: true });
}
/**
@@ -713,7 +577,7 @@
}
}
- await window.AchievementManager.syncFromPracticeRecordAPI({ includeRecords: true, notify: false });
+ await window.AchievementManager.syncFromAppData({ notify: false });
const all = window.AchievementManager.getAll();
list.innerHTML = all.map(a => `
diff --git a/js/services/libraryManager.js b/js/services/libraryManager.js
index befe06ba..84a16db6 100644
--- a/js/services/libraryManager.js
+++ b/js/services/libraryManager.js
@@ -60,23 +60,14 @@
&& global.listeningExamIndex.length > 0;
}
- function getActiveExamIndexSnapshot() {
- try {
- if (typeof global.getExamIndexState === 'function') {
- return global.getExamIndexState();
- }
- } catch (_) { }
- return Array.isArray(global.examIndex) ? global.examIndex : [];
- }
-
function hasActiveListeningLibrary(index) {
- return hasListeningEntries(Array.isArray(index) ? index : getActiveExamIndexSnapshot());
+ return hasListeningEntries(index);
}
function refreshListeningAvailabilityUI(index) {
if (typeof global.refreshListeningAvailabilityUI === 'function') {
try {
- global.refreshListeningAvailabilityUI(Array.isArray(index) ? index : getActiveExamIndexSnapshot());
+ global.refreshListeningAvailabilityUI(Array.isArray(index) ? index : []);
return;
} catch (error) {
console.warn('[LibraryManager] 刷新听力入口状态失败:', error);
@@ -165,36 +156,26 @@
}
async getActiveLibraryConfigurationKey() {
- return global.storage.get('active_exam_index_key', 'exam_index');
+ return global.AppData.library.getActive();
}
async setActiveLibraryConfiguration(key) {
- try {
- await global.storage.set('active_exam_index_key', key);
- } catch (error) {
- console.error('[LibraryManager] 设置活动题库配置失败:', error);
- }
+ return global.AppData.library.activate(typeof key === 'string' && key.trim() ? key.trim() : null);
}
async getLibraryConfigurations() {
- return global.storage.get('exam_index_configurations', []);
+ const configurations = await global.AppData.library.listConfigurations();
+ return [{ name: '默认题库', key: '', id: null, builtIn: true, sourceType: 'built-in-manifest' }]
+ .concat(Array.isArray(configurations) ? configurations : []);
}
async saveLibraryConfiguration(name, key, examCount, metadata = {}) {
try {
- let configs = await global.storage.get('exam_index_configurations', []);
- if (!Array.isArray(configs)) {
- configs = [];
- }
+ if (!key) return;
const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {};
- const entry = Object.assign({}, safeMetadata, { name, key, examCount, timestamp: Date.now() });
- const existingIndex = configs.findIndex((item) => item && item.key === key);
- if (existingIndex >= 0) {
- configs[existingIndex] = Object.assign({}, configs[existingIndex], entry);
- } else {
- configs.push(entry);
- }
- await global.storage.set('exam_index_configurations', configs);
+ await global.AppData.library.updateConfiguration(Object.assign({}, safeMetadata, {
+ id: key, key, name, examCount, timestamp: Date.now()
+ }));
} catch (error) {
console.error('[LibraryManager] 保存题库配置失败:', error);
}
@@ -290,20 +271,100 @@
: [];
}
- finishLibraryLoading(startTime) {
+ finishLibraryLoading(startTime, index) {
const loadTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() - startTime : 0;
if (typeof global.reportBootStage === 'function') {
global.reportBootStage('题库装载完成', 75);
}
- try { global.updateOverview && global.updateOverview(); } catch (_) { }
- refreshListeningAvailabilityUI();
- try { global.refreshBrowseProgressFromRecords && global.refreshBrowseProgressFromRecords(); } catch (_) { }
+ try { global.updateOverview && global.updateOverview(index); } catch (_) { }
+ refreshListeningAvailabilityUI(index);
+ if (typeof global.startPracticeRecordsSyncInBackground === 'function') {
+ global.startPracticeRecordsSyncInBackground('library-loaded', { forceRender: true });
+ }
try {
- global.dispatchEvent(new CustomEvent('examIndexLoaded'));
+ global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { index: cloneArray(index) } }));
} catch (_) { }
return loadTime;
}
+ async resolveDefaultIndex() {
+ await global.AppData.ready;
+ if (global.ensureExamDataScripts) {
+ try { await global.ensureExamDataScripts(); } catch (_) { }
+ }
+ return this.normalizeIndexForCustomConfig(
+ this.getDefaultReadingIndex().concat(this.resolveDefaultTypeIndex('listening'))
+ );
+ }
+
+ async resolveIndexForConfiguration(configurationId) {
+ await global.AppData.ready;
+ const id = typeof configurationId === 'string' && configurationId.trim()
+ ? configurationId.trim()
+ : null;
+ if (id === null) return this.resolveDefaultIndex();
+ return this.normalizeIndexForCustomConfig(await global.AppData.library.getIndex(id));
+ }
+
+ getRecordLibraryProvenance(record) {
+ const metadata = record && record.metadata && typeof record.metadata === 'object'
+ ? record.metadata
+ : {};
+ if (Object.prototype.hasOwnProperty.call(metadata, 'libraryConfigurationId')) {
+ const value = metadata.libraryConfigurationId;
+ return { known: true, configurationId: typeof value === 'string' && value.trim() ? value.trim() : null };
+ }
+ if (record && Object.prototype.hasOwnProperty.call(record, 'libraryConfigurationId')) {
+ const value = record.libraryConfigurationId;
+ return { known: true, configurationId: typeof value === 'string' && value.trim() ? value.trim() : null };
+ }
+ return { known: false, configurationId: null };
+ }
+
+ async resolveIndexForRecord(record) {
+ const provenance = this.getRecordLibraryProvenance(record);
+ // 记录带明确题库来源时严格按来源解析——多题库场景下这能防止同一 examId
+ // 被解析到别的库里的错误题目。
+ if (provenance.known) {
+ return this.resolveIndexForConfiguration(provenance.configurationId);
+ }
+ // 来源未知(几乎都是 v1 迁移来的旧记录:迁移时无法唯一确定来源就不会补
+ // libraryConfigurationId)。条件降级:只有当用户没有任何自定义题库时,
+ // examId 只可能对应默认库里的唯一题目,回退到当前活动题库解析是安全的
+ // (即 v1 一贯行为,修复旧记录回顾/详情/导出全部失败)。一旦存在自定义
+ // 题库,同一 examId 可能在多个库指向不同题目,无来源就无法安全判定,
+ // 保守返回空索引,由调用方按“题目不可用”提示,绝不静默解析到错题。
+ let customConfigCount = 0;
+ try {
+ const configurations = await global.AppData.library.listConfigurations();
+ customConfigCount = Array.isArray(configurations) ? configurations.length : 0;
+ } catch (_) {
+ // 读配置失败时按保守处理,不回退。
+ return [];
+ }
+ if (customConfigCount === 0) {
+ return this.resolveActiveIndex();
+ }
+ return [];
+ }
+
+ async resolveExamForRecord(record) {
+ if (!record || typeof record !== 'object') return null;
+ const metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {};
+ const candidateIds = [record.examId, metadata.examId]
+ .filter((value) => value !== null && value !== undefined && String(value).trim())
+ .map((value) => String(value));
+ if (!candidateIds.length) return null;
+ const index = await this.resolveIndexForRecord(record);
+ return index.find((exam) => exam && candidateIds.includes(String(exam.id))) || null;
+ }
+
+ async resolveActiveIndex() {
+ await global.AppData.ready;
+ const activeId = await global.AppData.library.getActive();
+ return this.resolveIndexForConfiguration(activeId);
+ }
+
async loadActiveLibrary(forceReload = false) {
const startTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
if (typeof global.reportBootStage === 'function') {
@@ -311,37 +372,36 @@
}
const rawKey = await this.getActiveLibraryConfigurationKey();
- const activeConfigKey = typeof rawKey === 'string' && rawKey.trim() ? rawKey.trim() : 'exam_index';
- const isDefaultConfig = activeConfigKey === 'exam_index';
+ const activeConfigKey = typeof rawKey === 'string' && rawKey.trim() ? rawKey.trim() : null;
+ const isDefaultConfig = activeConfigKey === null;
let cachedData = null;
try {
if (!isDefaultConfig) {
- cachedData = await global.storage.get(activeConfigKey);
+ cachedData = await global.AppData.library.getIndex(activeConfigKey);
} else {
- await global.storage.set('active_exam_index_key', 'exam_index');
+ await global.AppData.library.activate(null);
}
} catch (error) {
console.warn('[LibraryManager] 读取题库缓存失败:', error);
}
- if (!forceReload && !isDefaultConfig && Array.isArray(cachedData) && cachedData.length > 0) {
- const updatedIndex = global.setExamIndexState ? global.setExamIndexState(cachedData) : cachedData;
+ if (!isDefaultConfig && Array.isArray(cachedData) && cachedData.length > 0) {
+ const updatedIndex = this.normalizeIndexForCustomConfig(cachedData);
+ if (typeof global.assignExamSequenceNumbers === 'function') global.assignExamSequenceNumbers(updatedIndex);
await this.savePathMapForConfiguration(activeConfigKey, updatedIndex, { setActive: true });
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, updatedIndex);
return updatedIndex;
}
if (!isDefaultConfig) {
const normalized = Array.isArray(cachedData) ? cachedData : [];
- if (global.setExamIndexState) {
- global.setExamIndexState(normalized);
- }
if (!normalized.length && typeof global.showMessage === 'function') {
- global.showMessage('当前题库配置没有数据,请重新导入或切换至默认题库。', 'warning');
+ global.showMessage('当前题库配置没有数据,已自动切换至默认题库。', 'warning');
}
- this.finishLibraryLoading(startTime);
- return normalized;
+ // Continue through the built-in manifest path. Returning the empty
+ // custom index here used to dispatch examIndexLoaded([]) and left an
+ // otherwise valid generated Reading manifest invisible.
}
try {
@@ -360,11 +420,8 @@
const listeningExams = this.resolveDefaultTypeIndex('listening');
if (!readingExams.length && !listeningExams.length) {
- if (global.setExamIndexState) {
- global.setExamIndexState([]);
- }
console.warn('[LibraryManager] 未检测到默认题库脚本中的题源数据');
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, []);
return [];
}
@@ -372,7 +429,7 @@
if (typeof global.assignExamSequenceNumbers === 'function') {
global.assignExamSequenceNumbers(combined);
}
- const updatedIndex = global.setExamIndexState ? global.setExamIndexState(combined) : combined;
+ const updatedIndex = combined;
const metadata = {
source: 'default-script',
@@ -391,22 +448,19 @@
const overrideMap = this.buildOverridePathMap(metadata, this.DEFAULT_PATH_MAP);
- await global.storage.set('exam_index', updatedIndex);
- await this.saveLibraryConfiguration('默认题库', 'exam_index', updatedIndex.length);
- await this.setActiveLibraryConfiguration('exam_index');
- await this.savePathMapForConfiguration('exam_index', updatedIndex, { setActive: true, overrideMap });
+ if (isDefaultConfig) {
+ await this.setActiveLibraryConfiguration(null);
+ }
+ this.setActivePathMap(overrideMap);
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, updatedIndex);
return updatedIndex;
} catch (error) {
console.error('[LibraryManager] 加载默认题库失败:', error);
if (typeof global.showMessage === 'function') {
global.showMessage('题库刷新失败: ' + (error && error.message ? error.message : error), 'error');
}
- if (global.setExamIndexState) {
- global.setExamIndexState([]);
- }
- this.finishLibraryLoading(startTime);
+ this.finishLibraryLoading(startTime, []);
return [];
}
}
@@ -430,7 +484,7 @@
if (entry.trim() === key) {
mutated = true;
return {
- name: key === 'exam_index' ? '默认题库' : key,
+ name: key,
key,
examCount,
timestamp: now
@@ -448,7 +502,8 @@
return entry;
});
if (mutated) {
- await global.storage.set('exam_index_configurations', updated);
+ const target = updated.find((entry) => entry && entry.key === key);
+ if (target) await global.AppData.library.updateConfiguration(target);
}
} catch (error) {
console.warn('[LibraryManager] 无法刷新题库配置元数据', error);
@@ -456,11 +511,10 @@
}
async fetchLibraryDataset(key) {
- if (!key) {
- return [];
- }
try {
- const dataset = await global.storage.get(key);
+ const dataset = !key
+ ? this.resolveDefaultTypeIndex('reading').concat(this.resolveDefaultTypeIndex('listening'))
+ : await global.AppData.library.getIndex(key);
return Array.isArray(dataset) ? dataset : [];
} catch (error) {
console.warn('[LibraryManager] 无法读取题库数据:', key, error);
@@ -486,20 +540,13 @@
async resolveBaseLibraryIndex(activeKey) {
let currentIndex = [];
- const key = typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : 'exam_index';
+ const key = typeof activeKey === 'string' && activeKey.trim() ? activeKey.trim() : null;
try {
currentIndex = await this.fetchLibraryDataset(key);
} catch (_) {
currentIndex = [];
}
- if (!Array.isArray(currentIndex) || currentIndex.length === 0) {
- try {
- currentIndex = global.getExamIndexState ? global.getExamIndexState() : [];
- } catch (_) {
- currentIndex = [];
- }
- }
- if ((!Array.isArray(currentIndex) || currentIndex.length === 0) && key === 'exam_index') {
+ if ((!Array.isArray(currentIndex) || currentIndex.length === 0) && key === null) {
const reading = this.resolveDefaultTypeIndex('reading');
const listening = this.resolveDefaultTypeIndex('listening');
currentIndex = reading.concat(listening);
@@ -523,7 +570,7 @@
return this.normalizeIndexForCustomConfig(next);
}
- async buildUniqueImportedConfigKey(prefix = 'exam_index') {
+ async buildUniqueImportedConfigKey(prefix = 'library_import') {
let configs = [];
try {
configs = await this.getLibraryConfigurations();
@@ -542,16 +589,8 @@
if (used.has(key)) {
continue;
}
- try {
- const stored = global.storage && typeof global.storage.get === 'function'
- ? await global.storage.get(key)
- : null;
- if (!stored) {
- return key;
- }
- } catch (_) {
- return key;
- }
+ const stored = await global.AppData.library.getIndex(key);
+ if (!stored.length) return key;
}
return `${prefix}_${now}_${Math.random().toString(36).slice(2, 8)}`;
}
@@ -622,7 +661,7 @@
try { global.assignExamSequenceNumbers(newIndex); } catch (_) { }
}
- const key = options.key || await this.buildUniqueImportedConfigKey('exam_index');
+ const key = options.key || await this.buildUniqueImportedConfigKey('library_import');
const name = options.name || this.buildImportedConfigName(type, mode, options.label);
const counts = countIndexTypes(newIndex);
const sourceReport = options.discoveryResult && options.discoveryResult.report
@@ -636,22 +675,23 @@
mode,
accepted: additions.length,
rejected: sourceReport ? Number(sourceReport.rejected) || 0 : 0,
- createdFrom: activeKey || 'exam_index',
+ createdFrom: activeKey || null,
label: options.label || '',
timestamp: Date.now()
}
};
- await global.storage.set(key, newIndex);
- const pathFallback = await this.loadPathMapForConfiguration(activeKey || 'exam_index');
+ const pathFallback = await this.loadPathMapForConfiguration(activeKey);
const pathMap = this.resourceCore && typeof this.resourceCore.derivePathMapFromIndex === 'function'
? this.resourceCore.derivePathMapFromIndex(newIndex, pathFallback || this.DEFAULT_PATH_MAP)
: (pathFallback || null);
- await this.savePathMapForConfiguration(key, newIndex, {
- overrideMap: pathMap,
- setActive: options.activate !== false
+ await global.AppData.library.import({
+ id: key,
+ configuration: Object.assign({}, metadata, { id: key, key, name, examCount: newIndex.length, timestamp: Date.now() }),
+ index: newIndex,
+ operationId: options.operationId
});
- await this.saveLibraryConfiguration(name, key, newIndex.length, metadata);
+ if (options.activate !== false) this.setActivePathMap(pathMap);
let applied = true;
if (options.activate !== false) {
@@ -682,15 +722,13 @@
return false;
}
+ await this.setActiveLibraryConfiguration(key);
const currentPathMap = await this.loadPathMapForConfiguration(key);
const pathMap = this.resourceCore && typeof this.resourceCore.derivePathMapFromIndex === 'function'
? this.resourceCore.derivePathMapFromIndex(exams, currentPathMap || this.DEFAULT_PATH_MAP)
: (currentPathMap || null);
this.setActivePathMap(pathMap);
- if (global.setExamIndexState) {
- global.setExamIndexState(exams);
- }
refreshListeningAvailabilityUI(exams);
if (typeof global.setBrowseFilterState === 'function') {
global.setBrowseFilterState('all', 'all');
@@ -699,24 +737,18 @@
global.setFilteredExamsState([]);
}
- try {
- await this.setActiveLibraryConfiguration(key);
- } catch (error) {
- console.warn('[LibraryManager] 无法写入当前题库配置:', error);
- }
-
await this.updateLibraryConfigurationMetadata(key, exams.length);
await this.savePathMapForConfiguration(key, exams, {
overrideMap: pathMap,
setActive: true
});
- try { global.updateSystemInfo && global.updateSystemInfo(); } catch (_) { }
- try { global.updateOverview && global.updateOverview(); } catch (_) { }
- try { global.loadExamList && global.loadExamList(); } catch (_) { }
+ try { global.updateSystemInfo && global.updateSystemInfo(exams); } catch (_) { }
+ try { global.updateOverview && global.updateOverview(exams); } catch (_) { }
+ try { global.loadExamList && global.loadExamList(exams); } catch (_) { }
try {
- global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key } }));
+ global.dispatchEvent(new CustomEvent('examIndexLoaded', { detail: { key, index: cloneArray(exams) } }));
} catch (error) {
console.warn('[LibraryManager] 题库切换事件派发失败', error);
}
@@ -742,10 +774,6 @@
if (!configKey) {
return { deleted: false, reason: 'invalid-key' };
}
- if (configKey === 'exam_index') {
- return { deleted: false, reason: 'default-config' };
- }
-
const activeKey = await this.getActiveLibraryConfigurationKey();
if (activeKey === configKey) {
return { deleted: false, reason: 'active-config' };
@@ -788,12 +816,8 @@
return { deleted: false, reason: 'not-found' };
}
- if (!global.storage || typeof global.storage.remove !== 'function') {
- return { deleted: false, reason: 'storage-remove-unavailable' };
- }
- await global.storage.remove(configKey);
+ await global.AppData.library.remove(configKey);
await this.deletePathMapForConfiguration(configKey);
- await global.storage.set('exam_index_configurations', nextConfigs);
return {
deleted: true,
@@ -803,7 +827,8 @@
}
async loadLibrary(keyOrForceReload) {
- if (keyOrForceReload === 'default' || keyOrForceReload === 'exam_index') {
+ if (keyOrForceReload === 'default' || keyOrForceReload === null) {
+ await this.setActiveLibraryConfiguration(null);
return this.loadActiveLibrary(true);
}
if (typeof keyOrForceReload === 'string' && keyOrForceReload) {
@@ -824,7 +849,7 @@
async function switchLibraryConfig(key) {
const manager = getInstance();
- const nextKey = key || await manager.getActiveLibraryConfigurationKey() || 'exam_index';
+ const nextKey = typeof key === 'string' && key.trim() ? key.trim() : null;
return manager.applyLibraryConfiguration(nextKey);
}
@@ -832,10 +857,25 @@
return getInstance().loadLibrary(keyOrForceReload);
}
+ async function resolveActiveLibraryIndex() {
+ return getInstance().resolveActiveIndex();
+ }
+
+ async function resolveLibraryIndexForPracticeRecord(record) {
+ return getInstance().resolveIndexForRecord(record);
+ }
+
+ async function resolveExamForPracticeRecord(record) {
+ return getInstance().resolveExamForRecord(record);
+ }
+
global.LibraryManager = {
getInstance,
switchLibraryConfig,
loadLibrary,
+ resolveActiveIndex: resolveActiveLibraryIndex,
+ resolveIndexForRecord: resolveLibraryIndexForPracticeRecord,
+ resolveExamForRecord: resolveExamForPracticeRecord,
get RAW_DEFAULT_PATH_MAP() {
const manager = getInstance();
return manager.RAW_DEFAULT_PATH_MAP;
@@ -874,4 +914,7 @@
global.isBuiltInListeningLibraryAvailable = isBuiltInListeningLibraryAvailable;
global.switchLibraryConfig = switchLibraryConfig;
global.loadLibrary = loadLibrary;
+ global.resolveActiveLibraryIndex = resolveActiveLibraryIndex;
+ global.resolveLibraryIndexForPracticeRecord = resolveLibraryIndexForPracticeRecord;
+ global.resolveExamForPracticeRecord = resolveExamForPracticeRecord;
})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/theme-switcher.js b/js/theme-switcher.js
index a212bb8e..08513f32 100644
--- a/js/theme-switcher.js
+++ b/js/theme-switcher.js
@@ -1,50 +1,33 @@
-const THEME_PORTAL_STORAGE_KEY = 'preferred_theme_portal';
-const THEME_PORTAL_SESSION_SKIP_KEY = 'preferred_theme_skip_session';
-
-function safeParse(json) {
- if (!json) {
- return null;
- }
- try {
- const value = JSON.parse(json);
- return value && typeof value === 'object' ? value : null;
- } catch (error) {
- console.warn('[Theme] 无法解析主题首选项:', error);
- return null;
- }
-}
-
const themePreferenceController = {
- STORAGE_KEY: THEME_PORTAL_STORAGE_KEY,
- SESSION_KEY: THEME_PORTAL_SESSION_SKIP_KEY,
+ cache: null,
+ ready: null,
load() {
- try {
- return safeParse(localStorage.getItem(this.STORAGE_KEY));
- } catch (error) {
- console.warn('[Theme] 读取主题首选项失败:', error);
- return null;
+ return this.cache;
+ },
+
+ hydrate() {
+ if (!this.ready) {
+ this.ready = window.AppData.ready.then(() => window.AppData.preferences.getThemePortal()).then((value) => {
+ this.cache = value;
+ return value;
+ });
}
+ return this.ready;
},
- save(payload) {
+ async save(payload) {
if (!payload || typeof payload !== 'object') {
- this.clear();
- return;
- }
- try {
- localStorage.setItem(this.STORAGE_KEY, JSON.stringify(payload));
- } catch (error) {
- console.warn('[Theme] 保存主题首选项失败:', error);
+ return this.clear();
}
+ await window.AppData.preferences.setThemePortal(payload);
+ this.cache = payload;
+ return payload;
},
- clear() {
- try {
- localStorage.removeItem(this.STORAGE_KEY);
- } catch (_) {
- // no-op
- }
+ async clear() {
+ await window.AppData.preferences.setThemePortal(null);
+ this.cache = null;
},
recordInternalTheme(themeId = 'default') {
@@ -53,8 +36,8 @@ const themePreferenceController = {
theme: themeId,
updatedAt: Date.now()
};
- this.save(snapshot);
- return this.load();
+ this.save(snapshot).catch((error) => console.warn('[Theme] 保存主题首选项失败:', error));
+ return snapshot;
}
};
@@ -68,7 +51,7 @@ function applyTheme(theme) {
if (!theme) return;
try {
root.setAttribute('data-theme', theme);
- localStorage.setItem('theme', theme);
+ window.AppData.preferences.setTheme(theme).catch((error) => console.warn('[Theme] 保存主题失败:', error));
themePreferenceController.recordInternalTheme(theme);
} catch (e) {}
}
@@ -77,7 +60,7 @@ function applyDefaultTheme() {
const root = document.documentElement;
try {
root.removeAttribute('data-theme');
- localStorage.removeItem('theme');
+ window.AppData.preferences.setTheme('default').catch((error) => console.warn('[Theme] 保存主题失败:', error));
themePreferenceController.recordInternalTheme('default');
} catch (e) {}
}
@@ -145,7 +128,7 @@ function initializeThemeScrollerControls() {
syncThemeScrollerButtons();
}
-function initializeThemeSwitcher() {
+async function initializeThemeSwitcher() {
if (typeof window !== 'undefined' && window.__themeSwitcherInitialized) {
return;
}
@@ -155,10 +138,12 @@ function initializeThemeSwitcher() {
window.__syncThemeScrollerButtons = syncThemeScrollerButtons;
}
- // Restore general theme
try {
- const savedTheme = localStorage.getItem('theme');
- if (savedTheme) applyTheme(savedTheme);
+ await window.AppData.ready;
+ await themePreferenceController.hydrate();
+ const savedTheme = await window.AppData.preferences.getTheme();
+ if (savedTheme && savedTheme !== 'default') document.documentElement.setAttribute('data-theme', savedTheme);
+ else document.documentElement.removeAttribute('data-theme');
} catch (e) {}
// Close modal when clicking outside
diff --git a/js/utils/BrowsePreferencesUtils.js b/js/utils/BrowsePreferencesUtils.js
index cda54f98..f5dc3e98 100644
--- a/js/utils/BrowsePreferencesUtils.js
+++ b/js/utils/BrowsePreferencesUtils.js
@@ -4,8 +4,10 @@
(function (global) {
'use strict';
- const BROWSE_VIEW_PREFERENCE_KEY = 'browse_view_preferences_v2';
let browsePreferencesCache = null;
+ let browsePreferencesReady = null;
+ let browsePreferenceWriteQueue = Promise.resolve();
+ const pendingBrowsePreferenceWrites = [];
let currentBrowseScrollElement = null;
let removeBrowseScrollListener = null;
let pendingBrowseAutoScroll = null;
@@ -138,14 +140,20 @@
}
function loadBrowsePreferencesFromStorage() {
+ if (!browsePreferencesReady) {
+ browsePreferencesReady = Promise.resolve().then(async () => {
+ if (!global.AppData || !global.AppData.preferences) return;
+ await global.AppData.ready;
+ const parsed = await global.AppData.preferences.getBrowse();
+ const defaults = getDefaultBrowsePreferences();
+ const next = Object.assign({}, defaults, parsed || {});
+ if (!next.scrollPositions || typeof next.scrollPositions !== 'object') next.scrollPositions = {};
+ next.listAnchors = mergeBrowseAnchors({}, next.listAnchors);
+ browsePreferencesCache = next;
+ }).catch((error) => console.warn('[BrowsePreferences] 无法读取浏览偏好,使用默认值', error));
+ }
try {
- const raw = localStorage.getItem(BROWSE_VIEW_PREFERENCE_KEY);
- if (!raw) {
- return getDefaultBrowsePreferences();
- }
- const parsed = JSON.parse(raw);
- const defaults = getDefaultBrowsePreferences();
- const next = Object.assign({}, defaults, parsed || {});
+ const next = Object.assign({}, getDefaultBrowsePreferences(), browsePreferencesCache || {});
if (!next.scrollPositions || typeof next.scrollPositions !== 'object') {
next.scrollPositions = {};
}
@@ -164,9 +172,16 @@
return browsePreferencesCache;
}
- function saveBrowseViewPreferences(partial = {}) {
- const current = getBrowseViewPreferences();
- const next = {
+ async function whenBrowseViewPreferencesReady() {
+ loadBrowsePreferencesFromStorage();
+ if (browsePreferencesReady) {
+ await browsePreferencesReady;
+ }
+ return getBrowseViewPreferences();
+ }
+
+ function mergeBrowsePreferences(current, partial = {}) {
+ return {
scrollPositions: Object.assign({}, current.scrollPositions, partial.scrollPositions || {}),
listAnchors: mergeBrowseAnchors(current.listAnchors, partial.listAnchors),
autoScrollEnabled: Object.prototype.hasOwnProperty.call(partial, 'autoScrollEnabled')
@@ -176,15 +191,39 @@
? (partial.lastFilter || null)
: current.lastFilter
};
+ }
- try {
- localStorage.setItem(BROWSE_VIEW_PREFERENCE_KEY, JSON.stringify(next));
- browsePreferencesCache = next;
- } catch (error) {
- console.warn('[BrowsePreferences] 保存浏览偏好失败', error);
+ function saveBrowseViewPreferences(partial = {}) {
+ const request = { partial: Object.assign({}, partial) };
+ pendingBrowsePreferenceWrites.push(request);
+ const preview = pendingBrowsePreferenceWrites.reduce(
+ (current, pending) => mergeBrowsePreferences(current, pending.partial),
+ getBrowseViewPreferences()
+ );
+
+ if (!global.AppData || !global.AppData.preferences) {
+ pendingBrowsePreferenceWrites.splice(pendingBrowsePreferenceWrites.indexOf(request), 1);
+ console.warn('[BrowsePreferences] AppData.preferences 不可用,偏好未保存');
+ return preview;
+ }
+
+ browsePreferenceWriteQueue = browsePreferenceWriteQueue.then(async () => {
+ await global.AppData.ready;
+ if (browsePreferencesReady) await browsePreferencesReady;
+ const next = mergeBrowsePreferences(getBrowseViewPreferences(), request.partial);
+ await global.AppData.preferences.patchBrowse(next);
browsePreferencesCache = next;
- }
- return browsePreferencesCache;
+ }).catch((error) => {
+ console.warn('[BrowsePreferences] 保存浏览偏好失败,保留上次已提交值', error);
+ }).finally(() => {
+ const index = pendingBrowsePreferenceWrites.indexOf(request);
+ if (index >= 0) pendingBrowsePreferenceWrites.splice(index, 1);
+ });
+ return preview;
+ }
+
+ function flushBrowsePreferenceWrites() {
+ return browsePreferenceWriteQueue.then(() => getBrowseViewPreferences());
}
function persistBrowseFilter(category, type) {
@@ -426,20 +465,20 @@
};
}
- function findLastPracticeExamEntry(exams, category, type) {
+ function findLastPracticeExamEntry(exams, records, examIndex, category, type) {
const normalizedCategory = normalizeCategoryKey(category);
const normalizedType = normalizeExamType(type);
- const records = global.getPracticeRecordsState ? global.getPracticeRecordsState() : [];
- if (!Array.isArray(records) || records.length === 0) {
+ const recordSnapshot = Array.isArray(records) ? records : [];
+ if (recordSnapshot.length === 0) {
return null;
}
- const examIndex = global.getExamIndexState ? global.getExamIndexState() : [];
+ const indexSnapshot = Array.isArray(examIndex) ? examIndex : [];
let latest = null;
let latestTimestamp = Number.NEGATIVE_INFINITY;
- records.forEach((record) => {
- const info = resolveRecordExamInfo(record, examIndex);
+ recordSnapshot.forEach((record) => {
+ const info = resolveRecordExamInfo(record, indexSnapshot);
if (!info) {
return;
}
@@ -591,7 +630,7 @@
return parts.join(' ');
}
- function setupBrowsePreferenceUI() {
+ async function setupBrowsePreferenceUI() {
const trigger = document.getElementById('browse-title-trigger');
const panel = document.getElementById('browse-preference-panel');
const checkbox = document.getElementById('browse-remember-position');
@@ -600,7 +639,7 @@
return;
}
- const prefs = getBrowseViewPreferences();
+ const prefs = await whenBrowseViewPreferencesReady();
checkbox.checked = !!prefs.autoScrollEnabled;
updateBrowsePreferenceIndicator(prefs.autoScrollEnabled);
@@ -661,18 +700,18 @@
});
}
- function handlePostExamListRender(exams, { category, type } = {}) {
+ async function handlePostExamListRender(exams, { category, type } = {}) {
const scrollEl = document.querySelector('#exam-list-container .exam-list');
if (!scrollEl) {
return;
}
+ const prefs = await whenBrowseViewPreferencesReady();
ensureBrowseScrollListener(scrollEl);
const normalizedCategory = normalizeCategoryKey(category || (global.getCurrentCategory ? global.getCurrentCategory() : 'all'));
const normalizedType = normalizeExamType(type || (global.getCurrentExamType ? global.getCurrentExamType() : 'all'));
const autoScrollContext = consumeBrowseAutoScroll(normalizedCategory, normalizedType);
- const prefs = getBrowseViewPreferences();
const applyScroll = () => {
const performFallback = () => {
@@ -698,17 +737,12 @@
};
if (prefs.autoScrollEnabled && (normalizedCategory !== 'all' || normalizedType !== 'all')) {
- const entry = findLastPracticeExamEntry(exams, normalizedCategory, normalizedType);
- if (entry) {
- const retries = autoScrollContext ? 7 : 4;
- attemptScrollToEntry(entry, retries, performFallback);
- return;
- }
const anchor = getBrowseListAnchor(normalizedCategory, normalizedType);
if (anchor) {
const entryFromAnchor = findExamEntryByAnchor(exams, anchor);
if (entryFromAnchor) {
- attemptScrollToEntry(entryFromAnchor, 3, performFallback);
+ const retries = autoScrollContext ? 7 : 4;
+ attemptScrollToEntry(entryFromAnchor, retries, performFallback);
return;
}
}
@@ -724,14 +758,14 @@
}
}
- function updateBrowseAnchorsFromRecords(records) {
+ function updateBrowseAnchorsFromRecords(records, examIndex) {
const list = Array.isArray(records) ? records : [];
- const examIndex = global.getExamIndexState ? global.getExamIndexState() : [];
+ const indexSnapshot = Array.isArray(examIndex) ? examIndex : [];
const updates = {};
const seenKeys = new Set();
list.forEach((record) => {
- const info = resolveRecordExamInfo(record, examIndex);
+ const info = resolveRecordExamInfo(record, indexSnapshot);
if (!info) {
return;
}
@@ -787,7 +821,9 @@
global.normalizeExamType = normalizeExamType;
global.buildBrowseFilterKey = buildBrowseFilterKey;
global.getBrowseViewPreferences = getBrowseViewPreferences;
+ global.whenBrowseViewPreferencesReady = whenBrowseViewPreferencesReady;
global.saveBrowseViewPreferences = saveBrowseViewPreferences;
+ global.flushBrowsePreferenceWrites = flushBrowsePreferenceWrites;
global.persistBrowseFilter = persistBrowseFilter;
global.getPersistedBrowseFilter = getPersistedBrowseFilter;
global.updateBrowseAnchorsFromRecords = updateBrowseAnchorsFromRecords;
diff --git a/js/utils/answerComparisonUtils.js b/js/utils/answerComparisonUtils.js
index 9f91bb9a..01277566 100644
--- a/js/utils/answerComparisonUtils.js
+++ b/js/utils/answerComparisonUtils.js
@@ -572,158 +572,19 @@
};
}
- function getAllExamIndexes(globalObj) {
- let readingIndex = null;
- if (globalObj && typeof globalObj.getReadingExamIndex === 'function') {
- try {
- readingIndex = globalObj.getReadingExamIndex();
- } catch (_) {
- readingIndex = null;
- }
- }
- const sources = [
- readingIndex,
- globalObj.__READING_EXAM_INDEX__,
- globalObj.examIndex,
- globalObj.readingExamIndex,
- globalObj.listeningExamIndex,
- globalObj.fullExamIndex,
- globalObj.practiceExamIndex
- ];
- return sources
- .filter(Array.isArray)
- .reduce((acc, list) => acc.concat(list), []);
- }
-
- function normalizeTitle(title) {
- return toStringKey(title)
- .toLowerCase()
- .replace(/[\s\-_\u3000]+/g, '')
- .replace(/[^\w\u4e00-\u9fa5]/g, '');
- }
-
- function findExamEntry(record, metadata, globalObj) {
- const indexes = getAllExamIndexes(globalObj);
- if (indexes.length === 0) {
- return null;
- }
-
- const candidateIds = [
- record && record.examId,
- record && record.originalExamId,
- record && record.derivedExamId,
- record && record.realData && record.realData.examId,
- metadata && metadata.examId,
- metadata && metadata.id
- ]
- .map(toStringKey)
- .filter(Boolean);
-
- // 1. 精确 ID 匹配
- if (candidateIds.length > 0) {
- const idLookup = new Map();
- indexes.forEach(item => {
- if (!item || typeof item !== 'object') {
- return;
- }
- const itemId = toStringKey(item.id);
- if (itemId) {
- idLookup.set(itemId.toLowerCase(), item);
- }
- });
-
- for (const id of candidateIds) {
- const normalizedId = id.toLowerCase();
- if (idLookup.has(normalizedId)) {
- return idLookup.get(normalizedId);
- }
- }
- }
-
- // 2. 通过 URL 路径匹配(针对全量题库)
- if (record && record.url) {
- const urlPath = record.url.toLowerCase();
- const match = indexes.find(item => {
- if (!item || !item.path) return false;
- const itemPath = item.path.toLowerCase();
- // 提取 URL 中的文件夹名称
- const urlParts = urlPath.split('/').filter(Boolean);
- const pathParts = itemPath.split('/').filter(Boolean);
-
- // 检查是否有共同的文件夹路径
- for (let i = 0; i < Math.min(urlParts.length, pathParts.length); i++) {
- if (urlParts[urlParts.length - 1 - i] === pathParts[pathParts.length - 1 - i]) {
- return true;
- }
- }
- return false;
- });
- if (match) {
- console.log('[AnswerComparisonUtils] 通过 URL 路径匹配到题目:', match.id, match.title);
- return match;
- }
+ function inferCategory(record, metadata, examEntry) {
+ if (metadata && metadata.category && metadata.category !== 'Unknown') {
+ return metadata.category;
}
- // 3. 精确标题匹配
- const candidateTitles = [
- metadata && metadata.examTitle,
- metadata && metadata.title,
- record && record.title,
- record && record.examTitle,
- record && record.realData && record.realData.title
- ]
- .map(normalizeTitle)
- .filter(Boolean);
-
- if (candidateTitles.length > 0) {
- const titleLookup = new Map();
- indexes.forEach(item => {
- if (!item || typeof item !== 'object') {
- return;
- }
- const itemTitle = normalizeTitle(item.title);
- if (itemTitle) {
- titleLookup.set(itemTitle, item);
- }
- });
-
- for (const title of candidateTitles) {
- if (titleLookup.has(title)) {
- return titleLookup.get(title);
- }
- }
-
- // 4. 模糊标题匹配(移除标签前缀后比较)
- for (const candidateTitle of candidateTitles) {
- const match = indexes.find(item => {
- if (!item || !item.title) return false;
- const itemTitle = normalizeTitle(item.title);
- // 移除标签前缀,如 "[听力全量-...] City Development" vs "City Development"
- const cleanCandidate = candidateTitle.replace(/^\[.*?\]\s*/, '');
- const cleanItem = itemTitle.replace(/^\[.*?\]\s*/, '');
- return cleanCandidate === cleanItem ||
- (cleanCandidate.length > 5 && cleanItem.includes(cleanCandidate)) ||
- (cleanItem.length > 5 && cleanCandidate.includes(cleanItem));
- });
- if (match) {
- console.log('[AnswerComparisonUtils] 通过模糊标题匹配到题目:', match.id, match.title);
- return match;
- }
- }
+ if (record && record.category && record.category !== 'Unknown') {
+ return record.category;
}
- return null;
- }
-
- function inferCategory(record, metadata, examEntry) {
if (examEntry && examEntry.category) {
return examEntry.category;
}
- if (metadata && metadata.category && metadata.category !== 'Unknown') {
- return metadata.category;
- }
-
const candidates = [
record && record.examId,
metadata && metadata.examId,
@@ -748,7 +609,7 @@
return metadata && metadata.category ? metadata.category : 'Unknown';
}
- function enrichRecordMetadata(record) {
+ function enrichRecordMetadata(record, examEntry = null) {
if (!record || typeof record !== 'object') {
return {
category: 'Unknown',
@@ -764,25 +625,24 @@
return metadata;
}
- const globalObj = global || {};
- const examEntry = findExamEntry(record, metadata, globalObj);
+ const resolvedExam = examEntry && typeof examEntry === 'object' ? examEntry : null;
- if (examEntry) {
- if (examEntry.title && !metadata.examTitle) {
- metadata.examTitle = examEntry.title;
+ if (resolvedExam) {
+ if (resolvedExam.title && !metadata.examTitle) {
+ metadata.examTitle = resolvedExam.title;
}
- if (examEntry.frequency && !metadata.frequency) {
- metadata.frequency = examEntry.frequency;
+ if (resolvedExam.frequency && !metadata.frequency) {
+ metadata.frequency = resolvedExam.frequency;
}
- if (examEntry.type && !metadata.type) {
- metadata.type = examEntry.type;
+ if (resolvedExam.type && !metadata.type) {
+ metadata.type = resolvedExam.type;
}
}
- metadata.category = inferCategory(record, metadata, examEntry);
+ metadata.category = inferCategory(record, metadata, resolvedExam);
if (!metadata.frequency) {
- if (examEntry && examEntry.frequency) {
- metadata.frequency = examEntry.frequency;
+ if (resolvedExam && resolvedExam.frequency) {
+ metadata.frequency = resolvedExam.frequency;
} else if (metadata.frequency == null) {
metadata.frequency = 'unknown';
}
@@ -811,13 +671,13 @@
return metadata;
}
- function withEnrichedMetadata(record) {
+ function withEnrichedMetadata(record, examEntry = null) {
if (!record || typeof record !== 'object') {
return record;
}
const clone = Object.assign({}, record);
clone.metadata = Object.assign({}, record.metadata || {});
- enrichRecordMetadata(clone);
+ enrichRecordMetadata(clone, examEntry);
return clone;
}
diff --git a/js/utils/answerMatchCore.js b/js/utils/answerMatchCore.js
index 1e770ea2..1453769a 100644
--- a/js/utils/answerMatchCore.js
+++ b/js/utils/answerMatchCore.js
@@ -120,7 +120,20 @@
function compareAnswers(userAnswer, correctAnswer) {
const expected = splitAnswerTokens(correctAnswer);
- const actual = splitAnswerTokens(userAnswer);
+ let actual = splitAnswerTokens(userAnswer);
+
+ if (
+ expected.length === 1
+ && /^[A-Z]$/.test(expected[0])
+ && actual.length === 1
+ && !/^[A-Z]$/.test(actual[0])
+ && typeof userAnswer === 'string'
+ ) {
+ const labeledOption = userAnswer.trim().match(/^([A-Z])\s+\S/);
+ if (labeledOption) {
+ actual = [labeledOption[1]];
+ }
+ }
if (expected.length === 0 && actual.length === 0) {
return null;
diff --git a/js/utils/dataBackupManager.js b/js/utils/dataBackupManager.js
deleted file mode 100644
index 33eddd21..00000000
--- a/js/utils/dataBackupManager.js
+++ /dev/null
@@ -1,900 +0,0 @@
-/**
- * Data backup and recovery manager.
- * Provides export/import/cleanup functionality for the shared storage layer.
- */
-class DataBackupManager {
- constructor() {
- this.storageKeys = {
- backupSettings: 'backup_settings',
- exportHistory: 'export_history',
- importHistory: 'import_history',
- manualBackups: 'manual_backups'
- };
-
- this.supportedFormats = ['json', 'csv'];
- this.maxBackupHistory = 20;
- this.maxExportHistory = 50;
-
- this.initialize();
- }
-
- sanitizeExamTitle(title) {
- if (!title) return '';
- const str = String(title).trim();
- if (!str) return '';
- const pattern = /ielts\s+listening\s+practice\s*-\s*part\s*\d+\s*[:\-]?\s*(.+)$/i;
- const match = str.match(pattern);
- if (match && match[1]) {
- return match[1].trim();
- }
- if (str.includes(' - ')) {
- const segments = str.split(' - ').map((s) => s.trim()).filter(Boolean);
- if (segments.length > 1) {
- return segments[segments.length - 1];
- }
- }
- return str;
- }
-
- sanitizeRecord(record) {
- if (!record || typeof record !== 'object') {
- return record;
- }
- const clone = { ...record };
- const metadata = (clone.metadata && typeof clone.metadata === 'object') ? { ...clone.metadata } : {};
- const baseTitle = metadata.examTitle || metadata.title || clone.title || clone.examTitle;
- const cleanedTitle = this.sanitizeExamTitle(baseTitle);
- if (cleanedTitle) {
- metadata.examTitle = cleanedTitle;
- metadata.title = metadata.title || cleanedTitle;
- clone.title = cleanedTitle;
- if (!clone.examTitle) {
- clone.examTitle = cleanedTitle;
- }
- clone.metadata = metadata;
- }
- return clone;
- }
-
- async initialize() {
- try {
- await this.initializeSettings();
- } catch (error) {
- console.error('[DataBackupManager] failed to initialize settings', error);
- }
-
- this.setupPeriodicCleanup();
- }
-
- async initializeSettings() {
- const defaults = {
- autoBackup: true,
- backupInterval: 24,
- maxBackups: 10,
- compressionEnabled: false,
- encryptionEnabled: false,
- lastAutoBackup: null
- };
-
- try {
- const stored = await storage.get(this.storageKeys.backupSettings, defaults);
- await storage.set(this.storageKeys.backupSettings, { ...defaults, ...stored });
- } catch (error) {
- console.error('[DataBackupManager] unable to persist settings', error);
- }
- }
-
- async listPracticeRecords() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- return await window.PracticeRecordAPI.list();
- }
-
- throw new Error('统一练习记录存储未就绪');
- }
-
- async replacePracticeRecords(records, options = {}) {
- const normalizedRecords = Array.isArray(records) ? records : [];
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.replace === 'function') {
- await window.PracticeRecordAPI.replace(normalizedRecords, options);
- return true;
- }
-
- throw new Error('统一练习记录存储未就绪');
- }
-
- async restorePracticeRecords(records, stats = null) {
- const normalizedRecords = Array.isArray(records) ? records : [];
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.restoreRecords === 'function') {
- return await window.PracticeRecordAPI.restoreRecords(normalizedRecords, {
- stats: this.isPlainObject(stats) ? stats : null,
- updateStats: true
- });
- }
-
- throw new Error('统一练习记录恢复 API 未就绪');
- }
-
- async readUserStats() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.readStats === 'function') {
- return await window.PracticeRecordAPI.readStats();
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- async mergeUserStats(stats, mergeMode = 'merge') {
- if (!this.isPlainObject(stats)) {
- return await this.readUserStats();
- }
-
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeStats === 'function') {
- return await window.PracticeRecordAPI.mergeStats(stats, { mergeMode });
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- async resetUserStats(stats = null) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.resetStats === 'function') {
- return await window.PracticeRecordAPI.resetStats(stats);
- }
-
- throw new Error('统一练习统计 API 未就绪');
- }
-
- async createBackup(backupName = null, type = 'manual') {
- if (window.BackupAPI && typeof window.BackupAPI.create === 'function') {
- return await window.BackupAPI.create({
- id: backupName || undefined,
- type
- });
- }
-
- // Fallback when BackupAPI not loaded yet (early boot / isolated tests)
- const practiceRecords = await this.listPracticeRecords();
- const userStats = await this.readUserStats();
- const examIndex = await storage.get('exam_index', []);
- const backup = {
- id: backupName || `backup_${Date.now()}`,
- timestamp: new Date().toISOString(),
- type,
- data: {
- practice_records: practiceRecords,
- practiceRecords,
- user_stats: userStats,
- userStats,
- exam_index: examIndex,
- examIndex
- }
- };
-
- const backups = await storage.get(this.storageKeys.manualBackups, []);
- backups.unshift(backup);
- while (backups.length > this.maxBackupHistory) {
- backups.pop();
- }
- await storage.set(this.storageKeys.manualBackups, backups);
- return backup.id;
- }
-
- async exportPracticeRecords(options = {}) {
- const {
- format = 'json',
- includeStats = true,
- includeBackups = false,
- dateRange = null,
- categories = null,
- compression = false
- } = options;
-
- const normalizedFormat = String(format).toLowerCase();
- if (!this.supportedFormats.includes(normalizedFormat)) {
- throw new Error(`Unsupported export format: ${format}`);
- }
-
- let practiceRecords = await this.listPracticeRecords();
- practiceRecords = Array.isArray(practiceRecords) ? practiceRecords : [];
-
- if (dateRange) {
- practiceRecords = this.filterByDateRange(practiceRecords, dateRange);
- }
-
- if (Array.isArray(categories) && categories.length) {
- practiceRecords = practiceRecords.filter(record => categories.includes(record?.metadata?.category));
- }
-
- const exportPayload = {
- exportInfo: {
- timestamp: new Date().toISOString(),
- version: '0.6.2-fix',
- format: normalizedFormat,
- recordCount: practiceRecords.length,
- options: { format, includeStats, includeBackups, dateRange, categories }
- },
- practiceRecords
- };
-
- if (includeStats) {
- exportPayload.userStats = await this.readUserStats();
- }
-
- if (includeBackups) {
- try {
- // 统一经 BackupAPI 读全量列表;不再经 scoreStorage 的类型过滤旁路
- if (window.BackupAPI && typeof window.BackupAPI.list === 'function') {
- exportPayload.backups = await window.BackupAPI.list();
- } else {
- exportPayload.backups = await storage.get(this.storageKeys.manualBackups, []);
- }
- if (!Array.isArray(exportPayload.backups)) {
- exportPayload.backups = [];
- }
- } catch (error) {
- console.warn('[DataBackupManager] failed to include backups in export', error);
- exportPayload.backups = [];
- }
- }
-
- await this.recordExportHistory(exportPayload.exportInfo);
-
- switch (normalizedFormat) {
- case 'json':
- return this.exportAsJSON(exportPayload, compression);
- case 'csv':
- return this.exportAsCSV(exportPayload);
- default:
- throw new Error(`Format ${format} not implemented`);
- }
- }
-
- exportAsJSON(data, compressionEnabled = false) {
- const raw = JSON.stringify(data, null, 2);
- const payload = compressionEnabled ? this.compressData(raw) : raw;
-
- return {
- data: payload,
- filename: `practice_records_${this.getTimestamp()}.json`,
- mimeType: 'application/json',
- size: payload.length,
- compressed: compressionEnabled
- };
- }
-
- exportAsCSV(data) {
- const records = Array.isArray(data.practiceRecords) ? data.practiceRecords : [];
- const headers = [
- 'record_id',
- 'exam_id',
- 'title',
- 'status',
- 'score',
- 'accuracy',
- 'duration_seconds',
- 'start_time',
- 'end_time',
- 'category',
- 'frequency',
- 'created_at'
- ];
-
- const rows = records.map(record => {
- const metadata = record?.metadata || {};
- return [
- record?.id ?? '',
- record?.examId ?? '',
- record?.title ?? '',
- record?.status ?? '',
- record?.score ?? '',
- record?.accuracy ?? '',
- record?.duration ?? '',
- record?.startTime ?? '',
- record?.endTime ?? '',
- metadata.category ?? '',
- metadata.frequency ?? '',
- record?.createdAt ?? ''
- ];
- });
-
- const csvContent = [headers, ...rows]
- .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
- .join('\n');
-
- return {
- data: csvContent,
- filename: `practice_records_${this.getTimestamp()}.csv`,
- mimeType: 'text/csv',
- size: csvContent.length
- };
- }
- /**
- * Legacy-friendly wrapper.
- */
- async importPracticeRecords(source, options = {}) {
- return this.importPracticeData(source, options);
- }
-
- async importPracticeData(source, options = {}) {
- console.log('[DataBackupManager] importPracticeData called, source type:', typeof source, 'length:', Array.isArray(source) ? source.length : source.practiceRecords?.length);
- const {
- mergeMode = 'merge',
- createBackup = true,
- preserveIds = true
- } = options;
-
- let payload;
- try {
- payload = await this.parseImportSource(source, { allowFetch: true });
- } catch (error) {
- throw new Error(`Failed to read import source: ${error.message}`);
- }
-
- const normalized = this.normalizeImportPayload(payload, { preserveIds });
- console.log('[DataBackupManager] Normalized records:', normalized.practiceRecords.length);
-
- let practiceRecords = Array.isArray(normalized.practiceRecords) ? normalized.practiceRecords : [];
-
- if (!practiceRecords.length) {
- throw new Error('Import file does not contain any practice records.');
- }
-
- practiceRecords = practiceRecords.map((r) => this.sanitizeRecord(r));
- normalized.practiceRecords = practiceRecords;
- console.log('[DataBackupManager] After sanitize, records:', normalized.practiceRecords.length);
-
- let backupId = null;
- if (createBackup) {
- backupId = await this.createPreImportBackup();
- console.log('[DataBackupManager] Pre-import backup created:', backupId);
- }
-
- let mergeResult;
- try {
- // 若备份同时携带 user_stats,导入 records 时禁止并发 recalculateStats,
- // 否则会与后续 mergeUserStats 竞态,覆盖备份中的 practiceDays/streakDays 等字段。
- const hasImportedStats = Boolean(normalized.userStats);
- mergeResult = await this.mergePracticeRecords(
- normalized.practiceRecords,
- mergeMode,
- { updateStats: !hasImportedStats }
- );
- console.log('[DataBackupManager] Practice records imported through PracticeRecordAPI');
-
- if (normalized.userStats) {
- await this.mergeUserStats(normalized.userStats, mergeMode);
- }
- } catch (error) {
- if (backupId) {
- try {
- await this.restoreBackup(backupId);
- } catch (restoreError) {
- console.error('[DataBackupManager] failed to restore backup after import error', restoreError);
- }
- }
-
- await this.recordImportHistory({
- timestamp: new Date().toISOString(),
- mergeMode,
- backupId,
- success: false,
- error: error.message
- });
- throw error;
- }
-
- await this.recordImportHistory({
- timestamp: new Date().toISOString(),
- recordCount: mergeResult.importedCount,
- mergeMode,
- backupId,
- sources: normalized.sources,
- success: true
- });
-
- return {
- success: true,
- ...mergeResult,
- backupId,
- statsImported: Boolean(normalized.userStats),
- sources: normalized.sources
- };
- }
-
- async parseImportSource(source, { allowFetch = false } = {}) {
- if (source === undefined || source === null) {
- throw new Error('Import source is empty.');
- }
-
- if (typeof File !== 'undefined' && source instanceof File) {
- return this.parseImportSource(await source.text(), { allowFetch });
- }
-
- if (typeof Blob !== 'undefined' && source instanceof Blob) {
- return this.parseImportSource(await source.text(), { allowFetch });
- }
-
- if (typeof source === 'string') {
- const trimmed = source.trim();
- if (!trimmed) {
- throw new Error('Import source string is empty.');
- }
-
- if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
- try {
- return JSON.parse(trimmed);
- } catch (error) {
- throw new Error('Import string is not valid JSON.');
- }
- }
-
- if (!allowFetch) {
- throw new Error('Import string is neither JSON nor a fetchable path.');
- }
-
- const response = await fetch(trimmed);
- if (!response.ok) {
- throw new Error(`Failed to fetch import file: ${response.status}`);
- }
- return await response.json();
- }
-
- if (Array.isArray(source) || this.isPlainObject(source)) {
- return source;
- }
-
- throw new Error('Unsupported import source type.');
- }
-
- normalizeImportPayload(payload, { preserveIds = true } = {}) {
- if (payload === undefined || payload === null) {
- throw new Error('Import data is empty.');
- }
-
- const practiceRecords = [];
- const sources = [];
- let userStats = null;
-
- if (this.isPlainObject(payload)) {
- const directStats = payload.user_stats
- ?? payload.userStats
- ?? payload.stats
- ?? payload.data?.user_stats
- ?? payload.data?.userStats
- ?? payload.data?.stats;
- if (this.isPlainObject(directStats)) {
- userStats = this.prepareUserStats(directStats);
- }
- }
-
- this.extractRecordSources(payload).forEach(({ records, source }) => {
- const normalizedRecords = records
- .map((record, index) => this.normalizeRecord(record, {
- preserveIds,
- fallbackIdPrefix: source || 'record',
- index
- }))
- .filter(Boolean);
-
- if (normalizedRecords.length) {
- practiceRecords.push(...normalizedRecords);
- sources.push({ path: source || '(root array)', count: normalizedRecords.length });
- }
- });
-
- // Dual-schema payloads and multi-path recovery can surface the same id twice;
- // keep first occurrence so replace-mode import does not invent duplicates.
- const seenIds = new Set();
- const dedupedPracticeRecords = [];
- practiceRecords.forEach((record) => {
- if (!record || typeof record !== 'object') {
- return;
- }
- const id = record.id != null ? String(record.id) : null;
- if (id) {
- if (seenIds.has(id)) {
- return;
- }
- seenIds.add(id);
- }
- dedupedPracticeRecords.push(record);
- });
-
- return {
- practiceRecords: dedupedPracticeRecords,
- userStats,
- sources
- };
- }
-
- extractRecordSources(payload) {
- const sources = [];
- const add = (source, records) => {
- if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) {
- sources.push({ source, records });
- }
- };
- // App backups write dual aliases (practice_records + practiceRecords) for the same list.
- // Prefer the first non-empty array so replace-mode import does not double-append.
- const addPreferred = (candidates) => {
- for (const { source, records } of candidates) {
- if (Array.isArray(records) && records.some(item => this.isPlainObject(item))) {
- add(source, records);
- return true;
- }
- }
- return false;
- };
-
- if (Array.isArray(payload)) {
- add('(root array)', payload);
- return sources;
- }
- if (!this.isPlainObject(payload)) {
- return sources;
- }
-
- addPreferred([
- { source: 'practice_records', records: payload.practice_records },
- { source: 'practiceRecords', records: payload.practiceRecords }
- ]);
-
- const data = this.isPlainObject(payload.data) ? payload.data : {};
- const dataArrayPicked = addPreferred([
- { source: 'data.practice_records', records: data.practice_records },
- { source: 'data.practiceRecords', records: data.practiceRecords }
- ]);
- // Envelope form only when the preferred alias was not already a plain array source.
- if (!dataArrayPicked && this.isPlainObject(data.practice_records)) {
- add('data.practice_records.data', data.practice_records.data);
- } else if (!dataArrayPicked && this.isPlainObject(data.practiceRecords)) {
- add('data.practiceRecords.data', data.practiceRecords.data);
- }
- if (this.isPlainObject(data.exam_system_practice_records)) {
- add('data.exam_system_practice_records.data', data.exam_system_practice_records.data);
- }
- if (this.isPlainObject(payload.exam_system_practice_records)) {
- add('exam_system_practice_records.data', payload.exam_system_practice_records.data);
- }
-
- return sources;
- }
-
- async mergePracticeRecords(newRecords, mergeMode = 'merge', options = {}) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.mergeRecords === 'function') {
- return await window.PracticeRecordAPI.mergeRecords(
- Array.isArray(newRecords) ? newRecords : [],
- {
- mergeMode,
- updateStats: options.updateStats !== false
- }
- );
- }
-
- throw new Error('统一练习记录导入 API 未就绪');
- }
-
- prepareUserStats(candidate) {
- if (!this.isPlainObject(candidate)) {
- return null;
- }
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.prepareStats === 'function') {
- return window.PracticeRecordAPI.prepareStats(candidate);
- }
- throw new Error('统一练习统计 API 未就绪');
- }
-
- normalizeRecord(record, options = {}) {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.normalizeRecord === 'function') {
- return window.PracticeRecordAPI.normalizeRecord(record, options);
- }
- throw new Error('统一练习记录标准化 API 未就绪');
- }
- normalizeDateValue(value) {
- if (!value) {
- return null;
- }
-
- if (value instanceof Date && !Number.isNaN(value.getTime())) {
- return value.toISOString();
- }
-
- if (typeof value === 'number' && Number.isFinite(value)) {
- return new Date(value).toISOString();
- }
-
- if (typeof value === 'string') {
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
-
- if (/^\d+$/.test(trimmed)) {
- const numeric = Number(trimmed);
- if (Number.isFinite(numeric)) {
- const milliseconds = trimmed.length > 10 ? numeric : numeric * 1000;
- return new Date(milliseconds).toISOString();
- }
- }
-
- const parsed = new Date(trimmed);
- if (!Number.isNaN(parsed.getTime())) {
- return parsed.toISOString();
- }
- }
-
- return null;
- }
-
- getRecordTimestamp(record) {
- if (!record) {
- return 0;
- }
-
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.timestamp,
- record.date
- ];
-
- for (const candidate of candidates) {
- const iso = this.normalizeDateValue(candidate);
- if (iso) {
- const time = new Date(iso).getTime();
- if (Number.isFinite(time)) {
- return time;
- }
- }
- }
-
- return 0;
- }
-
- filterByDateRange(records, dateRange) {
- const { startDate, endDate } = dateRange;
- return (records || []).filter(record => {
- const value = this.normalizeDateValue(record?.startTime ?? record?.createdAt ?? record?.timestamp);
- if (!value) {
- return false;
- }
-
- const recordDate = new Date(value);
- if (startDate && recordDate < new Date(startDate)) {
- return false;
- }
- if (endDate && recordDate > new Date(endDate)) {
- return false;
- }
- return true;
- });
- }
-
- compressData(data) {
- try {
- if (window.pako && typeof window.pako.gzip === 'function') {
- return window.pako.gzip(data, { to: 'string' });
- }
- } catch (error) {
- console.warn('[DataBackupManager] compression failed', error);
- }
- return data;
- }
- async createPreImportBackup() {
- try {
- // 与 createBackup 共用 unshift + pop 裁剪,避免 push+shift 误删最新用户备份
- return await this.createBackup(`pre_import_${Date.now()}`, 'pre_import');
- } catch (error) {
- console.error('[DataBackupManager] failed to create backup', error);
- return null;
- }
- }
-
- async restoreBackup(backupId) {
- if (!backupId) {
- throw new Error('Invalid backup id.');
- }
-
- try {
- if (window.BackupAPI && typeof window.BackupAPI.restore === 'function') {
- const result = await window.BackupAPI.restore(backupId);
- return result.backup;
- }
-
- const backups = await storage.get(this.storageKeys.manualBackups, []);
- const backup = backups.find(item => item.id === backupId);
- if (!backup) {
- throw new Error(`Backup ${backupId} not found.`);
- }
-
- const data = backup.data || {};
- const records = Array.isArray(data.practice_records)
- ? data.practice_records
- : (Array.isArray(data.practiceRecords) ? data.practiceRecords : []);
- const stats = this.isPlainObject(data.user_stats)
- ? data.user_stats
- : (this.isPlainObject(data.userStats) ? data.userStats : null);
-
- await this.restorePracticeRecords(records, stats);
-
- const examIndex = Array.isArray(data.exam_index)
- ? data.exam_index
- : (Array.isArray(data.examIndex) ? data.examIndex : null);
- if (examIndex) {
- await storage.set('exam_index', examIndex);
- }
-
- return backup;
- } catch (error) {
- console.error('[DataBackupManager] backup restore failed', error);
- throw error;
- }
- }
-
- async clearData(options = {}) {
- const {
- clearPracticeRecords = false,
- clearUserStats = false,
- clearBackups = false,
- clearSettings = false,
- createBackup = true
- } = options;
-
- let backupId = null;
- if (createBackup) {
- backupId = await this.createPreImportBackup();
- }
-
- const clearedItems = [];
-
- if (clearPracticeRecords) {
- await this.replacePracticeRecords([], { updateStats: !clearUserStats });
- clearedItems.push('practice_records');
- if (!clearUserStats) {
- clearedItems.push('user_stats');
- }
- }
-
- if (clearUserStats) {
- await this.resetUserStats();
- clearedItems.push('user_stats');
- }
-
- if (clearBackups) {
- if (window.BackupAPI && typeof window.BackupAPI.clear === 'function') {
- await window.BackupAPI.clear();
- } else {
- await storage.set(this.storageKeys.manualBackups, []);
- }
- if (typeof storage.remove === 'function') {
- await storage.remove('backup_data');
- }
- clearedItems.push('backups');
- }
-
- if (clearSettings) {
- await storage.remove('settings');
- await storage.remove(this.storageKeys.backupSettings);
- clearedItems.push('settings');
- }
-
- return {
- success: true,
- clearedItems,
- backupId
- };
- }
-
- async recordExportHistory(info) {
- const history = await storage.get(this.storageKeys.exportHistory, []);
- history.push({ ...info, id: `export_${Date.now()}` });
- while (history.length > this.maxExportHistory) {
- history.shift();
- }
- await storage.set(this.storageKeys.exportHistory, history);
- }
-
- async recordImportHistory(info) {
- const history = await storage.get(this.storageKeys.importHistory, []);
- history.push({ ...info, id: `import_${Date.now()}` });
- while (history.length > this.maxExportHistory) {
- history.shift();
- }
- await storage.set(this.storageKeys.importHistory, history);
- }
-
- async getExportHistory() {
- const history = await storage.get(this.storageKeys.exportHistory, []);
- return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- }
-
- async getImportHistory() {
- const history = await storage.get(this.storageKeys.importHistory, []);
- return history.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
- }
- async getDataStats() {
- try {
- const practiceRecords = await this.listPracticeRecords();
- const userStats = await this.readUserStats();
- const exportHistory = await this.getExportHistory();
- const importHistory = await this.getImportHistory();
- const storageInfo = typeof storage.getStorageInfo === 'function' ? await storage.getStorageInfo() : null;
-
- const recordsArray = Array.isArray(practiceRecords) ? practiceRecords : [];
-
- return {
- practiceRecords: {
- count: recordsArray.length,
- oldestRecord: recordsArray.length ? recordsArray[0]?.startTime : null,
- newestRecord: recordsArray.length ? recordsArray[recordsArray.length - 1]?.startTime : null
- },
- userStats: {
- totalPractices: userStats?.totalPractices ?? 0,
- totalTimeSpent: userStats?.totalTimeSpent ?? 0,
- averageScore: userStats?.averageScore ?? 0
- },
- exportHistory: {
- count: exportHistory.length,
- lastExport: exportHistory.length ? exportHistory[0].timestamp : null
- },
- importHistory: {
- count: importHistory.length,
- lastImport: importHistory.length ? importHistory[0].timestamp : null
- },
- storage: storageInfo
- };
- } catch (error) {
- console.error('[DataBackupManager] failed to collect stats', error);
- return null;
- }
- }
-
- setupPeriodicCleanup() {
- if (this.cleanupTimer) {
- clearInterval(this.cleanupTimer);
- }
-
- this.cleanupTimer = setInterval(() => {
- this.cleanupExpiredData().catch(error => console.error('[DataBackupManager] cleanup failed', error));
- }, 24 * 60 * 60 * 1000);
- }
-
- async cleanupExpiredData() {
- try {
- const limit = 30 * 24 * 60 * 60 * 1000;
- const now = Date.now();
-
- const exportHistory = await storage.get(this.storageKeys.exportHistory, []);
- const freshExports = exportHistory.filter(item => now - new Date(item.timestamp).getTime() < limit);
- if (freshExports.length !== exportHistory.length) {
- await storage.set(this.storageKeys.exportHistory, freshExports);
- }
-
- const importHistory = await storage.get(this.storageKeys.importHistory, []);
- const freshImports = importHistory.filter(item => now - new Date(item.timestamp).getTime() < limit);
- if (freshImports.length !== importHistory.length) {
- await storage.set(this.storageKeys.importHistory, freshImports);
- }
- } catch (error) {
- console.error('[DataBackupManager] cleanup error', error);
- }
- }
-
- getTimestamp() {
- return new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
- }
-
- toCamelCaseKey(key) {
- return String(key)
- .replace(/[-_\s]+([a-zA-Z0-9])/g, (_, group) => group.toUpperCase())
- .replace(/^[A-Z]/, match => match.toLowerCase());
- }
-
- isPlainObject(value) {
- return Object.prototype.toString.call(value) === '[object Object]';
- }
-}
-
-window.DataBackupManager = DataBackupManager;
diff --git a/js/utils/environmentDetector.js b/js/utils/environmentDetector.js
index 9df4d550..4e92d917 100644
--- a/js/utils/environmentDetector.js
+++ b/js/utils/environmentDetector.js
@@ -3,34 +3,8 @@
return;
}
- const FLAG_KEY = '__ielts_test_env__';
const LOCATION_HINTS = ['test_env=1', 'suite_test=1', 'ci=1'];
- const readStorageFlag = () => {
- try {
- if (global.localStorage) {
- return global.localStorage.getItem(FLAG_KEY) === 'true';
- }
- } catch (error) {
- console.warn('[EnvDetector] 无法读取测试标记:', error);
- }
- return false;
- };
-
- const persistFlag = (value) => {
- try {
- if (global.localStorage) {
- if (value) {
- global.localStorage.setItem(FLAG_KEY, 'true');
- } else {
- global.localStorage.removeItem(FLAG_KEY);
- }
- }
- } catch (error) {
- console.warn('[EnvDetector] 无法写入测试标记:', error);
- }
- };
-
const shouldActivateFromLocation = () => {
if (!global.location) {
return false;
@@ -47,33 +21,19 @@
}
if (shouldActivateFromLocation()) {
- this.enableTestEnvironment({ persist: true });
- return true;
- }
-
- if (readStorageFlag()) {
- global.__IELTS_FORCE_TEST_ENV__ = true;
- return true;
- }
-
- const userAgent = (global.navigator && global.navigator.userAgent) || '';
- if (/\b(playwright|puppeteer|headlesschrome)\b/i.test(userAgent)) {
+ this.enableTestEnvironment();
return true;
}
return false;
},
- enableTestEnvironment(options = {}) {
+ enableTestEnvironment() {
global.__IELTS_FORCE_TEST_ENV__ = true;
- if (options.persist !== false) {
- persistFlag(true);
- }
},
disableTestEnvironment() {
global.__IELTS_FORCE_TEST_ENV__ = false;
- persistFlag(false);
}
};
diff --git a/js/utils/logger.js b/js/utils/logger.js
index 9cf76c43..6f6197c8 100644
--- a/js/utils/logger.js
+++ b/js/utils/logger.js
@@ -8,8 +8,6 @@
return;
}
- const STORAGE_KEY = 'exam_system_log_config_v2';
-
// Default configuration
const DEFAULT_CONFIG = {
level: 'info',
@@ -19,7 +17,7 @@
'PerformanceOptimizer': 'warn',
'System': 'info',
'PracticeRecorder': 'info',
- 'ScoreStorage': 'info'
+ 'DataKernel': 'warn'
}
};
@@ -45,6 +43,7 @@
this.debug = this.debug.bind(this);
this.overrideConsole();
+ Promise.resolve().then(() => this.hydrateConfig());
// Output initialization message
this.internalLog('info', 'Logger initialized', {
@@ -54,41 +53,47 @@
}
/**
- * Load configuration from localStorage or use defaults
+ * Build configuration from defaults and explicit bootstrap overrides.
*/
loadConfig(externalConfig) {
- let storedConfig = {};
- try {
- const stored = global.localStorage.getItem(STORAGE_KEY);
- if (stored) {
- storedConfig = JSON.parse(stored);
- }
- } catch (e) {
- // Ignore storage errors
- }
-
return {
- level: externalConfig.level || storedConfig.level || DEFAULT_CONFIG.level,
+ level: externalConfig.level || DEFAULT_CONFIG.level,
categories: {
...DEFAULT_CONFIG.categories,
- ...(storedConfig.categories || {}),
...(externalConfig.categories || {})
}
};
}
+ async hydrateConfig() {
+ try {
+ if (!global.AppData) return;
+ await global.AppData.ready;
+ const storedConfig = await global.AppData.preferences.getLogConfig();
+ if (!storedConfig || typeof storedConfig !== 'object') return;
+ this.config = {
+ level: storedConfig.level || this.config.level,
+ categories: { ...this.config.categories, ...(storedConfig.categories || {}) }
+ };
+ } catch (error) {
+ this.nativeConsole.warn('[AppLogger] 无法读取日志配置:', error);
+ }
+ }
+
/**
- * Save current configuration to localStorage
+ * Save current configuration through the preferences domain.
*/
saveConfig() {
- try {
- global.localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ if (!global.AppData) return Promise.resolve(false);
+ return global.AppData.ready.then(() =>
+ global.AppData.preferences.setLogConfig({
level: this.config.level,
categories: this.config.categories
- }));
- } catch (e) {
- // Ignore storage errors
- }
+ })
+ ).then(() => true).catch((error) => {
+ this.nativeConsole.warn('[AppLogger] 无法保存日志配置:', error);
+ return false;
+ });
}
/**
diff --git a/js/utils/markdownExporter.js b/js/utils/markdownExporter.js
index 5f1df08d..0fde777f 100644
--- a/js/utils/markdownExporter.js
+++ b/js/utils/markdownExporter.js
@@ -98,22 +98,11 @@ class MarkdownExporter {
}
return comparison;
}
- constructor() {
- this.storage = window.storage;
- }
-
async getPracticeRecordsUnified() {
- if (window.PracticeRecordAPI && typeof window.PracticeRecordAPI.list === 'function') {
- try {
- const records = await window.PracticeRecordAPI.list();
- return Array.isArray(records) ? records : [];
- } catch (error) {
- console.warn('[MarkdownExporter] 从 PracticeRecordAPI 获取练习记录失败:', error);
- return [];
- }
- }
-
- return [];
+ if (!window.AppData || !window.AppData.practice) throw new Error('AppData.practice is unavailable');
+ await window.AppData.ready;
+ const records = await window.AppData.practice.list({ projection: 'full' });
+ return Array.isArray(records) ? records : [];
}
/**
@@ -166,30 +155,15 @@ class MarkdownExporter {
*/
async performExport() {
try {
- // 尝试从不同的数据源获取记录
let practiceRecords = [];
- let examIndex = [];
this.updateProgress('正在加载数据...');
// 让出控制权
await new Promise(resolve => setTimeout(resolve, 10));
- // 只使用统一 PracticeRecordAPI 数据
+ // 只使用统一 practice domain 数据
practiceRecords = await this.getPracticeRecordsUnified();
-
- // examIndex 仍从存储/全局读取
- if (this.storage && typeof this.storage.get === 'function') {
- try {
- const idx = await this.storage.get('exam_index', []);
- examIndex = Array.isArray(idx) ? idx : [];
- } catch (_) {
- examIndex = [];
- }
- }
- if ((!Array.isArray(examIndex) || examIndex.length === 0) && window.examIndex) {
- examIndex = Array.isArray(window.examIndex) ? window.examIndex : [];
- }
if (practiceRecords.length === 0) {
throw new Error('没有练习记录可导出');
@@ -224,7 +198,7 @@ class MarkdownExporter {
await new Promise(resolve => setTimeout(resolve, 10));
// 按日期分组记录
- const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords, examIndex);
+ const recordsByDate = await this.groupRecordsByDateAsync(practiceRecords);
// 生成 Markdown 内容
const markdownContent = await this.generateMarkdownContentAsync(recordsByDate);
@@ -280,7 +254,27 @@ class MarkdownExporter {
/**
* 异步按日期分组记录
*/
- async groupRecordsByDateAsync(practiceRecords, examIndex) {
+ async resolveExamForRecord(record) {
+ if (typeof window.resolveExamForPracticeRecord !== 'function') {
+ return null;
+ }
+ return window.resolveExamForPracticeRecord(record);
+ }
+
+ enhanceRecordForExport(record, exam = null) {
+ const metadata = record && record.metadata && typeof record.metadata === 'object'
+ ? record.metadata
+ : {};
+ return {
+ ...record,
+ examInfo: exam || {},
+ title: record.title || metadata.examTitle || exam?.title || '未知题目',
+ category: record.category || metadata.category || exam?.category || 'Unknown',
+ frequency: record.frequency || metadata.frequency || exam?.frequency || 'unknown'
+ };
+ }
+
+ async groupRecordsByDateAsync(practiceRecords) {
const grouped = {};
for (let i = 0; i < practiceRecords.length; i++) {
@@ -293,15 +287,8 @@ class MarkdownExporter {
grouped[date] = [];
}
- // 获取考试信息
- const exam = examIndex.find(e => e.id === record.examId);
- const enhancedRecord = {
- ...record,
- examInfo: exam || {},
- title: exam?.title || record.title || '未知题目',
- category: exam?.category || record.category || 'Unknown',
- frequency: exam?.frequency || record.frequency || 'unknown'
- };
+ const exam = await this.resolveExamForRecord(record);
+ const enhancedRecord = this.enhanceRecordForExport(record, exam);
grouped[date].push(enhancedRecord);
@@ -317,7 +304,7 @@ class MarkdownExporter {
/**
* 按日期分组记录(同步版本,保持兼容性)
*/
- groupRecordsByDate(practiceRecords, examIndex) {
+ groupRecordsByDate(practiceRecords) {
const grouped = {};
practiceRecords.forEach(record => {
@@ -328,15 +315,7 @@ class MarkdownExporter {
grouped[date] = [];
}
- // 获取考试信息
- const exam = examIndex.find(e => e.id === record.examId);
- const enhancedRecord = {
- ...record,
- examInfo: exam || {},
- title: exam?.title || record.title || '未知题目',
- category: exam?.category || record.category || 'Unknown',
- frequency: exam?.frequency || record.frequency || 'unknown'
- };
+ const enhancedRecord = this.enhanceRecordForExport(record);
grouped[date].push(enhancedRecord);
});
diff --git a/js/utils/practiceTimerPreferences.js b/js/utils/practiceTimerPreferences.js
index b5f40b0e..3a726fbd 100644
--- a/js/utils/practiceTimerPreferences.js
+++ b/js/utils/practiceTimerPreferences.js
@@ -1,8 +1,6 @@
(function initPracticeTimerPreferences(global) {
'use strict';
- var READING_KEY = 'ielts_reading_timer_preferences_v2';
- var LISTENING_KEY = 'ielts_listening_timer_preferences_v1';
var VERSION = 1;
var DEFAULTS = {
version: VERSION,
@@ -39,26 +37,38 @@
};
}
- function keyFor(scope) {
- return String(scope || '').toLowerCase() === 'listening' ? LISTENING_KEY : READING_KEY;
+ var cache = Object.create(null);
+ var hydrationPromise = null;
+ function normalizeScope(scope) { return String(scope || '').toLowerCase() === 'listening' ? 'listening' : 'reading'; }
+ function hydrateTimerPreferences() {
+ if (cache.reading && cache.listening) return Promise.resolve(true);
+ if (hydrationPromise) return hydrationPromise;
+ if (!global.AppData || !global.AppData.preferences) return Promise.resolve(false);
+ hydrationPromise = Promise.resolve().then(async function loadTimerPreferences() {
+ await global.AppData.ready;
+ var stored = await global.AppData.preferences.getTimer();
+ cache.reading = normalize(stored && stored.reading);
+ cache.listening = normalize(stored && stored.listening);
+ return true;
+ }).catch(function onTimerPreferenceLoadError(error) {
+ hydrationPromise = null;
+ console.warn('[PracticeTimerPreferences] 加载失败:', error);
+ return false;
+ });
+ return hydrationPromise;
}
function read(scope) {
- try {
- var raw = global.localStorage && global.localStorage.getItem(keyFor(scope));
- return normalize(raw ? JSON.parse(raw) : null);
- } catch (_) {
- return normalize(null);
- }
+ return normalize(cache[normalizeScope(scope)]);
}
- function save(scope, preferences) {
+ async function save(scope, preferences) {
+ await hydrateTimerPreferences();
+ if (!global.AppData || !global.AppData.preferences) throw new Error('AppData.preferences is unavailable');
+ var normalizedScope = normalizeScope(scope);
var next = normalize(preferences);
- try {
- if (global.localStorage) {
- global.localStorage.setItem(keyFor(scope), JSON.stringify(next));
- }
- } catch (_) { }
+ await global.AppData.preferences.setTimer(normalizedScope, next);
+ cache[normalizedScope] = next;
return next;
}
@@ -66,15 +76,14 @@
return clampMinutes(value, DEFAULTS.countdownMinutes) * 60;
}
- global.PracticeTimerPreferences = {
+ var api = {
VERSION: VERSION,
- READING_KEY: READING_KEY,
- LISTENING_KEY: LISTENING_KEY,
DEFAULTS: Object.freeze(Object.assign({}, DEFAULTS)),
normalize: normalize,
read: read,
save: save,
- keyFor: keyFor,
minutesToSeconds: minutesToSeconds
};
+ Object.defineProperty(api, 'ready', { enumerable: true, get: hydrateTimerPreferences });
+ global.PracticeTimerPreferences = api;
})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/utils/safeObjectLiteralParser.js b/js/utils/safeObjectLiteralParser.js
new file mode 100644
index 00000000..bf3cd185
--- /dev/null
+++ b/js/utils/safeObjectLiteralParser.js
@@ -0,0 +1,300 @@
+(function (root, factory) {
+ 'use strict';
+
+ var api = factory();
+ if (typeof module === 'object' && module.exports) {
+ module.exports = api;
+ }
+ if (root) {
+ root.SafeObjectLiteralParser = api;
+ }
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ var DEFAULT_LIMITS = Object.freeze({
+ maxInputLength: 1024 * 1024,
+ maxDepth: 40,
+ maxProperties: 5000,
+ maxStringLength: 256 * 1024
+ });
+ function ParseError(message, index) {
+ this.name = 'SafeObjectLiteralParseError';
+ this.message = message + ' at index ' + index;
+ this.index = index;
+ if (Error.captureStackTrace) Error.captureStackTrace(this, ParseError);
+ }
+ ParseError.prototype = Object.create(Error.prototype);
+ ParseError.prototype.constructor = ParseError;
+
+ function makeLimits(options) {
+ options = options || {};
+ var limits = {};
+ Object.keys(DEFAULT_LIMITS).forEach(function (key) {
+ var configured = Number(options[key]);
+ limits[key] = Number.isFinite(configured) && configured > 0
+ ? Math.floor(configured)
+ : DEFAULT_LIMITS[key];
+ });
+ return limits;
+ }
+
+ function Parser(source, options) {
+ if (typeof source !== 'string') throw new TypeError('source must be a string');
+ this.source = source;
+ this.length = source.length;
+ this.index = 0;
+ this.depth = 0;
+ this.propertyCount = 0;
+ this.limits = makeLimits(options);
+ if (this.length > this.limits.maxInputLength) {
+ throw new ParseError('input exceeds maximum length', 0);
+ }
+ }
+
+ Parser.prototype.fail = function (message) {
+ throw new ParseError(message, this.index);
+ };
+
+ Parser.prototype.skipSpace = function () {
+ while (this.index < this.length) {
+ var ch = this.source.charAt(this.index);
+ if (/\s/.test(ch)) {
+ this.index++;
+ continue;
+ }
+ if (ch === '/' && this.source.charAt(this.index + 1) === '/') {
+ this.index += 2;
+ while (this.index < this.length && !/[\r\n]/.test(this.source.charAt(this.index))) {
+ this.index++;
+ }
+ continue;
+ }
+ if (ch === '/' && this.source.charAt(this.index + 1) === '*') {
+ var end = this.source.indexOf('*/', this.index + 2);
+ if (end < 0) this.fail('unterminated block comment');
+ this.index = end + 2;
+ continue;
+ }
+ break;
+ }
+ };
+
+ Parser.prototype.enter = function () {
+ this.depth++;
+ if (this.depth > this.limits.maxDepth) this.fail('maximum nesting depth exceeded');
+ };
+
+ Parser.prototype.leave = function () {
+ this.depth--;
+ };
+
+ Parser.prototype.countProperty = function () {
+ this.propertyCount++;
+ if (this.propertyCount > this.limits.maxProperties) {
+ this.fail('maximum property count exceeded');
+ }
+ };
+
+ Parser.prototype.parseString = function () {
+ var quote = this.source.charAt(this.index++);
+ var result = '';
+ while (this.index < this.length) {
+ var ch = this.source.charAt(this.index++);
+ if (ch === quote) return result;
+ if (ch === '\r' || ch === '\n') this.fail('unescaped newline in string');
+ if (ch !== '\\') {
+ result += ch;
+ } else {
+ if (this.index >= this.length) this.fail('unterminated string escape');
+ var escape = this.source.charAt(this.index++);
+ var simple = {
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+ v: '\v',
+ '0': '\0',
+ '\\': '\\',
+ '/': '/',
+ '"': '"',
+ "'": "'"
+ };
+ if (Object.prototype.hasOwnProperty.call(simple, escape)) {
+ if (escape === '0' && /[0-9]/.test(this.source.charAt(this.index))) {
+ this.fail('legacy octal escapes are not supported');
+ }
+ result += simple[escape];
+ } else if (escape === 'x') {
+ var hex = this.source.slice(this.index, this.index + 2);
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) this.fail('invalid hex escape');
+ result += String.fromCharCode(parseInt(hex, 16));
+ this.index += 2;
+ } else if (escape === 'u') {
+ var unicode = this.source.slice(this.index, this.index + 4);
+ if (!/^[0-9a-fA-F]{4}$/.test(unicode)) this.fail('invalid unicode escape');
+ result += String.fromCharCode(parseInt(unicode, 16));
+ this.index += 4;
+ } else {
+ this.fail('unsupported string escape');
+ }
+ }
+ if (result.length > this.limits.maxStringLength) {
+ this.fail('string exceeds maximum length');
+ }
+ }
+ this.fail('unterminated string');
+ };
+
+ Parser.prototype.parseNumber = function () {
+ var remaining = this.source.slice(this.index);
+ var match = remaining.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
+ if (!match) this.fail('invalid number');
+ var next = remaining.charAt(match[0].length);
+ if (next && /[A-Za-z0-9_$\.]/.test(next)) this.fail('invalid number suffix');
+ this.index += match[0].length;
+ var value = Number(match[0]);
+ if (!Number.isFinite(value)) this.fail('non-finite numbers are not supported');
+ return value;
+ };
+
+ Parser.prototype.parseIdentifier = function () {
+ var match = this.source.slice(this.index).match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
+ if (!match) this.fail('expected identifier');
+ this.index += match[0].length;
+ return match[0];
+ };
+
+ Parser.prototype.parseKey = function () {
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ var key;
+ if (ch === '"' || ch === "'") {
+ key = this.parseString();
+ } else if (/[A-Za-z_$]/.test(ch)) {
+ key = this.parseIdentifier();
+ } else {
+ var match = this.source.slice(this.index).match(/^(?:0|[1-9]\d*)/);
+ if (!match) this.fail('object keys must be quoted strings, identifiers, or integers');
+ key = match[0];
+ this.index += match[0].length;
+ }
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
+ this.fail('forbidden object key "' + key + '"');
+ }
+ return key;
+ };
+
+ Parser.prototype.parseObject = function () {
+ var result = Object.create(null);
+ this.index++;
+ this.enter();
+ this.skipSpace();
+ if (this.source.charAt(this.index) === '}') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ while (this.index < this.length) {
+ var key = this.parseKey();
+ this.countProperty();
+ this.skipSpace();
+ if (this.source.charAt(this.index) !== ':') {
+ this.fail('object properties require a colon');
+ }
+ this.index++;
+ result[key] = this.parseValue();
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ if (ch === '}') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ if (ch !== ',') this.fail('expected comma or closing brace');
+ this.index++;
+ this.skipSpace();
+ if (this.source.charAt(this.index) === '}') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ }
+ this.fail('unterminated object');
+ };
+
+ Parser.prototype.parseArray = function () {
+ var result = [];
+ this.index++;
+ this.enter();
+ this.skipSpace();
+ if (this.source.charAt(this.index) === ']') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ while (this.index < this.length) {
+ this.countProperty();
+ result.push(this.parseValue());
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ if (ch === ']') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ if (ch !== ',') this.fail('expected comma or closing bracket');
+ this.index++;
+ this.skipSpace();
+ if (this.source.charAt(this.index) === ']') {
+ this.index++;
+ this.leave();
+ return result;
+ }
+ }
+ this.fail('unterminated array');
+ };
+
+ Parser.prototype.parseValue = function () {
+ this.skipSpace();
+ var ch = this.source.charAt(this.index);
+ if (ch === '{') return this.parseObject();
+ if (ch === '[') return this.parseArray();
+ if (ch === '"' || ch === "'") return this.parseString();
+ if (ch === '-' || /[0-9]/.test(ch)) return this.parseNumber();
+ if (/[A-Za-z_$]/.test(ch)) {
+ var identifier = this.parseIdentifier();
+ if (identifier === 'true') return true;
+ if (identifier === 'false') return false;
+ if (identifier === 'null') return null;
+ this.fail('unsupported value "' + identifier + '"');
+ }
+ this.fail('unsupported value');
+ };
+
+ function parseAt(source, startIndex, options) {
+ var parser = new Parser(source, options);
+ parser.index = Math.max(0, Number(startIndex) || 0);
+ parser.skipSpace();
+ if (parser.source.charAt(parser.index) !== '{') {
+ parser.fail('expected object literal');
+ }
+ var value = parser.parseObject();
+ return { value: value, endIndex: parser.index };
+ }
+
+ function parse(source, options) {
+ var parsed = parseAt(source, 0, options);
+ var parser = new Parser(source, options);
+ parser.index = parsed.endIndex;
+ parser.skipSpace();
+ if (parser.index !== parser.length) parser.fail('unexpected trailing input');
+ return parsed.value;
+ }
+
+ return Object.freeze({
+ ParseError: ParseError,
+ parse: parse,
+ parseAt: parseAt
+ });
+});
diff --git a/js/utils/simpleStorageWrapper.js b/js/utils/simpleStorageWrapper.js
deleted file mode 100644
index 1dd90b1c..00000000
--- a/js/utils/simpleStorageWrapper.js
+++ /dev/null
@@ -1,183 +0,0 @@
-(function(window) {
- class SimpleStorageWrapper {
- constructor(repositories) {
- this.repos = repositories;
- }
-
- get settingsRepo() { return this.repos.settings; }
- get backupRepo() { return this.repos.backups; }
- get metaRepo() { return this.repos.meta; }
-
- isPracticeDataKey(key) {
- return key === 'practice_records' || key === 'user_stats';
- }
-
- getPracticeRecordAPI() {
- const api = window.PracticeRecordAPI;
- if (!api) {
- throw new Error('PracticeRecordAPI unavailable');
- }
- return api;
- }
-
- rejectPracticeDataWrite(methodName, targetName) {
- throw new Error(`SimpleStorageWrapper.${methodName} is disabled; use ${targetName}`);
- }
-
- async getPracticeRecords() {
- const api = this.getPracticeRecordAPI();
- if (typeof api.list !== 'function') {
- throw new Error('PracticeRecordAPI.list unavailable');
- }
- return await api.list();
- }
-
- async savePracticeRecords() {
- this.rejectPracticeDataWrite('savePracticeRecords', 'PracticeRecordAPI.replace');
- }
-
- async addPracticeRecord() {
- this.rejectPracticeDataWrite('addPracticeRecord', 'PracticeRecordAPI.saveRecord');
- }
-
- async getById(id) {
- const api = this.getPracticeRecordAPI();
- if (typeof api.getById !== 'function') {
- throw new Error('PracticeRecordAPI.getById unavailable');
- }
- return await api.getById(id);
- }
-
- async update() {
- this.rejectPracticeDataWrite('update', 'PracticeRecordAPI.saveRecord');
- }
-
- async delete() {
- this.rejectPracticeDataWrite('delete', 'PracticeRecordAPI.deleteById');
- }
-
- async deletePracticeRecord() {
- this.rejectPracticeDataWrite('deletePracticeRecord', 'PracticeRecordAPI.deleteById');
- }
-
- async deletePracticeRecords() {
- this.rejectPracticeDataWrite('deletePracticeRecords', 'PracticeRecordAPI.deleteMany');
- }
-
- async getPracticeRecordsCount() {
- const records = await this.getPracticeRecords();
- return Array.isArray(records) ? records.length : 0;
- }
-
- validatePracticeRecord(record) {
- const errors = [];
- if (!record || typeof record !== 'object') {
- errors.push('记录必须是对象');
- } else {
- if (!record.id || typeof record.id !== 'string') {
- errors.push('记录缺少有效的 id');
- }
- if (!record.type || typeof record.type !== 'string') {
- errors.push('记录缺少有效的 type');
- }
- if (record.score === undefined || record.score === null || typeof record.score !== 'number') {
- errors.push('记录缺少有效的 score');
- }
- if (record.totalQuestions !== undefined && typeof record.totalQuestions !== 'number') {
- errors.push('totalQuestions 必须是数字');
- }
- if (record.correctAnswers !== undefined && typeof record.correctAnswers !== 'number') {
- errors.push('correctAnswers 必须是数字');
- }
- if (record.duration !== undefined && typeof record.duration !== 'number') {
- errors.push('duration 必须是数字');
- }
- if (!record.date) {
- errors.push('记录缺少有效的 date');
- } else if (Number.isNaN(new Date(record.date).getTime())) {
- errors.push('date 格式无效');
- }
- }
- return {
- isValid: errors.length === 0,
- errors
- };
- }
-
- async getUserSettings() { return await this.settingsRepo.getAll(); }
- async saveUserSettings(settings) { await this.settingsRepo.saveAll(settings); return true; }
- async getUserSetting(key, defaultValue = null) { return await this.settingsRepo.get(key, defaultValue); }
- async setUserSetting(key, value) { await this.settingsRepo.set(key, value); return true; }
-
- async getBackups() { return await this.backupRepo.list(); }
- async saveBackups(backups) { await this.backupRepo.saveAll(backups); return true; }
- async addBackup(backup) { await this.backupRepo.add(backup); return true; }
- async deleteBackup(id) { return await this.backupRepo.delete(id); }
- async clearBackups() { await this.backupRepo.clear(); return true; }
-
- async get(key, defaultValue = null) {
- if (this.isPracticeDataKey(key)) {
- const api = this.getPracticeRecordAPI();
- if (key === 'practice_records') {
- if (typeof api.list !== 'function') {
- throw new Error('PracticeRecordAPI.list unavailable');
- }
- return await api.list();
- }
- if (typeof api.readStats !== 'function') {
- throw new Error('PracticeRecordAPI.readStats unavailable');
- }
- return await api.readStats({ fallback: defaultValue });
- }
- return await this.metaRepo.get(key, defaultValue);
- }
-
- async set(key, value) {
- if (this.isPracticeDataKey(key)) {
- if (key === 'practice_records') {
- this.rejectPracticeDataWrite('set(practice_records)', 'PracticeRecordAPI.replace');
- }
- this.rejectPracticeDataWrite('set(user_stats)', 'PracticeRecordAPI.writeStats');
- }
- await this.metaRepo.set(key, value);
- return true;
- }
-
- async remove(key) {
- if (this.isPracticeDataKey(key)) {
- if (key === 'practice_records') {
- this.rejectPracticeDataWrite('remove(practice_records)', 'PracticeRecordAPI.clear');
- }
- this.rejectPracticeDataWrite('remove(user_stats)', 'PracticeRecordAPI.resetStats');
- }
- await this.metaRepo.remove(key);
- return true;
- }
- }
-
- function connectWrapper(repositories) {
- if (!repositories) {
- return;
- }
- if (window.simpleStorageWrapper && window.simpleStorageWrapper.repos === repositories) {
- return;
- }
- window.simpleStorageWrapper = new SimpleStorageWrapper(repositories);
- console.log('[SimpleStorageWrapper] 已连接新的数据仓库接口');
- }
-
- const registry = window.StorageProviderRegistry;
- if (registry && typeof registry.onProvidersReady === 'function') {
- registry.onProvidersReady(({ repositories }) => connectWrapper(repositories));
- const current = registry.getCurrentProviders && registry.getCurrentProviders();
- if (current && current.repositories) {
- connectWrapper(current.repositories);
- }
- } else if (window.dataRepositories) {
- connectWrapper(window.dataRepositories);
- } else {
- console.warn('[SimpleStorageWrapper] 数据仓库尚未可用,等待外部注入');
- }
-
- window.SimpleStorageWrapper = SimpleStorageWrapper;
-})(window);
diff --git a/js/utils/stateSerializer.js b/js/utils/stateSerializer.js
deleted file mode 100644
index 240f9c52..00000000
--- a/js/utils/stateSerializer.js
+++ /dev/null
@@ -1,175 +0,0 @@
-/**
- * 状态序列化适配器
- * 解决Set/Map对象无法直接JSON序列化的问题
- */
-
-class StateSerializer {
- /**
- * 序列化状态值,处理特殊对象类型
- */
- static serialize(value) {
- if (value === null || value === undefined) {
- return value;
- }
-
- // 处理Set对象
- if (value instanceof Set) {
- return {
- __type: 'Set',
- __value: Array.from(value)
- };
- }
-
- // 处理Map对象
- if (value instanceof Map) {
- return {
- __type: 'Map',
- __value: Array.from(value.entries())
- };
- }
-
- // 处理Date对象
- if (value instanceof Date) {
- return {
- __type: 'Date',
- __value: value.toISOString()
- };
- }
-
- // 处理普通对象,递归处理嵌套
- if (typeof value === 'object') {
- if (Array.isArray(value)) {
- return value.map(item => StateSerializer.serialize(item));
- } else {
- const serialized = {};
- for (const [key, val] of Object.entries(value)) {
- serialized[key] = StateSerializer.serialize(val);
- }
- return serialized;
- }
- }
-
- // 基本类型直接返回
- return value;
- }
-
- /**
- * 反序列化状态值,恢复特殊对象类型
- */
- static deserialize(value) {
- if (value === null || value === undefined) {
- return value;
- }
-
- // 检查是否是特殊类型对象
- if (typeof value === 'object' && value !== null && '__type' in value) {
- switch (value.__type) {
- case 'Set':
- return new Set(value.__value);
- case 'Map':
- return new Map(value.__value);
- case 'Date':
- return new Date(value.__value);
- default:
- console.warn(`[StateSerializer] 未知类型: ${value.__type}`);
- return value.__value;
- }
- }
-
- // 处理数组
- if (Array.isArray(value)) {
- return value.map(item => StateSerializer.deserialize(item));
- }
-
- // 处理普通对象,递归处理嵌套
- if (typeof value === 'object') {
- const deserialized = {};
- for (const [key, val] of Object.entries(value)) {
- deserialized[key] = StateSerializer.deserialize(val);
- }
- return deserialized;
- }
-
- // 基本类型直接返回
- return value;
- }
-
- /**
- * 验证序列化/反序列化的一致性
- */
- static validate(originalValue) {
- try {
- const serialized = StateSerializer.serialize(originalValue);
- const deserialized = StateSerializer.deserialize(serialized);
-
- // 对于Set/Map,深度比较内容
- if (originalValue instanceof Set) {
- const originalArray = Array.from(originalValue);
- const deserializedArray = Array.from(deserialized);
- return JSON.stringify(originalArray.sort()) === JSON.stringify(deserializedArray.sort());
- }
-
- if (originalValue instanceof Map) {
- const originalArray = Array.from(originalValue.entries()).sort();
- const deserializedArray = Array.from(deserialized.entries()).sort();
- return JSON.stringify(originalArray) === JSON.stringify(deserializedArray);
- }
-
- // 其他类型直接比较
- return JSON.stringify(originalValue) === JSON.stringify(deserialized);
- } catch (error) {
- console.error('[StateSerializer] 验证失败:', error);
- return false;
- }
- }
-
- /**
- * 创建存储适配器,包装storage对象
- */
- static createStorageAdapter(baseStorage) {
- return {
- async get(key, defaultValue = null) {
- try {
- const value = await baseStorage.get(key, defaultValue);
- return StateSerializer.deserialize(value);
- } catch (error) {
- console.error(`[StateSerializer] 获取数据失败 ${key}:`, error);
- return defaultValue;
- }
- },
-
- async set(key, value) {
- try {
- const serializedValue = StateSerializer.serialize(value);
- return await baseStorage.set(key, serializedValue);
- } catch (error) {
- console.error(`[StateSerializer] 设置数据失败 ${key}:`, error);
- throw error;
- }
- },
-
- async remove(key) {
- try {
- return await baseStorage.remove(key);
- } catch (error) {
- console.error(`[StateSerializer] 删除数据失败 ${key}:`, error);
- throw error;
- }
- },
-
- async clear() {
- try {
- return await baseStorage.clear();
- } catch (error) {
- console.error('[StateSerializer] 清空存储失败:', error);
- throw error;
- }
- }
- };
- }
-}
-
-// 导出供使用
-if (typeof module !== 'undefined' && module.exports) {
- module.exports = StateSerializer;
-}
\ No newline at end of file
diff --git a/js/utils/storage.js b/js/utils/storage.js
deleted file mode 100644
index 26751e35..00000000
--- a/js/utils/storage.js
+++ /dev/null
@@ -1,2993 +0,0 @@
-(function initStorage(window) {
-'use strict';
-
-/**
- * 本地存储工具类
- * 提供统一的数据存储和检索接口
- */
-const STORAGE_INTERNAL_ACCESS_TOKEN = Symbol('StorageManager.internalAccessToken');
-
-const createInternalAccessOptions = (options = {}) => {
- return Object.assign({}, options, {
- skipPracticeCoreRedirect: true,
- internalAccessToken: STORAGE_INTERNAL_ACCESS_TOKEN
- });
-};
-
-const hasInternalAccessOptions = (options = {}) => {
- return Boolean(options && options.internalAccessToken === STORAGE_INTERNAL_ACCESS_TOKEN);
-};
-
-class StorageManager {
- constructor() {
- this.prefix = 'exam_system_';
- this.version = '0.6.2-fix';
- this.localStorageAvailable = false;
- this.sessionStorageAvailable = false;
- this.backendPreferenceKey = this.prefix + 'storage_backend';
- this.indexedDBBlocked = false;
- this.volatileMode = false;
- this.mode = 'indexeddb';
- this.protectedDataKeys = new Set([
- 'practice_records',
- 'user_stats'
- ]);
- this.persistentKeys = new Set([
- 'practice_records',
- 'user_stats',
- 'manual_backups',
- 'backup_settings',
- 'export_history',
- 'import_history',
- 'exam_index',
- 'exam_index_configurations',
- 'active_exam_index_key',
- 'settings',
- 'learning_goals'
- ]);
- this.ready = this.initializeStorage().catch(error => {
- console.error('[Storage] 初始化失败:', error);
- throw error;
- });
- }
-
- async waitForInitialization(skipReady = false) {
- if (!skipReady) {
- await this.ready;
- }
- }
-
- isProtectedDataKey(key) {
- return this.protectedDataKeys.has(String(key || ''));
- }
-
- isProtectedStorageKey(storageKey) {
- const key = String(storageKey || '');
- if (!key.startsWith(this.prefix)) {
- return false;
- }
- return this.isProtectedDataKey(key.slice(this.prefix.length));
- }
-
- async getPracticeRecordAPI(options = {}) {
- const api = window.PracticeRecordAPI;
- if (api) {
- return api;
- }
- if (options.skipReady || !this.ready || this._resolvingPracticeRecordAPI) {
- return null;
- }
- this._resolvingPracticeRecordAPI = true;
- try {
- await this.ready;
- return window.PracticeRecordAPI || null;
- } finally {
- this._resolvingPracticeRecordAPI = false;
- }
- }
-
- async readProtectedDataKey(key, defaultValue = null, options = {}) {
- const api = await this.getPracticeRecordAPI(options);
- if (key === 'practice_records') {
- if (api && typeof api.list === 'function') {
- return await api.list();
- }
- throw new Error('Storage.get(practice_records): PracticeRecordAPI.list not ready');
- }
- if (key === 'user_stats') {
- if (api && typeof api.readStats === 'function') {
- return await api.readStats({ fallback: defaultValue });
- }
- throw new Error('Storage.get(user_stats): PracticeRecordAPI.readStats not ready');
- }
- return defaultValue;
- }
-
- /**
- * 初始化存储系统
- */
- checkStorageAvailability(getter) {
- try {
- const store = getter();
- if (!store || typeof store.setItem !== 'function') {
- return false;
- }
- const testKey = this.prefix + 'storage_test_' + Math.random().toString(36).slice(2);
- store.setItem(testKey, '1');
- store.removeItem(testKey);
- return true;
- } catch (_) {
- return false;
- }
- }
-
- getStoredBackendPreference() {
- try {
- if (this.sessionStorageAvailable && sessionStorage.getItem(this.backendPreferenceKey)) {
- return sessionStorage.getItem(this.backendPreferenceKey);
- }
- } catch (_) { /* ignore */ }
- try {
- if (this.localStorageAvailable && localStorage.getItem(this.backendPreferenceKey)) {
- return localStorage.getItem(this.backendPreferenceKey);
- }
- } catch (_) { /* ignore */ }
- return null;
- }
-
- setBackendPreference(mode) {
- try {
- if (mode === 'session' && this.sessionStorageAvailable) {
- sessionStorage.setItem(this.backendPreferenceKey, 'session');
- return;
- }
- if (mode === 'local' && this.localStorageAvailable) {
- localStorage.setItem(this.backendPreferenceKey, 'local');
- return;
- }
- } catch (_) { /* ignore */ }
- }
-
- clearBackendPreference() {
- try { if (this.sessionStorageAvailable) { sessionStorage.removeItem(this.backendPreferenceKey); } } catch (_) {}
- try { if (this.localStorageAvailable) { localStorage.removeItem(this.backendPreferenceKey); } } catch (_) {}
- }
-
- async initializeStorage() {
- console.log('[Storage] 开始初始化存储系统');
- try {
- this.localStorageAvailable = this.checkStorageAvailability(() => localStorage);
- this.sessionStorageAvailable = this.checkStorageAvailability(() => sessionStorage);
- if (this.localStorageAvailable) {
- console.log('[Storage] localStorage 可用,将使用 localStorage 作为主要存储');
- this.setBackendPreference('local');
- } else {
- console.warn('[Storage] localStorage 不可用');
- }
- if (this.sessionStorageAvailable) {
- console.log('[Storage] sessionStorage 可用,可作为退路');
- } else {
- console.warn('[Storage] sessionStorage 不可用');
- }
-
- const storedPreference = this.getStoredBackendPreference();
- if (storedPreference === 'session') {
- this.useSessionStorageFallback = true;
- }
- if (!this.localStorageAvailable && this.sessionStorageAvailable) {
- this.useSessionStorageFallback = true;
- }
-
- // 强制初始化 IndexedDB 以实现 Hybrid 模式,并在版本检查前确保 DB ready
- console.log('[Storage] 强制初始化 IndexedDB 以实现 Hybrid 模式');
- await this.initializeIndexedDBStorage();
-
- // 初始化版本信息
- const currentVersion = await this.get('system_version', null, { skipReady: true });
- console.log(`[Storage] 当前版本: ${currentVersion}, 目标版本: ${this.version}`);
-
- if (!currentVersion) {
- // 首次安装
- console.log('[Storage] 首次安装,初始化默认数据');
- await this.handleVersionUpgrade(null, { skipReady: true });
- } else if (currentVersion !== this.version) {
- // 版本升级
- console.log('[Storage] 版本升级,迁移数据');
- await this.handleVersionUpgrade(currentVersion, { skipReady: true });
- } else {
- console.log('[Storage] 版本匹配,跳过初始化');
- }
-
- // 添加恢复逻辑
- } catch (error) {
- console.warn('[Storage] 初始化基本存储能力失败,尝试继续:', error);
- await this.initializeIndexedDBStorage();
- }
- }
-
- /**
- * 初始化IndexedDB存储
- */
- initializeIndexedDBStorage() {
- console.log('[Storage] 开始初始化 IndexedDB');
- if (this.indexedDBBlocked) {
- return Promise.resolve();
- }
- return new Promise((resolve, reject) => {
- try {
- // 检查IndexedDB支持
- if (!window.indexedDB) {
- this.indexedDBBlocked = true;
- this.indexedDB = null;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- this.volatileMode = false;
- this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage';
- console.warn('[Storage] IndexedDB 不支持,将使用现有本地/会话存储');
- resolve();
- return;
- }
- this.volatileMode = true;
- this.mode = 'volatile';
- console.warn('[Storage] IndexedDB 不支持且无本地存储,fallback 到内存存储');
- this.fallbackStorage = new Map();
- resolve();
- return;
- }
-
- this.dbName = 'ExamSystemDB';
- this.dbVersion = 1;
-
- console.log(`[Storage] 打开 IndexedDB 数据库: ${this.dbName}, 版本: ${this.dbVersion}`);
- const request = indexedDB.open(this.dbName, this.dbVersion);
- request.addEventListener('error', () => {
- this.indexedDB = null;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- this.volatileMode = false;
- this.mode = this.localStorageAvailable ? 'localStorage' : 'sessionStorage';
- return;
- }
- this.volatileMode = true;
- this.mode = 'volatile';
- this.fallbackStorage = this.fallbackStorage || new Map();
- });
- request.addEventListener('success', () => {
- this.volatileMode = false;
- this.mode = 'indexeddb';
- });
-
- request.onerror = (event) => {
- console.error('[Storage] IndexedDB 打开失败:', event.target.error);
- this.indexedDBBlocked = true;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- console.warn('[Storage] 使用 local/sessionStorage 作为回退存储');
- this.indexedDB = null;
- resolve();
- return;
- }
- this.fallbackStorage = new Map();
- resolve();
- };
-
- request.onupgradeneeded = (event) => {
- console.log('[Storage] IndexedDB 升级事件触发,旧版本:', event.oldVersion, '新版本:', event.newVersion);
- const db = event.target.result;
-
- // 创建存储对象
- if (!db.objectStoreNames.contains('keyValueStore')) {
- console.log('[Storage] 创建 objectStore: keyValueStore');
- const store = db.createObjectStore('keyValueStore', { keyPath: 'key' });
- store.createIndex('timestamp', 'timestamp', { unique: false });
- console.log('[Storage] objectStore 创建成功');
- } else {
- console.log('[Storage] objectStore 已存在,跳过创建');
- }
- };
-
- request.onsuccess = (event) => {
- this.indexedDB = event.target.result;
- this.indexedDBBlocked = false;
- console.log('[Storage] IndexedDB 初始化成功,数据库:', this.indexedDB.name, '版本:', this.indexedDB.version);
-
- // 迁移localStorage数据到IndexedDB
- console.log('[Storage] 开始从 localStorage 迁移数据');
- Promise.resolve()
- .then(() => this.migrateFromLocalStorage())
- .then(() => resolve())
- .catch((migrationError) => {
- console.warn('[Storage] 迁移过程中出现问题,但继续初始化:', migrationError);
- resolve();
- });
- };
-
- } catch (error) {
- console.error('[Storage] IndexedDB 初始化失败:', error);
- this.indexedDBBlocked = true;
- if (this.localStorageAvailable || this.sessionStorageAvailable) {
- console.warn('[Storage] IndexedDB 初始化失败,将使用 local/sessionStorage');
- this.indexedDB = null;
- resolve();
- return;
- }
- this.fallbackStorage = new Map();
- resolve();
- }
- });
- }
-
- /**
- * 确保 IndexedDB 已 ready
- */
- async ensureIndexedDBReady() {
- if (this.indexedDBBlocked) {
- return;
- }
- if (!this.indexedDB) {
- try {
- await this.initializeIndexedDBStorage();
- } catch (err) {
- this.indexedDBBlocked = true;
- }
- }
- }
-
- async tryPromoteToIndexedDB(serializedValue, key) {
- try {
- if (!this.indexedDB) {
- await this.initializeIndexedDBStorage();
- }
- if (this.indexedDB) {
- await this.setToIndexedDB(this.getKey(key), serializedValue);
- this.useSessionStorageFallback = false;
- this.setBackendPreference('local');
- this.dispatchStorageSync(key);
- return true;
- }
- } catch (e) {
- console.warn('[Storage] 提升到 IndexedDB 失败,继续使用退路:', e);
- }
- return false;
- }
-
- /**
- * 从localStorage迁移数据到IndexedDB
- */
- async migrateFromLocalStorage() {
- console.log('[Storage] 开始数据迁移');
- try {
- if (!this.indexedDB) {
- console.warn('[Storage] IndexedDB 不可用,跳过迁移');
- return;
- }
-
- const keys = Object.keys(localStorage);
- const migrationKeys = keys.filter(key => key.startsWith(this.prefix));
- console.log(`[Storage] 发现 ${migrationKeys.length} 条需要迁移的键`);
-
- if (migrationKeys.length === 0) {
- console.log('[Storage] 无数据需要迁移');
- return;
- }
-
- let migratedCount = 0;
- let failedCount = 0;
-
- for (const key of migrationKeys) {
- try {
- const value = localStorage.getItem(key);
- if (value) {
- await this.setToIndexedDB(key, value);
- localStorage.removeItem(key);
- migratedCount++;
- console.log(`[Storage] 成功迁移键: ${key}`);
- }
- } catch (error) {
- console.warn(`[Storage] 迁移数据失败: ${key}`, error);
- failedCount++;
- }
- }
-
- console.log(`[Storage] 数据迁移完成: ${migratedCount} 成功, ${failedCount} 失败`);
- } catch (error) {
- console.error('[Storage] 数据迁移失败:', error);
- }
- }
-
- /**
- * 存储到IndexedDB
- */
- setToIndexedDB(key, value) {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite');
- const store = transaction.objectStore('keyValueStore');
-
- const data = {
- key: key,
- value: value,
- timestamp: Date.now()
- };
-
- const request = store.put(data);
-
- request.onsuccess = () => resolve(true);
- request.onerror = () => reject(request.error);
- });
- }
-
- /**
- * 从IndexedDB获取数据
- */
- getFromIndexedDB(key) {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly');
- const store = transaction.objectStore('keyValueStore');
- const request = store.get(key);
-
- request.onsuccess = () => {
- if (request.result) {
- resolve(request.result.value);
- } else {
- resolve(null);
- }
- };
- request.onerror = () => reject(request.error);
- });
- }
-
- /**
- * 从IndexedDB删除数据
- */
- removeFromIndexedDB(key) {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite');
- const store = transaction.objectStore('keyValueStore');
- const request = store.delete(key);
-
- request.onsuccess = () => resolve(true);
- request.onerror = () => reject(request.error);
- });
- }
-
- /**
- * 处理版本升级
- */
- async handleVersionUpgrade(oldVersion, options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- console.log(`Upgrading storage from ${oldVersion || 'unknown'} to ${this.version}`);
-
- // 在这里处理数据迁移逻辑
- if (!oldVersion) {
- // 首次安装,初始化默认数据
- await this.initializeDefaultData({ skipReady });
- }
-
- await this.set('system_version', this.version, { skipReady });
-
- // 执行遗留数据迁移(只运行一次)
- if (!await this.get('migration_completed', null, { skipReady })) {
- console.log('[Storage] 检测到未完成迁移,开始执行...');
- await this.migrateLegacyData({ skipReady });
- } else {
- console.log('[Storage] 迁移已完成,跳过');
- }
- }
-
- /**
- * 初始化默认数据
- */
- async initializeDefaultData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- const defaultData = {
- settings: {
- theme: 'light',
- notifications: true,
- autoSave: true,
- reminderTime: '19:00'
- },
- exam_index: null,
- learning_goals: []
- };
-
- for (const [key, value] of Object.entries(defaultData)) {
- const existingValue = await this.get(key, null, { skipReady });
- if (existingValue === null || existingValue === undefined) {
- console.log(`[Storage] 初始化默认数据: ${key}`);
- await this.set(key, value, { skipReady });
- } else {
- console.log(`[Storage] 保留现有数据: ${key} (${Array.isArray(existingValue) ? existingValue.length + ' 项' : typeof existingValue})`);
- }
- }
- }
-
- /**
- * 设置存储命名空间
- */
- setNamespace(namespace) {
- if (typeof namespace === 'string' && namespace.trim()) {
- this.prefix = namespace.trim() + '_';
- console.log('[Storage] 命名空间已设置为:', this.prefix);
- } else {
- console.warn('[Storage] 无效的命名空间:', namespace);
- }
- }
-
- /**
- * 生成完整的存储键名
- */
- getKey(key) {
- return this.prefix + key;
- }
-
- createStoredEnvelope(value) {
- const compressedValue = this.compressData(value);
- return JSON.stringify({
- data: compressedValue,
- timestamp: Date.now(),
- version: this.version,
- compressed: compressedValue !== value
- });
- }
-
- parseStoredEnvelope(serializedValue, defaultValue = undefined) {
- if (serializedValue === undefined || serializedValue === null) {
- return defaultValue;
- }
- const parsed = JSON.parse(serializedValue);
- return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data')
- ? parsed.data
- : defaultValue;
- }
-
- readWebStorageValue(storage, storageKey) {
- if (!storage || typeof storage.getItem !== 'function') {
- return null;
- }
- try {
- return storage.getItem(storageKey);
- } catch (_) {
- return null;
- }
- }
-
- writeWebStorageValue(storage, storageKey, serializedValue) {
- if (!storage || typeof storage.setItem !== 'function') {
- return false;
- }
- try {
- storage.setItem(storageKey, serializedValue);
- return true;
- } catch (_) {
- return false;
- }
- }
-
- async writePersistentValue(key, value, options = {}) {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- throw new Error(`Storage.writePersistentValue(${key}) is internal-only`);
- }
- const serializedValue = this.createStoredEnvelope(value);
- const storageKey = this.getKey(key);
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- await this.setToIndexedDB(storageKey, serializedValue);
- this.dispatchStorageSync(key);
- return true;
- }
-
- if (this.localStorageAvailable && this.writeWebStorageValue(localStorage, storageKey, serializedValue)) {
- this.mode = 'localStorage';
- this.volatileMode = false;
- this.dispatchStorageSync(key);
- return true;
- }
-
- if (this.sessionStorageAvailable && this.writeWebStorageValue(sessionStorage, storageKey, serializedValue)) {
- this.mode = 'sessionStorage';
- this.volatileMode = false;
- this.dispatchStorageSync(key);
- return true;
- }
-
- if (this.fallbackStorage) {
- this.fallbackStorage.set(storageKey, serializedValue);
- this.dispatchStorageSync(key);
- return true;
- }
-
- this.volatileMode = true;
- this.mode = 'volatile';
- this.fallbackStorage = this.fallbackStorage || new Map();
- this.fallbackStorage.set(storageKey, serializedValue);
- this.dispatchStorageSync(key);
- return true;
- }
-
- async readPersistentValue(key, defaultValue = undefined, options = {}) {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- throw new Error(`Storage.readPersistentValue(${key}) is internal-only`);
- }
- const storageKey = this.getKey(key);
-
- if (this.fallbackStorage && this.fallbackStorage.has(storageKey)) {
- return this.parseStoredEnvelope(this.fallbackStorage.get(storageKey), defaultValue);
- }
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- const serializedValue = await this.getFromIndexedDB(storageKey);
- return this.parseStoredEnvelope(serializedValue, defaultValue);
- }
-
- if (this.localStorageAvailable) {
- return this.parseStoredEnvelope(this.readWebStorageValue(localStorage, storageKey), defaultValue);
- }
-
- if (this.sessionStorageAvailable) {
- return this.parseStoredEnvelope(this.readWebStorageValue(sessionStorage, storageKey), defaultValue);
- }
-
- return defaultValue;
- }
-
- async removePersistentValue(key, options = {}) {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- throw new Error(`Storage.removePersistentValue(${key}) is internal-only`);
- }
- const storageKey = this.getKey(key);
-
- if (this.fallbackStorage) {
- this.fallbackStorage.delete(storageKey);
- }
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- await this.removeFromIndexedDB(storageKey);
- }
-
- try { localStorage.removeItem(storageKey); } catch (_) { }
- try { sessionStorage.removeItem(storageKey); } catch (_) { }
- this.dispatchStorageSync(key);
- return true;
- }
-
- async clearPersistentStorage(options = {}) {
- if (!hasInternalAccessOptions(options)) {
- throw new Error('Storage.clearPersistentStorage is internal-only');
- }
- if (this.fallbackStorage) {
- this.fallbackStorage.clear();
- }
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readwrite');
- const store = transaction.objectStore('keyValueStore');
- const request = store.clear();
- await new Promise((resolve, reject) => {
- request.onsuccess = () => resolve();
- request.onerror = () => reject(request.error);
- });
- }
-
- try {
- Object.keys(localStorage)
- .filter((key) => key.startsWith(this.prefix))
- .forEach((key) => localStorage.removeItem(key));
- } catch (_) { }
- try {
- Object.keys(sessionStorage)
- .filter((key) => key.startsWith(this.prefix))
- .forEach((key) => sessionStorage.removeItem(key));
- } catch (_) { }
-
- this.clearBackendPreference();
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key: '*' } }));
- return true;
- }
-
- /**
- * 压缩数据
- */
- compressData(data) {
- try {
- // 切记:不要压缩数组,避免把列表写坏
- if (Array.isArray(data)) {
- return data;
- }
- // 仅对体积较大的“对象记录”压缩
- if (data && typeof data === 'object') {
- const len = JSON.stringify(data).length;
- if (len > 1000) {
- return this.compressObject(data);
- }
- }
- return data;
- } catch (error) {
- console.warn('[Storage] 数据压缩失败,使用原始数据:', error);
- return data;
- }
- }
-
- /**
- * 压缩对象数据
- */
- compressObject(obj) {
- // 只保留核心字段:用户答案、canonical 正确答案表、正误、得分、正确率、答题时长、答题时间
- const coreFields = [
- 'id', 'examId', 'title', 'category', 'frequency',
- 'score', 'totalQuestions', 'correctAnswers', 'correctAnswerMap', 'accuracy', 'percentage', 'duration',
- 'startTime', 'endTime', 'date', 'sessionId', 'timestamp',
- 'dataSource', 'realData'
- ];
-
- const compressed = {};
-
- // 只保留核心字段
- coreFields.forEach(field => {
- if (obj.hasOwnProperty(field)) {
- compressed[field] = obj[field];
- }
- });
-
- // 压缩realData,只保留核心内容
- if (obj.realData) {
- compressed.realData = this.compressRealData(obj.realData);
- }
-
- return compressed;
- }
-
- /**
- * 合并记录数组,避免重复
- * 基于 id 去重,保留最新的记录(按 updatedAt/createdAt/endTime/startTime 多字段回退)
- */
- mergeRecords(current, legacy) {
- if (!Array.isArray(current)) current = [];
- if (!Array.isArray(legacy)) return current;
-
- // canonical 记录的主要时间字段是 updatedAt/createdAt/endTime/startTime,
- // 不保证有顶层 timestamp。用多字段回退取最大时间戳,避免保留旧副本丢新副本。
- const resolveTimestamp = (record) => {
- if (!record || typeof record !== 'object') return 0;
- const candidates = [
- record.updatedAt,
- record.createdAt,
- record.endTime,
- record.startTime,
- record.date,
- record.timestamp
- ];
- for (let i = 0; i < candidates.length; i += 1) {
- const value = candidates[i];
- if (!value) continue;
- const time = new Date(value).getTime();
- if (Number.isFinite(time)) return time;
- }
- return 0;
- };
-
- const mergedMap = new Map();
- [...current, ...legacy].forEach(record => {
- if (record && record.id) {
- const existing = mergedMap.get(record.id);
- if (!existing || (resolveTimestamp(record) > resolveTimestamp(existing))) {
- mergedMap.set(record.id, record);
- }
- } else if (record && record.timestamp) {
- // 如果无 id,使用 timestamp 过滤
- mergedMap.set(record.timestamp, record);
- }
- });
-
- return Array.from(mergedMap.values()).sort((a, b) => resolveTimestamp(b) - resolveTimestamp(a));
- }
-
- async listPracticeRecordsCanonical(options = {}) {
- const { skipReady = false } = options;
-
- const api = await this.getPracticeRecordAPI({ skipReady });
- if (api && typeof api.list === 'function') {
- const records = await api.list();
- return Array.isArray(records) ? records : [];
- }
-
- throw new Error('Storage.listPracticeRecordsCanonical: unified store not ready');
- }
-
- async replacePracticeRecordsCanonical(records, options = {}) {
- const { skipReady = false, updateStats } = options;
- if (!Array.isArray(records)) {
- throw new Error('Storage.replacePracticeRecordsCanonical requires an array of records');
- }
-
- const api = await this.getPracticeRecordAPI({ skipReady });
- if (api && typeof api.replace === 'function') {
- // 透传 updateStats 选项:导入/回滚场景同时写入 user_stats,
- // 若此处 recalculateStats 会和并发 writeUserStatsCanonical 竞争,谁后写谁生效。
- // 默认 undefined 让 api.replace 自行决定(保存路径会重算),导入路径传 false 跳过。
- await api.replace(records, { maxRecords: 1000, updateStats });
- return true;
- }
-
- throw new Error('Storage.replacePracticeRecordsCanonical: unified store not ready');
- }
-
- async writeUserStatsCanonical(stats, options = {}) {
- const { skipReady = false } = options;
- const api = await this.getPracticeRecordAPI({ skipReady });
- if (api && typeof api.writeStats === 'function') {
- return await api.writeStats(stats);
- }
- throw new Error('Storage.writeUserStatsCanonical: unified stats store not ready');
- }
-
- async mergePracticeRecordsCanonical(records, options = {}) {
- if (!Array.isArray(records)) {
- throw new Error('Storage.mergePracticeRecordsCanonical requires an array of records');
- }
- const current = await this.listPracticeRecordsCanonical(options);
- const merged = this.mergeRecords(current, records);
- await this.replacePracticeRecordsCanonical(merged, options);
- return merged;
- }
-
- /**
- * 压缩realData数据
- */
- compressRealData(realData) {
- const compressed = {
- score: realData.score,
- totalQuestions: realData.totalQuestions,
- accuracy: realData.accuracy,
- percentage: realData.percentage,
- duration: realData.duration,
- answers: realData.answers || {},
- correctAnswerMap: realData.correctAnswerMap || {},
- isRealData: realData.isRealData,
- source: realData.source
- };
-
- // 压缩答案历史,只保留每个题目的最后一次答案
- if (realData.answerHistory) {
- const latestAnswers = {};
- Object.entries(realData.answerHistory).forEach(([questionId, history]) => {
- if (Array.isArray(history) && history.length > 0) {
- latestAnswers[questionId] = history[history.length - 1];
- }
- });
- compressed.answerHistory = latestAnswers;
- }
-
- // 压缩交互记录,只保留最近50次
- if (realData.interactions && Array.isArray(realData.interactions)) {
- compressed.interactions = realData.interactions.slice(-50);
- }
-
- // 压缩详细的题目比较信息
- if (realData.answerComparison) {
- const simplifiedComparison = {};
- Object.entries(realData.answerComparison).forEach(([questionId, comparison]) => {
- simplifiedComparison[questionId] = {
- userAnswer: comparison.userAnswer || '',
- isCorrect: typeof comparison.isCorrect === 'boolean' ? comparison.isCorrect : null
- };
- });
- compressed.answerComparison = simplifiedComparison;
- }
-
- return compressed;
- }
-
- /**
- * 存储数据
- */
- async set(key, value, options = {}) {
- const { skipReady = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- throw new Error(`Storage.set(${key}) is disabled; use PracticeRecordAPI`);
- }
- return await this.writePersistentValue(key, value, options);
- } catch (error) {
- console.error('[Storage] set 操作错误:', error);
- this.handleStorageError(key, value, error, options);
- if (protectedPublicAccess) {
- throw error;
- }
- return false;
- }
- }
-
- /**
- * 向数组追加新项
- * @param {string} key - 存储键名
- * @param {*} value - 要追加的项
- * @returns {Promise} 成功返回 true,失败返回 false
- */
- async append(key, value, options = {}) {
- const { skipReady = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- throw new Error(`Storage.append(${key}) is disabled; use PracticeRecordAPI`);
- }
- let currentList = await this.readPersistentValue(key, [], options);
- if (!Array.isArray(currentList)) {
- currentList = [];
- }
- currentList.push(value);
- return await this.writePersistentValue(key, currentList, options);
- } catch (error) {
- console.error('[Storage] Append error:', error);
- this.handleStorageError(key, value, error, options);
- if (protectedPublicAccess) {
- throw error;
- }
- return false;
- }
- }
-
- async get(key, defaultValue = null, options = {}) {
- const { skipReady = false, skipPracticeCoreRedirect = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- return await this.readProtectedDataKey(key, defaultValue, options);
- }
- return await this.readPersistentValue(key, defaultValue, options);
- } catch (error) {
- console.error('Storage get error:', error);
- if (protectedPublicAccess) {
- throw error;
- }
- return defaultValue;
- }
- }
-
- /**
- * 删除数据
- */
- async remove(key, options = {}) {
- const { skipReady = false } = options;
- const protectedPublicAccess = this.isProtectedDataKey(key) && !hasInternalAccessOptions(options);
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (protectedPublicAccess) {
- throw new Error(`Storage.remove(${key}) is disabled; use PracticeRecordAPI`);
- }
- return await this.removePersistentValue(key, options);
- } catch (error) {
- console.error('Storage remove error:', error);
- if (protectedPublicAccess) {
- throw error;
- }
- return false;
- }
- }
-
- /**
- * 清空所有数据
- */
- async clear(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- await this.ensureIndexedDBReady();
- if (!hasInternalAccessOptions(options)) {
- const api = await this.getPracticeRecordAPI(options);
- if (!api || typeof api.clear !== 'function' || typeof api.resetStats !== 'function') {
- throw new Error('Storage.clear: PracticeRecordAPI clear/resetStats not ready');
- }
- await api.clear({ updateStats: false });
- await api.resetStats();
- }
- return await this.clearPersistentStorage(createInternalAccessOptions(options));
- } catch (error) {
- console.error('Storage clear error:', error);
- return false;
- }
- }
-
- /**
- * 检查存储配额是否充足
- */
- async checkStorageQuota(dataSize, options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- console.log(`[Storage] 检查存储配额,需要空间: ${dataSize} 字节`);
- if (this.fallbackStorage) {
- console.log('[Storage] 内存存储,无配额限制');
- return true; // 内存存储没有配额限制
- }
-
- const storageInfo = await this.getStorageInfo({ skipReady });
- if (!storageInfo) {
- console.warn('[Storage] 无法获取存储信息,拒绝操作');
- return false;
- }
-
- console.log(`[Storage] 当前存储类型: ${storageInfo.type}, 已用: ${storageInfo.used} 字节`);
-
- if (storageInfo.type === 'Hybrid' || storageInfo.type === 'IndexedDB') {
- // 混合存储或IndexedDB没有固定配额限制,但我们仍然检查数据大小
- const maxSize = 105 * 1024 * 1024; // 105MB限制 (localStorage 5MB + IndexedDB 100MB)
- const hasSpace = storageInfo.used + dataSize <= maxSize;
- console.log(`[Storage] Hybrid/IndexedDB 检查: 已用 ${storageInfo.used}, 需要 ${dataSize}, 最大 ${maxSize}, 结果: ${hasSpace}`);
- return hasSpace;
- }
-
- const currentUsage = storageInfo.used;
- const quota = 5 * 1024 * 1024; // 5MB
- const availableSpace = quota - currentUsage;
-
- // 预留20%的缓冲空间
- const bufferSpace = quota * 0.2;
- const safeAvailableSpace = availableSpace - bufferSpace;
-
- console.log(`[Storage] localStorage 检查: 当前使用 ${(currentUsage / 1024).toFixed(2)}KB, 总配额 ${quota / 1024}KB, 可用 ${(availableSpace / 1024).toFixed(2)}KB, 安全可用 ${(safeAvailableSpace / 1024).toFixed(2)}KB, 需要 ${(dataSize / 1024).toFixed(2)}KB`);
-
- const hasSpace = safeAvailableSpace >= dataSize;
- if (!hasSpace) {
- console.warn('[Storage] localStorage 空间不足');
- }
- return hasSpace;
- } catch (error) {
- console.error('[Storage] 配额检查错误:', error);
- return false;
- }
- }
-
- /**
- * 获取存储使用情况
- */
- async getStorageInfo(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- if (this.fallbackStorage) {
- return {
- type: 'volatile',
- mode: this.mode,
- volatile: true,
- used: this.fallbackStorage.size,
- available: Infinity
- };
- }
-
- if (this.indexedDB && !this.indexedDBBlocked) {
- const indexedDBUsed = await this.getIndexedDBUsage();
- return {
- type: 'indexedDB',
- mode: this.mode,
- volatile: false,
- used: indexedDBUsed,
- available: Infinity,
- breakdown: {
- indexedDB: indexedDBUsed
- }
- };
- }
-
- if (this.fallbackStorage) {
- return {
- type: 'memory',
- used: this.fallbackStorage.size,
- available: Infinity
- };
- }
-
- if (this.indexedDB) {
- try {
- // 获取所有存储的使用情况
- const localStorageUsed = this.getLocalStorageUsage();
- const indexedDBUsed = await this.getIndexedDBUsage();
- const totalUsed = localStorageUsed + indexedDBUsed;
-
- return {
- type: 'Hybrid',
- used: totalUsed,
- available: Infinity, // 混合存储没有固定配额
- breakdown: {
- localStorage: localStorageUsed,
- indexedDB: indexedDBUsed
- }
- };
- } catch (error) {
- console.warn('[Storage] 获取混合存储使用情况失败:', error);
- // 降级到localStorage
- }
- }
-
- let used = 0;
- const keys = Object.keys(localStorage);
- keys.forEach(key => {
- if (key.startsWith(this.prefix)) {
- used += localStorage.getItem(key).length;
- }
- });
-
- return {
- type: 'localStorage',
- used: used,
- available: 5 * 1024 * 1024 - used // 假设5MB限制
- };
- } catch (error) {
- console.error('Storage info error:', error);
- return null;
- }
- }
-
- /**
- * 获取localStorage使用情况
- */
- getLocalStorageUsage() {
- try {
- let used = 0;
- const keys = Object.keys(localStorage);
- keys.forEach(key => {
- if (key.startsWith(this.prefix)) {
- used += localStorage.getItem(key).length;
- }
- });
- return used;
- } catch (error) {
- console.error('Get localStorage usage error:', error);
- return 0;
- }
- }
-
- /**
- * 获取IndexedDB使用情况
- */
- getIndexedDBUsage() {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly');
- const store = transaction.objectStore('keyValueStore');
- const request = store.getAll();
-
- request.onsuccess = () => {
- const items = request.result;
- let totalSize = 0;
-
- items.forEach(item => {
- if (item.key.startsWith(this.prefix) && item.value) {
- totalSize += item.value.length;
- }
- });
-
- resolve(totalSize);
- };
-
- request.onerror = () => reject(request.error);
- });
- }
-
- /**
- * 清理旧数据
- */
- async cleanupOldData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- console.log('[Storage] 开始清理旧数据...');
-
- const practiceRecords = await this.listPracticeRecordsCanonical({ skipReady });
- if (practiceRecords.length > 0) {
- console.log(`[Storage] 练习记录数据保留${practiceRecords.length}条记录,跳过压缩以保护答案数据完整性`);
- }
-
- // 清理错误日志
- const errorLogs = await this.get('injection_errors', [], { skipReady });
- if (errorLogs.length > 20) {
- const logsToKeep = errorLogs.slice(-20); // 保留最近20条
- await this.set('injection_errors', logsToKeep, { skipReady });
- console.log(`[Storage] 已清理错误日志,从${errorLogs.length}条减少到${logsToKeep.length}条`);
- }
-
- const collectionErrors = await this.get('collection_errors', [], { skipReady });
- if (collectionErrors.length > 20) {
- const logsToKeep = collectionErrors.slice(-20);
- await this.set('collection_errors', logsToKeep, { skipReady });
- console.log(`[Storage] 已清理数据收集错误日志,从${collectionErrors.length}条减少到${logsToKeep.length}条`);
- }
-
- // 清理活动会话(保留最近的)
- const activeSessions = await this.get('active_sessions', [], { skipReady });
- const now = Date.now();
- const recentSessions = activeSessions.filter(session => {
- const sessionTime = new Date(session.startTime).getTime();
- const hoursDiff = (now - sessionTime) / (1000 * 60 * 60);
- return hoursDiff < 1; // 只保留1小时内的会话
- });
-
- if (recentSessions.length !== activeSessions.length) {
- await this.set('active_sessions', recentSessions, { skipReady });
- console.log(`[Storage] 已清理过期会话,从${activeSessions.length}个减少到${recentSessions.length}个`);
- }
-
- } catch (error) {
- console.error('[Storage] 清理旧数据失败:', error);
- }
- }
-
- /**
- * 迁移遗留数据到新命名空间
- * 只运行一次
- */
- async migrateLegacyData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- console.log('[Storage] 开始迁移遗留数据');
- try {
- const legacyKeys = Object.keys(localStorage).filter(k =>
- k === 'practice_records' ||
- k === 'user_progress' ||
- k === 'scores' ||
- k.startsWith('old_prefix_')
- );
-
- if (legacyKeys.length === 0) {
- console.log('[Storage] 无遗留数据需要迁移');
- await this.set('migration_completed', true, { skipReady });
- } else {
- let migratedCount = 0;
- let deferredPracticeMigration = false;
- for (const oldKey of legacyKeys) {
- try {
- const legacyDataStr = localStorage.getItem(oldKey);
- if (!legacyDataStr) continue;
-
- let legacyData;
- try {
- legacyData = JSON.parse(legacyDataStr);
- } catch (parseError) {
- console.warn(`[Storage] 解析遗留数据失败: ${oldKey}`, parseError);
- continue;
- }
-
- if (!Array.isArray(legacyData)) {
- console.warn(`[Storage] 遗留数据非数组,跳过: ${oldKey}`);
- continue;
- }
-
- if (legacyData.length === 0) {
- console.log('[Storage] 旧数据为空,跳过迁移');
- continue;
- }
-
- // 对应新键(去除 old_prefix_ 如果存在)
- let newKey = oldKey.replace(/^old_prefix_/, '');
- const isPracticeRecordsKey = newKey === 'practice_records';
- if (isPracticeRecordsKey) {
- await this.mergePracticeRecordsCanonical(legacyData, { skipReady });
- } else {
- const current = await this.get(newKey, [], { skipReady });
- const merged = this.mergeRecords(current, legacyData);
- await this.set(newKey, merged, { skipReady });
- }
-
- // 删除旧键
- localStorage.removeItem(oldKey);
- migratedCount++;
- console.log(`[Storage] 成功迁移并合并数据: ${oldKey} -> ${newKey} (${legacyData.length} 项)`);
- } catch (migrateError) {
- const newKey = oldKey.replace(/^old_prefix_/, '');
- if (newKey === 'practice_records') {
- deferredPracticeMigration = true;
- }
- console.error(`[Storage] 迁移失败: ${oldKey}`, migrateError);
- }
- }
-
- console.log(`[Storage] 数据迁移完成: ${migratedCount} 个键成功迁移`);
- if (deferredPracticeMigration) {
- console.warn('[Storage] 练习记录迁移已延后,等待 PracticeRecordAPI 就绪后重试');
- } else {
- await this.set('migration_completed', true, { skipReady });
- }
- }
-
- if (!await this.get('my_melody_migration_completed', null, { skipReady })) {
- console.log('[Storage] 检查 MyMelody 遗留键迁移...');
- const canonicalPracticeKey = this.getKey('practice_records');
- console.warn('[Storage] 跳过 MyMelody 遗留键迁移:旧键与 canonical practice_records 键相同,继续迁移会误删当前记录', canonicalPracticeKey);
- await this.set('my_melody_migration_completed', true, { skipReady });
- }
-
- } catch (error) {
- console.error('[Storage] 迁移遗留数据失败:', error);
- // 即使失败也设置标志,避免无限重试
- await this.set('migration_completed', true, { skipReady });
- }
- }
-
- /**
- * 从备份文件恢复数据
- */
- async restoreFromBackup(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- console.log('[Storage] 开始从备份恢复数据');
-
- const backupPath = 'assets/data/backup-practice-records.json';
- const isFileProtocol = typeof window !== 'undefined'
- && window.location
- && window.location.protocol === 'file:';
-
- // Chromium 下 fetch(file://...) 会直接抛错;备份属于可选项,跳过即可。
- if (isFileProtocol) {
- console.info('[Storage] file:// 环境跳过内置备份恢复');
- return false;
- }
-
- try {
- const response = await fetch(backupPath);
- if (!response.ok) {
- return false;
- }
- const backupData = await response.json();
- if (!backupData || !Array.isArray(backupData.practice_records)) {
- console.warn('[Storage] 备份数据格式无效');
- return false;
- }
- // 运行期恢复必须走统一记录 API;raw practice_records 只允许启动迁移兼容使用。
- await this.replacePracticeRecordsCanonical(backupData.practice_records, { skipReady });
- console.log('[Storage] 从备份恢复 practice_records 成功');
- return true;
- } catch (error) {
- console.warn('[Storage] 备份恢复失败,已跳过:', error);
- return false;
- }
- }
-
- /**
- * 处理存储错误
- */
- handleStorageError(key, value, error, options = {}) {
- console.error('[Storage] 存储错误:', error);
-
- // 如果是配额错误,尝试切换到备用存储
- if (error.name === 'QuotaExceededError') {
- this.handleStorageQuotaExceeded(key, value, options);
- } else {
- // 其他错误
- if (window.showMessage) {
- window.showMessage('数据保存失败,请检查浏览器设置', 'error');
- }
-
- // 触发存储错误事件
- document.dispatchEvent(new CustomEvent('storageError', {
- detail: { key, value, error }
- }));
- }
- }
-
- /**
- * 导出数据
- */
- async exportData(options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- const data = {};
-
- // 1. 导出内存存储数据
- if (this.fallbackStorage) {
- this.fallbackStorage.forEach((value, key) => {
- if (key.startsWith(this.prefix)) {
- if (this.isProtectedStorageKey(key)) {
- return;
- }
- const cleanKey = key.replace(this.prefix, '');
- data[cleanKey] = JSON.parse(value);
- }
- });
- console.log(`[Storage] 已导出内存存储数据 ${this.fallbackStorage.size} 条`);
- }
-
- // 2. 导出IndexedDB数据
- if (this.indexedDB) {
- try {
- const items = await this.getAllFromIndexedDB();
- const indexedDBData = {};
- items.forEach(item => {
- if (item.key.startsWith(this.prefix) && !this.isProtectedStorageKey(item.key)) {
- const cleanKey = item.key.replace(this.prefix, '');
- indexedDBData[cleanKey] = JSON.parse(item.value);
- }
- });
- // 合并IndexedDB数据
- Object.assign(data, indexedDBData);
- console.log(`[Storage] 已导出IndexedDB数据 ${Object.keys(indexedDBData).length} 条`);
- } catch (error) {
- console.warn('[Storage] IndexedDB导出失败:', error);
- }
- }
-
- // 3. 导出localStorage数据
- const localStorageKeys = Object.keys(localStorage);
- const appKeys = localStorageKeys.filter(key => key.startsWith(this.prefix));
- appKeys.forEach(key => {
- const cleanKey = key.replace(this.prefix, '');
- if (this.isProtectedDataKey(cleanKey)) {
- return;
- }
- try {
- const value = localStorage.getItem(key);
- if (value) {
- data[cleanKey] = JSON.parse(value);
- }
- } catch (error) {
- console.warn(`[Storage] 解析localStorage数据失败: ${cleanKey}`, error);
- }
- });
- console.log(`[Storage] 已导出localStorage数据 ${appKeys.length} 条`);
-
- data.practice_records = await this.readProtectedDataKey('practice_records', [], { skipReady });
- data.user_stats = await this.readProtectedDataKey('user_stats', null, { skipReady });
-
- console.log(`[Storage] 数据导出完成,总计 ${Object.keys(data).length} 条记录`);
-
- return {
- version: this.version,
- exportDate: new Date().toISOString(),
- data: data,
- storageInfo: {
- totalRecords: Object.keys(data).length,
- sources: {
- memory: this.fallbackStorage ? this.fallbackStorage.size : 0,
- indexedDB: this.indexedDB ? Object.keys(data).length - (this.fallbackStorage ? this.fallbackStorage.size : 0) - appKeys.length : 0,
- localStorage: appKeys.length
- }
- }
- };
- } catch (error) {
- console.error('Export data error:', error);
- return null;
- }
- }
-
- /**
- * 从IndexedDB获取所有数据
- */
- getAllFromIndexedDB() {
- return new Promise((resolve, reject) => {
- if (!this.indexedDB) {
- reject(new Error('IndexedDB not available'));
- return;
- }
-
- const transaction = this.indexedDB.transaction(['keyValueStore'], 'readonly');
- const store = transaction.objectStore('keyValueStore');
- const request = store.getAll();
-
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
- });
- }
-
- /**
- * 导入数据
- */
- async importData(importedData, options = {}) {
- const { skipReady = false } = options;
- await this.waitForInitialization(skipReady);
- try {
- if (!importedData || !importedData.data) {
- throw new Error('Invalid import data format');
- }
-
- const importEntries = Object.entries(importedData.data);
- const api = await this.getPracticeRecordAPI({ skipReady });
- const hasPracticeRecords = importEntries.some(([key]) => key === 'practice_records');
- const hasUserStats = importEntries.some(([key]) => key === 'user_stats');
- if (hasPracticeRecords && (!api || typeof api.replace !== 'function')) {
- throw new Error('Storage.importData: unified practice record store not ready');
- }
- if (hasUserStats && (!api || typeof api.writeStats !== 'function')) {
- throw new Error('Storage.importData: unified user stats store not ready');
- }
-
- // 备份当前数据
- const backup = await this.exportData({ skipReady });
- const importEntry = ([key, value]) => {
- const nextValue = value && Object.prototype.hasOwnProperty.call(value, 'data')
- ? value.data
- : value;
- if (key === 'practice_records') {
- // updateStats: false — 导入时 user_stats 会通过 writeUserStatsCanonical 独立写入,
- // 若此处 recalculateStats 会和并发写入竞争,导致备份中的统计值被覆盖。
- return this.replacePracticeRecordsCanonical(nextValue, { skipReady, updateStats: false });
- }
- if (key === 'user_stats') {
- return this.writeUserStatsCanonical(nextValue, { skipReady });
- }
- return this.set(key, nextValue, { skipReady });
- };
-
- try {
- // 清空现有数据
- await this.clear({ skipReady });
-
- // 导入新数据
- const importPromises = importEntries.map(importEntry);
-
- await Promise.all(importPromises);
-
- return { success: true, message: 'Data imported successfully' };
- } catch (importError) {
- // 恢复备份
- console.error('Import failed, restoring backup:', importError);
- await this.clear({ skipReady });
-
- if (backup && backup.data) {
- const restorePromises = Object.entries(backup.data).map(importEntry);
- await Promise.all(restorePromises);
- }
-
- throw importError;
- }
- } catch (error) {
- console.error('Import data error:', error);
- return { success: false, message: error.message };
- }
- }
-
- /**
- * 数据验证
- */
- validateData(key, data) {
- const validators = {
- practice_records: (records) => {
- return Array.isArray(records) && records.every(record =>
- record.id && record.examId && record.startTime && record.endTime
- );
- },
- user_stats: (stats) => {
- return stats && typeof stats.totalPractices === 'number';
- },
- exam_index: (index) => {
- return !index || (Array.isArray(index) && index.every(exam =>
- exam.id && exam.title && exam.category
- ));
- }
- };
-
- const validator = validators[key];
- return validator ? validator(data) : true;
- }
-
- /**
- * 启动存储监控
- */
- async startStorageMonitoring() {
- await this.waitForInitialization();
- console.log('[Storage] 启动存储监控...');
-
- // 定期检查存储使用情况
- this.monitoringInterval = setInterval(async () => {
- try {
- const storageInfo = await this.getStorageInfo();
- if (storageInfo) {
- const usagePercent = storageInfo.type === 'localStorage'
- ? (storageInfo.used / (5 * 1024 * 1024)) * 100
- : (storageInfo.used / (105 * 1024 * 1024)) * 100;
-
- const maxSize = storageInfo.type === 'localStorage' ? '5MB' :
- storageInfo.type === 'Hybrid' ? '105MB' : '100MB';
- console.log(`[Storage] 使用率: ${usagePercent.toFixed(2)}% (${(storageInfo.used / 1024).toFixed(2)}KB / ${maxSize})`);
-
- // 显示详细的存储分布
- if (storageInfo.breakdown) {
- console.log(`[Storage] 存储分布: localStorage ${(storageInfo.breakdown.localStorage / 1024).toFixed(2)}KB, IndexedDB ${(storageInfo.breakdown.indexedDB / 1024).toFixed(2)}KB`);
- }
-
- // 当使用率超过80%时,自动清理
- if (usagePercent > 80) {
- console.warn('[Storage] 存储使用率过高,自动清理旧数据');
- await this.cleanupOldData();
-
- // 清理后再次检查
- const newStorageInfo = await this.getStorageInfo();
- if (newStorageInfo) {
- const newUsagePercent = newStorageInfo.type === 'localStorage'
- ? (newStorageInfo.used / (5 * 1024 * 1024)) * 100
- : (newStorageInfo.used / (105 * 1024 * 1024)) * 100;
-
- console.log(`[Storage] 清理后使用率: ${newUsagePercent.toFixed(2)}%`);
-
- // 如果仍然超过90%,显示警告
- if (newUsagePercent > 90) {
- if (window.showMessage) {
- window.showMessage('存储空间即将不足,建议导出数据备份', 'warning');
- }
- }
- }
- }
- }
- } catch (error) {
- console.error('[Storage] 存储监控错误:', error);
- }
- }, 300000); // 每5分钟检查一次
-
- // 页面卸载时清理监控 - 全局事件必须使用原生 addEventListener
- window.addEventListener('beforeunload', () => {
- if (this.monitoringInterval) {
- clearInterval(this.monitoringInterval);
- }
- });
- }
-
- // ==================== 词表存储专用方法 ====================
-
- /**
- * 词表存储键常量
- */
- getVocabStorageKeys() {
- return {
- P1_ERRORS: 'vocab_list_p1_errors',
- P4_ERRORS: 'vocab_list_p4_errors',
- MASTER_ERRORS: 'vocab_list_master_errors',
- CUSTOM: 'vocab_list_custom',
- READING_HIGHLIGHTS: 'vocab_list_reading_highlights',
- ACTIVE_LIST: 'vocab_active_list'
- };
- }
-
- /**
- * 验证词表数据结构
- */
- validateVocabList(vocabList) {
- if (!vocabList || typeof vocabList !== 'object') {
- return { valid: false, error: '词表数据无效' };
- }
-
- const requiredFields = ['id', 'name', 'source', 'words', 'createdAt', 'updatedAt'];
- for (const field of requiredFields) {
- if (!(field in vocabList)) {
- return { valid: false, error: `缺少必需字段: ${field}` };
- }
- }
-
- if (!Array.isArray(vocabList.words)) {
- return { valid: false, error: 'words 字段必须是数组' };
- }
-
- // 验证每个单词条目
- for (const word of vocabList.words) {
- if (!word.word || typeof word.word !== 'string') {
- return { valid: false, error: '单词条目缺少有效的 word 字段' };
- }
- if (!word.timestamp || typeof word.timestamp !== 'number') {
- return { valid: false, error: '单词条目缺少有效的 timestamp 字段' };
- }
- }
-
- return { valid: true };
- }
-
- /**
- * 清理词表数据
- * 移除重复单词,保留最新的记录
- */
- cleanVocabList(vocabList) {
- if (!vocabList || !Array.isArray(vocabList.words)) {
- return vocabList;
- }
-
- const wordMap = new Map();
-
- // 按时间戳排序,保留最新的
- vocabList.words.forEach(word => {
- const key = word.word.toLowerCase().trim();
- const existing = wordMap.get(key);
-
- if (!existing || word.timestamp > existing.timestamp) {
- wordMap.set(key, word);
- }
- });
-
- vocabList.words = Array.from(wordMap.values());
- vocabList.updatedAt = Date.now();
-
- return vocabList;
- }
-
- /**
- * 保存词表数据
- */
- async saveVocabList(vocabList, options = {}) {
- const { skipReady = false } = options;
-
- try {
- // 验证数据
- const validation = this.validateVocabList(vocabList);
- if (!validation.valid) {
- console.error('[Storage] 词表数据验证失败:', validation.error);
- return false;
- }
-
- // 清理数据
- const cleanedList = this.cleanVocabList(vocabList);
-
- // 确定存储键
- const keys = this.getVocabStorageKeys();
- let storageKey;
-
- switch (cleanedList.source) {
- case 'p1':
- storageKey = keys.P1_ERRORS;
- break;
- case 'p4':
- storageKey = keys.P4_ERRORS;
- break;
- case 'all':
- storageKey = keys.MASTER_ERRORS;
- break;
- case 'user':
- storageKey = keys.CUSTOM;
- break;
- case 'reading-highlight':
- storageKey = keys.READING_HIGHLIGHTS;
- break;
- default:
- storageKey = cleanedList.id;
- }
-
- console.log(`[Storage] 保存词表: ${storageKey}, 单词数: ${cleanedList.words.length}`);
-
- // 保存到存储
- const success = await this.set(storageKey, cleanedList, { skipReady });
-
- if (success) {
- console.log(`[Storage] 词表保存成功: ${storageKey}`);
- }
-
- return success;
- } catch (error) {
- console.error('[Storage] 保存词表失败:', error);
- return false;
- }
- }
-
- /**
- * 加载词表数据
- */
- async loadVocabList(listId, options = {}) {
- const { skipReady = false } = options;
-
- try {
- const keys = this.getVocabStorageKeys();
- let storageKey;
-
- // 根据 listId 确定存储键
- if (listId === 'spelling-errors-p1') {
- storageKey = keys.P1_ERRORS;
- } else if (listId === 'spelling-errors-p4') {
- storageKey = keys.P4_ERRORS;
- } else if (listId === 'spelling-errors-master') {
- storageKey = keys.MASTER_ERRORS;
- } else if (listId === 'custom') {
- storageKey = keys.CUSTOM;
- } else if (listId === 'reading-highlights') {
- storageKey = keys.READING_HIGHLIGHTS;
- } else {
- storageKey = listId;
- }
-
- console.log(`[Storage] 加载词表: ${storageKey}`);
-
- const vocabList = await this.get(storageKey, null, { skipReady });
-
- if (!vocabList) {
- console.log(`[Storage] 词表不存在: ${storageKey}`);
- return null;
- }
-
- if (Array.isArray(vocabList)) {
- const now = new Date().toISOString();
- const sourceMap = {
- 'spelling-errors-p1': 'p1',
- 'spelling-errors-p4': 'p4',
- 'spelling-errors-master': 'all',
- 'custom': 'user',
- 'reading-highlights': 'reading-highlight'
- };
- const nameMap = {
- 'spelling-errors-p1': 'P1 拼写错误',
- 'spelling-errors-p4': 'P4 拼写错误',
- 'spelling-errors-master': '综合错误词表',
- 'custom': '自定义词表',
- 'reading-highlights': '阅读高亮生词'
- };
- return {
- id: listId,
- name: nameMap[listId] || listId,
- source: sourceMap[listId] || listId,
- words: vocabList,
- createdAt: now,
- updatedAt: now
- };
- }
-
- // 验证加载的数据
- const validation = this.validateVocabList(vocabList);
- if (!validation.valid) {
- console.error('[Storage] 加载的词表数据无效:', validation.error);
- return null;
- }
-
- console.log(`[Storage] 词表加载成功: ${storageKey}, 单词数: ${vocabList.words.length}`);
- return vocabList;
- } catch (error) {
- console.error('[Storage] 加载词表失败:', error);
- return null;
- }
- }
-
- /**
- * 获取词表单词数量
- */
- async getVocabListWordCount(listId, options = {}) {
- const { skipReady = false } = options;
-
- try {
- const vocabList = await this.loadVocabList(listId, { skipReady });
- return vocabList ? vocabList.words.length : 0;
- } catch (error) {
- console.error('[Storage] 获取词表单词数量失败:', error);
- return 0;
- }
- }
-
- /**
- * 添加单词到词表
- */
- async addWordToVocabList(listId, word, options = {}) {
- const { skipReady = false } = options;
-
- try {
- let vocabList = await this.loadVocabList(listId, { skipReady });
-
- if (!vocabList) {
- // 创建新词表
- vocabList = {
- id: listId,
- name: this.getVocabListName(listId),
- source: this.getVocabListSource(listId),
- words: [],
- createdAt: Date.now(),
- updatedAt: Date.now()
- };
- }
-
- // 检查单词是否已存在
- const existingIndex = vocabList.words.findIndex(w =>
- w.word.toLowerCase() === word.word.toLowerCase()
- );
-
- if (existingIndex >= 0) {
- // 更新现有单词
- vocabList.words[existingIndex] = {
- ...vocabList.words[existingIndex],
- ...word,
- errorCount: (vocabList.words[existingIndex].errorCount || 0) + 1,
- timestamp: Date.now()
- };
- } else {
- // 添加新单词
- vocabList.words.push({
- ...word,
- errorCount: word.errorCount || 1,
- timestamp: word.timestamp || Date.now()
- });
- }
-
- vocabList.updatedAt = Date.now();
-
- return await this.saveVocabList(vocabList, { skipReady });
- } catch (error) {
- console.error('[Storage] 添加单词到词表失败:', error);
- return false;
- }
- }
-
- /**
- * 从词表中移除单词
- */
- async removeWordFromVocabList(listId, word, options = {}) {
- const { skipReady = false } = options;
-
- try {
- const vocabList = await this.loadVocabList(listId, { skipReady });
-
- if (!vocabList) {
- return false;
- }
-
- const normalizedWord = word.toLowerCase().trim();
- vocabList.words = vocabList.words.filter(w =>
- w.word.toLowerCase().trim() !== normalizedWord
- );
-
- vocabList.updatedAt = Date.now();
-
- return await this.saveVocabList(vocabList, { skipReady });
- } catch (error) {
- console.error('[Storage] 从词表移除单词失败:', error);
- return false;
- }
- }
-
- /**
- * 获取词表名称
- */
- getVocabListName(listId) {
- const names = {
- 'spelling-errors-p1': 'P1 拼写错误',
- 'spelling-errors-p4': 'P4 拼写错误',
- 'spelling-errors-master': '综合错误词表',
- 'custom': '自定义词表'
- };
- return names[listId] || listId;
- }
-
- /**
- * 获取词表来源
- */
- getVocabListSource(listId) {
- if (listId.includes('p1')) return 'p1';
- if (listId.includes('p4')) return 'p4';
- if (listId.includes('master')) return 'all';
- return 'user';
- }
-
- /**
- * 获取所有词表的元数据
- */
- async getAllVocabListsMetadata(options = {}) {
- const { skipReady = false } = options;
-
- const keys = this.getVocabStorageKeys();
- const listIds = [
- 'spelling-errors-p1',
- 'spelling-errors-p4',
- 'spelling-errors-master',
- 'custom'
- ];
-
- const metadata = [];
-
- for (const listId of listIds) {
- const count = await this.getVocabListWordCount(listId, { skipReady });
- metadata.push({
- id: listId,
- name: this.getVocabListName(listId),
- source: this.getVocabListSource(listId),
- wordCount: count
- });
- }
-
- return metadata;
- }
-
- // ==================== 数据同步逻辑 ====================
-
- /**
- * 同步词表数据(跨会话)
- * 处理数据冲突,使用最新时间戳
- */
- async syncVocabList(listId, newData, options = {}) {
- const { skipReady = false } = options;
-
- try {
- console.log(`[Storage] 开始同步词表: ${listId}`);
-
- // 加载现有数据
- const existingList = await this.loadVocabList(listId, { skipReady });
-
- if (!existingList) {
- // 没有现有数据,直接保存新数据
- console.log(`[Storage] 无现有数据,直接保存新词表`);
- return await this.saveVocabList(newData, { skipReady });
- }
-
- // 合并数据,解决冲突
- const mergedList = this.mergeVocabLists(existingList, newData);
-
- console.log(`[Storage] 词表合并完成,单词数: ${mergedList.words.length}`);
-
- // 保存合并后的数据
- return await this.saveVocabList(mergedList, { skipReady });
- } catch (error) {
- console.error('[Storage] 同步词表失败:', error);
- return false;
- }
- }
-
- /**
- * 合并两个词表,解决冲突
- * 使用最新时间戳的数据
- */
- mergeVocabLists(existing, incoming) {
- // 使用最新的元数据
- const merged = {
- id: existing.id,
- name: existing.name,
- source: existing.source,
- words: [],
- createdAt: existing.createdAt,
- updatedAt: Math.max(existing.updatedAt, incoming.updatedAt)
- };
-
- // 创建单词映射
- const wordMap = new Map();
-
- // 先添加现有单词
- existing.words.forEach(word => {
- const key = word.word.toLowerCase().trim();
- wordMap.set(key, word);
- });
-
- // 合并新单词,使用最新时间戳
- incoming.words.forEach(word => {
- const key = word.word.toLowerCase().trim();
- const existingWord = wordMap.get(key);
-
- if (!existingWord || word.timestamp > existingWord.timestamp) {
- // 新单词或更新的单词
- wordMap.set(key, {
- ...existingWord,
- ...word,
- errorCount: (existingWord?.errorCount || 0) + (word.errorCount || 1)
- });
- }
- });
-
- merged.words = Array.from(wordMap.values());
-
- return merged;
- }
-
- /**
- * 批量同步所有词表
- */
- async syncAllVocabLists(options = {}) {
- const { skipReady = false } = options;
-
- try {
- console.log('[Storage] 开始批量同步所有词表');
-
- const listIds = [
- 'spelling-errors-p1',
- 'spelling-errors-p4',
- 'spelling-errors-master',
- 'custom'
- ];
-
- const results = [];
-
- for (const listId of listIds) {
- const list = await this.loadVocabList(listId, { skipReady });
- if (list) {
- const success = await this.syncVocabList(listId, list, { skipReady });
- results.push({ listId, success });
- }
- }
-
- console.log('[Storage] 批量同步完成:', results);
- return results;
- } catch (error) {
- console.error('[Storage] 批量同步失败:', error);
- return [];
- }
- }
-
- /**
- * 确保数据持久化(页面关闭前)
- */
- async ensureDataPersisted(options = {}) {
- const { skipReady = false } = options;
-
- try {
- console.log('[Storage] 确保数据持久化');
-
- // 强制刷新所有待写入的数据
- if (this.indexedDB) {
- // IndexedDB 事务会自动提交,无需额外操作
- console.log('[Storage] IndexedDB 数据已自动持久化');
- }
-
- // 同步所有词表
- await this.syncAllVocabLists({ skipReady });
-
- console.log('[Storage] 数据持久化完成');
- return true;
- } catch (error) {
- console.error('[Storage] 数据持久化失败:', error);
- return false;
- }
- }
-
- /**
- * 监听页面卸载事件,确保数据持久化
- */
- setupBeforeUnloadHandler() {
- // 使用 beforeunload 事件确保数据保存
- window.addEventListener('beforeunload', async (event) => {
- try {
- console.log('[Storage] 页面即将关闭,确保数据持久化');
-
- // 同步保存所有待写入的数据
- await this.ensureDataPersisted({ skipReady: true });
-
- console.log('[Storage] 数据持久化完成');
- } catch (error) {
- console.error('[Storage] beforeunload 数据持久化失败:', error);
- }
- });
-
- console.log('[Storage] beforeunload 处理器已设置');
- }
-
- /**
- * 检测数据冲突
- */
- detectVocabListConflict(list1, list2) {
- if (!list1 || !list2) return false;
-
- // 检查是否有相同单词但不同内容
- const conflicts = [];
-
- const map1 = new Map(list1.words.map(w => [w.word.toLowerCase(), w]));
- const map2 = new Map(list2.words.map(w => [w.word.toLowerCase(), w]));
-
- for (const [word, data1] of map1) {
- const data2 = map2.get(word);
- if (data2 && data1.timestamp !== data2.timestamp) {
- conflicts.push({
- word,
- data1,
- data2,
- resolution: data1.timestamp > data2.timestamp ? 'use_list1' : 'use_list2'
- });
- }
- }
-
- return conflicts.length > 0 ? conflicts : false;
- }
-
- /**
- * 解决词表冲突
- */
- resolveVocabListConflict(list1, list2, strategy = 'latest') {
- if (strategy === 'latest') {
- return this.mergeVocabLists(list1, list2);
- } else if (strategy === 'keep_list1') {
- return list1;
- } else if (strategy === 'keep_list2') {
- return list2;
- }
-
- return this.mergeVocabLists(list1, list2);
- }
-
- // ==================== 降级存储方案 ====================
-
- /**
- * 检测 IndexedDB 可用性
- */
- isIndexedDBAvailable() {
- try {
- // 检查浏览器是否支持 IndexedDB
- if (!window.indexedDB) {
- console.log('[Storage] IndexedDB 不支持');
- return false;
- }
-
- // 检查是否已成功初始化
- if (this.indexedDB) {
- console.log('[Storage] IndexedDB 可用');
- return true;
- }
-
- console.log('[Storage] IndexedDB 未初始化');
- return false;
- } catch (error) {
- console.error('[Storage] IndexedDB 可用性检测失败:', error);
- return false;
- }
- }
-
- /**
- * 检测 localStorage 可用性
- */
- isLocalStorageAvailable() {
- try {
- const testKey = '__storage_test__';
- localStorage.setItem(testKey, 'test');
- localStorage.removeItem(testKey);
- console.log('[Storage] localStorage 可用');
- return true;
- } catch (error) {
- console.error('[Storage] localStorage 不可用:', error);
- return false;
- }
- }
-
- /**
- * 获取当前存储类型
- */
- getCurrentStorageType() {
- if (this.fallbackStorage) {
- return 'memory';
- } else if (this.indexedDB) {
- return 'indexedDB';
- } else if (this.isLocalStorageAvailable()) {
- return 'localStorage';
- }
- return 'none';
- }
-
- /**
- * 处理存储空间不足
- */
- async handleStorageQuotaExceeded(key, value, options = {}) {
- console.warn('[Storage] 存储空间不足,尝试清理');
-
- try {
- if (this.isProtectedDataKey(key) && !hasInternalAccessOptions(options)) {
- console.error(`[Storage] ${key} 空间不足时禁止 raw fallback`);
- if (window.showMessage) {
- window.showMessage('练习数据保存空间不足,请先导出备份并清理空间', 'error');
- }
- return false;
- }
-
- // 1. 清理旧数据
- await this.cleanupOldData({ skipReady: true });
-
- // 2. 再次尝试保存
- const retrySuccess = await this.set(key, value, { skipReady: true });
- if (retrySuccess) {
- console.log('[Storage] 清理后保存成功');
- return true;
- }
-
- // 3. 如果仍然失败,尝试降级存储
- console.warn('[Storage] 清理后仍然失败,尝试降级存储');
-
- const storageType = this.getCurrentStorageType();
-
- if (storageType === 'indexedDB') {
- // 降级到 localStorage
- console.log('[Storage] 从 IndexedDB 降级到 localStorage');
- try {
- const serializedValue = JSON.stringify({
- data: value,
- timestamp: Date.now(),
- version: this.version
- });
- localStorage.setItem(this.getKey(key), serializedValue);
- console.log('[Storage] localStorage 保存成功');
- return true;
- } catch (localStorageError) {
- console.error('[Storage] localStorage 保存失败:', localStorageError);
- }
- }
-
- // 4. 最后降级到内存存储
- console.warn('[Storage] 降级到内存存储');
- if (!this.fallbackStorage) {
- this.fallbackStorage = new Map();
- }
- const serializedValue = JSON.stringify({
- data: value,
- timestamp: Date.now(),
- version: this.version
- });
- this.fallbackStorage.set(this.getKey(key), serializedValue);
-
- // 提示用户
- if (window.showMessage) {
- window.showMessage('存储空间不足,数据已保存到临时存储,请导出备份', 'warning');
- }
-
- return true;
- } catch (error) {
- console.error('[Storage] 处理存储空间不足失败:', error);
-
- // 最终失败,提示用户
- if (window.showMessage) {
- window.showMessage('存储空间严重不足,无法保存数据,请清理旧数据', 'error');
- }
-
- return false;
- }
- }
-
- /**
- * 词表专用降级保存
- */
- async saveVocabListWithFallback(vocabList, options = {}) {
- const { skipReady = false } = options;
-
- try {
- // 首先尝试正常保存
- const success = await this.saveVocabList(vocabList, { skipReady });
-
- if (success) {
- return true;
- }
-
- // 如果失败,尝试降级保存
- console.warn('[Storage] 词表保存失败,尝试降级保存');
-
- // 压缩词表数据
- const compressedList = this.compressVocabList(vocabList);
-
- // 再次尝试保存压缩后的数据
- const compressedSuccess = await this.saveVocabList(compressedList, { skipReady });
-
- if (compressedSuccess) {
- console.log('[Storage] 压缩后保存成功');
- return true;
- }
-
- // 如果仍然失败,使用降级存储
- return await this.handleStorageQuotaExceeded(
- this.getVocabStorageKey(vocabList.id),
- compressedList
- );
- } catch (error) {
- console.error('[Storage] 词表降级保存失败:', error);
- return false;
- }
- }
-
- /**
- * 压缩词表数据
- */
- compressVocabList(vocabList) {
- return {
- id: vocabList.id,
- name: vocabList.name,
- source: vocabList.source,
- words: vocabList.words.map(word => ({
- word: word.word,
- userInput: word.userInput,
- timestamp: word.timestamp,
- errorCount: word.errorCount
- // 移除其他非必要字段
- })),
- createdAt: vocabList.createdAt,
- updatedAt: vocabList.updatedAt
- };
- }
-
- /**
- * 获取词表存储键
- */
- getVocabStorageKey(listId) {
- const keys = this.getVocabStorageKeys();
-
- if (listId === 'spelling-errors-p1') return keys.P1_ERRORS;
- if (listId === 'spelling-errors-p4') return keys.P4_ERRORS;
- if (listId === 'spelling-errors-master') return keys.MASTER_ERRORS;
- if (listId === 'custom') return keys.CUSTOM;
-
- return listId;
- }
-
- /**
- * 检查存储健康状态
- */
- async checkStorageHealth(options = {}) {
- const { skipReady = false } = options;
-
- try {
- const health = {
- indexedDB: this.isIndexedDBAvailable(),
- localStorage: this.isLocalStorageAvailable(),
- currentType: this.getCurrentStorageType(),
- quotaStatus: 'unknown'
- };
-
- // 检查配额状态
- const storageInfo = await this.getStorageInfo({ skipReady });
- if (storageInfo) {
- const usagePercent = storageInfo.type === 'localStorage'
- ? (storageInfo.used / (5 * 1024 * 1024)) * 100
- : (storageInfo.used / (105 * 1024 * 1024)) * 100;
-
- if (usagePercent < 70) {
- health.quotaStatus = 'healthy';
- } else if (usagePercent < 90) {
- health.quotaStatus = 'warning';
- } else {
- health.quotaStatus = 'critical';
- }
-
- health.usagePercent = usagePercent;
- health.used = storageInfo.used;
- }
-
- console.log('[Storage] 存储健康状态:', health);
- return health;
- } catch (error) {
- console.error('[Storage] 检查存储健康状态失败:', error);
- return {
- indexedDB: false,
- localStorage: false,
- currentType: 'none',
- quotaStatus: 'error'
- };
- }
- }
-
- // ==================== 数据导出功能 ====================
-
- /**
- * 导出练习记录
- */
- async exportPracticeRecords(options = {}) {
- const { skipReady = false, format = 'json' } = options;
-
- try {
- console.log('[Storage] 开始导出练习记录');
-
- const records = await this.listPracticeRecordsCanonical({ skipReady });
-
- const exportData = {
- type: 'practice_records',
- version: this.version,
- exportDate: new Date().toISOString(),
- recordCount: records.length,
- records: records
- };
-
- console.log(`[Storage] 练习记录导出完成,共 ${records.length} 条`);
-
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
- }
-
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出练习记录失败:', error);
- return null;
- }
- }
-
- /**
- * 导出词表数据
- */
- async exportVocabLists(options = {}) {
- const { skipReady = false, format = 'json', listIds = null } = options;
-
- try {
- console.log('[Storage] 开始导出词表数据');
-
- const vocabLists = [];
- const targetListIds = listIds || [
- 'spelling-errors-p1',
- 'spelling-errors-p4',
- 'spelling-errors-master',
- 'custom'
- ];
-
- for (const listId of targetListIds) {
- const list = await this.loadVocabList(listId, { skipReady });
- if (list && list.words.length > 0) {
- vocabLists.push(list);
- }
- }
-
- const exportData = {
- type: 'vocabulary_lists',
- version: this.version,
- exportDate: new Date().toISOString(),
- listCount: vocabLists.length,
- totalWords: vocabLists.reduce((sum, list) => sum + list.words.length, 0),
- lists: vocabLists
- };
-
- console.log(`[Storage] 词表导出完成,共 ${vocabLists.length} 个词表,${exportData.totalWords} 个单词`);
-
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
- }
-
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出词表数据失败:', error);
- return null;
- }
- }
-
- /**
- * 导出单个词表
- */
- async exportSingleVocabList(listId, options = {}) {
- const { skipReady = false, format = 'json' } = options;
-
- try {
- console.log(`[Storage] 开始导出词表: ${listId}`);
-
- const list = await this.loadVocabList(listId, { skipReady });
-
- if (!list) {
- console.warn(`[Storage] 词表不存在: ${listId}`);
- return null;
- }
-
- const exportData = {
- type: 'vocabulary_list',
- version: this.version,
- exportDate: new Date().toISOString(),
- list: list
- };
-
- console.log(`[Storage] 词表导出完成: ${listId}, ${list.words.length} 个单词`);
-
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
- }
-
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出词表失败:', error);
- return null;
- }
- }
-
- /**
- * 导出完整数据(包括练习记录和词表)
- */
- async exportCompleteData(options = {}) {
- const { skipReady = false, format = 'json' } = options;
-
- try {
- console.log('[Storage] 开始导出完整数据');
-
- // 导出所有数据
- const allData = await this.exportData({ skipReady });
-
- // 导出练习记录
- const practiceRecords = await this.exportPracticeRecords({
- skipReady,
- format: 'object'
- });
-
- // 导出词表
- const vocabLists = await this.exportVocabLists({
- skipReady,
- format: 'object'
- });
-
- const exportData = {
- type: 'complete_export',
- version: this.version,
- exportDate: new Date().toISOString(),
- summary: {
- totalRecords: allData?.storageInfo?.totalRecords || 0,
- practiceRecords: practiceRecords?.recordCount || 0,
- vocabLists: vocabLists?.listCount || 0,
- totalWords: vocabLists?.totalWords || 0
- },
- data: {
- all: allData,
- practiceRecords: practiceRecords,
- vocabLists: vocabLists
- }
- };
-
- console.log('[Storage] 完整数据导出完成');
-
- if (format === 'json') {
- return JSON.stringify(exportData, null, 2);
- }
-
- return exportData;
- } catch (error) {
- console.error('[Storage] 导出完整数据失败:', error);
- return null;
- }
- }
-
- /**
- * 下载导出数据为文件
- */
- downloadExportData(data, filename = null) {
- try {
- if (!data) {
- console.error('[Storage] 无数据可导出');
- return false;
- }
-
- // 确保数据是字符串格式
- const jsonString = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
-
- // 创建 Blob
- const blob = new Blob([jsonString], { type: 'application/json' });
-
- // 生成文件名
- const defaultFilename = `ielts-practice-export-${new Date().toISOString().split('T')[0]}.json`;
- const finalFilename = filename || defaultFilename;
-
- // 创建下载链接
- const url = URL.createObjectURL(blob);
- const link = document.createElement('a');
- link.href = url;
- link.download = finalFilename;
-
- // 触发下载
- document.body.appendChild(link);
- link.click();
-
- // 清理
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
-
- console.log(`[Storage] 数据已下载: ${finalFilename}`);
- return true;
- } catch (error) {
- console.error('[Storage] 下载导出数据失败:', error);
- return false;
- }
- }
-
- /**
- * 导出并下载练习记录
- */
- async exportAndDownloadPracticeRecords(filename = null) {
- try {
- const data = await this.exportPracticeRecords({ format: 'json' });
- if (data) {
- const defaultFilename = `practice-records-${new Date().toISOString().split('T')[0]}.json`;
- return this.downloadExportData(data, filename || defaultFilename);
- }
- return false;
- } catch (error) {
- console.error('[Storage] 导出并下载练习记录失败:', error);
- return false;
- }
- }
-
- /**
- * 导出并下载词表数据
- */
- async exportAndDownloadVocabLists(filename = null) {
- try {
- const data = await this.exportVocabLists({ format: 'json' });
- if (data) {
- const defaultFilename = `vocab-lists-${new Date().toISOString().split('T')[0]}.json`;
- return this.downloadExportData(data, filename || defaultFilename);
- }
- return false;
- } catch (error) {
- console.error('[Storage] 导出并下载词表数据失败:', error);
- return false;
- }
- }
-
- /**
- * 导出并下载完整数据
- */
- async exportAndDownloadCompleteData(filename = null) {
- try {
- const data = await this.exportCompleteData({ format: 'json' });
- if (data) {
- const defaultFilename = `complete-data-${new Date().toISOString().split('T')[0]}.json`;
- return this.downloadExportData(data, filename || defaultFilename);
- }
- return false;
- } catch (error) {
- console.error('[Storage] 导出并下载完整数据失败:', error);
- return false;
- }
- }
-
- /**
- * 导入词表数据
- */
- async importVocabLists(importData, options = {}) {
- const { skipReady = false, merge = true } = options;
-
- try {
- console.log('[Storage] 开始导入词表数据');
-
- if (!importData || !importData.lists) {
- console.error('[Storage] 导入数据格式无效');
- return false;
- }
-
- let successCount = 0;
- let failCount = 0;
-
- for (const list of importData.lists) {
- try {
- if (merge) {
- // 合并模式:与现有数据合并
- const success = await this.syncVocabList(list.id, list, { skipReady });
- if (success) {
- successCount++;
- } else {
- failCount++;
- }
- } else {
- // 覆盖模式:直接保存
- const success = await this.saveVocabList(list, { skipReady });
- if (success) {
- successCount++;
- } else {
- failCount++;
- }
- }
- } catch (error) {
- console.error(`[Storage] 导入词表失败: ${list.id}`, error);
- failCount++;
- }
- }
-
- console.log(`[Storage] 词表导入完成: ${successCount} 成功, ${failCount} 失败`);
- return { successCount, failCount };
- } catch (error) {
- console.error('[Storage] 导入词表数据失败:', error);
- return false;
- }
- }
-}
-
-const STORAGE_SYNC_IGNORED_KEYS = new Set([
- 'namespace_test',
- 'namespace_test_practice',
- 'namespace_test_enhancer'
-]);
-
-StorageManager.prototype.dispatchStorageSync = function(key) {
- try {
- const normalizedKey = typeof key === 'string' ? key.replace(this.prefix, '') : key;
- if (normalizedKey && STORAGE_SYNC_IGNORED_KEYS.has(normalizedKey)) {
- return;
- }
- } catch (_) {
- // ignore errors resolving key
- }
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } }));
-};
-
-// 创建全局存储实例
-class PreferenceStore {
- constructor(prefix = 'exam_system_') {
- this.prefix = prefix;
- this.ready = Promise.resolve();
- }
-
- setNamespace(namespace) {
- if (typeof namespace === 'string' && namespace.trim()) {
- this.prefix = namespace.trim() + '_';
- }
- }
-
- getScopedKey(key) {
- return key.startsWith(this.prefix) ? key : this.prefix + key;
- }
-
- getStorageArea(session = false) {
- return session ? window.sessionStorage : window.localStorage;
- }
-
- serialize(value) {
- return JSON.stringify({ data: value, timestamp: Date.now() });
- }
-
- deserialize(rawValue, defaultValue = null) {
- if (!rawValue) {
- return defaultValue;
- }
- try {
- const parsed = JSON.parse(rawValue);
- return parsed && Object.prototype.hasOwnProperty.call(parsed, 'data')
- ? parsed.data
- : defaultValue;
- } catch (_) {
- return defaultValue;
- }
- }
-
- async get(key, defaultValue = null, options = {}) {
- const storage = this.getStorageArea(options.session === true);
- return this.deserialize(storage.getItem(this.getScopedKey(key)), defaultValue);
- }
-
- async set(key, value, options = {}) {
- const storage = this.getStorageArea(options.session === true);
- storage.setItem(this.getScopedKey(key), this.serialize(value));
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } }));
- return true;
- }
-
- async remove(key, options = {}) {
- const storage = this.getStorageArea(options.session === true);
- storage.removeItem(this.getScopedKey(key));
- window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key } }));
- return true;
- }
-
- async clear(options = {}) {
- const storage = this.getStorageArea(options.session === true);
- Object.keys(storage)
- .filter((key) => key.startsWith(this.prefix))
- .forEach((key) => storage.removeItem(key));
- return true;
- }
-}
-
-class StorageKeyRegistry {
- constructor() {
- this.preferenceKeys = new Set([
- 'theme_settings',
- 'current_theme',
- 'keyboard_shortcuts_enabled',
- 'sound_effects_enabled',
- 'auto_save_enabled',
- 'notifications_enabled',
- 'theme',
- 'bloom-theme-mode',
- 'blue-theme-mode',
- 'browse_state',
- 'hasSeenGplLicense',
- 'preferred_theme_portal'
- ]);
- this.sessionKeys = new Set([
- 'preferred_theme_skip_session'
- ]);
- }
-
- resolve(key) {
- if (this.sessionKeys.has(key)) {
- return { key, storageClass: 'session' };
- }
- if (this.preferenceKeys.has(key)) {
- return { key, storageClass: 'preference' };
- }
- return { key, storageClass: 'persistent' };
- }
-}
-
-class StorageFacade {
- constructor(options = {}) {
- this.persistentStore = options.persistentStore;
- this.preferenceStore = options.preferenceStore;
- this.keyRegistry = options.keyRegistry;
- this.ready = this.persistentStore ? this.persistentStore.ready : Promise.resolve();
- }
-
- setNamespace(namespace) {
- if (this.persistentStore && typeof this.persistentStore.setNamespace === 'function') {
- this.persistentStore.setNamespace(namespace);
- }
- if (this.preferenceStore && typeof this.preferenceStore.setNamespace === 'function') {
- this.preferenceStore.setNamespace(namespace);
- }
- }
-
- resolveStore(key) {
- const entry = this.keyRegistry.resolve(key);
- if (entry.storageClass === 'preference') {
- return { entry, store: this.preferenceStore, options: { session: false } };
- }
- if (entry.storageClass === 'session') {
- return { entry, store: this.preferenceStore, options: { session: true } };
- }
- return { entry, store: this.persistentStore, options: {} };
- }
-
- async get(key, defaultValue = null, options = {}) {
- const target = this.resolveStore(key);
- return await target.store.get(key, defaultValue, Object.assign({}, target.options, options));
- }
-
- async set(key, value, options = {}) {
- const target = this.resolveStore(key);
- return await target.store.set(key, value, Object.assign({}, target.options, options));
- }
-
- async remove(key, options = {}) {
- const target = this.resolveStore(key);
- return await target.store.remove(key, Object.assign({}, target.options, options));
- }
-
- async clear(options = {}) {
- if (this.persistentStore && typeof this.persistentStore.clear === 'function') {
- await this.persistentStore.clear(options);
- }
- if (this.preferenceStore && typeof this.preferenceStore.clear === 'function') {
- await this.preferenceStore.clear({ session: false });
- await this.preferenceStore.clear({ session: true });
- }
- return true;
- }
-
- async getStorageInfo(options = {}) {
- const persistentInfo = this.persistentStore && typeof this.persistentStore.getStorageInfo === 'function'
- ? await this.persistentStore.getStorageInfo(options)
- : null;
- return Object.assign({}, persistentInfo || {}, {
- facade: 'storage-facade',
- volatile: Boolean(this.persistentStore && this.persistentStore.volatileMode)
- });
- }
-}
-
-const storageManager = new StorageManager();
-const preferenceStore = new PreferenceStore(storageManager.prefix);
-const storageKeyRegistry = new StorageKeyRegistry();
-const storageFacade = new StorageFacade({
- persistentStore: storageManager,
- preferenceStore,
- keyRegistry: storageKeyRegistry
-});
-
-window.persistentStore = storageManager;
-window.preferenceStore = preferenceStore;
-window.storageKeyRegistry = storageKeyRegistry;
-window.storage = storageFacade;
-Object.defineProperty(window, '__installStorageInternalAccess', {
- value(install) {
- if (typeof install !== 'function') {
- throw new Error('__installStorageInternalAccess requires an installer function');
- }
- const result = install(createInternalAccessOptions, hasInternalAccessOptions);
- if (result !== false) {
- try {
- delete window.__installStorageInternalAccess;
- } catch (_) {
- window.__installStorageInternalAccess = undefined;
- }
- }
- return result;
- },
- enumerable: false,
- configurable: true,
- writable: false
-});
-
-// 启动存储监控和数据同步
-storageManager.ready
- .then(() => {
- storageManager.startStorageMonitoring();
- storageManager.setupBeforeUnloadHandler();
- })
- .catch(error => {
- console.error('[Storage] 存储初始化失败,监控未启动:', error);
- });
-})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/js/utils/suitePreference.js b/js/utils/suitePreference.js
index 1240afbb..188af731 100644
--- a/js/utils/suitePreference.js
+++ b/js/utils/suitePreference.js
@@ -1,10 +1,6 @@
(function initSuitePreferenceUtils(global) {
'use strict';
- const FLOW_MODE_STORAGE_KEY = 'suite_flow_mode';
- const FREQUENCY_SCOPE_STORAGE_KEY = 'suite_frequency_scope';
- const AUTO_ADVANCE_STORAGE_KEY = 'suite_auto_advance_after_submit';
-
const FLOW_MODES = ['classic', 'simulation', 'stationary'];
const FREQUENCY_SCOPES = ['high', 'high_medium', 'all', 'custom'];
@@ -119,50 +115,54 @@
return null;
}
- function readStorageValue(key) {
- try {
- if (global.localStorage && typeof global.localStorage.getItem === 'function') {
- return global.localStorage.getItem(key);
- }
- } catch (_) {
- // ignore read failures
- }
- return null;
- }
-
- function writeStorageValue(key, value) {
- try {
- if (global.localStorage && typeof global.localStorage.setItem === 'function') {
- global.localStorage.setItem(key, String(value));
- }
- } catch (_) {
- // ignore write failures
- }
+ let hydrationPromise = null;
+ function hydrateSuitePreference() {
+ if (hydrationPromise) return hydrationPromise;
+ // runtime-entry.bundle.js is intentionally loaded before the data
+ // foundation. Do not memoize that early miss: a cached `false` would
+ // make every later resolver skip the persisted AppData preference.
+ if (!global.AppData || !global.AppData.preferences) {
+ return Promise.resolve(false);
+ }
+ hydrationPromise = Promise.resolve().then(async () => {
+ await global.AppData.ready;
+ const stored = await global.AppData.preferences.getSuite();
+ if (stored && typeof stored === 'object') Object.assign(ensurePracticeConfig().suite, stored);
+ return true;
+ }).catch((error) => {
+ console.warn('[SuitePreference] 加载失败:', error);
+ return false;
+ });
+ // A transient AppData initialization failure should be retryable on the
+ // next read, just like the pre-foundation early miss above.
+ hydrationPromise = hydrationPromise.then((hydrated) => {
+ if (!hydrated) hydrationPromise = null;
+ return hydrated;
+ });
+ return hydrationPromise;
}
- function resolveSuitePreference(overrides = {}) {
+ async function resolveSuitePreference(overrides = {}) {
+ await hydrateSuitePreference();
const config = ensurePracticeConfig();
const suiteConfig = config.suite || {};
const flowMode = normalizeFlowMode(overrides.flowMode)
|| normalizeFlowMode(suiteConfig.flowMode)
- || normalizeFlowMode(readStorageValue(FLOW_MODE_STORAGE_KEY))
|| 'classic';
const frequencyScope = normalizeFrequencyScope(overrides.frequencyScope)
|| normalizeFrequencyScope(suiteConfig.frequencyScope)
- || normalizeFrequencyScope(readStorageValue(FREQUENCY_SCOPE_STORAGE_KEY))
|| 'all';
const overrideAutoAdvance = parseBoolean(overrides.autoAdvanceAfterSubmit);
const configAutoAdvance = parseBoolean(suiteConfig.autoAdvanceAfterSubmit);
- const storedAutoAdvance = parseBoolean(readStorageValue(AUTO_ADVANCE_STORAGE_KEY));
const fallbackAutoAdvance = flowMode !== 'stationary';
const autoAdvanceAfterSubmit = overrideAutoAdvance != null
? overrideAutoAdvance
: (configAutoAdvance != null
? configAutoAdvance
- : (storedAutoAdvance != null ? storedAutoAdvance : fallbackAutoAdvance));
+ : fallbackAutoAdvance);
config.suite.flowMode = flowMode;
config.suite.frequencyScope = frequencyScope;
@@ -176,24 +176,34 @@
}
function persistSuitePreference(partial = {}) {
- const current = resolveSuitePreference();
+ const config = ensurePracticeConfig();
+ const suiteConfig = config.suite || {};
+ const fallbackCurrent = {
+ flowMode: normalizeFlowMode(suiteConfig.flowMode) || 'classic',
+ frequencyScope: normalizeFrequencyScope(suiteConfig.frequencyScope) || 'all',
+ autoAdvanceAfterSubmit: parseBoolean(suiteConfig.autoAdvanceAfterSubmit)
+ };
- const flowMode = normalizeFlowMode(partial.flowMode) || current.flowMode;
- const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || current.frequencyScope;
+ const flowMode = normalizeFlowMode(partial.flowMode) || fallbackCurrent.flowMode;
+ const frequencyScope = normalizeFrequencyScope(partial.frequencyScope) || fallbackCurrent.frequencyScope;
const partialAutoAdvance = parseBoolean(partial.autoAdvanceAfterSubmit);
const autoAdvanceAfterSubmit = partialAutoAdvance != null
? partialAutoAdvance
: (flowMode === 'stationary' ? false : true);
- const config = ensurePracticeConfig();
config.suite.flowMode = flowMode;
config.suite.frequencyScope = frequencyScope;
config.suite.autoAdvanceAfterSubmit = autoAdvanceAfterSubmit;
- writeStorageValue(FLOW_MODE_STORAGE_KEY, flowMode);
- writeStorageValue(FREQUENCY_SCOPE_STORAGE_KEY, frequencyScope);
- writeStorageValue(AUTO_ADVANCE_STORAGE_KEY, autoAdvanceAfterSubmit ? 'true' : 'false');
+ hydrateSuitePreference().then((hydrated) => {
+ if (!hydrated || !global.AppData || !global.AppData.preferences) return;
+ return global.AppData.preferences.patchSuite({
+ flowMode,
+ frequencyScope,
+ autoAdvanceAfterSubmit
+ });
+ }).catch((error) => console.warn('[SuitePreference] 保存失败:', error));
return {
flowMode,
@@ -210,12 +220,19 @@
normalizeFrequencyScope,
normalizeFrequency,
isFrequencyIncluded,
+ ready: hydrateSuitePreference,
resolveSuitePreference,
persistSuitePreference
};
global.SuitePreferenceUtils = api;
+ // Kick hydration off eagerly so any later resolver (including the
+ // synchronous readers inside suitePracticeMixin) does not race the very
+ // first AppData.preferences.getSuite() lookup. If the data foundation is
+ // not installed yet, hydrateSuitePreference deliberately retries later.
+ hydrateSuitePreference();
+
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
diff --git a/js/utils/vocabDataIO.js b/js/utils/vocabDataIO.js
index e009a2a8..79a81e16 100644
--- a/js/utils/vocabDataIO.js
+++ b/js/utils/vocabDataIO.js
@@ -235,10 +235,12 @@
return buildImportResult('progress', entries, {
format: 'json',
originalLength: payload.words.length,
+ listId: typeof payload.listId === 'string' && payload.listId.trim()
+ ? payload.listId.trim()
+ : undefined,
category: category || 'user',
version: typeof payload.version === 'string' ? payload.version : undefined,
config: payload.config && typeof payload.config === 'object' ? { ...payload.config } : undefined,
- reviewQueue: Array.isArray(payload.reviewQueue) ? payload.reviewQueue.slice() : undefined,
name: typeof payload.name === 'string' ? payload.name : undefined,
source: typeof payload.source === 'string' ? payload.source : undefined,
exportedAt: typeof payload.exportedAt === 'string' ? payload.exportedAt : undefined
@@ -309,17 +311,17 @@
}
async function exportProgress() {
- const store = window.VocabStore;
- if (!store || typeof store.init !== 'function') {
- throw new Error('VocabStore 未加载');
- }
- await store.init();
+ if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab 未加载');
+ await window.AppData.ready;
+ const config = await window.AppData.vocab.getConfig();
+ const listId = config.activeListId || 'default';
+ const list = await window.AppData.vocab.readList(listId);
const payload = {
version: DEFAULT_EXPORT_VERSION,
exportedAt: new Date().toISOString(),
- config: store.getConfig(),
- words: store.getWords(),
- reviewQueue: store.getReviewQueue()
+ listId,
+ config,
+ words: Array.isArray(list) ? list : (list && Array.isArray(list.words) ? list.words : [])
};
return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
}
diff --git a/js/views/legacyViewBundle.js b/js/views/legacyViewBundle.js
index 271dadb7..c9758812 100644
--- a/js/views/legacyViewBundle.js
+++ b/js/views/legacyViewBundle.js
@@ -230,12 +230,11 @@
if (!record) {
return false;
}
- var exam = index.find(function (item) {
- return item && (item.id === record.examId || item.title === record.title);
- });
- var examType = exam ? normalizeTypeValue(exam.type) : '';
- if (examType) {
- return examType === targetType;
+ var suiteEntries = ensureArray(record.suiteEntrySummaries);
+ if (suiteEntries.length) {
+ return suiteEntries.some(function (entry) {
+ return normalizeTypeValue(entry && entry.type) === targetType;
+ });
}
var recordType = normalizeTypeValue(
record.type ||
@@ -246,6 +245,13 @@
if (recordType) {
return recordType === targetType;
}
+ var exam = index.find(function (item) {
+ return item && (item.id === record.examId || item.title === record.title);
+ });
+ var examType = exam ? normalizeTypeValue(exam.type) : '';
+ if (examType) {
+ return examType === targetType;
+ }
// 无法确定类型时保持展示,避免题库切换导致历史记录被过滤掉
return true;
});
@@ -296,7 +302,9 @@
if (typeof value !== 'number' || isNaN(value)) {
return '0.0%';
}
- return value.toFixed(1) + '%';
+ // Practice-record summary UI: keep a single decimal place so
+ // correct/total ratios do not dump long floating tails into the list.
+ return (Math.round(value * 10) / 10).toFixed(1) + '%';
}
function formatMinutes(minutes) {
@@ -878,6 +886,21 @@
return used;
}
+ function addProjectedErrorCounts(counts, projectedCounts) {
+ if (!projectedCounts || typeof projectedCounts !== 'object') {
+ return false;
+ }
+ var used = false;
+ Object.keys(projectedCounts).forEach(function addProjected(type) {
+ var value = Math.max(0, Number(projectedCounts[type]) || 0);
+ if (value > 0) {
+ addRadarCount(counts, type, value);
+ used = true;
+ }
+ });
+ return used;
+ }
+
function addDetailCounts(counts, record) {
var questionTypeMap = buildReadingQuestionTypeMap(record);
var sources = getDetailSources(record);
@@ -904,7 +927,24 @@
function calculateReadingRadarData(records) {
var counts = {};
- var recentReadingRecords = ensureArray(records)
+ var radarCandidates = [];
+ ensureArray(records).forEach(function expandSuiteRecord(record) {
+ var suiteEntries = ensureArray(record && record.suiteEntrySummaries);
+ if (suiteEntries.length) {
+ suiteEntries.forEach(function addSuiteEntry(entry) {
+ if (!entry) {
+ return;
+ }
+ radarCandidates.push(Object.assign({}, entry, {
+ metadata: Object.assign({}, entry.metadata || {}, { type: entry.type }),
+ date: entry.date || (record && record.date)
+ }));
+ });
+ return;
+ }
+ radarCandidates.push(record);
+ });
+ var recentReadingRecords = radarCandidates
.filter(function filterReading(record) {
var metadata = record && record.metadata ? record.metadata : {};
var realData = record && record.realData ? record.realData : {};
@@ -924,6 +964,9 @@
.slice(0, 10);
recentReadingRecords.forEach(function collectRecord(record) {
+ if (addProjectedErrorCounts(counts, record && record.questionTypeErrorCounts)) {
+ return;
+ }
var performanceMap = record && (record.questionTypePerformance ||
(record.realData && record.realData.questionTypePerformance));
if (addPerformanceCounts(counts, performanceMap)) {
@@ -1547,31 +1590,24 @@
// 练习洞察卡片选中的组件(热力图 / 中高频余量 / 阅读雷达)持久化,
// 刷新或重开页面后沿用用户上次的选中组件,而不是总回到默认的热力图。
- var PRACTICE_WIDGET_PREFERENCE_KEY = 'practice_custom_widget';
var SUPPORTED_PRACTICE_WIDGETS = ['heatmap', 'priority', 'radar'];
+ var persistedPracticeWidget = null;
+ if (window.AppData && window.AppData.preferences) {
+ window.AppData.ready.then(function () { return window.AppData.preferences.getPracticeWidget(); }).then(function (value) {
+ persistedPracticeWidget = SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null;
+ }).catch(function () {});
+ }
function loadPersistedPracticeWidget() {
- try {
- if (typeof localStorage === 'undefined' || !localStorage) {
- return null;
- }
- var value = localStorage.getItem(PRACTICE_WIDGET_PREFERENCE_KEY);
- return SUPPORTED_PRACTICE_WIDGETS.indexOf(value) >= 0 ? value : null;
- } catch (_) {
- return null;
- }
+ return persistedPracticeWidget;
}
function persistPracticeWidget(widget) {
- try {
- if (typeof localStorage === 'undefined' || !localStorage) {
- return;
- }
- if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) {
- localStorage.setItem(PRACTICE_WIDGET_PREFERENCE_KEY, widget);
- }
- } catch (_) {
- /* 持久化失败不影响渲染 */
+ if (SUPPORTED_PRACTICE_WIDGETS.indexOf(widget) >= 0) {
+ persistedPracticeWidget = widget;
+ window.AppData.preferences.setPracticeWidget(widget).catch(function (error) {
+ console.warn('[PracticeWidget] 保存失败:', error);
+ });
}
}
@@ -2262,7 +2298,10 @@
var durationInSeconds = Number(record && record.duration) || 0;
var percentage = typeof record.percentage === 'number'
? record.percentage
- : Math.round((record.accuracy || 0) * 100);
+ : ((Number(record.accuracy) || 0) * 100);
+ if (!Number.isFinite(percentage)) {
+ percentage = 0;
+ }
var recordId = '';
if (record && record.id != null) {
@@ -2334,7 +2373,7 @@
createNode('div', {
className: 'record-percentage',
style: { color: helpers.getScoreColor(percentage) }
- }, percentage + '%')
+ }, formatPercentage(percentage))
]);
var actions = null;
@@ -3207,10 +3246,125 @@
};
}
+ var browseCompletionIndex = {
+ byExamId: new Map(),
+ byTitle: new Map(),
+ records: [],
+ ready: false
+ };
+
+ function rememberCompletionCandidate(map, key, candidate) {
+ if (!map || !key || !candidate) {
+ return;
+ }
+ var existing = map.get(key);
+ if (!existing || candidate.timestamp > existing.timestamp) {
+ map.set(key, candidate);
+ }
+ }
+
+ /**
+ * 在 setPracticeRecords 时重建一次正确率索引。
+ * 不使用 version 计数器;生命周期绑定“写状态那一次”。
+ */
+ function resolveRecordExamId(record) {
+ if (!record || typeof record !== 'object') {
+ return '';
+ }
+ var metadata = record.metadata && typeof record.metadata === 'object' ? record.metadata : {};
+ var realData = record.realData && typeof record.realData === 'object' ? record.realData : {};
+ var rawData = record.rawData && typeof record.rawData === 'object' ? record.rawData : {};
+ return record.examId || metadata.examId || realData.examId || rawData.examId || '';
+ }
+
+ function getBrowseSuiteEntries(record) {
+ if (!record || typeof record !== 'object') {
+ return [];
+ }
+ var summaries = Array.isArray(record.suiteEntrySummaries) ? record.suiteEntrySummaries : [];
+ if (summaries.length) {
+ return summaries;
+ }
+ return Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
+ }
+
+ function rebuildBrowseCompletionIndex(records) {
+ var byExamId = new Map();
+ var byTitle = new Map();
+ var recordSnapshot = ensureArray(records).slice();
+ recordSnapshot.forEach(function indexRecord(record) {
+ if (!record || typeof record !== 'object') {
+ return;
+ }
+ var candidate = buildCompletionStatusCandidate(record);
+ var recordExamId = resolveRecordExamId(record);
+ if (recordExamId) {
+ rememberCompletionCandidate(byExamId, String(recordExamId), candidate);
+ }
+ var recordTitle = record.title || record.examTitle || (record.metadata && record.metadata.examTitle) || '';
+ if (recordTitle) {
+ rememberCompletionCandidate(byTitle, String(recordTitle), candidate);
+ }
+ var suiteEntries = getBrowseSuiteEntries(record);
+ suiteEntries.forEach(function indexSuiteEntry(entry) {
+ if (!entry || typeof entry !== 'object') {
+ return;
+ }
+ var comparableEntry = buildComparableSuiteEntryRecord(record, entry);
+ var entryCandidate = buildCompletionStatusCandidate(comparableEntry, record);
+ var entryExamId = resolveRecordExamId(comparableEntry);
+ if (entryExamId) {
+ rememberCompletionCandidate(byExamId, String(entryExamId), entryCandidate);
+ }
+ var entryTitle = comparableEntry.title || comparableEntry.examTitle || '';
+ if (entryTitle) {
+ rememberCompletionCandidate(byTitle, String(entryTitle), entryCandidate);
+ }
+ });
+ });
+ browseCompletionIndex = {
+ byExamId: byExamId,
+ byTitle: byTitle,
+ records: recordSnapshot,
+ ready: true
+ };
+ return browseCompletionIndex;
+ }
+
+ function ensureBrowseCompletionIndex() {
+ if (browseCompletionIndex.ready) {
+ return browseCompletionIndex;
+ }
+ return browseCompletionIndex;
+ }
+
LegacyExamListView.prototype._getCompletionStatus = function _getCompletionStatus(exam) {
- var source = (typeof global.getPracticeRecordsState === 'function')
- ? global.getPracticeRecordsState()
- : global.practiceRecords;
+ var index = ensureBrowseCompletionIndex();
+ var byId = null;
+ var byTitle = null;
+ if (exam && exam.id && index.byExamId.has(String(exam.id))) {
+ byId = index.byExamId.get(String(exam.id));
+ }
+ if (exam && exam.title && index.byTitle.has(String(exam.title))) {
+ byTitle = index.byTitle.get(String(exam.title));
+ }
+ // 同时有 examId / title 命中时取较新时间戳,避免旧 examId 遮蔽更新 title 匹配。
+ var indexed = null;
+ if (byId && byTitle) {
+ indexed = (Number(byId.timestamp) || 0) >= (Number(byTitle.timestamp) || 0) ? byId : byTitle;
+ } else {
+ indexed = byId || byTitle;
+ }
+ if (indexed) {
+ return {
+ percentage: typeof indexed.percentage === 'number' ? indexed.percentage : 0,
+ date: indexed.date || null,
+ duration: typeof indexed.duration === 'number' ? indexed.duration : 0
+ };
+ }
+
+ // Path/file fallback scans the same authoritative snapshot used to build the index.
+ var source = index.records;
var statuses = [];
ensureArray(source).forEach(function collectStatus(record) {
if (!record || typeof record !== 'object') {
@@ -3219,7 +3373,7 @@
if (recordMatchesExam(exam, record)) {
statuses.push(buildCompletionStatusCandidate(record));
}
- var suiteEntries = Array.isArray(record.suiteEntries) ? record.suiteEntries : [];
+ var suiteEntries = getBrowseSuiteEntries(record);
suiteEntries.forEach(function collectSuiteEntry(entry) {
if (!entry || typeof entry !== 'object') {
return;
@@ -3244,6 +3398,8 @@
};
};
+ global.rebuildBrowseCompletionIndex = rebuildBrowseCompletionIndex;
+
// --- Legacy navigation controller ---
function LegacyNavigationController(options) {
options = options || {};
@@ -3522,8 +3678,8 @@
};
LibraryConfigView.prototype._renderItem = function _renderItem(config, activeKey, allowDelete) {
- var isActive = activeKey === config.key;
- var isDefault = config.key === 'exam_index';
+ var isDefault = config.builtIn === true;
+ var isActive = isDefault ? activeKey == null : activeKey === config.key;
var className = this.classNames.item + (isActive ? ' ' + this.classNames.itemActive : '');
var item = this._createElement('div', {
@@ -3551,7 +3707,7 @@
type: 'button',
dataset: {
configAction: 'switch',
- configKey: config.key,
+ configKey: config.key || '',
configActive: isActive ? '1' : '0'
}
}, '切换');
@@ -3578,7 +3734,7 @@
type: 'button',
dataset: {
configAction: 'delete',
- configKey: config.key,
+ configKey: config.key || '',
configActive: isActive ? '1' : '0'
}
}, '删除');
diff --git a/progress.md b/progress.md
new file mode 100644
index 00000000..f4cb297e
--- /dev/null
+++ b/progress.md
@@ -0,0 +1,141 @@
+# Progress
+
+- 2026-07-28: Read `planning-with-files` instructions and ran session catch-up.
+- 2026-07-28: Captured baseline branch, HEAD, divergence, and preserved dirty-file list.
+- 2026-07-28: Initialized implementation plan and findings files.
+- 2026-07-28: Default-mode subagent dispatch also returned `unsupported call`; switched to parallel read-only shell probes.
+- 2026-07-28: Captured existing UI source diffs and selected restore/journal/broadcast implementation contracts.
+- 2026-07-28: Implemented DataKernel cross-realm commit broadcast, `CORRUPT_RECORD` isolation, restore journal reset, and restore commit notifications; syntax check passed.
+- 2026-07-28: Session catch-up found an additional unsynced `AppData` projection patch in the worktree; exact projection/import coverage still requires diff review and focused tests before phase 2 can be marked complete.
+- 2026-07-28: Attempted the mandated read-only subagent split; `spawn_agent` again returned `unsupported call`. Stopped repeating the unavailable call and switched to parallel shell probes.
+- 2026-07-28: First parallel shell probe aborted because this repository has no root `package.json`; adjusted discovery to locate manifests/build runners instead of assuming layout.
+- 2026-07-28: Reviewed the unsynced AppData diff: legacy nested annotations, score aliases/accuracy normalization, and sanitized suite light summaries are implemented in source; consumers/tests are not yet verified.
+- 2026-07-28: Completed focused restore/vocab review. Full mirror/three-layer invariant and public restore journal reset are still missing; vocab collection RMW paths remain bare CAS.
+- 2026-07-28: Implemented full-scope cleared envelopes, atomic three-layer import planning with recordId-set validation, restore journal reset wiring, and corrupt-summary skipping. Syntax passed; the old orphan-preserving test now fails at the intended new validation gate.
+- 2026-07-28: Added a single vocab mutation queue with bounded fresh-read CAS retries across config, collections, word merge/patch, and progress paths.
+- 2026-07-28: Replaced the orphan-preserving test with full-mirror, incomplete replace, recordId-set, nested v1, sanitized suite light, journal reset, concurrent vocab, BroadcastChannel, and corrupt-row isolation regressions. Focused AppData/DataKernel tests pass.
+- 2026-07-28: Completed the protocol source read. Main mixin ACK/origin infrastructure is reusable; fallback origin, duplicate recorder, Listening pending submission, and vocab request receipts remain to implement.
+- 2026-07-28: Implemented fallback opaque-origin normalization and required submission IDs; removed the duplicate mixin fallback recorder.
+- 2026-07-28: Listening now caches pre-INIT completion details, generates one submissionId, resends the same persisted request, and marks completed only on trusted ACK.
+- 2026-07-28: Reading highlight vocab now generates requestId, waits for trusted host ACK/FAILED, falls back to direct AppData commit, and never labels a bare postMessage as success.
+- 2026-07-28: Separated practice record/session business IDs from mutation operation IDs in PracticeRecorder, suite finalize, and host persistence; added per-call/new-op and internal-retry/same-op tests.
+- 2026-07-28: Resumed from persisted plan; subagent dispatch remained unavailable with `unsupported call`.
+- 2026-07-28: Diagnosed the suite reset regression as stale fixture accounting and reset the recorder-start probe before the reset request.
+- 2026-07-28: `suiteModeRegression.test.js` passes after isolating reset-time recorder synchronization in the fixture.
+- 2026-07-28: Located existing focused surfaces for the remaining gates: listening parser, unified reading protocol, external backup v2, Browse controller/preferences, and file/listening E2E runners.
+- 2026-07-28: Confirmed the Listening source state machine and identified the parser test file as an encoding/readability edge case requiring byte-level inspection before editing.
+- 2026-07-28: Confirmed the parser test is ordinary UTF-8; selected a separate VM-based Listening protocol regression using the bridge's existing public test hooks.
+- 2026-07-28: Designed the Listening protocol harness to assert file-origin wildcard sends, no pre-INIT completion emission, same-submission retry, forged ACK rejection, and trusted ACK finalization.
+- 2026-07-28: Added and passed `listeningRecordBridgeProtocol.test.js`; registered it in the static suite.
+- 2026-07-28: Selected a VM source-injection harness for vocab UI protocol coverage so production exports remain unchanged.
+- 2026-07-28: Added and passed `reviewHighlightDictionaryProtocol.test.js`; registered it in the static suite.
+- 2026-07-28: Began Browse consumer tracing; ruled out `browseController.js` as the completion-state owner.
+- 2026-07-28: Located both Browse completion scans and the existing read-status regression; selected a shared `suiteEntrySummaries`-first helper with `suiteEntries` fallback.
+- 2026-07-28: Implemented Browse `suiteEntrySummaries` consumption in both indexed and path/file fallback paths; `legacyViewReadStatus.test.js` passes.
+- 2026-07-28: Verified the DataKernel → AppData backups → ExternalBackupService remote-commit subscription chain.
+- 2026-07-28: Made the ExternalBackupService regression emit an explicit `remote:true` child-realm commit; the v2 backup suite passes.
+- 2026-07-28: Located the app fallback origin regression surface in `practiceRecordPersistence.test.js`.
+- 2026-07-28: Extended the fallback persistence regression to file:// REQUEST_INIT and ACK, asserting declared `null` and wildcard targetOrigin; the test passes.
+- 2026-07-28: Started a repository-wide PRACTICE_COMPLETE/ACK sender audit.
+- 2026-07-28: Sender audit found Practice Enhancer completion messages missing submissionId at the final send boundary.
+- 2026-07-28: Completed sender inventory: Unified Reading and Listening are correlated; generic enhancer, injected collector, two templates, and the inline E2E fixture need metadata enrichment.
+- 2026-07-28: Selected existing enhancer VM coverage and session-bound submission ID reset semantics for the generic/template fixes.
+- 2026-07-28: Added completion correlation to Practice Enhancer and the host-injected collector; enhancer syntax and its 6/6 VM regressions pass.
+- 2026-07-28: Added session-bound submission correlation and file-origin normalization to the shipped placeholder/base templates; placeholder replay test passes.
+- 2026-07-28: Confirmed the unified E2E runner excludes Listening and found UA-based test-environment activation still permits synthetic saves under Playwright.
+- 2026-07-28: Removed automation-UA test mode, added its explicit-opt-in regression, and passed syntax/unit checks.
+- 2026-07-28: Added Listening to the unified E2E list; reading file submit and Listening now use a normal Chrome UA and assert production test-env/recorder/receipt/synthetic invariants. Python syntax checks pass.
+- 2026-07-28: Added placeholder completion contract coverage; the replay/submit regression passes.
+- 2026-07-28: Rebuilt all generated bundles successfully. The builder reported only the repository's eight known non-blocking symbol collisions.
+- 2026-07-28: Focused data/kernel/backup, recorder/persistence/completion, Listening/vocab/environment, host/unified-reading, suite/enhancer/placeholder, and Browse/view test groups all pass.
+- 2026-07-28: Replaced `codex/audit-tmp-migration` with the verified seven-commit `codex/squash-preview` history and force-pushed with an explicit remote lease; preserved `codex/audit-tmp-migration-backup` at the original 34-commit tip.
+- 2026-07-28: Took over the new production reports: extracted packages sometimes show zero exams, optional Listening manifest is absent, and practice completion can fail v2 validation because `correctAnswers` reaches `canonicalizeRecord` as a negative/non-finite value.
+- 2026-07-28: Started the manifest/submission hotfix trace; no source edits made yet.
+- 2026-07-29: Three read-only traces confirmed the legacy IndexedDB row-envelope bug, the `exam_index` sentinel/active-library mismatch, the non-idempotent migration guard, and the overloaded `correctAnswers` completion contract.
+- 2026-07-29: Local PowerShell spawning began failing globally with Windows `CreateProcessAsUserW error 5`; switched to the exact force-pushed branch through the connected GitHub read API for source verification. No code was edited through GitHub.
+- 2026-07-29: Fixed legacy IDB `{key,value,timestamp}` unwrapping and added sessionStorage fallback with an explicit completeness signal.
+- 2026-07-29: Added versioned, retry-safe v1-to-v2 repair: default sentinel translation, deterministic custom-library remap, poisoned active-state repair, current-v2 precedence, per-layer practice merge, stable missing-ID generation, and a completion marker written only after full success.
+- 2026-07-29: Added completion-score normalization that preserves answer maps, selects the first valid non-negative scalar (including zero), derives totals from scoreInfo, and keeps canonical validation strict; applied the same rule to suite light summaries.
+- 2026-07-29: Changed empty/damaged active custom-library startup to reset active state and continue through the generated Reading manifest path; optional Listening absence remains non-blocking.
+- 2026-07-29: Added regressions in dataKernelV2, appDataV2, legacyMigrationBrickRegression, and libraryManagerImportConfig for the three production reports.
+- 2026-07-29: Two independent verification agents confirmed the intended static paths and identified/fixed retry/idempotency/catch-boundary issues. Node execution and bundle rebuild remain blocked by the global Windows process-launch error; no hotfix commit or push was made.
+- 2026-07-29: Inspected the supplied 4 KB backup completely and reproduced its semantics: checksum-valid full snapshot, zero practice rows, missing imported-index envelope, poisoned active ID, and three legacy row wrappers.
+- 2026-07-29: Compared six opensource export paths and confirmed the catastrophic file is a v2 migration/export regression, while also documenting old-version backup completeness gaps.
+- 2026-07-29: Confirmed current import accepts the poisoned file, replace clears all practice stores, and bad active state hides the manifest; started backup trust/import-safety implementation.
+- 2026-07-29: Implemented semantic v2 import canonicalization for exact legacy row wrappers, sparse-snapshot degradation, cross-domain wrapper isolation, and all-or-nothing custom-library bundle validation.
+- 2026-07-29: Removed the unsafe rule that interpreted missing full-snapshot envelopes as clears; new exports now produce a dense present/cleared catalog snapshot.
+- 2026-07-29: Added preview-bound destructive confirmation tokens and exact practice existing/incoming/final/removed counts; wired ordinary import, external restore, and E2E callers.
+- 2026-07-29: Added `poisonedV2Repair` startup recovery that runs independently of the old completion marker, retries legacy practice recovery, repairs safe document wrappers, and resets/reconstructs invalid active-library state.
+- 2026-07-29: Rebuilt all 14 generated bundles; `build-bundles.mjs --check` passes. Focused AppData/DataKernel/migration/library/external-backup/recorder suites pass.
+- 2026-07-29: file:// export/import Playwright flow passes end-to-end, including v1 merge and v2 destructive replace with confirmation token.
+- 2026-07-29: The exact user-supplied poisoned JSON passed a dedicated file:// browser safety run: merge preserved the seeded record/current library and replace reported `1 → 0` then rejected a tokenless commit.
+- 2026-07-29: Full JS sweep passed all data/import-related suites; the unrelated `unifiedReadingCoreRegression.test.js` async notification-order assertion remains reproducibly failing.
+- 2026-07-29: Started a raw-data-first migration-chain review at the user's request; no new fallback code will be added until the original persisted shapes and every lossy boundary are re-established.
+- 2026-07-29: Three independent read-only audits completed: historical writer shapes, byte-to-domain loss tracing, and current-patch minimality. They converged on one root cause and identified multiple over-broad recovery risks for main-agent line-level verification.
+- 2026-07-29: Main-agent source checks confirmed the canonical old IDB writer/reader pair, real unprefixed Web Storage variants, the absence of a production raw-IDB writer, and the new forceReload overwrite bug.
+- 2026-07-29: Removed the unevidenced raw-IDB fallback, added explicit unprefixed Web Storage keys, unified wrapper decoding, preserved post-migration overlay fields, and rejected non-object wrapper payloads.
+- 2026-07-29: Replaced global marker-triggered replay with fresh-or-poison detection, removed the independent poisoned-repair marker, prevented resurrection from healthy v2 + frozen v1, and simplified destructive authorization to `confirmDestructive`.
+- 2026-07-29: Added real writer-envelope coverage through the complete IndexedDB → AppData migration chain, plus regressions for no resurrection, partial library imports, and healthy custom-library force reload.
+- 2026-07-29: Limited poison-time library recovery to the exact wrapped legacy index IDs; a poisoned active pointer can consult its old ID only when v2 has no usable current index.
+- 2026-07-29: Verified the supplied poisoned backup in a real file:// browser: merge is degraded to partial and preserves a seeded record/current library; replace is destructive and rejects commit without `confirmDestructive:true`.
+- 2026-07-29: Rebuilt all 14 bundles and passed focused data/import/library tests plus the file:// export/import E2E. Full JS sweep passed 50/51; only the unchanged standalone Unified Reading notification-order assertion failed.
+- 2026-07-29: Completed final bundle drift and `git diff --check` verification. No hotfix commit or push was made.
+- 2026-07-29: User changed the migration priority: surviving v1 data must be reconciled automatically on every startup and must overwrite known-poisoned v2 values. Started a new persistent-reconciliation phase.
+- 2026-07-29: Three independent read-only audits located the exact marker/fresh/poison gates, proposed the persistent union precedence, and identified the existing no-resurrection tests that must be reversed.
+- 2026-07-29: Main-agent source review confirmed the existing three-layer practice helper can atomically replace partial records and the library checksum comparisons can provide repeated-startup idempotency.
+- 2026-07-29: Implemented the first persistent-reconciliation source pass: legacy reads are no longer gated by markers/healthy v2, libraries are unioned, missing documents are seeded, partial practice records are atomically repaired, and the stable marker is diagnostic only.
+- 2026-07-29: AppData syntax check passed; the old migration regression now fails only at its expected retired `poison-repair` mode assertion.
+- 2026-07-29: Reversed the obsolete no-resurrection tests and added a shared-backing reboot harness with mutation counters and entity revisions.
+- 2026-07-29: Persistent reconciliation regression passes, including marker bypass, v1/v2 union, poisoned wrapper precedence, active-library recovery, partial-record repair, and second-boot zero-write idempotency.
+- 2026-07-29: DataKernel, AppData, LibraryManager, and ExternalBackupService focused suites all pass after the reconciliation change.
+- 2026-07-29: Extended the real browser/IndexedDB migration test across three realms: marker bypass and newly added v1 data on boot two, followed by zero business revision/checksum churn on boot three. The test passes.
+- 2026-07-29: Added operation-ID based recovery for collapsed legacy array/object documents and covered a collapsed `vocab.words` envelope.
+- 2026-07-29: Added an explicit persistent-union regression: deleting a v1-backed practice record from v2 causes exactly one atomic restoration on the next startup.
+- 2026-07-29: Persistent-reconciliation implementation and regressions are complete; focused VM and real IndexedDB migration tests pass.
+- 2026-07-29: Rebuilt all 14 generated bundles after the persistent-reconciliation source change; only the eight known non-blocking symbol conflicts remain.
+- 2026-07-29: Focused DataKernel, persistent migration, AppData, LibraryManager, and ExternalBackupService suites all pass on the rebuilt source.
+- 2026-07-29: file:// export/import E2E passes after persistent reconciliation, including v1 merge, visible history, and confirmed v2 replace.
+- 2026-07-29: Full JS sweep passed 50/51. All migration/data/import/library tests pass; the same unchanged Unified Reading notification-order assertion remains the sole failure.
+- 2026-07-29: Extended persistent reconciliation from missing/poisoned documents to catalog-aware unions for all valid v1 `patch` and `merge-by-id` documents; focused migration, real IndexedDB, and AppData suites pass.
+- 2026-07-29: Rebuilt bundles again after the document-union extension. Final full JS sweep remains 50/51 with only the unchanged Unified Reading notification-order failure.
+- 2026-07-29: Final file:// export/import E2E passes on the persistent-union build.
+- 2026-07-29: Final bundle drift check confirms all 14 outputs are current; `git diff --check` passes. Persistent v1 reconciliation is complete and remains uncommitted/unpushed.
+- 2026-07-30: Started the review/v2-insights/endless-mode change, read the planning skill and supplied automated-review report, and recovered the prior persistent-reconciliation context.
+- 2026-07-30: Three read-only audits confirmed all six review regressions, the light-summary/detail mismatch behind empty wrong-answer classification, and the deterministic null-state crash that prevents endless mode startup.
+- 2026-07-30: Recorded the current five-phase implementation plan and retained all existing uncommitted migration/bundle work.
+- 2026-07-30: Read the project README and the exact catalog/summary/radar/import/endless/open-exam source regions that define the affected contracts.
+- 2026-07-30: Located focused regression surfaces for achievement projection, Browse hydration, import sequencing, and the static method contract.
+- 2026-07-30: Implemented the first source pass for all six review findings, lightweight question-type error projections, suite-aware filtering/radar input, and the unified endless-mode exam-open lifecycle.
+- 2026-07-30: All changed JavaScript and Python files pass syntax checks; `git diff --check` is clean apart from expected CRLF conversion warnings.
+- 2026-07-30: Added executable regressions for transient recovery non-resurrection, durable achievements, Browse hydration readiness, light/suite error-count insights, suite type filtering, and the first/next endless exam lifecycle.
+- 2026-07-30: AppData v2 (47 tests), persistent migration, Browse preferences (5/5), practice custom card (9/9), and Unified Reading/endless lifecycle focused suites all pass.
+- 2026-07-30: Corrected the remaining Browse first-render gap by awaiting preference hydration alongside the active exam-index load in `initializeBrowseView()`.
+- 2026-07-30: The first unified static-suite run exceeded the 120-second shell bound with no emitted failure; recorded the timeout and deferred the longer rerun until after the required final bundle rebuild.
+- 2026-07-30: Rebuilt all 14 bundles; bundle drift check and all focused tests pass on generated outputs.
+- 2026-07-30: The unified static suite exceeded a 300-second outer timeout; source inspection confirmed several intentional 240s/360s/480s child gates, so the final attempt will use an outer bound that covers the runner's own declared timeouts.
+- 2026-07-31: Full static report completed. All changed-feature gates pass; remaining failures are the pre-existing v2 legacy-key allowlist mismatch, noisy suite-test JSON parsing, four NB replay content cases, and the 480-second Reading quick audit timeout.
+- 2026-07-31: Added bounded `practice.listInsights({limit:10})` compatibility reads so historical summaries also feed the wrong-answer radar without scanning annotations or all details; focused AppData and light-render tests pass.
+- 2026-07-31: First suite E2E attempt stopped before the reviewed preference assertion because an overview re-render detached the button during an explicit scroll; replaced that redundant scroll with Playwright's locator auto-wait path.
+- 2026-07-31: Second suite E2E attempt reached preference setup and exposed one stale Playwright positional-argument call; converted it to the current keyword-only `arg=` API.
+- 2026-07-31: Third suite E2E attempt passed lazy loading and preference setup, then hit the existing first-passage readiness timeout caused by unavailable local exercise assets; stopped expanding that unrelated browser fixture path.
+- 2026-07-31: Final focused verification passes for suite preferences, DataKernel, AppData, external backups, light render contracts, migration, Browse preferences, practice insights, and executable endless lifecycle.
+- 2026-07-31: Final syntax checks, 14-bundle drift check, exam-app method contract, and `git diff --check` all pass. Current change is complete and remains uncommitted/unpushed.
+- 2026-07-31: Started a read-only residual-gate triage at the user's request; recovered the prior plan/worktree state and separated the four static failures plus suite E2E readiness into independent audits.
+- 2026-07-31: Completed three independent audits and main-agent line verification. Classified the v2 guard, suite JSON parser, and four NB replay failures as stale test infrastructure; classified Reading quick and suite placeholder propagation as unresolved end-to-end coverage blockers.
+- 2026-07-31: Confirmed the missing listening manifest is optional noise rather than the suite button root cause. No production or test implementation was changed during this diagnostic pass.
+- 2026-07-31: User authorized implementation. Recovered the persistent plan and dirty worktree, opened a five-phase residual-gate repair, and retained the rule that runtime message safety must not be weakened to satisfy stale fixtures.
+- 2026-07-31: Three clean-context read-only agents completed exact reconnaissance for gate/fixture, Reading, and suite repairs. Chosen design uses the real Reading host/ACK protocol, one-process dataset export, narrow suite flag propagation, and trusted NB messages.
+- 2026-07-31: Main-agent inspection verified the exact semantic allowlist markers and current NB/date-sensitive fixture code before editing.
+- 2026-07-31: Repaired the v2 semantic guard and suite last-line JSON collection. The guard now reports zero source/test/bundle/html errors and suiteModeRegression exits successfully.
+- 2026-07-31: Updated NB replay to use trusted INIT/token/source and clone-scoped selectors; all 4/4 generated-resource cases pass without weakening runtime security.
+- 2026-07-31: Replaced the fixed migration timestamp with fresh/stale relative rows; the persistent migration regression passes and explicitly validates 30-day TTL pruning.
+- 2026-07-31: Reading exporter now returns all 232 datasets from one Node/VM process; syntax/count validation passes.
+- 2026-07-31: Reading quick now exercises the real iframe INIT/SESSION_READY/PRACTICE_COMPLETE/ACK chain. Static coverage passed 232/232 and UI coverage passed 12/12 in 22.3 seconds instead of timing out at 480 seconds.
+- 2026-07-31: Added narrow `suite_test=1` propagation, URL encoding regression coverage, and immediate E2E blocked-state diagnostics; the source-level suite regression passes.
+- 2026-07-31: Rebuilt all 14 bundles; bundle drift check passes with the same eight known non-blocking symbol conflicts.
+- 2026-07-31: Suite E2E now launches the unlocked placeholder, completes P1, and switches to P2. It then times out after P2 submit while waiting for P3, exposing a deeper transition defect that was previously masked by the blocked placeholder.
+- 2026-07-31: Added suite sequence exam definitions to every subsequent `openExam()` call and bound placeholder simulation navigation to its current session ID; these preserve the locked sequence and provide the strict fallback routing proof.
+- 2026-07-31: Hardened suite E2E GPL overlay dismissal against asynchronous modal appearance after an initial app-ready check.
+- 2026-07-31: Stabilized suite placeholder URL fallbacks for both stationary and simulation flows; late INIT/REVIEW_CONTEXT messages no longer erase manual navigation or final-submit state.
+- 2026-07-31: Final suite E2E passes automatic three-passage aggregation plus stationary manual review/finalization (180.5s). Reading quick 232/232 + 12/12, NB 4/4, migration, suite-mode, and unified readonly-submit regressions also pass.
+- 2026-07-31: Full unified static suite passes after adding the missing `listInsights()` method to the practice-persistence test harness; all gates are green, with only documented optional/skipped checks and eight non-blocking historical bundle symbol warnings.
diff --git a/scripts/build-bundles.mjs b/scripts/build-bundles.mjs
index f4b897a2..a9923c92 100644
--- a/scripts/build-bundles.mjs
+++ b/scripts/build-bundles.mjs
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
+const checkOnly = process.argv.includes('--check');
const bundles = {
'js/bundles/runtime-entry.bundle.js': [
@@ -17,25 +18,15 @@ const bundles = {
'js/bundles/core-foundation.bundle.js': [
'js/utils/environmentDetector.js',
'js/utils/logger.js',
- 'js/utils/storage.js',
- 'js/core/storageProviderRegistry.js',
- 'js/data/dataSources/storageDataSource.js',
- 'js/data/repositories/baseRepository.js',
- 'js/data/repositories/dataRepositoryRegistry.js',
- 'js/data/repositories/practiceRepository.js',
- 'js/data/repositories/settingsRepository.js',
- 'js/data/repositories/backupRepository.js',
- 'js/data/repositories/metaRepository.js',
- 'js/data/index.js',
- 'js/core/practiceCore.js',
- 'js/core/practiceRecordAPI.js',
- 'js/core/backupAPI.js',
+ 'js/data/practiceRecordSource.js',
+ 'js/data/v2/dataCatalog.js',
+ 'js/data/v2/dataKernel.js',
+ 'js/data/v2/appData.js',
'js/core/externalBackupService.js',
- 'js/core/practiceStore.js',
+ 'js/core/siteDataReset.js',
+ 'js/core/practiceCore.js',
'js/core/resourceCore.js',
'assets/generated/reading-exams/manifest.js',
- 'js/utils/stateSerializer.js',
- 'js/utils/simpleStorageWrapper.js',
'js/app/state-service.js',
'js/services/libraryDiscovery.js',
'js/services/libraryManager.js'
@@ -55,12 +46,12 @@ const bundles = {
],
'js/bundles/legacy-app.bundle.js': [
'js/boot-fallbacks.js',
- 'js/patches/runtime-fixes.js',
'js/app.js',
'js/components/onboardingTour.js'
],
'js/bundles/browse.bundle.js': [
'js/views/legacyViewBundle.js',
+ 'js/data/practiceRecordSource.js',
'js/app/examActions.js',
'js/app/spellingErrorCollector.js',
'js/app/examSessionMixin.js',
@@ -79,16 +70,11 @@ const bundles = {
'js/utils/dataConsistencyManager.js',
'js/utils/performance.js'
],
- 'js/bundles/settings.bundle.js': [
- 'js/components/DataIntegrityManager.js',
- 'js/utils/dataBackupManager.js'
- ],
'js/bundles/practice.bundle.js': [
'js/app/spellingErrorCollector.js',
'js/utils/markdownExporter.js',
'js/components/practiceRecordModal.js',
'js/components/practiceHistoryEnhancer.js',
- 'js/core/scoreStorage.js',
'js/utils/answerSanitizer.js',
'js/core/practiceRecorder.js'
],
@@ -96,6 +82,10 @@ const bundles = {
'js/app/suitePracticeMixin.js'
],
'js/bundles/reading-page.bundle.js': [
+ 'js/data/practiceRecordSource.js',
+ 'js/data/v2/dataCatalog.js',
+ 'js/data/v2/dataKernel.js',
+ 'js/data/v2/appData.js',
'js/runtime/readingExamRegistry.js',
'js/runtime/readingExplanationRegistry.js',
'js/runtime/readingHighlightShared.js',
@@ -109,17 +99,30 @@ const bundles = {
'js/runtime/unifiedReadingPage.js'
],
'js/bundles/practice-page-enhancer.bundle.js': [
+ 'js/data/practiceRecordSource.js',
+ 'js/data/v2/dataCatalog.js',
+ 'js/data/v2/dataKernel.js',
+ 'js/data/v2/appData.js',
'js/utils/suiteBackGuard.js',
'js/utils/answerMatchCore.js',
'js/app/spellingErrorCollector.js',
'js/practice-page-enhancer.js'
],
'js/bundles/listening-record-bridge.bundle.js': [
- 'js/utils/answerMatchCore.js',
- 'js/app/spellingErrorCollector.js',
- 'js/listeningRecordBridge.js'
- ],
+ 'js/data/practiceRecordSource.js',
+ 'js/data/v2/dataCatalog.js',
+ 'js/data/v2/dataKernel.js',
+ 'js/data/v2/appData.js',
+ 'js/utils/answerMatchCore.js',
+ 'js/app/spellingErrorCollector.js',
+ 'js/utils/safeObjectLiteralParser.js',
+ 'js/listeningRecordBridge.js'
+ ],
'js/bundles/listening-wrapper.bundle.js': [
+ 'js/data/practiceRecordSource.js',
+ 'js/data/v2/dataCatalog.js',
+ 'js/data/v2/dataKernel.js',
+ 'js/data/v2/appData.js',
'js/utils/practiceTimerPreferences.js',
'js/listeningUnifiedWrapper.js'
],
@@ -153,6 +156,323 @@ function readSource(relativePath) {
.replace(/\s*$/, '\n');
}
+// ============================================================================
+// 全局符号冲突检查
+//
+// bundle 是多个源文件的纯文本拼接,没有模块作用域隔离:两个文件写入同一个全局名字
+// 时,后者会静默覆盖前者。历史事故:js/app/examActions.js 内 IIFE 导出的
+// loadExamList 覆盖了 js/main.js 的同名顶层函数(两者语义不同),导致用户切换
+// 筛选/排序后题库渲染成空白。
+//
+// 真正的冲突面是"对全局命名空间的写入",共两条路径:
+// 1. `global.X = ...` / `window.X = ...` / `Object.defineProperty(global|window, 'X', ...)`
+// —— 允许任意缩进,因为这类写入通常发生在 IIFE 内部。
+// 2. 非 IIFE 包裹的"裸文件"(如 js/main.js)中缩进为 0 的顶层声明:
+// `function X` / `async function X` / `var|let|const X` / `class X`
+// —— 裸文件的顶层声明会直接落进 bundle 的顶层作用域,等价于全局写入。
+// IIFE 包裹的文件里这类声明是局部的,不算冲突(否则会漏掉上面那个真实 bug,
+// 同时把大量私有函数误报成冲突)。
+//
+// 只做正则词法分析,不引入任何解析器依赖;先屏蔽注释/字符串/模板/正则字面量,
+// 以规避把这些内容里的文本误判成代码。不追求 100% 精确。
+// ============================================================================
+
+/**
+ * 存量符号冲突白名单 —— 历史技术债务,待逐项清理。
+ *
+ * - 白名单内的冲突:打印警告,不阻断构建。
+ * - 白名单外的新增冲突:打印错误并以退出码 1 失败。
+ *
+ * 每行一项且互不影响:清理掉某处冲突后,把对应的那一行删掉即可。
+ * 判定为"已知"要求实际冲突文件是这里所列文件的子集;若有新文件加入同名符号,
+ * 说明冲突范围扩大了,会按新增冲突报错。
+ */
+const KNOWN_SYMBOL_CONFLICTS = {
+ 'js/bundles/browse.bundle.js': {
+ __browseFilterMode: ['js/app/examActions.js', 'js/app/browseController.js', 'js/main.js'],
+ __browsePath: ['js/app/examActions.js', 'js/app/browseController.js', 'js/main.js'],
+ __readingMemorizeBrowseMode: ['js/app/examActions.js', 'js/main.js'],
+ __browseMemorizeFilterMode: ['js/app/examActions.js', 'js/main.js'],
+ clearPendingBrowseAutoScroll: ['js/utils/BrowsePreferencesUtils.js', 'js/main.js'],
+ pdfHandler: ['js/components/PDFHandler.js', 'js/main.js'],
+ browseStateManager: ['js/components/BrowseStateManager.js', 'js/main.js']
+ },
+ 'js/bundles/practice-page-enhancer.bundle.js': {
+ spellingErrorCollector: ['js/app/spellingErrorCollector.js', 'js/practice-page-enhancer.js']
+ }
+};
+
+// `/` 出现在这些关键字之后只能是正则字面量,不可能是除法。
+const REGEX_ALLOWED_AFTER_KEYWORDS = new Set([
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete',
+ 'void', 'throw', 'case', 'do', 'else', 'yield', 'await'
+]);
+
+/**
+ * 把注释、字符串、模板字面量文本和正则字面量的内容替换成空格(保留换行与长度),
+ * 这样后续正则就不会把它们里面的文本误判成真实代码。
+ */
+function maskSource(source) {
+ const out = source.split('');
+ const length = source.length;
+ const blank = (from, to) => {
+ for (let index = from; index < to && index < length; index += 1) {
+ if (out[index] !== '\n') out[index] = ' ';
+ }
+ };
+
+ // 模板插值 `${...}` 内可能出现对象字面量的 `{}`,需要按大括号深度判断插值结束位置。
+ const templateBraceDepth = [];
+ const readTemplateChunk = (from) => {
+ let index = from;
+ while (index < length) {
+ if (source[index] === '\\') { index += 2; continue; }
+ if (source[index] === '`') return { end: index, interpolated: false };
+ if (source[index] === '$' && source[index + 1] === '{') return { end: index, interpolated: true };
+ index += 1;
+ }
+ return { end: length, interpolated: false };
+ };
+
+ let cursor = 0;
+ let previousChar = '';
+ let previousWord = '';
+ const regexAllowedHere = () => {
+ if (!previousChar) return true;
+ if (/[\w$]/.test(previousChar)) return REGEX_ALLOWED_AFTER_KEYWORDS.has(previousWord);
+ return previousChar !== ')' && previousChar !== ']';
+ };
+
+ while (cursor < length) {
+ const char = source[cursor];
+ const nextChar = source[cursor + 1];
+
+ if (char === '/' && nextChar === '/') {
+ let index = cursor;
+ while (index < length && source[index] !== '\n') index += 1;
+ blank(cursor, index);
+ cursor = index;
+ continue;
+ }
+
+ if (char === '/' && nextChar === '*') {
+ let index = cursor + 2;
+ while (index < length && !(source[index] === '*' && source[index + 1] === '/')) index += 1;
+ index = Math.min(index + 2, length);
+ blank(cursor, index);
+ cursor = index;
+ continue;
+ }
+
+ if (char === '"' || char === "'") {
+ let index = cursor + 1;
+ while (index < length) {
+ if (source[index] === '\\') { index += 2; continue; }
+ if (source[index] === char || source[index] === '\n') break;
+ index += 1;
+ }
+ blank(cursor + 1, index);
+ cursor = Math.min(index + 1, length);
+ previousChar = char;
+ previousWord = '';
+ continue;
+ }
+
+ if (char === '`') {
+ const chunk = readTemplateChunk(cursor + 1);
+ blank(cursor + 1, chunk.end);
+ if (chunk.interpolated) {
+ templateBraceDepth.push(0);
+ cursor = chunk.end + 2;
+ previousChar = '{';
+ previousWord = '';
+ continue;
+ }
+ cursor = Math.min(chunk.end + 1, length);
+ previousChar = '`';
+ previousWord = '';
+ continue;
+ }
+
+ if (templateBraceDepth.length && char === '{') {
+ templateBraceDepth[templateBraceDepth.length - 1] += 1;
+ } else if (templateBraceDepth.length && char === '}') {
+ if (templateBraceDepth[templateBraceDepth.length - 1] > 0) {
+ templateBraceDepth[templateBraceDepth.length - 1] -= 1;
+ } else {
+ // 插值结束,回到模板文本继续屏蔽。
+ templateBraceDepth.pop();
+ const chunk = readTemplateChunk(cursor + 1);
+ blank(cursor + 1, chunk.end);
+ if (chunk.interpolated) {
+ templateBraceDepth.push(0);
+ cursor = chunk.end + 2;
+ previousChar = '{';
+ previousWord = '';
+ continue;
+ }
+ cursor = Math.min(chunk.end + 1, length);
+ previousChar = '`';
+ previousWord = '';
+ continue;
+ }
+ }
+
+ if (char === '/' && regexAllowedHere()) {
+ let index = cursor + 1;
+ let inCharacterClass = false;
+ let closed = false;
+ while (index < length) {
+ const current = source[index];
+ if (current === '\\') { index += 2; continue; }
+ if (current === '\n') break;
+ if (current === '[') inCharacterClass = true;
+ else if (current === ']') inCharacterClass = false;
+ else if (current === '/' && !inCharacterClass) { closed = true; break; }
+ index += 1;
+ }
+ if (closed) {
+ blank(cursor + 1, index);
+ cursor = index + 1;
+ previousChar = '/';
+ previousWord = '';
+ continue;
+ }
+ }
+
+ if (/[\w$]/.test(char)) {
+ let index = cursor;
+ while (index < length && /[\w$]/.test(source[index])) index += 1;
+ previousWord = source.slice(cursor, index);
+ previousChar = source[index - 1];
+ cursor = index;
+ continue;
+ }
+
+ if (!/\s/.test(char)) {
+ previousChar = char;
+ previousWord = '';
+ }
+ cursor += 1;
+ }
+
+ return out.join('');
+}
+
+// 路径 1:对 global/window 属性的直接赋值,允许任意缩进(多在 IIFE 内部)。
+const GLOBAL_PROPERTY_WRITE = /(?:^|[^\w$.])(?:global|window)\s*\.\s*([A-Za-z_$][\w$]*)\s*=(?!=)/g;
+// 路径 1 的补充形式:Object.defineProperty(global|window, 'X', ...) 同样是全局写入。
+const GLOBAL_DEFINE_PROPERTY = /Object\s*\.\s*defineProperty\s*\(\s*(?:global|window)\s*,\s*['"]([A-Za-z_$][\w$]*)['"]/g;
+// 路径 2:裸文件里缩进为 0 的顶层声明(行首即声明关键字)。
+const TOP_LEVEL_DECLARATION = /^(?:async[ \t]+)?(?:function\b[ \t*]*|var[ \t]+|let[ \t]+|const[ \t]+|class[ \t]+)([A-Za-z_$][\w$]*)/;
+
+/** 判断整个文件是否被 IIFE 包裹(首个有效 token 就进入 `(function` / `((` 形态)。 */
+function isIifeWrapped(maskedSource) {
+ const head = maskedSource.replace(/\s+/g, ' ').trim();
+ return /^[!+~;]*\s*\(\s*(?:async\s+)?function\b/.test(head) || /^[!+~;]*\s*\(\s*\(/.test(head);
+}
+
+const globalSymbolCache = new Map();
+
+/** 提取单个源文件写入的全局符号名集合(同一文件在多个 bundle 中复用,带缓存)。 */
+function collectGlobalSymbols(relativePath) {
+ if (globalSymbolCache.has(relativePath)) return globalSymbolCache.get(relativePath);
+
+ const masked = maskSource(readSource(relativePath));
+ const symbols = new Set();
+
+ let match;
+ GLOBAL_PROPERTY_WRITE.lastIndex = 0;
+ while ((match = GLOBAL_PROPERTY_WRITE.exec(masked)) !== null) {
+ symbols.add(match[1]);
+ // 前缀里可能吃掉了下一处匹配的起始字符,回退一位避免漏检相邻写入。
+ GLOBAL_PROPERTY_WRITE.lastIndex = match.index + match[0].length - 1;
+ }
+
+ GLOBAL_DEFINE_PROPERTY.lastIndex = 0;
+ while ((match = GLOBAL_DEFINE_PROPERTY.exec(masked)) !== null) {
+ symbols.add(match[1]);
+ }
+
+ if (!isIifeWrapped(masked)) {
+ for (const line of masked.split('\n')) {
+ const declaration = TOP_LEVEL_DECLARATION.exec(line);
+ if (declaration) symbols.add(declaration[1]);
+ }
+ }
+
+ globalSymbolCache.set(relativePath, symbols);
+ return symbols;
+}
+
+/** 扫描所有 bundle,区分出"存量已知冲突"和"新增冲突"。 */
+function findSymbolConflicts(bundleMap) {
+ const known = [];
+ const introduced = [];
+ const staleWhitelistEntries = [];
+
+ for (const [outputPath, inputs] of Object.entries(bundleMap)) {
+ const writers = new Map();
+ for (const inputPath of inputs) {
+ for (const symbol of collectGlobalSymbols(inputPath)) {
+ if (!writers.has(symbol)) writers.set(symbol, []);
+ const owners = writers.get(symbol);
+ if (!owners.includes(inputPath)) owners.push(inputPath);
+ }
+ }
+
+ const whitelist = KNOWN_SYMBOL_CONFLICTS[outputPath] || {};
+ const conflictingSymbols = new Set();
+
+ for (const [symbol, owners] of writers) {
+ if (owners.length < 2) continue;
+ conflictingSymbols.add(symbol);
+ const allowedOwners = whitelist[symbol];
+ const isKnown = Array.isArray(allowedOwners)
+ && owners.every((owner) => allowedOwners.includes(owner));
+ (isKnown ? known : introduced).push({ outputPath, symbol, owners });
+ }
+
+ for (const symbol of Object.keys(whitelist)) {
+ if (!conflictingSymbols.has(symbol)) staleWhitelistEntries.push({ outputPath, symbol });
+ }
+ }
+
+ return { known, introduced, staleWhitelistEntries };
+}
+
+function formatConflict({ outputPath, symbol, owners }) {
+ return [
+ `符号冲突: ${outputPath}`,
+ ` "${symbol}" 同时写入于:`,
+ ...owners.map((owner) => ` - ${owner}`)
+ ].join('\n');
+}
+
+/** 构建与 --check 模式下都会执行;出现新增冲突时直接失败。 */
+function assertNoNewSymbolConflicts(bundleMap) {
+ const { known, introduced, staleWhitelistEntries } = findSymbolConflicts(bundleMap);
+
+ if (known.length) {
+ console.warn(`存量符号冲突 ${known.length} 处(历史债务,暂不阻断构建):`);
+ for (const conflict of known) console.warn(formatConflict(conflict));
+ }
+
+ if (staleWhitelistEntries.length) {
+ console.warn('以下白名单条目已不再冲突,可从 KNOWN_SYMBOL_CONFLICTS 中删除:');
+ for (const entry of staleWhitelistEntries) console.warn(` - ${entry.outputPath} :: ${entry.symbol}`);
+ }
+
+ if (introduced.length) {
+ console.error(`检测到 ${introduced.length} 处新增符号冲突(不在白名单内):`);
+ for (const conflict of introduced) console.error(formatConflict(conflict));
+ console.error('同一 bundle 内多个文件写入同名全局符号会静默互相覆盖,请改名或收敛到单一来源。');
+ process.exit(1);
+ }
+
+ if (!known.length) console.log('符号冲突检查通过: 未发现同一 bundle 内的重复全局写入。');
+}
+
function renderBundle(outputPath, inputs) {
const sections = inputs.map((inputPath) => {
const source = readSource(inputPath);
@@ -176,9 +496,40 @@ function renderBundle(outputPath, inputs) {
].join('\n');
}
+assertNoNewSymbolConflicts(bundles);
+
+const staleOutputs = [];
for (const [outputPath, inputs] of Object.entries(bundles)) {
const absoluteOutput = path.join(root, outputPath);
+ const expected = renderBundle(outputPath, inputs);
+ if (checkOnly) {
+ const actual = fs.existsSync(absoluteOutput) ? fs.readFileSync(absoluteOutput, 'utf8') : null;
+ if (actual !== expected) staleOutputs.push(outputPath);
+ continue;
+ }
fs.mkdirSync(path.dirname(absoluteOutput), { recursive: true });
- fs.writeFileSync(absoluteOutput, renderBundle(outputPath, inputs), 'utf8');
+ fs.writeFileSync(absoluteOutput, expected, 'utf8');
console.log(`${outputPath}: ${inputs.length} files`);
}
+
+const expectedOutputs = new Set(Object.keys(bundles).map((outputPath) => outputPath.replace(/\\/g, '/')));
+const bundleDirectory = path.join(root, 'js', 'bundles');
+const orphanOutputs = fs.existsSync(bundleDirectory)
+ ? fs.readdirSync(bundleDirectory)
+ .filter((name) => name.endsWith('.bundle.js'))
+ .map((name) => `js/bundles/${name}`)
+ .filter((outputPath) => !expectedOutputs.has(outputPath))
+ .sort()
+ : [];
+
+if (checkOnly) {
+ if (staleOutputs.length || orphanOutputs.length) {
+ if (staleOutputs.length) console.error(`Stale or missing bundles:\n${staleOutputs.map((item) => ` - ${item}`).join('\n')}`);
+ if (orphanOutputs.length) console.error(`Orphan bundles:\n${orphanOutputs.map((item) => ` - ${item}`).join('\n')}`);
+ process.exitCode = 1;
+ } else {
+ console.log(`Bundle check passed: ${expectedOutputs.size} outputs are current.`);
+ }
+} else if (orphanOutputs.length) {
+ console.warn(`Orphan bundles are not part of the manifest:\n${orphanOutputs.map((item) => ` - ${item}`).join('\n')}`);
+}
diff --git a/task_plan.md b/task_plan.md
new file mode 100644
index 00000000..6cf4d753
--- /dev/null
+++ b/task_plan.md
@@ -0,0 +1,232 @@
+# AppData v2 Audit Gate Implementation
+
+## Goal
+
+Implement the approved data integrity, file:// protocol, import/projection, idempotency/concurrency, cross-realm notification, and regression-test gates on `codex/audit-tmp-migration` while preserving the user's existing accuracy UI changes.
+
+## Constraints
+
+- Preserve existing worktree changes in:
+ - `js/components/practiceRecordModal.js`
+ - `js/views/legacyViewBundle.js`
+ - generated `js/bundles/browse.bundle.js`
+ - generated `js/bundles/practice.bundle.js`
+- Keep AppData IDB-only; do not add a long-lived legacy backend.
+- Subagents are read-only scouts; main agent owns edits and final verification.
+- Generated bundles must be rebuilt from source after source changes.
+
+## Phases
+
+1. **Baseline and contracts** — complete
+ - Capture current branch/diff/worktree state.
+ - Locate exact protocol/data/test surfaces and delegate independent read-only checks.
+2. **Data kernel and AppData** — complete
+ - Full mirror restore, entity-layer invariants, journal reset.
+ - Legacy projection normalization, suite light summaries, operation IDs.
+ - Vocab CAS retry/serialization and corruption isolation.
+ - Cross-realm commit broadcast.
+3. **Messaging protocols** — complete
+ - file:// fallback origin.
+ - Canonical fallback recorder.
+ - Listening submission correlation/ACK retry.
+ - Vocab save ACK.
+4. **Tests and bundles** — complete
+ - Update/add focused unit and Playwright coverage.
+ - Rebuild bundles without losing existing UI source edits.
+5. **Integration verification** — complete
+ - Run focused and full available suites.
+ - Fetch latest `origin/opensource`; integrate only if safe with the dirty worktree.
+ - Review final diff and report residual risks.
+
+## Current Hotfix: Manifest Loading And Practice Submission
+
+### Goal
+
+Restore reliable `file://` operation by making the generated reading manifest the only built-in exam-index source and by preventing valid practice completions from producing an invalid negative/non-finite `correctAnswers` value.
+
+### Phases
+
+1. **Trace exact failure paths** — complete
+ - Locate every reading exam-index source and the zero-index fallback path.
+ - Trace completion payload normalization into `AppData.practice.completeAttempt`.
+ - Separate optional missing Listening assets from Reading startup and submission.
+2. **Regression coverage** — complete
+ - Pin manifest-only built-in loading under `file://`.
+ - Pin score normalization for the reported completion payload shape.
+3. **Source fixes and bundle rebuild** — complete
+ - Apply narrowly scoped source changes.
+ - Rebuild all generated bundles once from the final source tree.
+ - Source changes and generated bundles are synchronized.
+4. **Verification** — complete
+ - Run focused and full relevant JS suites.
+ - Run bundle drift/syntax checks and the available `file://` submission flow.
+
+## Current Hotfix: Backup Trust And Import Safety
+
+### Goal
+
+Prevent semantically poisoned or sparse v2 backups from clearing practice records or hiding the built-in Reading manifest, while preserving recoverable user settings and maintaining explicit destructive restore semantics.
+
+### Phases
+
+1. **Real-backup reproduction and opensource comparison** — complete
+ - Inspect the supplied backup byte-for-byte and verify its checksum.
+ - Compare old export/import paths and identify v2-only regressions.
+2. **Semantic snapshot validation and salvage** — complete
+ - Repair known legacy row wrappers only when aliases match.
+ - Validate the library configuration/index/active-ID bundle as one unit.
+ - Classify declared/effective scope and surface repaired/missing keys.
+3. **Destructive import guard** — complete
+ - Compute existing/incoming/final/removed practice counts.
+ - Require explicit `confirmDestructive:true` after the UI confirmation before destructive commit.
+ - Update both ordinary import and external restore confirmations.
+4. **Dense export and round-trip coverage** — complete
+ - Materialize every exportable catalog key as present or explicitly cleared.
+ - Add the supplied poisoned snapshot as a regression fixture.
+5. **Bundle rebuild and end-to-end verification** — complete
+ - Fix existing test expectation drift, run focused/full suites, rebuild bundles once, and verify `file://` import/browse behavior.
+
+## Current Review: Raw-Data Migration Chain
+
+### Goal
+
+Re-audit the complete v1-to-v2 path from original persisted bytes, distinguish root-cause corrections from defensive recovery code, and simplify any fallback that is not justified by a demonstrated historical data shape.
+
+### Phases
+
+1. **Historical source-of-truth inventory** — complete
+ - Enumerate every v1 writer and the exact physical IndexedDB/localStorage shapes.
+ - Separate authoritative user records from generated/default manifest caches.
+2. **Byte-to-domain migration trace** — complete
+ - Replay representative raw rows through read, parse, normalize, mutate, export, and import.
+ - Record every lossy or shape-changing boundary.
+3. **Current patch minimality review** — complete
+ - Classify each new recovery/import safeguard as root fix, required compatibility, or removable overengineering.
+ - Prefer preventing the first bad write over repairing arbitrary poisoned states.
+4. **Evidence and decision** — complete
+ - Add only narrowly justified tests or corrections.
+ - Report the canonical migration contract and remaining unrecoverable cases.
+
+## Current Change: Persistent v1 Reconciliation
+
+### Goal
+
+Treat surviving v1 user data as the authoritative recovery source on every startup: merge all valid v1 records and user-library data into v2, and overwrite only v2 values carrying the known bad-migration fingerprints.
+
+### Phases
+
+1. **Reconciliation contract** — complete
+ - Define document, practice, library, and repeated-startup precedence.
+ - Preserve valid v2-only additions while ensuring all v1 records are present.
+2. **Implementation and regressions** — complete
+ - Remove the marker/healthy-v2 early exits that suppress legacy reconciliation.
+ - Add repeated-startup, damaged-v2 overwrite, and mixed v1/v2 merge coverage.
+3. **Bundles and verification** — complete
+ - Rebuild generated bundles.
+ - Run focused migration/import/library suites, file:// E2E, bundle drift, and diff checks.
+
+## Current Change: Review Fixes, v2 Insights, And Endless Mode
+
+### Goal
+
+Fix the six confirmed automated-review regressions, reconnect practice-record error classification to a lightweight v2 projection, and restore the complete endless-reading lifecycle without regressing the existing persistent v1 reconciliation work.
+
+### Phases
+
+1. **Evidence and contracts** — complete
+ - Confirm every review finding against the current source and tests.
+ - Trace the light-summary/detail split used by practice insights.
+ - Trace endless startup, navigation, completion, next-exam, and cleanup.
+2. **Review fixes** — complete
+ - Stop recurring reconciliation of transient recovery documents.
+ - Await Browse preference hydration before first UI/scroll restoration.
+ - Repair the method-contract scanner, achievement durability, async E2E assertion, and pre-import backup timing.
+3. **Lightweight practice insights** — complete
+ - Project compact question-type error counts into v2 summaries and suite-entry summaries.
+ - Teach the practice priority/radar consumer to use the compact projection.
+ - Use existing suite-entry summaries for exam-type filtering.
+4. **Endless mode lifecycle** — complete
+ - Fix first-start state construction.
+ - Carry an explicit endless marker through the unified exam-open path.
+ - Reuse the normal session lifecycle for subsequent exams and make startup failures visible/clean.
+5. **Regression coverage, bundles, and verification** — complete
+ - Add focused unit/contract/E2E coverage for every changed behavior.
+ - Rebuild generated bundles once from final source.
+ - Run focused suites, static suite, relevant E2E, bundle drift, and diff checks.
+
+## Current Audit: Residual Gate Triage
+
+### Goal
+
+Determine whether each residual unified-static/E2E failure represents a product defect that should be fixed, a test-runner defect worth repairing, or an optional resource-dependent audit that should be isolated from the default gate.
+
+### Phases
+
+1. **Independent evidence collection** — complete
+ - Audit the v2 legacy-key guard and suite JSON parser.
+ - Reproduce and classify the four NB replay failures.
+ - Trace the Reading quick timeout and suite first-passage readiness failure.
+2. **Main-agent verification** — complete
+ - Check agent-provided file/line evidence and rerun the smallest decisive probes.
+ - Estimate blast radius and implementation cost.
+3. **Recommendation** — complete
+ - Rank required, recommended, and optional fixes.
+ - Do not modify production or test code in this diagnostic turn.
+
+## Current Implementation: Residual Gate Repair
+
+### Goal
+
+Repair the stale static/test gates, restore deterministic Reading quick coverage, propagate suite test mode into the placeholder path, rebuild affected bundles, and verify the complete chains without weakening runtime safety.
+
+### Phases
+
+1. **Fresh source/test reconnaissance** — complete
+ - Locate exact minimal edits for the static allowlist, suite JSON parsing, NB trusted-message fixture, Reading ready/result contract, batch dataset loading, and suite placeholder URL.
+ - Preserve the existing dirty worktree and prior implementation.
+2. **Infrastructure and fixture repair** — complete
+ - Repair semantic allowlists and last-line JSON parsing.
+ - Update NB replay setup and clone-group selection.
+ - Remove the date-sensitive legacy migration fixture.
+3. **Reading and suite chain repair** — complete
+ - Establish a deterministic Reading ready/result assertion and eliminate per-dataset Node cold starts.
+ - Propagate the narrow suite test flag and add immediate E2E diagnostics.
+4. **Bundles and focused verification** — complete
+ - Rebuild only from final source using the repository build path.
+ - Run focused JS/Python/E2E tests and bundle drift checks.
+5. **Full gate verification** — complete
+ - Run the unified static suite with a realistic outer bound.
+ - Record any remaining unrelated failures without masking them.
+
+## Errors Encountered
+
+| Error | Attempt | Resolution |
+|---|---:|---|
+| Subagent tools returned `unsupported call` during the prior audit turn, Default mode, and the resumed implementation turn | 4 | Stop retrying the unavailable interface; use parallel read-only shell probes and record the limitation |
+| Parallel gate probe assumed a root `package.json`; PowerShell redirection also made `rg.exe` fail | 1 | Locate manifests with `rg --files` first; run probes with per-call error capture and no stderr redirection |
+| Bundled `rg.exe` subsequently failed to launch with Windows `Access denied` | 1 | Treat `rg` as unavailable for this session; use `git ls-files`, `git grep`, and `Select-String` |
+| Combined AppData/DataKernel patch missed the exact `createRestoreSnapshot` context and was rejected atomically | 1 | Split into smaller exact hunks after rereading the current function; no source changes were applied |
+| New corruption test asserted `summary.id`, but the seeded legacy test summary only contains `title/score` | 1 | Assert the surviving row by `title`; implementation behavior was correct |
+| Submission contract hunk missed an intervening `observedOrigin` assignment | 1 | Reread the 12-line target and inserted the guard immediately before message metadata is committed |
+| Fallback ACK regression kept Node alive on the 120-second receipt replay timer | 1 | Preserve the browser timer and call `unref()` only when the runtime timer supports it |
+| PowerShell regex quoting failed while locating suite completion fixtures | 1 | Switched to `Select-String -SimpleMatch`; no source action was repeated |
+| Resumed subagent dispatch still returned `unsupported call` | 5 | Honor the existing stop condition; continue with bounded read-only source probes |
+| `suiteModeRegression` counted the completion-time recorder rebind as a reset-time rebind | 1 | Clear the fixture's `recorderStarts` probe immediately before sending the reset request |
+| Multi-file PowerShell range printer hit an array type mismatch after printing the first targets | 1 | Retain the useful output and switch to exact `Select-String`/single-file reads for remaining senders |
+| PowerShell parsed unquoted `^{tree}` revisions incorrectly during squash replacement preflight | 1 | Safety check aborted before mutation; reran with quoted revisions, verified identical tree hashes, then force-pushed with an explicit lease |
+| All PowerShell/Node child processes fail before startup with `CreateProcessAsUserW failed: 5` | 3 execution paths + 4 agents | Switched to remote exact-tree reads and static review; source/tests are patched, but tests and bundle rebuild must wait for the desktop sandbox/process launcher to recover |
+| Full JS sweep exposed `unifiedReadingCoreRegression.test.js` notification-order failure | 2 | Reproduced alone; unrelated to the data/import files changed here and recorded as a pre-existing residual failure |
+| Existing file:// E2E called destructive `commitImport` without the new preview token | 1 | Updated the test to model the same explicit confirmation-token handoff as production UI; rerun passed |
+| Combined plan/findings status patch targeted a findings heading in `task_plan.md` | 1 | Atomic patch made no changes; split the update across the correct files |
+| Legacy migration regression still expected the retired `poison-repair` marker mode | 1 | Source syntax passed; update the test contract to persistent reconciliation before rerunning |
+| PowerShell range probe accidentally assigned inside the loop condition | 1 | Parser rejected before execution; reran with a fixed numeric upper bound |
+| Reboot harness treated delete entity operations as upserts and checksummed `undefined` | 1 | Added the harness delete branch so the persistent-restoration test exercises the real three-layer delete contract |
+| Planning skill completion helper reported `0/4` because this long-lived plan uses prose phase markers rather than its checkbox template | 1 | Manually verified and marked the current and overall verification phases complete; did not rewrite the established planning format |
+| Bundled `rg.exe` still fails to launch with Windows `Access denied` during the current change | 2 | Reuse the established fallback: `git grep`, `git ls-files`, and PowerShell `Select-String`; do not retry `rg` |
+| Unified static suite exceeded the initial 120-second command timeout without producing a failure report | 1 | Build final bundles first, then rerun the suite with its realistic longer timeout instead of repeating the same bound |
+| Unified static suite also exceeded a 300-second outer shell timeout | 2 | Inspection shows the runner legitimately contains 240s/360s/480s child-test bounds and emits only at completion; rerun once with an outer bound covering those declared gates |
+| `suite_practice_flow.py` retained a locator across an overview re-render and failed while scrolling a detached button | 1 | Replace the redundant explicit scroll with Playwright's visible wait and click auto-retry on the locator |
+| Suite E2E used the pre-keyword-only Playwright `wait_for_function` argument form in preference setup | 1 | Pass the payload through the current `arg=` keyword, matching every other parameterized wait in the file |
+| Cleanup of the newly generated `developer/tests/ci/__pycache__` was blocked by the desktop command policy | 1 | Leave the untracked cache untouched and report it; no retry or broader deletion |
+| Suite E2E passed placeholder launch and P1→P2, then timed out waiting for P2→P3 | 1 | Treat as newly exposed chain defect; inspect the exact transition/report rather than raising the 20-second wait |
+| Suite E2E later failed before suite launch because the asynchronously shown GPL modal intercepted browse navigation | 1 | Make overlay dismissal wait for visible state instead of a one-shot `.show` count check |
diff --git a/templates/ci-practice-fixtures/analysis-of-fear.html b/templates/ci-practice-fixtures/analysis-of-fear.html
index 7271e99c..ea83bdb5 100644
--- a/templates/ci-practice-fixtures/analysis-of-fear.html
+++ b/templates/ci-practice-fixtures/analysis-of-fear.html
@@ -895,37 +895,6 @@ Questions 36–40
return;
}
console.log('[PracticeEnhancer] 开始初始化');
- try {
- if (window.storage?.ready) {
- await window.storage.ready;
- }
-
- if (window.storage && typeof window.storage.setNamespace === 'function') {
- window.storage.setNamespace('exam_system');
- console.log('[PracticeEnhancer] 已设置共享命名空间: exam_system');
-
- setTimeout(async () => {
- const testKey = 'namespace_test_practice';
- const testValue = 'test_value_practice_' + Date.now();
- try {
- await window.storage.set(testKey, testValue);
- const retrievedValue = await window.storage.get(testKey);
- if (retrievedValue === testValue) {
- console.log('✅ 练习页面命名空间设置验证成功: 存储和读取正常');
- } else {
- console.warn('❌ 练习页面命名空间设置验证失败: 读取值不匹配');
- }
- await window.storage.remove(testKey);
- } catch (error) {
- console.error('❌ 练习页面命名空间设置验证失败', error);
- }
- }, 1000);
- } else {
- console.warn('[PracticeEnhancer] 存储管理器未加载或setNamespace方法不可用');
- }
- } catch (error) {
- console.error('[PracticeEnhancer] 存储初始化失败,跳过命名空间设置', error);
- }
this.setupCommunication();
this.setupAnswerListeners();
this.extractCorrectAnswers(); // 新增:提取正确答案
@@ -1778,7 +1747,10 @@ Questions 36–40
};
try {
- this.parentWindow.postMessage(message, '*');
+ this.parentWindow.postMessage(
+ message,
+ window.location.protocol === 'file:' ? '*' : window.location.origin
+ );
console.log('[PracticeEnhancer] 消息已发送:', type);
} catch (error) {
console.error('[PracticeEnhancer] 发送消息失败:', error);
@@ -1840,4 +1812,4 @@ Questions 36–40
}