Skip to content

Commit c1c16c3

Browse files
SilkePilongitbutler-client
authored andcommitted
feat(macros add import/export, sanitize imports, and refactor
- add buildDownloadFilename and pad2 helpers to consistently generate safe timestamped filenames for macro downloads - extract macro download logic into handleDownloadMacro and wire up Download button to use it - refactor normalizeSortOrders to a concise one-liner - introduce sanitizeImportedStep and sanitizeImportedMacro to validate imported JSON, enforce types, default values, and limit name length, preventing malformed data from corrupting store - generate new IDs for imported macros and ensure correct sortOrder - update Memo dependencies to include handleDownloadMacro These changes enable reliable macro export/import with sanitized inputs, improve code clarity by extracting utilities, and prevent issues from malformed external files.
1 parent a53c44e commit c1c16c3

File tree

1 file changed

+67
-87
lines changed

1 file changed

+67
-87
lines changed

ui/src/routes/devices.$id.settings.macros.tsx

Lines changed: 67 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,32 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
2323
import LoadingSpinner from "@/components/LoadingSpinner";
2424
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
2525

26-
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => {
27-
return macros.map((macro, index) => ({
28-
...macro,
29-
sortOrder: index + 1,
30-
}));
26+
const normalizeSortOrders = (macros: KeySequence[]): KeySequence[] => macros.map((m, i) => ({ ...m, sortOrder: i + 1 }));
27+
28+
const pad2 = (n: number) => String(n).padStart(2, "0");
29+
30+
const buildMacroDownloadFilename = (macro: KeySequence) => {
31+
const safeName = (macro.name || macro.id).replace(/[^a-z0-9-_]+/gi, "-").toLowerCase();
32+
const now = new Date();
33+
const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
34+
return `jetkvm-macro-${safeName}-${ts}.json`;
3135
};
3236

37+
const sanitizeImportedStep = (raw: any) => ({
38+
keys: Array.isArray(raw?.keys) ? raw.keys.filter((k: any) => typeof k === "string") : [],
39+
modifiers: Array.isArray(raw?.modifiers) ? raw.modifiers.filter((m: any) => typeof m === "string") : [],
40+
delay: typeof raw?.delay === "number" ? raw.delay : DEFAULT_DELAY,
41+
text: typeof raw?.text === "string" ? raw.text : undefined,
42+
wait: typeof raw?.wait === "boolean" ? raw.wait : false,
43+
});
44+
45+
const sanitizeImportedMacro = (raw: any, sortOrder: number): KeySequence => ({
46+
id: generateMacroId(),
47+
name: (typeof raw?.name === "string" && raw.name.trim() ? raw.name : "Imported Macro").slice(0, 50),
48+
steps: Array.isArray(raw?.steps) ? raw.steps.map(sanitizeImportedStep) : [],
49+
sortOrder,
50+
});
51+
3352
export default function SettingsMacrosRoute() {
3453
const { macros, loading, initialized, loadMacros, saveMacros } = useMacrosStore();
3554
const navigate = useNavigate();
@@ -120,6 +139,17 @@ export default function SettingsMacrosRoute() {
120139
}
121140
}, [macroToDelete, macros, saveMacros]);
122141

142+
const handleDownloadMacro = useCallback((macro: KeySequence) => {
143+
const data = JSON.stringify(macro, null, 2);
144+
const blob = new Blob([data], { type: "application/json" });
145+
const url = URL.createObjectURL(blob);
146+
const a = document.createElement("a");
147+
a.href = url;
148+
a.download = buildMacroDownloadFilename(macro);
149+
a.click();
150+
URL.revokeObjectURL(url);
151+
}, []);
152+
123153
const MacroList = useMemo(
124154
() => (
125155
<div className="space-y-2">
@@ -244,27 +274,7 @@ export default function SettingsMacrosRoute() {
244274
disabled={actionLoadingId === macro.id}
245275
aria-label={`Duplicate macro ${macro.name}`}
246276
/>
247-
<Button
248-
size="XS"
249-
theme="light"
250-
LeadingIcon={LuDownload}
251-
onClick={() => {
252-
const data = JSON.stringify(macro, null, 2);
253-
const blob = new Blob([data], { type: "application/json" });
254-
const url = URL.createObjectURL(blob);
255-
const a = document.createElement("a");
256-
const safeName = macro.name.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase();
257-
const now = new Date();
258-
const pad = (n: number) => String(n).padStart(2, "0");
259-
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
260-
a.href = url;
261-
a.download = `jetkvm-macro-${safeName || macro.id}-${ts}.json`;
262-
a.click();
263-
URL.revokeObjectURL(url);
264-
}}
265-
aria-label={`Download macro ${macro.name}`}
266-
disabled={actionLoadingId === macro.id}
267-
/>
277+
<Button size="XS" theme="light" LeadingIcon={LuDownload} onClick={() => handleDownloadMacro(macro)} aria-label={`Download macro ${macro.name}`} disabled={actionLoadingId === macro.id} />
268278
<Button
269279
size="XS"
270280
theme="light"
@@ -294,19 +304,7 @@ export default function SettingsMacrosRoute() {
294304
/>
295305
</div>
296306
),
297-
[
298-
macros,
299-
showDeleteConfirm,
300-
macroToDelete?.name,
301-
macroToDelete?.id,
302-
actionLoadingId,
303-
handleDeleteMacro,
304-
handleMoveMacro,
305-
selectedKeyboard.modifierDisplayMap,
306-
selectedKeyboard.keyDisplayMap,
307-
handleDuplicateMacro,
308-
navigate
309-
],
307+
[macros, showDeleteConfirm, macroToDelete?.name, macroToDelete?.id, actionLoadingId, handleDeleteMacro, handleMoveMacro, selectedKeyboard.modifierDisplayMap, selectedKeyboard.keyDisplayMap, handleDuplicateMacro, navigate, handleDownloadMacro],
310308
);
311309

312310
return (
@@ -326,57 +324,39 @@ export default function SettingsMacrosRoute() {
326324
aria-label="Add new macro"
327325
/>
328326
<div className="ml-2 flex items-center gap-2">
329-
<input
330-
ref={fileInputRef}
331-
type="file"
332-
accept="application/json"
333-
multiple
334-
className="hidden"
335-
onChange={async e => {
336-
const files = e.target.files;
337-
if (!files || files.length === 0) return;
338-
let working = [...macros];
339-
const imported: string[] = [];
340-
let errors = 0;
341-
let skipped = 0;
342-
for (const f of Array.from(files)) {
343-
if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
344-
try {
345-
const raw = await f.text();
346-
const parsed = JSON.parse(raw);
347-
const candidates = Array.isArray(parsed) ? parsed : [parsed];
348-
for (const c of candidates) {
349-
if (working.length >= MAX_TOTAL_MACROS) { skipped += (candidates.length); break; }
350-
if (!c || typeof c !== 'object') { errors++; continue; }
351-
const sanitized: KeySequence = {
352-
id: generateMacroId(),
353-
name: (c.name || 'Imported Macro').slice(0,50),
354-
steps: Array.isArray(c.steps) ? c.steps.map((s:any) => ({
355-
keys: Array.isArray(s.keys) ? s.keys : [],
356-
modifiers: Array.isArray(s.modifiers) ? s.modifiers : [],
357-
delay: typeof s.delay === 'number' ? s.delay : DEFAULT_DELAY,
358-
text: typeof s.text === 'string' ? s.text : undefined,
359-
wait: typeof s.wait === 'boolean' ? s.wait : false,
360-
})) : [],
361-
sortOrder: working.length + 1,
362-
};
363-
working.push(sanitized);
364-
imported.push(sanitized.name);
365-
}
366-
} catch { errors++; }
367-
}
327+
<input ref={fileInputRef} type="file" accept="application/json" multiple className="hidden" onChange={async e => {
328+
const fl = e.target.files;
329+
if (!fl || fl.length === 0) return;
330+
let working = [...macros];
331+
const imported: string[] = [];
332+
let errors = 0;
333+
let skipped = 0;
334+
for (const f of Array.from(fl)) {
335+
if (working.length >= MAX_TOTAL_MACROS) { skipped++; continue; }
368336
try {
369-
if (imported.length) {
370-
await saveMacros(normalizeSortOrders(working));
371-
notifications.success(`Imported ${imported.length} macro${imported.length===1?'':'s'}`);
337+
const raw = await f.text();
338+
const parsed = JSON.parse(raw);
339+
const candidates = Array.isArray(parsed) ? parsed : [parsed];
340+
for (const c of candidates) {
341+
if (working.length >= MAX_TOTAL_MACROS) { skipped += (candidates.length - candidates.indexOf(c)); break; }
342+
if (!c || typeof c !== "object") { errors++; continue; }
343+
const sanitized = sanitizeImportedMacro(c, working.length + 1);
344+
working.push(sanitized);
345+
imported.push(sanitized.name);
372346
}
373-
if (errors) notifications.error(`${errors} file${errors===1?'':'s'} failed`);
374-
if (skipped) notifications.error(`${skipped} macro${skipped===1?'':'s'} skipped (limit ${MAX_TOTAL_MACROS})`);
375-
} finally {
376-
if (fileInputRef.current) fileInputRef.current.value = '';
347+
} catch { errors++; }
348+
}
349+
try {
350+
if (imported.length) {
351+
await saveMacros(normalizeSortOrders(working));
352+
notifications.success(`Imported ${imported.length} macro${imported.length === 1 ? '' : 's'}`);
377353
}
378-
}}
379-
/>
354+
if (errors) notifications.error(`${errors} file${errors === 1 ? '' : 's'} failed`);
355+
if (skipped) notifications.error(`${skipped} macro${skipped === 1 ? '' : 's'} skipped (limit ${MAX_TOTAL_MACROS})`);
356+
} finally {
357+
if (fileInputRef.current) fileInputRef.current.value = '';
358+
}
359+
}} />
380360
<Button
381361
size="SM"
382362
theme="light"

0 commit comments

Comments
 (0)