// A user's synced display preferences: theme, default Reader translation, verse layout, the // commentary they last chose, and where they left off in the Reader. Each preference carries // its own timestamp ({ v, at }) so the most recent change to each one wins, device by device. const ENUMS = { theme: ['paper', 'sepia', 'dark'], translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], layout: ['flow', 'verses'], startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'], }; const DAY_MS = 86_400_000; const COLOR = /^#[0-9a-fA-F]{3,8}$/; // A cleaned value, or undefined when it isn't acceptable for that preference. export function cleanPrefValue(key, value) { if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined; if (key === 'colorLabels') { // What each highlight colour means to the reader: { "#fef08a": "Promises", ... } if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const out = {}; for (const [color, label] of Object.entries(value).slice(0, 12)) { if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30); } return out; } if (key === 'studyTemplates') { // The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with if (!Array.isArray(value)) return undefined; const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : ''); const fields = (o, names, max) => { const out = {}; for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; } return out; }; return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => { const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800); const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000); return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) }; }); } if (key === 'position') { if (!value || typeof value !== 'object') return undefined; const book = String(value.book ?? '').toUpperCase(); if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined; return { book, chapter: value.chapter }; } return undefined; } // Body -> { key: { v, at } } with only known keys, valid values and sane timestamps. export function cleanPrefsPatch(body, now = Date.now()) { const out = {}; const prefs = body?.prefs; if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out; for (const [key, entry] of Object.entries(prefs)) { const v = cleanPrefValue(key, entry?.v); const at = entry?.at; if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue; out[key] = { v, at }; } return out; } // Per key, the entry with the newer timestamp wins (the stored one on a tie). export function mergePrefs(existing = {}, patch = {}) { const merged = { ...existing }; for (const [key, entry] of Object.entries(patch)) { if (!merged[key] || entry.at > merged[key].at) merged[key] = entry; } return merged; }