Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add backup/restore settings #1422

Merged
merged 3 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"compare-versions": "^6.1.0",
"date-fns": "^3.3.1",
"dexie": "^4.0.1",
"dexie-export-import": "^4.1.1",
"dexie-react-hooks": "^1.1.7",
"history": "^4.10.1",
"ionicons": "^7.3.1",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/features/settings/general/other/Other.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DefaultFeed from "./DefaultFeed";
import NoSubscribedInFeed from "./NoSubscribedInFeed";
import OpenNativeApps from "./OpenNativeApps";
import ClearCache from "./ClearCache";
import BackupSettings from "./backup/BackupSettings";

export default function Other() {
return (
Expand All @@ -22,6 +23,7 @@ export default function Other() {
<Haptics />
<NoSubscribedInFeed />
<ClearCache />
<BackupSettings />
</IonList>
</>
);
Expand Down
144 changes: 144 additions & 0 deletions src/features/settings/general/other/backup/BackupSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { IonItem, IonLabel, useIonActionSheet } from "@ionic/react";
import { isAndroid, isNative } from "../../../../../helpers/device";
import { Share } from "@capacitor/share";
import { Directory, Encoding, Filesystem } from "@capacitor/filesystem";
import useAppToast from "../../../../../helpers/useAppToast";
import { createBackup, isBackup, restoreFromBackup } from "./helpers";

import "dexie-export-import";

export default function BackupSettings() {
const [presentActionSheet] = useIonActionSheet();
const [presentAddlActionSheet] = useIonActionSheet();
const presentAlert = useAppToast();

function clear() {
presentActionSheet({
header: "Backup and Restore Settings",
subHeader: "Lemmy account settings and login sessions are not exported",
buttons: [
{
text: "Backup",
handler: () => {
(async () => {
const filename = `voyager-export-${Math.floor(Date.now() / 1_000)}.json`;

const backup = await createBackup();

// MARK - annoying platform specific logic to save the file

if (isNative()) {
if (isAndroid()) {
// IDK a good way to show a file save prompt in Android
await Filesystem.writeFile({
path: filename,
data: JSON.stringify(backup),
directory: Directory.Documents,
encoding: Encoding.UTF8,
});
presentAlert({
message: `${filename} saved to Documents`,
});
} else {
const file = await Filesystem.writeFile({
path: filename,
data: JSON.stringify(backup),
directory: Directory.Cache,
encoding: Encoding.UTF8,
});
await Share.share({
files: [file.uri],
});
await Filesystem.deleteFile({ path: file.uri });
}
} else {
const blob = new Blob([JSON.stringify(backup)]);
const link = document.createElement("a");
link.download = filename;
const href = URL.createObjectURL(blob);
link.href = href;
link.click();
URL.revokeObjectURL(href);
}
})();
},
},
{
text: "Restore",
handler: () => {
presentAddlActionSheet({
header: "Are you sure?",
subHeader: "Import will overwrite any existing app settings",
buttons: [
{
text: "Select File",
role: "destructive",
handler: () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";

input.onchange = async () => {
const file = input.files?.[0];

if (!file) {
presentAlert({
message: "No file provided",
color: "danger",
});
return;
}

let data: unknown;

try {
data = JSON.parse(await file.text());
} catch (error) {
onRestoreFail();
throw error;
}

if (!isBackup(data)) {
onRestoreFail();
return;
}

await restoreFromBackup(data);

presentAlert({
message: "Import complete. Reloading...",
});

setTimeout(() => location.reload(), 2_000);
};

input.click();
},
},
{
text: "Cancel",
},
],
});
},
},
{
text: "Cancel",
},
],
});
}

function onRestoreFail() {
presentAlert({
message: "Voyager backup file malformed",
color: "danger",
});
}

return (
<IonItem button onClick={clear} detail={false}>
<IonLabel color="primary">Backup and Restore Settings</IonLabel>
</IonItem>
);
}
79 changes: 79 additions & 0 deletions src/features/settings/general/other/backup/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { pickBy, without } from "lodash";
import { db } from "../../../../../services/db";
import { getAllObjectValuesDeep } from "../../../../../helpers/object";
import { LOCALSTORAGE_KEYS } from "../../../settingsSlice";
import { get, set } from "../../../storage";

const BASE_BACKUP_JSON = {
voyagerBackupVersion: 1,
voyagerAppVersion: APP_VERSION,
} as const;

type Backup = typeof BASE_BACKUP_JSON & {
created: string;

dexie: unknown;
localStorage: Record<string, unknown>;
};

export function isBackup(potentialBackup: unknown): potentialBackup is Backup {
if (!potentialBackup || typeof potentialBackup !== "object") return false;
if (!("voyagerBackupVersion" in potentialBackup)) return false;

// TODO - if backup version changes, it should be handled
// Right now, just support v1
if (
potentialBackup.voyagerBackupVersion !==
BASE_BACKUP_JSON.voyagerBackupVersion
)
return false;

return true;
}

export async function createBackup(): Promise<Backup> {
const dexieBlob = await db.export({
skipTables: getSkipTables(),
});

const dexieExport = JSON.parse(await dexieBlob.text());

return {
...BASE_BACKUP_JSON,
created: new Date().toISOString(),
dexie: dexieExport,
localStorage: pickBy(
// pickBy: remove null/undefined
Object.assign(
{},
...getAllObjectValuesDeep(LOCALSTORAGE_KEYS).map((key) => ({
[key]: get(key),
})),
),
(p) => p != null,
),
};
}

export async function restoreFromBackup(backup: Backup) {
await db.import(new Blob([JSON.stringify(backup.dexie)]), {
clearTablesBeforeImport: true,
skipTables: getSkipTables(),
});

// Clear existing values of localStorage keys eligible for backup
getAllObjectValuesDeep(LOCALSTORAGE_KEYS).forEach((key) =>
localStorage.removeItem(key),
);

Object.entries(backup.localStorage).forEach(([key, value]) => {
set(key, value);
});
}

function getSkipTables() {
return without(
db.tables.map((t) => t.name),
"settings",
);
}
28 changes: 28 additions & 0 deletions src/helpers/object.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getAllObjectValuesDeep } from "./object";

describe("getAllObjectValuesDeep", () => {
it("works empty", () => {
expect(getAllObjectValuesDeep({})).toStrictEqual([]);
});

it("returns shallow", () => {
expect(getAllObjectValuesDeep({ foo: "bar" })).toStrictEqual(["bar"]);
});

it("returns deep", () => {
expect(getAllObjectValuesDeep({ foo: { bar: "baz" } })).toStrictEqual([
"baz",
]);
});

it("returns deep", () => {
expect(getAllObjectValuesDeep({ foo: { bar: "baz" } })).toStrictEqual([
"baz",
]);
});
it("returns multiple deep", () => {
expect(
getAllObjectValuesDeep({ foo: { bar: "baz" }, hi: "there" }),
).toStrictEqual(["baz", "there"]);
});
});
23 changes: 23 additions & 0 deletions src/helpers/object.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getAllObjectValuesDeep(obj: any): any[] {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const values: any[] = [];

// Helper function to recursively traverse the object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function traverse(obj: any) {
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
const value = obj[key];
if (typeof value === "object" && value !== null) {
traverse(value); // Recursively traverse nested objects
} else {
values.push(value); // Add non-object values to the result array
}
}
}
}

traverse(obj);
return values;
}
Loading