/* Copyright 2016 Software Freedom Conservancy Inc. * * This software is licensed under the GNU LGPL (version 2.1 or later). * See the COPYING file in this distribution. */ // // LibraryMonitor uses DirectoryMonitor to track assets in the user's library directory and make // sure they're reflected in the application. // // NOTE: There appears to be a bug where prior versions of Shotwell (<= 0.6.x) were not // properly loading the file modification timestamp during import. This was no issue // before but becomes imperative now with file monitoring. A "proper" algorithm is // to reimport an entire photo if the modification time in the database is different // than the file's, but that's Real Bad when the user first turns on monitoring, as it // will cause a lot of reimports (think of a 10,000 photo database) and will blow away // ALL transformations, as they are now suspect. // // So: If the modification time is zero and filesize is the same, simply update the // timestamp in the database and move on. // // TODO: Although it seems highly unlikely that a file's timestamp could change but the file size // has not and the file really be "changed", it *is* possible, even in the case of complex little // animals like photo files. We could be more liberal and treat this case as a metadata-changed // situation (since that's a likely case). // public class LibraryMonitorPool { private static LibraryMonitorPool? instance = null; private LibraryMonitor? monitor = null; private uint timer_id = 0; public signal void monitor_installed(LibraryMonitor monitor); public signal void monitor_destroyed(LibraryMonitor monitor); private LibraryMonitorPool() { } public static void init() { } public static void terminate() { if (instance != null) instance.close(); instance = null; } public static LibraryMonitorPool get_instance() { if (instance == null) instance = new LibraryMonitorPool(); return instance; } public LibraryMonitor? get_monitor() { return monitor; } // This closes and destroys the old monitor, if any, and replaces it with the new one. public void replace(LibraryMonitor replacement, int start_msec_delay = 0) { close(); monitor = replacement; if (start_msec_delay > 0 && timer_id == 0) timer_id = Timeout.add(start_msec_delay, on_start_monitor); monitor_installed(monitor); } private void close() { if (monitor == null) return; monitor.close(); LibraryMonitor closed = monitor; monitor = null; monitor_destroyed(closed); } private bool on_start_monitor() { // can set to zero because this function always returns false timer_id = 0; if (monitor == null) return false; monitor.start_discovery(); return false; } } public class LibraryMonitor : DirectoryMonitor { private const int FLUSH_IMPORT_QUEUE_SEC = 3; private const int IMPORT_ROLL_QUIET_SEC = 5 * 60; private const int MIN_BLACKLIST_DURATION_MSEC = 5 * 1000; private const int MAX_VERIFY_EXISTING_MEDIA_JOBS = 5; private class FindMoveJob : BackgroundJob { public File file; public Gee.Collection candidates; public Monitorable? match = null; public Gee.ArrayList? losers = null; public Error? err = null; public FindMoveJob(LibraryMonitor owner, File file, Gee.Collection candidates) { base (owner, owner.on_find_move_completed, owner.cancellable, owner.on_find_move_cancelled); this.file = file; this.candidates = candidates; set_completion_priority(Priority.LOW); } public override void execute() { // weed out any candidates that already have a backing master Gee.Iterator iter = candidates.iterator(); while (iter.next()) { if (iter.get().get_master_file().query_exists()) iter.remove(); } // if no more, done if (candidates.size == 0) return; string? md5 = null; try { md5 = md5_file(file); } catch (Error err) { this.err = err; return; } foreach (Monitorable candidate in candidates) { if (candidate.get_master_md5() != md5) continue; if (match != null) { warning("Found more than one media match for %s: %s and %s", file.get_path(), match.to_string(), candidate.to_string()); if (losers == null) losers = new Gee.ArrayList(); losers.add(candidate); continue; } match = candidate; } } } private class RuntimeFindMoveJob : BackgroundJob { public File file; public Gee.Collection candidates; public Monitorable? match = null; public Error? err = null; public RuntimeFindMoveJob(LibraryMonitor owner, File file, Gee.Collection candidates) { base (owner, owner.on_runtime_find_move_completed, owner.cancellable); this.file = file; this.candidates = candidates; set_completion_priority(Priority.LOW); } public override void execute() { string? md5 = null; try { md5 = md5_file(file); } catch (Error err) { this.err = err; return; } foreach (Monitorable candidate in candidates) { if (candidate.get_master_md5() == md5) { match = candidate; break; } } } } private class VerifyJob { public Monitorable monitorable; public MediaMonitor monitor; public VerifyJob(Monitorable monitorable, MediaMonitor monitor) { this.monitorable = monitorable; this.monitor = monitor; } } private static Gee.HashSet blacklist = new Gee.HashSet(file_hash, file_equal); private static HashTimedQueue to_unblacklist = new HashTimedQueue( MIN_BLACKLIST_DURATION_MSEC, on_unblacklist_file, file_hash, file_equal, Priority.LOW); private Workers workers = new Workers(Workers.thread_per_cpu_minus_one(), false); private Cancellable cancellable = new Cancellable(); private bool auto_import = false; private Gee.HashSet unknown_files = null; private Gee.List monitors = new Gee.ArrayList(); private Gee.HashMap> discovered = null; private Gee.HashSet import_queue = new Gee.HashSet(file_hash, file_equal); private Gee.HashSet pending_imports = new Gee.HashSet(file_hash, file_equal); private Gee.ArrayList batch_import_queue = new Gee.ArrayList(); private BatchImportRoll current_import_roll = null; private time_t last_import_roll_use = 0; private BatchImport current_batch_import = null; private int checksums_completed = 0; private int checksums_total = 0; private uint import_queue_timer_id = 0; private Gee.Queue verify_queue = new Gee.LinkedList(); private int outstanding_verify_jobs = 0; private int completed_monitorable_verifies = 0; private int total_monitorable_verifies = 0; public signal void auto_update_progress(int completed_files, int total_files); public signal void auto_import_preparing(); public signal void auto_import_progress(uint64 completed_bytes, uint64 total_bytes); public LibraryMonitor(File root, bool recurse, bool monitoring) { base (root, recurse, monitoring); // synchronize with configuration system auto_import = Config.Facade.get_instance().get_auto_import_from_library(); Config.Facade.get_instance().auto_import_from_library_changed.connect(on_config_changed); import_queue_timer_id = Timeout.add_seconds(FLUSH_IMPORT_QUEUE_SEC, on_flush_import_queue); } ~LibraryMonitor() { Config.Facade.get_instance().auto_import_from_library_changed.disconnect(on_config_changed); } public override void close() { cancellable.cancel(); foreach (MediaMonitor monitor in monitors) monitor.close(); if (import_queue_timer_id != 0) { Source.remove(import_queue_timer_id); import_queue_timer_id = 0; } base.close(); } private void add_to_discovered_list(MediaMonitor monitor, Monitorable monitorable) { if (!discovered.has_key(monitor)) discovered.set(monitor, new Gee.HashSet()); discovered.get(monitor).add(monitorable); } private MediaMonitor get_monitor_for_monitorable(Monitorable monitorable) { foreach (MediaMonitor monitor in monitors) { if (monitor.get_media_source_collection().holds_type_of_source(monitorable)) return monitor; } error("Unable to locate MediaMonitor for %s", monitorable.to_string()); } public override void discovery_started() { foreach (MediaSourceCollection collection in MediaCollectionRegistry.get_instance().get_all()) monitors.add(collection.create_media_monitor(workers, cancellable)); foreach (MediaMonitor monitor in monitors) monitor.notify_discovery_started(); discovered = new Gee.HashMap>(); unknown_files = new Gee.HashSet(file_hash, file_equal); base.discovery_started(); } public override void file_discovered(File file, FileInfo info) { Monitorable? representation = null; MediaMonitor representing = null; bool ignore = false; foreach (MediaMonitor monitor in monitors) { MediaMonitor.DiscoveredFile result = monitor.notify_file_discovered(file, info, out representation); if (result == MediaMonitor.DiscoveredFile.REPRESENTED) { representing = monitor; break; } else if (result == MediaMonitor.DiscoveredFile.IGNORE) { // known but not to be worried about (for purposes of discovery) ignore = true; break; } } if (representing != null) { assert(representation != null && !ignore); add_to_discovered_list(representing, representation); } else if (!ignore && !Tombstone.global.matches(file) && is_supported_filetype(file)) { unknown_files.add(file); } base.file_discovered(file, info); } public override void discovery_completed() { async_discovery_completed.begin(); } private async void async_discovery_completed() { // before marking anything online/offline, reimporting changed files, or auto-importing new // files, want to see if the unknown files are actually renamed files. Do this by examining // their FileInfo and calculating their MD5 in the background ... when all this is sorted // out, then go on and finish the other tasks if (unknown_files.size == 0) { discovery_stage_completed(); return; } Gee.ArrayList all_candidates = new Gee.ArrayList(); Gee.ArrayList adopted = new Gee.ArrayList(file_equal); foreach (File file in unknown_files) { FileInfo? info = get_file_info(file); if (info == null) continue; // clear before using (reused as accumulator) all_candidates.clear(); Gee.Collection? candidates = null; bool associated = false; foreach (MediaMonitor monitor in monitors) { MediaMonitor.DiscoveredFile result; candidates = monitor.candidates_for_unknown_file(file, info, out result); if (result == MediaMonitor.DiscoveredFile.REPRESENTED || result == MediaMonitor.DiscoveredFile.IGNORE) { associated = true; break; } else if (candidates != null) { all_candidates.add_all(candidates); } } if (associated) { adopted.add(file); continue; } // verify the matches with an MD5 comparison if (all_candidates.size > 0) { // copy for background thread Gee.ArrayList job_candidates = all_candidates; all_candidates = new Gee.ArrayList(); checksums_total++; workers.enqueue(new FindMoveJob(this, file, job_candidates)); } Idle.add(async_discovery_completed.callback); yield; } // remove all adopted files from the unknown list unknown_files.remove_all(adopted); checksums_completed = 0; if (checksums_total == 0) { discovery_stage_completed(); } else { mdbg("%d checksum jobs initiated to verify unknown photo files".printf(checksums_total)); auto_update_progress(checksums_completed, checksums_total); } } private void report_checksum_job_completed() { assert(checksums_completed < checksums_total); checksums_completed++; auto_update_progress(checksums_completed, checksums_total); if (checksums_completed == checksums_total) discovery_stage_completed(); } private void on_find_move_completed(BackgroundJob j) { FindMoveJob job = (FindMoveJob) j; // if match was found, give file to the media and removed from both the unknown list and // add to the discovered list ... do NOT mark losers as offline as other jobs may discover // files that belong to them; discovery_stage_completed() will work this out in the end if (job.match != null) { mdbg("Found moved master file: %s matches %s".printf(job.file.get_path(), job.match.to_string())); MediaMonitor monitor = get_monitor_for_monitorable(job.match); monitor.update_master_file(job.match, job.file); unknown_files.remove(job.file); add_to_discovered_list(monitor, job.match); } if (job.err != null) warning("Unable to checksum unknown media file %s: %s", job.file.get_path(), job.err.message); report_checksum_job_completed(); } private void on_find_move_cancelled(BackgroundJob j) { report_checksum_job_completed(); } private void discovery_stage_completed() { foreach (MediaMonitor monitor in monitors) { Gee.Set? monitorables = discovered.get(monitor); if (monitorables != null) { foreach (Monitorable monitorable in monitorables) enqueue_verify_monitorable(monitorable, monitor); } foreach (DataObject object in monitor.get_media_source_collection().get_all()) { Monitorable monitorable = (Monitorable) object; if (monitorables != null && monitorables.contains(monitorable)) continue; enqueue_verify_monitorable(monitorable, monitor); } foreach (DataSource source in monitor.get_media_source_collection().get_offline_bin().get_all()) { Monitorable monitorable = (Monitorable) source; if (monitorables != null && monitorables.contains(monitorable)) continue; enqueue_verify_monitorable(monitorable, monitor); } } // enqueue all remaining unknown photo files for import if (auto_import) enqueue_import_many(unknown_files); // release refs discovered = null; unknown_files = null; // Now that the discovery is completed, launch a scan of the tombstoned files and see if // they can be resurrected Tombstone.global.launch_scan(this, cancellable); // Only report discovery completed here, after all the other background work is done base.discovery_completed(); } private void enqueue_verify_monitorable(Monitorable monitorable, MediaMonitor monitor) { bool offered = verify_queue.offer(new VerifyJob(monitorable, monitor)); assert(offered); execute_next_verify_job(); } private void execute_next_verify_job() { if (outstanding_verify_jobs >= MAX_VERIFY_EXISTING_MEDIA_JOBS || verify_queue.size == 0) return; VerifyJob? job = verify_queue.poll(); assert(job != null); outstanding_verify_jobs++; verify_monitorable.begin(job.monitorable, job.monitor); } private async void verify_monitorable(Monitorable monitorable, MediaMonitor monitor) { File[] files = new File[1]; files[0] = monitor.get_master_file(monitorable); File[]? aux_files = monitor.get_auxilliary_backing_files(monitorable); if (aux_files != null) { foreach (File aux_file in aux_files) files += aux_file; } for (int ctr = 0; ctr < files.length; ctr++) { File file = files[ctr]; FileInfo? info = get_file_info(file); if (info == null) { try { info = yield file.query_info_async(SUPPLIED_ATTRIBUTES, FILE_INFO_FLAGS, DEFAULT_PRIORITY, cancellable); } catch (Error err) { // ignore, this happens when file is not found } } // if master file, control online/offline state if (ctr == 0) { if (info != null && monitor.is_offline(monitorable)) monitor.update_online(monitorable); else if (info == null && !monitor.is_offline(monitorable)) monitor.update_offline(monitorable); } monitor.update_backing_file_info(monitorable, file, info); } completed_monitorable_verifies++; auto_update_progress(completed_monitorable_verifies, total_monitorable_verifies); Idle.add(verify_monitorable.callback, DEFAULT_PRIORITY); yield; // finished, move on to the next job in the queue assert(outstanding_verify_jobs > 0); outstanding_verify_jobs--; execute_next_verify_job(); } private void on_config_changed() { bool value = Config.Facade.get_instance().get_auto_import_from_library(); if (auto_import == value) return; auto_import = value; if (auto_import) { if (!CommandlineOptions.no_runtime_monitoring) import_unrepresented_files(); } else { cancel_batch_imports(); } } private void enqueue_import(File file) { if (!pending_imports.contains(file) && is_supported_filetype(file) && !is_blacklisted(file)) import_queue.add(file); } private void enqueue_import_many(Gee.Collection files) { foreach (File file in files) enqueue_import(file); } private void remove_queued_import(File file) { import_queue.remove(file); } private bool on_flush_import_queue() { if (cancellable.is_cancelled()) return false; if (import_queue.size == 0) return true; // if currently importing, wait for it to finish before starting next one; this maximizes // the number of items submitted each time if (current_batch_import != null) return true; mdbg("Auto-importing %d files".printf(import_queue.size)); // If no import roll, or it's been over IMPORT_ROLL_QUIET_SEC since using the last one, // create a new one. This allows for multiple files to come in back-to-back and be // imported on the same roll. time_t now = (time_t) now_sec(); if (current_import_roll == null || (now - last_import_roll_use) >= IMPORT_ROLL_QUIET_SEC) current_import_roll = new BatchImportRoll(); last_import_roll_use = now; Gee.ArrayList jobs = new Gee.ArrayList(); foreach (File file in import_queue) { if (is_blacklisted(file)) continue; jobs.add(new FileImportJob(file, false, true)); pending_imports.add(file); } import_queue.clear(); BatchImport importer = new BatchImport(jobs, "LibraryMonitor autoimport", null, null, null, null, current_import_roll); importer.set_untrash_duplicates(false); importer.set_mark_duplicates_online(false); batch_import_queue.add(importer); schedule_next_batch_import(); return true; } private void schedule_next_batch_import() { if (current_batch_import != null || batch_import_queue.size == 0) return; current_batch_import = batch_import_queue[0]; current_batch_import.preparing.connect(on_import_preparing); current_batch_import.progress.connect(on_import_progress); current_batch_import.import_complete.connect(on_import_complete); current_batch_import.schedule(); } private void discard_current_batch_import() { assert(current_batch_import != null); bool removed = batch_import_queue.remove(current_batch_import); assert(removed); current_batch_import.preparing.disconnect(on_import_preparing); current_batch_import.progress.disconnect(on_import_progress); current_batch_import.import_complete.disconnect(on_import_complete); current_batch_import = null; // a "proper" way to do this would be a complex data structure that stores the association // of every file to its BatchImport and removes it from the pending_imports Set when // the BatchImport completes, cancelled or not (the removal using manifest.all in // on_import_completed doesn't catch files not imported due to cancellation) ... but, since // individual BatchImports can't be cancelled, only all of them, this works if (batch_import_queue.size == 0) pending_imports.clear(); } private void cancel_batch_imports() { // clear everything queued up (that is not the current batch import) int ctr = 0; while (ctr < batch_import_queue.size) { if (batch_import_queue[ctr] == current_batch_import) { ctr++; continue; } batch_import_queue.remove(batch_import_queue[ctr]); } // cancel the current import and remove it when the completion is called if (current_batch_import != null) current_batch_import.user_halt(); // remove all pending so if a new import comes in, it won't be skipped pending_imports.clear(); } private void on_import_preparing() { auto_import_preparing(); } private void on_import_progress(uint64 completed_bytes, uint64 total_bytes) { auto_import_progress(completed_bytes, total_bytes); } private void on_import_complete(BatchImport batch_import, ImportManifest manifest, BatchImportRoll import_roll) { assert(batch_import == current_batch_import); mdbg("auto-import batch completed %d".printf(manifest.all.size)); auto_import_progress(0, 0); foreach (BatchImportResult result in manifest.all) { // don't verify the pending_imports file is removed, it can be removed if the import // was cancelled if (result.file != null) pending_imports.remove(result.file); } if (manifest.already_imported.size > 0) { Gee.ArrayList to_tombstone = new Gee.ArrayList(); foreach (BatchImportResult result in manifest.already_imported) { FileInfo? info = get_file_info(result.file); if (info == null) { warning("Unable to get info for duplicate file %s", result.file.get_path()); continue; } to_tombstone.add(new TombstonedFile(result.file, info.get_size(), null)); } try { Tombstone.entomb_many_files(to_tombstone, Tombstone.Reason.AUTO_DETECTED_DUPLICATE); } catch (DatabaseError err) { AppWindow.database_error(err); } } mdbg("%d files remain pending for auto-import".printf(pending_imports.size)); discard_current_batch_import(); schedule_next_batch_import(); } // // Real-time monitoring & auto-import // // USE WITH CARE. Because changes to the photo's state will not be updated as its backing // file(s) change, it's possible for the library to diverge with what's on disk while the // media source is blacklisted. If the media source is removed from the blacklist and // unexpected state changes occur (such as file-altered being detected but not the file-create), // the change will either be dropped on the floor or the state of the library will be // indeterminate. // // Use of this method should be avoided at all costs (otherwise the point of the real-time // monitor is negated). public static void blacklist_file(File file, string reason) { mdbg("[%s] Blacklisting %s".printf(reason, file.get_path())); lock (blacklist) { blacklist.add(file); } } public static void unblacklist_file(File file) { // don't want to immediately remove the blacklisted file because the monitoring events // can come in much later lock (blacklist) { if (blacklist.contains(file) && !to_unblacklist.contains(file)) to_unblacklist.enqueue(file); } } private static void on_unblacklist_file(File file) { bool removed; lock (blacklist) { removed = blacklist.remove(file); } if (removed) mdbg("Blacklist for %s removed".printf(file.get_path())); else warning("File %s was not blacklisted but unblacklisted", file.get_path()); } public static bool is_blacklisted(File file) { lock (blacklist) { return blacklist.contains(file); } } private bool is_supported_filetype(File file) { return MediaCollectionRegistry.get_instance().get_collection_for_file(file) != null; } // NOTE: This only works when runtime monitoring is enabled. Otherwise, DirectoryMonitor will // not be tracking files. private void import_unrepresented_files() { if (!auto_import) return; Gee.ArrayList to_import = null; foreach (File file in get_files()) { FileInfo? info = get_file_info(file); if (info == null || info.get_file_type() != FileType.REGULAR) continue; if (pending_imports.contains(file)) continue; if (Tombstone.global.matches(file)) continue; bool represented = false; foreach (MediaMonitor monitor in monitors) { if (monitor.is_file_represented(file)) { represented = true; break; } } if (represented) continue; if (!is_supported_filetype(file)) continue; if (to_import == null) to_import = new Gee.ArrayList(file_equal); to_import.add(file); } if (to_import != null) enqueue_import_many(to_import); } // It's possible for the monitor to miss a file create but report other activities, which we // can use to pick up new files private void runtime_unknown_file_discovered(File file) { if (auto_import && is_supported_filetype(file) && !Tombstone.global.matches(file)) { mdbg("Unknown file %s discovered, enqueuing for import".printf(file.get_path())); enqueue_import(file); } } protected override void notify_file_created(File file, FileInfo info) { if (is_blacklisted(file)) { base.notify_file_created(file, info); return; } bool known = false; foreach (MediaMonitor monitor in monitors) { if (monitor.notify_file_created(file, info)) { known = true; break; } } if (!known) { // attempt to match the new file with a Monitorable that is offline Gee.HashSet all_candidates = null; foreach (MediaMonitor monitor in monitors) { MediaMonitor.DiscoveredFile result; Gee.Collection? candidates = monitor.candidates_for_unknown_file(file, info, out result); if (result == MediaMonitor.DiscoveredFile.REPRESENTED || result == MediaMonitor.DiscoveredFile.IGNORE) { mdbg("%s %s created file %s".printf(monitor.to_string(), result.to_string(), file.get_path())); known = true; break; } else if (candidates != null && candidates.size > 0) { mdbg("%s suggests %d candidates for created file %s".printf(monitor.to_string(), candidates.size, file.get_path())); if (all_candidates == null) all_candidates = new Gee.HashSet(); foreach (Monitorable candidate in candidates) { if (monitor.is_offline(candidate)) all_candidates.add(candidate); } } } if (!known && all_candidates != null && all_candidates.size > 0) { mdbg("%d candidates for created file %s being checksummed".printf(all_candidates.size, file.get_path())); workers.enqueue(new RuntimeFindMoveJob(this, file, all_candidates)); // mark as known to avoid adding file for possible import known = true; } } if (!known) runtime_unknown_file_discovered(file); base.notify_file_created(file, info); } private void on_runtime_find_move_completed(BackgroundJob j) { RuntimeFindMoveJob job = (RuntimeFindMoveJob) j; if (job.err != null) { critical("Error attempting to find a match at runtime for %s: %s", job.file.get_path(), job.err.message); } if (job.match != null) { MediaMonitor monitor = get_monitor_for_monitorable(job.match); monitor.update_master_file(job.match, job.file); monitor.update_online(job.match); } else { // no match found, mark file for possible import runtime_unknown_file_discovered(job.file); } } protected override void notify_file_moved(File old_file, File new_file, FileInfo new_info) { if (is_blacklisted(old_file) || is_blacklisted(new_file)) { base.notify_file_moved(old_file, new_file, new_info); return; } bool known = false; foreach (MediaMonitor monitor in monitors) { if (monitor.notify_file_moved(old_file, new_file, new_info)) { known = true; break; } } if (!known) runtime_unknown_file_discovered(new_file); base.notify_file_moved(old_file, new_file, new_info); } protected override void notify_file_altered(File file) { if (is_blacklisted(file)) { base.notify_file_altered(file); return; } bool known = false; foreach (MediaMonitor monitor in monitors) { if (monitor.notify_file_altered(file)) { known = true; break; } } if (!known) runtime_unknown_file_discovered(file); base.notify_file_altered(file); } protected override void notify_file_attributes_altered(File file) { if (is_blacklisted(file)) { base.notify_file_attributes_altered(file); return; } bool known = false; foreach (MediaMonitor monitor in monitors) { if (monitor.notify_file_attributes_altered(file)) { known = true; break; } } if (!known) runtime_unknown_file_discovered(file); base.notify_file_attributes_altered(file); } protected override void notify_file_alteration_completed(File file, FileInfo info) { if (is_blacklisted(file)) { base.notify_file_alteration_completed(file, info); return; } bool known = false; foreach (MediaMonitor monitor in monitors) { if (monitor.notify_file_alteration_completed(file, info)) { known = true; break; } } if (!known) runtime_unknown_file_discovered(file); base.notify_file_alteration_completed(file, info); } protected override void notify_file_deleted(File file) { if (is_blacklisted(file)) { base.notify_file_deleted(file); return; } bool known = false; foreach (MediaMonitor monitor in monitors) { if (monitor.notify_file_deleted(file)) { known = true; break; } } if (!known) { // ressurrect tombstone if deleted Tombstone? tombstone = Tombstone.global.locate(file); if (tombstone != null) { debug("Resurrecting tombstoned file %s", file.get_path()); Tombstone.global.resurrect(tombstone); } // remove from import queue remove_queued_import(file); } base.notify_file_deleted(file); } }