220 lines
7.8 KiB
TypeScript
220 lines
7.8 KiB
TypeScript
/**
|
|||
|
|
* [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<string, unknown>;
|
||
|
|
profile: { decoration: string | null };
|
||
|
|
local: { settings: Record<string, unknown> | null };
|
||
|
|
};
|
||
|
|
|
||
|
|
export const buildExport = (input: {
|
||
|
|
userId: string;
|
||
|
|
accountData: Record<string, unknown>;
|
||
|
|
decoration: string | null;
|
||
|
|
localSettings: Record<string, unknown> | null;
|
||
|
|
now?: Date;
|
||
|
|
}): ExportFile => {
|
||
|
|
const accountData: Record<string, unknown> = {};
|
||
|
|
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<string, unknown> =>
|
||
|
|
!!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<string, unknown> = {};
|
||
|
|
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<string, unknown> = { ...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<string, unknown>;
|
||
|
|
decoration: string | null;
|
||
|
|
localSettings: Record<string, unknown> | 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;
|
||
|
|
};
|