-
-
Notifications
You must be signed in to change notification settings - Fork 166
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Resolves #366
- Loading branch information
Showing
7 changed files
with
289 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
src/features/settings/general/other/backup/BackupSettings.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |