@eightyfourthousand/data-access 2026.3.1 → 2026.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ import type { Annotation, Passage } from './types';
2
+ import {
3
+ findLiteralOccurrences,
4
+ getPassageOccurrences,
5
+ replacePassageText,
6
+ } from './replace';
7
+
8
+ const createAnnotation = ({
9
+ end,
10
+ start,
11
+ uuid,
12
+ }: {
13
+ end: number;
14
+ start: number;
15
+ uuid: string;
16
+ }): Annotation => ({
17
+ end,
18
+ start,
19
+ passageUuid: 'passage-1',
20
+ type: 'paragraph',
21
+ uuid,
22
+ });
23
+
24
+ const createPassage = (
25
+ content: string,
26
+ annotations: Annotation[] = [],
27
+ ): Passage => ({
28
+ annotations,
29
+ content,
30
+ label: '1.1',
31
+ sort: 1,
32
+ type: 'translation',
33
+ uuid: 'passage-1',
34
+ workUuid: 'work-1',
35
+ });
36
+
37
+ describe('findLiteralOccurrences', () => {
38
+ it('returns non-overlapping matches in order', () => {
39
+ expect(findLiteralOccurrences('aba aba aba', 'aba')).toEqual([
40
+ { start: 0, end: 3 },
41
+ { start: 4, end: 7 },
42
+ { start: 8, end: 11 },
43
+ ]);
44
+ });
45
+
46
+ it('returns no matches for an empty search string', () => {
47
+ expect(findLiteralOccurrences('abc', '')).toEqual([]);
48
+ });
49
+ });
50
+
51
+ describe('getPassageOccurrences', () => {
52
+ it('flattens matches across passages with stable indices', () => {
53
+ expect(
54
+ getPassageOccurrences(
55
+ [
56
+ { uuid: 'p1', content: 'foo foo' },
57
+ { uuid: 'p2', content: 'foo' },
58
+ ],
59
+ 'foo',
60
+ ),
61
+ ).toEqual([
62
+ { passageUuid: 'p1', start: 0, end: 3, index: 0, passageOccurrenceIndex: 0 },
63
+ { passageUuid: 'p1', start: 4, end: 7, index: 1, passageOccurrenceIndex: 1 },
64
+ { passageUuid: 'p2', start: 0, end: 3, index: 2, passageOccurrenceIndex: 0 },
65
+ ]);
66
+ });
67
+ });
68
+
69
+ describe('replacePassageText', () => {
70
+ it('replaces one indexed occurrence inside a single passage', () => {
71
+ const result = replacePassageText({
72
+ passage: createPassage('foo foo foo'),
73
+ searchText: 'foo',
74
+ replaceText: 'bar',
75
+ occurrenceIndex: 1,
76
+ });
77
+
78
+ expect(result.passage.content).toBe('foo bar foo');
79
+ expect(result.replacementsApplied).toBe(1);
80
+ });
81
+
82
+ it('replaces all occurrences when no occurrence index is provided', () => {
83
+ const result = replacePassageText({
84
+ passage: createPassage('foo foo'),
85
+ searchText: 'foo',
86
+ replaceText: 'x',
87
+ });
88
+
89
+ expect(result.passage.content).toBe('x x');
90
+ expect(result.replacementsApplied).toBe(2);
91
+ });
92
+
93
+ it('preserves an annotation spanning across replaced text', () => {
94
+ const result = replacePassageText({
95
+ passage: createPassage('0123456789', [
96
+ createAnnotation({ uuid: 'a1', start: 1, end: 9 }),
97
+ ]),
98
+ searchText: '345',
99
+ replaceText: 'XX',
100
+ });
101
+
102
+ expect(result.passage.annotations[0]).toMatchObject({ start: 1, end: 8 });
103
+ expect(result.updatedAnnotationCount).toBe(1);
104
+ });
105
+
106
+ it('clamps an annotation that overlaps the left edge of the replaced range', () => {
107
+ const result = replacePassageText({
108
+ passage: createPassage('0123456789', [
109
+ createAnnotation({ uuid: 'a1', start: 2, end: 4 }),
110
+ ]),
111
+ searchText: '345',
112
+ replaceText: 'XYZ',
113
+ });
114
+
115
+ expect(result.passage.annotations[0]).toMatchObject({ start: 2, end: 6 });
116
+ });
117
+
118
+ it('shifts annotations that occur entirely after the replaced text', () => {
119
+ const result = replacePassageText({
120
+ passage: createPassage('0123456789', [
121
+ createAnnotation({ uuid: 'a1', start: 8, end: 10 }),
122
+ ]),
123
+ searchText: '34',
124
+ replaceText: 'ABCDE',
125
+ });
126
+
127
+ expect(result.passage.annotations[0]).toMatchObject({ start: 11, end: 13 });
128
+ });
129
+
130
+ it('deletes an annotation that is fully removed by an empty replacement', () => {
131
+ const result = replacePassageText({
132
+ passage: createPassage('abc', [
133
+ createAnnotation({ uuid: 'a1', start: 0, end: 3 }),
134
+ ]),
135
+ searchText: 'abc',
136
+ replaceText: '',
137
+ });
138
+
139
+ expect(result.passage.annotations).toEqual([]);
140
+ expect(result.deletedAnnotationCount).toBe(1);
141
+ });
142
+
143
+ it('maps an annotation contained within the replaced range onto the inserted replacement', () => {
144
+ const result = replacePassageText({
145
+ passage: createPassage('abc def ghi', [
146
+ createAnnotation({ uuid: 'a1', start: 4, end: 7 }),
147
+ ]),
148
+ searchText: 'def',
149
+ replaceText: 'uvwxyz',
150
+ });
151
+
152
+ expect(result.passage.annotations[0]).toMatchObject({ start: 4, end: 10 });
153
+ });
154
+ });
@@ -0,0 +1,244 @@
1
+ import type { Annotation, Passage } from './types';
2
+
3
+ export interface PassageOccurrence {
4
+ end: number;
5
+ index: number;
6
+ passageOccurrenceIndex: number;
7
+ passageUuid: string;
8
+ start: number;
9
+ type: 'passage' | 'alignment';
10
+ }
11
+
12
+ export interface PassageReplacementResult {
13
+ deletedAnnotationCount: number;
14
+ nextSearchStart?: number;
15
+ passage: Passage;
16
+ replacementsApplied: number;
17
+ updatedAnnotationCount: number;
18
+ }
19
+
20
+ interface RemapAnnotationsResult {
21
+ annotations: Annotation[];
22
+ deletedAnnotationCount: number;
23
+ updatedAnnotationCount: number;
24
+ }
25
+
26
+ export const findLiteralOccurrences = (
27
+ content: string,
28
+ searchText: string,
29
+ ): Array<{ end: number; start: number }> => {
30
+ if (!searchText) {
31
+ return [];
32
+ }
33
+
34
+ const matches: Array<{ end: number; start: number }> = [];
35
+ let fromIndex = 0;
36
+
37
+ while (fromIndex <= content.length - searchText.length) {
38
+ const start = content.indexOf(searchText, fromIndex);
39
+ if (start === -1) {
40
+ break;
41
+ }
42
+
43
+ matches.push({
44
+ start,
45
+ end: start + searchText.length,
46
+ });
47
+ fromIndex = start + searchText.length;
48
+ }
49
+
50
+ return matches;
51
+ };
52
+
53
+ export const getPassageOccurrences = (
54
+ passages: { content: string; uuid: string; type: 'passage' | 'alignment' }[],
55
+ searchText: string,
56
+ ): PassageOccurrence[] => {
57
+ let index = 0;
58
+
59
+ return passages.flatMap((passage) =>
60
+ findLiteralOccurrences(passage.content, searchText).map((match, passageIndex) => ({
61
+ ...match,
62
+ index: index++,
63
+ passageOccurrenceIndex: passageIndex,
64
+ passageUuid: passage.uuid,
65
+ type: passage.type,
66
+ })),
67
+ );
68
+ };
69
+
70
+ const mapEditPosition = ({
71
+ delta,
72
+ editEnd,
73
+ editStart,
74
+ position,
75
+ replacementLength,
76
+ stickTo,
77
+ }: {
78
+ delta: number;
79
+ editEnd: number;
80
+ editStart: number;
81
+ position: number;
82
+ replacementLength: number;
83
+ stickTo: 'end' | 'start';
84
+ }) => {
85
+ if (position <= editStart) {
86
+ return position;
87
+ }
88
+
89
+ if (position >= editEnd) {
90
+ return position + delta;
91
+ }
92
+
93
+ return stickTo === 'start' ? editStart : editStart + replacementLength;
94
+ };
95
+
96
+ const remapAnnotationsForEdit = ({
97
+ annotations,
98
+ editEnd,
99
+ editStart,
100
+ replacementLength,
101
+ }: {
102
+ annotations: Annotation[];
103
+ editEnd: number;
104
+ editStart: number;
105
+ replacementLength: number;
106
+ }): RemapAnnotationsResult => {
107
+ const delta = replacementLength - (editEnd - editStart);
108
+ const nextAnnotations: Annotation[] = [];
109
+ let deletedAnnotationCount = 0;
110
+ let updatedAnnotationCount = 0;
111
+
112
+ for (const annotation of annotations) {
113
+ if (annotation.end <= editStart) {
114
+ nextAnnotations.push(annotation);
115
+ continue;
116
+ }
117
+
118
+ if (annotation.start >= editEnd) {
119
+ nextAnnotations.push({
120
+ ...annotation,
121
+ start: annotation.start + delta,
122
+ end: annotation.end + delta,
123
+ });
124
+ updatedAnnotationCount++;
125
+ continue;
126
+ }
127
+
128
+ const nextStart = mapEditPosition({
129
+ delta,
130
+ editEnd,
131
+ editStart,
132
+ position: annotation.start,
133
+ replacementLength,
134
+ stickTo: 'start',
135
+ });
136
+ const nextEnd = mapEditPosition({
137
+ delta,
138
+ editEnd,
139
+ editStart,
140
+ position: annotation.end,
141
+ replacementLength,
142
+ stickTo: 'end',
143
+ });
144
+
145
+ if (nextStart === nextEnd && replacementLength === 0) {
146
+ deletedAnnotationCount++;
147
+ continue;
148
+ }
149
+
150
+ if (nextStart !== annotation.start || nextEnd !== annotation.end) {
151
+ updatedAnnotationCount++;
152
+ nextAnnotations.push({
153
+ ...annotation,
154
+ start: nextStart,
155
+ end: nextEnd,
156
+ });
157
+ continue;
158
+ }
159
+
160
+ nextAnnotations.push(annotation);
161
+ }
162
+
163
+ return {
164
+ annotations: nextAnnotations,
165
+ deletedAnnotationCount,
166
+ updatedAnnotationCount,
167
+ };
168
+ };
169
+
170
+ export const replacePassageText = ({
171
+ occurrenceIndex,
172
+ passage,
173
+ replaceText,
174
+ searchText,
175
+ }: {
176
+ occurrenceIndex?: number;
177
+ passage: Passage;
178
+ replaceText: string;
179
+ searchText: string;
180
+ }): PassageReplacementResult => {
181
+ if (!searchText || occurrenceIndex === null) {
182
+ return {
183
+ passage,
184
+ replacementsApplied: 0,
185
+ updatedAnnotationCount: 0,
186
+ deletedAnnotationCount: 0,
187
+ };
188
+ }
189
+
190
+ const matches = findLiteralOccurrences(passage.content, searchText);
191
+ const matchesToApply =
192
+ occurrenceIndex === undefined
193
+ ? matches
194
+ : matches.filter((_, index) => index === occurrenceIndex);
195
+
196
+ if (matchesToApply.length === 0) {
197
+ return {
198
+ passage,
199
+ replacementsApplied: 0,
200
+ updatedAnnotationCount: 0,
201
+ deletedAnnotationCount: 0,
202
+ };
203
+ }
204
+
205
+ let content = passage.content;
206
+ let annotations = passage.annotations.map((annotation) => ({ ...annotation }));
207
+ let cumulativeDelta = 0;
208
+ let updatedAnnotationCount = 0;
209
+ let deletedAnnotationCount = 0;
210
+ let nextSearchStart: number | undefined;
211
+
212
+ for (const match of matchesToApply) {
213
+ const editStart = match.start + cumulativeDelta;
214
+ const editEnd = match.end + cumulativeDelta;
215
+
216
+ content =
217
+ content.slice(0, editStart) + replaceText + content.slice(editEnd);
218
+
219
+ const remappedAnnotations = remapAnnotationsForEdit({
220
+ annotations,
221
+ editStart,
222
+ editEnd,
223
+ replacementLength: replaceText.length,
224
+ });
225
+
226
+ annotations = remappedAnnotations.annotations;
227
+ updatedAnnotationCount += remappedAnnotations.updatedAnnotationCount;
228
+ deletedAnnotationCount += remappedAnnotations.deletedAnnotationCount;
229
+ nextSearchStart = editStart + replaceText.length;
230
+ cumulativeDelta += replaceText.length - (match.end - match.start);
231
+ }
232
+
233
+ return {
234
+ passage: {
235
+ ...passage,
236
+ content,
237
+ annotations,
238
+ },
239
+ nextSearchStart,
240
+ replacementsApplied: matchesToApply.length,
241
+ updatedAnnotationCount,
242
+ deletedAnnotationCount,
243
+ };
244
+ };
@@ -3,7 +3,7 @@ export type FolioSide = (typeof FOLIO_SIDES)[number];
3
3
 
4
4
  export type FolioDTO = {
5
5
  folio_uuid: string;
6
- content: string;
6
+ content?: string;
7
7
  volume_number: number;
8
8
  folio_number: number;
9
9
  side: FolioSide;
@@ -20,7 +20,7 @@ export type Folio = {
20
20
  export const folioFromDTO = (dto: FolioDTO): Folio => {
21
21
  return {
22
22
  uuid: dto.folio_uuid,
23
- content: dto.content,
23
+ content: dto.content || '',
24
24
  volume: dto.volume_number,
25
25
  folio: dto.folio_number,
26
26
  side: dto.side,
@@ -62,40 +62,6 @@ export type GlossaryLandingItemDTO = {
62
62
  num_glossary_entries?: number;
63
63
  };
64
64
 
65
- export type GlossaryDetailDTO = {
66
- authority_uuid: string;
67
- xmlId?: string;
68
- headword: string;
69
- headword_language?: GlossaryPageLanguage | null;
70
- classifications?: string[] | null;
71
- definition?: string | null;
72
- tibetan_names?: string[] | null;
73
- sanskrit_names?: string[] | null;
74
- pali_names?: string[] | null;
75
- chinese_names?: string[] | null;
76
- english_names?: string[] | null;
77
- };
78
-
79
- export type GlossaryEntityDTO = {
80
- related_entity_object_headword: string;
81
- related_entity_object_uuid: string;
82
- related_entity_subject_headword: string;
83
- related_entity_subject_uuid: string;
84
- };
85
-
86
- export type GlossaryInstanceDTO = {
87
- glossary_entry_work_uuid: string;
88
- glossary_entry_toh: TohokuCatalogEntry;
89
- glossary_entry_english?: string | null;
90
- glossary_entry_tibetan?: string | null;
91
- glossary_entry_sanskrit?: string | null;
92
- glossary_entry_chinese?: string | null;
93
- glossary_entry_pali?: string | null;
94
- glossary_entry_definition?: string | null;
95
- glossary_entry_canon?: string | null;
96
- glossary_entry_creators?: string[] | null;
97
- };
98
-
99
65
  export const glossaryLandingItemFromDTO = (
100
66
  dto?: GlossaryLandingItemDTO,
101
67
  ): GlossaryLandingItem | null => {
@@ -118,87 +84,3 @@ export const glossaryLandingItemFromDTO = (
118
84
  numGlossaryEntries: dto.num_glossary_entries || 0,
119
85
  };
120
86
  };
121
-
122
- export const glossaryDetailFromDTO = (
123
- dto?: GlossaryDetailDTO,
124
- ): GlossaryDetailItem | null => {
125
- if (!dto) {
126
- return null;
127
- }
128
-
129
- return {
130
- authorityUuid: dto.authority_uuid,
131
- headword: dto.headword,
132
- language: dto.headword_language as GlossaryPageLanguage,
133
- classifications: dto.classifications || [],
134
- definition: dto.definition || undefined,
135
- xmlId: dto.xmlId,
136
- english: dto.english_names || [],
137
- tibetan: dto.tibetan_names || [],
138
- sanskrit: dto.sanskrit_names || [],
139
- pali: dto.pali_names || [],
140
- chinese: dto.chinese_names || [],
141
- };
142
- };
143
-
144
- export const glossaryInstanceFromDTO = (
145
- dto?: GlossaryInstanceDTO,
146
- ): GlossaryInstance | null => {
147
- if (!dto) {
148
- return null;
149
- }
150
-
151
- return {
152
- workUuid: dto.glossary_entry_work_uuid,
153
- toh: dto.glossary_entry_toh,
154
- definition: dto.glossary_entry_definition || undefined,
155
- canon: dto.glossary_entry_canon || undefined,
156
- english: dto.glossary_entry_english ? [dto.glossary_entry_english] : [],
157
- tibetan: dto.glossary_entry_tibetan ? [dto.glossary_entry_tibetan] : [],
158
- sanskrit: dto.glossary_entry_sanskrit ? [dto.glossary_entry_sanskrit] : [],
159
- pali: dto.glossary_entry_pali ? [dto.glossary_entry_pali] : [],
160
- chinese: dto.glossary_entry_chinese ? [dto.glossary_entry_chinese] : [],
161
- creators: dto.glossary_entry_creators || [],
162
- };
163
- };
164
-
165
- export const glossaryEntityFromDTO = (
166
- dto?: GlossaryEntityDTO,
167
- ): GlossaryEntity | null => {
168
- if (!dto) {
169
- return null;
170
- }
171
-
172
- return {
173
- sourceHeadword: dto.related_entity_subject_headword,
174
- sourceUuid: dto.related_entity_subject_uuid,
175
- targetHeadword: dto.related_entity_object_headword,
176
- targetUuid: dto.related_entity_object_uuid,
177
- };
178
- };
179
-
180
- export const glossaryPageItemFromDTO = (
181
- detail: GlossaryDetailDTO,
182
- instances: GlossaryInstanceDTO[],
183
- entities: GlossaryEntityDTO[],
184
- ): GlossaryPageItem => {
185
- const detailItem = glossaryDetailFromDTO(detail);
186
- if (!detailItem) {
187
- throw new Error('Invalid glossary detail item');
188
- }
189
-
190
- const relatedInstances =
191
- instances
192
- .map(glossaryInstanceFromDTO)
193
- .filter((i): i is GlossaryInstance => i !== null) || [];
194
- const relatedEntities =
195
- entities
196
- .map(glossaryEntityFromDTO)
197
- .filter((e): e is GlossaryEntity => e !== null) || [];
198
-
199
- return {
200
- ...detailItem,
201
- relatedInstances,
202
- relatedEntities,
203
- };
204
- };
@@ -1,4 +1,3 @@
1
-
2
1
  export type GlossaryItem = {
3
2
  authorityUuid: string;
4
3
  definition?: string | null;
@@ -46,12 +45,13 @@ export type GlossaryTermInstance = {
46
45
  sanskrit?: string;
47
46
  tibetan?: string;
48
47
  wylie?: string;
48
+ alternatives?: string;
49
49
  };
50
50
  passages?: {
51
51
  items: Array<{ uuid: string; type: string; label: string }>;
52
52
  nextCursor: string | null;
53
53
  hasMore: boolean;
54
- }
54
+ };
55
55
  };
56
56
 
57
57
  export type GlossaryTermInstances = GlossaryTermInstance[];
@@ -68,6 +68,7 @@ export type GlossaryTermInstanceDTO = {
68
68
  sanskrit?: string;
69
69
  tibetan?: string;
70
70
  wylie?: string;
71
+ alternatives?: string;
71
72
  };
72
73
  };
73
74
 
@@ -1,9 +1,7 @@
1
1
  export * from './alignment';
2
2
  export * from './annotation';
3
3
  export * from './bibliography';
4
- export * from './canon';
5
4
  export * from './client';
6
- export * from './contributor';
7
5
  export * from './editor-content';
8
6
  export * from './toh';
9
7
  export * from './folio';
@@ -14,7 +12,6 @@ export * from './language';
14
12
  export * from './layout';
15
13
  export * from './library';
16
14
  export * from './passage';
17
- export * from './project';
18
15
  export * from './semver';
19
16
  export * from './title';
20
17
  export * from './translation';
package/src/lib/canon.ts DELETED
@@ -1,111 +0,0 @@
1
- import {
2
- CanonDTO,
3
- CanonDetailDTO,
4
- CanonNode,
5
- CanonWorksDTO,
6
- DataClient,
7
- canonDetailFromDTO,
8
- canonNodeFromDTO,
9
- canonTreeFromDTOs,
10
- caononWorksFromDTO,
11
- } from './types';
12
-
13
- export const getCanonSections = async ({
14
- client,
15
- }: {
16
- client: DataClient;
17
- }): Promise<CanonNode[]> => {
18
- const { data, error } = await client.rpc('scholar_canon_get_all');
19
-
20
- if (error) {
21
- console.error('Error fetching canon sections:', error);
22
- return [];
23
- }
24
-
25
- return data.map((item: CanonDTO) => canonNodeFromDTO(item));
26
- };
27
-
28
- export const getCanonTree = async ({ client }: { client: DataClient }) => {
29
- const { data, error } = await client.rpc('scholar_canon_get_all');
30
-
31
- if (error) {
32
- console.error('Error fetching canon tree:', error);
33
- return null;
34
- }
35
-
36
- return canonTreeFromDTOs(data as CanonDTO[]);
37
- };
38
-
39
- export const getCanonSection = async ({
40
- client,
41
- uuid,
42
- }: {
43
- client: DataClient;
44
- uuid: string;
45
- }) => {
46
- const { data, error } = await client.rpc('scholar_canon_get_detail', {
47
- uuid_input: uuid,
48
- });
49
-
50
- if (error) {
51
- console.error('Error fetching canon section:', error);
52
- return null;
53
- }
54
-
55
- return canonDetailFromDTO(data[0] as CanonDetailDTO);
56
- };
57
-
58
- export const getCanonWorks = async ({
59
- client,
60
- uuids,
61
- }: {
62
- client: DataClient;
63
- uuids: string[];
64
- }) => {
65
- const { data, error } = await client
66
- .rpc('scholar_canon_get_works', {
67
- uuids_input: uuids,
68
- })
69
- .single();
70
-
71
- if (error) {
72
- console.error('Error fetching canon works:', error);
73
- return null;
74
- }
75
-
76
- return caononWorksFromDTO(data as CanonWorksDTO);
77
- };
78
-
79
- /** Travers the canon tree to find a node by its UUID */
80
- export const findNodeByUuid = (
81
- head: CanonNode[],
82
- uuid: string,
83
- ): CanonNode | null => {
84
- for (const node of head) {
85
- if (node.uuid === uuid) {
86
- return node;
87
- }
88
- if (node.children) {
89
- const found = findNodeByUuid(node.children, uuid);
90
- if (found) {
91
- return found;
92
- }
93
- }
94
- }
95
- return null;
96
- };
97
-
98
- /** Flatten the canon tree into a list of nodes */
99
- export const flattenCanonTree = (head: CanonNode): CanonNode[] => {
100
- const flatList: CanonNode[] = [head];
101
-
102
- const traverse = (node: CanonNode) => {
103
- flatList.push(node);
104
- if (node.children) {
105
- node.children.forEach(traverse);
106
- }
107
- };
108
-
109
- head.children?.forEach(traverse);
110
- return flatList;
111
- };