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,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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user