Skip to content

Add "Replace the original" to presets: the original goes to the Recycle Bin / Trash (#332) - #481

Merged
KnifeLemon merged 8 commits into
KnifeLemon:mainfrom
cre8ive-marketing:feature/replace-original
Oct 23, 2026
Merged

KnifeLemon merged 8 commits into
KnifeLemon:mainfrom
cre8ive-marketing:feature/replace-original

Conversation

@cre8ive-marketing

Copy link
Copy Markdown
Collaborator

Closes #332 (the "in place" part), built to the design @KnifeLemon approved in #332.

What it does

A new preset option, Replace the original (Settings → Presets → Output card). It is off by default.

When it is on, and a file's conversion is complete and the result has the same format as the source (for example a PNG trim → PNG):

  1. The original goes to the Recycle Bin / Trash. It is never overwritten, so the user can restore it.
  2. The result is then moved to the original's path and name.

When the original can't be moved to the Trash, both files are kept: the result is saved as a copy, as usual, and the file's notice says why. For example:

  • "The original was kept, the result was saved as a copy (the file is open in another program)"

Cases where it never replaces:

  • Watch folders. WatchRule.Apply always clears the option, otherwise the result would be converted again.
  • Files dragged from a browser. These are jobs with a fetcher.
  • A different format. The result is saved beside the original.
  • ZIP results, and results with more than one output.
  • The command line. The CLI has no platform services, so it always keeps the original and shows the notice.

Trash per OS (no new dependencies)

OS How Refuses (keeps the original)
Windows SHFileOperationW with FOF_ALLOWUNDO UNC paths and non-fixed drives, where the Recycle Bin would delete the file for good. A file open in another program: it is checked with FileShare.None before Windows is asked. Any aborted or failed operation.
macOS NSFileManager trashItemAtURL:resultingItemURL:error: Uses the reason macOS gives (localizedDescription)
Linux freedesktop.org home Trash ($XDG_DATA_HOME/Trash): writes info/<name>.trashinfo first, then moves the file to files/ The file is on another filesystem, or there is no Trash folder. When the move fails, the info record is removed.

IPlatformServices.MoveToTrash(path) returns null when it worked, or a short reason when it didn't. The default implementation returns a reason, so platforms without support keep originals.

Changes

  • OutputRule.ReplaceOriginal: the new option.
  • WatchRule.Apply: forces the option off.
  • FileResult.NoticeDetail: the reason, shown in brackets after the notice, the same way ErrorDetail is shown.
  • JobQueue:
    • A new optional constructor parameter moveToTrash.
    • ReplaceOriginal(...) runs after the final output is written and before the reserved names are released.
    • When the original went to the Trash but the result could not take its name, the notice says "The original is in the Recycle Bin / Trash, the result kept its own name".
  • AppHost: passes IPlatformServices.MoveToTrash to the queue.
  • JobViewModel: shows the notice with its detail, using FailureText.Reason.
  • CLI (text and --json): shows the notice with its detail.
  • Preset editor: the checkbox, plus a warning line, shown while the box is ticked, that says the original goes to the Recycle Bin / Trash.
  • 4 new strings (presets.replace_original, presets.replace_original_hint, notice.original_kept, notice.original_in_trash) in all 14 language files.
  • docs/ARCHITECTURE.md: a short note in the "Run" step.

Tests

  • ReplaceOriginalTests (Core, 8 tests), using a fake Trash:
    • On success, the result takes the original's place and the original is in the Trash.
    • When the Trash refuses, both files are kept, with notice.original_kept and the reason.
    • With no Trash, the original is kept.
    • A different format is saved beside the original.
    • The option is off by default.
    • Browser drops are never replaced.
    • Watch folders clear the option.
    • The option survives Clone.
  • TrashTests (Platform):
    • Windows: a file open elsewhere is refused and left untouched.
    • All OSes: a missing file gives a reason.
    • Linux (CI): the .trashinfo record, percent-encoded paths, and unique names for a second file with the same name.
    • macOS (CI): the real Trash takes the file.
  • App: the editor saves and reloads the option, which is off by default.
  • Checked by hand on Windows 11: a real file went to the Recycle Bin and was listed there.
  • Full suite on Windows: 3,176 passed, 0 failed, 28 skipped (Core 509, App 848, Engines 1,593, Platform 50, CLI 27, Setup 149).

The code that replaces originals is new, so please look closely at the macOS interop. I couldn't run it on a Mac myself; CI covers it.

Off by default. For a finished result of the source's own format, the
original goes to the Recycle Bin / Trash and the result takes its name
and place. When the original can't go there (no Recycle Bin on the
drive, a network share, a file open elsewhere, the command line), both
are kept and the file's notice says why. Watch folders and files from a
browser never replace.

- Windows: SHFileOperation with undo, only on local fixed drives, and a
  file open in another program is refused before Windows is asked.
- macOS: NSFileManager trashItemAtURL.
- Linux: the freedesktop.org home Trash with a .trashinfo record, only
  on the same filesystem.

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @cre8ive-marketing, this follows the design we agreed on in #332 closely, and the most important parts hold up. I merged it with current main and checked that a failed or cancelled conversion leaves the original alone and never touches the Trash, browser drops and watch folders don't replace anything, a different format is saved beside the original, and the option is off by default. dotnet format, the build, Core (509), Platform (50), CLI (27) and the App tests I ran (321) all pass, and all 14 language files have the new keys. The Linux .trashinfo record and the macOS call look right, and macOS CI ran the real-Trash test on both Macs.

There are some things to fix before it goes in, and the first one matters most:

1. Windows can still delete the original for good. With FOF_ALLOWUNDO | FOF_NOCONFIRMATION and no FOF_WANTNUKEWARNING, SHFileOperationW doesn't fail when a file can't be recycled: it deletes it for good without asking. The file is gone afterwards, so File.Exists(full) is false, Move returns null, and the queue moves the result in with no notice. The DriveType.Fixed check doesn't catch this:

  • I tried a subst drive, which reports Fixed. MoveToTrash returned null and the file was gone, but the Recycle Bin's item count (SHQueryRecycleBin) didn't change.
  • The same happens on a fixed drive with its Recycle Bin turned off ("Don't move files to the Recycle Bin"), and with a file larger than the bin's maximum size.

Please make sure Windows only ever recycles. One way is IFileOperation with FOFX_RECYCLEONDELETE, run on an STA thread because it doesn't support MTA. Add an IFileOperationProgressSink whose PreDeleteItem returns an error unless dwFlags has TSF_DELETE_RECYCLE_IF_POSSIBLE. That cancels a delete that would be permanent, and we can return a reason. A Windows test with a subst drive would cover this, and it needs no admin rights.

2. "Keep dates" stops working with it. CopyDates(file.SourcePath, …) runs after the replace, so it reads the result's own dates. I tried a source dated 2020 with Keep dates on, and the replaced file got today's date. Please read the source's dates before it goes to the Trash.

3. Move the result next to the original before the original goes. Right now the original goes to the Trash first, and then File.Move brings the result over. When the preset saves to a folder on another drive, that move is a copy. If it fails (disk full, permissions), the original is already in the Trash. A safer order:

  • Move the result to a temporary name in the original's folder.
  • Then move the original to the Trash.
  • Then rename the result within that folder.

That way the risky step happens while the original is still in place. Also, the result now ends up in the source's folder even when the preset says Save to → a folder. That's what "replace" means, but the hint could say so.

4. Same format, but not the same kind of file. The TXT format covers .txt, .log, .py, .bat, .command, .lua and .css (and .ps1 with #474). So a To TXT preset with this option on sends run.bat to the Trash and puts the UTF-8 run.txt in its place, named run.bat (I checked this with a test). Please also require the same extension (maybe allowing a format's aliases such as .jpeg / .jpg), or leave TXT out.

5. "Only if smaller" together with replace. When the result isn't smaller, the source is copied over the result. The original then still goes to the Trash, and the copy takes its place with the notice "Couldn't make it smaller, so it was copied as it is". Nothing changed, so please skip the replace in that case and leave the original where it is: no Trash entry and no copy.

6. Reasons in the user's language. The reason after the notice is in English in every language ("the file is open in another program"), and the user needs that part most. Please use keys, in all 14 files, for the reasons Filee works out itself:

  • the drive has no Recycle Bin
  • the file is open in another program
  • the file is read-only
  • the Trash is on another drive
  • there is no Trash folder
  • the command line doesn't replace originals

Keep the OS's own text (macOS localizedDescription, exception messages) as the detail.

Linux, smaller
7. MountOf matches by string prefix, so /home also matches /home2/.... Please compare with a trailing /, or use the device id from stat.
8. Suppose another program trashes a file with the same name at the same moment, so FileMode.CreateNew fails because that .trashinfo already exists. The catch then deletes the other program's record. Please delete only a record this call created.
9. Please create Trash, files and info with mode 0700 (Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute)), as the spec expects. Also check free names with Path.Exists, because the Trash holds folders too.

You're right that no settings schema change is needed (it's a new bool that defaults to false). We'll adjust the Korean strings to the app's usual tone ourselves when merging, so please leave those as they are. Thanks for taking on the Trash part, it's what makes this option safe.

Command line
10. filee convert should replace originals too when the preset says so, the same as the app (watch folders and browser drops stay excluded, as agreed in #332). Could the CLI get the platform's MoveToTrash the same way AppHost does?

To get this into the next release, we'll push the remaining changes to your branch ourselves today (maintainer edits are on). Have a look at the commits when they land, and tell us if anything looks off.

…r, CLI)

Review of KnifeLemon#481:
1. Windows: IFileOperation with FOFX_RECYCLEONDELETE on an STA thread and
   a progress sink whose PreDeleteItem refuses any delete Windows can't
   recycle, so a subst or network drive, a bin that is off or too small
   never deletes the original for good. Test with a subst drive.
2. "Keep dates" reads the source's dates before it goes to the Trash.
3. The result moves next to the source under a temporary name first,
   then the source goes to the Trash, then the result is renamed in that
   folder. When the Trash refuses, the result goes back to where it was
   saved. The hint says the result ends up in the original's folder.
4. TXT (scripts, logs) replaces only with the same extension.
5. "Only if smaller" that copied the source leaves the original alone,
   with no copy beside it (notice.original_unchanged).
6. Reasons are translated keys (trash.*) in all 14 languages; the
   system's own words stay as the detail.
7. Linux: a mount holds only its own folders (/home not /home2).
8. Linux: only a .trashinfo this call created is removed on failure.
9. Linux: Trash folders are created 0700; free names checked with
   Path.Exists.
10. filee convert replaces originals too, with the platform's Trash.
@cre8ive-marketing

Copy link
Copy Markdown
Collaborator Author

Thanks @KnifeLemon for the thorough checks, especially the subst and Recycle Bin size cases. All ten points are fixed in 4364c27 and d8a56c0 (after merging main in fda3faa):

  1. Windows only ever recycles. RecycleBin now uses IFileOperation with FOFX_RECYCLEONDELETE on an STA thread, and adds an IFileOperationProgressSink whose PreDeleteItem returns ERROR_CANCELLED unless dwFlags has TSF_DELETE_RECYCLE_IF_POSSIBLE. A delete that would be permanent is cancelled and gives trash.no_recycle_bin, and the file stays where it is. That covers a subst or network drive, a bin that is turned off, and a file that's too big for the bin. The read-only and in-use checks still run first. There's a new Windows test with a subst drive (no admin rights needed): the file stays and the reason is trash.no_recycle_bin. The same test checks that a file on the real drive does go to the Recycle Bin.
  2. Keep dates: the source's dates are read before the replace, and the result gets them. There's a test with a 2020 source.
  3. Order: the result first moves next to the original under a hidden temporary name. That's a copy when it was saved on another drive. Then the original goes to the Trash, and then the result is renamed in that folder. If the Trash refuses, the result goes back to where it was saved. If the last rename fails, the notice says the original is in the Trash. There are tests for a preset that saves to another folder (both success and refusal), and one that checks the original and the staged result are side by side when the Trash is asked. The hint now says the result is saved in the original's folder, whatever "Save to" says.
  4. TXT: it replaces only when the extension is the same. Other formats still treat their spellings as one, so .jpeg and .jpg both count. Test: run.bat with To TXT keeps run.bat and gets run.txt beside it.
  5. Only if smaller: when the source was copied, the replace is skipped. The copy is removed, so there's no Trash entry and no copy, and the notice is the new notice.original_unchanged ("Couldn't make it smaller, so the original was left as it is"). There's a test for it.
  6. Reasons in the user's language: MoveToTrash now returns a TrashRefusal(ReasonKey, Detail). The keys are trash.no_recycle_bin, trash.in_use, trash.read_only, trash.other_drive, trash.no_folder, trash.failed and trash.cannot_place, in all 14 files. The OS's own words (macOS localizedDescription, exception messages) stay as the detail. FileResult has NoticeReasonKey next to NoticeDetail, and the app and CLI show both through one helper (ILocalizer.Notice).
  7. Linux MountOf: a root now matches only with a trailing /, so /home no longer matches /home2/…. There's a unit test with /, /home and /media/usb.
  8. Linux .trashinfo: the catch deletes the record only if this call created it.
  9. Linux folders and names: Trash, files and info are created with mode 0700, and free names are checked with Path.Exists. The Linux test checks the modes, and that a folder in the Trash with the same name is skipped.
  10. CLI: filee convert now replaces originals too. CliHost passes the platform's MoveToTrash, and the CLI references the three small platform projects, which only depend on Core. Watch folders and browser drops are still excluded.

I left the Korean strings as they are, as you said.

dotnet format --verify-no-changes is clean. Full dotnet test: Platform 52, Core 516, CLI 27, Setup 149, Engines 1607 and App 851 pass, none fail.

KnifeLemon and others added 4 commits October 23, 2026 19:31
…he app's tone

One format can cover different kinds of file (PPT also .pps and .pot, AIFF also
.aifc, HTML also .xhtml, TXT also scripts and logs), and the result takes the
original's name. So only the same extension, or another spelling of it (.jpeg /
.jpg, .tif / .tiff), replaces; the TXT-only rule becomes the general one.

When the result can't take the original's name after the original went to the
Trash, it goes back to the name it was saved under instead of keeping the
temporary one.

Korean: the new notices, reasons and hint in 해요체, like the rest of the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ne.json and th.json: keep both the Replace-the-original strings and the OCR
notice from KnifeLemon#429.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KnifeLemon
KnifeLemon merged commit c01930c into KnifeLemon:main Oct 23, 2026
10 checks passed
cre8ive-marketing added a commit to cre8ive-marketing/Filee that referenced this pull request Oct 23, 2026
docs/ARCHITECTURE.md: kept the PDF finishing line and KnifeLemon#481's Replace the
original paragraph, in the order JobQueue runs them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: trim transparent borders of PNG images (single & batch, in-place)

2 participants