Skip to content

Commit

Permalink
feat: layout url import
Browse files Browse the repository at this point in the history
feat: backup import (except chords)
feat: legacy layout import
feat: separate layout, chord & setting backup downloads
  • Loading branch information
Theaninova committed Nov 15, 2023
1 parent acd5864 commit c5d9def
Show file tree
Hide file tree
Showing 20 changed files with 299 additions and 143 deletions.
3 changes: 2 additions & 1 deletion src/i18n/de/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
145 changes: 145 additions & 0 deletions src/lib/backup/backup.ts
Original file line number Diff line number Diff line change
@@ -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
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion src/lib/components/layout/Layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/lib/serialization/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/lib/serialization/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`data:application/octet-stream;base64,${base64
.replaceAll(".", "+")
Expand Down
53 changes: 53 additions & 0 deletions src/lib/share/action-array.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
27 changes: 16 additions & 11 deletions src/lib/share/action-array.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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))
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/share/chara-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 13 additions & 8 deletions src/lib/share/share-url.ts
Original file line number Diff line number Diff line change
@@ -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<
Expand All @@ -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
Expand Down Expand Up @@ -42,17 +43,21 @@ export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Pr
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 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)
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ""
Expand Down
12 changes: 12 additions & 0 deletions src/routes/+layout.ts
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit c5d9def

Please sign in to comment.