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, + }; +}