Skip to content

Commit f75e004

Browse files
authored
🎉 Add buttons to export/import menus
1 parent 84bddc0 commit f75e004

File tree

6 files changed

+213
-1
lines changed

6 files changed

+213
-1
lines changed

locales/en/translation.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@
225225
"empty-collection-note": "Edit the tags above or add a completely new menu to this collection with the button below.",
226226
"create-menu-button": "New menu",
227227
"duplicate-menu": "Duplicate menu",
228+
"export-menu": "Export menu",
229+
"import-menu": "Import menu",
230+
"import-menu-error-title": "Failed to import menu",
231+
"import-menu-error-message": "The selected file could not be imported. It does not contain a valid Kando exported menu.",
228232
"delete-menu": "Delete menu",
229233
"add-menu-item-hint": "Drag this item to add it to the menu above!",
230234
"add-menu-items": "Add Menu Items",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//////////////////////////////////////////////////////////////////////////////////////////
2+
// _ _ ____ _ _ ___ ____ //
3+
// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform //
4+
// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando //
5+
// //
6+
//////////////////////////////////////////////////////////////////////////////////////////
7+
8+
// SPDX-FileCopyrightText: yar2000T <github.com/yar2000T>
9+
// SPDX-License-Identifier: MIT
10+
11+
import * as z from 'zod';
12+
import { version } from './../../../package.json';
13+
14+
import { MENU_ITEM_SCHEMA_V1 } from './menu-settings-v1';
15+
16+
/**
17+
* This type describes the schema of an exported menu. This is used when exporting and
18+
* importing menus via the settings dialog.
19+
*/
20+
export const EXPORTED_MENU_SCHEMA_V1 = z.object({
21+
/**
22+
* The last version of Kando. This is used to determine whether the file needs to be
23+
* migrated to a newer version.
24+
*/
25+
version: z.string().default(version),
26+
27+
/** The actual menu. */
28+
menu: MENU_ITEM_SCHEMA_V1,
29+
});
30+
31+
export type ExportedMenuV1 = z.infer<typeof EXPORTED_MENU_SCHEMA_V1>;

src/common/settings-schemata/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export type {
2020
} from './menu-settings-v1';
2121
export { MENU_SETTINGS_SCHEMA_V1 as MENU_SETTINGS_SCHEMA } from './menu-settings-v1';
2222

23+
export type { ExportedMenuV1 as ExportedMenu } from './exported-menu-v1';
24+
export { EXPORTED_MENU_SCHEMA_V1 as EXPORTED_MENU_SCHEMA } from './exported-menu-v1';
25+
2326
import type { AchievementStatsV1 as AchievementStats } from './achievement-stats-v1';
2427
export type { AchievementStatsV1 as AchievementStats } from './achievement-stats-v1';
2528
export { ACHIEVEMENT_STATS_SCHEMA_V1 as ACHIEVEMENT_STATS_SCHEMA } from './achievement-stats-v1';

src/main/app.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import os from 'node:os';
1212
import fs from 'fs';
13+
import fsExtra from 'fs-extra';
1314
import mime from 'mime-types';
1415
import path from 'path';
1516
import json5 from 'json5';
@@ -41,6 +42,11 @@ import {
4142
tryLoadGeneralSettingsFile,
4243
tryLoadMenuSettingsFile,
4344
} from './settings';
45+
import { MENU_SCHEMA_V1 } from '../common/settings-schemata/menu-settings-v1';
46+
import {
47+
EXPORTED_MENU_SCHEMA_V1,
48+
ExportedMenuV1,
49+
} from '../common/settings-schemata/exported-menu-v1';
4450
import { Notification } from './utils/notification';
4551
import { UpdateChecker } from './utils/update-checker';
4652
import { AchievementTracker } from './achievements/achievement-tracker';
@@ -817,6 +823,114 @@ export class KandoApp {
817823
);
818824
});
819825

826+
// Export a single menu to a JSON file. If no filePath is provided, show a save dialog.
827+
ipcMain.handle(
828+
'settings-window.export-menu',
829+
async (event, menuIndex: number, filePath?: string) => {
830+
const settings = this.menuSettings.get();
831+
832+
if (menuIndex < 0 || menuIndex >= settings.menus.length) {
833+
console.error('Failed to export menu: Invalid menu index.');
834+
return false;
835+
}
836+
837+
// We only export the root menu item (so exported files stay compact and
838+
// don't include local UI flags like centered/anchored/hoverMode). This
839+
// also makes future extensions easier.
840+
const menu = settings.menus[menuIndex];
841+
const menuData: ExportedMenuV1 = {
842+
version: settings.version,
843+
menu: menu.root as MenuItem,
844+
};
845+
846+
try {
847+
let targetPath = filePath;
848+
if (!targetPath) {
849+
const result = await dialog.showSaveDialog(this.settingsWindow, {
850+
defaultPath: `${menu.root.name}.json`,
851+
filters: [{ name: 'JSON', extensions: ['json'] }],
852+
});
853+
854+
if (result.canceled || !result.filePath) {
855+
return false;
856+
}
857+
858+
targetPath = result.filePath;
859+
}
860+
861+
fs.writeFileSync(targetPath, JSON.stringify(menuData, null, 2), 'utf-8');
862+
return true;
863+
} catch (error) {
864+
console.error('Failed to export menu:', error);
865+
await dialog.showMessageBox(this.settingsWindow, {
866+
type: 'error',
867+
title: i18next.t('settings.export-menu-error-title', 'Failed to export menu'),
868+
message: 'Failed to export menu.',
869+
detail: error instanceof Error ? error.message : String(error),
870+
});
871+
return false;
872+
}
873+
}
874+
);
875+
876+
// Import a menu from a JSON file
877+
ipcMain.handle('settings-window.import-menu', async (event, filePath: string) => {
878+
try {
879+
const content = fsExtra.readJsonSync(filePath, 'utf-8');
880+
881+
// Validate the exported file format first (version + root menu item)
882+
const exported = EXPORTED_MENU_SCHEMA_V1.parse(content, { reportInput: true });
883+
884+
// Convert the exported root into a full MENU object so defaults (like
885+
// centered/anchored/hoverMode and shortcut fields) are applied.
886+
const validatedMenu = MENU_SCHEMA_V1.parse(
887+
{ root: exported.menu },
888+
{ reportInput: true }
889+
);
890+
891+
// Add the menu to the settings
892+
const settings = this.menuSettings.get();
893+
const newMenus = [...settings.menus, validatedMenu];
894+
895+
// Update the settings
896+
this.menuSettings.set({ menus: newMenus as Partial<MenuSettings>['menus'] });
897+
898+
return true;
899+
} catch (error) {
900+
console.error('Error importing menu:', error);
901+
902+
let detail = error instanceof Error ? error.message : String(error);
903+
904+
// If this is a schema/validation error, try to extract useful messages.
905+
if (
906+
error &&
907+
(
908+
error as unknown as {
909+
issues?: { path?: (string | number)[]; message: string }[];
910+
}
911+
).issues
912+
) {
913+
const issues = (
914+
error as unknown as {
915+
issues: { path?: (string | number)[]; message: string }[];
916+
}
917+
).issues;
918+
detail = issues
919+
.map((issue) => `${issue.path?.join('.') || '<root>'}: ${issue.message}`)
920+
.join('\n');
921+
}
922+
923+
await dialog.showMessageBox(this.settingsWindow, {
924+
type: 'error',
925+
title: i18next.t('settings.import-menu-error-title'),
926+
message: i18next.t('settings.import-menu-error-message'),
927+
detail,
928+
});
929+
930+
return false;
931+
}
932+
});
933+
820934
// Allow the renderer to retrieve the i18next locales.
821935
ipcMain.handle('common.get-locales', () => {
822936
return {

src/settings-renderer/components/menu-list/MenuList.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@ import React from 'react';
1212
import i18next from 'i18next';
1313
import classNames from 'classnames/bind';
1414
import { useAutoAnimate } from '@formkit/auto-animate/react';
15-
import { TbPlus, TbCopy, TbTrash } from 'react-icons/tb';
15+
import { TbPlus, TbCopy, TbTrash, TbDownload, TbUpload } from 'react-icons/tb';
1616

1717
import * as classes from './MenuList.module.scss';
1818
const cx = classNames.bind(classes);
1919

2020
import { useAppState, useMenuSettings, useMappedMenuProperties } from '../../state';
21+
import { WindowWithAPIs } from '../../settings-window-api';
2122

2223
import { Scrollbox, ThemedIcon, Swirl, Note, Button } from '../common';
2324
import CollectionDetails from './CollectionDetails';
2425
import { ensureUniqueKeys } from '../../utils';
2526

27+
declare const window: WindowWithAPIs;
28+
2629
/** For rendering the menus, a list of these objects is created. */
2730
type RenderedMenu = {
2831
/** A unique key for react. */
@@ -73,6 +76,31 @@ export default function MenuList() {
7376
const moveMenu = useMenuSettings((state) => state.moveMenu);
7477
const moveMenuItem = useMenuSettings((state) => state.moveMenuItem);
7578

79+
// Handle menu export
80+
const handleExportMenu = async () => {
81+
if (selectedMenu < 0 || selectedMenu >= menus.length) {
82+
return;
83+
}
84+
85+
// Let the main process show the save dialog and perform the export.
86+
await window.settingsAPI.exportMenu(selectedMenu);
87+
};
88+
89+
// Handle menu import
90+
const handleImportMenu = async () => {
91+
const result = await window.settingsAPI.openFilePicker({
92+
filters: [{ name: 'JSON', extensions: ['json'] }],
93+
});
94+
95+
if (result) {
96+
const success = await window.settingsAPI.importMenu(result);
97+
if (!success) {
98+
// Import failed; the main process already showed a descriptive error dialog.
99+
console.error('Import failed for file:', result);
100+
}
101+
}
102+
};
103+
76104
// This is set by the search bar in the collection details.
77105
const [filterTerm, setFilterTerm] = React.useState('');
78106

@@ -323,6 +351,22 @@ export default function MenuList() {
323351
duplicateMenu(selectedMenu);
324352
}}
325353
/>
354+
<Button
355+
isGrouped
356+
icon={<TbUpload />}
357+
size="large"
358+
tooltip={i18next.t('settings.export-menu')}
359+
variant="floating"
360+
onClick={handleExportMenu}
361+
/>
362+
<Button
363+
isGrouped
364+
icon={<TbDownload />}
365+
size="large"
366+
tooltip={i18next.t('settings.import-menu')}
367+
variant="floating"
368+
onClick={handleImportMenu}
369+
/>
326370
<Button
327371
isGrouped
328372
icon={<TbTrash />}

src/settings-renderer/settings-window-api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,22 @@ export const SETTINGS_WINDOW_API = {
186186
restoreMenuSettings: () => {
187187
ipcRenderer.send('settings-window.restore-menu-settings');
188188
},
189+
190+
/**
191+
* This will export a single menu to a JSON file. The native save dialog will be shown
192+
* if no file path is given.
193+
*/
194+
exportMenu: (menuIndex: number, filePath?: string): Promise<boolean> => {
195+
return ipcRenderer.invoke('settings-window.export-menu', menuIndex, filePath);
196+
},
197+
198+
/**
199+
* This will import a menu from a JSON file. The handler will show an error dialog on
200+
* failure.
201+
*/
202+
importMenu: (filePath: string): Promise<boolean> => {
203+
return ipcRenderer.invoke('settings-window.import-menu', filePath);
204+
},
189205
};
190206

191207
/**

0 commit comments

Comments
 (0)