Alice thought this a very curious thing.
+diff --git a/apps/readest-app/.claude/skills/gstack b/apps/readest-app/.claude/skills/gstack index 8ddfab23..1f4b6fd7 160000 --- a/apps/readest-app/.claude/skills/gstack +++ b/apps/readest-app/.claude/skills/gstack @@ -1 +1 @@ -Subproject commit 8ddfab233d3999edb172bed54aaf06fc5ff92646 +Subproject commit 1f4b6fd7a2a349dfc6f04d158b8b7778b5b74232 diff --git a/apps/readest-app/src/__tests__/utils/transform.test.ts b/apps/readest-app/src/__tests__/utils/transform.test.ts new file mode 100644 index 00000000..b86c7a92 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/transform.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { transformBookNoteToDB, transformBookNoteFromDB } from '@/utils/transform'; +import { BookNote } from '@/types/book'; +import { DBBookNote } from '@/types/records'; + +describe('transformBookNoteToDB with xpointer fields', () => { + it('passes through xpointer0 and xpointer1', () => { + const note: BookNote = { + bookHash: 'abc123', + metaHash: 'meta456', + id: 'note1', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + xpointer0: '/body/DocFragment[2]/body/div[1]/p[1]/text().0', + xpointer1: '/body/DocFragment[2]/body/div[1]/p[1]/text().50', + text: 'highlighted text', + note: 'my note', + style: 'highlight', + color: 'yellow', + createdAt: 1700000000000, + updatedAt: 1700000001000, + }; + + const dbNote = transformBookNoteToDB(note, 'user1'); + + expect(dbNote.xpointer0).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().0'); + expect(dbNote.xpointer1).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().50'); + expect(dbNote.cfi).toBe('epubcfi(/6/4!/4/2/1:0)'); + }); + + it('handles missing xpointer fields (Readest-only note)', () => { + const note: BookNote = { + bookHash: 'abc123', + id: 'note2', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + text: 'text', + note: '', + createdAt: 1700000000000, + updatedAt: 1700000001000, + }; + + const dbNote = transformBookNoteToDB(note, 'user1'); + + expect(dbNote.xpointer0).toBeUndefined(); + expect(dbNote.xpointer1).toBeUndefined(); + expect(dbNote.cfi).toBe('epubcfi(/6/4!/4/2/1:0)'); + }); +}); + +describe('transformBookNoteFromDB with xpointer fields', () => { + it('reads xpointer0 and xpointer1 from DB', () => { + const dbNote: DBBookNote = { + user_id: 'user1', + book_hash: 'abc123', + meta_hash: 'meta456', + id: 'note1', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + xpointer0: '/body/DocFragment[2]/body/div[1]/p[1]/text().0', + xpointer1: '/body/DocFragment[2]/body/div[1]/p[1]/text().50', + text: 'highlighted text', + note: 'my note', + style: 'highlight', + color: 'yellow', + created_at: '2023-11-14T22:13:20.000Z', + updated_at: '2023-11-14T22:13:21.000Z', + }; + + const note = transformBookNoteFromDB(dbNote); + + expect(note.xpointer0).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().0'); + expect(note.xpointer1).toBe('/body/DocFragment[2]/body/div[1]/p[1]/text().50'); + expect(note.cfi).toBe('epubcfi(/6/4!/4/2/1:0)'); + }); + + it('handles missing xpointer fields from DB', () => { + const dbNote: DBBookNote = { + user_id: 'user1', + book_hash: 'abc123', + id: 'note2', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + note: '', + created_at: '2023-11-14T22:13:20.000Z', + updated_at: '2023-11-14T22:13:21.000Z', + }; + + const note = transformBookNoteFromDB(dbNote); + + expect(note.xpointer0).toBeUndefined(); + expect(note.xpointer1).toBeUndefined(); + }); + + it('defaults cfi to empty string when missing from DB (KOReader note)', () => { + const dbNote: DBBookNote = { + user_id: 'user1', + book_hash: 'abc123', + id: 'note3', + type: 'annotation', + xpointer0: '/body/DocFragment[1]/body/p[1]/text().0', + xpointer1: '/body/DocFragment[1]/body/p[1]/text().20', + note: '', + created_at: '2023-11-14T22:13:20.000Z', + updated_at: '2023-11-14T22:13:21.000Z', + }; + + const note = transformBookNoteFromDB(dbNote); + + expect(note.cfi).toBe(''); + expect(note.xpointer0).toBe('/body/DocFragment[1]/body/p[1]/text().0'); + expect(note.xpointer1).toBe('/body/DocFragment[1]/body/p[1]/text().20'); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/xcfi.spec.ts b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts index 93411ffe..cba5493d 100644 --- a/apps/readest-app/src/__tests__/utils/xcfi.spec.ts +++ b/apps/readest-app/src/__tests__/utils/xcfi.spec.ts @@ -241,8 +241,12 @@ describe('CFIToXPointerConverter', () => { const xpointer = converter.cfiToXPointer(cfi); expect(cfi).toMatch(/^epubcfi\([^,]+,[^,]+,[^,]+\)$/); - expect(xpointer.pos0).toBe(pos0); - expect(xpointer.pos1).toBe(pos1); + // cfiToXPointer now produces text()[K].N format + expect(xpointer.pos0).toBe('/body/DocFragment[2]/body/div/p[1]/text().6'); + expect(xpointer.pos1).toBe('/body/DocFragment[2]/body/div/p[2]/text().16'); + // Round-trip: the new format should convert back to the same CFI + const backCfi = converter.xPointerToCFI(xpointer.pos0!, xpointer.pos1!); + expect(backCfi).toBe(cfi); }); }); @@ -382,4 +386,148 @@ describe('CFIToXPointerConverter', () => { expect(result.xpointer).toBe('/body/DocFragment[3]/body/div/section/article/p[1]'); }); }); + + describe('indexed text node XPointers (text()[N].offset)', () => { + let inlineDoc: Document; + + beforeEach(() => { + // Simulates:
...text... Megan likes the sea, too...
+ inlineDoc = new DOMParser().parseFromString( + ` +She spent a year of her Ph.D. at my old college at Cambridge. A woman, at Caius! Megan likes the sea, too. She's finishing her radioastronomy research.
+ `, + 'text/html', + ); + }); + + it('should convert text()[N].offset range XPointer to valid CFI', () => { + const converter = new XCFI(inlineDoc, 10); + // text()[2] = the 2nd direct text node child of, i.e. the text after the
+ const pos0 = '/body/DocFragment[11]/body/p/text()[2].44';
+ const pos1 = '/body/DocFragment[11]/body/p/text()[2].69';
+ const cfi = converter.xPointerToCFI(pos0, pos1);
+
+ // Should produce a valid range CFI pointing into the 3rd child node (text after )
+ expect(cfi).toMatch(/^epubcfi\(/);
+ expect(cfi).toMatch(/,.*,/); // Range CFI has two commas
+ // /3 = 3rd child of (1:text, 2:, 3:text), offsets 44 and 69
+ expect(cfi).toContain('/3:44');
+ expect(cfi).toContain('/3:69');
+ });
+
+ it('should convert text()[1].offset XPointer to valid CFI', () => {
+ const converter = new XCFI(inlineDoc, 10);
+ // text()[1] = the 1st direct text node child of , i.e. text before the
+ const xp = '/body/DocFragment[11]/body/p/text()[1].5';
+ const cfi = converter.xPointerToCFI(xp);
+
+ expect(cfi).toMatch(/^epubcfi\(/);
+ // /1 = 1st child of (text node), offset 5
+ expect(cfi).toContain('/1:5');
+ });
+
+ it('should handle text()[N].offset with multiple inline elements', () => {
+ const multiInlineDoc = new DOMParser().parseFromString(
+ ` Start text emphasis middle text link end text here. : text, , text, , text
+ // text()[3] = " end text here." (3rd direct text node of )
+ const xp = '/body/DocFragment[6]/body/p/text()[3].4';
+ const cfi = converter.xPointerToCFI(xp);
+
+ expect(cfi).toMatch(/^epubcfi\(/);
+ // /5 = 5th child of (1:text, 2:, 3:text, 4:, 5:text), offset 4
+ expect(cfi).toContain('/5:4');
+ });
+
+ it('should produce correct CFI for text()[1] with no inline siblings', () => {
+ const simpleDoc = new DOMParser().parseFromString(
+ ` Hello world Alice thought this a very curious thing. Alice thought this a very curious thing. via DOM, then build XPointer from it
+ const p = doc.querySelector('p')!;
+ // Use xPointerToCFI and verify the KOReader XPointer resolves correctly
+ const koXp = '/body/DocFragment[11]/body/div/div/div/p/text().10';
+ const cfi = converter.xPointerToCFI(koXp);
+
+ // Verify the same CFI is produced when starting from a Readest-generated range
+ const range = doc.createRange();
+ const textNode = p.firstChild!;
+ range.setStart(textNode, 10);
+ range.setEnd(textNode, 10);
+ // The xPointerToCFI should resolve through the content div, not the cfi-inert div
+ expect(cfi).toMatch(/^epubcfi\(/);
+ });
+
+ it('should handle cfi-inert with multiple real siblings', () => {
+ const doc = new DOMParser().parseFromString(
+ ` Content