feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
190
apps/mobile-rn/src/features/data-portability/data-file-picker.ts
Normal file
190
apps/mobile-rn/src/features/data-portability/data-file-picker.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
356
apps/mobile-rn/src/features/data-portability/document-binary.ts
Normal file
356
apps/mobile-rn/src/features/data-portability/document-binary.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
11
apps/mobile-rn/src/features/data-portability/index.ts
Normal file
11
apps/mobile-rn/src/features/data-portability/index.ts
Normal 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';
|
||||
728
apps/mobile-rn/src/features/data-portability/legacy-import.ts
Normal file
728
apps/mobile-rn/src/features/data-portability/legacy-import.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
189
apps/mobile-rn/src/features/data-portability/meeting-export.ts
Normal file
189
apps/mobile-rn/src/features/data-portability/meeting-export.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
260
apps/mobile-rn/src/features/data-portability/portable-file.ts
Normal file
260
apps/mobile-rn/src/features/data-portability/portable-file.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
229
apps/mobile-rn/src/features/data-portability/portable-text.ts
Normal file
229
apps/mobile-rn/src/features/data-portability/portable-text.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue