fix: crash when saving empty chords

This commit is contained in:
2025-12-12 17:41:54 +01:00
parent b13c34ca15
commit fe42dcd2ab
7 changed files with 379 additions and 150 deletions

View File

@@ -83,6 +83,8 @@ const config = {
"play_arrow",
"extension",
"upload_file",
"file_export",
"file_save",
"commit",
"bug_report",
"delete",
@@ -167,6 +169,7 @@ const config = {
experiment: "e686",
dictionary: "f539",
visibility_off: "e8f5",
file_save: "f17f",
},
};

View File

@@ -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];
if (!input) return;
const text = await input.text();
if (input.name.endsWith(".json")) {
restoreFromFile(JSON.parse(text));
restoreFromFile(JSON.parse(text), only);
} else if (isCsvLayout(text)) {
restoreFromFile(csvLayoutToJson(text));
restoreFromFile(csvLayoutToJson(text), only);
} else if (isCsvChords(text)) {
restoreFromFile(csvChordsToJson(text));
restoreFromFile(csvChordsToJson(text), only);
} else {
}
}
export function restoreFromFile(
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
only?: "chords" | "layout" | "settings",
) {
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
switch (file.type) {
@@ -112,33 +116,45 @@ export function restoreFromFile(
changes.update((changes) => {
changes.push([
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
...(!only || only === "chords"
? getChangesFromChordFile(recent[0])
: []),
...(!only || only === "layout"
? getChangesFromLayoutFile(recent[1])
: []),
...(!only || only === "settings"
? getChangesFromSettingsFile(recent[2])
: []),
]);
return changes;
});
break;
}
case "chords": {
changes.update((changes) => {
changes.push(getChangesFromChordFile(file));
return changes;
});
if (!only || only === "chords") {
changes.update((changes) => {
changes.push(getChangesFromChordFile(file));
return changes;
});
}
break;
}
case "layout": {
changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file));
return changes;
});
if (!only || only === "layout") {
changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file));
return changes;
});
}
break;
}
case "settings": {
changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file));
return changes;
});
if (!only || only === "settings") {
changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file));
return changes;
});
}
break;
}
default: {

View File

@@ -1,7 +1,12 @@
<script lang="ts">
import { serialLog, serialPort } from "$lib/serial/connection";
import { onMount } from "svelte";
import { slide } from "svelte/transition";
onMount(() => {
io.scrollTo({ top: io.scrollHeight });
});
function submit(event: Event) {
event.preventDefault();
$serialPort?.send(0, value.trim());

View File

@@ -342,23 +342,24 @@ export class CharaDevice {
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read(timeout);
if (readResult === undefined) {
return readResult
?.replace(new RegExp(`^${commandString} `), "")
.split(" ");
}).then((it) => {
if (it === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
string,
T
>;
}
const array = readResult
.replace(new RegExp(`^${commandString} `), "")
.split(" ");
if (array.length < expectedLength) {
if (it.length < expectedLength) {
console.error("Response too short");
return array.concat(
Array(expectedLength - array.length).fill("TOO_SHORT"),
return it.concat(
Array(expectedLength - it.length).fill("TOO_SHORT"),
) 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),
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">) {

View File

@@ -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(
chords,
(chords) =>

View File

@@ -7,20 +7,24 @@
layout,
overlay,
settings,
duplicateChords,
} 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 { action } from "$lib/title";
import { action, actionTooltip } from "$lib/title";
import {
deviceChords,
deviceLayout,
deviceSettings,
serialLog,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
import ProgressButton from "$lib/ProgressButton.svelte";
import { tick } from "svelte";
import { goto } from "$app/navigation";
function undo(event: MouseEvent) {
if (event.shiftKey) {
@@ -43,91 +47,11 @@
let redoQueue: Change[][] = $state([]);
let error = $state<Error | undefined>(undefined);
let progressButton: HTMLButtonElement | undefined = $state();
let shouldSaveNext = $state(false);
$effect(() => {
if ($serialPort && $syncStatus == "done" && shouldSaveNext) {
shouldSaveNext = false;
save();
}
});
async function save() {
async function saveLayoutChanges(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
const port = $serialPort;
if (!port) {
document
.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()) {
if (layout === undefined) continue;
for (const [layer, actions] of layout.entries()) {
@@ -135,66 +59,245 @@
for (const [id, action] of actions) {
if (action === undefined) continue;
await port.setLayoutKey(profile, layer + 1, id, action);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
progress();
}
}
}
$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()) {
if (settings === undefined) continue;
for (const [id, setting] of settings.entries()) {
if (setting === undefined) continue;
await port.setSetting(profile, id, setting);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
progress();
}
}
// 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) =>
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) {
error = e as Error;
console.error("Error while saving changes:", error);
serialLog.update((log) => {
log.push({ type: "system", value: error?.message ?? "Error" });
return log;
});
goto("/terminal");
} finally {
$syncStatus = "done";
}
if (needsSync) {
await sync();
}
}
let progressPopover: HTMLElement | undefined = $state();
</script>
<button
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
{@attach actionTooltip($LL.saveActions.UNDO(), "ctrl+z")}
class="icon"
disabled={$changes.length === 0}
onclick={undo}>undo</button
>
<button
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
{@attach actionTooltip($LL.saveActions.REDO(), "ctrl+y")}
class="icon"
disabled={redoQueue.length === 0}
onclick={redo}>redo</button
@@ -202,7 +305,7 @@
{#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
<div
transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
{@attach actionTooltip($LL.saveActions.SAVE(), "ctrl+shift+s")}
>
<ProgressButton
disabled={$syncStatus !== "done"}

View File

@@ -1,12 +1,75 @@
<script lang="ts">
import { fade, fly } from "svelte/transition";
import { fly, slide } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import { activeProfile, serialPort } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte";
import { page } from "$app/state";
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>
<nav>
@@ -14,10 +77,10 @@
<EditActions />
</div>
<div>
<div class="profiles">
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
<div
transition:fade={{ duration: 250, easing: expoOut }}
transition:fly={{ y: -8, duration: 250, easing: expoOut }}
class="profiles"
>
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
@@ -37,13 +100,13 @@
<div class="actions">
{#if $canShare}
<button
use:action={{ title: $LL.share.TITLE() }}
{@attach actionTooltip($LL.share.TITLE())}
transition:fly={{ x: -8 }}
class="icon"
onclick={triggerShare}>share</button
>
<button
use:action={{ title: $LL.print.TITLE() }}
{@attach actionTooltip($LL.print.TITLE())}
transition:fly={{ x: -8 }}
class="icon"
onclick={() => print()}>print</button
@@ -54,10 +117,32 @@
<PwaStatus />
{/await}
{/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>
</nav>
<style lang="scss">
.profiles {
display: flex;
justify-content: center;
}
input[type="file"] {
display: none;
}
.import-export {
display: flex;
}
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;