Performance: - Lazy-load pages, docx, and mammoth (initial JS 1,298 KB -> ~290 KB) - Gzip responses and cache hashed assets for a year - Memoize DrawCanvas strokes so ink isn't redrawn on every render - Precompute whole-Bible search index and defer the query while typing - Reload once if a lazy chunk is missing after a deploy Deployment: - setup.sh builds and runs one production server on APP_PORT (default 5173); --dev keeps the old Vite dev setup - Session cookie uses secure: 'auto' so login works behind any proxy Chunk editor: - Remove the Draw layout, Ink Notes panel, and ink in HTML export (existing inkStrokes data is left in saved projects) Refactor: - Move module-level helpers out of App.jsx into src/utils/* - Extract Bible reader state and logic into hooks/useBibleReader Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
4.2 KiB
JavaScript
145 lines
4.2 KiB
JavaScript
// Project persistence in localStorage, plus migrations from older data shapes.
|
|
import {
|
|
makeId,
|
|
indexKey,
|
|
projectKey,
|
|
loadProjectIndex,
|
|
} from './bibleUtils.jsx';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Migration helpers (exported for testing)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function migrateChunk(chunk) {
|
|
if (chunk.observation !== undefined) {
|
|
return {
|
|
...chunk,
|
|
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
|
|
generalNotes: chunk.generalNotes ?? '',
|
|
episodeNumber: chunk.episodeNumber ?? '',
|
|
episodeTitle: chunk.episodeTitle ?? '',
|
|
finalScript: chunk.finalScript ?? '',
|
|
tags: chunk.tags ?? [],
|
|
}; // already new format
|
|
}
|
|
return {
|
|
...chunk,
|
|
observation: chunk.notes ?? '',
|
|
interpretation: '',
|
|
application: '',
|
|
crossReferences: [],
|
|
tags: [],
|
|
spilloverEndVerse: null,
|
|
generalNotes: '',
|
|
episodeNumber: '',
|
|
episodeTitle: '',
|
|
finalScript: '',
|
|
notes: undefined,
|
|
};
|
|
}
|
|
|
|
export function migrateProject(raw) {
|
|
if (!raw) return null;
|
|
// Already new format
|
|
if (Array.isArray(raw.chapters)) {
|
|
return {
|
|
...raw,
|
|
chapters: raw.chapters.map((ch) => ({
|
|
...ch,
|
|
chunks: ch.chunks.map(migrateChunk),
|
|
})),
|
|
};
|
|
}
|
|
// Old flat format → wrap in chapters array
|
|
const id = raw.id ?? makeId();
|
|
return {
|
|
id,
|
|
title: raw.title ?? '',
|
|
translation: raw.translation ?? 'BSB',
|
|
lastEdited: raw.lastEdited ?? Date.now(),
|
|
selectedChunkId: raw.selectedChunkId ?? null,
|
|
chapters: [
|
|
{
|
|
book: raw.book ?? '',
|
|
bookAbbrev: raw.bookAbbrev ?? '',
|
|
chapter: raw.chapter ?? '1',
|
|
verses: raw.verses ?? [],
|
|
chunks: (raw.chunks ?? []).map(migrateChunk),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Storage helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function saveProjectToStorage(project) {
|
|
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
|
// and the server always receive the identical timestamp.
|
|
const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
|
|
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
|
const index = loadProjectIndex();
|
|
const existing = index.findIndex((e) => e.id === updated.id);
|
|
const tags = Array.from(
|
|
new Set(
|
|
(updated.chapters ?? [])
|
|
.flatMap((ch) => ch.chunks ?? [])
|
|
.flatMap((chunk) => chunk.tags ?? []),
|
|
),
|
|
);
|
|
const summary = {
|
|
id: updated.id,
|
|
title: updated.title,
|
|
lastEdited: updated.lastEdited,
|
|
chapterSummary: buildChapterSummary(updated),
|
|
tags,
|
|
};
|
|
if (existing >= 0) {
|
|
index[existing] = summary;
|
|
} else {
|
|
index.push(summary);
|
|
}
|
|
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
|
return updated; // caller can use this exact object for the server PUT
|
|
}
|
|
|
|
export function deleteProjectFromStorage(id) {
|
|
window.localStorage.removeItem(projectKey(id));
|
|
const index = loadProjectIndex().filter((e) => e.id !== id);
|
|
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
|
}
|
|
|
|
export function loadProjectById(id) {
|
|
try {
|
|
const raw = window.localStorage.getItem(projectKey(id));
|
|
return raw ? migrateProject(JSON.parse(raw)) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function migrateOldStorageKeys() {
|
|
const keys = Object.keys(window.localStorage);
|
|
keys.forEach((key) => {
|
|
if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
|
|
try {
|
|
const raw = JSON.parse(window.localStorage.getItem(key));
|
|
if (!raw || !raw.book) return;
|
|
const migrated = migrateProject(raw);
|
|
if (!migrated) return;
|
|
saveProjectToStorage(migrated);
|
|
window.localStorage.removeItem(key);
|
|
} catch {
|
|
// skip corrupt entries
|
|
}
|
|
});
|
|
}
|
|
|
|
export function buildChapterSummary(project) {
|
|
if (!Array.isArray(project.chapters)) return '';
|
|
return project.chapters
|
|
.map((ch) => `${ch.book} ${ch.chapter}`)
|
|
.join(', ');
|
|
}
|