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`에 있어요. + ## 스크린샷 @@ -103,26 +151,18 @@
-## 시작하기 - -**설치:** [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`。 + ## 截图 @@ -100,24 +145,18 @@
-## 快速开始 - -**安装:** 在 [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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +