mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-21 17:32:41 +00:00
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:
@@ -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: {
|
||||||
|
|||||||
@@ -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
145
src/lib/backup/backup.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(".", "+")
|
||||||
|
|||||||
53
src/lib/share/action-array.spec.ts
Normal file
53
src/lib/share/action-array.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
Reference in New Issue
Block a user