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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
insertAt, locate, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
} from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
const ids = (tree) => flatten(tree).map((f) => f.node.id);
// I. a (with A. a1, B. a2) II. b
const sample = () => [n('a', 'a', [n('a1'), n('a2')]), n('b')];
describe('labels', () => {
test('roman numerals, letters and numbers by level', () => {
expect([1, 4, 9, 14, 19].map(toRoman)).toEqual(['I', 'IV', 'IX', 'XIV', 'XIX']);
expect(labelFor(0, 1)).toBe('II.');
expect(labelFor(1, 0)).toBe('A.');
expect(labelFor(1, 26)).toBe('AA.');
expect(labelFor(2, 2)).toBe('3.');
expect(labelFor(3, 1)).toBe('b.');
expect(labelFor(4, 0)).toBe('(1)');
});
test('flatten gives reading order with depth and labels', () => {
expect(flatten(sample()).map((f) => `${f.label}${f.node.id}@${f.depth}`)).toEqual(['I.a@0', 'A.a1@1', 'B.a2@1', 'II.b@0']);
});
});
describe('editing', () => {
test('add after, add child, add root', () => {
expect(ids(addAfter(sample(), 'a1', n('x')))).toEqual(['a', 'a1', 'x', 'a2', 'b']);
expect(ids(addChild(sample(), 'b', n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']);
expect(ids(addRoot(sample(), n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']);
expect(flatten(addChild(sample(), 'b', n('x'))).at(-1).depth).toBe(1);
});
test('update and remove, including a node deep in the tree', () => {
expect(getNode(updateNode(sample(), 'a2', { text: 'new' }), 'a2').text).toBe('new');
expect(ids(removeNode(sample(), 'a1'))).toEqual(['a', 'a2', 'b']);
expect(ids(removeNode(sample(), 'a'))).toEqual(['b']); // takes its sub-points with it
});
test('indent makes it a sub-point of the one above; the first one cannot', () => {
const t = indent(sample(), 'b');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a1@1', 'a2@1', 'b@1']);
expect(indent(sample(), 'a')).toEqual(sample());
expect(indent(sample(), 'a1')).toEqual(sample());
});
test('indent carries a point\'s own sub-points with it', () => {
const t = indent([n('p'), n('q', 'q', [n('q1')])], 'q');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['p@0', 'q@1', 'q1@2']);
});
test('indent stops at the deepest level', () => {
// a chain: top > s1 > s2 > ... pushing every new point in as far as it will go
let tree = [n('top')];
for (let i = 1; i <= MAX_DEPTH + 2; i += 1) {
tree = addRoot(tree, n(`s${i}`));
for (let k = 0; k < MAX_DEPTH + 2; k += 1) tree = indent(tree, `s${i}`);
}
const deepest = Math.max(...flatten(tree).map((f) => f.depth));
expect(deepest).toBe(MAX_DEPTH - 1);
});
test('outdent moves a sub-point to sit just after its parent', () => {
const t = outdent(sample(), 'a1');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a2@1', 'a1@0', 'b@0']);
expect(outdent(sample(), 'a')).toEqual(sample()); // already at the top
});
test('move swaps with a sibling and stops at the ends', () => {
expect(ids(moveNode(sample(), 'b', -1))).toEqual(['b', 'a', 'a1', 'a2']);
expect(ids(moveNode(sample(), 'a2', -1))).toEqual(['a', 'a2', 'a1', 'b']);
expect(moveNode(sample(), 'a', -1)).toEqual(sample());
expect(moveNode(sample(), 'b', 1)).toEqual(sample());
});
test('edits never change the tree they are given', () => {
const t = sample();
const copy = JSON.stringify(t);
indent(t, 'b'); outdent(t, 'a1'); removeNode(t, 'a'); moveNode(t, 'b', -1); updateNode(t, 'a', { text: 'z' });
expect(JSON.stringify(t)).toBe(copy);
});
});
describe('reading it back', () => {
test('plain text and markdown carry labels, indentation and passages', () => {
const t = [{ ...n('a', 'Creation'), ref: 'Gen 1:1-2', children: [n('a1', 'Light')] }, n('b', '')];
expect(outlineToText(t)).toBe('I. Creation (Gen 1:1-2)\n A. Light\nII. (untitled)');
expect(outlineToMarkdown(t)).toBe('- **I.** Creation (Gen 1:1-2)\n - **A.** Light\n- **II.** (untitled)');
expect(countNodes(t)).toBe(3);
});
});
describe('seeding from chunks', () => {
const project = {
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }, { number: 3, text: 'c' }], chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: 'Paul greets.\nMore.', episodeTitle: '' },
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '', episodeTitle: 'Hope' },
] }],
};
test('one main point per chunk, linked to it and named from its notes or title', () => {
const t = seedFromChunks(project);
expect(t.map((x) => [x.text, x.ref, x.chunkId])).toEqual([['Paul greets.', 'Titus 1:1–2', 'c1'], ['Hope', 'Titus 1:3', 'c2']]);
});
test('keeps what is there and only adds chunks that are not yet in the outline', () => {
const first = seedFromChunks(project);
const edited = updateNode(first, first[0].id, { text: 'My own wording' });
const again = seedFromChunks(project, edited);
expect(again).toHaveLength(2);
expect(again[0].text).toBe('My own wording');
expect(seedFromChunks(project, [newNode('Extra')]).map((x) => x.chunkId)).toEqual([null, 'c1', 'c2']);
});
});
describe('putting a deleted point back', () => {
test('locate + insertAt restores a point, with its sub-points, exactly where it was', () => {
const tree = sample();
const where = locate(tree, 'a1');
const gone = getNode(tree, 'a1');
expect(where).toEqual({ parentId: 'a', index: 0 });
expect(ids(insertAt(removeNode(tree, 'a1'), where.parentId, where.index, gone))).toEqual(ids(tree));
const top = locate(tree, 'a');
expect(ids(insertAt(removeNode(tree, 'a'), top.parentId, top.index, getNode(tree, 'a')))).toEqual(ids(tree));
});
test('if the parent is gone it goes to the top level instead of vanishing', () => {
expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']);
});
});