diff --git a/AGENTS.md b/AGENTS.md
index 12823d9a8..1a3163569 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,7 +85,7 @@ Saved projects are listed through [ProjectSummaries](src/services/projectSummari
### Sessions
-[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on one source project's draft, paired with the interlinear project it saves to, or on a Paratext 9 import alone, read-only. A source has at most one session: opening another closes it. Each session runs its commands one at a time, through `run`, or `runEdit` for a command that changes stored analysis, which a read-only session refuses. The WebView doesn't use sessions yet.
+[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on one source project's draft, paired with the interlinear project it saves to, or on a Paratext 9 import alone, read-only. A source has at most one session: opening another closes it. Each session runs its commands one at a time, through `run`, or `runEdit` for a command that changes stored analysis, which a read-only session refuses. An edit reaches the repository as a `ChangeSet` of whole-record puts and deletes, applied through the session's [EditHistory](src/services/editHistory.ts) so that undo can revert it; boundary changes ride in the change set's scope record. The WebView doesn't use sessions yet.
### Components
diff --git a/src/__tests__/analysis-repository-conformance.ts b/src/__tests__/analysis-repository-conformance.ts
index 3359b941e..5205a8e66 100644
--- a/src/__tests__/analysis-repository-conformance.ts
+++ b/src/__tests__/analysis-repository-conformance.ts
@@ -1,10 +1,11 @@
///
import type { DraftProject, TextAnalysis } from 'interlinearizer';
-import type { AnalysisRepository, DraftExport } from '../types/analysis-repository';
+import type { AnalysisRepository, ChangeSet, DraftExport } from '../types/analysis-repository';
import { emptyAnalysis, emptyDraft } from '../types/empty-factories';
import { CURRENT_MODEL_VERSION } from '../types/model-version';
import { ReadOnlyAnalysisError } from '../types/read-only-analysis-error';
+import { scopeRecordOf } from '../utils/change-set';
import { FIXTURE_STAMPS, makePhraseLink, makeTokenLink } from './test-helpers';
/** One empty store an {@link AnalysisRepository} implementation keeps drafts in. */
@@ -196,6 +197,137 @@ function fullDraftWithoutGenesis(): DraftProject {
};
}
+/** One record of every kind in GEN, none of which {@link fullDraft} holds. */
+function newRecords() {
+ return {
+ segmentAnalyses: [
+ {
+ id: 'sa-gen',
+ ...FIXTURE_STAMPS,
+ surfaceText: 'In the beginning',
+ freeTranslation: { en: 'At first' },
+ },
+ ],
+ segmentAnalysisLinks: [
+ {
+ id: 'sl-gen',
+ analysisId: 'sa-gen',
+ ...FIXTURE_STAMPS,
+ status: 'approved' as const,
+ segmentId: 'GEN 1:1',
+ },
+ ],
+ tokenAnalyses: [{ id: 'ta-god', ...FIXTURE_STAMPS, surfaceText: 'God', gloss: { en: 'God' } }],
+ tokenAnalysisLinks: [
+ makeTokenLink('ta-god', 'GEN 1:1:17', { id: 'tl-gen-god', surfaceText: 'God' }),
+ ],
+ phraseAnalyses: [
+ {
+ id: 'pa-the-beginning',
+ ...FIXTURE_STAMPS,
+ surfaceText: 'the beginning',
+ gloss: { en: 'start' },
+ },
+ ],
+ phraseAnalysisLinks: [
+ makePhraseLink('pa-the-beginning', ['GEN 1:1:9', 'GEN 1:1:13'], ['the', 'beginning']),
+ ],
+ };
+}
+
+/** Puts {@link newRecords}, one list per record kind. */
+function puttingNewRecords(): ChangeSet {
+ const records = newRecords();
+ return {
+ intent: 'putNewRecords',
+ segmentAnalyses: { puts: records.segmentAnalyses, deletes: [] },
+ segmentAnalysisLinks: { puts: records.segmentAnalysisLinks, deletes: [] },
+ tokenAnalyses: { puts: records.tokenAnalyses, deletes: [] },
+ tokenAnalysisLinks: { puts: records.tokenAnalysisLinks, deletes: [] },
+ phraseAnalyses: { puts: records.phraseAnalyses, deletes: [] },
+ phraseAnalysisLinks: { puts: records.phraseAnalysisLinks, deletes: [] },
+ };
+}
+
+/** {@link fullDraft} holding {@link newRecords} as well. */
+function fullDraftWithNewRecords(): DraftProject {
+ const draft = fullDraft();
+ const records = newRecords();
+ const { analysis } = draft;
+ analysis.segmentAnalyses.push(...records.segmentAnalyses);
+ analysis.segmentAnalysisLinks.push(...records.segmentAnalysisLinks);
+ analysis.tokenAnalyses.push(...records.tokenAnalyses);
+ analysis.tokenAnalysisLinks.push(...records.tokenAnalysisLinks);
+ analysis.phraseAnalyses.push(...records.phraseAnalyses);
+ analysis.phraseAnalysisLinks.push(...records.phraseAnalysisLinks);
+ return draft;
+}
+
+/**
+ * Changes to {@link fullDraft} of every sort: new records and deleted ones in every kind, a replaced
+ * payload, and new boundaries in the scope's record.
+ */
+function mixedChanges(): ChangeSet {
+ const { analysis } = fullDraft();
+ const records = newRecords();
+ return {
+ intent: 'mixedChanges',
+ scope: {
+ ...scopeRecordOf(fullDraft()),
+ segmentation: {
+ removedVerseStarts: [],
+ addedStarts: [{ tokenRef: 'GEN 1:1:9', surfaceText: 'beginning' }],
+ },
+ },
+ segmentAnalyses: { puts: records.segmentAnalyses, deletes: ['sa-exo'] },
+ segmentAnalysisLinks: { puts: records.segmentAnalysisLinks, deletes: ['sl-exo'] },
+ tokenAnalyses: {
+ puts: [{ ...analysis.tokenAnalyses[0], gloss: { en: 'and then' } }, ...records.tokenAnalyses],
+ deletes: ['ta-unlinked'],
+ },
+ tokenAnalysisLinks: { puts: records.tokenAnalysisLinks, deletes: ['tl-gen-beginning'] },
+ phraseAnalyses: { puts: records.phraseAnalyses, deletes: ['pa-in-the'] },
+ phraseAnalysisLinks: { puts: records.phraseAnalysisLinks, deletes: ['pa-in-the'] },
+ };
+}
+
+/** Replaces EXO's segment payload with one carrying a new free translation. */
+function translatingExodus(): ChangeSet {
+ const [segment] = fullAnalysis().segmentAnalyses;
+ return {
+ intent: 'writeFreeTranslation',
+ segmentAnalyses: {
+ puts: [{ ...segment, freeTranslation: { en: 'And then.' } }],
+ deletes: [],
+ },
+ };
+}
+
+/** Deletes every record that only EXO holds, leaving EXO no stored analysis. */
+function deletingExodus(): ChangeSet {
+ return {
+ intent: 'deleteExodus',
+ segmentAnalyses: { puts: [], deletes: ['sa-exo'] },
+ segmentAnalysisLinks: { puts: [], deletes: ['sl-exo'] },
+ tokenAnalysisLinks: { puts: [], deletes: ['tl-exo-and'] },
+ };
+}
+
+/** {@link fullDraft} after {@link deletingExodus}. */
+function fullDraftWithoutExodus(): DraftProject {
+ const draft = fullDraft();
+ const { analysis } = draft;
+ return {
+ ...draft,
+ analysis: {
+ ...analysis,
+ segmentAnalyses: [],
+ segmentAnalysisLinks: [],
+ tokenAnalysisLinks: analysis.tokenAnalysisLinks.filter(({ id }) => id !== 'tl-exo-and'),
+ },
+ };
+}
+
/**
* The draft with each record list sorted by id. Lists of records promise no order, but the arrays
* inside a record keep theirs.
@@ -461,6 +593,13 @@ export function describeAnalysisRepositoryConformance(
await repository.replaceAll(draft);
await expect(reload(store)).resolves.toStrictEqual(inIdOrder(draft));
+ await store.damageBook(SOURCE, 'GEN', 'corrupt');
+ const {
+ draft: { analysis },
+ } = await reloadForCopy(store);
+ expect(analysis.tokenAnalyses.find(({ id }) => id === 'ta-and')?.gloss).toStrictEqual({
+ en: 'and then',
+ });
});
it('stamps what it saves with the current model version', async () => {
@@ -684,6 +823,380 @@ export function describeAnalysisRepositoryConformance(
});
});
+ describe('apply', () => {
+ it('puts new records of every kind, for the same repository and a newly opened one to load', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ await repository.apply(puttingNewRecords());
+
+ await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraftWithNewRecords()));
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraftWithNewRecords()));
+ });
+
+ it('replaces a stored record whole, dropping fields the put lacks', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const bare = { id: 'ta-beginning', ...FIXTURE_STAMPS, surfaceText: 'beginning' };
+
+ await repository.apply({ intent: 'replace', tokenAnalyses: { puts: [bare], deletes: [] } });
+
+ const { analysis } = await reload(store);
+ expect(analysis.tokenAnalyses.find(({ id }) => id === 'ta-beginning')).toStrictEqual(bare);
+ });
+
+ it('deletes records of every kind', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ await repository.apply(deletingExodus());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraftWithoutExodus()));
+ });
+
+ it('skips a delete of a record that is not stored, with nothing for its inverse to restore', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ const { inverse } = await repository.apply({
+ intent: 'deleteMissing',
+ tokenAnalyses: { puts: [], deletes: ['ta-missing'] },
+ });
+
+ expect(inverse).toStrictEqual({ intent: 'deleteMissing' });
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('applies to a draft this repository never loaded', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await (await store.open(SOURCE)).apply(puttingNewRecords());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraftWithNewRecords()));
+ });
+
+ it('applies to a draft never saved as to an empty one', async () => {
+ await (await store.open(SOURCE)).apply(puttingNewRecords());
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({
+ ...emptyDraft(SOURCE),
+ dirty: true,
+ analysis: { ...emptyAnalysis(), ...newRecords() },
+ }),
+ );
+ });
+
+ it('builds each apply on the one before it', async () => {
+ const repository = await store.open(SOURCE);
+ const { tokenAnalyses, tokenAnalysisLinks } = newRecords();
+
+ await repository.apply({
+ intent: 'first',
+ tokenAnalyses: { puts: tokenAnalyses, deletes: [] },
+ });
+ await repository.apply({
+ intent: 'second',
+ tokenAnalysisLinks: { puts: tokenAnalysisLinks, deletes: [] },
+ });
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({
+ ...emptyDraft(SOURCE),
+ dirty: true,
+ analysis: { ...emptyAnalysis(), tokenAnalyses, tokenAnalysisLinks },
+ }),
+ );
+ });
+
+ it('builds on a whole save made since the last apply', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.apply(puttingNewRecords());
+ await repository.replaceAll(fullDraft());
+
+ await repository.apply({
+ intent: 'deleteUnlinked',
+ tokenAnalyses: { puts: [], deletes: ['ta-unlinked'] },
+ });
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({
+ ...fullDraft(),
+ analysis: {
+ ...fullAnalysis(),
+ tokenAnalyses: fullAnalysis().tokenAnalyses.filter(({ id }) => id !== 'ta-unlinked'),
+ },
+ }),
+ );
+ });
+
+ it('applies over a draft another repository replaced, once reloaded', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.apply(puttingNewRecords());
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await repository.exportAll();
+
+ await repository.apply({
+ intent: 'deleteUnlinked',
+ tokenAnalyses: { puts: [], deletes: ['ta-unlinked'] },
+ });
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({
+ ...fullDraft(),
+ analysis: {
+ ...fullAnalysis(),
+ tokenAnalyses: fullAnalysis().tokenAnalyses.filter(({ id }) => id !== 'ta-unlinked'),
+ },
+ }),
+ );
+ });
+
+ it('saves an edit to a payload linked from several books, for both books to load', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const edited = { ...fullAnalysis().tokenAnalyses[0], gloss: { en: 'and then' } };
+
+ await repository.apply({
+ intent: 'writeGloss',
+ tokenAnalyses: { puts: [edited], deletes: [] },
+ });
+
+ const { analysis } = await reload(store);
+ expect(analysis.tokenAnalyses.filter(({ id }) => id === 'ta-and')).toStrictEqual([edited]);
+ expect(
+ analysis.tokenAnalysisLinks.filter(({ analysisId }) => analysisId === 'ta-and'),
+ ).toHaveLength(2);
+ await store.damageBook(SOURCE, 'GEN', 'corrupt');
+ const { draft } = await reloadForCopy(store);
+ expect(draft.analysis.tokenAnalyses.filter(({ id }) => id === 'ta-and')).toStrictEqual([
+ edited,
+ ]);
+ });
+
+ it("carries boundary changes in the scope's record", async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const segmentation = {
+ removedVerseStarts: [],
+ addedStarts: [{ tokenRef: 'GEN 1:1:9', surfaceText: 'beginning' }],
+ };
+
+ await repository.apply({
+ intent: 'splitSegment',
+ scope: { ...scopeRecordOf(fullDraft()), segmentation },
+ });
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({ ...fullDraft(), segmentation }),
+ );
+ });
+
+ it("clears the boundaries when the scope's record carries none", async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const { segmentation, ...withoutBoundaries } = scopeRecordOf(fullDraft());
+
+ await repository.apply({ intent: 'resetBoundaries', scope: withoutBoundaries });
+
+ const reloaded = await reload(store);
+ expect(segmentation).toBeDefined();
+ expect(reloaded).not.toHaveProperty('segmentation');
+ });
+
+ it('stamps what it applies with the current model version', async () => {
+ await (
+ await store.open(SOURCE)
+ ).apply({
+ intent: 'stamp',
+ scope: { ...scopeRecordOf(fullDraft()), modelVersion: CURRENT_MODEL_VERSION - 1 },
+ });
+
+ const { modelVersion } = await reload(store);
+
+ expect(modelVersion).toBe(CURRENT_MODEL_VERSION);
+ });
+
+ it('returns an inverse that undoes the changes, boundaries included', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ const { inverse } = await repository.apply(mixedChanges());
+ await repository.apply(inverse);
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it("redoes the changes when the inverse's inverse is applied", async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const { inverse } = await repository.apply(mixedChanges());
+ const changed = await reload(store);
+ const { inverse: redo } = await repository.apply(inverse);
+
+ await repository.apply(redo);
+
+ await expect(reload(store)).resolves.toStrictEqual(changed);
+ });
+
+ it('undoes the changes through a newly opened repository', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ const { inverse } = await (await store.open(SOURCE)).apply(mixedChanges());
+
+ await (await store.open(SOURCE)).apply(inverse);
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('names the intent of the changes in their inverse, as JSON-plain values', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ const { inverse } = await repository.apply(mixedChanges());
+
+ expect(inverse.intent).toBe('mixedChanges');
+ expectJsonPlain(inverse);
+ });
+
+ it('stores what it was given, unaffected by later changes to the changes or their inverse', async () => {
+ const repository = await store.open(SOURCE);
+ const changes = puttingNewRecords();
+ const { inverse } = await repository.apply(changes);
+ const applied = await reload(store);
+
+ changes.tokenAnalyses?.puts.push({
+ id: 'ta-later',
+ ...FIXTURE_STAMPS,
+ surfaceText: 'later',
+ });
+ inverse.tokenAnalyses?.deletes.push('ta-later');
+ const [god] = changes.tokenAnalyses?.puts ?? [];
+ god.surfaceText = 'changed';
+
+ await expect(load(repository)).resolves.toStrictEqual(applied);
+ await repository.apply({ intent: 'nothing' });
+ await expect(reload(store)).resolves.toStrictEqual(applied);
+ });
+
+ it('refuses an id named twice in one record kind, applying nothing', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const [and] = fullAnalysis().tokenAnalyses;
+
+ await expect(
+ repository.apply({
+ intent: 'conflicting',
+ tokenAnalyses: { puts: [{ ...and, gloss: { en: 'also' } }], deletes: ['ta-and'] },
+ }),
+ ).rejects.toThrow();
+
+ await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it("refuses a scope's record of another source project, applying nothing", async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ await expect(
+ repository.apply({
+ intent: 'wrongScope',
+ scope: { ...scopeRecordOf(fullDraft()), sourceProjectId: 'other-project' },
+ }),
+ ).rejects.toThrow();
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('keeps the drafts of different source projects apart', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await (await store.open('other-project')).apply(puttingNewRecords());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ await expect(reload(store, 'other-project')).resolves.toStrictEqual(
+ inIdOrder({
+ ...emptyDraft('other-project'),
+ dirty: true,
+ analysis: { ...emptyAnalysis(), ...newRecords() },
+ }),
+ );
+ });
+
+ it('marks a clean draft unsaved', async () => {
+ await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft());
+
+ await (await store.open(SOURCE)).apply(translatingExodus());
+
+ const { dirty } = await reload(store);
+ expect(dirty).toBe(true);
+ });
+
+ it('leaves the draft unsaved when undoing an edit made before it was marked clean', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(exodusOnlyDraft());
+ const { inverse } = await repository.apply(translatingExodus());
+ await repository.markClean();
+
+ await repository.apply(inverse);
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({ ...exodusOnlyDraft(), dirty: true }),
+ );
+ });
+
+ it('resolves once the changes have landed even if cleanup after them fails, and a later apply finishes that cleanup', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ const restore = store.failCleanup(SOURCE);
+
+ await (await store.open(SOURCE)).apply(deletingExodus());
+ await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(true);
+
+ restore();
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraftWithoutExodus()));
+ await (await store.open(SOURCE)).apply({ intent: 'nothing' });
+ await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(false);
+ });
+
+ it('leaves a draft written by a newer build in place, refusing to apply over it', async () => {
+ await store.storeNewerBuildDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).apply(puttingNewRecords())).rejects.toThrow();
+
+ await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow();
+ });
+ });
+
+ describe('apply after loading a book that could not be read', () => {
+ it('refuses changes that reach the book, applying nothing', async () => {
+ const repository = await loadedWithUnreadableGenesis(store);
+
+ await expect(repository.apply(puttingNewRecords())).rejects.toThrow();
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder(fullDraftWithoutGenesis()),
+ unreadableBooks: ['GEN'],
+ });
+ });
+
+ it('applies changes elsewhere, keeping the book as stored', async () => {
+ const repository = await loadedWithUnreadableGenesis(store);
+ const changes = translatingExodus();
+
+ await repository.apply(changes);
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder({
+ ...fullDraftWithoutGenesis(),
+ analysis: {
+ ...fullDraftWithoutGenesis().analysis,
+ segmentAnalyses: changes.segmentAnalyses?.puts ?? [],
+ },
+ }),
+ unreadableBooks: ['GEN'],
+ });
+ });
+ });
+
describe('capabilities', () => {
it("lets a source project's draft be written", async () => {
expect((await store.open(SOURCE)).capabilities.readOnly).toBe(false);
@@ -750,6 +1263,16 @@ export function describeAnalysisRepositoryConformance(
await expect(repository.markClean()).rejects.toThrow(ReadOnlyAnalysisError);
});
+ it('refuses to apply changes, leaving what it loads unchanged', async () => {
+ const repository = await store.openReadOnly(fullDraft());
+
+ await expect(repository.apply(puttingNewRecords())).rejects.toThrow(ReadOnlyAnalysisError);
+
+ await expect(analysisOf(repository)).resolves.toStrictEqual(
+ inIdOrder(fullDraft()).analysis,
+ );
+ });
+
it("keeps apart from the source project's draft", async () => {
await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft());
diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts
index ca39346ab..5d00fe95b 100644
--- a/src/__tests__/main.test.ts
+++ b/src/__tests__/main.test.ts
@@ -5,6 +5,7 @@ import papiBackendMock from '@papi/backend';
import { activate, deactivate } from '@main';
import type { InterlinearizerOpenOptions } from '@main';
import type { DraftProject, ProjectConfig } from 'interlinearizer';
+import { AnalysisSessions } from '../services/analysisSessions';
import * as bookService from '../services/bookService';
import * as hostPerf from '../services/hostPerf';
import * as projectStorage from '../services/projectStorage';
@@ -397,6 +398,8 @@ describe('main', () => {
'interlinearizer.openSession',
'interlinearizer.closeSession',
'interlinearizer.setAnalysisLanguage',
+ 'interlinearizer.undoEdit',
+ 'interlinearizer.redoEdit',
'interlinearizer.openSelectProjectModal',
'interlinearizer.openNewProjectModal',
'interlinearizer.openProjectInfoModal',
@@ -1830,6 +1833,7 @@ describe('main', () => {
exportForCopy: async () => ({ draft, unreadableBooks: [] }),
replaceAll: async () => [],
markClean: async () => {},
+ apply: async (changes) => ({ inverse: changes }),
};
}
@@ -1850,6 +1854,12 @@ describe('main', () => {
setAnalysisLanguage: handler<
(sessionId: string, analysisLanguage: string) => Promise
>('interlinearizer.setAnalysisLanguage'),
+ undoEdit: handler<(sessionId: string) => Promise>(
+ 'interlinearizer.undoEdit',
+ ),
+ redoEdit: handler<(sessionId: string) => Promise>(
+ 'interlinearizer.redoEdit',
+ ),
};
}
@@ -1960,6 +1970,58 @@ describe('main', () => {
);
});
+ it('replies to an undo with the changes it applied', async () => {
+ const reverted = { intent: 'writeGloss', tokenAnalyses: { puts: [], deletes: ['ta-1'] } };
+ jest.spyOn(AnalysisSessions.prototype, 'undo').mockResolvedValue(reverted);
+ const { undoEdit } = await activateSessionCommands();
+
+ await expect(undoEdit('session-id')).resolves.toBe(JSON.stringify(reverted));
+ expect(AnalysisSessions.prototype.undo).toHaveBeenCalledWith('session-id');
+ });
+
+ it('replies to an undo with nothing when there is no edit to undo', async () => {
+ const { openSession, undoEdit } = await activateSessionCommands();
+ const { sessionId } = JSON.parse(await openSession('src-project'));
+
+ await expect(undoEdit(sessionId)).resolves.toBeUndefined();
+ });
+
+ it('logs the error and rethrows when an undo fails', async () => {
+ const { undoEdit } = await activateSessionCommands();
+
+ await expect(undoEdit('not-a-session')).rejects.toThrow('no session not-a-session is open');
+ expect(__mockLogger.error).toHaveBeenCalledWith(
+ 'Interlinearizer: failed to undo an edit in session not-a-session',
+ expect.any(Error),
+ );
+ });
+
+ it('replies to a redo with the changes it applied', async () => {
+ const remade = { intent: 'writeGloss', tokenAnalyses: { puts: [], deletes: ['ta-1'] } };
+ jest.spyOn(AnalysisSessions.prototype, 'redo').mockResolvedValue(remade);
+ const { redoEdit } = await activateSessionCommands();
+
+ await expect(redoEdit('session-id')).resolves.toBe(JSON.stringify(remade));
+ expect(AnalysisSessions.prototype.redo).toHaveBeenCalledWith('session-id');
+ });
+
+ it('replies to a redo with nothing when there is no undone edit', async () => {
+ const { openSession, redoEdit } = await activateSessionCommands();
+ const { sessionId } = JSON.parse(await openSession('src-project'));
+
+ await expect(redoEdit(sessionId)).resolves.toBeUndefined();
+ });
+
+ it('logs the error and rethrows when a redo fails', async () => {
+ const { redoEdit } = await activateSessionCommands();
+
+ await expect(redoEdit('not-a-session')).rejects.toThrow('no session not-a-session is open');
+ expect(__mockLogger.error).toHaveBeenCalledWith(
+ 'Interlinearizer: failed to redo an edit in session not-a-session',
+ expect.any(Error),
+ );
+ });
+
it('closes every session on deactivation', async () => {
const { openSession } = await activateSessionCommands();
await openSession('src-project');
diff --git a/src/__tests__/services/analysisSessions.test.ts b/src/__tests__/services/analysisSessions.test.ts
index 55c51a7c7..0d5688c03 100644
--- a/src/__tests__/services/analysisSessions.test.ts
+++ b/src/__tests__/services/analysisSessions.test.ts
@@ -422,6 +422,57 @@ describe('AnalysisSessions', () => {
});
});
+ describe('undo and redo', () => {
+ const putting = (id: string) => ({
+ intent: `put ${id}`,
+ tokenAnalyses: { puts: [{ id, ...FIXTURE_STAMPS, surfaceText: id }], deletes: [] },
+ });
+
+ /** The ids of the token payloads the source project's stored draft holds. */
+ async function storedIds(storage: ReturnType['storage']) {
+ const { draft } = await new DraftRepository(storage, SOURCE, silentLogger()).exportAll();
+ return draft.analysis.tokenAnalyses.map(({ id }) => id);
+ }
+
+ it("undoes and redoes an edit applied through the session's history", async () => {
+ const { sessions, storage } = makeSessions();
+ const { sessionId } = await sessions.open(SOURCE);
+ await sessions.runEdit(sessionId, (session) => session.history.apply(putting('a')));
+
+ await expect(sessions.undo(sessionId)).resolves.toStrictEqual({
+ intent: 'put a',
+ tokenAnalyses: { puts: [], deletes: ['a'] },
+ });
+ await expect(storedIds(storage)).resolves.toStrictEqual([]);
+ await expect(sessions.redo(sessionId)).resolves.toStrictEqual(putting('a'));
+ await expect(storedIds(storage)).resolves.toStrictEqual(['a']);
+ });
+
+ it('starts a reopened source with nothing to undo', async () => {
+ const { sessions } = makeSessions();
+ const earlier = await sessions.open(SOURCE);
+ await sessions.runEdit(earlier.sessionId, (session) => session.history.apply(putting('a')));
+
+ const { sessionId } = await sessions.open(SOURCE);
+
+ await expect(sessions.undo(sessionId)).resolves.toBeUndefined();
+ });
+
+ it("refuses an undo on a Paratext 9 import's session", async () => {
+ const { sessions } = makeSessions([makeImport()]);
+ const { sessionId } = await sessions.open(SOURCE, 'import-id');
+
+ await expect(sessions.undo(sessionId)).rejects.toThrow(ReadOnlyAnalysisError);
+ });
+
+ it("refuses a redo on a Paratext 9 import's session", async () => {
+ const { sessions } = makeSessions([makeImport()]);
+ const { sessionId } = await sessions.open(SOURCE, 'import-id');
+
+ await expect(sessions.redo(sessionId)).rejects.toThrow(ReadOnlyAnalysisError);
+ });
+ });
+
describe('setAnalysisLanguage', () => {
it('sets the language the commands sent after it read', async () => {
const { sessions } = makeSessions();
diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts
index 7fdbbb88f..04c4561ea 100644
--- a/src/__tests__/services/draftRepository.test.ts
+++ b/src/__tests__/services/draftRepository.test.ts
@@ -248,4 +248,116 @@ describe('DraftRepository', () => {
expect(records.has(draftAnalysisKey('src', 'GEN'))).toBe(false);
});
});
+
+ describe('apply', () => {
+ it('rewrites only the books whose records the changes alter', async () => {
+ const { storage, writes } = memoryStorage();
+ const repository = new DraftRepository(storage, 'src', silentLogger());
+ await repository.replaceAll(twoBookDraft());
+ writes.length = 0;
+ const [segment] = twoBookAnalysis().segmentAnalyses;
+
+ await repository.apply({
+ intent: 'writeFreeTranslation',
+ segmentAnalyses: {
+ puts: [{ ...segment, freeTranslation: { en: 'And then.' } }],
+ deletes: [],
+ },
+ });
+
+ expect(writes).toContain(draftAnalysisKey('src', 'EXO'));
+ expect(writes).not.toContain(draftAnalysisKey('src', 'GEN'));
+ });
+
+ it('rewrites every book linking a payload it edits', async () => {
+ const { storage, records } = memoryStorage();
+ const repository = new DraftRepository(storage, 'src', silentLogger());
+ await repository.replaceAll(twoBookDraft());
+ const [payload] = twoBookAnalysis().tokenAnalyses;
+
+ await repository.apply({
+ intent: 'writeGloss',
+ tokenAnalyses: { puts: [{ ...payload, gloss: { en: 'and then' } }], deletes: [] },
+ });
+
+ expect(records.get(draftAnalysisKey('src', 'GEN'))).toContain('and then');
+ expect(records.get(draftAnalysisKey('src', 'EXO'))).toContain('and then');
+ });
+
+ describe('with a book whose shard could not be read', () => {
+ /** A repository that loaded {@link twoBookDraft} with its GEN shard unusable. */
+ async function loadedWithUnusableGenesis() {
+ const { storage, records, writes } = memoryStorage();
+ await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
+ records.set(draftAnalysisKey('src', 'GEN'), 'not json');
+ const repository = new DraftRepository(storage, 'src', silentLogger());
+ await repository.exportAll();
+ writes.length = 0;
+ return { repository, records, writes };
+ }
+
+ it('refuses changes that reach that book, writing nothing', async () => {
+ const { repository, records, writes } = await loadedWithUnusableGenesis();
+ const [payload] = twoBookAnalysis().tokenAnalyses;
+
+ await expect(
+ repository.apply({
+ intent: 'writeGloss',
+ tokenAnalyses: { puts: [{ ...payload, gloss: { en: 'and then' } }], deletes: [] },
+ tokenAnalysisLinks: { puts: twoBookAnalysis().tokenAnalysisLinks, deletes: [] },
+ }),
+ ).rejects.toThrow('could not read the stored analysis of GEN');
+
+ expect(writes).toEqual([]);
+ expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json');
+ });
+
+ it('applies changes elsewhere, keeping its shard', async () => {
+ const { repository, records } = await loadedWithUnusableGenesis();
+ const [segment] = twoBookAnalysis().segmentAnalyses;
+
+ await repository.apply({
+ intent: 'writeFreeTranslation',
+ segmentAnalyses: {
+ puts: [{ ...segment, freeTranslation: { en: 'And then.' } }],
+ deletes: [],
+ },
+ });
+
+ expect(records.get(draftAnalysisKey('src', 'EXO'))).toContain('And then.');
+ expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json');
+ });
+ });
+
+ it('keeps its draft as it was when a write fails, so the next apply leaves the failed one out', async () => {
+ const { storage, records } = memoryStorage();
+ const repository = new DraftRepository(storage, 'src', silentLogger());
+ await repository.replaceAll(twoBookDraft());
+ const [payload] = twoBookAnalysis().tokenAnalyses;
+ const [segment] = twoBookAnalysis().segmentAnalyses;
+ const write = jest.spyOn(storage, 'write').mockRejectedValueOnce(new Error('disk full'));
+
+ await expect(
+ repository.apply({
+ intent: 'writeGloss',
+ tokenAnalyses: { puts: [{ ...payload, gloss: { en: 'and then' } }], deletes: [] },
+ }),
+ ).rejects.toThrow('disk full');
+ write.mockRestore();
+ await repository.apply({
+ intent: 'writeFreeTranslation',
+ segmentAnalyses: {
+ puts: [{ ...segment, freeTranslation: { en: 'And then.' } }],
+ deletes: [],
+ },
+ });
+
+ const {
+ draft: { analysis },
+ } = await new DraftRepository(storage, 'src', silentLogger()).exportAll();
+ expect(analysis.tokenAnalyses[0].gloss).toEqual({ en: 'and' });
+ expect(analysis.segmentAnalyses[0].freeTranslation).toEqual({ en: 'And then.' });
+ expect(records.get(draftAnalysisKey('src', 'GEN'))).not.toContain('and then');
+ });
+ });
});
diff --git a/src/__tests__/services/editHistory.test.ts b/src/__tests__/services/editHistory.test.ts
new file mode 100644
index 000000000..9a9693cb4
--- /dev/null
+++ b/src/__tests__/services/editHistory.test.ts
@@ -0,0 +1,137 @@
+///
+
+import type { TokenAnalysis } from 'interlinearizer';
+import { DraftRepository } from '../../services/draftRepository';
+import { EditHistory } from '../../services/editHistory';
+import type { ChangeSet } from '../../types/analysis-repository';
+import { MAX_UNDO_STEPS } from '../../utils/undo-history';
+import { FIXTURE_STAMPS, memoryStorage, silentLogger } from '../test-helpers';
+
+function payload(id: string): TokenAnalysis {
+ return { id, ...FIXTURE_STAMPS, surfaceText: id };
+}
+
+function putting(id: string): ChangeSet {
+ return { intent: `put ${id}`, tokenAnalyses: { puts: [payload(id)], deletes: [] } };
+}
+
+/** A history over an empty draft in memory, with a way to read back the stored payload ids. */
+function makeHistory() {
+ const { storage } = memoryStorage();
+ const repository = new DraftRepository(storage, 'src', silentLogger());
+ const storedIds = async () =>
+ (
+ await new DraftRepository(storage, 'src', silentLogger()).exportAll()
+ ).draft.analysis.tokenAnalyses
+ .map(({ id }) => id)
+ .sort();
+ return { history: new EditHistory(repository), repository, storedIds };
+}
+
+describe('EditHistory', () => {
+ it('applies an edit through its repository', async () => {
+ const { history, storedIds } = makeHistory();
+
+ await history.apply(putting('a'));
+
+ await expect(storedIds()).resolves.toStrictEqual(['a']);
+ });
+
+ it('undoes edits latest first, returning what reverted each', async () => {
+ const { history, storedIds } = makeHistory();
+ await history.apply(putting('a'));
+ await history.apply(putting('b'));
+
+ await expect(history.undo()).resolves.toStrictEqual({
+ intent: 'put b',
+ tokenAnalyses: { puts: [], deletes: ['b'] },
+ });
+ await expect(storedIds()).resolves.toStrictEqual(['a']);
+ await history.undo();
+ await expect(storedIds()).resolves.toStrictEqual([]);
+ });
+
+ it('redoes undone edits latest undone first, returning what made each again', async () => {
+ const { history, storedIds } = makeHistory();
+ await history.apply(putting('a'));
+ await history.apply(putting('b'));
+ await history.undo();
+ await history.undo();
+
+ await expect(history.redo()).resolves.toStrictEqual({
+ intent: 'put a',
+ tokenAnalyses: { puts: [payload('a')], deletes: [] },
+ });
+ await expect(storedIds()).resolves.toStrictEqual(['a']);
+ await history.redo();
+ await expect(storedIds()).resolves.toStrictEqual(['a', 'b']);
+ });
+
+ it('has nothing to undo before any edit', async () => {
+ await expect(makeHistory().history.undo()).resolves.toBeUndefined();
+ });
+
+ it('has nothing to redo before any undo', async () => {
+ const { history } = makeHistory();
+ await history.apply(putting('a'));
+
+ await expect(history.redo()).resolves.toBeUndefined();
+ });
+
+ it(`forgets the oldest edit once more than ${MAX_UNDO_STEPS} are undoable`, async () => {
+ const { history, storedIds } = makeHistory();
+ const times = (count: number, step: (i: number) => Promise) =>
+ Array.from({ length: count }).reduce>(
+ (previous, _, i) => previous.then(() => step(i)),
+ Promise.resolve(),
+ );
+ await times(MAX_UNDO_STEPS + 1, (i) => history.apply(putting(`p${i}`)));
+ await times(MAX_UNDO_STEPS, () => history.undo());
+
+ await expect(history.undo()).resolves.toBeUndefined();
+ await expect(storedIds()).resolves.toStrictEqual(['p0']);
+ });
+
+ it('drops the undone edits when a new edit is applied', async () => {
+ const { history, storedIds } = makeHistory();
+ await history.apply(putting('a'));
+ await history.undo();
+
+ await history.apply(putting('b'));
+
+ await expect(history.redo()).resolves.toBeUndefined();
+ await expect(storedIds()).resolves.toStrictEqual(['b']);
+ });
+
+ it('records no step for an edit that fails', async () => {
+ const { history, repository } = makeHistory();
+ jest.spyOn(repository, 'apply').mockRejectedValueOnce(new Error('disk full'));
+
+ await expect(history.apply(putting('a'))).rejects.toThrow('disk full');
+
+ await expect(history.undo()).resolves.toBeUndefined();
+ });
+
+ it('keeps an edit to undo when undoing it fails', async () => {
+ const { history, repository, storedIds } = makeHistory();
+ await history.apply(putting('a'));
+ jest.spyOn(repository, 'apply').mockRejectedValueOnce(new Error('disk full'));
+
+ await expect(history.undo()).rejects.toThrow('disk full');
+
+ await history.undo();
+ await expect(storedIds()).resolves.toStrictEqual([]);
+ });
+
+ it('keeps an undone edit to redo when redoing it fails', async () => {
+ const { history, repository, storedIds } = makeHistory();
+ await history.apply(putting('a'));
+ await history.undo();
+ jest.spyOn(repository, 'apply').mockRejectedValueOnce(new Error('disk full'));
+
+ await expect(history.redo()).rejects.toThrow('disk full');
+
+ await history.redo();
+ await expect(storedIds()).resolves.toStrictEqual(['a']);
+ });
+});
diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts
index 40664973a..24a6a63ab 100644
--- a/src/__tests__/services/projectStorage.test.ts
+++ b/src/__tests__/services/projectStorage.test.ts
@@ -1574,6 +1574,24 @@ describe('projectStorage', () => {
});
});
+ it('leaves the stored draft dirty when changes were applied after the copy read it', async () => {
+ const stored = backStorageWithMap();
+ storeListedProject(stored, makeStubProject('proj-id'));
+ await saveDraft(token, 'src-proj', {
+ ...makeDraftSpanningBooks('src-proj', 'GEN'),
+ dirty: true,
+ });
+
+ const save = saveDraftToProject(token, 'src-proj', 'proj-id');
+ const apply = openDraftRepository(token, 'src-proj').apply({
+ intent: 'deleteLink',
+ tokenAnalysisLinks: { puts: [], deletes: ['link-GEN'] },
+ });
+ await Promise.all([save, apply]);
+
+ expect((await getDraft(token, 'src-proj')).draft.dirty).toBe(true);
+ });
+
it('does not mark the draft clean when the project is gone', async () => {
backStorageWithMap();
await saveDraft(token, 'src-proj', {
@@ -2883,6 +2901,20 @@ describe('projectStorage', () => {
expect(writtenKeys()).not.toContain('draft:src-proj:analysis:JHN');
});
+ it('applies changes to the draft saveDraft saved', async () => {
+ await saveDraft(token, 'src-proj', makeDraftSpanningBooks('src-proj', 'GEN'));
+
+ await openDraftRepository(token, 'src-proj').apply({
+ intent: 'deleteLink',
+ tokenAnalysisLinks: { puts: [], deletes: ['link-GEN'] },
+ });
+
+ const {
+ draft: { analysis },
+ } = await getDraft(token, 'src-proj');
+ expect(analysis.tokenAnalysisLinks).toStrictEqual([]);
+ });
+
it('waits for a draft call already made before reading', async () => {
let finishWrite: () => void = () => {};
__mockWriteUserData.mockImplementationOnce(
diff --git a/src/__tests__/services/pt9ImportRepository.test.ts b/src/__tests__/services/pt9ImportRepository.test.ts
index 111d87f06..8a8c29420 100644
--- a/src/__tests__/services/pt9ImportRepository.test.ts
+++ b/src/__tests__/services/pt9ImportRepository.test.ts
@@ -115,4 +115,16 @@ describe('Pt9ImportRepository', () => {
expect(readProject).not.toHaveBeenCalled();
});
});
+
+ describe('apply', () => {
+ it('refuses every change', async () => {
+ const readProject = jest.fn(async () => makeImport());
+ const repository: AnalysisRepository = new Pt9ImportRepository('import-id', readProject);
+
+ await expect(repository.apply({ intent: 'writeGloss' })).rejects.toThrow(
+ ReadOnlyAnalysisError,
+ );
+ expect(readProject).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/__tests__/utils/change-set.test.ts b/src/__tests__/utils/change-set.test.ts
new file mode 100644
index 000000000..26f0cb087
--- /dev/null
+++ b/src/__tests__/utils/change-set.test.ts
@@ -0,0 +1,207 @@
+///
+
+import type { DraftProject, TokenAnalysis } from 'interlinearizer';
+import { emptyAnalysis, emptyDraft } from '../../types/empty-factories';
+import { applyChangeSet, scopeRecordOf } from '../../utils/change-set';
+import { FIXTURE_STAMPS } from '../test-helpers';
+
+function payload(id: string, gloss?: string): TokenAnalysis {
+ return { id, ...FIXTURE_STAMPS, surfaceText: id, ...(gloss && { gloss: { en: gloss } }) };
+}
+
+function draftOf(...tokenAnalyses: TokenAnalysis[]): DraftProject {
+ return {
+ ...emptyDraft('src'),
+ analysisLanguages: ['en'],
+ analysis: { ...emptyAnalysis(), tokenAnalyses },
+ };
+}
+
+describe('scopeRecordOf', () => {
+ it("returns the draft's record without its analysis or its dirty flag", () => {
+ const draft = { ...draftOf(payload('a')), suggestedName: 'Draft', dirty: true };
+
+ expect(scopeRecordOf(draft)).toStrictEqual({
+ sourceProjectId: 'src',
+ modelVersion: draft.modelVersion,
+ analysisLanguages: ['en'],
+ suggestedName: 'Draft',
+ });
+ });
+});
+
+describe('applyChangeSet', () => {
+ it('puts a new record after those stored, restored by deleting it', () => {
+ const { draft, inverse } = applyChangeSet(draftOf(payload('a')), {
+ intent: 'add',
+ tokenAnalyses: { puts: [payload('b')], deletes: [] },
+ });
+
+ expect(draft.analysis.tokenAnalyses).toStrictEqual([payload('a'), payload('b')]);
+ expect(inverse).toStrictEqual({
+ intent: 'add',
+ tokenAnalyses: { puts: [], deletes: ['b'] },
+ });
+ });
+
+ it('replaces a stored record in its place, restored by putting it back', () => {
+ const { draft, inverse } = applyChangeSet(draftOf(payload('a'), payload('b'), payload('c')), {
+ intent: 'edit',
+ tokenAnalyses: { puts: [payload('b', 'bee')], deletes: [] },
+ });
+
+ expect(draft.analysis.tokenAnalyses).toStrictEqual([
+ payload('a'),
+ payload('b', 'bee'),
+ payload('c'),
+ ]);
+ expect(inverse.tokenAnalyses).toStrictEqual({ puts: [payload('b')], deletes: [] });
+ });
+
+ it('deletes a stored record, restored by putting it back', () => {
+ const { draft, inverse } = applyChangeSet(draftOf(payload('a'), payload('b')), {
+ intent: 'delete',
+ tokenAnalyses: { puts: [], deletes: ['a'] },
+ });
+
+ expect(draft.analysis.tokenAnalyses).toStrictEqual([payload('b')]);
+ expect(inverse.tokenAnalyses).toStrictEqual({ puts: [payload('a')], deletes: [] });
+ });
+
+ it('leaves out of the inverse a kind whose changes replaced and added nothing', () => {
+ const { inverse } = applyChangeSet(draftOf(payload('a')), {
+ intent: 'deleteMissing',
+ tokenAnalyses: { puts: [], deletes: ['missing'] },
+ });
+
+ expect(inverse).toStrictEqual({ intent: 'deleteMissing' });
+ });
+
+ it("puts the scope's record, restored by putting the one it replaced", () => {
+ const stored = { ...draftOf(), dirty: true, suggestedName: 'Old' };
+ const scope = {
+ ...scopeRecordOf(draftOf()),
+ segmentation: { removedVerseStarts: ['GEN 1:2'], addedStarts: [] },
+ };
+
+ const { draft, inverse } = applyChangeSet(stored, { intent: 'boundaries', scope });
+
+ expect(draft).toStrictEqual({ ...scope, dirty: true, analysis: stored.analysis });
+ expect(inverse.scope).toStrictEqual(scopeRecordOf(stored));
+ });
+
+ it('marks the draft unsaved, leaving that out of the inverse', () => {
+ const { draft, inverse } = applyChangeSet(draftOf(payload('a')), {
+ intent: 'edit',
+ scope: scopeRecordOf(draftOf()),
+ tokenAnalyses: { puts: [payload('a', 'ay')], deletes: [] },
+ });
+
+ expect(draft.dirty).toBe(true);
+ expect(inverse.scope).not.toHaveProperty('dirty');
+ });
+
+ it('leaves the draft it was given as it was', () => {
+ const stored = draftOf(payload('a'));
+
+ applyChangeSet(stored, {
+ intent: 'edit',
+ scope: { ...scopeRecordOf(stored), suggestedName: 'New' },
+ tokenAnalyses: { puts: [payload('a', 'ay'), payload('b')], deletes: [] },
+ });
+
+ expect(stored).toStrictEqual(draftOf(payload('a')));
+ });
+
+ it('holds copies of the records the changes put', () => {
+ const put = payload('b');
+ const scope = scopeRecordOf(draftOf());
+ const { draft } = applyChangeSet(draftOf(), {
+ intent: 'add',
+ scope,
+ tokenAnalyses: { puts: [put], deletes: [] },
+ });
+
+ put.surfaceText = 'changed';
+ scope.analysisLanguages.push('fr');
+
+ expect(draft.analysis.tokenAnalyses).toStrictEqual([payload('b')]);
+ expect(draft.analysisLanguages).toStrictEqual(['en']);
+ });
+
+ it('refuses an id both put and deleted', () => {
+ expect(() =>
+ applyChangeSet(draftOf(payload('a')), {
+ intent: 'conflict',
+ tokenAnalyses: { puts: [payload('a', 'ay')], deletes: ['a'] },
+ }),
+ ).toThrow('a change set names token analysis record a more than once');
+ });
+
+ it('refuses an id put twice', () => {
+ expect(() =>
+ applyChangeSet(draftOf(), {
+ intent: 'conflict',
+ tokenAnalyses: { puts: [payload('a'), payload('a', 'ay')], deletes: [] },
+ }),
+ ).toThrow('a change set names token analysis record a more than once');
+ });
+
+ it("refuses a scope's record of another source project", () => {
+ expect(() =>
+ applyChangeSet(draftOf(), {
+ intent: 'wrongScope',
+ scope: { ...scopeRecordOf(draftOf()), sourceProjectId: 'other' },
+ }),
+ ).toThrow('a change set for source project other cannot apply to the draft of src');
+ });
+
+ it('applies the changes of every record kind', () => {
+ const link = {
+ id: 'link',
+ analysisId: 'a',
+ ...FIXTURE_STAMPS,
+ status: 'approved' as const,
+ };
+ const { draft } = applyChangeSet(draftOf(), {
+ intent: 'everyKind',
+ segmentAnalyses: { puts: [{ id: 'sa', ...FIXTURE_STAMPS, surfaceText: 'In' }], deletes: [] },
+ segmentAnalysisLinks: {
+ puts: [{ ...link, id: 'sl', analysisId: 'sa', segmentId: 'GEN 1:1' }],
+ deletes: [],
+ },
+ tokenAnalyses: { puts: [payload('a')], deletes: [] },
+ tokenAnalysisLinks: {
+ puts: [{ ...link, id: 'tl', token: { tokenRef: 'GEN 1:1:0', surfaceText: 'In' } }],
+ deletes: [],
+ },
+ phraseAnalyses: {
+ puts: [{ id: 'pa', ...FIXTURE_STAMPS, surfaceText: 'In the' }],
+ deletes: [],
+ },
+ phraseAnalysisLinks: {
+ puts: [
+ {
+ ...link,
+ id: 'pl',
+ analysisId: 'pa',
+ tokens: [{ tokenRef: 'GEN 1:1:0', surfaceText: 'In' }],
+ },
+ ],
+ deletes: [],
+ },
+ });
+
+ const { analysis } = draft;
+ expect(
+ [
+ analysis.segmentAnalyses,
+ analysis.segmentAnalysisLinks,
+ analysis.tokenAnalyses,
+ analysis.tokenAnalysisLinks,
+ analysis.phraseAnalyses,
+ analysis.phraseAnalysisLinks,
+ ].map((records) => records.map(({ id }) => id)),
+ ).toStrictEqual([['sa'], ['sl'], ['a'], ['tl'], ['pa'], ['pl']]);
+ });
+});
diff --git a/src/main.ts b/src/main.ts
index 2a0ee018f..6d5af1dcb 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -773,6 +773,40 @@ async function setAnalysisLanguage(sessionId: string, analysisLanguage: string):
}
}
+/**
+ * Reverts a session's latest edit not yet undone.
+ *
+ * @returns The changes that reverted it, as JSON, or `undefined` when there is none.
+ * @throws If the session is read-only or not open, or the changes cannot be applied. The error is
+ * logged before rethrowing.
+ */
+async function undoEdit(sessionId: string): Promise {
+ try {
+ const changes = await sessions.undo(sessionId);
+ return changes && JSON.stringify(changes);
+ } catch (e) {
+ logger.error(`Interlinearizer: failed to undo an edit in session ${sessionId}`, e);
+ throw e;
+ }
+}
+
+/**
+ * Redoes a session's latest undone edit.
+ *
+ * @returns The changes that redid it, as JSON, or `undefined` when there is none.
+ * @throws If the session is read-only or not open, or the changes cannot be applied. The error is
+ * logged before rethrowing.
+ */
+async function redoEdit(sessionId: string): Promise {
+ try {
+ const changes = await sessions.redo(sessionId);
+ return changes && JSON.stringify(changes);
+ } catch (e) {
+ logger.error(`Interlinearizer: failed to redo an edit in session ${sessionId}`, e);
+ throw e;
+ }
+}
+
/**
* Returns a summary of every interlinearizer project for the given source project as a JSON string.
* The WebView deserializes this to populate its project picker and to decide whether to prompt
@@ -1347,6 +1381,37 @@ export async function activate(context: ExecutionActivationContext): Promise setHostPerfMarks(enabled),
@@ -1720,6 +1785,8 @@ export async function activate(context: ExecutionActivationContext): Promise {
+ return this.runEdit(sessionId, (session) => session.history.undo());
+ }
+
+ /**
+ * Redoes the session's latest undone edit.
+ *
+ * @returns The changes that redid it, or `undefined` when there is none.
+ * @throws {ReadOnlyAnalysisError} If the session is read-only.
+ * @throws {Error} If the session is not open.
+ * @throws Whatever applying the changes throws; the edit stays the next to redo.
+ */
+ redo(sessionId: string): Promise {
+ return this.runEdit(sessionId, (session) => session.history.redo());
+ }
+
/**
* Resolves the scope for a session on the source project, paired with `interlinearProjectId` when
* given.
diff --git a/src/services/draftRepository.ts b/src/services/draftRepository.ts
index 7b5c71c8e..6f2f2cf9b 100644
--- a/src/services/draftRepository.ts
+++ b/src/services/draftRepository.ts
@@ -1,6 +1,8 @@
import type { DraftProject, TextAnalysis } from 'interlinearizer';
import type {
AnalysisRepository,
+ ApplyResult,
+ ChangeSet,
DraftExport,
KeyValueStorage,
ReplaceAllOptions,
@@ -17,6 +19,7 @@ import {
} from '../types/type-guards';
import { mergeAnalyses, splitAnalysisByBook } from '../utils/analysis-book';
import { reportAnalysisViolations } from '../utils/analysis-violations';
+import { applyChangeSet } from '../utils/change-set';
import { perfTime, perfTimeAsync, perfTimeAsyncExcluding } from '../utils/perf-marks';
import { draftAnalysisKey, draftJournalKey, draftKey } from './storageKeys';
@@ -125,6 +128,12 @@ export class DraftRepository implements AnalysisRepository {
/** Whether this instance has loaded the stored draft, so it knows which shards it cannot read. */
private loaded = false;
+ /**
+ * The draft as this instance's last apply left it, which the next apply builds on. Dropped by
+ * every load and every whole save, since either may come from a caller holding another draft.
+ */
+ private current: DraftProject | undefined;
+
constructor(storage: KeyValueStorage, sourceProjectId: string, logger: WarningLogger) {
this.storage = storage;
this.logger = logger;
@@ -153,6 +162,7 @@ export class DraftRepository implements AnalysisRepository {
* @throws If storage rejects a read.
*/
async exportAll(): Promise {
+ this.current = undefined;
const exported = await this.readDraft(false);
this.loaded = true;
return exported;
@@ -169,6 +179,7 @@ export class DraftRepository implements AnalysisRepository {
* @throws If storage rejects a read.
*/
async exportForCopy(): Promise {
+ this.current = undefined;
const exported = await this.readDraft(true);
this.loaded = true;
return exported;
@@ -226,6 +237,7 @@ export class DraftRepository implements AnalysisRepository {
* @throws If storage rejects a write.
*/
async replaceAll(draft: DraftProject, options?: ReplaceAllOptions): Promise {
+ this.current = undefined;
if (!this.loaded) {
try {
await this.exportAll();
@@ -234,6 +246,42 @@ export class DraftRepository implements AnalysisRepository {
if (!(e instanceof SyntaxError)) throw e;
}
}
+ return this.writeDraft(draft, options, { refuseUnreadable: false });
+ }
+
+ /**
+ * Applies the changes to the draft as this instance's last apply left it, or to the stored draft
+ * when it has made none since its last whole load or save. Only the books whose content the
+ * changes alter are rewritten, so an edit to a payload rewrites every book linking it.
+ *
+ * @throws {Error} If an id appears more than once in one record kind's changes, or the scope's
+ * record names another source project; nothing is written.
+ * @throws {Error} If the changes reach a book whose shard could not be read; nothing is written.
+ * @throws {SyntaxError} If the draft's stored value contains invalid JSON.
+ * @throws {Error} If the stored draft was written by a newer build; nothing is written.
+ * @throws If storage rejects a read or a write, as {@link replaceAll} does; this instance's draft
+ * is left as it was.
+ */
+ async apply(changes: ChangeSet): Promise {
+ this.current ??= (await this.exportAll()).draft;
+ const { draft, inverse } = applyChangeSet(this.current, changes);
+ await this.writeDraft(draft, undefined, { refuseUnreadable: true });
+ this.current = draft;
+ return { inverse };
+ }
+
+ /**
+ * Saves `draft` as {@link replaceAll} describes.
+ *
+ * @throws {Error} If `refuseUnreadable` is set and the draft carries analysis for a book whose
+ * shard could not be read; nothing is written.
+ * @throws Whatever {@link replaceAll} throws.
+ */
+ private async writeDraft(
+ draft: DraftProject,
+ options: ReplaceAllOptions | undefined,
+ { refuseUnreadable }: { refuseUnreadable: boolean },
+ ): Promise {
const { sourceProjectId, storage, shardContents: saved, unreadableShards: unreadable } = this;
const { analysis } = draft;
const storedBooks = await perfTimeAsync('saveDraft:read', () =>
@@ -242,6 +290,11 @@ export class DraftRepository implements AnalysisRepository {
const byBook = perfTime('saveDraft:compare', () => splitAnalysisByBook(analysis));
if (options?.discardUnreadable) unreadable.clear();
const leftOut = [...unreadable].filter((bookCode) => byBook.has(bookCode));
+ if (refuseUnreadable && leftOut.length > 0) {
+ throw new Error(
+ `Interlinearizer: the draft for ${sourceProjectId} could not read the stored analysis of ${leftOut.join(', ')}, so it refuses changes there`,
+ );
+ }
leftOut.forEach((bookCode) => byBook.delete(bookCode));
// Books this save no longer carries, less those held back because the load could not read them.
diff --git a/src/services/editHistory.ts b/src/services/editHistory.ts
new file mode 100644
index 000000000..04170ff71
--- /dev/null
+++ b/src/services/editHistory.ts
@@ -0,0 +1,62 @@
+import type { AnalysisRepository, ChangeSet } from '../types/analysis-repository';
+import { MAX_UNDO_STEPS } from '../utils/undo-history';
+
+/**
+ * A linear undo history of the edits applied through it to one repository, keeping the latest
+ * {@link MAX_UNDO_STEPS}. Each edit is one step, and a new edit drops every step undone before it.
+ */
+export class EditHistory {
+ private readonly repository: AnalysisRepository;
+
+ /** What reverts each edit not yet undone, latest last. */
+ private readonly undoable: ChangeSet[] = [];
+
+ /** What redoes each undone edit, latest last. */
+ private readonly redoable: ChangeSet[] = [];
+
+ constructor(repository: AnalysisRepository) {
+ this.repository = repository;
+ }
+
+ /**
+ * Applies `changes` as one undo step.
+ *
+ * @throws Whatever applying the changes throws; the history is left as it was.
+ */
+ async apply(changes: ChangeSet): Promise {
+ const { inverse } = await this.repository.apply(changes);
+ this.undoable.push(inverse);
+ if (this.undoable.length > MAX_UNDO_STEPS) this.undoable.shift();
+ this.redoable.length = 0;
+ }
+
+ /**
+ * Reverts the latest edit not yet undone.
+ *
+ * @returns The changes that reverted it, or `undefined` when there is none.
+ * @throws Whatever applying those changes throws; the edit stays the next to undo.
+ */
+ undo(): Promise {
+ return this.step(this.undoable, this.redoable);
+ }
+
+ /**
+ * Redoes the latest undone edit.
+ *
+ * @returns The changes that redid it, or `undefined` when there is none.
+ * @throws Whatever applying those changes throws; the edit stays the next to redo.
+ */
+ redo(): Promise {
+ return this.step(this.redoable, this.undoable);
+ }
+
+ /** Applies the latest changes on `from`, moving what reverts them onto `to`. */
+ private async step(from: ChangeSet[], to: ChangeSet[]): Promise {
+ const changes = from.at(-1);
+ if (!changes) return undefined;
+ const { inverse } = await this.repository.apply(changes);
+ from.pop();
+ to.push(inverse);
+ return changes;
+ }
+}
diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts
index 193cd1be5..e6eefe7a3 100644
--- a/src/services/projectStorage.ts
+++ b/src/services/projectStorage.ts
@@ -1121,15 +1121,18 @@ export function openDraftRepository(
enqueueSerialized(draftQueues, sourceProjectId, () =>
call(draftRepositoryFor(token, sourceProjectId)),
);
+ // Counted when queued, so a copy can tell a write was sent after it read the draft.
+ const counted = (call: (repository: DraftRepository) => Promise) => {
+ draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1);
+ return queued(call);
+ };
return {
capabilities: { readOnly: false },
exportAll: () => queued((repository) => repository.exportAll()),
exportForCopy: () => queued((repository) => repository.exportForCopy()),
- replaceAll: (draft, options) => {
- draftWriteCounts.set(sourceProjectId, (draftWriteCounts.get(sourceProjectId) ?? 0) + 1);
- return queued((repository) => repository.replaceAll(draft, options));
- },
+ replaceAll: (draft, options) => counted((repository) => repository.replaceAll(draft, options)),
markClean: () => queued((repository) => repository.markClean()),
+ apply: (changes) => counted((repository) => repository.apply(changes)),
};
}
diff --git a/src/services/pt9ImportRepository.ts b/src/services/pt9ImportRepository.ts
index 2ad37f180..a8468b5a6 100644
--- a/src/services/pt9ImportRepository.ts
+++ b/src/services/pt9ImportRepository.ts
@@ -1,6 +1,7 @@
import type { InterlinearProject } from 'interlinearizer';
import type {
AnalysisRepository,
+ ApplyResult,
DraftExport,
RepositoryCapabilities,
} from '../types/analysis-repository';
@@ -80,6 +81,11 @@ export class Pt9ImportRepository implements AnalysisRepository {
throw this.readOnlyError();
}
+ /** @throws {ReadOnlyAnalysisError} Always; nothing is applied. */
+ async apply(): Promise {
+ throw this.readOnlyError();
+ }
+
private readOnlyError(): ReadOnlyAnalysisError {
return new ReadOnlyAnalysisError(
`Interlinearizer: Paratext 9 import ${this.interlinearProjectId} is read-only`,
diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts
index 27014f1c9..848f60163 100644
--- a/src/types/analysis-repository.ts
+++ b/src/types/analysis-repository.ts
@@ -1,4 +1,4 @@
-import type { DraftProject } from 'interlinearizer';
+import type { DraftProject, TextAnalysis } from 'interlinearizer';
/** Text records by key. */
export interface KeyValueStorage {
@@ -56,6 +56,39 @@ export type DraftExport = {
unreadableBooks: string[];
};
+/**
+ * A draft's own record: everything in it but its analysis and its `dirty` flag, which every apply
+ * sets.
+ */
+export type ScopeRecord = Omit;
+
+/** Whole-record puts and deletes of one record kind. */
+export type RecordChanges = {
+ /** Records to store whole, each replacing any stored under its id. */
+ puts: T[];
+ /** Ids of records to remove; an id with nothing stored under it is skipped. */
+ deletes: string[];
+};
+
+/**
+ * One command's changes, applied all-or-nothing. A record kind it leaves alone is absent, and an id
+ * appears at most once in a kind's puts and deletes together.
+ */
+export type ChangeSet = {
+ /** Names the command that made the changes. */
+ intent: string;
+ /** The scope's record, whole, which carries boundary changes; absent when it is left alone. */
+ scope?: ScopeRecord;
+} & {
+ [Kind in keyof TextAnalysis]?: RecordChanges;
+};
+
+/** What applying a {@link ChangeSet} tells its caller. */
+export type ApplyResult = {
+ /** Restores what the applied changes replaced; applying it undoes them. */
+ inverse: ChangeSet;
+};
+
/**
* Reads and writes one source project's draft, or, when read-only, reads analysis in a draft's
* shape. Every call is async and takes and returns only JSON-plain values, so an implementation may
@@ -122,4 +155,20 @@ export interface AnalysisRepository {
* @throws If storage rejects a read or a write.
*/
markClean(): Promise;
+
+ /**
+ * Applies `changes` to the stored draft whole or not at all, marked unsaved and stamped with this
+ * build's model version. A draft never saved counts as an empty one.
+ *
+ * @throws {ReadOnlyAnalysisError} If the repository is read-only; nothing is applied.
+ * @throws {Error} If an id appears more than once in one record kind's changes, or the scope's
+ * record names another source project; nothing is applied.
+ * @throws {Error} If the changes reach a book whose stored analysis could not be read; nothing is
+ * applied.
+ * @throws {SyntaxError} If the draft's stored value contains invalid JSON.
+ * @throws {Error} If the stored draft was written by a newer build; nothing is applied.
+ * @throws If storage rejects a read or a write. The draft as this repository holds it is
+ * unchanged, though storage may hold some of the changes until a later save.
+ */
+ apply(changes: ChangeSet): Promise;
}
diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts
index 5177060be..06ab18d47 100644
--- a/src/types/interlinearizer.d.ts
+++ b/src/types/interlinearizer.d.ts
@@ -363,6 +363,24 @@ declare module 'papi-shared-types' {
analysisLanguage: string,
) => Promise;
+ /**
+ * Reverts a session's latest edit not yet undone.
+ *
+ * @returns The changes that reverted it, as JSON, or `undefined` when there is none.
+ * @throws If the session is read-only or not open, or the changes cannot be applied (logged
+ * before rethrowing).
+ */
+ 'interlinearizer.undoEdit': (sessionId: string) => Promise;
+
+ /**
+ * Redoes a session's latest undone edit.
+ *
+ * @returns The changes that redid it, as JSON, or `undefined` when there is none.
+ * @throws If the session is read-only or not open, or the changes cannot be applied (logged
+ * before rethrowing).
+ */
+ 'interlinearizer.redoEdit': (sessionId: string) => Promise;
+
/**
* Turns the extension host's timing entries on or off, discarding any it holds. Off until
* turned on.
diff --git a/src/utils/change-set.ts b/src/utils/change-set.ts
new file mode 100644
index 000000000..e28124d9a
--- /dev/null
+++ b/src/utils/change-set.ts
@@ -0,0 +1,141 @@
+import type { DraftProject } from 'interlinearizer';
+import type { ChangeSet, RecordChanges, ScopeRecord } from '../types/analysis-repository';
+
+/** A draft with a {@link ChangeSet} applied, and the change set that restores what it replaced. */
+export type AppliedChanges = {
+ draft: DraftProject;
+ inverse: ChangeSet;
+};
+
+/** A copy of a JSON-plain value that shares nothing with it. */
+function copyOf(value: T): T {
+ return JSON.parse(JSON.stringify(value));
+}
+
+/** The draft's record, without its analysis or its `dirty` flag. */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars -- named only to leave them out
+export function scopeRecordOf({ analysis, dirty, ...scope }: DraftProject): ScopeRecord {
+ return scope;
+}
+
+/**
+ * Applies one record kind's changes to `records`.
+ *
+ * @returns The records the changes leave, and the changes that restore what they replaced, absent
+ * when the changes replaced nothing and added nothing.
+ * @throws {Error} If an id appears more than once in the changes.
+ */
+function applyRecordChanges(
+ records: T[],
+ changes: RecordChanges | undefined,
+ kind: string,
+): { records: T[]; inverse?: RecordChanges } {
+ if (!changes) return { records };
+ const named = new Set();
+ [...changes.puts.map(({ id }) => id), ...changes.deletes].forEach((id) => {
+ if (named.has(id)) {
+ throw new Error(`Interlinearizer: a change set names ${kind} record ${id} more than once`);
+ }
+ named.add(id);
+ });
+
+ const stored = new Map(records.map((record) => [record.id, record]));
+ const deleted = new Set(changes.deletes);
+ const puts = new Map(changes.puts.map((record) => [record.id, copyOf(record)]));
+ const inverse: RecordChanges = { puts: [], deletes: [] };
+ changes.puts.forEach(({ id }) => {
+ const prior = stored.get(id);
+ if (prior) inverse.puts.push(prior);
+ else inverse.deletes.push(id);
+ });
+ changes.deletes.forEach((id) => {
+ const prior = stored.get(id);
+ if (prior) inverse.puts.push(prior);
+ });
+
+ // A replaced record keeps its place, so records nearby are left as they were.
+ const next = records.flatMap((record) => {
+ if (deleted.has(record.id)) return [];
+ return [puts.get(record.id) ?? record];
+ });
+ puts.forEach((record, id) => {
+ if (!stored.has(id)) next.push(record);
+ });
+ return {
+ records: next,
+ ...((inverse.puts.length > 0 || inverse.deletes.length > 0) && { inverse }),
+ };
+}
+
+/**
+ * Applies `changes` to `draft`, leaving `draft` as it was. The result is marked unsaved. It holds
+ * copies of the records the changes put, and shares everything else with `draft`, the records the
+ * inverse restores included.
+ *
+ * @throws {Error} If an id appears more than once in one record kind's changes, or the scope's
+ * record names another source project.
+ */
+export function applyChangeSet(draft: DraftProject, changes: ChangeSet): AppliedChanges {
+ if (changes.scope && changes.scope.sourceProjectId !== draft.sourceProjectId) {
+ throw new Error(
+ `Interlinearizer: a change set for source project ${changes.scope.sourceProjectId} cannot apply to the draft of ${draft.sourceProjectId}`,
+ );
+ }
+ const { analysis } = draft;
+ const segmentAnalyses = applyRecordChanges(
+ analysis.segmentAnalyses,
+ changes.segmentAnalyses,
+ 'segment analysis',
+ );
+ const segmentAnalysisLinks = applyRecordChanges(
+ analysis.segmentAnalysisLinks,
+ changes.segmentAnalysisLinks,
+ 'segment analysis link',
+ );
+ const tokenAnalyses = applyRecordChanges(
+ analysis.tokenAnalyses,
+ changes.tokenAnalyses,
+ 'token analysis',
+ );
+ const tokenAnalysisLinks = applyRecordChanges(
+ analysis.tokenAnalysisLinks,
+ changes.tokenAnalysisLinks,
+ 'token analysis link',
+ );
+ const phraseAnalyses = applyRecordChanges(
+ analysis.phraseAnalyses,
+ changes.phraseAnalyses,
+ 'phrase analysis',
+ );
+ const phraseAnalysisLinks = applyRecordChanges(
+ analysis.phraseAnalysisLinks,
+ changes.phraseAnalysisLinks,
+ 'phrase analysis link',
+ );
+
+ const inverse: ChangeSet = {
+ intent: changes.intent,
+ ...(changes.scope && { scope: scopeRecordOf(draft) }),
+ ...(segmentAnalyses.inverse && { segmentAnalyses: segmentAnalyses.inverse }),
+ ...(segmentAnalysisLinks.inverse && { segmentAnalysisLinks: segmentAnalysisLinks.inverse }),
+ ...(tokenAnalyses.inverse && { tokenAnalyses: tokenAnalyses.inverse }),
+ ...(tokenAnalysisLinks.inverse && { tokenAnalysisLinks: tokenAnalysisLinks.inverse }),
+ ...(phraseAnalyses.inverse && { phraseAnalyses: phraseAnalyses.inverse }),
+ ...(phraseAnalysisLinks.inverse && { phraseAnalysisLinks: phraseAnalysisLinks.inverse }),
+ };
+ return {
+ draft: {
+ ...(changes.scope ? copyOf(changes.scope) : scopeRecordOf(draft)),
+ dirty: true,
+ analysis: {
+ segmentAnalyses: segmentAnalyses.records,
+ segmentAnalysisLinks: segmentAnalysisLinks.records,
+ tokenAnalyses: tokenAnalyses.records,
+ tokenAnalysisLinks: tokenAnalysisLinks.records,
+ phraseAnalyses: phraseAnalyses.records,
+ phraseAnalysisLinks: phraseAnalysisLinks.records,
+ },
+ },
+ inverse,
+ };
+}