|
10 | 10 |
|
11 | 11 | import os from 'node:os'; |
12 | 12 | import fs from 'fs'; |
| 13 | +import fsExtra from 'fs-extra'; |
13 | 14 | import mime from 'mime-types'; |
14 | 15 | import path from 'path'; |
15 | 16 | import json5 from 'json5'; |
@@ -41,6 +42,11 @@ import { |
41 | 42 | tryLoadGeneralSettingsFile, |
42 | 43 | tryLoadMenuSettingsFile, |
43 | 44 | } 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'; |
44 | 50 | import { Notification } from './utils/notification'; |
45 | 51 | import { UpdateChecker } from './utils/update-checker'; |
46 | 52 | import { AchievementTracker } from './achievements/achievement-tracker'; |
@@ -817,6 +823,114 @@ export class KandoApp { |
817 | 823 | ); |
818 | 824 | }); |
819 | 825 |
|
| 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 | + |
820 | 934 | // Allow the renderer to retrieve the i18next locales. |
821 | 935 | ipcMain.handle('common.get-locales', () => { |
822 | 936 | return { |
|
0 commit comments