@@ -23,13 +23,32 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
2323import LoadingSpinner from "@/components/LoadingSpinner" ;
2424import 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 - z 0 - 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+
3352export 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 - z 0 - 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