Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links - Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes) - Series: group studies on the dashboard, filter by series, calendar file for a study's sessions - Lesson handout: printable page per session (passage, outline, questions, writing lines) - Save a chunk's notes as your own study template (synced) - Voice dictation for notes and scripts (browser speech recognition) - Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices - Export highlights and bookmarks as Markdown or Word, grouped by named colour - Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY) - Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
146 lines
4.2 KiB
JavaScript
146 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),
|
|
series: (updated.series ?? '').trim(),
|
|
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(', ');
|
|
}
|