@eightyfourthousand/data-access 2026.3.0 → 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,324 @@
1
+ import {
2
+ ANNOTATIONS_TO_IGNORE,
3
+ DataClient,
4
+ Passage,
5
+ passagesToDTO,
6
+ passagesToRowDTO,
7
+ } from '../types';
8
+
9
+ const SAVE_PAGE_SIZE = 500;
10
+
11
+ export const savePassages = async ({
12
+ client,
13
+ passages,
14
+ }: {
15
+ client: DataClient;
16
+ passages: Passage[];
17
+ }) => {
18
+ /**
19
+ * 1. Extract all annotations from savePassages
20
+ * 2. Query the database for all current annotations for the savePassages
21
+ * 3. Determine which annotations need to be deleted or upserted
22
+ * 4. Upsert the passages
23
+ * 5. Upsert the annotations
24
+ * 6. Delete the annotations that are no longer present
25
+ */
26
+ const dtos = passagesToDTO(passages);
27
+ const passageRowDtos = passagesToRowDTO(passages);
28
+ const passageUuids = passages.map((p) => p.uuid);
29
+ const annotations = dtos.flatMap((p) => p.annotations || []);
30
+
31
+ const { data: existingAnnotations } = await client
32
+ .from('passage_annotations')
33
+ .select(`uuid`)
34
+ .in('passage_uuid', passageUuids)
35
+ .not('type', 'in', `(${ANNOTATIONS_TO_IGNORE.join(',')})`);
36
+
37
+ const annotationsToDelete = existingAnnotations?.filter(
38
+ (ea) => !annotations.find((a) => a.uuid === ea.uuid),
39
+ );
40
+
41
+ const { error: passageError } = await client
42
+ .from('passages')
43
+ .upsert(passageRowDtos, { onConflict: 'uuid' });
44
+
45
+ if (passageError) {
46
+ console.error('Error saving passages:', passageError);
47
+ return;
48
+ }
49
+
50
+ if (annotations.length > 0) {
51
+ const { error: annotationError } = await client
52
+ .from('passage_annotations')
53
+ .upsert(annotations, { onConflict: 'uuid' });
54
+
55
+ if (annotationError) {
56
+ console.error('Error saving annotations:', annotationError);
57
+ throw annotationError;
58
+ }
59
+ }
60
+
61
+ if (annotationsToDelete && annotationsToDelete.length > 0) {
62
+ const { error: deleteError } = await client
63
+ .from('passage_annotations')
64
+ .delete()
65
+ .in(
66
+ 'uuid',
67
+ annotationsToDelete.map((a) => a.uuid),
68
+ );
69
+
70
+ if (deleteError) {
71
+ console.error('Error deleting annotations:', deleteError);
72
+ throw deleteError;
73
+ }
74
+ }
75
+ };
76
+
77
+ async function normalizePassageLabelsAfter({
78
+ client,
79
+ workUuid,
80
+ fromSort,
81
+ fromLabel,
82
+ delta,
83
+ }: {
84
+ client: DataClient;
85
+ workUuid: string;
86
+ fromSort: number;
87
+ fromLabel: string;
88
+ delta: number;
89
+ }): Promise<void> {
90
+ const parts = fromLabel.split('.');
91
+ const depth = parts.length;
92
+ const prefix = depth > 1 ? `${parts.slice(0, -1).join('.')}.` : '';
93
+ let nextInt = Number.parseInt(parts[depth - 1], 10) + Math.max(delta, 0);
94
+ let lastSort = fromSort;
95
+ let done = false;
96
+
97
+ while (!done) {
98
+ const { data, error } = await client
99
+ .from('passages')
100
+ .select('uuid, label, sort')
101
+ .eq('work_uuid', workUuid)
102
+ .gt('sort', lastSort)
103
+ .order('sort', { ascending: true })
104
+ .limit(SAVE_PAGE_SIZE);
105
+
106
+ if (error || !data || data.length === 0) break;
107
+
108
+ const labelUpdates: { uuid: string; label: string }[] = [];
109
+ const prefixRenames: { oldPrefix: string; newPrefix: string }[] = [];
110
+
111
+ for (const row of data) {
112
+ const rowParts = (row.label ?? '').split('.');
113
+
114
+ if (rowParts.length < depth || !row.label?.startsWith(prefix)) {
115
+ done = true;
116
+ break;
117
+ }
118
+
119
+ if (rowParts.length > depth) continue;
120
+
121
+ const expectedLabel = prefix + nextInt;
122
+ if (row.label === expectedLabel) {
123
+ done = true;
124
+ break;
125
+ }
126
+
127
+ labelUpdates.push({ uuid: row.uuid, label: expectedLabel });
128
+ prefixRenames.push({
129
+ oldPrefix: `${row.label}.`,
130
+ newPrefix: `${expectedLabel}.`,
131
+ });
132
+ nextInt++;
133
+ }
134
+
135
+ if (labelUpdates.length > 0) {
136
+ const { error: upsertError } = await client
137
+ .from('passages')
138
+ .upsert(labelUpdates, { onConflict: 'uuid' });
139
+ if (upsertError) {
140
+ console.error('Error normalizing passage labels:', upsertError);
141
+ }
142
+
143
+ for (const { oldPrefix, newPrefix } of prefixRenames) {
144
+ const { error: prefixError } = await client.rpc(
145
+ 'rename_passage_label_prefix',
146
+ {
147
+ p_work_uuid: workUuid,
148
+ p_old_prefix: oldPrefix,
149
+ p_new_prefix: newPrefix,
150
+ },
151
+ );
152
+ if (prefixError) {
153
+ console.error('Error renaming passage label prefix:', prefixError);
154
+ }
155
+ }
156
+ }
157
+
158
+ if (done || data.length < SAVE_PAGE_SIZE) break;
159
+ lastSort = data[data.length - 1].sort;
160
+ }
161
+ }
162
+
163
+ export type SavePassagesWithDeletionsResult = {
164
+ success: boolean;
165
+ savedCount: number;
166
+ deletedCount?: number;
167
+ error?: string;
168
+ };
169
+
170
+ export const savePassagesWithDeletions = async ({
171
+ client,
172
+ passages,
173
+ deletedUuids = [],
174
+ }: {
175
+ client: DataClient;
176
+ passages: Passage[];
177
+ deletedUuids?: string[];
178
+ }): Promise<SavePassagesWithDeletionsResult> => {
179
+ const inputUuids = passages.map((p) => p.uuid);
180
+ const { data: existingRows } = await client
181
+ .from('passages')
182
+ .select('uuid')
183
+ .in('uuid', inputUuids);
184
+ const existingUuidSet = new Set((existingRows ?? []).map((r) => r.uuid));
185
+ const newPassages = passages.filter((p) => !existingUuidSet.has(p.uuid));
186
+ const sortedNewPassages = [...newPassages].sort((a, b) => b.sort - a.sort);
187
+
188
+ for (const passage of sortedNewPassages) {
189
+ const { error } = await client.rpc('shift_passage_sorts', {
190
+ p_work_uuid: passage.workUuid,
191
+ p_from_sort: passage.sort,
192
+ p_delta: 1,
193
+ });
194
+ if (error) {
195
+ console.error('Error shifting passage sorts:', error);
196
+ }
197
+ }
198
+
199
+ for (const passage of sortedNewPassages) {
200
+ await normalizePassageLabelsAfter({
201
+ client,
202
+ workUuid: passage.workUuid,
203
+ fromSort: passage.sort,
204
+ fromLabel: passage.label,
205
+ delta: 1,
206
+ });
207
+ }
208
+
209
+ let deletedCount = 0;
210
+ if (deletedUuids.length > 0) {
211
+ const { data: deletedPassages } = await client
212
+ .from('passages')
213
+ .select('uuid, sort, label, work_uuid')
214
+ .in('uuid', deletedUuids);
215
+
216
+ if (deletedPassages && deletedPassages.length > 0) {
217
+ const sortedDeleted = [...deletedPassages].sort(
218
+ (a, b) => b.sort - a.sort,
219
+ );
220
+ for (const deletedPassage of sortedDeleted) {
221
+ await normalizePassageLabelsAfter({
222
+ client,
223
+ workUuid: deletedPassage.work_uuid,
224
+ fromSort: deletedPassage.sort,
225
+ fromLabel: deletedPassage.label,
226
+ delta: -1,
227
+ });
228
+ }
229
+
230
+ const { error: deleteAnnotationsError } = await client
231
+ .from('passage_annotations')
232
+ .delete()
233
+ .in('passage_uuid', deletedUuids);
234
+
235
+ if (deleteAnnotationsError) {
236
+ console.error(
237
+ 'Error deleting annotations for deleted passages:',
238
+ deleteAnnotationsError,
239
+ );
240
+ }
241
+
242
+ const { error: deletePassagesError } = await client
243
+ .from('passages')
244
+ .delete()
245
+ .in('uuid', deletedUuids);
246
+
247
+ if (deletePassagesError) {
248
+ console.error('Error deleting passages:', deletePassagesError);
249
+ return {
250
+ success: false,
251
+ savedCount: 0,
252
+ error: `Failed to delete passages: ${deletePassagesError.message}`,
253
+ };
254
+ }
255
+
256
+ deletedCount = deletedPassages.length;
257
+ }
258
+ }
259
+
260
+ const dtos = passagesToDTO(passages);
261
+ const passageRowDtos = passagesToRowDTO(passages);
262
+ const passageUuids = passages.map((p) => p.uuid);
263
+ const annotations = dtos.flatMap((p) => p.annotations || []);
264
+ const { data: existingAnnotations } = await client
265
+ .from('passage_annotations')
266
+ .select('uuid')
267
+ .in('passage_uuid', passageUuids)
268
+ .not('type', 'in', `(${ANNOTATIONS_TO_IGNORE.join(',')})`);
269
+
270
+ const annotationsToDelete = existingAnnotations?.filter(
271
+ (existingAnnotation) =>
272
+ !annotations.find(
273
+ (annotation) => annotation.uuid === existingAnnotation.uuid,
274
+ ),
275
+ );
276
+
277
+ const { error: passageError } = await client
278
+ .from('passages')
279
+ .upsert(passageRowDtos, { onConflict: 'uuid' });
280
+
281
+ if (passageError) {
282
+ console.error('Error saving passages:', passageError);
283
+ return {
284
+ success: false,
285
+ savedCount: 0,
286
+ error: `Failed to save passages: ${passageError.message}`,
287
+ };
288
+ }
289
+
290
+ if (annotations.length > 0) {
291
+ const { error: annotationError } = await client
292
+ .from('passage_annotations')
293
+ .upsert(annotations, { onConflict: 'uuid' });
294
+
295
+ if (annotationError) {
296
+ console.error('Error saving annotations:', annotationError);
297
+ return {
298
+ success: false,
299
+ savedCount: passages.length,
300
+ error: `Passages saved but annotations failed: ${annotationError.message}`,
301
+ };
302
+ }
303
+ }
304
+
305
+ if (annotationsToDelete && annotationsToDelete.length > 0) {
306
+ const { error: deleteError } = await client
307
+ .from('passage_annotations')
308
+ .delete()
309
+ .in(
310
+ 'uuid',
311
+ annotationsToDelete.map((annotation) => annotation.uuid),
312
+ );
313
+
314
+ if (deleteError) {
315
+ console.error('Error deleting annotations:', deleteError);
316
+ }
317
+ }
318
+
319
+ return {
320
+ success: true,
321
+ savedCount: passages.length,
322
+ deletedCount,
323
+ };
324
+ };
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  BodyItemType,
3
3
  DataClient,
4
+ Title,
4
5
  TitlesDTO,
5
6
  WorkDTO,
6
7
  titlesFromDTO,
@@ -14,6 +15,19 @@ import {
14
15
  Work,
15
16
  } from './types';
16
17
 
18
+ type WorksPageInfo = {
19
+ nextCursor: string | null;
20
+ prevCursor: string | null;
21
+ hasMoreAfter: boolean;
22
+ hasMoreBefore: boolean;
23
+ };
24
+
25
+ export type WorksPage = {
26
+ items: Work[];
27
+ pageInfo: WorksPageInfo;
28
+ totalCount: number;
29
+ };
30
+
17
31
  export const getTranslationUuids = async ({
18
32
  client,
19
33
  }: {
@@ -23,6 +37,29 @@ export const getTranslationUuids = async ({
23
37
  return data?.map(({ uuid }: { uuid: string }) => uuid) || [];
24
38
  };
25
39
 
40
+ export const getWorkUuidByToh = async ({
41
+ client,
42
+ toh,
43
+ }: {
44
+ client: DataClient;
45
+ toh: string;
46
+ }): Promise<string | null> => {
47
+ const { data, error } = await client
48
+ .from('work_toh')
49
+ .select('work_uuid')
50
+ .eq('toh_clean', toh)
51
+ .single();
52
+
53
+ if (error || !data) {
54
+ if (error) {
55
+ console.error('Error fetching work UUID by TOH:', error);
56
+ }
57
+ return null;
58
+ }
59
+
60
+ return data.work_uuid ?? null;
61
+ };
62
+
26
63
  export const getTranslationPassages = async ({
27
64
  client,
28
65
  uuid,
@@ -110,6 +147,54 @@ export const getTranslationTitles = async ({
110
147
  return titlesFromDTO(data as TitlesDTO);
111
148
  };
112
149
 
150
+ export const getWorkTitles = async ({
151
+ client,
152
+ uuid,
153
+ }: {
154
+ client: DataClient;
155
+ uuid: string;
156
+ }): Promise<Title[]> => {
157
+ const { data, error } = await client.rpc('get_work_titles', {
158
+ work_uuid_input: uuid,
159
+ });
160
+
161
+ if (error) {
162
+ console.error('Error fetching work titles:', error);
163
+ return [];
164
+ }
165
+
166
+ return titlesFromDTO(data as TitlesDTO);
167
+ };
168
+
169
+ export const getWorkTitlesByUuids = async ({
170
+ client,
171
+ uuids,
172
+ }: {
173
+ client: DataClient;
174
+ uuids: readonly string[];
175
+ }): Promise<Map<string, string>> => {
176
+ const titlesByUuid = new Map<string, string>();
177
+ if (uuids.length === 0) return titlesByUuid;
178
+
179
+ const { data, error } = await client
180
+ .from('works')
181
+ .select('uuid, title')
182
+ .in('uuid', uuids as string[]);
183
+
184
+ if (error) {
185
+ console.error('Error batch loading work titles:', error);
186
+ return titlesByUuid;
187
+ }
188
+
189
+ for (const work of data ?? []) {
190
+ if (work.title) {
191
+ titlesByUuid.set(work.uuid, work.title);
192
+ }
193
+ }
194
+
195
+ return titlesByUuid;
196
+ };
197
+
113
198
  export const getTranslationMetadataByUuid = async ({
114
199
  client,
115
200
  uuid,
@@ -211,3 +296,86 @@ export const getTranslationsMetadata = async ({
211
296
  const dto = data as WorkDTO[];
212
297
  return dto?.map((work) => workFromDTO(work as WorkDTO)) || [];
213
298
  };
299
+
300
+ export const getWorksPage = async ({
301
+ client,
302
+ cursor,
303
+ limit = 50,
304
+ maxPages,
305
+ }: {
306
+ client: DataClient;
307
+ cursor?: string;
308
+ limit?: number;
309
+ maxPages?: number;
310
+ }): Promise<WorksPage> => {
311
+ const clampedLimit = Math.min(limit, 200);
312
+
313
+ let query = client
314
+ .from('works')
315
+ .select(
316
+ `
317
+ uuid,
318
+ title,
319
+ description,
320
+ tohs:work_toh!inner(toh:toh_clean),
321
+ publicationDate,
322
+ publicationVersion,
323
+ pages:source_pages,
324
+ restriction,
325
+ breadcrumb
326
+ `,
327
+ { count: 'exact' },
328
+ )
329
+ .not('toh', 'like', 'toh00%')
330
+ .order('title', { ascending: true })
331
+ .limit(clampedLimit + 1);
332
+
333
+ if (maxPages) {
334
+ query = query.lt('source_pages', maxPages);
335
+ }
336
+
337
+ if (cursor) {
338
+ const { data: cursorWork } = await client
339
+ .from('works')
340
+ .select('title')
341
+ .eq('uuid', cursor)
342
+ .single();
343
+
344
+ if (cursorWork) {
345
+ query = query.gt('title', cursorWork.title);
346
+ }
347
+ }
348
+
349
+ const { data, error, count } = await query;
350
+
351
+ if (error) {
352
+ console.error('Error fetching works:', error);
353
+ return {
354
+ items: [],
355
+ pageInfo: {
356
+ nextCursor: null,
357
+ prevCursor: null,
358
+ hasMoreAfter: false,
359
+ hasMoreBefore: false,
360
+ },
361
+ totalCount: 0,
362
+ };
363
+ }
364
+
365
+ const hasMoreAfter = (data ?? []).length > clampedLimit;
366
+ const items = hasMoreAfter
367
+ ? (data ?? []).slice(0, clampedLimit)
368
+ : (data ?? []);
369
+ const works = items.map((dto) => workFromDTO(dto as WorkDTO));
370
+
371
+ return {
372
+ items: works,
373
+ pageInfo: {
374
+ nextCursor: hasMoreAfter ? (works[works.length - 1]?.uuid ?? null) : null,
375
+ prevCursor: cursor ?? null,
376
+ hasMoreAfter,
377
+ hasMoreBefore: Boolean(cursor),
378
+ },
379
+ totalCount: count ?? 0,
380
+ };
381
+ };
@@ -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
+ });