diff --git a/docs/ENGINES.md b/docs/ENGINES.md index b95a12d14..f129306d4 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -5,14 +5,14 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | Engine | How it gets there | Handles | |---|---|---| -| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP, the merged picture of a Krita KRA and the icon of a Windows program or library (EXE, DLL) read | +| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP and the merged picture of a Krita KRA read | | Windows Imaging Component | part of Windows (Windows only) | **JPEG XR** (JXR, WDP, HDP; HDR screenshots of Windows) → the images ImageMagick writes, and PDF, ICNS, HEIC and SVG through PNG; HDR tone-mapped to SDR | | Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF, **HP-GL / HP-GL/2 plot files (PLT) → SVG**, PDF and images (pen moves, packed PE polylines, arcs and filled polygons; text labels are not drawn), **Tajima DST embroidery → SVG** (stitches as lines, a palette colour per thread; → PNG, JPG, PDF from there) | | ICNS | built in | images → ICNS (16–1024 px), ICNS → images | | PDFsharp | built in (library) | images → PDF (CMYK pictures stay CMYK), merge, split, page ranges, page organising, lossless rotate and crop-box edits, pages per sheet (N-up), spreadsheet print-studio assembly, **compression** (smaller PDFs, see below), PDFs without their metadata | | PDFium | built in (library) | PDF → images (multi-page TIFF/GIF supported); CMYK pages → CMYK JPG/TIFF through Ghostscript when it is installed | | Unhwp | built in (library) | HWP/HWPX → TXT, Markdown, HTML (pictures in a `_files` folder next to the Markdown, embedded in the HTML) | -| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, CMD, COMMAND, LUA, CSS, PS1, SQL, AHK, INI, RSC, C, H, CPP, CC, CXX, HPP, HH, HXX, CS, DART, HQL, DBML, JAVA → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | +| Text & Markdown (Markdig) | built in (library) | **Markdown → HTML, TXT**; **TXT, LOG, PY, PAC, BAT, CMD, COMMAND, LUA, CSS, PS1, SQL, AHK, INI, RSC, C, H, CPP, CC, CXX, HPP, HH, HXX, CS, DART, HQL, DBML, JAVA, MERMAID, MMD, RS, GO, KT, SWIFT, SCALA, SCSS, VUE, TSX → UTF-8 TXT**, preserving source text without running scripts (and → PDF / DOCX through the HWPX writer, rhwp or LibreOffice) | | Spreadsheets (ExcelDataReader for XLS) | built in (library) | **XLSX, XLS, ODS, CSV, TSV → XLSX, ODS, CSV, TSV** (one CSV / TSV per sheet) | | Office Open XML | built in | **DOCM / DOTX / DOTM ↔ DOCX, XLSM / XLTX ↔ XLSX, PPTM / POTX / POTM / PPSX ↔ PPTX** (macros removed for macro-free types) | | Fonts | built in | **TTF, OTF, WOFF, WOFF2, EOT ↔ each other**; **FON (NE monochrome FNT v2/v3, supported single-byte charsets) → TTF, OTF, WOFF, WOFF2, EOT only**; CFF (PostScript) outlines become TrueType for TTF and EOT | @@ -30,9 +30,9 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD, TXT**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) | | 3D models (SharpGLTF for glTF) | built in (library) | **GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY ↔ each other**; FBX → each of them; → PNG, JPG, … (an isometric preview) | | Pictures to 3D relief | built in | **PNG, JPG, WEBP, … (every picture ImageMagick reads) → GLB, glTF, STL, OBJ, 3MF, PLY**: a relief, brighter areas higher | -| **Geospatial vector data** | built in | **GeoJSON, KML, KMZ, GPX, Shapefile ↔ each other and CSV** (longitude / latitude or WKT); Garmin FIT (activities) and OpenStreetMap XML (`.osm`), both read only, → each of them and CSV; projected Shapefiles (UTM, Web Mercator, Lambert, Albers) are converted to WGS 84; through CSV to XLSX, JSON, … (see below) | +| **Geospatial vector data** | built in | **GeoJSON, KML, KMZ, GPX, Shapefile ↔ each other and CSV** (longitude / latitude or WKT); Garmin FIT (activities, read only) → each of them and CSV; projected Shapefiles (UTM, Web Mercator, Lambert, Albers) are converted to WGS 84; through CSV to XLSX, JSON, … (see below) | | rhwp | bundled with the installer (`engines/rhwp`, with the Microsoft C++ runtime, see below) | HWP/HWPX → PDF, **HWP → HWPX, HWPX → HWP** | -| Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP (including ANSYS Workbench WBPZ), 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, DMG, split (.001), Minecraft and Modrinth packs, … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ, the packs**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | +| Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP (including ANSYS Workbench WBPZ), 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, UDF, DMG, split (.001), Minecraft and Modrinth packs, … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ, the packs**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | | LibreOffice + H2Orestart + Java | **optional download** (~420 MB, 1.3 GB on disk) | older and rare formats only: DOC, XLS, PPT, RTF and OpenDocument → PDF and to each other, output as DOCX/ODT/ODS/ODP; HWP/HWPX → DOCX/ODT; read-only formats (see below) → PDF and their kind's editable formats; FODT, OpenDocument templates (OTT, OTS, OTP) and XLT out; SVG → EMF/WMF/ODG, a PDF page → SVG | | Pandoc | **optional download** (~42 MB, 240 MB on disk) | Markdown ↔ DOCX/ODT/RTF, DOCX/ODT/HTML/RTF → Markdown, HTML ↔ DOCX/ODT, markup languages (reStructuredText, LaTeX, Org, AsciiDoc, Typst, MediaWiki, DocBook, Jupyter, Textile, man, OPML) ↔ Markdown/HTML/DOCX/ODT and each other (and → RTF, EPUB, TXT), Markdown and markup → PPTX slides | | Ghostscript | **optional download** (~20 MB, 31 MB on disk) | EPS/PS → PDF, PDF → EPS/PS, PostScript-based AI → PDF (images through PDF and PDFium); smaller PDFs when a preset asks for it; renders CMYK PDF/AI pages for CMYK JPG/TIFF | @@ -329,13 +329,6 @@ in it also builds the package (artifact `windows-arm64-package`). without one gives a clear error. Krita's backup of the previous save, `name.kra~`, is the same file and is read the same way; its output is named `name.png`. Watch folders skip it (and any name ending in `~`), as it is written next to the drawing on every save. -- **EXE / DLL** (a Windows program or library, #1188) is read as its icon, the one Explorer shows: the first icon group - of its resources, put back together as an `.ico` in memory (`ProgramIcon`, Filee's own reader for 32 and 64-bit PE - files; only the headers and the icon are read, at most 64 MB, and the file is never loaded or run). To ICO it is - that icon as it is, every size the program has, unless the preset changes the picture or asks for other icon - sizes; then, as for every other target, the largest size is converted. A file without an icon of its own, or a - 16-bit or damaged one, gives a clear error. Programs are converted only when dropped or named: watch folders and - folders given to the command line skip them, and Recent conversions never opens one. - **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there). - **Camera RAW** (DNG, CR2, CR3, NEF, ARW, …, and `.raw`) is decoded by LibRaw with the camera's white balance, @@ -1536,7 +1529,7 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep | LZ, TAR.LZ (lzip) | Filee: each member rewritten as a .lzma file for 7-Zip's LZMA decoder, stopped at the size its trailer states, CRC checked | — | | GZ, BZ2, XZ | 7-Zip | 7-Zip; one file only (from another single-file format, or "Compress into one archive" with one file) | | LZMA, Z | 7-Zip | — | -| RAR 4/5, CAB, CPIO, DEB, RPM, DMG, ISO, IMG, LHA/LZH, ARJ | 7-Zip (an RPM's payload is unpacked until the files appear) | — | +| RAR 4/5, CAB, CPIO, DEB, RPM, DMG, ISO, UDF, IMG, LHA/LZH, ARJ | 7-Zip (an RPM's payload is unpacked until the files appear) | — | | Split archives (.001, .7z.001, .zip.001, .tar.001) | 7-Zip (combining .002, .003 … automatically) | — | | ALZ (ALZip) | Filee: store, deflate and ALZip's bzip2 variant | — | | EGG (ALZip) | Filee: store, deflate, bzip2, LZMA (decoded by 7-Zip); several blocks per file | — | @@ -1769,7 +1762,7 @@ format, and FFmpeg reads what the file holds, so a `.dat` with no video in it fa | APNG | one pass with the `apng` encoder (24-bit RGB, mixed prediction) and muxer, looping; the same frame rates, widths and playback speed as GIF | | MP4, M4V, MKV with *Video codec* HEVC · MOV with HEVC | x265 (preset medium, `hvc1` tag in MP4/M4V/MOV) + AAC | | MP4, M4V, MKV, WEBM with *Video codec* AV1 | SVT-AV1 (preset 8), or libaom-av1 (cpu-used 6, row multithreading) with an FFmpeg that lacks SVT-AV1 | - | MP3 · M4A/M4B · AAC · OGG · OPUS · WEBA · MKA · WMA · MP2 · AC3 · E-AC3 · AMR | LAME VBR (V0/V2/V5) or CBR · AAC, or ALAC with *Audio codec* ALAC · AAC (ADTS) · Vorbis · Opus · Opus · Opus · WMA 2 · MP2 · AC-3 · E-AC-3 · AMR-NB 12.2k (8 kHz mono) | + | MP3 · M4A/M4B/M4R (iPhone ringtone, always AAC) · AAC · OGG · OPUS · WEBA · MKA · WMA · MP2 · AC3 · E-AC3 · AMR | LAME VBR (V0/V2/V5) or CBR · AAC, or ALAC with *Audio codec* ALAC · AAC (ADTS) · Vorbis · Opus · Opus · Opus · WMA 2 · MP2 · AC-3 · E-AC-3 · AMR-NB 12.2k (8 kHz mono) | | WAV · W64 · AIFF · AU · CAF · VOC · FLAC · WV · TTA | 16-bit PCM (WAV and W64: 8/16/24/32-bit or 32-bit float; WAV switches to RF64 over 4 GB, Sony Wave64 has no limit) · FLAC · WavPack (lossless) · TTA | | SRT · ASS · VTT (from video) | the video's first text subtitle track, copied when it already is that format, else converted by FFmpeg | diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index dad011a79..69617fad2 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -269,7 +269,8 @@ .. WhisperLanguages.All new Choice("tr", "Türkçe"), new Choice("ne", "नेपाली"), new Choice("th", "ไทย"), - new Choice("id", "Bahasa Indonesia") + new Choice("id", "Bahasa Indonesia"), + new Choice("uk", "Українська") ]; _targetLanguage = TranslationLanguageChoices.FirstOrDefault(l => string.Equals(l.Value, _edit.Translate.TargetLanguage, StringComparison.OrdinalIgnoreCase)) ?? TranslationLanguageChoices[0]; diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index dae0079d1..84852bcb5 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -69,8 +69,8 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow "p7m", "xlsm", "xltx", "xls", "ods", "ots", "tsv", "et", "numbers", "pptm", "potx", "potm", "ppsx", "ppt", "odp", "otp", "key", "dps", "md", "html", "mhtml", "opml", "json", "xml", "vcf", "ics", "epub", "kepub", "azw3", "fb2", "cbz", "cbr", "cb7", "cbt", "cbc", "m4v", "mkv", "webm", "avi", "wmv", "mpg", "ts", - "m2ts", "ogv", "m4b", "aac", "flac", "ogg", "opus", "wma", "aiff", "weba", "mka", "qta", "7z", "rar", - "dmg", "iso", "img", "alz", "egg", "split", "dxf", "srt", "vtt", "smi", "lrc" + "m2ts", "ogv", "m4b", "m4r", "aac", "flac", "ogg", "opus", "wma", "aiff", "weba", "mka", "qta", "7z", "rar", + "dmg", "iso", "img", "udf", "alz", "egg", "split", "dxf", "srt", "vtt", "smi", "lrc" ]), (FormatGrade.Specialist, [ diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 9cfb125fc..c40592595 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -155,12 +155,15 @@ public static class FormatRegistry new("sda", "SDA", FormatCategory.Presentation, ["sda", "sdd"], Writable: false), // Plain text / markup - // Text logs, source code, database schemas (DBML), proxy scripts, style sheets and settings files are read as plain - // text, never executed, compiled or translated (issues #273, #284, #254, #388, #368, #607, #536, #630, #661, #660, - // #864, #828, #980, #997, #854, #1176). + // Text logs, source code, database schemas (DBML), diagrams written as text (Mermaid), proxy scripts, style sheets + // and settings files are read as plain text, never executed, compiled or translated (issues #273, #284, #254, #388, + // #368, #607, #536, #630, #661, #660, #864, #828, #980, #997, #854, #1176, #1309). Rust, Go, Kotlin, Swift, Scala, + // SCSS, Vue and TSX sources join the other code files; ".js", ".php" and shell scripts stay out (programs and web + // pages, see WebFileNames). new("txt", "TXT", FormatCategory.Text, ["txt", "log", "py", "pac", "bat", "cmd", "command", "lua", "css", "ps1", "sql", "ahk", "ini", "rsc", - "c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx", "cs", "dart", "hql", "dbml", "java"]), + "c", "h", "cpp", "cc", "cxx", "hpp", "hh", "hxx", "cs", "dart", "hql", "dbml", "java", + "mermaid", "mmd", "rs", "go", "kt", "swift", "scala", "scss", "vue", "tsx"]), new("md", "Markdown", FormatCategory.Text, ["md", "markdown"]), new("html", "HTML", FormatCategory.Text, ["html", "htm", "xhtml"]), // A web page saved with its pictures in one MIME file (Chrome, Edge, Word); read as the page it holds. @@ -266,6 +269,8 @@ public static class FormatRegistry new("mp3", "MP3", FormatCategory.Audio, ["mp3"]), new("m4a", "M4A", FormatCategory.Audio, ["m4a"]), new("m4b", "M4B", FormatCategory.Audio, ["m4b"]), + // iPhone ringtones (#1291): AAC in the same MP4 container as M4A, under the extension iTunes and iOS look for. + new("m4r", "M4R", FormatCategory.Audio, ["m4r"]), new("aac", "AAC", FormatCategory.Audio, ["aac"]), new("wav", "WAV", FormatCategory.Audio, ["wav", "wave"]), new("flac", "FLAC", FormatCategory.Audio, ["flac"]), @@ -330,6 +335,8 @@ public static class FormatRegistry new("dmg", "DMG", FormatCategory.Archive, ["dmg"], Writable: false), new("iso", "ISO", FormatCategory.Archive, ["iso"]), new("img", "IMG", FormatCategory.Archive, ["img"], Writable: false), + // UDF disc images (#1338): DVD, Blu-ray and data discs, unpacked by 7-Zip like ISO files. Never a target. + new("udf", "UDF", FormatCategory.Archive, ["udf"], Writable: false), new("lha", "LHA", FormatCategory.Archive, ["lha", "lzh"], Writable: false), new("arj", "ARJ", FormatCategory.Archive, ["arj"], Writable: false), new("alz", "ALZ", FormatCategory.Archive, ["alz"], Writable: false), diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d8d7825a8..c68b2af1b 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1081,12 +1081,13 @@ public static bool ApplyToLibrary(int from, List presets, List? s public static readonly IReadOnlyList SevenZipFormats = [ "zip", "jar", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", - "cab", "cpio", "deb", "rpm", "dmg", "iso", "img", "lha", "arj", "split", + "cab", "cpio", "deb", "rpm", "dmg", "iso", "img", "udf", "lha", "arj", "split", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", ]; diff --git a/src/Filee.Engines/Media/MediaEncoding.cs b/src/Filee.Engines/Media/MediaEncoding.cs index 6beb1cc13..bc7529063 100644 --- a/src/Filee.Engines/Media/MediaEncoding.cs +++ b/src/Filee.Engines/Media/MediaEncoding.cs @@ -60,6 +60,8 @@ internal static partial class MediaEncoding // The iPod muxer writes the M4A/M4B brands iTunes and phones expect. ["m4a"] = new("ipod", null, "aac"), ["m4b"] = new("ipod", null, "aac"), + // iPhone ringtones (#1291): AAC in the iPod muxer; the extension is what iTunes and iOS look for. + ["m4r"] = new("ipod", null, "aac"), ["aac"] = new("adts", null, "aac"), ["wav"] = new("wav", null, "pcm_s16le"), ["flac"] = new("flac", null, "flac"), @@ -730,7 +732,7 @@ internal static string BroadcastRate(double fps) internal static IEnumerable ContainerArguments(string target) => target switch { // Moves the index to the front so playback (and web streaming) can start before the file is loaded. - "mp4" or "m4v" or "mov" or "3gp" or "3g2" or "m4a" or "m4b" => ["-movflags", "+faststart"], + "mp4" or "m4v" or "mov" or "3gp" or "3g2" or "m4a" or "m4b" or "m4r" => ["-movflags", "+faststart"], // Blu-ray / AVCHD transport stream: 192-byte packets with timestamps. "m2ts" => ["-mpegts_m2ts_mode", "1"], // DVD program stream: 10.08 Mbit/s mux rate and 2 KB sectors. @@ -791,6 +793,7 @@ private static List Audio(string input, string output, string target, Me { ["m4a"] = ["aac", "alac"], ["m4b"] = ["aac", "alac"], + ["m4r"] = ["aac"], ["aac"] = ["aac"], ["mp3"] = ["mp3"], ["flac"] = ["flac"], diff --git a/src/Filee.Engines/Translation/TranslationService.cs b/src/Filee.Engines/Translation/TranslationService.cs index ef407317f..0d448533f 100644 --- a/src/Filee.Engines/Translation/TranslationService.cs +++ b/src/Filee.Engines/Translation/TranslationService.cs @@ -319,7 +319,10 @@ private static string StripThinkTags(string text) => ["polski"] = "pl", ["tr"] = "tr", ["turkish"] = "tr", - ["türkçe"] = "tr" + ["türkçe"] = "tr", + ["uk"] = "uk", + ["ukrainian"] = "uk", + ["українська"] = "uk" }; private static readonly Dictionary CodeToNameMap = new(StringComparer.OrdinalIgnoreCase) @@ -341,6 +344,7 @@ private static string StripThinkTags(string text) => ["pl"] = "Polish", ["tr"] = "Turkish", ["th"] = "Thai", - ["id"] = "Indonesian" + ["id"] = "Indonesian", + ["uk"] = "Ukrainian" }; } diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index 39c64a174..3089b2e56 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -1,268 +1,394 @@ -// Fields and grades of every format (#844): the table is complete, matches the classification published in #844, and -// IsShown hides a format only when every field it belongs to can be turned off and is off. -// -// The id lists below are pinned on purpose (#844): adding a format or changing a grade or field must update them, so the -// change shows up in review. They are written out here, not computed from FormatFields. +// Root settings object persisted to settings.json. -using Filee.Core.Formats; +using System.Text.Json; +using System.Text.Json.Serialization; +using Filee.Core.Presets; -namespace Filee.Core.Tests; +namespace Filee.Core.Settings; -public class FormatFieldsTests +public enum ThemeMode { - private static readonly FileFormat[] Formats = [.. FormatRegistry.Known.Where(f => f.Id != FormatRegistry.Folder)]; + System, + Light, + Dark, +} - private static readonly string[] TurnOffFields = - ["cad", "model3d", "geo", "font", "game", "design", "science", "developer", "writing", "video-pro", "audio-pro", "legacy"]; +public enum ThemeStyle +{ + Soft, + MaterialYou, +} - private static HashSet Off(params string[] fields) => new(fields); +/// What closing the main window does (Settings → General). +public enum CloseAction +{ + /// Ask, with "Don't ask again" (which saves the answer). + Ask, - private static string[] Hidden(IReadOnlySet turnedOff) => - [.. Formats.Where(f => !FormatFields.IsShown(f.Id, turnedOff)).Select(f => f.Id).Order(StringComparer.Ordinal)]; + /// Hide the window; Filee keeps running in the tray. + Tray, - private static string[] Sorted(string[] ids) => [.. ids.Order(StringComparer.Ordinal)]; + /// Quit Filee. + Quit, +} - // Every specialist and legacy format. - private static readonly string[] HiddenWithEverythingOff = - [ - "3g2", "3gp", "3mf", "602", "ac3", "adoc", "ai", "amr", "ape", "arj", "ass", "au", "azw", "azw4", "bik", - "bz2", "cab", "caf", "cavs", "cdr", "cgm", "chm", "cpio", "cur", "cwk", "dav", "dbk", "dcm", "dds", "deb", - "dff", "dot", "dsf", "dss", "dst", "dts", "dv", "dwg", "eac3", "eot", "eps", "exr", "fbx", "fit", "fits", - "flc", "flv", "fodg", "fodp", "fods", "fodt", "fon", "geojson", "glb", "gltf", "gpx", "gz", "h264", "h265", "har", - "hdr", "htmlz", "icns", "ipynb", "jar", "jp2", "jsonl", "kml", "kmz", "kra", "lha", "lit", "lrf", "lwp", "lz", - "lzma", "m4s", "man", "mcaddon", "mcpack", "mctemplate", "mcw", "mcworld", "mediawiki", "miff", "mng", "mobi", - "mod", "mp2", "mpc", "mrpack", "mxf", "obj", "oeb", - "org", "osm", "otf", "pam", "pcd", "pcx", "pdb", "pfm", "plist", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", - "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", - "ssa", "stl", "sub", "swf", "tak", "tar", "tar7z", "tbz2", "tcr", "tex", "textile", "tgz", "tlz", "toml", - "tta", "ttf", "ttml", "txtz", "txz", "typ", "tz", "usm", "vob", "voc", "w64", "wbmp", "wmf", "woff", "woff2", - "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xpm", "xz", "yaml", "z" - ]; +/// Look and feel. +public sealed class ThemeSettings +{ + public ThemeStyle Style { get; set; } = ThemeStyle.Soft; + + public ThemeMode Mode { get; set; } = ThemeMode.System; + + /// Accent colour as #RRGGBB. + public string Accent { get; set; } = "#7F77DD"; + + /// Active curated preset key (e.g. "mint", "peach") when a coordinated light/dark pair is used; null for custom choices. + public string? PresetKey { get; set; } + + /// Base corner radius in pixels. Larger = "softer" UI. + public double CornerRadius { get; set; } = 16; + + /// + /// Disables animations (for low-end PCs or motion sensitivity). + /// null follows the operating system setting. + /// + public bool? ReduceMotion { get; set; } + + /// + /// Draws the UI in the system's own font (Segoe UI on Windows, San Francisco on macOS, fontconfig's default on + /// Linux) instead of the embedded Noto fonts, which still draw what it lacks. Off as before it existed; a missing + /// value reads as off, so it needs no schema step. + /// + public bool UseSystemFont { get; set; } + + public ThemeSettings Clone() => (ThemeSettings)MemberwiseClone(); +} + +/// Geometry and appearance of the donut toolbar. +public sealed class DonutSettings +{ + /// Outer radius in logical pixels. + public double OuterRadius { get; set; } = 150; + + /// Inner (hole) radius as a fraction of the outer radius (0.2 - 0.7). + public double HoleRatio { get; set; } = 0.42; + + /// Background opacity of the slices ( - 1); labels and outlines stay opaque. + public double Opacity { get; set; } = 0.96; + + /// The lowest the Theme and Donut toolbar pages offer. + public const double MinOpacity = 0.1; + + /// + /// The small line under a slice's name saying what it makes ("SVG" under Trace, the file's own format under a + /// preset that keeps it, "Group of N" under a group). Off, the name stands alone in the middle of its slice; a + /// group is still marked by its chevron. A folder slice keeps its "Move" or "Copy", which says what it does, and + /// two slices of one name keep theirs, the only thing that tells them apart. + /// On as before it existed; a missing value (settings from before it) reads as on, so it needs no schema step. + /// + public bool ShowCaptions { get; set; } = true; + + /// The flip key unless the user picks another one (Settings → Shortcuts). + public const string DefaultFlipKey = "Tab"; - public static TheoryData HiddenWithOneFieldOff => new() + /// + /// The key that flips the open donut to its other face, as a SharpHook key name without "Vc" ("Tab", "F2", "Q"); + /// empty for none. See . + /// + public string FlipKey { get; set; } = DefaultFlipKey; + + /// A turn of the mouse wheel flips the open donut to its other face. + public bool FlipWithWheel { get; set; } = true; + + /// + /// Keys that never flip the donut: Esc closes it, Enter and Space convert or open a group, the arrows move between + /// slices and Backspace folds a group's ring. + /// + public static readonly IReadOnlySet ReservedFlipKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { - { "cad", ["dwg"] }, - { "model3d", ["fbx", "glb", "gltf", "ply"] }, - { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "osm", "shp"] }, - { "font", ["otf", "ttf", "woff", "woff2"] }, - { "game", ["bik", "dds", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "usm"] }, - { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "kra", "plt", "ps", "psb", "psd", "raw", "xcf"] }, - { "science", ["dcm", "fits"] }, - { "developer", [ - "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", - "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", - "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" - ] }, - { "writing", ["adoc", "cgm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, - { "video-pro", [ - "ac3", "ass", "cavs", "dav", "dts", "dv", "eac3", "h264", "h265", "mp2", "mxf", "scc", "ssa", "ttml" - ] }, - { "audio-pro", ["ape", "dff", "dsf", "dss", "tak", "tta", "w64", "wv"] }, - { "legacy", [ - "3g2", "3gp", "602", "amr", "arj", "au", "azw", "azw4", "cwk", "dot", "flv", "lha", "lit", "lrf", - "lwp", "mcw", "mng", "mobi", "mod", "oeb", "pcx", "pdb", "pml", "prc", "psw", "ras", "rb", "rm", - "sda", "sdc", "sdw", "sgi", "snb", "spx", "sub", "swf", "tcr", "tz", "vob", "voc", "wbmp", "wmf", - "wpd", "wtv", "xlt", "z" - ] }, + "Escape", "Enter", "Space", "Left", "Right", "Up", "Down", "Backspace", }; - public static TheoryData FieldMembers => new() + /// + /// Whether may flip the donut: not a key the donut uses (), nor + /// the key of a keyboard shortcut in . While a drag holds its modifier that shortcut would + /// fire too, and open the donut for the selected files instead. + /// + public static bool IsFlipKeyAllowed(string key, IEnumerable gestures) => + !string.IsNullOrWhiteSpace(key) && !ReservedFlipKeys.Contains(key) && + !gestures.Any(g => g.Kind == TriggerKind.KeyChord && string.Equals(g.Key, key, StringComparison.OrdinalIgnoreCase)); + + /// + /// The key that flips the donut now: none when the user cleared it; Tab in place of one that isn't allowed (a file + /// edited by hand, or a keyboard shortcut that took the key since), or none when Tab isn't either. + /// + public string? EffectiveFlipKey(IEnumerable gestures) { - { "cad", ["3mf", "dwg", "dxf", "obj", "stl"] }, - { "model3d", ["3mf", "fbx", "glb", "gltf", "obj", "ply", "stl"] }, - { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "osm", "shp"] }, - { "font", ["eot", "fon", "otf", "ttf", "woff", "woff2"] }, - { "game", ["bik", "dds", "flc", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "tga", "usm"] }, - { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "kra", "pcd", "plt", "ps", "psb", "psd", "raw", "xcf"] }, - { "science", ["dcm", "fits", "ipynb", "jp2"] }, - { "developer", [ - "bz2", "cab", "caf", "chm", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", - "icns", "ipynb", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", - "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" - ] }, - { "writing", ["adoc", "cgm", "chm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, - { "video-pro", [ - "ac3", "ass", "cavs", "dav", "dts", "dv", "eac3", "h264", "h265", "mp2", "mxf", "scc", "ssa", "ttml" - ] }, - { "audio-pro", ["ape", "caf", "dff", "dsf", "dss", "mpc", "tak", "tta", "w64", "wv"] }, - { "legacy", [ - "3g2", "3gp", "602", "amr", "arj", "au", "azw", "azw4", "cwk", "dot", "eot", "flc", "flv", "fon", "lha", - "lit", "lrf", "lwp", "mcw", "mng", "mobi", "mod", "mpc", "oeb", "pcd", "pcx", "pdb", "pml", "prc", - "psw", "ras", "rb", "rm", "sda", "sdc", "sdw", "sgi", "snb", "spx", "sub", "swf", "tcr", "tz", "vob", - "voc", "wbmp", "wmf", "wpd", "wtv", "xlt", "z" - ] }, - }; + if (string.IsNullOrWhiteSpace(FlipKey)) + return null; + var list = gestures as IReadOnlyCollection ?? gestures.ToList(); + if (IsFlipKeyAllowed(FlipKey.Trim(), list)) + return FlipKey.Trim(); + return IsFlipKeyAllowed(DefaultFlipKey, list) ? DefaultFlipKey : null; + } + + public DonutSettings Clone() => (DonutSettings)MemberwiseClone(); +} + +/// All user settings except presets and profiles (which have their own files). +public sealed class AppSettings +{ + /// Current schema version. Increase when the format changes and add a migration. + public const int CurrentSchemaVersion = 78; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + + /// "auto", "en", "ko" or "zh-CN". + public string Language { get; set; } = "auto"; + + public bool StartWithSystem { get; set; } = true; + + /// Show the Explorer "Convert with Filee" context menu entry. + public bool ContextMenuEnabled { get; set; } = true; + + public bool CheckForUpdates { get; set; } = true; + + /// What closing the main window does. A value from a newer Filee reads as . + [JsonConverter(typeof(LenientEnumJsonConverter))] + public CloseAction CloseAction { get; set; } = CloseAction.Ask; + + /// Record finished conversions in the history (home page). Off: nothing new is recorded. + public bool KeepHistory { get; set; } = true; + + /// + /// How many finished conversions the history keeps, newest first ( to + /// ; a value outside is taken as the nearest end). The home page lists the latest + /// ones and searches all of them. + /// + public int HistoryLimit { get; set; } = 200; + + public const int MinHistoryLimit = 50; + public const int MaxHistoryLimit = 2000; + + /// within its range. + [JsonIgnore] + public int EffectiveHistoryLimit => Math.Clamp(HistoryLimit, MinHistoryLimit, MaxHistoryLimit); + + /// Whether the macOS Dock shows Filee. + [JsonConverter(typeof(IconPreferenceJsonConverter))] + public bool ShowInDock { get; set; } = true; + + /// Whether the macOS menu bar shows Filee's status icon. + [JsonConverter(typeof(IconPreferenceJsonConverter))] + public bool ShowMenuBarIcon { get; set; } = true; + + /// Newest version the user was already told about, so the update notice shows once per release. + public string? NotifiedUpdateVersion { get; set; } + + /// + /// The version whose "What's new" slides were last shown (or that was installed fresh, which needs none). Null in + /// settings from before the slides existed: Filee was updated from an older version. + /// + public string? WhatsNewSeenVersion { get; set; } - // Every format registered when the admission rule came in (#844): they aren't held to it. - private static readonly HashSet FormatsBeforeAdmissionRule = + /// Engine package id → the new version the engine update notice was shown for (once per version). + public Dictionary NotifiedEngineVersions { get; set; } = []; + + /// Temporarily disable all trigger gestures (tray "Pause"). + public bool Paused { get; set; } + + public ThemeSettings Theme { get; set; } = new(); + + public DonutSettings Donut { get; set; } = new(); + + public List Triggers { get; set; } = TriggerGesture.Defaults(); + + /// Process names (without .exe) in which gestures never trigger. + public List ExcludedProcesses { get; set; } = []; + + /// Engine ids in preferred order (see ). + public List EnginePriority { get; set; } = [ - "png", "jpg", "webp", "tiff", "bmp", "gif", "ico", "avif", "heic", "jxl", "jp2", "psd", "psb", "tga", "ppm", - "icns", "apng", "mng", "qoi", "dds", "pcx", "cur", "xpm", "xbm", "wbmp", "pam", "pfm", "sgi", "ras", "miff", - "dcm", "pcd", "exr", "hdr", "fits", "jxr", "xcf", "livp", "raw", "svg", "svgz", "eps", "ps", "ai", "emf", - "wmf", "cdr", "cgm", "plt", "vsd", "dst", "odg", "fodg", "pdf", "docx", "docm", "dotx", "dotm", "doc", "dot", - "odt", "fodt", "ott", "rtf", "wps", "ofd", "wpd", "lwp", "abw", "pages", "sdw", "cwk", "mcw", "602", "psw", - "pub", "eml", "msg", "hwpx", "hwp", "xlsx", "xlsm", "xltx", "xls", "xlt", "ods", "ots", "fods", "csv", "tsv", - "et", "numbers", "sdc", "pptx", "pptm", "potx", "potm", "ppsx", "ppt", "odp", "otp", "fodp", "key", "dps", - "sda", "txt", "md", "html", "mhtml", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", - "textile", "man", "opml", "json", "jsonl", "yaml", "toml", "xml", "vcf", "har", "ics", "epub", "kepub", - "mobi", "azw3", "azw", "azw4", "prc", "fb2", "cbz", "cbr", "cb7", "cbt", "cbc", "htmlz", "txtz", "chm", - "lit", "lrf", "pdb", "pml", "rb", "snb", "tcr", "oeb", "mp4", "m4v", "mov", "mkv", "webm", "avi", "wmv", - "flv", "mpg", "ts", "m2ts", "3gp", "3g2", "ogv", "vob", "mod", "dv", "mxf", "rm", "swf", "wtv", "cavs", - "usm", "m4s", "dav", "bik", "h265", "h264", "mp3", "m4a", "m4b", "aac", "wav", "flac", "ogg", "opus", "wma", - "aiff", "ac3", "amr", "au", "caf", "weba", "mka", "mp2", "dss", "voc", "wv", "tta", "w64", "eac3", "ape", - "mpc", "tak", "dsf", "dff", "dts", "spx", "qta", "zip", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", - "tlz", "tz", "gz", "bz2", "xz", "lz", "lzma", "z", "jar", "cab", "cpio", "deb", "rpm", "dmg", "iso", "img", - "lha", "arj", "alz", "egg", "split", "dwg", "dxf", "ttf", "otf", "woff", "woff2", "eot", "fon", "srt", "ass", - "ssa", "vtt", "smi", "lrc", "ttml", "sub", "scc", "glb", "gltf", "stl", "obj", "3mf", "ply", "fbx", - "geojson", "kml", "kmz", "gpx", "shp", "fit" + "magick", "pdfium", "pdfsharp", "rhwp", "unhwp", "markdown", "spreadsheet", "ooxml", "hwpx-writer", "docx-writer", + "pdf-text", "pdf-slides", "email", "data", "vector", "icns", "font", "cad", "model3d", "geo", "archive", "ebook", + "subtitle", "wic", "ffmpeg", "pandoc", "ghostscript", "calibre", "libreoffice", ]; - [Fact] - public void Every_format_has_a_grade_and_at_least_one_known_field() - { - var fieldIds = FormatFields.All.Select(f => f.Id).ToHashSet(); + /// Set once the first-run welcome has been shown. + public bool FirstRunCompleted { get; set; } - foreach (var format in Formats) - { - FormatFields.GradeOf(format); - var fields = FormatFields.Of(format); - Assert.NotEmpty(fields); - Assert.All(fields, field => Assert.Contains(field.Id, fieldIds)); - } - } + /// Conversions that succeeded completely, counted until the feedback card has been shown. + public int SuccessfulConversions { get; set; } - [Fact] - public void Field_ids_are_unique() => - Assert.Equal(FormatFields.All.Count, FormatFields.All.Select(f => f.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + /// Set once the "Is Filee helping you?" card (GitHub star / feedback) has been shown. + public bool FeedbackPromptShown { get; set; } - [Fact] - public void Grade_counts_match_the_published_table() - { - var counts = Formats.GroupBy(FormatFields.GradeOf).ToDictionary(g => g.Key, g => g.Count()); - - Assert.Equal( - new Dictionary - { - [FormatGrade.Everyday] = 19, - [FormatGrade.General] = 96, - [FormatGrade.Specialist] = 115, - [FormatGrade.Legacy] = 51, - }, - counts); - } + /// macOS: set once Filee has shown the system's Accessibility alert, which it shows only once (#316). + public bool AccessibilityPromptShown { get; set; } - [Fact] - public void Turning_every_field_off_hides_exactly_the_specialist_and_legacy_formats() => - Assert.Equal(Sorted(HiddenWithEverythingOff), Hidden(Off(TurnOffFields))); + /// Folders whose new files are converted automatically (Settings → Watch folders). + public List WatchFolders { get; set; } = []; - [Theory] - [MemberData(nameof(HiddenWithOneFieldOff))] - public void Turning_one_field_off_hides_only_the_formats_nobody_else_makes(string field, string[] hidden) => - Assert.Equal(Sorted(hidden), Hidden(Off(field))); + /// Where presets set to "Default" save (Settings → General). + public DefaultOutputSettings DefaultOutput { get; set; } = new(); - [Fact] - public void A_format_in_two_fields_hides_only_when_both_are_off() - { - Assert.True(FormatFields.IsShown("stl", Off("cad"))); - Assert.True(FormatFields.IsShown("stl", Off("model3d"))); - Assert.False(FormatFields.IsShown("stl", Off("cad", "model3d"))); - } + /// Where files dragged from a web browser are saved (Settings → General). + public BrowserDropSettings BrowserDrops { get; set; } = new(); + + /// + /// Optional engines the user already has: package id ("ffmpeg", "calibre", …) → the program they picked. Used + /// instead of Filee's download (Settings → Engines). + /// + public Dictionary EngineOwnCopies { get; set; } = []; + + /// + /// With LibreOffice installed, presentations (PPTX, PPT, ODP) → PDF go through it instead of the built-in route + /// (HWPX writer and rhwp): slide backgrounds and shape positions come out as in PowerPoint. + /// + public bool LibreOfficeForSlides { get; set; } = true; + + /// Active local model for offline translation (e.g. "llama3.2", "qwen2.5"). + public string? PreferredOllamaModel { get; set; } + + /// + /// With LibreOffice and H2Orestart installed, HWP / HWPX → PDF go through it instead of rhwp: a way around a + /// document rhwp lays out wrong (each engine gets different documents right). + /// + public bool LibreOfficeForHwp { get; set; } + + /// + /// With LibreOffice installed, Word documents (DOCX, DOC, ODT, RTF) → PDF go through it instead of the built-in + /// route: Chinese punctuation and line-start rules, fonts the built-in route lacks. + /// + public bool LibreOfficeForDocuments { get; set; } + + /// + /// With LibreOffice installed, spreadsheets (XLSX, XLS, ODS, CSV) → PDF go through it: print areas, page setup and + /// page breaks as Excel prints them. + /// + public bool LibreOfficeForSheets { get; set; } + + /// Every conversion keeps the created and modified dates of the original, whatever the preset says. + public bool KeepFileDates { get; set; } + + /// + /// Files dropped or picked on the Home page wait in its list (true), instead of opening the donut for each drop. + /// + public bool CollectDroppedFiles { get; set; } = false; + + /// Every geospatial conversion is saved as a ZIP, whatever the preset says (see ). + public bool ZipGeoResults { get; set; } - [Theory] - [MemberData(nameof(FieldMembers))] - public void Fields_that_can_be_turned_off_hold_the_published_formats(string field, string[] members) => - Assert.Equal( - Sorted(members), - Formats.Where(f => FormatFields.Of(f).Any(x => x.Id == field)).Select(f => f.Id).Order(StringComparer.Ordinal)); - - [Theory] - [InlineData("tga", new[] { "image", "game" })] - [InlineData("dxf", new[] { "cad", "vector" })] - [InlineData("srt", new[] { "subtitle", "video", "audio" })] - [InlineData("png", new[] { "image" })] - public void Formats_get_their_exact_fields(string id, string[] fields) => - Assert.Equal(fields, FormatFields.Of(FormatRegistry.Get(id)).Select(f => f.Id)); - - [Theory] - [InlineData("png")] - [InlineData("jpg")] - [InlineData("webp")] - [InlineData("gif")] - [InlineData("heic")] - [InlineData("pdf")] - [InlineData("docx")] - [InlineData("hwpx")] - [InlineData("hwp")] - [InlineData("xlsx")] - [InlineData("csv")] - [InlineData("pptx")] - [InlineData("txt")] - [InlineData("md")] - [InlineData("mp4")] - [InlineData("mp3")] - [InlineData("m4a")] - [InlineData("srt")] - [InlineData("zip")] - [InlineData("svg")] - [InlineData("json")] - [InlineData("epub")] - public void Everyday_formats_stay_with_every_field_off(string id) => - Assert.True(FormatFields.IsShown(id, Off(TurnOffFields))); - - [Fact] - public void Turning_off_fields_that_cant_be_turned_off_or_dont_exist_hides_nothing() => - Assert.Empty(Hidden(Off("image", "video", "archive", "no-such-field"))); - - [Fact] - public void Ids_ignore_case() + /// + /// How large Filee's windows draw everything (Theme → Window size; Ctrl + mouse wheel, Ctrl + plus / minus, Ctrl + 0): + /// 1 as designed, 0.7 to 1.8. A missing value (settings from before it) reads as 1, so it needs no schema step. + /// + public double UiScale { get; set; } = 1; + + /// + /// The main window's navigation is collapsed to an icon rail (the button at its bottom). A missing value (settings from + /// before it) reads as expanded, so it needs no schema step. + /// + public bool NavCollapsed { get; set; } + + /// + /// Where the main window was when it last closed, so the next start opens it there (#586). Null until it has closed + /// once. A Filee from before it skips the property, so it needs no schema step. + /// + public WindowBounds? MainWindow { get; set; } + + /// + /// The fields turned off on the Fields page (ids of ): a format that belongs only to + /// them is left out of the donut and the preset lists. An empty list and a missing value both mean every field is on, so + /// settings from before it read as every field on and need no schema step. A new install gets its defaults from + /// . + /// + public List TurnedOffFields { get; set; } = []; + + /// as a set that ignores case, for . + public IReadOnlySet TurnedOffFieldSet() => TurnedOffFields.ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Returns itself, or a copy with the app-wide output settings applied: it saves to + /// when the preset's location is , and keeps the + /// original dates when is on and is saved as a ZIP when is on and the + /// target is geospatial. Name pattern and conflict handling stay the preset's. + /// + public Preset WithDefaultOutput(Preset preset) { - Assert.False(FormatFields.IsShown("DWG", Off("cad"))); - Assert.False(FormatFields.IsShown("dwg", Off("CAD"))); - Assert.True(FormatFields.IsShown("DXF", Off("CAD"))); + var location = preset.Output.Location == OutputLocation.Default; + var dates = KeepFileDates && !preset.Output.KeepDates; + var zip = ZipGeoResults && !preset.Output.ZipResult && Formats.FormatRegistry.FindById(preset.TargetFormat)?.Category == Formats.FormatCategory.Geo; + if (!location && !dates && !zip) + return preset; + var copy = preset.Clone(); + if (location) + { + copy.Output.Location = DefaultOutput.Location == OutputLocation.Default ? OutputLocation.SameFolder : DefaultOutput.Location; + copy.Output.SubfolderName = DefaultOutput.SubfolderName; + copy.Output.SubfolderOnlyForSeveralFiles = false; + copy.Output.CustomFolder = DefaultOutput.CustomFolder; + } + if (dates) + copy.Output.KeepDates = true; + if (zip) + copy.Output.ZipResult = true; + return copy; } +} - [Theory] - [InlineData("no-such-format")] - [InlineData("*")] - // Folder is in the archive field, which can't be turned off today; this guards "Extract" if that changes. - [InlineData(FormatRegistry.Folder)] - public void Ids_that_arent_formats_are_always_shown(string id) => - Assert.True(FormatFields.IsShown(id, Off([.. TurnOffFields, "archive"]))); - - [Fact] - public void Specialist_and_legacy_formats_belong_only_to_fields_that_can_be_turned_off() => - Assert.All( - Formats.Where(f => FormatFields.GradeOf(f) is FormatGrade.Specialist or FormatGrade.Legacy), - f => Assert.All(FormatFields.Of(f), field => Assert.True(field.CanTurnOff, $"{f.Id} is in {field.Id}"))); - - [Fact] - public void Legacy_formats_belong_to_the_legacy_field() => - Assert.All( - Formats.Where(f => FormatFields.GradeOf(f) == FormatGrade.Legacy), - f => Assert.Contains(FormatFields.Of(f), field => field.Id == "legacy")); - - [Fact] - public void Everyday_and_general_formats_belong_to_a_field_that_cant_be_turned_off() => - Assert.All( - Formats.Where(f => FormatFields.GradeOf(f) is FormatGrade.Everyday or FormatGrade.General), - f => Assert.Contains(FormatFields.Of(f), field => !field.CanTurnOff)); - - [Fact] - public void New_specialist_and_legacy_outputs_go_only_into_fields_that_are_off_by_default() => - Assert.All( - Formats.Where(f => f.Writable && !FormatsBeforeAdmissionRule.Contains(f.Id) - && FormatFields.GradeOf(f) is FormatGrade.Specialist or FormatGrade.Legacy), - f => Assert.All(FormatFields.Of(f), field => Assert.True(field.CanTurnOff && !field.OnByDefault, $"{f.Id} is in {field.Id}"))); - - [Fact] - public void Only_the_extra_fields_and_four_categories_can_be_turned_off_and_only_developer_starts_on() +/// Reads unexpected icon preference values as enabled while preserving valid booleans. +internal sealed class IconPreferenceJsonConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - Assert.Equal(TurnOffFields.Order(StringComparer.Ordinal), FormatFields.All.Where(f => f.CanTurnOff).Select(f => f.Id).Order(StringComparer.Ordinal)); - Assert.Equal(["developer"], FormatFields.All.Where(f => f.CanTurnOff && f.OnByDefault).Select(f => f.Id)); - Assert.All(FormatFields.All.Where(f => !f.CanTurnOff), f => Assert.True(f.OnByDefault, f.Id)); + if (reader.TokenType is JsonTokenType.True or JsonTokenType.False) + return reader.GetBoolean(); + + reader.Skip(); + return true; } - [Fact] - public void The_folder_target_has_no_grade() => - Assert.Throws(() => FormatFields.GradeOf(FormatRegistry.Get(FormatRegistry.Folder))); + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => + writer.WriteBooleanValue(value); +} + +/// +/// A window's place (): its top-left corner in screen pixels and its size in +/// device-independent pixels, as Avalonia gives them; a part not known is null. Maximized keeps the size it had before. +/// +public sealed class WindowBounds +{ + public int? X { get; set; } + public int? Y { get; set; } + public double? Width { get; set; } + public double? Height { get; set; } + public bool Maximized { get; set; } +} + +/// The save location presets set to "Default" use. Starts as "next to the source file", as before 1.4. +public sealed class DefaultOutputSettings +{ + /// , or . + public OutputLocation Location { get; set; } = OutputLocation.SameFolder; + + public string SubfolderName { get; set; } = "converted"; + + public string CustomFolder { get; set; } = ""; +} + +/// Files dragged out of a web browser (Settings → General). +public sealed class BrowserDropSettings +{ + /// Where they are saved; empty means the user's Downloads folder (looked up each time, as it can move). + public string Folder { get; set; } = ""; + + /// Keep only the converted file: the original goes into a work folder and is deleted after the conversion. + public bool SaveOnlyConverted { get; set; } + + /// + /// Links may lead to this computer and the local network (an intranet, a NAS). Off: a web page could otherwise make + /// Filee send requests to the router or other devices at home or at work. + /// + public bool AllowLocalNetwork { get; set; } } diff --git a/tests/Filee.Core.Tests/ScriptTextTests.cs b/tests/Filee.Core.Tests/ScriptTextTests.cs index 37bb074fa..0ed4615f0 100644 --- a/tests/Filee.Core.Tests/ScriptTextTests.cs +++ b/tests/Filee.Core.Tests/ScriptTextTests.cs @@ -1,6 +1,8 @@ // Lua, PowerShell, AutoHotkey and batch scripts, SQL and HiveQL, DBML database schemas, INI files, RouterOS exports, CSS -// style sheets and C, C++, C#, Dart and Java source files are plain text (issues #388, #368, #442, #607, #536, #630, #661, -// #660, #864, #828, #980, #997, #854, #1176): recognised, and learnt by existing text donuts. +// style sheets, Mermaid diagrams and C, C++, C#, Dart, Java, Rust, Go, Kotlin, Swift, Scala, SCSS, Vue and TSX source +// files are plain text (issues #388, #368, #442, #607, #536, #630, #661, #660, #864, #828, #980, #997, #854, #1176, +// #1309): recognised, and learnt by existing text donuts. UDF disc images (#1338) and iPhone ringtones (#1291) are +// recognised too. using Filee.Core.Formats; using Filee.Core.Presets; @@ -34,6 +36,16 @@ public class ScriptTextTests [InlineData("shop.dbml")] [InlineData("HelloWorld.java")] [InlineData("Legacy.JAVA")] + [InlineData("flow.mermaid")] + [InlineData("flow.MMD")] + [InlineData("main.rs")] + [InlineData("server.go")] + [InlineData("App.kt")] + [InlineData("View.swift")] + [InlineData("Main.scala")] + [InlineData("theme.scss")] + [InlineData("Home.vue")] + [InlineData("Button.tsx")] public void Scripts_and_style_sheets_are_text(string name) => Assert.Equal("txt", FormatRegistry.Detect(name)?.Id); [Fact] @@ -161,4 +173,35 @@ public void Version_70_text_donuts_learn_java_without_taking_custom_claims() Assert.False(SettingsMigrations.ApplyToLibrary(70, presets, profiles)); Assert.DoesNotContain("java", text); // the user's own donut has it } + + [Theory] + [InlineData("movie.udf", "udf", "archive", false)] + [InlineData("Ringtone.M4R", "m4r", "audio", true)] + public void Disc_images_and_ringtones_are_recognised(string name, string id, string category, bool writable) + { + var format = FormatRegistry.Detect(name); + Assert.Equal(id, format?.Id); + Assert.Equal(category, format?.Category.ToString().ToLowerInvariant()); + Assert.Equal(writable, format?.Writable); + } + + [Fact] + public void Version_76_donuts_learn_the_new_formats_without_taking_custom_claims() + { + string[] text = ["mermaid", "mmd", "rs", "go", "kt", "swift", "scala", "scss", "vue", "tsx"]; + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + foreach (var profile in profiles) + profile.Extensions.RemoveAll(e => e is "udf" or "m4r" || text.Contains(e)); + profiles.Insert(0, new ToolbarProfile { Id = "my-rust", Name = "Rust", Extensions = ["rs"] }); + + Assert.True(SettingsMigrations.ApplyToLibrary(76, presets, profiles)); + + var learnt = profiles.Single(p => p.Id == "text").Extensions; + Assert.All(text.Where(e => e != "rs"), e => Assert.Contains(e, learnt)); + Assert.DoesNotContain("rs", learnt); // the user's own donut has it + Assert.Contains("udf", profiles.Single(p => p.Id == "archives").Extensions); + Assert.Contains("m4r", profiles.Single(p => p.Id == "audio").Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(76, presets, profiles)); + } } diff --git a/tests/Filee.Engines.Tests/ArchiveTests.cs b/tests/Filee.Engines.Tests/ArchiveTests.cs index b042433d5..9a739d214 100644 --- a/tests/Filee.Engines.Tests/ArchiveTests.cs +++ b/tests/Filee.Engines.Tests/ArchiveTests.cs @@ -47,6 +47,7 @@ private async Task ConvertOkAsync(string source, Preset preset) [InlineData("tz")] [InlineData("iso")] [InlineData("dmg")] + [InlineData("udf")] [InlineData("cab")] [InlineData("rpm")] [InlineData("deb")] diff --git a/tests/Filee.Engines.Tests/MediaTests.cs b/tests/Filee.Engines.Tests/MediaTests.cs index 5ff65d91b..0ad4b7404 100644 --- a/tests/Filee.Engines.Tests/MediaTests.cs +++ b/tests/Filee.Engines.Tests/MediaTests.cs @@ -1221,6 +1221,15 @@ MediaInfo Source(string codec, string pixelFormat) => Assert.DoesNotContain("-auto-alt-ref", still); } + [Fact] + public void An_iphone_ringtone_is_aac_in_the_ipod_muxer_even_when_alac_is_asked_for() + { + // #1291: iOS plays AAC ringtones; ALAC is only offered for M4A and M4B. + var ringtone = Args("m4r", new MediaOptions { AudioCodec = AudioCodec.Alac }); + Assert.Equal(("aac", "ipod"), (After(ringtone, "-c:a"), After(ringtone, "-f"))); + Assert.EndsWith(".m4r", ringtone[^1]); + } + [Fact] public void Lossless_and_new_audio_targets_get_their_encoders() { diff --git a/tests/Filee.Engines.Tests/ScriptTextTests.cs b/tests/Filee.Engines.Tests/ScriptTextTests.cs index 586c0ebff..998854cc8 100644 --- a/tests/Filee.Engines.Tests/ScriptTextTests.cs +++ b/tests/Filee.Engines.Tests/ScriptTextTests.cs @@ -1,5 +1,5 @@ -// Lua scripts, CSS style sheets, DBML database schemas and Java source files (issues #388, #368, #854, #1176) become a PDF -// of their text, never run, compiled or translated. +// Lua scripts, CSS style sheets, DBML database schemas, Java, Rust and Go source files and Mermaid diagrams (issues #388, +// #368, #854, #1176, #1309) become a PDF of their text, never run, compiled or translated. using Filee.Core.Conversion; using Filee.Core.Presets; @@ -14,6 +14,9 @@ public class ScriptTextTests(EngineFixture fx) : IClassFixture [InlineData("init.lua", "local greeting = \"hello\" print(greeting)")] [InlineData("shop.dbml", "Table customers { id integer [primary key] }")] [InlineData("Hello.java", "class Hello { }")] + [InlineData("flow.mermaid", "graph TD; Start --> Stop")] + [InlineData("main.rs", "fn main() { println!(\"hi\"); }")] + [InlineData("main.go", "package main func main() { }")] public async Task The_code_becomes_a_pdf_of_its_text(string name, string code) { var available = fx.Converters.Where(c => fx.Catalog.StatusOf(c).IsAvailable).Select(c => c.Id).ToHashSet();