feat: layout url import

feat: backup import (except chords)
feat: legacy layout import
feat: separate layout, chord & setting backup downloads
This commit is contained in:
2023-11-15 01:14:34 +01:00
parent acd58646f6
commit c5d9defc9d
20 changed files with 299 additions and 143 deletions

View File

@@ -17,9 +17,10 @@ const de = {
}, },
backup: { backup: {
TITLE: "Sicherungskopie", TITLE: "Sicherungskopie",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER: DISCLAIMER:
"Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.", "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", RESTORE: "Wiederherstellen",
}, },
modal: { modal: {

View File

@@ -11,8 +11,9 @@ const en = {
}, },
backup: { backup: {
TITLE: "Local Backup", TITLE: "Local Backup",
INDIVIDUAL: "Individual backups",
DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.", 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", RESTORE: "Restore",
}, },
sync: { sync: {

145
src/lib/backup/backup.ts Normal file
View File

@@ -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<T extends CharaFile<string>>(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<CharaBackupFile>({
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<string>).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
}

View File

@@ -4,7 +4,7 @@
import GenericLayout from "$lib/components/layout/GenericLayout.svelte" import GenericLayout from "$lib/components/layout/GenericLayout.svelte"
import {getContext} from "svelte" import {getContext} from "svelte"
import type {Writable} from "svelte/store" 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" import type {CharaLayoutFile} from "$lib/share/chara-file"
export let layoutOverride: "ONE" | "LITE" | undefined = undefined export let layoutOverride: "ONE" | "LITE" | undefined = undefined

View File

@@ -24,7 +24,7 @@ export function decompressActions(raw: Uint8Array): number[] {
const actions: number[] = [] const actions: number[] = []
for (let i = 0; i < raw.length; i++) { for (let i = 0; i < raw.length; i++) {
let action = raw[i] let action = raw[i]
if (action < 32) { if (action > 0 && action < 32) {
action = (action << 8) | raw[++i] action = (action << 8) | raw[++i]
} }
actions.push(action) actions.push(action)

View File

@@ -20,7 +20,7 @@ export async function toBase64(blob: Blob): Promise<string> {
}) })
} }
export async function fromBase64(base64: string): Promise<Blob> { export async function fromBase64(base64: string, fetch = window.fetch): Promise<Blob> {
return fetch( return fetch(
`data:application/octet-stream;base64,${base64 `data:application/octet-stream;base64,${base64
.replaceAll(".", "+") .replaceAll(".", "+")

View File

@@ -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)
})
})

View File

@@ -1,5 +1,5 @@
import {compressActions, decompressActions} from "$lib/serialization/actions" import {compressActions, decompressActions} from "../serialization/actions"
import {CHARA_FILE_TYPES} from "$lib/share/share-url" import {CHARA_FILE_TYPES} from "../share/share-url"
export type ActionArray = number[] | ActionArray[] export type ActionArray = number[] | ActionArray[]
export function serializeActionArray(array: ActionArray): Uint8Array { export function serializeActionArray(array: ActionArray): Uint8Array {
@@ -11,7 +11,9 @@ export function serializeActionArray(array: ActionArray): Uint8Array {
return out return out
} else if (typeof array[0] === "number") { } else if (typeof array[0] === "number") {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("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])) { } else if (Array.isArray(array[0])) {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array")) writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array"))
return concatUint8Arrays(out, ...(array as ActionArray[]).map(serializeActionArray)) 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 reader = new DataView(raw.buffer)
const length = reader.getUint32(0) const length = reader.getUint32(cursor.pos)
const type = CHARA_FILE_TYPES[reader.getUint8(4)] cursor.pos += 4
const type = CHARA_FILE_TYPES[reader.getUint8(cursor.pos)]
cursor.pos++
console.log(cursor, raw)
if (type === "number") { 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") { } else if (type === "array") {
const innerLength = reader.getUint32(5)
const out = [] const out = []
let cursor = 5
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
out.push(deserializeActionArray(raw.slice(cursor, cursor + innerLength))) out.push(deserializeActionArray(raw, cursor))
cursor += innerLength
} }
return out return out
} else { } else {

View File

@@ -20,4 +20,4 @@ export interface CharaBackupFile extends CharaFile<"backup"> {
history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][] history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][]
} }
export type CharaFiles = CharaLayoutFile | CharaChordFile export type CharaFiles = CharaLayoutFile | CharaChordFile | CharaSettingsFile

View File

@@ -1,7 +1,7 @@
import type {CharaFile, CharaFiles} from "$lib/share/chara-file" import type {CharaFile, CharaFiles} from "../share/chara-file"
import type {ActionArray} from "$lib/share/action-array" import type {ActionArray} from "../share/action-array"
import {deserializeActionArray, serializeActionArray} from "$lib/share/action-array" import {deserializeActionArray, serializeActionArray} from "../share/action-array"
import {fromBase64, toBase64} from "$lib/serialization/base64" import {fromBase64, toBase64} from "../serialization/base64"
type CharaLayoutOrder = { type CharaLayoutOrder = {
[K in CharaFiles["type"]]: Array< [K in CharaFiles["type"]]: Array<
@@ -15,6 +15,7 @@ const keys: CharaLayoutOrder = {
["device", "string"], ["device", "string"],
], ],
chords: [["chords", "array"]], chords: [["chords", "array"]],
settings: [["settings", "array"]],
} }
export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const
@@ -42,17 +43,21 @@ export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Pr
return url return url
} }
export async function charaFileFromUriComponent<T extends CharaFiles>(uriComponent: string): Promise<T> { export async function charaFileFromUriComponent<T extends CharaFiles>(
uriComponent: string,
fetch = window.fetch,
): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep) 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]) { for (const [key, type] of keys[fileType as keyof typeof keys]) {
const value = values.pop()! const value = values.shift()!
if (type === "string") { if (type === "string") {
file[key] = value file[key] = value
} else if (type === "array") { } 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()) const actions = new Uint8Array(await new Response(stream).arrayBuffer())
console.log(actions)
file[key] = deserializeActionArray(actions) file[key] = deserializeActionArray(actions)
} }
} }

View File

@@ -24,6 +24,8 @@
import {runLayoutDetection} from "$lib/os-layout.js" import {runLayoutDetection} from "$lib/os-layout.js"
import PageTransition from "./PageTransition.svelte" import PageTransition from "./PageTransition.svelte"
import SyncOverlay from "./SyncOverlay.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() const locale = ((browser && localStorage.getItem("locale")) as Locales) || detectLocale()
loadLocale(locale) loadLocale(locale)
@@ -57,6 +59,12 @@
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) { if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial() 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 = "" let webManifestLink = ""

View File

@@ -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 prerender = true
export const trailingSlash = "always" 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

View File

@@ -1,6 +1,8 @@
import {redirect} from "@sveltejs/kit" import {redirect} from "@sveltejs/kit"
import type {PageLoad} from "./$types" import type {PageLoad} from "./$types"
export const load = (() => { export const load = (async ({url}) => {
throw redirect(302, "/config/") const newUrl = new URL(url)
newUrl.pathname = "/config/"
throw redirect(302, newUrl)
}) satisfies PageLoad }) satisfies PageLoad

View File

@@ -1,103 +1,14 @@
<script lang="ts"> <script lang="ts">
import {serialPort} from "$lib/serial/connection"
import {preference} from "$lib/preferences" import {preference} from "$lib/preferences"
import LL from "../i18n/i18n-svelte" import LL from "../i18n/i18n-svelte"
import type { import {
CharaBackupFile, createChordBackup,
CharaChordFile, createLayoutBackup,
CharaSettingsFile, createSettingsBackup,
CharaLayoutFile, downloadBackup,
} from "$lib/share/chara-file.js" downloadFile,
import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js" restoreBackup,
import type {Change} from "$lib/undo-redo.js" } from "$lib/backup/backup"
async function downloadBackup() {
const downloadUrl = URL.createObjectURL(
new Blob(
[
JSON.stringify({
charaVersion: 1,
type: "backup",
history: [
[
{charaVersion: 1, type: "chords", chords: $chords.map(it => [it.actions, it.phrase])},
{
charaVersion: 1,
type: "layout",
device: $serialPort?.device,
layout: $layout.map(it => it.map(it => it.action)) as [number[], number[], number[]],
},
{charaVersion: 1, type: "settings", settings: $settings.map(it => it.value)},
],
],
} satisfies CharaBackupFile),
],
{type: "application/json"},
),
)
const element = document.createElement("a")
element.setAttribute("download", "backup.json")
element.href = downloadUrl
element.setAttribute("target", "_blank")
element.click()
URL.revokeObjectURL(downloadUrl)
}
async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0]
if (!input) return
const backup: CharaBackupFile = JSON.parse(await input.text())
if (backup.charaVersion !== 1 || backup.type !== "backup") throw new Error("Invalid Backup")
const recent = backup.history[0]
if (recent[1].device !== $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
})
}
function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = []
// TODO...
return changes
}
function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = []
for (const [id, value] of file.settings.entries()) {
if ($settings[id].value !== value) {
changes.push({
type: ChangeType.Setting,
id,
setting: value,
})
}
}
return changes
}
function getChangesFromLayoutFile(file: CharaLayoutFile) {
const changes: Change[] = []
for (const [layer, keys] of file.layout.entries()) {
for (const [id, action] of keys.entries()) {
if ($layout[layer][id].action !== action) {
changes.push({
type: ChangeType.Layout,
layer,
id,
action,
})
}
}
}
return changes
}
</script> </script>
<section> <section>
@@ -105,6 +16,21 @@
<p class="disclaimer"> <p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i> <i>{$LL.backup.DISCLAIMER()}</i>
</p> </p>
<fieldset>
<legend>{$LL.backup.INDIVIDUAL()}</legend>
<button on:click={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button on:click={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button on:click={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
{$LL.configure.settings.TITLE()}
</button>
</fieldset>
<div class="save"> <div class="save">
<button class="primary" on:click={downloadBackup} <button class="primary" on:click={downloadBackup}
><span class="icon">save</span>{$LL.backup.DOWNLOAD()}</button ><span class="icon">save</span>{$LL.backup.DOWNLOAD()}</button
@@ -130,6 +56,13 @@
} }
} }
fieldset {
display: flex;
margin-block: 16px;
border: 1px solid currentcolor;
border-radius: 16px;
}
section { section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,6 +1,8 @@
import {redirect} from "@sveltejs/kit" import {redirect} from "@sveltejs/kit"
import type {PageLoad} from "./$types" import type {PageLoad} from "./$types"
export const load = (() => { export const load = (({url}) => {
throw redirect(302, "/config/chords/") const newUrl = new URL(url)
newUrl.pathname = "/config/layout/"
throw redirect(302, newUrl)
}) satisfies PageLoad }) satisfies PageLoad

View File

@@ -1,40 +1,29 @@
<script lang="ts"> <script lang="ts">
import {share} from "$lib/share" import {share} from "$lib/share"
import {deviceLayout} from "$lib/serial/connection"
import tippy from "tippy.js" import tippy from "tippy.js"
import {onMount, setContext} from "svelte" import {setContext} from "svelte"
import Layout from "$lib/components/layout/Layout.svelte" import Layout from "$lib/components/layout/Layout.svelte"
import {csvLayoutToJson, isCsvLayout} from "$lib/compat/legacy-layout" import {charaFileToUriComponent} from "$lib/share/share-url"
import {charaFileFromUriComponent, charaFileToUriComponent} from "$lib/share/share-url"
import type {CharaLayoutFile} from "$lib/share/chara-file"
import SharePopup from "../SharePopup.svelte" import SharePopup from "../SharePopup.svelte"
import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout" import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout"
import {writable} from "svelte/store" import {writable} from "svelte/store"
import {layout} from "$lib/undo-redo"
onMount(async () => {
const url = new URL(window.location.href)
if (url.searchParams.has("import")) {
const file = await charaFileFromUriComponent(url.searchParams.get("import")!)
if (file.type === "layout") {
$deviceLayout = file.layout
}
}
})
async function shareLayout(event: Event) { async function shareLayout(event: Event) {
const url = new URL(window.location.href) const url = new URL(window.location.origin)
url.searchParams.set( url.searchParams.set(
"import", "import",
await charaFileToUriComponent({ await charaFileToUriComponent({
charaVersion: 1, charaVersion: 1,
type: "layout", type: "layout",
device: "one", device: "one",
layout: $deviceLayout, layout: $layout.map(it => it.map(it => it.action)) as [number[], number[], number[]],
}), }),
) )
await navigator.clipboard.writeText(url.toString()) await navigator.clipboard.writeText(url.toString())
let shareComponent: SharePopup let shareComponent: SharePopup
tippy(event.target as HTMLElement, { tippy(event.target as HTMLElement, {
interactive: true,
onCreate(instance) { onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")! const target = instance.popper.querySelector(".tippy-content")!
shareComponent = new SharePopup({target}) shareComponent = new SharePopup({target})