/* 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. */ public class BackingFileState { public string filepath; public int64 filesize; public time_t modification_time; public string? md5; public BackingFileState(string filepath, int64 filesize, time_t modification_time, string? md5) { this.filepath = filepath; this.filesize = filesize; this.modification_time = modification_time; this.md5 = md5; } public BackingFileState.from_photo_row(BackingPhotoRow photo_row, string? md5) { this.filepath = photo_row.filepath; this.filesize = photo_row.filesize; this.modification_time = photo_row.timestamp; this.md5 = md5; } public File get_file() { return File.new_for_path(filepath); } } public abstract class MediaSource : ThumbnailSource, Indexable { public virtual signal void master_replaced(File old_file, File new_file) { } private Event? event = null; private string? indexable_keywords = null; public MediaSource(int64 object_id = INVALID_OBJECT_ID) { base (object_id); } protected static inline uint64 internal_add_flags(uint64 flags, uint64 selector) { return (flags | selector); } protected static inline uint64 internal_remove_flags(uint64 flags, uint64 selector) { return (flags & ~selector); } protected static inline bool internal_is_flag_set(uint64 flags, uint64 selector) { return ((flags & selector) != 0); } protected virtual void notify_master_replaced(File old_file, File new_file) { master_replaced(old_file, new_file); } protected override void notify_altered(Alteration alteration) { Alteration local = alteration; if (local.has_detail("metadata", "name") || local.has_detail("backing", "master")) { update_indexable_keywords(); local = local.compress(new Alteration("indexable", "keywords")); } base.notify_altered(local); } // use this method as a kind of post-constructor initializer; it means the DataSource has been // added or removed to a SourceCollection. protected override void notify_membership_changed(DataCollection? collection) { if (collection != null && indexable_keywords == null) { // don't fire the alteration here, as the MediaSource is only being added to its // SourceCollection update_indexable_keywords(); } base.notify_membership_changed(collection); } private void update_indexable_keywords() { string[] indexables = new string[3]; indexables[0] = get_title(); indexables[1] = get_basename(); indexables[2] = get_comment(); indexable_keywords = prepare_indexable_strings(indexables); } public unowned string? get_indexable_keywords() { return indexable_keywords; } protected abstract bool set_event_id(EventID id); protected bool delete_original_file() { bool ret = false; File file = get_master_file(); try { ret = file.trash(null); } catch (Error err) { // log error but don't abend, as this is not fatal to operation (also, could be // the photo is removed because it could not be found during a verify) message("Unable to move original photo %s to trash: %s", file.get_path(), err.message); } // remove empty directories corresponding to imported path, but only if file is located // inside the user's Pictures directory if (file.has_prefix(AppDirs.get_import_dir())) { File parent = file; while (!parent.equal(AppDirs.get_import_dir())) { parent = parent.get_parent(); if ((parent == null) || (parent.equal(AppDirs.get_import_dir()))) break; try { if (!query_is_directory_empty(parent)) break; } catch (Error err) { warning("Unable to query file info for %s: %s", parent.get_path(), err.message); break; } try { parent.delete(null); debug("Deleted empty directory %s", parent.get_path()); } catch (Error err) { // again, log error but don't abend message("Unable to delete empty directory %s: %s", parent.get_path(), err.message); } } } return ret; } public override string get_name() { string? title = get_title(); return is_string_empty(title) ? get_basename() : title; } public virtual string get_basename() { return get_file().get_basename(); } public abstract File get_file(); public abstract File get_master_file(); public abstract uint64 get_master_filesize(); public abstract uint64 get_filesize(); public abstract time_t get_timestamp(); // Must return at least one, for the master file. public abstract BackingFileState[] get_backing_files_state(); public abstract string? get_title(); public abstract string? get_comment(); public abstract void set_title(string? title); public abstract bool set_comment(string? comment); public static string? prep_title(string? title) { return prepare_input_text(title, PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.EMPTY_IS_NULL, DEFAULT_USER_TEXT_INPUT_LENGTH); } public static string? prep_comment(string? comment) { return prepare_input_text(comment, PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.STRIP_CRLF & ~PrepareInputTextOptions.EMPTY_IS_NULL, -1); } public abstract Rating get_rating(); public abstract void set_rating(Rating rating); public abstract void increase_rating(); public abstract void decrease_rating(); public abstract Dimensions get_dimensions(Photo.Exception disallowed_steps = Photo.Exception.NONE); // A preview pixbuf is one that can be quickly generated and scaled as a preview. For media // type that support transformations (i.e. photos) it is fully transformed. // // Note that an unscaled scaling is not considered a performance-killer for this method, // although the quality of the pixbuf may be quite poor compared to the actual unscaled // transformed pixbuf. public abstract Gdk.Pixbuf get_preview_pixbuf(Scaling scaling) throws Error; public abstract bool is_trashed(); public abstract void trash(); public abstract void untrash(); public abstract bool is_offline(); public abstract void mark_offline(); public abstract void mark_online(); public abstract string get_master_md5(); // WARNING: some child classes of MediaSource (e.g. Photo) implement this method in a // non-thread safe manner for efficiency. public abstract EventID get_event_id(); public Event? get_event() { if (event != null) return event; EventID event_id = get_event_id(); if (!event_id.is_valid()) return null; event = Event.global.fetch(event_id); return event; } public bool set_event(Event? new_event) { EventID event_id = (new_event != null) ? new_event.get_event_id() : EventID(); if (get_event_id().id == event_id.id) return true; bool committed = set_event_id(event_id); if (committed) { if (event != null) event.detach(this); if (new_event != null) new_event.attach(this); event = new_event; notify_altered(new Alteration("metadata", "event")); } return committed; } public static void set_many_to_event(Gee.Collection<MediaSource> media_sources, Event? event, TransactionController controller) throws Error { EventID event_id = (event != null) ? event.get_event_id() : EventID(); controller.begin(); foreach (MediaSource media in media_sources) { Event? old_event = media.get_event(); if (old_event != null) old_event.detach(media); media.set_event_id(event_id); media.event = event; } if (event != null) event.attach_many(media_sources); Alteration alteration = new Alteration("metadata", "event"); foreach (MediaSource media in media_sources) media.notify_altered(alteration); controller.commit(); } public abstract time_t get_exposure_time(); public abstract ImportID get_import_id(); } public class MediaSourceHoldingTank : DatabaseSourceHoldingTank { private Gee.HashMap<File, MediaSource> master_file_map = new Gee.HashMap<File, MediaSource>( file_hash, file_equal); public MediaSourceHoldingTank(MediaSourceCollection sources, SourceHoldingTank.CheckToKeep check_to_keep, GetSourceDatabaseKey get_key) { base (sources, check_to_keep, get_key); } public MediaSource? fetch_by_master_file(File file) { return master_file_map.get(file); } public MediaSource? fetch_by_md5(string md5) { foreach (MediaSource source in master_file_map.values) { if (source.get_master_md5() == md5) { return source; } } return null; } protected override void notify_contents_altered(Gee.Collection<DataSource>? added, Gee.Collection<DataSource>? removed) { if (added != null) { foreach (DataSource source in added) { MediaSource media_source = (MediaSource) source; master_file_map.set(media_source.get_master_file(), media_source); media_source.master_replaced.connect(on_master_source_replaced); } } if (removed != null) { foreach (DataSource source in removed) { MediaSource media_source = (MediaSource) source; bool is_removed = master_file_map.unset(media_source.get_master_file()); assert(is_removed); media_source.master_replaced.disconnect(on_master_source_replaced); } } base.notify_contents_altered(added, removed); } private void on_master_source_replaced(MediaSource media_source, File old_file, File new_file) { bool removed = master_file_map.unset(old_file); assert(removed); master_file_map.set(new_file, media_source); } } // This class is good for any MediaSourceCollection that is backed by a DatabaseTable (which should // be all of them, but if not, they should construct their own implementation). public class MediaSourceTransactionController : TransactionController { private MediaSourceCollection sources; public MediaSourceTransactionController(MediaSourceCollection sources) { this.sources = sources; } protected override void begin_impl() throws Error { DatabaseTable.begin_transaction(); sources.freeze_notifications(); } protected override void commit_impl() throws Error { sources.thaw_notifications(); DatabaseTable.commit_transaction(); } } public abstract class MediaSourceCollection : DatabaseSourceCollection { public abstract TransactionController transaction_controller { get; } private MediaSourceHoldingTank trashcan = null; private MediaSourceHoldingTank offline_bin = null; private Gee.HashMap<File, MediaSource> by_master_file = new Gee.HashMap<File, MediaSource>( file_hash, file_equal); private Gee.MultiMap<ImportID?, MediaSource> import_rolls = new Gee.TreeMultiMap<ImportID?, MediaSource>(ImportID.compare_func); private Gee.TreeSet<ImportID?> sorted_import_ids = new Gee.TreeSet<ImportID?>(ImportID.compare_func); private Gee.Set<MediaSource> flagged = new Gee.HashSet<MediaSource>(); // This signal is fired when MediaSources are added to the collection due to a successful import. // "items-added" and "contents-altered" will follow. public virtual signal void media_import_starting(Gee.Collection<MediaSource> media) { } // This signal is fired when MediaSources have been added to the collection due to a successful // import and import postprocessing has completed (such as adding an import Photo to its Tags). // Thus, signals that have already been fired (in this order) are "media-imported", "items-added", // "contents-altered" before this signal. public virtual signal void media_import_completed(Gee.Collection<MediaSource> media) { } public virtual signal void master_file_replaced(MediaSource media, File old_file, File new_file) { } public virtual signal void trashcan_contents_altered(Gee.Collection<MediaSource>? added, Gee.Collection<MediaSource>? removed) { } public virtual signal void import_roll_altered() { } public virtual signal void offline_contents_altered(Gee.Collection<MediaSource>? added, Gee.Collection<MediaSource>? removed) { } public virtual signal void flagged_contents_altered() { } public MediaSourceCollection(string name, GetSourceDatabaseKey source_key_func) { base(name, source_key_func); trashcan = create_trashcan(); offline_bin = create_offline_bin(); } public static void filter_media(Gee.Collection<MediaSource> media, Gee.Collection<LibraryPhoto>? photos, Gee.Collection<Video>? videos) { foreach (MediaSource source in media) { if (photos != null && source is LibraryPhoto) photos.add((LibraryPhoto) source); else if (videos != null && source is Video) videos.add((Video) source); else if (photos != null || videos != null) warning("Unrecognized media: %s", source.to_string()); } } public static void count_media(Gee.Collection<MediaSource> media, out int photo_count, out int video_count) { var photos = new Gee.ArrayList<LibraryPhoto>(); var videos = new Gee.ArrayList<Video>(); filter_media(media, photos, videos); photo_count = photos.size; video_count = videos.size; } public static bool has_photo(Gee.Collection<MediaSource> media) { foreach (MediaSource current_media in media) { if (current_media is Photo) { return true; } } return false; } public static bool has_video(Gee.Collection<MediaSource> media) { foreach (MediaSource current_media in media) { if (current_media is Video) { return true; } } return false; } protected abstract MediaSourceHoldingTank create_trashcan(); protected abstract MediaSourceHoldingTank create_offline_bin(); public abstract MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable); public abstract string get_typename(); public abstract bool is_file_recognized(File file); public MediaSourceHoldingTank get_trashcan() { return trashcan; } public MediaSourceHoldingTank get_offline_bin() { return offline_bin; } // NOTE: numeric id's are not unique throughout the system -- they're only unique // per media type. So a MediaSourceCollection should only ever hold media // of the same type. protected abstract MediaSource? fetch_by_numeric_id(int64 numeric_id); protected virtual void notify_import_roll_altered() { import_roll_altered(); } protected virtual void notify_flagged_contents_altered() { flagged_contents_altered(); } protected virtual void notify_media_import_starting(Gee.Collection<MediaSource> media) { media_import_starting(media); } protected virtual void notify_media_import_completed(Gee.Collection<MediaSource> media) { media_import_completed(media); } protected override void items_altered(Gee.Map<DataObject, Alteration> items) { Gee.ArrayList<MediaSource> to_trashcan = null; Gee.ArrayList<MediaSource> to_offline = null; bool flagged_altered = false; foreach (DataObject object in items.keys) { Alteration alteration = items.get(object); MediaSource source = (MediaSource) object; if (!alteration.has_subject("metadata")) continue; if (source.is_trashed() && !get_trashcan().contains(source)) { if (to_trashcan == null) to_trashcan = new Gee.ArrayList<MediaSource>(); to_trashcan.add(source); // sources can only be in trashcan or offline -- not both continue; } if (source.is_offline() && !get_offline_bin().contains(source)) { if (to_offline == null) to_offline = new Gee.ArrayList<MediaSource>(); to_offline.add(source); } Flaggable? flaggable = source as Flaggable; if (flaggable != null) { if (flaggable.is_flagged()) flagged_altered = flagged.add(source) || flagged_altered; else flagged_altered = flagged.remove(source) || flagged_altered; } } if (to_trashcan != null) get_trashcan().unlink_and_hold(to_trashcan); if (to_offline != null) get_offline_bin().unlink_and_hold(to_offline); if (flagged_altered) notify_flagged_contents_altered(); base.items_altered(items); } protected override void notify_contents_altered(Gee.Iterable<DataObject>? added, Gee.Iterable<DataObject>? removed) { bool import_roll_changed = false; bool flagged_altered = false; if (added != null) { foreach (DataObject object in added) { MediaSource media = (MediaSource) object; by_master_file.set(media.get_master_file(), media); media.master_replaced.connect(on_master_replaced); ImportID import_id = media.get_import_id(); if (import_id.is_valid()) { sorted_import_ids.add(import_id); import_rolls.set(import_id, media); import_roll_changed = true; } Flaggable? flaggable = media as Flaggable; if (flaggable != null ) { if (flaggable.is_flagged()) flagged_altered = flagged.add(media) || flagged_altered; else flagged_altered = flagged.remove(media) || flagged_altered; } } } if (removed != null) { foreach (DataObject object in removed) { MediaSource media = (MediaSource) object; bool is_removed = by_master_file.unset(media.get_master_file()); assert(is_removed); media.master_replaced.disconnect(on_master_replaced); ImportID import_id = media.get_import_id(); if (import_id.is_valid()) { is_removed = import_rolls.remove(import_id, media); assert(is_removed); if (!import_rolls.contains(import_id)) sorted_import_ids.remove(import_id); import_roll_changed = true; } flagged_altered = flagged.remove(media) || flagged_altered; } } if (import_roll_changed) notify_import_roll_altered(); if (flagged_altered) notify_flagged_contents_altered(); base.notify_contents_altered(added, removed); } private void on_master_replaced(MediaSource media, File old_file, File new_file) { bool is_removed = by_master_file.unset(old_file); assert(is_removed); by_master_file.set(new_file, media); master_file_replaced(media, old_file, new_file); } public MediaSource? fetch_by_master_file(File file) { return by_master_file.get(file); } public virtual MediaSource? fetch_by_source_id(string source_id) { string[] components = source_id.split("-"); assert(components.length == 2); return fetch_by_numeric_id(parse_int64(components[1], 16)); } public abstract Gee.Collection<string> get_event_source_ids(EventID event_id); public Gee.Collection<MediaSource> get_trashcan_contents() { return (Gee.Collection<MediaSource>) get_trashcan().get_all(); } public Gee.Collection<MediaSource> get_offline_bin_contents() { return (Gee.Collection<MediaSource>) get_offline_bin().get_all(); } public Gee.Collection<MediaSource> get_flagged() { return flagged.read_only_view; } // The returned set of ImportID's is sorted from oldest to newest. public Gee.SortedSet<ImportID?> get_import_roll_ids() { return sorted_import_ids; } public ImportID? get_last_import_id() { return sorted_import_ids.size != 0 ? sorted_import_ids.last() : null; } public Gee.Collection<MediaSource?>? get_import_roll(ImportID import_id) { return import_rolls.get(import_id); } public void add_many_to_trash(Gee.Collection<MediaSource> sources) { get_trashcan().add_many(sources); } public void add_many_to_offline(Gee.Collection<MediaSource> sources) { get_offline_bin().add_many(sources); } public int get_trashcan_count() { return get_trashcan().get_count(); } // This method should be used in place of add_many() when adding MediaSources due to a successful // import. This function fires appropriate signals and calls add_many(), so the signals // associated with that call will be fired too. public virtual void import_many(Gee.Collection<MediaSource> media) { notify_media_import_starting(media); add_many(media); postprocess_imported_media(media); notify_media_import_completed(media); } // Child classes can override this method to perform postprocessing on a imported media, such // as associating them with tags or events. protected virtual void postprocess_imported_media(Gee.Collection<MediaSource> media) { } // This operation cannot be cancelled; the return value of the ProgressMonitor is ignored. // Note that delete_backing dictates whether or not the photos are tombstoned (if deleted, // tombstones are not created). public void remove_from_app(Gee.Collection<MediaSource>? sources, bool delete_backing, ProgressMonitor? monitor = null, Gee.List<MediaSource>? not_removed = null) { assert(sources != null); // only tombstone if the backing is not being deleted Gee.HashSet<MediaSource> to_tombstone = !delete_backing ? new Gee.HashSet<MediaSource>() : null; // separate photos into two piles: those in the trash and those not Gee.ArrayList<MediaSource> trashed = new Gee.ArrayList<MediaSource>(); Gee.ArrayList<MediaSource> offlined = new Gee.ArrayList<MediaSource>(); Gee.ArrayList<MediaSource> not_trashed = new Gee.ArrayList<MediaSource>(); foreach (MediaSource source in sources) { if (source.is_trashed()) trashed.add(source); else if (source.is_offline()) offlined.add(source); else not_trashed.add(source); if (to_tombstone != null) to_tombstone.add(source); } int total_count = sources.size; assert(total_count == (trashed.size + offlined.size + not_trashed.size)); // use an aggregate progress monitor, as it's possible there are three steps here AggregateProgressMonitor agg_monitor = null; if (monitor != null) { agg_monitor = new AggregateProgressMonitor(total_count, monitor); monitor = agg_monitor.monitor; } if (trashed.size > 0) get_trashcan().destroy_orphans(trashed, delete_backing, monitor, not_removed); if (offlined.size > 0) get_offline_bin().destroy_orphans(offlined, delete_backing, monitor, not_removed); // untrashed media sources may be destroyed outright if (not_trashed.size > 0) destroy_marked(mark_many(not_trashed), delete_backing, monitor, not_removed); if (to_tombstone != null && to_tombstone.size > 0) { try { Tombstone.entomb_many_sources(to_tombstone, Tombstone.Reason.REMOVED_BY_USER); } catch (DatabaseError err) { AppWindow.database_error(err); } } } // Deletes (i.e. not trashes) the backing files. // Note: must be removed from DB first. public void delete_backing_files(Gee.Collection<MediaSource> sources, ProgressMonitor? monitor = null, Gee.List<MediaSource>? not_deleted = null) { int total_count = sources.size; int i = 1; foreach (MediaSource source in sources) { File file = source.get_file(); try { file.delete(null); } catch (Error err) { // Note: we may get an exception even though the delete succeeded. debug("Exception deleting file %s: %s", file.get_path(), err.message); } bool deleted = !file.query_exists(); if (!deleted && null != not_deleted) { not_deleted.add(source); } if (monitor != null) { monitor(i, total_count); } i++; } } } public class MediaCollectionRegistry { private const int LIBRARY_MONITOR_START_DELAY_MSEC = 1000; private static MediaCollectionRegistry? instance = null; private Gee.ArrayList<MediaSourceCollection> all = new Gee.ArrayList<MediaSourceCollection>(); private Gee.HashMap<string, MediaSourceCollection> by_typename = new Gee.HashMap<string, MediaSourceCollection>(); private MediaCollectionRegistry() { Application.get_instance().init_done.connect(on_init_done); } ~MediaCollectionRegistry() { Application.get_instance().init_done.disconnect(on_init_done); } private void on_init_done() { // install the default library monitor LibraryMonitor library_monitor = new LibraryMonitor(AppDirs.get_import_dir(), true, !CommandlineOptions.no_runtime_monitoring); LibraryMonitorPool.get_instance().replace(library_monitor, LIBRARY_MONITOR_START_DELAY_MSEC); } public static void init() { instance = new MediaCollectionRegistry(); Config.Facade.get_instance().import_directory_changed.connect(on_import_directory_changed); } public static void terminate() { Config.Facade.get_instance().import_directory_changed.disconnect(on_import_directory_changed); } private static void on_import_directory_changed() { File import_dir = AppDirs.get_import_dir(); LibraryMonitor? current = LibraryMonitorPool.get_instance().get_monitor(); if (current != null && current.get_root().equal(import_dir)) return; LibraryMonitor replacement = new LibraryMonitor(import_dir, true, !CommandlineOptions.no_runtime_monitoring); LibraryMonitorPool.get_instance().replace(replacement, LIBRARY_MONITOR_START_DELAY_MSEC); } public static MediaCollectionRegistry get_instance() { return instance; } public static string get_typename_from_source_id(string source_id) { // we have to special-case photos because their source id format is non-standard. this // is due to a historical quirk. if (source_id.has_prefix(Photo.TYPENAME)) { return Photo.TYPENAME; } else { string[] components = source_id.split("-"); assert(components.length == 2); return components[0]; } } public void register_collection(MediaSourceCollection collection) { all.add(collection); by_typename.set(collection.get_typename(), collection); } // NOTE: going forward, please use get_collection( ) and get_all_collections( ) to get the // collection associated with a specific media type or to get all registered collections, // respectively, instead of explicitly referencing Video.global and LibraryPhoto.global. // This will make it *much* easier to add new media types in the future. public MediaSourceCollection? get_collection(string typename) { return by_typename.get(typename); } public Gee.Collection<MediaSourceCollection> get_all() { return all.read_only_view; } public void freeze_all() { foreach (MediaSourceCollection sources in get_all()) sources.freeze_notifications(); } public void thaw_all() { foreach (MediaSourceCollection sources in get_all()) sources.thaw_notifications(); } public void begin_transaction_on_all() { foreach (MediaSourceCollection sources in get_all()) sources.transaction_controller.begin(); } public void commit_transaction_on_all() { foreach (MediaSourceCollection sources in get_all()) sources.transaction_controller.commit(); } public MediaSource? fetch_media(string source_id) { string typename = get_typename_from_source_id(source_id); MediaSourceCollection? collection = get_collection(typename); if (collection == null) { critical("source id '%s' has unrecognized media type '%s'", source_id, typename); return null; } return collection.fetch_by_source_id(source_id); } public ImportID? get_last_import_id() { ImportID last_import_id = ImportID(); foreach (MediaSourceCollection current_collection in get_all()) { ImportID? current_import_id = current_collection.get_last_import_id(); if (current_import_id == null) continue; if (current_import_id.id > last_import_id.id) last_import_id = current_import_id; } // VALA: can't use the ternary operator here because of bug 616897 : "Mixed nullability in // ternary operator fails" if (last_import_id.id == ImportID.INVALID) return null; else return last_import_id; } public Gee.Collection<string> get_source_ids_for_event_id(EventID event_id) { Gee.ArrayList<string> result = new Gee.ArrayList<string>(); foreach (MediaSourceCollection current_collection in get_all()) { result.add_all(current_collection.get_event_source_ids(event_id)); } return result; } public MediaSourceCollection? get_collection_for_file(File file) { foreach (MediaSourceCollection collection in get_all()) { if (collection.is_file_recognized(file)) return collection; } return null; } public bool is_valid_source_id(string? source_id) { if (is_string_empty(source_id)) { return false; } return (source_id.has_prefix(Photo.TYPENAME) || source_id.has_prefix(Video.TYPENAME + "-")); } }