/* Copyright 2009-2014 Yorba Foundation * * This software is licensed under the GNU LGPL (version 2.1 or later). * See the COPYING file in this distribution. */ public class EventSourceCollection : ContainerSourceCollection { public signal void no_event_collection_altered(); private ViewCollection no_event; private class NoEventViewManager : ViewManager { public override bool include_in_view(DataSource source) { // Note: this is not threadsafe return (((MediaSource) source).get_event_id().id != EventID.INVALID) ? false : base.include_in_view(source); } public override DataView create_view(DataSource source) { return new ThumbnailView((MediaSource) source); } } public EventSourceCollection() { base(Event.TYPENAME, "EventSourceCollection", get_event_key); attach_collection(LibraryPhoto.global); attach_collection(Video.global); } public void init() { no_event = new ViewCollection("No Event View Collection"); NoEventViewManager view_manager = new NoEventViewManager(); Alteration filter_alteration = new Alteration("metadata", "event"); no_event.monitor_source_collection(LibraryPhoto.global, view_manager, filter_alteration); no_event.monitor_source_collection(Video.global, view_manager, filter_alteration); no_event.contents_altered.connect(on_no_event_collection_altered); } public override bool holds_type_of_source(DataSource source) { return source is Event; } private static int64 get_event_key(DataSource source) { Event event = (Event) source; EventID event_id = event.get_event_id(); return event_id.id; } public Event? fetch(EventID event_id) { return (Event) fetch_by_key(event_id.id); } protected override Gee.Collection<ContainerSource>? get_containers_holding_source(DataSource source) { Event? event = ((MediaSource) source).get_event(); if (event == null) return null; Gee.ArrayList<ContainerSource> list = new Gee.ArrayList<ContainerSource>(); list.add(event); return list; } protected override ContainerSource? convert_backlink_to_container(SourceBacklink backlink) { EventID event_id = EventID(backlink.instance_id); Event? event = fetch(event_id); if (event != null) return event; foreach (ContainerSource container in get_holding_tank()) { if (((Event) container).get_event_id().id == event_id.id) return container; } return null; } public Gee.Collection<DataObject> get_no_event_objects() { return no_event.get_sources(); } private void on_no_event_collection_altered(Gee.Iterable<DataObject>? added, Gee.Iterable<DataObject>? removed) { no_event_collection_altered(); } } public class Event : EventSource, ContainerSource, Proxyable, Indexable { public const string TYPENAME = "event"; // SHOW_COMMENTS (bool) public const string PROP_SHOW_COMMENTS = "show-comments"; // In 24-hour time. public const int EVENT_BOUNDARY_HOUR = 4; private const time_t TIME_T_DAY = 24 * 60 * 60; private class EventSnapshot : SourceSnapshot { private EventRow row; private MediaSource primary_source; private Gee.ArrayList<MediaSource> attached_sources = new Gee.ArrayList<MediaSource>(); public EventSnapshot(Event event) { // save current state of event row = EventTable.get_instance().get_row(event.get_event_id()); primary_source = event.get_primary_source(); // stash all the media sources in the event ... these are not used when reconstituting // the event, but need to know when they're destroyed, as that means the event cannot // be restored foreach (MediaSource source in event.get_media()) attached_sources.add(source); LibraryPhoto.global.item_destroyed.connect(on_attached_source_destroyed); Video.global.item_destroyed.connect(on_attached_source_destroyed); } ~EventSnapshot() { LibraryPhoto.global.item_destroyed.disconnect(on_attached_source_destroyed); Video.global.item_destroyed.disconnect(on_attached_source_destroyed); } public EventRow get_row() { return row; } public override void notify_broken() { row = new EventRow(); primary_source = null; attached_sources.clear(); base.notify_broken(); } private void on_attached_source_destroyed(DataSource source) { MediaSource media_source = (MediaSource) source; // if one of the media sources in the event goes away, reconstitution is impossible if (media_source != null && primary_source.equals(media_source)) notify_broken(); else if (attached_sources.contains(media_source)) notify_broken(); } } private class EventProxy : SourceProxy { public EventProxy(Event event) { base (event); } public override DataSource reconstitute(int64 object_id, SourceSnapshot snapshot) { EventSnapshot event_snapshot = snapshot as EventSnapshot; assert(event_snapshot != null); return Event.reconstitute(object_id, event_snapshot.get_row()); } } public static EventSourceCollection global = null; private static EventTable event_table = null; private EventID event_id; private string? raw_name; private MediaSource primary_source; private ViewCollection view; private bool unlinking = false; private bool relinking = false; private string? indexable_keywords = null; private string? comment = null; private Event(EventRow event_row, int64 object_id = INVALID_OBJECT_ID) { base (object_id); // normalize user text event_row.name = prep_event_name(event_row.name); this.event_id = event_row.event_id; this.raw_name = event_row.name; this.comment = event_row.comment; Gee.Collection<string> event_source_ids = MediaCollectionRegistry.get_instance().get_source_ids_for_event_id(event_id); Gee.ArrayList<ThumbnailView> event_thumbs = new Gee.ArrayList<ThumbnailView>(); foreach (string current_source_id in event_source_ids) { MediaSource? media = MediaCollectionRegistry.get_instance().fetch_media(current_source_id); if (media != null) event_thumbs.add(new ThumbnailView(media)); } view = new ViewCollection("ViewCollection for Event %s".printf(event_id.id.to_string())); view.set_comparator(view_comparator, view_comparator_predicate); view.add_many(event_thumbs); // need to do this manually here because only want to monitor ViewCollection contents after // initial batch has been added, but need to keep EventSourceCollection apprised if (event_thumbs.size > 0) { global.notify_container_contents_added(this, event_thumbs, false); global.notify_container_contents_altered(this, event_thumbs, false, null, false); } // get the primary source for monitoring; if not available, use the first unrejected // source in the event primary_source = MediaCollectionRegistry.get_instance().fetch_media(event_row.primary_source_id); if (primary_source == null && view.get_count() > 0) { primary_source = (MediaSource) ((DataView) view.get_first_unrejected()).get_source(); event_table.set_primary_source_id(event_id, primary_source.get_source_id()); } // watch the primary source to reflect thumbnail changes if (primary_source != null) primary_source.thumbnail_altered.connect(on_primary_thumbnail_altered); // watch for for addition, removal, and alteration of photos and videos view.items_added.connect(on_media_added); view.items_removed.connect(on_media_removed); view.items_altered.connect(on_media_altered); // because we're no longer using source monitoring (for performance reasons), need to watch // for media destruction (but not removal, which is handled automatically in any case) LibraryPhoto.global.item_destroyed.connect(on_media_destroyed); Video.global.item_destroyed.connect(on_media_destroyed); update_indexable_keywords(); } ~Event() { if (primary_source != null) primary_source.thumbnail_altered.disconnect(on_primary_thumbnail_altered); view.items_altered.disconnect(on_media_altered); view.items_removed.disconnect(on_media_removed); view.items_added.disconnect(on_media_added); LibraryPhoto.global.item_destroyed.disconnect(on_media_destroyed); Video.global.item_destroyed.disconnect(on_media_destroyed); } public override string get_typename() { return TYPENAME; } public override int64 get_instance_id() { return get_event_id().id; } public override string get_representative_id() { return (primary_source != null) ? primary_source.get_source_id() : get_source_id(); } public override PhotoFileFormat get_preferred_thumbnail_format() { return (primary_source != null) ? primary_source.get_preferred_thumbnail_format() : PhotoFileFormat.get_system_default_format(); } public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error { return (primary_source != null) ? primary_source.create_thumbnail(scale) : null; } public static void init(ProgressMonitor? monitor = null) { event_table = EventTable.get_instance(); global = new EventSourceCollection(); global.init(); // add all events to the global collection Gee.ArrayList<Event> events = new Gee.ArrayList<Event>(); Gee.ArrayList<Event> unlinked = new Gee.ArrayList<Event>(); Gee.ArrayList<EventRow?> event_rows = event_table.get_events(); int count = event_rows.size; for (int ctr = 0; ctr < count; ctr++) { Event event = new Event(event_rows[ctr]); if (monitor != null) monitor(ctr, count); if (event.get_media_count() != 0) { events.add(event); continue; } // TODO: If event has no backlinks, destroy (empty Event stored in database) ... this // is expensive to check at startup time, however, should happen in background or // during a "clean" operation event.rehydrate_backlinks(global, null); unlinked.add(event); } global.add_many(events); global.init_add_many_unlinked(unlinked); } public static void terminate() { } private static int64 view_comparator(void *a, void *b) { return ((MediaSource) ((ThumbnailView *) a)->get_source()).get_exposure_time() - ((MediaSource) ((ThumbnailView *) b)->get_source()).get_exposure_time() ; } private static bool view_comparator_predicate(DataObject object, Alteration alteration) { return alteration.has_detail("metadata", "exposure-time"); } public static string? prep_event_name(string? name) { // Ticket #3218 - we tell prepare_input_text to // allow empty strings, and if the rest of the app sees // one, it already knows to rename it to // one of the default event names. return prepare_input_text(name, PrepareInputTextOptions.NORMALIZE | PrepareInputTextOptions.VALIDATE | PrepareInputTextOptions.INVALID_IS_NULL | PrepareInputTextOptions.STRIP | PrepareInputTextOptions.STRIP_CRLF, DEFAULT_USER_TEXT_INPUT_LENGTH); } // This is used by MediaSource to notify Event when it's joined. Don't use this to manually attach a // a photo or video to an Event, use MediaSource.set_event(). public void attach(MediaSource source) { view.add(new ThumbnailView(source)); } public void attach_many(Gee.Collection<MediaSource> media) { Gee.ArrayList<ThumbnailView> views = new Gee.ArrayList<ThumbnailView>(); foreach (MediaSource current_source in media) views.add(new ThumbnailView(current_source)); view.add_many(views); } // This is used by internally by Photos and Videos to notify their parent Event as to when // they're leaving. Don't use this manually to detach a MediaSource; instead use // MediaSource.set_event( ) public void detach(MediaSource source) { view.remove_marked(view.mark(view.get_view_for_source(source))); } public void detach_many(Gee.Collection<MediaSource> media) { Gee.ArrayList<ThumbnailView> views = new Gee.ArrayList<ThumbnailView>(); foreach (MediaSource current_source in media) { ThumbnailView? view = (ThumbnailView?) view.get_view_for_source(current_source); if (view != null) views.add(view); } view.remove_marked(view.mark_many(views)); } // TODO: A preferred way to do this is for ContainerSource to have an abstract interface for // obtaining the DataCollection in the ContainerSource of all the media objects. Then, // ContinerSource could offer this helper class. public bool contains_media_type(string media_type) { foreach (MediaSource media in get_media()) { if (media.get_typename() == media_type) return true; } return false; } private Gee.ArrayList<MediaSource> views_to_media(Gee.Iterable<DataObject> views) { Gee.ArrayList<MediaSource> media = new Gee.ArrayList<MediaSource>(); foreach (DataObject object in views) media.add((MediaSource) ((DataView) object).get_source()); return media; } private void on_media_added(Gee.Iterable<DataObject> added) { Gee.Collection<MediaSource> media = views_to_media(added); global.notify_container_contents_added(this, media, relinking); global.notify_container_contents_altered(this, media, relinking, null, false); notify_altered(new Alteration.from_list("contents:added, metadata:time")); } // Event needs to know whenever a media source is removed from the system to update the event private void on_media_removed(Gee.Iterable<DataObject> removed) { Gee.ArrayList<MediaSource> media = views_to_media(removed); global.notify_container_contents_removed(this, media, unlinking); global.notify_container_contents_altered(this, null, false, media, unlinking); // update primary source if it's been removed (and there's one to take its place) foreach (MediaSource current_source in media) { if (current_source == primary_source) { if (get_media_count() > 0) set_primary_source((MediaSource) view.get_first_unrejected().get_source()); else release_primary_source(); break; } } // evaporate event if no more media in it; do not touch thereafter if (get_media_count() == 0) { global.evaporate(this); // as it's possible (highly likely, in fact) that all refs to the Event object have // gone out of scope now, do NOT touch this, but exit immediately return; } notify_altered(new Alteration.from_list("contents:removed, metadata:time")); } private void on_media_destroyed(DataSource source) { ThumbnailView? thumbnail_view = (ThumbnailView) view.get_view_for_source(source); if (thumbnail_view != null) view.remove_marked(view.mark(thumbnail_view)); } public override void notify_relinking(SourceCollection sources) { assert(get_media_count() > 0); // If the primary source was lost in the unlink, reestablish it now. if (primary_source == null) set_primary_source((MediaSource) view.get_first_unrejected().get_source()); base.notify_relinking(sources); } /** @brief This gets called when one or more media items inside this * event gets modified in some fashion. If the media item's date changes * and the event was previously undated, the name of the event needs to * change as well; all of that happens automatically in here. * * In addition, if the _rating_ of one or more media items has changed, * the thumbnail of this event may need to change, as the primary * image may have been rejected and should not be the thumbnail anymore. */ private void on_media_altered(Gee.Map<DataObject, Alteration> items) { bool should_remake_thumb = false; foreach (Alteration alteration in items.values) { if (alteration.has_detail("metadata", "exposure-time")) { string alt_list = "metadata:time"; if(!has_name()) alt_list += (", metadata:name"); notify_altered(new Alteration.from_list(alt_list)); break; } if (alteration.has_detail("metadata", "rating")) should_remake_thumb = true; } if (should_remake_thumb) { // check whether we actually need to remake this thumbnail... if ((get_primary_source() == null) || (get_primary_source().get_rating() == Rating.REJECTED)) { // yes, rejected - drop it and get a new one... set_primary_source((MediaSource) view.get_first_unrejected().get_source()); } // ...otherwise, if the primary source wasn't rejected, just leave it alone. } } // This creates an empty event with a primary source. NOTE: This does not add the source to // the event. That must be done manually. public static Event? create_empty_event(MediaSource source) { try { Event event = new Event(EventTable.get_instance().create(source.get_source_id(), null)); global.add(event); debug("Created empty event %s", event.to_string()); return event; } catch (DatabaseError err) { AppWindow.database_error(err); return null; } } // This will create an event using the fields supplied in EventRow. The event_id is ignored. private static Event reconstitute(int64 object_id, EventRow row) { row.event_id = EventTable.get_instance().create_from_row(row); Event event = new Event(row, object_id); global.add(event); assert(global.contains(event)); debug("Reconstituted event %s", event.to_string()); return event; } public bool has_links() { return (LibraryPhoto.global.has_backlink(get_backlink()) || Video.global.has_backlink(get_backlink())); } public SourceBacklink get_backlink() { return new SourceBacklink.from_source(this); } public void break_link(DataSource source) { unlinking = true; ((MediaSource) source).set_event(null); unlinking = false; } public void break_link_many(Gee.Collection<DataSource> sources) { unlinking = true; Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>(); Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>(); MediaSourceCollection.filter_media((Gee.Collection<MediaSource>) sources, photos, videos); try { MediaSource.set_many_to_event(photos, null, LibraryPhoto.global.transaction_controller); } catch (Error err) { AppWindow.error_message("%s".printf(err.message)); } try { MediaSource.set_many_to_event(videos, null, Video.global.transaction_controller); } catch (Error err) { AppWindow.error_message("%s".printf(err.message)); } unlinking = false; } public void establish_link(DataSource source) { relinking = true; ((MediaSource) source).set_event(this); relinking = false; } public void establish_link_many(Gee.Collection<DataSource> sources) { relinking = true; Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>(); Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>(); MediaSourceCollection.filter_media((Gee.Collection<MediaSource>) sources, photos, videos); try { MediaSource.set_many_to_event(photos, this, LibraryPhoto.global.transaction_controller); } catch (Error err) { AppWindow.error_message("%s".printf(err.message)); } try { MediaSource.set_many_to_event(videos, this, Video.global.transaction_controller); } catch (Error err) { AppWindow.error_message("%s".printf(err.message)); } relinking = false; } private void update_indexable_keywords() { string[] components = new string[3]; int i = 0; string? rawname = get_raw_name(); if (rawname != null) components[i++] = rawname; string? comment = get_comment(); if (comment != null) components[i++] = comment; if (i == 0) indexable_keywords = null; else { components[i] = null; indexable_keywords = prepare_indexable_string(string.joinv(" ", components)); } } public unowned string? get_indexable_keywords() { return indexable_keywords; } public bool is_in_starting_day(time_t time) { // it's possible the Event ref is held although it's been emptied // (such as the user removing items during an import, when events // are being generate on-the-fly) ... return false here and let // the caller make a new one if (view.get_count() == 0) return false; // media sources are stored in ViewCollection from earliest to latest MediaSource earliest_media = (MediaSource) ((DataView) view.get_at(0)).get_source(); Time earliest_tm = Time.local(earliest_media.get_exposure_time()); // use earliest to generate the boundary hour for that day Time start_boundary_tm = Time(); start_boundary_tm.second = 0; start_boundary_tm.minute = 0; start_boundary_tm.hour = EVENT_BOUNDARY_HOUR; start_boundary_tm.day = earliest_tm.day; start_boundary_tm.month = earliest_tm.month; start_boundary_tm.year = earliest_tm.year; start_boundary_tm.isdst = -1; time_t start_boundary = start_boundary_tm.mktime(); // if the earliest's exposure time was on the day but *before* the boundary hour, // step it back a day to the prior day's boundary if (earliest_tm.hour < EVENT_BOUNDARY_HOUR) start_boundary -= TIME_T_DAY; time_t end_boundary = (start_boundary + TIME_T_DAY - 1); return time >= start_boundary && time <= end_boundary; } // This method attempts to add a media source to an event in the supplied list that it would // naturally fit into (i.e. its exposure is within the boundary day of the earliest event // photo). Otherwise, a new Event is generated and the source is added to it and the list. private static Event? generate_event(MediaSource media, ViewCollection events_so_far, string? event_name, out bool new_event) { time_t exposure_time = media.get_exposure_time(); if (exposure_time == 0 && event_name == null) { debug("Skipping event assignment to %s: no exposure time and no event name", media.to_string()); new_event = false; return null; } int count = events_so_far.get_count(); for (int ctr = 0; ctr < count; ctr++) { Event event = (Event) ((EventView) events_so_far.get_at(ctr)).get_source(); if ((event_name != null && event.has_name() && event_name == event.get_name()) || event.is_in_starting_day(exposure_time)) { new_event = false; return event; } } // no Event so far fits the bill for this photo or video, so create a new one try { Event event = new Event(EventTable.get_instance().create(media.get_source_id(), null)); if (event_name != null) event.rename(event_name); events_so_far.add(new EventView(event)); new_event = true; return event; } catch (DatabaseError err) { AppWindow.database_error(err); } new_event = false; return null; } public static void generate_single_event(MediaSource media, ViewCollection events_so_far, string? event_name = null) { // do not replace existing assignments if (media.get_event() != null) return; bool new_event; Event? event = generate_event(media, events_so_far, event_name, out new_event); if (event == null) return; media.set_event(event); if (new_event) global.add(event); } public static void generate_many_events(Gee.Collection<MediaSource> sources, ViewCollection events_so_far) { Gee.Collection<Event> to_add = new Gee.ArrayList<Event>(); foreach (MediaSource media in sources) { // do not replace existing assignments if (media.get_event() != null) continue; bool new_event; Event? event = generate_event(media, events_so_far, null, out new_event); if (event == null) continue; media.set_event(event); if (new_event) to_add.add(event); } if (to_add.size > 0) global.add_many(to_add); } public EventID get_event_id() { return event_id; } public override SourceSnapshot? save_snapshot() { return new EventSnapshot(this); } public SourceProxy get_proxy() { return new EventProxy(this); } public override bool equals(DataSource? source) { // Validate primary key is unique, which is vital to all this working Event? event = source as Event; if (event != null) { if (this != event) { assert(event_id.id != event.event_id.id); } } return base.equals(source); } public override string to_string() { return "Event [%s/%s] %s".printf(event_id.id.to_string(), get_object_id().to_string(), get_name()); } public bool has_name() { return raw_name != null && raw_name.length > 0; } public override string get_name() { if (has_name()) return get_raw_name(); // if no name, pretty up the start time string? datestring = get_formatted_daterange(); return !is_string_empty(datestring) ? datestring : _("Event %s").printf(event_id.id.to_string()); } public string? get_formatted_daterange() { time_t start_time = get_start_time(); time_t end_time = get_end_time(); if (end_time == 0 && start_time == 0) return null; if (end_time == 0 && start_time != 0) return format_local_date(Time.local(start_time)); Time start = Time.local(start_time); Time end = Time.local(end_time); if (start.day == end.day && start.month == end.month && start.day == end.day) return format_local_date(Time.local(start_time)); return format_local_datespan(start, end); } public string? get_raw_name() { return raw_name; } public override string? get_comment() { return comment; } public bool rename(string? name) { string? new_name = prep_event_name(name); // Allow rename to date but it should go dynamic, so set name to "" if (new_name == get_formatted_daterange()) { new_name = ""; } bool renamed = event_table.rename(event_id, new_name); if (renamed) { raw_name = new_name; update_indexable_keywords(); notify_altered(new Alteration.from_list("metadata:name, indexable:keywords")); } return renamed; } public override bool set_comment(string? comment) { string? new_comment = MediaSource.prep_comment(comment); bool committed = event_table.set_comment(event_id, new_comment); if (committed) { this.comment = new_comment; update_indexable_keywords(); notify_altered(new Alteration.from_list("metadata:comment, indexable:keywords")); } return committed; } public time_t get_creation_time() { return event_table.get_time_created(event_id); } public override time_t get_start_time() { // Because the ViewCollection is sorted by a DateComparator, the start time is the // first item. However, we keep looking if it has no start time. int count = view.get_count(); for (int i = 0; i < count; i++) { time_t time = ((MediaSource) (((DataView) view.get_at(i)).get_source())).get_exposure_time(); if (time != 0) return time; } return 0; } public override time_t get_end_time() { int count = view.get_count(); // Because the ViewCollection is sorted by a DateComparator, the end time is the // last item--no matter what. if (count == 0) return 0; return ((MediaSource) (((DataView) view.get_at(count - 1)).get_source())).get_exposure_time(); } public override uint64 get_total_filesize() { uint64 total = 0; foreach (MediaSource current_source in get_media()) { total += current_source.get_filesize(); } return total; } public override int get_media_count() { return view.get_count(); } public override Gee.Collection<MediaSource> get_media() { return (Gee.Collection<MediaSource>) view.get_sources(); } public void mirror_photos(ViewCollection view, CreateView mirroring_ctor) { view.mirror(this.view, mirroring_ctor, null); } private void on_primary_thumbnail_altered() { notify_thumbnail_altered(); } public MediaSource get_primary_source() { return primary_source; } public bool set_primary_source(MediaSource source) { assert(view.has_view_for_source(source)); bool committed = event_table.set_primary_source_id(event_id, source.get_source_id()); if (committed) { // switch to the new media source if (primary_source != null) primary_source.thumbnail_altered.disconnect(on_primary_thumbnail_altered); primary_source = source; primary_source.thumbnail_altered.connect(on_primary_thumbnail_altered); notify_thumbnail_altered(); } return committed; } private void release_primary_source() { if (primary_source == null) return; primary_source.thumbnail_altered.disconnect(on_primary_thumbnail_altered); primary_source = null; } public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error { return primary_source != null ? primary_source.get_thumbnail(scale) : null; } public Gdk.Pixbuf? get_preview_pixbuf(Scaling scaling) { try { return get_primary_source().get_preview_pixbuf(scaling); } catch (Error err) { return null; } } public override void destroy() { // stop monitoring the photos collection view.halt_all_monitoring(); // remove from the database try { event_table.remove(event_id); } catch (DatabaseError err) { AppWindow.database_error(err); } // mark all photos and videos for this event as now event-less PhotoTable.get_instance().drop_event(event_id); VideoTable.get_instance().drop_event(event_id); base.destroy(); } }