/** * [Gitea #114] Export / import "your data": the Lotus-side personal data of * an account (not message history, not encryption keys: those have their own * exports, and keys are secret). One JSON file: * * { format: 'lotus-chat-data', version: 1, exportedAt, userId, * accountData: { [type]: content }, profile: { decoration }, local: { settings } } * * Import MERGES: anything missing here is added; a value that differs is a * conflict, kept as it is unless the user chooses the file's. Pure helpers. */ export const EXPORT_FORMAT = 'lotus-chat-data'; export const EXPORT_VERSION = 1; /** The account-data types that are the user's own Lotus data, with labels. */ export const EXPORT_ACCOUNT_DATA: { type: string; label: string }[] = [ { type: 'io.lotus.settings', label: 'Synced settings' }, { type: 'io.lotus.bookmarks', label: 'Bookmarks' }, { type: 'io.lotus.user_notes', label: 'Private notes about people' }, { type: 'io.lotus.room_names', label: 'Your names for rooms' }, { type: 'in.cinny.spaces', label: 'Room sections and sidebar' }, { type: 'io.lotus.reminders', label: 'Reminders' }, { type: 'io.lotus.status_presets', label: 'Status presets' }, { type: 'io.lotus.thread_notifications', label: 'Thread notification settings' }, { type: 'io.lotus.mute_timers', label: 'Timed mutes' }, { type: 'io.lotus.soundboard', label: 'Soundboard (clips stay on the server)' }, { type: 'io.lotus.soundboard_rooms', label: 'Soundboards enabled everywhere' }, { type: 'im.ponies.user_emotes', label: 'Your emoji and stickers' }, { type: 'im.ponies.emote_rooms', label: 'Emoji packs enabled everywhere' }, ]; export type ExportFile = { format: typeof EXPORT_FORMAT; version: number; exportedAt: string; userId: string; accountData: Record; profile: { decoration: string | null }; local: { settings: Record | null }; }; export const buildExport = (input: { userId: string; accountData: Record; decoration: string | null; localSettings: Record | null; now?: Date; }): ExportFile => { const accountData: Record = {}; EXPORT_ACCOUNT_DATA.forEach(({ type }) => { const c = input.accountData[type]; if (c !== undefined && c !== null) accountData[type] = c; }); return { format: EXPORT_FORMAT, version: EXPORT_VERSION, exportedAt: (input.now ?? new Date()).toISOString(), userId: input.userId, accountData, profile: { decoration: input.decoration }, local: { settings: input.localSettings }, }; }; const isObject = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); export type ParseResult = { ok: true; file: ExportFile } | { ok: false; error: string }; /** Validate an imported file; older versions would be migrated here. */ export const parseExport = (text: string): ParseResult => { let v: unknown; try { v = JSON.parse(text); } catch { return { ok: false, error: "This isn't a Lotus Chat data file (it isn't valid JSON)." }; } if (!isObject(v) || v.format !== EXPORT_FORMAT) return { ok: false, error: "This isn't a Lotus Chat data file." }; if (typeof v.version !== 'number' || v.version < 1) return { ok: false, error: 'This data file is damaged (no version).' }; if (v.version > EXPORT_VERSION) return { ok: false, error: 'This file was made by a newer Lotus Chat. Update the app, then import it again.', }; const accountData = isObject(v.accountData) ? v.accountData : {}; const known = new Set(EXPORT_ACCOUNT_DATA.map((t) => t.type)); const filtered: Record = {}; Object.entries(accountData).forEach(([type, content]) => { // Only our own types: a file can't be used to write arbitrary account data. if (known.has(type) && content !== null && content !== undefined) filtered[type] = content; }); const profile = isObject(v.profile) ? v.profile : {}; const local = isObject(v.local) ? v.local : {}; return { ok: true, file: { format: EXPORT_FORMAT, version: v.version, exportedAt: typeof v.exportedAt === 'string' ? v.exportedAt : '', userId: typeof v.userId === 'string' ? v.userId : '', accountData: filtered, profile: { decoration: typeof profile.decoration === 'string' ? profile.decoration : null, }, local: { settings: isObject(local.settings) ? local.settings : null }, }, }; }; const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); const itemKey = (v: unknown): string => { if (isObject(v)) { const id = v.id ?? v.eventId ?? v.event_id ?? v.key ?? v.roomId ?? v.room_id ?? v.mxc; if (typeof id === 'string' || typeof id === 'number') return `id:${String(id)}`; } return `json:${JSON.stringify(v)}`; }; export type MergeResult = { value: unknown; added: number; conflicts: number }; /** * Pure: merge `incoming` into `current`. Lists: add items that are missing * (matched by id, else by content). Objects: add missing keys, recurse into * shared ones. A differing leaf is a conflict: `preferIncoming` decides. */ export const mergeData = ( current: unknown, incoming: unknown, preferIncoming = false, ): MergeResult => { if (current === undefined || current === null) { return { value: incoming, added: incoming === undefined ? 0 : 1, conflicts: 0 }; } if (Array.isArray(current) && Array.isArray(incoming)) { const have = new Map(current.map((v, i) => [itemKey(v), i])); const out = [...current]; let added = 0; let conflicts = 0; incoming.forEach((item) => { const k = itemKey(item); const at = have.get(k); if (at === undefined) { out.push(item); have.set(k, out.length - 1); added += 1; } else if (!same(out[at], item)) { conflicts += 1; if (preferIncoming) out[at] = item; } }); return { value: out, added, conflicts }; } if (isObject(current) && isObject(incoming)) { const out: Record = { ...current }; let added = 0; let conflicts = 0; Object.entries(incoming).forEach(([k, v]) => { if (!(k in current)) { out[k] = v; added += 1; return; } const r = mergeData(current[k], v, preferIncoming); out[k] = r.value; added += r.added; conflicts += r.conflicts; }); return { value: out, added, conflicts }; } if (same(current, incoming)) return { value: current, added: 0, conflicts: 0 }; return { value: preferIncoming ? incoming : current, added: 0, conflicts: 1 }; }; export type ImportPlanItem = { key: string; label: string; added: number; conflicts: number; }; /** What importing `file` into the current data would change, per category. */ export const planImport = ( file: ExportFile, current: { accountData: Record; decoration: string | null; localSettings: Record | null; }, ): ImportPlanItem[] => { const items: ImportPlanItem[] = []; EXPORT_ACCOUNT_DATA.forEach(({ type, label }) => { if (!(type in file.accountData)) return; const r = mergeData(current.accountData[type], file.accountData[type]); items.push({ key: type, label, added: r.added, conflicts: r.conflicts }); }); if (file.profile.decoration !== null) { const differs = current.decoration !== file.profile.decoration; items.push({ key: 'profile.decoration', label: 'Avatar decoration', added: !current.decoration && differs ? 1 : 0, conflicts: current.decoration && differs ? 1 : 0, }); } if (file.local.settings) { const r = mergeData(current.localSettings ?? {}, file.local.settings); items.push({ key: 'local.settings', label: "This device's settings", added: r.added, conflicts: r.conflicts, }); } return items; };