diff --git a/src/i18n/de/index.ts b/src/i18n/de/index.ts index 3adbc2df..46c6f5e5 100644 --- a/src/i18n/de/index.ts +++ b/src/i18n/de/index.ts @@ -17,9 +17,10 @@ const de = { }, backup: { TITLE: "Sicherungskopie", + INDIVIDUAL: "Einzeldateien", DISCLAIMER: "Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.", - DOWNLOAD: "Kopie Speichern", + DOWNLOAD: "Vollständig Speichern", RESTORE: "Wiederherstellen", }, modal: { diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 50cf4beb..cee960be 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -11,8 +11,9 @@ const en = { }, backup: { TITLE: "Local Backup", + INDIVIDUAL: "Individual backups", DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.", - DOWNLOAD: "Download Backup", + DOWNLOAD: "Full Backup", RESTORE: "Restore", }, sync: { diff --git a/src/lib/backup/backup.ts b/src/lib/backup/backup.ts new file mode 100644 index 00000000..1eb9a52a --- /dev/null +++ b/src/lib/backup/backup.ts @@ -0,0 +1,145 @@ +import type { + CharaBackupFile, + CharaChordFile, + CharaSettingsFile, + CharaLayoutFile, + CharaFile, +} from "$lib/share/chara-file.js" +import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js" +import type {Change} from "$lib/undo-redo.js" +import {get} from "svelte/store" +import {serialPort} from "../serial/connection" +import {csvLayoutToJson, isCsvLayout} from "$lib/backup/compat/legacy-layout" + +export function downloadFile>(contents: T) { + const downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(contents)], {type: "application/json"})) + const element = document.createElement("a") + element.setAttribute( + "download", + `${contents.type}-${get(serialPort)?.device}-${new Date().toISOString()}.json`, + ) + element.href = downloadUrl + element.setAttribute("target", "_blank") + element.click() + URL.revokeObjectURL(downloadUrl) +} + +export function downloadBackup() { + downloadFile({ + charaVersion: 1, + type: "backup", + history: [[createChordBackup(), createLayoutBackup(), createSettingsBackup()]], + }) +} + +export function createLayoutBackup(): CharaLayoutFile { + return { + charaVersion: 1, + type: "layout", + device: get(serialPort)?.device, + layout: get(layout).map(it => it.map(it => it.action)) as [number[], number[], number[]], + } +} + +export function createChordBackup(): CharaChordFile { + return {charaVersion: 1, type: "chords", chords: get(chords).map(it => [it.actions, it.phrase])} +} + +export function createSettingsBackup(): CharaSettingsFile { + return {charaVersion: 1, type: "settings", settings: get(settings).map(it => it.value)} +} + +export async function restoreBackup(event: Event) { + const input = (event.target as HTMLInputElement).files![0] + if (!input) return + const text = await input.text() + if (input.name.endsWith(".json")) { + restoreFromFile(JSON.parse(text)) + } else if (isCsvLayout(text)) { + restoreFromFile(csvLayoutToJson(text)) + } +} + +export function restoreFromFile( + file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile, +) { + if (file.charaVersion !== 1) throw new Error("Incompatible backup") + switch (file.type) { + case "backup": { + const recent = file.history[0] + if (recent[1].device !== get(serialPort)?.device) + throw new Error("Backup is incompatible with this device") + + changes.update(changes => { + changes.push( + ...getChangesFromChordFile(recent[0]), + ...getChangesFromLayoutFile(recent[1]), + ...getChangesFromSettingsFile(recent[2]), + ) + return changes + }) + break + } + case "chords": { + changes.update(changes => { + changes.push(...getChangesFromChordFile(file)) + return changes + }) + break + } + case "layout": { + changes.update(changes => { + changes.push(...getChangesFromLayoutFile(file)) + return changes + }) + break + } + case "settings": { + changes.update(changes => { + changes.push(...getChangesFromSettingsFile(file)) + return changes + }) + break + } + default: { + throw new Error(`Unknown backup type "${(file as CharaFile).type}"`) + } + } +} + +export function getChangesFromChordFile(file: CharaChordFile) { + const changes: Change[] = [] + // TODO... + return changes +} + +export function getChangesFromSettingsFile(file: CharaSettingsFile) { + const changes: Change[] = [] + for (const [id, value] of file.settings.entries()) { + if (get(settings)[id].value !== value) { + changes.push({ + type: ChangeType.Setting, + id, + setting: value, + }) + } + } + return changes +} + +export function getChangesFromLayoutFile(file: CharaLayoutFile) { + const changes: Change[] = [] + for (const [layer, keys] of file.layout.entries()) { + for (const [id, action] of keys.entries()) { + if (get(layout)[layer][id].action !== action) { + changes.push({ + type: ChangeType.Layout, + layer, + id, + action, + }) + } + } + } + return changes +} diff --git a/src/lib/compat/legacy-layout-converted.sample.json b/src/lib/backup/compat/legacy-layout-converted.sample.json similarity index 100% rename from src/lib/compat/legacy-layout-converted.sample.json rename to src/lib/backup/compat/legacy-layout-converted.sample.json diff --git a/src/lib/compat/legacy-layout.sample.csv b/src/lib/backup/compat/legacy-layout.sample.csv similarity index 100% rename from src/lib/compat/legacy-layout.sample.csv rename to src/lib/backup/compat/legacy-layout.sample.csv diff --git a/src/lib/compat/legacy-layout.spec.ts b/src/lib/backup/compat/legacy-layout.spec.ts similarity index 100% rename from src/lib/compat/legacy-layout.spec.ts rename to src/lib/backup/compat/legacy-layout.spec.ts diff --git a/src/lib/compat/legacy-layout.ts b/src/lib/backup/compat/legacy-layout.ts similarity index 100% rename from src/lib/compat/legacy-layout.ts rename to src/lib/backup/compat/legacy-layout.ts diff --git a/src/lib/components/layout/Layout.svelte b/src/lib/components/layout/Layout.svelte index e32422b4..f0d4a49f 100644 --- a/src/lib/components/layout/Layout.svelte +++ b/src/lib/components/layout/Layout.svelte @@ -4,7 +4,7 @@ import GenericLayout from "$lib/components/layout/GenericLayout.svelte" import {getContext} from "svelte" import type {Writable} from "svelte/store" - import {csvLayoutToJson, isCsvLayout} from "$lib/compat/legacy-layout" + import {csvLayoutToJson, isCsvLayout} from "$lib/backup/compat/legacy-layout" import type {CharaLayoutFile} from "$lib/share/chara-file" export let layoutOverride: "ONE" | "LITE" | undefined = undefined diff --git a/src/lib/serialization/actions.ts b/src/lib/serialization/actions.ts index 10f3384d..b837663b 100644 --- a/src/lib/serialization/actions.ts +++ b/src/lib/serialization/actions.ts @@ -24,7 +24,7 @@ export function decompressActions(raw: Uint8Array): number[] { const actions: number[] = [] for (let i = 0; i < raw.length; i++) { let action = raw[i] - if (action < 32) { + if (action > 0 && action < 32) { action = (action << 8) | raw[++i] } actions.push(action) diff --git a/src/lib/serialization/base64.ts b/src/lib/serialization/base64.ts index 72c125bb..2bc71f4c 100644 --- a/src/lib/serialization/base64.ts +++ b/src/lib/serialization/base64.ts @@ -20,7 +20,7 @@ export async function toBase64(blob: Blob): Promise { }) } -export async function fromBase64(base64: string): Promise { +export async function fromBase64(base64: string, fetch = window.fetch): Promise { return fetch( `data:application/octet-stream;base64,${base64 .replaceAll(".", "+") diff --git a/src/lib/share/action-array.spec.ts b/src/lib/share/action-array.spec.ts new file mode 100644 index 00000000..6562fd0c --- /dev/null +++ b/src/lib/share/action-array.spec.ts @@ -0,0 +1,53 @@ +import {describe, it, expect} from "vitest" +import {deserializeActionArray, serializeActionArray} from "./action-array" + +describe("action array", () => { + it("should work with number arrays", () => { + expect(deserializeActionArray(serializeActionArray([62, 256, 1235]))).toEqual([62, 256, 1235]) + }) + + it("should work with nested arrays", () => { + expect(deserializeActionArray(serializeActionArray([[], [[]]]))).toEqual([[], [[]]]) + }) + + it("should compress back and forth", () => { + expect( + deserializeActionArray( + serializeActionArray([ + [43, 746, 634], + [34, 63], + [332, 34], + ]), + ), + ).toEqual([ + [43, 746, 634], + [34, 63], + [332, 34], + ]) + }) + + it("should compress a full layout", () => { + const layout = Object.freeze([ + Object.freeze([ + 0, 0, 0, 0, 0, 53, 119, 45, 103, 122, 52, 107, 118, 109, 99, 51, 114, 36, 59, 101, 50, 105, 34, 46, + 111, 49, 39, 515, 44, 117, 0, 512, 514, 513, 550, 0, 319, 318, 321, 320, 326, 315, 314, 317, 316, 0, + 0, 0, 0, 0, 54, 98, 120, 536, 113, 55, 102, 112, 104, 100, 56, 97, 296, 544, 116, 57, 108, 299, 106, + 110, 48, 121, 297, 61, 115, 0, 518, 516, 517, 553, 0, 336, 338, 335, 337, 0, 325, 322, 323, 324, + ]), + Object.freeze([ + 0, 0, 0, 0, 0, 0, 91, 0, 0, 0, 0, 53, 0, 47, 52, 0, 51, 298, 0, 50, 0, 0, 127, 0, 49, 0, 0, 515, 0, 0, + 0, 512, 514, 513, 550, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 93, 0, 536, 0, 0, 54, 0, 92, + 55, 0, 56, 296, 544, 57, 0, 96, 299, 0, 48, 0, 0, 297, 0, 0, 0, 518, 516, 517, 553, 0, 336, 338, 335, + 337, 0, 0, 0, 0, 0, + ]), + Object.freeze([ + 0, 0, 0, 0, 0, 0, 64, 95, 43, 0, 0, 126, 38, 63, 40, 0, 35, 298, 36, 123, 0, 33, 127, 37, 60, 0, 34, + 515, 0, 0, 0, 512, 514, 513, 550, 0, 333, 331, 330, 334, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 536, + 0, 0, 94, 58, 124, 41, 0, 42, 296, 544, 125, 0, 126, 299, 0, 62, 0, 0, 297, 0, 0, 0, 518, 516, 517, + 553, 0, 336, 338, 335, 337, 0, 0, 0, 0, 0, + ]), + ]) + + expect(deserializeActionArray(serializeActionArray(layout as number[][]))).toEqual(layout) + }) +}) diff --git a/src/lib/share/action-array.ts b/src/lib/share/action-array.ts index 3359442e..ac8ec6cd 100644 --- a/src/lib/share/action-array.ts +++ b/src/lib/share/action-array.ts @@ -1,5 +1,5 @@ -import {compressActions, decompressActions} from "$lib/serialization/actions" -import {CHARA_FILE_TYPES} from "$lib/share/share-url" +import {compressActions, decompressActions} from "../serialization/actions" +import {CHARA_FILE_TYPES} from "../share/share-url" export type ActionArray = number[] | ActionArray[] export function serializeActionArray(array: ActionArray): Uint8Array { @@ -11,7 +11,9 @@ export function serializeActionArray(array: ActionArray): Uint8Array { return out } else if (typeof array[0] === "number") { writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number")) - return concatUint8Arrays(out, compressActions(array as number[])) + const compressed = compressActions(array as number[]) + writer.setUint32(0, compressed.length) + return concatUint8Arrays(out, compressed) } else if (Array.isArray(array[0])) { writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array")) return concatUint8Arrays(out, ...(array as ActionArray[]).map(serializeActionArray)) @@ -20,20 +22,23 @@ export function serializeActionArray(array: ActionArray): Uint8Array { } } -export function deserializeActionArray(raw: Uint8Array): ActionArray { +export function deserializeActionArray(raw: Uint8Array, cursor = {pos: 0}): ActionArray { const reader = new DataView(raw.buffer) - const length = reader.getUint32(0) - const type = CHARA_FILE_TYPES[reader.getUint8(4)] + const length = reader.getUint32(cursor.pos) + cursor.pos += 4 + const type = CHARA_FILE_TYPES[reader.getUint8(cursor.pos)] + cursor.pos++ + + console.log(cursor, raw) if (type === "number") { - return decompressActions(raw.slice(5, 5 + length)) + const decompressed = decompressActions(raw.slice(cursor.pos, cursor.pos + length)) + cursor.pos += length + return decompressed } else if (type === "array") { - const innerLength = reader.getUint32(5) const out = [] - let cursor = 5 for (let i = 0; i < length; i++) { - out.push(deserializeActionArray(raw.slice(cursor, cursor + innerLength))) - cursor += innerLength + out.push(deserializeActionArray(raw, cursor)) } return out } else { diff --git a/src/lib/share/chara-file.ts b/src/lib/share/chara-file.ts index fcc620e1..d28cf770 100644 --- a/src/lib/share/chara-file.ts +++ b/src/lib/share/chara-file.ts @@ -20,4 +20,4 @@ export interface CharaBackupFile extends CharaFile<"backup"> { history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][] } -export type CharaFiles = CharaLayoutFile | CharaChordFile +export type CharaFiles = CharaLayoutFile | CharaChordFile | CharaSettingsFile diff --git a/src/lib/share/share-url.ts b/src/lib/share/share-url.ts index 60b534d8..77fef7f4 100644 --- a/src/lib/share/share-url.ts +++ b/src/lib/share/share-url.ts @@ -1,7 +1,7 @@ -import type {CharaFile, CharaFiles} from "$lib/share/chara-file" -import type {ActionArray} from "$lib/share/action-array" -import {deserializeActionArray, serializeActionArray} from "$lib/share/action-array" -import {fromBase64, toBase64} from "$lib/serialization/base64" +import type {CharaFile, CharaFiles} from "../share/chara-file" +import type {ActionArray} from "../share/action-array" +import {deserializeActionArray, serializeActionArray} from "../share/action-array" +import {fromBase64, toBase64} from "../serialization/base64" type CharaLayoutOrder = { [K in CharaFiles["type"]]: Array< @@ -15,6 +15,7 @@ const keys: CharaLayoutOrder = { ["device", "string"], ], chords: [["chords", "array"]], + settings: [["settings", "array"]], } export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const @@ -42,17 +43,21 @@ export async function charaFileToUriComponent(file: T): Pr return url } -export async function charaFileFromUriComponent(uriComponent: string): Promise { +export async function charaFileFromUriComponent( + uriComponent: string, + fetch = window.fetch, +): Promise { const [fileType, version, ...values] = uriComponent.split(sep) - const file: any = {type: fileType, version: Number(version)} + const file: any = {type: fileType, charaVersion: Number(version)} for (const [key, type] of keys[fileType as keyof typeof keys]) { - const value = values.pop()! + const value = values.shift()! if (type === "string") { file[key] = value } else if (type === "array") { - const stream = (await fromBase64(value)).stream().pipeThrough(new DecompressionStream("deflate")) + const stream = (await fromBase64(value, fetch)).stream().pipeThrough(new DecompressionStream("deflate")) const actions = new Uint8Array(await new Response(stream).arrayBuffer()) + console.log(actions) file[key] = deserializeActionArray(actions) } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d015a7c3..50a744e6 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -24,6 +24,8 @@ import {runLayoutDetection} from "$lib/os-layout.js" import PageTransition from "./PageTransition.svelte" import SyncOverlay from "./SyncOverlay.svelte" + import {restoreFromFile} from "$lib/backup/backup" + import {goto} from "$app/navigation" const locale = ((browser && localStorage.getItem("locale")) as Locales) || detectLocale() loadLocale(locale) @@ -57,6 +59,12 @@ if (browser && $userPreferences.autoConnect && (await canAutoConnect())) { await initSerial() } + if (data.importFile) { + restoreFromFile(data.importFile) + const url = new URL(location.href) + url.searchParams.delete("import") + await goto(url.href, {replaceState: true}) + } }) let webManifestLink = "" diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index aaf46e44..487add9d 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,2 +1,14 @@ +import type {LayoutLoad} from "./$types" +import {browser} from "$app/environment" +import {charaFileFromUriComponent} from "$lib/share/share-url" + export const prerender = true export const trailingSlash = "always" + +export const load = (async ({url, data, fetch}) => { + const importFile = new URLSearchParams(url.search).get("import") + return { + ...data, + importFile: browser && importFile ? await charaFileFromUriComponent(importFile, fetch) : undefined, + } +}) satisfies LayoutLoad diff --git a/src/routes/+page.ts b/src/routes/+page.ts index 2c36f17c..6c3389f5 100644 --- a/src/routes/+page.ts +++ b/src/routes/+page.ts @@ -1,6 +1,8 @@ import {redirect} from "@sveltejs/kit" import type {PageLoad} from "./$types" -export const load = (() => { - throw redirect(302, "/config/") +export const load = (async ({url}) => { + const newUrl = new URL(url) + newUrl.pathname = "/config/" + throw redirect(302, newUrl) }) satisfies PageLoad diff --git a/src/routes/BackupPopup.svelte b/src/routes/BackupPopup.svelte index af8bb02a..5eef8642 100644 --- a/src/routes/BackupPopup.svelte +++ b/src/routes/BackupPopup.svelte @@ -1,103 +1,14 @@
@@ -105,6 +16,21 @@

{$LL.backup.DISCLAIMER()}

+
+ {$LL.backup.INDIVIDUAL()} + + + +
{ - throw redirect(302, "/config/chords/") +export const load = (({url}) => { + const newUrl = new URL(url) + newUrl.pathname = "/config/layout/" + throw redirect(302, newUrl) }) satisfies PageLoad diff --git a/src/routes/config/layout/+page.svelte b/src/routes/config/layout/+page.svelte index 5bfbb377..2acc8dda 100644 --- a/src/routes/config/layout/+page.svelte +++ b/src/routes/config/layout/+page.svelte @@ -1,40 +1,29 @@