-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalysisSlice.ts
More file actions
1185 lines (1124 loc) · 55.2 KB
/
Copy pathanalysisSlice.ts
File metadata and controls
1185 lines (1124 loc) · 55.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { createSelector, createSlice, current, type PayloadAction } from '@reduxjs/toolkit';
import type {
MorphemeAnalysis,
PhraseAnalysis,
PhraseAnalysisLink,
SegmentAnalysis,
SegmentAnalysisLink,
TextAnalysis,
TokenAnalysis,
TokenAnalysisLink,
TokenSnapshot,
} from 'interlinearizer';
import { emptyAnalysis } from '../types/empty-factories';
import { analysesAreIdentical } from '../utils/analysis-identity';
import { isEmptyMultiString } from '../utils/multi-string';
import {
buildPoolIndex,
deriveTokenSuggestion,
type ResolvedTokenAnalysis,
} from '../utils/suggestion-engine';
// #region Types
/** Redux state slice for the active `TextAnalysis` and its working language. */
export type AnalysisState = {
/** The active `TextAnalysis` being read and mutated. */
analysis: TextAnalysis;
/** BCP 47 tag identifying the language used when reading and writing gloss values. */
analysisLanguage: string;
};
/** Payload for the {@link writeGloss} action, extended with a pre-generated UUID. */
interface WriteGlossPayload {
/** `Token.ref` of the token being glossed. */
tokenRef: string;
/** Current surface text of the token, stored on the `TokenAnalysis` record. */
surfaceText: string;
/** New gloss string to assign in the active analysis language. */
value: string;
/** Pre-generated UUID for a new `TokenAnalysis` record, produced by the `prepare` callback. */
id: string;
}
/** Payload for the {@link createPhrase} action. */
interface CreatePhrasePayload {
/** Pre-generated UUID for the new `PhraseAnalysis`, produced by the `prepare` callback. */
id: string;
/** Ordered `TokenSnapshot`s forming the phrase, in document order. */
tokens: TokenSnapshot[];
}
/** Payload for the {@link updatePhrase} action. */
interface UpdatePhrasePayload {
/** ID of the `PhraseAnalysis` (and its link) to update. */
phraseId: string;
/** Replacement ordered `TokenSnapshot`s, in document order. */
tokens: TokenSnapshot[];
}
/** Payload for the {@link deletePhrase} action. */
interface DeletePhrasePayload {
/** ID of the `PhraseAnalysis` (and its link) to remove. */
phraseId: string;
}
/** Payload for the {@link mergePhrases} action. */
interface MergePhrasesPayload {
/** ID of the `PhraseAnalysis` to keep and grow; receives the merged token list. */
targetPhraseId: string;
/** The combined, document-ordered `TokenSnapshot`s for the target phrase. */
tokens: TokenSnapshot[];
/**
* ID of a neighboring phrase whose tokens were folded into `tokens` and that must be deleted in
* the same step. `undefined` when the absorbed neighbor was a free (unphrased) token, so there is
* no phrase record to remove.
*/
absorbedPhraseId?: string;
}
/** Payload for the {@link writePhraseGloss} action. */
interface WritePhraseGlossPayload {
/** ID of the `PhraseAnalysis` to update. */
phraseId: string;
/** New gloss string to assign in the active analysis language. */
value: string;
}
/** Payload for the {@link writeSegmentFreeTranslation} action, extended with a pre-generated UUID. */
interface WriteSegmentFreeTranslationPayload {
/** `Segment.id` of the segment being translated. */
segmentId: string;
/** Current baseline text of the segment, stored on the `SegmentAnalysis` record. */
surfaceText: string;
/** New free-translation string to assign in the active analysis language. */
value: string;
/** Pre-generated UUID for a new `SegmentAnalysis` record, produced by the `prepare` callback. */
id: string;
}
// #endregion
// #region Default state
/** Default `AnalysisState` used as the Redux initial state. */
export const defaultState: AnalysisState = {
analysis: emptyAnalysis(),
analysisLanguage: 'und',
};
// #endregion
// #region Slice
/**
* Derives the display surface text for a phrase by joining each token's surface text with a space.
*
* @param tokens - Ordered token snapshots forming the phrase, in document order.
* @returns The space-joined surface text string.
*/
function phraseSurfaceText(tokens: TokenSnapshot[]): string {
return tokens.map((t) => t.surfaceText).join(' ');
}
/**
* Removes the `PhraseAnalysis` record and its `PhraseAnalysisLink` matching `phraseId` from the
* Immer draft state in a single step, ensuring both collections stay in sync.
*
* @param state - Current slice state (Immer draft).
* @param phraseId - ID of the phrase to remove.
*/
function removePhraseById(state: AnalysisState, phraseId: string): void {
state.analysis.phraseAnalyses = state.analysis.phraseAnalyses.filter((pa) => pa.id !== phraseId);
state.analysis.phraseAnalysisLinks = state.analysis.phraseAnalysisLinks.filter(
(pl) => pl.analysisId !== phraseId,
);
}
/**
* Finds the approved `SegmentAnalysisLink` for `segmentId` together with the `SegmentAnalysis` it
* references. When the approved link references a missing analysis (an orphaned link from
* corruption or a migration), the link is removed from the draft — so the corruption never persists
* or accumulates duplicate approved links — and `undefined` is returned as if no approved link
* existed. Mirrors {@link resolveApprovedAnalysis} for the segment layer.
*
* @param state - Current slice state (Immer draft).
* @param segmentId - `Segment.id` of the segment to look up.
* @returns The approved link and its analysis, or `undefined` when the segment has none.
*/
function resolveApprovedSegmentAnalysis(
state: AnalysisState,
segmentId: string,
): { link: SegmentAnalysisLink; analysis: SegmentAnalysis } | undefined {
const link = state.analysis.segmentAnalysisLinks.find(
(l) => l.status === 'approved' && l.segmentId === segmentId,
);
if (!link) return undefined;
const analysis = state.analysis.segmentAnalyses.find((sa) => sa.id === link.analysisId);
if (!analysis) {
state.analysis.segmentAnalysisLinks = state.analysis.segmentAnalysisLinks.filter(
(l) => l !== link,
);
return undefined;
}
return { link, analysis };
}
/**
* Determines whether a `SegmentAnalysis` carries no content worth keeping, so a reducer that just
* emptied the free translation can drop the whole record instead of accumulating empty records in
* storage. `freeTranslation` and `literalTranslation` each count as empty when they have no entries
* or every entry is blank ({@link isEmptyMultiString}), so a populated `literalTranslation` (e.g. an
* imported word-for-word translation) still survives a free-translation clear while a record left
* holding only whitespace is dropped — mirroring how {@link isEmptyTokenAnalysis} preserves
* morphemes/pos.
*
* @param analysis - The `SegmentAnalysis` to inspect.
* @returns `true` when the record holds no content worth keeping.
*/
function isEmptySegmentAnalysis(analysis: SegmentAnalysis): boolean {
return (
isEmptyMultiString(analysis.freeTranslation) && isEmptyMultiString(analysis.literalTranslation)
);
}
/**
* Removes a `SegmentAnalysis` record and its `SegmentAnalysisLink` from the draft in a single step,
* keeping the two collections in sync. Called when an edit empties an analysis of all content.
*
* @param state - Current slice state (Immer draft).
* @param analysis - The `SegmentAnalysis` record to remove.
* @param link - The `SegmentAnalysisLink` referencing it.
*/
function removeSegmentAnalysis(
state: AnalysisState,
analysis: SegmentAnalysis,
link: SegmentAnalysisLink,
): void {
state.analysis.segmentAnalyses = state.analysis.segmentAnalyses.filter((sa) => sa !== analysis);
state.analysis.segmentAnalysisLinks = state.analysis.segmentAnalysisLinks.filter(
(l) => l !== link,
);
}
/**
* Finds the approved `TokenAnalysisLink` for `tokenRef` together with the `TokenAnalysis` it
* references. Uses `findLast` so that, in the data-model-violating case of multiple approved links
* for one token, the reducer mutates the same link the read selectors surface (both
* {@link selectApprovedIdByTokenRef} and the phrase-link selectors are last-wins); otherwise a write
* would land on a different link than `useGloss`/`useMorphemes` read and appear to no-op. When the
* approved link references a missing analysis (an orphaned link from corruption or a migration),
* the link is removed from the draft — so the corruption never persists or accumulates duplicate
* approved links — and `undefined` is returned as if no approved link existed. Every token-analysis
* reducer resolves through this helper so they all repair orphaned links the same way.
*
* @param state - Current slice state (Immer draft).
* @param tokenRef - `Token.ref` of the token to look up.
* @returns The approved link and its analysis, or `undefined` when the token has none.
*/
function resolveApprovedAnalysis(
state: AnalysisState,
tokenRef: string,
): { link: TokenAnalysisLink; analysis: TokenAnalysis } | undefined {
const link = state.analysis.tokenAnalysisLinks.findLast(
(l) => l.status === 'approved' && l.token.tokenRef === tokenRef,
);
if (!link) return undefined;
const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === link.analysisId);
if (!analysis) {
state.analysis.tokenAnalysisLinks = state.analysis.tokenAnalysisLinks.filter((l) => l !== link);
return undefined;
}
return { link, analysis };
}
/**
* Links a token to an approved `TokenAnalysis`, doing find-or-create so identical analyses are
* shared rather than duplicated: if an existing payload is content-identical to `analysis`
* ({@link analysesAreIdentical}), the new approved link points at that payload and `analysis` is
* discarded; otherwise `analysis` is appended as a new payload. Either way exactly one approved
* `TokenAnalysisLink` is pushed, keeping the two collections in sync. The link's token snapshot
* records _this_ token's surface text (from `analysis.surfaceText`), not the shared payload's, so
* per-token drift detection stays accurate even when a sentence-initial form links to a payload
* first created from a mid-sentence form.
*
* @param state - Current slice state (Immer draft).
* @param analysis - The candidate `TokenAnalysis` record to link or, if novel, append.
* @param tokenRef - `Token.ref` of the token the link points at.
*/
function appendApprovedAnalysis(
state: AnalysisState,
analysis: TokenAnalysis,
tokenRef: string,
): void {
const existing = state.analysis.tokenAnalyses.find((ta) => analysesAreIdentical(ta, analysis));
if (!existing) state.analysis.tokenAnalyses.push(analysis);
state.analysis.tokenAnalysisLinks.push({
analysisId: existing?.id ?? analysis.id,
status: 'approved',
token: { tokenRef, surfaceText: analysis.surfaceText },
});
}
/**
* Detaches a token from its analysis once an edit has emptied that analysis of all content: the
* editing token's `TokenAnalysisLink` is removed, and the `TokenAnalysis` payload itself is removed
* only when no other link still references it. Because payloads are shared across every token
* glossed identically (see {@link appendApprovedAnalysis}), removing the link before checking for
* remaining references is what stops an edit on one token from orphaning a payload that another
* token still links to. A payload kept alive by a surviving link may be momentarily empty; it is
* reclaimed when that last link is cleared.
*
* @param state - Current slice state (Immer draft).
* @param analysis - The emptied `TokenAnalysis` payload.
* @param link - The `TokenAnalysisLink` from the editing token to remove.
*/
function detachTokenAnalysisLink(
state: AnalysisState,
analysis: TokenAnalysis,
link: TokenAnalysisLink,
): void {
state.analysis.tokenAnalysisLinks = state.analysis.tokenAnalysisLinks.filter((l) => l !== link);
const stillReferenced = state.analysis.tokenAnalysisLinks.some(
(l) => l.analysisId === analysis.id,
);
if (!stillReferenced) {
state.analysis.tokenAnalyses = state.analysis.tokenAnalyses.filter((ta) => ta !== analysis);
}
}
/**
* Reports whether `analysisId`'s payload is referenced by any approved link other than `link` —
* i.e. whether an edit or clear reaching it through `link`'s token would also affect a different
* token. The shared/sole distinction is what tells a clear, delete, or fork whether it must work on
* a private clone (to spare the co-linked tokens) or may mutate the payload in place.
*
* @param state - Current slice state (Immer draft).
* @param link - The editing token's approved `TokenAnalysisLink` (excluded from the check).
* @param analysisId - `TokenAnalysis.id` of the payload to test.
* @returns `true` when at least one other link points at the same payload.
*/
function isPayloadSharedByOtherLinks(
state: AnalysisState,
link: TokenAnalysisLink,
analysisId: string,
): boolean {
return state.analysis.tokenAnalysisLinks.some(
(l) => l !== link && l.status === 'approved' && l.analysisId === analysisId,
);
}
/**
* Forks a shared `TokenAnalysis` payload onto a private clone under `cloneId` and repoints `link`
* (the editing token's approved link) to the clone, so a following in-place edit or clear touches
* only this token while every other token keeps the original shared payload. The clone carries the
* same content (including morpheme ids) under a new id, with fresh copies of the mutable `gloss`
* and `morphemes` containers so the returned draft can be edited or cleared in the same reducer
* without writing through to the frozen shared payload. Used by the per-token edit, clear, and
* delete paths so a token can edit or detach from a shared analysis without affecting the others.
*
* @param state - Current slice state (Immer draft).
* @param link - The editing token's approved `TokenAnalysisLink`, repointed to the clone.
* @param analysis - The shared payload to clone.
* @param cloneId - Pre-generated UUID for the clone (from the action's `prepare`).
* @returns The clone's draft, for the caller to edit or clear in place.
*/
function forkSharedAnalysis(
state: AnalysisState,
link: TokenAnalysisLink,
analysis: TokenAnalysis,
cloneId: string,
): TokenAnalysis {
const source = current(analysis);
state.analysis.tokenAnalyses.push({
...source,
id: cloneId,
...(source.gloss ? { gloss: { ...source.gloss } } : {}),
...(source.morphemes ? { morphemes: source.morphemes.map((m) => ({ ...m })) } : {}),
});
link.analysisId = cloneId;
return state.analysis.tokenAnalyses[state.analysis.tokenAnalyses.length - 1];
}
/**
* Re-converges a just-edited payload onto an existing content-identical one, so an in-place edit
* can never leave two identical payloads the way the create path's find-or-create
* ({@link appendApprovedAnalysis}) prevents on first write. When another `TokenAnalysis` is now
* {@link analysesAreIdentical} to `analysis`, every link pointing at `analysis` is repointed to that
* payload and `analysis` is dropped — collapsing a homograph instance that was edited to match a
* sibling back onto one shared payload (frequency re-merged, no duplicate suggestion). A no-op when
* the edit left the payload unique.
*
* @param state - Current slice state (Immer draft).
* @param analysis - The payload just edited in place.
*/
function mergeIntoIdenticalPayload(state: AnalysisState, analysis: TokenAnalysis): void {
const other = state.analysis.tokenAnalyses.find(
(ta) => ta !== analysis && analysesAreIdentical(ta, analysis),
);
if (!other) return;
state.analysis.tokenAnalysisLinks.forEach((l) => {
if (l.analysisId === analysis.id) l.analysisId = other.id;
});
state.analysis.tokenAnalyses = state.analysis.tokenAnalyses.filter((ta) => ta !== analysis);
}
/**
* Determines whether a `TokenAnalysis` carries no analysis content, so a reducer that just emptied
* one field can decide to drop the whole record instead of letting empty records accumulate in
* storage. Checks every content field of the type — `gloss`, `morphemes`, `pos`, `features`, and
* `glossSenseRef` — not only the field the caller emptied, so records carrying imported
* morphosyntactic or lexicon data are never discarded by an unrelated edit. A gloss counts as empty
* when it has no entries or every entry is blank, so a record left holding only whitespace glosses
* (junk from clearing a gloss field) is treated the same as one with no gloss at all.
*
* Provenance fields (`confidence`, `producer`, `sourceUser`) are intentionally NOT treated as
* content: they describe who/what produced an analysis, not an analysis worth keeping on their own.
* A record holding only provenance and no glosses/morphemes/pos/features is therefore considered
* empty and may be dropped when its last content field is cleared. This is a deliberate choice — if
* a future workflow needs provenance-only records (e.g. imported parser metadata) to survive a
* gloss clear, add the relevant fields to the check below.
*
* @param analysis - The `TokenAnalysis` to inspect.
* @returns `true` when the record holds no analysis content worth keeping.
*/
function isEmptyTokenAnalysis(analysis: TokenAnalysis): boolean {
return (
isEmptyMultiString(analysis.gloss) &&
/* v8 ignore next -- the length===0 sub-branch needs an empty-but-defined morphemes array, which no caller produces */
(!analysis.morphemes || analysis.morphemes.length === 0) &&
analysis.pos === undefined &&
analysis.features === undefined &&
analysis.glossSenseRef === undefined
);
}
const analysisSlice = createSlice({
name: 'analysis',
initialState: defaultState,
reducers: {
writeGloss: {
/**
* Generates a UUID for a potential new `TokenAnalysis` record before the action reaches the
* reducer, keeping the reducer pure.
*
* @param tokenRef - `Token.ref` of the token being glossed.
* @param surfaceText - Surface text of the token.
* @param value - New gloss string.
* @returns The prepared action payload including a pre-generated `id`.
*/
prepare(tokenRef: string, surfaceText: string, value: string) {
return { payload: { tokenRef, surfaceText, value, id: crypto.randomUUID() } };
},
/**
* Creates or updates an approved `TokenAnalysis` for the given token. If an approved link
* already exists for `tokenRef`, its analysis is updated and the stored surface text is
* refreshed on both the analysis and the link's token snapshot, so neither goes stale when
* the baseline text changed since the analysis was first written. The edit is **per-token**:
* when the payload is shared by other tokens, this token is forked onto a private clone
* ({@link forkSharedAnalysis}) and the clone is edited, so the co-linked tokens keep the
* shared gloss rather than being rewritten by an edit aimed at this one. (Editing every
* occurrence of a shared analysis is deferred; see user-questions.md "separating per-token
* edits from global analysis edits".) An edit that makes the payload identical to an existing
* one re-converges onto it ({@link mergeIntoIdenticalPayload}), so editing can never leave the
* duplicate the create path's find-or-create avoids. Otherwise a new `TokenAnalysis` and
* `TokenAnalysisLink` are appended (an orphaned approved link is repaired first; see
* {@link resolveApprovedAnalysis}). Non-approved analyses for the token are left untouched.
*
* A blank `value` (empty or whitespace) is treated as clearing the gloss rather than writing
* junk: the active language's entry is removed, and when that leaves the analysis with no
* content ({@link isEmptyTokenAnalysis}) the record and its link are removed entirely. The
* clear forks a shared payload just as an edit does, so the co-linked tokens keep the shared
* gloss rather than being stranded on an emptied payload. A blank write to a token with no
* approved analysis is a no-op, so a focus/blur cycle on an empty gloss never creates a
* record.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `WriteGlossPayload`.
*/
reducer(state, action: PayloadAction<WriteGlossPayload>) {
const { tokenRef, surfaceText, value, id } = action.payload;
const lang = state.analysisLanguage;
const isBlank = value.trim() === '';
const resolved = resolveApprovedAnalysis(state, tokenRef);
if (resolved) {
const { link, analysis } = resolved;
// Both the edit and the clear are per-token: when the payload is shared, fork this token
// onto a private clone first and mutate that, so the co-linked tokens keep the shared gloss
// instead of being rewritten or stranded by an edit aimed at this one. (Global "edit every
// occurrence" is deferred; see user-questions.md "separating per-token edits from global
// analysis edits".) Surface text is refreshed on the fork (not the shared original)
// so a co-linked sibling's payload is never rewritten.
const target = isPayloadSharedByOtherLinks(state, link, analysis.id)
? forkSharedAnalysis(state, link, analysis, id)
: analysis;
target.surfaceText = surfaceText;
link.token.surfaceText = surfaceText;
if (isBlank) {
if (target.gloss) {
delete target.gloss[lang];
if (Object.keys(target.gloss).length === 0) delete target.gloss;
}
// When the clear empties the analysis, detach it; otherwise the cleared payload (e.g. one
// left holding only morphemes) can be identical to an existing sibling, so re-converge —
// mirroring writeMorphemeGloss's clear path so a clear never leaves a duplicate the
// suggestion pool would double-count.
if (isEmptyTokenAnalysis(target)) detachTokenAnalysisLink(state, target, link);
else mergeIntoIdenticalPayload(state, target);
return;
}
if (!target.gloss) target.gloss = {};
target.gloss[lang] = value;
// An in-place edit can make this payload identical to an existing one (e.g. a homograph
// instance re-glossed to match its sibling); re-converge so the dedupe the create path
// guarantees on first write also holds after edits.
mergeIntoIdenticalPayload(state, target);
return;
}
if (isBlank) return;
appendApprovedAnalysis(state, { id, surfaceText, gloss: { [lang]: value } }, tokenRef);
},
},
writeMorphemes: {
/**
* Generates UUIDs for new morpheme records and a potential new `TokenAnalysis` before the
* action reaches the reducer.
*
* @param tokenRef - `Token.ref` of the token whose morphemes are being set.
* @param surfaceText - Surface text of the token.
* @param forms - Ordered morpheme form strings as entered by the user.
* @param writingSystem - BCP 47 tag of the token's surface text (`Token.writingSystem`),
* stored on each morpheme as the writing system of its form.
* @returns The prepared action payload.
*/
prepare(tokenRef: string, surfaceText: string, forms: string[], writingSystem: string) {
return {
payload: {
tokenRef,
surfaceText,
writingSystem,
analysisId: crypto.randomUUID(),
morphemes: forms.map((form) => ({ id: crypto.randomUUID(), form })),
},
};
},
/**
* Sets the morpheme breakdown on the approved `TokenAnalysis` for the given token. The edit
* is per-token: when the payload is shared by other tokens, this token is forked onto a
* private clone ({@link forkSharedAnalysis}) and the clone is re-segmented, so the co-linked
* tokens keep the shared breakdown. (Editing every occurrence of a shared analysis is
* deferred; see user-questions.md "separating per-token edits from global analysis edits".)
* When a morpheme form is unchanged the existing morpheme record is preserved whole —
* including its id, which `MorphemeLink.morphemeId` cross-references, so alignment links to
* unchanged morphemes survive edits to the rest of the breakdown. When no approved analysis
* exists, creates one (an orphaned approved link is repaired first; see
* {@link resolveApprovedAnalysis}). Also refreshes the stored surface text on both the
* analysis and the link's token snapshot, so neither goes stale when the baseline text
* changed since the analysis was first written. Every morpheme — preserved or new — is
* stamped with the supplied writing system, so records written before the writing system was
* threaded through (which wrongly stored the analysis language) self-correct on the next
* save.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the morpheme payload.
*/
reducer(
state,
action: PayloadAction<{
tokenRef: string;
surfaceText: string;
writingSystem: string;
analysisId: string;
morphemes: Array<{ id: string; form: string }>;
}>,
) {
const { tokenRef, surfaceText, writingSystem, analysisId, morphemes } = action.payload;
const resolved = resolveApprovedAnalysis(state, tokenRef);
if (resolved) {
const { link, analysis } = resolved;
// A breakdown edit is per-token: when the payload is shared, fork this token onto a private
// clone and re-segment the clone, so the co-linked tokens keep the shared breakdown. The
// prepared `analysisId` (otherwise consumed only by the create path below) names the clone.
// (Editing every occurrence of a shared analysis is deferred; see user-questions.md
// "separating per-token edits from global analysis edits".)
const target = isPayloadSharedByOtherLinks(state, link, analysis.id)
? forkSharedAnalysis(state, link, analysis, analysisId)
: analysis;
target.surfaceText = surfaceText;
link.token.surfaceText = surfaceText;
// Multimap with consumed entries so duplicate forms (e.g. reduplication "ba ba") each
// match a distinct old morpheme in order, instead of all inheriting the last one.
const oldByForm = new Map<string, MorphemeAnalysis[]>();
(target.morphemes ?? []).forEach((m) => {
const bucket = oldByForm.get(m.form);
if (bucket) bucket.push(m);
else oldByForm.set(m.form, [m]);
});
target.morphemes = morphemes.map(({ id, form }) => {
const old = oldByForm.get(form)?.shift();
// Keep the preserved morpheme's id (the prepared id is discarded) so external
// references to it stay valid; only the writing system is refreshed.
if (old) return { ...old, writingSystem };
return { id, form, writingSystem };
});
// An in-place breakdown edit can make this payload identical to an existing one (e.g. a
// homograph re-segmented to match a sibling); re-converge so the dedupe the create path
// guarantees on first write also holds after morpheme edits (mirrors writeGloss).
mergeIntoIdenticalPayload(state, target);
return;
}
appendApprovedAnalysis(
state,
{
id: analysisId,
surfaceText,
morphemes: morphemes.map(({ id, form }) => ({ id, form, writingSystem })),
},
tokenRef,
);
},
},
deleteMorphemes: {
/**
* Generates a UUID for a potential fork clone before the action reaches the reducer — used
* only when the breakdown is removed from a shared payload — keeping the reducer pure.
* Accepts the same `{ tokenRef }` argument the action took before, so call sites are
* unchanged.
*
* @param arg - Object carrying the `tokenRef` whose breakdown is removed.
* @param arg.tokenRef - `Token.ref` of the token whose morphemes are removed.
* @returns The prepared action payload including a pre-generated clone `id`.
*/
prepare(arg: { tokenRef: string }) {
return { payload: { tokenRef: arg.tokenRef, id: crypto.randomUUID() } };
},
/**
* Removes the morpheme breakdown from the approved `TokenAnalysis` for the given token. When
* the analysis carries no other content (gloss, POS, features, or lexicon sense reference —
* see {@link isEmptyTokenAnalysis}), the now-empty analysis record and its link are removed
* entirely so empty records do not accumulate in storage. When the payload is shared with
* other tokens, the breakdown is removed from a private clone of this token (see
* {@link forkSharedAnalysis}) so the co-linked tokens keep their morphemes. No-ops when the
* token has no approved analysis or the analysis has no morphemes (an orphaned approved link
* is still repaired; see {@link resolveApprovedAnalysis}).
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `tokenRef` whose breakdown is removed and the clone
* `id`.
*/
reducer(state, action: PayloadAction<{ tokenRef: string; id: string }>) {
const { tokenRef, id } = action.payload;
const resolved = resolveApprovedAnalysis(state, tokenRef);
if (!resolved?.analysis.morphemes) return;
const { link, analysis } = resolved;
const target = isPayloadSharedByOtherLinks(state, link, analysis.id)
? forkSharedAnalysis(state, link, analysis, id)
: analysis;
delete target.morphemes;
if (isEmptyTokenAnalysis(target)) {
detachTokenAnalysisLink(state, target, link);
return;
}
// Removing the breakdown can leave this payload identical to an existing one; re-converge so
// dedupe holds after morphology-only edits, the same way writeGloss does after a gloss edit.
mergeIntoIdenticalPayload(state, target);
},
},
/**
* Writes a gloss string onto a single morpheme within the approved `TokenAnalysis` for the
* given token. No-ops when the token has no approved analysis or the morpheme id is not found
* (an orphaned approved link is still repaired; see {@link resolveApprovedAnalysis}).
*
* A blank `value` (empty or whitespace) clears the gloss rather than storing junk: the active
* language's entry is removed, and when that leaves the morpheme with no glosses the `gloss`
* object is dropped entirely — mirroring the token-level {@link writeGloss}. The morpheme record
* itself is kept (a breakdown is content in its own right), so unlike `writeGloss` this never
* removes the enclosing analysis.
*
* Both the write and the clear are **per-token**: when the payload is shared by other tokens,
* this token is forked onto a private clone ({@link forkSharedAnalysis}, which preserves
* morpheme ids so `morphemeId` still resolves on the clone) and the clone's morpheme is edited,
* so the co-linked tokens keep the shared gloss. (Editing every occurrence of a shared analysis
* is deferred; see user-questions.md "separating per-token edits from global analysis edits".)
* Both are also identity-changing edits, so each re-converges onto an existing
* content-identical payload ({@link mergeIntoIdenticalPayload}) — keeping the create path's
* dedupe invariant symmetric across both directions, so a clear back to a sibling's state never
* leaves a duplicate.
*/
writeMorphemeGloss: {
/**
* Generates a UUID for the clone a per-token edit forks from a shared payload, before the
* action reaches the reducer, keeping the reducer pure. Unused when the payload is not
* shared.
*
* @param arg - Object carrying the token, morpheme, and new gloss value.
* @param arg.tokenRef - `Token.ref` of the token whose morpheme gloss is written.
* @param arg.morphemeId - Id of the morpheme within the token's approved analysis.
* @param arg.value - New gloss string (blank clears the morpheme's active-language gloss).
* @returns The prepared action payload including a pre-generated clone `id`.
*/
prepare(arg: { tokenRef: string; morphemeId: string; value: string }) {
return { payload: { ...arg, id: crypto.randomUUID() } };
},
/**
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the morpheme gloss payload and a pre-generated clone `id`.
*/
reducer(
state,
action: PayloadAction<{
tokenRef: string;
morphemeId: string;
value: string;
id: string;
}>,
) {
const { tokenRef, morphemeId, value, id } = action.payload;
const lang = state.analysisLanguage;
const resolved = resolveApprovedAnalysis(state, tokenRef);
if (!resolved) return;
const { link, analysis } = resolved;
if (!analysis.morphemes?.some((m) => m.id === morphemeId)) return;
// Fork before editing so the morpheme gloss change touches only this token; on the clone the
// morpheme keeps its id, so re-find it there.
const target = isPayloadSharedByOtherLinks(state, link, analysis.id)
? forkSharedAnalysis(state, link, analysis, id)
: analysis;
const morpheme = target.morphemes?.find((m) => m.id === morphemeId);
/* v8 ignore next -- forkSharedAnalysis preserves morpheme ids, so this always resolves */
if (!morpheme) return;
if (value.trim() === '') {
if (morpheme.gloss) {
delete morpheme.gloss[lang];
if (Object.keys(morpheme.gloss).length === 0) delete morpheme.gloss;
}
} else {
if (!morpheme.gloss) morpheme.gloss = {};
morpheme.gloss[lang] = value;
}
// A morpheme gloss is part of analysis identity (see analysesAreIdentical), so editing or
// clearing one can make this payload identical to an existing one (e.g. a homograph whose
// only difference was this morpheme's gloss); re-converge so dedupe holds after edits too.
mergeIntoIdenticalPayload(state, target);
},
},
/**
* Approves a shared `TokenAnalysis` payload for a token — the persisted half of accepting a
* suggestion or promoting a candidate (see {@link selectResolvedTokenAnalysis}). No new payload
* is created (unlike {@link writeGloss}'s find-or-create); the chosen payload's approval
* frequency rises by one and the token's derived suggestion disappears now that it carries its
* own approved decision.
*
* When the token already has an approved analysis the existing link is **repointed** to the
* chosen payload rather than a second link being appended, so the "at most one approved link
* per token" invariant is preserved while still letting an already-approved homograph be
* promoted to a different pool analysis (the affordance {@link selectResolvedTokenAnalysis}
* offers on approved tokens). Repointing through {@link resolveApprovedAnalysis} also reuses its
* last-wins/orphan-repair handling, so the swap lands on the same link the read selectors
* surface and an orphaned approved link is healed first rather than blocking the promotion.
* When the existing approval already points at the chosen payload the repoint is a no-op.
* Detaching the old payload after the repoint reclaims it when this was its last approved
* reference, so a promotion never strands an empty payload.
*
* An `analysisId` that resolves to no stored payload is rejected (no-op) rather than approved
* as a fresh orphan. The link's snapshot records _this_ token's `surfaceText` (not the shared
* payload's), matching {@link appendApprovedAnalysis} so per-token drift detection stays
* accurate.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the accepting `tokenRef`, its `surfaceText`, and the
* `analysisId` of the payload to approve (the suggested payload, or a candidate when
* promoting).
*/
approveAnalysisForToken(
state,
action: PayloadAction<{ tokenRef: string; surfaceText: string; analysisId: string }>,
) {
const { tokenRef, surfaceText, analysisId } = action.payload;
// Approve only a payload that actually exists: an unknown id would point an approved link at
// nothing, which the read selectors then have to repair as an orphan. Callers pass an id drawn
// from the live suggestion pool, but the reducer no longer relies on that alone.
if (!state.analysis.tokenAnalyses.some((ta) => ta.id === analysisId)) return;
const resolved = resolveApprovedAnalysis(state, tokenRef);
if (resolved) {
// Promote: repoint the one approved link to the chosen payload (a no-op when it already
// points there) instead of appending a second, then reclaim the old payload if this was its
// last approved reference.
const { link, analysis } = resolved;
if (link.analysisId === analysisId) return;
link.analysisId = analysisId;
link.token.surfaceText = surfaceText;
if (!isPayloadSharedByOtherLinks(state, link, analysis.id)) {
state.analysis.tokenAnalyses = state.analysis.tokenAnalyses.filter(
(ta) => ta !== analysis,
);
}
return;
}
state.analysis.tokenAnalysisLinks.push({
analysisId,
status: 'approved',
token: { tokenRef, surfaceText },
});
},
createPhrase: {
/**
* Generates a UUID for the new `PhraseAnalysis` before the action reaches the reducer,
* keeping the reducer pure.
*
* @param tokens - Ordered `TokenSnapshot`s forming the phrase, in document order.
* @returns The prepared action payload including a pre-generated `id`.
*/
prepare(tokens: TokenSnapshot[]) {
return { payload: { id: crypto.randomUUID(), tokens } };
},
/**
* Appends a new approved `PhraseAnalysis` and its `PhraseAnalysisLink` to the analysis.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `CreatePhrasePayload`.
*/
reducer(state, action: PayloadAction<CreatePhrasePayload>) {
const { id, tokens } = action.payload;
const newAnalysis: PhraseAnalysis = { id, surfaceText: phraseSurfaceText(tokens) };
const newLink: PhraseAnalysisLink = { analysisId: id, status: 'approved', tokens };
state.analysis.phraseAnalyses.push(newAnalysis);
state.analysis.phraseAnalysisLinks.push(newLink);
},
},
/**
* Replaces the token list of the matching `PhraseAnalysisLink` and re-derives the
* `PhraseAnalysis.surfaceText` from the new tokens (mirroring `createPhrase`) so the persisted
* surface form never goes stale. Does not create a new `PhraseAnalysis` record — preserves the
* phrase id and any gloss already written on it. When `tokens` is empty the phrase is removed
* entirely (both the analysis record and its link) so a zero-token phrase can never persist in
* the store.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `UpdatePhrasePayload`.
*/
updatePhrase(state, action: PayloadAction<UpdatePhrasePayload>) {
const { phraseId, tokens } = action.payload;
if (tokens.length === 0) {
removePhraseById(state, phraseId);
return;
}
const link = state.analysis.phraseAnalysisLinks.find((l) => l.analysisId === phraseId);
if (link) link.tokens = tokens;
const analysis = state.analysis.phraseAnalyses.find((pa) => pa.id === phraseId);
if (analysis) analysis.surfaceText = phraseSurfaceText(tokens);
},
/**
* Removes the `PhraseAnalysis` record and its `PhraseAnalysisLink` for the given phrase id.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `DeletePhrasePayload`.
*/
deletePhrase(state, action: PayloadAction<DeletePhrasePayload>) {
const { phraseId } = action.payload;
removePhraseById(state, phraseId);
},
/**
* Merges a neighboring phrase (or a free token) into the target phrase as a single atomic
* mutation: the target's tokens are replaced with the supplied merged list and, when an
* `absorbedPhraseId` is given, that neighbor's analysis record and link are removed in the same
* step. Doing both in one reducer avoids the transient state — produced when `updatePhrase` and
* `deletePhrase` were dispatched separately — where the neighbor's tokens briefly existed in
* two phrases at once, which a save between the two dispatches could persist.
*
* No-ops when `absorbedPhraseId === targetPhraseId` to prevent the update from being
* immediately undone by the delete.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `MergePhrasesPayload`.
*/
mergePhrases(state, action: PayloadAction<MergePhrasesPayload>) {
const { targetPhraseId, tokens, absorbedPhraseId } = action.payload;
if (absorbedPhraseId !== undefined && absorbedPhraseId === targetPhraseId) return;
const link = state.analysis.phraseAnalysisLinks.find((l) => l.analysisId === targetPhraseId);
if (link) link.tokens = tokens;
const analysis = state.analysis.phraseAnalyses.find((pa) => pa.id === targetPhraseId);
if (analysis) analysis.surfaceText = phraseSurfaceText(tokens);
if (absorbedPhraseId !== undefined) removePhraseById(state, absorbedPhraseId);
},
/**
* Writes a gloss value into the `PhraseAnalysis` record for the given phrase id. No-ops when no
* matching `PhraseAnalysis` is found.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `WritePhraseGlossPayload`.
*/
writePhraseGloss(state, action: PayloadAction<WritePhraseGlossPayload>) {
const { phraseId, value } = action.payload;
const pa = state.analysis.phraseAnalyses.find((p) => p.id === phraseId);
if (!pa) return;
const lang = state.analysisLanguage;
if (!pa.gloss) pa.gloss = {};
pa.gloss[lang] = value;
},
writeSegmentFreeTranslation: {
/**
* Generates a UUID for a potential new `SegmentAnalysis` record before the action reaches the
* reducer, keeping the reducer pure.
*
* @param segmentId - `Segment.id` of the segment being translated.
* @param surfaceText - Baseline text of the segment.
* @param value - New free-translation string.
* @returns The prepared action payload including a pre-generated `id`.
*/
prepare(segmentId: string, surfaceText: string, value: string) {
return { payload: { segmentId, surfaceText, value, id: crypto.randomUUID() } };
},
/**
* Creates or updates the approved `SegmentAnalysis` carrying a segment's free translation. If
* an approved link already exists for `segmentId`, its analysis is updated in place and the
* stored surface text is refreshed, so it never goes stale when the baseline text changed
* since the analysis was first written. Otherwise a new `SegmentAnalysis` and approved
* `SegmentAnalysisLink` are appended (an orphaned approved link is repaired first; see
* {@link resolveApprovedSegmentAnalysis}).
*
* A blank `value` (empty or whitespace) clears the free translation rather than writing junk:
* the active language's entry is removed, and when that leaves the analysis with no content
* ({@link isEmptySegmentAnalysis}) the record and its link are removed entirely. A blank write
* to a segment with no approved analysis is a no-op, so a focus/blur cycle on an empty input
* never creates a record.
*
* @param state - Current slice state (Immer draft).
* @param action - Action carrying the `WriteSegmentFreeTranslationPayload`.
*/
reducer(state, action: PayloadAction<WriteSegmentFreeTranslationPayload>) {
const { segmentId, surfaceText, value, id } = action.payload;
const lang = state.analysisLanguage;
const isBlank = value.trim() === '';
const resolved = resolveApprovedSegmentAnalysis(state, segmentId);
if (resolved) {
const { link, analysis } = resolved;
analysis.surfaceText = surfaceText;
if (isBlank) {
if (analysis.freeTranslation) {
delete analysis.freeTranslation[lang];
if (Object.keys(analysis.freeTranslation).length === 0)
delete analysis.freeTranslation;
}
if (isEmptySegmentAnalysis(analysis)) removeSegmentAnalysis(state, analysis, link);
return;
}
if (!analysis.freeTranslation) analysis.freeTranslation = {};
analysis.freeTranslation[lang] = value;
return;
}
if (isBlank) return;
const newAnalysis: SegmentAnalysis = {
id,
surfaceText,
freeTranslation: { [lang]: value },
};
const newLink: SegmentAnalysisLink = { analysisId: id, status: 'approved', segmentId };
state.analysis.segmentAnalyses.push(newAnalysis);
state.analysis.segmentAnalysisLinks.push(newLink);
},
},
},
});
export const {
writeGloss,
writeMorphemes,
deleteMorphemes,
writeMorphemeGloss,
approveAnalysisForToken,
createPhrase,
updatePhrase,
deletePhrase,
mergePhrases,
writePhraseGloss,
writeSegmentFreeTranslation,
} = analysisSlice.actions;
export default analysisSlice.reducer;
// #endregion
// #region Selectors
/**
* Projects `tokenAnalyses` out of `AnalysisState` for use as a `createSelector` input.
*
* @param state - The analysis slice state.
* @returns The `tokenAnalyses` array.
*/
const selectTokenAnalyses = (state: AnalysisState) => state.analysis.tokenAnalyses;
/**
* Projects `tokenAnalysisLinks` out of `AnalysisState` for use as a `createSelector` input.
*
* @param state - The analysis slice state.
* @returns The `tokenAnalysisLinks` array.
*/
const selectTokenAnalysisLinks = (state: AnalysisState) => state.analysis.tokenAnalysisLinks;
/**
* Projects `analysisLanguage` out of `AnalysisState` for use as a `createSelector` input.
*
* @param state - The analysis slice state.
* @returns The active BCP 47 analysis language tag.
*/
export const selectAnalysisLanguage = (state: AnalysisState) => state.analysisLanguage;
/**
* Memoized selector that builds a `Map` from `TokenAnalysis.id` to `TokenAnalysis` for O(1) lookup.
* Recomputes only when `tokenAnalyses` changes reference.
*/
const selectAnalysisById = createSelector(
selectTokenAnalyses,
(tokenAnalyses) => new Map(tokenAnalyses.map((ta) => [ta.id, ta])),
);
/**
* Memoized selector that builds a `Map` from `tokenRef` to the approved `TokenAnalysis.id` for that
* token. Only the last approved link per token is indexed (the data model allows at most one).
* Recomputes only when `tokenAnalysisLinks` or `tokenAnalyses` change reference.
*/
const selectApprovedIdByTokenRef = createSelector(
selectTokenAnalysisLinks,
selectAnalysisById,
(links, byId) =>
links.reduce((index, link) => {
if (link.status === 'approved' && byId.has(link.analysisId)) {
index.set(link.token.tokenRef, link.analysisId);
}