diff --git a/__mocks__/papi-backend.ts b/__mocks__/papi-backend.ts index 38c2eba9..26bfef0b 100644 --- a/__mocks__/papi-backend.ts +++ b/__mocks__/papi-backend.ts @@ -15,6 +15,8 @@ const mockReadUserData = jest.fn(); const mockWriteUserData = jest.fn(); const mockDeleteUserData = jest.fn(); const mockNotificationsSend = jest.fn(); +const mockProjectDataProvidersGet = jest.fn(); +const mockGetLocalizedString = jest.fn(); const mockLogger = { debug: jest.fn(), error: jest.fn(), @@ -35,6 +37,12 @@ const papi = { notifications: { send: mockNotificationsSend, }, + projectDataProviders: { + get: mockProjectDataProvidersGet, + }, + localization: { + getLocalizedString: mockGetLocalizedString, + }, storage: { readUserData: mockReadUserData, writeUserData: mockWriteUserData, @@ -69,6 +77,8 @@ const defaultExport = { __mockWriteUserData: mockWriteUserData, __mockDeleteUserData: mockDeleteUserData, __mockNotificationsSend: mockNotificationsSend, + __mockProjectDataProvidersGet: mockProjectDataProvidersGet, + __mockGetLocalizedString: mockGetLocalizedString, __mockLogger: mockLogger, }; diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 7aef2897..75bd2ebf 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -156,6 +156,11 @@ "%interlinearizer_error_delete_project_failed%": "Could not delete the interlinearizer project. Please try again.", "%interlinearizer_error_update_project_failed%": "Could not update the interlinearizer project. Please try again.", "%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.", + "%interlinearizer_pt9Import_name%": "Paratext 9 Interlinear", + "%interlinearizer_pt9Import_description%": "Imported from this project's Paratext 9 interlinear data. Read-only; synced from the Paratext 9 files.", + "%interlinearizer_error_pt9Import_failed%": "Could not import the Paratext 9 interlinear data. Please try again.", + "%interlinearizer_warning_pt9Import_sourceEmpty%": "The project's Paratext 9 interlinear files are missing, so the imported data was left as it was.", + "%interlinearizer_error_createEditableCopy_failed%": "Could not copy the imported project. Please try again.", "%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again.", "%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again." } diff --git a/src/__tests__/converters/pt9/report.test.ts b/src/__tests__/converters/pt9/report.test.ts new file mode 100644 index 00000000..6661d401 --- /dev/null +++ b/src/__tests__/converters/pt9/report.test.ts @@ -0,0 +1,89 @@ +/// + +import { isPt9ImportReport } from '../../../converters/pt9'; + +/** A minimal report with one language and one book, valid unless a test breaks a piece of it. */ +function makeReport() { + return { + languages: [ + { + rawLanguage: 'en', + tag: 'en', + tagIsFallback: false, + books: [ + { + bookId: 'MAT', + bookFound: true, + versesTotal: 1, + versesHashed: 1, + versesNotFound: 0, + clustersTotal: 2, + clustersConverted: 2, + phrasesConverted: 0, + clusterDrops: { + verseNotFound: 0, + formMismatch: 0, + lemmaOrOther: 0, + duplicateCluster: 0, + unparseableLexemeId: 0, + }, + ambiguousAnchors: 0, + punctuationEntriesIgnored: 0, + }, + ], + }, + ], + merge: { + mergedTokenRecords: 0, + parseConflicts: 0, + approvedDemotedToCandidate: 0, + sameTagCollisions: [], + }, + senses: { + specificResolved: 0, + defaultSingleResolved: 0, + unresolvedGlossText: 0, + entryRefsResolved: 0, + entryRefsUnresolved: 0, + senseRefsResolved: 0, + senseRefsUnresolved: 0, + }, + barePayloads: { added: 0, skippedExistingIdentical: 0, droppedUnparseable: 0, droppedEmpty: 0 }, + }; +} + +describe('isPt9ImportReport', () => { + it('accepts a conversion report', () => { + expect(isPt9ImportReport(makeReport())).toBe(true); + }); + + it('rejects a non-object and a missing aggregate section', () => { + expect(isPt9ImportReport(undefined)).toBe(false); + expect(isPt9ImportReport({ ...makeReport(), merge: undefined })).toBe(false); + }); + + it('rejects a language without a tag', () => { + const report = makeReport(); + expect(isPt9ImportReport({ ...report, languages: [{ books: [] }] })).toBe(false); + }); + + it('rejects a book with a non-number count', () => { + const broken = makeReport(); + const book: Record = { ...broken.languages[0].books[0] }; + book.clustersTotal = 'two'; + expect( + isPt9ImportReport({ ...broken, languages: [{ ...broken.languages[0], books: [book] }] }), + ).toBe(false); + }); + + it('rejects a book with a non-number drop count', () => { + const report = makeReport(); + const book: Record = { + ...report.languages[0].books[0], + clusterDrops: { verseNotFound: 'many' }, + }; + expect( + isPt9ImportReport({ ...report, languages: [{ ...report.languages[0], books: [book] }] }), + ).toBe(false); + }); +}); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 65c807c7..70b47b67 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -5,10 +5,12 @@ import papiBackendMock from '@papi/backend'; import { activate, deactivate } from '@main'; import type { InterlinearizerOpenOptions } from '@main'; import * as projectStorage from '../services/projectStorage'; +import * as pt9ImportService from '../services/pt9ImportService'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { createTestActivationContext, makeStubProject } from './test-helpers'; jest.mock('../services/projectStorage'); +jest.mock('../services/pt9ImportService'); /** Shape of the Jest-mocked @papi/backend default export used in these tests. */ interface PapiBackendTestMock { @@ -144,6 +146,18 @@ const getUpdateProjectMetadataHandler = () => ) => Promise >('interlinearizer.updateProjectMetadata'); +/** Activates the extension and returns the `interlinearizer.importPt9Project` handler. */ +const getImportPt9ProjectHandler = () => + activateAndGetHandler<(sourceProjectId: string) => Promise>( + 'interlinearizer.importPt9Project', + ); + +/** Activates the extension and returns the `interlinearizer.createEditableCopy` handler. */ +const getCreateEditableCopyHandler = () => + activateAndGetHandler<(id: string, name: string, description?: string) => Promise>( + 'interlinearizer.createEditableCopy', + ); + /** Activates the extension and returns the `interlinearizer.getProject` handler. */ const getGetProjectHandler = () => activateAndGetHandler<(id: string) => Promise>('interlinearizer.getProject'); @@ -1152,4 +1166,106 @@ describe('main', () => { expect(__mockLogger.debug).toHaveBeenCalledWith('Interlinearizer extension is deactivating!'); }); }); + + describe('interlinearizer.importPt9Project command', () => { + const mockImport = jest.mocked(pt9ImportService.importPt9Project); + + it('registers the interlinearizer.importPt9Project command', async () => { + const context = createTestActivationContext(); + + await activate(context); + + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.importPt9Project', + expect.any(Function), + expect.any(Object), + ); + }); + + it('returns the import result as JSON without a warning when data was imported', async () => { + mockImport.mockResolvedValue({ outcome: 'imported', projectId: 'import-id' }); + const handler = await getImportPt9ProjectHandler(); + + const result = await handler('src-project'); + + expect(mockImport).toHaveBeenCalledWith(expect.anything(), 'src-project'); + expect(JSON.parse(result)).toStrictEqual({ outcome: 'imported', projectId: 'import-id' }); + expect(__mockNotificationsSend).not.toHaveBeenCalled(); + }); + + it('sends a warning notification when the stored import was kept', async () => { + mockImport.mockResolvedValue({ outcome: 'staleKept', projectId: 'import-id' }); + const handler = await getImportPt9ProjectHandler(); + + const result = await handler('src-project'); + + expect(JSON.parse(result)).toStrictEqual({ outcome: 'staleKept', projectId: 'import-id' }); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: '%interlinearizer_warning_pt9Import_sourceEmpty%', + severity: 'warning', + }); + }); + + it('logs the error, sends an error notification, and rethrows when the import fails', async () => { + mockImport.mockRejectedValue(new Error('nothing to import')); + const handler = await getImportPt9ProjectHandler(); + + await expect(handler('src-project')).rejects.toThrow('nothing to import'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to import Paratext 9 interlinear data', + expect.any(Error), + ); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: '%interlinearizer_error_pt9Import_failed%', + severity: 'error', + }); + }); + }); + + describe('interlinearizer.createEditableCopy command', () => { + const mockCopy = jest.mocked(projectStorage.createEditableCopy); + + it('registers the interlinearizer.createEditableCopy command', async () => { + const context = createTestActivationContext(); + + await activate(context); + + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.createEditableCopy', + expect.any(Function), + expect.any(Object), + ); + }); + + it('returns the created copy as JSON', async () => { + const copy = { ...makeStubProject('copy-id'), name: 'My Copy' }; + mockCopy.mockResolvedValue(copy); + const handler = await getCreateEditableCopyHandler(); + + const result = await handler('import-id', 'My Copy', 'my description'); + + expect(mockCopy).toHaveBeenCalledWith( + expect.anything(), + 'import-id', + 'My Copy', + 'my description', + ); + expect(JSON.parse(result)).toStrictEqual(copy); + }); + + it('logs the error, sends an error notification, and rethrows when the copy fails', async () => { + mockCopy.mockRejectedValue(new Error('not a Paratext 9 import')); + const handler = await getCreateEditableCopyHandler(); + + await expect(handler('plain-id', 'My Copy')).rejects.toThrow('not a Paratext 9 import'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to create an editable copy', + expect.any(Error), + ); + expect(__mockNotificationsSend).toHaveBeenCalledWith({ + message: '%interlinearizer_error_createEditableCopy_failed%', + severity: 'error', + }); + }); + }); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 0ff9634a..fb84a5d6 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -2,21 +2,29 @@ import papiBackendMock from '@papi/backend'; import { + createEditableCopy, createProject, deleteProject, getDraft, getProject, getProjectsForSource, + getPt9ImportForSource, listProjects, resetQueuesForTesting, saveDraft, + savePt9Import, sweepPendingCleanup, updateAnalysis, updateProjectMetadata, } from '../../services/projectStorage'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; -import { createTestActivationContext, FIXTURE_STAMPS, makeStubProject } from '../test-helpers'; +import { + createTestActivationContext, + enoentError, + FIXTURE_STAMPS, + makeStubProject, +} from '../test-helpers'; /** * Mock implementation of storage methods used in tests. Exposes `__mockReadUserData`, @@ -47,14 +55,6 @@ const { __mockReadUserData, __mockWriteUserData, __mockDeleteUserData, __mockLog const token = createTestActivationContext().executionToken; -/** - * Constructs an ENOENT Error that mirrors the error thrown by `papi.storage.readUserData` when a - * storage key has never been written. - */ -function enoentError(): Error { - return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); -} - describe('projectStorage', () => { beforeEach(() => { resetQueuesForTesting(); @@ -1270,4 +1270,241 @@ describe('projectStorage', () => { expect(writeCallCount).toBe(2); }); }); + + describe('Paratext 9 import projects', () => { + const PT9_PROVENANCE = { + fileHashes: { 'Lexicon.xml': 'aaaa1111' }, + importedAt: '2026-08-01T00:00:00.000Z', + }; + const importedProject = { + ...makeStubProject('import-id'), + name: 'stale name', + description: 'stale description', + pt9Import: PT9_PROVENANCE, + }; + const SAVE_TIME = '2026-08-21T12:00:00.000Z'; + + /** Serves each record as the stored JSON for its key; any other key reads as never written. */ + function mockStore(records: Record): void { + __mockReadUserData.mockImplementation((_t: unknown, key: unknown) => { + if (typeof key === 'string' && key in records) + return Promise.resolve(JSON.stringify(records[key])); + return Promise.reject(enoentError()); + }); + } + + describe('freeze guard', () => { + it('rejects updateAnalysis without writing', async () => { + mockStore({ 'project:import-id': importedProject }); + + await expect(updateAnalysis(token, 'import-id', emptyAnalysis())).rejects.toThrow( + 'Paratext 9 import and is read-only', + ); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); + + it('rejects updateProjectMetadata without writing', async () => { + mockStore({ 'project:import-id': importedProject }); + + await expect( + updateProjectMetadata(token, 'import-id', 'new name', undefined, ['en']), + ).rejects.toThrow('Paratext 9 import and is read-only'); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); + + it('still allows deleteProject', async () => { + mockStore({ 'project:import-id': importedProject, projectIds: ['import-id'] }); + + await deleteProject(token, 'import-id'); + + expect(__mockDeleteUserData).toHaveBeenCalledWith(token, 'project:import-id'); + }); + }); + + describe('getPt9ImportForSource', () => { + it('returns the import among the source projects', async () => { + mockStore({ + projectIds: ['plain-id', 'import-id'], + 'project:plain-id': makeStubProject('plain-id'), + 'project:import-id': importedProject, + }); + + const result = await getPt9ImportForSource(token, 'src-project'); + + expect(result?.id).toBe('import-id'); + }); + + it('returns undefined when no source project carries pt9Import', async () => { + mockStore({ + projectIds: ['plain-id'], + 'project:plain-id': makeStubProject('plain-id'), + }); + + await expect(getPt9ImportForSource(token, 'src-project')).resolves.toBeUndefined(); + }); + }); + + describe('savePt9Import', () => { + const newAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'pt9:ta:GEN 1:1:0:0', surfaceText: 'hello' }], + }; + const NEW_PROVENANCE = { + fileHashes: { 'Lexicon.xml': 'bbbb2222' }, + importedAt: SAVE_TIME, + }; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date(SAVE_TIME)); + }); + + it('creates the import and indexes it when the source has none', async () => { + mockStore({ projectIds: [] }); + + const project = await savePt9Import( + token, + 'src-project', + 'Paratext 9 Interlinear', + 'Imported from Paratext 9.', + ['en', 'fr'], + newAnalysis, + NEW_PROVENANCE, + ); + + expect(project).toMatchObject({ + id: '00000000-0000-0000-0000-000000000001', + createdAt: SAVE_TIME, + updatedAt: SAVE_TIME, + name: 'Paratext 9 Interlinear', + description: 'Imported from Paratext 9.', + sourceProjectId: 'src-project', + analysisLanguages: ['en', 'fr'], + analysis: newAnalysis, + pt9Import: NEW_PROVENANCE, + }); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'projectIds', + JSON.stringify(['00000000-0000-0000-0000-000000000001']), + ); + }); + + it('replaces the existing import wholesale, keeping only id and createdAt', async () => { + mockStore({ + projectIds: ['import-id'], + 'project:import-id': { ...importedProject, targetProjectId: 'stray-target' }, + }); + + const project = await savePt9Import( + token, + 'src-project', + 'Paratext 9 Interlinear', + 'Imported from Paratext 9.', + ['en'], + newAnalysis, + NEW_PROVENANCE, + ); + + expect(project.id).toBe('import-id'); + expect(__mockWriteUserData).toHaveBeenCalledTimes(1); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'project:import-id', + JSON.stringify({ + id: 'import-id', + modelVersion: CURRENT_MODEL_VERSION, + createdAt: importedProject.createdAt, + updatedAt: SAVE_TIME, + name: 'Paratext 9 Interlinear', + description: 'Imported from Paratext 9.', + sourceProjectId: 'src-project', + analysisLanguages: ['en'], + analysis: newAnalysis, + pt9Import: NEW_PROVENANCE, + }), + ); + }); + + it('creates a fresh record when the import is deleted between lookup and write', async () => { + let importReads = 0; + __mockReadUserData.mockImplementation((_t: unknown, key: unknown) => { + if (key === 'projectIds') return Promise.resolve(JSON.stringify(['import-id'])); + if (key === 'project:import-id') { + importReads += 1; + if (importReads === 1) return Promise.resolve(JSON.stringify(importedProject)); + } + return Promise.reject(enoentError()); + }); + + const project = await savePt9Import( + token, + 'src-project', + 'Paratext 9 Interlinear', + 'Imported from Paratext 9.', + ['en'], + newAnalysis, + NEW_PROVENANCE, + ); + + expect(project.id).toBe('00000000-0000-0000-0000-000000000001'); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'project:00000000-0000-0000-0000-000000000001', + expect.stringContaining('"pt9Import"'), + ); + }); + }); + + describe('createEditableCopy', () => { + it('creates an editable project carrying the analysis and no pt9Import', async () => { + mockStore({ + projectIds: ['import-id'], + 'project:import-id': importedProject, + }); + + const copy = await createEditableCopy(token, 'import-id', 'My Copy', 'my description'); + + expect(copy).toMatchObject({ + id: '00000000-0000-0000-0000-000000000001', + name: 'My Copy', + description: 'my description', + sourceProjectId: importedProject.sourceProjectId, + analysisLanguages: importedProject.analysisLanguages, + analysis: importedProject.analysis, + }); + expect(copy).not.toHaveProperty('pt9Import'); + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'projectIds', + JSON.stringify(['import-id', '00000000-0000-0000-0000-000000000001']), + ); + }); + + it('omits the description when none is given', async () => { + mockStore({ projectIds: ['import-id'], 'project:import-id': importedProject }); + + const copy = await createEditableCopy(token, 'import-id', 'My Copy'); + + expect(copy).not.toHaveProperty('description'); + }); + + it('throws when the project does not exist', async () => { + mockStore({}); + + await expect(createEditableCopy(token, 'missing', 'My Copy')).rejects.toThrow( + 'does not exist', + ); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); + + it('throws when the project is not a Paratext 9 import', async () => { + mockStore({ 'project:plain-id': makeStubProject('plain-id') }); + + await expect(createEditableCopy(token, 'plain-id', 'My Copy')).rejects.toThrow( + 'not a Paratext 9 import', + ); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/__tests__/services/pt9ImportService.test.ts b/src/__tests__/services/pt9ImportService.test.ts new file mode 100644 index 00000000..f2af38f6 --- /dev/null +++ b/src/__tests__/services/pt9ImportService.test.ts @@ -0,0 +1,282 @@ +/// + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import papiBackendMock from '@papi/backend'; +import type { Pt9InterlinearProjectData } from 'platform-scripture'; +import { importPt9Project } from '../../services/pt9ImportService'; +import { resetQueuesForTesting } from '../../services/projectStorage'; +import { createTestActivationContext, enoentError, makeStubProject } from '../test-helpers'; + +/** + * The backend-mock jest fns this suite drives: the PAPI boundary (project data providers, + * localization, storage) around the real converter and storage module. + */ +interface BackendMock { + __mockProjectDataProvidersGet: jest.Mock; + __mockGetLocalizedString: jest.Mock; + __mockReadUserData: jest.Mock; + __mockWriteUserData: jest.Mock; + __mockDeleteUserData: jest.Mock; + __mockLogger: { debug: jest.Mock; error: jest.Mock; info: jest.Mock; warn: jest.Mock }; +} + +function isBackendMock(m: unknown): m is BackendMock { + return ( + !!m && + typeof m === 'object' && + '__mockProjectDataProvidersGet' in m && + '__mockGetLocalizedString' in m && + '__mockReadUserData' in m && + '__mockWriteUserData' in m + ); +} + +if (!isBackendMock(papiBackendMock)) throw new Error('Expected mocked @papi/backend'); +const { + __mockProjectDataProvidersGet, + __mockGetLocalizedString, + __mockReadUserData, + __mockWriteUserData, + __mockDeleteUserData, + __mockLogger, +} = papiBackendMock; + +const token = createTestActivationContext().executionToken; + +const IMPORT_TIME = '2026-08-21T15:00:00.000Z'; + +/** Reads the coherent PT9 project payload fixture the converter tests are built on. */ +function readFixtureData(): Pt9InterlinearProjectData { + return JSON.parse( + fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'test-data', 'Pt9InterlinearProjectData.json'), + 'utf-8', + ), + ); +} + +/** The manifest the projectInterface serves for the fixture set: path to change token. */ +const FIXTURE_MANIFEST: Record = { + 'Interlinear_en/Interlinear_en_MAT.xml': 'hash-interlinear', + 'Lexicon.xml': 'hash-lexicon', + 'WordAnalyses.xml': 'hash-word-analyses', +}; + +/** A USJ book whose verse texts match what the fixture interlinear data anchors against. */ +const MAT_USJ = { + content: [ + { type: 'book', code: 'MAT', content: [] }, + { type: 'chapter', number: '1', sid: 'MAT 1' }, + { + type: 'para', + marker: 'p', + content: [ + { type: 'verse', sid: 'MAT 1:1', number: '1' }, + 'hello aokaybe abe abc this is a footnote with a note تمان oj', + { type: 'verse', sid: 'MAT 1:2', number: '2' }, + 'oooo dearly', + { type: 'verse', sid: 'MAT 1:9', number: '9' }, + 'hello', + ], + }, + ], +}; + +/** Serves fake PDPs for the three projectInterfaces the service consumes. */ +function mockPdps({ + manifest = FIXTURE_MANIFEST, + data = () => Promise.resolve(readFixtureData()), + usj = MAT_USJ, + languageTag = 'en', +}: { + manifest?: Record; + /** Produces the parsed payload; reject to simulate a platform-side read or parse failure. */ + data?: () => Promise; + usj?: unknown; + languageTag?: unknown; +} = {}): void { + __mockProjectDataProvidersGet.mockImplementation((projectInterface: unknown) => { + if (projectInterface === 'platformScripture.Pt9Interlinear') + return Promise.resolve({ + getPt9InterlinearManifest: jest.fn().mockResolvedValue(manifest), + getPt9InterlinearData: jest.fn().mockImplementation(data), + }); + if (projectInterface === 'platformScripture.USJ_Book') + return Promise.resolve({ getBookUSJ: jest.fn().mockResolvedValue(usj) }); + return Promise.resolve({ getSetting: jest.fn().mockResolvedValue(languageTag) }); + }); +} + +/** The localized values the import resolves and stamps. */ +const LOCALIZED: Record = { + '%interlinearizer_pt9Import_name%': 'Paratext 9 Interlinear', + '%interlinearizer_pt9Import_description%': 'Imported from Paratext 9.', +}; + +/** The project record JSON written under a `project:` key, parsed; throws when none was written. */ +function writtenProject(): ReturnType { + const call = __mockWriteUserData.mock.calls.find( + (c: unknown[]) => typeof c[1] === 'string' && c[1].startsWith('project:'), + ); + if (!call || typeof call[2] !== 'string') throw new Error('Expected a project write'); + return JSON.parse(call[2]); +} + +describe('importPt9Project', () => { + beforeEach(() => { + resetQueuesForTesting(); + __mockReadUserData.mockRejectedValue(enoentError()); + __mockWriteUserData.mockResolvedValue(undefined); + __mockDeleteUserData.mockResolvedValue(undefined); + __mockGetLocalizedString.mockImplementation(({ localizeKey }: { localizeKey: string }) => + Promise.resolve(LOCALIZED[localizeKey] ?? localizeKey), + ); + mockPdps(); + jest.useFakeTimers().setSystemTime(new Date(IMPORT_TIME)); + jest.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000001'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('imports the fixture set end to end and persists the frozen project', async () => { + const result = await importPt9Project(token, 'src-project'); + + expect(result.outcome).toBe('imported'); + expect(result.projectId).toBe('00000000-0000-0000-0000-000000000001'); + const [language] = result.report?.languages ?? []; + expect(language.tag).toBe('en'); + expect(language.books[0]).toMatchObject({ + bookId: 'MAT', + bookFound: true, + versesTotal: 36, + clustersConverted: 24, + }); + + const project = writtenProject(); + expect(project).toMatchObject({ + name: 'Paratext 9 Interlinear', + description: 'Imported from Paratext 9.', + sourceProjectId: 'src-project', + analysisLanguages: ['en'], + pt9Import: { + importedAt: IMPORT_TIME, + fileHashes: { + 'Interlinear_en/Interlinear_en_MAT.xml': 'hash-interlinear', + 'Lexicon.xml': 'hash-lexicon', + 'WordAnalyses.xml': 'hash-word-analyses', + }, + }, + }); + expect(project.analysis.tokenAnalyses).toHaveLength(18); + }); + + it('replaces the existing import on sync, keeping its id', async () => { + const existing = { + ...makeStubProject('import-id'), + pt9Import: { fileHashes: { 'Lexicon.xml': 'old' }, importedAt: '2026-08-01T00:00:00.000Z' }, + }; + __mockReadUserData.mockImplementation((_t: unknown, key: unknown) => { + if (key === 'projectIds') return Promise.resolve(JSON.stringify(['import-id'])); + if (key === 'project:import-id') return Promise.resolve(JSON.stringify(existing)); + return Promise.reject(enoentError()); + }); + + const result = await importPt9Project(token, 'src-project'); + + expect(result).toMatchObject({ outcome: 'imported', projectId: 'import-id' }); + }); + + it('imports past a book of interlinear data that carries no book id', async () => { + const data = readFixtureData(); + data.books.push({ + glossLanguage: 'en', + verses: [], + filePath: 'Interlinear_en/Interlinear_en.xml', + isCanonicalPath: false, + }); + mockPdps({ data: () => Promise.resolve(data) }); + + const result = await importPt9Project(token, 'src-project'); + + expect(result.outcome).toBe('imported'); + expect(result.report?.booksMissingIdentity).toBe(1); + }); + + it('skips a book the project has no USJ for and reports it missing', async () => { + // eslint-disable-next-line no-null/no-null -- null defeats the option's MAT_USJ default, which undefined would trigger + mockPdps({ usj: null }); + + const result = await importPt9Project(token, 'src-project'); + + expect(result.outcome).toBe('imported'); + expect(result.report?.languages[0].books[0].bookFound).toBe(false); + expect(__mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('no USJ for book MAT')); + }); + + it('aborts without writing when the source has no interlinear data and no import exists', async () => { + mockPdps({ manifest: {} }); + + await expect(importPt9Project(token, 'src-project')).rejects.toThrow( + 'no Paratext 9 interlinear data to import', + ); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); + + it('keeps the stored import untouched when the source files have disappeared', async () => { + mockPdps({ manifest: {} }); + const existing = { + ...makeStubProject('import-id'), + pt9Import: { fileHashes: { 'Lexicon.xml': 'old' }, importedAt: '2026-08-01T00:00:00.000Z' }, + }; + __mockReadUserData.mockImplementation((_t: unknown, key: unknown) => { + if (key === 'projectIds') return Promise.resolve(JSON.stringify(['import-id'])); + if (key === 'project:import-id') return Promise.resolve(JSON.stringify(existing)); + return Promise.reject(enoentError()); + }); + + const result = await importPt9Project(token, 'src-project'); + + expect(result).toStrictEqual({ outcome: 'staleKept', projectId: 'import-id' }); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + expect(__mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('keeping the stored import'), + ); + }); + + it('falls back to the und writing system when the language tag setting is empty', async () => { + mockPdps({ languageTag: '' }); + + await importPt9Project(token, 'src-project'); + + const project = writtenProject(); + const bare = project.analysis.tokenAnalyses.find( + (a) => a.producer === 'pt9-import:word-analyses', + ); + expect(bare?.morphemes?.[0].writingSystem).toBe('und'); + }); + + it('falls back to the und writing system when the language tag setting is not a string', async () => { + mockPdps({ languageTag: 42 }); + + await importPt9Project(token, 'src-project'); + + const project = writtenProject(); + const bare = project.analysis.tokenAnalyses.find( + (a) => a.producer === 'pt9-import:word-analyses', + ); + expect(bare?.morphemes?.[0].writingSystem).toBe('und'); + }); + + it('propagates a platform-side read failure without writing', async () => { + mockPdps({ data: () => Promise.reject(new Error('Lexicon.xml is unreadable')) }); + + await expect(importPt9Project(token, 'src-project')).rejects.toThrow( + 'Lexicon.xml is unreadable', + ); + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index b1a3dfa8..58dd6886 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -357,3 +357,11 @@ export function pretendMacOs(): void { .spyOn(window.navigator, 'userAgent', 'get') .mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); } + +/** + * Constructs an ENOENT Error that mirrors the error thrown by `papi.storage.readUserData` when a + * storage key has never been written. + */ +export function enoentError(): Error { + return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); +} diff --git a/src/__tests__/types/type-guards.test.ts b/src/__tests__/types/type-guards.test.ts index 33f9866d..f7a27d6e 100644 --- a/src/__tests__/types/type-guards.test.ts +++ b/src/__tests__/types/type-guards.test.ts @@ -1,5 +1,7 @@ import { emptyAnalysis } from '../../types/empty-factories'; -import { isTextAnalysis } from '../../types/type-guards'; +import { isPt9ImportProvenance, isTextAnalysis } from '../../types/type-guards'; +import { toProjectSummary } from '../../types/interlinear-project-summary'; +import { makeStubProject } from '../test-helpers'; /** Stands in for any id space: the boundary never checks which authority a ref names. */ const AUTHORITY = 'x-test'; @@ -111,3 +113,39 @@ describe('isTextAnalysis', () => { expect(isTextAnalysis(analysisWithTokenFields({ glossSenseRef: null }))).toBe(false); }); }); + +const PROVENANCE = { + fileHashes: { 'Lexicon.xml': 'aaaa1111' }, + importedAt: '2026-08-01T00:00:00.000Z', +}; + +describe('isPt9ImportProvenance', () => { + it('accepts hashes keyed by path with an import timestamp', () => { + expect(isPt9ImportProvenance(PROVENANCE)).toBe(true); + }); + + it('rejects a missing importedAt', () => { + expect(isPt9ImportProvenance({ fileHashes: {} })).toBe(false); + }); + + it('rejects a non-string hash value', () => { + expect(isPt9ImportProvenance({ fileHashes: { 'Lexicon.xml': 5 }, importedAt: 'now' })).toBe( + false, + ); + }); + + it('rejects a non-object', () => { + expect(isPt9ImportProvenance('pt9')).toBe(false); + }); +}); + +describe('toProjectSummary', () => { + it('carries pt9Import through and drops fields outside the summary', () => { + const summary = toProjectSummary({ ...makeStubProject('import-id'), pt9Import: PROVENANCE }); + expect(summary.pt9Import).toStrictEqual(PROVENANCE); + }); + + it('omits pt9Import when the input has none', () => { + expect(toProjectSummary(makeStubProject('plain-id'))).not.toHaveProperty('pt9Import'); + }); +}); diff --git a/src/converters/pt9/index.ts b/src/converters/pt9/index.ts index 3c44bfbc..fe5530e6 100644 --- a/src/converters/pt9/index.ts +++ b/src/converters/pt9/index.ts @@ -2,7 +2,8 @@ * The PT9 interlinear converter's public surface: one function turning a project's PT9 interlinear * data, as the platform serves it, into the extension's analysis layer, the seam through which * lexical identities resolve, and the report types describing what a conversion did. The types each - * stage hands the next are internal to the conversion and deliberately absent here. + * stage hands the next are internal to the conversion and deliberately absent here. The report + * guard rides along for callers receiving a report as JSON. */ export { convertPt9Project } from './convertPt9Project'; @@ -10,6 +11,7 @@ export type { Pt9ConversionInput, Pt9ConversionResult } from './convertPt9Projec export type { Pt9LexiconResolver } from './lexiconResolver'; +export { isPt9ImportReport } from './report'; export type { Pt9BarePayloadReport, Pt9BookReport, diff --git a/src/converters/pt9/report.ts b/src/converters/pt9/report.ts index 4926819b..9a98c14e 100644 --- a/src/converters/pt9/report.ts +++ b/src/converters/pt9/report.ts @@ -176,3 +176,58 @@ export function emptyPt9ImportReport(): Pt9ImportReport { booksDroppedAsDuplicates: 0, }; } + +/** Validates one per-book section of a conversion report: identity, counts, and drop reasons. */ +function isPt9BookReport(value: unknown): boolean { + return ( + !!value && + typeof value === 'object' && + 'bookId' in value && + typeof value.bookId === 'string' && + 'bookFound' in value && + typeof value.bookFound === 'boolean' && + 'clustersTotal' in value && + typeof value.clustersTotal === 'number' && + 'clustersConverted' in value && + typeof value.clustersConverted === 'number' && + 'phrasesConverted' in value && + typeof value.phrasesConverted === 'number' && + 'clusterDrops' in value && + !!value.clusterDrops && + typeof value.clusterDrops === 'object' && + Object.values(value.clusterDrops).every((count) => typeof count === 'number') + ); +} + +/** + * Type guard for the conversion report inside the import command's JSON payload. Validates the + * per-language and per-book fields the report summary folds over; the aggregate sections are only + * checked for presence. + */ +export function isPt9ImportReport(value: unknown): value is Pt9ImportReport { + return ( + !!value && + typeof value === 'object' && + 'merge' in value && + !!value.merge && + typeof value.merge === 'object' && + 'senses' in value && + !!value.senses && + typeof value.senses === 'object' && + 'barePayloads' in value && + !!value.barePayloads && + typeof value.barePayloads === 'object' && + 'languages' in value && + Array.isArray(value.languages) && + value.languages.every( + (language) => + !!language && + typeof language === 'object' && + 'tag' in language && + typeof language.tag === 'string' && + 'books' in language && + Array.isArray(language.books) && + language.books.every(isPt9BookReport), + ) + ); +} diff --git a/src/main.ts b/src/main.ts index 4a2f1e29..5e97557d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import type { SegmentationDelta } from 'interlinearizer'; import interlinearizerReact from './interlinearizer.web-view?inline'; import interlinearizerStyles from './interlinearizer.web-view.scss?inline'; import * as projectStorage from './services/projectStorage'; +import * as pt9ImportService from './services/pt9ImportService'; import { isDraftProject, isSegmentationDelta, isTextAnalysis } from './types/type-guards'; // #region WebView provider @@ -233,6 +234,76 @@ async function updateProjectMetadata( } } +/** + * Imports or syncs the source project's Paratext 9 interlinear data. Returns the run's outcome as a + * JSON string; when the source's files have disappeared but an earlier import exists, the stored + * import is kept and a warning notification is sent. + * + * @param sourceProjectId - Platform.Bible project ID whose Paratext 9 interlinear files to import. + * @throws If the import fails or the source has nothing to import. The error is logged and an error + * notification is sent before rethrowing so the frontend `catch` block can suppress it without + * sending a second notification. + */ +async function importPt9Project(sourceProjectId: string): Promise { + try { + const result = await pt9ImportService.importPt9Project(executionToken, sourceProjectId); + if (result.outcome === 'staleKept') { + await papi.notifications + .send({ + message: '%interlinearizer_warning_pt9Import_sourceEmpty%', + severity: 'warning', + }) + .catch(() => {}); + } + return JSON.stringify(result); + } catch (e) { + logger.error('Interlinearizer: failed to import Paratext 9 interlinear data', e); + await papi.notifications + .send({ + message: '%interlinearizer_error_pt9Import_failed%', + severity: 'error', + }) + .catch(() => {}); + throw e; + } +} + +/** + * Creates an editable copy of a Paratext 9 import project. Returns the created project as a JSON + * string. + * + * @param interlinearProjectId - UUID of the Paratext 9 import to copy. + * @param name - User-facing name for the copy, chosen in the copy dialog. + * @param description - Optional user-facing description for the copy. + * @throws If the project does not exist, is not a Paratext 9 import, or storage fails. The error is + * logged and an error notification is sent before rethrowing so the frontend `catch` block can + * suppress it without sending a second notification. + */ +async function createEditableCopy( + interlinearProjectId: string, + name: string, + description?: string, +): Promise { + try { + const project = await projectStorage.createEditableCopy( + executionToken, + interlinearProjectId, + name, + description, + ); + return JSON.stringify(project); + } catch (e) { + logger.error('Interlinearizer: failed to create an editable copy', e); + await papi.notifications + .send({ + message: '%interlinearizer_error_createEditableCopy_failed%', + severity: 'error', + }) + .catch(() => {}); + throw e; + } +} + /** * Loads the interlinearizer project with the given ID, including its full `TextAnalysis`. The * WebView calls this when the active project changes to load the stored analysis into the @@ -707,6 +778,68 @@ export async function activate(context: ExecutionActivationContext): Promise { + const { id } = project; await papi.storage.writeUserData(token, projectKey(id), JSON.stringify(project)); try { await enqueueIndexOp(async () => { @@ -405,7 +425,139 @@ export async function createProject( } throw indexError; } +} +/** + * Reads the single Paratext 9 import project for the given source project, or `undefined` when the + * source has none. At most one project per source carries `pt9Import` (the import replaces it in + * place rather than creating another), so the first match is the only match. + * + * @throws {SyntaxError} If a project's storage value contains invalid JSON. + * @throws {Error} If a stored record was written by a newer build. + * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason. + */ +export async function getPt9ImportForSource( + token: ExecutionToken, + sourceProjectId: string, +): Promise { + const projects = await getProjectsForSource(token, sourceProjectId); + return projects.find((project) => project.pt9Import !== undefined); +} + +/** + * Persists the outcome of a Paratext 9 interlinear import: creates the source project's import + * record, or replaces it wholesale when one exists. This is the only write path for + * `pt9Import`-carrying projects - the public update functions reject them - and every field except + * `id` and `createdAt` is rebuilt from the arguments on replace, so nothing from the previous + * import survives a sync. + * + * @param token - The execution token for storage access. + * @param sourceProjectId - The Platform.Bible project ID the interlinear data was imported from. + * @param name - Fixed, already-localized project name the import stamps on every run. + * @param description - Fixed, already-localized project description, stamped like `name`. + * @param analysisLanguages - Resolved gloss-language tags from the conversion. + * @param analysis - The converted analysis layer. + * @param pt9Import - Provenance to store: per-file hashes and the import timestamp. + * @throws {SyntaxError} If a read storage value contains invalid JSON. + * @throws {Error} If a stored record was written by a newer build. + * @throws If `papi.storage.readUserData` or `papi.storage.writeUserData` rejects for a non-ENOENT + * reason. On a create, an index-write failure rolls back the record as in {@link createProject}. + */ +export async function savePt9Import( + token: ExecutionToken, + sourceProjectId: string, + name: string, + description: string, + analysisLanguages: string[], + analysis: TextAnalysis, + pt9Import: NonNullable, +): Promise { + const buildNew = (): InterlinearProject => { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + modelVersion: CURRENT_MODEL_VERSION, + createdAt: now, + updatedAt: now, + name, + description, + sourceProjectId, + analysisLanguages, + analysis, + pt9Import, + }; + }; + + const existing = await getPt9ImportForSource(token, sourceProjectId); + if (existing) { + const replaced = await enqueueProjectOp(existing.id, async () => { + const current = await getProject(token, existing.id); + if (!current) return undefined; + const updated: InterlinearProject = { + id: current.id, + modelVersion: CURRENT_MODEL_VERSION, + createdAt: current.createdAt, + updatedAt: new Date().toISOString(), + name, + description, + sourceProjectId, + analysisLanguages, + analysis, + pt9Import, + }; + await papi.storage.writeUserData(token, projectKey(current.id), JSON.stringify(updated)); + return updated; + }); + if (replaced) return replaced; + // The import was deleted between the lookup and the queued write; fall through and create. + } + + const project = buildNew(); + await persistNewProject(token, project); + return project; +} + +/** + * Creates an editable project from a Paratext 9 import: a new record with a fresh id and timestamps + * carrying the import's analysis and analysis languages verbatim, the same source project, and no + * `pt9Import` - so the copy is an ordinary editable project that never syncs. The import itself is + * untouched. + * + * @param token - The execution token for storage access. + * @param projectId - The Paratext 9 import to copy. + * @param name - User-facing name for the copy, chosen in the copy dialog. + * @param description - Optional user-facing description for the copy. + * @throws {Error} If no project with the given ID exists. + * @throws {Error} If the project is not a Paratext 9 import - only frozen imports need an editable + * copy; ordinary projects are already editable. + * @throws {SyntaxError} If the project's storage value contains invalid JSON. + * @throws If storage reads or writes reject for a non-ENOENT reason. On an index-write failure the + * new record rolls back as in {@link createProject}. + */ +export async function createEditableCopy( + token: ExecutionToken, + projectId: string, + name: string, + description?: string, +): Promise { + const source = await getProject(token, projectId); + if (!source) throw new Error(`Project ${projectId} does not exist`); + if (!source.pt9Import) + throw new Error(`Project ${projectId} is not a Paratext 9 import; it is already editable`); + + const now = new Date().toISOString(); + const project: InterlinearProject = { + id: crypto.randomUUID(), + modelVersion: CURRENT_MODEL_VERSION, + createdAt: now, + updatedAt: now, + name, + ...(description !== undefined && { description }), + sourceProjectId: source.sourceProjectId, + analysisLanguages: source.analysisLanguages, + analysis: source.analysis, + }; + await persistNewProject(token, project); return project; } @@ -512,6 +664,8 @@ export async function getProjectsForSource( * @returns The updated project record, or `undefined` if no project with the given ID exists. * @throws {SyntaxError} If the project's storage value contains invalid JSON. * @throws {Error} If the stored record was written by a newer build; nothing is written. + * @throws {Error} If the project is a Paratext 9 import (`pt9Import` present); imports are + * read-only and only {@link savePt9Import} replaces their content. * @throws If `papi.storage.readUserData` or `papi.storage.writeUserData` rejects for a non-ENOENT * reason. */ @@ -524,6 +678,7 @@ export async function updateAnalysis( return enqueueProjectOp(id, async () => { const project = await getProject(token, id); if (!project) return undefined; + if (project.pt9Import) throw pt9ImportReadOnlyError(id); const updated: InterlinearProject = { ...project, modelVersion: CURRENT_MODEL_VERSION, @@ -553,6 +708,8 @@ export async function updateAnalysis( * @returns The updated project record, or `undefined` if no project with the given ID exists. * @throws {SyntaxError} If the project's storage value contains invalid JSON. * @throws {Error} If the stored record was written by a newer build; nothing is written. + * @throws {Error} If the project is a Paratext 9 import (`pt9Import` present); imports are + * read-only, their name and description included - the import stamps fixed values. * @throws If `papi.storage.readUserData` or `papi.storage.writeUserData` rejects for a non-ENOENT * reason. */ @@ -567,6 +724,7 @@ export async function updateProjectMetadata( return enqueueProjectOp(id, async () => { const project = await getProject(token, id); if (!project) return undefined; + if (project.pt9Import) throw pt9ImportReadOnlyError(id); const updated: InterlinearProject = { ...project, modelVersion: CURRENT_MODEL_VERSION, diff --git a/src/services/pt9ImportService.ts b/src/services/pt9ImportService.ts new file mode 100644 index 00000000..4f4b1be0 --- /dev/null +++ b/src/services/pt9ImportService.ts @@ -0,0 +1,118 @@ +import papi, { logger } from '@papi/backend'; +import type { ExecutionToken } from '@papi/core'; +import type { Book } from 'interlinearizer'; +import { extractBookFromUsj } from 'parsers/papi/usjBookExtractor'; +import { tokenizeBook } from 'parsers/papi/bookTokenizer'; +import { convertPt9Project, Pt9ImportReport } from '../converters/pt9'; +import * as projectStorage from './projectStorage'; + +/** The outcome of one import run, returned to the caller as the command's JSON payload. */ +export interface Pt9ImportResult { + /** + * `imported` when a conversion ran and its outcome was persisted. `staleKept` when the source's + * interlinear files have disappeared while an earlier import exists: the stored import is left + * untouched rather than replaced with nothing, and only an explicit delete removes it. + */ + outcome: 'imported' | 'staleKept'; + + /** The id of the created, replaced, or kept import project. */ + projectId: string; + + /** The conversion's report; absent when no conversion ran (`staleKept`). */ + report?: Pt9ImportReport; +} + +/** + * Resolves the writing system tag for the source project's text, falling back to `und` when the + * project setting is unavailable. + */ +async function getWritingSystem(sourceProjectId: string): Promise { + const basePdp = await papi.projectDataProviders.get('platform.base', sourceProjectId); + const languageTag = await basePdp.getSetting('platform.languageTag'); + return typeof languageTag === 'string' && languageTag !== '' ? languageTag : 'und'; +} + +/** + * Imports the source project's Paratext 9 interlinear data into the extension's model, serving both + * first import and sync: fetches the parsed data through the read-only Pt9Interlinear + * projectInterface, rebuilds the text layer for every book it references from the project's USJ, + * converts, and persists the outcome as the source's single frozen import project - created on + * first run, replaced wholesale on later runs. The stored name and description are the fixed + * localized values, resolved at import time. + * + * A book the source project has no USJ for is skipped and counted in the report rather than failing + * the import. + * + * @throws {Error} If the source project has no Paratext 9 interlinear data and no earlier import + * exists - nothing is created for an empty source. + * @throws If the platform cannot read or parse the project's interlinear files, the conversion + * rejects the input, or persistence fails. Nothing has been written unless persistence itself + * failed. + */ +export async function importPt9Project( + token: ExecutionToken, + sourceProjectId: string, +): Promise { + const pt9Pdp = await papi.projectDataProviders.get( + 'platformScripture.Pt9Interlinear', + sourceProjectId, + ); + const fileHashes = await pt9Pdp.getPt9InterlinearManifest(); + + if (Object.keys(fileHashes).length === 0) { + const existing = await projectStorage.getPt9ImportForSource(token, sourceProjectId); + if (existing) { + logger.warn( + `Interlinearizer: project ${sourceProjectId} has no Paratext 9 interlinear files; keeping the stored import ${existing.id} unchanged`, + ); + return { outcome: 'staleKept', projectId: existing.id }; + } + throw new Error(`Project ${sourceProjectId} has no Paratext 9 interlinear data to import`); + } + + const data = await pt9Pdp.getPt9InterlinearData(); + + const bookIds = [ + ...new Set(data.books.flatMap((book) => (book.bookId === undefined ? [] : [book.bookId]))), + ]; + const writingSystem = await getWritingSystem(sourceProjectId); + const usjPdp = await papi.projectDataProviders.get('platformScripture.USJ_Book', sourceProjectId); + const books: Book[] = ( + await Promise.all( + bookIds.map(async (bookId): Promise => { + const usj = await usjPdp.getBookUSJ({ book: bookId, chapterNum: 1, verseNum: 1 }); + if (!usj) { + logger.warn( + `Interlinearizer: project ${sourceProjectId} has no USJ for book ${bookId}; its interlinear data is skipped`, + ); + return []; + } + return [tokenizeBook(extractBookFromUsj(usj, writingSystem))]; + }), + ) + ).flat(); + + const importedAt = new Date().toISOString(); + const { analysis, analysisLanguages, report } = convertPt9Project({ data, books, importedAt }); + + const [name, description] = await Promise.all([ + papi.localization.getLocalizedString({ localizeKey: '%interlinearizer_pt9Import_name%' }), + papi.localization.getLocalizedString({ + localizeKey: '%interlinearizer_pt9Import_description%', + }), + ]); + + const project = await projectStorage.savePt9Import( + token, + sourceProjectId, + name, + description, + analysisLanguages, + analysis, + { fileHashes, importedAt }, + ); + logger.info( + `Interlinearizer: imported Paratext 9 interlinear data from ${sourceProjectId} into ${project.id}`, + ); + return { outcome: 'imported', projectId: project.id, report }; +} diff --git a/src/types/interlinear-project-summary.ts b/src/types/interlinear-project-summary.ts index e307f1bb..2a2ebadf 100644 --- a/src/types/interlinear-project-summary.ts +++ b/src/types/interlinear-project-summary.ts @@ -11,6 +11,7 @@ export type InterlinearProjectSummary = Pick< | 'analysisLanguages' | 'name' | 'description' + | 'pt9Import' >; /** @@ -33,5 +34,6 @@ export function toProjectSummary(summary: InterlinearProjectSummary): Interlinea ...(summary.targetProjectId !== undefined && { targetProjectId: summary.targetProjectId }), ...(summary.name !== undefined && { name: summary.name }), ...(summary.description !== undefined && { description: summary.description }), + ...(summary.pt9Import !== undefined && { pt9Import: summary.pt9Import }), }; } diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index bf4e4a42..b10e6d69 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -229,6 +229,42 @@ declare module 'papi-shared-types' { analysisLanguages: string[], targetProjectId?: string, ) => Promise; + + /** + * Imports the source project's Paratext 9 interlinear data, serving both first import and sync: + * creates the source's single frozen import project, or replaces its content wholesale when one + * exists. When the source's interlinear files have disappeared but an earlier import exists, + * the stored import is kept unchanged instead. + * + * @param sourceProjectId - Platform.Bible project ID whose Paratext 9 interlinear files to + * import. + * @returns A JSON string of `{ outcome, projectId, report? }`: `outcome` is `'imported'` or + * `'staleKept'`, `projectId` the import project's id, and `report` the conversion report + * (present only when `outcome` is `'imported'`). + * @throws If the source has no Paratext 9 interlinear data and no earlier import exists, if a + * file fails to parse, or if persistence fails. The error is logged and an error notification + * is sent before rethrowing so callers do not need to send a second notification. + */ + 'interlinearizer.importPt9Project': (sourceProjectId: string) => Promise; + + /** + * Creates an editable project from a Paratext 9 import: a new project carrying the import's + * analysis verbatim and no import provenance, so it never syncs and is edited like any other + * project. The import itself is untouched. + * + * @param interlinearProjectId - UUID of the Paratext 9 import to copy. + * @param name - User-facing name for the copy, chosen in the copy dialog. + * @param description - Optional user-facing description for the copy. + * @returns The created project as a JSON string. + * @throws If the project does not exist or is not a Paratext 9 import, or if storage fails. The + * error is logged and an error notification is sent before rethrowing so callers do not need + * to send a second notification. + */ + 'interlinearizer.createEditableCopy': ( + interlinearProjectId: string, + name: string, + description?: string, + ) => Promise; } } @@ -1362,6 +1398,25 @@ declare module 'interlinearizer' { * {@link SegmentationDelta}. */ segmentation?: SegmentationDelta; + + /** + * Provenance recorded by the Paratext 9 interlinear import; absent on user-created projects. + * While present, `updateAnalysis` and `updateProjectMetadata` reject the project and only + * `savePt9Import` replaces its content: a repeat import for the same `sourceProjectId` writes + * this project in place instead of creating another, so at most one project per source carries + * this field. `deleteProject` still accepts it. + */ + pt9Import?: { + /** + * SHA-256 hex of each imported source file, keyed by project-relative path, exactly as the + * source projectInterface reported it at import time. A later import compares a fresh + * manifest against this to decide whether the source changed since this import. + */ + fileHashes: Record; + + /** ISO 8601 timestamp of the import that produced the current analysis. */ + importedAt: string; + }; } /** diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts index 7d70fc10..d036f82b 100644 --- a/src/types/type-guards.ts +++ b/src/types/type-guards.ts @@ -31,7 +31,24 @@ export function isInterlinearProjectSummary(p: unknown): p is InterlinearProject p.analysisLanguages.every((l) => typeof l === 'string') && (!('name' in p) || typeof p.name === 'string') && (!('description' in p) || typeof p.description === 'string') && - (!('targetProjectId' in p) || typeof p.targetProjectId === 'string') + (!('targetProjectId' in p) || typeof p.targetProjectId === 'string') && + (!('pt9Import' in p) || isPt9ImportProvenance(p.pt9Import)) + ); +} + +/** Type guard for the `pt9Import` provenance an imported project carries. */ +export function isPt9ImportProvenance( + value: unknown, +): value is NonNullable { + return ( + !!value && + typeof value === 'object' && + 'importedAt' in value && + typeof value.importedAt === 'string' && + 'fileHashes' in value && + !!value.fileHashes && + typeof value.fileHashes === 'object' && + Object.values(value.fileHashes).every((hash) => typeof hash === 'string') ); }