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')
);
}