Files
study-bible-project/src/utils/backup.js
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00

88 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// "Download my data" / "Restore": everything you've made in one JSON file you keep — studies, bookmarks,
// highlights (and what you named the colours), ink notes and reading plans. Restoring only ever adds or
// updates; it never deletes anything you already have here.
import { loadProjectIndex } from './bibleUtils.jsx';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `reader` is what the app holds in memory: { bookmarks, highlights, colorLabels, ink }.
export function buildBackup({ reader, readingPlans, email = null, now = new Date() }) {
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
return {
app: BACKUP_APP,
version: BACKUP_VERSION,
exportedAt: now.toISOString(),
account: email,
projects,
reader: {
bookmarks: reader?.bookmarks ?? {},
highlights: reader?.highlights ?? [],
colorLabels: reader?.colorLabels ?? {},
ink: reader?.ink ?? {},
},
readingPlans: readingPlans ?? { progress: [], customPlans: [], activity: [] },
};
}
export const backupFileName = (now = new Date()) => `bible-study-backup-${now.toISOString().slice(0, 10)}.json`;
// -> { ok: true, backup, summary } or { ok: false, error }. Checks the file really is one of ours.
export function validateBackup(data) {
if (!data || typeof data !== 'object' || data.app !== BACKUP_APP) return { ok: false, error: 'That doesn’t look like a Bible Study backup file.' };
if (typeof data.version !== 'number' || data.version > BACKUP_VERSION) return { ok: false, error: 'This backup was made by a newer version of the app.' };
const projects = Array.isArray(data.projects) ? data.projects.filter((p) => p && typeof p.id === 'string' && p.title && Array.isArray(p.chapters)) : [];
const reader = data.reader && typeof data.reader === 'object' ? data.reader : {};
const bookmarks = reader.bookmarks && typeof reader.bookmarks === 'object' && !Array.isArray(reader.bookmarks) ? reader.bookmarks : {};
const highlights = Array.isArray(reader.highlights) ? reader.highlights.filter((h) => h && typeof h.id === 'string') : [];
const colorLabels = reader.colorLabels && typeof reader.colorLabels === 'object' && !Array.isArray(reader.colorLabels) ? reader.colorLabels : {};
const ink = reader.ink && typeof reader.ink === 'object' && !Array.isArray(reader.ink) ? reader.ink : {};
const plans = data.readingPlans && typeof data.readingPlans === 'object' ? data.readingPlans : {};
const backup = {
projects, reader: { bookmarks, highlights, colorLabels, ink },
readingPlans: {
progress: Array.isArray(plans.progress) ? plans.progress : [],
customPlans: Array.isArray(plans.customPlans) ? plans.customPlans : [],
activity: Array.isArray(plans.activity) ? plans.activity : [],
},
};
return {
ok: true, backup,
summary: {
projects: projects.length, bookmarks: Object.keys(bookmarks).length, highlights: highlights.length,
inkPages: Object.keys(ink).length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
},
};
}
// Brings the studies back onto this device: a study that isn't here is added, one that is here is
// replaced only when the backup's copy was edited more recently. -> the projects that were written
// (so the caller can send them to the server too).
export function restoreProjects(projects) {
const written = [];
for (const project of projects) {
const here = loadProjectById(project.id);
if (here && (here.lastEdited ?? 0) >= (project.lastEdited ?? 0)) continue;
written.push(saveProjectToStorage(project));
}
return written;
}
// Reading-plan progress from a backup combined with what is here: each plan run is kept once, and the
// copy with the later activity wins; custom plans and activity days are unioned.
export function mergeReadingPlans(local, incoming) {
const progress = new Map((local.progress ?? []).map((p) => [p.id, p]));
for (const p of incoming.progress ?? []) {
const mine = progress.get(p.id);
if (!mine || (p.lastActivityAt ?? 0) > (mine.lastActivityAt ?? 0)) progress.set(p.id, p);
}
const customPlans = new Map((local.customPlans ?? []).map((p) => [p.id, p]));
for (const p of incoming.customPlans ?? []) if (!customPlans.has(p.id)) customPlans.set(p.id, p);
return {
progress: [...progress.values()],
customPlans: [...customPlans.values()],
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
};
}