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>
This commit is contained in:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
@@ -0,0 +1,87 @@
|
||||
// "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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user