Implement FR #3142: Add local recycle bin or trash folder option (#3168)

* This commit implements Feature Request #3142 by introducing a configurable local recycle bin or trash folder mechanism within the OneDrive Client for Linux. This enhancement provides users with a safeguard against unintended data loss during synchronisation operations. This capability is designed to be compatible with the FreeDesktop.org Trash Specification.
This commit is contained in:
abraunegg 2025-04-23 04:28:32 +10:00 committed by GitHub
commit 898f350336
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 402 additions and 72 deletions

View file

@ -383,6 +383,7 @@ thnk
tidx
timerfd
tlsv
trashinfo
Tting
typecons
uda

6
config
View file

@ -115,6 +115,9 @@
## This configuration option controls whether the client operates in read-only mode.
#read_only_auth_scope = "false"
## This configuration option allows you to specify the 'Recycle Bin' path for the application. This is only used if 'use_recycle_bin' is enabled.
#recycle_bin_path = "/path/to/desired/location/"
## This option removes the local file after a successful upload to OneDrive.
#remove_source_files = "false"
@ -169,6 +172,9 @@
## Only upload changes to OneDrive, do not download from cloud.
#upload_only = "false"
## This configuration option controls the application function to move online deleted files to a 'Recycle Bin' on your system.
#use_recycle_bin = "false"
## Custom User-Agent string for requests to OneDrive. If you change this, you will get throttled by the Microsoft Graph API. Change with caution.
#user_agent = "ISV|abraunegg|OneDrive Client for Linux/vX.Y.Z-A-bcdefghi"

View file

@ -42,6 +42,7 @@ Before reading this document, please ensure you are running application version
- [permanent_delete](#permanent_delete)
- [rate_limit](#rate_limit)
- [read_only_auth_scope](#read_only_auth_scope)
- [recycle_bin_path](#recycle_bin_path)
- [remove_source_files](#remove_source_files)
- [resync](#resync)
- [resync_auth](#resync_auth)
@ -60,6 +61,7 @@ Before reading this document, please ensure you are running application version
- [threads](#threads)
- [transfer_order](#transfer_order)
- [upload_only](#upload_only)
- [use_recycle_bin](#use_recycle_bin)
- [user_agent](#user_agent)
- [webhook_enabled](#webhook_enabled)
- [webhook_expiration_interval](#webhook_expiration_interval)
@ -624,6 +626,17 @@ _**Config Example:**_ `read_only_auth_scope = "false"` or `read_only_auth_scope
> [!IMPORTANT]
> When using 'read_only_auth_scope' you also will need to remove your existing application access consent otherwise old authentication consent will be valid and will be used. This will mean the application will technically have the consent to upload data until you revoke this consent.
### recycle_bin_path
_**Description:**_ This configuration option allows you to specify the 'Recycle Bin' path for the application.
_**Value Type:**_ String
_**Default Value:**_ *None* however the application will use `~/.local/share/Trash` as the pre-defined default so that files will be placed in the correct location for your user profile.
_**CLI Option Use:**_ *None - this is a config file option only*
_**Config Example:**_ `recycle_bin_path = "/path/to/desired/location/"`
### remove_source_files
_**Description:**_ This configuration option controls whether the OneDrive Client for Linux removes the local file post successful transfer to Microsoft OneDrive.
@ -942,6 +955,17 @@ _**CLI Option Use:**_ `--upload-only`
> [!IMPORTANT]
> To ensure that data deleted locally remains accessible online, you can use the 'no_remote_delete' option. If you want to delete the data from your local storage after a successful upload to Microsoft OneDrive, you can use the 'remove_source_files' option.
### use_recycle_bin
_**Description:**_ This configuration option controls the application function to move online deleted files to a 'Recycle Bin' on your system. This allows you to review online deleted data manually before this is purged from your actual system.
_**Value Type:**_ Boolean
_**Default Value:**_ False
_**Config Example:**_ `use_recycle_bin = "false"` or `use_recycle_bin = "true"`
_**CLI Option Use:**_ *None - this is a config file option only*
### user_agent
_**Description:**_ This configuration option controls the 'User-Agent' request header that is presented to Microsoft Graph API when accessing the Microsoft OneDrive service. This string lets servers and network peers identify the application, operating system, vendor, and/or version of the application making the request. We recommend users not to tamper with this option unless strictly necessary.

View file

@ -30,6 +30,7 @@ Before reading this document, please ensure you are running application version
- [Enabling the Client Activity Log](#enabling-the-client-activity-log)
- [Client Activity Log Example:](#client-activity-log-example)
- [Client Activity Log Differences](#client-activity-log-differences)
- [Using a local Recycle Bin](#using-a-local-recycle-bin)
- [GUI Notifications](#gui-notifications)
- [Handling a Microsoft OneDrive Account Password Change](#handling-a-microsoft-onedrive-account-password-change)
- [Determining the synchronisation result](#determining-the-synchronisation-result)
@ -739,6 +740,31 @@ Using 'user' configuration path for application state data: /home/user/.config/o
Using the following path to store the runtime application log: /var/log/onedrive
```
### Using a local Recycle Bin
By default, this application will process online deletions and directly delete the corresponding file or folder directly from your configured 'sync_dir'.
In some cases, it may actually be desirable to move these files to your Linux user default 'Recycle Bin', so that you can manually delete the files at your own discretion.
To enable this application functionality, add the following to your 'config' file:
```
use_recycle_bin = "true"
```
This capability is designed to be compatible with the [FreeDesktop.org Trash Specification](https://specifications.freedesktop.org/trash-spec/1.0/), ensuring interoperability with GUI-based desktop environments such as GNOME (GIO) and KDE (KIO). It follows the required structure by:
* Moving deleted files and directories to `~/.local/share/Trash/files/`
* Creating matching metadata files in `~/.local/share/Trash/info/` with the correct `.trashinfo` format, including the original absolute path and ISO 8601-formatted deletion timestamp
* Resolving filename collisions using a `name.N.ext` pattern (e.g., `Document.2.docx`), consistent with GNOME and KDE behaviour.
To specify an explicit 'Recycle Bin' directory, add the following to your 'config' file:
```
recycle_bin_path = "/path/to/desired/location/"
```
The same FreeDesktop.org Trash Specification will be used with this explicit 'Recycle Bin' directory.
### GUI Notifications
To enable GUI notifications, you must compile the application with GUI Notification Support. Refer to [GUI Notification Support](install.md#gui-notification-support) for details. Once compiled, GUI notifications will work by default in the display manager session under the following conditions:
@ -773,6 +799,11 @@ To enable these specific notifications, add the following to your 'config' file:
notify_file_actions = "true"
```
To disable *all* GUI notifications, add the following to your 'config' file:
```
disable_notifications = "true"
```
### Handling a Microsoft OneDrive Account Password Change
If you change your Microsoft OneDrive Account Password, the client will no longer be authorised to sync, and will generate the following error upon next application run:
```text

View file

@ -179,6 +179,7 @@ class ApplicationConfig {
// Store items that come in from the 'config' file, otherwise these need to be set the defaults
private string configFileSyncDir = defaultSyncDir;
private string configFileSkipFile = ""; // Default for now, if post reading in any user configuration, if still empty, default will be used
private bool configFileSkipFileReadIn = false; // If we actually read in something from 'config' file, this gets set to true
private string configFileSkipDir = ""; // Default here is no directories are skipped
private string configFileDriveId = ""; // Default here is that no drive id is specified
private bool configFileSkipDotfiles = false;
@ -199,6 +200,11 @@ class ApplicationConfig {
bool xdg_exists = false;
bool dbus_exists = false;
// Recycle Bin Configuration
// These paths are used by the application, if 'use_recycle_bin' is enabled
string recycleBinFilePath;
string recycleBinInfoPath;
// Initialise the application configuration
bool initialise(string confdirOption, bool helpRequested) {
@ -242,6 +248,12 @@ class ApplicationConfig {
// - name_dsc = file name descending
stringValues["transfer_order"] = "default";
// Recycle Bin Configuration
// Enable|Disable feature
boolValues["use_recycle_bin"] = false;
// Recycle Bin Folder - empty string as a default
stringValues["recycle_bin_path"] = "";
// - Store how many times was --verbose added
longValues["verbose"] = verbosityCount;
// - The amount of time (seconds) between monitor sync loops
@ -395,9 +407,16 @@ class ApplicationConfig {
}
}
// outcome of setting defaultHomePath
// Outcome of setting 'defaultHomePath'
if (debugLogging) {addLogEntry("runtime_environment: Calculated defaultHomePath: " ~ defaultHomePath, ["debug"]);}
// Configure the default path for the Recycle Bin
// Both GNOME and KDE use '~/.local/share/Trash/' as the default path
// ~/.local/share/Trash/
// ├── files/ # The actual trashed files
// └── info/ # .trashinfo metadata about each file (original path, deletion date)
setValueString("recycle_bin_path", defaultHomePath ~ "/.local/share/Trash/");
// DEVELOPER OPTIONS
// display_memory = true | false
// - It may be desirable to display the memory usage of the application to assist with diagnosing memory issues with the application
@ -803,6 +822,8 @@ class ApplicationConfig {
forceExit();
}
} else if (key == "skip_file") {
// Flag this as true
configFileSkipFileReadIn = true;
// Handle multiple 'config' file entries of skip_file
if (configFileSkipFile.empty) {
// currently no entry exists
@ -1154,8 +1175,8 @@ class ApplicationConfig {
"Specify the local directory used for synchronisation to OneDrive",
&stringValues["sync_dir_cli"],
"share-password",
"Require a password to access the shared link when used with --create-share-link <file>",
&stringValues["share_password"],
"Require a password to access the shared link when used with --create-share-link <file>",
&stringValues["share_password"],
"sync|s",
"Perform a synchronisation with Microsoft OneDrive",
&boolValues["synchronize"],
@ -1445,7 +1466,7 @@ class ApplicationConfig {
addLogEntry("Config option 'cleanup_local_files' = " ~ to!string(getValueBool("cleanup_local_files")));
addLogEntry("Config option 'disable_permission_set' = " ~ to!string(getValueBool("disable_permission_set")));
addLogEntry("Config option 'transfer_order' = " ~ getValueString("transfer_order"));
// data integrity
addLogEntry("Config option 'classify_as_big_delete' = " ~ to!string(getValueLong("classify_as_big_delete")));
addLogEntry("Config option 'disable_upload_validation' = " ~ to!string(getValueBool("disable_upload_validation")));
@ -1484,6 +1505,10 @@ class ApplicationConfig {
addLogEntry("Compile time option --enable-notifications = false");
}
// Recycle Bin
addLogEntry("Config option 'use_recycle_bin' = " ~ to!string(getValueBool("use_recycle_bin")));
addLogEntry("Config option 'recycle_bin_path' = " ~ getValueString("recycle_bin_path"));
// Is sync_list configured and contains entries?
if (exists(syncListFilePath) && getSize(syncListFilePath) > 0) {
addLogEntry(); // used instead of an empty 'writeln();' to ensure the line break is correct in the buffered console output ordering
@ -1750,8 +1775,8 @@ class ApplicationConfig {
skip_file_present = true;
// Handle multiple entries of skip_file
if (backupConfigFileSkipFile.empty) {
// currently no entry exists, include 'defaultSkipFile' entries
backupConfigFileSkipFile = defaultSkipFile ~ "|" ~ to!string(c.front.dup);
// currently no entry value exists
backupConfigFileSkipFile = to!string(c.front.dup);
} else {
// add to existing backupConfigFileSkipFile entry
backupConfigFileSkipFile = backupConfigFileSkipFile ~ "|" ~ to!string(c.front.dup);
@ -1763,8 +1788,8 @@ class ApplicationConfig {
skip_dir_present = true;
// Handle multiple entries of skip_dir
if (backupConfigFileSkipDir.empty) {
// currently no entry exists
backupConfigFileSkipDir = c.front.dup;
// currently no entry value exists
backupConfigFileSkipDir = to!string(c.front.dup);
} else {
// add to existing backupConfigFileSkipDir entry
backupConfigFileSkipDir = backupConfigFileSkipDir ~ "|" ~ to!string(c.front.dup);
@ -1792,17 +1817,32 @@ class ApplicationConfig {
}
}
}
// Debug logging
if (debugLogging) {
addLogEntry("skip_file in actual config = " ~ to!string(configFileSkipFileReadIn), ["debug"]);
addLogEntry("skip_file in backup config = " ~ to!string(skip_file_present), ["debug"]);
addLogEntry("defaultSkipFile value = " ~ to!string(defaultSkipFile), ["debug"]);
addLogEntry("configFileSkipFile value = " ~ to!string(configFileSkipFile), ["debug"]);
addLogEntry("backupConfigFileSkipFile value = " ~ to!string(backupConfigFileSkipFile), ["debug"]);
}
// skip_file can be specified multiple times
if (skip_file_present && backupConfigFileSkipFile != configFileSkipFile) logAndSetDifference("skip_file" ~ configOptionModifiedMessage, 4);
// skip_file can also be an empty string, thus when removed, as an empty string, we are going back to application defaults
if (skip_file_present && backupConfigFileSkipFile != defaultSkipFile) logAndSetDifference("skip_file" ~ configOptionModifiedMessage, 4);
// skip_dir can be specified multiple times
if (skip_dir_present && backupConfigFileSkipDir != configFileSkipDir) logAndSetDifference("skip_dir" ~ configOptionModifiedMessage, 5);
// Check for newly added configuration options
// Check for newly added configuration options to the 'config' file vs being present in the 'backup' config file
if (!drive_id_present && configFileDriveId != "") logAndSetDifference("drive_id newly added ... --resync needed", 2);
if (!sync_dir_present && configFileSyncDir != defaultSyncDir) logAndSetDifference("sync_dir newly added ... --resync needed", 3);
if (!skip_file_present && configFileSkipFile != defaultSkipFile) logAndSetDifference("skip_file newly added ... --resync needed", 4);
if (configFileSkipFileReadIn) {
// We actually read a 'skip_file' configuration line from the 'config' file
if (!skip_file_present && configFileSkipFile != defaultSkipFile) logAndSetDifference("skip_file newly added ... --resync needed", 4);
}
if (!skip_dir_present && configFileSkipDir != "") logAndSetDifference("skip_dir newly added ... --resync needed", 5);
if (!skip_dotfiles_present && configFileSkipDotfiles) logAndSetDifference("skip_dotfiles newly added ... --resync needed", 6);
if (!skip_symlinks_present && configFileSkipSymbolicLinks) logAndSetDifference("skip_symlinks newly added ... --resync needed", 7);
@ -2545,6 +2585,21 @@ class ApplicationConfig {
// Return result
return variablesAvailable;
}
// Set the Recycle Bin Paths
void setRecycleBinPaths() {
// Get the configured base path
string basePath = getValueString("recycle_bin_path");
// Ensure basePath ends with a single '/'
if (!basePath.endsWith("/")) {
basePath ~= "/";
}
// Append subdirectories based on the recycle bin path
recycleBinFilePath = basePath ~ "files/";
recycleBinInfoPath = basePath ~ "info/";
}
}
// Output the full application help when --help is passed in

View file

@ -201,6 +201,50 @@ int main(string[] cliArgs) {
syncOrMonitorMissing = true; // --sync or --monitor is missing
}
// Has the user configured to use the 'Recycle Bin' locally, for any files that are deleted online?
if (appConfig.getValueBool("use_recycle_bin")) {
// Configure the internal application paths which will be used to move rather than delete any online deletes to
appConfig.setRecycleBinPaths();
// We need to ensure that the Recycle Bin Paths exist on the file system, and if they do not exist, create them
// Test for appConfig.recycleBinFilePath
if (!exists(appConfig.recycleBinFilePath)) {
try {
// Attempt to create the 'Recycle Bin' file path we have been configured with
mkdirRecurse(appConfig.recycleBinFilePath);
// Configure the applicable permissions for the folder
if (debugLogging) {addLogEntry("Setting directory permissions for: " ~ appConfig.recycleBinFilePath, ["debug"]);}
appConfig.recycleBinFilePath.setAttributes(octal!700); // Set to 0700 as Trash may contain sensitive and is the expected default permissions by GIO or KIO
} catch (std.file.FileException e) {
// Creating the 'Recycle Bin' file path failed
addLogEntry("ERROR: Unable to create the configured local 'Recycle Bin' file directory: " ~ e.msg, ["info", "notify"]);
// Use exit scopes to shutdown API
return EXIT_FAILURE;
}
}
// Test for appConfig.recycleBinInfoPath
if (!exists(appConfig.recycleBinInfoPath)) {
try {
// Attempt to create the 'Recycle Bin' info path we have been configured with
mkdirRecurse(appConfig.recycleBinInfoPath);
// Configure the applicable permissions for the folder
if (debugLogging) {addLogEntry("Setting directory permissions for: " ~ appConfig.recycleBinInfoPath, ["debug"]);}
appConfig.recycleBinInfoPath.setAttributes(octal!700); // Set to 0700 as Trash may contain sensitive and is the expected default permissions by GIO or KIO
} catch (std.file.FileException e) {
// Creating the 'Recycle Bin' info path failed
addLogEntry("ERROR: Unable to create the configured local 'Recycle Bin' info directory: " ~ e.msg, ["info", "notify"]);
// Use exit scopes to shutdown API
return EXIT_FAILURE;
}
}
}
// Are we performing some sort of 'no-sync' operation task?
noSyncTaskOperationRequested = appConfig.hasNoSyncOperationBeenRequested(); // returns true if we are

View file

@ -4295,7 +4295,7 @@ class SyncEngine {
return false;
}
// Process items that need to be removed
// Process items that need to be removed from the local filesystem as they were removed online
void processDeleteItems() {
// Function Start Time
SysTime functionStartTime;
@ -4308,78 +4308,154 @@ class SyncEngine {
displayFunctionProcessingStart(thisFunctionName, logKey);
}
foreach_reverse (i; idsToDelete) {
Item item;
string path;
if (!itemDB.selectById(i[0], i[1], item)) continue; // check if the item is in the db
// Compute this item path
path = computeItemPath(i[0], i[1]);
// Log the action if the path exists .. it may of already been removed and this is a legacy array item
if (exists(path)) {
if (item.type == ItemType.file) {
addLogEntry("Trying to delete local file: " ~ path);
} else {
addLogEntry("Trying to delete local directory: " ~ path);
}
}
// Process the database entry removal. In a --dry-run scenario, this is being done against a DB copy
itemDB.deleteById(item.driveId, item.id);
if (item.remoteDriveId != null) {
// delete the linked remote folder
itemDB.deleteById(item.remoteDriveId, item.remoteId);
}
// Add to pathFakeDeletedArray
// We dont want to try and upload this item again, so we need to track this objects removal
if (dryRun) {
// We need to add './' here so that it can be correctly searched to ensure it is not uploaded
string pathToAdd = "./" ~ path;
pathFakeDeletedArray ~= pathToAdd;
}
// Has the user configured to use the 'Recycle Bin' locally, for any files that are deleted online?
if (!appConfig.getValueBool("use_recycle_bin")) {
if (debugLogging) {addLogEntry("Performing filesystem deletion, using reverse order of items to delete", ["debug"]);}
foreach_reverse (i; idsToDelete) {
Item item;
string path;
if (!itemDB.selectById(i[0], i[1], item)) continue; // check if the item is in the db
// Compute this item path
path = computeItemPath(i[0], i[1]);
bool needsRemoval = false;
if (exists(path)) {
// path exists on the local system
// make sure that the path refers to the correct item
Item pathItem;
if (itemDB.selectByPath(path, item.driveId, pathItem)) {
if (pathItem.id == item.id) {
needsRemoval = true;
// Log the action if the path exists .. it may of already been removed and this is a legacy array item
if (exists(path)) {
if (item.type == ItemType.file) {
addLogEntry("Trying to delete local file: " ~ path);
} else {
addLogEntry("Skipped due to id difference!");
addLogEntry("Trying to delete local directory: " ~ path);
}
} else {
// item has disappeared completely
needsRemoval = true;
}
}
if (needsRemoval) {
// Log the action
if (item.type == ItemType.file) {
addLogEntry("Deleting local file: " ~ path, fileTransferNotifications());
} else {
addLogEntry("Deleting local directory: " ~ path, fileTransferNotifications());
}
// Perform the action
if (!dryRun) {
if (isFile(path)) {
remove(path);
} else {
try {
// Remove any children of this path if they still exist
// Resolve 'Directory not empty' error when deleting local files
foreach (DirEntry child; dirEntries(path, SpanMode.depth, false)) {
attrIsDir(child.linkAttributes) ? rmdir(child.name) : remove(child.name);
}
// Remove the path now that it is empty of children
rmdirRecurse(path);
} catch (FileException e) {
// display the error message
displayFileSystemErrorMessage(e.msg, thisFunctionName);
// Process the database entry removal. In a --dry-run scenario, this is being done against a DB copy
itemDB.deleteById(item.driveId, item.id);
if (item.remoteDriveId != null) {
// delete the linked remote folder
itemDB.deleteById(item.remoteDriveId, item.remoteId);
}
// Add to pathFakeDeletedArray
// We dont want to try and upload this item again, so we need to track this objects removal
if (dryRun) {
// We need to add './' here so that it can be correctly searched to ensure it is not uploaded
string pathToAdd = "./" ~ path;
pathFakeDeletedArray ~= pathToAdd;
}
bool needsRemoval = false;
if (exists(path)) {
// path exists on the local system
// make sure that the path refers to the correct item
Item pathItem;
if (itemDB.selectByPath(path, item.driveId, pathItem)) {
if (pathItem.id == item.id) {
needsRemoval = true;
} else {
addLogEntry("Skipped due to id difference!");
}
} else {
// item has disappeared completely
needsRemoval = true;
}
}
if (needsRemoval) {
// Log the action
if (item.type == ItemType.file) {
addLogEntry("Deleting local file: " ~ path, fileTransferNotifications());
} else {
addLogEntry("Deleting local directory: " ~ path, fileTransferNotifications());
}
// Perform the action
if (!dryRun) {
if (isFile(path)) {
remove(path);
} else {
try {
// Remove any children of this path if they still exist
// Resolve 'Directory not empty' error when deleting local files
foreach (DirEntry child; dirEntries(path, SpanMode.depth, false)) {
attrIsDir(child.linkAttributes) ? rmdir(child.name) : remove(child.name);
}
// Remove the path now that it is empty of children
rmdirRecurse(path);
} catch (FileException e) {
// display the error message
displayFileSystemErrorMessage(e.msg, thisFunctionName);
}
}
}
}
}
} else {
if (debugLogging) {addLogEntry("Moving online deleted files to configured local Recycle Bin", ["debug"]);}
// Process in normal order, so that the parent, if a folder, gets moved 'first' mirroring how files / folders are deleted in GNOME and KDE
foreach (i; idsToDelete) {
Item item;
string path;
if (!itemDB.selectById(i[0], i[1], item)) continue; // check if the item is in the db
// Compute this item path
path = computeItemPath(i[0], i[1]);
// Log the action if the path exists .. it may of already been removed and this is a legacy array item
if (exists(path)) {
if (item.type == ItemType.file) {
addLogEntry("Trying to move this local file to the configured 'Recycle Bin': " ~ path);
} else {
addLogEntry("Trying to move this local directory to the configured 'Recycle Bin': " ~ path);
}
}
// Process the database entry removal. In a --dry-run scenario, this is being done against a DB copy
itemDB.deleteById(item.driveId, item.id);
if (item.remoteDriveId != null) {
// delete the linked remote folder
itemDB.deleteById(item.remoteDriveId, item.remoteId);
}
// Add to pathFakeDeletedArray
// We dont want to try and upload this item again, so we need to track this objects removal
if (dryRun) {
// We need to add './' here so that it can be correctly searched to ensure it is not uploaded
string pathToAdd = "./" ~ path;
pathFakeDeletedArray ~= pathToAdd;
}
bool needsRemoval = false;
if (exists(path)) {
// path exists on the local system
// make sure that the path refers to the correct item
Item pathItem;
if (itemDB.selectByPath(path, item.driveId, pathItem)) {
if (pathItem.id == item.id) {
needsRemoval = true;
} else {
addLogEntry("Skipped due to id difference!");
}
} else {
// item has disappeared completely
needsRemoval = true;
}
}
if (needsRemoval) {
// Log the action
if (item.type == ItemType.file) {
addLogEntry("Moving this local file to the configured 'Recycle Bin': " ~ path, fileTransferNotifications());
} else {
addLogEntry("Moving this local directory to the configured 'Recycle Bin': " ~ path, fileTransferNotifications());
}
// Perform the action
if (!dryRun) {
// Move the 'path' to the configured recycle bin
movePathToRecycleBin(path);
}
}
}
@ -4396,6 +4472,99 @@ class SyncEngine {
displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey);
}
}
// Move to the 'Recycle Bin' rather than a hard delete locally of the online deleted item
void movePathToRecycleBin(string path) {
// Function Start Time
SysTime functionStartTime;
string logKey;
string thisFunctionName = format("%s.%s", strip(__MODULE__) , strip(getFunctionName!({})));
// Only set this if we are generating performance processing times
if (appConfig.getValueBool("display_processing_time") && debugLogging) {
functionStartTime = Clock.currTime();
logKey = generateAlphanumericString();
displayFunctionProcessingStart(thisFunctionName, logKey);
}
// This is a 2 step process
// 1. Move the file
// - If the destination 'name' already exists, the file being moved to the 'Recycle Bin' needs to have a number added to it.
// 2. Create the metadata about where the file came from
// - This is in a specific format:
// [Trash Info]
// Path=/original/absolute/path/to/the/file/or/folder
// DeletionDate=YYYY-MM-DDTHH:MM:SS
// Calculate all the initial paths required
string computedFullLocalPath = absolutePath(path);
string fileNameOnly = baseName(path);
string computedRecycleBinFilePath = appConfig.recycleBinFilePath ~ fileNameOnly;
string computedRecycleBinInfoPath = appConfig.recycleBinInfoPath ~ fileNameOnly ~ ".trashinfo";
bool isPathFile = isFile(computedFullLocalPath);
// The 'destination' needs to be unique, but if there is a 'collision' the RecycleBin paths need to be updated to be:
// - file1.data (1)
// - file1.data (1).trashinfo
if (exists(computedRecycleBinFilePath)) {
// There is an existing file with the same name already in the 'Recycle Bin'
// - Testing has show that this counter MUST start at 2 to be compatible with FreeDesktop.org Trash Specification ....
int n = 2;
// We need to split this out
string nameOnly = stripExtension(fileNameOnly); // "file1"
string extension = extension(fileNameOnly); // ".data"
// We need to test for this: nameOnly.n.extension
while (exists(format(appConfig.recycleBinFilePath ~ nameOnly ~ ".%d." ~ extension, n))) {
n++;
}
// Generate newFileNameOnly
string newFileNameOnly = format(nameOnly ~ ".%d." ~ extension, n);
// UPDATE:
// - computedRecycleBinFilePath
// - computedRecycleBinInfoPath
computedRecycleBinFilePath = appConfig.recycleBinFilePath ~ newFileNameOnly;
computedRecycleBinInfoPath = appConfig.recycleBinInfoPath ~ newFileNameOnly ~ ".trashinfo";
}
// Move the file to the 'Recycle Bin' path computedRecycleBinFilePath
// - DMD has no 'move' specifically, it uses 'rename' to achieve this
// https://forum.dlang.org/thread/kwnwrlqtjehldckyfmau@forum.dlang.org
// Use rename() as Linux is POSIX compliant, we have an atomic operation where at no point in time the 'to' is missing.
try {
rename(computedFullLocalPath, computedRecycleBinFilePath);
} catch (Exception e) {
// Handle exceptions, e.g., log error
if (isPathFile) {
addLogEntry("Move of local file failed for " ~ to!string(path) ~ ": " ~ e.msg, ["error"]);
} else {
addLogEntry("Move of local directory failed for " ~ to!string(path) ~ ": " ~ e.msg, ["error"]);
}
}
// Generate the 'Recycle Bin' metadata file using computedRecycleBinInfoPath
auto now = Clock.currTime().toLocalTime();
string deletionDate = format("%04d-%02d-%02dT%02d:%02d:%02d",now.year, now.month, now.day, now.hour, now.minute, now.second);
// Format the content of the .trashinfo file
string content = format("[Trash Info]\nPath=%s\nDeletionDate=%s\n", computedFullLocalPath, deletionDate);
// Write the metadata file
try {
std.file.write(computedRecycleBinInfoPath, content);
} catch (Exception e) {
// Handle exceptions, e.g., log error
addLogEntry("Writing of .trashinfo metadata file failed for " ~ computedRecycleBinInfoPath ~ ": " ~ e.msg, ["error"]);
}
// Display function processing time if configured to do so
if (appConfig.getValueBool("display_processing_time") && debugLogging) {
// Combine module name & running Function
displayFunctionProcessingTime(thisFunctionName, functionStartTime, Clock.currTime(), logKey);
}
}
// List items that were deleted online, but, due to --download-only being used, will not be deleted locally
void listDeletedItems() {