feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -0,0 +1,206 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { supabase } from '../../lib/supabase';
import {
createPortableArchive,
serializePortableArchive,
} from './canonical-json';
import { parseImportedPortableFile } from './legacy-import';
import { parsePortableArchive } from './portability-schema';
import { PortabilityError, toPortabilityError } from './portability-error';
import type {
PortableArchive,
PortablePayload,
PortabilityExportRpcRow,
PortabilityRestoreResult,
} from './portability-types';
interface RpcClient {
rpc: (
functionName: string,
args?: Record<string, unknown>,
) => PromiseLike<{ data: unknown; error: unknown }>;
}
export interface AccountExportResult {
archive: PortableArchive;
payload: PortablePayload;
serialized: string;
rowCount: number;
exportedAt: string;
}
export interface AccountImportResult {
result: PortabilityRestoreResult;
payload: PortablePayload;
legacy: boolean;
detectedFormat: string;
}
function requireUserId(userId: string): void {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
userId,
)
) {
throw new PortabilityError('auth', 'A valid signed-in account is required');
}
}
async function assertCurrentUser(
client: SupabaseClient,
userId: string,
): Promise<void> {
requireUserId(userId);
const { data, error } = await client.auth.getUser();
if (error !== null) throw toPortabilityError(error);
if (data.user === null || data.user.id !== userId) {
throw new PortabilityError(
'auth',
'The active session does not match the requested account',
);
}
}
function parseExportRpcRow(value: unknown): PortabilityExportRpcRow {
const candidate = Array.isArray(value) ? value[0] : value;
if (candidate === null || typeof candidate !== 'object') {
throw new PortabilityError('server', 'Export server returned no archive');
}
const row = candidate as Record<string, unknown>;
if (
typeof row.canonical_payload !== 'string' ||
typeof row.checksum !== 'string' ||
typeof row.exported_at !== 'string' ||
!Number.isSafeInteger(row.row_count) ||
Number(row.row_count) < 0
) {
throw new PortabilityError(
'server',
'Export server returned an invalid archive contract',
);
}
return {
canonical_payload: row.canonical_payload,
checksum: row.checksum,
exported_at: row.exported_at,
row_count: Number(row.row_count),
};
}
function parseRestoreResult(value: unknown): PortabilityRestoreResult {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new PortabilityError(
'server',
'Restore server returned an invalid result',
);
}
const result = value as Record<string, unknown>;
if (
(result.status !== 'imported' && result.status !== 'duplicate') ||
typeof result.checksum !== 'string' ||
!/^[0-9a-f]{64}$/.test(result.checksum) ||
!Number.isSafeInteger(result.imported_rows) ||
Number(result.imported_rows) < 0 ||
!Number.isSafeInteger(result.skipped_rows) ||
Number(result.skipped_rows) < 0 ||
typeof result.imported_at !== 'string' ||
!Number.isFinite(Date.parse(result.imported_at))
) {
throw new PortabilityError(
'server',
'Restore server result failed validation',
);
}
return {
status: result.status,
checksum: result.checksum,
imported_rows: Number(result.imported_rows),
skipped_rows: Number(result.skipped_rows),
imported_at: result.imported_at,
};
}
export async function exportAccountArchive(
userId: string,
client: SupabaseClient = supabase,
): Promise<AccountExportResult> {
try {
await assertCurrentUser(client, userId);
const { data, error } = await (client as unknown as RpcClient).rpc(
'export_account_portability',
);
if (error !== null) throw error;
const row = parseExportRpcRow(data);
const archive = createPortableArchive(row.canonical_payload, row.checksum);
const parsed = parsePortableArchive(
serializePortableArchive(archive),
userId,
);
const actualRows = Object.values(parsed.payload.datasets).reduce(
(sum, rows) => sum + rows.length,
0,
);
if (actualRows !== row.row_count) {
throw new PortabilityError(
'server',
'Export row count does not match the archive',
);
}
if (parsed.payload.exported_at !== row.exported_at) {
throw new PortabilityError(
'server',
'Export timestamp does not match the archive',
);
}
return {
archive,
payload: parsed.payload,
serialized: serializePortableArchive(archive),
rowCount: actualRows,
exportedAt: row.exported_at,
};
} catch (error) {
throw toPortabilityError(error);
}
}
export async function importAccountArchive(
userId: string,
rawFile: string,
fileName: string,
mimeType: string | null,
client: SupabaseClient = supabase,
): Promise<AccountImportResult> {
try {
await assertCurrentUser(client, userId);
const imported = parseImportedPortableFile(
rawFile,
fileName,
mimeType,
userId,
);
const { data, error } = await (client as unknown as RpcClient).rpc(
'restore_account_portability',
{
canonical_payload: imported.archive.canonical_payload,
supplied_checksum: imported.archive.checksum,
},
);
if (error !== null) throw error;
const result = parseRestoreResult(data);
if (result.checksum !== imported.archive.checksum) {
throw new PortabilityError(
'server',
'Restore result checksum does not match the selected archive',
);
}
return {
result,
payload: imported.payload,
legacy: imported.legacy,
detectedFormat: imported.detectedFormat,
};
} catch (error) {
throw toPortabilityError(error);
}
}

View file

@ -0,0 +1,90 @@
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
import {
PORTABILITY_CHECKSUM_ALGORITHM,
PORTABILITY_FORMAT,
PORTABILITY_MAX_BYTES,
PORTABILITY_SCHEMA_VERSION,
type PortableArchive,
} from './portability-types';
import { PortabilityError } from './portability-error';
export function utf8ByteLength(value: string): number {
return utf8ToBytes(value).length;
}
export function sha256Hex(value: string): string {
return bytesToHex(sha256(utf8ToBytes(value)));
}
export function stableJsonStringify(value: unknown): string {
if (value === null) return 'null';
if (typeof value === 'string' || typeof value === 'boolean')
return JSON.stringify(value);
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
throw new PortabilityError(
'validation',
'Portable JSON cannot contain non-finite numbers',
);
}
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(item => stableJsonStringify(item)).join(',')}]`;
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
const entries = Object.keys(record)
.sort()
.map(key => `${JSON.stringify(key)}:${stableJsonStringify(record[key])}`);
return `{${entries.join(',')}}`;
}
throw new PortabilityError(
'validation',
`Portable JSON cannot contain ${typeof value}`,
);
}
export function createPortableArchive(
canonicalPayload: string,
expectedChecksum?: string,
): PortableArchive {
const byteLength = utf8ByteLength(canonicalPayload);
if (byteLength === 0 || byteLength > PORTABILITY_MAX_BYTES) {
throw new PortabilityError(
'size',
`Portable payload must contain 1-${PORTABILITY_MAX_BYTES} UTF-8 bytes`,
);
}
const checksum = sha256Hex(canonicalPayload);
if (
expectedChecksum !== undefined &&
!constantTimeHexEqual(checksum, expectedChecksum.toLowerCase())
) {
throw new PortabilityError(
'checksum',
'Server export checksum did not match its payload',
);
}
return {
format: PORTABILITY_FORMAT,
schema_version: PORTABILITY_SCHEMA_VERSION,
checksum_algorithm: PORTABILITY_CHECKSUM_ALGORITHM,
checksum,
canonical_payload: canonicalPayload,
};
}
export function serializePortableArchive(archive: PortableArchive): string {
return `${stableJsonStringify(archive)}\n`;
}
export function constantTimeHexEqual(left: string, right: string): boolean {
if (left.length !== right.length || left.length === 0) return false;
let difference = 0;
for (let index = 0; index < left.length; index += 1) {
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return difference === 0;
}

View file

@ -0,0 +1,190 @@
import {
errorCodes,
isErrorWithCode,
keepLocalCopy,
pick,
types,
} from '@react-native-documents/picker';
import { Dirs, FileSystem } from 'react-native-file-access';
import { PortabilityError } from './portability-error';
import { PORTABILITY_MAX_BYTES } from './portability-types';
export interface PickedPortabilityFile {
fileName: string;
mimeType: string | null;
content: string;
dispose: () => Promise<void>;
}
const MAX_ARCHIVE_FILE_BYTES = PORTABILITY_MAX_BYTES + 128 * 1024;
const ALLOWED_EXTENSIONS = ['.json', '.csv', '.txt', '.md', '.markdown'];
const ALLOWED_MIME_TYPES = new Set([
'application/json',
'text/csv',
'text/plain',
'text/markdown',
'text/x-markdown',
'application/octet-stream',
'',
]);
function localPathFromUri(uri: string): string {
if (!uri.startsWith('file://')) return uri;
const path = decodeURIComponent(uri.slice('file://'.length));
return path.startsWith('/') ? path : `/${path}`;
}
function isOwnedCachePath(path: string): boolean {
const root = Dirs.CacheDir.replace(/\\/g, '/').replace(/\/$/, '');
const normalized = path.replace(/\\/g, '/');
return (
normalized.startsWith(`${root}/`) &&
!normalized
.slice(root.length + 1)
.split('/')
.includes('..')
);
}
async function removeOwnedCopy(path: string): Promise<void> {
if (!isOwnedCachePath(path)) {
throw new PortabilityError(
'file-read',
'Refusing to remove a file outside the import cache',
);
}
if (await FileSystem.exists(path)) await FileSystem.unlink(path);
}
function validateMetadata(
nameValue: string | null,
mimeValue: string | null,
): string {
const fileName = (nameValue ?? '').trim();
const mimeType = (mimeValue ?? '').trim().toLowerCase();
const lowerName = fileName.toLowerCase();
if (fileName.length === 0 || fileName.length > 255) {
throw new PortabilityError(
'format',
'Selected file name is missing or too long',
);
}
if (!ALLOWED_EXTENSIONS.some(extension => lowerName.endsWith(extension))) {
throw new PortabilityError(
'format',
'Selected file extension is unsupported',
);
}
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
throw new PortabilityError(
'format',
'Selected file MIME type is unsupported',
);
}
return fileName;
}
export async function pickPortabilityFile(): Promise<PickedPortabilityFile> {
try {
const [selected] = await pick({
type: [
types.plainText,
'application/json',
'text/csv',
'text/markdown',
'text/x-markdown',
],
allowMultiSelection: false,
allowVirtualFiles: false,
mode: 'import',
presentationStyle: 'fullScreen',
});
if (selected.error !== null || selected.isVirtual === true) {
throw new PortabilityError(
'file-read',
'The file provider returned unreadable metadata',
);
}
if (
selected.size !== null &&
(!Number.isSafeInteger(selected.size) ||
selected.size <= 0 ||
selected.size > MAX_ARCHIVE_FILE_BYTES)
) {
throw new PortabilityError(
'size',
'Selected import file is empty or too large',
);
}
const fileName = validateMetadata(selected.name, selected.type);
const [copy] = await keepLocalCopy({
files: [{ uri: selected.uri, fileName }],
destination: 'cachesDirectory',
});
if (copy.status !== 'success') {
throw new PortabilityError(
'file-read',
'Selected file could not be copied into app storage',
true,
);
}
const path = localPathFromUri(copy.localUri);
const dispose = async (): Promise<void> => removeOwnedCopy(path);
try {
const stat = await FileSystem.stat(path);
if (
!Number.isSafeInteger(stat.size) ||
stat.size <= 0 ||
stat.size > MAX_ARCHIVE_FILE_BYTES
) {
throw new PortabilityError(
'size',
'Copied import file is empty or too large',
);
}
const content = await FileSystem.readFile(path, 'utf8');
const replacementCount = [...content].filter(
character => character === '\ufffd',
).length;
if (replacementCount > Math.max(3, Math.floor(content.length * 0.01))) {
throw new PortabilityError(
'format',
'Selected file is not valid UTF-8 text',
);
}
return { fileName, mimeType: selected.type, content, dispose };
} catch (error) {
await dispose().catch(() => undefined);
throw error;
}
} catch (error) {
if (error instanceof PortabilityError) throw error;
if (isErrorWithCode(error)) {
if (error.code === errorCodes.OPERATION_CANCELED) {
throw new PortabilityError('cancelled', 'File selection was cancelled');
}
if (error.code === errorCodes.IN_PROGRESS) {
throw new PortabilityError(
'file-read',
'A file picker is already open',
true,
error,
);
}
if (error.code === errorCodes.UNABLE_TO_OPEN_FILE_TYPE) {
throw new PortabilityError(
'format',
'This file provider cannot export the selected type',
false,
error,
);
}
}
throw new PortabilityError(
'file-read',
'The data import file picker could not be opened',
true,
error,
);
}
}

View file

@ -0,0 +1,356 @@
import { utf8ToBytes } from '@noble/hashes/utils';
import { PortabilityError } from './portability-error';
function concatBytes(chunks: Uint8Array[]): Uint8Array {
const length = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
function uint16le(value: number): Uint8Array {
return Uint8Array.of(value & 0xff, (value >>> 8) & 0xff);
}
function uint32le(value: number): Uint8Array {
return Uint8Array.of(
value & 0xff,
(value >>> 8) & 0xff,
(value >>> 16) & 0xff,
(value >>> 24) & 0xff,
);
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < table.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
interface ZipEntry {
name: string;
data: Uint8Array;
}
function createStoredZip(entries: ZipEntry[]): Uint8Array {
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
for (const entry of entries) {
const name = utf8ToBytes(entry.name);
const checksum = crc32(entry.data);
const localHeader = concatBytes([
uint32le(0x04034b50),
uint16le(20),
uint16le(0x0800),
uint16le(0),
uint16le(0),
uint16le(0),
uint32le(checksum),
uint32le(entry.data.length),
uint32le(entry.data.length),
uint16le(name.length),
uint16le(0),
name,
]);
localChunks.push(localHeader, entry.data);
centralChunks.push(
concatBytes([
uint32le(0x02014b50),
uint16le(20),
uint16le(20),
uint16le(0x0800),
uint16le(0),
uint16le(0),
uint16le(0),
uint32le(checksum),
uint32le(entry.data.length),
uint32le(entry.data.length),
uint16le(name.length),
uint16le(0),
uint16le(0),
uint16le(0),
uint16le(0),
uint32le(0),
uint32le(localOffset),
name,
]),
);
localOffset += localHeader.length + entry.data.length;
}
const central = concatBytes(centralChunks);
const end = concatBytes([
uint32le(0x06054b50),
uint16le(0),
uint16le(0),
uint16le(entries.length),
uint16le(entries.length),
uint32le(central.length),
uint32le(localOffset),
uint16le(0),
]);
return concatBytes([...localChunks, central, end]);
}
function xmlEscape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function markdownParagraphXml(markdown: string): string {
return markdown
.split(/\r?\n/)
.map(line => {
let text = line;
let style = '';
let numbering = '';
if (line.startsWith('### ')) {
text = line.slice(4);
style = '<w:pPr><w:pStyle w:val="Heading3"/></w:pPr>';
} else if (line.startsWith('## ')) {
text = line.slice(3);
style = '<w:pPr><w:pStyle w:val="Heading2"/></w:pPr>';
} else if (line.startsWith('# ')) {
text = line.slice(2);
style = '<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>';
} else if (line.startsWith('- ') || line.startsWith('* ')) {
text = line.slice(2);
numbering =
'<w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr>';
}
if (text.length === 0) return '<w:p/>';
return `<w:p>${
style || numbering
}<w:r><w:t xml:space="preserve">${xmlEscape(text)}</w:t></w:r></w:p>`;
})
.join('');
}
export function buildDocx(
markdown: string,
title: string,
createdAt = new Date(),
): Uint8Array {
if (markdown.length === 0 || markdown.length > 2_000_000) {
throw new PortabilityError(
'validation',
'DOCX source is empty or too large',
);
}
const iso = createdAt.toISOString();
const files: ZipEntry[] = [
{
name: '[Content_Types].xml',
data: utf8ToBytes(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>',
),
},
{
name: '_rels/.rels',
data: utf8ToBytes(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>',
),
},
{
name: 'word/_rels/document.xml.rels',
data: utf8ToBytes(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>',
),
},
{
name: 'word/styles.xml',
data: utf8ToBytes(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="24"/></w:rPr></w:style></w:styles>',
),
},
{
name: 'word/numbering.xml',
data: utf8ToBytes(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="•"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>',
),
},
{
name: 'word/document.xml',
data: utf8ToBytes(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${markdownParagraphXml(
markdown,
)}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"/></w:sectPr></w:body></w:document>`,
),
},
{
name: 'docProps/core.xml',
data: utf8ToBytes(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(
title,
)}</dc:title><dc:creator>D3RO Voice</dc:creator><dcterms:created xsi:type="dcterms:W3CDTF">${iso}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">${iso}</dcterms:modified></cp:coreProperties>`,
),
},
{
name: 'docProps/app.xml',
data: utf8ToBytes(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>D3RO Voice</Application><AppVersion>1.0</AppVersion></Properties>',
),
},
];
return createStoredZip(files);
}
function utf16BeHex(value: string): string {
let result = '';
for (let index = 0; index < value.length; index += 1) {
result += value
.charCodeAt(index)
.toString(16)
.padStart(4, '0')
.toUpperCase();
}
return result;
}
function pdfTextLines(markdown: string): string[] {
const lines: string[] = [];
for (const sourceLine of markdown.replace(/\r\n?/g, '\n').split('\n')) {
const line = sourceLine.replace(/^#{1,3}\s+/, '').replace(/^[-*]\s+/, '• ');
if (line.length === 0) {
lines.push('');
continue;
}
const characters = [...line];
for (let offset = 0; offset < characters.length; offset += 54) {
lines.push(characters.slice(offset, offset + 54).join(''));
}
}
return lines;
}
function asciiBytes(value: string): Uint8Array {
const bytes = new Uint8Array(value.length);
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code > 0x7f)
throw new PortabilityError(
'validation',
'PDF container contains non-ASCII syntax',
);
bytes[index] = code;
}
return bytes;
}
export function buildPdf(markdown: string): Uint8Array {
if (markdown.length === 0 || markdown.length > 2_000_000) {
throw new PortabilityError(
'validation',
'PDF source is empty or too large',
);
}
const allLines = pdfTextLines(markdown);
const pageLines: string[][] = [];
for (let offset = 0; offset < allLines.length; offset += 48) {
pageLines.push(allLines.slice(offset, offset + 48));
}
if (pageLines.length === 0) pageLines.push(['']);
const objects = new Map<number, string>();
objects.set(1, '<< /Type /Catalog /Pages 2 0 R >>');
objects.set(
3,
'<< /Type /Font /Subtype /Type0 /BaseFont /HYSMyeongJo-Medium /Encoding /UniKS-UCS2-H /DescendantFonts [4 0 R] >>',
);
objects.set(
4,
'<< /Type /Font /Subtype /CIDFontType0 /BaseFont /HYSMyeongJo-Medium /CIDSystemInfo << /Registry (Adobe) /Ordering (Korea1) /Supplement 2 >> >>',
);
const pageObjectIds: number[] = [];
pageLines.forEach((lines, pageIndex) => {
const pageObjectId = 5 + pageIndex * 2;
const contentObjectId = pageObjectId + 1;
pageObjectIds.push(pageObjectId);
const commands = [
'BT',
'/F1 10 Tf',
'48 794 Td',
'14 TL',
...lines.flatMap(line => [`<${utf16BeHex(line)}> Tj`, 'T*']),
'ET',
].join('\n');
objects.set(
pageObjectId,
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`,
);
objects.set(
contentObjectId,
`<< /Length ${commands.length} >>\nstream\n${commands}\nendstream`,
);
});
objects.set(
2,
`<< /Type /Pages /Count ${pageObjectIds.length} /Kids [${pageObjectIds
.map(id => `${id} 0 R`)
.join(' ')}] >>`,
);
const maxObjectId = 4 + pageLines.length * 2;
let pdf = '%PDF-1.4\n% D3RO Voice\n';
const offsets = new Array<number>(maxObjectId + 1).fill(0);
for (let id = 1; id <= maxObjectId; id += 1) {
const body = objects.get(id);
if (body === undefined)
throw new PortabilityError(
'validation',
'PDF object graph is incomplete',
);
offsets[id] = pdf.length;
pdf += `${id} 0 obj\n${body}\nendobj\n`;
}
const xrefOffset = pdf.length;
pdf += `xref\n0 ${maxObjectId + 1}\n0000000000 65535 f \n`;
for (let id = 1; id <= maxObjectId; id += 1) {
pdf += `${offsets[id].toString().padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${
maxObjectId + 1
} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return asciiBytes(pdf);
}
export function bytesToBase64(bytes: Uint8Array): string {
const alphabet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let result = '';
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index];
const second = index + 1 < bytes.length ? bytes[index + 1] : 0;
const third = index + 2 < bytes.length ? bytes[index + 2] : 0;
const value = (first << 16) | (second << 8) | third;
result += alphabet[(value >>> 18) & 63];
result += alphabet[(value >>> 12) & 63];
result += index + 1 < bytes.length ? alphabet[(value >>> 6) & 63] : '=';
result += index + 2 < bytes.length ? alphabet[value & 63] : '=';
}
return result;
}

View file

@ -0,0 +1,11 @@
export * from './account-portability-service';
export * from './canonical-json';
export * from './data-file-picker';
export * from './document-binary';
export * from './legacy-import';
export * from './meeting-export';
export * from './portability-error';
export * from './portability-schema';
export * from './portability-types';
export * from './portable-file';
export * from './portable-text';

View file

@ -0,0 +1,728 @@
import {
createPortableArchive,
sha256Hex,
stableJsonStringify,
utf8ByteLength,
} from './canonical-json';
import {
parsePortableArchive,
parsePortablePayload,
} from './portability-schema';
import { PortabilityError } from './portability-error';
import { parseCsv, unescapeD3roTxtField } from './portable-text';
import {
PORTABILITY_FORMAT,
PORTABILITY_MAX_BYTES,
PORTABILITY_SCHEMA_VERSION,
type PortableArchive,
type PortableDatasets,
type PortableDictionaryRow,
type PortableHistoryRow,
type PortableMeetingRow,
type PortablePayload,
} from './portability-types';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export interface ImportedPortableFile {
archive: PortableArchive;
payload: PortablePayload;
legacy: boolean;
detectedFormat:
| 'd3ro-json'
| 'legacy-json'
| 'dictionary-csv'
| 'dictionary-txt'
| 'desktop-meeting-markdown'
| 'plain-text';
}
interface LegacyContext {
ownerId: string;
now: string;
fileName: string;
}
type UnknownRecord = Record<string, unknown>;
function record(value: unknown, label: string): UnknownRecord {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new PortabilityError('format', `${label} must be an object`);
}
return value as UnknownRecord;
}
function deterministicUuid(
ownerId: string,
namespace: string,
identity: string,
): string {
const hex = sha256Hex(`${ownerId}\u0000${namespace}\u0000${identity}`)
.slice(0, 32)
.split('');
hex[12] = '5';
const variant = Number.parseInt(hex[16], 16);
hex[16] = ((variant & 0x3) | 0x8).toString(16);
const joined = hex.join('');
return `${joined.slice(0, 8)}-${joined.slice(8, 12)}-${joined.slice(
12,
16,
)}-${joined.slice(16, 20)}-${joined.slice(20)}`;
}
function legacyId(
value: unknown,
context: LegacyContext,
namespace: string,
identity: string,
): string {
if (typeof value === 'string' && UUID_PATTERN.test(value))
return value.toLowerCase();
return deterministicUuid(context.ownerId, namespace, identity);
}
function legacyOwner(row: UnknownRecord, context: LegacyContext): string {
const supplied = row.user_id ?? row.userId ?? row.owner_id ?? row.ownerId;
if (supplied === undefined || supplied === null || supplied === '')
return context.ownerId;
if (
typeof supplied !== 'string' ||
supplied.toLowerCase() !== context.ownerId.toLowerCase()
) {
throw new PortabilityError(
'owner',
'Legacy file declares a different account owner',
);
}
return context.ownerId;
}
function legacyString(
value: unknown,
label: string,
max: number,
allowEmpty = false,
): string {
if (typeof value !== 'string')
throw new PortabilityError('format', `${label} must be text`);
const normalized = value.trim();
if ((!allowEmpty && normalized.length === 0) || normalized.length > max) {
throw new PortabilityError('validation', `${label} has an invalid length`);
}
return normalized;
}
function optionalString(
value: unknown,
label: string,
max: number,
): string | null {
if (value === undefined || value === null || value === '') return null;
return legacyString(value, label, max, true);
}
function legacyTimestamp(
value: unknown,
fallback: string,
label: string,
): string {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
const date = new Date(value);
if (!Number.isFinite(date.getTime()))
throw new PortabilityError('format', `${label} is invalid`);
return date.toISOString();
}
if (typeof value === 'string' && Number.isFinite(Date.parse(value))) {
return new Date(value).toISOString();
}
throw new PortabilityError('format', `${label} is not a timestamp`);
}
function emptyDatasets(): PortableDatasets {
return {
dictionary: [],
history: [],
meetings: [],
transcripts: [],
meeting_memos: [],
meeting_documents: [],
custom_instructions: [],
};
}
function payloadFromDatasets(
context: LegacyContext,
datasets: PortableDatasets,
source: 'desktop-legacy' | 'web-legacy',
): PortablePayload {
return {
format: PORTABILITY_FORMAT,
schema_version: PORTABILITY_SCHEMA_VERSION,
exported_at: context.now,
owner_id: context.ownerId,
source,
account: { profile: null, settings: null, subscription: null },
datasets,
exclusions: [
'raw_audio',
'storage_objects',
'payment_credentials',
'push_tokens',
],
};
}
function dictionaryRowFromLegacy(
value: unknown,
index: number,
context: LegacyContext,
): PortableDictionaryRow {
const row = record(value, `dictionary[${index}]`);
legacyOwner(row, context);
const word = legacyString(row.word, `dictionary[${index}].word`, 120);
const categoryValue = row.category ?? 'user';
if (!['user', 'auto', 'technical'].includes(String(categoryValue))) {
throw new PortabilityError(
'validation',
`dictionary[${index}].category is invalid`,
);
}
const category = String(categoryValue) as PortableDictionaryRow['category'];
const pronunciation = optionalString(
row.pronunciation,
`dictionary[${index}].pronunciation`,
200,
);
const identity = `${word.toLocaleLowerCase()}\u0000${category}`;
const createdAt = legacyTimestamp(
row.created_at ?? row.createdAt,
context.now,
`dictionary[${index}].created_at`,
);
const updatedAt = legacyTimestamp(
row.updated_at ?? row.updatedAt,
createdAt,
`dictionary[${index}].updated_at`,
);
const usageValue = row.usage_count ?? row.usageCount ?? 0;
if (!Number.isSafeInteger(Number(usageValue)) || Number(usageValue) < 0) {
throw new PortabilityError(
'validation',
`dictionary[${index}].usage_count is invalid`,
);
}
return {
id: legacyId(row.id, context, 'dictionary', identity),
user_id: context.ownerId,
word,
pronunciation,
category,
usage_count: Number(usageValue),
last_used_at:
row.last_used_at === null || row.lastUsedAt === null
? null
: row.last_used_at === undefined && row.lastUsedAt === undefined
? null
: legacyTimestamp(
row.last_used_at ?? row.lastUsedAt,
createdAt,
`dictionary[${index}].last_used_at`,
),
created_at: createdAt,
updated_at: updatedAt,
};
}
function historyRowFromLegacy(
value: unknown,
index: number,
context: LegacyContext,
): PortableHistoryRow {
const row = record(value, `history[${index}]`);
legacyOwner(row, context);
const originalText = legacyString(
row.original_text ?? row.originalText ?? row.text ?? '',
`history[${index}].original_text`,
1_000_000,
true,
);
const modeValue = row.mode ?? 'dictation';
if (
![
'dictation',
'translate',
'command',
'caption',
'file-transcription',
].includes(String(modeValue))
) {
throw new PortabilityError(
'validation',
`history[${index}].mode is invalid`,
);
}
const statusValue = row.status ?? 'completed';
if (!['completed', 'cancelled', 'error'].includes(String(statusValue))) {
throw new PortabilityError(
'validation',
`history[${index}].status is invalid`,
);
}
const createdAt = legacyTimestamp(
row.created_at ?? row.createdAt,
context.now,
`history[${index}].created_at`,
);
const updatedAt = legacyTimestamp(
row.updated_at ?? row.updatedAt,
createdAt,
`history[${index}].updated_at`,
);
const identity = `${createdAt}\u0000${originalText}\u0000${String(
modeValue,
)}`;
const duration = Number(row.duration ?? row.durationMs ?? 0);
const wordCount = Number(
row.word_count ??
row.wordCount ??
originalText.split(/\s+/).filter(Boolean).length,
);
const revision = Number(row.revision ?? 1);
if (
!Number.isFinite(duration) ||
duration < 0 ||
!Number.isSafeInteger(wordCount) ||
wordCount < 0 ||
!Number.isSafeInteger(revision) ||
revision < 1
) {
throw new PortabilityError(
'validation',
`history[${index}] numeric metadata is invalid`,
);
}
return {
id: legacyId(row.id, context, 'history', identity),
user_id: context.ownerId,
title: optionalString(row.title, `history[${index}].title`, 300),
original_text: originalText,
polished_text: optionalString(
row.polished_text ?? row.polishedText,
`history[${index}].polished_text`,
1_000_000,
),
focused_app: optionalString(
row.focused_app ?? row.focusedApp,
`history[${index}].focused_app`,
500,
),
focused_app_name: optionalString(
row.focused_app_name ?? row.focusedAppName,
`history[${index}].focused_app_name`,
500,
),
focused_app_window_title: optionalString(
row.focused_app_window_title ?? row.focusedAppWindowTitle,
`history[${index}].focused_app_window_title`,
2_000,
),
mode: String(modeValue) as PortableHistoryRow['mode'],
status: String(statusValue) as PortableHistoryRow['status'],
error_code: optionalString(
row.error_code ?? row.errorCode,
`history[${index}].error_code`,
120,
),
duration,
detected_language: optionalString(
row.detected_language ?? row.detectedLanguage,
`history[${index}].detected_language`,
32,
),
mic_device: optionalString(
row.mic_device ?? row.micDevice,
`history[${index}].mic_device`,
500,
),
word_count: wordCount,
stt_model: optionalString(
row.stt_model ?? row.sttModel,
`history[${index}].stt_model`,
120,
),
llm_model: optionalString(
row.llm_model ?? row.llmModel,
`history[${index}].llm_model`,
120,
),
stt_latency_ms: null,
llm_latency_ms: null,
app_version:
optionalString(
row.app_version ?? row.appVersion,
`history[${index}].app_version`,
64,
) ?? 'legacy-import',
summary_text: optionalString(
row.summary_text ?? row.summaryText,
`history[${index}].summary_text`,
1_000_000,
),
is_favorite: row.is_favorite === true || row.isFavorite === true,
revision,
created_at: createdAt,
updated_at: updatedAt,
};
}
function legacyJsonPayload(
value: unknown,
context: LegacyContext,
): PortablePayload {
const datasets = emptyDatasets();
let dictionaryValues: unknown[] = [];
let historyValues: unknown[] = [];
if (Array.isArray(value)) {
if (value.length === 0)
throw new PortabilityError('format', 'Legacy JSON array is empty');
const first = record(value[0], 'legacy[0]');
if ('word' in first) dictionaryValues = value;
else if (
'originalText' in first ||
'original_text' in first ||
'text' in first
)
historyValues = value;
else
throw new PortabilityError(
'format',
'Legacy JSON dataset could not be identified',
);
} else {
const root = record(value, 'legacy JSON');
const owner = root.owner_id ?? root.ownerId ?? root.user_id ?? root.userId;
if (owner !== undefined && owner !== context.ownerId) {
throw new PortabilityError(
'owner',
'Legacy JSON belongs to a different account',
);
}
const dictionaryCandidate = root.dictionary ?? root.entries;
const historyCandidate = root.history;
if (dictionaryCandidate !== undefined) {
if (!Array.isArray(dictionaryCandidate))
throw new PortabilityError(
'format',
'Legacy dictionary must be an array',
);
dictionaryValues = dictionaryCandidate;
}
if (historyCandidate !== undefined) {
if (!Array.isArray(historyCandidate))
throw new PortabilityError('format', 'Legacy history must be an array');
historyValues = historyCandidate;
}
if (dictionaryValues.length === 0 && historyValues.length === 0) {
throw new PortabilityError(
'format',
'Legacy JSON has no supported dataset',
);
}
}
datasets.dictionary = dictionaryValues.map((row, index) =>
dictionaryRowFromLegacy(row, index, context),
);
datasets.history = historyValues.map((row, index) =>
historyRowFromLegacy(row, index, context),
);
return payloadFromDatasets(context, datasets, 'desktop-legacy');
}
function csvPayload(raw: string, context: LegacyContext): PortablePayload {
const rows = parseCsv(raw.replace(/^\uFEFF/, ''));
const headers = rows[0].map(header => header.trim());
const datasets = emptyDatasets();
const headerSet = new Set(headers);
if (!headerSet.has('word'))
throw new PortabilityError('format', 'Only dictionary CSV is supported');
const allowed = new Set([
'd3ro_schema_version',
'id',
'user_id',
'userId',
'word',
'pronunciation',
'category',
'usage_count',
'usageCount',
'last_used_at',
'lastUsedAt',
'created_at',
'createdAt',
'updated_at',
'updatedAt',
]);
if (
headers.some(header => !allowed.has(header)) ||
new Set(headers).size !== headers.length
) {
throw new PortabilityError(
'format',
'Dictionary CSV has duplicate or unsupported columns',
);
}
if (
headerSet.has('d3ro_schema_version') &&
rows
.slice(1)
.some(row => row[headers.indexOf('d3ro_schema_version')] !== '1')
) {
throw new PortabilityError(
'format',
'Dictionary CSV schema version is unsupported',
);
}
datasets.dictionary = rows.slice(1).map((cells, index) => {
const row: UnknownRecord = {};
headers.forEach((header, column) => {
if (header !== 'd3ro_schema_version' && cells[column] !== '')
row[header] = cells[column];
});
return dictionaryRowFromLegacy(row, index, context);
});
if (datasets.dictionary.length === 0)
throw new PortabilityError('format', 'Dictionary CSV has no rows');
return payloadFromDatasets(context, datasets, 'desktop-legacy');
}
function dictionaryTxtPayload(
raw: string,
context: LegacyContext,
): PortablePayload {
const normalized = raw.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const lines = normalized.split('\n').filter(line => line.length > 0);
if (
lines[0] !== '# D3RO dictionary TXT v1' ||
lines[1] !== 'word\tpronunciation\tcategory'
) {
throw new PortabilityError('format', 'Dictionary TXT header is invalid');
}
const datasets = emptyDatasets();
datasets.dictionary = lines.slice(2).map((line, index) => {
const cells = line.split('\t');
if (cells.length !== 3)
throw new PortabilityError(
'format',
`Dictionary TXT row ${index + 1} is invalid`,
);
return dictionaryRowFromLegacy(
{
word: unescapeD3roTxtField(cells[0]),
pronunciation: unescapeD3roTxtField(cells[1]),
category: cells[2],
},
index,
context,
);
});
if (datasets.dictionary.length === 0)
throw new PortabilityError('format', 'Dictionary TXT has no rows');
return payloadFromDatasets(context, datasets, 'desktop-legacy');
}
function desktopMeetingPayload(
raw: string,
context: LegacyContext,
): PortablePayload {
const normalized = raw.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const titleMatch =
normalized.match(/^# 회의록 — (.+)$/m) ??
normalized.match(/^# Meeting Minutes — (.+)$/m);
const transcriptMarker = normalized.match(
/^## (?:원문 전사|Original Transcript)\s*$/m,
);
if (
titleMatch === null ||
transcriptMarker === null ||
transcriptMarker.index === undefined
) {
throw new PortabilityError(
'format',
'Desktop meeting Markdown markers are missing',
);
}
const transcript = normalized
.slice(transcriptMarker.index + transcriptMarker[0].length)
.trim();
const minutesStart = normalized.indexOf('\n---\n');
const minutesEnd = normalized.lastIndexOf('\n---\n', transcriptMarker.index);
const minutes =
minutesStart >= 0 && minutesEnd > minutesStart
? normalized.slice(minutesStart + 5, minutesEnd).trim()
: null;
const meetingId = deterministicUuid(
context.ownerId,
'meeting-markdown',
sha256Hex(normalized),
);
const meeting: PortableMeetingRow = {
id: meetingId,
user_id: context.ownerId,
team_id: null,
title: legacyString(titleMatch[1], 'meeting title', 300),
status: 'completed',
started_at: context.now,
ended_at: context.now,
duration_ms: null,
raw_transcript:
transcript === '(없음)' || transcript === '(none)' ? null : transcript,
edited_transcript: null,
minutes_markdown: minutes,
minutes_json: null,
stt_model: null,
llm_model: null,
stt_latency_ms: null,
llm_latency_ms: null,
error_message: null,
created_at: context.now,
updated_at: context.now,
};
const datasets = emptyDatasets();
datasets.meetings = [meeting];
return payloadFromDatasets(context, datasets, 'desktop-legacy');
}
function plainTextPayload(
raw: string,
context: LegacyContext,
): PortablePayload {
if (raw.trim().length === 0 || raw.length > 1_000_000) {
throw new PortabilityError(
'validation',
'TXT import must contain 1-1000000 characters',
);
}
const datasets = emptyDatasets();
datasets.history = [
historyRowFromLegacy(
{
title: context.fileName.replace(/\.[^.]+$/, ''),
original_text: raw.replace(/^\uFEFF/, '').trim(),
mode: 'file-transcription',
status: 'completed',
app_version: 'text-import',
},
0,
context,
),
];
return payloadFromDatasets(context, datasets, 'web-legacy');
}
function archiveFromLegacyPayload(
payload: PortablePayload,
): ImportedPortableFile {
const canonicalPayload = stableJsonStringify(payload);
const validatedPayload = parsePortablePayload(canonicalPayload);
return {
archive: createPortableArchive(canonicalPayload),
payload: validatedPayload,
legacy: true,
detectedFormat: 'legacy-json',
};
}
export function parseImportedPortableFile(
raw: string,
fileName: string,
mimeType: string | null,
ownerId: string,
now = new Date().toISOString(),
): ImportedPortableFile {
if (!UUID_PATTERN.test(ownerId))
throw new PortabilityError('auth', 'A valid signed-in account is required');
if (
utf8ByteLength(raw) === 0 ||
utf8ByteLength(raw) > PORTABILITY_MAX_BYTES + 128 * 1024
) {
throw new PortabilityError(
'size',
'Selected import file is empty or too large',
);
}
const normalizedName = fileName.trim().toLowerCase();
const normalizedMime = (mimeType ?? '').trim().toLowerCase();
const context: LegacyContext = {
ownerId: ownerId.toLowerCase(),
now,
fileName: fileName.trim() || 'import.txt',
};
if (
normalizedName.endsWith('.json') ||
normalizedMime === 'application/json'
) {
let parsed: unknown;
try {
parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
} catch (error) {
throw new PortabilityError(
'format',
'JSON import is malformed',
false,
error,
);
}
if (
parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
(parsed as UnknownRecord).format === PORTABILITY_FORMAT
) {
const result = parsePortableArchive(raw, ownerId);
return { ...result, legacy: false, detectedFormat: 'd3ro-json' };
}
return archiveFromLegacyPayload(legacyJsonPayload(parsed, context));
}
if (normalizedName.endsWith('.csv') || normalizedMime === 'text/csv') {
const converted = archiveFromLegacyPayload(csvPayload(raw, context));
return { ...converted, detectedFormat: 'dictionary-csv' };
}
if (
normalizedName.endsWith('.md') ||
normalizedName.endsWith('.markdown') ||
normalizedMime === 'text/markdown'
) {
const converted = archiveFromLegacyPayload(
desktopMeetingPayload(raw, context),
);
return { ...converted, detectedFormat: 'desktop-meeting-markdown' };
}
if (normalizedName.endsWith('.txt') || normalizedMime === 'text/plain') {
const converted = raw
.replace(/^\uFEFF/, '')
.startsWith('# D3RO dictionary TXT v1')
? archiveFromLegacyPayload(dictionaryTxtPayload(raw, context))
: archiveFromLegacyPayload(plainTextPayload(raw, context));
return {
...converted,
detectedFormat: raw
.replace(/^\uFEFF/, '')
.startsWith('# D3RO dictionary TXT v1')
? 'dictionary-txt'
: 'plain-text',
};
}
throw new PortabilityError(
'format',
'Only JSON, CSV, TXT, MD, and Markdown imports are supported',
);
}

View file

@ -0,0 +1,189 @@
import type {
Meeting,
MeetingDocument,
MeetingMemo,
Transcript,
} from '@d3ro/api-client';
import { buildDocx, buildPdf } from './document-binary';
import { PortabilityError } from './portability-error';
import type { MeetingDocumentFormat } from './portability-types';
export interface MeetingExportInput {
meeting: Meeting;
transcripts: Transcript[];
memos: MeetingMemo[];
documents: MeetingDocument[];
}
function formatDuration(milliseconds: number | null): string {
if (milliseconds === null) return '-';
const totalMinutes = Math.floor(milliseconds / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
const hours = Math.floor(seconds / 3_600);
const minutes = Math.floor((seconds % 3_600) / 60);
const remainder = seconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, '0')}:${String(
remainder,
).padStart(2, '0')}`
: `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(
2,
'0',
)}`;
}
function escapeMarkdownTable(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, '<br>');
}
function transcriptText(input: MeetingExportInput): string {
if (input.transcripts.length > 0) {
return input.transcripts
.slice()
.sort((left, right) => left.segment_index - right.segment_index)
.map(segment => {
const speaker = segment.speaker?.trim();
return `[${formatElapsed(segment.timestamp_ms)}] ${
speaker ? `${speaker}: ` : ''
}${segment.text}`;
})
.join('\n');
}
return (
input.meeting.edited_transcript?.trim() ||
input.meeting.raw_transcript?.trim() ||
'(none)'
);
}
export function buildMeetingMarkdown(input: MeetingExportInput): string {
if (input.meeting.user_id.length === 0) {
throw new PortabilityError('validation', 'Meeting export owner is missing');
}
const title = input.meeting.title?.trim() || 'Untitled meeting';
const memoTable =
input.memos.length > 0
? [
'## Participant notes',
'',
'| Time | Note |',
'|---|---|',
...input.memos
.slice()
.sort((left, right) => left.timestamp_ms - right.timestamp_ms)
.map(
memo =>
`| ${formatElapsed(memo.timestamp_ms)} | ${escapeMarkdownTable(
memo.content,
)} |`,
),
].join('\n')
: '## Participant notes\n\n(none)';
const documents =
input.documents.length > 0
? input.documents
.slice()
.sort((left, right) =>
left.created_at.localeCompare(right.created_at),
)
.map(
document =>
`## ${document.title}\n\n${document.content || '(empty)'}`,
)
.join('\n\n---\n\n')
: '## Generated documents\n\n(none)';
return [
`# Meeting Minutes — ${title}`,
'',
`- **Started**: ${input.meeting.started_at}`,
`- **Ended**: ${input.meeting.ended_at ?? '-'}`,
`- **Duration**: ${formatDuration(input.meeting.duration_ms)}`,
`- **STT model**: ${input.meeting.stt_model ?? '-'}`,
`- **LLM model**: ${input.meeting.llm_model ?? '-'}`,
'',
'---',
'',
input.meeting.minutes_markdown?.trim() ||
'## Summary\n\nNo minutes were generated.',
'',
'---',
'',
memoTable,
'',
'---',
'',
documents,
'',
'---',
'',
'## Original Transcript',
'',
transcriptText(input),
'',
].join('\n');
}
export function buildMeetingPlainText(input: MeetingExportInput): string {
const markdown = buildMeetingMarkdown(input);
return markdown
.replace(/^#{1,3}\s+/gm, '')
.replace(/^---$/gm, '────────────────────────')
.replace(/^\|---\|---\|$/gm, '')
.replace(/^\|\s*(.*?)\s*\|\s*(.*?)\s*\|$/gm, '$1 — $2')
.replace(/^[-*]\s+\*\*(.+?)\*\*:\s*/gm, '$1: ')
.replace(/<br>/g, '\n')
.replace(/\\\|/g, '|');
}
export function buildMeetingExport(
input: MeetingExportInput,
format: MeetingDocumentFormat,
): {
mimeType: string;
extension: string;
text: string | null;
bytes: Uint8Array | null;
} {
const markdown = buildMeetingMarkdown(input);
if (format === 'md') {
return {
mimeType: 'text/markdown',
extension: 'md',
text: markdown,
bytes: null,
};
}
if (format === 'txt') {
return {
mimeType: 'text/plain',
extension: 'txt',
text: buildMeetingPlainText(input),
bytes: null,
};
}
if (format === 'pdf') {
return {
mimeType: 'application/pdf',
extension: 'pdf',
text: null,
bytes: buildPdf(markdown),
};
}
return {
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
extension: 'docx',
text: null,
bytes: buildDocx(markdown, input.meeting.title?.trim() || 'D3RO meeting'),
};
}

View file

@ -0,0 +1,81 @@
export type PortabilityErrorCode =
| 'auth'
| 'cancelled'
| 'checksum'
| 'conflict'
| 'duplicate'
| 'file-read'
| 'file-share'
| 'format'
| 'network'
| 'owner'
| 'server'
| 'size'
| 'validation';
export class PortabilityError extends Error {
constructor(
public readonly code: PortabilityErrorCode,
message: string,
public readonly retryable = false,
public readonly causeValue: unknown = null,
) {
super(message);
this.name = 'PortabilityError';
}
}
export function toPortabilityError(error: unknown): PortabilityError {
if (error instanceof PortabilityError) return error;
const candidate = error as {
code?: unknown;
message?: unknown;
details?: unknown;
};
const code = typeof candidate?.code === 'string' ? candidate.code : '';
const message =
typeof candidate?.message === 'string'
? candidate.message
: 'Data portability request failed';
const details =
typeof candidate?.details === 'string' ? candidate.details : '';
const combined = `${message} ${details}`.toLowerCase();
if (
error instanceof TypeError ||
combined.includes('network request failed') ||
combined.includes('failed to fetch') ||
combined.includes('networkerror')
) {
return new PortabilityError('network', message, true, error);
}
if (
code === 'PGRST301' ||
code === '42501' ||
combined.includes('authentication_required')
) {
return new PortabilityError('auth', message, false, error);
}
if (
code === '40001' ||
(code === 'P0001' &&
(combined.includes('conflict') ||
combined.includes('portability_import_busy')))
) {
return new PortabilityError('conflict', message, true, error);
}
if (combined.includes('checksum')) {
return new PortabilityError('checksum', message, false, error);
}
if (combined.includes('owner') || combined.includes('cross_user')) {
return new PortabilityError('owner', message, false, error);
}
if (combined.includes('payload_too_large')) {
return new PortabilityError('size', message, false, error);
}
if (code.startsWith('22') || combined.includes('invalid_')) {
return new PortabilityError('validation', message, false, error);
}
return new PortabilityError('server', message, false, error);
}

View file

@ -0,0 +1,972 @@
import { createPortableArchive, utf8ByteLength } from './canonical-json';
import { PortabilityError } from './portability-error';
import {
PORTABILITY_CHECKSUM_ALGORITHM,
PORTABILITY_FORMAT,
PORTABILITY_MAX_BYTES,
PORTABILITY_MAX_ROWS,
PORTABILITY_SCHEMA_VERSION,
type PortableAccountSnapshot,
type PortableArchive,
type PortableCustomInstructionRow,
type PortableDatasets,
type PortableDictionaryRow,
type PortableHistoryRow,
type PortableMeetingDocumentRow,
type PortableMeetingMemoRow,
type PortableMeetingRow,
type PortablePayload,
type PortableProfileSnapshot,
type PortableSettingsSnapshot,
type PortableSubscriptionSnapshot,
type PortableTranscriptRow,
type PortabilitySource,
} from './portability-types';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const CHECKSUM_PATTERN = /^[0-9a-f]{64}$/;
const ARCHIVE_MAX_BYTES = PORTABILITY_MAX_BYTES + 128 * 1024;
type UnknownRecord = Record<string, unknown>;
function fail(message: string): never {
throw new PortabilityError('format', message);
}
function record(value: unknown, label: string): UnknownRecord {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return fail(`${label} must be an object`);
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
return fail(`${label} has an unsupported prototype`);
}
return value as UnknownRecord;
}
function exactKeys(
value: UnknownRecord,
keys: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
fail(`${label} contains missing or unsupported fields`);
}
}
function stringValue(
value: unknown,
label: string,
maxLength: number,
allowEmpty = false,
): string {
if (
typeof value !== 'string' ||
(!allowEmpty && value.length === 0) ||
value.length > maxLength
) {
return fail(
`${label} must be ${allowEmpty ? '0' : '1'}-${maxLength} characters`,
);
}
return value;
}
function nullableString(
value: unknown,
label: string,
maxLength: number,
): string | null {
if (value === null) return null;
return stringValue(value, label, maxLength, true);
}
function uuid(value: unknown, label: string): string {
if (typeof value !== 'string' || !UUID_PATTERN.test(value)) {
return fail(`${label} must be a UUID`);
}
return value.toLowerCase();
}
function timestamp(value: unknown, label: string): string {
const parsed = stringValue(value, label, 40);
if (
!/^\d{4}-\d{2}-\d{2}T/.test(parsed) ||
!Number.isFinite(Date.parse(parsed))
) {
return fail(`${label} must be an ISO-8601 timestamp`);
}
return parsed;
}
function nullableTimestamp(value: unknown, label: string): string | null {
return value === null ? null : timestamp(value, label);
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') return fail(`${label} must be a boolean`);
return value;
}
function numberValue(
value: unknown,
label: string,
options: { integer?: boolean; min?: number; max?: number } = {},
): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fail(`${label} must be a finite number`);
}
if (options.integer && !Number.isSafeInteger(value))
return fail(`${label} must be an integer`);
if (options.min !== undefined && value < options.min)
return fail(`${label} is too small`);
if (options.max !== undefined && value > options.max)
return fail(`${label} is too large`);
return value;
}
function nullableNumber(
value: unknown,
label: string,
options: { integer?: boolean; min?: number; max?: number } = {},
): number | null {
return value === null ? null : numberValue(value, label, options);
}
function enumValue<T extends string>(
value: unknown,
allowed: readonly T[],
label: string,
): T {
if (typeof value !== 'string' || !allowed.includes(value as T)) {
return fail(`${label} is unsupported`);
}
return value as T;
}
function arrayValue(value: unknown, label: string, maxRows: number): unknown[] {
if (!Array.isArray(value) || value.length > maxRows) {
return fail(`${label} must be an array with at most ${maxRows} rows`);
}
return value;
}
function parseProfile(value: unknown): PortableProfileSnapshot | null {
if (value === null) return null;
const row = record(value, 'account.profile');
exactKeys(
row,
['id', 'name', 'avatar_url', 'locale', 'tier', 'created_at', 'updated_at'],
'account.profile',
);
return {
id: uuid(row.id, 'account.profile.id'),
name: nullableString(row.name, 'account.profile.name', 80),
avatar_url: nullableString(
row.avatar_url,
'account.profile.avatar_url',
2048,
),
locale: stringValue(row.locale, 'account.profile.locale', 16),
tier: stringValue(row.tier, 'account.profile.tier', 32),
created_at: timestamp(row.created_at, 'account.profile.created_at'),
updated_at: timestamp(row.updated_at, 'account.profile.updated_at'),
};
}
function parseSettings(value: unknown): PortableSettingsSnapshot | null {
if (value === null) return null;
const row = record(value, 'account.settings');
exactKeys(
row,
[
'theme_mode',
'locale',
'haptic_enabled',
'auto_polish_enabled',
'preferred_stt_model',
'preferred_llm_model',
'onboarding_version',
'tutorial_completed_at',
'revision',
'updated_at',
],
'account.settings',
);
return {
theme_mode: enumValue(
row.theme_mode,
['system', 'light', 'dark'],
'account.settings.theme_mode',
),
locale: stringValue(row.locale, 'account.settings.locale', 16),
haptic_enabled: booleanValue(
row.haptic_enabled,
'account.settings.haptic_enabled',
),
auto_polish_enabled: booleanValue(
row.auto_polish_enabled,
'account.settings.auto_polish_enabled',
),
preferred_stt_model: nullableString(
row.preferred_stt_model,
'account.settings.preferred_stt_model',
120,
),
preferred_llm_model: nullableString(
row.preferred_llm_model,
'account.settings.preferred_llm_model',
120,
),
onboarding_version: numberValue(
row.onboarding_version,
'account.settings.onboarding_version',
{ integer: true, min: 0, max: 1_000 },
),
tutorial_completed_at: nullableTimestamp(
row.tutorial_completed_at,
'account.settings.tutorial_completed_at',
),
revision: numberValue(row.revision, 'account.settings.revision', {
integer: true,
min: 1,
}),
updated_at: timestamp(row.updated_at, 'account.settings.updated_at'),
};
}
function parseSubscription(
value: unknown,
): PortableSubscriptionSnapshot | null {
if (value === null) return null;
const row = record(value, 'account.subscription');
exactKeys(
row,
[
'tier',
'provider',
'status',
'current_period_start',
'current_period_end',
'cancel_at',
'auto_renewing',
'updated_at',
],
'account.subscription',
);
return {
tier: stringValue(row.tier, 'account.subscription.tier', 32),
provider: stringValue(row.provider, 'account.subscription.provider', 32),
status: nullableString(row.status, 'account.subscription.status', 64),
current_period_start: nullableTimestamp(
row.current_period_start,
'account.subscription.current_period_start',
),
current_period_end: nullableTimestamp(
row.current_period_end,
'account.subscription.current_period_end',
),
cancel_at: nullableTimestamp(
row.cancel_at,
'account.subscription.cancel_at',
),
auto_renewing:
row.auto_renewing === null
? null
: booleanValue(row.auto_renewing, 'account.subscription.auto_renewing'),
updated_at: timestamp(row.updated_at, 'account.subscription.updated_at'),
};
}
function parseAccount(value: unknown): PortableAccountSnapshot {
const account = record(value, 'account');
exactKeys(account, ['profile', 'settings', 'subscription'], 'account');
return {
profile: parseProfile(account.profile),
settings: parseSettings(account.settings),
subscription: parseSubscription(account.subscription),
};
}
function parseDictionaryRow(
value: unknown,
index: number,
): PortableDictionaryRow {
const label = `datasets.dictionary[${index}]`;
const row = record(value, label);
exactKeys(
row,
[
'id',
'user_id',
'word',
'pronunciation',
'category',
'usage_count',
'last_used_at',
'created_at',
'updated_at',
],
label,
);
return {
id: uuid(row.id, `${label}.id`),
user_id: uuid(row.user_id, `${label}.user_id`),
word: stringValue(row.word, `${label}.word`, 120),
pronunciation: nullableString(
row.pronunciation,
`${label}.pronunciation`,
200,
),
category: enumValue(
row.category,
['user', 'auto', 'technical'],
`${label}.category`,
),
usage_count: numberValue(row.usage_count, `${label}.usage_count`, {
integer: true,
min: 0,
max: 2_147_483_647,
}),
last_used_at: nullableTimestamp(row.last_used_at, `${label}.last_used_at`),
created_at: timestamp(row.created_at, `${label}.created_at`),
updated_at: timestamp(row.updated_at, `${label}.updated_at`),
};
}
function parseHistoryRow(value: unknown, index: number): PortableHistoryRow {
const label = `datasets.history[${index}]`;
const row = record(value, label);
exactKeys(
row,
[
'id',
'user_id',
'title',
'original_text',
'polished_text',
'focused_app',
'focused_app_name',
'focused_app_window_title',
'mode',
'status',
'error_code',
'duration',
'detected_language',
'mic_device',
'word_count',
'stt_model',
'llm_model',
'stt_latency_ms',
'llm_latency_ms',
'app_version',
'summary_text',
'is_favorite',
'revision',
'created_at',
'updated_at',
],
label,
);
return {
id: uuid(row.id, `${label}.id`),
user_id: uuid(row.user_id, `${label}.user_id`),
title: nullableString(row.title, `${label}.title`, 300),
original_text: stringValue(
row.original_text,
`${label}.original_text`,
1_000_000,
true,
),
polished_text: nullableString(
row.polished_text,
`${label}.polished_text`,
1_000_000,
),
focused_app: nullableString(row.focused_app, `${label}.focused_app`, 500),
focused_app_name: nullableString(
row.focused_app_name,
`${label}.focused_app_name`,
500,
),
focused_app_window_title: nullableString(
row.focused_app_window_title,
`${label}.focused_app_window_title`,
2_000,
),
mode: enumValue(
row.mode,
['dictation', 'translate', 'command', 'caption', 'file-transcription'],
`${label}.mode`,
),
status: enumValue(
row.status,
['completed', 'cancelled', 'error'],
`${label}.status`,
),
error_code: nullableString(row.error_code, `${label}.error_code`, 120),
duration: numberValue(row.duration, `${label}.duration`, {
min: 0,
max: 86_400_000,
}),
detected_language: nullableString(
row.detected_language,
`${label}.detected_language`,
32,
),
mic_device: nullableString(row.mic_device, `${label}.mic_device`, 500),
word_count: numberValue(row.word_count, `${label}.word_count`, {
integer: true,
min: 0,
max: 10_000_000,
}),
stt_model: nullableString(row.stt_model, `${label}.stt_model`, 120),
llm_model: nullableString(row.llm_model, `${label}.llm_model`, 120),
stt_latency_ms: nullableNumber(
row.stt_latency_ms,
`${label}.stt_latency_ms`,
{ integer: true, min: 0 },
),
llm_latency_ms: nullableNumber(
row.llm_latency_ms,
`${label}.llm_latency_ms`,
{ integer: true, min: 0 },
),
app_version: stringValue(row.app_version, `${label}.app_version`, 64),
summary_text: nullableString(
row.summary_text,
`${label}.summary_text`,
1_000_000,
),
is_favorite: booleanValue(row.is_favorite, `${label}.is_favorite`),
revision: numberValue(row.revision, `${label}.revision`, {
integer: true,
min: 1,
}),
created_at: timestamp(row.created_at, `${label}.created_at`),
updated_at: timestamp(row.updated_at, `${label}.updated_at`),
};
}
function parseMinutesJson(
value: unknown,
label: string,
): Record<string, unknown> | null {
if (value === null) return null;
const result = record(value, label);
if (JSON.stringify(result).length > 250_000) fail(`${label} is too large`);
return result;
}
function parseMeetingRow(value: unknown, index: number): PortableMeetingRow {
const label = `datasets.meetings[${index}]`;
const row = record(value, label);
exactKeys(
row,
[
'id',
'user_id',
'team_id',
'title',
'status',
'started_at',
'ended_at',
'duration_ms',
'raw_transcript',
'edited_transcript',
'minutes_markdown',
'minutes_json',
'stt_model',
'llm_model',
'stt_latency_ms',
'llm_latency_ms',
'error_message',
'created_at',
'updated_at',
],
label,
);
if (row.team_id !== null)
fail(`${label}.team_id must be null in a personal archive`);
return {
id: uuid(row.id, `${label}.id`),
user_id: uuid(row.user_id, `${label}.user_id`),
team_id: null,
title: nullableString(row.title, `${label}.title`, 300),
status: enumValue(
row.status,
['recording', 'processing', 'completed', 'error'],
`${label}.status`,
),
started_at: timestamp(row.started_at, `${label}.started_at`),
ended_at: nullableTimestamp(row.ended_at, `${label}.ended_at`),
duration_ms: nullableNumber(row.duration_ms, `${label}.duration_ms`, {
integer: true,
min: 0,
max: 604_800_000,
}),
raw_transcript: nullableString(
row.raw_transcript,
`${label}.raw_transcript`,
2_000_000,
),
edited_transcript: nullableString(
row.edited_transcript,
`${label}.edited_transcript`,
2_000_000,
),
minutes_markdown: nullableString(
row.minutes_markdown,
`${label}.minutes_markdown`,
1_000_000,
),
minutes_json: parseMinutesJson(row.minutes_json, `${label}.minutes_json`),
stt_model: nullableString(row.stt_model, `${label}.stt_model`, 120),
llm_model: nullableString(row.llm_model, `${label}.llm_model`, 120),
stt_latency_ms: nullableNumber(
row.stt_latency_ms,
`${label}.stt_latency_ms`,
{ integer: true, min: 0 },
),
llm_latency_ms: nullableNumber(
row.llm_latency_ms,
`${label}.llm_latency_ms`,
{ integer: true, min: 0 },
),
error_message: nullableString(
row.error_message,
`${label}.error_message`,
4_000,
),
created_at: timestamp(row.created_at, `${label}.created_at`),
updated_at: timestamp(row.updated_at, `${label}.updated_at`),
};
}
function parseTranscriptRow(
value: unknown,
index: number,
): PortableTranscriptRow {
const label = `datasets.transcripts[${index}]`;
const row = record(value, label);
exactKeys(
row,
[
'id',
'meeting_id',
'segment_index',
'timestamp_ms',
'duration_ms',
'text',
'speaker',
'edited',
'created_at',
'updated_at',
],
label,
);
return {
id: uuid(row.id, `${label}.id`),
meeting_id: uuid(row.meeting_id, `${label}.meeting_id`),
segment_index: numberValue(row.segment_index, `${label}.segment_index`, {
integer: true,
min: 0,
max: 1_000_000,
}),
timestamp_ms: numberValue(row.timestamp_ms, `${label}.timestamp_ms`, {
integer: true,
min: 0,
max: 604_800_000,
}),
duration_ms: nullableNumber(row.duration_ms, `${label}.duration_ms`, {
integer: true,
min: 0,
max: 86_400_000,
}),
text: stringValue(row.text, `${label}.text`, 100_000, true),
speaker: nullableString(row.speaker, `${label}.speaker`, 120),
edited: booleanValue(row.edited, `${label}.edited`),
created_at: timestamp(row.created_at, `${label}.created_at`),
updated_at: timestamp(row.updated_at, `${label}.updated_at`),
};
}
function parseMemoRow(value: unknown, index: number): PortableMeetingMemoRow {
const label = `datasets.meeting_memos[${index}]`;
const row = record(value, label);
exactKeys(
row,
['id', 'meeting_id', 'user_id', 'content', 'timestamp_ms', 'created_at'],
label,
);
return {
id: uuid(row.id, `${label}.id`),
meeting_id: uuid(row.meeting_id, `${label}.meeting_id`),
user_id: uuid(row.user_id, `${label}.user_id`),
content: stringValue(row.content, `${label}.content`, 4_000),
timestamp_ms: numberValue(row.timestamp_ms, `${label}.timestamp_ms`, {
integer: true,
min: 0,
max: 604_800_000,
}),
created_at: timestamp(row.created_at, `${label}.created_at`),
};
}
function parseDocumentRow(
value: unknown,
index: number,
): PortableMeetingDocumentRow {
const label = `datasets.meeting_documents[${index}]`;
const row = record(value, label);
exactKeys(
row,
[
'id',
'meeting_id',
'user_id',
'template_type',
'title',
'content',
'prompt_used',
'llm_model',
'llm_latency_ms',
'created_at',
'updated_at',
],
label,
);
return {
id: uuid(row.id, `${label}.id`),
meeting_id: uuid(row.meeting_id, `${label}.meeting_id`),
user_id: uuid(row.user_id, `${label}.user_id`),
template_type: enumValue(
row.template_type,
['minutes', 'report', 'idea-note', 'custom', 'mindmap'],
`${label}.template_type`,
),
title: stringValue(row.title, `${label}.title`, 300),
content: stringValue(row.content, `${label}.content`, 1_000_000, true),
prompt_used: nullableString(
row.prompt_used,
`${label}.prompt_used`,
10_000,
),
llm_model: nullableString(row.llm_model, `${label}.llm_model`, 120),
llm_latency_ms: nullableNumber(
row.llm_latency_ms,
`${label}.llm_latency_ms`,
{ integer: true, min: 0 },
),
created_at: timestamp(row.created_at, `${label}.created_at`),
updated_at: timestamp(row.updated_at, `${label}.updated_at`),
};
}
function parseInstructionRow(
value: unknown,
index: number,
): PortableCustomInstructionRow {
const label = `datasets.custom_instructions[${index}]`;
const row = record(value, label);
exactKeys(
row,
[
'id',
'user_id',
'builtin_key',
'name',
'description',
'prompt',
'icon',
'sort_order',
'revision',
'created_at',
'updated_at',
],
label,
);
if (row.builtin_key !== null) fail(`${label}.builtin_key must be null`);
return {
id: uuid(row.id, `${label}.id`),
user_id: uuid(row.user_id, `${label}.user_id`),
builtin_key: null,
name: stringValue(row.name, `${label}.name`, 80),
description: stringValue(
row.description,
`${label}.description`,
240,
true,
),
prompt: stringValue(row.prompt, `${label}.prompt`, 4_000),
icon: stringValue(row.icon, `${label}.icon`, 32),
sort_order: numberValue(row.sort_order, `${label}.sort_order`, {
integer: true,
min: 0,
max: 1_000_000,
}),
revision: numberValue(row.revision, `${label}.revision`, {
integer: true,
min: 1,
}),
created_at: timestamp(row.created_at, `${label}.created_at`),
updated_at: timestamp(row.updated_at, `${label}.updated_at`),
};
}
function parseDatasets(value: unknown): PortableDatasets {
const datasets = record(value, 'datasets');
exactKeys(
datasets,
[
'dictionary',
'history',
'meetings',
'transcripts',
'meeting_memos',
'meeting_documents',
'custom_instructions',
],
'datasets',
);
const result: PortableDatasets = {
dictionary: arrayValue(
datasets.dictionary,
'datasets.dictionary',
5_000,
).map(parseDictionaryRow),
history: arrayValue(datasets.history, 'datasets.history', 5_000).map(
parseHistoryRow,
),
meetings: arrayValue(datasets.meetings, 'datasets.meetings', 1_000).map(
parseMeetingRow,
),
transcripts: arrayValue(
datasets.transcripts,
'datasets.transcripts',
10_000,
).map(parseTranscriptRow),
meeting_memos: arrayValue(
datasets.meeting_memos,
'datasets.meeting_memos',
5_000,
).map(parseMemoRow),
meeting_documents: arrayValue(
datasets.meeting_documents,
'datasets.meeting_documents',
2_000,
).map(parseDocumentRow),
custom_instructions: arrayValue(
datasets.custom_instructions,
'datasets.custom_instructions',
1_000,
).map(parseInstructionRow),
};
const total = Object.values(result).reduce(
(sum, rows) => sum + rows.length,
0,
);
if (total > PORTABILITY_MAX_ROWS)
fail(`datasets exceed ${PORTABILITY_MAX_ROWS} total rows`);
return result;
}
export function parsePortablePayload(
canonicalPayload: string,
): PortablePayload {
const byteLength = utf8ByteLength(canonicalPayload);
if (byteLength === 0 || byteLength > PORTABILITY_MAX_BYTES) {
throw new PortabilityError(
'size',
'Portable payload is empty or too large',
);
}
let parsed: unknown;
try {
parsed = JSON.parse(canonicalPayload);
} catch (error) {
throw new PortabilityError(
'format',
'Portable payload is not valid JSON',
false,
error,
);
}
const root = record(parsed, 'payload');
exactKeys(
root,
[
'format',
'schema_version',
'exported_at',
'owner_id',
'source',
'account',
'datasets',
'exclusions',
],
'payload',
);
if (root.format !== PORTABILITY_FORMAT)
fail('Portable payload format is unsupported');
if (root.schema_version !== PORTABILITY_SCHEMA_VERSION)
fail('Portable payload schema version is unsupported');
const exclusions = arrayValue(root.exclusions, 'exclusions', 4);
const expectedExclusions = [
'raw_audio',
'storage_objects',
'payment_credentials',
'push_tokens',
];
if (
exclusions.length !== expectedExclusions.length ||
exclusions.some((value, index) => value !== expectedExclusions[index])
) {
fail('Portable payload exclusions are invalid');
}
const ownerId = uuid(root.owner_id, 'payload.owner_id');
const account = parseAccount(root.account);
if (account.profile !== null && account.profile.id !== ownerId) {
throw new PortabilityError(
'owner',
'Profile owner does not match archive owner',
);
}
const datasets = parseDatasets(root.datasets);
assertDatasetOwnershipAndRelationships(ownerId, datasets);
return {
format: PORTABILITY_FORMAT,
schema_version: PORTABILITY_SCHEMA_VERSION,
exported_at: timestamp(root.exported_at, 'payload.exported_at'),
owner_id: ownerId,
source: enumValue(
root.source,
['cloud', 'mobile', 'desktop-legacy', 'web-legacy'],
'payload.source',
) as PortabilitySource,
account,
datasets,
exclusions: [
'raw_audio',
'storage_objects',
'payment_credentials',
'push_tokens',
],
};
}
function assertDatasetOwnershipAndRelationships(
ownerId: string,
datasets: PortableDatasets,
): void {
const ownedRows = [
...datasets.dictionary,
...datasets.history,
...datasets.meetings,
...datasets.meeting_memos,
...datasets.meeting_documents,
...datasets.custom_instructions,
];
if (ownedRows.some(row => row.user_id !== ownerId)) {
throw new PortabilityError(
'owner',
'Archive contains data belonging to another account',
);
}
const meetingIds = new Set(datasets.meetings.map(meeting => meeting.id));
const childRows = [
...datasets.transcripts,
...datasets.meeting_memos,
...datasets.meeting_documents,
];
if (childRows.some(row => !meetingIds.has(row.meeting_id))) {
throw new PortabilityError(
'format',
'Archive contains a meeting child without its parent',
);
}
for (const rows of Object.values(datasets)) {
const ids = new Set<string>();
for (const row of rows) {
if (ids.has(row.id))
throw new PortabilityError('duplicate', 'Archive repeats a row id');
ids.add(row.id);
}
}
}
export function parsePortableArchive(
rawFile: string,
expectedOwnerId?: string,
): {
archive: PortableArchive;
payload: PortablePayload;
} {
if (utf8ByteLength(rawFile) > ARCHIVE_MAX_BYTES) {
throw new PortabilityError('size', 'Portable archive is too large');
}
let parsed: unknown;
try {
parsed = JSON.parse(rawFile.replace(/^\uFEFF/, ''));
} catch (error) {
throw new PortabilityError(
'format',
'Selected archive is not valid JSON',
false,
error,
);
}
const root = record(parsed, 'archive');
exactKeys(
root,
[
'format',
'schema_version',
'checksum_algorithm',
'checksum',
'canonical_payload',
],
'archive',
);
if (root.format !== PORTABILITY_FORMAT) fail('Archive format is unsupported');
if (root.schema_version !== PORTABILITY_SCHEMA_VERSION)
fail('Archive schema version is unsupported');
if (root.checksum_algorithm !== PORTABILITY_CHECKSUM_ALGORITHM)
fail('Archive checksum algorithm is unsupported');
if (
typeof root.checksum !== 'string' ||
!CHECKSUM_PATTERN.test(root.checksum)
) {
fail('Archive checksum is invalid');
}
const canonicalPayload = stringValue(
root.canonical_payload,
'archive.canonical_payload',
PORTABILITY_MAX_BYTES,
);
const archive = createPortableArchive(canonicalPayload, root.checksum);
const payload = parsePortablePayload(canonicalPayload);
if (
expectedOwnerId !== undefined &&
payload.owner_id !== uuid(expectedOwnerId, 'expected owner')
) {
throw new PortabilityError(
'owner',
'This archive belongs to a different account',
);
}
return { archive, payload };
}

View file

@ -0,0 +1,230 @@
export const PORTABILITY_FORMAT = 'd3ro-account-portability' as const;
export const PORTABILITY_SCHEMA_VERSION = 1 as const;
export const PORTABILITY_CHECKSUM_ALGORITHM = 'sha256' as const;
export const PORTABILITY_MAX_BYTES = 5 * 1024 * 1024;
export const PORTABILITY_MAX_ROWS = 10_000;
export type PortabilitySource =
| 'cloud'
| 'mobile'
| 'desktop-legacy'
| 'web-legacy';
export interface PortableProfileSnapshot {
id: string;
name: string | null;
avatar_url: string | null;
locale: string;
tier: string;
created_at: string;
updated_at: string;
}
export interface PortableSettingsSnapshot {
theme_mode: 'system' | 'light' | 'dark';
locale: string;
haptic_enabled: boolean;
auto_polish_enabled: boolean;
preferred_stt_model: string | null;
preferred_llm_model: string | null;
onboarding_version: number;
tutorial_completed_at: string | null;
revision: number;
updated_at: string;
}
export interface PortableSubscriptionSnapshot {
tier: string;
provider: string;
status: string | null;
current_period_start: string | null;
current_period_end: string | null;
cancel_at: string | null;
auto_renewing: boolean | null;
updated_at: string;
}
export interface PortableAccountSnapshot {
profile: PortableProfileSnapshot | null;
settings: PortableSettingsSnapshot | null;
subscription: PortableSubscriptionSnapshot | null;
}
export interface PortableDictionaryRow {
id: string;
user_id: string;
word: string;
pronunciation: string | null;
category: 'user' | 'auto' | 'technical';
usage_count: number;
last_used_at: string | null;
created_at: string;
updated_at: string;
}
export interface PortableHistoryRow {
id: string;
user_id: string;
title: string | null;
original_text: string;
polished_text: string | null;
focused_app: string | null;
focused_app_name: string | null;
focused_app_window_title: string | null;
mode:
| 'dictation'
| 'translate'
| 'command'
| 'caption'
| 'file-transcription';
status: 'completed' | 'cancelled' | 'error';
error_code: string | null;
duration: number;
detected_language: string | null;
mic_device: string | null;
word_count: number;
stt_model: string | null;
llm_model: string | null;
stt_latency_ms: number | null;
llm_latency_ms: number | null;
app_version: string;
summary_text: string | null;
is_favorite: boolean;
revision: number;
created_at: string;
updated_at: string;
}
export interface PortableMeetingRow {
id: string;
user_id: string;
team_id: null;
title: string | null;
status: 'recording' | 'processing' | 'completed' | 'error';
started_at: string;
ended_at: string | null;
duration_ms: number | null;
raw_transcript: string | null;
edited_transcript: string | null;
minutes_markdown: string | null;
minutes_json: Record<string, unknown> | null;
stt_model: string | null;
llm_model: string | null;
stt_latency_ms: number | null;
llm_latency_ms: number | null;
error_message: string | null;
created_at: string;
updated_at: string;
}
export interface PortableTranscriptRow {
id: string;
meeting_id: string;
segment_index: number;
timestamp_ms: number;
duration_ms: number | null;
text: string;
speaker: string | null;
edited: boolean;
created_at: string;
updated_at: string;
}
export interface PortableMeetingMemoRow {
id: string;
meeting_id: string;
user_id: string;
content: string;
timestamp_ms: number;
created_at: string;
}
export interface PortableMeetingDocumentRow {
id: string;
meeting_id: string;
user_id: string;
template_type: 'minutes' | 'report' | 'idea-note' | 'custom' | 'mindmap';
title: string;
content: string;
prompt_used: string | null;
llm_model: string | null;
llm_latency_ms: number | null;
created_at: string;
updated_at: string;
}
export interface PortableCustomInstructionRow {
id: string;
user_id: string;
builtin_key: null;
name: string;
description: string;
prompt: string;
icon: string;
sort_order: number;
revision: number;
created_at: string;
updated_at: string;
}
export interface PortableDatasets {
dictionary: PortableDictionaryRow[];
history: PortableHistoryRow[];
meetings: PortableMeetingRow[];
transcripts: PortableTranscriptRow[];
meeting_memos: PortableMeetingMemoRow[];
meeting_documents: PortableMeetingDocumentRow[];
custom_instructions: PortableCustomInstructionRow[];
}
export interface PortablePayload {
format: typeof PORTABILITY_FORMAT;
schema_version: typeof PORTABILITY_SCHEMA_VERSION;
exported_at: string;
owner_id: string;
source: PortabilitySource;
account: PortableAccountSnapshot;
datasets: PortableDatasets;
exclusions: [
'raw_audio',
'storage_objects',
'payment_credentials',
'push_tokens',
];
}
export interface PortableArchive {
format: typeof PORTABILITY_FORMAT;
schema_version: typeof PORTABILITY_SCHEMA_VERSION;
checksum_algorithm: typeof PORTABILITY_CHECKSUM_ALGORITHM;
checksum: string;
canonical_payload: string;
}
export interface PortabilityExportRpcRow {
canonical_payload: string;
checksum: string;
exported_at: string;
row_count: number;
}
export interface PortabilityRestoreResult {
status: 'imported' | 'duplicate';
checksum: string;
imported_rows: number;
skipped_rows: number;
imported_at: string;
}
export type PortableTextFormat = 'json' | 'csv' | 'txt';
export type MeetingDocumentFormat = 'md' | 'txt' | 'pdf' | 'docx';
export interface PreparedPortableFile {
path: string;
uri: string;
fileName: string;
mimeType: string;
format: PortableTextFormat | MeetingDocumentFormat;
textContent: string | null;
dispose: () => Promise<void>;
}

View file

@ -0,0 +1,260 @@
import { NativeModules, Platform, Share } from 'react-native';
import { Dirs, FileSystem } from 'react-native-file-access';
import { utf8ByteLength } from './canonical-json';
import { bytesToBase64 } from './document-binary';
import { buildMeetingExport, type MeetingExportInput } from './meeting-export';
import { PortabilityError } from './portability-error';
import { createUuidV4 } from '../../lib/random-id';
import {
buildAccountSummaryTxt,
buildDictionaryCsv,
buildDictionaryTxt,
buildHistoryCsv,
} from './portable-text';
import type {
MeetingDocumentFormat,
PortablePayload,
PreparedPortableFile,
} from './portability-types';
interface NativeFileShareModule {
shareFile: (options: {
path: string;
mimeType: string;
fileName: string;
dialogTitle: string;
}) => Promise<void>;
}
const nativeFileShare = NativeModules.D3ROFileShare as
| NativeFileShareModule
| undefined;
const MAX_ANDROID_TEXT_SHARE_BYTES = 500 * 1024;
const PORTABLE_EXPORT_DIRECTORY = 'portable_exports';
function exportDirectory(): string {
const root = Dirs.CacheDir.replace(/\\/g, '/').replace(/\/$/, '');
return `${root}/${PORTABLE_EXPORT_DIRECTORY}`;
}
function safeBaseName(value: string): string {
const normalized = value
.normalize('NFKC')
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.slice(0, 80);
return normalized || 'd3ro-export';
}
function ownedCachePath(fileName: string): string {
return `${exportDirectory()}/${fileName}`;
}
function isOwnedExportPath(path: string): boolean {
const root = exportDirectory();
const normalized = path.replace(/\\/g, '/');
return (
normalized.startsWith(`${root}/`) &&
!normalized.slice(root.length + 1).includes('/') &&
normalized.slice(root.length + 1) !== '' &&
normalized.slice(root.length + 1) !== '.' &&
normalized.slice(root.length + 1) !== '..'
);
}
async function disposeExport(path: string): Promise<void> {
if (!isOwnedExportPath(path)) {
throw new PortabilityError(
'file-read',
'Refusing to remove a file outside the export cache',
);
}
if (await FileSystem.exists(path)) await FileSystem.unlink(path);
}
async function persistExport(options: {
baseName: string;
extension: string;
mimeType: string;
format: PreparedPortableFile['format'];
text: string | null;
bytes: Uint8Array | null;
}): Promise<PreparedPortableFile> {
if ((options.text === null) === (options.bytes === null)) {
throw new PortabilityError(
'validation',
'Export must contain exactly one text or binary payload',
);
}
const nonce = createUuidV4();
const fileName = `${safeBaseName(options.baseName)}-${nonce}.${
options.extension
}`;
const path = ownedCachePath(fileName);
const dispose = async (): Promise<void> => disposeExport(path);
try {
const directory = exportDirectory();
if (!(await FileSystem.exists(directory))) {
await FileSystem.mkdir(directory);
}
if (options.text !== null) {
await FileSystem.writeFile(path, options.text, 'utf8');
} else {
await FileSystem.writeFile(
path,
bytesToBase64(options.bytes as Uint8Array),
'base64',
);
}
const stat = await FileSystem.stat(path);
if (!Number.isSafeInteger(stat.size) || stat.size <= 0) {
throw new PortabilityError(
'file-read',
'Export file was not written completely',
);
}
return {
path,
uri: `file://${path}`,
fileName,
mimeType: options.mimeType,
format: options.format,
textContent: options.text,
dispose,
};
} catch (error) {
await dispose().catch(() => undefined);
if (error instanceof PortabilityError) throw error;
throw new PortabilityError(
'file-read',
'Export file could not be written',
true,
error,
);
}
}
export async function prepareAccountJsonFile(
serializedArchive: string,
): Promise<PreparedPortableFile> {
return persistExport({
baseName: 'd3ro-account',
extension: 'd3ro.json',
mimeType: 'application/json',
format: 'json',
text: serializedArchive,
bytes: null,
});
}
export async function prepareDictionaryFile(
payload: PortablePayload,
format: 'csv' | 'txt',
): Promise<PreparedPortableFile> {
return persistExport({
baseName: 'd3ro-dictionary',
extension: format,
mimeType: format === 'csv' ? 'text/csv' : 'text/plain',
format,
text:
format === 'csv'
? buildDictionaryCsv(payload.datasets.dictionary)
: buildDictionaryTxt(payload.datasets.dictionary),
bytes: null,
});
}
export async function prepareHistoryCsvFile(
payload: PortablePayload,
): Promise<PreparedPortableFile> {
return persistExport({
baseName: 'd3ro-history',
extension: 'csv',
mimeType: 'text/csv',
format: 'csv',
text: buildHistoryCsv(payload.datasets.history),
bytes: null,
});
}
export async function prepareAccountSummaryFile(
payload: PortablePayload,
): Promise<PreparedPortableFile> {
return persistExport({
baseName: 'd3ro-account-summary',
extension: 'txt',
mimeType: 'text/plain',
format: 'txt',
text: buildAccountSummaryTxt(payload),
bytes: null,
});
}
export async function prepareMeetingExportFile(
input: MeetingExportInput,
format: MeetingDocumentFormat,
): Promise<PreparedPortableFile> {
const result = buildMeetingExport(input, format);
return persistExport({
baseName: input.meeting.title?.trim() || 'd3ro-meeting',
extension: result.extension,
mimeType: result.mimeType,
format,
text: result.text,
bytes: result.bytes,
});
}
export async function sharePreparedPortableFile(
file: PreparedPortableFile,
dialogTitle = 'Share D3RO export',
): Promise<void> {
try {
if (nativeFileShare !== undefined) {
await nativeFileShare.shareFile({
path: file.path,
mimeType: file.mimeType,
fileName: file.fileName,
dialogTitle,
});
return;
}
if (Platform.OS === 'ios') {
await Share.share(
{ title: file.fileName, url: file.uri },
{ subject: file.fileName },
);
return;
}
if (Platform.OS === 'android' && file.textContent !== null) {
if (utf8ByteLength(file.textContent) > MAX_ANDROID_TEXT_SHARE_BYTES) {
throw new PortabilityError(
'file-share',
'This export is too large for Android text sharing and requires the secure file-share module',
);
}
await Share.share(
{ title: file.fileName, message: file.textContent },
{ dialogTitle },
);
return;
}
throw new PortabilityError(
'file-share',
'Binary file sharing is unavailable until the secure Android FileProvider module is registered',
);
} catch (error) {
if (error instanceof PortabilityError) throw error;
throw new PortabilityError(
'file-share',
'Android share sheet could not be opened',
true,
error,
);
}
}

View file

@ -0,0 +1,229 @@
import { PortabilityError } from './portability-error';
import type {
PortableDictionaryRow,
PortableHistoryRow,
PortablePayload,
} from './portability-types';
const DANGEROUS_CSV_PREFIX = /^[=+\-@]/;
function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
let text = String(value);
if (DANGEROUS_CSV_PREFIX.test(text)) text = `'${text}`;
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
export function parseCsv(
input: string,
maxRows = 5_001,
maxColumns = 32,
): string[][] {
if (input.includes('\u0000'))
throw new PortabilityError('format', 'CSV contains a null byte');
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
for (let index = 0; index < input.length; index += 1) {
const character = input[index];
if (quoted) {
if (character === '"') {
if (input[index + 1] === '"') {
cell += '"';
index += 1;
} else {
quoted = false;
}
} else {
cell += character;
}
continue;
}
if (character === '"') {
if (cell.length !== 0)
throw new PortabilityError('format', 'CSV quote starts inside a field');
quoted = true;
} else if (character === ',') {
row.push(cell);
cell = '';
if (row.length > maxColumns)
throw new PortabilityError('format', 'CSV has too many columns');
} else if (character === '\n' || character === '\r') {
if (character === '\r' && input[index + 1] === '\n') index += 1;
row.push(cell);
cell = '';
if (row.length > maxColumns)
throw new PortabilityError('format', 'CSV has too many columns');
if (row.some(value => value.length > 0)) rows.push(row);
row = [];
if (rows.length > maxRows)
throw new PortabilityError('size', 'CSV has too many rows');
} else {
cell += character;
}
if (cell.length > 1_000_000)
throw new PortabilityError('size', 'CSV field is too large');
}
if (quoted)
throw new PortabilityError('format', 'CSV has an unterminated quote');
row.push(cell);
if (row.some(value => value.length > 0)) rows.push(row);
if (rows.length === 0) throw new PortabilityError('format', 'CSV is empty');
const columnCount = rows[0].length;
if (
columnCount === 0 ||
rows.some(candidate => candidate.length !== columnCount)
) {
throw new PortabilityError(
'format',
'CSV rows do not have a consistent column count',
);
}
return rows;
}
export function buildDictionaryCsv(rows: PortableDictionaryRow[]): string {
const header = [
'd3ro_schema_version',
'id',
'user_id',
'word',
'pronunciation',
'category',
'usage_count',
'last_used_at',
'created_at',
'updated_at',
];
const body = rows.map(row =>
[
'1',
row.id,
row.user_id,
row.word,
row.pronunciation,
row.category,
row.usage_count,
row.last_used_at,
row.created_at,
row.updated_at,
]
.map(csvCell)
.join(','),
);
return `\uFEFF${header.join(',')}\r\n${body.join('\r\n')}\r\n`;
}
export function buildHistoryCsv(rows: PortableHistoryRow[]): string {
const header = [
'd3ro_schema_version',
'id',
'user_id',
'title',
'original_text',
'polished_text',
'mode',
'status',
'duration',
'detected_language',
'word_count',
'app_version',
'summary_text',
'is_favorite',
'revision',
'created_at',
'updated_at',
];
const body = rows.map(row =>
[
'1',
row.id,
row.user_id,
row.title,
row.original_text,
row.polished_text,
row.mode,
row.status,
row.duration,
row.detected_language,
row.word_count,
row.app_version,
row.summary_text,
row.is_favorite,
row.revision,
row.created_at,
row.updated_at,
]
.map(csvCell)
.join(','),
);
return `\uFEFF${header.join(',')}\r\n${body.join('\r\n')}\r\n`;
}
function txtEscape(value: string | null): string {
return (value ?? '')
.replace(/\\/g, '\\\\')
.replace(/\t/g, '\\t')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n');
}
export function buildDictionaryTxt(rows: PortableDictionaryRow[]): string {
const lines = rows.map(row =>
[txtEscape(row.word), txtEscape(row.pronunciation), row.category].join(
'\t',
),
);
return `# D3RO dictionary TXT v1\nword\tpronunciation\tcategory\n${lines.join(
'\n',
)}\n`;
}
export function buildAccountSummaryTxt(payload: PortablePayload): string {
const counts = payload.datasets;
return [
'# D3RO account export summary',
`schema_version: ${payload.schema_version}`,
`exported_at: ${payload.exported_at}`,
`owner_id: ${payload.owner_id}`,
`dictionary: ${counts.dictionary.length}`,
`history: ${counts.history.length}`,
`meetings: ${counts.meetings.length}`,
`transcripts: ${counts.transcripts.length}`,
`meeting_memos: ${counts.meeting_memos.length}`,
`meeting_documents: ${counts.meeting_documents.length}`,
`custom_instructions: ${counts.custom_instructions.length}`,
'excluded: raw_audio, storage_objects, payment_credentials, push_tokens',
'',
].join('\n');
}
export function unescapeD3roTxtField(value: string): string {
let result = '';
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== '\\') {
result += character;
continue;
}
const escaped = value[index + 1];
if (escaped === undefined)
throw new PortabilityError('format', 'TXT field ends with an escape');
if (escaped === 't') result += '\t';
else if (escaped === 'r') result += '\r';
else if (escaped === 'n') result += '\n';
else if (escaped === '\\') result += '\\';
else
throw new PortabilityError(
'format',
'TXT field contains an unsupported escape',
);
index += 1;
}
return result;
}