├── .gitignore
├── .idea
└── .idea.Aictionary
│ └── .idea
│ ├── .gitignore
│ ├── avalonia.xml
│ ├── encodings.xml
│ ├── indexLayout.xml
│ └── vcs.xml
├── .serena
├── .gitignore
└── project.yml
├── Aictionary.sln
├── Aictionary
├── Aictionary.csproj
├── App.axaml
├── App.axaml.cs
├── Assets
│ ├── AppIcon.icns
│ └── avalonia-logo.ico
├── Behaviors
│ └── WordJumpBehavior.cs
├── Converters
│ └── FormNameConverter.cs
├── Helpers
│ └── LocaleHelper.cs
├── Info.plist
├── Models
│ ├── AppSettings.cs
│ ├── Definition.cs
│ ├── DictionaryDownloadSource.cs
│ ├── QueryHistoryEntry.cs
│ ├── WordComparison.cs
│ ├── WordDefinition.cs
│ └── WordForms.cs
├── Program.cs
├── Services
│ ├── DictionaryDownloadService.cs
│ ├── DictionaryResourceService.cs
│ ├── DictionaryService.cs
│ ├── HotkeyService.cs
│ ├── IDictionaryDownloadService.cs
│ ├── IDictionaryResourceService.cs
│ ├── IDictionaryService.cs
│ ├── IHotkeyService.cs
│ ├── IOpenAIService.cs
│ ├── IQueryHistoryService.cs
│ ├── ISettingsService.cs
│ ├── OpenAIService.cs
│ ├── QueryHistoryService.cs
│ ├── QuickQueryService.cs
│ └── SettingsService.cs
├── ViewLocator.cs
├── ViewModels
│ ├── DownloadProgressViewModel.cs
│ ├── MainWindowViewModel.cs
│ ├── SettingsViewModel.cs
│ ├── StatisticsViewModel.cs
│ └── ViewModelBase.cs
├── Views
│ ├── DownloadProgressWindow.axaml
│ ├── DownloadProgressWindow.axaml.cs
│ ├── MainWindow.axaml
│ ├── MainWindow.axaml.cs
│ ├── SettingsWindow.axaml
│ ├── SettingsWindow.axaml.cs
│ ├── StatisticsWindow.axaml
│ └── StatisticsWindow.axaml.cs
└── app.manifest
├── README.md
└── build
├── BUILD.md
├── Build.cs
├── build.csproj
├── build.ps1
└── build.sh
/.gitignore:
--------------------------------------------------------------------------------
1 | bin/
2 | obj/
3 | /packages/
4 | riderModule.iml
5 | /_ReSharper.Caches/
6 | Aictionary.sln.DotSettings.user
7 | .DS_Store
8 | # NUKE Build
9 | artifacts/
10 | .nuke/
11 | build/bin/
12 | build/obj/
13 | .tmp
--------------------------------------------------------------------------------
/.idea/.idea.Aictionary/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Rider ignored files
5 | /contentModel.xml
6 | /modules.xml
7 | /.idea.Aictionary.iml
8 | /projectSettingsUpdater.xml
9 | # Editor-based HTTP Client requests
10 | /httpRequests/
11 | # Datasource local storage ignored files
12 | /dataSources/
13 | /dataSources.local.xml
14 |
--------------------------------------------------------------------------------
/.idea/.idea.Aictionary/.idea/avalonia.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
13 |
--------------------------------------------------------------------------------
/.idea/.idea.Aictionary/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/.idea.Aictionary/.idea/indexLayout.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/.idea.Aictionary/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.serena/.gitignore:
--------------------------------------------------------------------------------
1 | /cache
2 |
--------------------------------------------------------------------------------
/.serena/project.yml:
--------------------------------------------------------------------------------
1 | # language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
2 | # * For C, use cpp
3 | # * For JavaScript, use typescript
4 | # Special requirements:
5 | # * csharp: Requires the presence of a .sln file in the project folder.
6 | language: csharp
7 |
8 | # the encoding used by text files in the project
9 | # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
10 | encoding: "utf-8"
11 |
12 | # whether to use the project's gitignore file to ignore files
13 | # Added on 2025-04-07
14 | ignore_all_files_in_gitignore: true
15 | # list of additional paths to ignore
16 | # same syntax as gitignore, so you can use * and **
17 | # Was previously called `ignored_dirs`, please update your config if you are using that.
18 | # Added (renamed) on 2025-04-07
19 | ignored_paths: []
20 |
21 | # whether the project is in read-only mode
22 | # If set to true, all editing tools will be disabled and attempts to use them will result in an error
23 | # Added on 2025-04-18
24 | read_only: false
25 |
26 | # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
27 | # Below is the complete list of tools for convenience.
28 | # To make sure you have the latest list of tools, and to view their descriptions,
29 | # execute `uv run scripts/print_tool_overview.py`.
30 | #
31 | # * `activate_project`: Activates a project by name.
32 | # * `check_onboarding_performed`: Checks whether project onboarding was already performed.
33 | # * `create_text_file`: Creates/overwrites a file in the project directory.
34 | # * `delete_lines`: Deletes a range of lines within a file.
35 | # * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
36 | # * `execute_shell_command`: Executes a shell command.
37 | # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
38 | # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
39 | # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
40 | # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
41 | # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
42 | # * `initial_instructions`: Gets the initial instructions for the current project.
43 | # Should only be used in settings where the system prompt cannot be set,
44 | # e.g. in clients you have no control over, like Claude Desktop.
45 | # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
46 | # * `insert_at_line`: Inserts content at a given line in a file.
47 | # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
48 | # * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
49 | # * `list_memories`: Lists memories in Serena's project-specific memory store.
50 | # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
51 | # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
52 | # * `read_file`: Reads a file within the project directory.
53 | # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
54 | # * `remove_project`: Removes a project from the Serena configuration.
55 | # * `replace_lines`: Replaces a range of lines within a file with new content.
56 | # * `replace_symbol_body`: Replaces the full definition of a symbol.
57 | # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
58 | # * `search_for_pattern`: Performs a search for a pattern in the project.
59 | # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
60 | # * `switch_modes`: Activates modes by providing a list of their names
61 | # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
62 | # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
63 | # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
64 | # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
65 | excluded_tools: []
66 |
67 | # initial prompt for the project. It will always be given to the LLM upon activating the project
68 | # (contrary to the memories, which are loaded on demand).
69 | initial_prompt: ""
70 |
71 | project_name: "Aictionary"
72 |
--------------------------------------------------------------------------------
/Aictionary.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aictionary", "Aictionary\Aictionary.csproj", "{F713BC49-89A0-492D-AF2D-4B3338BB2C66}"
4 | EndProject
5 | Global
6 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
7 | Debug|Any CPU = Debug|Any CPU
8 | Release|Any CPU = Release|Any CPU
9 | EndGlobalSection
10 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
11 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
12 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Debug|Any CPU.Build.0 = Debug|Any CPU
13 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Release|Any CPU.ActiveCfg = Release|Any CPU
14 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Release|Any CPU.Build.0 = Release|Any CPU
15 | EndGlobalSection
16 | EndGlobal
17 |
--------------------------------------------------------------------------------
/Aictionary/Aictionary.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | WinExe
4 | net8.0
5 | enable
6 | true
7 | app.manifest
8 | true
9 | true
10 | true
11 | false
12 | true
13 | bin\$(Configuration)\$(Platform)\Aictionary.app/Contents/MacOS
14 | false
15 | true
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
43 |
44 | None
45 | All
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | DownloadProgressWindow.axaml
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/Aictionary/App.axaml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/Aictionary/App.axaml.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using Avalonia;
3 | using Avalonia.Controls.ApplicationLifetimes;
4 | using Avalonia.Markup.Xaml;
5 | using Aictionary.Services;
6 | using Aictionary.ViewModels;
7 | using Aictionary.Views;
8 |
9 | namespace Aictionary;
10 |
11 | public partial class App : Application
12 | {
13 | private static ISettingsService? _settingsService;
14 | private static IDictionaryService? _dictionaryService;
15 | private static IOpenAIService? _openAIService;
16 | private static IQueryHistoryService? _queryHistoryService;
17 | private static IHotkeyService? _hotkeyService;
18 | private static QuickQueryService? _quickQueryService;
19 |
20 | public override void Initialize()
21 | {
22 | AvaloniaXamlLoader.Load(this);
23 | }
24 |
25 | public override async void OnFrameworkInitializationCompleted()
26 | {
27 | System.Console.WriteLine("[App] OnFrameworkInitializationCompleted started");
28 |
29 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
30 | {
31 | System.Console.WriteLine("[App] Desktop lifetime detected");
32 |
33 | // Initialize settings service first to get dictionary path
34 | System.Console.WriteLine("[App] Initializing settings service...");
35 | _settingsService = new SettingsService();
36 | await _settingsService.LoadSettingsAsync();
37 |
38 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath;
39 | System.Console.WriteLine($"[App] Dictionary path from settings: {dictionaryPath}");
40 |
41 | // Ensure dictionary exists before initializing other services
42 | var resourceService = new DictionaryResourceService();
43 | System.Console.WriteLine("[App] DictionaryResourceService created");
44 |
45 | var downloadService = new DictionaryDownloadService(resourceService);
46 | System.Console.WriteLine("[App] DictionaryDownloadService created");
47 |
48 | var dictionaryExists = downloadService.DictionaryExists(dictionaryPath);
49 | System.Console.WriteLine($"[App] Dictionary exists: {dictionaryExists}");
50 |
51 | Views.DownloadProgressWindow? downloadWindow = null;
52 | var downloadSucceeded = true;
53 |
54 | if (!dictionaryExists)
55 | {
56 | System.Console.WriteLine("[App] Creating download window");
57 | downloadWindow = new Views.DownloadProgressWindow();
58 |
59 | System.Console.WriteLine("[App] Showing download window");
60 | downloadWindow.Show();
61 |
62 | try
63 | {
64 | System.Console.WriteLine("[App] Starting download...");
65 | var downloadSource = _settingsService.CurrentSettings.DictionaryDownloadSource;
66 | System.Console.WriteLine($"[App] Using download source: {downloadSource}");
67 | await downloadService.EnsureDictionaryExistsAsync(dictionaryPath, downloadSource, (message, progress) =>
68 | {
69 | System.Console.WriteLine($"[App] Progress callback: {message} ({progress}%)");
70 | Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
71 | {
72 | System.Console.WriteLine($"[App] UI Thread updating: {message}");
73 | downloadWindow.ViewModel.StatusMessage = message;
74 | downloadWindow.ViewModel.Progress = progress;
75 | downloadWindow.ViewModel.IsIndeterminate = progress < 10;
76 |
77 | if (progress >= 100)
78 | {
79 | System.Console.WriteLine("[App] Download completed!");
80 | downloadWindow.ViewModel.IsCompleted = true;
81 | }
82 | });
83 | });
84 | System.Console.WriteLine("[App] Download finished successfully");
85 | }
86 | catch (System.Exception ex)
87 | {
88 | downloadSucceeded = false;
89 | System.Console.WriteLine($"[App] Download ERROR: {ex.GetType().Name}: {ex.Message}");
90 | System.Console.WriteLine($"[App] Stack trace: {ex.StackTrace}");
91 | await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
92 | {
93 | downloadWindow.ViewModel.StatusMessage = $"Error: {ex.Message}";
94 | downloadWindow.ViewModel.IsCompleted = true;
95 | downloadWindow.ViewModel.IsIndeterminate = false;
96 | downloadWindow.ViewModel.Progress = 0;
97 | });
98 |
99 | // Keep the error window open and wait for user to close it
100 | System.Console.WriteLine("[App] Download failed. Window will remain open for user to read error and close manually.");
101 | return;
102 | }
103 | }
104 |
105 | System.Console.WriteLine("[App] Initializing other services...");
106 |
107 | _dictionaryService = new DictionaryService(_settingsService);
108 | _openAIService = new OpenAIService(_settingsService);
109 | _queryHistoryService = new QueryHistoryService();
110 | _hotkeyService = new HotkeyService();
111 | _quickQueryService = new QuickQueryService(_hotkeyService, _settingsService);
112 |
113 | System.Console.WriteLine("[App] Initializing quick query service...");
114 | _quickQueryService.Initialize();
115 |
116 | System.Console.WriteLine("[App] Creating main window");
117 | desktop.MainWindow = new MainWindow
118 | {
119 | DataContext = new MainWindowViewModel(_dictionaryService, _openAIService, _settingsService, _queryHistoryService),
120 | };
121 | System.Console.WriteLine("[App] Main window created");
122 |
123 | // Show main window first, then close download window to prevent app exit
124 | if (downloadWindow != null)
125 | {
126 | System.Console.WriteLine("[App] Showing main window and closing download window");
127 | await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
128 | {
129 | desktop.MainWindow.Show();
130 | System.Console.WriteLine("[App] Main window shown, now closing download window");
131 | downloadWindow.Close();
132 | System.Console.WriteLine("[App] Download window closed");
133 | });
134 | }
135 | else
136 | {
137 | // No download window, just show main window normally
138 | desktop.MainWindow.Show();
139 | }
140 | }
141 |
142 | base.OnFrameworkInitializationCompleted();
143 | System.Console.WriteLine("[App] OnFrameworkInitializationCompleted finished");
144 | }
145 |
146 | public static SettingsWindow CreateSettingsWindow()
147 | {
148 | if (_settingsService == null || _dictionaryService == null || _openAIService == null)
149 | {
150 | throw new System.InvalidOperationException("Services not initialized");
151 | }
152 |
153 | return new SettingsWindow
154 | {
155 | DataContext = new SettingsViewModel(_settingsService, _dictionaryService, _openAIService, _hotkeyService, _quickQueryService)
156 | };
157 | }
158 |
159 | public static StatisticsWindow CreateStatisticsWindow()
160 | {
161 | if (_queryHistoryService == null)
162 | {
163 | throw new System.InvalidOperationException("Services not initialized");
164 | }
165 |
166 | return new StatisticsWindow
167 | {
168 | DataContext = new StatisticsViewModel(_queryHistoryService)
169 | };
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/Aictionary/Assets/AppIcon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahpxex/Aictionary/19fc436d2a92581b087d4ce5fe7677637d9dfda2/Aictionary/Assets/AppIcon.icns
--------------------------------------------------------------------------------
/Aictionary/Assets/avalonia-logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ahpxex/Aictionary/19fc436d2a92581b087d4ce5fe7677637d9dfda2/Aictionary/Assets/avalonia-logo.ico
--------------------------------------------------------------------------------
/Aictionary/Behaviors/WordJumpBehavior.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Text.RegularExpressions;
3 | using Avalonia;
4 | using Avalonia.Controls;
5 | using Avalonia.Input;
6 | using Avalonia.Interactivity;
7 | using Avalonia.Media;
8 | using Aictionary.ViewModels;
9 | using Avalonia.VisualTree;
10 |
11 | namespace Aictionary.Behaviors;
12 |
13 | public static class WordJumpBehavior
14 | {
15 | public static readonly AttachedProperty IsEnabledProperty =
16 | AvaloniaProperty.RegisterAttached("IsEnabled", typeof(WordJumpBehavior));
17 |
18 | public static bool GetIsEnabled(SelectableTextBlock element)
19 | {
20 | return element.GetValue(IsEnabledProperty);
21 | }
22 |
23 | public static void SetIsEnabled(SelectableTextBlock element, bool value)
24 | {
25 | element.SetValue(IsEnabledProperty, value);
26 | }
27 |
28 | static WordJumpBehavior()
29 | {
30 | IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged);
31 | }
32 |
33 | private static void OnIsEnabledChanged(SelectableTextBlock textBlock, AvaloniaPropertyChangedEventArgs e)
34 | {
35 | if (e.NewValue is true)
36 | {
37 | textBlock.PointerMoved += OnPointerMoved;
38 | textBlock.PointerExited += OnPointerExited;
39 | // Use AddHandler with handledEventsToo=true to receive events even if SelectableTextBlock handled them
40 | textBlock.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, handledEventsToo: true);
41 | }
42 | else
43 | {
44 | textBlock.PointerMoved -= OnPointerMoved;
45 | textBlock.PointerExited -= OnPointerExited;
46 | textBlock.RemoveHandler(InputElement.PointerPressedEvent, OnPointerPressed);
47 | }
48 | }
49 |
50 | private static bool _isModifierPressed = false;
51 |
52 | private static void OnPointerMoved(object? sender, PointerEventArgs e)
53 | {
54 | if (sender is not SelectableTextBlock textBlock) return;
55 |
56 | var keyModifiers = e.KeyModifiers;
57 | var isCmdOrCtrlPressed = keyModifiers.HasFlag(KeyModifiers.Meta) || keyModifiers.HasFlag(KeyModifiers.Control);
58 |
59 | if (isCmdOrCtrlPressed)
60 | {
61 | _isModifierPressed = true;
62 | textBlock.Cursor = new Cursor(StandardCursorType.Hand);
63 | textBlock.TextDecorations = TextDecorations.Underline;
64 | }
65 | else
66 | {
67 | _isModifierPressed = false;
68 | textBlock.Cursor = Cursor.Default;
69 | textBlock.TextDecorations = null;
70 | }
71 | }
72 |
73 | private static void OnPointerExited(object? sender, PointerEventArgs e)
74 | {
75 | if (sender is not SelectableTextBlock textBlock) return;
76 |
77 | textBlock.Cursor = Cursor.Default;
78 | textBlock.TextDecorations = null;
79 | }
80 |
81 | private static void OnPointerPressed(object? sender, PointerPressedEventArgs e)
82 | {
83 | if (sender is not SelectableTextBlock textBlock) return;
84 |
85 | var keyModifiers = e.KeyModifiers;
86 | var isCmdOrCtrlPressed = keyModifiers.HasFlag(KeyModifiers.Meta) || keyModifiers.HasFlag(KeyModifiers.Control);
87 |
88 | if (!isCmdOrCtrlPressed) return;
89 |
90 | var point = e.GetCurrentPoint(textBlock);
91 | if (!point.Properties.IsLeftButtonPressed) return;
92 |
93 | // Get the word at the click position
94 | var word = GetWordAtPosition(textBlock);
95 | if (string.IsNullOrWhiteSpace(word)) return;
96 |
97 | // Only jump if it's an English word (contains only ASCII letters)
98 | if (!IsEnglishWord(word)) return;
99 |
100 | // Find the MainWindow and trigger search
101 | var window = GetParentWindow(textBlock);
102 | if (window?.DataContext is MainWindowViewModel viewModel)
103 | {
104 | viewModel.SearchText = word;
105 | viewModel.SearchCommand.Execute().Subscribe();
106 | }
107 |
108 | e.Handled = true;
109 | }
110 |
111 | private static string? GetWordAtPosition(SelectableTextBlock textBlock)
112 | {
113 | // If there's a selection, use the selected text
114 | var selectedText = textBlock.SelectedText;
115 | if (!string.IsNullOrWhiteSpace(selectedText))
116 | {
117 | // Extract the first English word from selection
118 | var words = Regex.Matches(selectedText, @"\b[a-zA-Z]+\b");
119 | if (words.Count > 0)
120 | {
121 | return words[0].Value;
122 | }
123 | }
124 |
125 | // Otherwise, use the entire text (for comparison words)
126 | var text = textBlock.Text;
127 | if (string.IsNullOrWhiteSpace(text)) return null;
128 |
129 | // If the entire text is a single English word, return it
130 | text = text.Trim();
131 | if (IsEnglishWord(text))
132 | {
133 | return text;
134 | }
135 |
136 | // Otherwise, extract the first English word from the text
137 | var wordMatches = Regex.Matches(text, @"\b[a-zA-Z]+\b");
138 | if (wordMatches.Count > 0)
139 | {
140 | return wordMatches[0].Value;
141 | }
142 |
143 | return null;
144 | }
145 |
146 | private static bool IsEnglishWord(string word)
147 | {
148 | // Check if the word contains only ASCII letters
149 | return Regex.IsMatch(word, @"^[a-zA-Z]+$");
150 | }
151 |
152 | private static Window? GetParentWindow(Visual? visual)
153 | {
154 | while (visual != null)
155 | {
156 | if (visual is Window window)
157 | return window;
158 | visual = visual.GetVisualParent();
159 | }
160 | return null;
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/Aictionary/Converters/FormNameConverter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 | using Avalonia.Data.Converters;
4 |
5 | namespace Aictionary.Converters;
6 |
7 | public class FormNameConverter : IValueConverter
8 | {
9 | public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
10 | {
11 | if (value is not string formName)
12 | return value;
13 |
14 | // Convert snake_case to Title Case
15 | // e.g., "third_person_singular" -> "Third Person Singular"
16 | var words = formName.Split('_');
17 | for (int i = 0; i < words.Length; i++)
18 | {
19 | if (words[i].Length > 0)
20 | {
21 | words[i] = char.ToUpper(words[i][0]) + words[i].Substring(1);
22 | }
23 | }
24 | return string.Join(" ", words);
25 | }
26 |
27 | public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
28 | {
29 | // ConvertBack is not needed for one-way display bindings
30 | return value;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Aictionary/Helpers/LocaleHelper.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 | using Aictionary.Models;
4 |
5 | namespace Aictionary.Helpers;
6 |
7 | public static class LocaleHelper
8 | {
9 | public static DictionaryDownloadSource GetDefaultDownloadSource()
10 | {
11 | try
12 | {
13 | var currentCulture = CultureInfo.CurrentCulture;
14 | var timeZone = TimeZoneInfo.Local;
15 |
16 | // Check if system is in Simplified Chinese and UTC+8
17 | var isSimplifiedChinese = currentCulture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase) ||
18 | currentCulture.Name.Equals("zh-Hans", StringComparison.OrdinalIgnoreCase);
19 |
20 | var isUtcPlus8 = timeZone.BaseUtcOffset.TotalHours == 8;
21 |
22 | if (isSimplifiedChinese && isUtcPlus8)
23 | {
24 | return DictionaryDownloadSource.Gitee;
25 | }
26 | }
27 | catch (Exception ex)
28 | {
29 | Console.WriteLine($"[LocaleHelper] Error detecting locale: {ex.Message}");
30 | }
31 |
32 | return DictionaryDownloadSource.GitHub;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Aictionary/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleName
6 | Aictionary
7 | CFBundleDisplayName
8 | Aictionary
9 | CFBundleIdentifier
10 | com.ahpx.aictionary
11 | CFBundleVersion
12 | 1.0.0
13 | CFBundleShortVersionString
14 | 1.0.0
15 | CFBundleExecutable
16 | Aictionary
17 | CFBundlePackageType
18 | APPL
19 | CFBundleSignature
20 | ????
21 | CFBundleSupportedPlatforms
22 |
23 | MacOSX
24 |
25 | LSMinimumSystemVersion
26 | 11.0
27 | NSPrincipalClass
28 | NSApplication
29 | NSHighResolutionCapable
30 |
31 | CFBundleIconFile
32 | AppIcon.icns
33 |
34 |
35 |
--------------------------------------------------------------------------------
/Aictionary/Models/AppSettings.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 | using Aictionary.Helpers;
3 |
4 | namespace Aictionary.Models;
5 |
6 | public class AppSettings
7 | {
8 | [JsonPropertyName("api_base_url")]
9 | public string ApiBaseUrl { get; set; } = "https://api.openai.com/v1";
10 |
11 | [JsonPropertyName("api_key")]
12 | public string ApiKey { get; set; } = string.Empty;
13 |
14 | [JsonPropertyName("model")]
15 | public string Model { get; set; } = "gpt-4o-mini";
16 |
17 | [JsonPropertyName("dictionary_path")]
18 | public string DictionaryPath { get; set; } = string.Empty;
19 |
20 | [JsonPropertyName("quick_query_hotkey")]
21 | public string QuickQueryHotkey { get; set; } = "Command+Shift+D";
22 |
23 | [JsonPropertyName("dictionary_download_source")]
24 | public DictionaryDownloadSource DictionaryDownloadSource { get; set; } = LocaleHelper.GetDefaultDownloadSource();
25 | }
26 |
--------------------------------------------------------------------------------
/Aictionary/Models/Definition.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Aictionary.Models;
4 |
5 | public class Definition
6 | {
7 | [JsonPropertyName("pos")]
8 | public string Pos { get; set; } = string.Empty;
9 |
10 | [JsonPropertyName("explanation_en")]
11 | public string ExplanationEn { get; set; } = string.Empty;
12 |
13 | [JsonPropertyName("explanation_cn")]
14 | public string ExplanationCn { get; set; } = string.Empty;
15 |
16 | [JsonPropertyName("example_en")]
17 | public string ExampleEn { get; set; } = string.Empty;
18 |
19 | [JsonPropertyName("example_cn")]
20 | public string ExampleCn { get; set; } = string.Empty;
21 | }
22 |
--------------------------------------------------------------------------------
/Aictionary/Models/DictionaryDownloadSource.cs:
--------------------------------------------------------------------------------
1 | namespace Aictionary.Models;
2 |
3 | public enum DictionaryDownloadSource
4 | {
5 | GitHub,
6 | Gitee
7 | }
8 |
--------------------------------------------------------------------------------
/Aictionary/Models/QueryHistoryEntry.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace Aictionary.Models;
5 |
6 | public class QueryHistoryEntry
7 | {
8 | [JsonPropertyName("word")]
9 | public string Word { get; set; } = string.Empty;
10 |
11 | [JsonPropertyName("queried_at")]
12 | public DateTime QueriedAt { get; set; }
13 |
14 | [JsonPropertyName("concise_definition")]
15 | public string? ConciseDefinition { get; set; }
16 | }
17 |
--------------------------------------------------------------------------------
/Aictionary/Models/WordComparison.cs:
--------------------------------------------------------------------------------
1 | using System.Text.Json.Serialization;
2 |
3 | namespace Aictionary.Models;
4 |
5 | public class WordComparison
6 | {
7 | [JsonPropertyName("word_to_compare")]
8 | public string WordToCompare { get; set; } = string.Empty;
9 |
10 | [JsonPropertyName("analysis")]
11 | public string Analysis { get; set; } = string.Empty;
12 | }
13 |
--------------------------------------------------------------------------------
/Aictionary/Models/WordDefinition.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace Aictionary.Models;
5 |
6 | public class WordDefinition
7 | {
8 | [JsonPropertyName("word")]
9 | public string Word { get; set; } = string.Empty;
10 |
11 | [JsonPropertyName("pronunciation")]
12 | public string Pronunciation { get; set; } = string.Empty;
13 |
14 | [JsonPropertyName("concise_definition")]
15 | public string ConciseDefinition { get; set; } = string.Empty;
16 |
17 | [JsonPropertyName("forms")]
18 | public WordForms? Forms { get; set; }
19 |
20 | [JsonPropertyName("definitions")]
21 | public List Definitions { get; set; } = new();
22 |
23 | [JsonPropertyName("comparison")]
24 | public List Comparison { get; set; } = new();
25 | }
26 |
--------------------------------------------------------------------------------
/Aictionary/Models/WordForms.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using System.Text.Json;
4 |
5 | namespace Aictionary.Models;
6 |
7 | public class WordForms : Dictionary
8 | {
9 | // Helper method to get value as string (for single values)
10 | public string? GetString(string key)
11 | {
12 | if (!TryGetValue(key, out var value))
13 | return null;
14 |
15 | if (value is JsonElement element)
16 | {
17 | if (element.ValueKind == JsonValueKind.String)
18 | return element.GetString();
19 | if (element.ValueKind == JsonValueKind.Array)
20 | return string.Join(", ", element.EnumerateArray().Select(e => e.GetString() ?? ""));
21 | }
22 |
23 | return value?.ToString();
24 | }
25 |
26 | // Helper method to get value as string array (for array values like variant)
27 | public List? GetStringList(string key)
28 | {
29 | if (!TryGetValue(key, out var value))
30 | return null;
31 |
32 | if (value is JsonElement element)
33 | {
34 | if (element.ValueKind == JsonValueKind.Array)
35 | return element.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
36 | if (element.ValueKind == JsonValueKind.String)
37 | return new List { element.GetString() ?? "" };
38 | }
39 |
40 | if (value is string str)
41 | return new List { str };
42 |
43 | return null;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Aictionary/Program.cs:
--------------------------------------------------------------------------------
1 | using Avalonia;
2 | using Avalonia.ReactiveUI;
3 | using System;
4 |
5 | namespace Aictionary;
6 |
7 | sealed class Program
8 | {
9 | // Initialization code. Don't use any Avalonia, third-party APIs or any
10 | // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
11 | // yet and stuff might break.
12 | [STAThread]
13 | public static void Main(string[] args) => BuildAvaloniaApp()
14 | .StartWithClassicDesktopLifetime(args);
15 |
16 | // Avalonia configuration, don't remove; also used by visual designer.
17 | public static AppBuilder BuildAvaloniaApp()
18 | => AppBuilder.Configure()
19 | .UsePlatformDetect()
20 | .WithInterFont()
21 | .LogToTrace()
22 | .UseReactiveUI();
23 | }
--------------------------------------------------------------------------------
/Aictionary/Services/DictionaryDownloadService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.IO.Compression;
5 | using System.Net.Http;
6 | using System.Threading.Tasks;
7 | using Aictionary.Models;
8 |
9 | namespace Aictionary.Services;
10 |
11 | public class DictionaryDownloadService : IDictionaryDownloadService
12 | {
13 | private readonly IDictionaryResourceService _resourceService;
14 | private readonly HttpClient _httpClient;
15 | private const int MinimumDictionaryFiles = 20000;
16 |
17 | public DictionaryDownloadService(IDictionaryResourceService resourceService)
18 | {
19 | Console.WriteLine("[DictionaryDownloadService] Constructor called");
20 | _resourceService = resourceService;
21 | _httpClient = new HttpClient();
22 | }
23 |
24 | public bool DictionaryExists(string dictionaryPath)
25 | {
26 | Console.WriteLine($"[DictionaryDownloadService] DictionaryExists check for path: {dictionaryPath}");
27 |
28 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath))
29 | {
30 | Console.WriteLine("[DictionaryDownloadService] Path is empty or doesn't exist");
31 | return false;
32 | }
33 |
34 | try
35 | {
36 | var fileCount = Directory.GetFiles(dictionaryPath, "*.json").Length;
37 | var isValid = fileCount >= MinimumDictionaryFiles;
38 | Console.WriteLine($"[DictionaryDownloadService] Found {fileCount} files, minimum required: {MinimumDictionaryFiles}, valid: {isValid}");
39 | return isValid;
40 | }
41 | catch (Exception ex)
42 | {
43 | Console.WriteLine($"[DictionaryDownloadService] Error checking dictionary: {ex.Message}");
44 | return false;
45 | }
46 | }
47 |
48 | public async Task EnsureDictionaryExistsAsync(string dictionaryPath, DictionaryDownloadSource downloadSource, Action? progressCallback = null)
49 | {
50 | Console.WriteLine("[DictionaryDownloadService] EnsureDictionaryExistsAsync called");
51 | Console.WriteLine($"[DictionaryDownloadService] Dictionary path: {dictionaryPath}");
52 | Console.WriteLine($"[DictionaryDownloadService] Download source: {downloadSource}");
53 |
54 | if (string.IsNullOrEmpty(dictionaryPath))
55 | {
56 | var errorMessage = "Dictionary path is not configured.";
57 | Console.WriteLine($"[DictionaryDownloadService] {errorMessage}");
58 | progressCallback?.Invoke(errorMessage, 0);
59 | throw new InvalidOperationException(errorMessage);
60 | }
61 |
62 | // Check if directory exists and has enough files
63 | if (Directory.Exists(dictionaryPath))
64 | {
65 | try
66 | {
67 | var fileCount = Directory.GetFiles(dictionaryPath, "*.json").Length;
68 | Console.WriteLine($"[DictionaryDownloadService] Found {fileCount} files in existing directory");
69 |
70 | if (fileCount >= MinimumDictionaryFiles)
71 | {
72 | Console.WriteLine("[DictionaryDownloadService] Dictionary already exists with sufficient files, skipping download");
73 | progressCallback?.Invoke("Dictionary already exists. No download needed.", 100);
74 | return;
75 | }
76 |
77 | // Directory exists but doesn't have enough files - it's broken
78 | Console.WriteLine($"[DictionaryDownloadService] Dictionary is broken (only {fileCount} files, need {MinimumDictionaryFiles}), removing...");
79 | progressCallback?.Invoke($"Dictionary incomplete ({fileCount} files). Removing and re-downloading...", 5);
80 | Directory.Delete(dictionaryPath, true);
81 | }
82 | catch (Exception ex)
83 | {
84 | Console.WriteLine($"[DictionaryDownloadService] Error checking/removing existing directory: {ex.Message}");
85 | progressCallback?.Invoke("Error checking dictionary. Re-downloading...", 5);
86 | try
87 | {
88 | Directory.Delete(dictionaryPath, true);
89 | }
90 | catch
91 | {
92 | // Ignore deletion errors
93 | }
94 | }
95 | }
96 |
97 | Console.WriteLine("[DictionaryDownloadService] Starting download...");
98 | await DownloadAndExtractDictionaryAsync(dictionaryPath, downloadSource, progressCallback);
99 | }
100 |
101 | private static readonly HashSet WindowsReservedNames = new HashSet(StringComparer.OrdinalIgnoreCase)
102 | {
103 | "CON", "PRN", "AUX", "NUL",
104 | "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
105 | "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
106 | };
107 |
108 | private static bool IsWindowsReservedName(string name)
109 | {
110 | if (string.IsNullOrEmpty(name))
111 | return false;
112 |
113 | // Check if the name (without extension) is reserved
114 | var nameWithoutExtension = Path.GetFileNameWithoutExtension(name);
115 | return WindowsReservedNames.Contains(nameWithoutExtension);
116 | }
117 |
118 | private async Task DownloadAndExtractDictionaryAsync(string dictionaryPath, DictionaryDownloadSource downloadSource, Action? progressCallback)
119 | {
120 | Console.WriteLine("[DictionaryDownloadService] DownloadAndExtractDictionaryAsync started");
121 | Console.WriteLine($"[DictionaryDownloadService] Dictionary path: {dictionaryPath}");
122 | Console.WriteLine($"[DictionaryDownloadService] Download source: {downloadSource}");
123 | Console.WriteLine($"[DictionaryDownloadService] progressCallback is null: {progressCallback == null}");
124 |
125 | var parentDirectory = Directory.GetParent(dictionaryPath)?.FullName ?? Directory.GetCurrentDirectory();
126 | var tempZipPath = Path.Combine(parentDirectory, "dictionary_temp.zip");
127 |
128 | try
129 | {
130 | Console.WriteLine("[DictionaryDownloadService] Invoking progress callback: Fetching download URL...");
131 | progressCallback?.Invoke("Fetching download URL...", 0);
132 |
133 | Console.WriteLine("[DictionaryDownloadService] Calling GetDictionaryDownloadUrlAsync...");
134 | var downloadUrl = await _resourceService.GetDictionaryDownloadUrlAsync(downloadSource);
135 | Console.WriteLine($"[DictionaryDownloadService] Download URL: {downloadUrl}");
136 |
137 | Console.WriteLine($"[DictionaryDownloadService] Temp zip path: {tempZipPath}");
138 |
139 | Console.WriteLine("[DictionaryDownloadService] Invoking progress callback: Downloading dictionary...");
140 | progressCallback?.Invoke("Downloading dictionary...", 5);
141 |
142 | Console.WriteLine("[DictionaryDownloadService] Starting HTTP request...");
143 | using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead);
144 | Console.WriteLine($"[DictionaryDownloadService] HTTP response status: {response.StatusCode}");
145 |
146 | if (!response.IsSuccessStatusCode)
147 | {
148 | throw new HttpRequestException($"Failed to download dictionary. Server returned status code: {response.StatusCode}");
149 | }
150 |
151 | var totalBytes = response.Content.Headers.ContentLength ?? -1;
152 | var canReportProgress = totalBytes != -1;
153 | Console.WriteLine($"[DictionaryDownloadService] Total bytes: {totalBytes}, Can report progress: {canReportProgress}");
154 |
155 | if (!canReportProgress)
156 | {
157 | progressCallback?.Invoke("Downloading dictionary (size unknown)...", 5);
158 | }
159 |
160 | Console.WriteLine("[DictionaryDownloadService] Starting file download...");
161 | await using (var fileStream = new FileStream(tempZipPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920))
162 | {
163 | await using var contentStream = await response.Content.ReadAsStreamAsync();
164 | var buffer = new byte[81920]; // 80KB buffer for better performance
165 | var totalRead = 0L;
166 | int bytesRead;
167 | var lastProgressUpdate = 0L;
168 |
169 | while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
170 | {
171 | await fileStream.WriteAsync(buffer, 0, bytesRead);
172 | totalRead += bytesRead;
173 |
174 | // Update progress more frequently (every 500KB) to avoid appearing stuck
175 | if (canReportProgress && (totalRead - lastProgressUpdate) >= (512 * 1024))
176 | {
177 | lastProgressUpdate = totalRead;
178 | var downloadedMB = totalRead / (1024.0 * 1024);
179 | var totalMB = totalBytes / (1024.0 * 1024);
180 | var progress = 5 + (totalRead * 75.0 / totalBytes);
181 |
182 | Console.WriteLine($"[DictionaryDownloadService] Downloaded: {downloadedMB:F1} MB / {totalMB:F1} MB ({progress:F1}%)");
183 | progressCallback?.Invoke($"Downloading: {downloadedMB:F1} MB / {totalMB:F1} MB", progress);
184 | }
185 | else if (!canReportProgress && (totalRead - lastProgressUpdate) >= (5 * 1024 * 1024))
186 | {
187 | lastProgressUpdate = totalRead;
188 | var downloadedMB = totalRead / (1024.0 * 1024);
189 | Console.WriteLine($"[DictionaryDownloadService] Downloaded: {downloadedMB:F1} MB");
190 | progressCallback?.Invoke($"Downloading: {downloadedMB:F1} MB", 50);
191 | }
192 | }
193 |
194 | Console.WriteLine($"[DictionaryDownloadService] Download complete. Total read: {totalRead} bytes ({totalRead / (1024.0 * 1024):F1} MB)");
195 | progressCallback?.Invoke($"Download complete ({totalRead / (1024.0 * 1024):F1} MB)", 80);
196 | }
197 |
198 | // Ensure the directory doesn't exist before extraction
199 | if (Directory.Exists(dictionaryPath))
200 | {
201 | Console.WriteLine("[DictionaryDownloadService] Removing existing dictionary directory before extraction...");
202 | progressCallback?.Invoke("Preparing extraction...", 82);
203 | Directory.Delete(dictionaryPath, true);
204 | }
205 |
206 | Console.WriteLine("[DictionaryDownloadService] Starting extraction...");
207 | progressCallback?.Invoke("Extracting dictionary files...", 85);
208 |
209 | try
210 | {
211 | // Extract with overwrite support for Windows
212 | using (var archive = System.IO.Compression.ZipFile.OpenRead(tempZipPath))
213 | {
214 | var totalEntries = archive.Entries.Count;
215 | var extractedEntries = 0;
216 | var skippedEntries = 0;
217 | var lastProgressUpdate = 0;
218 |
219 | Console.WriteLine($"[DictionaryDownloadService] Extracting {totalEntries} files...");
220 |
221 | foreach (var entry in archive.Entries)
222 | {
223 | // Check for Windows reserved names in the path
224 | var pathParts = entry.FullName.Split('/', '\\');
225 | var hasReservedName = false;
226 |
227 | foreach (var part in pathParts)
228 | {
229 | if (IsWindowsReservedName(part))
230 | {
231 | hasReservedName = true;
232 | Console.WriteLine($"[DictionaryDownloadService] Skipping entry with Windows reserved name: {entry.FullName}");
233 | skippedEntries++;
234 | break;
235 | }
236 | }
237 |
238 | if (hasReservedName)
239 | {
240 | extractedEntries++;
241 | continue;
242 | }
243 |
244 | var destinationPath = Path.Combine(parentDirectory, entry.FullName);
245 |
246 | // Create directory if it's a directory entry
247 | if (string.IsNullOrEmpty(entry.Name))
248 | {
249 | try
250 | {
251 | Directory.CreateDirectory(destinationPath);
252 | }
253 | catch (Exception ex)
254 | {
255 | Console.WriteLine($"[DictionaryDownloadService] Failed to create directory {destinationPath}: {ex.Message}");
256 | skippedEntries++;
257 | }
258 | }
259 | else
260 | {
261 | try
262 | {
263 | // Ensure parent directory exists
264 | var destinationDir = Path.GetDirectoryName(destinationPath);
265 | if (!string.IsNullOrEmpty(destinationDir))
266 | {
267 | Directory.CreateDirectory(destinationDir);
268 | }
269 |
270 | // Extract file with overwrite
271 | entry.ExtractToFile(destinationPath, overwrite: true);
272 | }
273 | catch (Exception ex)
274 | {
275 | Console.WriteLine($"[DictionaryDownloadService] Failed to extract {entry.FullName}: {ex.Message}");
276 | skippedEntries++;
277 | }
278 | }
279 |
280 | extractedEntries++;
281 |
282 | // Update progress every 5% of files
283 | var progressPercent = (extractedEntries * 100) / totalEntries;
284 | if (progressPercent - lastProgressUpdate >= 5)
285 | {
286 | lastProgressUpdate = progressPercent;
287 | var extractionProgress = 85 + (progressPercent * 0.14); // 85% to 99%
288 | Console.WriteLine($"[DictionaryDownloadService] Extracted {extractedEntries}/{totalEntries} files ({progressPercent}%)");
289 | progressCallback?.Invoke($"Extracting: {extractedEntries}/{totalEntries} files ({progressPercent}%)", extractionProgress);
290 | }
291 | }
292 |
293 | if (skippedEntries > 0)
294 | {
295 | Console.WriteLine($"[DictionaryDownloadService] Skipped {skippedEntries} problematic files during extraction");
296 | }
297 | }
298 |
299 | Console.WriteLine("[DictionaryDownloadService] Extraction complete");
300 | progressCallback?.Invoke("Verifying installation...", 99);
301 |
302 | // Verify extraction succeeded
303 | if (!Directory.Exists(dictionaryPath))
304 | {
305 | throw new InvalidOperationException("Extraction failed: Dictionary directory not found after extraction. The archive may not contain the expected 'dictionary' folder.");
306 | }
307 |
308 | var fileCount = Directory.GetFiles(dictionaryPath, "*.json").Length;
309 | Console.WriteLine($"[DictionaryDownloadService] Verification: Found {fileCount} dictionary files");
310 |
311 | if (fileCount < MinimumDictionaryFiles)
312 | {
313 | throw new InvalidOperationException($"Extraction incomplete: Only {fileCount} files found, expected at least {MinimumDictionaryFiles}. Please try downloading again.");
314 | }
315 |
316 | Console.WriteLine("[DictionaryDownloadService] Installation complete and verified");
317 | progressCallback?.Invoke("Download complete!", 100);
318 | }
319 | catch (UnauthorizedAccessException ex)
320 | {
321 | Console.WriteLine($"[DictionaryDownloadService] Access denied during extraction: {ex.Message}");
322 | throw new InvalidOperationException($"Cannot extract files - access denied. Please run the application as administrator or choose a different dictionary location. Error: {ex.Message}", ex);
323 | }
324 | catch (IOException ex)
325 | {
326 | Console.WriteLine($"[DictionaryDownloadService] IO error during extraction: {ex.Message}");
327 | throw new InvalidOperationException($"File system error during extraction. Please ensure you have enough disk space and the dictionary path is accessible. Error: {ex.Message}", ex);
328 | }
329 | }
330 | catch (HttpRequestException ex)
331 | {
332 | Console.WriteLine($"[DictionaryDownloadService] Network error: {ex.Message}");
333 | throw new InvalidOperationException($"Network error while downloading dictionary. Please check your internet connection and try again. Error: {ex.Message}", ex);
334 | }
335 | catch (TaskCanceledException ex)
336 | {
337 | Console.WriteLine($"[DictionaryDownloadService] Download timeout: {ex.Message}");
338 | throw new InvalidOperationException($"Download timed out. Please check your internet connection and try again. Error: {ex.Message}", ex);
339 | }
340 | catch (Exception ex) when (ex is not InvalidOperationException)
341 | {
342 | Console.WriteLine($"[DictionaryDownloadService] Unexpected error: {ex.GetType().Name}: {ex.Message}");
343 | Console.WriteLine($"[DictionaryDownloadService] Stack trace: {ex.StackTrace}");
344 | throw new InvalidOperationException($"Unexpected error during dictionary download: {ex.Message}. Please try again or contact support if the problem persists.", ex);
345 | }
346 | finally
347 | {
348 | if (File.Exists(tempZipPath))
349 | {
350 | try
351 | {
352 | Console.WriteLine("[DictionaryDownloadService] Deleting temp zip file");
353 | File.Delete(tempZipPath);
354 | }
355 | catch (Exception ex)
356 | {
357 | Console.WriteLine($"[DictionaryDownloadService] Failed to delete temp file: {ex.Message}");
358 | }
359 | }
360 | }
361 | }
362 | }
363 |
--------------------------------------------------------------------------------
/Aictionary/Services/DictionaryResourceService.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using Aictionary.Models;
3 |
4 | namespace Aictionary.Services;
5 |
6 | public class DictionaryResourceService : IDictionaryResourceService
7 | {
8 | private const string GitHubDictionaryUrl = "https://github.com/ahpxex/open-english-dictionary/releases/download/v1.1/open-english-dictionary.zip";
9 | private const string GiteeDictionaryUrl = "https://gitee.com/fanxiao25/open-english-dictionary/releases/download/v1.1/open-english-dictionary.zip";
10 |
11 | public Task GetDictionaryDownloadUrlAsync(DictionaryDownloadSource source = DictionaryDownloadSource.GitHub)
12 | {
13 | var url = source switch
14 | {
15 | DictionaryDownloadSource.Gitee => GiteeDictionaryUrl,
16 | _ => GitHubDictionaryUrl
17 | };
18 |
19 | return Task.FromResult(url);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aictionary/Services/DictionaryService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Text.Json;
6 | using System.Threading.Tasks;
7 | using Aictionary.Models;
8 |
9 | namespace Aictionary.Services;
10 |
11 | public class DictionaryService : IDictionaryService
12 | {
13 | private readonly ISettingsService _settingsService;
14 | private readonly JsonSerializerOptions _jsonOptions;
15 |
16 | public DictionaryService(ISettingsService settingsService)
17 | {
18 | _settingsService = settingsService;
19 | _jsonOptions = new JsonSerializerOptions
20 | {
21 | PropertyNameCaseInsensitive = true
22 | };
23 | }
24 |
25 | public async Task GetDefinitionAsync(string word)
26 | {
27 | if (string.IsNullOrWhiteSpace(word))
28 | return null;
29 |
30 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath;
31 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath))
32 | return null;
33 |
34 | var fileName = $"{word.ToLower()}.json";
35 | var filePath = Path.Combine(dictionaryPath, fileName);
36 |
37 | if (!File.Exists(filePath))
38 | return null;
39 |
40 | try
41 | {
42 | var json = await File.ReadAllTextAsync(filePath);
43 | return JsonSerializer.Deserialize(json, _jsonOptions);
44 | }
45 | catch (Exception)
46 | {
47 | return null;
48 | }
49 | }
50 |
51 | public async Task> GetCachedWordsAsync()
52 | {
53 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath;
54 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath))
55 | return new List();
56 |
57 | try
58 | {
59 | var files = Directory.GetFiles(dictionaryPath, "*.json");
60 | return files.Select(f => Path.GetFileNameWithoutExtension(f)).OrderBy(w => w).ToList();
61 | }
62 | catch (Exception)
63 | {
64 | return new List();
65 | }
66 | }
67 |
68 | public async Task DeleteCachedWordAsync(string word)
69 | {
70 | if (string.IsNullOrWhiteSpace(word))
71 | return false;
72 |
73 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath;
74 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath))
75 | return false;
76 |
77 | var fileName = $"{word.ToLower()}.json";
78 | var filePath = Path.Combine(dictionaryPath, fileName);
79 |
80 | if (!File.Exists(filePath))
81 | return false;
82 |
83 | try
84 | {
85 | File.Delete(filePath);
86 | return true;
87 | }
88 | catch (Exception)
89 | {
90 | return false;
91 | }
92 | }
93 |
94 | public async Task SaveDefinitionAsync(WordDefinition definition)
95 | {
96 | if (definition == null || string.IsNullOrWhiteSpace(definition.Word))
97 | return false;
98 |
99 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath;
100 | if (string.IsNullOrEmpty(dictionaryPath))
101 | return false;
102 |
103 | try
104 | {
105 | // Create directory if it doesn't exist
106 | if (!Directory.Exists(dictionaryPath))
107 | {
108 | Directory.CreateDirectory(dictionaryPath);
109 | }
110 |
111 | var fileName = $"{definition.Word.ToLower()}.json";
112 | var filePath = Path.Combine(dictionaryPath, fileName);
113 |
114 | var json = JsonSerializer.Serialize(definition, new JsonSerializerOptions { WriteIndented = true });
115 | await File.WriteAllTextAsync(filePath, json);
116 | return true;
117 | }
118 | catch (Exception)
119 | {
120 | return false;
121 | }
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/Aictionary/Services/HotkeyService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Diagnostics;
4 | using System.Globalization;
5 | using System.Linq;
6 | using System.Runtime.InteropServices;
7 | using System.Threading.Tasks;
8 | using SharpHook;
9 | using SharpHook.Data;
10 | using SharpHook.Native;
11 | using SharpHook.Providers;
12 |
13 | namespace Aictionary.Services;
14 |
15 | public sealed class HotkeyService : IHotkeyService, IDisposable
16 | {
17 | private readonly object _syncRoot = new();
18 | private readonly Dictionary _hotkeys = new(StringComparer.OrdinalIgnoreCase);
19 | private readonly HashSet _activeHotkeys = new(StringComparer.OrdinalIgnoreCase);
20 | private readonly UioHookProvider _hookProvider;
21 | private readonly SimpleGlobalHook _hook;
22 | private Task? _hookTask;
23 | private bool _disposed;
24 |
25 | public HotkeyService()
26 | {
27 | _hookProvider = UioHookProvider.Instance;
28 | _hookProvider.PromptUserIfAxApiDisabled = false;
29 | _hook = new SimpleGlobalHook(GlobalHookType.Keyboard, _hookProvider, runAsyncOnBackgroundThread: true);
30 | _hook.KeyPressed += OnKeyPressed;
31 | _hook.KeyReleased += OnKeyReleased;
32 | _hook.HookDisabled += OnHookDisabled;
33 |
34 | EnsureHookRunning();
35 | }
36 |
37 | public void RegisterHotkey(string hotkey, Action callback)
38 | {
39 | if (string.IsNullOrWhiteSpace(hotkey))
40 | {
41 | Console.WriteLine("[HotkeyService] Empty hotkey provided");
42 | return;
43 | }
44 |
45 | if (callback == null)
46 | {
47 | throw new ArgumentNullException(nameof(callback));
48 | }
49 |
50 | var registration = TryCreateRegistration(hotkey, callback);
51 | if (registration == null)
52 | {
53 | Console.WriteLine($"[HotkeyService] Failed to parse hotkey: {hotkey}");
54 | return;
55 | }
56 |
57 | lock (_syncRoot)
58 | {
59 | _hotkeys[hotkey] = registration;
60 | _activeHotkeys.Remove(hotkey);
61 | }
62 |
63 | Console.WriteLine($"[HotkeyService] Registered hotkey: {hotkey}");
64 | EnsureHookRunning();
65 | }
66 |
67 | public void UnregisterHotkey(string hotkey)
68 | {
69 | if (string.IsNullOrWhiteSpace(hotkey))
70 | {
71 | return;
72 | }
73 |
74 | lock (_syncRoot)
75 | {
76 | if (_hotkeys.Remove(hotkey))
77 | {
78 | _activeHotkeys.Remove(hotkey);
79 | Console.WriteLine($"[HotkeyService] Unregistered hotkey: {hotkey}");
80 | }
81 | }
82 | }
83 |
84 | public void UnregisterAll()
85 | {
86 | lock (_syncRoot)
87 | {
88 | _hotkeys.Clear();
89 | _activeHotkeys.Clear();
90 | }
91 |
92 | Console.WriteLine("[HotkeyService] Cleared all registered hotkeys");
93 | }
94 |
95 | public bool CheckAccessibilityPermissions()
96 | {
97 | var enabled = HasAccessibilityPermission(promptUser: false);
98 |
99 | if (enabled)
100 | {
101 | EnsureHookRunning();
102 | Console.WriteLine("[HotkeyService] Accessibility permissions granted");
103 | }
104 | else
105 | {
106 | Console.WriteLine("[HotkeyService] Accessibility permissions missing");
107 | }
108 |
109 | return enabled;
110 | }
111 |
112 | public void RequestAccessibilityPermissions()
113 | {
114 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
115 | {
116 | Console.WriteLine("[HotkeyService] Accessibility permissions not required on this platform");
117 | return;
118 | }
119 |
120 | Console.WriteLine("[HotkeyService] Opening System Preferences for accessibility permissions...");
121 | OpenAccessibilityPreferences();
122 |
123 | Console.WriteLine("[HotkeyService] Requesting accessibility permissions via system prompt...");
124 | var enabled = HasAccessibilityPermission(promptUser: true);
125 |
126 | if (enabled)
127 | {
128 | Console.WriteLine("[HotkeyService] Accessibility permissions granted after prompt");
129 | EnsureHookRunning();
130 | }
131 | else
132 | {
133 | Console.WriteLine("[HotkeyService] Accessibility permissions still missing after prompt");
134 | }
135 | }
136 |
137 | public void Dispose()
138 | {
139 | if (_disposed)
140 | {
141 | return;
142 | }
143 |
144 | _disposed = true;
145 |
146 | _hook.KeyPressed -= OnKeyPressed;
147 | _hook.KeyReleased -= OnKeyReleased;
148 | _hook.HookDisabled -= OnHookDisabled;
149 |
150 | try
151 | {
152 | if (_hook.IsRunning)
153 | {
154 | _hook.Stop();
155 | }
156 | }
157 | catch (Exception ex)
158 | {
159 | Console.WriteLine($"[HotkeyService] Error while stopping global hook: {ex.Message}");
160 | }
161 |
162 | _hook.Dispose();
163 | lock (_syncRoot)
164 | {
165 | _hotkeys.Clear();
166 | _activeHotkeys.Clear();
167 | }
168 | }
169 |
170 | private void OnKeyPressed(object? sender, KeyboardHookEventArgs e)
171 | {
172 | HotkeyRegistration[] registrations;
173 |
174 | lock (_syncRoot)
175 | {
176 | registrations = _hotkeys.Values.ToArray();
177 | }
178 |
179 | var actualMask = e.RawEvent.Mask;
180 | foreach (var registration in registrations)
181 | {
182 | if (registration.TriggerKey != e.Data.KeyCode)
183 | {
184 | continue;
185 | }
186 |
187 | if (!HasRequiredModifiers(actualMask, registration.RequiredMask))
188 | {
189 | continue;
190 | }
191 |
192 | bool shouldInvoke;
193 | lock (_syncRoot)
194 | {
195 | shouldInvoke = _activeHotkeys.Add(registration.Hotkey);
196 | }
197 |
198 | if (shouldInvoke)
199 | {
200 | Task.Run(() =>
201 | {
202 | try
203 | {
204 | registration.Callback();
205 | }
206 | catch (Exception ex)
207 | {
208 | Console.WriteLine($"[HotkeyService] Hotkey callback error ({registration.Hotkey}): {ex.Message}");
209 | }
210 | });
211 | }
212 | }
213 | }
214 |
215 | private void OnKeyReleased(object? sender, KeyboardHookEventArgs e)
216 | {
217 | var releasedKey = e.Data.KeyCode;
218 |
219 | lock (_syncRoot)
220 | {
221 | foreach (var registration in _hotkeys.Values)
222 | {
223 | if (registration.TriggerKey == releasedKey || registration.ModifierKeyCodes.Contains(releasedKey))
224 | {
225 | _activeHotkeys.Remove(registration.Hotkey);
226 | }
227 | }
228 | }
229 | }
230 |
231 | private void OnHookDisabled(object? sender, HookEventArgs e)
232 | {
233 | Console.WriteLine("[HotkeyService] Global hook disabled");
234 | lock (_syncRoot)
235 | {
236 | _hookTask = null;
237 | _activeHotkeys.Clear();
238 | }
239 | }
240 |
241 | private bool HasAccessibilityPermission(bool promptUser)
242 | {
243 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
244 | {
245 | return true;
246 | }
247 |
248 | try
249 | {
250 | if (promptUser)
251 | {
252 | _hookProvider.PromptUserIfAxApiDisabled = true;
253 | }
254 |
255 | return _hookProvider.IsAxApiEnabled(promptUser);
256 | }
257 | catch (Exception ex)
258 | {
259 | Console.WriteLine($"[HotkeyService] Accessibility check failed: {ex.Message}");
260 | return false;
261 | }
262 | finally
263 | {
264 | if (promptUser)
265 | {
266 | _hookProvider.PromptUserIfAxApiDisabled = false;
267 | }
268 | }
269 | }
270 |
271 | private void OpenAccessibilityPreferences()
272 | {
273 | try
274 | {
275 | var process = new Process
276 | {
277 | StartInfo = new ProcessStartInfo
278 | {
279 | FileName = "open",
280 | Arguments = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
281 | UseShellExecute = false,
282 | CreateNoWindow = true
283 | }
284 | };
285 |
286 | if (process.Start())
287 | {
288 | Console.WriteLine("[HotkeyService] System Preferences launched to Accessibility pane");
289 | }
290 | else
291 | {
292 | Console.WriteLine("[HotkeyService] Failed to start System Preferences process");
293 | }
294 | }
295 | catch (Exception ex)
296 | {
297 | Console.WriteLine($"[HotkeyService] Unable to open System Preferences: {ex.Message}");
298 | }
299 | }
300 |
301 | private void EnsureHookRunning()
302 | {
303 | if (_disposed)
304 | {
305 | return;
306 | }
307 |
308 | if (!HasAccessibilityPermission(promptUser: false))
309 | {
310 | Console.WriteLine("[HotkeyService] Global hook not started because accessibility permissions are missing");
311 | return;
312 | }
313 |
314 | lock (_syncRoot)
315 | {
316 | if (_hookTask is { IsCompleted: false })
317 | {
318 | return;
319 | }
320 |
321 | try
322 | {
323 | _hookTask = _hook.RunAsync();
324 | _hookTask.ContinueWith(task =>
325 | {
326 | if (task.IsFaulted)
327 | {
328 | var message = task.Exception?.GetBaseException().Message ?? "Unknown error";
329 | Console.WriteLine($"[HotkeyService] Global hook stopped unexpectedly: {message}");
330 | }
331 |
332 | lock (_syncRoot)
333 | {
334 | _activeHotkeys.Clear();
335 | _hookTask = null;
336 | }
337 | }, TaskScheduler.Default);
338 |
339 | Console.WriteLine("[HotkeyService] Global hook started");
340 | }
341 | catch (HookException ex)
342 | {
343 | Console.WriteLine($"[HotkeyService] Failed to start global hook: {ex.Message}");
344 | }
345 | catch (Exception ex)
346 | {
347 | Console.WriteLine($"[HotkeyService] Unexpected error starting global hook: {ex.Message}");
348 | }
349 | }
350 | }
351 |
352 | private static bool HasRequiredModifiers(EventMask actual, EventMask required)
353 | {
354 | if (required == EventMask.None)
355 | {
356 | return true;
357 | }
358 |
359 | if (!CheckModifier(actual, required, EventMask.Shift, EventMask.LeftShift, EventMask.RightShift))
360 | {
361 | return false;
362 | }
363 |
364 | if (!CheckModifier(actual, required, EventMask.Ctrl, EventMask.LeftCtrl, EventMask.RightCtrl))
365 | {
366 | return false;
367 | }
368 |
369 | if (!CheckModifier(actual, required, EventMask.Alt, EventMask.LeftAlt, EventMask.RightAlt))
370 | {
371 | return false;
372 | }
373 |
374 | if (!CheckModifier(actual, required, EventMask.Meta, EventMask.LeftMeta, EventMask.RightMeta))
375 | {
376 | return false;
377 | }
378 |
379 | return true;
380 | }
381 |
382 | private static bool CheckModifier(EventMask actual, EventMask required, EventMask anyMask, EventMask leftMask, EventMask rightMask) =>
383 | (required & anyMask) == 0 ||
384 | (actual & (anyMask | leftMask | rightMask)) != 0;
385 |
386 | private HotkeyRegistration? TryCreateRegistration(string hotkey, Action callback)
387 | {
388 | var parts = hotkey.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
389 |
390 | if (parts.Length == 0)
391 | {
392 | return null;
393 | }
394 |
395 | var requiredMask = EventMask.None;
396 | var modifierKeyCodes = new HashSet();
397 | var triggerKey = KeyCode.VcUndefined;
398 |
399 | foreach (var part in parts)
400 | {
401 | if (TryApplyModifier(part, ref requiredMask, modifierKeyCodes))
402 | {
403 | continue;
404 | }
405 |
406 | if (TryParseKey(part, out var key))
407 | {
408 | triggerKey = key;
409 | continue;
410 | }
411 |
412 | Console.WriteLine($"[HotkeyService] Unknown hotkey segment '{part}'");
413 | return null;
414 | }
415 |
416 | if (triggerKey == KeyCode.VcUndefined)
417 | {
418 | Console.WriteLine("[HotkeyService] Hotkey must include a non-modifier key");
419 | return null;
420 | }
421 |
422 | return new HotkeyRegistration
423 | {
424 | Hotkey = hotkey,
425 | TriggerKey = triggerKey,
426 | RequiredMask = requiredMask,
427 | ModifierKeyCodes = modifierKeyCodes,
428 | Callback = callback
429 | };
430 | }
431 |
432 | private static bool TryApplyModifier(string token, ref EventMask mask, HashSet modifierKeyCodes)
433 | {
434 | var part = token.Trim();
435 |
436 | if (part.Equals("Shift", StringComparison.OrdinalIgnoreCase))
437 | {
438 | mask |= EventMask.Shift;
439 | modifierKeyCodes.Add(KeyCode.VcLeftShift);
440 | modifierKeyCodes.Add(KeyCode.VcRightShift);
441 | return true;
442 | }
443 |
444 | if (part.Equals("Control", StringComparison.OrdinalIgnoreCase) ||
445 | part.Equals("Ctrl", StringComparison.OrdinalIgnoreCase))
446 | {
447 | mask |= EventMask.Ctrl;
448 | modifierKeyCodes.Add(KeyCode.VcLeftControl);
449 | modifierKeyCodes.Add(KeyCode.VcRightControl);
450 | return true;
451 | }
452 |
453 | if (part.Equals("Alt", StringComparison.OrdinalIgnoreCase) ||
454 | part.Equals("Option", StringComparison.OrdinalIgnoreCase))
455 | {
456 | mask |= EventMask.Alt;
457 | modifierKeyCodes.Add(KeyCode.VcLeftAlt);
458 | modifierKeyCodes.Add(KeyCode.VcRightAlt);
459 | return true;
460 | }
461 |
462 | if (part.Equals("Command", StringComparison.OrdinalIgnoreCase) ||
463 | part.Equals("Cmd", StringComparison.OrdinalIgnoreCase) ||
464 | part.Equals("Meta", StringComparison.OrdinalIgnoreCase) ||
465 | part.Equals("Super", StringComparison.OrdinalIgnoreCase))
466 | {
467 | mask |= EventMask.Meta;
468 | modifierKeyCodes.Add(KeyCode.VcLeftMeta);
469 | modifierKeyCodes.Add(KeyCode.VcRightMeta);
470 | return true;
471 | }
472 |
473 | return false;
474 | }
475 |
476 | private static bool TryParseKey(string token, out KeyCode keyCode)
477 | {
478 | if (string.IsNullOrWhiteSpace(token))
479 | {
480 | keyCode = KeyCode.VcUndefined;
481 | return false;
482 | }
483 |
484 | var part = token.Trim();
485 |
486 | if (part.Length == 1)
487 | {
488 | var character = part[0];
489 |
490 | if (char.IsLetter(character))
491 | {
492 | var name = $"Vc{char.ToUpperInvariant(character)}";
493 | if (Enum.TryParse(name, ignoreCase: true, out keyCode))
494 | {
495 | return true;
496 | }
497 | }
498 |
499 | if (char.IsDigit(character))
500 | {
501 | var name = $"Vc{character}";
502 | if (Enum.TryParse(name, ignoreCase: false, out keyCode))
503 | {
504 | return true;
505 | }
506 | }
507 | }
508 |
509 | if (part.Length > 1 && (part[0] == 'F' || part[0] == 'f') && int.TryParse(part.AsSpan(1), out var fnNumber))
510 | {
511 | if (fnNumber is >= 1 and <= 24)
512 | {
513 | var name = $"VcF{fnNumber}";
514 | if (Enum.TryParse(name, ignoreCase: false, out keyCode))
515 | {
516 | return true;
517 | }
518 | }
519 | }
520 |
521 | if (SpecialKeys.TryGetValue(part, out keyCode))
522 | {
523 | return true;
524 | }
525 |
526 | var normalized = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(part.ToLowerInvariant()).Replace(" ", string.Empty);
527 | if (Enum.TryParse($"Vc{normalized}", ignoreCase: false, out keyCode))
528 | {
529 | return true;
530 | }
531 |
532 | keyCode = KeyCode.VcUndefined;
533 | return false;
534 | }
535 |
536 | private static readonly Dictionary SpecialKeys = new(StringComparer.OrdinalIgnoreCase)
537 | {
538 | ["Space"] = KeyCode.VcSpace,
539 | ["Spacebar"] = KeyCode.VcSpace,
540 | ["Enter"] = KeyCode.VcEnter,
541 | ["Return"] = KeyCode.VcEnter,
542 | ["Tab"] = KeyCode.VcTab,
543 | ["Escape"] = KeyCode.VcEscape,
544 | ["Esc"] = KeyCode.VcEscape,
545 | ["Backspace"] = KeyCode.VcBackspace,
546 | ["Delete"] = KeyCode.VcDelete,
547 | ["Del"] = KeyCode.VcDelete,
548 | ["Home"] = KeyCode.VcHome,
549 | ["End"] = KeyCode.VcEnd,
550 | ["PageUp"] = KeyCode.VcPageUp,
551 | ["PageDown"] = KeyCode.VcPageDown,
552 | ["Up"] = KeyCode.VcUp,
553 | ["Down"] = KeyCode.VcDown,
554 | ["Left"] = KeyCode.VcLeft,
555 | ["Right"] = KeyCode.VcRight,
556 | };
557 |
558 | private sealed class HotkeyRegistration
559 | {
560 | public required string Hotkey { get; init; }
561 | public required KeyCode TriggerKey { get; init; }
562 | public required EventMask RequiredMask { get; init; }
563 | public required HashSet ModifierKeyCodes { get; init; }
564 | public required Action Callback { get; init; }
565 | }
566 | }
567 |
--------------------------------------------------------------------------------
/Aictionary/Services/IDictionaryDownloadService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Aictionary.Models;
4 |
5 | namespace Aictionary.Services;
6 |
7 | public interface IDictionaryDownloadService
8 | {
9 | Task EnsureDictionaryExistsAsync(string dictionaryPath, DictionaryDownloadSource downloadSource, Action? progressCallback = null);
10 | bool DictionaryExists(string dictionaryPath);
11 | }
12 |
--------------------------------------------------------------------------------
/Aictionary/Services/IDictionaryResourceService.cs:
--------------------------------------------------------------------------------
1 | using System.Threading.Tasks;
2 | using Aictionary.Models;
3 |
4 | namespace Aictionary.Services;
5 |
6 | public interface IDictionaryResourceService
7 | {
8 | Task GetDictionaryDownloadUrlAsync(DictionaryDownloadSource source = DictionaryDownloadSource.GitHub);
9 | }
10 |
--------------------------------------------------------------------------------
/Aictionary/Services/IDictionaryService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Threading.Tasks;
3 | using Aictionary.Models;
4 |
5 | namespace Aictionary.Services;
6 |
7 | public interface IDictionaryService
8 | {
9 | Task GetDefinitionAsync(string word);
10 | Task> GetCachedWordsAsync();
11 | Task DeleteCachedWordAsync(string word);
12 | Task SaveDefinitionAsync(WordDefinition definition);
13 | }
14 |
--------------------------------------------------------------------------------
/Aictionary/Services/IHotkeyService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace Aictionary.Services;
4 |
5 | public interface IHotkeyService
6 | {
7 | void RegisterHotkey(string hotkey, Action callback);
8 | void UnregisterHotkey(string hotkey);
9 | void UnregisterAll();
10 | bool CheckAccessibilityPermissions();
11 | void RequestAccessibilityPermissions();
12 | }
13 |
--------------------------------------------------------------------------------
/Aictionary/Services/IOpenAIService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Threading.Tasks;
3 | using Aictionary.Models;
4 |
5 | namespace Aictionary.Services;
6 |
7 | public interface IOpenAIService
8 | {
9 | Task GenerateDefinitionAsync(string word);
10 | Task> GetAvailableModelsAsync();
11 | }
12 |
--------------------------------------------------------------------------------
/Aictionary/Services/IQueryHistoryService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Threading.Tasks;
4 | using Aictionary.Models;
5 |
6 | namespace Aictionary.Services;
7 |
8 | public interface IQueryHistoryService
9 | {
10 | Task AddEntryAsync(string word, DateTime queriedAt, string? conciseDefinition = null);
11 | Task> GetEntriesAsync();
12 | Task RemoveWordEntriesAsync(string word);
13 | }
14 |
--------------------------------------------------------------------------------
/Aictionary/Services/ISettingsService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Aictionary.Models;
4 |
5 | namespace Aictionary.Services;
6 |
7 | public interface ISettingsService
8 | {
9 | Task LoadSettingsAsync();
10 | Task SaveSettingsAsync(AppSettings settings);
11 | AppSettings CurrentSettings { get; }
12 | event EventHandler? SettingsChanged;
13 | }
14 |
--------------------------------------------------------------------------------
/Aictionary/Services/OpenAIService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.ClientModel;
3 | using System.Collections.Generic;
4 | using System.Linq;
5 | using System.Text.Json;
6 | using System.Threading.Tasks;
7 | using Aictionary.Models;
8 | using OpenAI;
9 | using OpenAI.Chat;
10 | using OpenAI.Models;
11 |
12 | namespace Aictionary.Services;
13 |
14 | public class OpenAIService : IOpenAIService
15 | {
16 | private readonly ISettingsService _settingsService;
17 | private readonly JsonSerializerOptions _jsonOptions;
18 |
19 | public OpenAIService(ISettingsService settingsService)
20 | {
21 | _settingsService = settingsService;
22 | _jsonOptions = new JsonSerializerOptions
23 | {
24 | PropertyNameCaseInsensitive = true
25 | };
26 | }
27 |
28 | private ChatClient CreateChatClient()
29 | {
30 | var settings = _settingsService.CurrentSettings;
31 | var apiKey = settings.ApiKey;
32 | var model = settings.Model;
33 |
34 | if (string.IsNullOrEmpty(apiKey))
35 | {
36 | throw new InvalidOperationException("OpenAI API key is not configured. Please set it in Settings.");
37 | }
38 |
39 | // Create OpenAI client with custom endpoint if specified
40 | if (!string.IsNullOrEmpty(settings.ApiBaseUrl) &&
41 | settings.ApiBaseUrl != "https://api.openai.com/v1")
42 | {
43 | var options = new OpenAIClientOptions
44 | {
45 | Endpoint = new Uri(settings.ApiBaseUrl)
46 | };
47 | var client = new OpenAIClient(new ApiKeyCredential(apiKey), options);
48 | return client.GetChatClient(model);
49 | }
50 |
51 | return new ChatClient(model, apiKey);
52 | }
53 |
54 | public async Task GenerateDefinitionAsync(string word)
55 | {
56 | if (string.IsNullOrWhiteSpace(word))
57 | {
58 | System.Console.WriteLine("[OpenAI] GenerateDefinition - Word is null or whitespace");
59 | return null;
60 | }
61 |
62 | try
63 | {
64 | System.Console.WriteLine($"[OpenAI] GenerateDefinition START for word: '{word}'");
65 |
66 | var chatClient = CreateChatClient();
67 |
68 | System.Console.WriteLine("[OpenAI] ChatClient created successfully");
69 |
70 | var prompt = """
71 | 你是一位严谨的双语词典编纂专家。你的任务是为一个给定的英语单词及其近义词生成一份详细的中文解释,并以严格的 JSON 格式输出。
72 | 当 JSON 值中需要出现引号时,请使用中文双引号(“ ”),不要使用英文双引号。确保 JSON 中的所有字段都有完整的取值,不要遗漏或留空任何内容。
73 |
74 | 请精确遵循下面范例中提供的结构和内容深度。
75 |
76 | ---
77 | ### 范例 1
78 |
79 | **用户输入:**
80 | example
81 |
82 | **模型输出:**
83 | {
84 | "word": "example",
85 | "pronunciation": "uhg·zam·pl",
86 | "concise_definition": "n. 例子, 范例, 榜样",
87 | "forms": {
88 | "plural": "examples"
89 | },
90 | "definitions": [
91 | {
92 | "pos": "noun",
93 | "explanation_en": "A specific case or instance used to clarify a general rule, principle, or idea, aiming to help explain, clarify, or support a point.",
94 | "explanation_cn": "指用以说明一般性规则、原则或想法的一个具体事例或个案,旨在帮助解释、澄清或支持一个观点。",
95 | "example_en": "This is a classic example of how marketing can influence consumer behavior.",
96 | "example_cn": "这是一个关于市场营销如何影响消费者行为的经典范例。"
97 | },
98 | {
99 | "pos": "noun",
100 | "explanation_en": "A model or standard for imitation, which can be positive (a role model) or negative (a cautionary tale).",
101 | "explanation_cn": "指一个可供他人模仿的榜样或典范,也可以指应引以为戒的反面教材。",
102 | "example_en": "Her dedication to the community sets a fine example for all of us.",
103 | "example_cn": "她对社区的奉献为我们所有人树立了一个好榜样。"
104 | }
105 | ],
106 | "comparison": [
107 | {
108 | "word_to_compare": "sample",
109 | "analysis": "“Sample” (样本) 侧重于从一个整体中取出的一小部分,用以展示整体的质量、风格或特性。它强调“代表性”。例如,布料的样品、产品的试用装。而 “example” 是为了“说明”一个概念或规则,不一定来自一个更大的实体。"
110 | },
111 | {
112 | "word_to_compare": "illustration",
113 | "analysis": "“Illustration” (图解/例证) 强调“视觉化”或“形象化”地解释说明。它可以是一个图片、图表,也可以是一个生动的故事,目的是让抽象的概念变得具体易懂。它的解释功能比 “example” 更强、更形象。"
114 | },
115 | {
116 | "word_to_compare": "instance",
117 | "analysis": "“Instance” (实例) 与 “example” 非常接近,常可互换,但 “instance” 更侧重于指一个具体“事件”或“情况”的发生,作为某个现象存在的证据。它比 “example” 更具客观性和事实性,常用于比较正式的论述中。"
118 | }
119 | ]
120 | }
121 |
122 | ---
123 |
124 | ### 范例2
125 |
126 | **用户输入:**
127 | develop
128 |
129 | **模型输出:**
130 | {
131 | "word": "develop",
132 | "pronunciation": "duh·veh·luhp",
133 | "concise_definition": "v. 开发, 发展, 成长, 显影",
134 | "forms": {
135 | "third_person_singular": "develops",
136 | "past_tense": "developed",
137 | "past_participle": "developed",
138 | "present_participle": "developing"
139 | },
140 | "definitions": [
141 | {
142 | "pos": "verb",
143 | "explanation_en": "To create something or bring it to a more advanced or mature state, often from a basic or non-existent form (e.g., a skill, product, or idea).",
144 | "explanation_cn": "指使某事物(如技能、产品、想法)从无到有或从简单到复杂地被创造或变得更先进、更成熟。",
145 | "example_en": "The company is developing a new software to manage projects.",
146 | "example_cn": "该公司正在开发一款用于管理项目的新软件。"
147 | },
148 | {
149 | "pos": "verb",
150 | "explanation_en": "For a situation or event to unfold, emerge, or undergo new changes over time.",
151 | "explanation_cn": "指(情况、事件)逐渐展开、显现或发生新的变化。",
152 | "example_en": "A crisis was developing in the financial markets.",
153 | "example_cn": "金融市场正酝酿着一场危机。"
154 | },
155 | {
156 | "pos": "verb",
157 | "explanation_en": "To treat photographic film with chemicals to make the captured image visible.",
158 | "explanation_cn": "指冲洗胶片以使其图像显现。",
159 | "example_en": "I need to get these photos developed.",
160 | "example_cn": "我需要把这些照片冲洗出来。"
161 | }
162 | ],
163 | "comparison": [
164 | {
165 | "word_to_compare": "evolve",
166 | "analysis": "“Evolve” (演变/进化) 强调一个长期的、渐进的、通常是自发的自然变化过程,从简单的形态向更复杂的形态发展。它常用于生物进化或社会、思想的长期演变。而 “develop” 通常暗示了有意识的努力和规划。"
167 | },
168 | {
169 | "word_to_compare": "grow",
170 | "analysis": "“Grow” (生长/增长) 主要指尺寸、数量或程度上的增加,可以是自然的(如植物生长),也可以是抽象的(如信心增长)。“Develop” 更侧重于内在结构、能力或复杂性的提升,是质变,而 “grow” 更偏向于量变。"
171 | },
172 | {
173 | "word_to_compare": "expand",
174 | "analysis": "“Expand” (扩张/扩大) 指在范围、规模、体积上的向外延伸。例如公司扩大市场、气球膨胀。它强调边界的延展。“Develop” 则是指内部变得更加完善和高级。"
175 | }
176 | ]
177 | }
178 |
179 | ---
180 |
181 | ### TASK
182 |
183 | 现在,请严格按照上面的范例,为用户输入的单词生成 JSON 输出。不要在 JSON 对象之外添加任何额外的说明或文字。
184 | """;
185 |
186 | System.Console.WriteLine(prompt);
187 |
188 | var messages = new ChatMessage[]
189 | {
190 | new SystemChatMessage(prompt),
191 | new UserChatMessage(word)
192 | };
193 |
194 | var options = new ChatCompletionOptions
195 | {
196 | Temperature = 0.1f
197 | };
198 |
199 | System.Console.WriteLine("[OpenAI] Sending request to API...");
200 | var completion = await chatClient.CompleteChatAsync(messages, options);
201 | var response = completion.Value.Content[0].Text;
202 |
203 | System.Console.WriteLine($"[OpenAI] Received response (length: {response.Length} chars)");
204 | System.Console.WriteLine($"[OpenAI] Response preview: \n{response}\n");
205 |
206 | // Try to extract JSON if there's markdown code block
207 | var jsonStart = response.IndexOf('{');
208 | var jsonEnd = response.LastIndexOf('}');
209 |
210 | if (jsonStart >= 0 && jsonEnd >= 0)
211 | {
212 | var jsonContent = response.Substring(jsonStart, jsonEnd - jsonStart + 1);
213 | System.Console.WriteLine($"[OpenAI] Extracted JSON (length: {jsonContent.Length} chars)");
214 |
215 | var definition = JsonSerializer.Deserialize(jsonContent, _jsonOptions);
216 |
217 | if (definition != null)
218 | {
219 | System.Console.WriteLine($"[OpenAI] Successfully deserialized definition for word: '{definition.Word}'");
220 | }
221 | else
222 | {
223 | System.Console.WriteLine("[OpenAI] ERROR: Deserialization returned null");
224 | }
225 |
226 | return definition;
227 | }
228 | else
229 | {
230 | System.Console.WriteLine($"[OpenAI] ERROR: Could not find JSON in response. jsonStart={jsonStart}, jsonEnd={jsonEnd}");
231 | System.Console.WriteLine($"[OpenAI] Full response: {response}");
232 | }
233 |
234 | return null;
235 | }
236 | catch (Exception ex)
237 | {
238 | System.Console.WriteLine($"[OpenAI] EXCEPTION in GenerateDefinition: {ex.GetType().Name}");
239 | System.Console.WriteLine($"[OpenAI] Exception message: {ex.Message}");
240 | System.Console.WriteLine($"[OpenAI] Stack trace: {ex.StackTrace}");
241 |
242 | if (ex.InnerException != null)
243 | {
244 | System.Console.WriteLine($"[OpenAI] Inner exception: {ex.InnerException.GetType().Name}");
245 | System.Console.WriteLine($"[OpenAI] Inner exception message: {ex.InnerException.Message}");
246 | }
247 |
248 | return null;
249 | }
250 | }
251 |
252 | public async Task> GetAvailableModelsAsync()
253 | {
254 | try
255 | {
256 | var settings = _settingsService.CurrentSettings;
257 | var apiKey = settings.ApiKey;
258 |
259 | if (string.IsNullOrEmpty(apiKey))
260 | {
261 | return new List();
262 | }
263 |
264 | OpenAIClient client;
265 |
266 | if (!string.IsNullOrEmpty(settings.ApiBaseUrl) &&
267 | settings.ApiBaseUrl != "https://api.openai.com/v1")
268 | {
269 | var options = new OpenAIClientOptions
270 | {
271 | Endpoint = new Uri(settings.ApiBaseUrl)
272 | };
273 | client = new OpenAIClient(new ApiKeyCredential(apiKey), options);
274 | }
275 | else
276 | {
277 | client = new OpenAIClient(apiKey);
278 | }
279 |
280 | var modelClient = client.GetOpenAIModelClient();
281 | var models = new List();
282 |
283 | var result = await modelClient.GetModelsAsync();
284 | var modelCollection = result.Value;
285 |
286 | foreach (var model in modelCollection)
287 | {
288 | models.Add(model.Id);
289 | }
290 |
291 | return models.OrderBy(m => m).ToList();
292 | }
293 | catch (Exception)
294 | {
295 | return new List();
296 | }
297 | }
298 | }
299 |
--------------------------------------------------------------------------------
/Aictionary/Services/QueryHistoryService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Text.Json;
5 | using System.Threading;
6 | using System.Threading.Tasks;
7 | using Aictionary.Models;
8 |
9 | namespace Aictionary.Services;
10 |
11 | public class QueryHistoryService : IQueryHistoryService
12 | {
13 | private readonly string _historyFilePath;
14 | private readonly JsonSerializerOptions _jsonOptions;
15 | private readonly SemaphoreSlim _fileSemaphore = new(1, 1);
16 |
17 | public QueryHistoryService()
18 | {
19 | var appDirectory = AppDomain.CurrentDomain.BaseDirectory;
20 | _historyFilePath = Path.Combine(appDirectory, "query-history.json");
21 | _jsonOptions = new JsonSerializerOptions
22 | {
23 | PropertyNameCaseInsensitive = true,
24 | WriteIndented = true
25 | };
26 |
27 | if (!File.Exists(_historyFilePath))
28 | {
29 | var emptyJson = JsonSerializer.Serialize(new List(), _jsonOptions);
30 | File.WriteAllText(_historyFilePath, emptyJson);
31 | }
32 | }
33 |
34 | public async Task AddEntryAsync(string word, DateTime queriedAt, string? conciseDefinition = null)
35 | {
36 | if (string.IsNullOrWhiteSpace(word))
37 | {
38 | return;
39 | }
40 |
41 | var entry = new QueryHistoryEntry
42 | {
43 | Word = word.Trim(),
44 | QueriedAt = queriedAt,
45 | ConciseDefinition = conciseDefinition
46 | };
47 |
48 | await _fileSemaphore.WaitAsync();
49 |
50 | try
51 | {
52 | var entries = await ReadEntriesInternalAsync();
53 | entries.Add(entry);
54 | await WriteEntriesInternalAsync(entries);
55 | }
56 | finally
57 | {
58 | _fileSemaphore.Release();
59 | }
60 | }
61 |
62 | public async Task> GetEntriesAsync()
63 | {
64 | await _fileSemaphore.WaitAsync();
65 |
66 | try
67 | {
68 | var entries = await ReadEntriesInternalAsync();
69 | return entries.AsReadOnly();
70 | }
71 | finally
72 | {
73 | _fileSemaphore.Release();
74 | }
75 | }
76 |
77 | public async Task RemoveWordEntriesAsync(string word)
78 | {
79 | if (string.IsNullOrWhiteSpace(word))
80 | {
81 | return;
82 | }
83 |
84 | await _fileSemaphore.WaitAsync();
85 |
86 | try
87 | {
88 | var entries = await ReadEntriesInternalAsync();
89 | entries.RemoveAll(e => string.Equals(e.Word, word.Trim(), StringComparison.OrdinalIgnoreCase));
90 | await WriteEntriesInternalAsync(entries);
91 | }
92 | finally
93 | {
94 | _fileSemaphore.Release();
95 | }
96 | }
97 |
98 | private async Task> ReadEntriesInternalAsync()
99 | {
100 | try
101 | {
102 | var json = await File.ReadAllTextAsync(_historyFilePath);
103 | var entries = JsonSerializer.Deserialize>(json, _jsonOptions);
104 | return entries ?? new List();
105 | }
106 | catch (Exception)
107 | {
108 | return new List();
109 | }
110 | }
111 |
112 | private async Task WriteEntriesInternalAsync(List entries)
113 | {
114 | try
115 | {
116 | var json = JsonSerializer.Serialize(entries, _jsonOptions);
117 | await File.WriteAllTextAsync(_historyFilePath, json);
118 | }
119 | catch (Exception)
120 | {
121 | // Intentionally swallow to avoid crashing the app on logging failure.
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/Aictionary/Services/QuickQueryService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Runtime.InteropServices;
3 | using System.Threading.Tasks;
4 | using Aictionary.ViewModels;
5 | using Aictionary.Views;
6 | using Avalonia;
7 | using Avalonia.Controls;
8 | using Avalonia.Controls.ApplicationLifetimes;
9 | using Avalonia.Input.Platform;
10 | using Avalonia.Threading;
11 | using SharpHook;
12 | using SharpHook.Data;
13 | using SharpHook.Native;
14 |
15 | namespace Aictionary.Services;
16 |
17 | public class QuickQueryService
18 | {
19 | private readonly IHotkeyService _hotkeyService;
20 | private readonly ISettingsService _settingsService;
21 | private readonly EventSimulator _eventSimulator;
22 |
23 | public QuickQueryService(IHotkeyService hotkeyService, ISettingsService settingsService)
24 | {
25 | _hotkeyService = hotkeyService;
26 | _settingsService = settingsService;
27 | _eventSimulator = new EventSimulator();
28 | }
29 |
30 | public void Initialize()
31 | {
32 | // Check permissions before registering
33 | if (!_hotkeyService.CheckAccessibilityPermissions())
34 | {
35 | Console.WriteLine("[QuickQueryService] Accessibility permissions not granted. Hotkey not registered.");
36 | Console.WriteLine("[QuickQueryService] Please grant accessibility permissions in Settings > Keyboard.");
37 | return;
38 | }
39 |
40 | RegisterQuickQueryHotkey();
41 |
42 | // Re-register when settings change
43 | _settingsService.SettingsChanged += (sender, args) =>
44 | {
45 | RegisterQuickQueryHotkey();
46 | };
47 | }
48 |
49 | public void ReregisterHotkey()
50 | {
51 | // This method can be called after permissions are granted
52 | if (_hotkeyService.CheckAccessibilityPermissions())
53 | {
54 | RegisterQuickQueryHotkey();
55 | }
56 | }
57 |
58 | private void RegisterQuickQueryHotkey()
59 | {
60 | var hotkey = _settingsService.CurrentSettings.QuickQueryHotkey;
61 | if (string.IsNullOrEmpty(hotkey))
62 | {
63 | Console.WriteLine("[QuickQueryService] No hotkey configured");
64 | return;
65 | }
66 |
67 | Console.WriteLine($"[QuickQueryService] Registering quick query hotkey: {hotkey}");
68 |
69 | _hotkeyService.UnregisterAll();
70 | _hotkeyService.RegisterHotkey(hotkey, async () =>
71 | {
72 | await Dispatcher.UIThread.InvokeAsync(async () =>
73 | {
74 | await HandleQuickQuery();
75 | });
76 | });
77 | }
78 |
79 | private async Task HandleQuickQuery()
80 | {
81 | try
82 | {
83 | Console.WriteLine("[QuickQueryService] Quick query triggered");
84 |
85 | // Step 1: Simulate Cmd+C (or Ctrl+C on Windows/Linux) to copy selected text
86 | await SimulateCopyShortcut();
87 |
88 | // Wait a bit for the copy operation to complete
89 | await Task.Delay(100);
90 |
91 | // Get or create main window
92 | var mainWindow = GetMainWindow();
93 | if (mainWindow == null)
94 | {
95 | Console.WriteLine("[QuickQueryService] Could not get main window");
96 | return;
97 | }
98 |
99 | // Get the selected text from clipboard via TopLevel
100 | var topLevel = TopLevel.GetTopLevel(mainWindow);
101 | var clipboard = topLevel?.Clipboard;
102 | if (clipboard == null)
103 | {
104 | Console.WriteLine("[QuickQueryService] Clipboard not available");
105 | return;
106 | }
107 |
108 | // Get text from clipboard
109 | var text = await clipboard.GetTextAsync();
110 | if (string.IsNullOrWhiteSpace(text))
111 | {
112 | Console.WriteLine("[QuickQueryService] No text in clipboard");
113 | return;
114 | }
115 |
116 | Console.WriteLine($"[QuickQueryService] Text from clipboard: '{text}'");
117 |
118 | // Show and activate the window
119 | if (!mainWindow.IsVisible)
120 | {
121 | mainWindow.Show();
122 | }
123 |
124 | mainWindow.Activate();
125 | mainWindow.BringIntoView();
126 |
127 | // Set the search text and trigger search
128 | if (mainWindow.DataContext is MainWindowViewModel viewModel)
129 | {
130 | viewModel.SearchText = text.Trim();
131 | // Execute the search command (no await needed, it returns IObservable)
132 | viewModel.SearchCommand.Execute().Subscribe();
133 | }
134 |
135 | Console.WriteLine("[QuickQueryService] Quick query completed");
136 | }
137 | catch (Exception ex)
138 | {
139 | Console.WriteLine($"[QuickQueryService] Error in HandleQuickQuery: {ex.Message}");
140 | }
141 | }
142 |
143 | private async Task SimulateCopyShortcut()
144 | {
145 | try
146 | {
147 | Console.WriteLine("[QuickQueryService] Simulating copy shortcut...");
148 |
149 | // Determine which modifier key to use based on OS
150 | var modifierKeyCode = RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
151 | ? KeyCode.VcLeftMeta // Command on macOS
152 | : KeyCode.VcLeftControl; // Ctrl on Windows/Linux
153 |
154 | // Press modifier key (Command or Ctrl)
155 | _eventSimulator.SimulateKeyPress(modifierKeyCode);
156 | await Task.Delay(50);
157 |
158 | // Press C key
159 | _eventSimulator.SimulateKeyPress(KeyCode.VcC);
160 | await Task.Delay(50);
161 |
162 | // Release C key
163 | _eventSimulator.SimulateKeyRelease(KeyCode.VcC);
164 | await Task.Delay(50);
165 |
166 | // Release modifier key
167 | _eventSimulator.SimulateKeyRelease(modifierKeyCode);
168 |
169 | Console.WriteLine("[QuickQueryService] Copy shortcut simulated");
170 | }
171 | catch (Exception ex)
172 | {
173 | Console.WriteLine($"[QuickQueryService] Error simulating copy shortcut: {ex.Message}");
174 | }
175 | }
176 |
177 | private MainWindow? GetMainWindow()
178 | {
179 | if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
180 | {
181 | return desktop.MainWindow as MainWindow;
182 | }
183 |
184 | return null;
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/Aictionary/Services/SettingsService.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Text.Json;
4 | using System.Threading.Tasks;
5 | using Aictionary.Helpers;
6 | using Aictionary.Models;
7 | using Microsoft.Extensions.Configuration;
8 |
9 | namespace Aictionary.Services;
10 |
11 | public class SettingsService : ISettingsService
12 | {
13 | private readonly string _settingsFilePath;
14 | private readonly IConfigurationRoot _configuration;
15 | private AppSettings _currentSettings;
16 |
17 | public event EventHandler? SettingsChanged;
18 |
19 | public SettingsService()
20 | {
21 | var appDirectory = AppDomain.CurrentDomain.BaseDirectory;
22 | _settingsFilePath = Path.Combine(appDirectory, "appsettings.json");
23 |
24 | // Ensure settings file exists
25 | if (!File.Exists(_settingsFilePath))
26 | {
27 | var defaultSettings = new AppSettings
28 | {
29 | DictionaryPath = Path.Combine(appDirectory, "dictionary"),
30 | ApiBaseUrl = "https://api.openai.com/v1",
31 | Model = "gpt-4o-mini"
32 | };
33 | var json = JsonSerializer.Serialize(defaultSettings, new JsonSerializerOptions { WriteIndented = true });
34 | File.WriteAllText(_settingsFilePath, json);
35 | }
36 |
37 | _configuration = new ConfigurationBuilder()
38 | .SetBasePath(appDirectory)
39 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
40 | .Build();
41 |
42 | _currentSettings = new AppSettings
43 | {
44 | DictionaryPath = Path.Combine(appDirectory, "dictionary")
45 | };
46 |
47 | // Subscribe to configuration changes
48 | _configuration.GetReloadToken().RegisterChangeCallback(_ =>
49 | {
50 | Console.WriteLine("[SettingsService] Configuration file changed, reloading...");
51 | LoadSettingsFromConfiguration();
52 | }, null);
53 | }
54 |
55 | public AppSettings CurrentSettings => _currentSettings;
56 |
57 | public async Task LoadSettingsAsync()
58 | {
59 | await Task.CompletedTask;
60 | LoadSettingsFromConfiguration();
61 | return _currentSettings;
62 | }
63 |
64 | private void LoadSettingsFromConfiguration()
65 | {
66 | var appDirectory = AppDomain.CurrentDomain.BaseDirectory;
67 |
68 | _currentSettings.ApiBaseUrl = _configuration["ApiBaseUrl"] ?? "https://api.openai.com/v1";
69 | _currentSettings.ApiKey = _configuration["ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? string.Empty;
70 | _currentSettings.Model = _configuration["Model"] ?? "gpt-4o-mini";
71 | _currentSettings.DictionaryPath = _configuration["DictionaryPath"] ?? Path.Combine(appDirectory, "dictionary");
72 | _currentSettings.QuickQueryHotkey = _configuration["QuickQueryHotkey"] ?? "Command+Shift+D";
73 |
74 | // Load DictionaryDownloadSource from config, or use auto-detected default
75 | var downloadSourceStr = _configuration["DictionaryDownloadSource"];
76 | if (!string.IsNullOrEmpty(downloadSourceStr) && Enum.TryParse(downloadSourceStr, out var downloadSource))
77 | {
78 | _currentSettings.DictionaryDownloadSource = downloadSource;
79 | }
80 | else
81 | {
82 | _currentSettings.DictionaryDownloadSource = LocaleHelper.GetDefaultDownloadSource();
83 | }
84 | }
85 |
86 | public async Task SaveSettingsAsync(AppSettings settings)
87 | {
88 | _currentSettings = settings;
89 |
90 | var settingsToSave = new
91 | {
92 | ApiBaseUrl = settings.ApiBaseUrl,
93 | ApiKey = settings.ApiKey,
94 | Model = settings.Model,
95 | DictionaryPath = settings.DictionaryPath,
96 | QuickQueryHotkey = settings.QuickQueryHotkey,
97 | DictionaryDownloadSource = settings.DictionaryDownloadSource.ToString()
98 | };
99 |
100 | var json = JsonSerializer.Serialize(settingsToSave, new JsonSerializerOptions { WriteIndented = true });
101 | await File.WriteAllTextAsync(_settingsFilePath, json);
102 |
103 | SettingsChanged?.Invoke(this, EventArgs.Empty);
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/Aictionary/ViewLocator.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Avalonia.Controls;
3 | using Avalonia.Controls.Templates;
4 | using Aictionary.ViewModels;
5 |
6 | namespace Aictionary;
7 |
8 | public class ViewLocator : IDataTemplate
9 | {
10 | public Control? Build(object? param)
11 | {
12 | if (param is null)
13 | return null;
14 |
15 | var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
16 | var type = Type.GetType(name);
17 |
18 | if (type != null)
19 | {
20 | return (Control)Activator.CreateInstance(type)!;
21 | }
22 |
23 | return new TextBlock { Text = "Not Found: " + name };
24 | }
25 |
26 | public bool Match(object? data)
27 | {
28 | return data is ViewModelBase;
29 | }
30 | }
--------------------------------------------------------------------------------
/Aictionary/ViewModels/DownloadProgressViewModel.cs:
--------------------------------------------------------------------------------
1 | using ReactiveUI;
2 |
3 | namespace Aictionary.ViewModels;
4 |
5 | public class DownloadProgressViewModel : ViewModelBase
6 | {
7 | private string _statusMessage = "Initializing download...";
8 | private bool _isIndeterminate = true;
9 | private double _progress;
10 | private bool _isCompleted;
11 |
12 | public string StatusMessage
13 | {
14 | get => _statusMessage;
15 | set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
16 | }
17 |
18 | public bool IsIndeterminate
19 | {
20 | get => _isIndeterminate;
21 | set => this.RaiseAndSetIfChanged(ref _isIndeterminate, value);
22 | }
23 |
24 | public double Progress
25 | {
26 | get => _progress;
27 | set => this.RaiseAndSetIfChanged(ref _progress, value);
28 | }
29 |
30 | public bool IsCompleted
31 | {
32 | get => _isCompleted;
33 | set => this.RaiseAndSetIfChanged(ref _isCompleted, value);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Aictionary/ViewModels/MainWindowViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Reactive;
3 | using System.Threading.Tasks;
4 | using Aictionary.Models;
5 | using Aictionary.Services;
6 | using ReactiveUI;
7 |
8 | namespace Aictionary.ViewModels;
9 |
10 | public class MainWindowViewModel : ViewModelBase
11 | {
12 | private readonly IDictionaryService _dictionaryService;
13 | private readonly IOpenAIService _openAIService;
14 | private readonly ISettingsService _settingsService;
15 | private readonly IQueryHistoryService _queryHistoryService;
16 |
17 | private string _searchText = string.Empty;
18 | private WordDefinition? _currentDefinition;
19 | private bool _isLoading;
20 | private string _errorMessage = string.Empty;
21 |
22 | public event EventHandler? OpenSettingsRequested;
23 | public event EventHandler? OpenStatisticsRequested;
24 |
25 | public MainWindowViewModel(
26 | IDictionaryService dictionaryService,
27 | IOpenAIService openAIService,
28 | ISettingsService settingsService,
29 | IQueryHistoryService queryHistoryService)
30 | {
31 | _dictionaryService = dictionaryService;
32 | _openAIService = openAIService;
33 | _settingsService = settingsService;
34 | _queryHistoryService = queryHistoryService;
35 |
36 | SearchCommand = ReactiveCommand.CreateFromTask(
37 | SearchAsync,
38 | this.WhenAnyValue(x => x.SearchText, text => !string.IsNullOrWhiteSpace(text))
39 | );
40 |
41 | OpenSettingsCommand = ReactiveCommand.Create(OpenSettings);
42 | OpenStatisticsCommand = ReactiveCommand.Create(OpenStatistics);
43 | }
44 |
45 | public string SearchText
46 | {
47 | get => _searchText;
48 | set => this.RaiseAndSetIfChanged(ref _searchText, value);
49 | }
50 |
51 | public WordDefinition? CurrentDefinition
52 | {
53 | get => _currentDefinition;
54 | set => this.RaiseAndSetIfChanged(ref _currentDefinition, value);
55 | }
56 |
57 | public bool IsLoading
58 | {
59 | get => _isLoading;
60 | set => this.RaiseAndSetIfChanged(ref _isLoading, value);
61 | }
62 |
63 | public string ErrorMessage
64 | {
65 | get => _errorMessage;
66 | set => this.RaiseAndSetIfChanged(ref _errorMessage, value);
67 | }
68 |
69 | public ReactiveCommand SearchCommand { get; }
70 | public ReactiveCommand OpenSettingsCommand { get; }
71 | public ReactiveCommand OpenStatisticsCommand { get; }
72 |
73 | private async Task SearchAsync()
74 | {
75 | var query = SearchText.Trim();
76 | if (string.IsNullOrWhiteSpace(query))
77 | {
78 | return;
79 | }
80 |
81 | IsLoading = true;
82 | ErrorMessage = string.Empty;
83 | CurrentDefinition = null;
84 |
85 | System.Console.WriteLine($"[MainViewModel] SearchAsync START for word: '{query}'");
86 |
87 | try
88 | {
89 | // First, try to get from local cache
90 | System.Console.WriteLine("[MainViewModel] Attempting to get definition from cache...");
91 | var definition = await _dictionaryService.GetDefinitionAsync(query);
92 | var fromCache = definition != null;
93 |
94 | if (fromCache)
95 | {
96 | System.Console.WriteLine("[MainViewModel] Definition found in cache");
97 | }
98 | else
99 | {
100 | System.Console.WriteLine("[MainViewModel] Definition not found in cache");
101 | }
102 |
103 | if (definition == null)
104 | {
105 | // If not found in cache, query OpenAI
106 | System.Console.WriteLine("[MainViewModel] Querying OpenAI for definition...");
107 | definition = await _openAIService.GenerateDefinitionAsync(query);
108 |
109 | // Save the AI-generated definition to cache
110 | if (definition != null)
111 | {
112 | System.Console.WriteLine("[MainViewModel] AI generated definition successfully, saving to cache...");
113 | await _dictionaryService.SaveDefinitionAsync(definition);
114 | System.Console.WriteLine("[MainViewModel] Definition saved to cache");
115 | }
116 | else
117 | {
118 | System.Console.WriteLine("[MainViewModel] OpenAI returned null definition");
119 | }
120 | }
121 |
122 | if (definition != null)
123 | {
124 | CurrentDefinition = definition;
125 |
126 | // Add to history with concise definition
127 | await _queryHistoryService.AddEntryAsync(query, DateTime.UtcNow, definition.ConciseDefinition);
128 |
129 | if (!fromCache)
130 | {
131 | ErrorMessage = "Definition generated by AI and saved to cache.";
132 | }
133 | System.Console.WriteLine("[MainViewModel] Definition set successfully");
134 | }
135 | else
136 | {
137 | // Add to history without definition
138 | await _queryHistoryService.AddEntryAsync(query, DateTime.UtcNow);
139 |
140 | System.Console.WriteLine("[MainViewModel] ERROR: No definition available (both cache and AI failed)");
141 | ErrorMessage = "Could not find or generate definition for this word. Check console logs for details.";
142 | }
143 | }
144 | catch (Exception ex)
145 | {
146 | System.Console.WriteLine($"[MainViewModel] EXCEPTION in SearchAsync: {ex.GetType().Name}");
147 | System.Console.WriteLine($"[MainViewModel] Exception message: {ex.Message}");
148 | System.Console.WriteLine($"[MainViewModel] Stack trace: {ex.StackTrace}");
149 | ErrorMessage = $"Error: {ex.Message}";
150 | }
151 | finally
152 | {
153 | IsLoading = false;
154 | System.Console.WriteLine("[MainViewModel] SearchAsync END");
155 | }
156 | }
157 |
158 | private void OpenSettings()
159 | {
160 | OpenSettingsRequested?.Invoke(this, EventArgs.Empty);
161 | }
162 |
163 | private void OpenStatistics()
164 | {
165 | OpenStatisticsRequested?.Invoke(this, EventArgs.Empty);
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/Aictionary/ViewModels/SettingsViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.ObjectModel;
4 | using System.Linq;
5 | using System.Reactive;
6 | using System.Reactive.Linq;
7 | using System.Threading.Tasks;
8 | using System.Runtime.InteropServices;
9 | using Aictionary.Models;
10 | using Aictionary.Services;
11 | using DynamicData;
12 | using ReactiveUI;
13 |
14 | namespace Aictionary.ViewModels;
15 |
16 | public class SettingsViewModel : ViewModelBase
17 | {
18 | private readonly ISettingsService _settingsService;
19 | private readonly IDictionaryService _dictionaryService;
20 | private readonly IOpenAIService _openAIService;
21 | private readonly IHotkeyService? _hotkeyService;
22 | private readonly QuickQueryService? _quickQueryService;
23 |
24 | private string _apiBaseUrl = string.Empty;
25 | private string _apiKey = string.Empty;
26 | private string _model = string.Empty;
27 | private string _dictionaryPath = string.Empty;
28 | private string _statusMessage = string.Empty;
29 | private bool _isApiKeyVisible = false;
30 | private DictionaryDownloadSource _selectedDownloadSource;
31 | private bool _isLoadingModels = false;
32 | private string _searchText = string.Empty;
33 | private bool _isLoadingCachedWords = false;
34 | private string _quickQueryHotkey = "Command+Shift+D";
35 | private bool _hasAccessibilityPermissions = true;
36 | private string _permissionStatusMessage = string.Empty;
37 | private bool _isCheckingPermissions = false;
38 | private bool _isAccessibilitySectionVisible;
39 |
40 | private readonly ObservableCollection _availableModels = new();
41 | private readonly ObservableCollection _cachedWords = new();
42 | private readonly ObservableCollection _filteredCachedWords = new();
43 | private readonly ObservableCollection _availableDownloadSources = new()
44 | {
45 | DictionaryDownloadSource.GitHub,
46 | DictionaryDownloadSource.Gitee
47 | };
48 |
49 | public SettingsViewModel(
50 | ISettingsService settingsService,
51 | IDictionaryService dictionaryService,
52 | IOpenAIService openAIService,
53 | IHotkeyService? hotkeyService = null,
54 | QuickQueryService? quickQueryService = null)
55 | {
56 | _settingsService = settingsService;
57 | _dictionaryService = dictionaryService;
58 | _openAIService = openAIService;
59 | _hotkeyService = hotkeyService;
60 | _quickQueryService = quickQueryService;
61 |
62 | IsAccessibilitySectionVisible = _hotkeyService != null && RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
63 |
64 | DownloadDictionaryCommand = ReactiveCommand.CreateFromTask(DownloadDictionaryAsync);
65 | RefreshModelsCommand = ReactiveCommand.CreateFromTask(RefreshModelsAsync);
66 | ToggleApiKeyVisibilityCommand = ReactiveCommand.Create(ToggleApiKeyVisibility);
67 | RefreshCachedWordsCommand = ReactiveCommand.CreateFromTask(RefreshCachedWordsAsync);
68 | var canManageAccessibility = this.WhenAnyValue(x => x.IsAccessibilitySectionVisible);
69 | RequestAccessibilityPermissionsCommand = ReactiveCommand.Create(RequestAccessibilityPermissions, canManageAccessibility);
70 | CheckPermissionsCommand = ReactiveCommand.CreateFromTask(CheckPermissionsAsync, canManageAccessibility);
71 |
72 | LoadSettings();
73 | _ = Task.Run(async () => await CheckPermissionsAsync());
74 |
75 | // Auto-load cached words on startup
76 | _ = Task.Run(async () =>
77 | {
78 | await RefreshCachedWordsAsync();
79 | });
80 |
81 | // Initialize available models with current model if it exists
82 | if (!string.IsNullOrEmpty(Model))
83 | {
84 | _availableModels.Add(Model);
85 | }
86 |
87 | // Auto-load models list only if no model is selected
88 | if (string.IsNullOrEmpty(Model))
89 | {
90 | _ = Task.Run(async () =>
91 | {
92 | await RefreshModelsAsync();
93 | });
94 | }
95 |
96 | // Auto-save settings when any property changes
97 | this.WhenAnyValue(
98 | x => x.ApiBaseUrl,
99 | x => x.ApiKey,
100 | x => x.Model,
101 | x => x.DictionaryPath,
102 | x => x.QuickQueryHotkey,
103 | x => x.SelectedDownloadSource
104 | )
105 | .Skip(1) // Skip the initial load
106 | .Throttle(TimeSpan.FromMilliseconds(500)) // Debounce to avoid saving too frequently
107 | .SelectMany(_ => System.Reactive.Linq.Observable.FromAsync(() => AutoSaveSettingsAsync()))
108 | .Subscribe(
109 | _ => { },
110 | ex => System.Console.WriteLine($"[SettingsViewModel] Auto-save subscription ERROR: {ex.Message}")
111 | );
112 | }
113 |
114 | public string ApiBaseUrl
115 | {
116 | get => _apiBaseUrl;
117 | set => this.RaiseAndSetIfChanged(ref _apiBaseUrl, value);
118 | }
119 |
120 | public string ApiKey
121 | {
122 | get => _apiKey;
123 | set => this.RaiseAndSetIfChanged(ref _apiKey, value);
124 | }
125 |
126 | public string Model
127 | {
128 | get => _model;
129 | set
130 | {
131 | System.Console.WriteLine($"[Model Property] Setting Model from '{_model}' to '{value}'");
132 | this.RaiseAndSetIfChanged(ref _model, value);
133 | }
134 | }
135 |
136 | public string DictionaryPath
137 | {
138 | get => _dictionaryPath;
139 | set => this.RaiseAndSetIfChanged(ref _dictionaryPath, value);
140 | }
141 |
142 | public string QuickQueryHotkey
143 | {
144 | get => _quickQueryHotkey;
145 | set => this.RaiseAndSetIfChanged(ref _quickQueryHotkey, value);
146 | }
147 |
148 | public string StatusMessage
149 | {
150 | get => _statusMessage;
151 | set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
152 | }
153 |
154 | public bool IsApiKeyVisible
155 | {
156 | get => _isApiKeyVisible;
157 | set
158 | {
159 | this.RaiseAndSetIfChanged(ref _isApiKeyVisible, value);
160 | this.RaisePropertyChanged(nameof(ApiKeyToggleButtonText));
161 | }
162 | }
163 |
164 | public string ApiKeyToggleButtonText => IsApiKeyVisible ? "Hide" : "Show";
165 |
166 | public bool IsLoadingModels
167 | {
168 | get => _isLoadingModels;
169 | set => this.RaiseAndSetIfChanged(ref _isLoadingModels, value);
170 | }
171 |
172 | public ObservableCollection AvailableModels => _availableModels;
173 |
174 | public string SearchText
175 | {
176 | get => _searchText;
177 | set
178 | {
179 | this.RaiseAndSetIfChanged(ref _searchText, value);
180 | FilterCachedWords();
181 | }
182 | }
183 |
184 | public bool IsLoadingCachedWords
185 | {
186 | get => _isLoadingCachedWords;
187 | set => this.RaiseAndSetIfChanged(ref _isLoadingCachedWords, value);
188 | }
189 |
190 | public ObservableCollection CachedWords => _cachedWords;
191 | public ObservableCollection FilteredCachedWords => _filteredCachedWords;
192 |
193 | public DictionaryDownloadSource SelectedDownloadSource
194 | {
195 | get => _selectedDownloadSource;
196 | set => this.RaiseAndSetIfChanged(ref _selectedDownloadSource, value);
197 | }
198 |
199 | public ObservableCollection AvailableDownloadSources => _availableDownloadSources;
200 |
201 | public bool HasAccessibilityPermissions
202 | {
203 | get => _hasAccessibilityPermissions;
204 | set => this.RaiseAndSetIfChanged(ref _hasAccessibilityPermissions, value);
205 | }
206 |
207 | public string PermissionStatusMessage
208 | {
209 | get => _permissionStatusMessage;
210 | set => this.RaiseAndSetIfChanged(ref _permissionStatusMessage, value);
211 | }
212 |
213 | public bool IsCheckingPermissions
214 | {
215 | get => _isCheckingPermissions;
216 | set => this.RaiseAndSetIfChanged(ref _isCheckingPermissions, value);
217 | }
218 |
219 | public bool IsAccessibilitySectionVisible
220 | {
221 | get => _isAccessibilitySectionVisible;
222 | private set => this.RaiseAndSetIfChanged(ref _isAccessibilitySectionVisible, value);
223 | }
224 |
225 | public ReactiveCommand DownloadDictionaryCommand { get; }
226 | public ReactiveCommand RefreshModelsCommand { get; }
227 | public ReactiveCommand ToggleApiKeyVisibilityCommand { get; }
228 | public ReactiveCommand RefreshCachedWordsCommand { get; }
229 | public ReactiveCommand RequestAccessibilityPermissionsCommand { get; }
230 | public ReactiveCommand CheckPermissionsCommand { get; }
231 |
232 | private void LoadSettings()
233 | {
234 | var settings = _settingsService.CurrentSettings;
235 | ApiBaseUrl = settings.ApiBaseUrl;
236 | ApiKey = settings.ApiKey;
237 | Model = settings.Model;
238 | DictionaryPath = settings.DictionaryPath;
239 | QuickQueryHotkey = settings.QuickQueryHotkey;
240 | SelectedDownloadSource = settings.DictionaryDownloadSource;
241 | }
242 |
243 | private async Task AutoSaveSettingsAsync()
244 | {
245 | try
246 | {
247 | var settings = new AppSettings
248 | {
249 | ApiBaseUrl = ApiBaseUrl,
250 | ApiKey = ApiKey,
251 | Model = Model,
252 | DictionaryPath = DictionaryPath,
253 | QuickQueryHotkey = QuickQueryHotkey,
254 | DictionaryDownloadSource = SelectedDownloadSource
255 | };
256 |
257 | await _settingsService.SaveSettingsAsync(settings);
258 | }
259 | catch (Exception ex)
260 | {
261 | System.Console.WriteLine($"[SettingsViewModel] Auto-save ERROR: {ex.Message}");
262 | }
263 | }
264 |
265 | private async Task DownloadDictionaryAsync()
266 | {
267 | System.Console.WriteLine("[SettingsViewModel] DownloadDictionaryAsync started");
268 | try
269 | {
270 | if (string.IsNullOrEmpty(DictionaryPath))
271 | {
272 | StatusMessage = "Please configure dictionary path first.";
273 | return;
274 | }
275 |
276 | System.Console.WriteLine("[SettingsViewModel] Creating services");
277 | var resourceService = new DictionaryResourceService();
278 | var downloadService = new DictionaryDownloadService(resourceService);
279 |
280 | System.Console.WriteLine("[SettingsViewModel] Creating download window");
281 | var downloadWindow = new Views.DownloadProgressWindow();
282 |
283 | System.Console.WriteLine("[SettingsViewModel] Showing download window");
284 | downloadWindow.Show();
285 |
286 | System.Console.WriteLine($"[SettingsViewModel] Starting download to path: {DictionaryPath}");
287 | System.Console.WriteLine($"[SettingsViewModel] Using download source: {SelectedDownloadSource}");
288 | await downloadService.EnsureDictionaryExistsAsync(DictionaryPath, SelectedDownloadSource, (message, progress) =>
289 | {
290 | System.Console.WriteLine($"[SettingsViewModel] Progress callback: {message} ({progress}%)");
291 | Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
292 | {
293 | System.Console.WriteLine($"[SettingsViewModel] UI Thread updating: {message}");
294 | downloadWindow.ViewModel.StatusMessage = message;
295 | downloadWindow.ViewModel.Progress = progress;
296 | downloadWindow.ViewModel.IsIndeterminate = progress < 10;
297 |
298 | if (progress >= 100)
299 | {
300 | System.Console.WriteLine("[SettingsViewModel] Download completed!");
301 | downloadWindow.ViewModel.IsCompleted = true;
302 | StatusMessage = "Dictionary downloaded successfully!";
303 |
304 | // Refresh the cached words list
305 | _ = Task.Run(async () => await RefreshCachedWordsAsync());
306 | }
307 | });
308 | });
309 | System.Console.WriteLine("[SettingsViewModel] Download finished successfully");
310 | }
311 | catch (Exception ex)
312 | {
313 | System.Console.WriteLine($"[SettingsViewModel] Download ERROR: {ex.GetType().Name}: {ex.Message}");
314 | System.Console.WriteLine($"[SettingsViewModel] Stack trace: {ex.StackTrace}");
315 | StatusMessage = $"Error downloading dictionary: {ex.Message}";
316 | }
317 | }
318 |
319 | private async Task RefreshModelsAsync()
320 | {
321 | try
322 | {
323 | IsLoadingModels = true;
324 | StatusMessage = "Fetching available models...";
325 |
326 | System.Console.WriteLine($"[RefreshModels] START - Current Model: '{Model}'");
327 |
328 | // Save the current model selection before clearing
329 | var selectedModel = Model;
330 | System.Console.WriteLine($"[RefreshModels] Saved selected model: '{selectedModel}'");
331 |
332 | var models = await _openAIService.GetAvailableModelsAsync();
333 |
334 | System.Console.WriteLine($"[RefreshModels] Fetched {models.Count} models from API");
335 | System.Console.WriteLine($"[RefreshModels] Before Clear - AvailableModels count: {_availableModels.Count}");
336 |
337 | _availableModels.Clear();
338 |
339 | System.Console.WriteLine($"[RefreshModels] After Clear - Current Model is now: '{Model}'");
340 |
341 | // Add the saved model first if it's not in the list
342 | if (!string.IsNullOrEmpty(selectedModel) && !models.Contains(selectedModel))
343 | {
344 | System.Console.WriteLine($"[RefreshModels] Adding saved model '{selectedModel}' to list (not in API response)");
345 | _availableModels.Add(selectedModel);
346 | }
347 | else if (!string.IsNullOrEmpty(selectedModel))
348 | {
349 | System.Console.WriteLine($"[RefreshModels] Saved model '{selectedModel}' is already in API response");
350 | }
351 |
352 | _availableModels.AddRange(models);
353 |
354 | System.Console.WriteLine($"[RefreshModels] After AddRange - AvailableModels count: {_availableModels.Count}");
355 |
356 | // Restore the selected model
357 | if (!string.IsNullOrEmpty(selectedModel))
358 | {
359 | System.Console.WriteLine($"[RefreshModels] Restoring Model to: '{selectedModel}'");
360 | Model = selectedModel;
361 | }
362 |
363 | System.Console.WriteLine($"[RefreshModels] END - Current Model: '{Model}'");
364 |
365 | StatusMessage = models.Count > 0
366 | ? $"Loaded {models.Count} models successfully"
367 | : "No models found. Please check your API key and base URL.";
368 | }
369 | catch (Exception ex)
370 | {
371 | System.Console.WriteLine($"[RefreshModels] ERROR: {ex.Message}");
372 | StatusMessage = $"Error fetching models: {ex.Message}";
373 |
374 | // If fetching fails and we have a current model, ensure it's in the list
375 | if (!string.IsNullOrEmpty(Model) && !_availableModels.Contains(Model))
376 | {
377 | System.Console.WriteLine($"[RefreshModels] Exception handler - Adding model '{Model}' to list");
378 | _availableModels.Add(Model);
379 | }
380 | }
381 | finally
382 | {
383 | IsLoadingModels = false;
384 | System.Console.WriteLine($"[RefreshModels] FINALLY - IsLoadingModels set to false");
385 | }
386 | }
387 |
388 | private void ToggleApiKeyVisibility()
389 | {
390 | IsApiKeyVisible = !IsApiKeyVisible;
391 | }
392 |
393 | private async Task RefreshCachedWordsAsync()
394 | {
395 | try
396 | {
397 | IsLoadingCachedWords = true;
398 |
399 | var words = await _dictionaryService.GetCachedWordsAsync();
400 |
401 | _cachedWords.Clear();
402 | _cachedWords.AddRange(words);
403 |
404 | FilterCachedWords();
405 | }
406 | catch (Exception ex)
407 | {
408 | System.Console.WriteLine($"[SettingsViewModel] Error loading cached words: {ex.Message}");
409 | }
410 | finally
411 | {
412 | IsLoadingCachedWords = false;
413 | }
414 | }
415 |
416 | private void FilterCachedWords()
417 | {
418 | _filteredCachedWords.Clear();
419 |
420 | if (string.IsNullOrWhiteSpace(SearchText))
421 | {
422 | _filteredCachedWords.AddRange(_cachedWords);
423 | }
424 | else
425 | {
426 | var filtered = _cachedWords.Where(word =>
427 | word.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
428 | _filteredCachedWords.AddRange(filtered);
429 | }
430 | }
431 |
432 | private async Task CheckPermissionsAsync()
433 | {
434 | if (!IsAccessibilitySectionVisible || _hotkeyService == null)
435 | {
436 | HasAccessibilityPermissions = true;
437 | PermissionStatusMessage = string.Empty;
438 | IsCheckingPermissions = false;
439 | return;
440 | }
441 |
442 | try
443 | {
444 | IsCheckingPermissions = true;
445 | PermissionStatusMessage = "Checking accessibility permissions...";
446 |
447 | // Give UI a chance to update
448 | await Task.Delay(100);
449 |
450 | var hasPermissions = await Task.Run(() => _hotkeyService.CheckAccessibilityPermissions());
451 | HasAccessibilityPermissions = hasPermissions;
452 |
453 | PermissionStatusMessage = hasPermissions
454 | ? "Accessibility permissions granted"
455 | : "Accessibility permissions required for global hotkeys";
456 |
457 | // If permissions are now granted, re-register the hotkey
458 | if (hasPermissions && _quickQueryService != null)
459 | {
460 | System.Console.WriteLine("[SettingsViewModel] Permissions granted, re-registering hotkey...");
461 | _quickQueryService.ReregisterHotkey();
462 | PermissionStatusMessage = "Accessibility permissions granted. Hotkey registered successfully.";
463 | }
464 | }
465 | catch (System.Exception ex)
466 | {
467 | PermissionStatusMessage = $"Error checking permissions: {ex.Message}";
468 | System.Console.WriteLine($"[SettingsViewModel] Error checking accessibility permissions: {ex.Message}");
469 | }
470 | finally
471 | {
472 | IsCheckingPermissions = false;
473 | System.Console.WriteLine($"[SettingsViewModel] Accessibility permissions: {HasAccessibilityPermissions}");
474 | }
475 | }
476 |
477 | private void RequestAccessibilityPermissions()
478 | {
479 | if (!IsAccessibilitySectionVisible || _hotkeyService == null)
480 | {
481 | return;
482 | }
483 |
484 | PermissionStatusMessage = "Opening System Preferences. Grant access and then click Recheck Status.";
485 | _hotkeyService.RequestAccessibilityPermissions();
486 |
487 | // Recheck after a short delay to update status
488 | Task.Delay(1000).ContinueWith(_ =>
489 | {
490 | Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () =>
491 | {
492 | await CheckPermissionsAsync();
493 | });
494 | });
495 | }
496 | }
497 |
--------------------------------------------------------------------------------
/Aictionary/ViewModels/StatisticsViewModel.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Collections.ObjectModel;
4 | using System.Globalization;
5 | using System.Linq;
6 | using System.Threading.Tasks;
7 | using Aictionary.Models;
8 | using Aictionary.Services;
9 | using ReactiveUI;
10 |
11 | namespace Aictionary.ViewModels;
12 |
13 | public class StatisticsViewModel : ViewModelBase
14 | {
15 | private readonly IQueryHistoryService _historyService;
16 |
17 | private bool _isLoading;
18 | private string _emptyMessage = string.Empty;
19 |
20 | public StatisticsViewModel(IQueryHistoryService historyService)
21 | {
22 | _historyService = historyService;
23 | }
24 |
25 | public ObservableCollection DailyGroups { get; } = new();
26 | public ObservableCollection WeeklyGroups { get; } = new();
27 | public ObservableCollection MonthlyGroups { get; } = new();
28 | public ObservableCollection YearlyGroups { get; } = new();
29 |
30 | public bool IsLoading
31 | {
32 | get => _isLoading;
33 | private set => this.RaiseAndSetIfChanged(ref _isLoading, value);
34 | }
35 |
36 | public string EmptyMessage
37 | {
38 | get => _emptyMessage;
39 | private set => this.RaiseAndSetIfChanged(ref _emptyMessage, value);
40 | }
41 |
42 | public async Task LoadAsync()
43 | {
44 | IsLoading = true;
45 | DailyGroups.Clear();
46 | WeeklyGroups.Clear();
47 | MonthlyGroups.Clear();
48 | YearlyGroups.Clear();
49 | EmptyMessage = string.Empty;
50 |
51 | try
52 | {
53 | var entries = await _historyService.GetEntriesAsync();
54 |
55 | if (entries.Count == 0)
56 | {
57 | EmptyMessage = "No queries recorded yet.";
58 | return;
59 | }
60 |
61 | PopulateDailyGroups(entries);
62 | PopulateWeeklyGroups(entries);
63 | PopulateMonthlyGroups(entries);
64 | PopulateYearlyGroups(entries);
65 | }
66 | finally
67 | {
68 | IsLoading = false;
69 | }
70 | }
71 |
72 | private void PopulateDailyGroups(IReadOnlyList entries)
73 | {
74 | PopulateGroups(
75 | entries,
76 | DailyGroups,
77 | localTime => localTime.Date,
78 | start => start.ToString("MMMM dd, yyyy"));
79 | }
80 |
81 | private void PopulateWeeklyGroups(IReadOnlyList entries)
82 | {
83 | PopulateGroups(
84 | entries,
85 | WeeklyGroups,
86 | GetStartOfWeek,
87 | start =>
88 | {
89 | var end = start.AddDays(6);
90 | return $"Week of {start:MMM dd} – {end:MMM dd, yyyy}";
91 | });
92 | }
93 |
94 | private void PopulateMonthlyGroups(IReadOnlyList entries)
95 | {
96 | PopulateGroups(
97 | entries,
98 | MonthlyGroups,
99 | localTime => new DateTime(localTime.Year, localTime.Month, 1),
100 | start => start.ToString("MMMM yyyy"));
101 | }
102 |
103 | private void PopulateYearlyGroups(IReadOnlyList entries)
104 | {
105 | PopulateGroups(
106 | entries,
107 | YearlyGroups,
108 | localTime => new DateTime(localTime.Year, 1, 1),
109 | start => start.ToString("yyyy"));
110 | }
111 |
112 | private void PopulateGroups(
113 | IReadOnlyList entries,
114 | ObservableCollection target,
115 | Func periodStartSelector,
116 | Func periodLabelSelector)
117 | {
118 | var grouped = entries
119 | .Select(entry =>
120 | {
121 | var localTime = entry.QueriedAt.ToLocalTime();
122 | var start = periodStartSelector(localTime);
123 | var label = periodLabelSelector(start);
124 | return new
125 | {
126 | Entry = entry,
127 | LocalTime = localTime,
128 | Start = start,
129 | Label = label
130 | };
131 | })
132 | .GroupBy(x => x.Start)
133 | .OrderByDescending(g => g.Key);
134 |
135 | foreach (var group in grouped)
136 | {
137 | var wordGroups = group
138 | .GroupBy(x => x.Entry.Word, StringComparer.OrdinalIgnoreCase)
139 | .Select(gw =>
140 | {
141 | var mostRecent = gw.Max(x => x.LocalTime);
142 | var mostRecentEntry = gw
143 | .OrderByDescending(x => x.LocalTime)
144 | .ThenBy(x => x.Entry.Word, StringComparer.OrdinalIgnoreCase)
145 | .First();
146 | return new StatisticsWordCountViewModel(
147 | mostRecentEntry.Entry.Word,
148 | gw.Count(),
149 | mostRecent,
150 | mostRecentEntry.Entry.ConciseDefinition);
151 | })
152 | .OrderByDescending(x => x.Count)
153 | .ThenBy(x => x.Word, StringComparer.OrdinalIgnoreCase)
154 | .ToList();
155 |
156 | target.Add(new StatisticsGroupItemViewModel(
157 | group.First().Label,
158 | group.Count(),
159 | wordGroups));
160 | }
161 | }
162 |
163 | private static DateTime GetStartOfWeek(DateTime date)
164 | {
165 | var culture = CultureInfo.CurrentCulture;
166 | var diff = (7 + (date.DayOfWeek - culture.DateTimeFormat.FirstDayOfWeek)) % 7;
167 | return date.Date.AddDays(-diff);
168 | }
169 |
170 | public IEnumerable GetAllWords()
171 | {
172 | var allWords = new HashSet(StringComparer.OrdinalIgnoreCase);
173 |
174 | foreach (var group in DailyGroups)
175 | {
176 | foreach (var wordCount in group.WordCounts)
177 | {
178 | allWords.Add(wordCount.Word);
179 | }
180 | }
181 |
182 | foreach (var group in WeeklyGroups)
183 | {
184 | foreach (var wordCount in group.WordCounts)
185 | {
186 | allWords.Add(wordCount.Word);
187 | }
188 | }
189 |
190 | foreach (var group in MonthlyGroups)
191 | {
192 | foreach (var wordCount in group.WordCounts)
193 | {
194 | allWords.Add(wordCount.Word);
195 | }
196 | }
197 |
198 | foreach (var group in YearlyGroups)
199 | {
200 | foreach (var wordCount in group.WordCounts)
201 | {
202 | allWords.Add(wordCount.Word);
203 | }
204 | }
205 |
206 | return allWords.OrderBy(w => w, StringComparer.OrdinalIgnoreCase);
207 | }
208 |
209 | public async Task RemoveWordAsync(string word)
210 | {
211 | await _historyService.RemoveWordEntriesAsync(word);
212 | await LoadAsync();
213 | }
214 | }
215 |
216 | public class StatisticsGroupItemViewModel
217 | {
218 | public StatisticsGroupItemViewModel(string label, int totalQueries, IList wordCounts)
219 | {
220 | Label = label;
221 | TotalQueries = totalQueries;
222 | WordCounts = new ReadOnlyCollection(wordCounts);
223 | }
224 |
225 | public string Label { get; }
226 | public int TotalQueries { get; }
227 | public IReadOnlyList WordCounts { get; }
228 | }
229 |
230 | public class StatisticsWordCountViewModel
231 | {
232 | public StatisticsWordCountViewModel(string word, int count, DateTime lastQueriedAtLocal, string? conciseDefinition = null)
233 | {
234 | Word = word;
235 | Count = count;
236 | LastQueriedAtLocal = lastQueriedAtLocal;
237 | ConciseDefinition = conciseDefinition;
238 | }
239 |
240 | public string Word { get; }
241 | public int Count { get; }
242 | public DateTime LastQueriedAtLocal { get; }
243 | public string? ConciseDefinition { get; }
244 | }
245 |
--------------------------------------------------------------------------------
/Aictionary/ViewModels/ViewModelBase.cs:
--------------------------------------------------------------------------------
1 | using ReactiveUI;
2 |
3 | namespace Aictionary.ViewModels;
4 |
5 | public class ViewModelBase : ReactiveObject
6 | {
7 | }
--------------------------------------------------------------------------------
/Aictionary/Views/DownloadProgressWindow.axaml:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
24 |
25 |
30 |
31 |
32 |
36 |
37 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/Aictionary/Views/DownloadProgressWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 | using Avalonia.Interactivity;
3 | using Aictionary.ViewModels;
4 | using System.ComponentModel;
5 |
6 | namespace Aictionary.Views;
7 |
8 | public partial class DownloadProgressWindow : Window
9 | {
10 | public DownloadProgressWindow()
11 | {
12 | InitializeComponent();
13 | DataContext = new DownloadProgressViewModel();
14 | Closing += OnClosing;
15 | }
16 |
17 | public DownloadProgressViewModel ViewModel => (DownloadProgressViewModel)DataContext!;
18 |
19 | private void OnClosing(object? sender, CancelEventArgs e)
20 | {
21 | // Prevent closing if download is not completed
22 | if (!ViewModel.IsCompleted)
23 | {
24 | e.Cancel = true;
25 | }
26 | }
27 |
28 | private void CloseButton_Click(object? sender, RoutedEventArgs e)
29 | {
30 | Close();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Aictionary/Views/MainWindow.axaml:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
36 |
37 |
38 |
43 |
47 |
52 |
53 |
54 |
58 |
64 |
70 |
71 |
72 |
73 |
78 |
79 |
80 |
81 |
82 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
114 |
115 |
116 |
117 |
118 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
191 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
--------------------------------------------------------------------------------
/Aictionary/Views/MainWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Aictionary.ViewModels;
3 | using Avalonia.Controls;
4 |
5 | namespace Aictionary.Views;
6 |
7 | public partial class MainWindow : Window
8 | {
9 | public MainWindow()
10 | {
11 | InitializeComponent();
12 | }
13 |
14 | protected override void OnDataContextChanged(EventArgs e)
15 | {
16 | base.OnDataContextChanged(e);
17 |
18 | if (DataContext is MainWindowViewModel viewModel)
19 | {
20 | viewModel.OpenSettingsRequested += OnOpenSettingsRequested;
21 | viewModel.OpenStatisticsRequested += OnOpenStatisticsRequested;
22 | }
23 | }
24 |
25 | private async void OnOpenSettingsRequested(object? sender, EventArgs e)
26 | {
27 | var settingsWindow = App.CreateSettingsWindow();
28 | await settingsWindow.ShowDialog(this);
29 | }
30 |
31 | private async void OnOpenStatisticsRequested(object? sender, EventArgs e)
32 | {
33 | var statisticsWindow = App.CreateStatisticsWindow();
34 | await statisticsWindow.ShowDialog(this);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Aictionary/Views/SettingsWindow.axaml:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
28 |
29 |
30 |
31 |
32 |
33 |
38 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
67 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
86 |
92 |
98 |
99 |
100 |
101 |
102 |
103 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
117 |
122 |
126 |
127 |
132 |
138 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
160 |
163 |
165 |
166 |
167 |
172 |
173 |
174 |
176 |
177 |
178 |
179 |
182 |
185 |
186 |
187 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
234 |
235 |
236 |
237 |
--------------------------------------------------------------------------------
/Aictionary/Views/SettingsWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using Avalonia.Controls;
2 | using Avalonia.Interactivity;
3 | using Avalonia.Platform.Storage;
4 | using Aictionary.ViewModels;
5 | using System.Diagnostics;
6 | using System.IO;
7 | using System.Linq;
8 | using System.Runtime.InteropServices;
9 |
10 | namespace Aictionary.Views;
11 |
12 | public partial class SettingsWindow : Window
13 | {
14 | public SettingsWindow()
15 | {
16 | InitializeComponent();
17 | }
18 |
19 | private async void BrowseDictionaryButton_Click(object? sender, RoutedEventArgs e)
20 | {
21 | var storageProvider = StorageProvider;
22 |
23 | if (!storageProvider.CanPickFolder)
24 | {
25 | return;
26 | }
27 |
28 | var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
29 | {
30 | Title = "Select Dictionary Folder",
31 | AllowMultiple = false
32 | });
33 |
34 | if (folders.Count == 1 && DataContext is SettingsViewModel viewModel)
35 | {
36 | viewModel.DictionaryPath = folders[0].Path.LocalPath;
37 | }
38 | }
39 |
40 | private void OpenDictionaryButton_Click(object? sender, RoutedEventArgs e)
41 | {
42 | if (DataContext is not SettingsViewModel viewModel)
43 | return;
44 |
45 | var path = viewModel.DictionaryPath;
46 | if (string.IsNullOrEmpty(path) || !Directory.Exists(path))
47 | return;
48 |
49 | try
50 | {
51 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
52 | {
53 | Process.Start(new ProcessStartInfo
54 | {
55 | FileName = "explorer.exe",
56 | Arguments = path,
57 | UseShellExecute = true
58 | });
59 | }
60 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
61 | {
62 | Process.Start(new ProcessStartInfo
63 | {
64 | FileName = "open",
65 | Arguments = path,
66 | UseShellExecute = true
67 | });
68 | }
69 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
70 | {
71 | Process.Start(new ProcessStartInfo
72 | {
73 | FileName = "xdg-open",
74 | Arguments = path,
75 | UseShellExecute = true
76 | });
77 | }
78 | }
79 | catch
80 | {
81 | // Silently fail if unable to open folder
82 | }
83 | }
84 |
85 | private void ResetDictionaryButton_Click(object? sender, RoutedEventArgs e)
86 | {
87 | if (DataContext is SettingsViewModel viewModel)
88 | {
89 | viewModel.DictionaryPath = Path.Combine(Directory.GetCurrentDirectory(), "dictionary");
90 | }
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Aictionary/Views/StatisticsWindow.axaml:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
20 |
25 |
26 |
29 |
31 |
32 |
33 |
34 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
47 |
50 |
51 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
72 |
73 |
76 |
84 |
85 |
86 |
91 |
95 |
98 |
99 |
100 |
102 |
103 |
104 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
--------------------------------------------------------------------------------
/Aictionary/Views/StatisticsWindow.axaml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Diagnostics;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Runtime.InteropServices;
6 | using Aictionary.ViewModels;
7 | using Avalonia.Controls;
8 | using Avalonia.Interactivity;
9 | using Avalonia.Platform.Storage;
10 |
11 | namespace Aictionary.Views;
12 |
13 | public partial class StatisticsWindow : Window
14 | {
15 | private bool _isInitialized;
16 |
17 | public StatisticsWindow()
18 | {
19 | InitializeComponent();
20 | }
21 |
22 | protected override async void OnOpened(EventArgs e)
23 | {
24 | base.OnOpened(e);
25 |
26 | if (_isInitialized)
27 | {
28 | return;
29 | }
30 |
31 | if (DataContext is StatisticsViewModel viewModel)
32 | {
33 | try
34 | {
35 | await viewModel.LoadAsync();
36 | }
37 | catch (Exception ex)
38 | {
39 | System.Console.WriteLine($"[StatisticsWindow] LoadAsync ERROR: {ex.Message}");
40 | }
41 | }
42 |
43 | _isInitialized = true;
44 | }
45 |
46 | private async void ExportButton_Click(object? sender, RoutedEventArgs e)
47 | {
48 | if (DataContext is not StatisticsViewModel viewModel)
49 | return;
50 |
51 | var storageProvider = StorageProvider;
52 |
53 | if (!storageProvider.CanSave)
54 | return;
55 |
56 | var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
57 | {
58 | Title = "Export Words to TXT",
59 | SuggestedFileName = "words.txt",
60 | FileTypeChoices = new[]
61 | {
62 | new FilePickerFileType("Text File")
63 | {
64 | Patterns = new[] { "*.txt" }
65 | }
66 | }
67 | });
68 |
69 | if (file == null)
70 | return;
71 |
72 | try
73 | {
74 | var words = viewModel.GetAllWords();
75 | var content = string.Join(Environment.NewLine, words);
76 | var filePath = file.Path.LocalPath;
77 | await File.WriteAllTextAsync(filePath, content);
78 |
79 | // Open the file location in Finder/Explorer
80 | OpenFileLocation(filePath);
81 | }
82 | catch (Exception ex)
83 | {
84 | System.Console.WriteLine($"[StatisticsWindow] Export ERROR: {ex.Message}");
85 | }
86 | }
87 |
88 | private void OpenFileLocation(string filePath)
89 | {
90 | try
91 | {
92 | var directory = Path.GetDirectoryName(filePath);
93 | if (string.IsNullOrEmpty(directory))
94 | return;
95 |
96 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
97 | {
98 | Process.Start(new ProcessStartInfo
99 | {
100 | FileName = "explorer.exe",
101 | Arguments = $"/select,\"{filePath}\"",
102 | UseShellExecute = true
103 | });
104 | }
105 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
106 | {
107 | Process.Start(new ProcessStartInfo
108 | {
109 | FileName = "open",
110 | Arguments = $"-R \"{filePath}\"",
111 | UseShellExecute = true
112 | });
113 | }
114 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
115 | {
116 | Process.Start(new ProcessStartInfo
117 | {
118 | FileName = "xdg-open",
119 | Arguments = directory,
120 | UseShellExecute = true
121 | });
122 | }
123 | }
124 | catch (Exception ex)
125 | {
126 | System.Console.WriteLine($"[StatisticsWindow] Open location ERROR: {ex.Message}");
127 | }
128 | }
129 |
130 | private async void DeleteWordButton_Click(object? sender, RoutedEventArgs e)
131 | {
132 | if (sender is not Button button || button.Tag is not string word)
133 | return;
134 |
135 | if (DataContext is not StatisticsViewModel viewModel)
136 | return;
137 |
138 | try
139 | {
140 | await viewModel.RemoveWordAsync(word);
141 | }
142 | catch (Exception ex)
143 | {
144 | System.Console.WriteLine($"[StatisticsWindow] Delete word ERROR: {ex.Message}");
145 | }
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/Aictionary/app.manifest:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
10 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Aictionary
2 |
3 | 快速且异常好用的词典 App,而且,它甚至跨平台!
4 |
5 |
6 |
7 |
8 | ---
9 |
10 | ## 功能特性
11 |
12 | - **最详细的中文解释** - 不同于传统词典的粗浅释义,本项目的中文释义都非常详细、易懂
13 | - **快速查询单词** - 基于词频统计的前 25000 个单词已经被默认下载,覆盖绝大部分生词情景
14 | - **大语言模型驱动** - 如果你真的遇到了词库里没有的词,也可以调用大模型生成一份词义
15 | - **跨平台支持** - 基于 Avalonia 框架,支持 Windows、macOS 和 Linux
16 |
17 | 除此之外,我们还围绕日常使用体验做了不少优化:
18 |
19 | - **实时响应**:查词窗口轻量、打开即用,支持键盘快捷键快速查询、复制和刷新;
20 | - **本地缓存**:自动保存历史查询记录,可离线查看,并可按需刷新保证释义始终同步;
21 | - **多形态发布**:提供自包含与框架依赖两套版本,macOS 用户还可直接使用打包好的 DMG。
22 |
23 | ## 下载与安装
24 |
25 | 构建完成后,所有产物都会落在 `artifacts/` 目录下,也可在发布页直接获取:
26 |
27 | | 平台 | 自包含产物 | 体积更小的框架依赖产物 |
28 | | --- | --- | --- |
29 | | macOS | `artifacts/macos/Aictionary.app`
`artifacts/macos/Aictionary.dmg` | `artifacts/macos-framework-dependent/Aictionary.app` |
30 | | Windows | `artifacts/windows/Aictionary-win-x64` | `artifacts/windows-framework-dependent/Aictionary-win-x64` |
31 |
32 | - macOS 建议直接打开 `Aictionary.dmg`,将 App 拖入 `Applications` 即可;
33 | - Windows 可按是否安装 .NET 运行时选择对应文件夹;
34 | - Linux 用户可使用源码编译运行(参考下文“开发构建”)。
35 |
36 | > 初次使用别忘了在设置页配置 OpenAI API Key,当本地词库缺少词条时会调用大模型补充释义。
37 |
38 | ## 使用提示
39 |
40 | - **键盘操作**:涵盖复制查询、刷新词库等快捷键,阅读英文资料时切换成本极低;
41 | - **缓存管理**:设置页可查看、刷新缓存词条,方便构建个人生词本;
42 | - **中文释义**:大模型生成的解释包含语义说明、例句与常见搭配,更贴合技术、学术场景。
43 |
44 | ## 技术栈
45 |
46 | - **.NET 8.0**
47 | - **Avalonia 11.2.7**
48 | - **ReactiveUI**
49 |
50 | ## 开发构建
51 |
52 | 项目使用 [NUKE](https://nuke.build/) 编排构建流程,运行以下命令即可生成全部发行包:
53 |
54 | ```bash
55 | dotnet run --project build/build.csproj
56 | ```
57 |
58 | 执行后会在 `artifacts/` 目录生成:
59 |
60 | - macOS 自包含 App、框架依赖 App 以及 DMG 安装包;
61 | - Windows 自包含与框架依赖版本。
62 |
63 | 如需自定义流程,可阅读 `build/Build.cs` 中各个 target 的实现。
64 |
65 | ## 反馈与贡献
66 |
67 | - 欢迎通过 Issue 反馈问题、分享改进想法;
68 | - PR 亦非常欢迎,请附上截图或说明,便于快速审阅;
69 | - 希望扩展词库、适配更多平台或接入新的大模型?一起来讨论吧!
70 |
--------------------------------------------------------------------------------
/build/BUILD.md:
--------------------------------------------------------------------------------
1 | # Aictionary Build Documentation
2 |
3 | This project uses [NUKE](https://nuke.build/) for automated build and publishing.
4 |
5 | ## Prerequisites
6 |
7 | - .NET 8.0 SDK or later
8 | - NUKE global tool (will be installed automatically if missing)
9 |
10 | ## Available Build Targets
11 |
12 | The build system supports the following targets:
13 |
14 | - **Clean**: Cleans the artifacts directory
15 | - **Restore**: Restores NuGet packages
16 | - **Compile**: Compiles the solution
17 | - **PublishWindows**: Publishes Windows x64 build
18 | - **PublishMacOS**: Publishes macOS ARM64 build
19 | - **Publish**: Publishes both Windows and macOS builds (default target)
20 |
21 | ## Quick Start
22 |
23 | ```bash
24 | # From the project root directory
25 | ./build.sh
26 |
27 | # Or on Windows
28 | .\build.ps1
29 | ```
30 |
31 | ## How to Run
32 |
33 | ### Option 1: Using build scripts (Recommended)
34 |
35 | #### On macOS/Linux:
36 | ```bash
37 | ./build.sh
38 |
39 | # Publish only Windows
40 | ./build.sh --target PublishWindows
41 |
42 | # Publish only macOS
43 | ./build.sh --target PublishMacOS
44 | ```
45 |
46 | #### On Windows:
47 | ```powershell
48 | .\build.ps1
49 |
50 | # Publish only Windows
51 | .\build.ps1 --target PublishWindows
52 |
53 | # Publish only macOS
54 | .\build.ps1 --target PublishMacOS
55 | ```
56 |
57 | ### Option 2: Using dotnet run
58 |
59 | Navigate to the repository root and run:
60 |
61 | ```bash
62 | # Publish all platforms (default)
63 | dotnet run --project build/build.csproj
64 |
65 | # Publish only Windows
66 | dotnet run --project build/build.csproj --target PublishWindows
67 |
68 | # Publish only macOS
69 | dotnet run --project build/build.csproj --target PublishMacOS
70 |
71 | # Just compile without publishing
72 | dotnet run --project build/build.csproj --target Compile
73 | ```
74 |
75 | ## Output
76 |
77 | Published artifacts will be located in the `artifacts` directory:
78 |
79 | - **Windows**: `artifacts/windows/Aictionary-win-x64/`
80 | - **macOS**: `artifacts/macos/Aictionary.app/`
81 |
82 | The macOS build includes:
83 | - Proper app bundle structure
84 | - App icon (AppIcon.icns)
85 | - Info.plist configuration
86 |
87 | ## Build Configuration
88 |
89 | You can specify the configuration (Debug/Release) using the `--configuration` parameter:
90 |
91 | ```bash
92 | dotnet run --project build/build.csproj --configuration Debug
93 | ```
94 |
95 | Default configuration is **Release**.
96 |
97 | ## Notes
98 |
99 | - The Windows build is self-contained and targets x64 architecture
100 | - The macOS build is self-contained and targets ARM64 (Apple Silicon)
101 | - Both builds use single-file publishing for easier distribution
102 | - Trimming is disabled to avoid potential issues with reflection-heavy code
103 |
104 | ## Troubleshooting
105 |
106 | If you encounter issues:
107 |
108 | 1. Ensure .NET 8.0 SDK is installed: `dotnet --version`
109 | 2. Clean and restore: `dotnet run --project build/build.csproj --target Clean`
110 | 3. Check the build output for specific error messages
111 |
112 | For more information about NUKE, visit: https://nuke.build/
113 |
--------------------------------------------------------------------------------
/build/Build.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using Nuke.Common;
4 | using Nuke.Common.IO;
5 | using Nuke.Common.ProjectModel;
6 | using Nuke.Common.Tooling;
7 | using Nuke.Common.Tools.DotNet;
8 | using static Nuke.Common.Tools.DotNet.DotNetTasks;
9 |
10 | class Build : NukeBuild
11 | {
12 | public static int Main() => Execute(x => x.Publish);
13 |
14 | [Parameter("Configuration to build - Default is 'Release'")]
15 | readonly string Configuration = "Release";
16 |
17 | AbsolutePath SourceDirectory => RootDirectory / "Aictionary";
18 | AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts";
19 | AbsolutePath WindowsArtifactDirectory => ArtifactsDirectory / "windows";
20 | AbsolutePath WindowsFrameworkArtifactDirectory => ArtifactsDirectory / "windows-framework-dependent";
21 | AbsolutePath MacOSArtifactDirectory => ArtifactsDirectory / "macos";
22 | AbsolutePath MacOSFrameworkArtifactDirectory => ArtifactsDirectory / "macos-framework-dependent";
23 | AbsolutePath MacOSDmgFile => MacOSArtifactDirectory / "Aictionary.dmg";
24 | AbsolutePath ProjectFile => SourceDirectory / "Aictionary.csproj";
25 |
26 | Target Clean => _ => _
27 | .Before(Restore)
28 | .Executes(() =>
29 | {
30 | if (Directory.Exists(ArtifactsDirectory))
31 | {
32 | try
33 | {
34 | Directory.Delete(ArtifactsDirectory, true);
35 | }
36 | catch (IOException)
37 | {
38 | var backupDirectory = ArtifactsDirectory + $"_{DateTime.Now:yyyyMMddHHmmssfff}";
39 | Directory.Move(ArtifactsDirectory, backupDirectory);
40 | Serilog.Log.Warning("Could not delete artifacts directory, moved to {BackupDirectory}", backupDirectory);
41 | }
42 | catch (UnauthorizedAccessException)
43 | {
44 | var backupDirectory = ArtifactsDirectory + $"_{DateTime.Now:yyyyMMddHHmmssfff}";
45 | Directory.Move(ArtifactsDirectory, backupDirectory);
46 | Serilog.Log.Warning("Could not delete artifacts directory due to access restrictions, moved to {BackupDirectory}", backupDirectory);
47 | }
48 | }
49 |
50 | Directory.CreateDirectory(ArtifactsDirectory);
51 | });
52 |
53 | Target Restore => _ => _
54 | .DependsOn(Clean)
55 | .Executes(() =>
56 | {
57 | DotNetRestore(s => s
58 | .SetProjectFile(ProjectFile));
59 | });
60 |
61 | Target Compile => _ => _
62 | .DependsOn(Restore)
63 | .Executes(() =>
64 | {
65 | DotNetBuild(s => s
66 | .SetProjectFile(ProjectFile)
67 | .SetConfiguration(Configuration)
68 | .EnableNoRestore());
69 | });
70 |
71 | Target PublishWindows => _ => _
72 | .DependsOn(Clean)
73 | .Executes(() =>
74 | {
75 | DotNetPublish(s => s
76 | .SetProject(ProjectFile)
77 | .SetConfiguration(Configuration)
78 | .SetRuntime("win-x64")
79 | .SetSelfContained(true)
80 | .SetPublishSingleFile(true)
81 | .SetPublishTrimmed(false)
82 | .SetOutput(WindowsArtifactDirectory / "Aictionary-win-x64"));
83 |
84 | Serilog.Log.Information($"Windows artifact published to: {WindowsArtifactDirectory}");
85 | });
86 |
87 | Target PublishMacOS => _ => _
88 | .DependsOn(Clean)
89 | .Executes(() =>
90 | {
91 | var publishOutput = MacOSArtifactDirectory / "publish";
92 | var appBundlePath = MacOSArtifactDirectory / "Aictionary.app";
93 | var contentsPath = appBundlePath / "Contents";
94 | var macOsPath = contentsPath / "MacOS";
95 |
96 | if (Directory.Exists(publishOutput))
97 | Directory.Delete(publishOutput, true);
98 |
99 | DotNetPublish(s => s
100 | .SetProject(ProjectFile)
101 | .SetConfiguration(Configuration)
102 | .SetRuntime("osx-arm64")
103 | .SetSelfContained(true)
104 | .SetPublishSingleFile(true)
105 | .SetPublishTrimmed(false)
106 | .SetOutput(publishOutput));
107 |
108 | if (Directory.Exists(appBundlePath))
109 | Directory.Delete(appBundlePath, true);
110 |
111 | Directory.CreateDirectory(macOsPath);
112 | CopyDirectoryContents(publishOutput, macOsPath);
113 | Directory.Delete(publishOutput, true);
114 |
115 | // Copy Info.plist and icon
116 | var infoSource = SourceDirectory / "Info.plist";
117 | var iconSource = SourceDirectory / "Assets" / "AppIcon.icns";
118 | var resourcesDir = contentsPath / "Resources";
119 | var infoTarget = contentsPath / "Info.plist";
120 |
121 | Directory.CreateDirectory(resourcesDir);
122 | if (File.Exists(infoSource))
123 | {
124 | File.Copy(infoSource, infoTarget, true);
125 | }
126 | if (File.Exists(iconSource))
127 | {
128 | File.Copy(iconSource, resourcesDir / "AppIcon.icns", true);
129 | }
130 |
131 | Serilog.Log.Information($"macOS artifact published to: {MacOSArtifactDirectory}");
132 | });
133 |
134 | Target PublishMacOSFrameworkDependent => _ => _
135 | .DependsOn(Clean)
136 | .Executes(() =>
137 | {
138 | var publishOutput = MacOSFrameworkArtifactDirectory / "publish";
139 | var appBundlePath = MacOSFrameworkArtifactDirectory / "Aictionary.app";
140 | var contentsPath = appBundlePath / "Contents";
141 | var macOsPath = contentsPath / "MacOS";
142 |
143 | if (Directory.Exists(publishOutput))
144 | Directory.Delete(publishOutput, true);
145 |
146 | DotNetPublish(s => s
147 | .SetProject(ProjectFile)
148 | .SetConfiguration(Configuration)
149 | .SetRuntime("osx-arm64")
150 | .DisableSelfContained()
151 | .DisablePublishSingleFile()
152 | .SetPublishTrimmed(false)
153 | .SetOutput(publishOutput));
154 |
155 | if (Directory.Exists(appBundlePath))
156 | Directory.Delete(appBundlePath, true);
157 |
158 | Directory.CreateDirectory(macOsPath);
159 | CopyDirectoryContents(publishOutput, macOsPath);
160 | Directory.Delete(publishOutput, true);
161 |
162 | var infoSource = SourceDirectory / "Info.plist";
163 | var iconSource = SourceDirectory / "Assets" / "AppIcon.icns";
164 | var resourcesDir = contentsPath / "Resources";
165 | var infoTarget = contentsPath / "Info.plist";
166 |
167 | Directory.CreateDirectory(resourcesDir);
168 | if (File.Exists(infoSource))
169 | {
170 | File.Copy(infoSource, infoTarget, true);
171 | }
172 | if (File.Exists(iconSource))
173 | {
174 | File.Copy(iconSource, resourcesDir / "AppIcon.icns", true);
175 | }
176 |
177 | Serilog.Log.Information($"macOS framework-dependent artifact published to: {MacOSFrameworkArtifactDirectory}");
178 | });
179 |
180 | Target Publish => _ => _
181 | .DependsOn(PublishWindows,
182 | PublishMacOS,
183 | PublishWindowsFrameworkDependent,
184 | PublishMacOSFrameworkDependent,
185 | CreateMacOSDmg)
186 | .Executes(() =>
187 | {
188 | Serilog.Log.Information("All platforms published successfully!");
189 | Serilog.Log.Information($"Artifacts location: {ArtifactsDirectory}");
190 | });
191 |
192 | Target PublishWindowsFrameworkDependent => _ => _
193 | .DependsOn(Clean)
194 | .Executes(() =>
195 | {
196 | var outputDirectory = WindowsFrameworkArtifactDirectory / "Aictionary-win-x64";
197 |
198 | DotNetPublish(s => s
199 | .SetProject(ProjectFile)
200 | .SetConfiguration(Configuration)
201 | .SetRuntime("win-x64")
202 | .DisableSelfContained()
203 | .DisablePublishSingleFile()
204 | .SetPublishTrimmed(false)
205 | .SetOutput(outputDirectory));
206 |
207 | Serilog.Log.Information($"Windows framework-dependent artifact published to: {WindowsFrameworkArtifactDirectory}");
208 | });
209 |
210 | Target CreateMacOSDmg => _ => _
211 | .DependsOn(PublishMacOS)
212 | .Executes(() =>
213 | {
214 | if (File.Exists(MacOSDmgFile))
215 | {
216 | File.Delete(MacOSDmgFile);
217 | }
218 |
219 | var appBundlePath = MacOSArtifactDirectory / "Aictionary.app";
220 |
221 | var arguments = string.Join(" ", new[]
222 | {
223 | "--volname \"Aictionary\"",
224 | "--window-pos 200 120",
225 | "--window-size 800 400",
226 | "--icon-size 128",
227 | "--app-drop-link 600 185",
228 | $"\"{MacOSDmgFile}\"",
229 | $"\"{appBundlePath}\""
230 | });
231 |
232 | ProcessTasks.StartProcess("create-dmg", arguments)
233 | .AssertZeroExitCode();
234 |
235 | Serilog.Log.Information($"macOS DMG created at: {MacOSDmgFile}");
236 | });
237 |
238 | static void CopyDirectoryContents(string source, string destination)
239 | {
240 | Directory.CreateDirectory(destination);
241 |
242 | foreach (var file in Directory.GetFiles(source))
243 | {
244 | var targetFile = Path.Combine(destination, Path.GetFileName(file));
245 | File.Copy(file, targetFile, true);
246 | }
247 |
248 | foreach (var directory in Directory.GetDirectories(source))
249 | {
250 | var targetDirectory = Path.Combine(destination, Path.GetFileName(directory));
251 | CopyDirectoryContents(directory, targetDirectory);
252 | }
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/build/build.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net8.0
6 | enable
7 | enable
8 | 1
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/build/build.ps1:
--------------------------------------------------------------------------------
1 | [CmdletBinding()]
2 | Param(
3 | [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)]
4 | [string[]]$BuildArguments
5 | )
6 |
7 | $ScriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
8 |
9 | & dotnet run --project "$ScriptDir/build.csproj" -- $BuildArguments
10 |
11 | exit $LASTEXITCODE
12 |
--------------------------------------------------------------------------------
/build/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | set -eo pipefail
4 |
5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
6 |
7 | dotnet run --project "$SCRIPT_DIR/build.csproj" -- "$@"
8 |
--------------------------------------------------------------------------------