mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-21 17:32:41 +00:00
fix: crash when saving empty chords
This commit is contained in:
@@ -83,6 +83,8 @@ const config = {
|
|||||||
"play_arrow",
|
"play_arrow",
|
||||||
"extension",
|
"extension",
|
||||||
"upload_file",
|
"upload_file",
|
||||||
|
"file_export",
|
||||||
|
"file_save",
|
||||||
"commit",
|
"commit",
|
||||||
"bug_report",
|
"bug_report",
|
||||||
"delete",
|
"delete",
|
||||||
@@ -167,6 +169,7 @@ const config = {
|
|||||||
experiment: "e686",
|
experiment: "e686",
|
||||||
dictionary: "f539",
|
dictionary: "f539",
|
||||||
visibility_off: "e8f5",
|
visibility_off: "e8f5",
|
||||||
|
file_save: "f17f",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -72,22 +72,26 @@ export function createSettingsBackup(): CharaSettingsFile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreBackup(event: Event) {
|
export async function restoreBackup(
|
||||||
|
event: Event,
|
||||||
|
only?: "chords" | "layout" | "settings",
|
||||||
|
) {
|
||||||
const input = (event.target as HTMLInputElement).files![0];
|
const input = (event.target as HTMLInputElement).files![0];
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
const text = await input.text();
|
const text = await input.text();
|
||||||
if (input.name.endsWith(".json")) {
|
if (input.name.endsWith(".json")) {
|
||||||
restoreFromFile(JSON.parse(text));
|
restoreFromFile(JSON.parse(text), only);
|
||||||
} else if (isCsvLayout(text)) {
|
} else if (isCsvLayout(text)) {
|
||||||
restoreFromFile(csvLayoutToJson(text));
|
restoreFromFile(csvLayoutToJson(text), only);
|
||||||
} else if (isCsvChords(text)) {
|
} else if (isCsvChords(text)) {
|
||||||
restoreFromFile(csvChordsToJson(text));
|
restoreFromFile(csvChordsToJson(text), only);
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreFromFile(
|
export function restoreFromFile(
|
||||||
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
||||||
|
only?: "chords" | "layout" | "settings",
|
||||||
) {
|
) {
|
||||||
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
||||||
switch (file.type) {
|
switch (file.type) {
|
||||||
@@ -112,33 +116,45 @@ export function restoreFromFile(
|
|||||||
|
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push([
|
changes.push([
|
||||||
...getChangesFromChordFile(recent[0]),
|
...(!only || only === "chords"
|
||||||
...getChangesFromLayoutFile(recent[1]),
|
? getChangesFromChordFile(recent[0])
|
||||||
...getChangesFromSettingsFile(recent[2]),
|
: []),
|
||||||
|
...(!only || only === "layout"
|
||||||
|
? getChangesFromLayoutFile(recent[1])
|
||||||
|
: []),
|
||||||
|
...(!only || only === "settings"
|
||||||
|
? getChangesFromSettingsFile(recent[2])
|
||||||
|
: []),
|
||||||
]);
|
]);
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chords": {
|
case "chords": {
|
||||||
|
if (!only || only === "chords") {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(getChangesFromChordFile(file));
|
changes.push(getChangesFromChordFile(file));
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "layout": {
|
case "layout": {
|
||||||
|
if (!only || only === "layout") {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(getChangesFromLayoutFile(file));
|
changes.push(getChangesFromLayoutFile(file));
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "settings": {
|
case "settings": {
|
||||||
|
if (!only || only === "settings") {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(getChangesFromSettingsFile(file));
|
changes.push(getChangesFromSettingsFile(file));
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { serialLog, serialPort } from "$lib/serial/connection";
|
import { serialLog, serialPort } from "$lib/serial/connection";
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
io.scrollTo({ top: io.scrollHeight });
|
||||||
|
});
|
||||||
|
|
||||||
function submit(event: Event) {
|
function submit(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
$serialPort?.send(0, value.trim());
|
$serialPort?.send(0, value.trim());
|
||||||
|
|||||||
@@ -342,23 +342,24 @@ export class CharaDevice {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||||
const readResult = await read(timeout);
|
const readResult = await read(timeout);
|
||||||
if (readResult === undefined) {
|
return readResult
|
||||||
|
?.replace(new RegExp(`^${commandString} `), "")
|
||||||
|
.split(" ");
|
||||||
|
}).then((it) => {
|
||||||
|
if (it === undefined) {
|
||||||
console.error("No response");
|
console.error("No response");
|
||||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||||
string,
|
string,
|
||||||
T
|
T
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
const array = readResult
|
if (it.length < expectedLength) {
|
||||||
.replace(new RegExp(`^${commandString} `), "")
|
|
||||||
.split(" ");
|
|
||||||
if (array.length < expectedLength) {
|
|
||||||
console.error("Response too short");
|
console.error("Response too short");
|
||||||
return array.concat(
|
return it.concat(
|
||||||
Array(expectedLength - array.length).fill("TOO_SHORT"),
|
Array(expectedLength - it.length).fill("TOO_SHORT"),
|
||||||
) as LengthArray<string, T>;
|
) as LengthArray<string, T>;
|
||||||
}
|
}
|
||||||
return array as LengthArray<string, T>;
|
return it as LengthArray<string, T>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +402,7 @@ export class CharaDevice {
|
|||||||
stringifyChordActions(chord.actions),
|
stringifyChordActions(chord.actions),
|
||||||
stringifyPhrase(chord.phrase),
|
stringifyPhrase(chord.phrase),
|
||||||
]);
|
]);
|
||||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||||
|
|||||||
@@ -179,6 +179,22 @@ export const chords = derived(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const duplicateChords = derived(chords, (chords) => {
|
||||||
|
const duplicates = new Set<string>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const chord of chords) {
|
||||||
|
const key = JSON.stringify(chord.actions);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
duplicates.add(key);
|
||||||
|
} else {
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
});
|
||||||
|
|
||||||
export const chordHashes = derived(
|
export const chordHashes = derived(
|
||||||
chords,
|
chords,
|
||||||
(chords) =>
|
(chords) =>
|
||||||
|
|||||||
@@ -7,20 +7,24 @@
|
|||||||
layout,
|
layout,
|
||||||
overlay,
|
overlay,
|
||||||
settings,
|
settings,
|
||||||
|
duplicateChords,
|
||||||
} from "$lib/undo-redo";
|
} from "$lib/undo-redo";
|
||||||
import type { Change } from "$lib/undo-redo";
|
import type { Change, ChordChange } from "$lib/undo-redo";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import { action } from "$lib/title";
|
import { action, actionTooltip } from "$lib/title";
|
||||||
import {
|
import {
|
||||||
deviceChords,
|
deviceChords,
|
||||||
deviceLayout,
|
deviceLayout,
|
||||||
deviceSettings,
|
deviceSettings,
|
||||||
|
serialLog,
|
||||||
serialPort,
|
serialPort,
|
||||||
|
sync,
|
||||||
syncProgress,
|
syncProgress,
|
||||||
syncStatus,
|
syncStatus,
|
||||||
} from "$lib/serial/connection";
|
} from "$lib/serial/connection";
|
||||||
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
|
|
||||||
import ProgressButton from "$lib/ProgressButton.svelte";
|
import ProgressButton from "$lib/ProgressButton.svelte";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
function undo(event: MouseEvent) {
|
function undo(event: MouseEvent) {
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
@@ -43,91 +47,11 @@
|
|||||||
let redoQueue: Change[][] = $state([]);
|
let redoQueue: Change[][] = $state([]);
|
||||||
let error = $state<Error | undefined>(undefined);
|
let error = $state<Error | undefined>(undefined);
|
||||||
let progressButton: HTMLButtonElement | undefined = $state();
|
let progressButton: HTMLButtonElement | undefined = $state();
|
||||||
let shouldSaveNext = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
async function saveLayoutChanges(progress: () => void): Promise<boolean> {
|
||||||
if ($serialPort && $syncStatus == "done" && shouldSaveNext) {
|
|
||||||
shouldSaveNext = false;
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
try {
|
|
||||||
const port = $serialPort;
|
const port = $serialPort;
|
||||||
if (!port) {
|
if (!port) return false;
|
||||||
document
|
try {
|
||||||
.getElementById("connect-popup")
|
|
||||||
?.showPopover({ source: progressButton });
|
|
||||||
shouldSaveNext = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$syncStatus = "uploading";
|
|
||||||
|
|
||||||
const layoutChanges = $overlay.layout.reduce(
|
|
||||||
(acc, profile) =>
|
|
||||||
acc + profile.reduce((acc, layer) => acc + layer.size, 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const settingChanges = $overlay.settings.reduce(
|
|
||||||
(acc, profile) => acc + profile.size,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const chordChanges = $overlay.chords.size;
|
|
||||||
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
|
||||||
const progressMax = layoutChanges + settingChanges + chordChanges;
|
|
||||||
|
|
||||||
let progressCurrent = 0;
|
|
||||||
|
|
||||||
syncProgress.set({
|
|
||||||
max: progressMax,
|
|
||||||
current: progressCurrent,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log($overlay);
|
|
||||||
|
|
||||||
for (const [id, chord] of $overlay.chords) {
|
|
||||||
if (!chord.deleted) {
|
|
||||||
if (id !== JSON.stringify(chord.actions)) {
|
|
||||||
const existingChord = await port.getChordPhrase(chord.actions);
|
|
||||||
if (
|
|
||||||
existingChord !== undefined &&
|
|
||||||
!(await askForConfirmation(
|
|
||||||
$LL.configure.chords.conflict.TITLE(),
|
|
||||||
$LL.configure.chords.conflict.DESCRIPTION(),
|
|
||||||
$LL.configure.chords.conflict.CONFIRM(),
|
|
||||||
$LL.configure.chords.conflict.ABORT(),
|
|
||||||
chord,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
changes.update((changes) =>
|
|
||||||
changes
|
|
||||||
.map((it) =>
|
|
||||||
it.filter(
|
|
||||||
(it) =>
|
|
||||||
!(
|
|
||||||
it.type === ChangeType.Chord &&
|
|
||||||
JSON.stringify(it.id) === id
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.filter((it) => it.length > 0),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await port.deleteChord({ actions: JSON.parse(id) });
|
|
||||||
}
|
|
||||||
await port.setChord({ actions: chord.actions, phrase: chord.phrase });
|
|
||||||
} else {
|
|
||||||
await port.deleteChord({ actions: chord.actions });
|
|
||||||
}
|
|
||||||
syncProgress.set({
|
|
||||||
max: progressMax,
|
|
||||||
current: progressCurrent++,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [profile, layout] of $overlay.layout.entries()) {
|
for (const [profile, layout] of $overlay.layout.entries()) {
|
||||||
if (layout === undefined) continue;
|
if (layout === undefined) continue;
|
||||||
for (const [layer, actions] of layout.entries()) {
|
for (const [layer, actions] of layout.entries()) {
|
||||||
@@ -135,66 +59,245 @@
|
|||||||
for (const [id, action] of actions) {
|
for (const [id, action] of actions) {
|
||||||
if (action === undefined) continue;
|
if (action === undefined) continue;
|
||||||
await port.setLayoutKey(profile, layer + 1, id, action);
|
await port.setLayoutKey(profile, layer + 1, id, action);
|
||||||
syncProgress.set({
|
progress();
|
||||||
max: progressMax,
|
|
||||||
current: progressCurrent++,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$deviceLayout = $layout.map((profile) =>
|
||||||
|
profile.map((layer) => layer.map<number>(({ action }) => action)),
|
||||||
|
);
|
||||||
|
changes.update((changes) =>
|
||||||
|
changes
|
||||||
|
.map((it) => it.filter((it) => it.type !== ChangeType.Layout))
|
||||||
|
.filter((it) => it.length > 0),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(progress: () => void): Promise<boolean> {
|
||||||
|
const port = $serialPort;
|
||||||
|
if (!port) return false;
|
||||||
|
try {
|
||||||
for (const [profile, settings] of $overlay.settings.entries()) {
|
for (const [profile, settings] of $overlay.settings.entries()) {
|
||||||
if (settings === undefined) continue;
|
if (settings === undefined) continue;
|
||||||
for (const [id, setting] of settings.entries()) {
|
for (const [id, setting] of settings.entries()) {
|
||||||
if (setting === undefined) continue;
|
if (setting === undefined) continue;
|
||||||
await port.setSetting(profile, id, setting);
|
await port.setSetting(profile, id, setting);
|
||||||
syncProgress.set({
|
progress();
|
||||||
max: progressMax,
|
|
||||||
current: progressCurrent++,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yes, this is a completely arbitrary and unnecessary delay.
|
|
||||||
// The only purpose of it is to create a sense of weight,
|
|
||||||
// aka make it more "energy intensive" to click.
|
|
||||||
// The only conceivable way users could reach the commit limit in this case
|
|
||||||
// would be if they click it every time they change a setting.
|
|
||||||
// Because of that, we don't need to show a fearmongering message such as
|
|
||||||
// "Your device will break after you click this 10,000 times!"
|
|
||||||
if (needsCommit) {
|
|
||||||
await port.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
$deviceLayout = $layout.map((profile) =>
|
|
||||||
profile.map((layer) => layer.map<number>(({ action }) => action)),
|
|
||||||
);
|
|
||||||
$deviceChords = $chords
|
|
||||||
.filter(({ deleted }) => !deleted)
|
|
||||||
.map(({ actions, phrase }) => ({ actions, phrase }));
|
|
||||||
$deviceSettings = $settings.map((profile) =>
|
$deviceSettings = $settings.map((profile) =>
|
||||||
profile.map(({ value }) => value),
|
profile.map(({ value }) => value),
|
||||||
);
|
);
|
||||||
$changes = [];
|
changes.update((changes) =>
|
||||||
|
changes
|
||||||
|
.map((it) => it.filter((it) => it.type !== ChangeType.Setting))
|
||||||
|
.filter((it) => it.length > 0),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeDeleteChord(actions: number[]): Promise<boolean> {
|
||||||
|
const port = $serialPort;
|
||||||
|
if (!port) return false;
|
||||||
|
try {
|
||||||
|
await port.deleteChord({ actions });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
try {
|
||||||
|
if ((await port.getChordPhrase(actions)) === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveChords(progress: () => void): Promise<boolean> {
|
||||||
|
const port = $serialPort;
|
||||||
|
if (!port) return false;
|
||||||
|
let ok = true;
|
||||||
|
|
||||||
|
const empty = new Set<string>();
|
||||||
|
for (const [id, chord] of $overlay.chords) {
|
||||||
|
if (chord.actions.length === 0 || chord.phrase.length === 0) {
|
||||||
|
empty.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes.update((changes) => {
|
||||||
|
changes.push([
|
||||||
|
...empty.keys().map(
|
||||||
|
(id) =>
|
||||||
|
({
|
||||||
|
type: ChangeType.Chord,
|
||||||
|
id: JSON.parse(id),
|
||||||
|
deleted: true,
|
||||||
|
actions: [],
|
||||||
|
phrase: [],
|
||||||
|
}) satisfies ChordChange,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const deleted = new Set<string>();
|
||||||
|
const changed = new Map<string, number[]>();
|
||||||
|
for (const [id, chord] of $overlay.chords) {
|
||||||
|
if (!chord.deleted) continue;
|
||||||
|
if (await safeDeleteChord(JSON.parse(id))) {
|
||||||
|
deleted.add(id);
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
progress();
|
||||||
|
}
|
||||||
|
deviceChords.update((chords) =>
|
||||||
|
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions))),
|
||||||
|
);
|
||||||
|
deleted.clear();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
for (const [id, chord] of $overlay.chords) {
|
||||||
|
if (chord.deleted) continue;
|
||||||
|
if ($duplicateChords.has(JSON.stringify(chord.actions))) {
|
||||||
|
ok = false;
|
||||||
|
} else {
|
||||||
|
let skip = false;
|
||||||
|
if (id !== JSON.stringify(chord.actions)) {
|
||||||
|
if (await safeDeleteChord(JSON.parse(id))) {
|
||||||
|
deleted.add(id);
|
||||||
|
} else {
|
||||||
|
skip = true;
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!skip) {
|
||||||
|
try {
|
||||||
|
await port.setChord({
|
||||||
|
actions: chord.actions,
|
||||||
|
phrase: chord.phrase,
|
||||||
|
});
|
||||||
|
deleted.add(JSON.stringify(chord.actions));
|
||||||
|
changed.set(JSON.stringify(chord.actions), chord.phrase);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress();
|
||||||
|
}
|
||||||
|
deviceChords.update((chords) => {
|
||||||
|
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions)));
|
||||||
|
for (const [id, phrase] of changed) {
|
||||||
|
chords.push({ actions: JSON.parse(id), phrase });
|
||||||
|
}
|
||||||
|
return chords;
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
let needsSync = false;
|
||||||
|
try {
|
||||||
|
const port = $serialPort;
|
||||||
|
if (!port) {
|
||||||
|
document
|
||||||
|
.getElementById("connect-popup")
|
||||||
|
?.showPopover({ source: progressButton });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$syncStatus = "uploading";
|
||||||
|
|
||||||
|
const layoutChanges = $overlay.layout.reduce(
|
||||||
|
(acc, profile) =>
|
||||||
|
acc +
|
||||||
|
(profile?.reduce((acc, layer) => acc + (layer?.size ?? 0), 0) ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const settingChanges = $overlay.settings.reduce(
|
||||||
|
(acc, profile) => acc + (profile?.size ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const chordChanges = $overlay.chords.size;
|
||||||
|
needsSync = chordChanges > 0;
|
||||||
|
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||||
|
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||||
|
|
||||||
|
let progressCurrent = 0;
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
syncProgress.set({
|
||||||
|
max: progressMax,
|
||||||
|
current: Math.min(progressMax, progressCurrent++),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateProgress();
|
||||||
|
|
||||||
|
let layoutSuccess = await saveLayoutChanges(updateProgress);
|
||||||
|
let settingsSuccess = await saveSettings(updateProgress);
|
||||||
|
|
||||||
|
if (needsCommit) {
|
||||||
|
try {
|
||||||
|
await port.commit();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error during commit:", e);
|
||||||
|
layoutSuccess = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let chordsSuccess = await saveChords(updateProgress);
|
||||||
|
|
||||||
|
if (layoutSuccess && settingsSuccess && chordsSuccess) {
|
||||||
|
changes.set([]);
|
||||||
|
needsSync = true;
|
||||||
|
} else {
|
||||||
|
throw new Error("Some changes could not be saved.");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e as Error;
|
error = e as Error;
|
||||||
console.error("Error while saving changes:", error);
|
console.error("Error while saving changes:", error);
|
||||||
|
serialLog.update((log) => {
|
||||||
|
log.push({ type: "system", value: error?.message ?? "Error" });
|
||||||
|
return log;
|
||||||
|
});
|
||||||
|
goto("/terminal");
|
||||||
} finally {
|
} finally {
|
||||||
$syncStatus = "done";
|
$syncStatus = "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (needsSync) {
|
||||||
|
await sync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let progressPopover: HTMLElement | undefined = $state();
|
let progressPopover: HTMLElement | undefined = $state();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
|
{@attach actionTooltip($LL.saveActions.UNDO(), "ctrl+z")}
|
||||||
class="icon"
|
class="icon"
|
||||||
disabled={$changes.length === 0}
|
disabled={$changes.length === 0}
|
||||||
onclick={undo}>undo</button
|
onclick={undo}>undo</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
|
{@attach actionTooltip($LL.saveActions.REDO(), "ctrl+y")}
|
||||||
class="icon"
|
class="icon"
|
||||||
disabled={redoQueue.length === 0}
|
disabled={redoQueue.length === 0}
|
||||||
onclick={redo}>redo</button
|
onclick={redo}>redo</button
|
||||||
@@ -202,7 +305,7 @@
|
|||||||
{#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
|
{#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ x: 10 }}
|
transition:fly={{ x: 10 }}
|
||||||
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
|
{@attach actionTooltip($LL.saveActions.SAVE(), "ctrl+shift+s")}
|
||||||
>
|
>
|
||||||
<ProgressButton
|
<ProgressButton
|
||||||
disabled={$syncStatus !== "done"}
|
disabled={$syncStatus !== "done"}
|
||||||
|
|||||||
@@ -1,12 +1,75 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fly, slide } from "svelte/transition";
|
||||||
import { canShare, triggerShare } from "$lib/share";
|
import { canShare, triggerShare } from "$lib/share";
|
||||||
import { action } from "$lib/title";
|
import { actionTooltip } from "$lib/title";
|
||||||
import { activeProfile, serialPort } from "$lib/serial/connection";
|
import { activeProfile, serialPort } from "$lib/serial/connection";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "$i18n/i18n-svelte";
|
||||||
import EditActions from "./EditActions.svelte";
|
import EditActions from "./EditActions.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { expoOut } from "svelte/easing";
|
import { expoOut } from "svelte/easing";
|
||||||
|
import {
|
||||||
|
createChordBackup,
|
||||||
|
createLayoutBackup,
|
||||||
|
createSettingsBackup,
|
||||||
|
downloadFile,
|
||||||
|
restoreBackup,
|
||||||
|
} from "$lib/backup/backup";
|
||||||
|
|
||||||
|
const routeOrder = [
|
||||||
|
"/(app)/config/settings",
|
||||||
|
"/(app)/config/chords",
|
||||||
|
"/(app)/config/layout",
|
||||||
|
];
|
||||||
|
|
||||||
|
let pageIndex = $derived(
|
||||||
|
routeOrder.findIndex((it) => page.route.id?.startsWith(it)),
|
||||||
|
);
|
||||||
|
let importExport: HTMLDivElement | undefined = $state(undefined);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
pageIndex;
|
||||||
|
importExport?.animate(
|
||||||
|
[
|
||||||
|
{ transform: "translateX(0)", opacity: 1 },
|
||||||
|
{ transform: "translateX(-8px)", opacity: 0, offset: 0.2 },
|
||||||
|
{ transform: "translateX(8px)", opacity: 0, offset: 0.7 },
|
||||||
|
{ transform: "translateX(0)", opacity: 1 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration: 1500,
|
||||||
|
easing: "cubic-bezier(0.19, 1, 0.22, 1)",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function importBackup(event: Event) {
|
||||||
|
switch (pageIndex) {
|
||||||
|
case 0:
|
||||||
|
restoreBackup(event, "settings");
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
restoreBackup(event, "chords");
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
restoreBackup(event, "layout");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
(event.target as HTMLInputElement).value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportBackup() {
|
||||||
|
switch (pageIndex) {
|
||||||
|
case 0:
|
||||||
|
downloadFile(createSettingsBackup());
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
downloadFile(createChordBackup());
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
downloadFile(createLayoutBackup());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
@@ -14,10 +77,10 @@
|
|||||||
<EditActions />
|
<EditActions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="profiles">
|
||||||
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
|
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
|
||||||
<div
|
<div
|
||||||
transition:fade={{ duration: 250, easing: expoOut }}
|
transition:fly={{ y: -8, duration: 250, easing: expoOut }}
|
||||||
class="profiles"
|
class="profiles"
|
||||||
>
|
>
|
||||||
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
|
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
|
||||||
@@ -37,13 +100,13 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if $canShare}
|
{#if $canShare}
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.share.TITLE() }}
|
{@attach actionTooltip($LL.share.TITLE())}
|
||||||
transition:fly={{ x: -8 }}
|
transition:fly={{ x: -8 }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={triggerShare}>share</button
|
onclick={triggerShare}>share</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.print.TITLE() }}
|
{@attach actionTooltip($LL.print.TITLE())}
|
||||||
transition:fly={{ x: -8 }}
|
transition:fly={{ x: -8 }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={() => print()}>print</button
|
onclick={() => print()}>print</button
|
||||||
@@ -54,10 +117,32 @@
|
|||||||
<PwaStatus />
|
<PwaStatus />
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="import-export" bind:this={importExport}>
|
||||||
|
<label
|
||||||
|
><input type="file" oninput={importBackup} />
|
||||||
|
<span class="icon">upload_file</span>Import</label
|
||||||
|
>
|
||||||
|
<button onclick={exportBackup}
|
||||||
|
><span class="icon">file_save</span>Export</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.profiles {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user