@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.
- package/README.md +4 -2
- package/package.json +2 -2
- package/src/index.ts +1 -2
- package/src/lib/folio.ts +23 -9
- package/src/lib/glossary/batch.ts +35 -0
- package/src/lib/glossary/index.ts +4 -0
- package/src/lib/glossary/instance.ts +54 -0
- package/src/lib/glossary/landing.ts +50 -0
- package/src/lib/glossary/pagination.ts +471 -0
- package/src/lib/passage/batch.ts +229 -0
- package/src/lib/passage/index.ts +5 -0
- package/src/lib/passage/pagination.ts +277 -0
- package/src/lib/passage/read.ts +100 -0
- package/src/lib/passage/replace-persistence.ts +153 -0
- package/src/lib/passage/save.ts +324 -0
- package/src/lib/publications.ts +168 -0
- package/src/lib/replace.spec.ts +154 -0
- package/src/lib/replace.ts +244 -0
- package/src/lib/types/folio.ts +2 -2
- package/src/lib/types/glossary-page.ts +0 -118
- package/src/lib/types/glossary.ts +3 -2
- package/src/lib/types/index.ts +0 -3
- package/src/lib/canon.ts +0 -111
- package/src/lib/glossary.ts +0 -147
- package/src/lib/passage.ts +0 -122
- package/src/lib/projects.ts +0 -107
- package/src/lib/types/canon.ts +0 -148
- package/src/lib/types/contributor.ts +0 -84
- package/src/lib/types/project.ts +0 -200
|
@@ -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
|
+
};
|
package/src/lib/publications.ts
CHANGED
|
@@ -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
|
+
});
|