Skip to content

Commit

Permalink
Add backup/restore settings (#1422)
Browse files Browse the repository at this point in the history
Resolves #366
  • Loading branch information
aeharding committed Apr 28, 2024
1 parent d154e5e commit 8323625
Show file tree
Hide file tree
Showing 7 changed files with 289 additions and 0 deletions.
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;
}

0 comments on commit 8323625

Please sign in to comment.