mdld-parse 0.6.0 → 0.7.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/src/applyDiff.js DELETED
@@ -1,583 +0,0 @@
1
- import { parse } from './parse.js';
2
- import {
3
- shortenIRI,
4
- normalizeQuad,
5
- quadToKeyForOrigin,
6
- parseQuadIndexKey,
7
- findVacantSlot,
8
- occupySlot,
9
- markSlotAsVacant,
10
- normalizeAttrsTokens,
11
- writeAttrsTokens,
12
- removeOneToken,
13
- addObjectToken,
14
- removeObjectToken,
15
- addSoftFragmentToken,
16
- removeSoftFragmentToken,
17
- objectSignature,
18
- expandIRI,
19
- DataFactory
20
- } from './utils.js';
21
-
22
- function getBlockById(base, blockId) {
23
- return blockId ? base?.quadMap?.get(blockId) : null;
24
- }
25
-
26
- function getEntryByQuadKey(base, quadKey) {
27
- return quadKey ? base?.quadMap?.get(quadKey) : null;
28
- }
29
-
30
- // Helper functions for cleaner term type checking
31
- function isLiteral(term) {
32
- return term?.termType === 'Literal';
33
- }
34
-
35
- function isNamedNode(term) {
36
- return term?.termType === 'NamedNode';
37
- }
38
-
39
- function isRdfType(term) {
40
- return term?.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
41
- }
42
-
43
- function createAnnotationForQuad(quad, ctx) {
44
- const predShort = shortenIRI(quad.predicate.value, ctx);
45
- if (isLiteral(quad.object)) {
46
- const value = String(quad.object.value ?? '');
47
- const ann = createLiteralAnnotation(value, predShort, quad.object.language, quad.object.datatype, ctx);
48
- return { text: `[${value}] {${ann}}`, isLiteral: true };
49
- } else if (isNamedNode(quad.object)) {
50
- const objectShort = shortenIRI(quad.object.value, ctx);
51
- const objectAnn = createObjectAnnotation(objectShort, predShort);
52
- return { text: objectAnn, isLiteral: false };
53
- }
54
- return null;
55
- }
56
-
57
- function createSubjectBlockForQuad(quad, ctx) {
58
- const subjectShort = shortenIRI(quad.subject.value, ctx);
59
- const predShort = shortenIRI(quad.predicate.value, ctx);
60
- const subjectName = extractLocalName(quad.subject.value);
61
-
62
- if (isNamedNode(quad.object)) {
63
- // IRI object: create object reference
64
- const objectShort = shortenIRI(quad.object.value, ctx);
65
- return { text: `\n\n# ${subjectName.charAt(0).toUpperCase() + subjectName.slice(1)} {=${subjectShort}}\n[${objectShort}] {${predShort}}\n`, isNewSubject: true };
66
- } else {
67
- // Literal object: create property on separate line
68
- const value = String(quad.object.value ?? '');
69
- const annotation = createLiteralAnnotation(value, predShort, quad.object.language, quad.object.datatype, ctx);
70
- return { text: `\n\n# ${subjectName.charAt(0).toUpperCase() + subjectName.slice(1)} {=${subjectShort}}\n[${value}] {${annotation}}\n`, isNewSubject: true };
71
- }
72
- }
73
-
74
- function extractLocalName(iri) {
75
- return iri.split('/').pop() || iri.split('#').pop() || iri;
76
- }
77
-
78
- function isValidQuad(quad) {
79
- return quad && quad.subject && quad.predicate && quad.object;
80
- }
81
-
82
- function normalizeDiffQuads(quads, ctx) {
83
- // Use DataFactory.fromQuad for proper RDF/JS compatibility
84
- // But first expand any CURIEs in the quads to ensure proper matching
85
- return quads.map(quad => {
86
- // Expand CURIEs to full IRIs before normalization
87
- const expandedQuad = {
88
- subject: quad.subject.termType === 'NamedNode'
89
- ? { ...quad.subject, value: expandIRI(quad.subject.value, ctx) }
90
- : quad.subject,
91
- predicate: quad.predicate.termType === 'NamedNode'
92
- ? { ...quad.predicate, value: expandIRI(quad.predicate.value, ctx) }
93
- : quad.predicate,
94
- object: quad.object,
95
- graph: quad.graph
96
- };
97
- return DataFactory.fromQuad(expandedQuad);
98
- }).filter(isValidQuad);
99
- }
100
-
101
- function createLiteralAnnotation(value, predicate, language, datatype, ctx) {
102
- let ann = predicate;
103
- if (language) ann += ` @${language}`;
104
- else if (datatype?.value && datatype.value !== DataFactory.literal('').datatype.value) {
105
- ann += ` ^^${shortenIRI(datatype.value, ctx)}`;
106
- }
107
- return ann;
108
- }
109
-
110
- function createObjectAnnotation(objectShort, predicateShort, isSoftFragment = false, fragment = null) {
111
- if (isSoftFragment) {
112
- return `[${objectShort}] {+#${fragment} ?${predicateShort}}`;
113
- }
114
- return `[${objectShort}] {+${objectShort} ?${predicateShort}}`;
115
- }
116
-
117
- function readSpan(block, text, spanType = 'attrs') {
118
- const range = spanType === 'attrs' ? block?.attrsRange : block?.valueRange;
119
- if (!range) return null;
120
- const { start, end } = range;
121
- return (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end >= start)
122
- ? { start, end, text: text.substring(start, end) }
123
- : null;
124
- }
125
-
126
- function sanitizeCarrierValueForBlock(block, raw) {
127
- const s = String(raw ?? '');
128
- const t = block?.carrierType;
129
- if (t === 'code') return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
130
- const oneLine = s.replace(/[\n\r]+/g, ' ').trim();
131
- return (t === 'span' || t === 'link') ? oneLine.replace(/[\[\]]/g, ' ') : oneLine;
132
- }
133
-
134
- function blockTokensFromEntries(block) {
135
- return block?.entries?.length ? block.entries.map(e => e.raw).filter(Boolean) : null;
136
- }
137
-
138
- function removeEntryAt(block, entryIndex) {
139
- if (!block?.entries || entryIndex == null || entryIndex < 0 || entryIndex >= block.entries.length) return null;
140
- return [...block.entries.slice(0, entryIndex), ...block.entries.slice(entryIndex + 1)];
141
- }
142
-
143
- function replaceLangDatatypeEntries(block, lit, ctx) {
144
- if (!block?.entries) return null;
145
- const filtered = block.entries.filter(e => e.kind !== 'language' && e.kind !== 'datatype');
146
- const extras = [];
147
- if (lit?.language) extras.push({ kind: 'language', language: lit.language, raw: `@${lit.language}`, relRange: { start: 0, end: 0 } });
148
- const dt = lit?.datatype?.value;
149
- if (!lit?.language && dt && dt !== 'http://www.w3.org/2001/XMLSchema#string') {
150
- extras.push({ kind: 'datatype', datatype: shortenIRI(dt, ctx), raw: `^^${shortenIRI(dt, ctx)}`, relRange: { start: 0, end: 0 } });
151
- }
152
- return [...filtered, ...extras];
153
- }
154
-
155
- function updateAttrsDatatypeLang(tokens, newLit, ctx) {
156
- const predicatesAndTypes = tokens.filter(t => !t.startsWith('@') && !t.startsWith('^^'));
157
- if (newLit?.language) return [...predicatesAndTypes, `@${newLit.language}`];
158
- const dt = newLit?.datatype?.value;
159
- if (dt && dt !== 'http://www.w3.org/2001/XMLSchema#string') {
160
- return [...predicatesAndTypes, `^^${shortenIRI(dt, ctx)}`];
161
- }
162
- return predicatesAndTypes;
163
- }
164
-
165
- // Direct slot operations - no class abstraction needed
166
- function removeTokenFromSlot(entry, tokens, ctx, quad) {
167
- if (!entry) return { tokens, removed: false };
168
-
169
- if (entry.kind === 'object') {
170
- const objectIRI = shortenIRI(quad.object.value, ctx);
171
- return removeObjectToken(tokens, objectIRI);
172
- } else if (entry.kind === 'softFragment') {
173
- const fragment = entry.fragment;
174
- return removeSoftFragmentToken(tokens, fragment);
175
- } else if (entry.kind === 'type' && quad.predicate.value.endsWith('rdf-syntax-ns#type')) {
176
- const expectedType = entry.expandedType || quad.object.value;
177
- return removeOneToken(tokens, t => {
178
- if (!t.startsWith('.')) return false;
179
- const raw = t.slice(1);
180
- return expandIRI(raw, ctx) === expectedType;
181
- });
182
- } else {
183
- const expectedPred = entry.expandedPredicate || quad.predicate.value;
184
- const expectedForm = entry.form;
185
- return removeOneToken(tokens, t => {
186
- const m = String(t).match(/^(\^\?|\^|\?|)(.+)$/);
187
- if (!m) return false;
188
- const form = m[1] || '';
189
- const raw = m[2];
190
- if (expectedForm != null && form !== expectedForm) return false;
191
- return expandIRI(raw, ctx) === expectedPred;
192
- });
193
- }
194
- }
195
-
196
- function addTokenToSlot(tokens, ctx, quad) {
197
- // Use cleaner helper functions
198
- if (isRdfType(quad.predicate) && isNamedNode(quad.object)) {
199
- const typeShort = shortenIRI(quad.object.value, ctx);
200
- const typeToken = typeShort.includes(':') || !typeShort.startsWith('http') ? `.${typeShort}` : null;
201
- if (typeToken && !tokens.includes(typeToken)) {
202
- return [...tokens, typeToken];
203
- }
204
- } else if (isNamedNode(quad.object)) {
205
- const objectShort = shortenIRI(quad.object.value, ctx);
206
- const isSoftFragment = quad.object.value.includes('#');
207
- const fragment = isSoftFragment ? quad.object.value.split('#')[1] : null;
208
-
209
- if (fragment) {
210
- return addSoftFragmentToken(tokens, objectShort, fragment);
211
- } else {
212
- return addObjectToken(tokens, objectShort);
213
- }
214
- } else if (isLiteral(quad.object)) {
215
- const predShort = shortenIRI(quad.predicate.value, ctx);
216
- if (!tokens.includes(predShort)) {
217
- return [...tokens, predShort];
218
- }
219
- }
220
- return tokens;
221
- }
222
-
223
- function markEntryAsVacant(entry, quad) {
224
- if (entry && entry.slotId) {
225
- return markSlotAsVacant(entry, quad.object);
226
- }
227
- return null;
228
- }
229
-
230
- export function applyDiff({ text, diff, origin, options = {} }) {
231
- if (!diff || (!diff.add?.length && !diff.delete?.length)) {
232
- const reparsed = parse(text, { context: options.context || {} });
233
- return { text, origin: reparsed.origin };
234
- }
235
-
236
- const base = origin || parse(text, { context: options.context || {} }).origin;
237
- const ctx = options.context || {};
238
-
239
- // Phase 1: Plan operations (pure, no text edits)
240
- const plan = planOperations(diff, base, ctx);
241
-
242
- // Phase 2: Materialize edits (ranges + strings)
243
- const edits = materializeEdits(plan, text, ctx, base);
244
-
245
- // Phase 3: Apply edits + reparse
246
- return applyEdits(text, edits, ctx, base);
247
- }
248
-
249
-
250
- function planOperations(diff, base, ctx) {
251
- // Normalize quads using DataFactory for proper RDF/JS compatibility
252
- const normAdds = normalizeDiffQuads(diff.add || [], ctx);
253
- const normDeletes = normalizeDiffQuads(diff.delete || [], ctx);
254
-
255
- const plan = {
256
- literalUpdates: [],
257
- vacantSlotOccupations: [],
258
- deletes: [],
259
- adds: [],
260
- consumedAdds: new Set()
261
- };
262
-
263
- // Build lookup maps
264
- const addBySP = new Map();
265
- for (const quad of normAdds) {
266
- const k = JSON.stringify([quad.subject.value, quad.predicate.value]);
267
- const list = addBySP.get(k) || [];
268
- list.push(quad);
269
- addBySP.set(k, list);
270
- }
271
-
272
- // Build anchors for delete operations
273
- const anchors = new Map();
274
- for (const quad of normDeletes) {
275
- const key = JSON.stringify([quad.subject.value, objectSignature(quad.object)]);
276
- const quadKey = quadToKeyForOrigin(quad);
277
- const entry = getEntryByQuadKey(base, quadKey);
278
- const block = entry; // In unified structure, entry is the block
279
- if (block?.attrsRange) {
280
- anchors.set(key, { block, entry });
281
- }
282
- }
283
-
284
- // Detect literal updates early
285
- for (const deleteQuad of normDeletes) {
286
- if (!isLiteral(deleteQuad.object)) continue;
287
-
288
- const k = JSON.stringify([deleteQuad.subject.value, deleteQuad.predicate.value]);
289
- const candidates = addBySP.get(k) || [];
290
- const addQuad = candidates.find(x =>
291
- isLiteral(x?.object) && !plan.consumedAdds.has(quadToKeyForOrigin(x))
292
- );
293
-
294
- if (!addQuad) continue;
295
-
296
- const entry = resolveOriginEntry(deleteQuad, base);
297
- const block = entry; // In unified structure, the entry is the block
298
-
299
- if (block) {
300
- plan.literalUpdates.push({ deleteQuad, addQuad, entry, block });
301
- plan.consumedAdds.add(quadToKeyForOrigin(addQuad));
302
- }
303
- }
304
-
305
- // Find vacant slot occupations
306
- for (const quad of normAdds) {
307
- if (!isLiteral(quad.object)) continue;
308
- if (plan.consumedAdds.has(quadToKeyForOrigin(quad))) continue;
309
-
310
- const vacantSlot = findVacantSlot(base?.quadMap, quad.subject, quad.predicate);
311
- if (!vacantSlot) continue;
312
-
313
- const block = vacantSlot; // In unified structure, the slot is the block
314
- if (block) {
315
- plan.vacantSlotOccupations.push({ quad, vacantSlot, block });
316
- plan.consumedAdds.add(quadToKeyForOrigin(quad));
317
- }
318
- }
319
-
320
- // Plan remaining deletes
321
- for (const quad of normDeletes) {
322
- if (isLiteral(quad.object)) {
323
- const isUpdated = plan.literalUpdates.some(u =>
324
- u.deleteQuad.subject.value === quad.subject.value &&
325
- u.deleteQuad.predicate.value === quad.predicate.value &&
326
- u.deleteQuad.object.value === quad.object.value
327
- );
328
- if (isUpdated) continue;
329
- }
330
-
331
- const entry = resolveOriginEntry(quad, base);
332
- const block = entry; // In unified structure, entry is the block
333
- if (block) {
334
- plan.deletes.push({ quad, entry, block });
335
- }
336
- }
337
-
338
- // Plan remaining adds
339
- for (const quad of normAdds) {
340
- if (plan.consumedAdds.has(quadToKeyForOrigin(quad))) continue;
341
-
342
- const targetBlock = findTargetBlock(quad, base, anchors);
343
- plan.adds.push({ quad, targetBlock });
344
- }
345
-
346
- return plan;
347
- }
348
-
349
- function materializeEdits(plan, text, ctx, base) {
350
- const edits = [];
351
-
352
- // Materialize vacant slot occupations
353
- for (const { quad, vacantSlot, block } of plan.vacantSlotOccupations) {
354
- const span = readSpan(block, text, 'attrs');
355
- if (!span) continue;
356
-
357
- // Update carrier value
358
- const valueSpan = readSpan(block, text, 'value');
359
- if (valueSpan) {
360
- edits.push({ start: valueSpan.start, end: valueSpan.end, text: quad.object.value });
361
- }
362
-
363
- // Update annotation block
364
- const tokens = normalizeAttrsTokens(span.text);
365
- const predToken = `${vacantSlot.form || ''}${shortenIRI(quad.predicate.value, ctx)}`;
366
-
367
- if (tokens.length === 0) {
368
- edits.push({ start: span.start, end: span.end, text: `{${predToken}}` });
369
- } else if (!tokens.includes(predToken)) {
370
- const updated = [...tokens, predToken];
371
- edits.push({ start: span.start, end: span.end, text: writeAttrsTokens(updated) });
372
- }
373
- }
374
-
375
- // Materialize literal updates
376
- for (const { deleteQuad, addQuad, entry, block } of plan.literalUpdates) {
377
- const span = readSpan(block, text, 'value');
378
- if (span) {
379
- const newValue = sanitizeCarrierValueForBlock(block, addQuad.object.value);
380
- edits.push({ start: span.start, end: span.end, text: newValue });
381
- }
382
-
383
- const aSpan = readSpan(block, text, 'attrs');
384
- if (aSpan) {
385
- if (block?.entries?.length) {
386
- const nextEntries = replaceLangDatatypeEntries(block, addQuad.object, ctx);
387
- if (nextEntries) {
388
- const nextTokens = nextEntries.map(e => e.raw).filter(Boolean);
389
- const newText = nextTokens.length === 0 ? '{}' : writeAttrsTokens(nextTokens);
390
- edits.push({ start: aSpan.start, end: aSpan.end, text: newText });
391
- }
392
- } else {
393
- const tokens = normalizeAttrsTokens(aSpan.text);
394
- const updated = updateAttrsDatatypeLang(tokens, addQuad.object, ctx);
395
- if (updated.join(' ') !== tokens.join(' ')) {
396
- const newText = updated.length === 0 ? '{}' : writeAttrsTokens(updated);
397
- edits.push({ start: aSpan.start, end: aSpan.end, text: newText });
398
- }
399
- }
400
- }
401
- }
402
-
403
- // Materialize deletes
404
- for (const { quad, entry, block } of plan.deletes) {
405
- // Mark slot as vacant
406
- const vacantSlot = markEntryAsVacant(entry, quad);
407
- if (vacantSlot && block) {
408
- const blockInfo = {
409
- id: entry.blockId,
410
- range: block.range,
411
- attrsRange: block.attrsRange,
412
- valueRange: block.valueRange,
413
- carrierType: block.carrierType,
414
- subject: block.subject,
415
- context: block.context
416
- };
417
- vacantSlot.blockInfo = blockInfo;
418
- const key = quadToKeyForOrigin(quad);
419
- if (key) base.quadMap.set(key, vacantSlot);
420
- }
421
-
422
- const span = readSpan(block, text, 'attrs');
423
- if (!span) continue;
424
-
425
- // Handle entry removal by index
426
- if (entry?.entryIndex != null && block?.entries?.length) {
427
- const nextEntries = removeEntryAt(block, entry.entryIndex);
428
- if (nextEntries) {
429
- const nextTokens = nextEntries.map(e => e.raw).filter(Boolean);
430
- const newText = nextTokens.length === 0 ? '{}' : writeAttrsTokens(nextTokens);
431
- edits.push({ start: span.start, end: span.end, text: newText });
432
- continue;
433
- }
434
- }
435
-
436
- // Handle token-based removals using direct functions
437
- const tokens = normalizeAttrsTokens(span.text);
438
- const { tokens: updated, removed } = removeTokenFromSlot(entry, tokens, ctx, quad);
439
-
440
- if (removed) {
441
- const newText = updated.length === 0 ? '{}' : writeAttrsTokens(updated);
442
- edits.push({ start: span.start, end: span.end, text: newText });
443
- }
444
- }
445
-
446
- // Materialize adds
447
- for (const { quad, targetBlock } of plan.adds) {
448
- const quadKey = quadToKeyForOrigin(quad);
449
- if (plan.consumedAdds.has(quadKey)) {
450
- continue;
451
- }
452
-
453
- if (isLiteral(quad.object) || isNamedNode(quad.object)) {
454
- if (!targetBlock) {
455
- // No target block - check if subject already exists in document
456
- const subjectExists = Array.from(base?.quadMap?.values() || [])
457
- .some(block => block.subject?.value === quad.subject.value);
458
-
459
- let annotation;
460
- if (!subjectExists && isNamedNode(quad.object)) {
461
- // New subject with IRI object - create subject block
462
- annotation = createSubjectBlockForQuad(quad, ctx);
463
- } else if (subjectExists) {
464
- // Existing subject - create simple annotation
465
- annotation = createAnnotationForQuad(quad, ctx);
466
- } else {
467
- // New subject with literal - create subject block
468
- annotation = createSubjectBlockForQuad(quad, ctx);
469
- }
470
-
471
- if (annotation) {
472
- edits.push({ start: text.length, end: text.length, text: annotation.text });
473
- }
474
- continue;
475
- }
476
-
477
- // Insert annotation after target block's range
478
- const annotation = createAnnotationForQuad(quad, ctx);
479
- if (annotation) {
480
- // Find the end of the target block's content, not just its range
481
- const targetBlockEnd = targetBlock.range.end;
482
- let insertPos = targetBlockEnd;
483
-
484
- // Skip past the target block's content to find the right insertion point
485
- while (insertPos < text.length && text[insertPos] !== '\n') {
486
- insertPos++;
487
- }
488
-
489
- // Insert after the target block's content
490
- const finalInsertPos = insertPos < text.length ? insertPos : text.length;
491
- edits.push({ start: finalInsertPos, end: finalInsertPos, text: `\n${annotation.text}` });
492
- }
493
- }
494
- }
495
-
496
- return edits;
497
- }
498
-
499
- function applyEdits(text, edits, ctx, base) {
500
- let result = text;
501
-
502
- // Sort edits descending to avoid position shifts
503
- edits.sort((a, b) => b.start - a.start);
504
- edits.forEach(edit => {
505
- result = result.substring(0, edit.start) + edit.text + result.substring(edit.end);
506
- });
507
-
508
- // Extract vacant slots before reparsing
509
- const vacantSlots = new Map();
510
- base?.quadMap?.forEach((slot, key) => {
511
- if (slot.isVacant) vacantSlots.set(key, slot);
512
- });
513
-
514
- const reparsed = parse(result, { context: ctx });
515
-
516
- // Merge vacant slots back
517
- vacantSlots.forEach((vacantSlot, key) => {
518
- if (!reparsed.origin.quadMap.has(vacantSlot.id) && vacantSlot.blockInfo) {
519
- const { blockInfo } = vacantSlot;
520
- const emptyBlock = {
521
- id: blockInfo.id,
522
- range: blockInfo.range || { start: 0, end: 0 },
523
- attrsRange: blockInfo.attrsRange,
524
- valueRange: blockInfo.valueRange,
525
- carrierType: blockInfo.carrierType || 'span',
526
- subject: blockInfo.subject || '',
527
- types: [],
528
- predicates: [],
529
- context: blockInfo.context || { ...ctx }
530
- };
531
- reparsed.origin.quadMap.set(vacantSlot.id, emptyBlock);
532
- }
533
- reparsed.origin.quadMap.set(key, vacantSlot);
534
- });
535
-
536
- return { text: result, origin: reparsed.origin };
537
- }
538
-
539
- // Helper functions for origin lookup
540
- function resolveOriginEntry(quad, base) {
541
- const key = quadToKeyForOrigin(quad);
542
- let entry = key ? base?.quadMap?.get(key) : null;
543
-
544
- if (!entry && isLiteral(quad.object)) {
545
- // Fallback: search by value
546
- for (const [k, e] of base?.quadMap || []) {
547
- const parsed = parseQuadIndexKey(k);
548
- if (parsed && parsed.s === quad.subject.value &&
549
- parsed.p === quad.predicate.value &&
550
- parsed.o?.t === 'Literal' &&
551
- parsed.o?.v === quad.object.value) {
552
- entry = e;
553
- break;
554
- }
555
- }
556
- }
557
-
558
- return entry;
559
- }
560
-
561
- function findTargetBlock(quad, base, anchors) {
562
- const anchorKey = JSON.stringify([quad.subject.value, objectSignature(quad.object)]);
563
- const anchored = anchors.get(anchorKey);
564
- if (anchored?.block) return anchored.block;
565
-
566
- // Find the best position within the subject's section
567
- // Look for blocks with the same subject and sort by position
568
- const subjectBlocks = Array.from(base?.quadMap?.values() || [])
569
- .filter(block => block.subject?.value === quad.subject.value)
570
- .sort((a, b) => a.range.start - b.range.start);
571
-
572
- if (subjectBlocks.length === 0) return null;
573
-
574
- // Strategy: Find the last block with attrsRange to maintain consistency
575
- // For identical subject blocks, prefer the first one to avoid creating duplicates
576
- const blocksWithAttrs = subjectBlocks.filter(block => block.attrsRange);
577
- if (blocksWithAttrs.length > 0) {
578
- return blocksWithAttrs[blocksWithAttrs.length - 1]; // Return last matching block
579
- }
580
-
581
- // Fallback: return the last block in the subject's section
582
- return subjectBlocks[subjectBlocks.length - 1];
583
- }