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", "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",
}, },
}; };

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]; 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: {

View File

@@ -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());

View File

@@ -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">) {

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( export const chordHashes = derived(
chords, chords,
(chords) => (chords) =>

View File

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

View File

@@ -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;