-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstorage.mjs
More file actions
146 lines (136 loc) · 6.35 KB
/
Copy pathstorage.mjs
File metadata and controls
146 lines (136 loc) · 6.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// storage.mjs — the persistence layer: IndexedDB primitives, at-rest
// encryption glue, and the per-exam load/save helpers for progress,
// overrides, and the exam-outcome log. Imports only core (state, toast) and
// crypto; everything above it (features, render) imports from here, so the
// dependency arrow points one way and there's no cycle back into app.js.
import { state, toast } from './core.mjs';
import { encryptJSON, decryptJSON, isEncryptedBlob } from './crypto.mjs';
export const DB_NAME = 'aplus-study';
export const DB_VERSION = 5;
export const STORE = 'progress';
export const OSTORE = 'overrides'; // per-question edits: { [qid]: {options?, image?, images?} }
export const DSTORE = 'drawings'; // per-question scratchpad canvas PNGs (base64 dataURL)
export const RSTORE = 'reference'; // user's reference book PDF (per-exam): { blob, name, size, pageCount, uploadedAt, pageText? }
export const ESTORE = 'examEvents'; // outcome-loop log: { [examEventId]: ExamEvent }. PR #67. See ExamEvent shape comment near loadExamEvents().
export function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
if (!db.objectStoreNames.contains(OSTORE)) db.createObjectStore(OSTORE);
if (!db.objectStoreNames.contains(DSTORE)) db.createObjectStore(DSTORE);
if (!db.objectStoreNames.contains(RSTORE)) db.createObjectStore(RSTORE);
if (!db.objectStoreNames.contains(ESTORE)) db.createObjectStore(ESTORE);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export function idbGet(store, key) {
return openDB().then(db => new Promise(resolve => {
const tx = db.transaction(store, 'readonly');
const r = tx.objectStore(store).get(key);
r.onsuccess = () => resolve(r.result);
r.onerror = () => resolve(undefined);
}));
}
export function idbPut(store, key, value) {
return openDB().then(db => new Promise(resolve => {
const tx = db.transaction(store, 'readwrite');
tx.objectStore(store).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
}));
}
export function idbDelete(store, key) {
return openDB().then(db => new Promise(resolve => {
const tx = db.transaction(store, 'readwrite');
tx.objectStore(store).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
}));
}
//─── AT-REST ENCRYPTION GLUE ─────────────────────────────────
// The derived key is held in memory only (state._cryptoKey) for the current
// session; closing the app drops it and requires re-unlock.
export async function maybeEncrypt(obj) {
return state._cryptoKey ? encryptJSON(state._cryptoKey, obj) : obj;
}
export async function maybeDecrypt(raw, fallback) {
if (raw === undefined || raw === null) return fallback;
if (!isEncryptedBlob(raw)) return raw;
if (!state._cryptoKey) throw new Error('locked');
return decryptJSON(state._cryptoKey, raw);
}
// Per-exam keys so each exam's progress lives in its own slot.
export async function loadProgress(examId = state.exam) {
try {
const raw = await idbGet(STORE, examId);
return (await maybeDecrypt(raw, {})) || {};
} catch (e) { if (e.message === 'locked') throw e; return {}; }
}
// One-time-per-session warning so the user knows their FSRS progress
// stopped persisting (IDB blocked, quota exhausted, etc.) instead of
// silently studying with ratings that won't survive a tab close.
let _idbFailToasted = false;
export async function saveProgress(examId = state.exam) {
try { await idbPut(STORE, examId, await maybeEncrypt(state.progress)); }
catch (e) {
console.warn('Save progress failed', e);
if (!_idbFailToasted) {
_idbFailToasted = true;
try { toast('Couldn\'t save progress to device storage. Ratings may not persist after closing.', 'error', 6000); } catch {}
}
}
}
export async function clearProgress(examId = state.exam) {
try {
await idbDelete(STORE, examId);
state.progress = {};
} catch (e) { console.warn('Clear failed', e); }
}
export async function loadOverrides(examId = state.exam) {
try {
const raw = await idbGet(OSTORE, examId);
return (await maybeDecrypt(raw, {})) || {};
} catch (e) { if (e.message === 'locked') throw e; return {}; }
}
export async function saveOverrides(examId = state.exam) {
try { await idbPut(OSTORE, examId, await maybeEncrypt(state.overrides)); }
catch (e) { console.warn('Save overrides failed', e); }
}
//─── EXAM-OUTCOME LOG (PR #67) ───────────────────────────────
// Stored shape (per exam attempt — one record per exam taken):
// {
// id: 'evt-<ms>', // primary key, sortable
// exam: 'core2', // dataset id (state.exam)
// examDateISO: '2026-07-15', // user-supplied
// loggedAtMs: 1721059200000,
// // Pre-exam (may be null if user only logs after the fact):
// pre: { predictedReadinessPct: 78, selfPredictedPct: 75, confidencePct: 60 } | null,
// // Post-exam:
// post: {
// actualScorePct: 82, // user types from CompTIA score report
// postdictionPct: 70, // gut % right after, before learning score
// passed: true, // derived vs MOCK_EXAM_PASS_PCT
// lovettGap: 'I overestimated…',
// lovettStrategies: ['mocks','quiz','reading'],
// lovettForward: 'More mocks earlier next time.',
// } | null,
// }
// Persisted under a single IDB key per exam dataset (the value is an
// ARRAY of events), encrypted under the PIN if one's set. Calibration
// metrics only compute meaningfully at n >= 3 per the WWC SCED standard
// surfaced by the metacognition research; below that, the UI labels
// the data 'anecdotal'.
export async function loadExamEvents(examId = state.exam) {
try {
const raw = await idbGet(ESTORE, examId);
return (await maybeDecrypt(raw, [])) || [];
} catch (e) { if (e.message === 'locked') throw e; return []; }
}
export async function saveExamEvents(events, examId = state.exam) {
try { await idbPut(ESTORE, examId, await maybeEncrypt(events)); }
catch (e) { console.warn('Save exam events failed', e); }
}