diff --git a/Directory.Build.props b/Directory.Build.props
index 298451d..e87a2f7 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
parameters of a member is fine (CS1573). -->
$(NoWarn);CS1591;CS1573
- 1.2.3
+ 1.3.0
Filee
Filee contributors
Copyright (c) Filee contributors
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..1bae32c 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -10,7 +10,8 @@
웹사이트 ·
다운로드 ·
- 시작하기 ·
+ 설치 ·
+ 명령줄 ·
문서
@@ -27,7 +28,7 @@
커서 바로 옆에서 앱 창을 열 필요가 없어요. 끌고 있는 파일에 맞는 형식만 지금 있는 자리에 나타나요.
- 170가지 넘는 형식, 다른 프로그램 없이 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD를 오피스·한컴오피스·LibreOffice 없이 변환해요.
+ 180가지 형식, 다른 프로그램 없이 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD를 오피스·한컴오피스·LibreOffice 없이 변환해요.
내 PC 안에서만 아무것도 업로드하지 않아요. 오프라인으로 동작하고 결과는 원본 바로 옆에 저장해요.
@@ -39,6 +40,24 @@
27초 영상과 직접 해보는 데모는 filee.sh 에서
+## 설치
+
+[Releases](https://github.com/KnifeLemon/Filee/releases/latest)에서 최신 `Filee-<버전>-win-Setup.exe`를
+받아 실행하세요(Windows 10/11, 64비트). 관리자 권한을 한 번 요청하고, 모든 사용자를 위해 Program Files에 설치해요.
+설치 없이 쓰고 싶다면 `Filee-<버전>-win-Portable.zip`을 원하는 곳에 풀고 `Filee.exe`를 실행하면 돼요.
+
+- **자주 쓰는 형식은 설치 파일에 다 들어 있어요.** 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD는
+ 설치하자마자 변환돼요.
+- **설치하면서 골라요.** 탐색기 우클릭 메뉴의 “Filee로 변환”(Windows 11에서는 “추가 옵션 표시” 없이 기본 메뉴에도),
+ 로그인할 때 시작, 그리고 추가 엔진을 크기를 보며 고를 수 있어요: 동영상·오디오(FFmpeg, 약 100MB)와 드문
+ 형식(LibreOffice, calibre, Ghostscript, Pandoc). 고른 엔진은 설치가 끝나고 Filee가 시작되면서 내려받고, 설정 → 변환
+ 엔진에서 다운로드 속도와 남은 시간을 보며 언제든 설치하거나 지울 수 있어요.
+- **업데이트는 알려 드려요.** 새 버전이 나오면 메뉴 하단, 알림, 트레이 메뉴에 표시되고 누르면 최신 릴리스 페이지가
+ 열려요. 새 설치 파일을 실행하면 Filee를 닫고 업데이트하며, 설정과 엔진은 그대로 유지돼요. 사용자별로 설치된
+ Filee 1.1 이하도 같은 방법으로 넘겨받아요.
+
+macOS 지원을 계획 중이고, Linux 지원도 검토하고 있어요.
+
## 기능
- **커서 위치에 뜨는 도넛 툴바.** 수정키(기본 Ctrl )를 누른 채 파일을 끌면 돼요. 수정키 조합, 마우스 버튼, 드래그
@@ -54,6 +73,10 @@
엔진을 써요.
- **네 가지 사용 방법.** 드래그 동작, 탐색기 우클릭 메뉴(Windows 11은 기본 메뉴에도)와 보내기, 탐색기에서 선택한 파일 +
단축키, 메인 창 드롭 영역.
+- **자동 변환 폴더.** 정해 둔 폴더에 들어오는 파일을 Filee가 실행 중일 때 자동으로 변환해요. 다운로드나 복사가 끝난 뒤에
+ 변환하고, 원본은 그대로 두거나 따로 옮겨서 폴더를 받은편지함처럼 쓸 수 있어요.
+- **명령줄.** `filee convert *.heic --to jpg`, `filee watch D:\Inbox --to pdf`처럼 쓰고, 앱의 프리셋, 스크립트용 JSON 출력,
+ 종료 코드를 지원해요. 설치할 때 `filee` 명령을 PATH에 추가할 수 있어요.
- **보이는 엔진.** 설정 → 변환 엔진에서 엔진마다 버전과 변환 범위를 보여 주고, 추가 엔진은 다운로드 속도와 남은 시간을
보며 받을 수 있어요.
- **말랑한 애니메이션 UI.** 라이트·다크·시스템 테마, 강조색, 모서리 둥글기, 저사양 PC를 위한 *애니메이션 줄이기*.
@@ -79,9 +102,34 @@
| **글꼴**: TTF, OTF, WOFF, WOFF2, EOT | 서로 변환 |
표시가 없는 것은 설치하자마자 변환돼요. MS 오피스·한컴오피스·LibreOffice가 없어도 되고, PC에 설치된 프로그램은 쓰지 않아요.
-처음 실행할 때 받을지 물어보는 추가 엔진: ¹ Ghostscript, ² LibreOffice, ³ Pandoc, ⁴ calibre, ⁵ FFmpeg. 엔진마다 무엇을
+설치할 때 고르거나 나중에 앱에서 받는 추가 엔진: ¹ Ghostscript, ² LibreOffice, ³ Pandoc, ⁴ calibre, ⁵ FFmpeg. 엔진마다 무엇을
유지하고 무엇을 빼는지는 [docs/ENGINES.md](docs/ENGINES.md)에 있어요.
+## 명령줄
+
+`filee` 명령으로 앱을 열지 않고도 파일을 변환하고 폴더를 감시할 수 있어요. 앱과 같은 엔진과 프리셋을 써요.
+설치할 때 **"filee" 명령어를 PATH에 추가**를 체크한 뒤 새 터미널을 열면 돼요.
+
+```
+filee convert photo.heic --to jpg
+filee convert *.png --to webp --quality 80 -o converted
+filee convert D:\Scans --recursive --preset to-pdf --json
+filee watch D:\Inbox --to pdf --move-originals
+filee formats heic
+filee presets
+```
+
+| 명령 | 하는 일 |
+|---|---|
+| `filee convert` | 파일, 와일드카드(`*.heic`), 폴더를 `--to <형식>`이나 `--preset <프리셋>`으로 변환 |
+| `filee watch` | Ctrl +C 를 누를 때까지 폴더에 들어오는 파일을 변환 |
+| `filee formats` | 모든 형식, 또는 한 형식이 무엇으로 바뀔 수 있는지 보기 |
+| `filee presets` | 앱에 저장된 프리셋 목록 |
+
+`--json`을 붙이면 스크립트나 다른 프로그램이 읽기 좋은 JSON으로 결과를 출력해요. 종료 코드: `0` 모두 변환,
+`1` 일부 실패, `2` 잘못된 인자나 없는 파일, `3` 변환할 파일 없음. 모든 옵션은 [명령줄 가이드](https://filee.sh/ko/cli/)와
+`filee help`에 있어요.
+
## 스크린샷
-## 시작하기
-
-**설치:** [Releases](https://github.com/KnifeLemon/Filee/releases/latest)에서 최신 `Filee-<버전>-win-Setup.exe`를
-받아 실행하세요(Windows 10/11, 64비트). 관리자 권한을 한 번 요청하고, 모든 사용자를 위해 Program Files에 설치해요.
-설치 없이 쓰고 싶다면 `Filee-<버전>-win-Portable.zip`을 원하는 곳에 풀고 `Filee.exe`를 실행하면 돼요.
-
-- **자주 쓰는 형식은 설치 파일에 다 들어 있어요.** 이미지, PDF, 워드, 엑셀, 파워포인트, HWP, 전자책, 압축 파일, 글꼴, CAD는
- 설치하자마자 변환돼요.
-- **설치하면서 골라요.** 탐색기 우클릭 메뉴의 “Filee로 변환”(Windows 11에서는 “추가 옵션 표시” 없이 기본 메뉴에도),
- 로그인할 때 시작, 그리고 추가 엔진을 크기를 보며 고를 수 있어요: 동영상·오디오(FFmpeg, 약 100MB)와 드문
- 형식(LibreOffice, calibre, Ghostscript, Pandoc). 고른 엔진은 설치가 끝나고 Filee가 시작되면서 내려받고, 설정 → 변환
- 엔진에서 다운로드 속도와 남은 시간을 보며 언제든 설치하거나 지울 수 있어요.
-- **업데이트는 알려 드려요.** 새 버전이 나오면 메뉴 하단, 알림, 트레이 메뉴에 표시되고 누르면 최신 릴리스 페이지가
- 열려요. 새 설치 파일을 실행하면 Filee를 닫고 업데이트하며, 설정과 엔진은 그대로 유지돼요. 사용자별로 설치된
- Filee 1.1 이하도 같은 방법으로 넘겨받아요.
+## 문서
-macOS 지원은 계획 중입니다.
+| 하고 싶은 일 | 여기서 시작 |
+|---|---|
+| 명령줄로 변환하기 | [filee.sh/ko/cli](https://filee.sh/ko/cli/) |
+| 앱 구조 이해하기 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
+| 어떤 엔진이 무엇을 변환하는지, 라이선스 | [docs/ENGINES.md](docs/ENGINES.md) |
+| 새 변환 추가하기 | [docs/ADDING-A-CONVERTER.md](docs/ADDING-A-CONVERTER.md) |
+| 새 언어로 번역하기 | [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) |
+| PR 보내기 | [CONTRIBUTING.md](CONTRIBUTING.md) |
-
-소스에서 빌드
+## 소스에서 빌드
필요: .NET 10 SDK, Windows 10/11.
@@ -135,7 +175,9 @@ dotnet run --project src/Filee.App
dotnet test
```
-설정은 `%APPDATA%\Filee`에 저장됩니다. 디버그 빌드는 탐색기 우클릭 메뉴나 자동 시작을 건드리지 않습니다.
+설정은 `%APPDATA%\Filee`에 저장됩니다. 소스에서 빌드한 Filee는 탐색기 우클릭 메뉴나 자동 시작을 건드리지 않습니다.
+`pwsh build/build-installer.ps1`로 설치 프로그램을 `Releases/`에 만들 수 있어요(고정된 버전의 Inno Setup을 `build/.cache`에
+받아서 쓰고, 아무것도 설치하지 않아요).
| 프로젝트 | 설명 |
|---|---|
@@ -143,20 +185,9 @@ 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 렌더링 포함). |
-
-
-## 문서
-
-| 하고 싶은 일 | 여기서 시작 |
-|---|---|
-| 앱 구조 이해하기 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
-| 어떤 엔진이 무엇을 변환하는지, 라이선스 | [docs/ENGINES.md](docs/ENGINES.md) |
-| 새 변환 추가하기 | [docs/ADDING-A-CONVERTER.md](docs/ADDING-A-CONVERTER.md) |
-| 새 언어로 번역하기 | [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) |
-| PR 보내기 | [CONTRIBUTING.md](CONTRIBUTING.md) |
-
## 기여
PR 환영합니다. 시작하기 좋은 작업은 [변환기 추가](docs/ADDING-A-CONVERTER.md)와 [언어 추가](docs/ADDING-A-LANGUAGE.md)입니다.
diff --git a/README.md b/README.md
index 5bc5289..62d847f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@
Website ·
Download ·
- Get started ·
+ Install ·
+ Command line ·
Docs
@@ -27,7 +28,7 @@
Right at your cursor No app window to open. The formats appear where you already are, only the ones that make sense for the files you drag.
- 170+ formats, no other software Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts and CAD work without Office, Hancom Office or LibreOffice.
+ 180 formats, no other software Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts and CAD work without Office, Hancom Office or LibreOffice.
Stays on your PC Nothing is uploaded. Filee works offline and saves the result right next to the original.
@@ -39,6 +40,26 @@
Watch the 27-second video and try the interactive demo on filee.sh
+## Install
+
+Download the latest `Filee--win-Setup.exe` from
+[Releases](https://github.com/KnifeLemon/Filee/releases/latest) (Windows 10/11, 64-bit) and run it. Setup asks for
+administrator rights once and installs Filee for all users in Program Files. Prefer no installer? Unzip
+`Filee--win-Portable.zip` anywhere and run `Filee.exe`.
+
+- **Everything common is in the installer.** Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts and
+ CAD work right away.
+- **Choose in Setup.** Add “Convert with Filee” to the File Explorer menu (on Windows 11 also at the top of the menu,
+ not only under “Show more options”), start Filee when you sign in, and pick optional engines: video and audio
+ (FFmpeg, ~100 MB) and rare formats (LibreOffice, calibre, Ghostscript, Pandoc), each with its size. Filee downloads
+ the chosen engines when Setup starts it; install or remove them any time in Settings → Engines, which shows download
+ speed and time left.
+- **You hear about updates.** When a new release is out, Filee says so at the bottom of the menu, in a notice and in
+ the tray menu, and opens the latest release to download it. Run the new installer: it closes Filee, updates it and
+ keeps your settings and engines. Filee 1.1 and earlier (installed per user) are taken over the same way.
+
+macOS support is planned, and Linux is being considered.
+
## Features
- **Donut toolbar at the cursor.** Hold a modifier key (Ctrl by default) and drag files. Everything is
@@ -55,6 +76,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*
@@ -85,6 +110,31 @@ never uses programs installed on your PC. Optional engines Filee offers to downl
³ Pandoc, ⁴ calibre, ⁵ FFmpeg. Details, including what each engine keeps and leaves out:
[docs/ENGINES.md](docs/ENGINES.md).
+## Command line
+
+The `filee` command converts files and watches folders without opening the app, with the same engines and presets.
+Tick **Add the "filee" command to PATH** in Setup, then open a new terminal.
+
+```
+filee convert photo.heic --to jpg
+filee convert *.png --to webp --quality 80 -o converted
+filee convert D:\Scans --recursive --preset to-pdf --json
+filee watch D:\Inbox --to pdf --move-originals
+filee formats heic
+filee presets
+```
+
+| Command | What it does |
+|---|---|
+| `filee convert` | Converts files, wildcards (`*.heic`) and folders with `--to ` or `--preset ` |
+| `filee watch` | Converts files that land in a folder until Ctrl +C |
+| `filee formats` | Lists every format, or what one format converts to |
+| `filee presets` | Lists the presets saved in the app |
+
+`--json` prints the result for scripts and other programs. Exit codes: `0` all converted, `1` some files failed,
+`2` wrong arguments or a missing input, `3` nothing to convert. Every option is in the
+[command line guide](https://filee.sh/cli/) and in `filee help`.
+
## Screenshots
@@ -106,28 +156,18 @@ never uses programs installed on your PC. Optional engines Filee offers to downl
-## Get started
-
-**Install:** download the latest `Filee--win-Setup.exe` from
-[Releases](https://github.com/KnifeLemon/Filee/releases/latest) (Windows 10/11, 64-bit) and run it. Setup asks for
-administrator rights once and installs Filee for all users in Program Files. Prefer no installer? Unzip
-`Filee--win-Portable.zip` anywhere and run `Filee.exe`.
-
-- **Everything common is in the installer.** Images, PDF, Word, Excel, PowerPoint, HWP, e-books, archives, fonts and
- CAD work right away.
-- **Choose in Setup.** Add “Convert with Filee” to the File Explorer menu (on Windows 11 also at the top of the menu,
- not only under “Show more options”), start Filee when you sign in, and pick optional engines: video and audio
- (FFmpeg, ~100 MB) and rare formats (LibreOffice, calibre, Ghostscript, Pandoc), each with its size. Filee downloads
- the chosen engines when Setup starts it; install or remove them any time in Settings → Engines, which shows download
- speed and time left.
-- **You hear about updates.** When a new release is out, Filee says so at the bottom of the menu, in a notice and in
- the tray menu, and opens the latest release to download it. Run the new installer: it closes Filee, updates it and
- keeps your settings and engines. Filee 1.1 and earlier (installed per user) are taken over the same way.
+## Documentation
-macOS support is planned.
+| I want to… | Start here |
+|---|---|
+| Use the command line | [filee.sh/cli](https://filee.sh/cli/) |
+| Understand how the app fits together | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
+| Know which engine converts what, and their licenses | [docs/ENGINES.md](docs/ENGINES.md) |
+| Add a new conversion | [docs/ADDING-A-CONVERTER.md](docs/ADDING-A-CONVERTER.md) |
+| Translate Filee into my language | [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) |
+| Send a pull request | [CONTRIBUTING.md](CONTRIBUTING.md) |
-
-Build from source
+## Build from source
Requirements: .NET 10 SDK, Windows 10/11.
@@ -150,20 +190,9 @@ 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. |
-
-
-## Documentation
-
-| I want to… | Start here |
-|---|---|
-| Understand how the app fits together | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
-| Know which engine converts what, and their licenses | [docs/ENGINES.md](docs/ENGINES.md) |
-| Add a new conversion | [docs/ADDING-A-CONVERTER.md](docs/ADDING-A-CONVERTER.md) |
-| Translate Filee into my language | [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) |
-| Send a pull request | [CONTRIBUTING.md](CONTRIBUTING.md) |
-
## Contributing
Pull requests are welcome. Good first steps are [adding a converter](docs/ADDING-A-CONVERTER.md) and
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 2c71b0c..077b3a9 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -10,7 +10,8 @@
官网 ·
下载 ·
- 快速开始 ·
+ 安装 ·
+ 命令行 ·
文档
@@ -27,7 +28,7 @@
就在光标旁 无需打开应用窗口。只显示适合当前拖动文件的格式,就在你所在的位置。
- 170 多种格式,无需其他软件 图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体和 CAD,无需 Office、韩文办公软件或 LibreOffice。
+ 180 种格式,无需其他软件 图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体和 CAD,无需 Office、韩文办公软件或 LibreOffice。
只在你的电脑上 不上传任何文件。Filee 离线工作,结果保存在原文件旁边。
@@ -39,6 +40,22 @@
27 秒视频和可交互演示请访问 filee.sh
+## 安装
+
+在 [Releases](https://github.com/KnifeLemon/Filee/releases/latest) 下载最新的
+`Filee-<版本>-win-Setup.exe`(Windows 10/11,64 位)并运行。安装程序会请求一次管理员权限,为所有用户安装到
+Program Files。不想安装?把 `Filee-<版本>-win-Portable.zip` 解压到任意位置,运行 `Filee.exe` 即可。
+
+- **常用格式都已包含在安装包中。** 图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体和 CAD 装好即用。
+- **安装时即可选择。** 在资源管理器右键菜单中添加“用 Filee 转换”(Windows 11 上还可直接显示在主菜单,无需点
+ “显示更多选项”)、登录时启动,并按大小挑选可选引擎:视频/音频(FFmpeg,约 100 MB)和少见格式(LibreOffice、
+ calibre、Ghostscript、Pandoc)。所选引擎会在安装完成、Filee 启动后下载;之后可随时在“设置 → 转换引擎”中安装或
+ 删除,并显示下载速度和剩余时间。
+- **有更新会提醒你。** 新版本发布后,Filee 会在菜单底部、通知和托盘菜单中提示,点击即可打开最新发布页面下载。
+ 运行新的安装程序会关闭 Filee 并完成更新,设置和引擎都会保留。按用户安装的 Filee 1.1 及更早版本也会以同样方式接管。
+
+macOS 版本正在计划中,也在考虑支持 Linux。
+
## 功能
- **光标处的格式圆环。** 按住修饰键(默认 Ctrl )拖动文件即可。修饰键组合、鼠标按键、拖动距离、长按手势,
@@ -52,6 +69,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 语言。
@@ -75,10 +96,34 @@
| **CAD**:DWG、DXF | PDF、SVG、PNG、DWG ↔ DXF |
| **字体**:TTF、OTF、WOFF、WOFF2、EOT | 互相转换 |
-未标注的格式装好即用:无需 Microsoft Office、韩文办公软件或 LibreOffice,Filee 也从不调用电脑上已安装的程序。首次启动时
-可选择下载的引擎:¹ Ghostscript、² LibreOffice、³ Pandoc、⁴ calibre、⁵ FFmpeg。各引擎保留和不支持的内容见
+未标注的格式装好即用:无需 Microsoft Office、韩文办公软件或 LibreOffice,Filee 也从不调用电脑上已安装的程序。可在安装时勾选或之后在应用内
+下载的引擎:¹ Ghostscript、² LibreOffice、³ Pandoc、⁴ calibre、⁵ FFmpeg。各引擎保留和不支持的内容见
[docs/ENGINES.md](docs/ENGINES.md)。
+## 命令行
+
+`filee` 命令无需打开应用即可转换文件、监视文件夹,与应用共用引擎和预设。安装时勾选 **将 "filee" 命令添加到 PATH**,
+然后打开新的终端即可。
+
+```
+filee convert photo.heic --to jpg
+filee convert *.png --to webp --quality 80 -o converted
+filee convert D:\Scans --recursive --preset to-pdf --json
+filee watch D:\Inbox --to pdf --move-originals
+filee formats heic
+filee presets
+```
+
+| 命令 | 作用 |
+|---|---|
+| `filee convert` | 用 `--to <格式>` 或 `--preset <预设>` 转换文件、通配符(`*.heic`)和文件夹 |
+| `filee watch` | 转换放入文件夹的文件,直到按下 Ctrl +C |
+| `filee formats` | 列出所有格式,或某个格式可以转换成什么 |
+| `filee presets` | 列出应用中保存的预设 |
+
+加上 `--json` 会输出便于脚本和其他程序读取的 JSON。退出码:`0` 全部转换,`1` 部分失败,`2` 参数错误或文件不存在,
+`3` 没有可转换的文件。全部选项见[命令行指南](https://filee.sh/zh-cn/cli/)和 `filee help`。
+
## 截图
-## 快速开始
-
-**安装:** 在 [Releases](https://github.com/KnifeLemon/Filee/releases/latest) 下载最新的
-`Filee-<版本>-win-Setup.exe`(Windows 10/11,64 位)并运行。安装程序会请求一次管理员权限,为所有用户安装到
-Program Files。不想安装?把 `Filee-<版本>-win-Portable.zip` 解压到任意位置,运行 `Filee.exe` 即可。
-
-- **常用格式都已包含在安装包中。** 图片、PDF、Word、Excel、PowerPoint、HWP、电子书、压缩包、字体和 CAD 装好即用。
-- **安装时即可选择。** 在资源管理器右键菜单中添加“用 Filee 转换”(Windows 11 上还可直接显示在主菜单,无需点
- “显示更多选项”)、登录时启动,并按大小挑选可选引擎:视频/音频(FFmpeg,约 100 MB)和少见格式(LibreOffice、
- calibre、Ghostscript、Pandoc)。所选引擎会在安装完成、Filee 启动后下载;之后可随时在“设置 → 转换引擎”中安装或
- 删除,并显示下载速度和剩余时间。
-- **有更新会提醒你。** 新版本发布后,Filee 会在菜单底部、通知和托盘菜单中提示,点击即可打开最新发布页面下载。
- 运行新的安装程序会关闭 Filee 并完成更新,设置和引擎都会保留。按用户安装的 Filee 1.1 及更早版本也会以同样方式接管。
+## 文档
-macOS 版本正在计划中。
+| 我想要… | 从这里开始 |
+|---|---|
+| 使用命令行 | [filee.sh/zh-cn/cli](https://filee.sh/zh-cn/cli/) |
+| 了解应用的整体结构 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
+| 了解各引擎负责哪些转换及其许可证 | [docs/ENGINES.md](docs/ENGINES.md) |
+| 添加新的转换 | [docs/ADDING-A-CONVERTER.md](docs/ADDING-A-CONVERTER.md) |
+| 把 Filee 翻译成我的语言 | [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) |
+| 提交 Pull Request | [CONTRIBUTING.md](CONTRIBUTING.md) |
-
-从源码构建
+## 从源码构建
需要:.NET 10 SDK、Windows 10/11。
@@ -138,20 +177,9 @@ 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 渲染。 |
-
-
-## 文档
-
-| 我想要… | 从这里开始 |
-|---|---|
-| 了解应用的整体结构 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
-| 了解各引擎负责哪些转换及其许可证 | [docs/ENGINES.md](docs/ENGINES.md) |
-| 添加新的转换 | [docs/ADDING-A-CONVERTER.md](docs/ADDING-A-CONVERTER.md) |
-| 把 Filee 翻译成我的语言 | [docs/ADDING-A-LANGUAGE.md](docs/ADDING-A-LANGUAGE.md) |
-| 提交 Pull Request | [CONTRIBUTING.md](CONTRIBUTING.md) |
-
## 参与贡献
欢迎提交 Pull Request。适合入门的任务:[添加转换器](docs/ADDING-A-CONVERTER.md)和[添加语言](docs/ADDING-A-LANGUAGE.md)。
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..4e38f5e 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",
@@ -134,6 +135,11 @@
"general.modern_menu_failed": "Windows did not add the entry: {0}",
"general.modern_menu_remove_failed": "Windows did not remove the entry: {0}",
"general.check_updates": "Check for updates automatically",
+ "general.history": "Conversion history",
+ "general.no_history": "Don't keep a history of recent conversions",
+ "general.clear_history": "Clear history",
+ "general.history_count": "{0} conversion(s) in the history",
+ "general.history_cleared": "History cleared.",
"general.check_now": "Check now",
"general.update_available": "Version {0} is available",
"general.update_none": "You're up to date ({0})",
@@ -380,5 +386,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..39c89cf 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": "정보",
@@ -134,6 +135,11 @@
"general.modern_menu_failed": "Windows가 항목을 추가하지 못했어요: {0}",
"general.modern_menu_remove_failed": "Windows가 항목을 제거하지 못했어요: {0}",
"general.check_updates": "자동으로 업데이트 확인",
+ "general.history": "변환 기록",
+ "general.no_history": "최근 변환 기록 남기지 않기",
+ "general.clear_history": "기록 지우기",
+ "general.history_count": "기록 {0}개",
+ "general.history_cleared": "기록을 지웠어요.",
"general.check_now": "지금 확인",
"general.update_available": "{0} 버전을 사용할 수 있어요",
"general.update_none": "최신 버전이에요 ({0})",
@@ -380,5 +386,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..ab4eae4 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": "关于",
@@ -134,6 +135,11 @@
"general.modern_menu_failed": "Windows 未能添加此项:{0}",
"general.modern_menu_remove_failed": "Windows 未能移除此项:{0}",
"general.check_updates": "自动检查更新",
+ "general.history": "转换记录",
+ "general.no_history": "不保留最近的转换记录",
+ "general.clear_history": "清除记录",
+ "general.history_count": "共 {0} 条记录",
+ "general.history_cleared": "记录已清除。",
"general.check_now": "立即检查",
"general.update_available": "有新版本 {0}",
"general.update_none": "已是最新版本({0})",
@@ -380,5 +386,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/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
index 445e611..51e715e 100644
--- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
@@ -32,6 +32,8 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic
_startWithSystem = store.Settings.StartWithSystem;
_contextMenu = store.Settings.ContextMenuEnabled;
_checkForUpdates = store.Settings.CheckForUpdates;
+ _noHistory = !store.Settings.KeepHistory;
+ _historyCount = store.History.Count;
ShowUpdateState(updates.LatestVersion);
RefreshModernMenu();
}
@@ -117,6 +119,32 @@ private void RefreshModernMenu()
}
partial void OnCheckForUpdatesChanged(bool value) => Save(s => s.CheckForUpdates = value);
+ // Conversion history (home page): don't record it, or clear what is there.
+ [ObservableProperty] private bool _noHistory;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HistoryText))]
+ [NotifyCanExecuteChangedFor(nameof(ClearHistoryCommand))]
+ private int _historyCount;
+
+ [ObservableProperty] private bool _historyCleared;
+
+ public string HistoryText => HistoryCleared ? _loc["general.history_cleared"] : _loc.Format("general.history_count", HistoryCount);
+
+ partial void OnNoHistoryChanged(bool value) => Save(s => s.KeepHistory = !value);
+
+ partial void OnHistoryClearedChanged(bool value) => OnPropertyChanged(nameof(HistoryText));
+
+ private bool CanClearHistory() => HistoryCount > 0;
+
+ [RelayCommand(CanExecute = nameof(CanClearHistory))]
+ private void ClearHistory()
+ {
+ _store.ClearHistory();
+ HistoryCount = 0;
+ HistoryCleared = true;
+ }
+
private void Save(Action change)
{
change(_store.Settings);
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/GeneralPage.axaml b/src/Filee.App/Views/Pages/GeneralPage.axaml
index 97bb96f..df72fe9 100644
--- a/src/Filee.App/Views/Pages/GeneralPage.axaml
+++ b/src/Filee.App/Views/Pages/GeneralPage.axaml
@@ -41,6 +41,17 @@
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/Pages/WatchFoldersPage.axaml.cs b/src/Filee.App/Views/Pages/WatchFoldersPage.axaml.cs
new file mode 100644
index 0000000..3436385
--- /dev/null
+++ b/src/Filee.App/Views/Pages/WatchFoldersPage.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Filee.App.ViewModels.Pages;
+
+namespace Filee.App.Views.Pages;
+
+public partial class WatchFoldersPage : UserControl
+{
+ public WatchFoldersPage() => InitializeComponent();
+
+ private async void OnBrowseFolder(object? sender, RoutedEventArgs e)
+ {
+ if ((sender as Control)?.DataContext is WatchRuleViewModel rule && await PickFolderAsync() is { } path)
+ rule.Folder = path;
+ }
+
+ private async void OnBrowseOutput(object? sender, RoutedEventArgs e)
+ {
+ if ((sender as Control)?.DataContext is WatchRuleViewModel rule && await PickFolderAsync() is { } path)
+ rule.OutputFolder = path;
+ }
+
+ private async Task PickFolderAsync()
+ {
+ if (TopLevel.GetTopLevel(this) is not { } top)
+ return null;
+ var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { AllowMultiple = false });
+ return folders.FirstOrDefault()?.TryGetLocalPath();
+ }
+}
diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs
new file mode 100644
index 0000000..1c14700
--- /dev/null
+++ b/src/Filee.Cli/Cli.cs
@@ -0,0 +1,325 @@
+// The "filee" commands: convert, watch, formats, presets. Writes results for people (one line per file) or, with
+// --json, one JSON document for other programs. Exit codes: 0 everything converted, 1 some files failed,
+// 2 wrong arguments or a missing input, 3 nothing to convert.
+
+using System.Text.Json;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Watching;
+
+namespace Filee.Cli;
+
+internal static class Cli
+{
+ public const int Success = 0;
+ public const int SomeFailed = 1;
+ public const int UsageError = 2;
+ public const int NothingToDo = 3;
+
+ /// Engines and settings (tests pass a host with a temporary settings folder).
+ public static async Task RunAsync(IReadOnlyList args, TextWriter output, TextWriter errors,
+ CancellationToken cancellationToken, Func? createHost = null)
+ {
+ var options = CliOptions.Parse(args);
+ if (options.Error is not null)
+ {
+ errors.WriteLine($"filee: {options.Error}");
+ errors.WriteLine("Run 'filee help' for usage.");
+ return UsageError;
+ }
+ switch (options.Command)
+ {
+ case "help":
+ output.Write(Help.Text(options.Inputs.FirstOrDefault()));
+ return Success;
+ case "version":
+ output.WriteLine($"filee {Help.Version}");
+ return Success;
+ }
+
+ await using var host = (createHost ?? (() => CliHost.Create()))();
+ return options.Command switch
+ {
+ "convert" => await ConvertAsync(options, host, output, errors, cancellationToken),
+ "watch" => await WatchAsync(options, host, output, errors, cancellationToken),
+ "formats" => ListFormats(options, host, output, errors),
+ _ => ListPresets(host, output),
+ };
+ }
+
+ private static async Task ConvertAsync(CliOptions options, CliHost host, TextWriter output, TextWriter errors, CancellationToken cancellationToken)
+ {
+ if (ResolvePreset(options, host, errors) is not { } preset)
+ return UsageError;
+ if (options.Output is not null)
+ UseFolder(preset, options.Output);
+
+ var missing = new List();
+ var files = ExpandInputs(options.Inputs, options.Recursive, missing);
+ foreach (var path in missing)
+ errors.WriteLine($"filee: not found: {path}");
+ if (missing.Count > 0)
+ return UsageError;
+ if (files.Count == 0)
+ {
+ errors.WriteLine("filee: no files to convert.");
+ return NothingToDo;
+ }
+
+ var job = await RunJobAsync(files, preset, host, options.Json || options.Quiet ? null : output, cancellationToken);
+ var done = job.Files.Count(f => f.State == FileState.Done);
+ var failed = job.Files.Count(f => f.State is FileState.Failed or FileState.Cancelled);
+ if (options.Json)
+ WriteJson(job, host.Texts, output);
+ else if (!options.Quiet)
+ output.WriteLine(failed == 0
+ ? $"Converted {done} of {job.Files.Count} file(s)."
+ : $"Converted {done} of {job.Files.Count} file(s), {failed} failed.");
+ return failed == 0 ? Success : SomeFailed;
+ }
+
+ private static async Task WatchAsync(CliOptions options, CliHost host, TextWriter output, TextWriter errors, CancellationToken cancellationToken)
+ {
+ if (ResolvePreset(options, host, errors) is not { } preset)
+ return UsageError;
+ var rule = new WatchRule
+ {
+ Folder = Path.GetFullPath(options.Inputs[0]),
+ IncludeSubfolders = options.Recursive,
+ OutputFolder = options.Output is null ? "" : Path.GetFullPath(options.Output),
+ Originals = options.MoveOriginals ? AfterConversion.MoveToOriginals : AfterConversion.Keep,
+ };
+ UseFolder(preset, rule.ResolvedOutputFolder);
+
+ var settle = options.SettleSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : (TimeSpan?)null;
+ await using var watcher = new FolderWatcher(rule,
+ async (files, token) => await RunJobAsync(files, preset, host, options.Quiet ? null : output, token),
+ settleTime: settle);
+ if (!watcher.Start())
+ {
+ errors.WriteLine($"filee: {watcher.Error}");
+ return UsageError;
+ }
+ if (!options.Quiet)
+ output.WriteLine($"Watching {rule.Folder} → {host.Texts.DisplayName(preset)}, saving to {rule.ResolvedOutputFolder}. Press Ctrl+C to stop.");
+ try
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ return Success;
+ }
+
+ /// Runs one job and prints each file's result as soon as it is final (when is given).
+ private static async Task RunJobAsync(IReadOnlyList files, Preset preset, CliHost host, TextWriter? output,
+ CancellationToken cancellationToken)
+ {
+ var job = new ConversionJob(files, preset, host.Texts.DisplayName(preset));
+ var printed = new HashSet();
+ void Print(object? sender, ConversionJob updated)
+ {
+ if (output is null || updated != job)
+ return;
+ lock (printed)
+ {
+ foreach (var file in job.Files.Where(f => f.State is not (FileState.Pending or FileState.Running) && printed.Add(f)))
+ output.WriteLine(Describe(file, host.Texts));
+ }
+ }
+
+ host.Queue.JobUpdated += Print;
+ try
+ {
+ using var _ = cancellationToken.Register(job.Cancel);
+ await host.Queue.RunAsync(job);
+ }
+ finally
+ {
+ host.Queue.JobUpdated -= Print;
+ }
+ Print(null, job);
+ return job;
+ }
+
+ private static string Describe(FileResult file, ILocalizer texts)
+ {
+ var source = Shorten(file.SourcePath);
+ return file.State switch
+ {
+ FileState.Done => $"ok {source} -> {string.Join(", ", file.Outputs.Select(Shorten))}",
+ FileState.Skipped => $"skip {source} (the output file already exists)",
+ FileState.Cancelled => $"stop {source} (cancelled)",
+ _ => $"fail {source}: {Error(file, texts)}",
+ };
+ }
+
+ private static string Error(FileResult file, ILocalizer texts)
+ {
+ var message = file.ErrorKey is null ? "conversion failed" : texts[file.ErrorKey];
+ return string.IsNullOrWhiteSpace(file.ErrorDetail) ? message : $"{message} ({file.ErrorDetail})";
+ }
+
+ /// Relative to the current folder when the file is inside it, else the full path.
+ private static string Shorten(string path)
+ {
+ var relative = Path.GetRelativePath(Environment.CurrentDirectory, path);
+ return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) ? path : relative;
+ }
+
+ private static void WriteJson(ConversionJob job, ILocalizer texts, TextWriter output)
+ {
+ using var stream = new MemoryStream();
+ using (var json = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
+ {
+ json.WriteStartObject();
+ json.WriteString("preset", job.PresetDisplayName);
+ json.WriteString("target", job.Preset.TargetFormat);
+ json.WriteNumber("converted", job.Files.Count(f => f.State == FileState.Done));
+ json.WriteNumber("skipped", job.Files.Count(f => f.State == FileState.Skipped));
+ json.WriteNumber("failed", job.Files.Count(f => f.State is FileState.Failed or FileState.Cancelled));
+ json.WriteStartArray("files");
+ foreach (var file in job.Files)
+ {
+ json.WriteStartObject();
+ json.WriteString("source", file.SourcePath);
+ json.WriteString("state", file.State.ToString().ToLowerInvariant());
+ json.WriteStartArray("outputs");
+ foreach (var path in file.Outputs)
+ json.WriteStringValue(path);
+ json.WriteEndArray();
+ if (file.State is FileState.Failed or FileState.Cancelled)
+ json.WriteString("error", Error(file, texts));
+ json.WriteEndObject();
+ }
+ json.WriteEndArray();
+ json.WriteEndObject();
+ }
+ output.WriteLine(System.Text.Encoding.UTF8.GetString(stream.ToArray()));
+ }
+
+ /// The preset from --to or --preset, with --quality and --overwrite/--skip applied (a copy).
+ private static Preset? ResolvePreset(CliOptions options, CliHost host, TextWriter errors)
+ {
+ Preset preset;
+ if (options.To is not null)
+ {
+ if (FindFormat(options.To) is not { } format)
+ {
+ errors.WriteLine($"filee: unknown format '{options.To}'. Run 'filee formats' for the list.");
+ return null;
+ }
+ if (!format.Writable)
+ {
+ errors.WriteLine($"filee: Filee reads {format.DisplayName} but can't write it.");
+ return null;
+ }
+ preset = new Preset { TargetFormat = format.Id };
+ }
+ else
+ {
+ var wanted = options.Preset!;
+ var found = host.Store.Presets.FirstOrDefault(p => string.Equals(p.Id, wanted, StringComparison.OrdinalIgnoreCase))
+ ?? host.Store.Presets.FirstOrDefault(p => string.Equals(host.Texts.DisplayName(p), wanted, StringComparison.OrdinalIgnoreCase));
+ if (found is null)
+ {
+ errors.WriteLine($"filee: no preset '{wanted}'. Run 'filee presets' for the list.");
+ return null;
+ }
+ preset = found.Clone();
+ }
+ if (options.Quality is { } quality)
+ preset.Image.Quality = quality;
+ if (options.Conflict is { } conflict)
+ preset.Output.Conflict = conflict;
+ return preset;
+ }
+
+ private static void UseFolder(Preset preset, string folder)
+ {
+ preset.Output.Location = OutputLocation.CustomFolder;
+ preset.Output.CustomFolder = Path.GetFullPath(folder);
+ }
+
+ /// A format by id ("jpg") or by extension ("jpeg", ".jpg").
+ internal static FileFormat? FindFormat(string text)
+ {
+ var key = text.Trim().TrimStart('.').ToLowerInvariant();
+ return FormatRegistry.FindById(key) ?? FormatRegistry.FindByExtension(key);
+ }
+
+ ///
+ /// Files from the arguments: files as given, wildcards ("*.heic", which cmd.exe and PowerShell pass on unexpanded)
+ /// and folders (their files in formats Filee knows; with --recursive also in sub folders).
+ ///
+ internal static List ExpandInputs(IEnumerable inputs, bool recursive, List missing)
+ {
+ var option = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+ var files = new List();
+ foreach (var input in inputs)
+ {
+ if (input.IndexOfAny(['*', '?']) >= 0)
+ {
+ var folder = Path.GetDirectoryName(input) is { Length: > 0 } dir ? dir : ".";
+ var matches = Directory.Exists(folder)
+ ? Directory.EnumerateFiles(folder, Path.GetFileName(input), option).ToList()
+ : [];
+ if (matches.Count == 0)
+ missing.Add(input);
+ files.AddRange(matches);
+ }
+ else if (Directory.Exists(input))
+ files.AddRange(Directory.EnumerateFiles(input, "*", option).Where(f => FormatRegistry.Detect(f) is not null));
+ else if (File.Exists(input))
+ files.Add(input);
+ else
+ missing.Add(input);
+ }
+ return files.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ }
+
+ private static int ListFormats(CliOptions options, CliHost host, TextWriter output, TextWriter errors)
+ {
+ if (options.Inputs.FirstOrDefault() is { } name)
+ {
+ if (FindFormat(name) is not { } from)
+ {
+ errors.WriteLine($"filee: unknown format '{name}'.");
+ return UsageError;
+ }
+ var installed = host.Catalog.CreatePlanner();
+ var everything = host.Catalog.CreatePlanner(host.Catalog.All.Select(c => c.Id).ToList());
+ var targets = FormatRegistry.Known.Where(t => t.Writable && t.Id != from.Id && t.Id != FormatRegistry.Folder).ToList();
+ var now = targets.Where(t => installed.CanConvert(from.Id, t.Id)).Select(t => t.Id).ToList();
+ var later = targets.Where(t => !now.Contains(t.Id) && everything.CanConvert(from.Id, t.Id)).Select(t => t.Id).ToList();
+ output.WriteLine($"{from.DisplayName} ({string.Join(", ", from.Extensions)}) converts to:");
+ output.WriteLine(now.Count > 0 ? " " + string.Join(", ", now) : " (nothing with the engines installed now)");
+ if (later.Count > 0)
+ output.WriteLine($"With optional engines (install them in Filee, Settings → Engines):{Environment.NewLine} {string.Join(", ", later)}");
+ return Success;
+ }
+
+ foreach (var group in FormatRegistry.Known.Where(f => f.Id != FormatRegistry.Folder).GroupBy(f => f.Category))
+ {
+ output.WriteLine($"{group.Key}:");
+ foreach (var format in group)
+ output.WriteLine($" {format.Id,-10} {format.DisplayName}{(format.Writable ? "" : " (read only)")} .{string.Join(" .", format.Extensions)}");
+ }
+ output.WriteLine();
+ output.WriteLine("Run 'filee formats ' to see what a format converts to.");
+ return Success;
+ }
+
+ private static int ListPresets(CliHost host, TextWriter output)
+ {
+ output.WriteLine($"{"ID",-24} {"NAME",-28} TARGET");
+ foreach (var preset in host.Store.Presets)
+ output.WriteLine($"{preset.Id,-24} {host.Texts.DisplayName(preset),-28} {preset.TargetFormat}");
+ output.WriteLine();
+ output.WriteLine("Use one with: filee convert --preset ");
+ return Success;
+ }
+}
diff --git a/src/Filee.Cli/CliHost.cs b/src/Filee.Cli/CliHost.cs
new file mode 100644
index 0000000..26b5a92
--- /dev/null
+++ b/src/Filee.Cli/CliHost.cs
@@ -0,0 +1,38 @@
+// The conversion pipeline without the app: the user's settings and presets, every engine (bundled ones next to the
+// executable, downloaded ones in the per-user engines folder) and a job queue. Mirrors AppHost in Filee.App.
+
+using Filee.Core.Conversion;
+using Filee.Core.Localization;
+using Filee.Core.Settings;
+using Filee.Engines;
+using Filee.Engines.Infrastructure;
+
+namespace Filee.Cli;
+
+internal sealed class CliHost : IAsyncDisposable
+{
+ private CliHost(UserDataStore store, ConverterCatalog catalog, JobQueue queue)
+ {
+ Store = store;
+ Catalog = catalog;
+ Queue = queue;
+ }
+
+ public UserDataStore Store { get; }
+ public ConverterCatalog Catalog { get; }
+ public JobQueue Queue { get; }
+ public ILocalizer Texts { get; } = new EnglishTexts();
+
+ /// Settings folder; the app's (%APPDATA%\Filee) when null.
+ public static CliHost Create(string? dataDirectory = null)
+ {
+ var store = new UserDataStore(dataDirectory ?? UserDataStore.DefaultDirectory);
+ store.Load();
+ var converters = EngineRegistry.CreateAll(new EngineEnvironment(store.Directory));
+ var catalog = new ConverterCatalog(converters) { Priority = store.Settings.EnginePriority };
+ var queue = new JobQueue(catalog, EngineRegistry.FindPdfMerger(converters), combiner: EngineRegistry.FindFileCombiner(converters));
+ return new CliHost(store, catalog, queue);
+ }
+
+ public ValueTask DisposeAsync() => Queue.DisposeAsync();
+}
diff --git a/src/Filee.Cli/CliOptions.cs b/src/Filee.Cli/CliOptions.cs
new file mode 100644
index 0000000..aa1f1c8
--- /dev/null
+++ b/src/Filee.Cli/CliOptions.cs
@@ -0,0 +1,125 @@
+// Parses the "filee" command line. Kept dependency-free: a handful of commands and options.
+
+using Filee.Core.Presets;
+
+namespace Filee.Cli;
+
+/// A parsed command line. is set when the arguments are wrong.
+internal sealed record CliOptions
+{
+ public string Command { get; init; } = "help";
+ public List Inputs { get; } = [];
+ public string? To { get; set; }
+ public string? Preset { get; set; }
+ public string? Output { get; set; }
+ public bool Recursive { get; set; }
+ public int? Quality { get; set; }
+ public ConflictPolicy? Conflict { get; set; }
+ public bool Json { get; set; }
+ public bool Quiet { get; set; }
+ public bool MoveOriginals { get; set; }
+ public double? SettleSeconds { get; set; }
+ public string? Error { get; set; }
+
+ public static readonly string[] Commands = ["convert", "watch", "formats", "presets", "help", "version"];
+
+ public static CliOptions Parse(IReadOnlyList args)
+ {
+ if (args.Count == 0)
+ return new CliOptions { Command = "help" };
+ var first = args[0].ToLowerInvariant();
+ var command = first switch
+ {
+ "-h" or "--help" or "/?" => "help",
+ "-v" or "--version" => "version",
+ _ => first,
+ };
+ var options = new CliOptions { Command = command };
+ if (!Commands.Contains(command))
+ {
+ options.Error = $"Unknown command '{args[0]}'.";
+ return options;
+ }
+
+ for (var i = 1; i < args.Count; i++)
+ {
+ var arg = args[i];
+ string? Value()
+ {
+ if (i + 1 < args.Count)
+ return args[++i];
+ options.Error ??= $"{arg} needs a value.";
+ return null;
+ }
+
+ switch (arg.ToLowerInvariant())
+ {
+ case "-t" or "--to":
+ options.To = Value();
+ break;
+ case "-p" or "--preset":
+ options.Preset = Value();
+ break;
+ case "-o" or "--output":
+ options.Output = Value();
+ break;
+ case "-r" or "--recursive":
+ options.Recursive = true;
+ break;
+ case "-q" or "--quality":
+ var quality = Value();
+ if (quality is not null)
+ {
+ if (int.TryParse(quality, out var q) && q is >= 1 and <= 100)
+ options.Quality = q;
+ else
+ options.Error ??= "--quality must be a number from 1 to 100.";
+ }
+ break;
+ case "--overwrite":
+ options.Conflict = ConflictPolicy.Overwrite;
+ break;
+ case "--skip":
+ options.Conflict = ConflictPolicy.Skip;
+ break;
+ case "--json":
+ options.Json = true;
+ break;
+ case "--quiet":
+ options.Quiet = true;
+ break;
+ case "--move-originals":
+ options.MoveOriginals = true;
+ break;
+ case "--settle":
+ var settle = Value();
+ if (settle is not null)
+ {
+ if (double.TryParse(settle, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var s) && s >= 0)
+ options.SettleSeconds = s;
+ else
+ options.Error ??= "--settle must be a number of seconds.";
+ }
+ break;
+ case "-h" or "--help":
+ options.Inputs.Insert(0, options.Command);
+ return options with { Command = "help" };
+ default:
+ if (arg.StartsWith("--", StringComparison.Ordinal) || (arg.StartsWith('-') && arg.Length == 2))
+ options.Error ??= $"Unknown option '{arg}'.";
+ else
+ options.Inputs.Add(arg);
+ break;
+ }
+ }
+
+ options.Error ??= options.Command switch
+ {
+ "convert" when options.Inputs.Count == 0 => "Give at least one file or folder to convert.",
+ "convert" or "watch" when options.To is null == (options.Preset is null) => "Use either --to or --preset .",
+ "watch" when options.Inputs.Count != 1 => "Give exactly one folder to watch.",
+ _ => null,
+ };
+ return options;
+ }
+}
diff --git a/src/Filee.Cli/EnglishTexts.cs b/src/Filee.Cli/EnglishTexts.cs
new file mode 100644
index 0000000..9d207aa
--- /dev/null
+++ b/src/Filee.Cli/EnglishTexts.cs
@@ -0,0 +1,30 @@
+// English texts for the command line, read from the app's translation file (embedded): error messages of failed
+// files and the names of built-in presets.
+
+using System.Text.Json;
+using Filee.Core.Localization;
+
+namespace Filee.Cli;
+
+internal sealed class EnglishTexts : ILocalizer
+{
+ private readonly Dictionary _texts;
+
+ public EnglishTexts()
+ {
+ using var stream = typeof(EnglishTexts).Assembly.GetManifestResourceStream("Filee.Cli.en.json")
+ ?? throw new InvalidOperationException("Embedded en.json is missing.");
+ using var json = JsonDocument.Parse(stream);
+ _texts = json.RootElement.EnumerateObject()
+ .Where(p => p.Value.ValueKind == JsonValueKind.String)
+ .ToDictionary(p => p.Name, p => p.Value.GetString()!);
+ }
+
+ public string Language => "en";
+
+ public string this[string key] => _texts.GetValueOrDefault(key, key);
+
+ public string Format(string key, params object[] args) => string.Format(System.Globalization.CultureInfo.InvariantCulture, this[key], args);
+
+ public event EventHandler? LanguageChanged { add { } remove { } }
+}
diff --git a/src/Filee.Cli/Filee.Cli.csproj b/src/Filee.Cli/Filee.Cli.csproj
new file mode 100644
index 0000000..af728be
--- /dev/null
+++ b/src/Filee.Cli/Filee.Cli.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ filee-cli
+ Filee.Cli
+ ..\Filee.App\Assets\Icons\filee.ico
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.Cli/Help.cs b/src/Filee.Cli/Help.cs
new file mode 100644
index 0000000..ff1bc65
--- /dev/null
+++ b/src/Filee.Cli/Help.cs
@@ -0,0 +1,79 @@
+// Usage text of the "filee" command.
+
+using System.Reflection;
+
+namespace Filee.Cli;
+
+internal static class Help
+{
+ public static string Version =>
+ typeof(Help).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "?";
+
+ public static string Text(string? command) => command?.ToLowerInvariant() switch
+ {
+ "convert" => Convert,
+ "watch" => Watch,
+ "formats" => "Usage: filee formats []\n\nLists every format, or what one format converts to.\n",
+ "presets" => "Usage: filee presets\n\nLists the presets saved in Filee (built-in and your own).\n",
+ _ => General,
+ };
+
+ private const string General = """
+ Filee: convert files offline from the command line.
+
+ Usage:
+ filee convert --to [options]
+ filee convert --preset [options]
+ filee watch --to [options]
+ filee formats []
+ filee presets
+ filee help []
+ filee version
+
+ Examples:
+ filee convert photo.heic --to jpg
+ filee convert *.png --to webp --quality 80 -o converted
+ filee convert D:\Scans --recursive --preset to-pdf
+ filee watch D:\Inbox --to pdf --move-originals
+
+ Presets and engines are the ones in the Filee app. Exit codes: 0 all converted, 1 some failed,
+ 2 wrong arguments or a missing input, 3 nothing to convert.
+
+ """;
+
+ private const string Convert = """
+ Usage: filee convert (--to | --preset ) [options]
+
+ Files can be wildcards (*.heic). Folders convert the files in them that Filee knows.
+
+ Options:
+ -t, --to Target format, e.g. jpg, png, pdf, docx, mp4 (see 'filee formats')
+ -p, --preset A preset from the app, by id or name (see 'filee presets')
+ -o, --output Save there instead of next to the sources
+ -r, --recursive Include sub folders
+ -q, --quality <1-100> Image quality for JPG, WebP, AVIF, …
+ --overwrite Replace existing output files (default: add a number)
+ --skip Leave existing output files alone
+ --json Print the result as JSON (for scripts and other programs)
+ --quiet Print nothing but errors
+
+ """;
+
+ private const string Watch = """
+ Usage: filee watch (--to | --preset ) [options]
+
+ Converts files that land in the folder until Ctrl+C. Files are converted once they are complete (no
+ longer growing or open in another program). Output goes to a "converted" folder inside it unless -o is given.
+
+ Options:
+ -t, --to Target format
+ -p, --preset A preset from the app, by id or name
+ -o, --output Where converted files go (default: \converted)
+ -r, --recursive Also watch sub folders
+ --move-originals Move converted sources to \originals; files already waiting are
+ converted at start
+ --settle How long a file must stay unchanged (default 2)
+ --quiet Print nothing but errors
+
+ """;
+}
diff --git a/src/Filee.Cli/Program.cs b/src/Filee.Cli/Program.cs
new file mode 100644
index 0000000..39676df
--- /dev/null
+++ b/src/Filee.Cli/Program.cs
@@ -0,0 +1,16 @@
+// Entry point of filee-cli.exe (run as "filee" through cli\filee.cmd). Ctrl+C cancels the running conversion or
+// stops watching; a second Ctrl+C ends the process at once.
+
+using System.Text;
+using Filee.Cli;
+
+Console.OutputEncoding = Encoding.UTF8;
+using var cancel = new CancellationTokenSource();
+Console.CancelKeyPress += (_, e) =>
+{
+ if (cancel.IsCancellationRequested)
+ return;
+ e.Cancel = true;
+ cancel.Cancel();
+};
+return await Cli.RunAsync(args, Console.Out, Console.Error, cancel.Token);
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index 0f21840..b69d51b 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -62,6 +62,9 @@ public sealed class AppSettings
public bool CheckForUpdates { get; set; } = true;
+ /// Record finished conversions in the history (home page). Off: nothing new is recorded.
+ public bool KeepHistory { get; set; } = true;
+
/// Newest version the user was already told about, so the update notice shows once per release.
public string? NotifiedUpdateVersion { get; set; }
@@ -93,4 +96,7 @@ public sealed class AppSettings
/// Set once the "Is Filee helping you?" card (GitHub star / feedback) has been shown.
public bool FeedbackPromptShown { get; set; }
+
+ /// Folders whose new files are converted automatically (Settings → Watch folders).
+ public List WatchFolders { get; set; } = [];
}
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index a8648f7..1975d5f 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -89,8 +89,11 @@ public void SaveLibrary()
LibraryChanged?.Invoke(this, EventArgs.Empty);
}
+ /// Records a finished conversion, unless the user turned the history off ( ).
public void AddHistory(HistoryEntry entry)
{
+ if (!Settings.KeepHistory)
+ return;
lock (_saveLock)
{
History.Insert(0, entry);
diff --git a/src/Filee.Core/Watching/FolderWatcher.cs b/src/Filee.Core/Watching/FolderWatcher.cs
new file mode 100644
index 0000000..5d3143b
--- /dev/null
+++ b/src/Filee.Core/Watching/FolderWatcher.cs
@@ -0,0 +1,282 @@
+// Watches one folder (WatchRule) and converts files that arrive in it. Used by the tray app (Settings → Watch folders)
+// and by "filee watch". A file is converted only once it is complete: it must keep the same size and time for
+// SettleTime and open without sharing, so downloads and copies still in progress are left alone. Temporary files,
+// the output folder and the originals folder are never picked up, so converted files can't loop back in.
+
+using System.Collections.Concurrent;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Filee.Core.Watching;
+
+/// Converts files that land in a watched folder.
+public sealed class FolderWatcher : IAsyncDisposable
+{
+ /// Converts a batch of complete files and returns the finished job (null when it could not start).
+ public delegate Task ConvertFiles(IReadOnlyList files, CancellationToken cancellationToken);
+
+ /// Default time a file must stay unchanged before it is converted.
+ public static readonly TimeSpan DefaultSettleTime = TimeSpan.FromSeconds(2);
+
+ private static readonly HashSet TemporaryExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".tmp", ".temp", ".part", ".partial", ".crdownload", ".download", ".opdownload", ".!ut", ".filepart",
+ };
+
+ private static readonly HashSet SystemFiles = new(StringComparer.OrdinalIgnoreCase) { "desktop.ini", "thumbs.db" };
+
+ private readonly ConvertFiles _convert;
+ private readonly ILogger _log;
+ private readonly TimeSpan _settleTime;
+ private readonly TimeProvider _time;
+ private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _handled = new(StringComparer.OrdinalIgnoreCase);
+ private readonly SemaphoreSlim _busy = new(1, 1);
+ private readonly CancellationTokenSource _stop = new();
+ private FileSystemWatcher? _watcher;
+ private ITimer? _timer;
+
+ /// The folder and its options; the watcher keeps its own copy.
+ /// Runs the conversion (the app's queue or the command line's).
+ /// How long a file must stay unchanged; if null.
+ /// Clock (tests use a fake one).
+ public FolderWatcher(WatchRule rule, ConvertFiles convert, ILogger? log = null, TimeSpan? settleTime = null, TimeProvider? time = null)
+ {
+ Rule = rule.Clone();
+ _convert = convert;
+ _log = log ?? NullLogger.Instance;
+ _settleTime = settleTime ?? DefaultSettleTime;
+ _time = time ?? TimeProvider.System;
+ }
+
+ public WatchRule Rule { get; }
+
+ /// Why the folder is not being watched (it does not exist, access denied), or null.
+ public string? Error { get; private set; }
+
+ /// Raised after each converted batch (on a background thread).
+ public event EventHandler? Converted;
+
+ ///
+ /// Starts watching. With the files already in the folder are
+ /// converted too (they are what arrived while nobody was watching); with
+ /// only new files are, because the old ones were converted before. Returns false when the folder can't be watched.
+ ///
+ public bool Start()
+ {
+ if (!Directory.Exists(Rule.Folder))
+ {
+ Error = $"Folder not found: {Rule.Folder}";
+ return false;
+ }
+ try
+ {
+ _watcher = new FileSystemWatcher(Rule.Folder)
+ {
+ IncludeSubdirectories = Rule.IncludeSubfolders,
+ NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite,
+ InternalBufferSize = 64 * 1024,
+ };
+ _watcher.Created += (_, e) => Notice(e.FullPath);
+ _watcher.Changed += (_, e) => Notice(e.FullPath);
+ _watcher.Renamed += (_, e) => Notice(e.FullPath);
+ _watcher.Error += (_, e) =>
+ {
+ // The change buffer overflowed (thousands of files at once): look at the folder itself.
+ _log.LogWarning(e.GetException(), "Watching {Folder} missed changes; rescanning", Rule.Folder);
+ ScanExisting();
+ };
+ _watcher.EnableRaisingEvents = true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
+ {
+ Error = ex.Message;
+ return false;
+ }
+
+ if (Rule.Originals == AfterConversion.MoveToOriginals)
+ ScanExisting();
+ _timer = _time.CreateTimer(_ => _ = ProcessPendingAsync(), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
+ Error = null;
+ return true;
+ }
+
+ /// Registers a file that appeared or changed; it is converted once it has settled.
+ public void Notice(string path)
+ {
+ if (ShouldIgnore(path, Rule))
+ return;
+ var now = _time.GetUtcNow();
+ _pending.AddOrUpdate(path, _ => Candidate.Of(path, now), (_, old) => old.Refresh(path, now));
+ }
+
+ ///
+ /// Converts the pending files that are complete. Called every second by the timer; tests call it directly.
+ /// Returns how many files were handed to the conversion.
+ ///
+ public async Task ProcessPendingAsync()
+ {
+ if (!await _busy.WaitAsync(0))
+ return 0; // the previous batch is still converting; new files wait for the next tick
+ try
+ {
+ var ready = new List();
+ var now = _time.GetUtcNow();
+ foreach (var (path, candidate) in _pending)
+ {
+ var current = candidate.Refresh(path, now);
+ if (!current.Exists)
+ {
+ _pending.TryRemove(path, out _);
+ continue;
+ }
+ _pending[path] = current;
+ if (now - current.StableSince < _settleTime || !CanOpenExclusively(path))
+ continue;
+ _pending.TryRemove(path, out _);
+ // A file that was converted before (same size and time) is not converted again.
+ if (_handled.Add(current.Key(path)))
+ ready.Add(path);
+ }
+ if (ready.Count == 0 || _stop.IsCancellationRequested)
+ return 0;
+
+ ready.Sort(StringComparer.OrdinalIgnoreCase);
+ _log.LogInformation("Watch folder {Folder}: converting {Count} file(s)", Rule.Folder, ready.Count);
+ var job = await _convert(ready, _stop.Token);
+ if (job is not null)
+ {
+ if (Rule.Originals == AfterConversion.MoveToOriginals)
+ foreach (var file in job.Files.Where(f => f.State == FileState.Done))
+ MoveToOriginals(file.SourcePath);
+ Converted?.Invoke(this, job);
+ }
+ return ready.Count;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log.LogError(ex, "Watch folder {Folder} failed", Rule.Folder);
+ return 0;
+ }
+ finally
+ {
+ _busy.Release();
+ }
+ }
+
+ /// True for files a watch folder never converts: temporary, hidden or system files, unknown formats,
+ /// and anything inside the output or originals folder.
+ public static bool ShouldIgnore(string path, WatchRule rule)
+ {
+ var name = Path.GetFileName(path);
+ if (name.Length == 0 || name.StartsWith("~$", StringComparison.Ordinal) || name.StartsWith('.')
+ || SystemFiles.Contains(name) || TemporaryExtensions.Contains(Path.GetExtension(name)))
+ return true;
+ if (IsInside(path, rule.ResolvedOutputFolder) || IsInside(path, rule.OriginalsFolder))
+ return true;
+ if (!rule.IncludeSubfolders && !SameFolder(Path.GetDirectoryName(path), rule.Folder))
+ return true;
+ if (FormatRegistry.Detect(path) is null)
+ return true;
+ try
+ {
+ var attributes = File.GetAttributes(path);
+ return (attributes & (FileAttributes.Directory | FileAttributes.Hidden | FileAttributes.System)) != 0;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return true; // gone already, or not ours to read
+ }
+ }
+
+ private void ScanExisting()
+ {
+ try
+ {
+ var option = Rule.IncludeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+ foreach (var file in Directory.EnumerateFiles(Rule.Folder, "*", option))
+ Notice(file);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _log.LogWarning(ex, "Could not list {Folder}", Rule.Folder);
+ }
+ }
+
+ private void MoveToOriginals(string source)
+ {
+ try
+ {
+ var target = Path.Combine(Rule.OriginalsFolder, Path.GetRelativePath(Rule.Folder, source));
+ Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+ var name = Path.GetFileNameWithoutExtension(target);
+ var extension = Path.GetExtension(target);
+ for (var n = 2; File.Exists(target); n++)
+ target = Path.Combine(Path.GetDirectoryName(target)!, $"{name} ({n}){extension}");
+ File.Move(source, target);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _log.LogWarning(ex, "Could not move {File} to the originals folder", source);
+ }
+ }
+
+ /// A writer still holding the file (download, copy) makes an exclusive open fail.
+ private static bool CanOpenExclusively(string path)
+ {
+ try
+ {
+ using var _ = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None);
+ return true;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsInside(string path, string folder)
+ {
+ if (string.IsNullOrWhiteSpace(folder))
+ return false;
+ var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(folder)) + Path.DirectorySeparatorChar;
+ return Path.GetFullPath(path).StartsWith(root, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool SameFolder(string? a, string b) =>
+ a is not null && string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(a)),
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(b)), StringComparison.OrdinalIgnoreCase);
+
+ public async ValueTask DisposeAsync()
+ {
+ _stop.Cancel();
+ _watcher?.Dispose();
+ if (_timer is not null)
+ await _timer.DisposeAsync();
+ // Let a running batch finish moving its originals.
+ await _busy.WaitAsync();
+ _busy.Release();
+ }
+
+ /// Size and time of a pending file, and since when they have not changed.
+ private readonly record struct Candidate(bool Exists, long Length, DateTime LastWriteUtc, DateTimeOffset StableSince)
+ {
+ public static Candidate Of(string path, DateTimeOffset now)
+ {
+ var info = new FileInfo(path);
+ return info.Exists
+ ? new Candidate(true, info.Length, info.LastWriteTimeUtc, now)
+ : new Candidate(false, 0, default, now);
+ }
+
+ public Candidate Refresh(string path, DateTimeOffset now)
+ {
+ var fresh = Of(path, now);
+ return fresh.Exists && fresh.Length == Length && fresh.LastWriteUtc == LastWriteUtc ? this : fresh;
+ }
+
+ public string Key(string path) => $"{path}|{Length}|{LastWriteUtc.Ticks}";
+ }
+}
diff --git a/src/Filee.Core/Watching/WatchRule.cs b/src/Filee.Core/Watching/WatchRule.cs
new file mode 100644
index 0000000..ab49a6e
--- /dev/null
+++ b/src/Filee.Core/Watching/WatchRule.cs
@@ -0,0 +1,49 @@
+// A watch folder: files that land in the folder are converted with a preset (Settings → Watch folders, or
+// "filee watch" on the command line).
+
+namespace Filee.Core.Watching;
+
+/// What happens to a source file once it was converted.
+public enum AfterConversion
+{
+ /// The source stays where it is.
+ Keep,
+
+ /// The source moves into , so the watch folder works like an inbox.
+ MoveToOriginals,
+}
+
+/// One watched folder and how its files are converted.
+public sealed class WatchRule
+{
+ /// Name of the default output folder inside the watched folder.
+ public const string DefaultOutputName = "converted";
+
+ /// Name of the folder inside the watched folder that receives converted sources ( ).
+ public const string OriginalsName = "originals";
+
+ public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
+
+ public bool Enabled { get; set; } = true;
+
+ public string Folder { get; set; } = "";
+
+ /// Preset to convert with ( ).
+ public string PresetId { get; set; } = "";
+
+ public bool IncludeSubfolders { get; set; }
+
+ /// Where converted files go; empty means converted inside .
+ public string OutputFolder { get; set; } = "";
+
+ public AfterConversion Originals { get; set; } = AfterConversion.Keep;
+
+ /// The output folder actually used.
+ public string ResolvedOutputFolder =>
+ string.IsNullOrWhiteSpace(OutputFolder) ? Path.Combine(Folder, DefaultOutputName) : OutputFolder;
+
+ /// Where converted sources go with .
+ public string OriginalsFolder => Path.Combine(Folder, OriginalsName);
+
+ public WatchRule Clone() => (WatchRule)MemberwiseClone();
+}
diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs
index 149f042..faa6927 100644
--- a/tests/Filee.App.Tests/RenderTests.cs
+++ b/tests/Filee.App.Tests/RenderTests.cs
@@ -44,6 +44,36 @@ public void Main_window_pages_render(string page, string language)
window.Close();
}
+ [AvaloniaTheory]
+ [InlineData("en")]
+ [InlineData("ko")]
+ public void Watch_folders_page_renders_a_rule(string language)
+ {
+ TestServices.EnsureInitialized(language);
+ var store = AppHost.Get();
+ var rule = new Filee.Core.Watching.WatchRule { Folder = @"C:\Scans\Inbox", PresetId = "to-pdf", Enabled = false };
+ store.Settings.WatchFolders.Add(rule);
+ try
+ {
+ var vm = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get());
+ vm.Navigate("watch");
+ var window = new MainWindow { DataContext = vm, Width = 1080, Height = 820 };
+ window.Show();
+ Pump();
+
+ var page = (Filee.App.ViewModels.Pages.WatchFoldersPageViewModel)vm.CurrentPage;
+ var item = Assert.Single(page.Rules);
+ Assert.Equal("to-pdf", item.Preset?.Value);
+ Assert.False(string.IsNullOrWhiteSpace(item.Status));
+ Save(window, $"page-watch-{language}.png");
+ window.Close();
+ }
+ finally
+ {
+ store.Settings.WatchFolders.Remove(rule);
+ }
+ }
+
[AvaloniaFact]
public void Engines_page_shows_versions_descriptions_and_download_speed()
{
diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs
new file mode 100644
index 0000000..787342d
--- /dev/null
+++ b/tests/Filee.Cli.Tests/CliTests.cs
@@ -0,0 +1,169 @@
+// End-to-end tests of the "filee" command: real engines, a temporary settings folder, files in a temporary folder.
+
+using System.Text.Json;
+using Filee.Core.Presets;
+
+namespace Filee.Cli.Tests;
+
+public sealed class CliTests : IDisposable
+{
+ private readonly string _folder = Directory.CreateTempSubdirectory("filee-cli-").FullName;
+ private readonly string _settings = Directory.CreateTempSubdirectory("filee-cli-settings-").FullName;
+
+ public void Dispose()
+ {
+ foreach (var dir in new[] { _folder, _settings })
+ try { Directory.Delete(dir, true); } catch (IOException) { }
+ }
+
+ private string Png(string name)
+ {
+ var path = Path.Combine(_folder, name);
+ using var image = new ImageMagick.MagickImage(ImageMagick.MagickColors.Orange, 40, 30);
+ image.Write(path, ImageMagick.MagickFormat.Png);
+ return path;
+ }
+
+ private async Task<(int Code, string Output, string Errors)> Run(params string[] args)
+ {
+ var output = new StringWriter();
+ var errors = new StringWriter();
+ var code = await Cli.RunAsync(args, output, errors, TestContext.Current.CancellationToken, () => CliHost.Create(_settings));
+ return (code, output.ToString(), errors.ToString());
+ }
+
+ [Fact]
+ public async Task Converts_files_and_wildcards_into_an_output_folder()
+ {
+ Png("a.png");
+ Png("b.png");
+ var outFolder = Path.Combine(_folder, "out");
+
+ var (code, output, errors) = await Run("convert", Path.Combine(_folder, "*.png"), "--to", "jpg", "-o", outFolder, "--quality", "70");
+
+ Assert.Equal(Cli.Success, code);
+ Assert.Equal("", errors);
+ Assert.Contains("Converted 2 of 2", output);
+ Assert.True(File.Exists(Path.Combine(outFolder, "a.jpg")));
+ Assert.True(File.Exists(Path.Combine(outFolder, "b.jpg")));
+ }
+
+ [Fact]
+ public async Task Json_output_lists_every_file()
+ {
+ var source = Png("a.png");
+
+ var (code, output, _) = await Run("convert", source, "--to", ".webp", "--json");
+
+ Assert.Equal(Cli.Success, code);
+ using var json = JsonDocument.Parse(output);
+ Assert.Equal(1, json.RootElement.GetProperty("converted").GetInt32());
+ var file = json.RootElement.GetProperty("files")[0];
+ Assert.Equal("done", file.GetProperty("state").GetString());
+ Assert.EndsWith("a.webp", file.GetProperty("outputs")[0].GetString());
+ }
+
+ [Fact]
+ public async Task A_folder_converts_the_files_filee_knows()
+ {
+ Png("a.png");
+ File.WriteAllText(Path.Combine(_folder, "notes.unknownformat"), "x");
+
+ var (code, output, _) = await Run("convert", _folder, "--preset", BuiltInData.CreatePresets().First(p => p.TargetFormat == "jpg").Id);
+
+ Assert.Equal(Cli.Success, code);
+ Assert.Contains("Converted 1 of 1", output);
+ }
+
+ [Fact]
+ public async Task A_file_that_cannot_be_converted_fails_with_exit_code_1()
+ {
+ var text = Path.Combine(_folder, "notes.txt");
+ File.WriteAllText(text, "hello");
+
+ var (code, output, _) = await Run("convert", text, "--to", "mp3");
+
+ Assert.Equal(Cli.SomeFailed, code);
+ Assert.Contains("fail", output);
+ }
+
+ [Theory]
+ [InlineData(new[] { "convert", "a.png" }, "--to")]
+ [InlineData(new[] { "convert", "a.png", "--to", "jpg", "--preset", "x" }, "--to")]
+ [InlineData(new[] { "convert", "a.png", "--to", "nosuchformat" }, "unknown format")]
+ [InlineData(new[] { "convert", "a.png", "--preset", "nosuchpreset" }, "no preset")]
+ [InlineData(new[] { "convert", "--to", "jpg" }, "at least one file")]
+ [InlineData(new[] { "convert", "a.png", "--to", "jpg", "--quality", "300" }, "--quality")]
+ [InlineData(new[] { "frobnicate" }, "Unknown command")]
+ public async Task Wrong_arguments_explain_themselves_with_exit_code_2(string[] args, string message)
+ {
+ Png("a.png");
+ var withPaths = args.Select(a => a == "a.png" ? Path.Combine(_folder, a) : a).ToArray();
+
+ var (code, _, errors) = await Run(withPaths);
+
+ Assert.Equal(Cli.UsageError, code);
+ Assert.Contains(message, errors, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task A_missing_input_is_reported()
+ {
+ var (code, _, errors) = await Run("convert", Path.Combine(_folder, "missing.png"), "--to", "jpg");
+
+ Assert.Equal(Cli.UsageError, code);
+ Assert.Contains("not found", errors);
+ }
+
+ [Fact]
+ public async Task Watch_needs_an_existing_folder()
+ {
+ var (code, _, errors) = await Run("watch", Path.Combine(_folder, "nope"), "--to", "jpg");
+
+ Assert.Equal(Cli.UsageError, code);
+ Assert.Contains("not found", errors, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Watch_converts_files_that_arrive_until_cancelled()
+ {
+ using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var output = new StringWriter();
+ var watching = Cli.RunAsync(["watch", _folder, "--to", "jpg", "--settle", "0.2"], TextWriter.Synchronized(output), TextWriter.Null,
+ stop.Token, () => CliHost.Create(_settings));
+
+ await Task.Delay(500, TestContext.Current.CancellationToken);
+ Png("arrived.png");
+ var converted = Path.Combine(_folder, "converted", "arrived.jpg");
+ for (var i = 0; i < 100 && !File.Exists(converted); i++)
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ stop.Cancel();
+
+ Assert.Equal(Cli.Success, await watching);
+ Assert.True(File.Exists(converted), output.ToString());
+ }
+
+ [Fact]
+ public async Task Formats_and_presets_are_listed()
+ {
+ var (code, output, _) = await Run("formats", "heic");
+ Assert.Equal(Cli.Success, code);
+ Assert.Contains("jpg", output);
+
+ (code, output, _) = await Run("presets");
+ Assert.Equal(Cli.Success, code);
+ Assert.Contains("to-png", output);
+ }
+
+ [Fact]
+ public async Task Help_and_version_need_no_engines()
+ {
+ var (code, output, _) = await Run("help", "convert");
+ Assert.Equal(Cli.Success, code);
+ Assert.Contains("--to", output);
+
+ (code, output, _) = await Run("--version");
+ Assert.Equal(Cli.Success, code);
+ Assert.StartsWith("filee ", output);
+ }
+}
diff --git a/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
new file mode 100644
index 0000000..488b0a4
--- /dev/null
+++ b/tests/Filee.Cli.Tests/Filee.Cli.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+ Exe
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Filee.Core.Tests/FolderWatcherTests.cs b/tests/Filee.Core.Tests/FolderWatcherTests.cs
new file mode 100644
index 0000000..a12af78
--- /dev/null
+++ b/tests/Filee.Core.Tests/FolderWatcherTests.cs
@@ -0,0 +1,160 @@
+using Filee.Core.Conversion;
+using Filee.Core.Presets;
+using Filee.Core.Watching;
+
+namespace Filee.Core.Tests;
+
+public class FolderWatcherTests
+{
+ /// A clock the test moves by hand; timers never fire (the test calls ProcessPendingAsync).
+ private sealed class FakeTime : TimeProvider
+ {
+ public DateTimeOffset Now { get; set; } = new(2026, 10, 3, 12, 0, 0, TimeSpan.Zero);
+ public override DateTimeOffset GetUtcNow() => Now;
+ public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) => new NoTimer();
+ public void Advance(double seconds) => Now = Now.AddSeconds(seconds);
+
+ private sealed class NoTimer : ITimer
+ {
+ public bool Change(TimeSpan dueTime, TimeSpan period) => true;
+ public void Dispose() { }
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+ }
+
+ /// A watcher whose conversions run on a JobQueue with a fake jpg → png converter into the rule's output folder.
+ private static (FolderWatcher Watcher, FakeTime Time, List Jobs, JobQueue Queue) Create(WatchRule rule)
+ {
+ var queue = new JobQueue(new ConverterCatalog([new FakeConverter("magick", new ConversionEdge("jpg", "png"))]));
+ var jobs = new List();
+ var time = new FakeTime();
+ var watcher = new FolderWatcher(rule, async (files, _) =>
+ {
+ var preset = new Preset { TargetFormat = "png", Output = { Location = OutputLocation.CustomFolder, CustomFolder = rule.ResolvedOutputFolder } };
+ var job = new ConversionJob(files, preset, "Test");
+ await queue.RunAsync(job);
+ jobs.Add(job);
+ return job;
+ }, time: time);
+ return (watcher, time, jobs, queue);
+ }
+
+ [Fact]
+ public async Task A_file_is_converted_once_it_has_settled()
+ {
+ using var dir = new TempDir();
+ var rule = new WatchRule { Folder = dir.Path };
+ var (watcher, time, jobs, queue) = Create(rule);
+ await using var _ = queue;
+ await using var __ = watcher;
+
+ var photo = dir.File("photo.jpg");
+ watcher.Notice(photo);
+ time.Advance(1);
+ Assert.Equal(0, await watcher.ProcessPendingAsync()); // still within the settle time
+
+ time.Advance(2);
+ Assert.Equal(1, await watcher.ProcessPendingAsync());
+ Assert.True(File.Exists(Path.Combine(dir.Path, "converted", "photo.png")));
+ Assert.True(File.Exists(photo), "Keep leaves the source where it is");
+
+ // Another event for the unchanged file does not convert it again.
+ watcher.Notice(photo);
+ time.Advance(3);
+ Assert.Equal(0, await watcher.ProcessPendingAsync());
+ Assert.Single(jobs);
+ }
+
+ [Fact]
+ public async Task A_file_that_is_still_growing_waits()
+ {
+ using var dir = new TempDir();
+ var (watcher, time, jobs, queue) = Create(new WatchRule { Folder = dir.Path });
+ await using var _ = queue;
+ await using var __ = watcher;
+
+ var photo = dir.File("photo.jpg", "part");
+ watcher.Notice(photo);
+ time.Advance(3);
+ File.AppendAllText(photo, " and more"); // the download continues
+ Assert.Equal(0, await watcher.ProcessPendingAsync());
+
+ time.Advance(3);
+ Assert.Equal(1, await watcher.ProcessPendingAsync());
+ Assert.Single(jobs);
+ }
+
+ [Fact]
+ public async Task A_file_another_program_holds_open_waits()
+ {
+ using var dir = new TempDir();
+ var (watcher, time, jobs, queue) = Create(new WatchRule { Folder = dir.Path });
+ await using var _ = queue;
+ await using var __ = watcher;
+
+ var photo = dir.File("photo.jpg");
+ watcher.Notice(photo);
+ time.Advance(3);
+ using (new FileStream(photo, FileMode.Open, FileAccess.ReadWrite, FileShare.Read))
+ Assert.Equal(0, await watcher.ProcessPendingAsync());
+
+ Assert.Equal(1, await watcher.ProcessPendingAsync());
+ Assert.Single(jobs);
+ }
+
+ [Theory]
+ [InlineData("photo.jpg.crdownload")]
+ [InlineData("~$report.docx")]
+ [InlineData(".hidden.jpg")]
+ [InlineData("notes.unknownformat")]
+ [InlineData("converted/photo.jpg")]
+ [InlineData("originals/photo.jpg")]
+ [InlineData("sub/photo.jpg")]
+ public void Temporary_unknown_and_own_files_are_ignored(string relative)
+ {
+ var rule = new WatchRule { Folder = Path.Combine(Path.GetTempPath(), "inbox") };
+ Assert.True(FolderWatcher.ShouldIgnore(Path.Combine(rule.Folder, relative), rule));
+ }
+
+ [Fact]
+ public void Subfolders_count_only_when_the_rule_includes_them()
+ {
+ using var dir = new TempDir();
+ var file = Path.Combine(dir.Path, "sub", "photo.jpg");
+ Directory.CreateDirectory(Path.GetDirectoryName(file)!);
+ File.WriteAllText(file, "x");
+
+ Assert.True(FolderWatcher.ShouldIgnore(file, new WatchRule { Folder = dir.Path }));
+ Assert.False(FolderWatcher.ShouldIgnore(file, new WatchRule { Folder = dir.Path, IncludeSubfolders = true }));
+ }
+
+ [Fact]
+ public async Task Move_to_originals_converts_what_is_already_there_and_empties_the_folder()
+ {
+ using var dir = new TempDir();
+ var photo = dir.File("photo.jpg");
+ var rule = new WatchRule { Folder = dir.Path, Originals = AfterConversion.MoveToOriginals };
+ var (watcher, time, _, queue) = Create(rule);
+ await using var __ = queue;
+ await using var ___ = watcher;
+
+ Assert.True(watcher.Start()); // picks up the file that was already waiting
+ time.Advance(3);
+ Assert.Equal(1, await watcher.ProcessPendingAsync());
+
+ Assert.False(File.Exists(photo));
+ Assert.True(File.Exists(Path.Combine(dir.Path, "originals", "photo.jpg")));
+ Assert.True(File.Exists(Path.Combine(dir.Path, "converted", "photo.png")));
+ }
+
+ [Fact]
+ public async Task A_missing_folder_is_reported_instead_of_watched()
+ {
+ var (watcher, _, _, queue) = Create(new WatchRule { Folder = Path.Combine(Path.GetTempPath(), "filee-missing-" + Guid.NewGuid().ToString("N")) });
+ await using var _ = queue;
+ await using var __ = watcher;
+
+ Assert.False(watcher.Start());
+ Assert.NotNull(watcher.Error);
+ }
+}
diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs
index cc5f951..7c22711 100644
--- a/tests/Filee.Core.Tests/UserDataStoreTests.cs
+++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs
@@ -30,6 +30,26 @@ public void Round_trips_settings_presets_and_profiles()
Assert.Equal("mine", reloaded.Profiles[0].PresetIds[0]);
}
+ [Fact]
+ public void History_is_kept_until_turned_off_and_can_be_cleared()
+ {
+ using var dir = new TempDir();
+ var store = new UserDataStore(dir.Path);
+ store.Load();
+
+ store.AddHistory(new Filee.Core.History.HistoryEntry { JobId = "a" });
+ Assert.Single(store.History);
+
+ store.Settings.KeepHistory = false;
+ store.AddHistory(new Filee.Core.History.HistoryEntry { JobId = "b" });
+ Assert.Single(store.History); // not recorded
+
+ store.ClearHistory();
+ var reloaded = new UserDataStore(dir.Path);
+ reloaded.Load();
+ Assert.Empty(reloaded.History);
+ }
+
[Fact]
public void Version_1_settings_are_upgraded_to_the_current_engine_list()
{