From 94521233979645487c856d22463e9214355e0ff1 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sat, 3 Oct 2026 06:01:05 +0900 Subject: [PATCH 1/3] Add the filee command line and watch folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command line (src/Filee.Cli, published next to Filee.exe as filee-cli.exe; the installer can put the "filee" command, cli\filee.cmd, on PATH): - filee convert --to | --preset , with -o, --recursive, --quality, --overwrite/--skip, --json for scripts, and exit codes (0 ok, 1 some failed, 2 usage, 3 nothing). - filee watch , filee formats [], filee presets, help, version. - Same engines, presets and engine order as the app (CliHost mirrors AppHost). Watch folders (Settings → Watch folders, and filee watch): - FolderWatcher (Filee.Core) converts files that land in a folder once they have settled and open without sharing, so downloads and copies in progress wait; temporary files, unknown formats and the output and originals folders are ignored. Per rule: preset, subfolders, output folder (default \converted), and whether originals stay or move to \originals (then waiting files are converted at start). - The tray app runs one watcher per enabled rule (WatchFolderService); conversions show in the toast and history like any other. Tests: FolderWatcherTests (settling, files still being written or held open, ignored files, moving originals, missing folder), Filee.Cli.Tests (conversion, JSON, folders, failures, argument errors, a live watch), a render test of the new page. Co-Authored-By: Claude Opus 5.5 --- Filee.slnx | 2 + README.ko.md | 5 + README.md | 5 + README.zh-CN.md | 5 + build/build-installer.ps1 | 8 + docs/ARCHITECTURE.md | 12 + installer/Filee.iss | 63 +++- src/Filee.App/App.axaml.cs | 2 + src/Filee.App/Assets/i18n/en.json | 25 +- src/Filee.App/Assets/i18n/ko.json | 25 +- src/Filee.App/Assets/i18n/zh-CN.json | 25 +- src/Filee.App/Services/AppHost.cs | 1 + src/Filee.App/Services/ConversionService.cs | 21 ++ src/Filee.App/Services/WatchFolderService.cs | 98 ++++++ .../ViewModels/MainWindowViewModel.cs | 1 + .../Pages/WatchFoldersPageViewModel.cs | 125 +++++++ .../Views/Pages/WatchFoldersPage.axaml | 74 ++++ .../Views/Pages/WatchFoldersPage.axaml.cs | 31 ++ src/Filee.Cli/Cli.cs | 325 ++++++++++++++++++ src/Filee.Cli/CliHost.cs | 38 ++ src/Filee.Cli/CliOptions.cs | 125 +++++++ src/Filee.Cli/EnglishTexts.cs | 30 ++ src/Filee.Cli/Filee.Cli.csproj | 27 ++ src/Filee.Cli/Help.cs | 79 +++++ src/Filee.Cli/Program.cs | 16 + src/Filee.Core/Settings/AppSettings.cs | 3 + src/Filee.Core/Watching/FolderWatcher.cs | 282 +++++++++++++++ src/Filee.Core/Watching/WatchRule.cs | 49 +++ tests/Filee.App.Tests/RenderTests.cs | 30 ++ tests/Filee.Cli.Tests/CliTests.cs | 169 +++++++++ tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj | 21 ++ tests/Filee.Core.Tests/FolderWatcherTests.cs | 160 +++++++++ 32 files changed, 1878 insertions(+), 4 deletions(-) create mode 100644 src/Filee.App/Services/WatchFolderService.cs create mode 100644 src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs create mode 100644 src/Filee.App/Views/Pages/WatchFoldersPage.axaml create mode 100644 src/Filee.App/Views/Pages/WatchFoldersPage.axaml.cs create mode 100644 src/Filee.Cli/Cli.cs create mode 100644 src/Filee.Cli/CliHost.cs create mode 100644 src/Filee.Cli/CliOptions.cs create mode 100644 src/Filee.Cli/EnglishTexts.cs create mode 100644 src/Filee.Cli/Filee.Cli.csproj create mode 100644 src/Filee.Cli/Help.cs create mode 100644 src/Filee.Cli/Program.cs create mode 100644 src/Filee.Core/Watching/FolderWatcher.cs create mode 100644 src/Filee.Core/Watching/WatchRule.cs create mode 100644 tests/Filee.Cli.Tests/CliTests.cs create mode 100644 tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj create mode 100644 tests/Filee.Core.Tests/FolderWatcherTests.cs diff --git a/Filee.slnx b/Filee.slnx index a95bd26..8cd8f7f 100644 --- a/Filee.slnx +++ b/Filee.slnx @@ -4,11 +4,13 @@ + + diff --git a/README.ko.md b/README.ko.md index c94fcb4..d503fd3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -54,6 +54,10 @@ 엔진을 써요. - **네 가지 사용 방법.** 드래그 동작, 탐색기 우클릭 메뉴(Windows 11은 기본 메뉴에도)와 보내기, 탐색기에서 선택한 파일 + 단축키, 메인 창 드롭 영역. +- **자동 변환 폴더.** 정해 둔 폴더에 들어오는 파일을 Filee가 실행 중일 때 자동으로 변환해요. 다운로드나 복사가 끝난 뒤에 + 변환하고, 원본은 그대로 두거나 따로 옮겨서 폴더를 받은편지함처럼 쓸 수 있어요. +- **명령줄.** `filee convert *.heic --to jpg`, `filee watch D:\Inbox --to pdf`처럼 쓰고, 앱의 프리셋, 스크립트용 JSON 출력, + 종료 코드를 지원해요. 설치할 때 `filee` 명령을 PATH에 추가할 수 있어요. - **보이는 엔진.** 설정 → 변환 엔진에서 엔진마다 버전과 변환 범위를 보여 주고, 추가 엔진은 다운로드 속도와 남은 시간을 보며 받을 수 있어요. - **말랑한 애니메이션 UI.** 라이트·다크·시스템 테마, 강조색, 모서리 둥글기, 저사양 PC를 위한 *애니메이션 줄이기*. @@ -143,6 +147,7 @@ dotnet test | `src/Filee.Engines` | 변환 엔진마다 클래스 하나(`IConverter`). HWPX 작성기 포함. | | `src/Filee.Platform.Windows` | 탐색기 연동, 우클릭 메뉴, 자동 시작. | | `src/Filee.App` | Avalonia UI: 도넛 툴바, 설정 창, 트레이, 알림. | +| `src/Filee.Cli` | `filee` 명령줄(convert, watch, formats, presets). 앱과 같은 엔진과 프리셋을 써요. | | `tests/*` | xUnit v3 테스트(헤드리스 UI 렌더링 포함). | diff --git a/README.md b/README.md index 5bc5289..8148279 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ each step with the engine that does it best. - **Four ways in.** The drag gesture, Explorer's right-click menu (on Windows 11 also in the main menu) and Send To, a keyboard shortcut on the Explorer selection, and the drop zone in the main window. +- **Watch folders.** Files that land in a folder you choose are converted automatically while Filee runs, once + downloads and copies are complete. Keep the originals or move them aside so the folder works like an inbox. +- **Command line.** `filee convert *.heic --to jpg`, `filee watch D:\Inbox --to pdf`, with your presets, JSON output + for scripts and exit codes. Setup can put the `filee` command on PATH. - **Engines you can see.** Settings → Engines lists every engine with its version and what it converts; optional engines download with speed and time left. - **Soft, animated UI.** Light, dark or system theme, your accent colour, adjustable roundness and a *Reduce animations* @@ -150,6 +154,7 @@ portable Inno Setup into `build/.cache`; nothing is installed). | `src/Filee.Engines` | One class per conversion engine (`IConverter`), including the HWPX writer. | | `src/Filee.Platform.Windows` | Explorer integration, context menu, auto-start. | | `src/Filee.App` | Avalonia UI: donut toolbar, settings window, tray, toasts. | +| `src/Filee.Cli` | The `filee` command line (convert, watch, formats, presets) on the same engines and presets. | | `tests/*` | xUnit v3 tests, including headless UI rendering. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2c71b0c..e121801 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,6 +52,10 @@ - **自动规划路径。** 自动找到多步转换路径(例如 HWP → HWPX → DOCX、EPUB → HWPX → PDF → PNG),每一步使用最擅长的引擎。 - **四种使用方式。** 拖动手势、资源管理器右键菜单(Windows 11 也可加入主菜单)和“发送到”、对资源管理器中选中的文件按 快捷键、主窗口拖放区。 +- **自动转换文件夹。** Filee 运行时,放入指定文件夹的文件会在下载或复制完成后自动转换;原文件可以保留,也可以移走, + 让文件夹像收件箱一样使用。 +- **命令行。** `filee convert *.heic --to jpg`、`filee watch D:\Inbox --to pdf`,支持应用中的预设、供脚本使用的 JSON 输出和 + 退出码。安装时可以把 `filee` 命令加入 PATH。 - **引擎一目了然。** “设置 → 转换引擎”列出每个引擎的版本和可转换内容;可选引擎下载时显示速度和剩余时间。 - **柔和的动画界面。** 浅色、深色或跟随系统,自定义强调色和圆角,旧电脑可开启“减少动画”。 - **简体中文、English、한국어**,默认跟随 Windows 语言。 @@ -138,6 +142,7 @@ dotnet test | `src/Filee.Engines` | 每个转换引擎一个类(`IConverter`),包括 HWPX 写入器。 | | `src/Filee.Platform.Windows` | 资源管理器集成、右键菜单、开机自启动。 | | `src/Filee.App` | Avalonia 界面:圆环工具栏、设置窗口、托盘、通知。 | +| `src/Filee.Cli` | `filee` 命令行(convert、watch、formats、presets),与应用共用引擎和预设。 | | `tests/*` | xUnit v3 测试,包括无头 UI 渲染。 | diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index 23041c6..02d9375 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -100,6 +100,14 @@ elseif (-not $PublishDir) { dotnet publish (Join-Path $root 'src\Filee.App\Filee.App.csproj') -c Release -r win-x64 --self-contained ` "-p:Version=$Version" -p:PublishReadyToRun=true -p:FileeRelease=true -o $PublishDir } + # The command line, in the same folder so it shares the runtime and the engines (filee-cli.exe). Its name can't + # be filee.exe next to Filee.exe, so the "filee" command is cli\filee.cmd, which the installer can put on PATH. + Invoke-Checked 'dotnet publish (cli)' { + dotnet publish (Join-Path $root 'src\Filee.Cli\Filee.Cli.csproj') -c Release -r win-x64 --self-contained ` + "-p:Version=$Version" -p:PublishReadyToRun=true -p:FileeRelease=true -o $PublishDir + } + New-Item -ItemType Directory -Force -Path (Join-Path $PublishDir 'cli') | Out-Null + Set-Content (Join-Path $PublishDir 'cli\filee.cmd') "@`"%~dp0..\filee-cli.exe`" %*" -Encoding ascii # Next to Filee.exe: the Explorer menu package's external location is the install folder. & (Join-Path $PSScriptRoot 'build-explorer-menu.ps1') -Destination $PublishDir -Version $Version # Only the small rhwp and 7-Zip are bundled; the app downloads the others when the user picks them. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d9124af..e1434f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,6 +67,18 @@ Bump `AppSettings.CurrentSchemaVersion` and add a step to `SettingsMigrations` w - `ThemeService` writes the accent colour, corner radii, fonts and Fluent's `SystemAccentColor*` into application resources. Light/dark surface colours are theme dictionaries in `Styles/Soft.axaml`. +## Command line and watch folders + +- `Filee.Cli` (`filee-cli.exe`, run as `filee` through `cli\filee.cmd`, which Setup can put on PATH) composes the same + pipeline as the app without any UI (`CliHost`: `UserDataStore`, `EngineRegistry`, `ConverterCatalog`, `JobQueue`) + and reads the app's presets and engine order. It is published into the app folder, so both share one runtime and + the bundled engines; its name can't be `filee.exe` next to `Filee.exe` on a case-insensitive file system. +- `Filee.Core/Watching/FolderWatcher` watches one `WatchRule` with a `FileSystemWatcher` and converts a file once it + has kept its size and time for a moment and opens without sharing (downloads and copies in progress are skipped). + Temporary files, unknown formats and the output and originals folders are ignored, so outputs never loop back. + The tray app runs one per rule (`WatchFolderService`, Settings → Watch folders, conversions in the toast and + history); `filee watch` runs one on its own. + ## Platform code Everything OS-specific goes through `IPlatformServices` (`Filee.Core/Platform`). Windows lives in diff --git a/installer/Filee.iss b/installer/Filee.iss index e877015..45426a0 100644 --- a/installer/Filee.iss +++ b/installer/Filee.iss @@ -11,7 +11,8 @@ ; keeps the settings (%APPDATA%\Filee) and downloaded engines (%LocalAppData%\Filee\engines); ; - on Windows 11 registers the top-level File Explorer menu entry (an unsigned sparse package, which needs the ; administrator rights Setup already has); -; - offers the optional conversion engines; Filee downloads the chosen ones (verified) when Setup starts it. +; - offers the optional conversion engines; Filee downloads the chosen ones (verified) when Setup starts it; +; - can put the "filee" command line on PATH ({app}\cli\filee.cmd runs filee-cli.exe). ; The uninstaller runs "Filee.exe --uninstall-cleanup" for the per-user parts (UninstallCleanup.cs). #ifndef AppVersion @@ -64,6 +65,8 @@ CloseApplicationsFilter=*.exe RestartApplications=no ; Per-user parts (the old Velopack copy, Start menu shortcuts) belong to the account that runs Setup. UsedUserAreasWarning=no +; The "filee" command folder is added to PATH: tell running programs (new terminals) about it. +ChangesEnvironment=yes SetupLogging=yes [Languages] @@ -94,6 +97,9 @@ zhcn.ReadyEngines=转换引擎(Filee 启动后下载): en.FinishedRunning=Filee is installed and running in the notification area of the taskbar.%n%nTo convert files, drag them while holding the trigger key, or right-click them in File Explorer. ko.FinishedRunning=Filee가 설치되어 작업 표시줄 알림 영역에서 실행 중이에요.%n%n파일을 변환하려면 지정한 키를 누른 채 파일을 끌거나, 탐색기에서 파일을 우클릭하세요. zhcn.FinishedRunning=Filee 已安装,正在任务栏通知区域运行。%n%n要转换文件,请按住触发键拖动文件,或在资源管理器中右键单击文件。 +en.TaskCli=Add the "filee" command to PATH (convert from the command line) +ko.TaskCli="filee" 명령어를 PATH에 추가 (명령줄에서 변환) +zhcn.TaskCli=将 "filee" 命令添加到 PATH(在命令行中转换) en.FinishedEngines=The conversion engines you picked are downloading in the Filee window. ko.FinishedEngines=선택한 변환 엔진은 Filee 창에서 내려받고 있어요. zhcn.FinishedEngines=你选择的转换引擎正在 Filee 窗口中下载。 @@ -102,6 +108,7 @@ zhcn.FinishedEngines=你选择的转换引擎正在 Filee 窗口中下载。 Name: "contextmenu"; Description: "{cm:TaskContextMenu}"; GroupDescription: "{cm:GroupOptions}"; Check: IsFreshInstall Name: "contextmenu\top"; Description: "{cm:TaskModernMenu}"; GroupDescription: "{cm:GroupOptions}"; Check: IsFreshInstall and IsExplorerMenuSupported Name: "startup"; Description: "{cm:TaskStartup}"; GroupDescription: "{cm:GroupOptions}"; Check: IsFreshInstall +Name: "cli"; Description: "{cm:TaskCli}"; GroupDescription: "{cm:GroupOptions}"; Check: not IsCliOnPath Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked [Files] @@ -321,6 +328,55 @@ begin RegDeleteKeyIncludingSubkeys(HKCU, VelopackUninstallKey); end; +const + EnvironmentKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + +// The folder with filee.cmd (the command line), which goes on the system PATH. +function CliFolder: String; +begin + Result := ExpandConstant('{app}\cli'); +end; + +function PathHas(const Paths, Folder: String): Boolean; +begin + Result := Pos(';' + Uppercase(RemoveBackslashUnlessRoot(Folder)) + ';', ';' + Uppercase(Paths) + ';') > 0; +end; + +function IsCliOnPath: Boolean; +var + Paths: String; +begin + Result := RegQueryStringValue(HKLM, EnvironmentKey, 'Path', Paths) and PathHas(Paths, CliFolder); +end; + +procedure AddCliToPath; +var + Paths: String; +begin + if not RegQueryStringValue(HKLM, EnvironmentKey, 'Path', Paths) or PathHas(Paths, CliFolder) then + Exit; + if (Paths <> '') and (Copy(Paths, Length(Paths), 1) <> ';') then + Paths := Paths + ';'; + // REG_EXPAND_SZ like Windows' own value, so %SystemRoot% entries keep working. + RegWriteExpandStringValue(HKLM, EnvironmentKey, 'Path', Paths + CliFolder); +end; + +procedure RemoveCliFromPath; +var + Paths, Folder: String; + At: Integer; +begin + if not RegQueryStringValue(HKLM, EnvironmentKey, 'Path', Paths) then + Exit; + Folder := ';' + Uppercase(CliFolder) + ';'; + Paths := ';' + Paths + ';'; + At := Pos(Folder, Uppercase(Paths)); + if At = 0 then + Exit; + Delete(Paths, At, Length(Folder) - 1); + RegWriteExpandStringValue(HKLM, EnvironmentKey, 'Path', Copy(Paths, 2, Length(Paths) - 2)); +end; + // True when every listed component has been downloaded already (EngineInstaller writes the marker last). function IsEngineInstalled(const MarkerFolders: String): Boolean; var @@ -496,6 +552,8 @@ begin ssPostInstall: begin UpdateExplorerMenu; + if WizardIsTaskSelected('cli') then + AddCliToPath; StartFilee; end; end; @@ -519,7 +577,10 @@ begin usUninstall: MoveLoadedDllAside(ExpandConstant('{tmp}')); usPostUninstall: + begin + RemoveCliFromPath; if DirExists(ExpandConstant('{app}')) and not RemoveDir(ExpandConstant('{app}')) then RestartReplace(ExpandConstant('{app}'), ''); + end; end; end; diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 121806a..3378ca5 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -62,6 +62,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) AppHost.Get().Create(this); AppHost.Get().EnsureToast(); AppHost.Get().FeedbackDue += (_, _) => AppHost.Get().ShowFeedbackNotice(); + AppHost.Get().Start(); // Once LibreOffice is installed: prepare its profiles while the user isn't converting yet. AppHost.Get().WarmUpInBackground(TimeSpan.FromSeconds(10)); @@ -76,6 +77,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) { AppHost.Get().Dispose(); AppHost.Get().Dispose(); + AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); }; HandleCommandLine(Program.Options, firstLaunch: true); diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index cc1bef9..f548e5a 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -7,6 +7,7 @@ "nav.triggers": "Shortcuts", "nav.toolbar": "Donut toolbar", "nav.presets": "Presets", + "nav.watch": "Watch folders", "nav.theme": "Theme", "nav.engines": "Engines", "nav.about": "About", @@ -380,5 +381,27 @@ "setup.background": "Continue in the background", "setup.done": "Done", "setup.selection": "Download: {0}", - "setup.nothing_selected": "Nothing selected" + "setup.nothing_selected": "Nothing selected", + "watch.title": "Watch folders", + "watch.description": "Files that land in these folders are converted automatically while Filee runs. Downloads and copies are converted once they are complete.", + "watch.add": "Add folder", + "watch.empty": "No watch folders yet.", + "watch.enabled": "On", + "watch.remove": "Remove", + "watch.folder": "Folder to watch", + "watch.folder_placeholder": "Choose a folder", + "watch.preset": "Convert with", + "watch.originals": "After converting", + "watch.originals_keep": "Leave the original where it is", + "watch.originals_move": "Move the original to \"originals\"", + "watch.originals_hint": "Moving originals makes the folder work like an inbox, and files already waiting there when Filee starts are converted too.", + "watch.output": "Save converted files to", + "watch.output_placeholder": "\"converted\" inside the watched folder", + "watch.subfolders": "Include subfolders", + "watch.status_off": "Off", + "watch.status_no_folder": "Choose a folder to start", + "watch.status_watching": "Watching", + "watch.status_starting": "Starting…", + "watch.status_error": "Can't watch this folder: {0}", + "watch.cli_hint": "Also on the command line: filee watch --to " } diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bfd5eff..584a71f 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -7,6 +7,7 @@ "nav.triggers": "단축키", "nav.toolbar": "원형 툴바", "nav.presets": "프리셋", + "nav.watch": "자동 변환 폴더", "nav.theme": "테마", "nav.engines": "변환 엔진", "nav.about": "정보", @@ -380,5 +381,27 @@ "setup.background": "백그라운드에서 계속", "setup.done": "완료", "setup.selection": "다운로드: {0}", - "setup.nothing_selected": "선택한 엔진 없음" + "setup.nothing_selected": "선택한 엔진 없음", + "watch.title": "자동 변환 폴더", + "watch.description": "Filee가 실행 중일 때 이 폴더들에 들어오는 파일을 자동으로 변환해요. 다운로드나 복사 중인 파일은 끝난 뒤에 변환해요.", + "watch.add": "폴더 추가", + "watch.empty": "아직 자동 변환 폴더가 없어요.", + "watch.enabled": "사용", + "watch.remove": "삭제", + "watch.folder": "감시할 폴더", + "watch.folder_placeholder": "폴더를 고르세요", + "watch.preset": "변환할 프리셋", + "watch.originals": "변환한 원본은", + "watch.originals_keep": "그대로 두기", + "watch.originals_move": "\"originals\" 폴더로 옮기기", + "watch.originals_hint": "원본을 옮기면 폴더가 받은편지함처럼 동작하고, Filee가 시작될 때 이미 들어 있던 파일도 변환해요.", + "watch.output": "변환된 파일 저장 위치", + "watch.output_placeholder": "감시 폴더 안의 \"converted\" 폴더", + "watch.subfolders": "하위 폴더 포함", + "watch.status_off": "꺼짐", + "watch.status_no_folder": "폴더를 고르면 시작해요", + "watch.status_watching": "감시 중", + "watch.status_starting": "시작하는 중…", + "watch.status_error": "이 폴더를 감시할 수 없어요: {0}", + "watch.cli_hint": "명령줄에서도 쓸 수 있어요: filee watch <폴더> --to <형식>" } diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6018827..78438b4 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -7,6 +7,7 @@ "nav.triggers": "快捷方式", "nav.toolbar": "环形工具栏", "nav.presets": "预设", + "nav.watch": "自动转换文件夹", "nav.theme": "主题", "nav.engines": "转换引擎", "nav.about": "关于", @@ -380,5 +381,27 @@ "setup.background": "在后台继续", "setup.done": "完成", "setup.selection": "下载:{0}", - "setup.nothing_selected": "未选择引擎" + "setup.nothing_selected": "未选择引擎", + "watch.title": "自动转换文件夹", + "watch.description": "Filee 运行时,放入这些文件夹的文件会自动转换。正在下载或复制的文件会等完成后再转换。", + "watch.add": "添加文件夹", + "watch.empty": "还没有自动转换文件夹。", + "watch.enabled": "启用", + "watch.remove": "删除", + "watch.folder": "要监视的文件夹", + "watch.folder_placeholder": "选择文件夹", + "watch.preset": "转换预设", + "watch.originals": "转换后的原文件", + "watch.originals_keep": "保留在原处", + "watch.originals_move": "移到 \"originals\" 文件夹", + "watch.originals_hint": "移动原文件后,文件夹会像收件箱一样工作;Filee 启动时已在其中的文件也会被转换。", + "watch.output": "转换后的文件保存到", + "watch.output_placeholder": "监视文件夹中的 \"converted\" 文件夹", + "watch.subfolders": "包括子文件夹", + "watch.status_off": "已关闭", + "watch.status_no_folder": "选择文件夹后开始", + "watch.status_watching": "正在监视", + "watch.status_starting": "正在启动…", + "watch.status_error": "无法监视此文件夹:{0}", + "watch.cli_hint": "也可以在命令行中使用:filee watch <文件夹> --to <格式>" } diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 61a89be..1ada5ff 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -53,6 +53,7 @@ public static IServiceProvider Build(string? dataDirectory = null) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Filee.App/Services/ConversionService.cs b/src/Filee.App/Services/ConversionService.cs index 40bb504..b49473b 100644 --- a/src/Filee.App/Services/ConversionService.cs +++ b/src/Filee.App/Services/ConversionService.cs @@ -27,6 +27,7 @@ public sealed class ConversionService private readonly ILocalizer _loc; private readonly IPlatformServices _platform; private readonly System.Collections.Concurrent.ConcurrentDictionary _byId = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary> _waiting = new(); public ConversionService(JobQueue queue, UserDataStore store, ILocalizer loc, IPlatformServices platform) { @@ -68,6 +69,24 @@ public ConversionService(JobQueue queue, UserDataStore store, ILocalizer loc, IP return vm; } + /// + /// Converts like (shown in the toast and history) and completes when the job has finished. + /// Callable from any thread; used by watch folders. + /// + public async Task RunAsync(IReadOnlyList files, Preset preset) + { + var vm = await Dispatcher.UIThread.InvokeAsync(() => Start(files, preset)); + if (vm is null) + return null; + var finished = _waiting.GetOrAdd(vm.Job.Id, _ => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + // The job may have finished before the wait was registered. + if (vm.Job.State is not (JobState.Queued or JobState.Running)) + finished.TrySetResult(vm.Job); + var job = await finished.Task; + _waiting.TryRemove(job.Id, out _); + return job; + } + /// Moves a job into the toast window (no-op if it is already there or gone). public void ShowInToast(JobViewModel vm) { @@ -85,6 +104,8 @@ private void OnJobUpdated(object? sender, ConversionJob job) private void OnJobFinished(object? sender, ConversionJob job) { _store.AddHistory(HistoryEntry.From(job)); + if (_waiting.TryGetValue(job.Id, out var waiting)) + waiting.TrySetResult(job); Dispatcher.UIThread.Post(() => { if (!_byId.TryGetValue(job.Id, out var vm)) diff --git a/src/Filee.App/Services/WatchFolderService.cs b/src/Filee.App/Services/WatchFolderService.cs new file mode 100644 index 0000000..04621c9 --- /dev/null +++ b/src/Filee.App/Services/WatchFolderService.cs @@ -0,0 +1,98 @@ +// Runs the watch folders of the settings (Settings → Watch folders) while Filee is in the tray: one FolderWatcher +// per enabled rule, rebuilt shortly after the rules change. Conversions go through ConversionService, so they show in +// the toast and the history like any other. + +using Avalonia.Threading; +using Filee.Core.Settings; +using Filee.Core.Watching; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +public sealed class WatchFolderService(UserDataStore store, ConversionService conversions, ILogger log) + : IAsyncDisposable +{ + /// Typing a folder path saves on every key: wait for a pause before (re)starting watchers. + private static readonly TimeSpan ApplyDelay = TimeSpan.FromSeconds(1.5); + + private readonly Dictionary _running = []; + private readonly Dictionary _errors = []; + private IDisposable? _pendingApply; + + /// Raised (UI thread) when a rule started, stopped or failed to start. + public event EventHandler? StatusChanged; + + public void Start() + { + _ = ApplyAsync(); + store.SettingsChanged += (_, _) => + { + _pendingApply?.Dispose(); + _pendingApply = DispatcherTimer.RunOnce(() => _ = ApplyAsync(), ApplyDelay); + }; + } + + /// True when the rule's folder is being watched. + public bool IsWatching(string ruleId) => _running.ContainsKey(ruleId); + + /// Why an enabled rule is not being watched (folder missing, …), or null. + public string? ErrorOf(string ruleId) => _errors.GetValueOrDefault(ruleId); + + /// Starts, restarts and stops watchers so they match the settings. + private async Task ApplyAsync() + { + var wanted = store.Settings.WatchFolders + .Where(r => r.Enabled && !string.IsNullOrWhiteSpace(r.Folder) && !string.IsNullOrWhiteSpace(r.PresetId)) + .ToDictionary(r => r.Id, r => r.Clone()); + + foreach (var (id, (signature, watcher)) in _running.ToList()) + { + if (wanted.TryGetValue(id, out var rule) && Signature(rule) == signature) + continue; + _running.Remove(id); + await watcher.DisposeAsync(); + } + _errors.Clear(); + foreach (var (id, rule) in wanted) + { + if (_running.ContainsKey(id)) + continue; + var watcher = new FolderWatcher(rule, (files, _) => ConvertAsync(rule, files), log); + if (watcher.Start()) + { + _running[id] = (Signature(rule), watcher); + log.LogInformation("Watching {Folder}", rule.Folder); + } + else + { + _errors[id] = watcher.Error; + await watcher.DisposeAsync(); + } + } + StatusChanged?.Invoke(this, EventArgs.Empty); + } + + private async Task ConvertAsync(WatchRule rule, IReadOnlyList files) + { + var preset = store.Presets.FirstOrDefault(p => p.Id == rule.PresetId)?.Clone(); + if (preset is null) + { + log.LogWarning("Watch folder {Folder}: preset {Preset} no longer exists", rule.Folder, rule.PresetId); + return null; + } + preset.Output.Location = Core.Presets.OutputLocation.CustomFolder; + preset.Output.CustomFolder = rule.ResolvedOutputFolder; + return await conversions.RunAsync(files, preset); + } + + /// Everything that needs a new watcher when it changes. + private static string Signature(WatchRule rule) => + string.Join('|', rule.Folder, rule.PresetId, rule.IncludeSubfolders, rule.OutputFolder, rule.Originals); + + public async ValueTask DisposeAsync() + { + foreach (var (_, watcher) in _running.Values) + await watcher.DisposeAsync(); + _running.Clear(); + } +} diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs index 6c210cc..09ef6fc 100644 --- a/src/Filee.App/ViewModels/MainWindowViewModel.cs +++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs @@ -49,6 +49,7 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ new("triggers", "nav.triggers", "Icon.Keyboard", typeof(TriggersPageViewModel)), new("toolbar", "nav.toolbar", "Icon.Donut", typeof(ToolbarPageViewModel)), new("presets", "nav.presets", "Icon.Tune", typeof(PresetsPageViewModel)), + new("watch", "nav.watch", "Icon.Folder", typeof(WatchFoldersPageViewModel)), new("theme", "nav.theme", "Icon.Palette", typeof(ThemePageViewModel)), new("engines", "nav.engines", "Icon.Engine", typeof(EnginesPageViewModel)), new("about", "nav.about", "Icon.Info", typeof(AboutPageViewModel)), diff --git a/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs new file mode 100644 index 0000000..9336019 --- /dev/null +++ b/src/Filee.App/ViewModels/Pages/WatchFoldersPageViewModel.cs @@ -0,0 +1,125 @@ +// Watch folders page: folders whose new files are converted automatically (WatchFolderService). Every change is +// saved at once; the service applies it after a short pause. + +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; +using Filee.Core.Localization; +using Filee.Core.Settings; +using Filee.Core.Watching; + +namespace Filee.App.ViewModels.Pages; + +public sealed partial class WatchFoldersPageViewModel : ObservableObject, IDisposable +{ + private readonly UserDataStore _store; + private readonly WatchFolderService _service; + private readonly ILocalizer _loc; + + public WatchFoldersPageViewModel(UserDataStore store, WatchFolderService service, ILocalizer loc) + { + _store = store; + _service = service; + _loc = loc; + Presets = store.Presets.Select(p => new Choice(p.Id, loc.DisplayName(p), FormatLabels.NameOf(loc, p.TargetFormat))).ToList(); + OriginalsChoices = + [ + new Choice(AfterConversion.Keep, loc["watch.originals_keep"]), + new Choice(AfterConversion.MoveToOriginals, loc["watch.originals_move"]), + ]; + foreach (var rule in store.Settings.WatchFolders) + Rules.Add(new WatchRuleViewModel(rule, this)); + service.StatusChanged += OnStatusChanged; + } + + public ObservableCollection Rules { get; } = []; + public IReadOnlyList> Presets { get; } + public IReadOnlyList> OriginalsChoices { get; } + public bool IsEmpty => Rules.Count == 0; + + [RelayCommand] + private void Add() + { + var rule = new WatchRule { PresetId = Presets.FirstOrDefault(p => p.Value == "to-pdf")?.Value ?? Presets.FirstOrDefault()?.Value ?? "" }; + _store.Settings.WatchFolders.Add(rule); + Rules.Add(new WatchRuleViewModel(rule, this)); + OnPropertyChanged(nameof(IsEmpty)); + Save(); + } + + internal void Remove(WatchRuleViewModel item) + { + _store.Settings.WatchFolders.Remove(item.Rule); + Rules.Remove(item); + OnPropertyChanged(nameof(IsEmpty)); + Save(); + } + + internal void Save() => _store.SaveSettings(); + + internal string StatusOf(WatchRule rule) => + !rule.Enabled ? _loc["watch.status_off"] + : string.IsNullOrWhiteSpace(rule.Folder) ? _loc["watch.status_no_folder"] + : _service.ErrorOf(rule.Id) is { } error ? _loc.Format("watch.status_error", error) + : _service.IsWatching(rule.Id) ? _loc["watch.status_watching"] + : _loc["watch.status_starting"]; + + private void OnStatusChanged(object? sender, EventArgs e) + { + foreach (var rule in Rules) + rule.RefreshStatus(); + } + + public void Dispose() => _service.StatusChanged -= OnStatusChanged; +} + +/// One watch folder on the page; edits go straight into the settings' . +public sealed partial class WatchRuleViewModel : ObservableObject +{ + private readonly WatchFoldersPageViewModel _page; + + public WatchRuleViewModel(WatchRule rule, WatchFoldersPageViewModel page) + { + Rule = rule; + _page = page; + _enabled = rule.Enabled; + _folder = rule.Folder; + _preset = page.Presets.FirstOrDefault(p => p.Value == rule.PresetId); + _includeSubfolders = rule.IncludeSubfolders; + _outputFolder = rule.OutputFolder; + _originals = page.OriginalsChoices.First(c => c.Value == rule.Originals); + _status = page.StatusOf(rule); + } + + public WatchRule Rule { get; } + public IReadOnlyList> Presets => _page.Presets; + public IReadOnlyList> OriginalsChoices => _page.OriginalsChoices; + + [ObservableProperty] private bool _enabled; + [ObservableProperty] private string _folder; + [ObservableProperty] private Choice? _preset; + [ObservableProperty] private bool _includeSubfolders; + [ObservableProperty] private string _outputFolder; + [ObservableProperty] private Choice _originals; + [ObservableProperty] private string _status; + + partial void OnEnabledChanged(bool value) => Change(r => r.Enabled = value); + partial void OnFolderChanged(string value) => Change(r => r.Folder = value.Trim()); + partial void OnPresetChanged(Choice? value) => Change(r => r.PresetId = value?.Value ?? ""); + partial void OnIncludeSubfoldersChanged(bool value) => Change(r => r.IncludeSubfolders = value); + partial void OnOutputFolderChanged(string value) => Change(r => r.OutputFolder = value.Trim()); + partial void OnOriginalsChanged(Choice value) => Change(r => r.Originals = value.Value); + + [RelayCommand] + private void Remove() => _page.Remove(this); + + internal void RefreshStatus() => Status = _page.StatusOf(Rule); + + private void Change(Action change) + { + change(Rule); + _page.Save(); + RefreshStatus(); + } +} diff --git a/src/Filee.App/Views/Pages/WatchFoldersPage.axaml b/src/Filee.App/Views/Pages/WatchFoldersPage.axaml new file mode 100644 index 0000000..6eeb100 --- /dev/null +++ b/src/Filee.App/Views/Pages/WatchFoldersPage.axaml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +