diff options
Diffstat (limited to 'src')
157 files changed, 9491 insertions, 6036 deletions
diff --git a/src/AppDirs.vala b/src/AppDirs.vala index 6c4541c..20df920 100644 --- a/src/AppDirs.vala +++ b/src/AppDirs.vala @@ -169,15 +169,14 @@ class AppDirs { } // Library folder + photo folder, based on user's preferred directory pattern. - public static File get_baked_import_dir(time_t tm) { + public static File get_baked_import_dir(DateTime tm) { string? pattern = Config.Facade.get_instance().get_directory_pattern(); if (is_string_empty(pattern)) pattern = Config.Facade.get_instance().get_directory_pattern_custom(); if (is_string_empty(pattern)) pattern = "%Y" + Path.DIR_SEPARATOR_S + "%m" + Path.DIR_SEPARATOR_S + "%d"; // default - DateTime date = new DateTime.from_unix_local(tm); - return File.new_for_path(get_import_dir().get_path() + Path.DIR_SEPARATOR_S + date.format(pattern)); + return File.new_for_path(get_import_dir().get_path() + Path.DIR_SEPARATOR_S + tm.to_local().format(pattern)); } // Returns true if the File is in or is equal to the library/import directory. @@ -210,7 +209,7 @@ class AppDirs { return tmp_dir; } - + public static File get_data_subdir(string name, string? subname = null) { File subdir = get_data_dir().get_child(name); if (subname != null) @@ -262,7 +261,7 @@ class AppDirs { File? install_dir = get_install_dir(); return (install_dir != null) ? install_dir.get_child("share").get_child("shotwell") - : get_exec_dir(); + : get_lib_dir(); } public static File get_lib_dir() { @@ -317,32 +316,67 @@ class AppDirs { return f; } + public static File get_metadata_helper() { + const string filename = "shotwell-video-metadata-handler"; + File f = AppDirs.get_libexec_dir().get_child("video-support").get_child (filename); + if (!f.query_exists()) { + // If we're running installed. + f = AppDirs.get_libexec_dir () .get_child ("shotwell").get_child (filename); + } + return f; + } + public static File get_settings_migrator_bin() { const string filename = "shotwell-settings-migrator"; - File f = AppDirs.get_libexec_dir().get_child ("settings-migrator").get_child (filename); + File f = AppDirs.get_libexec_dir().get_child("settings-migrator").get_child (filename); if (!f.query_exists()) { // If we're running installed. f = AppDirs.get_libexec_dir () .get_child ("shotwell").get_child (filename); } + + if (!f.query_exists()) { + f = AppDirs.get_libexec_dir().get_parent().get_child("settings-migrator").get_child(filename); + } + return f; } + public static File get_haarcascade_file() { + const string filename = "facedetect-haarcascade.xml"; + var f = AppDirs.get_resources_dir().get_parent().get_child("subprojects").get_child("shotwell-facedetect").get_child (filename); + if (f.query_exists()) {//testing meson builddir + return f; + } + return get_resources_dir().get_child("facedetect-haarcascade.xml"); + } + + +#if ENABLE_FACE_DETECTION public static File get_facedetect_bin() { const string filename = "shotwell-facedetect"; - File f = AppDirs.get_libexec_dir().get_parent().get_child("facedetect").get_child (filename); + File f = AppDirs.get_libexec_dir().get_parent().get_child("subprojects").get_child(filename).get_child (filename); if (!f.query_exists()) { f = AppDirs.get_libexec_dir().get_child("shotwell").get_child(filename); } return f; } - - public static File get_haarcascade_file() { - File f = File.new_for_path(AppDirs.get_exec_dir().get_parent().get_parent().get_child("facedetect").get_child("facedetect-haarcascade.xml").get_path()); + + public static File get_openface_dnn_dir() { + return File.new_for_path(Environment.get_user_data_dir()).get_child(DEFAULT_DATA_DIR).get_child("facedetect"); + } + + public static File get_openface_dnn_system_dir() { + var f = File.new_for_path("/app/extra"); + if (f.query_exists()) + return f; + + f = AppDirs.get_resources_dir().get_parent().get_child("subprojects").get_child("shotwell-facedetect"); if (f.query_exists()) {//testing meson builddir return f; } - return get_resources_dir().get_child("facedetect-haarcascade.xml"); + + return AppDirs.get_resources_dir().get_child("facedetect"); } +#endif } - diff --git a/src/AppWindow.vala b/src/AppWindow.vala index a5b27a4..438806c 100644 --- a/src/AppWindow.vala +++ b/src/AppWindow.vala @@ -6,7 +6,7 @@ public class FullscreenWindow : PageWindow { public const int TOOLBAR_INVOCATION_MSEC = 250; - public const int TOOLBAR_DISMISSAL_SEC = 2; + public const int TOOLBAR_DISMISSAL_SEC = 2 * 1000000; public const int TOOLBAR_CHECK_DISMISSAL_MSEC = 500; private Gtk.Overlay overlay = new Gtk.Overlay(); @@ -15,7 +15,7 @@ public class FullscreenWindow : PageWindow { private Gtk.ToggleToolButton pin_button = new Gtk.ToggleToolButton(); private bool is_toolbar_shown = false; private bool waiting_for_invoke = false; - private time_t left_toolbar_time = 0; + private int64 left_toolbar_time = 0; private bool switched_to = false; private bool is_toolbar_dismissal_enabled; @@ -246,13 +246,13 @@ public class FullscreenWindow : PageWindow { // if this is the first time noticed, start the timer and keep checking if (left_toolbar_time == 0) { - left_toolbar_time = time_t(); + left_toolbar_time = GLib.get_monotonic_time(); return true; } // see if enough time has elapsed - time_t now = time_t(); + int64 now = GLib.get_monotonic_time(); assert(now >= left_toolbar_time); if (now - left_toolbar_time < TOOLBAR_DISMISSAL_SEC) @@ -367,7 +367,6 @@ public abstract class PageWindow : Gtk.ApplicationWindow { var display = get_window ().get_display (); var cursor = new Gdk.Cursor.for_display (display, Gdk.CursorType.WATCH); get_window().set_cursor (cursor); - spin_event_loop(); } public void set_normal_cursor() { @@ -381,7 +380,6 @@ public abstract class PageWindow : Gtk.ApplicationWindow { var display = get_window ().get_display (); var cursor = new Gdk.Cursor.for_display (display, Gdk.CursorType.LEFT_PTR); get_window().set_cursor (cursor); - spin_event_loop(); } } @@ -415,7 +413,7 @@ public abstract class AppWindow : PageWindow { instance = this; title = Resources.APP_TITLE; - set_default_icon_name("shotwell"); + set_default_icon_name("org.gnome.Shotwell"); // restore previous size and maximization state if (this is LibraryWindow) { @@ -441,10 +439,6 @@ public abstract class AppWindow : PageWindow { // with each ActionGroup while we're adding the groups to the UIManager. add_actions (); - - Gtk.CssProvider provider = new Gtk.CssProvider(); - provider.load_from_resource("/org/gnome/Shotwell/misc/org.gnome.Shotwell.css"); - Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); } private const GLib.ActionEntry[] common_actions = { @@ -475,19 +469,6 @@ public abstract class AppWindow : PageWindow { return fullscreen_window; } - public static Gtk.Builder create_builder(string glade_filename = "shotwell.ui", void *user = null) { - Gtk.Builder builder = new Gtk.Builder(); - try { - builder.add_from_resource(Resources.get_ui(glade_filename)); - } catch(GLib.Error error) { - warning("Unable to create Gtk.Builder: %s\n", error.message); - } - - builder.connect_signals(user); - - return builder; - } - public static void error_message(string message, Gtk.Window? parent = null) { error_message_with_title(Resources.APP_TITLE, message, parent); } @@ -560,27 +541,26 @@ public abstract class AppWindow : PageWindow { return (Gtk.ResponseType) response; } - public static Gtk.ResponseType negate_affirm_all_cancel_question(string message, - string negative, string affirmative, string affirmative_all, string? title = null, - Gtk.Window? parent = null) { + public static int export_overwrite_or_replace_question(string message, + string alt1, string alt2, string alt3, string alt4, string alt5, string alt6, + string? title = null, Gtk.Window? parent = null) { Gtk.MessageDialog dialog = new Gtk.MessageDialog((parent != null) ? parent : get_instance(), Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", message); dialog.title = (title != null) ? title : Resources.APP_TITLE; - dialog.add_buttons(negative, Gtk.ResponseType.NO, affirmative, Gtk.ResponseType.YES, - affirmative_all, Gtk.ResponseType.APPLY, _("_Cancel"), Gtk.ResponseType.CANCEL); + dialog.add_buttons(alt1, 1, alt2, 2, alt3, 3, alt4, 4, alt5, 5, alt6, 6); int response = dialog.run(); dialog.destroy(); - return (Gtk.ResponseType) response; + return response; } - - public static void database_error(DatabaseError err) { + + public static void database_error(Error err) { panic(_("A fatal error occurred when accessing Shotwell’s library. Shotwell cannot continue.\n\n%s").printf( err.message)); } - + public static void panic(string msg) { critical(msg); error_message(msg); @@ -591,9 +571,13 @@ public abstract class AppWindow : PageWindow { public abstract string get_app_role(); protected void on_about() { - const string[] artists = { "Celler Schloss created by Hajotthu, CC BY-SA 3.0, https://commons.wikimedia.org/wiki/File:Celler_Schloss_April_2010.jpg#file", null }; + var hash = ""; + if (Resources.GIT_VERSION != null && Resources.GIT_VERSION != "" && Resources.GIT_VERSION != Resources.APP_VERSION) { + hash = " (%s)".printf(Resources.GIT_VERSION.substring(0,7)); + } + string[] artists = {"Image of the Delmenhorst Town Hall by Charlie1965nrw, source: https://commons.wikimedia.org/wiki/File:Delmenhorst_Rathaus.jpg", null}; Gtk.show_about_dialog(this, - "version", Resources.APP_VERSION + " \u2013 “Celle”", + "version", Resources.APP_VERSION + hash + " — Delmenhorst", "comments", get_app_role(), "copyright", Resources.COPYRIGHT, "website", Resources.HOME_URL, @@ -601,8 +585,8 @@ public abstract class AppWindow : PageWindow { "website-label", _("Visit the Shotwell web site"), "authors", Resources.AUTHORS, "logo", Resources.get_icon(Resources.ICON_ABOUT_LOGO, -1), - "artists", artists, "translator-credits", _("translator-credits"), + "artists", artists, null ); } diff --git a/src/Application.vala b/src/Application.vala index 36acc41..59bae36 100644 --- a/src/Application.vala +++ b/src/Application.vala @@ -69,6 +69,16 @@ public class Application { system_app.startup.connect(on_activated); } + public static double get_scale() { + var instance = get_instance().system_app; + unowned GLib.List<Gtk.Window> windows = instance.get_windows(); + + if (windows == null) + return 1.0; + + return windows.data.get_scale_factor(); + } + /** * @brief This is a helper for library mode that should only be * called if we've gotten a camera mount and are _not_ the primary @@ -104,7 +114,7 @@ public class Application { } /** - * @brief Signal handler for GApplication's 'command-line' signal. + * @brief Signal handler for GApplication's 'activate' signal. * * The most likely scenario for this to be fired is if the user * either tried to run us twice in library mode, or we've just gotten diff --git a/src/BatchImport.vala b/src/BatchImport.vala index 0e31441..90ccba8 100644 --- a/src/BatchImport.vala +++ b/src/BatchImport.vala @@ -201,10 +201,10 @@ public abstract class BatchImportJob { return false; } - // returns a non-zero time_t value if this has a valid exposure time override, returns zero + // returns a non-null DateTime value if this has a valid exposure time override, returns zero // otherwise - public virtual time_t get_exposure_time_override() { - return 0; + public virtual DateTime? get_exposure_time_override() { + return null; } public virtual bool recurse() { @@ -1597,6 +1597,11 @@ private class WorkSniffer : BackgroundImportJob { } public void search_dir(BatchImportJob job, File dir, bool copy_to_library, bool recurse) throws Error { + if (dir.get_child(".nomedia").query_exists()) { + debug("Folder %s contains \".nomedia\" file, ignoring.", dir.get_path()); + return; + } + FileEnumerator enumerator = dir.enumerate_children("standard::*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); diff --git a/src/CheckerboardItem.vala b/src/CheckerboardItem.vala new file mode 100644 index 0000000..a8a5e63 --- /dev/null +++ b/src/CheckerboardItem.vala @@ -0,0 +1,734 @@ +/* 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 abstract class CheckerboardItem : ThumbnailView { + // Collection properties CheckerboardItem understands + // SHOW_TITLES (bool) + public const string PROP_SHOW_TITLES = "show-titles"; + // SHOW_COMMENTS (bool) + public const string PROP_SHOW_COMMENTS = "show-comments"; + // SHOW_SUBTITLES (bool) + public const string PROP_SHOW_SUBTITLES = "show-subtitles"; + + public const int FRAME_WIDTH = 8; + public const int LABEL_PADDING = 4; + public const int BORDER_WIDTH = 1; + + public const int SHADOW_RADIUS = 4; + public const float SHADOW_INITIAL_ALPHA = 0.5f; + + public const int TRINKET_SCALE = 12; + public const int TRINKET_PADDING = 1; + + public const int BRIGHTEN_SHIFT = 0x18; + + public Dimensions requisition = Dimensions(); + public Gdk.Rectangle allocation = Gdk.Rectangle(); + + private bool exposure = false; + private CheckerboardItemText? title = null; + private bool title_visible = true; + private CheckerboardItemText? comment = null; + private bool comment_visible = true; + private CheckerboardItemText? subtitle = null; + private bool subtitle_visible = false; + private bool is_cursor = false; + private Pango.Alignment tag_alignment = Pango.Alignment.LEFT; + private Gee.List<Tag>? user_visible_tag_list = null; + private Gee.Collection<Tag> tags; + private Gdk.Pixbuf pixbuf = null; + private Gdk.Pixbuf display_pixbuf = null; + private Gdk.Pixbuf brightened = null; + private Dimensions pixbuf_dim = Dimensions(); + private int col = -1; + private int row = -1; + private int horizontal_trinket_offset = 0; + + protected CheckerboardItem(ThumbnailSource source, Dimensions initial_pixbuf_dim, string title, string? comment, + bool marked_up = false, Pango.Alignment alignment = Pango.Alignment.LEFT) { + base(source); + + pixbuf_dim = initial_pixbuf_dim; + this.title = new CheckerboardItemText(title, alignment, marked_up); + // on the checkboard page we display the comment in + // one line, i.e., replacing all newlines with spaces. + // that means that the display will contain "..." if the comment + // is too long. + // warning: changes here have to be done in set_comment, too! + if (comment != null) + this.comment = new CheckerboardItemText(comment.replace("\n", " "), alignment, + marked_up); + + // Don't calculate size here, wait for the item to be assigned to a ViewCollection + // (notify_membership_changed) and calculate when the collection's property settings + // are known + } + + public bool has_tags { get; private set; } + + public override string get_name() { + return (title != null) ? title.get_text() : base.get_name(); + } + + public string get_title() { + return (title != null) ? title.get_text() : ""; + } + + public string get_comment() { + return (comment != null) ? comment.get_text() : ""; + } + + public void set_title(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (title != null && title.is_set_to(text, marked_up, alignment)) + return; + + title = new CheckerboardItemText(text, alignment, marked_up); + + if (title_visible) { + recalc_size("set_title"); + notify_view_altered(); + } + } + + public void translate_coordinates(ref int x, ref int y) { + x -= allocation.x + FRAME_WIDTH; + y -= allocation.y + FRAME_WIDTH; + } + + public void clear_title() { + if (title == null) + return; + + title = null; + + if (title_visible) { + recalc_size("clear_title"); + notify_view_altered(); + } + } + + private void set_title_visible(bool visible) { + if (title_visible == visible) + return; + + title_visible = visible; + + recalc_size("set_title_visible"); + notify_view_altered(); + } + + public void set_comment(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (comment != null && comment.is_set_to(text, marked_up, alignment)) + return; + + comment = new CheckerboardItemText(text.replace("\n", " "), alignment, marked_up); + + if (comment_visible) { + recalc_size("set_comment"); + notify_view_altered(); + } + } + + public void clear_comment() { + if (comment == null) + return; + + comment = null; + + if (comment_visible) { + recalc_size("clear_comment"); + notify_view_altered(); + } + } + + private void set_comment_visible(bool visible) { + if (comment_visible == visible) + return; + + comment_visible = visible; + + recalc_size("set_comment_visible"); + notify_view_altered(); + } + + public void set_tags(Gee.Collection<Tag>? tags, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + has_tags = (tags != null && tags.size > 0); + tag_alignment = alignment; + string text; + if (has_tags) { + this.tags = tags; + user_visible_tag_list = Tag.make_user_visible_tag_list(tags); + text = Tag.make_tag_markup_string(user_visible_tag_list); + } else { + text = "<small>.</small>"; + } + + if (subtitle != null && subtitle.is_set_to(text, true, alignment)) + return; + subtitle = new CheckerboardItemText(text, alignment, true); + + if (subtitle_visible) { + recalc_size("set_subtitle"); + notify_view_altered(); + } + } + + public void clear_tags() { + clear_subtitle(); + has_tags = false; + user_visible_tag_list = null; + } + + public void highlight_user_visible_tag(int index) + requires (user_visible_tag_list != null) { + string text = Tag.make_tag_markup_string(user_visible_tag_list, index); + subtitle = new CheckerboardItemText(text, tag_alignment, true); + + if (subtitle_visible) + notify_view_altered(); + } + + public Tag get_user_visible_tag(int index) + requires (index >= 0 && index < user_visible_tag_list.size) { + return user_visible_tag_list.get(index); + } + + public Pango.Layout? get_tag_list_layout() { + return has_tags ? subtitle.get_pango_layout() : null; + } + + public Gdk.Rectangle get_subtitle_allocation() { + return subtitle.allocation; + } + + public string get_subtitle() { + return (subtitle != null) ? subtitle.get_text() : ""; + } + + public void set_subtitle(string text, bool marked_up = false, + Pango.Alignment alignment = Pango.Alignment.LEFT) { + if (subtitle != null && subtitle.is_set_to(text, marked_up, alignment)) + return; + + subtitle = new CheckerboardItemText(text, alignment, marked_up); + + if (subtitle_visible) { + recalc_size("set_subtitle"); + notify_view_altered(); + } + } + + public void clear_subtitle() { + if (subtitle == null) + return; + + subtitle = null; + + if (subtitle_visible) { + recalc_size("clear_subtitle"); + notify_view_altered(); + } + } + + private void set_subtitle_visible(bool visible) { + if (subtitle_visible == visible) + return; + + subtitle_visible = visible; + + recalc_size("set_subtitle_visible"); + notify_view_altered(); + } + + public void set_is_cursor(bool is_cursor) { + this.is_cursor = is_cursor; + } + + public bool get_is_cursor() { + return is_cursor; + } + + public virtual void handle_mouse_motion(int x, int y, int height, int width) { + + } + + public virtual void handle_mouse_leave() { + unbrighten(); + } + + public virtual void handle_mouse_enter() { + brighten(); + } + + protected override void notify_membership_changed(DataCollection? collection) { + bool title_visible = (bool) get_collection_property(PROP_SHOW_TITLES, true); + bool comment_visible = (bool) get_collection_property(PROP_SHOW_COMMENTS, true); + bool subtitle_visible = (bool) get_collection_property(PROP_SHOW_SUBTITLES, false); + + bool altered = false; + if (this.title_visible != title_visible) { + this.title_visible = title_visible; + altered = true; + } + + if (this.comment_visible != comment_visible) { + this.comment_visible = comment_visible; + altered = true; + } + + if (this.subtitle_visible != subtitle_visible) { + this.subtitle_visible = subtitle_visible; + altered = true; + } + + if (altered || !requisition.has_area()) { + recalc_size("notify_membership_changed"); + notify_view_altered(); + } + + base.notify_membership_changed(collection); + } + + protected override void notify_collection_property_set(string name, Value? old, Value val) { + switch (name) { + case PROP_SHOW_TITLES: + set_title_visible((bool) val); + break; + + case PROP_SHOW_COMMENTS: + set_comment_visible((bool) val); + break; + + case PROP_SHOW_SUBTITLES: + set_subtitle_visible((bool) val); + break; + } + + base.notify_collection_property_set(name, old, val); + } + + // The alignment point is the coordinate on the y-axis (relative to the top of the + // CheckerboardItem) which this item should be aligned to. This allows for + // bottom-alignment along the bottom edge of the thumbnail. + public int get_alignment_point() { + return FRAME_WIDTH + BORDER_WIDTH + pixbuf_dim.height; + } + + public virtual void exposed() { + exposure = true; + } + + public virtual void unexposed() { + exposure = false; + + if (title != null) + title.clear_pango_layout(); + + if (comment != null) + comment.clear_pango_layout(); + + if (subtitle != null) + subtitle.clear_pango_layout(); + } + + public virtual bool is_exposed() { + return exposure; + } + + public bool has_image() { + return pixbuf != null; + } + + public Gdk.Pixbuf? get_image() { + return pixbuf; + } + + public void set_image(Gdk.Pixbuf pixbuf) { + this.pixbuf = pixbuf; + display_pixbuf = pixbuf; + pixbuf_dim = Dimensions.for_pixbuf(pixbuf); + + recalc_size("set_image"); + notify_view_altered(); + } + + public void clear_image(Dimensions dim) { + bool had_image = pixbuf != null; + + pixbuf = null; + display_pixbuf = null; + pixbuf_dim = dim; + + recalc_size("clear_image"); + + if (had_image) + notify_view_altered(); + } + + public static int get_max_width(int scale) { + // width is frame width (two sides) + frame padding (two sides) + width of pixbuf (text + // never wider) + return (FRAME_WIDTH * 2) + scale; + } + + private void recalc_size(string reason) { + Dimensions old_requisition = requisition; + + // only add in the text heights if they're displayed + int title_height = (title != null && title_visible) + ? title.get_height() + LABEL_PADDING : 0; + int comment_height = (comment != null && comment_visible) + ? comment.get_height() + LABEL_PADDING : 0; + int subtitle_height = (subtitle != null && subtitle_visible) + ? subtitle.get_height() + LABEL_PADDING : 0; + + // width is frame width (two sides) + frame padding (two sides) + width of pixbuf + // (text never wider) + requisition.width = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + pixbuf_dim.width; + + // height is frame width (two sides) + frame padding (two sides) + height of pixbuf + // + height of text + label padding (between pixbuf and text) + requisition.height = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + + pixbuf_dim.height + title_height + comment_height + subtitle_height; + +#if TRACE_REFLOW_ITEMS + debug("recalc_size %s: %s title_height=%d comment_height=%d subtitle_height=%d requisition=%s", + get_source().get_name(), reason, title_height, comment_height, subtitle_height, + requisition.to_string()); +#endif + + if (!requisition.approx_equals(old_requisition)) { +#if TRACE_REFLOW_ITEMS + debug("recalc_size %s: %s notifying geometry altered", get_source().get_name(), reason); +#endif + notify_geometry_altered(); + } + } + + protected static Dimensions get_border_dimensions(Dimensions object_dim, int border_width) { + Dimensions dimensions = Dimensions(); + dimensions.width = object_dim.width + (border_width * 2); + dimensions.height = object_dim.height + (border_width * 2); + return dimensions; + } + + protected static Gdk.Point get_border_origin(Gdk.Point object_origin, int border_width) { + Gdk.Point origin = Gdk.Point(); + origin.x = object_origin.x - border_width; + origin.y = object_origin.y - border_width; + return origin; + } + + protected virtual void paint_shadow(Cairo.Context ctx, Dimensions dimensions, Gdk.Point origin, + int radius, float initial_alpha) { + double rgb_all = 0.0; + + // top right corner + paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + radius, rgb_all, radius, + initial_alpha, -0.5 * Math.PI, 0); + // bottom right corner + paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + dimensions.height, rgb_all, + radius, initial_alpha, 0, 0.5 * Math.PI); + // bottom left corner + paint_shadow_in_corner(ctx, origin.x + radius, origin.y + dimensions.height, rgb_all, radius, + initial_alpha, 0.5 * Math.PI, Math.PI); + + // left right + Cairo.Pattern lr = new Cairo.Pattern.linear(0, origin.y + dimensions.height, + 0, origin.y + dimensions.height + radius); + lr.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + lr.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); + ctx.set_source(lr); + ctx.rectangle(origin.x + radius, origin.y + dimensions.height, dimensions.width - radius, radius); + ctx.fill(); + + // top down + Cairo.Pattern td = new Cairo.Pattern.linear(origin.x + dimensions.width, + 0, origin.x + dimensions.width + radius, 0); + td.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + td.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); + ctx.set_source(td); + ctx.rectangle(origin.x + dimensions.width, origin.y + radius, + radius, dimensions.height - radius); + ctx.fill(); + } + + protected void paint_shadow_in_corner(Cairo.Context ctx, int x, int y, + double rgb_all, float radius, float initial_alpha, double arc1, double arc2) { + Cairo.Pattern p = new Cairo.Pattern.radial(x, y, 0, x, y, radius); + p.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); + p.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0); + ctx.set_source(p); + ctx.move_to(x, y); + ctx.arc(x, y, radius, arc1, arc2); + ctx.close_path(); + ctx.fill(); + } + + protected virtual void paint_border(Cairo.Context ctx, Dimensions object_dimensions, + Gdk.Point object_origin, int border_width) { + if (border_width == 1) { + ctx.rectangle(object_origin.x - border_width, object_origin.y - border_width, + object_dimensions.width + (border_width * 2), + object_dimensions.height + (border_width * 2)); + ctx.fill(); + } else { + Dimensions dimensions = get_border_dimensions(object_dimensions, border_width); + Gdk.Point origin = get_border_origin(object_origin, border_width); + + // amount of rounding needed on corners varies by size of object + double scale = int.max(object_dimensions.width, object_dimensions.height); + draw_rounded_corners_filled(ctx, dimensions, origin, 0.25 * scale); + } + } + + protected virtual void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf, Gdk.Point origin) { + paint_pixmap_with_background(ctx, pixbuf, origin.x, origin.y); + } + + private int get_selection_border_width(int scale) { + return ((scale <= ((Thumbnail.MIN_SCALE + Thumbnail.MAX_SCALE) / 3)) ? 5 : 4) + + BORDER_WIDTH; + } + + protected virtual Gdk.Pixbuf? get_top_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_top_right_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) { + return null; + } + + protected virtual Gdk.Pixbuf? get_bottom_right_trinket(int scale) { + return null; + } + + public void paint(Gtk.StyleContext style_context, Cairo.Context ctx, Gdk.RGBA bg_color, Gdk.RGBA selected_color, + Gdk.RGBA? border_color, Gdk.RGBA? focus_color) { + ctx.save(); + ctx.translate(allocation.x + FRAME_WIDTH, + allocation.y + FRAME_WIDTH); + // calc the top-left point of the pixbuf + Gdk.Point pixbuf_origin = Gdk.Point(); + pixbuf_origin.x = BORDER_WIDTH; + pixbuf_origin.y = BORDER_WIDTH; + + ctx.set_line_width(FRAME_WIDTH); + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + selected_color.alpha); + + // draw shadow + if (border_color != null) { + ctx.save(); + Dimensions shadow_dim = Dimensions(); + shadow_dim.width = pixbuf_dim.width + BORDER_WIDTH; + shadow_dim.height = pixbuf_dim.height + BORDER_WIDTH; + paint_shadow(ctx, shadow_dim, pixbuf_origin, SHADOW_RADIUS, SHADOW_INITIAL_ALPHA); + ctx.restore(); + } + + // draw a border for the cursor with the selection width and normal border color + if (is_cursor) { + ctx.save(); + ctx.set_source_rgba(focus_color.red, focus_color.green, focus_color.blue, + focus_color.alpha); + paint_border(ctx, pixbuf_dim, pixbuf_origin, + get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); + ctx.restore(); + } + + // draw selection border + if (is_selected()) { + // border thickness depends on the size of the thumbnail + ctx.save(); + paint_border(ctx, pixbuf_dim, pixbuf_origin, + get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); + ctx.restore(); + } + + if (display_pixbuf != null) { + ctx.save(); + ctx.set_source_rgba(bg_color.red, bg_color.green, bg_color.blue, bg_color.alpha); + paint_image(ctx, display_pixbuf, pixbuf_origin); + ctx.restore(); + } + + // title and subtitles are LABEL_PADDING below bottom of pixbuf + int text_y = pixbuf_dim.height + FRAME_WIDTH + LABEL_PADDING; + if (title != null && title_visible) { + // get the layout sized so its width is no more than the pixbuf's + // resize the text width to be no more than the pixbuf's + title.allocation.x = 0; + title.allocation.y = text_y; + title.allocation.width = pixbuf_dim.width; + title.allocation.height = title.get_height(); + style_context.render_layout(ctx, title.allocation.x, title.allocation.y, + title.get_pango_layout(pixbuf_dim.width)); + + text_y += title.get_height() + LABEL_PADDING; + } + + if (comment != null && comment_visible) { + comment.allocation.x = 0; + comment.allocation.y = text_y; + comment.allocation.width = pixbuf_dim.width; + comment.allocation.height = comment.get_height(); + style_context.render_layout(ctx, comment.allocation.x, comment.allocation.y, + comment.get_pango_layout(pixbuf_dim.width)); + + text_y += comment.get_height() + LABEL_PADDING; + } + + if (subtitle != null && subtitle_visible) { + subtitle.allocation.x = 0; + subtitle.allocation.y = text_y; + subtitle.allocation.width = pixbuf_dim.width; + subtitle.allocation.height = subtitle.get_height(); + + style_context.render_layout(ctx, subtitle.allocation.x, subtitle.allocation.y, + subtitle.get_pango_layout(pixbuf_dim.width)); + + // increment text_y if more text lines follow + } + + ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, + selected_color.alpha); + + // draw trinkets last + Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); + int y = pixbuf_origin.y + pixbuf_dim.height - trinket.get_height() - + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_top_left_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); + int y = pixbuf_origin.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_top_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - + get_horizontal_trinket_offset() - TRINKET_PADDING; + int y = pixbuf_origin.y + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + + trinket = get_bottom_right_trinket(TRINKET_SCALE); + if (trinket != null) { + int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - + get_horizontal_trinket_offset() - TRINKET_PADDING; + int y = pixbuf_origin.y + pixbuf_dim.height - trinket.height - + TRINKET_PADDING; + Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); + ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); + ctx.fill(); + } + ctx.restore(); + } + + protected void set_horizontal_trinket_offset(int horizontal_trinket_offset) { + assert(horizontal_trinket_offset >= 0); + this.horizontal_trinket_offset = horizontal_trinket_offset; + } + + protected int get_horizontal_trinket_offset() { + return horizontal_trinket_offset; + } + + public void set_grid_coordinates(int col, int row) { + this.col = col; + this.row = row; + } + + public int get_column() { + return col; + } + + public int get_row() { + return row; + } + + public void brighten() { + // "should" implies "can" and "didn't already" + if (brightened != null || pixbuf == null) + return; + + // create a new lightened pixbuf to display + brightened = pixbuf.copy(); + shift_colors(brightened, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, 0); + + display_pixbuf = brightened; + + notify_view_altered(); + } + + + public void unbrighten() { + // "should", "can", "didn't already" + if (brightened == null || pixbuf == null) + return; + + brightened = null; + + // return to the normal image + display_pixbuf = pixbuf; + + notify_view_altered(); + } + + public override void visibility_changed(bool visible) { + // if going from visible to hidden, unbrighten + if (!visible) + unbrighten(); + + base.visibility_changed(visible); + } + + private bool query_tooltip_on_text(CheckerboardItemText text, Gtk.Tooltip tooltip) { + if (!text.get_pango_layout().is_ellipsized()) + return false; + + if (text.is_marked_up()) + tooltip.set_markup(text.get_text()); + else + tooltip.set_text(text.get_text()); + + return true; + } + + public bool query_tooltip(int x, int y, Gtk.Tooltip tooltip) { + if (title != null && title_visible && coord_in_rectangle(x, y, title.allocation)) + return query_tooltip_on_text(title, tooltip); + + if (comment != null && comment_visible && coord_in_rectangle(x, y, comment.allocation)) + return query_tooltip_on_text(comment, tooltip); + + if (subtitle != null && subtitle_visible && coord_in_rectangle(x, y, subtitle.allocation)) + return query_tooltip_on_text(subtitle, tooltip); + + return false; + } +} + + diff --git a/src/CheckerboardItemText.vala b/src/CheckerboardItemText.vala new file mode 100644 index 0000000..8924938 --- /dev/null +++ b/src/CheckerboardItemText.vala @@ -0,0 +1,98 @@ +/* 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. + */ + +private class CheckerboardItemText { + private static int one_line_height = 0; + + private string text; + private bool marked_up; + private Pango.Alignment alignment; + private Pango.Layout layout = null; + private bool single_line = true; + private int height = 0; + + public Gdk.Rectangle allocation = Gdk.Rectangle(); + + public CheckerboardItemText(string text, Pango.Alignment alignment = Pango.Alignment.LEFT, + bool marked_up = false) { + this.text = text; + this.marked_up = marked_up; + this.alignment = alignment; + + single_line = is_single_line(); + } + + private bool is_single_line() { + return !String.contains_char(text, '\n'); + } + + public bool is_marked_up() { + return marked_up; + } + + public bool is_set_to(string text, bool marked_up, Pango.Alignment alignment) { + return (this.marked_up == marked_up && this.alignment == alignment && this.text == text); + } + + public string get_text() { + return text; + } + + public int get_height() { + if (height == 0) + update_height(); + + return height; + } + + public Pango.Layout get_pango_layout(int max_width = 0) { + if (layout == null) + create_pango(); + + if (max_width > 0) + layout.set_width(max_width * Pango.SCALE); + + return layout; + } + + public void clear_pango_layout() { + layout = null; + } + + private void update_height() { + if (one_line_height != 0 && single_line) + height = one_line_height; + else + create_pango(); + } + + private void create_pango() { + // create layout for this string and ellipsize so it never extends past its laid-down width + layout = AppWindow.get_instance().create_pango_layout(null); + if (!marked_up) + layout.set_text(text, -1); + else + layout.set_markup(text, -1); + + layout.set_ellipsize(Pango.EllipsizeMode.END); + layout.set_alignment(alignment); + + // getting pixel size is expensive, and we only need the height, so use cached values + // whenever possible + if (one_line_height != 0 && single_line) { + height = one_line_height; + } else { + int width; + layout.get_pixel_size(out width, out height); + + // cache first one-line height discovered + if (one_line_height == 0 && single_line) + one_line_height = height; + } + } +} + + diff --git a/src/CheckerboardLayout.vala b/src/CheckerboardLayout.vala index 70e3b5c..85232f3 100644 --- a/src/CheckerboardLayout.vala +++ b/src/CheckerboardLayout.vala @@ -4,824 +4,6 @@ * See the COPYING file in this distribution. */ -private class CheckerboardItemText { - private static int one_line_height = 0; - - private string text; - private bool marked_up; - private Pango.Alignment alignment; - private Pango.Layout layout = null; - private bool single_line = true; - private int height = 0; - - public Gdk.Rectangle allocation = Gdk.Rectangle(); - - public CheckerboardItemText(string text, Pango.Alignment alignment = Pango.Alignment.LEFT, - bool marked_up = false) { - this.text = text; - this.marked_up = marked_up; - this.alignment = alignment; - - single_line = is_single_line(); - } - - private bool is_single_line() { - return !String.contains_char(text, '\n'); - } - - public bool is_marked_up() { - return marked_up; - } - - public bool is_set_to(string text, bool marked_up, Pango.Alignment alignment) { - return (this.marked_up == marked_up && this.alignment == alignment && this.text == text); - } - - public string get_text() { - return text; - } - - public int get_height() { - if (height == 0) - update_height(); - - return height; - } - - public Pango.Layout get_pango_layout(int max_width = 0) { - if (layout == null) - create_pango(); - - if (max_width > 0) - layout.set_width(max_width * Pango.SCALE); - - return layout; - } - - public void clear_pango_layout() { - layout = null; - } - - private void update_height() { - if (one_line_height != 0 && single_line) - height = one_line_height; - else - create_pango(); - } - - private void create_pango() { - // create layout for this string and ellipsize so it never extends past its laid-down width - layout = AppWindow.get_instance().create_pango_layout(null); - if (!marked_up) - layout.set_text(text, -1); - else - layout.set_markup(text, -1); - - layout.set_ellipsize(Pango.EllipsizeMode.END); - layout.set_alignment(alignment); - - // getting pixel size is expensive, and we only need the height, so use cached values - // whenever possible - if (one_line_height != 0 && single_line) { - height = one_line_height; - } else { - int width; - layout.get_pixel_size(out width, out height); - - // cache first one-line height discovered - if (one_line_height == 0 && single_line) - one_line_height = height; - } - } -} - -public abstract class CheckerboardItem : ThumbnailView { - // Collection properties CheckerboardItem understands - // SHOW_TITLES (bool) - public const string PROP_SHOW_TITLES = "show-titles"; - // SHOW_COMMENTS (bool) - public const string PROP_SHOW_COMMENTS = "show-comments"; - // SHOW_SUBTITLES (bool) - public const string PROP_SHOW_SUBTITLES = "show-subtitles"; - - public const int FRAME_WIDTH = 8; - public const int LABEL_PADDING = 4; - public const int BORDER_WIDTH = 1; - - public const int SHADOW_RADIUS = 4; - public const float SHADOW_INITIAL_ALPHA = 0.5f; - - public const int TRINKET_SCALE = 12; - public const int TRINKET_PADDING = 1; - - public const int BRIGHTEN_SHIFT = 0x18; - - public Dimensions requisition = Dimensions(); - public Gdk.Rectangle allocation = Gdk.Rectangle(); - - private bool exposure = false; - private CheckerboardItemText? title = null; - private bool title_visible = true; - private CheckerboardItemText? comment = null; - private bool comment_visible = true; - private CheckerboardItemText? subtitle = null; - private bool subtitle_visible = false; - private bool is_cursor = false; - private Pango.Alignment tag_alignment = Pango.Alignment.LEFT; - private Gee.List<Tag>? user_visible_tag_list = null; - private Gee.Collection<Tag> tags; - private Gdk.Pixbuf pixbuf = null; - private Gdk.Pixbuf display_pixbuf = null; - private Gdk.Pixbuf brightened = null; - private Dimensions pixbuf_dim = Dimensions(); - private int col = -1; - private int row = -1; - private int horizontal_trinket_offset = 0; - - protected CheckerboardItem(ThumbnailSource source, Dimensions initial_pixbuf_dim, string title, string? comment, - bool marked_up = false, Pango.Alignment alignment = Pango.Alignment.LEFT) { - base(source); - - pixbuf_dim = initial_pixbuf_dim; - this.title = new CheckerboardItemText(title, alignment, marked_up); - // on the checkboard page we display the comment in - // one line, i.e., replacing all newlines with spaces. - // that means that the display will contain "..." if the comment - // is too long. - // warning: changes here have to be done in set_comment, too! - if (comment != null) - this.comment = new CheckerboardItemText(comment.replace("\n", " "), alignment, - marked_up); - - // Don't calculate size here, wait for the item to be assigned to a ViewCollection - // (notify_membership_changed) and calculate when the collection's property settings - // are known - } - - public bool has_tags { get; private set; } - - public override string get_name() { - return (title != null) ? title.get_text() : base.get_name(); - } - - public string get_title() { - return (title != null) ? title.get_text() : ""; - } - - public string get_comment() { - return (comment != null) ? comment.get_text() : ""; - } - - public void set_title(string text, bool marked_up = false, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - if (title != null && title.is_set_to(text, marked_up, alignment)) - return; - - title = new CheckerboardItemText(text, alignment, marked_up); - - if (title_visible) { - recalc_size("set_title"); - notify_view_altered(); - } - } - - public void translate_coordinates(ref int x, ref int y) { - x -= allocation.x + FRAME_WIDTH; - y -= allocation.y + FRAME_WIDTH; - } - - public void clear_title() { - if (title == null) - return; - - title = null; - - if (title_visible) { - recalc_size("clear_title"); - notify_view_altered(); - } - } - - private void set_title_visible(bool visible) { - if (title_visible == visible) - return; - - title_visible = visible; - - recalc_size("set_title_visible"); - notify_view_altered(); - } - - public void set_comment(string text, bool marked_up = false, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - if (comment != null && comment.is_set_to(text, marked_up, alignment)) - return; - - comment = new CheckerboardItemText(text.replace("\n", " "), alignment, marked_up); - - if (comment_visible) { - recalc_size("set_comment"); - notify_view_altered(); - } - } - - public void clear_comment() { - if (comment == null) - return; - - comment = null; - - if (comment_visible) { - recalc_size("clear_comment"); - notify_view_altered(); - } - } - - private void set_comment_visible(bool visible) { - if (comment_visible == visible) - return; - - comment_visible = visible; - - recalc_size("set_comment_visible"); - notify_view_altered(); - } - - public void set_tags(Gee.Collection<Tag>? tags, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - has_tags = (tags != null && tags.size > 0); - tag_alignment = alignment; - string text; - if (has_tags) { - this.tags = tags; - user_visible_tag_list = Tag.make_user_visible_tag_list(tags); - text = Tag.make_tag_markup_string(user_visible_tag_list); - } else { - text = "<small>.</small>"; - } - - if (subtitle != null && subtitle.is_set_to(text, true, alignment)) - return; - subtitle = new CheckerboardItemText(text, alignment, true); - - if (subtitle_visible) { - recalc_size("set_subtitle"); - notify_view_altered(); - } - } - - public void clear_tags() { - clear_subtitle(); - has_tags = false; - user_visible_tag_list = null; - } - - public void highlight_user_visible_tag(int index) - requires (user_visible_tag_list != null) { - string text = Tag.make_tag_markup_string(user_visible_tag_list, index); - subtitle = new CheckerboardItemText(text, tag_alignment, true); - - if (subtitle_visible) - notify_view_altered(); - } - - public Tag get_user_visible_tag(int index) - requires (index >= 0 && index < user_visible_tag_list.size) { - return user_visible_tag_list.get(index); - } - - public Pango.Layout? get_tag_list_layout() { - return has_tags ? subtitle.get_pango_layout() : null; - } - - public Gdk.Rectangle get_subtitle_allocation() { - return subtitle.allocation; - } - - public string get_subtitle() { - return (subtitle != null) ? subtitle.get_text() : ""; - } - - public void set_subtitle(string text, bool marked_up = false, - Pango.Alignment alignment = Pango.Alignment.LEFT) { - if (subtitle != null && subtitle.is_set_to(text, marked_up, alignment)) - return; - - subtitle = new CheckerboardItemText(text, alignment, marked_up); - - if (subtitle_visible) { - recalc_size("set_subtitle"); - notify_view_altered(); - } - } - - public void clear_subtitle() { - if (subtitle == null) - return; - - subtitle = null; - - if (subtitle_visible) { - recalc_size("clear_subtitle"); - notify_view_altered(); - } - } - - private void set_subtitle_visible(bool visible) { - if (subtitle_visible == visible) - return; - - subtitle_visible = visible; - - recalc_size("set_subtitle_visible"); - notify_view_altered(); - } - - public void set_is_cursor(bool is_cursor) { - this.is_cursor = is_cursor; - } - - public bool get_is_cursor() { - return is_cursor; - } - - public virtual void handle_mouse_motion(int x, int y, int height, int width) { - - } - - public virtual void handle_mouse_leave() { - unbrighten(); - } - - public virtual void handle_mouse_enter() { - brighten(); - } - - protected override void notify_membership_changed(DataCollection? collection) { - bool title_visible = (bool) get_collection_property(PROP_SHOW_TITLES, true); - bool comment_visible = (bool) get_collection_property(PROP_SHOW_COMMENTS, true); - bool subtitle_visible = (bool) get_collection_property(PROP_SHOW_SUBTITLES, false); - - bool altered = false; - if (this.title_visible != title_visible) { - this.title_visible = title_visible; - altered = true; - } - - if (this.comment_visible != comment_visible) { - this.comment_visible = comment_visible; - altered = true; - } - - if (this.subtitle_visible != subtitle_visible) { - this.subtitle_visible = subtitle_visible; - altered = true; - } - - if (altered || !requisition.has_area()) { - recalc_size("notify_membership_changed"); - notify_view_altered(); - } - - base.notify_membership_changed(collection); - } - - protected override void notify_collection_property_set(string name, Value? old, Value val) { - switch (name) { - case PROP_SHOW_TITLES: - set_title_visible((bool) val); - break; - - case PROP_SHOW_COMMENTS: - set_comment_visible((bool) val); - break; - - case PROP_SHOW_SUBTITLES: - set_subtitle_visible((bool) val); - break; - } - - base.notify_collection_property_set(name, old, val); - } - - // The alignment point is the coordinate on the y-axis (relative to the top of the - // CheckerboardItem) which this item should be aligned to. This allows for - // bottom-alignment along the bottom edge of the thumbnail. - public int get_alignment_point() { - return FRAME_WIDTH + BORDER_WIDTH + pixbuf_dim.height; - } - - public virtual void exposed() { - exposure = true; - } - - public virtual void unexposed() { - exposure = false; - - if (title != null) - title.clear_pango_layout(); - - if (comment != null) - comment.clear_pango_layout(); - - if (subtitle != null) - subtitle.clear_pango_layout(); - } - - public virtual bool is_exposed() { - return exposure; - } - - public bool has_image() { - return pixbuf != null; - } - - public Gdk.Pixbuf? get_image() { - return pixbuf; - } - - public void set_image(Gdk.Pixbuf pixbuf) { - this.pixbuf = pixbuf; - display_pixbuf = pixbuf; - pixbuf_dim = Dimensions.for_pixbuf(pixbuf); - - recalc_size("set_image"); - notify_view_altered(); - } - - public void clear_image(Dimensions dim) { - bool had_image = pixbuf != null; - - pixbuf = null; - display_pixbuf = null; - pixbuf_dim = dim; - - recalc_size("clear_image"); - - if (had_image) - notify_view_altered(); - } - - public static int get_max_width(int scale) { - // width is frame width (two sides) + frame padding (two sides) + width of pixbuf (text - // never wider) - return (FRAME_WIDTH * 2) + scale; - } - - private void recalc_size(string reason) { - Dimensions old_requisition = requisition; - - // only add in the text heights if they're displayed - int title_height = (title != null && title_visible) - ? title.get_height() + LABEL_PADDING : 0; - int comment_height = (comment != null && comment_visible) - ? comment.get_height() + LABEL_PADDING : 0; - int subtitle_height = (subtitle != null && subtitle_visible) - ? subtitle.get_height() + LABEL_PADDING : 0; - - // width is frame width (two sides) + frame padding (two sides) + width of pixbuf - // (text never wider) - requisition.width = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) + pixbuf_dim.width; - - // height is frame width (two sides) + frame padding (two sides) + height of pixbuf - // + height of text + label padding (between pixbuf and text) - requisition.height = (FRAME_WIDTH * 2) + (BORDER_WIDTH * 2) - + pixbuf_dim.height + title_height + comment_height + subtitle_height; - -#if TRACE_REFLOW_ITEMS - debug("recalc_size %s: %s title_height=%d comment_height=%d subtitle_height=%d requisition=%s", - get_source().get_name(), reason, title_height, comment_height, subtitle_height, - requisition.to_string()); -#endif - - if (!requisition.approx_equals(old_requisition)) { -#if TRACE_REFLOW_ITEMS - debug("recalc_size %s: %s notifying geometry altered", get_source().get_name(), reason); -#endif - notify_geometry_altered(); - } - } - - protected static Dimensions get_border_dimensions(Dimensions object_dim, int border_width) { - Dimensions dimensions = Dimensions(); - dimensions.width = object_dim.width + (border_width * 2); - dimensions.height = object_dim.height + (border_width * 2); - return dimensions; - } - - protected static Gdk.Point get_border_origin(Gdk.Point object_origin, int border_width) { - Gdk.Point origin = Gdk.Point(); - origin.x = object_origin.x - border_width; - origin.y = object_origin.y - border_width; - return origin; - } - - protected virtual void paint_shadow(Cairo.Context ctx, Dimensions dimensions, Gdk.Point origin, - int radius, float initial_alpha) { - double rgb_all = 0.0; - - // top right corner - paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + radius, rgb_all, radius, - initial_alpha, -0.5 * Math.PI, 0); - // bottom right corner - paint_shadow_in_corner(ctx, origin.x + dimensions.width, origin.y + dimensions.height, rgb_all, - radius, initial_alpha, 0, 0.5 * Math.PI); - // bottom left corner - paint_shadow_in_corner(ctx, origin.x + radius, origin.y + dimensions.height, rgb_all, radius, - initial_alpha, 0.5 * Math.PI, Math.PI); - - // left right - Cairo.Pattern lr = new Cairo.Pattern.linear(0, origin.y + dimensions.height, - 0, origin.y + dimensions.height + radius); - lr.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); - lr.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); - ctx.set_source(lr); - ctx.rectangle(origin.x + radius, origin.y + dimensions.height, dimensions.width - radius, radius); - ctx.fill(); - - // top down - Cairo.Pattern td = new Cairo.Pattern.linear(origin.x + dimensions.width, - 0, origin.x + dimensions.width + radius, 0); - td.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); - td.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0.0); - ctx.set_source(td); - ctx.rectangle(origin.x + dimensions.width, origin.y + radius, - radius, dimensions.height - radius); - ctx.fill(); - } - - protected void paint_shadow_in_corner(Cairo.Context ctx, int x, int y, - double rgb_all, float radius, float initial_alpha, double arc1, double arc2) { - Cairo.Pattern p = new Cairo.Pattern.radial(x, y, 0, x, y, radius); - p.add_color_stop_rgba(0.0, rgb_all, rgb_all, rgb_all, initial_alpha); - p.add_color_stop_rgba(1.0, rgb_all, rgb_all, rgb_all, 0); - ctx.set_source(p); - ctx.move_to(x, y); - ctx.arc(x, y, radius, arc1, arc2); - ctx.close_path(); - ctx.fill(); - } - - protected virtual void paint_border(Cairo.Context ctx, Dimensions object_dimensions, - Gdk.Point object_origin, int border_width) { - if (border_width == 1) { - ctx.rectangle(object_origin.x - border_width, object_origin.y - border_width, - object_dimensions.width + (border_width * 2), - object_dimensions.height + (border_width * 2)); - ctx.fill(); - } else { - Dimensions dimensions = get_border_dimensions(object_dimensions, border_width); - Gdk.Point origin = get_border_origin(object_origin, border_width); - - // amount of rounding needed on corners varies by size of object - double scale = int.max(object_dimensions.width, object_dimensions.height); - draw_rounded_corners_filled(ctx, dimensions, origin, 0.25 * scale); - } - } - - protected virtual void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf, Gdk.Point origin) { - paint_pixmap_with_background(ctx, pixbuf, origin.x, origin.y); - } - - private int get_selection_border_width(int scale) { - return ((scale <= ((Thumbnail.MIN_SCALE + Thumbnail.MAX_SCALE) / 3)) ? 5 : 4) - + BORDER_WIDTH; - } - - protected virtual Gdk.Pixbuf? get_top_left_trinket(int scale) { - return null; - } - - protected virtual Gdk.Pixbuf? get_top_right_trinket(int scale) { - return null; - } - - protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) { - return null; - } - - protected virtual Gdk.Pixbuf? get_bottom_right_trinket(int scale) { - return null; - } - - public void paint(Gtk.StyleContext style_context, Cairo.Context ctx, Gdk.RGBA bg_color, Gdk.RGBA selected_color, - Gdk.RGBA? border_color, Gdk.RGBA? focus_color) { - ctx.save(); - ctx.translate(allocation.x + FRAME_WIDTH, - allocation.y + FRAME_WIDTH); - // calc the top-left point of the pixbuf - Gdk.Point pixbuf_origin = Gdk.Point(); - pixbuf_origin.x = BORDER_WIDTH; - pixbuf_origin.y = BORDER_WIDTH; - - ctx.set_line_width(FRAME_WIDTH); - ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, - selected_color.alpha); - - // draw shadow - if (border_color != null) { - ctx.save(); - Dimensions shadow_dim = Dimensions(); - shadow_dim.width = pixbuf_dim.width + BORDER_WIDTH; - shadow_dim.height = pixbuf_dim.height + BORDER_WIDTH; - paint_shadow(ctx, shadow_dim, pixbuf_origin, SHADOW_RADIUS, SHADOW_INITIAL_ALPHA); - ctx.restore(); - } - - // draw a border for the cursor with the selection width and normal border color - if (is_cursor) { - ctx.save(); - ctx.set_source_rgba(focus_color.red, focus_color.green, focus_color.blue, - focus_color.alpha); - paint_border(ctx, pixbuf_dim, pixbuf_origin, - get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); - ctx.restore(); - } - - // draw selection border - if (is_selected()) { - // border thickness depends on the size of the thumbnail - ctx.save(); - paint_border(ctx, pixbuf_dim, pixbuf_origin, - get_selection_border_width(int.max(pixbuf_dim.width, pixbuf_dim.height))); - ctx.restore(); - } - - if (display_pixbuf != null) { - ctx.save(); - ctx.set_source_rgba(bg_color.red, bg_color.green, bg_color.blue, bg_color.alpha); - paint_image(ctx, display_pixbuf, pixbuf_origin); - ctx.restore(); - } - - // title and subtitles are LABEL_PADDING below bottom of pixbuf - int text_y = pixbuf_dim.height + FRAME_WIDTH + LABEL_PADDING; - if (title != null && title_visible) { - // get the layout sized so its width is no more than the pixbuf's - // resize the text width to be no more than the pixbuf's - title.allocation.x = 0; - title.allocation.y = text_y; - title.allocation.width = pixbuf_dim.width; - title.allocation.height = title.get_height(); - style_context.render_layout(ctx, title.allocation.x, title.allocation.y, - title.get_pango_layout(pixbuf_dim.width)); - - text_y += title.get_height() + LABEL_PADDING; - } - - if (comment != null && comment_visible) { - comment.allocation.x = 0; - comment.allocation.y = text_y; - comment.allocation.width = pixbuf_dim.width; - comment.allocation.height = comment.get_height(); - style_context.render_layout(ctx, comment.allocation.x, comment.allocation.y, - comment.get_pango_layout(pixbuf_dim.width)); - - text_y += comment.get_height() + LABEL_PADDING; - } - - if (subtitle != null && subtitle_visible) { - subtitle.allocation.x = 0; - subtitle.allocation.y = text_y; - subtitle.allocation.width = pixbuf_dim.width; - subtitle.allocation.height = subtitle.get_height(); - - style_context.render_layout(ctx, subtitle.allocation.x, subtitle.allocation.y, - subtitle.get_pango_layout(pixbuf_dim.width)); - - // increment text_y if more text lines follow - } - - ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue, - selected_color.alpha); - - // draw trinkets last - Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); - int y = pixbuf_origin.y + pixbuf_dim.height - trinket.get_height() - - TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - - trinket = get_top_left_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + TRINKET_PADDING + get_horizontal_trinket_offset(); - int y = pixbuf_origin.y + TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - - trinket = get_top_right_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - - get_horizontal_trinket_offset() - TRINKET_PADDING; - int y = pixbuf_origin.y + TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - - trinket = get_bottom_right_trinket(TRINKET_SCALE); - if (trinket != null) { - int x = pixbuf_origin.x + pixbuf_dim.width - trinket.width - - get_horizontal_trinket_offset() - TRINKET_PADDING; - int y = pixbuf_origin.y + pixbuf_dim.height - trinket.height - - TRINKET_PADDING; - Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y); - ctx.rectangle(x, y, trinket.get_width(), trinket.get_height()); - ctx.fill(); - } - ctx.restore(); - } - - protected void set_horizontal_trinket_offset(int horizontal_trinket_offset) { - assert(horizontal_trinket_offset >= 0); - this.horizontal_trinket_offset = horizontal_trinket_offset; - } - - protected int get_horizontal_trinket_offset() { - return horizontal_trinket_offset; - } - - public void set_grid_coordinates(int col, int row) { - this.col = col; - this.row = row; - } - - public int get_column() { - return col; - } - - public int get_row() { - return row; - } - - public void brighten() { - // "should" implies "can" and "didn't already" - if (brightened != null || pixbuf == null) - return; - - // create a new lightened pixbuf to display - brightened = pixbuf.copy(); - shift_colors(brightened, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, BRIGHTEN_SHIFT, 0); - - display_pixbuf = brightened; - - notify_view_altered(); - } - - - public void unbrighten() { - // "should", "can", "didn't already" - if (brightened == null || pixbuf == null) - return; - - brightened = null; - - // return to the normal image - display_pixbuf = pixbuf; - - notify_view_altered(); - } - - public override void visibility_changed(bool visible) { - // if going from visible to hidden, unbrighten - if (!visible) - unbrighten(); - - base.visibility_changed(visible); - } - - private bool query_tooltip_on_text(CheckerboardItemText text, Gtk.Tooltip tooltip) { - if (!text.get_pango_layout().is_ellipsized()) - return false; - - if (text.is_marked_up()) - tooltip.set_markup(text.get_text()); - else - tooltip.set_text(text.get_text()); - - return true; - } - - public bool query_tooltip(int x, int y, Gtk.Tooltip tooltip) { - if (title != null && title_visible && coord_in_rectangle(x, y, title.allocation)) - return query_tooltip_on_text(title, tooltip); - - if (comment != null && comment_visible && coord_in_rectangle(x, y, comment.allocation)) - return query_tooltip_on_text(comment, tooltip); - - if (subtitle != null && subtitle_visible && coord_in_rectangle(x, y, subtitle.allocation)) - return query_tooltip_on_text(subtitle, tooltip); - - return false; - } -} - public class CheckerboardLayout : Gtk.DrawingArea { public const int TOP_PADDING = 16; public const int BOTTOM_PADDING = 16; @@ -836,7 +18,7 @@ public class CheckerboardLayout : Gtk.DrawingArea { // The number of pixels that the scrollbars of Gtk.ScrolledWindows allocate for themselves // before their final size is computed. This must be taken into account when computing // the width of this widget. This value was 0 in Gtk+ 2.x but is 1 in Gtk+ 3.x. See - // ticket #3870 (http://redmine.yorba.org/issues/3870) for more information + // ticket #3870 (https://bugzilla.gnome.org/show_bug.cgi?id=717754) for more information private const int SCROLLBAR_PLACEHOLDER_WIDTH = 1; private class LayoutRow { @@ -857,7 +39,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { private Gee.HashSet<CheckerboardItem> exposed_items = new Gee.HashSet<CheckerboardItem>(); private Gtk.Adjustment hadjustment = null; private Gtk.Adjustment vadjustment = null; - private string message = null; private Gdk.RGBA selected_color; private Gdk.RGBA unselected_color; private Gdk.RGBA focus_color; @@ -963,23 +144,18 @@ public class CheckerboardLayout : Gtk.DrawingArea { Gtk.Allocation parent_allocation; parent.get_allocation(out parent_allocation); - if (message == null) { - // set the layout's new size to be the same as the parent's width but maintain - // it's own height + // set the layout's new size to be the same as the parent's width but maintain + // it's own height #if TRACE_REFLOW - debug("on_viewport_resized: due_to_reflow=%s set_size_request %dx%d", - size_allocate_due_to_reflow.to_string(), parent_allocation.width, req.height); + debug("on_viewport_resized: due_to_reflow=%s set_size_request %dx%d", + size_allocate_due_to_reflow.to_string(), parent_allocation.width, req.height); #endif - // But if the current height is 0, don't request a size yet. Delay - // it to do_reflow (bgo#766864) - if (req.height != 0) { - set_size_request(parent_allocation.width - SCROLLBAR_PLACEHOLDER_WIDTH, req.height); - } - } else { - // set the layout's width and height to always match the parent's - set_size_request(parent_allocation.width, parent_allocation.height); + // But if the current height is 0, don't request a size yet. Delay + // it to do_reflow (bgo#766864) + if (req.height != 0) { + set_size_request(parent_allocation.width - SCROLLBAR_PLACEHOLDER_WIDTH, req.height); } - + // possible for this widget's size_allocate not to be called, so need to update the page // rect here viewport_resized(); @@ -1070,8 +246,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { private void on_contents_altered(Gee.Iterable<DataObject>? added, Gee.Iterable<DataObject>? removed) { - if (added != null) - message = null; if (removed != null) { foreach (DataObject object in removed) @@ -1142,31 +316,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { queue_draw(); } - public void set_message(string? text) { - if (text == message) - return; - - message = text; - - if (text != null) { - // message is being set, change size to match parent's; if no parent, then the size - // will be set later when added to the parent - if (parent != null) { - Gtk.Allocation parent_allocation; - parent.get_allocation(out parent_allocation); - - set_size_request(parent_allocation.width, parent_allocation.height); - } - } else { - // message is being cleared, layout all the items again - need_reflow("set_message"); - } - } - - public void unset_message() { - set_message(null); - } - private void update_visible_page() { if (hadjustment != null && vadjustment != null) visible_page = get_adjustment_page(hadjustment, vadjustment); @@ -1185,7 +334,7 @@ public class CheckerboardLayout : Gtk.DrawingArea { } public CheckerboardItem? get_item_at_pixel(double xd, double yd) { - if (message != null || item_rows == null) + if (item_rows == null) return null; int x = (int) xd; @@ -1560,10 +709,6 @@ public class CheckerboardLayout : Gtk.DrawingArea { private void reflow(string caller) { reflow_needed = false; - // if set in message mode, nothing to do here - if (message != null) - return; - Gtk.Allocation allocation; get_allocation(out allocation); @@ -1957,35 +1102,17 @@ public class CheckerboardLayout : Gtk.DrawingArea { get_allocation(out allocation); get_style_context().render_background (ctx, 0, 0, allocation.width, allocation.height); - // watch for message mode - if (message == null) { #if TRACE_REFLOW - debug("draw %s: %s", page_name, rectangle_to_string(visible_page)); + debug("draw %s: %s", page_name, rectangle_to_string(visible_page)); #endif - - if (exposure_dirty) - expose_items("draw"); - - // have all items in the exposed area paint themselves - foreach (CheckerboardItem item in intersection(visible_page)) { - item.paint(get_style_context(), ctx, bg_color, item.is_selected() ? selected_color : unselected_color, - border_color, focus_color); - } - } else { - // draw the message in the center of the window - Pango.Layout pango_layout = create_pango_layout(message); - int text_width, text_height; - pango_layout.get_pixel_size(out text_width, out text_height); - - get_allocation(out allocation); - - int x = allocation.width - text_width; - x = (x > 0) ? x / 2 : 0; - - int y = allocation.height - text_height; - y = (y > 0) ? y / 2 : 0; - - get_style_context().render_layout(ctx, x, y, pango_layout); + + if (exposure_dirty) + expose_items("draw"); + + // have all items in the exposed area paint themselves + foreach (CheckerboardItem item in intersection(visible_page)) { + item.paint(get_style_context(), ctx, bg_color, item.is_selected() ? selected_color : unselected_color, + border_color, focus_color); } bool result = (base.draw != null) ? base.draw(ctx) : true; @@ -2025,7 +1152,14 @@ public class CheckerboardLayout : Gtk.DrawingArea { public override bool query_tooltip(int x, int y, bool keyboard_mode, Gtk.Tooltip tooltip) { CheckerboardItem? item = get_item_at_pixel(x, y); - return (item != null) ? item.query_tooltip(x, y, tooltip) : false; + // Note: X & Y allocations are relative to parents, so we need to query the item's tooltip + // relative to its INTERNAL coordinates, otherwise tooltips don't work + if (item != null) { + item.translate_coordinates(ref x, ref y); + return item.query_tooltip(x, y, tooltip); + } else { + return false; + } } private void on_colors_changed() { diff --git a/src/CheckerboardPage.vala b/src/CheckerboardPage.vala new file mode 100644 index 0000000..24a252a --- /dev/null +++ b/src/CheckerboardPage.vala @@ -0,0 +1,758 @@ +/* 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 abstract class CheckerboardPage : Page { + private const int AUTOSCROLL_PIXELS = 50; + private const int AUTOSCROLL_TICKS_MSEC = 50; + + private CheckerboardLayout layout; + private Gtk.Stack stack; + private PageMessagePane message_pane; + private string item_context_menu_path = null; + private string page_context_menu_path = null; + private Gtk.Viewport viewport = new Gtk.Viewport(null, null); + protected CheckerboardItem anchor = null; + protected CheckerboardItem cursor = null; + private CheckerboardItem current_hovered_item = null; + private bool autoscroll_scheduled = false; + private CheckerboardItem activated_item = null; + private Gee.ArrayList<CheckerboardItem> previously_selected = null; + + public enum Activator { + KEYBOARD, + MOUSE + } + + public struct KeyboardModifiers { + public KeyboardModifiers(Page page) { + ctrl_pressed = page.get_ctrl_pressed(); + alt_pressed = page.get_alt_pressed(); + shift_pressed = page.get_shift_pressed(); + super_pressed = page.get_super_pressed(); + } + + public bool ctrl_pressed; + public bool alt_pressed; + public bool shift_pressed; + public bool super_pressed; + } + + protected CheckerboardPage(string page_name) { + base (page_name); + + stack = new Gtk.Stack(); + message_pane = new PageMessagePane(); + + layout = new CheckerboardLayout(get_view()); + layout.set_name(page_name); + stack.add_named (layout, "layout"); + stack.add_named (message_pane, "message"); + stack.set_visible_child(layout); + + set_event_source(layout); + + set_border_width(0); + set_shadow_type(Gtk.ShadowType.NONE); + + viewport.set_border_width(0); + viewport.set_shadow_type(Gtk.ShadowType.NONE); + + viewport.add(stack); + + // want to set_adjustments before adding to ScrolledWindow to let our signal handlers + // run first ... otherwise, the thumbnails draw late + layout.set_adjustments(get_hadjustment(), get_vadjustment()); + + add(viewport); + + // need to monitor items going hidden when dealing with anchor/cursor/highlighted items + get_view().items_hidden.connect(on_items_hidden); + get_view().contents_altered.connect(on_contents_altered); + get_view().items_state_changed.connect(on_items_state_changed); + get_view().items_visibility_changed.connect(on_items_visibility_changed); + + // scrollbar policy + set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + } + + public void init_item_context_menu(string path) { + item_context_menu_path = path; + } + + public void init_page_context_menu(string path) { + page_context_menu_path = path; + } + + public Gtk.Menu? get_context_menu() { + // show page context menu if nothing is selected + return (get_view().get_selected_count() != 0) ? get_item_context_menu() : + get_page_context_menu(); + } + + private Gtk.Menu item_context_menu; + public virtual Gtk.Menu? get_item_context_menu() { + if (item_context_menu == null) { + var model = this.builder.get_object (item_context_menu_path) + as GLib.MenuModel; + item_context_menu = new Gtk.Menu.from_model (model); + item_context_menu.attach_to_widget (this, null); + } + + return item_context_menu; + } + + private Gtk.Menu page_context_menu; + public override Gtk.Menu? get_page_context_menu() { + if (page_context_menu_path == null) + return null; + + if (page_context_menu == null) { + var model = this.builder.get_object (page_context_menu_path) + as GLib.MenuModel; + page_context_menu = new Gtk.Menu.from_model (model); + page_context_menu.attach_to_widget (this, null); + } + + return page_context_menu; + } + + protected override bool on_context_keypress() { + return popup_context_menu(get_context_menu()); + } + + protected virtual string get_view_empty_icon() { + return "image-x-generic-symbolic"; + } + + protected virtual string get_view_empty_message() { + return _("No photos/videos"); + } + + protected virtual string get_filter_no_match_message() { + return _("No photos/videos found which match the current filter"); + } + + protected virtual void on_item_activated(CheckerboardItem item, Activator activator, + KeyboardModifiers modifiers) { + } + + public CheckerboardLayout get_checkerboard_layout() { + return layout; + } + + // Gets the search view filter for this page. + public abstract SearchViewFilter get_search_view_filter(); + + public virtual Core.ViewTracker? get_view_tracker() { + return null; + } + + public override void switching_from() { + layout.set_in_view(false); + get_search_view_filter().refresh.disconnect(on_view_filter_refresh); + + // unselect everything so selection won't persist after page loses focus + get_view().unselect_all(); + + base.switching_from(); + } + + public void scroll_to_item(CheckerboardItem item) { + Gtk.Adjustment vadj = get_vadjustment(); + if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE + && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) { + + // scroll to see the new item + int top = 0; + if (item.allocation.y < vadj.get_value()) { + top = item.allocation.y; + top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2; + } else { + top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size(); + top += CheckerboardLayout.ROW_GUTTER_PADDING / 2; + } + + vadj.set_value(top); + + } + } + + public override void switched_to() { + layout.set_in_view(true); + get_search_view_filter().refresh.connect(on_view_filter_refresh); + on_view_filter_refresh(); + + if (get_view().get_selected_count() > 0) { + CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0); + + // if item is in any way out of view, scroll to it + scroll_to_item(item); + } + + base.switched_to(); + } + + private void on_view_filter_refresh() { + update_view_filter_message(); + } + + private void on_contents_altered(Gee.Iterable<DataObject>? added, + Gee.Iterable<DataObject>? removed) { + update_view_filter_message(); + } + + private void on_items_state_changed(Gee.Iterable<DataView> changed) { + update_view_filter_message(); + } + + private void on_items_visibility_changed(Gee.Collection<DataView> changed) { + update_view_filter_message(); + } + + private void update_view_filter_message() { + if (get_view().are_items_filtered_out() && get_view().get_count() == 0) { + set_page_message(get_filter_no_match_message()); + } else if (get_view().get_count() == 0) { + set_page_message(get_view_empty_message()); + } else { + unset_page_message(); + } + } + + public void set_page_message(string message) { + message_pane.label.label = message; + try { + message_pane.icon_image.icon_name = null; + message_pane.icon_image.gicon = Icon.new_for_string (get_view_empty_icon()); + } catch (Error error) { + message_pane.icon_image.gicon = null; + message_pane.icon_image.icon_name = "image-x-generic-symbolic"; + } + stack.set_visible_child_name ("message"); + } + + public void unset_page_message() { + stack.set_visible_child (layout); + } + + public override void set_page_name(string name) { + base.set_page_name(name); + + layout.set_name(name); + } + + public CheckerboardItem? get_item_at_pixel(double x, double y) { + return layout.get_item_at_pixel(x, y); + } + + private void on_items_hidden(Gee.Iterable<DataView> hidden) { + foreach (DataView view in hidden) { + CheckerboardItem item = (CheckerboardItem) view; + + if (anchor == item) + anchor = null; + + if (cursor == item) + cursor = null; + + if (current_hovered_item == item) + current_hovered_item = null; + } + } + + protected override bool key_press_event(Gdk.EventKey event) { + bool handled = true; + + // mask out the modifiers we're interested in + uint state = event.state & Gdk.ModifierType.SHIFT_MASK; + + switch (Gdk.keyval_name(event.keyval)) { + case "Up": + case "KP_Up": + move_cursor(CompassPoint.NORTH); + select_anchor_to_cursor(state); + break; + + case "Down": + case "KP_Down": + move_cursor(CompassPoint.SOUTH); + select_anchor_to_cursor(state); + break; + + case "Left": + case "KP_Left": + move_cursor(CompassPoint.WEST); + select_anchor_to_cursor(state); + break; + + case "Right": + case "KP_Right": + move_cursor(CompassPoint.EAST); + select_anchor_to_cursor(state); + break; + + case "Home": + case "KP_Home": + CheckerboardItem? first = (CheckerboardItem?) get_view().get_first(); + if (first != null) + cursor_to_item(first); + select_anchor_to_cursor(state); + break; + + case "End": + case "KP_End": + CheckerboardItem? last = (CheckerboardItem?) get_view().get_last(); + if (last != null) + cursor_to_item(last); + select_anchor_to_cursor(state); + break; + + case "Return": + case "KP_Enter": + if (get_view().get_selected_count() == 1) + on_item_activated((CheckerboardItem) get_view().get_selected_at(0), + Activator.KEYBOARD, KeyboardModifiers(this)); + else + handled = false; + break; + + case "space": + Marker marker = get_view().mark(layout.get_cursor()); + get_view().toggle_marked(marker); + break; + + default: + handled = false; + break; + } + + if (handled) + return true; + + return (base.key_press_event != null) ? base.key_press_event(event) : true; + } + + protected override bool on_left_click(Gdk.EventButton event) { + // only interested in single-click and double-clicks for now + if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS)) + return false; + + // mask out the modifiers we're interested in + uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK); + + // use clicks for multiple selection and activation only; single selects are handled by + // button release, to allow for multiple items to be selected then dragged ... + CheckerboardItem item = get_item_at_pixel(event.x, event.y); + if (item != null) { + // ... however, there is no dragging if the user clicks on an interactive part of the + // CheckerboardItem (e.g. a tag) + if (layout.handle_left_click(item, event.x, event.y, event.state)) + return true; + + switch (state) { + case Gdk.ModifierType.CONTROL_MASK: + // with only Ctrl pressed, multiple selections are possible ... chosen item + // is toggled + Marker marker = get_view().mark(item); + get_view().toggle_marked(marker); + + if (item.is_selected()) { + anchor = item; + cursor = item; + } + break; + + case Gdk.ModifierType.SHIFT_MASK: + get_view().unselect_all(); + + if (anchor == null) + anchor = item; + + select_between_items(anchor, item); + + cursor = item; + break; + + case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: + // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run + // of contiguous selected items without unselecting previously-selected items + // a la Nautilus. + // Same as the case for SHIFT_MASK, but don't unselect anything first. + if (anchor == null) + anchor = item; + + select_between_items(anchor, item); + + cursor = item; + break; + + default: + if (event.type == Gdk.EventType.2BUTTON_PRESS) { + activated_item = item; + } else { + // if the user has selected one or more items and is preparing for a drag, + // don't want to blindly unselect: if they've clicked on an unselected item + // unselect all and select that one; if they've clicked on a previously + // selected item, do nothing + if (!item.is_selected()) { + Marker all = get_view().start_marking(); + all.mark_many(get_view().get_selected()); + + get_view().unselect_and_select_marked(all, get_view().mark(item)); + } + } + + anchor = item; + cursor = item; + break; + } + layout.set_cursor(item); + } else { + // user clicked on "dead" area; only unselect if control is not pressed + // do we want similar behavior for shift as well? + if (state != Gdk.ModifierType.CONTROL_MASK) + get_view().unselect_all(); + + // grab previously marked items + previously_selected = new Gee.ArrayList<CheckerboardItem>(); + foreach (DataView view in get_view().get_selected()) + previously_selected.add((CheckerboardItem) view); + + layout.set_drag_select_origin((int) event.x, (int) event.y); + + return true; + } + + // need to determine if the signal should be passed to the DnD handlers + // Return true to block the DnD handler, false otherwise + + return get_view().get_selected_count() == 0; + } + + protected override bool on_left_released(Gdk.EventButton event) { + previously_selected = null; + + // if drag-selecting, stop here and do nothing else + if (layout.is_drag_select_active()) { + layout.clear_drag_select(); + anchor = cursor; + + return true; + } + + // only interested in non-modified button releases + if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0) + return false; + + // if the item was activated in the double-click, report it now + if (activated_item != null) { + on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this)); + activated_item = null; + + return true; + } + + CheckerboardItem item = get_item_at_pixel(event.x, event.y); + if (item == null) { + // released button on "dead" area + return true; + } + + if (cursor != item) { + // user released mouse button after moving it off the initial item, or moved from dead + // space onto one. either way, unselect everything + get_view().unselect_all(); + } else { + // the idea is, if a user single-clicks on an item with no modifiers, then all other items + // should be deselected, however, if they single-click in order to drag one or more items, + // they should remain selected, hence performing this here rather than on_left_click + // (item may not be selected if an unimplemented modifier key was used) + if (item.is_selected()) + get_view().unselect_all_but(item); + } + + return true; + } + + protected override bool on_right_click(Gdk.EventButton event) { + // only interested in single-clicks for now + if (event.type != Gdk.EventType.BUTTON_PRESS) + return false; + + // get what's right-clicked upon + CheckerboardItem item = get_item_at_pixel(event.x, event.y); + if (item != null) { + // mask out the modifiers we're interested in + switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) { + case Gdk.ModifierType.CONTROL_MASK: + // chosen item is toggled + Marker marker = get_view().mark(item); + get_view().toggle_marked(marker); + break; + + case Gdk.ModifierType.SHIFT_MASK: + // TODO + break; + + case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: + // TODO + break; + + default: + // if the item is already selected, proceed; if item is not selected, a bare right + // click unselects everything else but it + if (!item.is_selected()) { + Marker all = get_view().start_marking(); + all.mark_many(get_view().get_selected()); + + get_view().unselect_and_select_marked(all, get_view().mark(item)); + } + break; + } + } else { + // clicked in "dead" space, unselect everything + get_view().unselect_all(); + } + + Gtk.Menu context_menu = get_context_menu(); + return popup_context_menu(context_menu, event); + } + + protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) { + if (item != null) + layout.handle_mouse_motion(item, x, y, mask); + + // if hovering over the last hovered item, or both are null (nothing highlighted and + // hovering over empty space), do nothing + if (item == current_hovered_item) + return true; + + // either something new is highlighted or now hovering over empty space, so dim old item + if (current_hovered_item != null) { + current_hovered_item.handle_mouse_leave(); + current_hovered_item = null; + } + + // if over empty space, done + if (item == null) + return true; + + // brighten the new item + current_hovered_item = item; + current_hovered_item.handle_mouse_enter(); + + return true; + } + + protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) { + // report what item the mouse is hovering over + if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask)) + return false; + + // go no further if not drag-selecting + if (!layout.is_drag_select_active()) + return false; + + // set the new endpoint of the drag selection + layout.set_drag_select_endpoint(x, y); + + updated_selection_band(); + + // if out of bounds, schedule a check to auto-scroll the viewport + if (!autoscroll_scheduled + && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) { + Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll); + autoscroll_scheduled = true; + } + + // return true to stop a potential drag-and-drop operation + return true; + } + + private void updated_selection_band() { + assert(layout.is_drag_select_active()); + + // get all items inside the selection + Gee.List<CheckerboardItem>? intersection = layout.items_in_selection_band(); + if (intersection == null) + return; + + Marker to_unselect = get_view().start_marking(); + Marker to_select = get_view().start_marking(); + + // mark all selected items to be unselected + to_unselect.mark_many(get_view().get_selected()); + + // except for the items that were selected before the drag began + assert(previously_selected != null); + to_unselect.unmark_many(previously_selected); + to_select.mark_many(previously_selected); + + // toggle selection on everything in the intersection and update the cursor + cursor = null; + + foreach (CheckerboardItem item in intersection) { + if (to_select.toggle(item)) + to_unselect.unmark(item); + else + to_unselect.mark(item); + + if (cursor == null) + cursor = item; + } + + get_view().select_marked(to_select); + get_view().unselect_marked(to_unselect); + } + + private bool selection_autoscroll() { + if (!layout.is_drag_select_active()) { + autoscroll_scheduled = false; + + return false; + } + + // as the viewport never scrolls horizontally, only interested in vertical + Gtk.Adjustment vadj = get_vadjustment(); + + int x, y; + Gdk.ModifierType mask; + get_event_source_pointer(out x, out y, out mask); + + int new_value = (int) vadj.get_value(); + switch (get_adjustment_relation(vadj, y)) { + case AdjustmentRelation.BELOW: + // pointer above window, scroll up + new_value -= AUTOSCROLL_PIXELS; + layout.set_drag_select_endpoint(x, new_value); + break; + + case AdjustmentRelation.ABOVE: + // pointer below window, scroll down, extend selection to bottom of page + new_value += AUTOSCROLL_PIXELS; + layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size()); + break; + + case AdjustmentRelation.IN_RANGE: + autoscroll_scheduled = false; + + return false; + + default: + warn_if_reached(); + break; + } + + // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16. + // This may have to do with how adjustments are different w/ scrollbars, that they're upper + // clamp is upper - page_size ... either way, enforce these limits here + vadj.set_value(new_value.clamp((int) vadj.get_lower(), + (int) vadj.get_upper() - (int) vadj.get_page_size())); + + updated_selection_band(); + + return true; + } + + public void cursor_to_item(CheckerboardItem item) { + assert(get_view().contains(item)); + + cursor = item; + + if (!get_ctrl_pressed()) { + get_view().unselect_all(); + Marker marker = get_view().mark(item); + get_view().select_marked(marker); + } + layout.set_cursor(item); + scroll_to_item(item); + } + + public void move_cursor(CompassPoint point) { + // if no items, nothing to do + if (get_view().get_count() == 0) + return; + + // if there is no better starting point, simply select the first and exit + // The right half of the or is related to Bug #732334, the cursor might be non-null and still not contained in + // the view, if the user dragged a full screen Photo off screen + if (cursor == null && layout.get_cursor() == null || cursor != null && !get_view().contains(cursor)) { + CheckerboardItem item = layout.get_item_at_coordinate(0, 0); + cursor_to_item(item); + anchor = item; + + return; + } + + if (cursor == null) { + cursor = layout.get_cursor() as CheckerboardItem; + } + + // move the cursor relative to the "first" item + CheckerboardItem? item = layout.get_item_relative_to(cursor, point); + if (item != null) + cursor_to_item(item); + } + + public void set_cursor(CheckerboardItem item) { + Marker marker = get_view().mark(item); + get_view().select_marked(marker); + + cursor = item; + anchor = item; + } + + public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) { + Marker marker = get_view().start_marking(); + + bool passed_start = false; + bool passed_end = false; + + foreach (DataObject object in get_view().get_all()) { + CheckerboardItem item = (CheckerboardItem) object; + + if (item_start == item) + passed_start = true; + + if (item_end == item) + passed_end = true; + + if (passed_start || passed_end) + marker.mark((DataView) object); + + if (passed_start && passed_end) + break; + } + + get_view().select_marked(marker); + } + + public void select_anchor_to_cursor(uint state) { + if (cursor == null || anchor == null) + return; + + if (state == Gdk.ModifierType.SHIFT_MASK) { + get_view().unselect_all(); + select_between_items(anchor, cursor); + } else { + anchor = cursor; + } + } + + protected virtual void set_display_titles(bool display) { + get_view().freeze_notifications(); + get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display); + get_view().thaw_notifications(); + } + + protected virtual void set_display_comments(bool display) { + get_view().freeze_notifications(); + get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display); + get_view().thaw_notifications(); + } +} + + diff --git a/src/Commands.vala b/src/Commands.vala index 589ae38..76aecb4 100644 --- a/src/Commands.vala +++ b/src/Commands.vala @@ -1316,8 +1316,17 @@ public class AdjustDateTimePhotoCommand : SingleDataSourceCommand { this.modify_original = modify_original; } + private DateTime get_base_time() { + var exposure_time = dateable.get_exposure_time(); + if (exposure_time == null) { + exposure_time = new DateTime.from_unix_utc(0); + } + + return exposure_time; + } + public override void execute() { - set_time(dateable, dateable.get_exposure_time() + (time_t) time_shift); + set_time(dateable, get_base_time().add_seconds(time_shift)); prev_event = dateable.get_event(); @@ -1333,12 +1342,12 @@ public class AdjustDateTimePhotoCommand : SingleDataSourceCommand { } public override void undo() { - set_time(dateable, dateable.get_exposure_time() - (time_t) time_shift); + set_time(dateable, get_base_time().add_seconds(-1 * time_shift)); dateable.set_event(prev_event); } - private void set_time(Dateable dateable, time_t exposure_time) { + private void set_time(Dateable dateable, DateTime exposure_time) { if (modify_original && dateable is Photo) { try { ((Photo)dateable).set_exposure_time_persistent(exposure_time); @@ -1358,8 +1367,8 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { private Gee.Map<Dateable, Event?> prev_events; // used when photos are batch changed instead of shifted uniformly - private time_t? new_time = null; - private Gee.HashMap<Dateable, time_t?> old_times; + private DateTime? new_time = null; + private Gee.HashMap<Dateable, DateTime?> old_times; private Gee.ArrayList<Dateable> error_list; public AdjustDateTimePhotosCommand(Gee.Iterable<DataView> iter, int64 time_shift, @@ -1377,16 +1386,24 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { // this should be replaced by a first function when we migrate to Gee's List foreach (DataView view in iter) { - prev_events.set(view.get_source() as Dateable, (view.get_source() as MediaSource).get_event()); + prev_events.set(view.get_source() as Dateable, ((MediaSource) view.get_source()).get_event()); if (new_time == null) { - new_time = ((Dateable) view.get_source()).get_exposure_time() + - (time_t) time_shift; + new_time = get_base_time((Dateable)view.get_source()).add_seconds(time_shift); break; } } - old_times = new Gee.HashMap<Dateable, time_t?>(); + old_times = new Gee.HashMap<Dateable, DateTime?>(); + } + + private DateTime get_base_time(Dateable dateable) { + var exposure_time = dateable.get_exposure_time(); + if (exposure_time == null) { + exposure_time = new DateTime.from_unix_utc(0); + } + + return exposure_time; } public override void execute() { @@ -1425,7 +1442,7 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { } } - private void set_time(Dateable dateable, time_t exposure_time) { + private void set_time(Dateable dateable, DateTime exposure_time) { // set_exposure_time_persistent wouldn't work on videos, // since we can't actually write them from inside shotwell, // so check whether we're working on a Photo or a Video @@ -1445,8 +1462,8 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { public override void execute_on_source(DataSource source) { Dateable dateable = ((Dateable) source); - if (keep_relativity && dateable.get_exposure_time() != 0) { - set_time(dateable, dateable.get_exposure_time() + (time_t) time_shift); + if (keep_relativity && dateable.get_exposure_time() != null) { + set_time(dateable, dateable.get_exposure_time().add_seconds(time_shift)); } else { old_times.set(dateable, dateable.get_exposure_time()); set_time(dateable, new_time); @@ -1470,10 +1487,10 @@ public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand { set_time(photo, old_times.get(photo)); old_times.unset(photo); } else { - set_time(photo, photo.get_exposure_time() - (time_t) time_shift); + set_time(photo, photo.get_exposure_time().add_seconds(-1 * time_shift)); } - (source as MediaSource).set_event(prev_events.get(source as Dateable)); + ((MediaSource) source).set_event(prev_events.get(source as Dateable)); } } @@ -2165,8 +2182,6 @@ public class ModifyTagsCommand : SingleDataSourceCommand { } foreach (string path in new_paths) { - assert(Tag.global.exists(path)); - SourceProxy proxy = Tag.for_path(path).get_proxy(); to_add.add(proxy); proxy.broken.connect(on_proxy_broken); @@ -2541,7 +2556,8 @@ public class RemoveFacesFromPhotosCommand : SimpleProxyableCommand { face.attach_many(map_source_geometry.keys); foreach (Gee.Map.Entry<MediaSource, string> entry in map_source_geometry.entries) - FaceLocation.create(face.get_face_id(), ((Photo) entry.key).get_photo_id(), entry.value); + FaceLocation.create(face.get_face_id(), ((Photo) entry.key).get_photo_id(), + { entry.value, null }); } private void on_source_destroyed(DataSource source) { @@ -2572,6 +2588,26 @@ public class RenameFaceCommand : SimpleProxyableCommand { } } +public class SetFaceRefCommand : SimpleProxyableCommand { + private FaceLocation face_loc; + + public SetFaceRefCommand(Face face, MediaSource source) { + base (face, Resources.set_face_from_photo_label(face.get_name()), face.get_name()); + Gee.Map<FaceID?, FaceLocation>? face_loc_map = FaceLocation.get_locations_by_photo((Photo)source); + face_loc = face_loc_map.get(face.get_face_id()); + } + + protected override void execute_on_source(DataSource source) { + if (!((Face) source).set_reference(face_loc)) + AppWindow.error_message(Resources.set_face_from_photo_error()); + } + + protected override void undo_on_source(DataSource source) { + //if (!((Face) source).rename(old_name)) + // AppWindow.error_message(Resources.rename_face_exists_message(old_name)); + } +} + public class DeleteFaceCommand : SimpleProxyableCommand { private Gee.Map<PhotoID?, string> photo_geometry_map = new Gee.HashMap<PhotoID?, string> ((Gee.HashDataFunc)FaceLocation.photo_id_hash, (Gee.EqualDataFunc)FaceLocation.photo_ids_equal); @@ -2607,7 +2643,8 @@ public class DeleteFaceCommand : SimpleProxyableCommand { Face face = (Face) source; face.attach(photo); - FaceLocation.create(face.get_face_id(), entry.key, entry.value); + FaceLocation.create(face.get_face_id(), entry.key, + { entry.value, null }); } } } @@ -2617,10 +2654,10 @@ public class ModifyFacesCommand : SingleDataSourceCommand { private MediaSource media; private Gee.ArrayList<SourceProxy> to_add = new Gee.ArrayList<SourceProxy>(); private Gee.ArrayList<SourceProxy> to_remove = new Gee.ArrayList<SourceProxy>(); - private Gee.Map<SourceProxy, string> to_update = new Gee.HashMap<SourceProxy, string>(); - private Gee.Map<SourceProxy, string> geometries = new Gee.HashMap<SourceProxy, string>(); + private Gee.Map<SourceProxy, FaceLocationData?> to_update = new Gee.HashMap<SourceProxy, FaceLocationData?>(); + private Gee.Map<SourceProxy, FaceLocationData?> geometries = new Gee.HashMap<SourceProxy, FaceLocationData?>(); - public ModifyFacesCommand(MediaSource media, Gee.Map<Face, string> new_face_list) { + public ModifyFacesCommand(MediaSource media, Gee.Map<Face, FaceLocationData?> new_face_list) { base (media, Resources.MODIFY_FACES_LABEL, ""); this.media = media; @@ -2639,13 +2676,13 @@ public class ModifyFacesCommand : SingleDataSourceCommand { FaceLocation.get_face_location(face.get_face_id(), ((Photo) media).get_photo_id()); assert(face_location != null); - geometries.set(proxy, face_location.get_serialized_geometry()); + geometries.set(proxy, face_location.get_face_data()); } } } // Add any face that's in the new list but not the original - foreach (Gee.Map.Entry<Face, string> entry in new_face_list.entries) { + foreach (Gee.Map.Entry<Face, FaceLocationData?> entry in new_face_list.entries) { if (original_faces == null || !original_faces.contains(entry.key)) { SourceProxy proxy = entry.key.get_proxy(); @@ -2661,13 +2698,13 @@ public class ModifyFacesCommand : SingleDataSourceCommand { assert(face_location != null); string old_geometry = face_location.get_serialized_geometry(); - if (old_geometry != entry.value) { + if (old_geometry != entry.value.geometry) { SourceProxy proxy = entry.key.get_proxy(); to_update.set(proxy, entry.value); proxy.broken.connect(on_proxy_broken); - geometries.set(proxy, old_geometry); + geometries.set(proxy, face_location.get_face_data()); } } } @@ -2694,7 +2731,7 @@ public class ModifyFacesCommand : SingleDataSourceCommand { foreach (SourceProxy proxy in to_remove) ((Face) proxy.get_source()).detach(media); - foreach (Gee.Map.Entry<SourceProxy, string> entry in to_update.entries) { + foreach (Gee.Map.Entry<SourceProxy, FaceLocationData?> entry in to_update.entries) { Face face = (Face) entry.key.get_source(); FaceLocation.create(face.get_face_id(), ((Photo) media).get_photo_id(), entry.value); } diff --git a/src/Debug.vala b/src/Debug.vala index f159b0d..799a94f 100644 --- a/src/Debug.vala +++ b/src/Debug.vala @@ -33,7 +33,7 @@ namespace Debug { string log_file_error_msg = null; - // logging to disk is currently off for viewer more; see http://trac.yorba.org/ticket/2078 + // logging to disk is currently off for viewer more; see https://bugzilla.gnome.org/show_bug.cgi?id=716474 File? log_file = (log_app_version_prefix == LIBRARY_PREFIX) ? AppDirs.get_log_file() : null; if(log_file != null) { File log_dir = log_file.get_parent(); @@ -104,11 +104,10 @@ namespace Debug { } private void log(FileStream stream, string prefix, string message) { - time_t now = time_t(); stream.printf("%s %d %s [%s] %s\n", log_app_version_prefix, Posix.getpid(), - Time.local(now).to_string(), + new DateTime.now_local().format("%F %T"), prefix, message ); diff --git a/src/DesktopIntegration.vala b/src/DesktopIntegration.vala index 68d1ec6..754d9a1 100644 --- a/src/DesktopIntegration.vala +++ b/src/DesktopIntegration.vala @@ -9,7 +9,6 @@ namespace DesktopIntegration { private const string DESKTOP_SLIDESHOW_XML_FILENAME = "wallpaper.xml"; private int init_count = 0; -private bool send_to_installed = false; private ExporterUI send_to_exporter = null; private ExporterUI desktop_slideshow_exporter = null; private double desktop_slideshow_transition = 0.0; @@ -113,6 +112,7 @@ public async void files_send_to(File[] files) { yield portal.compose_email(parent, {null}, null, null, _("Send files per Mail: ") + file_names.str, null, file_paths, Xdp.EmailFlags.NONE, null); } catch (Error e){ + // Translators: The first %s is the name of the file, the second %s is the reason why it could not be sent AppWindow.error_message(_("Unable to send file %s, %s").printf( file_names.str, e.message)); } diff --git a/src/Dialogs.vala b/src/Dialogs.vala index b1f6e08..70dc76d 100644 --- a/src/Dialogs.vala +++ b/src/Dialogs.vala @@ -5,7 +5,7 @@ */ // namespace for future migration of AppWindow alert and other question dialogs into single -// place: http://trac.yorba.org/ticket/3452 +// place: https://bugzilla.gnome.org/show_bug.cgi?id=717659 namespace Dialogs { public bool confirm_delete_tag(Tag tag) { @@ -67,7 +67,7 @@ public File? choose_file(string current_file_basename) { current_export_dir = File.new_for_path(Environment.get_home_dir()); string file_chooser_title = VideoReader.is_supported_video_filename(current_file_basename) ? - _("Export Video") : _("Export Photo"); + _("Export Video") : GLib.dpgettext2 (null, "Dialog Title", "Export Photo"); var chooser = new Gtk.FileChooserNative(file_chooser_title, AppWindow.get_instance(), Gtk.FileChooserAction.SAVE, Resources.SAVE_LABEL, Resources.CANCEL_LABEL); @@ -214,7 +214,7 @@ public string create_result_report_from_manifest(ImportManifest manifest) { StringBuilder builder = new StringBuilder(); string header = _("Import Results Report") + " (Shotwell " + Resources.APP_VERSION + " @ " + - TimeVal().to_iso8601() + ")\n\n"; + new DateTime.now_utc().format_iso8601() + ")\n\n"; builder.append(header); string subhead = (ngettext("Attempted to import %d file.", "Attempted to import %d files.", @@ -817,8 +817,14 @@ public void multiple_object_error_dialog(Gee.ArrayList<DataObject> objects, stri public abstract class TagsDialog : TextEntryDialogMediator { protected TagsDialog(string title, string label, string? initial_text = null) { - base (title, label, initial_text, HierarchicalTagIndex.get_global_index().get_all_tags(), - ","); + var all = new Gee.ArrayList<string>(); + all.add_all(HierarchicalTagIndex.get_global_index().get_all_tags()); + var paths = HierarchicalTagIndex.get_global_index().get_all_paths(); + foreach (var p in paths) { + if (p.has_prefix("/")) all.add(p); + } + + base (title, label, initial_text, all, ","); } } @@ -840,14 +846,24 @@ public class AddTagsDialog : TagsDialog { } protected override bool on_modify_validate(string text) { - if (text.contains(Tag.PATH_SEPARATOR_STRING)) - return false; - - // Can't simply call Tag.prep_tag_names().length because of this bug: - // https://bugzilla.gnome.org/show_bug.cgi?id=602208 string[] names = Tag.prep_tag_names(text.split(",")); - - return names.length > 0; + if (names.length == 0) + return false; + + // If allowing hierarchies, they have to start with a "/" + for (int i = 0; i < names.length; i++) { + if (names[i].contains(Tag.PATH_SEPARATOR_STRING) && !names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING)) + return false; + + if (names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING) && names[i].strip().length == 1) + return false; + + if (names[i].strip().contains(Tag.PATH_SEPARATOR_STRING + Tag.PATH_SEPARATOR_STRING)) { + return false; + } + } + + return true; } } @@ -904,7 +920,26 @@ public class ModifyTagsDialog : TagsDialog { } protected override bool on_modify_validate(string text) { - return (!text.contains(Tag.PATH_SEPARATOR_STRING)); + string[] names = Tag.prep_tag_names(text.split(",")); + if (names.length == 0) + return false; + + // If allowing hierarchies, they have to start with a "/" + for (int i = 0; i < names.length; i++) { + if (names[i].contains(Tag.PATH_SEPARATOR_STRING) && !names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING)) { + return false; + } + + if (names[i].strip().has_prefix(Tag.PATH_SEPARATOR_STRING) && names[i].strip().length == 1) + return false; + + if (names[i].strip().contains(Tag.PATH_SEPARATOR_STRING + Tag.PATH_SEPARATOR_STRING)) { + return false; + } + } + + return true; + } } @@ -930,7 +965,7 @@ public Gtk.ResponseType copy_files_dialog() { public void remove_photos_from_library(Gee.Collection<LibraryPhoto> photos) { remove_from_app(photos, _("Remove From Library"), - (photos.size == 1) ? _("Removing Photo From Library") : _("Removing Photos From Library")); + ngettext("Removing Photo From Library", "Removing Photos From Library", photos.size)); } public void remove_from_app(Gee.Collection<MediaSource> sources, string dialog_title, diff --git a/src/Dimensions.vala b/src/Dimensions.vala index 3b4163c..32bf32c 100644 --- a/src/Dimensions.vala +++ b/src/Dimensions.vala @@ -59,8 +59,9 @@ public struct Dimensions { public static Dimensions for_widget_allocation(Gtk.Widget widget) { Gtk.Allocation allocation; widget.get_allocation(out allocation); + var scale = widget.get_scale_factor(); - return Dimensions(allocation.width, allocation.height); + return Dimensions(allocation.width * scale, allocation.height * scale); } public static Dimensions for_rectangle(Gdk.Rectangle rect) { diff --git a/src/DirectoryMonitor.vala b/src/DirectoryMonitor.vala index a37b124..19992dd 100644 --- a/src/DirectoryMonitor.vala +++ b/src/DirectoryMonitor.vala @@ -60,7 +60,7 @@ public class DirectoryMonitor : Object { public const int DEFAULT_PRIORITY = Priority.LOW; public const FileQueryInfoFlags DIR_INFO_FLAGS = FileQueryInfoFlags.NONE; - public const FileQueryInfoFlags FILE_INFO_FLAGS = FileQueryInfoFlags.NOFOLLOW_SYMLINKS; + public const FileQueryInfoFlags FILE_INFO_FLAGS = FileQueryInfoFlags.NONE; // when using UNKNOWN_FILE_FLAGS, check if the resulting FileInfo's symlink status matches // symlink support for files and directories by calling is_file_symlink_supported(). @@ -290,7 +290,7 @@ public class DirectoryMonitor : Object { // get all the interesting matchable items from the supplied FileInfo int64 match_size = match.get_size(); - TimeVal match_time = match.get_modification_time(); + var match_time = match.get_modification_date_time(); foreach (File file in map.keys) { FileInfo info = map.get(file); @@ -303,9 +303,9 @@ public class DirectoryMonitor : Object { if (match_size != info.get_size()) continue; - TimeVal time = info.get_modification_time(); + var time = info.get_modification_date_time(); - if (time.tv_sec != match_time.tv_sec) + if (!time.equal(match_time)) continue; return file; diff --git a/src/DragAndDropHandler.vala b/src/DragAndDropHandler.vala new file mode 100644 index 0000000..ece6d9d --- /dev/null +++ b/src/DragAndDropHandler.vala @@ -0,0 +1,182 @@ +/* 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. + */ + +// +// DragAndDropHandler attaches signals to a Page to properly handle drag-and-drop requests for the +// Page as a DnD Source. (DnD Destination handling is handled by the appropriate AppWindow, i.e. +// LibraryWindow and DirectWindow). Assumes the Page's ViewCollection holds MediaSources. +// +public class DragAndDropHandler { + private enum TargetType { + XDS, + MEDIA_LIST + } + + private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = { + { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS }, + { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST } + }; + + private static Gdk.Atom? XDS_ATOM = null; + private static Gdk.Atom? TEXT_ATOM = null; + private static uint8[]? XDS_FAKE_TARGET = null; + + private weak Page page; + private Gtk.Widget event_source; + private File? drag_destination = null; + private ExporterUI exporter = null; + + public DragAndDropHandler(Page page) { + this.page = page; + this.event_source = page.get_event_source(); + assert(event_source != null); + assert(event_source.get_has_window()); + + // Need to do this because static member variables are not properly handled + if (XDS_ATOM == null) + XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0"); + + if (TEXT_ATOM == null) + TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain"); + + if (XDS_FAKE_TARGET == null) + XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt"); + + // register what's available on this DnD Source + Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES, + Gdk.DragAction.COPY); + + // attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget + // and does not emit them + event_source.drag_begin.connect(on_drag_begin); + event_source.drag_data_get.connect(on_drag_data_get); + event_source.drag_end.connect(on_drag_end); + event_source.drag_failed.connect(on_drag_failed); + } + + ~DragAndDropHandler() { + if (event_source != null) { + event_source.drag_begin.disconnect(on_drag_begin); + event_source.drag_data_get.disconnect(on_drag_data_get); + event_source.drag_end.disconnect(on_drag_end); + event_source.drag_failed.disconnect(on_drag_failed); + } + + page = null; + event_source = null; + } + + private void on_drag_begin(Gdk.DragContext context) { + debug("on_drag_begin (%s)", page.get_page_name()); + + if (page == null || page.get_view().get_selected_count() == 0 || exporter != null) + return; + + drag_destination = null; + + // use the first media item as the icon + ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source(); + + try { + Gdk.Pixbuf icon = thumb.get_thumbnail(AppWindow.DND_ICON_SCALE); + Gtk.drag_source_set_icon_pixbuf(event_source, icon); + } catch (Error err) { + warning("Unable to fetch icon for drag-and-drop from %s: %s", thumb.to_string(), + err.message); + } + + // set the XDS property to indicate an XDS save is available + Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE, + XDS_FAKE_TARGET, 1); + } + + private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data, + uint target_type, uint time) { + debug("on_drag_data_get (%s)", page.get_page_name()); + + if (page == null || page.get_view().get_selected_count() == 0) + return; + + switch (target_type) { + case TargetType.XDS: + // Fetch the XDS property that has been set with the destination path + uchar[] data = new uchar[4096]; + Gdk.Atom actual_type; + int actual_format = 0; + bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM, + 0, data.length, 0, out actual_type, out actual_format, out data); + + // the destination path is actually for our XDS_FAKE_TARGET, use its parent + // to determine where the file(s) should go + if (fetched && data != null && data.length > 0) + drag_destination = File.new_for_uri(uchar_array_to_string(data)).get_parent(); + + debug("on_drag_data_get (%s): %s", page.get_page_name(), + (drag_destination != null) ? drag_destination.get_path() : "(no path)"); + + // Set the property to "S" for Success or "E" for Error + selection_data.set(XDS_ATOM, 8, + string_to_uchar_array((drag_destination != null) ? "S" : "E")); + break; + + case TargetType.MEDIA_LIST: + Gee.Collection<MediaSource> sources = + (Gee.Collection<MediaSource>) page.get_view().get_selected_sources(); + + // convert the selected media sources to Gdk.Atom-encoded sourceID strings for + // internal drag-and-drop + selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom), + serialize_media_sources(sources)); + break; + + default: + warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(), + target_type); + break; + } + } + + private void on_drag_end() { + debug("on_drag_end (%s)", page.get_page_name()); + + if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null + || exporter != null) { + return; + } + + debug("Exporting to %s", drag_destination.get_path()); + + // drag-and-drop export doesn't pop up an export dialog, so use what are likely the + // most common export settings (the current -- or "working" -- file format, with + // all transformations applied, at the image's original size). + if (drag_destination.get_path() != null) { + exporter = new ExporterUI(new Exporter( + (Gee.Collection<Photo>) page.get_view().get_selected_sources(), + drag_destination, Scaling.for_original(), ExportFormatParameters.current())); + exporter.export(on_export_completed); + } else { + AppWindow.error_message(_("Photos cannot be exported to this directory.")); + } + + drag_destination = null; + } + + private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { + debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result); + + if (page == null) + return false; + + drag_destination = null; + + return false; + } + + private void on_export_completed() { + exporter = null; + } + +} diff --git a/src/Event.vala b/src/Event.vala index 084df97..69d27d0 100644 --- a/src/Event.vala +++ b/src/Event.vala @@ -100,8 +100,6 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { // 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; @@ -303,8 +301,10 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { } 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() ; + var time_a = ((MediaSource) ((ThumbnailView *) a)->get_source()).get_exposure_time(); + var time_b = ((MediaSource) ((ThumbnailView *) b)->get_source()).get_exposure_time(); + + return nullsafe_date_time_comperator(time_a, time_b); } private static bool view_comparator_predicate(DataObject object, Alteration alteration) { @@ -591,7 +591,7 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { return indexable_keywords; } - public bool is_in_starting_day(time_t time) { + public bool is_in_starting_day(DateTime 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 @@ -601,30 +601,25 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { // 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()); + var earliest_tm = earliest_media.get_exposure_time().to_local(); // 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(); - + var start_boundary = new DateTime.local(earliest_tm.get_year(), + earliest_tm.get_month(), + earliest_tm.get_day_of_month(), + EVENT_BOUNDARY_HOUR, + 0, + 0); // 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) { + if (earliest_tm.get_hour() < EVENT_BOUNDARY_HOUR) { debug("Hour before boundary, shifting back one day"); - start_boundary -= TIME_T_DAY; + start_boundary = start_boundary.add_days(-1); } - time_t end_boundary = (start_boundary + TIME_T_DAY - 1); - - return time >= start_boundary && time <= end_boundary; + var end_boundary = start_boundary.add_days(1).add_seconds(-1); + + return time.compare(start_boundary) >= 0 && time.compare(end_boundary) <= 0; } // This method attempts to add a media source to an event in the supplied list that it would @@ -632,9 +627,9 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { // 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(); + DateTime? exposure_time = media.get_exposure_time(); - if (exposure_time == 0 && event_name == null) { + if (exposure_time == null && event_name == null) { debug("Skipping event assignment to %s: no exposure time and no event name", media.to_string()); new_event = false; @@ -754,22 +749,20 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { } public string? get_formatted_daterange() { - time_t start_time = get_start_time(); - time_t end_time = get_end_time(); + DateTime? start_time = get_start_time(); + DateTime? end_time = get_end_time(); - if (end_time == 0 && start_time == 0) + if (end_time == null && start_time == null) 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 (end_time == null && start_time != null) + return format_local_date(start_time.to_local()); - if (start.day == end.day && start.month == end.month && start.day == end.day) - return format_local_date(Time.local(start_time)); + if (start_time.get_year() == end_time.get_year() && + start_time.get_day_of_year() == end_time.get_day_of_year()) + return format_local_date(start_time.to_local()); - return format_local_datespan(start, end); + return format_local_datespan(start_time.to_local(), end_time.to_local()); } public string? get_raw_name() { @@ -811,30 +804,30 @@ public class Event : EventSource, ContainerSource, Proxyable, Indexable { return committed; } - public time_t get_creation_time() { + public DateTime? get_creation_time() { return event_table.get_time_created(event_id); } - public override time_t get_start_time() { + public override DateTime? 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) + var time = ((MediaSource) (((DataView) view.get_at(i)).get_source())).get_exposure_time(); + if (time != null) return time; } - return 0; + return null; } - public override time_t get_end_time() { + public override DateTime? 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 null; return ((MediaSource) (((DataView) view.get_at(count - 1)).get_source())).get_exposure_time(); } diff --git a/src/Exporter.vala b/src/Exporter.vala index b9596f5..a7f7b6b 100644 --- a/src/Exporter.vala +++ b/src/Exporter.vala @@ -55,7 +55,9 @@ public class Exporter : Object { YES, NO, CANCEL, - REPLACE_ALL + REPLACE_ALL, + RENAME, + RENAME_ALL, } public delegate void CompletionCallback(Exporter exporter, bool is_cancelled); @@ -116,8 +118,10 @@ public class Exporter : Object { private unowned ProgressMonitor? monitor = null; private Cancellable cancellable; private bool replace_all = false; + private bool rename_all = false; private bool aborted = false; private ExportFormatParameters export_params; + private static File? USE_TEMPORARY_EXPORT_FOLDER = null; public Exporter(Gee.Collection<MediaSource> to_export, File? dir, Scaling scaling, ExportFormatParameters export_params, bool auto_replace_all = false) { @@ -131,7 +135,7 @@ public class Exporter : Object { public Exporter.for_temp_file(Gee.Collection<MediaSource> to_export, Scaling scaling, ExportFormatParameters export_params) { this.to_export.add_all(to_export); - this.dir = null; + this.dir = USE_TEMPORARY_EXPORT_FOLDER; this.scaling = scaling; this.export_params = export_params; } @@ -193,6 +197,7 @@ public class Exporter : Object { private bool process_queue() { int submitted = 0; + Gee.HashSet<string> used = new Gee.HashSet<string>(); foreach (MediaSource source in to_export) { File? use_source_file = null; PhotoFileFormat real_export_format = PhotoFileFormat.get_system_default_format(); @@ -227,7 +232,7 @@ public class Exporter : Object { if (export_dir == null) { try { bool collision; - dest = generate_unique_file(AppDirs.get_temp_dir(), basename, out collision); + dest = generate_unique_file(AppDirs.get_temp_dir(), basename, out collision, used); } catch (Error err) { AppWindow.error_message(_("Unable to generate a temporary file for %s: %s").printf( source.get_file().get_basename(), err.message)); @@ -236,17 +241,30 @@ public class Exporter : Object { } } else { dest = dir.get_child(basename); + bool rename = false; - if (!replace_all && dest.query_exists(null)) { - switch (overwrite_callback(this, dest)) { + if (!replace_all && (dest.query_exists(null) || used.contains(basename))) { + if (rename_all) { + rename = true; + } else { + switch (overwrite_callback(this, dest)) { case Overwrite.YES: // continue - break; + break; case Overwrite.REPLACE_ALL: replace_all = true; - break; - + break; + + case Overwrite.RENAME: + rename = true; + break; + + case Overwrite.RENAME_ALL: + rename = true; + rename_all = true; + break; + case Overwrite.CANCEL: cancellable.cancel(); @@ -264,10 +282,22 @@ public class Exporter : Object { } continue; + } + } + if (rename) { + try { + bool collision; + dest = generate_unique_file(dir, basename, out collision, used); + } catch (Error err) { + AppWindow.error_message(_("Unable to generate a temporary file for %s: %s").printf( + source.get_file().get_basename(), err.message)); + break; + } } } } + used.add(dest.get_basename()); workers.enqueue(new ExportJob(this, source, dest, scaling, export_params.quality, real_export_format, cancellable, export_params.mode == ExportFormatMode.UNMODIFIED, export_params.export_metadata)); submitted++; @@ -315,24 +345,30 @@ public class ExporterUI { private Exporter.Overwrite on_export_overwrite(Exporter exporter, File file) { progress_dialog.set_modal(false); string question = _("File %s already exists. Replace?").printf(file.get_basename()); - Gtk.ResponseType response = AppWindow.negate_affirm_all_cancel_question(question, - _("_Skip"), _("_Replace"), _("Replace _All"), _("Export")); + int response = AppWindow.export_overwrite_or_replace_question(question, + _("_Skip"), _("Rename"), _("Rename All"),_("_Replace"), _("Replace _All"), _("_Cancel"), _("Export")); progress_dialog.set_modal(true); switch (response) { - case Gtk.ResponseType.APPLY: - return Exporter.Overwrite.REPLACE_ALL; + case 2: + return Exporter.Overwrite.RENAME; + + case 3: + return Exporter.Overwrite.RENAME_ALL; - case Gtk.ResponseType.YES: - return Exporter.Overwrite.YES; + case 4: + return Exporter.Overwrite.YES; - case Gtk.ResponseType.CANCEL: - return Exporter.Overwrite.CANCEL; + case 5: + return Exporter.Overwrite.REPLACE_ALL; - case Gtk.ResponseType.NO: - default: - return Exporter.Overwrite.NO; + case 6: + return Exporter.Overwrite.CANCEL; + + case 1: + default: + return Exporter.Overwrite.NO; } } @@ -340,4 +376,3 @@ public class ExporterUI { return export_error_dialog(file, remaining > 0) != Gtk.ResponseType.CANCEL; } } - diff --git a/src/LibraryFiles.vala b/src/LibraryFiles.vala index bbacb6c..4941742 100644 --- a/src/LibraryFiles.vala +++ b/src/LibraryFiles.vala @@ -26,18 +26,18 @@ public void select_copy_function() { // Thus, when the method returns success a file may exist already, and should be overwritten. // // This function is thread safe. -public File? generate_unique_file(string basename, MediaMetadata? metadata, time_t ts, out bool collision) +public File? generate_unique_file(string basename, MediaMetadata? metadata, DateTime ts, out bool collision) throws Error { // use exposure timestamp over the supplied one (which probably comes from the file's // modified time, or is simply time()), unless it's zero, in which case use current time - time_t timestamp = ts; + DateTime timestamp = ts; if (metadata != null) { MetadataDateTime? date_time = metadata.get_creation_date_time(); if (date_time != null) timestamp = date_time.get_timestamp(); - else if (timestamp == 0) - timestamp = time_t(); + else if (timestamp == null) + timestamp = new DateTime.now_utc(); } // build a directory tree inside the library @@ -71,7 +71,7 @@ public string convert_basename(string basename) { // This function is thread-safe. private File duplicate(File src, FileProgressCallback? progress_callback, bool blacklist) throws Error { - time_t timestamp = 0; + DateTime? timestamp = null; try { timestamp = query_file_modified(src); } catch (Error err) { diff --git a/src/LibraryMonitor.vala b/src/LibraryMonitor.vala index f9291d7..a291f15 100644 --- a/src/LibraryMonitor.vala +++ b/src/LibraryMonitor.vala @@ -96,7 +96,7 @@ public class LibraryMonitorPool { public class LibraryMonitor : DirectoryMonitor { private const int FLUSH_IMPORT_QUEUE_SEC = 3; - private const int IMPORT_ROLL_QUIET_SEC = 5 * 60; + private const int IMPORT_ROLL_QUIET_SEC = 5 * 60 * 1000 * 1000; private const int MIN_BLACKLIST_DURATION_MSEC = 5 * 1000; private const int MAX_VERIFY_EXISTING_MEDIA_JOBS = 5; @@ -217,7 +217,7 @@ public class LibraryMonitor : DirectoryMonitor { private Gee.HashSet<File> pending_imports = new Gee.HashSet<File>(file_hash, file_equal); private Gee.ArrayList<BatchImport> batch_import_queue = new Gee.ArrayList<BatchImport>(); private BatchImportRoll current_import_roll = null; - private time_t last_import_roll_use = 0; + private int64 last_import_roll_use = 0; private BatchImport current_batch_import = null; private int checksums_completed = 0; private int checksums_total = 0; @@ -583,7 +583,7 @@ public class LibraryMonitor : DirectoryMonitor { // 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(); + var now = GLib.get_monotonic_time(); if (current_import_roll == null || (now - last_import_roll_use) >= IMPORT_ROLL_QUIET_SEC) current_import_roll = new BatchImportRoll(); last_import_roll_use = now; @@ -996,7 +996,7 @@ public class LibraryMonitor : DirectoryMonitor { } if (!known) { - // ressurrect tombstone if deleted + // resurrect tombstone if deleted Tombstone? tombstone = Tombstone.global.locate(file); if (tombstone != null) { debug("Resurrecting tombstoned file %s", file.get_path()); diff --git a/src/MapWidget.vala b/src/MapWidget.vala new file mode 100644 index 0000000..ddfae38 --- /dev/null +++ b/src/MapWidget.vala @@ -0,0 +1,788 @@ +/* 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. + */ + +private class MarkerImageSet { + public float marker_image_width; + public float marker_image_height; + public Clutter.Image? marker_image; + public Clutter.Image? marker_selected_image; + public Clutter.Image? marker_highlighted_image; +} + +private enum SelectionAction { + SET, + ADD, + REMOVE +} + +private abstract class PositionMarker : Object { + protected bool _highlighted = false; + protected bool _selected = false; + protected MarkerImageSet image_set; + + protected PositionMarker(Champlain.Marker champlain_marker, MarkerImageSet image_set) { + this.champlain_marker = champlain_marker; + this.image_set = image_set; + champlain_marker.selectable = true; + champlain_marker.set_content(image_set.marker_image); + float w = image_set.marker_image_width; + float h = image_set.marker_image_height; + champlain_marker.set_size(w, h); + champlain_marker.set_translation(-w * MapWidget.MARKER_IMAGE_HORIZONTAL_PIN_RATIO, + -h * MapWidget.MARKER_IMAGE_VERTICAL_PIN_RATIO, 0); + } + + public Champlain.Marker champlain_marker { get; protected set; } + + public bool highlighted { + get { + return _highlighted; + } + set { + if (_highlighted == value) + return; + _highlighted = value; + var base_image = _selected ? image_set.marker_selected_image : image_set.marker_image; + champlain_marker.set_content(value ? image_set.marker_highlighted_image : base_image); + } + } + public bool selected { + get { + return _selected; + } + set { + if (_selected == value) + return; + _selected = value; + if (!_highlighted) { + var base_image = value ? image_set.marker_selected_image : image_set.marker_image; + champlain_marker.set_content(base_image); + } + champlain_marker.set_selected(value); + } + } +} + +private class DataViewPositionMarker : PositionMarker { + private Gee.LinkedList<weak DataViewPositionMarker> _data_view_position_markers = + new Gee.LinkedList<weak DataViewPositionMarker>(); + + public weak DataView view { get; protected set; } + + public DataViewPositionMarker(DataView view, Champlain.Marker champlain_marker, + MarkerImageSet image_set) { + base(champlain_marker, image_set); + this.view = view; + + this._data_view_position_markers.add(this); + } + + public void bind_mouse_events(MapWidget map_widget) { + champlain_marker.button_release_event.connect ((event) => { + if (event.button > 1) + return true; + bool mod = (bool)(event.modifier_state & + (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK)); + SelectionAction action = SelectionAction.SET; + if (mod) + action = _selected ? SelectionAction.REMOVE : SelectionAction.ADD; + selected = (action != SelectionAction.REMOVE); + map_widget.select_data_views(_data_view_position_markers, action); + return true; + }); + champlain_marker.enter_event.connect ((event) => { + highlighted = true; + map_widget.highlight_data_views(_data_view_position_markers); + return true; + }); + champlain_marker.leave_event.connect ((event) => { + highlighted = false; + map_widget.unhighlight_data_views(_data_view_position_markers); + return true; + }); + } +} + +private class MarkerGroup : PositionMarker { + private Gee.Collection<weak DataViewPositionMarker> _data_view_position_markers = + new Gee.LinkedList<weak DataViewPositionMarker>(); + private Gee.Collection<PositionMarker> _position_markers = new Gee.LinkedList<PositionMarker>(); + private Champlain.BoundingBox bbox = new Champlain.BoundingBox(); + + public void bind_mouse_events(MapWidget map_widget) { + champlain_marker.button_release_event.connect ((event) => { + if (event.button > 1) + return true; + bool mod = (bool)(event.modifier_state & + (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK)); + SelectionAction action = SelectionAction.SET; + if (mod) + action = _selected ? SelectionAction.REMOVE : SelectionAction.ADD; + selected = (action != SelectionAction.REMOVE); + foreach (var m in _data_view_position_markers) { + m.selected = _selected; + } + map_widget.select_data_views(_data_view_position_markers.read_only_view, action); + return true; + }); + champlain_marker.enter_event.connect ((event) => { + highlighted = true; + map_widget.highlight_data_views(_data_view_position_markers.read_only_view); + return true; + }); + champlain_marker.leave_event.connect ((event) => { + highlighted = false; + map_widget.unhighlight_data_views(_data_view_position_markers.read_only_view); + return true; + }); + } + + public Gee.Collection<PositionMarker> position_markers { + owned get { return _position_markers.read_only_view; } + } + + public MarkerGroup(Champlain.Marker champlain_marker, MarkerImageSet image_set) { + base(champlain_marker, image_set); + } + + public void add_position_marker(PositionMarker marker) { + var data_view_position_marker = marker as DataViewPositionMarker; + if (data_view_position_marker != null) + _data_view_position_markers.add(data_view_position_marker); + var new_champlain_marker = marker.champlain_marker; + bbox.extend(new_champlain_marker.latitude, new_champlain_marker.longitude); + double lat, lon; + bbox.get_center(out lat, out lon); + champlain_marker.set_location(lat, lon); + _position_markers.add(marker); + } +} + +private class MarkerGroupRaster : Object { + private const long MARKER_GROUP_RASTER_WIDTH_PX = 30l; + private const long MARKER_GROUP_RASTER_HEIGHT_PX = 30l; + + private weak MapWidget map_widget; + private weak Champlain.View map_view; + private weak Champlain.MarkerLayer marker_layer; + + public bool is_empty { + get { + return position_markers.is_empty; + } + } + + // position_markers_tree is a two-dimensional tree for grouping position + // markers indexed by x (outer tree) and y (inner tree) raster coordinates. + // It maps coordinates to the PositionMarker (DataViewMarker or MarkerGroup) + // corresponding to them. + // If either raster index keys are empty, there is no marker within the + // raster cell. If both exist there are two possibilities: + // (1) the value is a MarkerGroup which means that multiple markers are + // grouped together, or (2) the value is a PositionMarker (but not a + // MarkerGroup) which means that there is exactly one marker in the raster + // cell. The tree is recreated every time the zoom level changes. + private Gee.TreeMap<long, Gee.TreeMap<long, unowned PositionMarker?>?> position_markers_tree = + new Gee.TreeMap<long, Gee.TreeMap<long, unowned PositionMarker?>?>(); + // The marker group's collection keeps track of and owns all PositionMarkers including the marker groups + private Gee.Map<DataView, unowned PositionMarker> data_view_map = new Gee.HashMap<DataView, unowned PositionMarker>(); + private Gee.Set<PositionMarker> position_markers = new Gee.HashSet<PositionMarker>(); + + public MarkerGroupRaster(MapWidget map_widget, Champlain.View map_view, Champlain.MarkerLayer marker_layer) { + this.map_widget = map_widget; + this.map_view = map_view; + this.marker_layer = marker_layer; + map_widget.zoom_changed.connect(regroup); + } + + public void clear() { + lock (position_markers) { + data_view_map.clear(); + position_markers_tree.clear(); + position_markers.clear(); + } + } + + public void clear_selection() { + lock (position_markers) { + foreach (PositionMarker m in position_markers) { + m.selected = false; + } + } + } + + public unowned PositionMarker? find_position_marker(DataView data_view) { + if (!data_view_map.has_key(data_view)) + return null; + unowned PositionMarker? m; + lock (position_markers) { + m = data_view_map.get(data_view); + } + return m; + } + + public void rasterize_marker(PositionMarker position_marker, bool already_on_map=false) { + var data_view_position_marker = position_marker as DataViewPositionMarker; + var champlain_marker = position_marker.champlain_marker; + long x, y; + + lock (position_markers) { + rasterize_coords(champlain_marker.longitude, champlain_marker.latitude, out x, out y); + var yg = position_markers_tree.get(x); + if (yg == null) { + yg = new Gee.TreeMap<long, unowned PositionMarker?>(); + position_markers_tree.set(x, yg); + } + var cell = yg.get(y); + if (cell == null) { + // first marker in this raster cell + yg.set(y, position_marker); + position_markers.add(position_marker); + if (!already_on_map) + marker_layer.add_marker(position_marker.champlain_marker); + if (data_view_position_marker != null) + data_view_map.set(data_view_position_marker.view, position_marker); + + } else { + var marker_group = cell as MarkerGroup; + if (marker_group == null) { + // single marker already occupies raster cell: create new group + GpsCoords rasterized_gps_coords = GpsCoords() { + has_gps = 1, + longitude = map_view.x_to_longitude(x), + latitude = map_view.y_to_latitude(y) + }; + marker_group = map_widget.create_marker_group(rasterized_gps_coords); + marker_group.add_position_marker(cell); + if (cell.selected) // group becomes selected if any contained marker is + marker_group.selected = true; + if (cell is DataViewPositionMarker) + data_view_map.set(((DataViewPositionMarker) cell).view, marker_group); + yg.set(y, marker_group); + position_markers.add(marker_group); + position_markers.remove(cell); + marker_layer.add_marker(marker_group.champlain_marker); + marker_layer.remove_marker(cell.champlain_marker); + } + // group already exists, add new marker to it + marker_group.add_position_marker(position_marker); + if (already_on_map) + marker_layer.remove_marker(position_marker.champlain_marker); + if (data_view_position_marker != null) + data_view_map.set(data_view_position_marker.view, marker_group); + } + } + } + + private void rasterize_coords(double longitude, double latitude, out long x, out long y) { + x = (Math.lround(map_view.longitude_to_x(longitude) / MARKER_GROUP_RASTER_WIDTH_PX)) * + MARKER_GROUP_RASTER_WIDTH_PX + (MARKER_GROUP_RASTER_WIDTH_PX / 2); + y = (Math.lround(map_view.latitude_to_y(latitude) / MARKER_GROUP_RASTER_HEIGHT_PX)) * + MARKER_GROUP_RASTER_HEIGHT_PX + (MARKER_GROUP_RASTER_HEIGHT_PX / 2); + } + + internal void regroup() { + lock (position_markers) { + var position_markers_current = (owned) position_markers; + position_markers = new Gee.HashSet<PositionMarker>(); + position_markers_tree.clear(); + + foreach (var pm in position_markers_current) { + var marker_group = pm as MarkerGroup; + if (marker_group != null) { + marker_layer.remove_marker(marker_group.champlain_marker); + foreach (var position_marker in marker_group.position_markers) { + rasterize_marker(position_marker, false); + } + } else { + rasterize_marker(pm, true); + } + } + position_markers_current = null; + } + } +} + +private class MapWidget : Gtk.Bin { + private const string MAPBOX_API_TOKEN = "pk.eyJ1IjoiamVuc2dlb3JnIiwiYSI6ImNqZ3FtYmhrMTBkOW8yeHBlNG8xN3hlNTAifQ.ek7i8UHeNIlkKi10fhgFgg"; + private const uint DEFAULT_ZOOM_LEVEL = 8; + + private static MapWidget instance = null; + private bool hide_map = false; + + private GtkChamplain.Embed gtk_champlain_widget = new GtkChamplain.Embed(); + private Champlain.View map_view = null; + private Champlain.Scale map_scale = new Champlain.Scale(); + private Champlain.MarkerLayer marker_layer = new Champlain.MarkerLayer(); + public bool map_edit_lock { get; set; } + private MarkerGroupRaster marker_group_raster = null; + private Gee.Map<DataView, unowned DataViewPositionMarker> data_view_marker_cache = + new Gee.HashMap<DataView, unowned DataViewPositionMarker>(); + private weak Page? page = null; + private Clutter.Image? map_edit_locked_image; + private Clutter.Image? map_edit_unlocked_image; + private Clutter.Actor map_edit_lock_button = new Clutter.Actor(); + private uint position_markers_timeout = 0; + + public const float MARKER_IMAGE_HORIZONTAL_PIN_RATIO = 0.5f; + public const float MARKER_IMAGE_VERTICAL_PIN_RATIO = 0.825f; + public float map_edit_lock_image_width { get; private set; } + public float map_edit_lock_image_height { get; private set; } + public MarkerImageSet marker_image_set { get; private set; } + public MarkerImageSet marker_group_image_set { get; private set; } + public const Clutter.Color marker_point_color = { 10, 10, 255, 192 }; + + public signal void zoom_changed(); + + private MapWidget() { + setup_map(); + add(gtk_champlain_widget); + } + + public static MapWidget get_instance() { + if (instance == null) + instance = new MapWidget(); + return instance; + } + + public override bool drag_motion(Gdk.DragContext context, int x, int y, uint time) { + if (!map_edit_lock) + map_view.stop_go_to(); + else + Gdk.drag_status(context, 0, time); + return true; + } + + public override void drag_data_received(Gdk.DragContext context, int x, int y, + Gtk.SelectionData selection_data, uint info, uint time) { + bool success = false; + Gee.List<MediaSource>? media = unserialize_media_sources(selection_data.get_data(), + selection_data.get_length()); + if (media != null && media.size > 0) { + double lat = map_view.y_to_latitude(y); + double lon = map_view.x_to_longitude(x); + success = internal_drop_received(media, lat, lon); + } + + Gtk.drag_finish(context, success, false, time); + } + + public new void set_visible(bool visible) { + /* hides Gtk.Widget.set_visible */ + hide_map = !visible; + base.set_visible(visible); + } + + public override void show_all() { + if (!hide_map) + base.show_all(); + } + + public void set_page(Page page) { + bool page_changed = false; + if (this.page != page) { + this.page = page; + page_changed = true; + clear(); + } + ViewCollection view_collection = page.get_view(); + if (view_collection == null) + return; + + if (page_changed) { + data_view_marker_cache.clear(); + foreach (DataObject view in view_collection.get_all()) { + if (view is DataView) + add_data_view((DataView) view); + } + show_position_markers(); + } + // In any case, the selection did change.. + var selected = view_collection.get_selected(); + if (selected != null) { + marker_group_raster.clear_selection(); + foreach (DataView v in view_collection.get_selected()) { + + var position_marker = marker_group_raster.find_position_marker(v); + if (position_marker != null) + position_marker.selected = true; + if (position_marker is MarkerGroup) { + DataViewPositionMarker? m = data_view_marker_cache.get(v); + if (m != null) + m.selected = true; + } + } + } + } + + public void clear() { + data_view_marker_cache.clear(); + marker_layer.remove_all(); + marker_group_raster.clear(); + } + + public void add_data_view(DataView view) { + DataSource view_source = view.get_source(); + if (!(view_source is Positionable)) + return; + Positionable p = (Positionable) view_source; + GpsCoords gps_coords = p.get_gps_coords(); + if (gps_coords.has_gps <= 0) + return; + PositionMarker position_marker = create_position_marker(view); + marker_group_raster.rasterize_marker(position_marker); + } + + public void show_position_markers() { + if (marker_group_raster.is_empty) + return; + + map_view.stop_go_to(); + double lat, lon; + var bbox = marker_layer.get_bounding_box(); + var zoom_level = map_view.get_zoom_level(); + var zoom_level_test = zoom_level < 2 ? 0 : zoom_level - 2; + bbox.get_center(out lat, out lon); + + if (map_view.get_bounding_box_for_zoom_level(zoom_level_test).covers(lat, lon)) { + // Don't zoom in/out if target is in proximity + map_view.ensure_visible(bbox, true); + } else if (zoom_level >= DEFAULT_ZOOM_LEVEL) { + // zoom out to DEFAULT_ZOOM_LEVEL first, then move + map_view.set_zoom_level(DEFAULT_ZOOM_LEVEL); + map_view.ensure_visible(bbox, true); + } else { + // move first, then zoom in to DEFAULT_ZOOM_LEVEL + map_view.go_to(lat, lon); + // There seems to be a runtime issue with the animation_completed signal + // sig = map_view.animation_completed["go-to"].connect((v) => { ... } + // so we're using a timeout-based approach instead. It should be kept in sync with + // the animation time (500ms by default.) + if (position_markers_timeout > 0) + Source.remove(position_markers_timeout); + position_markers_timeout = Timeout.add(500, () => { + map_view.center_on(lat, lon); // ensure the timeout wasn't too fast + if (map_view.get_zoom_level() < DEFAULT_ZOOM_LEVEL) + map_view.set_zoom_level(DEFAULT_ZOOM_LEVEL); + map_view.ensure_visible(bbox, true); + position_markers_timeout = 0; + return Source.REMOVE; + }); + } + } + + public void select_data_views(Gee.Collection<unowned DataViewPositionMarker> ms, + SelectionAction action = SelectionAction.SET) { + if (page == null) + return; + + ViewCollection page_view = page.get_view(); + if (page_view != null) { + Marker marked = page_view.start_marking(); + foreach (var m in ms) { + if (m.view is CheckerboardItem) { + marked.mark(m.view); + } + } + if (action == SelectionAction.REMOVE) { + page_view.unselect_marked(marked); + } else { + if (action == SelectionAction.SET) + page_view.unselect_all(); + page_view.select_marked(marked); + } + } + } + + public void highlight_data_views(Gee.Collection<unowned DataViewPositionMarker> ms) { + if (page == null) + return; + + bool did_adjust_view = false; + foreach (var m in ms) { + if (!(m.view is CheckerboardItem)) { + continue; + } + + CheckerboardItem item = m.view as CheckerboardItem; + + if (!did_adjust_view && page is CheckerboardPage) { + ((CheckerboardPage) page).scroll_to_item(item); + did_adjust_view = true; + } + item.brighten(); + } + } + + public void unhighlight_data_views(Gee.Collection<unowned DataViewPositionMarker> ms) { + if (page == null) + return; + + foreach (var m in ms) { + if (m.view is CheckerboardItem) { + CheckerboardItem item = (CheckerboardItem) m.view; + item.unbrighten(); + } + } + } + + public void highlight_position_marker(DataView v) { + var position_marker = marker_group_raster.find_position_marker(v); + if (position_marker != null) { + position_marker.highlighted = true; + } + } + + public void unhighlight_position_marker(DataView v) { + var position_marker = marker_group_raster.find_position_marker(v); + if (position_marker != null) { + position_marker.highlighted = false; + } + } + + public void media_source_position_changed(Gee.List<MediaSource> media, GpsCoords gps_coords) { + if (page == null) + return; + var view_collection = page.get_view(); + foreach (var source in media) { + var view = view_collection.get_view_for_source(source); + if (view == null) + continue; + var marker = data_view_marker_cache.get(view); + if (marker != null) { + if (gps_coords.has_gps > 0) { + // update individual marker cache + marker.champlain_marker.set_location(gps_coords.latitude, gps_coords.longitude); + } else { + // TODO: position removal not supported by GUI + // remove marker from cache, map_layer + // remove from marker_group_raster (needs a removal method which also removes the + // item from the group if (marker_group_raster.find_position_marker(view) is MarkerGroup) + } + } + } + marker_group_raster.regroup(); + } + + private Champlain.MapSource create_map_source() { + var map_source = new Champlain.MapSourceChain(); + var file_cache = new Champlain.FileCache.full(10 * 1024 * 1024, + AppDirs.get_cache_dir().get_child("tiles").get_child("mapbox-outdoors").get_path(), + new Champlain.ImageRenderer()); + var memory_cache = new Champlain.MemoryCache.full(10 * 1024 * 1024, new Champlain.ImageRenderer()); + var error_source = new Champlain.NullTileSource.full(new Champlain.ImageRenderer()); + + var tile_source = new Champlain.NetworkTileSource.full("mapbox-outdoors", + "Mapbox outdoors tiles", + "", + "", + 0, + 19, + 512, + Champlain.MapProjection.MERCATOR, + "https://api.mapbox.com/styles/v1/mapbox/outdoors-v11/tiles/#Z#/#X#/#Y#?access_token=" + + MAPBOX_API_TOKEN, + new Champlain.ImageRenderer()); + + var user_agent = "Shotwell/%s libchamplain/%s".printf(_VERSION, Champlain.VERSION_S); + tile_source.set_user_agent(user_agent); + tile_source.max_conns = 2; + + map_source.push(error_source); + map_source.push(tile_source); + map_source.push(file_cache); + map_source.push(memory_cache); + + return map_source; + } + + private Clutter.Actor create_attribution_actor() { + const string IMPROVE_TEXT = N_("Improve this map"); + var label = new Gtk.Label(null); + label.set_markup("<a href=\"https://www.mapbox.com/about/maps/\">© Mapbox</a> <a href=\"https://openstreetmap.org/about/\">© OpenStreetMap</a> <a href=\"https://www.mapbox.com/map-feedback/\">%s</a>".printf(IMPROVE_TEXT)); + label.get_style_context().add_class("map-attribution"); + + return new GtkClutter.Actor.with_contents(label); + } + + private void setup_map() { + map_view = gtk_champlain_widget.get_view(); + map_view.add_layer(marker_layer); + map_view.set_map_source(create_map_source()); + + var map_attribution_text = create_attribution_actor(); + map_attribution_text.content_gravity = Clutter.ContentGravity.BOTTOM_RIGHT; + map_attribution_text.set_x_align(Clutter.ActorAlign.END); + map_attribution_text.set_x_expand(true); + map_attribution_text.set_y_align(Clutter.ActorAlign.END); + map_attribution_text.set_y_expand(true); + + // add lock/unlock button to top left corner of map + map_edit_lock_button.content_gravity = Clutter.ContentGravity.TOP_RIGHT; + map_edit_lock_button.reactive = true; + map_edit_lock_button.set_x_align(Clutter.ActorAlign.END); + map_edit_lock_button.set_x_expand(true); + map_edit_lock_button.set_y_align(Clutter.ActorAlign.START); + map_edit_lock_button.set_y_expand(true); + + map_edit_lock_button.button_release_event.connect((a, e) => { + if (e.button != 1 /* CLUTTER_BUTTON_PRIMARY */) + return false; + map_edit_lock = !map_edit_lock; + map_edit_lock_button.set_content(map_edit_lock ? + map_edit_locked_image : map_edit_unlocked_image); + return true; + }); + map_view.add_child(map_edit_lock_button); + map_view.add_child(map_attribution_text); + + gtk_champlain_widget.has_tooltip = true; + gtk_champlain_widget.query_tooltip.connect((x, y, keyboard_tooltip, tooltip) => { + Gdk.Rectangle lock_rect = { + (int) map_edit_lock_button.x, + (int) map_edit_lock_button.y, + (int) map_edit_lock_button.width, + (int) map_edit_lock_button.height, + }; + Gdk.Rectangle mouse_pos = { x, y, 1, 1 }; + if (!lock_rect.intersect(mouse_pos, null)) + return false; + tooltip.set_text(_("Lock or unlock map for geotagging by dragging pictures onto the map")); + return true; + }); + + // add scale to bottom left corner of the map + map_scale.content_gravity = Clutter.ContentGravity.BOTTOM_LEFT; + map_scale.connect_view(map_view); + map_scale.set_x_align(Clutter.ActorAlign.START); + map_scale.set_x_expand(true); + map_scale.set_y_align(Clutter.ActorAlign.END); + map_scale.set_y_expand(true); + map_view.add_child(map_scale); + + map_view.set_zoom_on_double_click(false); + map_view.notify.connect((o, p) => { + if (p.name == "zoom-level") + zoom_changed(); + }); + + Gtk.TargetEntry[] dnd_targets = { + LibraryWindow.DND_TARGET_ENTRIES[LibraryWindow.TargetType.URI_LIST], + LibraryWindow.DND_TARGET_ENTRIES[LibraryWindow.TargetType.MEDIA_LIST] + }; + Gtk.drag_dest_set(this, Gtk.DestDefaults.ALL, dnd_targets, + Gdk.DragAction.COPY | Gdk.DragAction.LINK | Gdk.DragAction.ASK); + button_press_event.connect(map_zoom_handler); + set_size_request(200, 200); + + marker_group_raster = new MarkerGroupRaster(this, map_view, marker_layer); + + // Load icons + float w, h; + marker_image_set = new MarkerImageSet(); + marker_group_image_set = new MarkerImageSet(); + marker_image_set.marker_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_MARKER, out w, out h); + marker_image_set.marker_image_width = w; + marker_image_set.marker_image_height = h; + marker_image_set.marker_selected_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_MARKER_SELECTED, out w, out h); + marker_image_set.marker_highlighted_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_MARKER_HIGHLIGHTED, out w, out h); + + marker_group_image_set.marker_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_GROUP_MARKER, out w, out h); + marker_group_image_set.marker_image_width = w; + marker_group_image_set.marker_image_height = h; + marker_group_image_set.marker_selected_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_GROUP_MARKER_SELECTED, out w, out h); + marker_group_image_set.marker_highlighted_image = Resources.get_icon_as_clutter_image( + Resources.ICON_GPS_GROUP_MARKER_HIGHLIGHTED, out w, out h); + + map_edit_locked_image = Resources.get_icon_as_clutter_image( + Resources.ICON_MAP_EDIT_LOCKED, out w, out h); + map_edit_unlocked_image = Resources.get_icon_as_clutter_image( + Resources.ICON_MAP_EDIT_UNLOCKED, out w, out h); + map_edit_lock_image_width = w; + map_edit_lock_image_height = h; + if (map_edit_locked_image == null) { + warning("Couldn't load map edit lock image"); + } else { + map_edit_lock_button.set_content(map_edit_locked_image); + map_edit_lock_button.set_size(map_edit_lock_image_width, map_edit_lock_image_height); + map_edit_lock = true; + } + } + + private Champlain.Marker create_champlain_marker(GpsCoords gps_coords) { + assert(gps_coords.has_gps > 0); + + Champlain.Marker champlain_marker; + champlain_marker = new Champlain.Marker(); + champlain_marker.set_pivot_point(0.5f, 0.5f); // set center of marker + champlain_marker.set_location(gps_coords.latitude, gps_coords.longitude); + return champlain_marker; + } + + private DataViewPositionMarker create_position_marker(DataView view) { + var position_marker = data_view_marker_cache.get(view); + if (position_marker != null) + return position_marker; + DataSource data_source = view.get_source(); + Positionable p = (Positionable) data_source; + GpsCoords gps_coords = p.get_gps_coords(); + Champlain.Marker champlain_marker = create_champlain_marker(gps_coords); + position_marker = new DataViewPositionMarker(view, champlain_marker, marker_image_set); + position_marker.bind_mouse_events(this); + data_view_marker_cache.set(view, position_marker); + return (owned) position_marker; + } + + internal MarkerGroup create_marker_group(GpsCoords gps_coords) { + Champlain.Marker champlain_marker = create_champlain_marker(gps_coords); + var g = new MarkerGroup(champlain_marker, marker_group_image_set); + g.bind_mouse_events(this); + return (owned) g; + } + + private bool map_zoom_handler(Gdk.EventButton event) { + if (event.type == Gdk.EventType.2BUTTON_PRESS) { + if (event.button == 1 || event.button == 3) { + double lat = map_view.y_to_latitude(event.y); + double lon = map_view.x_to_longitude(event.x); + if (event.button == 1) { + map_view.zoom_in(); + } else { + map_view.zoom_out(); + } + map_view.center_on(lat, lon); + return true; + } + } + return false; + } + + private bool internal_drop_received(Gee.List<MediaSource> media, double lat, double lon) { + if (map_edit_lock) + return false; + + bool success = false; + GpsCoords gps_coords = GpsCoords() { + has_gps = 1, + latitude = lat, + longitude = lon + }; + foreach (var m in media) { + Positionable p = m as Positionable; + if (p != null) { + p.set_gps_coords(gps_coords); + success = true; + } + } + media_source_position_changed(media, gps_coords); + return success; + } +} diff --git a/src/MediaDataRepresentation.vala b/src/MediaDataRepresentation.vala index 6e6af00..3400577 100644 --- a/src/MediaDataRepresentation.vala +++ b/src/MediaDataRepresentation.vala @@ -7,10 +7,10 @@ public class BackingFileState { public string filepath; public int64 filesize; - public time_t modification_time; + public DateTime modification_time; public string? md5; - public BackingFileState(string filepath, int64 filesize, time_t modification_time, string? md5) { + public BackingFileState(string filepath, int64 filesize, DateTime modification_time, string? md5) { this.filepath = filepath; this.filesize = filesize; this.modification_time = modification_time; @@ -154,7 +154,7 @@ public abstract class MediaSource : ThumbnailSource, Indexable { public abstract File get_master_file(); public abstract uint64 get_master_filesize(); public abstract uint64 get_filesize(); - public abstract time_t get_timestamp(); + public abstract DateTime? get_timestamp(); // Must return at least one, for the master file. public abstract BackingFileState[] get_backing_files_state(); @@ -262,7 +262,7 @@ public abstract class MediaSource : ThumbnailSource, Indexable { controller.commit(); } - public abstract time_t get_exposure_time(); + public abstract DateTime? get_exposure_time(); public abstract ImportID get_import_id(); } diff --git a/src/MediaInterfaces.vala b/src/MediaInterfaces.vala index 1a352a2..f2f570c 100644 --- a/src/MediaInterfaces.vala +++ b/src/MediaInterfaces.vala @@ -206,7 +206,7 @@ public interface Monitorable : MediaSource { // from Photo to here in order to add this capability to videos. It should // fire a "metadata:exposure-time" alteration when called. public interface Dateable : MediaSource { - public abstract void set_exposure_time(time_t target_time); + public abstract void set_exposure_time(DateTime target_time); - public abstract time_t get_exposure_time(); + public abstract DateTime? get_exposure_time(); } diff --git a/src/MediaMetadata.vala b/src/MediaMetadata.vala deleted file mode 100644 index b2ba1b7..0000000 --- a/src/MediaMetadata.vala +++ /dev/null @@ -1,125 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -public abstract class MediaMetadata { - public abstract void read_from_file(File file) throws Error; - - public abstract MetadataDateTime? get_creation_date_time(); - - public abstract string? get_title(); - - public abstract string? get_comment(); -} - -public struct MetadataRational { - public int numerator; - public int denominator; - - public MetadataRational(int numerator, int denominator) { - this.numerator = numerator; - this.denominator = denominator; - } - - private bool is_component_valid(int component) { - return (component >= 0) && (component <= 1000000); - } - - public bool is_valid() { - return (is_component_valid(numerator) && is_component_valid(denominator)); - } - - public string to_string() { - return (is_valid()) ? ("%d/%d".printf(numerator, denominator)) : ""; - } -} - -public errordomain MetadataDateTimeError { - INVALID_FORMAT, - UNSUPPORTED_FORMAT -} - -public class MetadataDateTime { - - private time_t timestamp; - - public MetadataDateTime(time_t timestamp) { - this.timestamp = timestamp; - } - - public MetadataDateTime.from_exif(string label) throws MetadataDateTimeError { - if (!from_exif_date_time(label, out timestamp)) - throw new MetadataDateTimeError.INVALID_FORMAT("%s is not EXIF format date/time", label); - } - - public MetadataDateTime.from_iptc(string date, string time) throws MetadataDateTimeError { - // TODO: Support IPTC date/time format - throw new MetadataDateTimeError.UNSUPPORTED_FORMAT("IPTC date/time format not currently supported"); - } - - public MetadataDateTime.from_xmp(string label) throws MetadataDateTimeError { - TimeVal time_val = TimeVal(); - if (!time_val.from_iso8601(label)) - throw new MetadataDateTimeError.INVALID_FORMAT("%s is not XMP format date/time", label); - - timestamp = time_val.tv_sec; - } - - public time_t get_timestamp() { - return timestamp; - } - - public string get_exif_label() { - return to_exif_date_time(timestamp); - } - - // TODO: get_iptc_date() and get_iptc_time() - - public string get_xmp_label() { - TimeVal time_val = TimeVal(); - time_val.tv_sec = timestamp; - time_val.tv_usec = 0; - - return time_val.to_iso8601(); - } - - public static bool from_exif_date_time(string date_time, out time_t timestamp) { - timestamp = 0; - - Time tm = Time(); - - // Check standard EXIF format - if (date_time.scanf("%d:%d:%d %d:%d:%d", - &tm.year, &tm.month, &tm.day, &tm.hour, &tm.minute, &tm.second) != 6) { - // Fallback in a more generic format - string tmp = date_time.dup(); - tmp.canon("0123456789", ' '); - if (tmp.scanf("%4d%2d%2d%2d%2d%2d", - &tm.year, &tm.month, &tm.day, &tm.hour, &tm.minute,&tm.second) != 6) - return false; - } - - // watch for bogosity - if (tm.year <= 1900 || tm.month <= 0 || tm.day < 0 || tm.hour < 0 || tm.minute < 0 || tm.second < 0) - return false; - - tm.year -= 1900; - tm.month--; - tm.isdst = -1; - - timestamp = tm.mktime(); - - return true; - } - - public static string to_exif_date_time(time_t timestamp) { - return Time.local(timestamp).format("%Y:%m:%d %H:%M:%S"); - } - - public string to_string() { - return to_exif_date_time(timestamp); - } -} - diff --git a/src/MediaPage.vala b/src/MediaPage.vala index f849ac3..5fa3fca 100644 --- a/src/MediaPage.vala +++ b/src/MediaPage.vala @@ -90,10 +90,7 @@ public abstract class MediaPage : CheckerboardPage { } public static double scale_to_slider(int value) { - assert(value >= Thumbnail.MIN_SCALE); - assert(value <= Thumbnail.MAX_SCALE); - - return (double) ((value - Thumbnail.MIN_SCALE) / SLIDER_STEPPING); + return (double) ((value.clamp(Thumbnail.MIN_SCALE, Thumbnail.MAX_SCALE) - Thumbnail.MIN_SCALE) / SLIDER_STEPPING); } public static int slider_to_scale(double value) { @@ -887,7 +884,7 @@ public abstract class MediaPage : CheckerboardPage { case SortBy.EXPOSURE_DATE: if (ascending) comparator = Thumbnail.exposure_time_ascending_comparator; - else comparator = Thumbnail.exposure_time_desending_comparator; + else comparator = Thumbnail.exposure_time_descending_comparator; predicate = Thumbnail.exposure_time_comparator_predicate; break; diff --git a/src/MetadataWriter.vala b/src/MetadataWriter.vala index 0c23260..5fc26d1 100644 --- a/src/MetadataWriter.vala +++ b/src/MetadataWriter.vala @@ -15,7 +15,7 @@ public class MetadataWriter : Object { public const uint COMMIT_DELAY_MSEC = 3000; public const uint COMMIT_SPACING_MSEC = 50; - private const string[] INTERESTED_PHOTO_METADATA_DETAILS = { "name", "comment", "rating", "exposure-time" }; + private const string[] INTERESTED_PHOTO_METADATA_DETAILS = { "name", "comment", "rating", "exposure-time", "gps" }; private class CommitJob : BackgroundJob { public LibraryPhoto photo; @@ -108,18 +108,26 @@ public class MetadataWriter : Object { } // exposure date/time - time_t current_exposure_time = photo.get_exposure_time(); - time_t metadata_exposure_time = 0; + DateTime? current_exposure_time = photo.get_exposure_time(); + DateTime? metadata_exposure_time = null; MetadataDateTime? metadata_exposure_date_time = metadata.get_exposure_date_time(); if (metadata_exposure_date_time != null) metadata_exposure_time = metadata_exposure_date_time.get_timestamp(); if (current_exposure_time != metadata_exposure_time) { - metadata.set_exposure_date_time(current_exposure_time != 0 + metadata.set_exposure_date_time(current_exposure_time != null ? new MetadataDateTime(current_exposure_time) : null); changed = true; } + // gps location + GpsCoords current_gps_coords = photo.get_gps_coords(); + GpsCoords metadata_gps_coords = metadata.get_gps_coords(); + if (!current_gps_coords.equals(ref metadata_gps_coords)) { + metadata.set_gps_coords(current_gps_coords); + changed = true; + } + // tags (keywords) ... replace (or clear) entirely rather than union or intersection Gee.Set<string> safe_keywords = new Gee.HashSet<string>(); @@ -681,7 +689,7 @@ public class MetadataWriter : Object { try { job.photo.set_master_metadata_dirty(false); - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); } diff --git a/src/Page.vala b/src/Page.vala index 65b263b..6b07568 100644 --- a/src/Page.vala +++ b/src/Page.vala @@ -13,13 +13,13 @@ public class InjectionGroup { } public string name; public string action; - public string? accellerator; + public string? accelerator; public ItemType kind; - public Element(string name, string? action, string? accellerator, ItemType kind) { + public Element(string name, string? action, string? accelerator, ItemType kind) { this.name = name; this.action = action != null ? action : name; - this.accellerator = accellerator; + this.accelerator = accelerator; this.kind = kind; } } @@ -40,8 +40,8 @@ public class InjectionGroup { return elements; } - public void add_menu_item(string name, string? action = null, string? accellerator = null) { - elements.add(new Element(name, action, accellerator, Element.ItemType.MENUITEM)); + public void add_menu_item(string name, string? action = null, string? accelerator = null) { + elements.add(new Element(name, action, accelerator, Element.ItemType.MENUITEM)); } public void add_menu(string name, string? action = null) { @@ -68,7 +68,6 @@ public abstract class Page : Gtk.ScrolledWindow { private string toolbar_path; private Gdk.Rectangle last_position = Gdk.Rectangle(); private Gtk.Widget event_source = null; - private bool dnd_enabled = false; private ulong last_configure_ms = 0; private bool report_move_finished = false; private bool report_resize_finished = false; @@ -85,6 +84,9 @@ public abstract class Page : Gtk.ScrolledWindow { private int cursor_hide_time_cached = 0; private bool are_actions_attached = false; private OneShotScheduler? update_actions_scheduler = null; + + protected double wheel_factor = 0.0; + protected double modified_wheel_factor = 1.0; protected Page(string page_name) { this.page_name = page_name; @@ -193,8 +195,6 @@ public abstract class Page : Gtk.ScrolledWindow { event_source.leave_notify_event.disconnect(on_leave_notify_event); event_source.scroll_event.disconnect(on_mousewheel_internal); - disable_drag_source(); - event_source = null; } @@ -230,10 +230,10 @@ public abstract class Page : Gtk.ScrolledWindow { case InjectionGroup.Element.ItemType.MENUITEM: var item = new GLib.MenuItem (element.name, "win." + element.action); - if (element.accellerator != null) { + if (element.accelerator != null) { item.set_attribute ("accel", "s", - element.accellerator); + element.accelerator); } menu.append_item (item); @@ -592,76 +592,6 @@ public abstract class Page : Gtk.ScrolledWindow { protected virtual void update_actions(int selected_count, int count) { } - // This method enables drag-and-drop on the event source and routes its events through this - // object - public void enable_drag_source(Gdk.DragAction actions, Gtk.TargetEntry[] source_target_entries) { - if (dnd_enabled) - return; - - assert(event_source != null); - - Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, source_target_entries, actions); - - // hook up handlers which route the event_source's DnD signals to the Page's (necessary - // because Page is a NO_WINDOW widget and cannot support DnD on its own). - event_source.drag_begin.connect(on_drag_begin); - event_source.drag_data_get.connect(on_drag_data_get); - event_source.drag_data_delete.connect(on_drag_data_delete); - event_source.drag_end.connect(on_drag_end); - event_source.drag_failed.connect(on_drag_failed); - - dnd_enabled = true; - } - - public void disable_drag_source() { - if (!dnd_enabled) - return; - - assert(event_source != null); - - event_source.drag_begin.disconnect(on_drag_begin); - event_source.drag_data_get.disconnect(on_drag_data_get); - event_source.drag_data_delete.disconnect(on_drag_data_delete); - event_source.drag_end.disconnect(on_drag_end); - event_source.drag_failed.disconnect(on_drag_failed); - Gtk.drag_source_unset(event_source); - - dnd_enabled = false; - } - - public bool is_dnd_enabled() { - return dnd_enabled; - } - - private void on_drag_begin(Gdk.DragContext context) { - drag_begin(context); - } - - private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data, - uint info, uint time) { - drag_data_get(context, selection_data, info, time); - } - - private void on_drag_data_delete(Gdk.DragContext context) { - drag_data_delete(context); - } - - private void on_drag_end(Gdk.DragContext context) { - drag_end(context); - } - - // wierdly, Gtk 2.16.1 doesn't supply a drag_failed virtual method in the GtkWidget impl ... - // Vala binds to it, but it's not available in gtkwidget.h, and so gcc complains. Have to - // makeshift one for now. - // https://bugzilla.gnome.org/show_bug.cgi?id=584247 - public virtual bool source_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { - return false; - } - - private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { - return source_drag_failed(context, drag_result); - } - // Use this function rather than GDK or GTK's get_pointer, especially if called during a // button-down mouse drag (i.e. a window grab). // @@ -1032,13 +962,13 @@ public abstract class Page : Gtk.ScrolledWindow { double dx, dy; event.get_scroll_deltas(out dx, out dy); - if (dy < 0) + if (dy < -1.0 * this.wheel_factor) return on_mousewheel_up(event); - else if (dy > 0) + else if (dy > this.wheel_factor) return on_mousewheel_down(event); - else if (dx < 0) + else if (dx < -1.0 * this.wheel_factor) return on_mousewheel_left(event); - else if (dx > 0) + else if (dx > this.wheel_factor) return on_mousewheel_right(event); else return false; @@ -1115,15 +1045,19 @@ public abstract class Page : Gtk.ScrolledWindow { } public void stop_cursor_hiding() { - if (last_timeout_id != 0) + if (last_timeout_id != 0) { Source.remove(last_timeout_id); + last_timeout_id = 0; + } } public void suspend_cursor_hiding() { cursor_hide_time_cached = cursor_hide_msec; - if (last_timeout_id != 0) + if (last_timeout_id != 0) { Source.remove(last_timeout_id); + last_timeout_id = 0; + } cursor_hide_msec = 0; } @@ -1209,1454 +1143,3 @@ public abstract class Page : Gtk.ScrolledWindow { } -public abstract class CheckerboardPage : Page { - private const int AUTOSCROLL_PIXELS = 50; - private const int AUTOSCROLL_TICKS_MSEC = 50; - - private CheckerboardLayout layout; - private string item_context_menu_path = null; - private string page_context_menu_path = null; - private Gtk.Viewport viewport = new Gtk.Viewport(null, null); - protected CheckerboardItem anchor = null; - protected CheckerboardItem cursor = null; - private CheckerboardItem current_hovered_item = null; - private bool autoscroll_scheduled = false; - private CheckerboardItem activated_item = null; - private Gee.ArrayList<CheckerboardItem> previously_selected = null; - - public enum Activator { - KEYBOARD, - MOUSE - } - - public struct KeyboardModifiers { - public KeyboardModifiers(Page page) { - ctrl_pressed = page.get_ctrl_pressed(); - alt_pressed = page.get_alt_pressed(); - shift_pressed = page.get_shift_pressed(); - super_pressed = page.get_super_pressed(); - } - - public bool ctrl_pressed; - public bool alt_pressed; - public bool shift_pressed; - public bool super_pressed; - } - - protected CheckerboardPage(string page_name) { - base (page_name); - - layout = new CheckerboardLayout(get_view()); - layout.set_name(page_name); - - set_event_source(layout); - - set_border_width(0); - set_shadow_type(Gtk.ShadowType.NONE); - - viewport.set_border_width(0); - viewport.set_shadow_type(Gtk.ShadowType.NONE); - - viewport.add(layout); - - // want to set_adjustments before adding to ScrolledWindow to let our signal handlers - // run first ... otherwise, the thumbnails draw late - layout.set_adjustments(get_hadjustment(), get_vadjustment()); - - add(viewport); - - // need to monitor items going hidden when dealing with anchor/cursor/highlighted items - get_view().items_hidden.connect(on_items_hidden); - get_view().contents_altered.connect(on_contents_altered); - get_view().items_state_changed.connect(on_items_state_changed); - get_view().items_visibility_changed.connect(on_items_visibility_changed); - - // scrollbar policy - set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - } - - public void init_item_context_menu(string path) { - item_context_menu_path = path; - } - - public void init_page_context_menu(string path) { - page_context_menu_path = path; - } - - public Gtk.Menu? get_context_menu() { - // show page context menu if nothing is selected - return (get_view().get_selected_count() != 0) ? get_item_context_menu() : - get_page_context_menu(); - } - - private Gtk.Menu item_context_menu; - public virtual Gtk.Menu? get_item_context_menu() { - if (item_context_menu == null) { - var model = this.builder.get_object (item_context_menu_path) - as GLib.MenuModel; - item_context_menu = new Gtk.Menu.from_model (model); - item_context_menu.attach_to_widget (this, null); - } - - return item_context_menu; - } - - private Gtk.Menu page_context_menu; - public override Gtk.Menu? get_page_context_menu() { - if (page_context_menu_path == null) - return null; - - if (page_context_menu == null) { - var model = this.builder.get_object (page_context_menu_path) - as GLib.MenuModel; - page_context_menu = new Gtk.Menu.from_model (model); - page_context_menu.attach_to_widget (this, null); - } - - return page_context_menu; - } - - protected override bool on_context_keypress() { - return popup_context_menu(get_context_menu()); - } - - protected virtual string get_view_empty_message() { - return _("No photos/videos"); - } - - protected virtual string get_filter_no_match_message() { - return _("No photos/videos found which match the current filter"); - } - - protected virtual void on_item_activated(CheckerboardItem item, Activator activator, - KeyboardModifiers modifiers) { - } - - public CheckerboardLayout get_checkerboard_layout() { - return layout; - } - - // Gets the search view filter for this page. - public abstract SearchViewFilter get_search_view_filter(); - - public virtual Core.ViewTracker? get_view_tracker() { - return null; - } - - public override void switching_from() { - layout.set_in_view(false); - get_search_view_filter().refresh.disconnect(on_view_filter_refresh); - - // unselect everything so selection won't persist after page loses focus - get_view().unselect_all(); - - base.switching_from(); - } - - public override void switched_to() { - layout.set_in_view(true); - get_search_view_filter().refresh.connect(on_view_filter_refresh); - on_view_filter_refresh(); - - if (get_view().get_selected_count() > 0) { - CheckerboardItem? item = (CheckerboardItem?) get_view().get_selected_at(0); - - // if item is in any way out of view, scroll to it - Gtk.Adjustment vadj = get_vadjustment(); - if (!(get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE - && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE))) { - - // scroll to see the new item - int top = 0; - if (item.allocation.y < vadj.get_value()) { - top = item.allocation.y; - top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } else { - top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size(); - top += CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } - - vadj.set_value(top); - - } - } - - base.switched_to(); - } - - private void on_view_filter_refresh() { - update_view_filter_message(); - } - - private void on_contents_altered(Gee.Iterable<DataObject>? added, - Gee.Iterable<DataObject>? removed) { - update_view_filter_message(); - } - - private void on_items_state_changed(Gee.Iterable<DataView> changed) { - update_view_filter_message(); - } - - private void on_items_visibility_changed(Gee.Collection<DataView> changed) { - update_view_filter_message(); - } - - private void update_view_filter_message() { - if (get_view().are_items_filtered_out() && get_view().get_count() == 0) { - set_page_message(get_filter_no_match_message()); - } else if (get_view().get_count() == 0) { - set_page_message(get_view_empty_message()); - } else { - unset_page_message(); - } - } - - public void set_page_message(string message) { - layout.set_message(message); - if (is_in_view()) - layout.queue_draw(); - } - - public void unset_page_message() { - layout.unset_message(); - if (is_in_view()) - layout.queue_draw(); - } - - public override void set_page_name(string name) { - base.set_page_name(name); - - layout.set_name(name); - } - - public CheckerboardItem? get_item_at_pixel(double x, double y) { - return layout.get_item_at_pixel(x, y); - } - - private void on_items_hidden(Gee.Iterable<DataView> hidden) { - foreach (DataView view in hidden) { - CheckerboardItem item = (CheckerboardItem) view; - - if (anchor == item) - anchor = null; - - if (cursor == item) - cursor = null; - - if (current_hovered_item == item) - current_hovered_item = null; - } - } - - protected override bool key_press_event(Gdk.EventKey event) { - bool handled = true; - - // mask out the modifiers we're interested in - uint state = event.state & Gdk.ModifierType.SHIFT_MASK; - - switch (Gdk.keyval_name(event.keyval)) { - case "Up": - case "KP_Up": - move_cursor(CompassPoint.NORTH); - select_anchor_to_cursor(state); - break; - - case "Down": - case "KP_Down": - move_cursor(CompassPoint.SOUTH); - select_anchor_to_cursor(state); - break; - - case "Left": - case "KP_Left": - move_cursor(CompassPoint.WEST); - select_anchor_to_cursor(state); - break; - - case "Right": - case "KP_Right": - move_cursor(CompassPoint.EAST); - select_anchor_to_cursor(state); - break; - - case "Home": - case "KP_Home": - CheckerboardItem? first = (CheckerboardItem?) get_view().get_first(); - if (first != null) - cursor_to_item(first); - select_anchor_to_cursor(state); - break; - - case "End": - case "KP_End": - CheckerboardItem? last = (CheckerboardItem?) get_view().get_last(); - if (last != null) - cursor_to_item(last); - select_anchor_to_cursor(state); - break; - - case "Return": - case "KP_Enter": - if (get_view().get_selected_count() == 1) - on_item_activated((CheckerboardItem) get_view().get_selected_at(0), - Activator.KEYBOARD, KeyboardModifiers(this)); - else - handled = false; - break; - - case "space": - Marker marker = get_view().mark(layout.get_cursor()); - get_view().toggle_marked(marker); - break; - - default: - handled = false; - break; - } - - if (handled) - return true; - - return (base.key_press_event != null) ? base.key_press_event(event) : true; - } - - protected override bool on_left_click(Gdk.EventButton event) { - // only interested in single-click and double-clicks for now - if ((event.type != Gdk.EventType.BUTTON_PRESS) && (event.type != Gdk.EventType.2BUTTON_PRESS)) - return false; - - // mask out the modifiers we're interested in - uint state = event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK); - - // use clicks for multiple selection and activation only; single selects are handled by - // button release, to allow for multiple items to be selected then dragged ... - CheckerboardItem item = get_item_at_pixel(event.x, event.y); - if (item != null) { - // ... however, there is no dragging if the user clicks on an interactive part of the - // CheckerboardItem (e.g. a tag) - if (layout.handle_left_click(item, event.x, event.y, event.state)) - return true; - - switch (state) { - case Gdk.ModifierType.CONTROL_MASK: - // with only Ctrl pressed, multiple selections are possible ... chosen item - // is toggled - Marker marker = get_view().mark(item); - get_view().toggle_marked(marker); - - if (item.is_selected()) { - anchor = item; - cursor = item; - } - break; - - case Gdk.ModifierType.SHIFT_MASK: - get_view().unselect_all(); - - if (anchor == null) - anchor = item; - - select_between_items(anchor, item); - - cursor = item; - break; - - case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: - // Ticket #853 - Make Ctrl + Shift + Mouse Button 1 able to start a new run - // of contiguous selected items without unselecting previously-selected items - // a la Nautilus. - // Same as the case for SHIFT_MASK, but don't unselect anything first. - if (anchor == null) - anchor = item; - - select_between_items(anchor, item); - - cursor = item; - break; - - default: - if (event.type == Gdk.EventType.2BUTTON_PRESS) { - activated_item = item; - } else { - // if the user has selected one or more items and is preparing for a drag, - // don't want to blindly unselect: if they've clicked on an unselected item - // unselect all and select that one; if they've clicked on a previously - // selected item, do nothing - if (!item.is_selected()) { - Marker all = get_view().start_marking(); - all.mark_many(get_view().get_selected()); - - get_view().unselect_and_select_marked(all, get_view().mark(item)); - } - } - - anchor = item; - cursor = item; - break; - } - layout.set_cursor(item); - } else { - // user clicked on "dead" area; only unselect if control is not pressed - // do we want similar behavior for shift as well? - if (state != Gdk.ModifierType.CONTROL_MASK) - get_view().unselect_all(); - - // grab previously marked items - previously_selected = new Gee.ArrayList<CheckerboardItem>(); - foreach (DataView view in get_view().get_selected()) - previously_selected.add((CheckerboardItem) view); - - layout.set_drag_select_origin((int) event.x, (int) event.y); - - return true; - } - - // need to determine if the signal should be passed to the DnD handlers - // Return true to block the DnD handler, false otherwise - - return get_view().get_selected_count() == 0; - } - - protected override bool on_left_released(Gdk.EventButton event) { - previously_selected = null; - - // if drag-selecting, stop here and do nothing else - if (layout.is_drag_select_active()) { - layout.clear_drag_select(); - anchor = cursor; - - return true; - } - - // only interested in non-modified button releases - if ((event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) != 0) - return false; - - // if the item was activated in the double-click, report it now - if (activated_item != null) { - on_item_activated(activated_item, Activator.MOUSE, KeyboardModifiers(this)); - activated_item = null; - - return true; - } - - CheckerboardItem item = get_item_at_pixel(event.x, event.y); - if (item == null) { - // released button on "dead" area - return true; - } - - if (cursor != item) { - // user released mouse button after moving it off the initial item, or moved from dead - // space onto one. either way, unselect everything - get_view().unselect_all(); - } else { - // the idea is, if a user single-clicks on an item with no modifiers, then all other items - // should be deselected, however, if they single-click in order to drag one or more items, - // they should remain selected, hence performing this here rather than on_left_click - // (item may not be selected if an unimplemented modifier key was used) - if (item.is_selected()) - get_view().unselect_all_but(item); - } - - return true; - } - - protected override bool on_right_click(Gdk.EventButton event) { - // only interested in single-clicks for now - if (event.type != Gdk.EventType.BUTTON_PRESS) - return false; - - // get what's right-clicked upon - CheckerboardItem item = get_item_at_pixel(event.x, event.y); - if (item != null) { - // mask out the modifiers we're interested in - switch (event.state & (Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK)) { - case Gdk.ModifierType.CONTROL_MASK: - // chosen item is toggled - Marker marker = get_view().mark(item); - get_view().toggle_marked(marker); - break; - - case Gdk.ModifierType.SHIFT_MASK: - // TODO - break; - - case Gdk.ModifierType.CONTROL_MASK | Gdk.ModifierType.SHIFT_MASK: - // TODO - break; - - default: - // if the item is already selected, proceed; if item is not selected, a bare right - // click unselects everything else but it - if (!item.is_selected()) { - Marker all = get_view().start_marking(); - all.mark_many(get_view().get_selected()); - - get_view().unselect_and_select_marked(all, get_view().mark(item)); - } - break; - } - } else { - // clicked in "dead" space, unselect everything - get_view().unselect_all(); - } - - Gtk.Menu context_menu = get_context_menu(); - return popup_context_menu(context_menu, event); - } - - protected virtual bool on_mouse_over(CheckerboardItem? item, int x, int y, Gdk.ModifierType mask) { - if (item != null) - layout.handle_mouse_motion(item, x, y, mask); - - // if hovering over the last hovered item, or both are null (nothing highlighted and - // hovering over empty space), do nothing - if (item == current_hovered_item) - return true; - - // either something new is highlighted or now hovering over empty space, so dim old item - if (current_hovered_item != null) { - current_hovered_item.handle_mouse_leave(); - current_hovered_item = null; - } - - // if over empty space, done - if (item == null) - return true; - - // brighten the new item - current_hovered_item = item; - current_hovered_item.handle_mouse_enter(); - - return true; - } - - protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) { - // report what item the mouse is hovering over - if (!on_mouse_over(get_item_at_pixel(x, y), x, y, mask)) - return false; - - // go no further if not drag-selecting - if (!layout.is_drag_select_active()) - return false; - - // set the new endpoint of the drag selection - layout.set_drag_select_endpoint(x, y); - - updated_selection_band(); - - // if out of bounds, schedule a check to auto-scroll the viewport - if (!autoscroll_scheduled - && get_adjustment_relation(get_vadjustment(), y) != AdjustmentRelation.IN_RANGE) { - Timeout.add(AUTOSCROLL_TICKS_MSEC, selection_autoscroll); - autoscroll_scheduled = true; - } - - // return true to stop a potential drag-and-drop operation - return true; - } - - private void updated_selection_band() { - assert(layout.is_drag_select_active()); - - // get all items inside the selection - Gee.List<CheckerboardItem>? intersection = layout.items_in_selection_band(); - if (intersection == null) - return; - - Marker to_unselect = get_view().start_marking(); - Marker to_select = get_view().start_marking(); - - // mark all selected items to be unselected - to_unselect.mark_many(get_view().get_selected()); - - // except for the items that were selected before the drag began - assert(previously_selected != null); - to_unselect.unmark_many(previously_selected); - to_select.mark_many(previously_selected); - - // toggle selection on everything in the intersection and update the cursor - cursor = null; - - foreach (CheckerboardItem item in intersection) { - if (to_select.toggle(item)) - to_unselect.unmark(item); - else - to_unselect.mark(item); - - if (cursor == null) - cursor = item; - } - - get_view().select_marked(to_select); - get_view().unselect_marked(to_unselect); - } - - private bool selection_autoscroll() { - if (!layout.is_drag_select_active()) { - autoscroll_scheduled = false; - - return false; - } - - // as the viewport never scrolls horizontally, only interested in vertical - Gtk.Adjustment vadj = get_vadjustment(); - - int x, y; - Gdk.ModifierType mask; - get_event_source_pointer(out x, out y, out mask); - - int new_value = (int) vadj.get_value(); - switch (get_adjustment_relation(vadj, y)) { - case AdjustmentRelation.BELOW: - // pointer above window, scroll up - new_value -= AUTOSCROLL_PIXELS; - layout.set_drag_select_endpoint(x, new_value); - break; - - case AdjustmentRelation.ABOVE: - // pointer below window, scroll down, extend selection to bottom of page - new_value += AUTOSCROLL_PIXELS; - layout.set_drag_select_endpoint(x, new_value + (int) vadj.get_page_size()); - break; - - case AdjustmentRelation.IN_RANGE: - autoscroll_scheduled = false; - - return false; - - default: - warn_if_reached(); - break; - } - - // It appears that in GTK+ 2.18, the adjustment is not clamped the way it was in 2.16. - // This may have to do with how adjustments are different w/ scrollbars, that they're upper - // clamp is upper - page_size ... either way, enforce these limits here - vadj.set_value(new_value.clamp((int) vadj.get_lower(), - (int) vadj.get_upper() - (int) vadj.get_page_size())); - - updated_selection_band(); - - return true; - } - - public void cursor_to_item(CheckerboardItem item) { - assert(get_view().contains(item)); - - cursor = item; - - if (!get_ctrl_pressed()) { - get_view().unselect_all(); - Marker marker = get_view().mark(item); - get_view().select_marked(marker); - } - layout.set_cursor(item); - - // if item is in any way out of view, scroll to it - Gtk.Adjustment vadj = get_vadjustment(); - if (get_adjustment_relation(vadj, item.allocation.y) == AdjustmentRelation.IN_RANGE - && (get_adjustment_relation(vadj, item.allocation.y + item.allocation.height) == AdjustmentRelation.IN_RANGE)) - return; - - // scroll to see the new item - int top = 0; - if (item.allocation.y < vadj.get_value()) { - top = item.allocation.y; - top -= CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } else { - top = item.allocation.y + item.allocation.height - (int) vadj.get_page_size(); - top += CheckerboardLayout.ROW_GUTTER_PADDING / 2; - } - - vadj.set_value(top); - } - - public void move_cursor(CompassPoint point) { - // if no items, nothing to do - if (get_view().get_count() == 0) - return; - - // if there is no better starting point, simply select the first and exit - // The right half of the or is related to Bug #732334, the cursor might be non-null and still not contained in - // the view, if the user dragged a full screen Photo off screen - if (cursor == null && layout.get_cursor() == null || cursor != null && !get_view().contains(cursor)) { - CheckerboardItem item = layout.get_item_at_coordinate(0, 0); - cursor_to_item(item); - anchor = item; - - return; - } - - if (cursor == null) { - cursor = layout.get_cursor() as CheckerboardItem; - } - - // move the cursor relative to the "first" item - CheckerboardItem? item = layout.get_item_relative_to(cursor, point); - if (item != null) - cursor_to_item(item); - } - - public void set_cursor(CheckerboardItem item) { - Marker marker = get_view().mark(item); - get_view().select_marked(marker); - - cursor = item; - anchor = item; - } - - public void select_between_items(CheckerboardItem item_start, CheckerboardItem item_end) { - Marker marker = get_view().start_marking(); - - bool passed_start = false; - bool passed_end = false; - - foreach (DataObject object in get_view().get_all()) { - CheckerboardItem item = (CheckerboardItem) object; - - if (item_start == item) - passed_start = true; - - if (item_end == item) - passed_end = true; - - if (passed_start || passed_end) - marker.mark((DataView) object); - - if (passed_start && passed_end) - break; - } - - get_view().select_marked(marker); - } - - public void select_anchor_to_cursor(uint state) { - if (cursor == null || anchor == null) - return; - - if (state == Gdk.ModifierType.SHIFT_MASK) { - get_view().unselect_all(); - select_between_items(anchor, cursor); - } else { - anchor = cursor; - } - } - - protected virtual void set_display_titles(bool display) { - get_view().freeze_notifications(); - get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES, display); - get_view().thaw_notifications(); - } - - protected virtual void set_display_comments(bool display) { - get_view().freeze_notifications(); - get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS, display); - get_view().thaw_notifications(); - } -} - -public abstract class SinglePhotoPage : Page { - public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST; - public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR; - public const int KEY_REPEAT_INTERVAL_MSEC = 200; - - public enum UpdateReason { - NEW_PIXBUF, - QUALITY_IMPROVEMENT, - RESIZED_CANVAS - } - - protected Gtk.DrawingArea canvas = new Gtk.DrawingArea(); - protected Gtk.Viewport viewport = new Gtk.Viewport(null, null); - - private bool scale_up_to_viewport; - private TransitionClock transition_clock; - private int transition_duration_msec = 0; - private Cairo.Surface pixmap = null; - private Cairo.Context pixmap_ctx = null; - private Cairo.Context text_ctx = null; - private Dimensions pixmap_dim = Dimensions(); - private Gdk.Pixbuf unscaled = null; - private Dimensions max_dim = Dimensions(); - private Gdk.Pixbuf scaled = null; - private Gdk.Pixbuf old_scaled = null; // previous scaled image - private Gdk.Rectangle scaled_pos = Gdk.Rectangle(); - private ZoomState static_zoom_state; - private bool zoom_high_quality = true; - private ZoomState saved_zoom_state; - private bool has_saved_zoom_state = false; - private uint32 last_nav_key = 0; - - protected SinglePhotoPage(string page_name, bool scale_up_to_viewport) { - base(page_name); - - this.scale_up_to_viewport = scale_up_to_viewport; - - transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); - - // With the current code automatically resizing the image to the viewport, scrollbars - // should never be shown, but this may change if/when zooming is supported - set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - - set_border_width(0); - set_shadow_type(Gtk.ShadowType.NONE); - - viewport.set_shadow_type(Gtk.ShadowType.NONE); - viewport.set_border_width(0); - viewport.add(canvas); - - add(viewport); - - canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK - | Gdk.EventMask.SUBSTRUCTURE_MASK); - - viewport.size_allocate.connect(on_viewport_resize); - canvas.draw.connect(on_canvas_exposed); - - set_event_source(canvas); - Config.Facade.get_instance().colors_changed.connect(on_colors_changed); - } - - ~SinglePhotoPage() { - Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed); - } - - public bool is_transition_in_progress() { - return transition_clock.is_in_progress(); - } - - public void cancel_transition() { - if (transition_clock.is_in_progress()) - transition_clock.cancel(); - } - - public void set_transition(string effect_id, int duration_msec) { - cancel_transition(); - - transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id); - if (transition_clock == null) - transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); - - transition_duration_msec = duration_msec; - } - - // This method includes a call to pixmap_ctx.paint(). - private void render_zoomed_to_pixmap(ZoomState zoom_state) { - assert(is_zoom_supported()); - - Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content(); - - Gdk.Pixbuf zoomed; - if (get_zoom_buffer() != null) { - zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) : - get_zoom_buffer().get_zoom_preview_image(zoom_state); - } else { - Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled); - - Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x, - view_rect_proj.y, view_rect_proj.width, view_rect_proj.height); - - zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height, - Gdk.InterpType.BILINEAR); - } - - if (zoomed == null) { - return; - } - - int draw_x = (pixmap_dim.width - view_rect.width) / 2; - draw_x = draw_x.clamp(0, int.MAX); - - int draw_y = (pixmap_dim.height - view_rect.height) / 2; - draw_y = draw_y.clamp(0, int.MAX); - paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y); - } - - protected void on_interactive_zoom(ZoomState interactive_zoom_state) { - assert(is_zoom_supported()); - - set_source_color_from_string(pixmap_ctx, "#000"); - pixmap_ctx.paint(); - - bool old_quality_setting = zoom_high_quality; - zoom_high_quality = false; - render_zoomed_to_pixmap(interactive_zoom_state); - zoom_high_quality = old_quality_setting; - - canvas.queue_draw(); - } - - protected void on_interactive_pan(ZoomState interactive_zoom_state) { - assert(is_zoom_supported()); - - set_source_color_from_string(pixmap_ctx, "#000"); - pixmap_ctx.paint(); - - bool old_quality_setting = zoom_high_quality; - zoom_high_quality = true; - render_zoomed_to_pixmap(interactive_zoom_state); - zoom_high_quality = old_quality_setting; - - canvas.queue_draw(); - } - - protected virtual bool is_zoom_supported() { - return false; - } - - protected virtual void cancel_zoom() { - if (pixmap != null) { - set_source_color_from_string(pixmap_ctx, "#000"); - pixmap_ctx.paint(); - } - } - - protected virtual void save_zoom_state() { - saved_zoom_state = static_zoom_state; - has_saved_zoom_state = true; - } - - protected virtual void restore_zoom_state() { - if (!has_saved_zoom_state) - return; - - static_zoom_state = saved_zoom_state; - repaint(); - has_saved_zoom_state = false; - } - - protected virtual ZoomBuffer? get_zoom_buffer() { - return null; - } - - protected ZoomState get_saved_zoom_state() { - return saved_zoom_state; - } - - protected void set_zoom_state(ZoomState zoom_state) { - assert(is_zoom_supported()); - - static_zoom_state = zoom_state; - } - - protected ZoomState get_zoom_state() { - assert(is_zoom_supported()); - - return static_zoom_state; - } - - public override void switched_to() { - base.switched_to(); - - if (unscaled != null) - repaint(); - } - - public override void set_container(Gtk.Window container) { - base.set_container(container); - - // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift - // off the screen - if (container is FullscreenWindow) - set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); - } - - // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and - // the caller capable of producing larger ones depending on the viewport size). max_dim - // is used when scale_up_to_viewport is set to true. Pass a Dimensions with no area if - // max_dim should be ignored (i.e. scale_up_to_viewport is false). - public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) { - static_zoom_state = ZoomState(max_dim, pixmap_dim, - static_zoom_state.get_interpolation_factor(), - static_zoom_state.get_viewport_center()); - - cancel_transition(); - - this.unscaled = unscaled; - this.max_dim = max_dim; - this.old_scaled = scaled; - scaled = null; - - // need to make sure this has happened - canvas.realize(); - - repaint(direction); - } - - public void blank_display() { - unscaled = null; - max_dim = Dimensions(); - scaled = null; - pixmap = null; - - // this has to have happened - canvas.realize(); - - // force a redraw - invalidate_all(); - } - - public Cairo.Surface? get_surface() { - return pixmap; - } - - public Dimensions get_surface_dim() { - return pixmap_dim; - } - - public Cairo.Context get_cairo_context() { - return pixmap_ctx; - } - - public void paint_text(Pango.Layout pango_layout, int x, int y) { - text_ctx.move_to(x, y); - Pango.cairo_show_layout(text_ctx, pango_layout); - } - - public Scaling get_canvas_scaling() { - return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), scale_up_to_viewport) - : Scaling.for_widget(viewport, scale_up_to_viewport); - } - - public Gdk.Pixbuf? get_unscaled_pixbuf() { - return unscaled; - } - - public Gdk.Pixbuf? get_scaled_pixbuf() { - return scaled; - } - - // Returns a rectangle describing the pixbuf in relation to the canvas - public Gdk.Rectangle get_scaled_pixbuf_position() { - return scaled_pos; - } - - public bool is_inside_pixbuf(int x, int y) { - return coord_in_rectangle(x, y, scaled_pos); - } - - public void invalidate(Gdk.Rectangle rect) { - if (canvas.get_window() != null) - canvas.get_window().invalidate_rect(rect, false); - } - - public void invalidate_all() { - if (canvas.get_window() != null) - canvas.get_window().invalidate_rect(null, false); - } - - private void on_viewport_resize() { - // do fast repaints while resizing - internal_repaint(true, null); - } - - protected override void on_resize_finished(Gdk.Rectangle rect) { - base.on_resize_finished(rect); - - // when the resize is completed, do a high-quality repaint - repaint(); - } - - private bool on_canvas_exposed(Cairo.Context exposed_ctx) { - // draw pixmap onto canvas unless it's not been instantiated, in which case draw black - // (so either old image or contents of another page is not left on screen) - if (pixmap != null) - exposed_ctx.set_source_surface(pixmap, 0, 0); - else - set_source_color_from_string(exposed_ctx, "#000"); - - exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height()); - exposed_ctx.paint(); - - return true; - } - - protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) { - } - - protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) { - } - - protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) { - if (is_zoom_supported() && (!static_zoom_state.is_default())) { - set_source_color_from_string(ctx, "#000"); - ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); - ctx.fill(); - - render_zoomed_to_pixmap(static_zoom_state); - } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) { - // transition is not running, so paint the full image on a black background - set_source_color_from_string(ctx, "#000"); - - ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); - ctx.fill(); - - paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y); - } - } - - private void repaint_pixmap() { - if (pixmap_ctx == null) - return; - - paint(pixmap_ctx, pixmap_dim); - invalidate_all(); - } - - public void repaint(Direction? direction = null) { - internal_repaint(false, direction); - } - - private void internal_repaint(bool fast, Direction? direction) { - // if not in view, assume a full repaint needed in future but do nothing more - if (!is_in_view()) { - pixmap = null; - scaled = null; - - return; - } - - // no image or window, no painting - if (unscaled == null || canvas.get_window() == null) - return; - - Gtk.Allocation allocation; - viewport.get_allocation(out allocation); - - int width = allocation.width; - int height = allocation.height; - - if (width <= 0 || height <= 0) - return; - - bool new_pixbuf = (scaled == null); - - // save if reporting an image being rescaled - Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos); - Gdk.Rectangle old_scaled_pos = scaled_pos; - - // attempt to reuse pixmap - if (pixmap_dim.width != width || pixmap_dim.height != height) - pixmap = null; - - // if necessary, create a pixmap as large as the entire viewport - bool new_pixmap = false; - if (pixmap == null) { - init_pixmap(width, height); - new_pixmap = true; - } - - if (new_pixbuf || new_pixmap) { - Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled); - - // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up, - // respect it - Dimensions scaled_dim = Dimensions(); - if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < height) - scaled_dim = max_dim; - else - scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim); - - assert(width >= scaled_dim.width); - assert(height >= scaled_dim.height); - - // center pixbuf on the canvas - scaled_pos.x = (width - scaled_dim.width) / 2; - scaled_pos.y = (height - scaled_dim.height) / 2; - scaled_pos.width = scaled_dim.width; - scaled_pos.height = scaled_dim.height; - } - - Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP; - - // rescale if canvas rescaled or better quality is requested - if (scaled == null) { - scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp); - - UpdateReason reason = UpdateReason.RESIZED_CANVAS; - if (new_pixbuf) - reason = UpdateReason.NEW_PIXBUF; - else if (!new_pixmap && interp == QUALITY_INTERP) - reason = UpdateReason.QUALITY_IMPROVEMENT; - - static_zoom_state = ZoomState(max_dim, pixmap_dim, - static_zoom_state.get_interpolation_factor(), - static_zoom_state.get_viewport_center()); - - updated_pixbuf(scaled, reason, old_scaled_dim); - } - - zoom_high_quality = !fast; - - if (direction != null && !transition_clock.is_in_progress()) { - Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled, - old_scaled_pos, scaled, scaled_pos, parse_color("#000")); - - transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec, - repaint_pixmap); - } - - if (!transition_clock.is_in_progress()) - repaint_pixmap(); - } - - private void init_pixmap(int width, int height) { - assert(unscaled != null); - assert(canvas.get_window() != null); - - // Cairo backing surface (manual double-buffering) - pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height); - pixmap_dim = Dimensions(width, height); - - // Cairo context for drawing on the pixmap - pixmap_ctx = new Cairo.Context(pixmap); - - // need a new pixbuf to fit this scale - scaled = null; - - // Cairo context for drawing text on the pixmap - text_ctx = new Cairo.Context(pixmap); - set_source_color_from_string(text_ctx, "#fff"); - - - // no need to resize canvas, viewport does that automatically - - new_surface(pixmap_ctx, pixmap_dim); - } - - protected override bool on_context_keypress() { - return popup_context_menu(get_page_context_menu()); - } - - protected virtual void on_previous_photo() { - } - - protected virtual void on_next_photo() { - } - - public override bool key_press_event(Gdk.EventKey event) { - // if the user holds the arrow keys down, we will receive a steady stream of key press - // events for an operation that isn't designed for a rapid succession of output ... - // we staunch the supply of new photos to under a quarter second (#533) - bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC; - - bool handled = true; - switch (Gdk.keyval_name(event.keyval)) { - case "Left": - case "KP_Left": - case "BackSpace": - if (nav_ok) { - on_previous_photo(); - last_nav_key = event.time; - } - break; - - case "Right": - case "KP_Right": - case "space": - if (nav_ok) { - on_next_photo(); - last_nav_key = event.time; - } - break; - - default: - handled = false; - break; - } - - if (handled) - return true; - - return (base.key_press_event != null) ? base.key_press_event(event) : true; - } - - private void on_colors_changed() { - invalidate_transparent_background(); - repaint(); - } -} - -// -// DragAndDropHandler attaches signals to a Page to properly handle drag-and-drop requests for the -// Page as a DnD Source. (DnD Destination handling is handled by the appropriate AppWindow, i.e. -// LibraryWindow and DirectWindow). Assumes the Page's ViewCollection holds MediaSources. -// -public class DragAndDropHandler { - private enum TargetType { - XDS, - MEDIA_LIST - } - - private const Gtk.TargetEntry[] SOURCE_TARGET_ENTRIES = { - { "XdndDirectSave0", Gtk.TargetFlags.OTHER_APP, TargetType.XDS }, - { "shotwell/media-id-atom", Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST } - }; - - private static Gdk.Atom? XDS_ATOM = null; - private static Gdk.Atom? TEXT_ATOM = null; - private static uint8[]? XDS_FAKE_TARGET = null; - - private weak Page page; - private Gtk.Widget event_source; - private File? drag_destination = null; - private ExporterUI exporter = null; - - public DragAndDropHandler(Page page) { - this.page = page; - this.event_source = page.get_event_source(); - assert(event_source != null); - assert(event_source.get_has_window()); - - // Need to do this because static member variables are not properly handled - if (XDS_ATOM == null) - XDS_ATOM = Gdk.Atom.intern_static_string("XdndDirectSave0"); - - if (TEXT_ATOM == null) - TEXT_ATOM = Gdk.Atom.intern_static_string("text/plain"); - - if (XDS_FAKE_TARGET == null) - XDS_FAKE_TARGET = string_to_uchar_array("shotwell.txt"); - - // register what's available on this DnD Source - Gtk.drag_source_set(event_source, Gdk.ModifierType.BUTTON1_MASK, SOURCE_TARGET_ENTRIES, - Gdk.DragAction.COPY); - - // attach to the event source's DnD signals, not the Page's, which is a NO_WINDOW widget - // and does not emit them - event_source.drag_begin.connect(on_drag_begin); - event_source.drag_data_get.connect(on_drag_data_get); - event_source.drag_end.connect(on_drag_end); - event_source.drag_failed.connect(on_drag_failed); - } - - ~DragAndDropHandler() { - if (event_source != null) { - event_source.drag_begin.disconnect(on_drag_begin); - event_source.drag_data_get.disconnect(on_drag_data_get); - event_source.drag_end.disconnect(on_drag_end); - event_source.drag_failed.disconnect(on_drag_failed); - } - - page = null; - event_source = null; - } - - private void on_drag_begin(Gdk.DragContext context) { - debug("on_drag_begin (%s)", page.get_page_name()); - - if (page == null || page.get_view().get_selected_count() == 0 || exporter != null) - return; - - drag_destination = null; - - // use the first media item as the icon - ThumbnailSource thumb = (ThumbnailSource) page.get_view().get_selected_at(0).get_source(); - - try { - Gdk.Pixbuf icon = thumb.get_thumbnail(AppWindow.DND_ICON_SCALE); - Gtk.drag_source_set_icon_pixbuf(event_source, icon); - } catch (Error err) { - warning("Unable to fetch icon for drag-and-drop from %s: %s", thumb.to_string(), - err.message); - } - - // set the XDS property to indicate an XDS save is available -#if VALA_0_20 - Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE, - XDS_FAKE_TARGET, 1); -#else - Gdk.property_change(context.get_source_window(), XDS_ATOM, TEXT_ATOM, 8, Gdk.PropMode.REPLACE, - XDS_FAKE_TARGET); -#endif - } - - private void on_drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data, - uint target_type, uint time) { - debug("on_drag_data_get (%s)", page.get_page_name()); - - if (page == null || page.get_view().get_selected_count() == 0) - return; - - switch (target_type) { - case TargetType.XDS: - // Fetch the XDS property that has been set with the destination path - uchar[] data = new uchar[4096]; - Gdk.Atom actual_type; - int actual_format = 0; - bool fetched = Gdk.property_get(context.get_source_window(), XDS_ATOM, TEXT_ATOM, - 0, data.length, 0, out actual_type, out actual_format, out data); - - // the destination path is actually for our XDS_FAKE_TARGET, use its parent - // to determine where the file(s) should go - if (fetched && data != null && data.length > 0) - drag_destination = File.new_for_uri(uchar_array_to_string(data)).get_parent(); - - debug("on_drag_data_get (%s): %s", page.get_page_name(), - (drag_destination != null) ? drag_destination.get_path() : "(no path)"); - - // Set the property to "S" for Success or "E" for Error - selection_data.set(XDS_ATOM, 8, - string_to_uchar_array((drag_destination != null) ? "S" : "E")); - break; - - case TargetType.MEDIA_LIST: - Gee.Collection<MediaSource> sources = - (Gee.Collection<MediaSource>) page.get_view().get_selected_sources(); - - // convert the selected media sources to Gdk.Atom-encoded sourceID strings for - // internal drag-and-drop - selection_data.set(Gdk.Atom.intern_static_string("SourceIDAtom"), (int) sizeof(Gdk.Atom), - serialize_media_sources(sources)); - break; - - default: - warning("on_drag_data_get (%s): unknown target type %u", page.get_page_name(), - target_type); - break; - } - } - - private void on_drag_end() { - debug("on_drag_end (%s)", page.get_page_name()); - - if (page == null || page.get_view().get_selected_count() == 0 || drag_destination == null - || exporter != null) { - return; - } - - debug("Exporting to %s", drag_destination.get_path()); - - // drag-and-drop export doesn't pop up an export dialog, so use what are likely the - // most common export settings (the current -- or "working" -- file format, with - // all transformations applied, at the image's original size). - if (drag_destination.get_path() != null) { - exporter = new ExporterUI(new Exporter( - (Gee.Collection<Photo>) page.get_view().get_selected_sources(), - drag_destination, Scaling.for_original(), ExportFormatParameters.current())); - exporter.export(on_export_completed); - } else { - AppWindow.error_message(_("Photos cannot be exported to this directory.")); - } - - drag_destination = null; - } - - private bool on_drag_failed(Gdk.DragContext context, Gtk.DragResult drag_result) { - debug("on_drag_failed (%s): %d", page.get_page_name(), (int) drag_result); - - if (page == null) - return false; - - drag_destination = null; - - return false; - } - - private void on_export_completed() { - exporter = null; - } - -} diff --git a/src/PageMessagePane.vala b/src/PageMessagePane.vala new file mode 100644 index 0000000..e773dad --- /dev/null +++ b/src/PageMessagePane.vala @@ -0,0 +1,19 @@ +/* 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. + */ + +[GtkTemplate (ui = "/org/gnome/Shotwell/ui/message_pane.ui")] +private class PageMessagePane : Gtk.Box { + [GtkChild] + public unowned Gtk.Label label; + + [GtkChild] + public unowned Gtk.Image icon_image; + + public PageMessagePane() { + Object(); + } +} + diff --git a/src/Photo.vala b/src/Photo.vala index b67457e..f31a17d 100644 --- a/src/Photo.vala +++ b/src/Photo.vala @@ -155,7 +155,7 @@ public enum Rating { // particular photo without modifying the backing image file. The interface allows for // transformations to be stored persistently elsewhere or in memory until they're committed en // masse to an image file. -public abstract class Photo : PhotoSource, Dateable { +public abstract class Photo : PhotoSource, Dateable, Positionable { // Need to use "thumb" rather than "photo" for historical reasons -- this name is used // directly to load thumbnails from disk by already-existing filenames public const string TYPENAME = "thumb"; @@ -183,7 +183,7 @@ public abstract class Photo : PhotoSource, Dateable { "pns", "jps", "mpo", // RAW extensions - "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cap", "iiq", "eip", "dcs", "dcr", "drf", + "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cr3", "cap", "iiq", "eip", "dcs", "dcr", "drf", "k25", "kdc", "dng", "erf", "fff", "mef", "mos", "mrw", "nef", "nrw", "orf", "ptx", "pef", "pxn", "r3d", "raf", "raw", "rw2", "rwl", "rwz", "x3f", "srw" }; @@ -210,7 +210,7 @@ public abstract class Photo : PhotoSource, Dateable { // Here, we cache the exposure time to avoid paying to access the row every time we // need to know it. This is initially set in the constructor, and updated whenever // the exposure time is set (please see set_exposure_time() for details). - private time_t cached_exposure_time; + private DateTime? cached_exposure_time; public enum Exception { NONE = 0, @@ -640,7 +640,7 @@ public abstract class Photo : PhotoSource, Dateable { File file = File.new_for_path(bpr.filepath); FileInfo info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); PhotoFileInterrogator interrogator = new PhotoFileInterrogator( file, PhotoFileSniffer.Options.GET_ALL); @@ -655,7 +655,7 @@ public abstract class Photo : PhotoSource, Dateable { bpr.dim = detected.image_dim; bpr.filesize = info.get_size(); - bpr.timestamp = timestamp.tv_sec; + bpr.timestamp = timestamp; bpr.original_orientation = detected.metadata != null ? detected.metadata.get_orientation() : Orientation.TOP_LEFT; @@ -832,7 +832,7 @@ public abstract class Photo : PhotoSource, Dateable { if (!developments.has_key(d)) return; // we tried! - // Disgard changes. + // Discard changes. revert_to_master(false); // Switch master to the new photo. @@ -1185,7 +1185,7 @@ public abstract class Photo : PhotoSource, Dateable { return ImportResult.UNSUPPORTED_FORMAT; } - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); // if all MD5s supplied, don't sniff for them if (params.exif_md5 != null && params.thumbnail_md5 != null && params.full_md5 != null) @@ -1217,8 +1217,9 @@ public abstract class Photo : PhotoSource, Dateable { } Orientation orientation = Orientation.TOP_LEFT; - time_t exposure_time = 0; + DateTime? exposure_time = null; string title = ""; + GpsCoords gps_coords = GpsCoords(); string comment = ""; Rating rating = Rating.UNRATED; @@ -1234,6 +1235,7 @@ public abstract class Photo : PhotoSource, Dateable { orientation = detected.metadata.get_orientation(); title = detected.metadata.get_title(); + gps_coords = detected.metadata.get_gps_coords(); comment = detected.metadata.get_comment(); params.keywords = detected.metadata.get_keywords(); rating = detected.metadata.get_rating(); @@ -1255,7 +1257,7 @@ public abstract class Photo : PhotoSource, Dateable { params.row.master.filepath = file.get_path(); params.row.master.dim = detected.image_dim; params.row.master.filesize = info.get_size(); - params.row.master.timestamp = timestamp.tv_sec; + params.row.master.timestamp = timestamp; params.row.exposure_time = exposure_time; params.row.orientation = orientation; params.row.master.original_orientation = orientation; @@ -1269,6 +1271,7 @@ public abstract class Photo : PhotoSource, Dateable { params.row.flags = 0; params.row.master.file_format = detected.file_format; params.row.title = title; + params.row.gps_coords = gps_coords; params.row.comment = comment; params.row.rating = rating; @@ -1296,8 +1299,8 @@ public abstract class Photo : PhotoSource, Dateable { params.row.master.filepath = file.get_path(); params.row.master.dim = Dimensions(0,0); params.row.master.filesize = 0; - params.row.master.timestamp = 0; - params.row.exposure_time = 0; + params.row.master.timestamp = null; + params.row.exposure_time = null; params.row.orientation = Orientation.TOP_LEFT; params.row.master.original_orientation = Orientation.TOP_LEFT; params.row.import_id = params.import_id; @@ -1310,6 +1313,7 @@ public abstract class Photo : PhotoSource, Dateable { params.row.flags = 0; params.row.master.file_format = PhotoFileFormat.JFIF; params.row.title = null; + params.row.gps_coords = GpsCoords(); params.row.comment = null; params.row.rating = Rating.UNRATED; @@ -1350,10 +1354,10 @@ public abstract class Photo : PhotoSource, Dateable { return null; } - TimeVal modification_time = info.get_modification_time(); + var modification_time = info.get_modification_date_time(); backing.filepath = file.get_path(); - backing.timestamp = modification_time.tv_sec; + backing.timestamp = modification_time; backing.filesize = info.get_size(); backing.file_format = detected.file_format; backing.dim = detected.image_dim; @@ -1462,14 +1466,22 @@ public abstract class Photo : PhotoSource, Dateable { list += "image:orientation"; updated_row.master.original_orientation = backing.original_orientation; } - + + GpsCoords gps_coords = GpsCoords(); + if (detected.metadata != null) { MetadataDateTime? date_time = detected.metadata.get_exposure_date_time(); - if (date_time != null && updated_row.exposure_time != date_time.get_timestamp()) + if (date_time != null && updated_row.exposure_time != null && + !updated_row.exposure_time.equal(date_time.get_timestamp())) list += "metadata:exposure-time"; if (updated_row.title != detected.metadata.get_title()) list += "metadata:name"; + + gps_coords = detected.metadata.get_gps_coords(); + if (updated_row.gps_coords != gps_coords) + list += "metadata:gps"; + if (updated_row.comment != detected.metadata.get_comment()) list += "metadata:comment"; @@ -1490,7 +1502,8 @@ public abstract class Photo : PhotoSource, Dateable { MetadataDateTime? date_time = detected.metadata.get_exposure_date_time(); if (date_time != null) updated_row.exposure_time = date_time.get_timestamp(); - + + updated_row.gps_coords = gps_coords; updated_row.title = detected.metadata.get_title(); updated_row.comment = detected.metadata.get_comment(); updated_row.rating = detected.metadata.get_rating(); @@ -1601,6 +1614,7 @@ public abstract class Photo : PhotoSource, Dateable { if (reimport_state.metadata != null) { set_title(reimport_state.metadata.get_title()); + set_gps_coords(reimport_state.metadata.get_gps_coords()); set_comment(reimport_state.metadata.get_comment()); set_rating(reimport_state.metadata.get_rating()); apply_user_metadata_for_reimport(reimport_state.metadata); @@ -1695,17 +1709,17 @@ public abstract class Photo : PhotoSource, Dateable { // Use this only if the master file's modification time has been changed (i.e. touched) public void set_master_timestamp(FileInfo info) { - TimeVal modification = info.get_modification_time(); + var modification = info.get_modification_date_time(); try { lock (row) { - if (row.master.timestamp == modification.tv_sec) + if (row.master.timestamp.equal(modification)) return; - PhotoTable.get_instance().update_timestamp(row.photo_id, modification.tv_sec); - row.master.timestamp = modification.tv_sec; + PhotoTable.get_instance().update_timestamp(row.photo_id, modification); + row.master.timestamp = modification; } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); return; @@ -1718,15 +1732,15 @@ public abstract class Photo : PhotoSource, Dateable { } // Use this only if the editable file's modification time has been changed (i.e. touched) - public void update_editable_modification_time(FileInfo info) throws DatabaseError { - TimeVal modification = info.get_modification_time(); + public void update_editable_modification_time(FileInfo info) throws Error { + var modification = info.get_modification_date_time(); bool altered = false; lock (row) { - if (row.editable_id.is_valid() && editable.timestamp != modification.tv_sec) { + if (row.editable_id.is_valid() && !editable.timestamp.equal(modification)) { BackingPhotoTable.get_instance().update_timestamp(row.editable_id, - modification.tv_sec); - editable.timestamp = modification.tv_sec; + modification); + editable.timestamp = modification; altered = true; } } @@ -1739,8 +1753,13 @@ public abstract class Photo : PhotoSource, Dateable { public static void update_many_editable_timestamps(Gee.Map<Photo, FileInfo> map) throws DatabaseError { DatabaseTable.begin_transaction(); - foreach (Photo photo in map.keys) - photo.update_editable_modification_time(map.get(photo)); + foreach (Photo photo in map.keys) { + try { + photo.update_editable_modification_time(map.get(photo)); + } catch (Error err) { + debug("Failed to update modification time: %s", err.message); + } + } DatabaseTable.commit_transaction(); } @@ -1853,7 +1872,7 @@ public abstract class Photo : PhotoSource, Dateable { } } } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); } @@ -1906,7 +1925,7 @@ public abstract class Photo : PhotoSource, Dateable { } } } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); } @@ -1993,7 +2012,7 @@ public abstract class Photo : PhotoSource, Dateable { } } - public override time_t get_timestamp() { + public override DateTime? get_timestamp() { lock (row) { return backing_photo_row.timestamp; } @@ -2169,7 +2188,7 @@ public abstract class Photo : PhotoSource, Dateable { } } - public void set_master_metadata_dirty(bool dirty) throws DatabaseError { + public void set_master_metadata_dirty(bool dirty) throws Error { bool committed = false; lock (row) { if (row.metadata_dirty != dirty) { @@ -2277,7 +2296,7 @@ public abstract class Photo : PhotoSource, Dateable { error("Unable to read file information for %s: %s", to_string(), err.message); } - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); // interrogate file for photo information PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file); @@ -2297,7 +2316,7 @@ public abstract class Photo : PhotoSource, Dateable { bool success; lock (row) { success = PhotoTable.get_instance().master_exif_updated(get_photo_id(), info.get_size(), - timestamp.tv_sec, detected.md5, detected.exif_md5, detected.thumbnail_md5, row); + timestamp, detected.md5, detected.exif_md5, detected.thumbnail_md5, row); } if (success) @@ -2324,7 +2343,7 @@ public abstract class Photo : PhotoSource, Dateable { } } - public override time_t get_exposure_time() { + public override DateTime? get_exposure_time() { return cached_exposure_time; } @@ -2362,6 +2381,29 @@ public abstract class Photo : PhotoSource, Dateable { if (committed) notify_altered(new Alteration("metadata", "name")); } + + public GpsCoords get_gps_coords() { + lock (row) { + return row.gps_coords; + } + } + + public void set_gps_coords(GpsCoords gps_coords) { + DatabaseError dberr = null; + lock (row) { + try { + PhotoTable.get_instance().set_gps_coords(row.photo_id, gps_coords); + row.gps_coords = gps_coords; + } catch (DatabaseError err) { + dberr = err; + } + } + if (dberr == null) + notify_altered(new Alteration("metadata", "gps")); + else + warning("Unable to write gps coordinates for %s: %s", to_string(), dberr.message); + } + public override bool set_comment(string? comment) { string? new_comment = prep_comment(comment); @@ -2455,7 +2497,7 @@ public abstract class Photo : PhotoSource, Dateable { file_exif_updated(); } - public void set_exposure_time(time_t time) { + public void set_exposure_time(DateTime time) { bool committed; lock (row) { committed = PhotoTable.get_instance().set_exposure_time(row.photo_id, time); @@ -2469,7 +2511,7 @@ public abstract class Photo : PhotoSource, Dateable { notify_altered(new Alteration("metadata", "exposure-time")); } - public void set_exposure_time_persistent(time_t time) throws Error { + public void set_exposure_time_persistent(DateTime time) throws Error { PhotoFileReader source = get_source_reader(); // Try to write to backing file @@ -2742,7 +2784,8 @@ public abstract class Photo : PhotoSource, Dateable { lock (row) { return row.transformations == null && (row.orientation != backing_photo_row.original_orientation - || (date_time != null && row.exposure_time != date_time.get_timestamp())); + || (date_time != null && row.exposure_time != null && + !row.exposure_time.equal(date_time.get_timestamp()))); } } @@ -2763,7 +2806,7 @@ public abstract class Photo : PhotoSource, Dateable { // No, use file timestamp as date/time. lock (row) { // Did we manually set an exposure date? - if(backing_photo_row.timestamp != row.exposure_time) { + if(nullsafe_date_time_comperator(backing_photo_row.timestamp, row.exposure_time) != 0) { // Yes, we need to save this. return true; } @@ -2773,7 +2816,7 @@ public abstract class Photo : PhotoSource, Dateable { lock (row) { return row.transformations != null || row.orientation != backing_photo_row.original_orientation - || (date_time != null && row.exposure_time != date_time.get_timestamp()) + || (date_time != null && !row.exposure_time.equal(date_time.get_timestamp())) || (get_comment() != comment) || (get_title() != title); } @@ -3212,6 +3255,7 @@ public abstract class Photo : PhotoSource, Dateable { double orientation_time = 0.0; total_timer.start(); + #endif // get required fields all at once, to avoid holding the row lock @@ -3618,7 +3662,7 @@ public abstract class Photo : PhotoSource, Dateable { debug("Updating metadata of %s", writer.get_filepath()); - if (get_exposure_time() != 0) + if (get_exposure_time() != null) metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time())); else metadata.set_exposure_date_time(null); @@ -3714,7 +3758,7 @@ public abstract class Photo : PhotoSource, Dateable { metadata.set_comment(get_comment()); metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION); - if (get_exposure_time() != 0) + if (get_exposure_time() != null) metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time())); else metadata.set_exposure_date_time(null); @@ -3970,15 +4014,15 @@ public abstract class Photo : PhotoSource, Dateable { return; } - TimeVal timestamp = info.get_modification_time(); + var timestamp = info.get_modification_date_time(); - BackingPhotoTable.get_instance().update_attributes(editable_id, timestamp.tv_sec, + BackingPhotoTable.get_instance().update_attributes(editable_id, timestamp, info.get_size()); lock (row) { - timestamp_changed = editable.timestamp != timestamp.tv_sec; + timestamp_changed = !editable.timestamp.equal(timestamp); filesize_changed = editable.filesize != info.get_size(); - editable.timestamp = timestamp.tv_sec; + editable.timestamp = timestamp; editable.filesize = info.get_size(); } } else { @@ -4057,7 +4101,7 @@ public abstract class Photo : PhotoSource, Dateable { PhotoTable.get_instance().detach_editable(row); backing_photo_row = row.master; } - } catch (DatabaseError err) { + } catch (Error err) { warning("Unable to remove editable from PhotoTable: %s", err.message); } @@ -4976,7 +5020,12 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { this.import_keywords = null; thumbnail_scheduler = new OneShotScheduler("LibraryPhoto", generate_thumbnails); - + // import gps coords of photos imported with prior versions of shotwell + if (row.gps_coords.has_gps == -1) { + var gps_import_scheduler = new OneShotScheduler("LibraryPhoto", import_gps_metadata); + gps_import_scheduler.at_priority_idle(Priority.LOW); + } + // if marked in a state where they're held in an orphanage, rehydrate their backlinks if ((row.flags & (FLAG_TRASH | FLAG_OFFLINE)) != 0) rehydrate_backlinks(global, row.backlinks); @@ -5097,7 +5146,12 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { // fire signal that thumbnails have changed notify_thumbnail_altered(); } - + + private void import_gps_metadata() { + GpsCoords gps_coords = get_metadata().get_gps_coords(); + set_gps_coords(gps_coords); + } + // These keywords are only used during import and should not be relied upon elsewhere. public Gee.Collection<string>? get_import_keywords() { return import_keywords; @@ -5218,7 +5272,7 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { if (location != null) { face.attach(dupe); FaceLocation.create(face.get_face_id(), dupe.get_photo_id(), - location.get_serialized_geometry()); + location.get_face_data()); } } } @@ -5332,10 +5386,14 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { PhotoMetadata? metadata = get_metadata(); if (metadata == null) - return tags != null || tags.size > 0 || get_rating() != Rating.UNRATED; + return tags != null || tags.size > 0 || get_rating() != Rating.UNRATED || get_gps_coords().has_gps != 0; if (get_rating() != metadata.get_rating()) return true; + + var old_coords = metadata.get_gps_coords(); + if (!get_gps_coords().equals(ref old_coords)) + return true; Gee.Set<string>? keywords = metadata.get_keywords(); int tags_count = (tags != null) ? tags.size : 0; @@ -5366,6 +5424,7 @@ public class LibraryPhoto : Photo, Flaggable, Monitorable { metadata.set_keywords(null); metadata.set_rating(get_rating()); + metadata.set_gps_coords(get_gps_coords()); } protected override void apply_user_metadata_for_reimport(PhotoMetadata metadata) { diff --git a/src/PhotoPage.vala b/src/PhotoPage.vala index fd513b2..10ebb10 100644 --- a/src/PhotoPage.vala +++ b/src/PhotoPage.vala @@ -759,7 +759,7 @@ public abstract class EditingHostPage : SinglePhotoPage { return false; zoom_about_event_cursor_point(event, ZOOM_INCREMENT_SIZE); - return false; + return true; } protected override bool on_mousewheel_down(Gdk.EventScroll event) { @@ -767,7 +767,7 @@ public abstract class EditingHostPage : SinglePhotoPage { return false; zoom_about_event_cursor_point(event, -ZOOM_INCREMENT_SIZE); - return false; + return true; } protected override void restore_zoom_state() { @@ -1466,8 +1466,9 @@ public abstract class EditingHostPage : SinglePhotoPage { return; } - if (unscaled != null) + if (unscaled != null) { set_pixbuf(unscaled, max_dim); + } // create the PhotoCanvas object for a two-way interface to the tool EditingTools.PhotoCanvas photo_canvas = new EditingHostCanvas(this); @@ -1528,8 +1529,9 @@ public abstract class EditingHostPage : SinglePhotoPage { needs_improvement = true; } - if (replacement != null) + if (replacement != null) { set_pixbuf(replacement, new_max_dim); + } cancel_editing_pixbuf = null; // if this is a rough pixbuf, schedule an improvement @@ -2458,7 +2460,7 @@ public class LibraryPhotoPage : EditingHostPage { base.add_actions (map); map.add_action_entries (entries, this); - (get_action ("ViewRatings") as GLib.SimpleAction).change_state (Config.Facade.get_instance ().get_display_photo_ratings ()); + ((GLib.SimpleAction) get_action ("ViewRatings")).change_state (Config.Facade.get_instance ().get_display_photo_ratings ()); var d = Config.Facade.get_instance().get_default_raw_developer(); var action = new GLib.SimpleAction.stateful("RawDeveloper", GLib.VariantType.STRING, d == RawDeveloper.SHOTWELL ? "Shotwell" : "Camera"); @@ -2888,7 +2890,8 @@ public class LibraryPhotoPage : EditingHostPage { Gee.Collection<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>(); photos.add(photo); - remove_from_app(photos, _("Remove From Library"), _("Removing Photo From Library")); + remove_from_app(photos, GLib.dpgettext2(null, "Dialog Title", "Remove From Library"), + GLib.dpgettext2(null, "Dialog Title", "Removing Photo From Library")); } private void on_move_to_trash() { @@ -3012,7 +3015,7 @@ public class LibraryPhotoPage : EditingHostPage { if (!has_photo()) return; - ExportDialog export_dialog = new ExportDialog(_("Export Photo")); + ExportDialog export_dialog = new ExportDialog(GLib.dpgettext2(null, "Dialog Title", "Export Photo")); int scale; ScaleConstraint constraint; @@ -3171,7 +3174,7 @@ public class LibraryPhotoPage : EditingHostPage { } protected override void insert_faces_button(Gtk.Toolbar toolbar) { - faces_button = new Gtk.ToggleToolButton.from_stock(Resources.FACES_TOOL); + faces_button = new Gtk.ToggleToolButton(); faces_button.set_icon_name(Resources.ICON_FACES); faces_button.set_label(Resources.FACES_LABEL); faces_button.set_tooltip_text(Resources.FACES_TOOLTIP); diff --git a/src/Printing.vala b/src/Printing.vala index 988a456..bef3476 100644 --- a/src/Printing.vala +++ b/src/Printing.vala @@ -271,29 +271,29 @@ public class CustomPrintTab : Gtk.Box { private const int CENTIMETERS_COMBO_CHOICE = 1; [GtkChild] - private Gtk.RadioButton standard_size_radio; + private unowned Gtk.RadioButton standard_size_radio; [GtkChild] - private Gtk.RadioButton custom_size_radio; + private unowned Gtk.RadioButton custom_size_radio; [GtkChild] - private Gtk.RadioButton image_per_page_radio; + private unowned Gtk.RadioButton image_per_page_radio; [GtkChild] - private Gtk.ComboBoxText image_per_page_combo; + private unowned Gtk.ComboBoxText image_per_page_combo; [GtkChild] - private Gtk.ComboBoxText standard_sizes_combo; + private unowned Gtk.ComboBoxText standard_sizes_combo; [GtkChild] - private Gtk.ComboBoxText units_combo; + private unowned Gtk.ComboBoxText units_combo; [GtkChild] - private Gtk.Entry custom_width_entry; + private unowned Gtk.Entry custom_width_entry; [GtkChild] - private Gtk.Entry custom_height_entry; + private unowned Gtk.Entry custom_height_entry; [GtkChild] - private Gtk.Entry ppi_entry; + private unowned Gtk.Entry ppi_entry; [GtkChild] - private Gtk.CheckButton aspect_ratio_check; + private unowned Gtk.CheckButton aspect_ratio_check; [GtkChild] - private Gtk.CheckButton title_print_check; + private unowned Gtk.CheckButton title_print_check; [GtkChild] - private Gtk.FontButton title_print_font; + private unowned Gtk.FontButton title_print_font; private Measurement local_content_width = Measurement(5.0, MeasurementUnit.INCHES); private Measurement local_content_height = Measurement(5.0, MeasurementUnit.INCHES); @@ -683,7 +683,7 @@ public class CustomPrintTab : Gtk.Box { } private void set_print_titles_font(string fontname) { - title_print_font.set_font_name(fontname); + ((Gtk.FontChooser) title_print_font).set_font(fontname); } @@ -696,7 +696,7 @@ public class CustomPrintTab : Gtk.Box { } private string get_print_titles_font() { - return title_print_font.get_font_name(); + return ((Gtk.FontChooser) title_print_font).get_font(); } public PrintJob get_source_job() { diff --git a/src/ProfileBrowser.vala b/src/ProfileBrowser.vala new file mode 100644 index 0000000..4532a20 --- /dev/null +++ b/src/ProfileBrowser.vala @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Jens Georg <mail@jensge.org> +// SPDX-License-Identifier: LGPL-2.1-or-later + +namespace Shotwell { + class ProfileEditor : Gtk.Dialog { + public string profile_name {get; set;} + public string id{get; default = Uuid.string_random();} + public string library_folder{get; set;} + public string data_folder{get; set;} + + public ProfileEditor() { + Object(use_header_bar : Resources.use_header_bar()); + } + + public override void constructed() { + base.constructed(); + + set_size_request(640, -1); + + add_buttons(_("Create"), Gtk.ResponseType.OK, _("Cancel"), Gtk.ResponseType.CANCEL, null); + var create_button = get_widget_for_response(Gtk.ResponseType.OK); + create_button.get_style_context().add_class("suggested-action"); + create_button.sensitive = false; + set_title(_("Create new Profile")); + + data_folder = Path.build_filename(Environment.get_user_data_dir(), "shotwell", "profiles", id); + library_folder = Environment.get_user_special_dir(UserDirectory.PICTURES); + + var grid = new Gtk.Grid(); + grid.hexpand = true; + grid.vexpand = true; + grid.margin = 6; + grid.set_row_spacing(12); + grid.set_column_spacing(12); + var label = new Gtk.Label(_("Name")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + grid.attach(label, 0, 0, 1, 1); + + var entry = new Gtk.Entry(); + entry.hexpand = true; + entry.bind_property("text", this, "profile-name", GLib.BindingFlags.DEFAULT); + entry.bind_property("text", create_button, "sensitive", GLib.BindingFlags.DEFAULT, (binding, from, ref to) => { + to = from.get_string() != ""; + return true; + }); + grid.attach(entry, 1, 0, 2, 1); + + label = new Gtk.Label(_("Library Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + grid.attach(label, 0, 1, 1, 1); + + entry = new Gtk.Entry(); + entry.hexpand = true; + grid.attach(entry, 1, 1, 1, 1); + bind_property("library-folder", entry, "text", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL); + entry.bind_property("text", create_button, "sensitive", GLib.BindingFlags.DEFAULT, (binding, from, ref to) => { + to = from.get_string() != ""; + return true; + }); + + var button = new Gtk.Button.from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON); + button.hexpand = false; + button.vexpand = false; + button.halign = Gtk.Align.FILL; + button.clicked.connect(() => { + var dialog = new Gtk.FileChooserNative(_("Choose Library Folder"), this, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel")); + dialog.set_current_folder(library_folder); + var result = dialog.run(); + dialog.hide(); + if (result == Gtk.ResponseType.ACCEPT) { + library_folder = dialog.get_current_folder_file().get_path(); + } + dialog.destroy(); + }); + grid.attach(button, 2, 1, 1, 1); + + + label = new Gtk.Label(_("Data Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + grid.attach(label, 0, 2, 1, 1); + + entry = new Gtk.Entry(); + entry.set_text(Environment.get_user_special_dir(UserDirectory.PICTURES)); + entry.hexpand = true; + bind_property("data-folder", entry, "text", GLib.BindingFlags.SYNC_CREATE | GLib.BindingFlags.BIDIRECTIONAL); + entry.bind_property("text", create_button, "sensitive", GLib.BindingFlags.DEFAULT, (binding, from, ref to) => { + to = from.get_string() != ""; + return true; + }); + grid.attach(entry, 1, 2, 1, 1); + + button = new Gtk.Button.from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON); + button.hexpand = false; + button.vexpand = false; + button.halign = Gtk.Align.FILL; + button.clicked.connect(() => { + var dialog = new Gtk.FileChooserNative(_("Choose Data Folder"), this, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel")); + dialog.set_current_folder(data_folder); + var result = dialog.run(); + dialog.hide(); + if (result == Gtk.ResponseType.ACCEPT) { + data_folder = dialog.get_current_folder_file().get_path(); + } + dialog.destroy(); + }); + + grid.attach(button, 2, 2, 1, 1); + + get_content_area().add(grid); + + show_all(); + } + } + + private class ProfileRow : Gtk.Box { + public Profile profile{get; construct; } + + public ProfileRow(Profile profile) { + Object(orientation: Gtk.Orientation.VERTICAL, + profile: profile, margin_top: 6, margin_bottom:6, margin_start:6, margin_end:6); + } + + public override void constructed() { + base.constructed(); + var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6); + pack_start(content, true); + + var revealer = new Gtk.Revealer(); + revealer.margin_top = 6; + pack_end(revealer, true); + + var label = new Gtk.Label(null); + label.set_markup("<span weight=\"bold\">%s</span>".printf(profile.name)); + label.halign = Gtk.Align.START; + content.pack_start(label, true, true, 6); + + Gtk.Image image; + if (profile.active) { + image = new Gtk.Image.from_icon_name ("emblem-default-symbolic", Gtk.IconSize.SMALL_TOOLBAR); + image.set_tooltip_text(_("This is the currently active profile")); + + } else { + image = new Gtk.Image(); + } + content.pack_start(image, false, false, 6); + + var button = new Gtk.ToggleButton(); + button.get_style_context().add_class("flat"); + content.pack_start(button, false, false, 6); + button.bind_property("active", revealer, "reveal-child", BindingFlags.DEFAULT); + image = new Gtk.Image.from_icon_name("go-down-symbolic", Gtk.IconSize.SMALL_TOOLBAR); + button.add(image); + + // FIXME: Would love to use the facade here, but this is currently hardwired to use a fixed profile + // and that even is not yet initialized + string settings_path; + if (profile.id == Profile.SYSTEM) { + settings_path = "/org/gnome/shotwell/preferences/files/"; + } else { + settings_path = "/org/gnome/shotwell/profiles/" + profile.id + "/preferences/files/"; + } + + var settings = new Settings.with_path("org.gnome.shotwell.preferences.files", settings_path); + var import_dir = settings.get_string("import-dir"); + if (import_dir == "") { + import_dir = Environment.get_user_special_dir(UserDirectory.PICTURES); + } + + var grid = new Gtk.Grid(); + grid.get_style_context().add_class("content"); + grid.set_row_spacing(12); + grid.set_column_spacing(6); + revealer.add(grid); + label = new Gtk.Label(_("Library Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 0, 1, 1); + label = new Gtk.Label(import_dir); + label.halign = Gtk.Align.START; + label.set_ellipsize(Pango.EllipsizeMode.END); + grid.attach(label, 1, 0, 1, 1); + + label = new Gtk.Label(_("Data Folder")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 1, 1, 1); + label = new Gtk.Label(profile.data_dir); + label.halign = Gtk.Align.START; + label.hexpand = true; + label.set_ellipsize(Pango.EllipsizeMode.END); + grid.attach(label, 1, 1, 1, 1); + + if (profile.id != Profile.SYSTEM && !profile.active) { + var remove_button = new Gtk.Button.with_label(_("Remove Profile")); + remove_button.get_style_context().add_class("destructive-action"); + remove_button.set_tooltip_text(_("Remove this profile")); + remove_button.hexpand = false; + remove_button.halign = Gtk.Align.END; + grid.attach(remove_button, 1, 2, 1, 1); + + remove_button.clicked.connect(() => { + var flags = Gtk.DialogFlags.DESTROY_WITH_PARENT | Gtk.DialogFlags.MODAL; + if (Resources.use_header_bar() == 1) { + flags |= Gtk.DialogFlags.USE_HEADER_BAR; + } + + var d = new Gtk.MessageDialog((Gtk.Window) this.get_toplevel(), flags, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, null); + var title = _("Remove profile “%s”").printf(profile.name); + var subtitle = _("None of the options will remove any of the images associated with this profile"); + d.set_markup(_("<b><span size=\"larger\">%s</span></b>\n<span weight=\"light\">%s</span>").printf(title, subtitle)); + + d.add_buttons(_("Remove profile and files"), Gtk.ResponseType.OK, _("Remove profile only"), Gtk.ResponseType.ACCEPT, _("Cancel"), Gtk.ResponseType.CANCEL); + d.get_widget_for_response(Gtk.ResponseType.OK).get_style_context().add_class("destructive-action"); + var response = d.run(); + d.destroy(); + if (response == Gtk.ResponseType.OK || response == Gtk.ResponseType.ACCEPT) { + ProfileManager.get_instance().remove(profile.id, response == Gtk.ResponseType.OK); + } + }); + } + } + } + + class ProfileBrowser : Gtk.Box { + public ProfileBrowser() { + Object(orientation: Gtk.Orientation.VERTICAL, vexpand: true, hexpand: true); + } + + public signal void profile_activated(string? profile); + + public override void constructed() { + var scrollable = new Gtk.ScrolledWindow(null, null); + scrollable.hexpand = true; + scrollable.vexpand = true; + + var list_box = new Gtk.ListBox(); + list_box.activate_on_single_click = false; + list_box.row_activated.connect((list_box, row) => { + var index = row.get_index(); + var profile = (Profile) ProfileManager.get_instance().get_item(index); + if (profile.id == Profile.SYSTEM) { + profile_activated(null); + } else { + profile_activated(profile.name); + } + }); + list_box.get_style_context().add_class("rich-list"); + list_box.hexpand = true; + list_box.vexpand = true; + scrollable.add (list_box); + list_box.bind_model(ProfileManager.get_instance(), on_widget_create); + list_box.set_header_func(on_header); + + var button = new Gtk.Button.with_label(_("Create new Profile")); + pack_start(button, false, false, 6); + button.clicked.connect(() => { + var editor = new ProfileEditor(); + editor.set_transient_for((Gtk.Window)get_ancestor(typeof(Gtk.Window))); + var result = editor.run(); + editor.hide(); + if (result == Gtk.ResponseType.OK) { + debug("Request to add new profile: %s %s %s %s", editor.id, editor.profile_name, editor.library_folder, editor.data_folder); + ProfileManager.get_instance().add_profile(editor.id, editor.profile_name, editor.library_folder, editor.data_folder); + } + editor.destroy(); + }); + add(scrollable); + show_all(); + } + + private Gtk.Widget on_widget_create(Object item) { + var row = new Gtk.ListBoxRow(); + row.add(new ProfileRow((Profile) item)); + row.show_all(); + + return row; + } + + private void on_header(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) { + if (before == null || row.get_header() != null) { + return; + } + + var separator = new Gtk.Separator(Gtk.Orientation.HORIZONTAL); + separator.show(); + row.set_header(separator); + } + } +} diff --git a/src/Profiles.vala b/src/Profiles.vala new file mode 100644 index 0000000..ec52800 --- /dev/null +++ b/src/Profiles.vala @@ -0,0 +1,303 @@ +/* Copyright 2019 Jens Georg. + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +namespace Shotwell { + class Profile : Object { + public const string SYSTEM = "__shotwell_default_system"; + public Profile(string name, string id, string data_dir, bool active) { + Object(name: name, id: id, data_dir: data_dir, active: active); + } + public string name {get; construct;} + public string id {get; construct;} + public string data_dir {get; construct;} + public bool active {get; construct;} + } + + class ProfileManager : Object, GLib.ListModel { + // ListModel implementations + Type get_item_type() { + return typeof(Profile); + } + + uint get_n_items() { + // All that is in the ini file plus one for the default profile + return profiles.get_groups().length + 1; + } + + GLib.Object? get_item (uint position) { + if (position == 0) { + return new Profile(_("System Profile"), Profile.SYSTEM, + Path.build_path(Path.DIR_SEPARATOR_S, Environment.get_user_data_dir(), "shotwell"), + this.profile == null); + } + + try { + var group = profiles.get_groups()[position - 1]; + var id = profiles.get_value(group, "Id"); + var name = profiles.get_value(group, "Name"); + var active = this.profile == name; + return new Profile(profiles.get_value(group, "Name"), + id, + get_data_dir_for_profile(id, group), + active); + } catch (KeyFileError err) { + if (err is KeyFileError.GROUP_NOT_FOUND) { + assert_not_reached(); + } + + warning("Profile configuration file corrupt: %s", err.message); + } + + return null; + } + + private static ProfileManager instance; + public static ProfileManager get_instance() { + if (instance == null) + instance = new ProfileManager(); + + return instance; + } + + private ProfileManager() { + Object(); + } + + private void write() { + try { + profiles.save_to_file(path); + } catch (Error error) { + critical("Failed to write profiles: %s", error.message); + } + } + + private KeyFile profiles; + private string profile = null; + private string path; + private string group_name; + + public override void constructed() { + base.constructed(); + + profiles = new KeyFile(); + path = Path.build_filename(Environment.get_user_config_dir(), "shotwell"); + DirUtils.create_with_parents(path, 0700); + path = Path.build_filename(path, "profiles.ini"); + + try { + profiles.load_from_file(path, KeyFileFlags.KEEP_COMMENTS); + } catch (Error error) { + debug("Could not read profiles: %s", error.message); + } + } + + public bool has_profile (string profile, out string group_name = null) { + group_name = Base64.encode (profile.data); + return profiles.has_group(group_name); + } + + public void set_profile(string profile) { + message("Using profile %s for this session", profile); + assert(this.profile == null); + + this.profile = profile; + + add_profile(Uuid.string_random(), profile, null, null); + } + + public void add_profile(string id, string name, string? library_folder, string? data_folder) { + if (has_profile(name, out this.group_name)) { + return; + } + + try { + profiles.set_string(group_name, "Name", name); + profiles.set_string(group_name, "Id", id); + if (data_folder != null) { + profiles.set_string(group_name, "DataDir", data_folder); + } + + // Need to set comment after setting keys since it does not create the group + profiles.set_comment(group_name, null, "Profile settings for \"%s\"".printf(name)); + + write(); + } catch (Error err) { + error("Failed to create profile: %s", err.message); + } + + if (library_folder != null) { + errno = 0; + var f = File.new_for_commandline_arg(library_folder); + try { + f.make_directory_with_parents(); + } catch (Error err) { + warning ("Failed to create library folder: %s", err.message); + } + var settings_path = "/org/gnome/shotwell/profiles/" + id + "/preferences/files/"; + + + var settings = new Settings.with_path("org.gnome.shotwell.preferences.files", settings_path); + settings.set_string("import-dir", library_folder); + } + + items_changed(profiles.get_groups().length, 0, 1); + } + + public string derive_data_dir(string? data_dir) { + if (data_dir != null) { + debug ("Using user-provided data dir %s", data_dir); + + try { + profiles.get_string(group_name, "DataDir"); + } catch (Error error) { + if (profile != null && profile != "") { + profiles.set_string(group_name ,"DataDir", data_dir); + debug("Using %s as data dir for profile %s", data_dir, profile); + write(); + } + } + + return data_dir; + } + + return Path.build_filename(Environment.get_user_data_dir(), "shotwell", "profiles", id()); + } + + public string id() { + // We are not running on any profile + if (profile == null || profile == "") + return ""; + + try { + return profiles.get_string(group_name, "Id"); + } catch (Error error) { + assert_not_reached(); + } + } + + private string get_data_dir_for_profile(string id, string group) throws KeyFileError { + if ("DataDir" in profiles.get_keys(group)) { + return profiles.get_value(group, "DataDir"); + } else { + return Path.build_filename(Environment.get_user_data_dir(), "shotwell", "profiles", id); + } + } + + public void print_profiles() { + print("Available profiles:\n"); + print("-------------------\n"); + try { + foreach (var group in profiles.get_groups()) { + print("Profile name: %s\n", profiles.get_value(group, "Name")); + var id = profiles.get_value(group, "Id"); + print("Profile Id: %s\n", id); + print("Data dir: %s\n", get_data_dir_for_profile(id, group)); + print("\n"); + } + } catch (Error error) { + print("Failed to print profiles: %s", error.message); + } + } + + const string SCHEMAS[] = { + "sharing", + "printing", + "plugins.enable-state", + "preferences.ui", + "preferences.slideshow", + "preferences.window", + "preferences.files", + "preferences.editing", + "preferences.export", + }; + + void reset_all_keys(Settings settings) { + SettingsSchema schema; + ((Object)settings).get("settings-schema", out schema, null); + + foreach (var key in schema.list_keys()) { + debug("Resetting key %s", key); + settings.reset(key); + } + + foreach (var c in settings.list_children()) { + debug("Checking children %s", c); + var child = settings.get_child (c); + reset_all_keys (child); + } + } + + private void remove_settings_recursively(string id) { + var source = SettingsSchemaSource.get_default(); + foreach (var schema in SCHEMAS) { + var path = "/org/gnome/shotwell/profiles/%s/%s/".printf(id, schema.replace(".", "/")); + var schema_name = "org.gnome.shotwell.%s".printf(schema); + debug("%s @ %s", schema_name, path); + var schema_definition = source.lookup(schema_name, false); + var settings = new Settings.full (schema_definition, null, path); + settings.delay(); + reset_all_keys (settings); + foreach (var key in schema_definition.list_keys()) { + debug("Resetting key %s", key); + settings.reset(key); + } + settings.apply(); + Settings.sync(); + } + } + + public void remove(string id, bool remove_all) { + debug("Request to remove profile %s, with files? %s", id, remove_all.to_string()); + int index = 1; + string group = null; + + foreach (var g in profiles.get_groups()) { + try { + if (profiles.get_value(g, "Id") == id) { + group = g; + break; + } + index++; + } catch (KeyFileError error) { + assert_not_reached(); + } + } + + if (group != null) { + string? data_dir = null; + + try { + data_dir = get_data_dir_for_profile(id, group); + // Remove profile + string? key = null; + profiles.remove_comment(group, key); + profiles.remove_group(group); + } catch (KeyFileError err) { + // We checked the existence of the group above. + assert_not_reached(); + } + + remove_settings_recursively(id); + + if (remove_all) { + try { + var file = File.new_for_commandline_arg(data_dir); + file.trash(); + } catch (Error error) { + warning("Failed to remove data folder: %s", error.message); + } + } + + Idle.add(() => { + items_changed(index, 1, 0); + + return false; + }); + write(); + } + } + } +} diff --git a/src/Properties.vala b/src/Properties.vala index ad0a041..c0cf2fd 100644 --- a/src/Properties.vala +++ b/src/Properties.vala @@ -4,12 +4,16 @@ * See the COPYING file in this distribution. */ -private abstract class Properties : Gtk.Grid { - uint line_count = 0; +private abstract class Properties : Gtk.Box { + protected Gtk.Grid grid = new Gtk.Grid(); + protected uint line_count = 0; protected Properties() { - row_spacing = 6; - column_spacing = 12; + Object(orientation: Gtk.Orientation.VERTICAL, homogeneous : false); + + grid.row_spacing = 6; + grid.column_spacing = 12; + pack_start(grid, false, false, 0); } protected void add_line(string label_text, string info_text, bool multi_line = false, string? href = null) { @@ -62,18 +66,18 @@ private abstract class Properties : Gtk.Grid { info = (Gtk.Widget) info_label; } - attach(label, 0, (int) line_count, 1, 1); + grid.attach(label, 0, (int) line_count, 1, 1); if (multi_line) { - attach(info, 1, (int) line_count, 1, 3); + grid.attach(info, 1, (int) line_count, 1, 3); } else { - attach(info, 1, (int) line_count, 1, 1); + grid.attach(info, 1, (int) line_count, 1, 1); } line_count++; } - protected string get_prettyprint_time(Time time) { + protected string get_prettyprint_time(DateTime time) { string timestring = time.format(Resources.get_hh_mm_format_string()); if (timestring[0] == '0') @@ -82,7 +86,7 @@ private abstract class Properties : Gtk.Grid { return timestring; } - protected string get_prettyprint_time_with_seconds(Time time) { + protected string get_prettyprint_time_with_seconds(DateTime time) { string timestring = time.format(Resources.get_hh_mm_ss_format_string()); if (timestring[0] == '0') @@ -91,12 +95,12 @@ private abstract class Properties : Gtk.Grid { return timestring; } - protected string get_prettyprint_date(Time date) { + protected string get_prettyprint_date(DateTime date) { string date_string = null; - Time today = Time.local(time_t()); - if (date.day_of_year == today.day_of_year && date.year == today.year) { + var today = new DateTime.now_local(); + if (date.get_day_of_year() == today.get_day_of_year() && date.get_year() == today.get_year()) { date_string = _("Today"); - } else if (date.day_of_year == (today.day_of_year - 1) && date.year == today.year) { + } else if (date.get_day_of_year() == (today.get_day_of_year() - 1) && date.get_year() == today.get_year()) { date_string = _("Yesterday"); } else { date_string = format_local_date(date); @@ -140,9 +144,9 @@ private abstract class Properties : Gtk.Grid { } protected virtual void clear_properties() { - foreach (Gtk.Widget child in get_children()) - remove(child); - + foreach (Gtk.Widget child in grid.get_children()) + grid.remove(child); + line_count = 0; } @@ -159,8 +163,8 @@ private abstract class Properties : Gtk.Grid { private class BasicProperties : Properties { private string title; - private time_t start_time = time_t(); - private time_t end_time = time_t(); + private DateTime? start_time = new DateTime.now_utc(); + private DateTime? end_time = new DateTime.now_utc(); private Dimensions dimensions; private int photo_count; private int event_count; @@ -173,13 +177,14 @@ private class BasicProperties : Properties { private string raw_assoc; public BasicProperties() { + base(); } protected override void clear_properties() { base.clear_properties(); title = ""; - start_time = 0; - end_time = 0; + start_time = null; + end_time = null; dimensions = Dimensions(0,0); photo_count = -1; event_count = -1; @@ -269,20 +274,20 @@ private class BasicProperties : Properties { video_count = 0; foreach (DataView view in iter) { DataSource source = view.get_source(); - - if (source is PhotoSource || source is PhotoImportSource) { - time_t exposure_time = (source is PhotoSource) ? + + if (source is PhotoSource || source is PhotoImportSource) { + var exposure_time = (source is PhotoSource) ? ((PhotoSource) source).get_exposure_time() : ((PhotoImportSource) source).get_exposure_time(); - if (exposure_time != 0) { - if (start_time == 0 || exposure_time < start_time) + if (exposure_time != null) { + if (start_time == null || exposure_time.compare(start_time) < 0) start_time = exposure_time; - if (end_time == 0 || exposure_time > end_time) + if (end_time == null || exposure_time.compare(end_time) > 0) end_time = exposure_time; } - + photo_count++; } else if (source is EventSource) { EventSource event_source = (EventSource) source; @@ -290,14 +295,14 @@ private class BasicProperties : Properties { if (event_count == -1) event_count = 0; - if ((start_time == 0 || event_source.get_start_time() < start_time) && - event_source.get_start_time() != 0 ) { + if ((start_time == null || event_source.get_start_time().compare(start_time) < 0) && + event_source.get_start_time() != null ) { start_time = event_source.get_start_time(); } - if ((end_time == 0 || event_source.get_end_time() > end_time) && - event_source.get_end_time() != 0 ) { + if ((end_time == null || event_source.get_end_time().compare(end_time) > 0) && + event_source.get_end_time() != null ) { end_time = event_source.get_end_time(); - } else if (end_time == 0 || event_source.get_start_time() > end_time) { + } else if (end_time == null || event_source.get_start_time().compare(end_time) > 0) { end_time = event_source.get_start_time(); } @@ -310,15 +315,15 @@ private class BasicProperties : Properties { video_count += event_video_count; event_count++; } else if (source is VideoSource || source is VideoImportSource) { - time_t exposure_time = (source is VideoSource) ? + var exposure_time = (source is VideoSource) ? ((VideoSource) source).get_exposure_time() : ((VideoImportSource) source).get_exposure_time(); - if (exposure_time != 0) { - if (start_time == 0 || exposure_time < start_time) + if (exposure_time != null) { + if (start_time == null || exposure_time.compare(start_time) < 0) start_time = exposure_time; - if (end_time == 0 || exposure_time > end_time) + if (end_time == null || exposure_time.compare(end_time) > 0) end_time = exposure_time; } @@ -330,9 +335,9 @@ private class BasicProperties : Properties { protected override void get_properties(Page current_page) { base.get_properties(current_page); - if (end_time == 0) + if (end_time == null) end_time = start_time; - if (start_time == 0) + if (start_time == null) start_time = end_time; } @@ -373,11 +378,11 @@ private class BasicProperties : Properties { add_line("", video_num_string); } - if (start_time != 0) { - string start_date = get_prettyprint_date(Time.local(start_time)); - string start_time = get_prettyprint_time(Time.local(start_time)); - string end_date = get_prettyprint_date(Time.local(end_time)); - string end_time = get_prettyprint_time(Time.local(end_time)); + if (start_time != null) { + string start_date = get_prettyprint_date(start_time.to_local()); + string start_time = get_prettyprint_time(start_time.to_local()); + string end_date = get_prettyprint_date(end_time.to_local()); + string end_time = get_prettyprint_time(end_time.to_local()); if (start_date == end_date) { // display only one date if start and end are the same @@ -485,7 +490,7 @@ private class ExtendedProperties : Properties { public ExtendedProperties() { base(); - row_spacing = 6; + grid.row_spacing = 6; } // Event stuff @@ -574,9 +579,9 @@ private class ExtendedProperties : Properties { copyright = metadata.get_copyright(); software = metadata.get_software(); exposure_bias = metadata.get_exposure_bias(); - time_t exposure_time_obj = metadata.get_exposure_date_time().get_timestamp(); - exposure_date = get_prettyprint_date(Time.local(exposure_time_obj)); - exposure_time = get_prettyprint_time_with_seconds(Time.local(exposure_time_obj)); + DateTime exposure_time_obj = metadata.get_exposure_date_time().get_timestamp(); + exposure_date = get_prettyprint_date(exposure_time_obj.to_local()); + exposure_time = get_prettyprint_time_with_seconds(exposure_time_obj.to_local()); comment = media.get_comment(); } else if (source is EventSource) { Event event = (Event) source; @@ -634,7 +639,7 @@ private class ExtendedProperties : Properties { add_line(_("GPS longitude:"), (gps_long != -1 && gps_long_ref != "" && gps_long_ref != null) ? "%f °%s".printf(gps_long, gps_long_ref) : NO_VALUE, false, osm_link); - add_line(_("Artist:"), (artist != "" && artist != null) ? artist : NO_VALUE); + add_line(_("Artist:"), (artist != "" && artist != null) ? Markup.escape_text(artist) : NO_VALUE); add_line(_("Copyright:"), (copyright != "" && copyright != null) ? copyright : NO_VALUE); diff --git a/src/Resources.vala b/src/Resources.vala index b65ec52..d03a214 100644 --- a/src/Resources.vala +++ b/src/Resources.vala @@ -24,6 +24,7 @@ namespace Resources { private const string LIBEXECDIR = _LIBEXECDIR; public const string PREFIX = _PREFIX; + public const string PIXBUF_LOADER_PATH = _PIXBUF_LOADER_PATH; public const double TRANSIENT_WINDOW_OPACITY = 0.90; @@ -69,12 +70,20 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., public const string GO_NEXT = "go-next-symbolic"; public const string GO_PREVIOUS = "go-previous-symbolic"; - public const string ICON_ABOUT_LOGO = "about-celle.jpg"; + public const string ICON_ABOUT_LOGO = "Delmenhorst_Rathaus.jpg"; public const string ICON_GENERIC_PLUGIN = "application-x-addon-symbolic"; public const string ICON_SLIDESHOW_EXTENSION_POINT = "slideshow-extension-point"; public const int ICON_FILTER_REJECTED_OR_BETTER_FIXED_SIZE = 32; public const int ICON_FILTER_UNRATED_OR_BETTER_FIXED_SIZE = 16; public const int ICON_ZOOM_SCALE = 16; + public const string ICON_GPS_MARKER = "gps-marker"; + public const string ICON_GPS_MARKER_HIGHLIGHTED = "gps-marker-highlighted"; + public const string ICON_GPS_MARKER_SELECTED = "gps-marker-selected"; + public const string ICON_GPS_GROUP_MARKER = "gps-markers-many"; + public const string ICON_GPS_GROUP_MARKER_HIGHLIGHTED = "gps-markers-many-highlighted"; + public const string ICON_GPS_GROUP_MARKER_SELECTED = "gps-markers-many-selected"; + public const string ICON_MAP_EDIT_LOCKED = "map-edit-locked"; + public const string ICON_MAP_EDIT_UNLOCKED = "map-edit-unlocked"; public const string ICON_CAMERAS = "camera-photo-symbolic"; public const string ICON_EVENTS = "multiple-events-symbolic"; @@ -403,6 +412,20 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., return ngettext ("Remove Face “%s” From Photo", "Remove Face “%s” From Photos", count).printf(name); } + + public string set_face_from_photo_menu(string name) { + /* Translators: This means to teach the face to the face recognition system */ + return _("_Train Face “%s” From Photo").printf(name); + } + + public string set_face_from_photo_label(string name) { + /* Translators: This means to teach the face to the face recognition system */ + return _("_Train Face “%s” From Photo").printf(name); + } + + public static string set_face_from_photo_error() { + return "Unable to set face as reference"; + } public string rename_face_menu(string name) { return _("Re_name Face “%s”…").printf(name); @@ -777,7 +800,8 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., private string END_MULTIMONTH_DATE_FORMAT_STRING = null; public void init () { - get_icon_theme_engine(); + init_icon_theme_engine(); + init_css_provider(); // load application-wide stock icons as IconSets generate_rating_strings(); } @@ -969,12 +993,21 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., return noninterpretable_badge_pixbuf; } + + private void init_css_provider() { + Gtk.CssProvider provider = new Gtk.CssProvider(); + provider.load_from_resource("/org/gnome/Shotwell/themes/org.gnome.Shotwell.css"); + Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); + } - public Gtk.IconTheme get_icon_theme_engine() { + private void init_icon_theme_engine() { Gtk.IconTheme icon_theme = Gtk.IconTheme.get_default(); icon_theme.add_resource_path("/org/gnome/Shotwell/icons"); - - return icon_theme; + icon_theme.add_resource_path("/org/gnome/Shotwell/icons/hicolor"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Publishing/icons"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Publishing/icons/hicolor"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Transitions/icons"); + icon_theme.add_resource_path("/org/gnome/Shotwell/Transitions/icons/hicolor"); } // This method returns a reference to a cached pixbuf that may be shared throughout the system. @@ -1038,7 +1071,7 @@ along with Shotwell; if not, write to the Free Software Foundation, Inc., return (scale > 0) ? scale_pixbuf(pixbuf, scale, Gdk.InterpType.BILINEAR, false) : pixbuf; } - + // Get the directory where our help files live. Returns a string // describing the help path we want, or, if we're installed system // -wide already, returns null. diff --git a/src/SearchFilter.vala b/src/SearchFilter.vala index ad8b7ec..969591f 100644 --- a/src/SearchFilter.vala +++ b/src/SearchFilter.vala @@ -148,7 +148,7 @@ public abstract class SearchViewFilter : ViewFilter { } public void set_search_filter(string? text) { - search_filter = !is_string_empty(text) ? text.down() : null; + search_filter = !is_string_empty(text) ? String.remove_diacritics(text.down()) : null; search_filter_words = search_filter != null ? search_filter.split(" ") : null; } @@ -775,12 +775,13 @@ public class SearchFilterToolbar : Gtk.Revealer { switch (filter) { case RatingFilter.REJECTED_OR_HIGHER: - icon = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); + var box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0); var image = new Gtk.Image.from_icon_name ("emblem-photos-symbolic", Gtk.IconSize.SMALL_TOOLBAR); image.margin_end = 2; - (icon as Gtk.Box).pack_start(image); + box.pack_start(image); image = new Gtk.Image.from_icon_name ("window-close-symbolic", Gtk.IconSize.SMALL_TOOLBAR); - (icon as Gtk.Box).pack_start(image); + box.pack_start(image); + icon = box; icon.show_all(); break; @@ -1015,7 +1016,8 @@ public class SearchFilterToolbar : Gtk.Revealer { } private SavedSearch get_search(Gtk.ListBoxRow row) { - DataButton button = (row.get_children().first().data as Gtk.Box).get_children().last().data as DataButton; + var box = (Gtk.Box) row.get_children().first().data; + DataButton button = box.get_children().last().data as DataButton; return button.search; } @@ -1191,7 +1193,7 @@ public class SearchFilterToolbar : Gtk.Revealer { bool has_flagged) { if (has_photos || has_raw) // As a user, I would expect, that a raw photo is still a photo. - // Let's enable the photo button even if there ar only raw photos. + // Let's enable the photo button even if there are only raw photos. toolbtn_photos.set_icon_name("filter-photos-symbolic"); else toolbtn_photos.set_icon_name("filter-photos-disabled-symbolic"); diff --git a/src/SinglePhotoPage.vala b/src/SinglePhotoPage.vala new file mode 100644 index 0000000..754a649 --- /dev/null +++ b/src/SinglePhotoPage.vala @@ -0,0 +1,537 @@ +/* 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 abstract class SinglePhotoPage : Page { + public const Gdk.InterpType FAST_INTERP = Gdk.InterpType.NEAREST; + public const Gdk.InterpType QUALITY_INTERP = Gdk.InterpType.BILINEAR; + public const int KEY_REPEAT_INTERVAL_MSEC = 200; + + public enum UpdateReason { + NEW_PIXBUF, + QUALITY_IMPROVEMENT, + RESIZED_CANVAS + } + + protected Gtk.DrawingArea canvas = new Gtk.DrawingArea(); + protected Gtk.Viewport viewport = new Gtk.Viewport(null, null); + + private bool scale_up_to_viewport; + private TransitionClock transition_clock; + private int transition_duration_msec = 0; + private Cairo.Surface pixmap = null; + private Cairo.Context pixmap_ctx = null; + private Cairo.Context text_ctx = null; + private Dimensions pixmap_dim = Dimensions(); + private Gdk.Pixbuf unscaled = null; + private Dimensions max_dim = Dimensions(); + private Gdk.Pixbuf scaled = null; + private Gdk.Pixbuf old_scaled = null; // previous scaled image + private Gdk.Rectangle scaled_pos = Gdk.Rectangle(); + private ZoomState static_zoom_state; + private bool zoom_high_quality = true; + private ZoomState saved_zoom_state; + private bool has_saved_zoom_state = false; + private uint32 last_nav_key = 0; + + protected SinglePhotoPage(string page_name, bool scale_up_to_viewport) { + base(page_name); + this.wheel_factor = 0.9999; + + this.scale_up_to_viewport = scale_up_to_viewport; + + transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); + + // With the current code automatically resizing the image to the viewport, scrollbars + // should never be shown, but this may change if/when zooming is supported + set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + + set_border_width(0); + set_shadow_type(Gtk.ShadowType.NONE); + + viewport.set_shadow_type(Gtk.ShadowType.NONE); + viewport.set_border_width(0); + viewport.add(canvas); + + add(viewport); + + canvas.add_events(Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.STRUCTURE_MASK + | Gdk.EventMask.SUBSTRUCTURE_MASK); + + viewport.size_allocate.connect(on_viewport_resize); + canvas.draw.connect(on_canvas_exposed); + + set_event_source(canvas); + Config.Facade.get_instance().colors_changed.connect(on_colors_changed); + } + + ~SinglePhotoPage() { + Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed); + } + + public bool is_transition_in_progress() { + return transition_clock.is_in_progress(); + } + + public void cancel_transition() { + if (transition_clock.is_in_progress()) + transition_clock.cancel(); + } + + public void set_transition(string effect_id, int duration_msec) { + cancel_transition(); + + transition_clock = TransitionEffectsManager.get_instance().create_transition_clock(effect_id); + if (transition_clock == null) + transition_clock = TransitionEffectsManager.get_instance().create_null_transition_clock(); + + transition_duration_msec = duration_msec; + } + + // This method includes a call to pixmap_ctx.paint(). + private void render_zoomed_to_pixmap(ZoomState zoom_state) { + assert(is_zoom_supported()); + + Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content(); + + Gdk.Pixbuf zoomed; + if (get_zoom_buffer() != null) { + zoomed = (zoom_high_quality) ? get_zoom_buffer().get_zoomed_image(zoom_state) : + get_zoom_buffer().get_zoom_preview_image(zoom_state); + } else { + Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(unscaled); + + Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(unscaled, view_rect_proj.x, + view_rect_proj.y, view_rect_proj.width, view_rect_proj.height); + + zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height, + Gdk.InterpType.BILINEAR); + } + + if (zoomed == null) { + return; + } + + int draw_x = (pixmap_dim.width - view_rect.width) / 2; + draw_x = draw_x.clamp(0, int.MAX); + + int draw_y = (pixmap_dim.height - view_rect.height) / 2; + draw_y = draw_y.clamp(0, int.MAX); + paint_pixmap_with_background(pixmap_ctx, zoomed, draw_x, draw_y); + } + + protected void on_interactive_zoom(ZoomState interactive_zoom_state) { + assert(is_zoom_supported()); + + set_source_color_from_string(pixmap_ctx, "#000"); + pixmap_ctx.paint(); + + bool old_quality_setting = zoom_high_quality; + zoom_high_quality = false; + render_zoomed_to_pixmap(interactive_zoom_state); + zoom_high_quality = old_quality_setting; + + canvas.queue_draw(); + } + + protected void on_interactive_pan(ZoomState interactive_zoom_state) { + assert(is_zoom_supported()); + + set_source_color_from_string(pixmap_ctx, "#000"); + pixmap_ctx.paint(); + + bool old_quality_setting = zoom_high_quality; + zoom_high_quality = true; + render_zoomed_to_pixmap(interactive_zoom_state); + zoom_high_quality = old_quality_setting; + + canvas.queue_draw(); + } + + protected virtual bool is_zoom_supported() { + return false; + } + + protected virtual void cancel_zoom() { + if (pixmap != null) { + set_source_color_from_string(pixmap_ctx, "#000"); + pixmap_ctx.paint(); + } + } + + protected virtual void save_zoom_state() { + saved_zoom_state = static_zoom_state; + has_saved_zoom_state = true; + } + + protected virtual void restore_zoom_state() { + if (!has_saved_zoom_state) + return; + + static_zoom_state = saved_zoom_state; + repaint(); + has_saved_zoom_state = false; + } + + protected virtual ZoomBuffer? get_zoom_buffer() { + return null; + } + + protected ZoomState get_saved_zoom_state() { + return saved_zoom_state; + } + + protected void set_zoom_state(ZoomState zoom_state) { + assert(is_zoom_supported()); + + static_zoom_state = zoom_state; + } + + protected ZoomState get_zoom_state() { + assert(is_zoom_supported()); + + return static_zoom_state; + } + + public override void switched_to() { + base.switched_to(); + + if (unscaled != null) + repaint(); + } + + public override void set_container(Gtk.Window container) { + base.set_container(container); + + // scrollbar policy in fullscreen mode needs to be auto/auto, else the pixbuf will shift + // off the screen + if (container is FullscreenWindow) + set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + } + + // max_dim represents the maximum size of the original pixbuf (i.e. pixbuf may be scaled and + // the caller capable of producing larger ones depending on the viewport size). max_dim + // is used when scale_up_to_viewport is set to true. Pass a Dimensions with no area if + // max_dim should be ignored (i.e. scale_up_to_viewport is false). + public void set_pixbuf(Gdk.Pixbuf unscaled, Dimensions max_dim, Direction? direction = null) { + static_zoom_state = ZoomState(max_dim, pixmap_dim, + static_zoom_state.get_interpolation_factor(), + static_zoom_state.get_viewport_center()); + + cancel_transition(); + + this.unscaled = unscaled; + this.max_dim = max_dim; + this.old_scaled = scaled; + scaled = null; + + // need to make sure this has happened + canvas.realize(); + + repaint(direction); + } + + public void blank_display() { + unscaled = null; + max_dim = Dimensions(); + scaled = null; + pixmap = null; + + // this has to have happened + canvas.realize(); + + // force a redraw + invalidate_all(); + } + + public Cairo.Surface? get_surface() { + return pixmap; + } + + public Dimensions get_surface_dim() { + return pixmap_dim; + } + + public Cairo.Context get_cairo_context() { + return pixmap_ctx; + } + + public void paint_text(Pango.Layout pango_layout, int x, int y) { + text_ctx.move_to(x, y); + Pango.cairo_show_layout(text_ctx, pango_layout); + } + + public Scaling get_canvas_scaling() { + return (get_container() is FullscreenWindow) ? Scaling.for_screen(AppWindow.get_instance(), scale_up_to_viewport) + : Scaling.for_widget(viewport, scale_up_to_viewport); + } + + public Gdk.Pixbuf? get_unscaled_pixbuf() { + return unscaled; + } + + public Gdk.Pixbuf? get_scaled_pixbuf() { + return scaled; + } + + // Returns a rectangle describing the pixbuf in relation to the canvas + public Gdk.Rectangle get_scaled_pixbuf_position() { + return scaled_pos; + } + + public bool is_inside_pixbuf(int x, int y) { + return coord_in_rectangle((int)Math.lround(x * Application.get_scale()), + (int)Math.lround(y * Application.get_scale()), scaled_pos); + } + + public void invalidate(Gdk.Rectangle rect) { + if (canvas.get_window() != null) + canvas.get_window().invalidate_rect(rect, false); + } + + public void invalidate_all() { + if (canvas.get_window() != null) + canvas.get_window().invalidate_rect(null, false); + } + + private void on_viewport_resize() { + // do fast repaints while resizing + internal_repaint(true, null); + } + + protected override void on_resize_finished(Gdk.Rectangle rect) { + base.on_resize_finished(rect); + + // when the resize is completed, do a high-quality repaint + repaint(); + } + + private bool on_canvas_exposed(Cairo.Context exposed_ctx) { + // draw pixmap onto canvas unless it's not been instantiated, in which case draw black + // (so either old image or contents of another page is not left on screen) + if (pixmap != null) { + pixmap.set_device_scale(Application.get_scale(), Application.get_scale()); + exposed_ctx.set_source_surface(pixmap, 0, 0); + } + else + set_source_color_from_string(exposed_ctx, "#000"); + + exposed_ctx.rectangle(0, 0, get_allocated_width(), get_allocated_height()); + exposed_ctx.paint(); + + if (pixmap != null) { + pixmap.set_device_scale(1.0, 1.0); + } + + return true; + } + + protected virtual void new_surface(Cairo.Context ctx, Dimensions ctx_dim) { + } + + protected virtual void updated_pixbuf(Gdk.Pixbuf pixbuf, UpdateReason reason, Dimensions old_dim) { + } + + protected virtual void paint(Cairo.Context ctx, Dimensions ctx_dim) { + if (is_zoom_supported() && (!static_zoom_state.is_default())) { + set_source_color_from_string(ctx, "#000"); + ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); + ctx.fill(); + + render_zoomed_to_pixmap(static_zoom_state); + } else if (!transition_clock.paint(ctx, ctx_dim.width, ctx_dim.height)) { + // transition is not running, so paint the full image on a black background + set_source_color_from_string(ctx, "#000"); + + ctx.rectangle(0, 0, pixmap_dim.width, pixmap_dim.height); + ctx.fill(); + + //scaled.save("src%010d.png".printf(buffer_counter), "png"); + paint_pixmap_with_background(ctx, scaled, scaled_pos.x, scaled_pos.y); + //pixmap.write_to_png("%010d.png".printf(buffer_counter++)); + } + } + + private void repaint_pixmap() { + if (pixmap_ctx == null) + return; + + paint(pixmap_ctx, pixmap_dim); + invalidate_all(); + } + + public void repaint(Direction? direction = null) { + internal_repaint(false, direction); + } + + private void internal_repaint(bool fast, Direction? direction) { + // if not in view, assume a full repaint needed in future but do nothing more + if (!is_in_view()) { + pixmap = null; + scaled = null; + + return; + } + + // no image or window, no painting + if (unscaled == null || canvas.get_window() == null) + return; + + Gtk.Allocation allocation; + viewport.get_allocation(out allocation); + + int width = allocation.width; + int height = allocation.height; + + if (width <= 0 || height <= 0) + return; + + bool new_pixbuf = (scaled == null); + + // save if reporting an image being rescaled + Dimensions old_scaled_dim = Dimensions.for_rectangle(scaled_pos); + + Gdk.Rectangle old_scaled_pos = scaled_pos; + + // attempt to reuse pixmap + if (pixmap_dim.width != width || pixmap_dim.height != height) + pixmap = null; + + // if necessary, create a pixmap as large as the entire viewport + bool new_pixmap = false; + if (pixmap == null) { + init_pixmap((int)Math.lround(width * Application.get_scale()), (int)Math.lround(height * Application.get_scale())); + new_pixmap = true; + } + + if (new_pixbuf || new_pixmap) { + Dimensions unscaled_dim = Dimensions.for_pixbuf(unscaled); + + // determine scaled size of pixbuf ... if a max dimensions is set and not scaling up, + // respect it + Dimensions scaled_dim = Dimensions(); + if (!scale_up_to_viewport && max_dim.has_area() && max_dim.width < width && max_dim.height < height) + scaled_dim = max_dim; + else + scaled_dim = unscaled_dim.get_scaled_proportional(pixmap_dim); + + // center pixbuf on the canvas + scaled_pos.x = (int)Math.lround(((width * Application.get_scale()) - scaled_dim.width) / 2.0); + scaled_pos.y = (int)Math.lround(((height * Application.get_scale()) - scaled_dim.height) / 2.0); + scaled_pos.width = scaled_dim.width; + scaled_pos.height = scaled_dim.height; + } + + Gdk.InterpType interp = (fast) ? FAST_INTERP : QUALITY_INTERP; + + // rescale if canvas rescaled or better quality is requested + if (scaled == null) { + scaled = resize_pixbuf(unscaled, Dimensions.for_rectangle(scaled_pos), interp); + + UpdateReason reason = UpdateReason.RESIZED_CANVAS; + if (new_pixbuf) + reason = UpdateReason.NEW_PIXBUF; + else if (!new_pixmap && interp == QUALITY_INTERP) + reason = UpdateReason.QUALITY_IMPROVEMENT; + + static_zoom_state = ZoomState(max_dim, pixmap_dim, + static_zoom_state.get_interpolation_factor(), + static_zoom_state.get_viewport_center()); + + updated_pixbuf(scaled, reason, old_scaled_dim); + } + + zoom_high_quality = !fast; + + if (direction != null && !transition_clock.is_in_progress()) { + Spit.Transitions.Visuals visuals = new Spit.Transitions.Visuals(old_scaled, + old_scaled_pos, scaled, scaled_pos, parse_color("#000")); + + transition_clock.start(visuals, direction.to_transition_direction(), transition_duration_msec, + repaint_pixmap); + } + + if (!transition_clock.is_in_progress()) + repaint_pixmap(); + } + + private void init_pixmap(int width, int height) { + assert(unscaled != null); + assert(canvas.get_window() != null); + + // Cairo backing surface (manual double-buffering) + pixmap = new Cairo.ImageSurface(Cairo.Format.ARGB32, width, height); + pixmap_dim = Dimensions(width, height); + + // Cairo context for drawing on the pixmap + pixmap_ctx = new Cairo.Context(pixmap); + + // need a new pixbuf to fit this scale + scaled = null; + + // Cairo context for drawing text on the pixmap + text_ctx = new Cairo.Context(pixmap); + set_source_color_from_string(text_ctx, "#fff"); + + + // no need to resize canvas, viewport does that automatically + + new_surface(pixmap_ctx, pixmap_dim); + } + + protected override bool on_context_keypress() { + return popup_context_menu(get_page_context_menu()); + } + + protected virtual void on_previous_photo() { + } + + protected virtual void on_next_photo() { + } + + public override bool key_press_event(Gdk.EventKey event) { + // if the user holds the arrow keys down, we will receive a steady stream of key press + // events for an operation that isn't designed for a rapid succession of output ... + // we staunch the supply of new photos to under a quarter second (#533) + bool nav_ok = (event.time - last_nav_key) > KEY_REPEAT_INTERVAL_MSEC; + + bool handled = true; + switch (Gdk.keyval_name(event.keyval)) { + case "Left": + case "KP_Left": + case "BackSpace": + if (nav_ok) { + on_previous_photo(); + last_nav_key = event.time; + } + break; + + case "Right": + case "KP_Right": + case "space": + if (nav_ok) { + on_next_photo(); + last_nav_key = event.time; + } + break; + + default: + handled = false; + break; + } + + if (handled) + return true; + + return (base.key_press_event != null) ? base.key_press_event(event) : true; + } + + private void on_colors_changed() { + invalidate_transparent_background(); + repaint(); + } +} + + diff --git a/src/SlideshowPage.vala b/src/SlideshowPage.vala index 9810236..adfec7f 100644 --- a/src/SlideshowPage.vala +++ b/src/SlideshowPage.vala @@ -26,19 +26,19 @@ class SlideshowPage : SinglePhotoPage { [GtkTemplate (ui = "/org/gnome/Shotwell/ui/slideshow_settings.ui")] private class SettingsDialog : Gtk.Dialog { [GtkChild] - Gtk.Adjustment delay_adjustment; + unowned Gtk.Adjustment delay_adjustment; [GtkChild] - Gtk.SpinButton delay_entry; + unowned Gtk.SpinButton delay_entry; [GtkChild] - Gtk.ComboBoxText transition_effect_selector; + unowned Gtk.ComboBoxText transition_effect_selector; [GtkChild] - Gtk.Scale transition_effect_hscale; + unowned Gtk.Scale transition_effect_hscale; [GtkChild] - Gtk.SpinButton transition_effect_entry; + unowned Gtk.SpinButton transition_effect_entry; [GtkChild] - Gtk.Adjustment transition_effect_adjustment; + unowned Gtk.Adjustment transition_effect_adjustment; [GtkChild] - Gtk.CheckButton show_title_button; + unowned Gtk.CheckButton show_title_button; public SettingsDialog() { Object (use_header_bar: Resources.use_header_bar()); diff --git a/src/SortedList.vala b/src/SortedList.vala index 20e6771..420190d 100644 --- a/src/SortedList.vala +++ b/src/SortedList.vala @@ -142,7 +142,7 @@ public class SortedList<G> : Object, Gee.Traversable<G>, Gee.Iterable<G>, Gee.Co return list.get(index); } - private int binary_search(G search, EqualFunc? equal_func) { + private int binary_search(G search, EqualFunc<G>? equal_func) { assert(cmp != null); int min = 0; @@ -181,7 +181,7 @@ public class SortedList<G> : Object, Gee.Traversable<G>, Gee.Iterable<G>, Gee.Co } // See notes at index_of for the difference between this method and it. - public int locate(G search, bool altered, EqualFunc equal_func = direct_equal) { + public int locate(G search, bool altered, EqualFunc<G> equal_func = direct_equal) { if (cmp == null || altered) { int count = list.size; for (int ctr = 0; ctr < count; ctr++) { diff --git a/src/Tag.vala b/src/Tag.vala index 46cbfaa..baf5694 100644 --- a/src/Tag.vala +++ b/src/Tag.vala @@ -552,11 +552,13 @@ public class Tag : DataSource, ContainerSource, Proxyable, Indexable { // path should have already been prepared by prep_tag_name. public static Tag for_path(string name) { Tag? tag = global.fetch_by_name(name, true); - if (tag == null) + if (tag == null) { tag = global.restore_tag_from_holding_tank(name); + } - if (tag != null) + if (tag != null) { return tag; + } // create a new Tag for this name try { diff --git a/src/Thumbnail.vala b/src/Thumbnail.vala index f47fc69..51d2612 100644 --- a/src/Thumbnail.vala +++ b/src/Thumbnail.vala @@ -169,14 +169,15 @@ public class Thumbnail : MediaSourceItem { } public static int64 exposure_time_ascending_comparator(void *a, void *b) { - int64 time_a = (int64) (((Thumbnail *) a)->media.get_exposure_time()); - int64 time_b = (int64) (((Thumbnail *) b)->media.get_exposure_time()); - int64 result = (time_a - time_b); + var time_a = (((Thumbnail *) a)->media.get_exposure_time()); + var time_b = (((Thumbnail *) b)->media.get_exposure_time()); + + var result = nullsafe_date_time_comperator(time_a, time_b); return (result != 0) ? result : filename_ascending_comparator(a, b); } - public static int64 exposure_time_desending_comparator(void *a, void *b) { + public static int64 exposure_time_descending_comparator(void *a, void *b) { int64 result = exposure_time_ascending_comparator(b, a); return (result != 0) ? result : filename_descending_comparator(a, b); diff --git a/src/ThumbnailCache.vala b/src/ThumbnailCache.vala index a0b27fd..5585708 100644 --- a/src/ThumbnailCache.vala +++ b/src/ThumbnailCache.vala @@ -33,7 +33,8 @@ public class ThumbnailCache : Object { // so be careful before changing any of these values (and especially careful before arbitrarily // manipulating a Size enum) public enum Size { - LARGEST = 360, + LARGEST = 512, + LARGE = 512, BIG = 360, MEDIUM = 128, SMALLEST = 128; @@ -47,11 +48,18 @@ public class ThumbnailCache : Object { } public static Size get_best_size(int scale) { - return scale <= MEDIUM.get_scale() ? MEDIUM : BIG; + var real_scale = Application.get_scale() * scale; + + if (real_scale <= MEDIUM.get_scale()) + return MEDIUM; + if (real_scale <= BIG.get_scale()) + return BIG; + + return LARGE; } } - private static Size[] ALL_SIZES = { Size.BIG, Size.MEDIUM }; + private static Size[] ALL_SIZES = { Size.LARGE, Size.BIG, Size.MEDIUM }; public delegate void AsyncFetchCallback(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim, Gdk.InterpType interp, Error? err); @@ -167,9 +175,11 @@ public class ThumbnailCache : Object { public const ulong MAX_BIG_CACHED_BYTES = 40 * 1024 * 1024; public const ulong MAX_MEDIUM_CACHED_BYTES = 30 * 1024 * 1024; + public const ulong MAX_LARGE_CACHED_BYTES = 15 * 1024 * 1024; private static ThumbnailCache big = null; private static ThumbnailCache medium = null; + private static ThumbnailCache large = null; private static OneShotScheduler debug_scheduler = null; private static int cycle_fetched_thumbnails = 0; @@ -203,6 +213,7 @@ public class ThumbnailCache : Object { big = new ThumbnailCache(Size.BIG, MAX_BIG_CACHED_BYTES); medium = new ThumbnailCache(Size.MEDIUM, MAX_MEDIUM_CACHED_BYTES); + large = new ThumbnailCache(Size.LARGE, MAX_LARGE_CACHED_BYTES); } public static void terminate() { @@ -213,27 +224,33 @@ public class ThumbnailCache : Object { debug("import from source: %s", source.to_string()); big._import_from_source(source, force); medium._import_from_source(source, force); + large._import_from_source(source, force); } public static void import_thumbnails(ThumbnailSource source, Thumbnails thumbnails, bool force = false) throws Error { big._import_thumbnail(source, thumbnails.get(Size.BIG), force); medium._import_thumbnail(source, thumbnails.get(Size.MEDIUM), force); + large._import_thumbnail(source, thumbnails.get(Size.LARGE), force); } public static void duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) { big._duplicate(src_source, dest_source); medium._duplicate(src_source, dest_source); + large._duplicate(src_source, dest_source); } public static void remove(ThumbnailSource source) { big._remove(source); medium._remove(source); + large._remove(source); } private static ThumbnailCache get_best_cache(int scale) { Size size = Size.get_best_size(scale); - if (size == Size.BIG) { + if (size == Size.LARGE) { + return large; + } else if (size == Size.BIG) { return big; } else { assert(size == Size.MEDIUM); @@ -244,6 +261,9 @@ public class ThumbnailCache : Object { private static ThumbnailCache get_cache_for(Size size) { switch (size) { + case Size.LARGE: + return large; + case Size.BIG: return big; diff --git a/src/TimedQueue.vala b/src/TimedQueue.vala index 47faf3c..4ea6a23 100644 --- a/src/TimedQueue.vala +++ b/src/TimedQueue.vala @@ -50,7 +50,7 @@ public class TimedQueue<G> { // finding a workaround, namely using a delegate: // https://bugzilla.gnome.org/show_bug.cgi?id=628639 public TimedQueue(uint hold_msec, DequeuedCallback<G> callback, - owned Gee.EqualDataFunc? equal_func = null, int priority = Priority.DEFAULT) { + owned Gee.EqualDataFunc<G>? equal_func = null, int priority = Priority.DEFAULT) { this.hold_msec = hold_msec; this.callback = callback; diff --git a/src/Upgrades.vala b/src/Upgrades.vala index 85349ae..b06ccad 100644 --- a/src/Upgrades.vala +++ b/src/Upgrades.vala @@ -62,7 +62,7 @@ private interface UpgradeTask : Object{ // Deletes the mimics folder, if it still exists. // Note: for the step count to be consistent, files cannot be written -// to the mimcs folder for the duration of this task. +// to the mimics folder for the duration of this task. private class MimicsRemovalTask : Object, UpgradeTask { // Mimics folder (to be deleted, if present) private File mimic_dir = AppDirs.get_data_dir().get_child("mimics"); diff --git a/src/VideoMetadata.vala b/src/VideoMetadata.vala deleted file mode 100644 index 49ba8ef..0000000 --- a/src/VideoMetadata.vala +++ /dev/null @@ -1,655 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -public class VideoMetadata : MediaMetadata { - - private MetadataDateTime timestamp = null; - private string title = null; - private string comment = null; - - public VideoMetadata() { - } - - ~VideoMetadata() { - } - - public override void read_from_file(File file) throws Error { - QuickTimeMetadataLoader quicktime = new QuickTimeMetadataLoader(file); - if (quicktime.is_supported()) { - timestamp = quicktime.get_creation_date_time(); - title = quicktime.get_title(); - // TODO: is there an quicktime.get_comment ?? - comment = null; - return; - } - AVIMetadataLoader avi = new AVIMetadataLoader(file); - if (avi.is_supported()) { - timestamp = avi.get_creation_date_time(); - title = avi.get_title(); - comment = null; - return; - } - - throw new IOError.NOT_SUPPORTED("File %s is not a supported video format", file.get_path()); - } - - public override MetadataDateTime? get_creation_date_time() { - return timestamp; - } - - public override string? get_title() { - return title; - } - - public override string? get_comment() { - return comment; - } - -} - -private class QuickTimeMetadataLoader { - - // Quicktime calendar date/time format is number of seconds since January 1, 1904. - // This converts to UNIX time (66 years + 17 leap days). - public const time_t QUICKTIME_EPOCH_ADJUSTMENT = 2082844800; - - private File file = null; - - public QuickTimeMetadataLoader(File file) { - this.file = file; - } - - public MetadataDateTime? get_creation_date_time() { - return new MetadataDateTime((time_t) get_creation_date_time_for_quicktime()); - } - - public string? get_title() { - // Not supported. - return null; - } - - // Checks if the given file is a QuickTime file. - public bool is_supported() { - QuickTimeAtom test = new QuickTimeAtom(file); - - bool ret = false; - try { - test.open_file(); - test.read_atom(); - - // Look for the header. - if ("ftyp" == test.get_current_atom_name()) { - ret = true; - } else { - // Some versions of QuickTime don't have - // an ftyp section, so we'll just look - // for the mandatory moov section. - while(true) { - if ("moov" == test.get_current_atom_name()) { - ret = true; - break; - } - test.next_atom(); - test.read_atom(); - if (test.is_last_atom()) { - break; - } - } - } - } catch (GLib.Error e) { - debug("Error while testing for QuickTime file for %s: %s", file.get_path(), e.message); - } - - try { - test.close_file(); - } catch (GLib.Error e) { - debug("Error while closing Quicktime file: %s", e.message); - } - return ret; - } - - private ulong get_creation_date_time_for_quicktime() { - QuickTimeAtom test = new QuickTimeAtom(file); - time_t timestamp = 0; - - try { - test.open_file(); - bool done = false; - while(!done) { - // Look for "moov" section. - test.read_atom(); - if (test.is_last_atom()) break; - if ("moov" == test.get_current_atom_name()) { - QuickTimeAtom child = test.get_first_child_atom(); - while (!done) { - // Look for "mvhd" section, or break if none is found. - child.read_atom(); - if (child.is_last_atom() || 0 == child.section_size_remaining()) { - done = true; - break; - } - - if ("mvhd" == child.get_current_atom_name()) { - // Skip 4 bytes (version + flags) - child.read_uint32(); - // Grab the timestamp. - timestamp = child.read_uint32() - QUICKTIME_EPOCH_ADJUSTMENT; - done = true; - break; - } - child.next_atom(); - } - } - test.next_atom(); - } - } catch (GLib.Error e) { - debug("Error while testing for QuickTime file: %s", e.message); - } - - try { - test.close_file(); - } catch (GLib.Error e) { - debug("Error while closing Quicktime file: %s", e.message); - } - - // Some Android phones package videos recorded with their internal cameras in a 3GP - // container that looks suspiciously like a QuickTime container but really isn't -- for - // the timestamps of these Android 3GP videos are relative to the UNIX epoch - // (January 1, 1970) instead of the QuickTime epoch (January 1, 1904). So, if we detect a - // QuickTime movie with a negative timestamp, we can be pretty sure it isn't a valid - // QuickTime movie that was shot before 1904 but is instead a non-compliant 3GP video - // file. If we detect such a video, we correct its time. See this Redmine ticket - // (http://redmine.yorba.org/issues/3314) for more information. - if (timestamp < 0) - timestamp += QUICKTIME_EPOCH_ADJUSTMENT; - - return (ulong) timestamp; - } -} - -private class QuickTimeAtom { - private GLib.File file = null; - private string section_name = ""; - private uint64 section_size = 0; - private uint64 section_offset = 0; - private GLib.DataInputStream input = null; - private QuickTimeAtom? parent = null; - - public QuickTimeAtom(GLib.File file) { - this.file = file; - } - - private QuickTimeAtom.with_input_stream(GLib.DataInputStream input, QuickTimeAtom parent) { - this.input = input; - this.parent = parent; - } - - public void open_file() throws GLib.Error { - close_file(); - input = new GLib.DataInputStream(file.read()); - input.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); - section_size = 0; - section_offset = 0; - section_name = ""; - } - - public void close_file() throws GLib.Error { - if (null != input) { - input.close(); - input = null; - } - } - - private void advance_section_offset(uint64 amount) { - section_offset += amount; - if (null != parent) { - parent.advance_section_offset(amount); - } - } - - public QuickTimeAtom get_first_child_atom() { - // Child will simply have the input stream - // but not the size/offset. This works because - // child atoms follow immediately after a header, - // so no skipping is required to access the child - // from the current position. - return new QuickTimeAtom.with_input_stream(input, this); - } - - public uchar read_byte() throws GLib.Error { - advance_section_offset(1); - return input.read_byte(); - } - - public uint32 read_uint32() throws GLib.Error { - advance_section_offset(4); - return input.read_uint32(); - } - - public uint64 read_uint64() throws GLib.Error { - advance_section_offset(8); - return input.read_uint64(); - } - - public void read_atom() throws GLib.Error { - // Read atom size. - section_size = read_uint32(); - - // Read atom name. - GLib.StringBuilder sb = new GLib.StringBuilder(); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - section_name = sb.str; - - // Check string. - if (section_name.length != 4) { - throw new IOError.NOT_SUPPORTED("QuickTime atom name length is invalid for %s", - file.get_path()); - } - for (int i = 0; i < section_name.length; i++) { - if (!section_name[i].isprint()) { - throw new IOError.NOT_SUPPORTED("Bad QuickTime atom in file %s", file.get_path()); - } - } - - if (1 == section_size) { - // This indicates the section size is a 64-bit - // value, specified below the atom name. - section_size = read_uint64(); - } - } - - private void skip(uint64 skip_amount) throws GLib.Error { - skip_uint64(input, skip_amount); - } - - public uint64 section_size_remaining() { - assert(section_size >= section_offset); - return section_size - section_offset; - } - - public void next_atom() throws GLib.Error { - skip(section_size_remaining()); - section_size = 0; - section_offset = 0; - } - - public string get_current_atom_name() { - return section_name; - } - - public bool is_last_atom() { - return 0 == section_size; - } - -} - -private class AVIMetadataLoader { - - private File file = null; - - // A numerical date string, i.e 2010:01:28 14:54:25 - private const int NUMERICAL_DATE_LENGTH = 19; - - // Marker for timestamp section in a Nikon nctg blob. - private const uint16 NIKON_NCTG_TIMESTAMP_MARKER = 0x13; - - // Size limit to ensure we don't parse forever on a bad file. - private const int MAX_STRD_LENGTH = 100; - - public AVIMetadataLoader(File file) { - this.file = file; - } - - public MetadataDateTime? get_creation_date_time() { - return new MetadataDateTime((time_t) get_creation_date_time_for_avi()); - } - - public string? get_title() { - // Not supported. - return null; - } - - // Checks if the given file is an AVI file. - public bool is_supported() { - AVIChunk chunk = new AVIChunk(file); - bool ret = false; - try { - chunk.open_file(); - chunk.read_chunk(); - // Look for the header and identifier. - if ("RIFF" == chunk.get_current_chunk_name() && - "AVI " == chunk.read_name()) { - ret = true; - } - } catch (GLib.Error e) { - debug("Error while testing for AVI file: %s", e.message); - } - - try { - chunk.close_file(); - } catch (GLib.Error e) { - debug("Error while closing AVI file: %s", e.message); - } - return ret; - } - - // Parses a Nikon nctg tag. Based losely on avi_read_nikon() in FFmpeg. - private string read_nikon_nctg_tag(AVIChunk chunk) throws GLib.Error { - bool found_date = false; - while (chunk.section_size_remaining() > sizeof(uint16)*2) { - uint16 tag = chunk.read_uint16(); - uint16 size = chunk.read_uint16(); - if (NIKON_NCTG_TIMESTAMP_MARKER == tag) { - found_date = true; - break; - } - chunk.skip(size); - } - - if (found_date) { - // Read numerical date string, example: 2010:01:28 14:54:25 - GLib.StringBuilder sb = new GLib.StringBuilder(); - for (int i = 0; i < NUMERICAL_DATE_LENGTH; i++) { - sb.append_c((char) chunk.read_byte()); - } - return sb.str; - } - return ""; - } - - // Parses a Fujifilm strd tag. Based on information from: - // http://www.eden-foundation.org/products/code/film_date_stamp/index.html - private string read_fuji_strd_tag(AVIChunk chunk) throws GLib.Error { - chunk.skip(98); // Ignore 98-byte binary blob. - chunk.skip(8); // Ignore the string "FUJIFILM" - // Read until we find four colons, then two more chars. - int colons = 0; - int post_colons = 0; - GLib.StringBuilder sb = new GLib.StringBuilder(); - // End of date is two chars past the fourth colon. - while (colons <= 4 && post_colons < 2) { - char c = (char) chunk.read_byte(); - if (4 == colons) { - post_colons++; - } - if (':' == c) { - colons++; - } - if (c.isprint()) { - sb.append_c(c); - } - if (sb.len > MAX_STRD_LENGTH) { - return ""; // Give up searching. - } - } - - if (sb.str.length < NUMERICAL_DATE_LENGTH) { - return ""; - } - // Date is now at the end of the string. - return sb.str.substring(sb.str.length - NUMERICAL_DATE_LENGTH); - } - - // Recursively read file until the section is found. - private string? read_section(AVIChunk chunk) throws GLib.Error { - while (true) { - chunk.read_chunk(); - string name = chunk.get_current_chunk_name(); - if ("IDIT" == name) { - return chunk.section_to_string(); - } else if ("nctg" == name) { - return read_nikon_nctg_tag(chunk); - } else if ("strd" == name) { - return read_fuji_strd_tag(chunk); - } - - if ("LIST" == name) { - chunk.read_name(); // Read past list name. - string result = read_section(chunk.get_first_child_chunk()); - if (null != result) { - return result; - } - } - - if (chunk.is_last_chunk()) { - break; - } - chunk.next_chunk(); - } - return null; - } - - // Parses a date from a string. - // Largely based on GStreamer's avi/gstavidemux.c - // and the information here: - // http://www.eden-foundation.org/products/code/film_date_stamp/index.html - private ulong parse_date(string sdate) { - if (sdate.length == 0) { - return 0; - } - - Date date = Date(); - uint seconds = 0; - int year, month, day, hour, min, sec; - char weekday[4]; - char monthstr[4]; - - if (sdate[0].isdigit()) { - // Format is: 2005:08:17 11:42:43 - // Format is: 2010/11/30/ 19:42 - // Format is: 2010/11/30 19:42 - string tmp = sdate.dup(); - tmp.canon("0123456789 ", ' '); // strip everything but numbers and spaces - sec = 0; - int result = tmp.scanf("%d %d %d %d %d %d", out year, out month, out day, out hour, out min, out sec); - if(result < 5) { - return 0; - } - date.set_dmy((DateDay) day, (DateMonth) month, (DateYear) year); - seconds = sec + min * 60 + hour * 3600; - } else { - // Format is: Mon Mar 3 09:44:56 2008 - if(7 != sdate.scanf("%3s %3s %d %d:%d:%d %d", weekday, monthstr, out day, out hour, - out min, out sec, out year)) { - return 0; // Error - } - date.set_dmy((DateDay) day, month_from_string((string) monthstr), (DateYear) year); - seconds = sec + min * 60 + hour * 3600; - } - - Time time = Time(); - date.to_time(out time); - - // watch for overflow (happens on quasi-bogus dates, like Year 200) - time_t tm = time.mktime(); - ulong result = tm + seconds; - if (result < tm) { - debug("Overflow for timestamp in video file %s", file.get_path()); - - return 0; - } - - return result; - } - - private DateMonth month_from_string(string s) { - switch (s.down()) { - case "jan": - return DateMonth.JANUARY; - case "feb": - return DateMonth.FEBRUARY; - case "mar": - return DateMonth.MARCH; - case "apr": - return DateMonth.APRIL; - case "may": - return DateMonth.MAY; - case "jun": - return DateMonth.JUNE; - case "jul": - return DateMonth.JULY; - case "aug": - return DateMonth.AUGUST; - case "sep": - return DateMonth.SEPTEMBER; - case "oct": - return DateMonth.OCTOBER; - case "nov": - return DateMonth.NOVEMBER; - case "dec": - return DateMonth.DECEMBER; - } - return DateMonth.BAD_MONTH; - } - - private ulong get_creation_date_time_for_avi() { - AVIChunk chunk = new AVIChunk(file); - ulong timestamp = 0; - try { - chunk.open_file(); - chunk.nonsection_skip(12); // Advance past 12 byte header. - string sdate = read_section(chunk); - if (null != sdate) { - timestamp = parse_date(sdate.strip()); - } - } catch (GLib.Error e) { - debug("Error while reading AVI file: %s", e.message); - } - - try { - chunk.close_file(); - } catch (GLib.Error e) { - debug("Error while closing AVI file: %s", e.message); - } - return timestamp; - } -} - -private class AVIChunk { - private GLib.File file = null; - private string section_name = ""; - private uint64 section_size = 0; - private uint64 section_offset = 0; - private GLib.DataInputStream input = null; - private AVIChunk? parent = null; - private const int MAX_STRING_TO_SECTION_LENGTH = 1024; - - public AVIChunk(GLib.File file) { - this.file = file; - } - - private AVIChunk.with_input_stream(GLib.DataInputStream input, AVIChunk parent) { - this.input = input; - this.parent = parent; - } - - public void open_file() throws GLib.Error { - close_file(); - input = new GLib.DataInputStream(file.read()); - input.set_byte_order(DataStreamByteOrder.LITTLE_ENDIAN); - section_size = 0; - section_offset = 0; - section_name = ""; - } - - public void close_file() throws GLib.Error { - if (null != input) { - input.close(); - input = null; - } - } - - public void nonsection_skip(uint64 skip_amount) throws GLib.Error { - skip_uint64(input, skip_amount); - } - - public void skip(uint64 skip_amount) throws GLib.Error { - advance_section_offset(skip_amount); - skip_uint64(input, skip_amount); - } - - public AVIChunk get_first_child_chunk() { - return new AVIChunk.with_input_stream(input, this); - } - - private void advance_section_offset(uint64 amount) { - if ((section_offset + amount) > section_size) - amount = section_size - section_offset; - - section_offset += amount; - if (null != parent) { - parent.advance_section_offset(amount); - } - } - - public uchar read_byte() throws GLib.Error { - advance_section_offset(1); - return input.read_byte(); - } - - public uint16 read_uint16() throws GLib.Error { - advance_section_offset(2); - return input.read_uint16(); - } - - public void read_chunk() throws GLib.Error { - // don't use checked reads here because they advance the section offset, which we're trying - // to determine here - GLib.StringBuilder sb = new GLib.StringBuilder(); - sb.append_c((char) input.read_byte()); - sb.append_c((char) input.read_byte()); - sb.append_c((char) input.read_byte()); - sb.append_c((char) input.read_byte()); - section_name = sb.str; - section_size = input.read_uint32(); - section_offset = 0; - } - - public string read_name() throws GLib.Error { - GLib.StringBuilder sb = new GLib.StringBuilder(); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - sb.append_c((char) read_byte()); - return sb.str; - } - - public void next_chunk() throws GLib.Error { - skip(section_size_remaining()); - section_size = 0; - section_offset = 0; - } - - public string get_current_chunk_name() { - return section_name; - } - - public bool is_last_chunk() { - return section_size == 0; - } - - public uint64 section_size_remaining() { - assert(section_size >= section_offset); - return section_size - section_offset; - } - - // Reads section contents into a string. - public string section_to_string() throws GLib.Error { - GLib.StringBuilder sb = new GLib.StringBuilder(); - while (section_offset < section_size) { - sb.append_c((char) read_byte()); - if (sb.len > MAX_STRING_TO_SECTION_LENGTH) { - return sb.str; - } - } - return sb.str; - } - -} - diff --git a/src/camera/CameraBranch.vala b/src/camera/CameraBranch.vala index 052f093..83e6a66 100644 --- a/src/camera/CameraBranch.vala +++ b/src/camera/CameraBranch.vala @@ -104,7 +104,7 @@ public class Camera.SidebarEntry : Sidebar.SimplePageEntry { } protected override Page create_page() { - return new ImportPage(camera.gcamera, uri, get_sidebar_name(), get_sidebar_icon()); + return new ImportPage(camera); } public string get_uri() { diff --git a/src/camera/CameraTable.vala b/src/camera/CameraTable.vala index 5f888ac..172c00a 100644 --- a/src/camera/CameraTable.vala +++ b/src/camera/CameraTable.vala @@ -4,20 +4,6 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ -public class DiscoveredCamera { - public GPhoto.Camera gcamera; - public string uri; - public string display_name; - public string? icon; - - public DiscoveredCamera(GPhoto.Camera gcamera, string uri, string display_name, string? icon) { - this.gcamera = gcamera; - this.uri = uri; - this.display_name = display_name; - this.icon = icon; - } -} - public class CameraTable { private const int UPDATE_DELAY_MSEC = 1000; @@ -119,32 +105,6 @@ public class CameraTable { return "gphoto2://[%s]/".printf(port); } - public static string? get_port_path(string port) { - // Accepted format is usb:001,005 - return port.has_prefix("usb:") ? - "/dev/bus/usb/%s".printf(port.substring(4).replace(",", "/")) : null; - } - -#if HAVE_UDEV - private string? get_name_for_uuid(string uuid) { - foreach (Volume volume in volume_monitor.get_volumes()) { - if (volume.get_identifier(VolumeIdentifier.UUID) == uuid) { - return volume.get_name(); - } - } - return null; - } - - private string? get_icon_for_uuid(string uuid) { - foreach (Volume volume in volume_monitor.get_volumes()) { - if (volume.get_identifier(VolumeIdentifier.UUID) == uuid) { - return volume.get_symbolic_icon().to_string(); - } - } - return null; - } -#endif - private void update_camera_table() throws GPhotoError { // need to do this because virtual ports come and go in the USB world (and probably others) GPhoto.PortInfoList port_info_list; @@ -217,8 +177,6 @@ public class CameraTable { // add cameras which were not present before foreach (string port in detected_map.keys) { string name = detected_map.get(port); - string display_name = null; - string? icon = null; string uri = get_port_uri(port); if (camera_map.has_key(uri)) { @@ -227,41 +185,7 @@ public class CameraTable { continue; } - -#if HAVE_UDEV - // Get display name for camera. - string path = get_port_path(port); - if (null != path) { - GUdev.Device device = client.query_by_device_file(path); - string serial = device.get_property("ID_SERIAL_SHORT"); - if (null != serial) { - // Try to get the name and icon. - display_name = get_name_for_uuid(serial); - icon = get_icon_for_uuid(serial); - } - if (null == display_name) { - display_name = device.get_sysfs_attr("product"); - } - if (null == display_name) { - display_name = device.get_property("ID_MODEL"); - } - } -#endif - - if (port.has_prefix("disk:")) { - try { - var mount = File.new_for_path (port.substring(5)).find_enclosing_mount(); - var volume = mount.get_volume(); - // Translators: First %s is the name of camera as gotten from GPhoto, second is the GVolume name, e.g. Mass storage camera (510MB volume) - display_name = _("%s (%s)").printf (name, volume.get_name ()); - icon = volume.get_symbolic_icon().to_string(); - } catch (Error e) { } - } - if (null == display_name) { - // Default to GPhoto detected name. - display_name = name; - } int index = port_info_list.lookup_path(port); if (index < 0) do_op((GPhoto.Result) index, "lookup port %s".printf(port)); @@ -283,14 +207,9 @@ public class CameraTable { do_op(abilities_list.get_abilities(index, out camera_abilities), "lookup camera abilities for %s".printf(name)); - GPhoto.Camera gcamera; - do_op(GPhoto.Camera.create(out gcamera), "create camera object for %s".printf(name)); - do_op(gcamera.set_abilities(camera_abilities), "set camera abilities for %s".printf(name)); - do_op(gcamera.set_port_info(port_info), "set port info for %s on %s".printf(name, port)); - debug("Adding to camera table: %s @ %s", name, port); - DiscoveredCamera camera = new DiscoveredCamera(gcamera, uri, display_name, icon); + var camera = new DiscoveredCamera(name, port, port_info, camera_abilities); camera_map.set(uri, camera); camera_added(camera); diff --git a/src/camera/DiscoveredCamera.vala b/src/camera/DiscoveredCamera.vala new file mode 100644 index 0000000..700af8b --- /dev/null +++ b/src/camera/DiscoveredCamera.vala @@ -0,0 +1,119 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class DiscoveredCamera { + public GPhoto.Camera gcamera; + public string uri; + public string display_name; + public string? icon; + + private string port; + private string camera_name; + private string[] mount_uris; + + public DiscoveredCamera(string name, string port, GPhoto.PortInfo port_info, GPhoto.CameraAbilities camera_abilities) throws GPhotoError { + this.port = port; + this.camera_name = name; + this.uri = "gphoto2://[%s]".printf(port); + + this.mount_uris = new string[0]; + this.mount_uris += this.uri; + this.mount_uris += "mtp://[%s]".printf(port); + + var res = GPhoto.Camera.create(out this.gcamera); + + if (res != GPhoto.Result.OK) { + throw new GPhotoError.LIBRARY("[%d] Unable to create camera object for %s: %s", + (int) res, name, res.as_string()); + } + + res = gcamera.set_abilities(camera_abilities); + if (res != GPhoto.Result.OK) { + throw new GPhotoError.LIBRARY("[%d] Unable to set camera abilities for %s: %s", + (int) res, name, res.as_string()); + } + + res = gcamera.set_port_info(port_info); + if (res != GPhoto.Result.OK) { + throw new GPhotoError.LIBRARY("[%d] Unable to set port infor for %s: %s", + (int) res, name, res.as_string()); + } + + var path = get_port_path(port); + if (path != null) { + var monitor = VolumeMonitor.get(); + foreach (var volume in monitor.get_volumes()) { + if (volume.get_identifier(VolumeIdentifier.UNIX_DEVICE) == path) { + this.display_name = volume.get_name(); + this.icon = volume.get_symbolic_icon().to_string(); + } + } + +#if HAVE_UDEV + var client = new GUdev.Client(null); + var device = client.query_by_device_file(path); + + + // Create alternative uris (used for unmount) + var serial = device.get_property("ID_SERIAL"); + this.mount_uris += "gphoto2://%s".printf(serial); + this.mount_uris += "mtp://%s".printf(serial); + + // Look-up alternative display names + if (display_name == null) { + display_name = device.get_sysfs_attr("product"); + } + + if (display_name == null) { + display_name = device.get_property("ID_MODEL"); + } +#endif + } + + if (port.has_prefix("disk:")) { + try { + var mount = File.new_for_path (port.substring(5)).find_enclosing_mount(); + var volume = mount.get_volume(); + if (volume != null) { + // Translators: First %s is the name of camera as gotten from GPhoto, second is the GVolume name, e.g. Mass storage camera (510MB volume) + display_name = _("%s (%s)").printf (name, volume.get_name ()); + icon = volume.get_symbolic_icon().to_string(); + } else { + // Translators: First %s is the name of camera as gotten from GPhoto, second is the GMount name, e.g. Mass storage camera (510MB volume) + display_name = _("%s (%s)").printf (name, mount.get_name ()); + icon = mount.get_symbolic_icon().to_string(); + } + + } catch (Error e) { } + } + + if (display_name == null) { + this.display_name = camera_name; + } + } + + public Mount? get_mount() { + foreach (var uri in this.mount_uris) { + var f = File.new_for_uri(uri); + try { + var mount = f.find_enclosing_mount(null); + if (mount != null) + return mount; + } catch (Error error) {} + } + + return null; + } + + private string? get_port_path(string port) { + // Accepted format is usb:001,005 + return port.has_prefix("usb:") ? + "/dev/bus/usb/%s".printf(port.substring(4).replace(",", "/")) : null; + } + +} + + diff --git a/src/camera/GPhoto.vala b/src/camera/GPhoto.vala index 9bcb151..702f307 100644 --- a/src/camera/GPhoto.vala +++ b/src/camera/GPhoto.vala @@ -93,7 +93,7 @@ namespace GPhoto { } // For CameraFileInfoFile, CameraFileInfoPreview, and CameraStorageInformation. See: - // http://redmine.yorba.org/issues/1851 + // https://bugzilla.gnome.org/show_bug.cgi?id=716252 // https://bugzilla.redhat.com/show_bug.cgi?id=585676 // https://sourceforge.net/tracker/?func=detail&aid=3000198&group_id=8874&atid=108874 public const int MAX_FILENAME_LENGTH = 63; @@ -129,11 +129,10 @@ namespace GPhoto { // Libgphoto will in some instances refuse to get metadata from a camera, but the camera is accessible as a // filesystem. In these cases shotwell can access the file directly. See: - // http://redmine.yorba.org/issues/2959 + // https://bugzilla.gnome.org/show_bug.cgi?id=716915 public PhotoMetadata? get_fallback_metadata(Camera camera, Context context, string folder, string filename) { // Fixme: Why do we need to query get_storageinfo here first? GPhoto.CameraStorageInformation[] sifs = null; - int count = 0; camera.get_storageinfo(out sifs, context); GPhoto.PortInfo port_info; diff --git a/src/camera/ImportPage.vala b/src/camera/ImportPage.vala index 84d7cbe..a5d3b4e 100644 --- a/src/camera/ImportPage.vala +++ b/src/camera/ImportPage.vala @@ -21,13 +21,13 @@ abstract class ImportSource : ThumbnailSource, Indexable { private string folder; private string filename; private ulong file_size; - private time_t modification_time; + private DateTime modification_time; private Gdk.Pixbuf? preview = null; private string? indexable_keywords = null; protected ImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder, - string filename, ulong file_size, time_t modification_time) { - this.camera_name = camera_name; + string filename, ulong file_size, DateTime modification_time) { + this.camera_name =camera_name; this.camera = camera; this.fsid = fsid; this.folder = folder; @@ -65,7 +65,7 @@ abstract class ImportSource : ThumbnailSource, Indexable { return file_size; } - public time_t get_modification_time() { + public DateTime get_modification_time() { return modification_time; } @@ -73,7 +73,7 @@ abstract class ImportSource : ThumbnailSource, Indexable { return preview; } - public virtual time_t get_exposure_time() { + public virtual DateTime get_exposure_time() { return get_modification_time(); } @@ -110,7 +110,7 @@ abstract class ImportSource : ThumbnailSource, Indexable { class VideoImportSource : ImportSource { public VideoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder, - string filename, ulong file_size, time_t modification_time) { + string filename, ulong file_size, DateTime modification_time) { base(camera_name, camera, fsid, folder, filename, file_size, modification_time); } @@ -159,7 +159,7 @@ class PhotoImportSource : ImportSource { private PhotoImportSource? associated = null; // JPEG source for RAW+JPEG public PhotoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder, - string filename, ulong file_size, time_t modification_time, PhotoFileFormat file_format) { + string filename, ulong file_size, DateTime modification_time, PhotoFileFormat file_format) { base(camera_name, camera, fsid, folder, filename, file_size, modification_time); this.file_format = file_format; } @@ -200,7 +200,7 @@ class PhotoImportSource : ImportSource { this.exif_md5 = exif_md5; } - public override time_t get_exposure_time() { + public override DateTime get_exposure_time() { if (metadata == null) return get_modification_time(); @@ -340,10 +340,10 @@ class ImportPreview : MediaSourceItem { if (duplicated_photo_id.is_valid()) { // Check exposure timestamp LibraryPhoto duplicated_photo = LibraryPhoto.global.fetch(duplicated_photo_id); - time_t photo_exposure_time = photo_import_source.get_exposure_time(); - time_t duplicated_photo_exposure_time = duplicated_photo.get_exposure_time(); + DateTime photo_exposure_time = photo_import_source.get_exposure_time(); + DateTime duplicated_photo_exposure_time = duplicated_photo.get_exposure_time(); - if (photo_exposure_time == duplicated_photo_exposure_time) { + if (photo_exposure_time.equal(duplicated_photo_exposure_time)) { duplicated_file = DuplicatedFile.create_from_photo_id( LibraryPhoto.global.get_basename_filesize_duplicate( get_import_source().get_filename(), (int64) filesize)); @@ -485,7 +485,7 @@ public class ImportPage : CheckerboardPage { private string filename; private uint64 filesize; private PhotoMetadata metadata; - private time_t exposure_time; + private DateTime exposure_time; private CameraImportJob? associated = null; private BackingPhotoRow? associated_file = null; private DuplicatedFile? duplicated_file; @@ -503,12 +503,13 @@ public class ImportPage : CheckerboardPage { assert(fulldir != null); filename = import_file.get_filename(); filesize = import_file.get_filesize(); - metadata = (import_file is PhotoImportSource) ? - (import_file as PhotoImportSource).get_metadata() : null; + var photo_import_source = import_file as PhotoImportSource; + metadata = (photo_import_source != null) ? + photo_import_source.get_metadata() : null; exposure_time = import_file.get_exposure_time(); } - public time_t get_exposure_time() { + public DateTime get_exposure_time() { return exposure_time; } @@ -516,8 +517,8 @@ public class ImportPage : CheckerboardPage { return duplicated_file; } - public override time_t get_exposure_time_override() { - return (import_file is VideoImportSource) ? get_exposure_time() : 0; + public override DateTime? get_exposure_time_override() { + return (import_file is VideoImportSource) ? get_exposure_time() : null; } public override string get_dest_identifier() { @@ -682,16 +683,13 @@ public class ImportPage : CheckerboardPage { private Gtk.Label camera_label = new Gtk.Label(null); private Gtk.CheckButton hide_imported; private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar(); - private GPhoto.Camera camera; - private string uri; + private DiscoveredCamera dcamera; private bool busy = false; private bool refreshed = false; private GPhoto.Result refresh_result = GPhoto.Result.OK; private string refresh_error = null; - private string camera_name; private VolumeMonitor volume_monitor = null; private ImportPage? local_ref = null; - private string? icon; private ImportPageSearchViewFilter search_filter = new ImportPageSearchViewFilter(); private HideImportedViewFilter hide_imported_filter = new HideImportedViewFilter(); private CameraViewTracker tracker; @@ -707,28 +705,15 @@ public class ImportPage : CheckerboardPage { LIBRARY_ERROR } - public ImportPage(GPhoto.Camera camera, string uri, string? display_name = null, string? icon = null) { + public ImportPage(DiscoveredCamera dcamera) { base(_("Camera")); - this.camera = camera; - this.uri = uri; - this.import_sources = new ImportSourceCollection("ImportSources for %s".printf(uri)); - this.icon = icon; + this.dcamera = dcamera; + this.import_sources = new ImportSourceCollection("ImportSources for %s".printf(dcamera.uri)); tracker = new CameraViewTracker(get_view()); - // Get camera name. - if (null != display_name) { - camera_name = display_name; - } else { - GPhoto.CameraAbilities abilities; - GPhoto.Result res = camera.get_abilities(out abilities); - if (res != GPhoto.Result.OK) { - debug("Unable to get camera abilities: %s", res.to_full_string()); - camera_name = _("Camera"); - } - } - camera_label.set_text(camera_name); - set_page_name(camera_name); + camera_label.set_text(dcamera.display_name); + set_page_name(dcamera.display_name); // Mount.unmounted signal is *only* fired when a VolumeMonitor has been instantiated. this.volume_monitor = VolumeMonitor.get(); @@ -846,6 +831,14 @@ public class ImportPage : CheckerboardPage { return tracker; } + protected override string get_view_empty_icon() { + if (this.dcamera.icon != null) { + return this.dcamera.icon; + } + + return "camera-photo-symbolic"; + } + protected override string get_view_empty_message() { return _("The camera seems to be empty. No photos/videos found to import"); } @@ -855,8 +848,8 @@ public class ImportPage : CheckerboardPage { } private static int64 preview_comparator(void *a, void *b) { - return ((ImportPreview *) a)->get_import_source().get_exposure_time() - - ((ImportPreview *) b)->get_import_source().get_exposure_time(); + return nullsafe_date_time_comperator(((ImportPreview *) a)->get_import_source().get_exposure_time(), + ((ImportPreview *) b)->get_import_source().get_exposure_time()); } private static bool preview_comparator_predicate(DataObject object, Alteration alteration) { @@ -864,7 +857,7 @@ public class ImportPage : CheckerboardPage { } private int64 import_job_comparator(void *a, void *b) { - return ((CameraImportJob *) a)->get_exposure_time() - ((CameraImportJob *) b)->get_exposure_time(); + return nullsafe_date_time_comperator(((CameraImportJob *) a)->get_exposure_time(), ((CameraImportJob *) b)->get_exposure_time()); } protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) { @@ -896,11 +889,11 @@ public class ImportPage : CheckerboardPage { } public GPhoto.Camera get_camera() { - return camera; + return dcamera.gcamera; } public string get_uri() { - return uri; + return dcamera.uri; } public bool is_busy() { @@ -998,27 +991,9 @@ public class ImportPage : CheckerboardPage { } // if locked because it's mounted, offer to unmount - debug("Checking if %s is mounted…", uri); + debug("Checking if %s is mounted…", dcamera.uri); - File uri = File.new_for_uri(uri); - - Mount mount = null; - try { - mount = uri.find_enclosing_mount(null); - } catch (Error err) { - // error means not mounted - } - - // Could not find mount for gphoto2://, re-try with mtp:// - // It seems some devices are mounted using MTP and not gphoto2 daemon - if (mount == null && this.uri.has_prefix("gphoto2")) { - uri = File.new_for_uri("mtp" + this.uri.substring(7)); - try { - mount = uri.find_enclosing_mount(null); - } catch (Error err) { - // error means not mounted - } - } + var mount = dcamera.get_mount(); if (mount != null) { // it's mounted, offer to unmount for the user @@ -1128,7 +1103,7 @@ public class ImportPage : CheckerboardPage { * @param search_target The name of the directory to look for. */ private bool check_directory_exists(int fsid, string dir, string search_target) { - string? fulldir = get_fulldir(camera, camera_name, fsid, dir); + string? fulldir = get_fulldir(dcamera.gcamera, dcamera.display_name, fsid, dir); GPhoto.Result result; GPhoto.CameraList folders; @@ -1138,7 +1113,7 @@ public class ImportPage : CheckerboardPage { return false; } - result = camera.list_folders(fulldir, folders, spin_idle_context.context); + result = dcamera.gcamera.list_folders(fulldir, folders, spin_idle_context.context); if (result != GPhoto.Result.OK) { // fetching the list failed - can't determine whether specified dir is present return false; @@ -1167,7 +1142,7 @@ public class ImportPage : CheckerboardPage { update_status(busy, false); refresh_error = null; - refresh_result = camera.init(spin_idle_context.context); + refresh_result = dcamera.gcamera.init(spin_idle_context.context); // If we fail to claim the device, we might have run into a conflict // with gvfs-gphoto2-volume-monitor. Back off, try again after @@ -1209,7 +1184,7 @@ public class ImportPage : CheckerboardPage { Gee.ArrayList<ImportSource> import_list = new Gee.ArrayList<ImportSource>(); GPhoto.CameraStorageInformation[] sifs = null; - refresh_result = camera.get_storageinfo(out sifs, spin_idle_context.context); + refresh_result = dcamera.gcamera.get_storageinfo(out sifs, spin_idle_context.context); if (refresh_result == GPhoto.Result.OK) { for (int fsid = 0; fsid < sifs.length; fsid++) { // Check well-known video and image paths first to prevent accidental @@ -1302,7 +1277,7 @@ public class ImportPage : CheckerboardPage { progress_bar.set_text(""); progress_bar.set_fraction(0.0); - GPhoto.Result res = camera.exit(spin_idle_context.context); + GPhoto.Result res = dcamera.gcamera.exit(spin_idle_context.context); if (res != GPhoto.Result.OK) { // log but don't fail warning("Unable to unlock camera: %s", res.to_full_string()); @@ -1386,7 +1361,7 @@ public class ImportPage : CheckerboardPage { } private bool enumerate_files(int fsid, string dir, Gee.ArrayList<ImportSource> import_list) { - string? fulldir = get_fulldir(camera, camera_name, fsid, dir); + string? fulldir = get_fulldir(dcamera.gcamera, dcamera.display_name, fsid, dir); if (fulldir == null) { warning("Skipping enumerating %s: invalid folder name", dir); @@ -1401,7 +1376,7 @@ public class ImportPage : CheckerboardPage { return false; } - refresh_result = camera.list_files(fulldir, files, spin_idle_context.context); + refresh_result = dcamera.gcamera.list_files(fulldir, files, spin_idle_context.context); if (refresh_result != GPhoto.Result.OK) { warning("Unable to list files in %s: %s", fulldir, refresh_result.to_full_string()); @@ -1424,7 +1399,7 @@ public class ImportPage : CheckerboardPage { try { GPhoto.CameraFileInfo info; - if (!GPhoto.get_info(spin_idle_context.context, camera, fulldir, filename, out info)) { + if (!GPhoto.get_info(spin_idle_context.context, dcamera.gcamera, fulldir, filename, out info)) { warning("Skipping import of %s/%s: name too long", fulldir, filename); continue; @@ -1438,8 +1413,8 @@ public class ImportPage : CheckerboardPage { } if (VideoReader.is_supported_video_filename(filename)) { - VideoImportSource video_source = new VideoImportSource(camera_name, camera, - fsid, dir, filename, info.file.size, info.file.mtime); + VideoImportSource video_source = new VideoImportSource(dcamera.display_name, dcamera.gcamera, + fsid, dir, filename, info.file.size, new DateTime.from_unix_utc(info.file.mtime)); import_list.add(video_source); } else { // determine file format from type, and then from file extension @@ -1454,8 +1429,8 @@ public class ImportPage : CheckerboardPage { continue; } } - import_list.add(new PhotoImportSource(camera_name, camera, fsid, dir, filename, - info.file.size, info.file.mtime, file_format)); + import_list.add(new PhotoImportSource(dcamera.display_name, dcamera.gcamera, fsid, dir, filename, + info.file.size, new DateTime.from_unix_utc(info.file.mtime), file_format)); } progress_bar.pulse(); @@ -1479,7 +1454,7 @@ public class ImportPage : CheckerboardPage { return false; } - refresh_result = camera.list_folders(fulldir, folders, spin_idle_context.context); + refresh_result = dcamera.gcamera.list_folders(fulldir, folders, spin_idle_context.context); if (refresh_result != GPhoto.Result.OK) { warning("Unable to list folders in %s: %s", fulldir, refresh_result.to_full_string()); @@ -1498,8 +1473,12 @@ public class ImportPage : CheckerboardPage { return false; } - if (!enumerate_files(fsid, append_path(dir, subdir), import_list)) - return false; + if (subdir.has_prefix(".")) { + debug("Skipping hidden sub-folder %s in %s", subdir, dir); + } else { + if (!enumerate_files(fsid, append_path(dir, subdir), import_list)) + return false; + } } return true; @@ -1575,7 +1554,7 @@ public class ImportPage : CheckerboardPage { PhotoMetadata? metadata = null; if (!VideoReader.is_supported_video_filename(filename)) { try { - metadata = GPhoto.load_metadata(spin_idle_context.context, camera, fulldir, + metadata = GPhoto.load_metadata(spin_idle_context.context, dcamera.gcamera, fulldir, filename); } catch (Error err) { warning("Unable to fetch metadata for %s/%s: %s", fulldir, filename, @@ -1604,7 +1583,7 @@ public class ImportPage : CheckerboardPage { preview_fulldir = associated.get_fulldir(); preview_filename = associated.get_filename(); } - preview = GPhoto.load_preview(spin_idle_context.context, camera, preview_fulldir, + preview = GPhoto.load_preview(spin_idle_context.context, dcamera.gcamera, preview_fulldir, preview_filename, out preview_md5); } catch (Error err) { // only issue the warning message if we're not reading a video. GPhoto is capable @@ -1621,17 +1600,18 @@ public class ImportPage : CheckerboardPage { debug("camera MD5 %s: exif=%s preview=%s", filename, exif_only_md5, preview_md5); #endif - if (import_source is VideoImportSource) - (import_source as VideoImportSource).update(preview); + var video_import_source = import_source as VideoImportSource; + if (video_import_source != null) + video_import_source.update(preview); - if (import_source is PhotoImportSource) - (import_source as PhotoImportSource).update(preview, preview_md5, metadata, - exif_only_md5); + var photo_import_source = import_source as PhotoImportSource; + if (photo_import_source != null) + photo_import_source.update(preview, preview_md5, metadata, exif_only_md5); if (associated != null) { try { PhotoMetadata? associated_metadata = GPhoto.load_metadata(spin_idle_context.context, - camera, associated.get_fulldir(), associated.get_filename()); + dcamera.gcamera, associated.get_fulldir(), associated.get_filename()); associated.update(preview, preview_md5, associated_metadata, null); } catch (Error err) { warning("Unable to fetch metadata for %s/%s: %s", associated.get_fulldir(), @@ -1671,7 +1651,7 @@ public class ImportPage : CheckerboardPage { } private void import(Gee.Iterable<DataObject> items) { - GPhoto.Result res = camera.init(spin_idle_context.context); + GPhoto.Result res = dcamera.gcamera.init(spin_idle_context.context); if (res != GPhoto.Result.OK) { AppWindow.error_message(_("Unable to lock camera: %s").printf(res.to_full_string())); @@ -1712,14 +1692,14 @@ public class ImportPage : CheckerboardPage { jobs.add(import_job); } - debug("Importing %d files from %s", jobs.size, camera_name); + debug("Importing %d files from %s", jobs.size, dcamera.display_name); if (jobs.size > 0) { // see import_reporter() to see why this is held during the duration of the import assert(local_ref == null); local_ref = this; - BatchImport batch_import = new BatchImport(jobs, camera_name, import_reporter, + BatchImport batch_import = new BatchImport(jobs, dcamera.display_name, import_reporter, null, already_imported); batch_import.import_job_failed.connect(on_import_job_failed); batch_import.import_complete.connect(close_import); @@ -1811,7 +1791,7 @@ public class ImportPage : CheckerboardPage { } private void close_import() { - GPhoto.Result res = camera.exit(spin_idle_context.context); + GPhoto.Result res = dcamera.gcamera.exit(spin_idle_context.context); if (res != GPhoto.Result.OK) { // log but don't fail message("Unable to unlock camera: %s", res.to_full_string()); diff --git a/src/config/Config.vala b/src/config/Config.vala index 0e2798a..3081ff0 100644 --- a/src/config/Config.vala +++ b/src/config/Config.vala @@ -26,7 +26,7 @@ public class Facade : ConfigurationFacade { public signal void colors_changed(); private Facade() { - base(new GSettingsConfigurationEngine()); + base(new GSettingsConfigurationEngine(Shotwell.ProfileManager.get_instance().id())); transparent_background_type_changed.connect(on_color_name_changed); transparent_background_color_changed.connect(on_color_name_changed); diff --git a/src/config/ConfigurationInterfaces.vala b/src/config/ConfigurationInterfaces.vala index a8d8192..12c7da1 100644 --- a/src/config/ConfigurationInterfaces.vala +++ b/src/config/ConfigurationInterfaces.vala @@ -39,6 +39,7 @@ public enum ConfigurableProperty { DISPLAY_EXTENDED_PROPERTIES, DISPLAY_SIDEBAR, DISPLAY_TOOLBAR, + DISPLAY_MAP_WIDGET, DISPLAY_SEARCH_BAR, DISPLAY_PHOTO_RATINGS, DISPLAY_PHOTO_TAGS, @@ -149,7 +150,10 @@ public enum ConfigurableProperty { case DISPLAY_TOOLBAR: return "DISPLAY_TOOLBAR"; - + + case DISPLAY_MAP_WIDGET: + return "DISPLAY_MAP_WIDGET"; + case DISPLAY_SEARCH_BAR: return "DISPLAY_SEARCH_BAR"; @@ -400,6 +404,9 @@ public abstract class ConfigurationFacade : Object { case ConfigurableProperty.IMPORT_DIR: import_directory_changed(); break; + default: + // We do not support notification for the rest of the properties + break; } } @@ -718,7 +725,6 @@ public abstract class ConfigurationFacade : Object { on_configuration_error(err); } } - // // display toolbar @@ -742,6 +748,26 @@ public abstract class ConfigurationFacade : Object { } // + // display map widget + // + public virtual bool get_display_map_widget() { + try { + return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_MAP_WIDGET); + } catch (ConfigurationError err) { + on_configuration_error(err); + + return false; + } + } + public virtual void set_display_map_widget(bool display) { + try { + get_engine().set_bool_property(ConfigurableProperty.DISPLAY_MAP_WIDGET, display); + } catch (ConfigurationError err) { + on_configuration_error(err); + } + } + + // // display search & filter toolbar // public virtual bool get_display_search_bar() { diff --git a/src/config/GSettingsEngine.vala b/src/config/GSettingsEngine.vala index d35eb93..d4d95c6 100644 --- a/src/config/GSettingsEngine.vala +++ b/src/config/GSettingsEngine.vala @@ -5,7 +5,7 @@ */ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { - private const string ROOT_SCHEMA_NAME = "org.yorba.shotwell"; + private const string ROOT_SCHEMA_NAME = "org.gnome.shotwell"; private const string PREFS_SCHEMA_NAME = ROOT_SCHEMA_NAME + ".preferences"; private const string UI_PREFS_SCHEMA_NAME = PREFS_SCHEMA_NAME + ".ui"; private const string SLIDESHOW_PREFS_SCHEMA_NAME = PREFS_SCHEMA_NAME + ".slideshow"; @@ -25,8 +25,11 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { private string[] schema_names; private string[] key_names; private Gee.HashMap<string, Settings> settings_cache = new Gee.HashMap<string, Settings>(); - - public GSettingsConfigurationEngine() { + + private string profile = ""; + + public GSettingsConfigurationEngine(string? profile) { + this.profile = profile == null ? "" : profile; schema_names = new string[ConfigurableProperty.NUM_PROPERTIES]; schema_names[ConfigurableProperty.AUTO_IMPORT_FROM_LIBRARY] = FILES_PREFS_SCHEMA_NAME; @@ -47,6 +50,7 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { schema_names[ConfigurableProperty.DISPLAY_EXTENDED_PROPERTIES] = UI_PREFS_SCHEMA_NAME; schema_names[ConfigurableProperty.DISPLAY_SIDEBAR] = UI_PREFS_SCHEMA_NAME; schema_names[ConfigurableProperty.DISPLAY_TOOLBAR] = UI_PREFS_SCHEMA_NAME; + schema_names[ConfigurableProperty.DISPLAY_MAP_WIDGET] = UI_PREFS_SCHEMA_NAME; schema_names[ConfigurableProperty.DISPLAY_SEARCH_BAR] = UI_PREFS_SCHEMA_NAME; schema_names[ConfigurableProperty.DISPLAY_PHOTO_RATINGS] = UI_PREFS_SCHEMA_NAME; schema_names[ConfigurableProperty.DISPLAY_PHOTO_TAGS] = UI_PREFS_SCHEMA_NAME; @@ -120,6 +124,7 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { key_names[ConfigurableProperty.DISPLAY_EXTENDED_PROPERTIES] = "display-extended-properties"; key_names[ConfigurableProperty.DISPLAY_SIDEBAR] = "display-sidebar"; key_names[ConfigurableProperty.DISPLAY_TOOLBAR] = "display-toolbar"; + key_names[ConfigurableProperty.DISPLAY_MAP_WIDGET] = "display-map-widget"; key_names[ConfigurableProperty.DISPLAY_SEARCH_BAR] = "display-search-bar"; key_names[ConfigurableProperty.DISPLAY_PHOTO_RATINGS] = "display-photo-ratings"; key_names[ConfigurableProperty.DISPLAY_PHOTO_TAGS] = "display-photo-tags"; @@ -176,7 +181,14 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { private Settings get_settings(string schema) { if (!this.settings_cache.has_key(schema)) { - this.settings_cache[schema] = new Settings(schema); + if (schema.has_prefix (ROOT_SCHEMA_NAME)) { + var path = schema.replace(ROOT_SCHEMA_NAME, ""); + path = "/org/gnome/shotwell/%s%s/".printf(profile == "" ? "" : "profiles/" + profile, path.replace(".", "/")); + path = path.replace("//", "/"); + this.settings_cache[schema] = new Settings.with_path (schema, path); + } else { + this.settings_cache[schema] = new Settings(schema); + } } return this.settings_cache[schema]; @@ -229,7 +241,9 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { Settings schema_object = get_settings(schema); - return schema_object.get_int(key); + var v = schema_object.get_int(key); + + return v; } private void set_gs_int(string schema, string key, int value) throws ConfigurationError { @@ -292,7 +306,7 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { if (cleaned_id == null) cleaned_id = "default"; - cleaned_id = cleaned_id.replace("org.yorba.shotwell.", ""); + cleaned_id = cleaned_id.replace("org.gnome.shotwell.", ""); cleaned_id = cleaned_id.replace(".", "-"); return cleaned_id; @@ -304,7 +318,7 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { cleaned_id = "default"; cleaned_id = cleaned_id.replace(".", "-"); - return "org.yorba.shotwell.%s.%s".printf(domain, cleaned_id); + return "org.gnome.shotwell.%s.%s".printf(domain, cleaned_id); } private static string make_gsettings_key(string gconf_key) { @@ -513,4 +527,64 @@ public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object { } } + /*! @brief Migrates settings data over from old-style /org/yorba/ paths to /org/gnome/ ones. + * Should only be called ONCE, during DB upgrading; otherwise, stale data may be copied + * over newer data by accident. + */ + public static void run_gsettings_migrator_v2() { + var source = SettingsSchemaSource.get_default(); + var schema = source.lookup("org.yorba.shotwell", true); + var settings = new Settings.full(schema, null, null); + + copy_schema(settings); + + Settings.sync(); + } + + static void copy_schema(Settings settings) { + SettingsSchema schema; + ((Object)settings).get("settings-schema", out schema, null); + var id = schema.get_id(); + var path = schema.get_path(); + + var new_id = id.replace("org.yorba.shotwell", "org.gnome.shotwell"); + var new_path = path.replace("/org/yorba/shotwell", "/org/gnome/shotwell"); + + var new_schema = SettingsSchemaSource.get_default().lookup(new_id, true); + + // If we cannot find this schema, we cannot migrate the keys anyway, so skip it + if (new_schema != null) { + var new_settings = new Settings.with_path(new_id, new_path); + new_settings.delay(); + + foreach (var k in schema.list_keys()) { + var key = schema.get_key(k); + var default_value = key.get_default_value(); + var val = settings.get_value(k); + if (val.equal(default_value)) { + debug("%s is default value, skipping", k); + continue; + } + + if (!new_schema.has_key(k)) { + debug("Cannot migrate %s as it does not exist", k); + continue; + } + + debug("Will migrate %s %s @ %s -> %s:%s %s", k, id, path, new_id, new_path, val.print(true)); + if (!new_settings.set_value(k, val)) { + debug(" Failed..."); + } + } + new_settings.apply(); + } + else { + debug("%s does not exist, skipping\n", new_id); + } + + foreach (var c in schema.list_children()) { + var child = settings.get_child(c); + copy_schema(child); + } + } } diff --git a/src/core/DataCollection.vala b/src/core/DataCollection.vala index 83a216d..044f7b6 100644 --- a/src/core/DataCollection.vala +++ b/src/core/DataCollection.vala @@ -559,7 +559,7 @@ public class DataCollection { if (!properties.unset(name)) return; - // only notify if the propery was unset (that is, was set to begin with) + // only notify if the property was unset (that is, was set to begin with) notify_property_cleared(name); // notify all items diff --git a/src/core/DataSourceTypes.vala b/src/core/DataSourceTypes.vala index a79264f..1baf387 100644 --- a/src/core/DataSourceTypes.vala +++ b/src/core/DataSourceTypes.vala @@ -72,9 +72,9 @@ public abstract class EventSource : ThumbnailSource { base (object_id); } - public abstract time_t get_start_time(); + public abstract DateTime? get_start_time(); - public abstract time_t get_end_time(); + public abstract DateTime? get_end_time(); public abstract uint64 get_total_filesize(); diff --git a/src/core/SourceInterfaces.vala b/src/core/SourceInterfaces.vala index 91a8aca..6e0c149 100644 --- a/src/core/SourceInterfaces.vala +++ b/src/core/SourceInterfaces.vala @@ -42,3 +42,18 @@ public interface Indexable : DataSource { } } +// Positionable DataSources provide a globally locatable point in longitude and latitude degrees + +public struct GpsCoords { + public int has_gps; + public double latitude; + public double longitude; + public bool equals(ref GpsCoords gps) { + return (has_gps == 0 && gps.has_gps == 0) || (latitude == gps.latitude && longitude == gps.longitude); + } +} + +public interface Positionable : DataSource { + public abstract GpsCoords get_gps_coords(); + public abstract void set_gps_coords(GpsCoords gps_coords); +} diff --git a/src/core/util.vala b/src/core/util.vala index 9507895..461d2c0 100644 --- a/src/core/util.vala +++ b/src/core/util.vala @@ -190,7 +190,12 @@ public bool null_progress_monitor(uint64 count, uint64 total) { return true; } +public static int64 nullsafe_date_time_comperator(DateTime? time_a, DateTime? time_b) { + if (time_a == null && time_b == null) return 0; -double degrees_to_radians(double theta) { - return (theta * (GLib.Math.PI / 180.0)); -} + if (time_a == null && time_b != null) return -1; + if (time_a != null && time_b == null) return 1; + + return time_a.compare(time_b); + +}
\ No newline at end of file diff --git a/src/data_imports/DataImportJob.vala b/src/data_imports/DataImportJob.vala index 4035ae6..eeaec40 100644 --- a/src/data_imports/DataImportJob.vala +++ b/src/data_imports/DataImportJob.vala @@ -13,7 +13,7 @@ public class DataImportJob : BatchImportJob { private DataImportSource import_source; private File? src_file; private uint64 filesize; - private time_t exposure_time; + private DateTime? exposure_time; private DataImportJob? associated = null; private HierarchicalTagIndex? detected_htags = null; @@ -48,7 +48,7 @@ public class DataImportJob : BatchImportJob { return (detected_htags.size > 0) ? HierarchicalTagIndex.from_paths(detected_htags) : null; } - public time_t get_exposure_time() { + public DateTime get_exposure_time() { return exposure_time; } @@ -158,7 +158,7 @@ public class DataImportJob : BatchImportJob { if (title != null) photo.set_title(title); // exposure time - time_t? date_time = src_photo.get_exposure_time(); + var date_time = src_photo.get_exposure_time(); if (date_time != null) photo.set_exposure_time(date_time); // import ID diff --git a/src/data_imports/DataImportSource.vala b/src/data_imports/DataImportSource.vala index ba00be3..012abdc 100644 --- a/src/data_imports/DataImportSource.vala +++ b/src/data_imports/DataImportSource.vala @@ -20,7 +20,7 @@ public class DataImportSource { private string? title = null; private string? preview_md5 = null; private uint64 file_size; - private time_t modification_time; + private DateTime modification_time; private MetadataDateTime? exposure_time; public DataImportSource(ImportableMediaItem db_photo) { @@ -52,7 +52,7 @@ public class DataImportSource { if (title == null) { title = (metadata != null) ? metadata.get_title() : null; } - time_t? date_time = db_photo.get_exposure_time(); + var date_time = db_photo.get_exposure_time(); if (date_time != null) { exposure_time = new MetadataDateTime(date_time); } else { @@ -110,7 +110,7 @@ public class DataImportSource { return get_name(); } - public time_t get_exposure_time() { + public DateTime get_exposure_time() { return (exposure_time != null) ? exposure_time.get_timestamp() : modification_time; } diff --git a/src/data_imports/DataImports.vala b/src/data_imports/DataImports.vala index a98c91b..258a653 100644 --- a/src/data_imports/DataImports.vala +++ b/src/data_imports/DataImports.vala @@ -17,7 +17,7 @@ namespace DataImports { public void init() throws Error { string[] core_ids = new string[0]; - core_ids += "org.yorba.shotwell.dataimports.fspot"; + core_ids += "org.gnome.shotwell.dataimports.fspot"; Plugins.register_extension_point(typeof(Spit.DataImports.Service), _("Data Imports"), Resources.IMPORT, core_ids); diff --git a/src/data_imports/DataImportsPluginHost.vala b/src/data_imports/DataImportsPluginHost.vala index 158b8f4..46cfa46 100644 --- a/src/data_imports/DataImportsPluginHost.vala +++ b/src/data_imports/DataImportsPluginHost.vala @@ -474,8 +474,9 @@ private void data_import_reporter(ImportManifest manifest, BatchImportRoll impor } private int64 import_job_comparator(void *a, void *b) { - return ((DataImportJob *) a)->get_exposure_time() - - ((DataImportJob *) b)->get_exposure_time(); + + return nullsafe_date_time_comperator(((DataImportJob *) a)->get_exposure_time(), + ((DataImportJob *) b)->get_exposure_time()); } } diff --git a/src/data_imports/DataImportsUI.vala b/src/data_imports/DataImportsUI.vala index 29791a4..6fb7158 100644 --- a/src/data_imports/DataImportsUI.vala +++ b/src/data_imports/DataImportsUI.vala @@ -34,7 +34,7 @@ public class ConcreteDialogPane : Spit.DataImports.DialogPane, GLib.Object { public class StaticMessagePane : ConcreteDialogPane { public StaticMessagePane(string message_string) { Gtk.Label message_label = new Gtk.Label(message_string); - (get_widget() as Gtk.Box).pack_start(message_label, true, true, 0); + ((Gtk.Box) get_widget()).pack_start(message_label, true, true, 0); } public StaticMessagePane.with_pango(string msg) { @@ -42,7 +42,7 @@ public class StaticMessagePane : ConcreteDialogPane { label.set_markup(msg); label.set_line_wrap(true); - (get_widget() as Gtk.Box).pack_start(label, true, true, 0); + ((Gtk.Box) get_widget()).pack_start(label, true, true, 0); } } @@ -123,7 +123,7 @@ public class LibrarySelectionPane : ConcreteDialogPane { button_box.add(import_button); content_box.pack_end(button_box, true, false, 6); - (get_widget() as Gtk.Box).pack_start(content_box, true, true, 0); + ((Gtk.Box) get_widget()).pack_start(content_box, true, true, 0); set_import_button_sensitivity(); } @@ -177,7 +177,7 @@ public class ProgressPane : ConcreteDialogPane { progress_label = new Gtk.Label(""); content_box.pack_start(progress_label, false, true, 6); - (get_widget() as Gtk.Container).add(content_box); + ((Gtk.Container) get_widget()).add(content_box); } public void update_progress(double progress, string? progress_message) { @@ -285,7 +285,7 @@ public class DataImportsDialog : Gtk.Dialog { } } - // Intall the central area in all cases + // Install the central area in all cases central_area_layouter = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); ((Gtk.Box) get_content_area()).pack_start(central_area_layouter, true, true, 0); diff --git a/src/db/DatabaseTable.vala b/src/db/DatabaseTable.vala index 5ec5be1..dea797a 100644 --- a/src/db/DatabaseTable.vala +++ b/src/db/DatabaseTable.vala @@ -21,12 +21,12 @@ public abstract class DatabaseTable { * tables are created on demand and tables and columns are easily ignored when already present. * However, the change should be noted in upgrade_database() as a comment. ***/ - public const int SCHEMA_VERSION = 20; - + public const int SCHEMA_VERSION = 24; + protected static Sqlite.Database db; - + private static int in_transaction = 0; - + public string table_name = null; private static void prepare_db(string filename) { @@ -287,7 +287,19 @@ public abstract class DatabaseTable { if (res != Sqlite.DONE) throw_error("DatabaseTable.update_int64_by_id_2 %s.%s".printf(table_name, column), res); } - + + protected void update_double_by_id_2(int64 id, string column, double value) throws DatabaseError { + Sqlite.Statement stmt; + prepare_update_by_id(id, column, out stmt); + + int res = stmt.bind_double(1, value); + assert(res == Sqlite.OK); + + res = stmt.step(); + if (res != Sqlite.DONE) + throw_error("DatabaseTable.update_double_by_id_2 %s.%s".printf(table_name, column), res); + } + protected void delete_by_id(int64 id) throws DatabaseError { Sqlite.Statement stmt; int res = db.prepare_v2("DELETE FROM %s WHERE id=?".printf(table_name), -1, out stmt); diff --git a/src/db/Db.vala b/src/db/Db.vala index 3eca8ce..5072967 100644 --- a/src/db/Db.vala +++ b/src/db/Db.vala @@ -293,7 +293,7 @@ private VerifyResult upgrade_database(int input_version) { } version = 16; - + // // Version 17: // * Added comment column to PhotoTable and VideoTable @@ -349,11 +349,72 @@ private VerifyResult upgrade_database(int input_version) { // version = 20; + // - // Finalize the upgrade process + // Version 21: + // * Add has_gps, gps_lat and gps_lon columns to PhotoTable + + if (!DatabaseTable.ensure_column("PhotoTable", "has_gps", "INTEGER DEFAULT -1", + "upgrade_database: adding gps_lat column to PhotoTable") + || !DatabaseTable.ensure_column("PhotoTable", "gps_lat", "REAL", + "upgrade_database: adding gps_lat column to PhotoTable") + || !DatabaseTable.ensure_column("PhotoTable", "gps_lon", "REAL", + "upgrade_database: adding gps_lon column to PhotoTable")) { + return VerifyResult.UPGRADE_ERROR; + } + + version = 21; + + // + // Version 22: + // * Create face detection tables even if feasture is not enabled + // * Added face pixels column to FaceLocationTable + // * Added face vector column to FaceTable // + FaceTable.get_instance(); + FaceLocationTable.get_instance(); + if (!DatabaseTable.has_column("FaceLocationTable", "vec")) { + message("upgrade_database: adding vec column to FaceLocationTable"); + if (!DatabaseTable.add_column("FaceLocationTable", "vec", "TEXT")) + return VerifyResult.UPGRADE_ERROR; + } + if (!DatabaseTable.has_column("FaceLocationTable", "guess")) { + message("upgrade_database: adding guess column to FaceLocationTable"); + if (!DatabaseTable.add_column("FaceLocationTable", "guess", "INTEGER DEFAULT 0")) + return VerifyResult.UPGRADE_ERROR; + } + if (!DatabaseTable.has_column("FaceTable", "ref")) { + message("upgrade_database: adding ref column to FaceTable"); + if (!DatabaseTable.add_column("FaceTable", "ref", "INTEGER DEFAULT -1")) + return VerifyResult.UPGRADE_ERROR; + } + version = 22; + + // + // Finalize the upgrade process + // + + if (input_version < 23) { + // Run the settings migrator to copy settings data from /org/yorba/shotwell to /org/gnome/shotwell + GSettingsConfigurationEngine.run_gsettings_migrator_v2(); + } + + version = 23; + + if (input_version < 24) { + // Convert timestamp 0 to NULL to represent unset date and free 0 to be 1.1.1970 00:00 + message("upgrade_database: Shifting times from 0 to null for unset times"); + try { + PhotoTable.upgrade_for_unset_timestamp(); + VideoTable.upgrade_for_unset_timestamp(); + version = 24; + } catch (DatabaseError err) { + critical("Failed to upgrade database to version 24: %s", err.message); + } + } + assert(version == DatabaseTable.SCHEMA_VERSION); VersionTable.get_instance().update_version(version, Resources.APP_VERSION); diff --git a/src/db/EventTable.vala b/src/db/EventTable.vala index 593d51c..3b7df17 100644 --- a/src/db/EventTable.vala +++ b/src/db/EventTable.vala @@ -25,7 +25,7 @@ public struct EventID { public class EventRow { public EventID event_id; public string? name; - public time_t time_created; + public int64 time_created; public string? primary_source_id; public string? comment; } @@ -80,7 +80,7 @@ public class EventTable : DatabaseTable { -1, out stmt); assert(res == Sqlite.OK); - time_t time_created = (time_t) now_sec(); + int64 time_created = now_sec(); res = stmt.bind_text(1, primary_source_id); assert(res == Sqlite.OK); @@ -151,7 +151,7 @@ public class EventTable : DatabaseTable { if (row.name != null && row.name.length == 0) row.name = null; row.primary_source_id = source_id_upgrade(stmt.column_int64(1), stmt.column_text(2)); - row.time_created = (time_t) stmt.column_int64(3); + row.time_created = stmt.column_int64(3); row.comment = stmt.column_text(4); return row; @@ -183,7 +183,7 @@ public class EventTable : DatabaseTable { row.event_id = EventID(stmt.column_int64(0)); row.name = stmt.column_text(1); row.primary_source_id = source_id_upgrade(stmt.column_int64(2), stmt.column_text(3)); - row.time_created = (time_t) stmt.column_int64(4); + row.time_created = stmt.column_int64(4); row.comment = stmt.column_text(5); event_rows.add(row); @@ -218,12 +218,12 @@ public class EventTable : DatabaseTable { return update_text_by_id(event_id.id, "primary_source_id", primary_source_id); } - public time_t get_time_created(EventID event_id) { + public DateTime? get_time_created(EventID event_id) { Sqlite.Statement stmt; if (!select_by_id(event_id.id, "time_created", out stmt)) - return 0; + return null; - return (time_t) stmt.column_int64(0); + return new DateTime.from_unix_utc(stmt.column_int64(0)); } public bool set_comment(EventID event_id, string new_comment) { diff --git a/src/db/FaceLocationTable.vala b/src/db/FaceLocationTable.vala index 8398616..f4c88d7 100644 --- a/src/db/FaceLocationTable.vala +++ b/src/db/FaceLocationTable.vala @@ -27,6 +27,7 @@ public class FaceLocationRow { public FaceID face_id; public PhotoID photo_id; public string geometry; + public string vec; } public class FaceLocationTable : DatabaseTable { @@ -42,7 +43,9 @@ public class FaceLocationTable : DatabaseTable { + "id INTEGER NOT NULL PRIMARY KEY, " + "face_id INTEGER NOT NULL, " + "photo_id INTEGER NOT NULL, " - + "geometry TEXT" + + "geometry TEXT, " + + "vec TEXT, " + + "guess INTEGER DEFAULT 0" + ")", -1, out stmt); assert(res == Sqlite.OK); @@ -58,10 +61,10 @@ public class FaceLocationTable : DatabaseTable { return instance; } - public FaceLocationRow add(FaceID face_id, PhotoID photo_id, string geometry) throws DatabaseError { + public FaceLocationRow add(FaceID face_id, PhotoID photo_id, string geometry, string? vec = null) throws DatabaseError { Sqlite.Statement stmt; int res = db.prepare_v2( - "INSERT INTO FaceLocationTable (face_id, photo_id, geometry) VALUES (?, ?, ?)", + "INSERT INTO FaceLocationTable (face_id, photo_id, geometry, vec) VALUES (?, ?, ?, ?)", -1, out stmt); assert(res == Sqlite.OK); @@ -71,6 +74,9 @@ public class FaceLocationTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_text(3, geometry); assert(res == Sqlite.OK); + if (vec == null) vec = ""; + res = stmt.bind_text(4, vec); + assert(res == Sqlite.OK); res = stmt.step(); if (res != Sqlite.DONE) @@ -81,6 +87,7 @@ public class FaceLocationTable : DatabaseTable { row.face_id = face_id; row.photo_id = photo_id; row.geometry = geometry; + row.vec = vec; return row; } @@ -88,7 +95,7 @@ public class FaceLocationTable : DatabaseTable { public Gee.List<FaceLocationRow?> get_all_rows() throws DatabaseError { Sqlite.Statement stmt; int res = db.prepare_v2( - "SELECT id, face_id, photo_id, geometry FROM FaceLocationTable", + "SELECT id, face_id, photo_id, geometry, vec FROM FaceLocationTable", -1, out stmt); assert(res == Sqlite.OK); @@ -107,6 +114,7 @@ public class FaceLocationTable : DatabaseTable { row.face_id = FaceID(stmt.column_int64(1)); row.photo_id = PhotoID(stmt.column_int64(2)); row.geometry = stmt.column_text(3); + row.vec = stmt.column_text(4); rows.add(row); } @@ -195,4 +203,63 @@ public class FaceLocationTable : DatabaseTable { if (res != Sqlite.DONE) throw_error("FaceLocationTable.update_face_location_serialized_geometry", res); } + + public void update_face_location_face_data(FaceLocation face_location) + throws DatabaseError { + Sqlite.Statement stmt; + int res = db.prepare_v2("UPDATE FaceLocationTable SET geometry=?, vec=? WHERE id=?", -1, out stmt); + assert(res == Sqlite.OK); + + FaceLocationData face_data = face_location.get_face_data(); + res = stmt.bind_text(1, face_data.geometry); + assert(res == Sqlite.OK); + res = stmt.bind_text(2, face_data.vec); + assert(res == Sqlite.OK); + res = stmt.bind_int64(3, face_location.get_face_location_id().id); + assert(res == Sqlite.OK); + + res = stmt.step(); + if (res != Sqlite.DONE) + throw_error("FaceLocationTable.update_face_location_serialized_geometry", res); + } + public Gee.List<FaceLocationRow?> get_face_ref_vecs(Gee.List<FaceRow?> face_rows) + throws DatabaseError { + Sqlite.Statement stmt; + + string[] where_in = {}; + foreach (var r in face_rows) { + if (r != null) where_in += "?"; + } + int res = db.prepare_v2( + "SELECT id, face_id, photo_id, geometry, vec FROM FaceLocationTable WHERE photo_id IN (%s)" + .printf(string.joinv(",", where_in)), + -1, out stmt); + assert(res == Sqlite.OK); + int c = 1; + foreach (var r in face_rows) { + if (r != null) { + res = stmt.bind_int64(c, r.ref.id); + assert(res == Sqlite.OK); + } + c++; + } + + Gee.List<FaceLocationRow?> rows = new Gee.ArrayList<FaceLocationRow?>(); + for (;;) { + res = stmt.step(); + if (res == Sqlite.DONE) + break; + else if (res != Sqlite.ROW) + throw_error("FaceLocationTable.get_face_ref_vecs", res); + + FaceLocationRow row = new FaceLocationRow(); + row.face_location_id = FaceLocationID(stmt.column_int64(0)); + row.face_id = FaceID(stmt.column_int64(1)); + row.photo_id = PhotoID(stmt.column_int64(2)); + row.geometry = stmt.column_text(3); + row.vec = stmt.column_text(4); + rows.add(row); + } + return rows; + } } diff --git a/src/db/FaceTable.vala b/src/db/FaceTable.vala index 4836910..e799f97 100644 --- a/src/db/FaceTable.vala +++ b/src/db/FaceTable.vala @@ -25,7 +25,9 @@ public struct FaceID { public class FaceRow { public FaceID face_id; public string name; - public time_t time_created; + public int64 time_created; + public PhotoID ref; + public string vec; } public class FaceTable : DatabaseTable { @@ -40,7 +42,8 @@ public class FaceTable : DatabaseTable { + "(" + "id INTEGER NOT NULL PRIMARY KEY, " + "name TEXT NOT NULL, " - + "time_created TIMESTAMP" + + "time_created TIMESTAMP, " + + "ref INTEGER DEFAULT -1" + ")", -1, out stmt); assert(res == Sqlite.OK); @@ -62,7 +65,7 @@ public class FaceTable : DatabaseTable { out stmt); assert(res == Sqlite.OK); - time_t time_created = (time_t) now_sec(); + var time_created = now_sec(); res = stmt.bind_text(1, name); assert(res == Sqlite.OK); @@ -129,7 +132,7 @@ public class FaceTable : DatabaseTable { FaceRow row = new FaceRow(); row.face_id = face_id; row.name = stmt.column_text(0); - row.time_created = (time_t) stmt.column_int64(1); + row.time_created = stmt.column_int64(1); return row; } @@ -153,7 +156,7 @@ public class FaceTable : DatabaseTable { FaceRow row = new FaceRow(); row.face_id = FaceID(stmt.column_int64(0)); row.name = stmt.column_text(1); - row.time_created = (time_t) stmt.column_int64(2); + row.time_created = stmt.column_int64(2); rows.add(row); } @@ -164,4 +167,47 @@ public class FaceTable : DatabaseTable { public void rename(FaceID face_id, string new_name) throws DatabaseError { update_text_by_id_2(face_id.id, "name", new_name); } + + public void set_reference(FaceID face_id, PhotoID photo_id) + throws DatabaseError { + Sqlite.Statement stmt; + int res = db.prepare_v2("UPDATE FaceTable SET ref=? WHERE id=?", -1, out stmt); + assert(res == Sqlite.OK); + res = stmt.bind_int64(1, photo_id.id); + assert(res == Sqlite.OK); + res = stmt.bind_int64(2, face_id.id); + assert(res == Sqlite.OK); + + res = stmt.step(); + if (res != Sqlite.DONE) + throw_error("FaceTable.set_reference", res); + } + + public Gee.List<FaceRow?> get_ref_rows() throws DatabaseError { + Sqlite.Statement stmt; + int res = db.prepare_v2("SELECT id, name, time_created, ref FROM FaceTable WHERE ref != -1", -1, + out stmt); + assert(res == Sqlite.OK); + + Gee.List<FaceRow?> rows = new Gee.ArrayList<FaceRow?>(); + + for (;;) { + res = stmt.step(); + if (res == Sqlite.DONE) + break; + else if (res != Sqlite.ROW) + throw_error("FaceTable.get_all_rows", res); + + // res == Sqlite.ROW + FaceRow row = new FaceRow(); + row.face_id = FaceID(stmt.column_int64(0)); + row.name = stmt.column_text(1); + row.time_created = stmt.column_int64(2); + row.ref = PhotoID(stmt.column_int64(3)); + + rows.add(row); + } + + return rows; + } } diff --git a/src/db/PhotoTable.vala b/src/db/PhotoTable.vala index 24cec86..4e3f672 100644 --- a/src/db/PhotoTable.vala +++ b/src/db/PhotoTable.vala @@ -44,9 +44,7 @@ public struct ImportID { } public static ImportID generate() { - TimeVal timestamp = TimeVal(); - timestamp.get_current_time(); - int64 id = timestamp.tv_sec; + int64 id = GLib.get_real_time () / Util.USEC_PER_SEC; return ImportID(id); } @@ -72,7 +70,7 @@ public struct ImportID { public class PhotoRow { public PhotoID photo_id; public BackingPhotoRow master; - public time_t exposure_time; + public DateTime? exposure_time; public ImportID import_id; public EventID event_id; public Orientation orientation; @@ -80,13 +78,14 @@ public class PhotoRow { public string md5; public string thumbnail_md5; public string exif_md5; - public time_t time_created; + public int64 time_created; public uint64 flags; public Rating rating; public string title; + public GpsCoords gps_coords; public string comment; public string? backlinks; - public time_t time_reimported; + public int64 time_reimported; public BackingPhotoID editable_id; public bool metadata_dirty; @@ -103,6 +102,10 @@ public class PhotoRow { development_ids = new BackingPhotoID[RawDeveloper.as_array().length]; foreach (RawDeveloper d in RawDeveloper.as_array()) development_ids[d] = BackingPhotoID(); + gps_coords = GpsCoords(); + development_ids = new BackingPhotoID[RawDeveloper.as_array().length]; + foreach (RawDeveloper d in RawDeveloper.as_array()) + development_ids[d] = BackingPhotoID(); } } @@ -140,6 +143,9 @@ public class PhotoTable : DatabaseTable { + "develop_shotwell_id INTEGER DEFAULT -1, " + "develop_camera_id INTEGER DEFAULT -1, " + "develop_embedded_id INTEGER DEFAULT -1, " + + "has_gps INTEGER DEFAULT -1, " + + "gps_lat REAL, " + + "gps_lon REAL, " + "comment TEXT" + ")", -1, out stmt); assert(res == Sqlite.OK); @@ -209,12 +215,12 @@ public class PhotoTable : DatabaseTable { int res = db.prepare_v2( "INSERT INTO PhotoTable (filename, width, height, filesize, timestamp, exposure_time, " + "orientation, original_orientation, import_id, event_id, md5, thumbnail_md5, " - + "exif_md5, time_created, file_format, title, rating, editable_id, developer, comment) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "exif_md5, time_created, file_format, title, rating, editable_id, developer, has_gps, gps_lat, gps_lon, comment) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", -1, out stmt); assert(res == Sqlite.OK); - ulong time_created = now_sec(); + var time_created = now_sec(); res = stmt.bind_text(1, photo_row.master.filepath); assert(res == Sqlite.OK); @@ -224,9 +230,17 @@ public class PhotoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_int64(4, photo_row.master.filesize); assert(res == Sqlite.OK); - res = stmt.bind_int64(5, photo_row.master.timestamp); + if (photo_row.master.timestamp == null) { + res = stmt.bind_null(5); + } else { + res = stmt.bind_int64(5, photo_row.master.timestamp.to_unix()); + } assert(res == Sqlite.OK); - res = stmt.bind_int64(6, photo_row.exposure_time); + if (photo_row.exposure_time == null) { + res = stmt.bind_null(6); + } else { + res = stmt.bind_int64(6, photo_row.exposure_time.to_unix()); + } assert(res == Sqlite.OK); res = stmt.bind_int(7, photo_row.master.original_orientation); assert(res == Sqlite.OK); @@ -254,7 +268,13 @@ public class PhotoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_text(19, photo_row.developer.to_string()); assert(res == Sqlite.OK); - res = stmt.bind_text(20, photo_row.comment); + res = stmt.bind_int(20, photo_row.gps_coords.has_gps); + assert(res == Sqlite.OK); + res = stmt.bind_double(21, photo_row.gps_coords.latitude); + assert(res == Sqlite.OK); + res = stmt.bind_double(22, photo_row.gps_coords.longitude); + assert(res == Sqlite.OK); + res = stmt.bind_text(23, photo_row.comment); assert(res == Sqlite.OK); res = stmt.step(); @@ -269,7 +289,7 @@ public class PhotoTable : DatabaseTable { photo_row.photo_id = PhotoID(db.last_insert_rowid()); photo_row.orientation = photo_row.master.original_orientation; photo_row.event_id = EventID(); - photo_row.time_created = (time_t) time_created; + photo_row.time_created = time_created; photo_row.flags = 0; return photo_row.photo_id; @@ -285,11 +305,12 @@ public class PhotoTable : DatabaseTable { int res = db.prepare_v2( "UPDATE PhotoTable SET width = ?, height = ?, filesize = ?, timestamp = ?, " + "exposure_time = ?, orientation = ?, original_orientation = ?, md5 = ?, " - + "exif_md5 = ?, thumbnail_md5 = ?, file_format = ?, title = ?, time_reimported = ? " + + "exif_md5 = ?, thumbnail_md5 = ?, file_format = ?, title = ?, " + + "has_gps = ?, gps_lat = ?, gps_lon = ?, time_reimported = ? " + "WHERE id = ?", -1, out stmt); assert(res == Sqlite.OK); - time_t time_reimported = (time_t) now_sec(); + var time_reimported = now_sec(); res = stmt.bind_int(1, row.master.dim.width); assert(res == Sqlite.OK); @@ -297,9 +318,13 @@ public class PhotoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_int64(3, row.master.filesize); assert(res == Sqlite.OK); - res = stmt.bind_int64(4, row.master.timestamp); + res = stmt.bind_int64(4, row.master.timestamp.to_unix()); assert(res == Sqlite.OK); - res = stmt.bind_int64(5, row.exposure_time); + if (row.exposure_time == null) { + res = stmt.bind_null(5); + } else { + res = stmt.bind_int64(5, row.exposure_time.to_unix()); + } assert(res == Sqlite.OK); res = stmt.bind_int(6, row.master.original_orientation); assert(res == Sqlite.OK); @@ -315,9 +340,15 @@ public class PhotoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_text(12, row.title); assert(res == Sqlite.OK); - res = stmt.bind_int64(13, time_reimported); + res = stmt.bind_int(13, row.gps_coords.has_gps); + assert(res == Sqlite.OK); + res = stmt.bind_double(14, row.gps_coords.latitude); + assert(res == Sqlite.OK); + res = stmt.bind_double(15, row.gps_coords.longitude); + assert(res == Sqlite.OK); + res = stmt.bind_int64(16, time_reimported); assert(res == Sqlite.OK); - res = stmt.bind_int64(14, row.photo_id.id); + res = stmt.bind_int64(17, row.photo_id.id); assert(res == Sqlite.OK); res = stmt.step(); @@ -328,7 +359,7 @@ public class PhotoTable : DatabaseTable { row.orientation = row.master.original_orientation; } - public bool master_exif_updated(PhotoID photoID, int64 filesize, long timestamp, + public bool master_exif_updated(PhotoID photoID, int64 filesize, DateTime timestamp, string md5, string? exif_md5, string? thumbnail_md5, PhotoRow row) { Sqlite.Statement stmt; int res = db.prepare_v2( @@ -338,7 +369,7 @@ public class PhotoTable : DatabaseTable { res = stmt.bind_int64(1, filesize); assert(res == Sqlite.OK); - res = stmt.bind_int64(2, timestamp); + res = stmt.bind_int64(2, timestamp.to_unix()); assert(res == Sqlite.OK); res = stmt.bind_text(3, md5); assert(res == Sqlite.OK); @@ -372,7 +403,7 @@ public class PhotoTable : DatabaseTable { // the DB as a zero due to Vala 0.14 breaking the way it handled // objects passed as 'ref' arguments to methods. // - // For further details, please see http://redmine.yorba.org/issues/4354 and + // For further details, please see https://bugzilla.gnome.org/show_bug.cgi?id=718194 and // https://bugzilla.gnome.org/show_bug.cgi?id=663818 . private void validate_orientation(PhotoRow row) { if ((row.orientation < Orientation.MIN) || @@ -390,7 +421,7 @@ public class PhotoTable : DatabaseTable { + "original_orientation, import_id, event_id, transformations, md5, thumbnail_md5, " + "exif_md5, time_created, flags, rating, file_format, title, backlinks, " + "time_reimported, editable_id, metadata_dirty, developer, develop_shotwell_id, " - + "develop_camera_id, develop_embedded_id, comment " + + "develop_camera_id, develop_embedded_id, has_gps, gps_lat, gps_lon, comment " + "FROM PhotoTable WHERE id=?", -1, out stmt); assert(res == Sqlite.OK); @@ -406,8 +437,12 @@ public class PhotoTable : DatabaseTable { row.master.filepath = stmt.column_text(0); row.master.dim = Dimensions(stmt.column_int(1), stmt.column_int(2)); row.master.filesize = stmt.column_int64(3); - row.master.timestamp = (time_t) stmt.column_int64(4); - row.exposure_time = (time_t) stmt.column_int64(5); + row.master.timestamp = new DateTime.from_unix_utc(stmt.column_int64(4)); + if (stmt.column_type(5) == Sqlite.NULL) { + row.exposure_time = null; + } else { + row.exposure_time = new DateTime.from_unix_utc(stmt.column_int64(5)); + } row.orientation = (Orientation) stmt.column_int(6); row.master.original_orientation = (Orientation) stmt.column_int(7); row.import_id.id = stmt.column_int64(8); @@ -416,13 +451,13 @@ public class PhotoTable : DatabaseTable { row.md5 = stmt.column_text(11); row.thumbnail_md5 = stmt.column_text(12); row.exif_md5 = stmt.column_text(13); - row.time_created = (time_t) stmt.column_int64(14); + row.time_created = stmt.column_int64(14); row.flags = stmt.column_int64(15); row.rating = Rating.unserialize(stmt.column_int(16)); row.master.file_format = PhotoFileFormat.unserialize(stmt.column_int(17)); row.title = stmt.column_text(18); row.backlinks = stmt.column_text(19); - row.time_reimported = (time_t) stmt.column_int64(20); + row.time_reimported = stmt.column_int64(20); row.editable_id = BackingPhotoID(stmt.column_int64(21)); row.metadata_dirty = stmt.column_int(22) != 0; row.developer = stmt.column_text(23) != null ? RawDeveloper.from_string(stmt.column_text(23)) : @@ -430,7 +465,10 @@ public class PhotoTable : DatabaseTable { row.development_ids[RawDeveloper.SHOTWELL] = BackingPhotoID(stmt.column_int64(24)); row.development_ids[RawDeveloper.CAMERA] = BackingPhotoID(stmt.column_int64(25)); row.development_ids[RawDeveloper.EMBEDDED] = BackingPhotoID(stmt.column_int64(26)); - row.comment = stmt.column_text(27); + row.gps_coords.has_gps = stmt.column_int(27); + row.gps_coords.latitude = stmt.column_double(28); + row.gps_coords.longitude = stmt.column_double(29); + row.comment = stmt.column_text(30); return row; } @@ -442,7 +480,7 @@ public class PhotoTable : DatabaseTable { + "original_orientation, import_id, event_id, transformations, md5, thumbnail_md5, " + "exif_md5, time_created, flags, rating, file_format, title, backlinks, time_reimported, " + "editable_id, metadata_dirty, developer, develop_shotwell_id, develop_camera_id, " - + "develop_embedded_id, comment FROM PhotoTable", + + "develop_embedded_id, has_gps, gps_lat, gps_lon, comment FROM PhotoTable", -1, out stmt); assert(res == Sqlite.OK); @@ -454,8 +492,12 @@ public class PhotoTable : DatabaseTable { row.master.filepath = stmt.column_text(1); row.master.dim = Dimensions(stmt.column_int(2), stmt.column_int(3)); row.master.filesize = stmt.column_int64(4); - row.master.timestamp = (time_t) stmt.column_int64(5); - row.exposure_time = (time_t) stmt.column_int64(6); + row.master.timestamp = new DateTime.from_unix_utc(stmt.column_int64(5)); + if (stmt.column_type(6) == Sqlite.NULL) { + row.exposure_time = null; + } else { + row.exposure_time = new DateTime.from_unix_utc(stmt.column_int64(6)); + } row.orientation = (Orientation) stmt.column_int(7); row.master.original_orientation = (Orientation) stmt.column_int(8); row.import_id.id = stmt.column_int64(9); @@ -464,13 +506,13 @@ public class PhotoTable : DatabaseTable { row.md5 = stmt.column_text(12); row.thumbnail_md5 = stmt.column_text(13); row.exif_md5 = stmt.column_text(14); - row.time_created = (time_t) stmt.column_int64(15); + row.time_created = stmt.column_int64(15); row.flags = stmt.column_int64(16); row.rating = Rating.unserialize(stmt.column_int(17)); row.master.file_format = PhotoFileFormat.unserialize(stmt.column_int(18)); row.title = stmt.column_text(19); row.backlinks = stmt.column_text(20); - row.time_reimported = (time_t) stmt.column_int64(21); + row.time_reimported = stmt.column_int64(21); row.editable_id = BackingPhotoID(stmt.column_int64(22)); row.metadata_dirty = stmt.column_int(23) != 0; row.developer = stmt.column_text(24) != null ? RawDeveloper.from_string(stmt.column_text(24)) : @@ -478,7 +520,10 @@ public class PhotoTable : DatabaseTable { row.development_ids[RawDeveloper.SHOTWELL] = BackingPhotoID(stmt.column_int64(25)); row.development_ids[RawDeveloper.CAMERA] = BackingPhotoID(stmt.column_int64(26)); row.development_ids[RawDeveloper.EMBEDDED] = BackingPhotoID(stmt.column_int64(27)); - row.comment = stmt.column_text(28); + row.gps_coords.has_gps = stmt.column_int(28); + row.gps_coords.latitude = stmt.column_double(29); + row.gps_coords.longitude = stmt.column_double(30); + row.comment = stmt.column_text(31); validate_orientation(row); @@ -500,9 +545,9 @@ public class PhotoTable : DatabaseTable { int res = db.prepare_v2("INSERT INTO PhotoTable (filename, width, height, filesize, " + "timestamp, exposure_time, orientation, original_orientation, import_id, event_id, " + "transformations, md5, thumbnail_md5, exif_md5, time_created, flags, rating, " - + "file_format, title, editable_id, developer, develop_shotwell_id, develop_camera_id, " - + "develop_embedded_id, comment) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + + "file_format, title, has_gps, gps_lat, gps_lon, editable_id, developer, " + + "develop_shotwell_id, develop_camera_id, develop_embedded_id, comment) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", -1, out stmt); assert(res == Sqlite.OK); @@ -514,9 +559,13 @@ public class PhotoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_int64(4, original.master.filesize); assert(res == Sqlite.OK); - res = stmt.bind_int64(5, original.master.timestamp); + res = stmt.bind_int64(5, original.master.timestamp.to_unix()); assert(res == Sqlite.OK); - res = stmt.bind_int64(6, original.exposure_time); + if (original.exposure_time == null) { + res = stmt.bind_null(6); + } else { + res = stmt.bind_int64(6, original.exposure_time.to_unix()); + } assert(res == Sqlite.OK); res = stmt.bind_int(7, original.orientation); assert(res == Sqlite.OK); @@ -544,18 +593,23 @@ public class PhotoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_text(19, original.title); assert(res == Sqlite.OK); - res = stmt.bind_int64(20, editable_id.id); + res = stmt.bind_int(20, original.gps_coords.has_gps); assert(res == Sqlite.OK); - - res = stmt.bind_text(21, original.developer.to_string()); + res = stmt.bind_double(21, original.gps_coords.latitude); assert(res == Sqlite.OK); - res = stmt.bind_int64(22, develop_shotwell.id); + res = stmt.bind_double(22, original.gps_coords.longitude); assert(res == Sqlite.OK); - res = stmt.bind_int64(23, develop_camera_id.id); + res = stmt.bind_int64(23, editable_id.id); assert(res == Sqlite.OK); - res = stmt.bind_int64(24, develop_embedded_id.id); + res = stmt.bind_text(24, original.developer.to_string()); assert(res == Sqlite.OK); - res = stmt.bind_text(25, original.comment); + res = stmt.bind_int64(25, develop_shotwell.id); + assert(res == Sqlite.OK); + res = stmt.bind_int64(26, develop_camera_id.id); + assert(res == Sqlite.OK); + res = stmt.bind_int64(27, develop_embedded_id.id); + assert(res == Sqlite.OK); + res = stmt.bind_text(28, original.comment); assert(res == Sqlite.OK); res = stmt.step(); @@ -572,7 +626,15 @@ public class PhotoTable : DatabaseTable { public bool set_title(PhotoID photo_id, string? new_title) { return update_text_by_id(photo_id.id, "title", new_title != null ? new_title : ""); } - + + public void set_gps_coords(PhotoID photo_id, GpsCoords new_gps_coords) throws DatabaseError { + update_int_by_id_2(photo_id.id, "has_gps", new_gps_coords.has_gps); + if (new_gps_coords.has_gps > 0) { + update_double_by_id_2(photo_id.id, "gps_lat", new_gps_coords.latitude); + update_double_by_id_2(photo_id.id, "gps_lon", new_gps_coords.longitude); + } + } + public bool set_comment(PhotoID photo_id, string? new_comment) { return update_text_by_id(photo_id.id, "comment", new_comment != null ? new_comment : ""); } @@ -581,12 +643,12 @@ public class PhotoTable : DatabaseTable { update_text_by_id_2(photo_id.id, "filename", filepath); } - public void update_timestamp(PhotoID photo_id, time_t timestamp) throws DatabaseError { - update_int64_by_id_2(photo_id.id, "timestamp", timestamp); + public void update_timestamp(PhotoID photo_id, DateTime timestamp) throws DatabaseError { + update_int64_by_id_2(photo_id.id, "timestamp", timestamp.to_unix()); } - public bool set_exposure_time(PhotoID photo_id, time_t time) { - return update_int64_by_id(photo_id.id, "exposure_time", (int64) time); + public bool set_exposure_time(PhotoID photo_id, DateTime time) { + return update_int64_by_id(photo_id.id, "exposure_time", time.to_unix()); } public void set_import_id(PhotoID photo_id, ImportID import_id) throws DatabaseError { @@ -1051,6 +1113,16 @@ public class PhotoTable : DatabaseTable { public void remove_development(PhotoRow row, RawDeveloper rd) throws DatabaseError { update_raw_development(row, rd, BackingPhotoID()); } + + public static void upgrade_for_unset_timestamp() throws DatabaseError { + Sqlite.Statement stmt; + int res = db.prepare_v2("UPDATE PhotoTable SET exposure_time = NULL WHERE exposure_time = '0'", -1, out stmt); + assert(res == Sqlite.OK); + res = stmt.step(); + if (res != Sqlite.DONE) { + throw_error("PhotoTable.upgrade_for_unset_timestamp", res); + } + } } @@ -1084,10 +1156,10 @@ public struct BackingPhotoID { public class BackingPhotoRow { public BackingPhotoID id; - public time_t time_created; + public int64 time_created; public string? filepath = null; public int64 filesize; - public time_t timestamp; + public DateTime? timestamp; public PhotoFileFormat file_format; public Dimensions dim; public Orientation original_orientation; @@ -1095,15 +1167,21 @@ public class BackingPhotoRow { public bool matches_file_info(FileInfo info) { if (filesize != info.get_size()) return false; + + if (timestamp == null) + return false; - return timestamp == info.get_modification_time().tv_sec; + return timestamp.equal(info.get_modification_date_time()); } public bool is_touched(FileInfo info) { if (filesize != info.get_size()) return false; + + if (timestamp == null) + return true; - return timestamp != info.get_modification_time().tv_sec; + return !timestamp.equal(info.get_modification_date_time()); } // Copies another backing photo row into this one. @@ -1162,11 +1240,11 @@ public class BackingPhotoTable : DatabaseTable { -1, out stmt); assert(res == Sqlite.OK); - time_t time_created = (time_t) now_sec(); + var time_created = now_sec(); res = stmt.bind_text(1, state.filepath); assert(res == Sqlite.OK); - res = stmt.bind_int64(2, state.timestamp); + res = stmt.bind_int64(2, state.timestamp.to_unix()); assert(res == Sqlite.OK); res = stmt.bind_int64(3, state.filesize); assert(res == Sqlite.OK); @@ -1208,12 +1286,12 @@ public class BackingPhotoTable : DatabaseTable { BackingPhotoRow row = new BackingPhotoRow(); row.id = id; row.filepath = stmt.column_text(0); - row.timestamp = (time_t) stmt.column_int64(1); + row.timestamp = new DateTime.from_unix_utc(stmt.column_int64(1)); row.filesize = stmt.column_int64(2); row.dim = Dimensions(stmt.column_int(3), stmt.column_int(4)); row.original_orientation = (Orientation) stmt.column_int(5); row.file_format = PhotoFileFormat.unserialize(stmt.column_int(6)); - row.time_created = (time_t) stmt.column_int64(7); + row.time_created = stmt.column_int64(7); return row; } @@ -1227,7 +1305,7 @@ public class BackingPhotoTable : DatabaseTable { -1, out stmt); assert(res == Sqlite.OK); - res = stmt.bind_int64(1, row.timestamp); + res = stmt.bind_int64(1, row.timestamp.to_unix()); assert(res == Sqlite.OK); res = stmt.bind_int64(2, row.filesize); assert(res == Sqlite.OK); @@ -1247,13 +1325,13 @@ public class BackingPhotoTable : DatabaseTable { throw_error("BackingPhotoTable.update", res); } - public void update_attributes(BackingPhotoID id, time_t timestamp, int64 filesize) throws DatabaseError { + public void update_attributes(BackingPhotoID id, DateTime timestamp, int64 filesize) throws DatabaseError { Sqlite.Statement stmt; int res = db.prepare_v2("UPDATE BackingPhotoTable SET timestamp=?, filesize=? WHERE id=?", -1, out stmt); assert(res == Sqlite.OK); - res = stmt.bind_int64(1, timestamp); + res = stmt.bind_int64(1, timestamp.to_unix()); assert(res == Sqlite.OK); res = stmt.bind_int64(2, filesize); assert(res == Sqlite.OK); @@ -1273,8 +1351,8 @@ public class BackingPhotoTable : DatabaseTable { update_text_by_id_2(id.id, "filepath", filepath); } - public void update_timestamp(BackingPhotoID id, time_t timestamp) throws DatabaseError { - update_int64_by_id_2(id.id, "timestamp", timestamp); + public void update_timestamp(BackingPhotoID id, DateTime timestamp) throws DatabaseError { + update_int64_by_id_2(id.id, "timestamp", timestamp.to_unix()); } } diff --git a/src/db/TagTable.vala b/src/db/TagTable.vala index d650641..ce191c1 100644 --- a/src/db/TagTable.vala +++ b/src/db/TagTable.vala @@ -26,7 +26,7 @@ public class TagRow { public TagID tag_id; public string name; public Gee.Set<string>? source_id_list; - public time_t time_created; + public int64 time_created; } public class TagTable : DatabaseTable { @@ -79,7 +79,7 @@ public class TagTable : DatabaseTable { out stmt); assert(res == Sqlite.OK); - time_t time_created = (time_t) now_sec(); + var time_created = now_sec(); res = stmt.bind_text(1, name); assert(res == Sqlite.OK); @@ -151,7 +151,7 @@ public class TagTable : DatabaseTable { row.tag_id = tag_id; row.name = stmt.column_text(0); row.source_id_list = unserialize_source_ids(stmt.column_text(1)); - row.time_created = (time_t) stmt.column_int64(2); + row.time_created = stmt.column_int64(2); return row; } @@ -176,7 +176,7 @@ public class TagTable : DatabaseTable { row.tag_id = TagID(stmt.column_int64(0)); row.name = stmt.column_text(1); row.source_id_list = unserialize_source_ids(stmt.column_text(2)); - row.time_created = (time_t) stmt.column_int64(3); + row.time_created = stmt.column_int64(3); rows.add(row); } diff --git a/src/db/TombstoneTable.vala b/src/db/TombstoneTable.vala index 892198f..5c19c5c 100644 --- a/src/db/TombstoneTable.vala +++ b/src/db/TombstoneTable.vala @@ -27,7 +27,7 @@ public class TombstoneRow { public string filepath; public int64 filesize; public string? md5; - public time_t time_created; + public int64 time_created; public Tombstone.Reason reason; } @@ -71,7 +71,7 @@ public class TombstoneTable : DatabaseTable { -1, out stmt); assert(res == Sqlite.OK); - time_t time_created = (time_t) now_sec(); + var time_created = now_sec(); res = stmt.bind_text(1, filepath); assert(res == Sqlite.OK); @@ -124,7 +124,7 @@ public class TombstoneTable : DatabaseTable { row.filepath = stmt.column_text(1); row.filesize = stmt.column_int64(2); row.md5 = stmt.column_text(3); - row.time_created = (time_t) stmt.column_int64(4); + row.time_created = stmt.column_int64(4); row.reason = Tombstone.Reason.unserialize(stmt.column_int(5)); rows[index++] = row; diff --git a/src/db/VideoTable.vala b/src/db/VideoTable.vala index 7bd1bb7..8af1278 100644 --- a/src/db/VideoTable.vala +++ b/src/db/VideoTable.vala @@ -38,20 +38,20 @@ public class VideoRow { public VideoID video_id; public string filepath; public int64 filesize; - public time_t timestamp; + public DateTime timestamp; public int width; public int height; public double clip_duration; public bool is_interpretable; - public time_t exposure_time; + public DateTime? exposure_time; public ImportID import_id; public EventID event_id; public string md5; - public time_t time_created; + public int64 time_created; public Rating rating; public string title; public string? backlinks; - public time_t time_reimported; + public int64 time_reimported; public uint64 flags; public string comment; } @@ -119,7 +119,7 @@ public class VideoTable : DatabaseTable { -1, out stmt); assert(res == Sqlite.OK); - ulong time_created = now_sec(); + var time_created = now_sec(); res = stmt.bind_text(1, video_row.filepath); assert(res == Sqlite.OK); @@ -133,9 +133,13 @@ public class VideoTable : DatabaseTable { assert(res == Sqlite.OK); res = stmt.bind_int64(6, video_row.filesize); assert(res == Sqlite.OK); - res = stmt.bind_int64(7, video_row.timestamp); + res = stmt.bind_int64(7, video_row.timestamp.to_unix()); assert(res == Sqlite.OK); - res = stmt.bind_int64(8, video_row.exposure_time); + if (video_row.exposure_time == null) { + stmt.bind_null(8); + } else { + res = stmt.bind_int64(8, video_row.exposure_time.to_unix()); + } assert(res == Sqlite.OK); res = stmt.bind_int64(9, video_row.import_id.id); assert(res == Sqlite.OK); @@ -159,7 +163,7 @@ public class VideoTable : DatabaseTable { // fill in ignored fields with database values video_row.video_id = VideoID(db.last_insert_rowid()); video_row.event_id = EventID(); - video_row.time_created = (time_t) time_created; + video_row.time_created = time_created; video_row.flags = 0; return video_row.video_id; @@ -208,16 +212,19 @@ public class VideoTable : DatabaseTable { row.clip_duration = stmt.column_double(3); row.is_interpretable = (stmt.column_int(4) == 1); row.filesize = stmt.column_int64(5); - row.timestamp = (time_t) stmt.column_int64(6); - row.exposure_time = (time_t) stmt.column_int64(7); + if (stmt.column_type(6) == Sqlite.NULL) { + row.exposure_time = null; + } else { + row.exposure_time = new DateTime.from_unix_utc(stmt.column_int64(6)); + } row.import_id.id = stmt.column_int64(8); row.event_id.id = stmt.column_int64(9); row.md5 = stmt.column_text(10); - row.time_created = (time_t) stmt.column_int64(11); + row.time_created = stmt.column_int64(11); row.rating = Rating.unserialize(stmt.column_int(12)); row.title = stmt.column_text(13); row.backlinks = stmt.column_text(14); - row.time_reimported = (time_t) stmt.column_int64(15); + row.time_reimported = stmt.column_int64(15); row.flags = stmt.column_int64(16); row.comment = stmt.column_text(17); @@ -244,16 +251,20 @@ public class VideoTable : DatabaseTable { row.clip_duration = stmt.column_double(4); row.is_interpretable = (stmt.column_int(5) == 1); row.filesize = stmt.column_int64(6); - row.timestamp = (time_t) stmt.column_int64(7); - row.exposure_time = (time_t) stmt.column_int64(8); - row.import_id.id = stmt.column_int64(9); + row.timestamp = new DateTime.from_unix_utc(stmt.column_int64(7)); + if (stmt.column_type(8) == Sqlite.NULL) { + row.exposure_time = null; + } else { + row.exposure_time = new DateTime.from_unix_utc(stmt.column_int64(8)); + } + row.import_id.id = stmt.column_int64(9); row.event_id.id = stmt.column_int64(10); row.md5 = stmt.column_text(11); - row.time_created = (time_t) stmt.column_int64(12); + row.time_created = stmt.column_int64(12); row.rating = Rating.unserialize(stmt.column_int(13)); row.title = stmt.column_text(14); row.backlinks = stmt.column_text(15); - row.time_reimported = (time_t) stmt.column_int64(16); + row.time_reimported = stmt.column_int64(16); row.flags = stmt.column_int64(17); row.comment = stmt.column_text(18); @@ -275,8 +286,8 @@ public class VideoTable : DatabaseTable { update_text_by_id_2(video_id.id, "comment", new_comment != null ? new_comment : ""); } - public void set_exposure_time(VideoID video_id, time_t time) throws DatabaseError { - update_int64_by_id_2(video_id.id, "exposure_time", (int64) time); + public void set_exposure_time(VideoID video_id, DateTime time) throws DatabaseError { + update_int64_by_id_2(video_id.id, "exposure_time", time.to_unix()); } public void set_rating(VideoID video_id, Rating rating) throws DatabaseError { @@ -455,8 +466,19 @@ public class VideoTable : DatabaseTable { return result; } - public void set_timestamp(VideoID video_id, time_t timestamp) throws DatabaseError { - update_int64_by_id_2(video_id.id, "timestamp", (int64) timestamp); + public void set_timestamp(VideoID video_id, DateTime timestamp) throws DatabaseError { + update_int64_by_id_2(video_id.id, "timestamp", timestamp.to_unix()); } + + public static void upgrade_for_unset_timestamp() throws DatabaseError { + Sqlite.Statement stmt; + int res = db.prepare_v2("UPDATE VideoTable SET exposure_time = NULL WHERE exposure_time = '0'", -1, out stmt); + assert(res == Sqlite.OK); + res = stmt.step(); + if (res != Sqlite.DONE) { + throw_error("VideoTable.upgrade_for_unset_timestamp", res); + } + } + } diff --git a/src/dialogs/AdjustDateTimeDialog.vala b/src/dialogs/AdjustDateTimeDialog.vala index fc08a3f..f475773 100644 --- a/src/dialogs/AdjustDateTimeDialog.vala +++ b/src/dialogs/AdjustDateTimeDialog.vala @@ -14,7 +14,7 @@ public class AdjustDateTimeDialog : Gtk.Dialog { private const int CALENDAR_THUMBNAIL_SCALE = 1; - time_t original_time; + DateTime? original_time; Gtk.Label original_time_label; Gtk.Calendar calendar; Gtk.SpinButton hour; @@ -182,32 +182,33 @@ public class AdjustDateTimeDialog : Gtk.Dialog { original_time = source.get_exposure_time(); - if (original_time == 0) { - original_time = time_t(); + if (original_time == null) { + // This came from + original_time = new DateTime.now_utc(); no_original_time = true; } - set_time(Time.local(original_time)); + set_time(original_time.to_local()); set_original_time_label(Config.Facade.get_instance().get_use_24_hour_time()); } - private void set_time(Time time) { - calendar.select_month(time.month, time.year + YEAR_OFFSET); - calendar.select_day(time.day); + private void set_time(DateTime time) { + calendar.select_month(time.get_month() - 1, time.get_year()); + calendar.select_day(time.get_day_of_month()); calendar.notify_property("year"); calendar.notify_property("month"); if (Config.Facade.get_instance().get_use_24_hour_time()) { system.set_active(TimeSystem.24HR); - hour.set_value(time.hour); + hour.set_value(time.get_hour()); } else { - int AMPM_hour = time.hour % 12; + int AMPM_hour = time.get_hour() % 12; hour.set_value((AMPM_hour == 0) ? 12 : AMPM_hour); - system.set_active((time.hour >= 12) ? TimeSystem.PM : TimeSystem.AM); + system.set_active((time.get_hour() >= 12) ? TimeSystem.PM : TimeSystem.AM); } - minute.set_value(time.minute); - second.set_value(time.second); + minute.set_value(time.get_minute()); + second.set_value(time.get_second()); previous_time_system = (TimeSystem) system.get_active(); } @@ -217,43 +218,35 @@ public class AdjustDateTimeDialog : Gtk.Dialog { return; original_time_label.set_text(_("Original: ") + - Time.local(original_time).format(use_24_hr_format ? _("%m/%d/%Y, %H:%M:%S") : + original_time.to_local().format(use_24_hr_format ? _("%m/%d/%Y, %H:%M:%S") : _("%m/%d/%Y, %I:%M:%S %p"))); } - private time_t get_time() { - Time time = Time(); - - time.second = (int) second.get_value(); - time.minute = (int) minute.get_value(); - + private DateTime get_time() { // convert to 24 hr int hour = (int) hour.get_value(); - time.hour = (hour == 12 && system.get_active() != TimeSystem.24HR) ? 0 : hour; - time.hour += ((system.get_active() == TimeSystem.PM) ? 12 : 0); + hour = (hour == 12 && system.get_active() != TimeSystem.24HR) ? 0 : hour; + hour += ((system.get_active() == TimeSystem.PM) ? 12 : 0); uint year, month, day; calendar.get_date(out year, out month, out day); - time.year = ((int) year) - YEAR_OFFSET; - time.month = (int) month; - time.day = (int) day; - time.isdst = -1; - - return time.mktime(); + return new DateTime.local((int)year, (int)month + 1, (int)day, hour, (int)minute.get_value(), (int)second.get_value()); } - public bool execute(out int64 time_shift, out bool keep_relativity, + public bool execute(out TimeSpan time_shift, out bool keep_relativity, out bool modify_originals) { show_all(); bool response = false; if (run() == Gtk.ResponseType.OK) { - if (no_original_time) - time_shift = (int64) get_time(); - else - time_shift = (int64) (get_time() - original_time); + // Difference returns microseconds, so divide by 1000000, we need seconds + if (no_original_time) { + time_shift = get_time().difference(new DateTime.from_unix_utc(0)) / 1000 / 1000; + } else { + time_shift = (get_time().difference(original_time)) / 1000 / 1000; + } keep_relativity = relativity_radio_button.get_active(); @@ -286,7 +279,7 @@ public class AdjustDateTimeDialog : Gtk.Dialog { } private void on_time_changed() { - int64 time_shift = ((int64) get_time() - (int64) original_time); + var time_shift = get_time().difference (original_time); calendar.notify_property("year"); calendar.notify_property("month"); @@ -301,12 +294,12 @@ public class AdjustDateTimeDialog : Gtk.Dialog { time_shift = time_shift.abs(); - days = (int) (time_shift / SECONDS_IN_DAY); - time_shift = time_shift % SECONDS_IN_DAY; - hours = (int) (time_shift / SECONDS_IN_HOUR); - time_shift = time_shift % SECONDS_IN_HOUR; - minutes = (int) (time_shift / SECONDS_IN_MINUTE); - seconds = (int) (time_shift % SECONDS_IN_MINUTE); + days = (int) (time_shift / TimeSpan.DAY); + time_shift = time_shift % TimeSpan.DAY; + hours = (int) (time_shift / TimeSpan.HOUR); + time_shift = time_shift % TimeSpan.HOUR; + minutes = (int) (time_shift / TimeSpan.MINUTE); + seconds = (int) ((time_shift % TimeSpan.MINUTE) / TimeSpan.SECOND); string shift_status = (forward) ? _("Exposure time will be shifted forward by\n%d %s, %d %s, %d %s, and %d %s.") : diff --git a/src/dialogs/MultiTextEntryDialog.vala b/src/dialogs/MultiTextEntryDialog.vala index 42e5318..ddbd59b 100644 --- a/src/dialogs/MultiTextEntryDialog.vala +++ b/src/dialogs/MultiTextEntryDialog.vala @@ -11,7 +11,7 @@ public class MultiTextEntryDialog : Gtk.Dialog { private unowned OnModifyValidateType on_modify_validate; [GtkChild] - private Gtk.TextView entry; + private unowned Gtk.TextView entry; public MultiTextEntryDialog() { Object (use_header_bar: Resources.use_header_bar()); diff --git a/src/dialogs/Preferences.vala b/src/dialogs/Preferences.vala index 17b16cf..efd9589 100644 --- a/src/dialogs/Preferences.vala +++ b/src/dialogs/Preferences.vala @@ -19,49 +19,49 @@ public class PreferencesDialog : Gtk.Dialog { private static PreferencesDialog preferences_dialog; [GtkChild] - private Gtk.Switch switch_dark; + private unowned Gtk.Switch switch_dark; [GtkChild] - private Gtk.ComboBox photo_editor_combo; + private unowned Gtk.ComboBox photo_editor_combo; [GtkChild] - private Gtk.ComboBox raw_editor_combo; + private unowned Gtk.ComboBox raw_editor_combo; private SortedList<AppInfo> external_raw_apps; private SortedList<AppInfo> external_photo_apps; [GtkChild] - private Gtk.FileChooserButton library_dir_button; + private unowned Gtk.FileChooserButton library_dir_button; [GtkChild] - private Gtk.ComboBoxText dir_pattern_combo; + private unowned Gtk.ComboBoxText dir_pattern_combo; [GtkChild] - private Gtk.Entry dir_pattern_entry; + private unowned Gtk.Entry dir_pattern_entry; [GtkChild] - private Gtk.Label dir_pattern_example; + private unowned Gtk.Label dir_pattern_example; private bool allow_closing = false; private string? lib_dir = null; private Gee.ArrayList<PathFormat> path_formats = new Gee.ArrayList<PathFormat>(); private GLib.DateTime example_date = new GLib.DateTime.local(2009, 3, 10, 18, 16, 11); [GtkChild] - private Gtk.CheckButton lowercase; + private unowned Gtk.CheckButton lowercase; private Plugins.ManifestWidgetMediator plugins_mediator = new Plugins.ManifestWidgetMediator(); [GtkChild] - private Gtk.ComboBoxText default_raw_developer_combo; + private unowned Gtk.ComboBoxText default_raw_developer_combo; [GtkChild] - private Gtk.CheckButton autoimport; + private unowned Gtk.CheckButton autoimport; [GtkChild] - private Gtk.CheckButton write_metadata; + private unowned Gtk.CheckButton write_metadata; [GtkChild] - private Gtk.Label pattern_help; + private unowned Gtk.Label pattern_help; [GtkChild] - private Gtk.Notebook preferences_notebook; + private unowned Gtk.Stack preferences_stack; [GtkChild] - private Gtk.RadioButton transparent_checker_radio; + private unowned Gtk.RadioButton transparent_checker_radio; [GtkChild] - private Gtk.RadioButton transparent_solid_radio; + private unowned Gtk.RadioButton transparent_solid_radio; [GtkChild] - private Gtk.ColorButton transparent_solid_color; + private unowned Gtk.ColorButton transparent_solid_color; [GtkChild] - private Gtk.RadioButton transparent_none_radio; + private unowned Gtk.RadioButton transparent_none_radio; private PreferencesDialog() { Object (use_header_bar: Resources.use_header_bar()); @@ -81,7 +81,7 @@ public class PreferencesDialog : Gtk.Dialog { Gdk.RGBA color = Gdk.RGBA(); color.parse(Config.Facade.get_instance().get_transparent_background_color()); - (transparent_solid_color as Gtk.ColorChooser).rgba = color; + ((Gtk.ColorChooser) transparent_solid_color).rgba = color; transparent_solid_color.color_set.connect(on_color_changed); switch (Config.Facade.get_instance().get_transparent_background_type()) { @@ -105,11 +105,11 @@ public class PreferencesDialog : Gtk.Dialog { if (help_path == null) { // We're installed system-wide, so use the system help. - pattern_help.set_markup("<a href=\"" + Resources.DIR_PATTERN_URI_SYSWIDE + "\">" + _("(Help)") + "</a>"); + pattern_help.set_markup("<a href=\"%s\">%s</a>".printf(Resources.DIR_PATTERN_URI_SYSWIDE, _("(Help)"))); } else { // We're being run from the build directory; we'll have to handle clicks to this // link manually ourselves, due to a limitation of help: URIs. - pattern_help.set_markup("<a href=\"dummy:\">" + _("(Help)") + "</a>"); + pattern_help.set_markup("<a href=\"dummy:\">%s</a>".printf(_("(Help)"))); pattern_help.activate_link.connect(on_local_pattern_help); } @@ -126,7 +126,9 @@ public class PreferencesDialog : Gtk.Dialog { lowercase.toggled.connect(on_lowercase_toggled); - (preferences_notebook.get_nth_page (2) as Gtk.Container).add (plugins_mediator); + ((Gtk.Box)preferences_stack.get_child_by_name("plugins")).add(plugins_mediator); + ((Gtk.Box)preferences_stack.get_child_by_name("profiles")).add(new Shotwell.ProfileBrowser()); + populate_preference_options(); @@ -177,7 +179,7 @@ public class PreferencesDialog : Gtk.Dialog { } private void on_color_changed() { - var color = (transparent_solid_color as Gtk.ColorChooser).rgba.to_string(); + var color = ((Gtk.ColorChooser) transparent_solid_color).rgba.to_string(); Config.Facade.get_instance().set_transparent_background_color(color); } diff --git a/src/dialogs/SetBackground.vala b/src/dialogs/SetBackground.vala index d9a77c4..ec56502 100644 --- a/src/dialogs/SetBackground.vala +++ b/src/dialogs/SetBackground.vala @@ -8,9 +8,9 @@ [GtkTemplate (ui = "/org/gnome/Shotwell/ui/set_background_dialog.ui")] public class SetBackgroundPhotoDialog : Gtk.Dialog { [GtkChild] - private Gtk.CheckButton desktop_background_checkbox; + private unowned Gtk.CheckButton desktop_background_checkbox; [GtkChild] - private Gtk.CheckButton screensaver_checkbox; + private unowned Gtk.CheckButton screensaver_checkbox; public SetBackgroundPhotoDialog() { Object(use_header_bar: Resources.use_header_bar()); diff --git a/src/dialogs/SetBackgroundSlideshow.vala b/src/dialogs/SetBackgroundSlideshow.vala index 914af76..479b0c7 100644 --- a/src/dialogs/SetBackgroundSlideshow.vala +++ b/src/dialogs/SetBackgroundSlideshow.vala @@ -8,13 +8,13 @@ [GtkTemplate (ui = "/org/gnome/Shotwell/ui/set_background_slideshow_dialog.ui")] public class SetBackgroundSlideshowDialog : Gtk.Dialog { [GtkChild] - private Gtk.CheckButton desktop_background_checkbox; + private unowned Gtk.CheckButton desktop_background_checkbox; [GtkChild] - private Gtk.CheckButton screensaver_checkbox; + private unowned Gtk.CheckButton screensaver_checkbox; [GtkChild] - private Gtk.Scale delay_scale; + private unowned Gtk.Scale delay_scale; [GtkChild] - private Gtk.Label delay_value_label; + private unowned Gtk.Label delay_value_label; private int delay_value = 0; diff --git a/src/dialogs/TextEntry.vala b/src/dialogs/TextEntry.vala index d82fdbd..a2e4653 100644 --- a/src/dialogs/TextEntry.vala +++ b/src/dialogs/TextEntry.vala @@ -12,10 +12,10 @@ public class TextEntryDialog : Gtk.Dialog { private unowned OnModifyValidateType on_modify_validate; [GtkChild] - private Gtk.Entry entry; + private unowned Gtk.Entry entry; [GtkChild] - private Gtk.Label label; + private unowned Gtk.Label label; public TextEntryDialog() { Object (use_header_bar: Resources.use_header_bar()); diff --git a/src/dialogs/WelcomeDialog.vala b/src/dialogs/WelcomeDialog.vala index e40686d..7fa0b7c 100644 --- a/src/dialogs/WelcomeDialog.vala +++ b/src/dialogs/WelcomeDialog.vala @@ -60,11 +60,9 @@ public class WelcomeDialog : Gtk.Dialog { Gtk.Label instructions = new Gtk.Label(""); string indent_prefix = " "; // we can't tell what the indent prefix is going to be so assume we need one - string arrow_glyph = (get_direction() == Gtk.TextDirection.RTL) ? "◂" : "▸"; - instructions.set_markup(((indent_prefix + "• %s\n") + (indent_prefix + "• %s\n") + (indent_prefix + "• %s")).printf( - _("Choose <span weight=\"bold\">File %s Import From Folder</span>").printf(arrow_glyph), + _("Choose “Import From Folder” from the File menu"), _("Drag and drop photos onto the Shotwell window"), _("Connect a camera to your computer and import"))); instructions.xalign = 0.0f; diff --git a/src/direct/DirectPhotoPage.vala b/src/direct/DirectPhotoPage.vala index 39a87f1..cc7186c 100644 --- a/src/direct/DirectPhotoPage.vala +++ b/src/direct/DirectPhotoPage.vala @@ -219,8 +219,9 @@ public class DirectPhotoPage : EditingHostPage { return true; } else { - if (get_container() is DirectWindow) { - (get_container() as DirectWindow).do_fullscreen(); + var direct_window = get_container() as DirectWindow; + if (direct_window != null) { + direct_window.do_fullscreen(); return true; } diff --git a/src/editing_tools/EditingTools.vala b/src/editing_tools/EditingTools.vala index 82fef0f..0042d57 100644 --- a/src/editing_tools/EditingTools.vala +++ b/src/editing_tools/EditingTools.vala @@ -87,7 +87,8 @@ public abstract class EditingToolWindow : Gtk.Window { } public override void realize() { - (this as Gtk.Widget).set_opacity(Resources.TRANSIENT_WINDOW_OPACITY); + // Force the use of gtk_widget_set_opacity; gtk_window_set_opacity is deprecated + ((Gtk.Widget) this).set_opacity(Resources.TRANSIENT_WINDOW_OPACITY); base.realize(); } @@ -381,12 +382,13 @@ public abstract class PhotoCanvas { } public void erase_horizontal_line(int x, int y, int width) { + var scale = Application.get_scale(); default_ctx.save(); default_ctx.set_operator(Cairo.Operator.SOURCE); default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y); default_ctx.rectangle(scaled_position.x + x, scaled_position.y + y, - width - 1, 1); + width - 1, 1 * scale); default_ctx.fill(); default_ctx.restore(); @@ -404,6 +406,8 @@ public abstract class PhotoCanvas { public void erase_vertical_line(int x, int y, int height) { default_ctx.save(); + var scale = Application.get_scale(); + // Ticket #3146 - artifacting when moving the crop box or // enlarging it from the lower right. // We now no longer subtract one from the height before choosing @@ -411,7 +415,7 @@ public abstract class PhotoCanvas { default_ctx.set_operator(Cairo.Operator.SOURCE); default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y); default_ctx.rectangle(scaled_position.x + x, scaled_position.y + y, - 1, height); + 1 * scale, height); default_ctx.fill(); default_ctx.restore(); @@ -427,12 +431,19 @@ public abstract class PhotoCanvas { public void invalidate_area(Box area) { Gdk.Rectangle rect = area.get_rectangle(); + rect.x += scaled_position.x; rect.y += scaled_position.y; drawing_window.invalidate_rect(rect, false); } + public void set_cursor(Gdk.CursorType cursor_type) { + var display = get_drawing_window().get_display(); + var cursor = new Gdk.Cursor.for_display (display, cursor_type); + get_drawing_window().set_cursor(cursor); + } + private Cairo.Surface pixbuf_to_surface(Cairo.Context default_ctx, Gdk.Pixbuf pixbuf, Gdk.Rectangle pos) { Cairo.Surface surface = new Cairo.Surface.similar(default_ctx.get_target(), @@ -1220,11 +1231,7 @@ public class CropTool : EditingTool { // make sure the cursor isn't set to a modify indicator if (canvas != null) { - var drawing_window = canvas.get_drawing_window (); - var display = drawing_window.get_display (); - var cursor = new Gdk.Cursor.for_display (display, - Gdk.CursorType.LEFT_PTR); - drawing_window.set_cursor (cursor); + canvas.set_cursor (Gdk.CursorType.LEFT_PTR); } crop_surface = null; @@ -1244,20 +1251,22 @@ public class CropTool : EditingTool { } private void prepare_ctx(Cairo.Context ctx, Dimensions dim) { + var scale = Application.get_scale(); wide_black_ctx = new Cairo.Context(ctx.get_target()); set_source_color_from_string(wide_black_ctx, "#000"); - wide_black_ctx.set_line_width(1); + wide_black_ctx.set_line_width(1 * scale); wide_white_ctx = new Cairo.Context(ctx.get_target()); set_source_color_from_string(wide_white_ctx, "#FFF"); - wide_white_ctx.set_line_width(1); + wide_white_ctx.set_line_width(1 * scale); thin_white_ctx = new Cairo.Context(ctx.get_target()); set_source_color_from_string(thin_white_ctx, "#FFF"); - thin_white_ctx.set_line_width(0.5); + thin_white_ctx.set_line_width(0.5 * scale); text_ctx = new Cairo.Context(ctx.get_target()); text_ctx.select_font_face("Sans", Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL); + text_ctx.set_font_size(10.0 * scale); } private void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) { @@ -1286,7 +1295,8 @@ public class CropTool : EditingTool { Box offset_scaled_crop = scaled_crop.get_offset(scaled_pixbuf_pos.x, scaled_pixbuf_pos.y); // determine where the mouse down landed and store for future events - in_manipulation = offset_scaled_crop.approx_location(x, y); + in_manipulation = offset_scaled_crop.approx_location((int)Math.lround(x * Application.get_scale()), + (int)Math.lround(y * Application.get_scale())); last_grab_x = x -= scaled_pixbuf_pos.x; last_grab_y = y -= scaled_pixbuf_pos.y; @@ -1314,19 +1324,21 @@ public class CropTool : EditingTool { // only deal with manipulating the crop tool when click-and-dragging one of the edges // or the interior if (in_manipulation != BoxLocation.OUTSIDE) - on_canvas_manipulation(x, y); + on_canvas_manipulation((int)Math.lround(x * Application.get_scale()), + (int)Math.lround(y * Application.get_scale())); update_cursor(x, y); canvas.repaint(); } public override void paint(Cairo.Context default_ctx) { + var scale = Application.get_scale(); // fill region behind the crop surface with neutral color int w = canvas.get_drawing_window().get_width(); int h = canvas.get_drawing_window().get_height(); default_ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0); - default_ctx.rectangle(0, 0, w, h); + default_ctx.rectangle(0, 0, w * scale, h * scale); default_ctx.fill(); default_ctx.paint(); @@ -1376,7 +1388,8 @@ public class CropTool : EditingTool { Box offset_scaled_crop = scaled_crop.get_offset(scaled_pos.x, scaled_pos.y); Gdk.CursorType cursor_type = Gdk.CursorType.LEFT_PTR; - switch (offset_scaled_crop.approx_location(x, y)) { + switch (offset_scaled_crop.approx_location((int)Math.lround(x * Application.get_scale()), + (int)Math.lround(y * Application.get_scale()))) { case BoxLocation.LEFT_SIDE: cursor_type = Gdk.CursorType.LEFT_SIDE; break; @@ -1419,10 +1432,7 @@ public class CropTool : EditingTool { } if (cursor_type != current_cursor_type) { - var drawing_window = canvas.get_drawing_window (); - var display = drawing_window.get_display (); - var cursor = new Gdk.Cursor.for_display (display, cursor_type); - drawing_window.set_cursor (cursor); + canvas.set_cursor(cursor_type); current_cursor_type = cursor_type; } } @@ -1893,8 +1903,6 @@ public class RedeyeTool : EditingTool { private bool is_reticle_move_in_progress = false; private Gdk.Point reticle_move_mouse_start_point; private Gdk.Point reticle_move_anchor; - private Gdk.Cursor cached_arrow_cursor; - private Gdk.Cursor cached_grab_cursor; private Gdk.Rectangle old_scaled_pixbuf_position; private Gdk.Pixbuf current_pixbuf = null; @@ -1928,13 +1936,14 @@ public class RedeyeTool : EditingTool { } private void prepare_ctx(Cairo.Context ctx, Dimensions dim) { + var scale = Application.get_scale(); wider_gray_ctx = new Cairo.Context(ctx.get_target()); set_source_color_from_string(wider_gray_ctx, "#111"); - wider_gray_ctx.set_line_width(3); + wider_gray_ctx.set_line_width(3 * scale); thin_white_ctx = new Cairo.Context(ctx.get_target()); set_source_color_from_string(thin_white_ctx, "#FFF"); - thin_white_ctx.set_line_width(1); + thin_white_ctx.set_line_width(1 * scale); } private void draw_redeye_instance(RedeyeInstance inst) { @@ -2044,10 +2053,6 @@ public class RedeyeTool : EditingTool { bind_window_handlers(); - var display = canvas.get_drawing_window().get_display(); - cached_arrow_cursor = new Gdk.Cursor.for_display(display, Gdk.CursorType.LEFT_PTR); - cached_grab_cursor = new Gdk.Cursor.for_display(display, Gdk.CursorType.FLEUR); - DataCollection? owner = canvas.get_photo().get_membership(); if (owner != null) owner.items_altered.connect(on_photos_altered); @@ -2112,13 +2117,17 @@ public class RedeyeTool : EditingTool { } public override void on_left_click(int x, int y) { + var scale = Application.get_scale(); + Gdk.Rectangle bounds_rect = RedeyeInstance.to_bounds_rect(user_interaction_instance); - if (coord_in_rectangle(x, y, bounds_rect)) { + + if (coord_in_rectangle((int)Math.lround(x * scale), (int)Math.lround(y * scale), bounds_rect)) { + print("Motion in progress!!\n"); is_reticle_move_in_progress = true; - reticle_move_mouse_start_point.x = x; - reticle_move_mouse_start_point.y = y; + reticle_move_mouse_start_point.x = (int)Math.lround(x * scale); + reticle_move_mouse_start_point.y = (int)Math.lround(y * scale); reticle_move_anchor = user_interaction_instance.center; } } @@ -2128,6 +2137,8 @@ public class RedeyeTool : EditingTool { } public override void on_motion(int x, int y, Gdk.ModifierType mask) { + var scale = Application.get_scale(); + if (is_reticle_move_in_progress) { Gdk.Rectangle active_region_rect = @@ -2144,8 +2155,8 @@ public class RedeyeTool : EditingTool { active_region_rect.y + active_region_rect.height - user_interaction_instance.radius - 1; - int delta_x = x - reticle_move_mouse_start_point.x; - int delta_y = y - reticle_move_mouse_start_point.y; + int delta_x = (int)Math.lround(x * scale) - reticle_move_mouse_start_point.x; + int delta_y = (int)Math.lround(y * scale) - reticle_move_mouse_start_point.y; user_interaction_instance.center.x = reticle_move_anchor.x + delta_x; @@ -2164,10 +2175,10 @@ public class RedeyeTool : EditingTool { Gdk.Rectangle bounds = RedeyeInstance.to_bounds_rect(user_interaction_instance); - if (coord_in_rectangle(x, y, bounds)) { - canvas.get_drawing_window().set_cursor(cached_grab_cursor); + if (coord_in_rectangle((int)Math.lround(x * scale), (int)Math.lround(y * scale), bounds)) { + canvas.set_cursor(Gdk.CursorType.FLEUR); } else { - canvas.get_drawing_window().set_cursor(cached_arrow_cursor); + canvas.set_cursor(Gdk.CursorType.LEFT_PTR); } } } diff --git a/src/editing_tools/StraightenTool.vala b/src/editing_tools/StraightenTool.vala index f427b99..2b0591a 100644 --- a/src/editing_tools/StraightenTool.vala +++ b/src/editing_tools/StraightenTool.vala @@ -80,13 +80,13 @@ public class StraightenTool : EditingTool { // different backgrounds. ctx.set_source_rgba(0.0, 0.0, 0.0, alpha); ctx.set_dash(GUIDE_DASH, GUIDE_DASH[0] / 2); - ctx.move_to(x[0] + 0.5, y[0] + 0.5); - ctx.line_to(x[1] + 0.5, y[1] + 0.5); + ctx.move_to(x[0] * Application.get_scale() + 0.5, y[0]* Application.get_scale() + 0.5); + ctx.line_to(x[1] * Application.get_scale()+ 0.5, y[1]* Application.get_scale() + 0.5); ctx.stroke(); ctx.set_dash(GUIDE_DASH, -GUIDE_DASH[0] / 2); ctx.set_source_rgba(1.0, 1.0, 1.0, alpha); - ctx.move_to(x[0] + 0.5, y[0] + 0.5); - ctx.line_to(x[1] + 0.5, y[1] + 0.5); + ctx.move_to(x[0] * Application.get_scale()+ 0.5, y[0]* Application.get_scale() + 0.5); + ctx.line_to(x[1] * Application.get_scale()+ 0.5, y[1] * Application.get_scale()+ 0.5); ctx.stroke(); } } @@ -456,7 +456,7 @@ public class StraightenTool : EditingTool { */ private void update_rotated_surface() { draw_rotated_source(photo_surf, rotate_ctx, view_width, view_height, photo_angle); - rotate_ctx.set_line_width(1.0); + rotate_ctx.set_line_width(1.0 * Application.get_scale()); draw_superimposed_grid(rotate_ctx, view_width, view_height); } @@ -468,8 +468,8 @@ public class StraightenTool : EditingTool { * it's not used. */ public override void paint(Cairo.Context ctx) { - int w = canvas.get_drawing_window().get_width(); - int h = canvas.get_drawing_window().get_height(); + var w = canvas.get_drawing_window().get_width() * Application.get_scale(); + var h = canvas.get_drawing_window().get_height() * Application.get_scale(); // fill region behind the rotation surface with neutral color. canvas.get_default_ctx().identity_matrix(); diff --git a/src/events/EventDirectoryItem.vala b/src/events/EventDirectoryItem.vala index 5b177fb..dbab1b1 100644 --- a/src/events/EventDirectoryItem.vala +++ b/src/events/EventDirectoryItem.vala @@ -60,7 +60,7 @@ class EventDirectoryItem : CheckerboardItem { pixbuf = media.get_preview_pixbuf(squared_scaling); } catch (Error error) { ThumbnailCache.fetch_async_scaled(media, ThumbnailCache.Size.BIG, - new Dimensions(ThumbnailCache.Size.BIG, ThumbnailCache.Size.BIG), + Dimensions(ThumbnailCache.Size.BIG, ThumbnailCache.Size.BIG), ThumbnailCache.DEFAULT_INTERP, () => {}); if (media is LibraryPhoto) { LibraryPhoto photo = (LibraryPhoto) media; diff --git a/src/events/EventsBranch.vala b/src/events/EventsBranch.vala index 097a664..0550eb7 100644 --- a/src/events/EventsBranch.vala +++ b/src/events/EventsBranch.vala @@ -133,8 +133,8 @@ public class Events.Branch : Sidebar.Branch { b = swap; } - int64 result = ((Events.EventEntry) a).get_event().get_start_time() - - ((Events.EventEntry) b).get_event().get_start_time(); + int64 result = nullsafe_date_time_comperator(((Events.EventEntry) a).get_event().get_start_time(), + ((Events.EventEntry) b).get_event().get_start_time()); // to stabilize sort (events with the same start time are allowed) if (result == 0) { @@ -215,14 +215,14 @@ public class Events.Branch : Sidebar.Branch { } private void add_event(Event event) { - time_t event_time = event.get_start_time(); - if (event_time == 0) { + DateTime? event_time = event.get_start_time(); + if (event_time == null) { add_undated_event(event); return; } - Time event_tm = Time.local(event_time); + var event_tm = event_time.to_local(); Sidebar.Entry? year; Sidebar.Entry? month = find_event_month(event, event_tm, out year); @@ -246,14 +246,14 @@ public class Events.Branch : Sidebar.Branch { } private void move_event(Event event) { - time_t event_time = event.get_start_time(); - if (event_time == 0) { + DateTime? event_time = event.get_start_time(); + if (event_time == null) { move_to_undated_event(event); return; } - Time event_tm = Time.local(event_time); + var event_tm = event_time.to_local(); Sidebar.Entry? year; Sidebar.Entry? month = find_event_month(event, event_tm, out year); @@ -296,13 +296,13 @@ public class Events.Branch : Sidebar.Branch { } } - private Sidebar.Entry? find_event_month(Event event, Time event_tm, out Sidebar.Entry found_year) { + private Sidebar.Entry? find_event_month(Event event, DateTime event_tm, out Sidebar.Entry found_year) { // find the year first found_year = find_event_year(event, event_tm); if (found_year == null) return null; - int event_month = event_tm.month + 1; + int event_month = event_tm.get_month(); // found the year, traverse the months return find_first_child(found_year, (entry) => { @@ -310,8 +310,8 @@ public class Events.Branch : Sidebar.Branch { }); } - private Sidebar.Entry? find_event_year(Event event, Time event_tm) { - int event_year = event_tm.year + 1900; + private Sidebar.Entry? find_event_year(Event event, DateTime event_tm) { + int event_year = event_tm.get_year(); return find_first_child(get_root(), (entry) => { if ((entry is Events.UndatedDirectoryEntry) || (entry is Events.NoEventEntry) || @@ -400,9 +400,9 @@ public class Events.MasterDirectoryEntry : Events.DirectoryEntry { public class Events.YearDirectoryEntry : Events.DirectoryEntry { private string name; - private Time tm; + private DateTime tm; - public YearDirectoryEntry(string name, Time tm) { + public YearDirectoryEntry(string name, DateTime tm) { this.name = name; this.tm = tm; } @@ -412,7 +412,7 @@ public class Events.YearDirectoryEntry : Events.DirectoryEntry { } public int get_year() { - return tm.year + 1900; + return tm.get_year(); } protected override Page create_page() { @@ -422,9 +422,9 @@ public class Events.YearDirectoryEntry : Events.DirectoryEntry { public class Events.MonthDirectoryEntry : Events.DirectoryEntry { private string name; - private Time tm; + private DateTime tm; - public MonthDirectoryEntry(string name, Time tm) { + public MonthDirectoryEntry(string name, DateTime tm) { this.name = name; this.tm = tm; } @@ -434,11 +434,11 @@ public class Events.MonthDirectoryEntry : Events.DirectoryEntry { } public int get_year() { - return tm.year + 1900; + return tm.get_year(); } public int get_month() { - return tm.month + 1; + return tm.get_month(); } protected override Page create_page() { @@ -456,7 +456,7 @@ public class Events.UndatedDirectoryEntry : Events.DirectoryEntry { protected override Page create_page() { return new SubEventsDirectoryPage(SubEventsDirectoryPage.DirectoryType.UNDATED, - Time.local(0)); + new DateTime.now_local()); } } diff --git a/src/events/EventsDirectoryPage.vala b/src/events/EventsDirectoryPage.vala index 7ead1a0..c00e4bf 100644 --- a/src/events/EventsDirectoryPage.vala +++ b/src/events/EventsDirectoryPage.vala @@ -88,10 +88,10 @@ public abstract class EventsDirectoryPage : CheckerboardPage { } private static int64 event_ascending_comparator(void *a, void *b) { - time_t start_a = ((EventDirectoryItem *) a)->event.get_start_time(); - time_t start_b = ((EventDirectoryItem *) b)->event.get_start_time(); + DateTime start_a = ((EventDirectoryItem *) a)->event.get_start_time(); + DateTime start_b = ((EventDirectoryItem *) b)->event.get_start_time(); - return start_a - start_b; + return start_a.compare(start_b); } private static int64 event_descending_comparator(void *a, void *b) { @@ -239,21 +239,21 @@ public class SubEventsDirectoryPage : EventsDirectoryPage { } public const string UNDATED_PAGE_NAME = _("Undated"); - public const string YEAR_FORMAT = _("%Y"); - public const string MONTH_FORMAT = _("%B"); + public const string YEAR_FORMAT = "%Y"; + public const string MONTH_FORMAT = "%0B"; private class SubEventDirectoryManager : EventsDirectoryPage.EventDirectoryManager { private int month = 0; private int year = 0; DirectoryType type; - public SubEventDirectoryManager(DirectoryType type, Time time) { + public SubEventDirectoryManager(DirectoryType type, DateTime time) { base(); if (type == DirectoryType.MONTH) - month = time.month; + month = time.get_month(); this.type = type; - year = time.year; + year = time.get_year(); } public override bool include_in_view(DataSource source) { @@ -261,10 +261,10 @@ public class SubEventsDirectoryPage : EventsDirectoryPage { return false; EventSource event = (EventSource) source; - Time event_time = Time.local(event.get_start_time()); - if (event_time.year == year) { + var event_time = event.get_start_time().to_local(); + if (event_time.get_year() == year) { if (type == DirectoryType.MONTH) { - return (event_time.month == month); + return (event_time.get_month() == month); } return true; } @@ -284,12 +284,26 @@ public class SubEventsDirectoryPage : EventsDirectoryPage { } } - public SubEventsDirectoryPage(DirectoryType type, Time time) { + public SubEventsDirectoryPage(DirectoryType type, DateTime time) { string page_name; if (type == SubEventsDirectoryPage.DirectoryType.UNDATED) { page_name = UNDATED_PAGE_NAME; } else { - page_name = time.format((type == DirectoryType.YEAR) ? YEAR_FORMAT : MONTH_FORMAT); + switch (type) { + case DirectoryType.MONTH: { + page_name = time.format(MONTH_FORMAT); + if (page_name.index_of("%0B") != -1) { + page_name = time.format("%B"); + } + } + break; + case DirectoryType.YEAR: { + page_name = time.format(YEAR_FORMAT); + } + break; + default: + assert_not_reached(); + } } base(page_name, new SubEventDirectoryManager(type, time), null); diff --git a/src/faces/Face.vala b/src/faces/Face.vala index 9304023..cdccc1b 100644 --- a/src/faces/Face.vala +++ b/src/faces/Face.vala @@ -345,9 +345,19 @@ public class Face : DataSource, ContainerSource, Proxyable, Indexable { // add them all at once to the SourceCollection global.add_many(faces); global.init_add_many_unlinked(unlinked); + +#if ENABLE_FACE_DETECTION + // Start the face detection background process + // FaceTool talks to it over DBus + start_facedetect_process(); +#endif } public static void terminate() { + try { + if (FaceDetect.face_detect_proxy != null) + FaceDetect.face_detect_proxy.terminate(); + } catch(Error e) {} } public static int compare_names(void *a, void *b) { @@ -365,6 +375,14 @@ public class Face : DataSource, ContainerSource, Proxyable, Indexable { public static bool equal_name_strings(void *a, void *b) { return String.collated_equals(a, b); } + +#if ENABLE_FACE_DETECTION + private static void start_facedetect_process() { + message("Launching facedetect process: %s", AppDirs.get_facedetect_bin().get_path()); + // Start the watcher, process started via DBus service + FaceDetect.init(AppDirs.get_openface_dnn_system_dir().get_path() + ":" + AppDirs.get_openface_dnn_dir().get_path()); + } +#endif // Returns a Face for the name, creating a new empty one if it does not already exist. // name should have already been prepared by prep_face_name. @@ -387,7 +405,7 @@ public class Face : DataSource, ContainerSource, Proxyable, Indexable { return face; } - + // Utility function to cleanup a face name that comes from user input and prepare it for use // in the system and storage in the database. Returns null if the name is unacceptable. public static string? prep_face_name(string name) { @@ -574,6 +592,16 @@ public class Face : DataSource, ContainerSource, Proxyable, Indexable { return true; } + + public bool set_reference(FaceLocation face_loc) { + try { + FaceTable.get_instance().set_reference(row.face_id, face_loc.get_photo_id()); + } catch (DatabaseError err) { + AppWindow.database_error(err); + return false; + } + return true; + } public bool contains(MediaSource source) { return media_views.has_view_for_source(source); diff --git a/src/faces/FaceDetect.vala b/src/faces/FaceDetect.vala new file mode 100644 index 0000000..83caa4d --- /dev/null +++ b/src/faces/FaceDetect.vala @@ -0,0 +1,146 @@ +/** + * Face detection and recognition functions + * Copyright 2018 Narendra A (narendra_m_a(at)yahoo(dot)com) + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// DBus face_detect_proxy definition +public struct FaceRect { + public double x; + public double y; + public double width; + public double height; + public double[] vec; +} + +[DBus (name = "org.gnome.Shotwell.Faces1")] +public interface FaceDetectInterface : DBusProxy { + public abstract FaceRect[] detect_faces(string inputName, string cascadeName, double scale, bool infer) + throws IOError, DBusError; + public abstract bool load_net(string netFile) + throws IOError, DBusError; + public abstract void terminate() throws IOError, DBusError; +} + +// Class to communicate with facedetect process over DBus +public class FaceDetect { + public const string DBUS_NAME = "org.gnome.Shotwell.Faces1"; + public const string DBUS_PATH = "/org/gnome/shotwell/faces"; + public static bool connected = false; + public static string net_file; + public const string ERROR_MESSAGE = "Unable to connect to facedetect service"; + + public static FaceDetectInterface face_detect_proxy; + +#if FACEDETECT_BUS_PRIVATE + private static GLib.DBusServer dbus_server; + private static Subprocess process; +#endif + + public static void create_face_detect_proxy(DBusConnection connection, string bus_name, string owner) { + if (bus_name == DBUS_NAME) { + message("Dbus name %s available", bus_name); + + try { + // Service file should automatically run the facedetect binary + face_detect_proxy = Bus.get_proxy_sync (BusType.SESSION, DBUS_NAME, DBUS_PATH); + face_detect_proxy.load_net(net_file); + connected = true; + } catch(IOError e) { + AppWindow.error_message(ERROR_MESSAGE); + } catch(DBusError e) { + AppWindow.error_message(ERROR_MESSAGE); + } + } + } + + public static void interface_gone(DBusConnection connection, string bus_name) { + message("Dbus name %s gone", bus_name); + connected = false; + face_detect_proxy = null; + } + +#if FACEDETECT_BUS_PRIVATE + private static bool on_new_connection(DBusServer server, DBusConnection connection) { + try { + face_detect_proxy = connection.get_proxy_sync(null, DBUS_PATH, + DBusProxyFlags.DO_NOT_LOAD_PROPERTIES + | DBusProxyFlags.DO_NOT_CONNECT_SIGNALS, + null); + Idle.add(() => { + try { + face_detect_proxy.load_net(net_file); + connected = true; + } catch (Error error) { + critical("Failed to call load_net: %s", error.message); + AppWindow.error_message(ERROR_MESSAGE); + } + return false; + }); + + return true; + } catch (Error error) { + critical("Failed to create face_detect_proxy for face detect: %s", error.message); + AppWindow.error_message(ERROR_MESSAGE); + + return false; + } + } +#endif + + public static void init(string net_file) { + FaceDetect.net_file = net_file; +#if FACEDETECT_BUS_PRIVATE + var address = "unix:tmpdir=%s".printf(Environment.get_tmp_dir()); + var observer = new DBusAuthObserver(); + observer.authorize_authenticated_peer.connect((stream, credentials) => { + debug("Observer trying to authorize for %s", credentials.to_string()); + if (credentials == null) + return false; + + try { + if (!credentials.is_same_user(new Credentials())) + return false; + return true; + } catch (Error error) { + return false; + } + }); + + try { + dbus_server = new GLib.DBusServer.sync(address, DBusServerFlags.NONE, DBus.generate_guid(), observer, null); + dbus_server.new_connection.connect(on_new_connection); + dbus_server.start(); + process = new Subprocess(SubprocessFlags.NONE, AppDirs.get_facedetect_bin().get_path(), + "--address=" + dbus_server.get_client_address()); + + } catch (Error error) { + warning("Failed to create private DBus server: %s", error.message); + AppWindow.error_message(ERROR_MESSAGE); + } +#else + Bus.watch_name(BusType.SESSION, DBUS_NAME, BusNameWatcherFlags.NONE, + create_face_detect_proxy, interface_gone); +#endif + } + +} diff --git a/src/faces/FaceLocation.vala b/src/faces/FaceLocation.vala index e143b2e..0f4e383 100644 --- a/src/faces/FaceLocation.vala +++ b/src/faces/FaceLocation.vala @@ -4,6 +4,11 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ +// Encapsulate geometry and pixels of a Face +public struct FaceLocationData { + public string geometry; + public string vec; +} public class FaceLocation : Object { private static Gee.Map<FaceID?, Gee.Map<PhotoID?, FaceLocation>> face_photos_map; @@ -12,17 +17,17 @@ public class FaceLocation : Object { private FaceLocationID face_location_id; private FaceID face_id; private PhotoID photo_id; - private string geometry; - + private FaceLocationData face_data; + private FaceLocation(FaceLocationID face_location_id, FaceID face_id, PhotoID photo_id, - string geometry) { + FaceLocationData face_data) { this.face_location_id = face_location_id; this.face_id = face_id; this.photo_id = photo_id; - this.geometry = geometry; + this.face_data = face_data; } - public static FaceLocation create(FaceID face_id, PhotoID photo_id, string geometry) { + public static FaceLocation create(FaceID face_id, PhotoID photo_id, FaceLocationData face_data) { FaceLocation face_location = null; // Test if that FaceLocation already exists (that face in that photo) ... @@ -33,12 +38,11 @@ public class FaceLocation : Object { face_location = faces_map.get(face_id); - if (face_location.get_serialized_geometry() != geometry) { - face_location.set_serialized_geometry(geometry); + if (face_location.get_serialized_geometry() != face_data.geometry) { + face_location.set_face_data(face_data); try { - FaceLocationTable.get_instance().update_face_location_serialized_geometry( - face_location); + FaceLocationTable.get_instance().update_face_location_face_data(face_location); } catch (DatabaseError err) { AppWindow.database_error(err); } @@ -51,7 +55,7 @@ public class FaceLocation : Object { try { face_location = FaceLocation.add_from_row( - FaceLocationTable.get_instance().add(face_id, photo_id, geometry)); + FaceLocationTable.get_instance().add(face_id, photo_id, face_data.geometry, face_data.vec)); } catch (DatabaseError err) { AppWindow.database_error(err); } @@ -84,7 +88,8 @@ public class FaceLocation : Object { public static FaceLocation add_from_row(FaceLocationRow row) { FaceLocation face_location = - new FaceLocation(row.face_location_id, row.face_id, row.photo_id, row.geometry); + new FaceLocation(row.face_location_id, row.face_id, row.photo_id, + { row.geometry, row.vec }); Gee.Map<PhotoID?, FaceLocation> photos_map = face_photos_map.get(row.face_id); if (photos_map == null) {photos_map = new Gee.HashMap<PhotoID?, FaceLocation> @@ -196,10 +201,22 @@ public class FaceLocation : Object { } public string get_serialized_geometry() { - return geometry; + return face_data.geometry; + } + + public string get_serialized_vec() { + return face_data.vec; + } + + public FaceLocationData get_face_data() { + return face_data; + } + + public PhotoID get_photo_id() { + return photo_id; } - private void set_serialized_geometry(string geometry) { - this.geometry = geometry; + private void set_face_data(FaceLocationData face_data) { + this.face_data = face_data; } } diff --git a/src/faces/FacePage.vala b/src/faces/FacePage.vala index f2512d5..1766b91 100644 --- a/src/faces/FacePage.vala +++ b/src/faces/FacePage.vala @@ -44,6 +44,7 @@ public class FacePage : CollectionPage { { "DeleteFace", on_delete_face }, { "RenameFace", on_rename_face }, { "RemoveFaceFromPhotos", on_remove_face_from_photos }, + { "SetFaceRefFromPhoto", on_set_face_ref }, { "DeleteFaceSidebar", on_delete_face }, { "RenameFaceSidebar", on_rename_face } }; @@ -74,6 +75,7 @@ public class FacePage : CollectionPage { menuFaces.add_menu_item(Resources.remove_face_from_photos_menu(this.face.get_name(), get_view().get_count()), "RemoveFaceFromPhotos", "<Primary>r"); menuFaces.add_menu_item(Resources.rename_face_menu(this.face.get_name()), "RenameFace", "<Primary>e"); + menuFaces.add_menu_item(Resources.set_face_from_photo_menu(this.face.get_name()), "SetFaceRefFromPhoto", null); menuFaces.add_menu_item(Resources.delete_face_menu(this.face.get_name()), "DeleteFace", "<Primary>t"); return menuFaces; @@ -102,6 +104,11 @@ public class FacePage : CollectionPage { null, selected_count > 0); + set_action_details("SetFaceRefFromPhoto", + Resources.set_face_from_photo_menu(face.get_name()), + null, + selected_count == 1); + base.update_actions(selected_count, count); } @@ -120,4 +127,11 @@ public class FacePage : CollectionPage { (Gee.Collection<MediaSource>) get_view().get_selected_sources())); } } + + private void on_set_face_ref() { + if (get_view().get_selected_count() == 1) { + get_command_manager().execute(new SetFaceRefCommand(face, + (MediaSource) get_view().get_selected_at(0).get_source())); + } + } } diff --git a/src/faces/FaceShape.vala b/src/faces/FaceShape.vala index 1ff01fd..f90f254 100644 --- a/src/faces/FaceShape.vala +++ b/src/faces/FaceShape.vala @@ -18,14 +18,16 @@ public abstract class FaceShape : Object { protected Gdk.CursorType current_cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER; protected EditingTools.PhotoCanvas canvas; protected string serialized = null; + protected double[] face_vec; private bool editable = true; private bool visible = true; private bool known = true; + private double guess = 0.0; private weak FacesTool.FaceWidget face_widget = null; - protected FaceShape(EditingTools.PhotoCanvas canvas) { + protected FaceShape(EditingTools.PhotoCanvas canvas, double[] vec) { this.canvas = canvas; this.canvas.new_surface.connect(prepare_ctx); @@ -37,19 +39,21 @@ public abstract class FaceShape : Object { face_window.show_all(); face_window.hide(); - this.canvas.get_drawing_window().set_cursor(new Gdk.Cursor(current_cursor_type)); + this.face_vec = vec; + this.canvas.set_cursor(current_cursor_type); } ~FaceShape() { - if (visible) + if (visible) { erase(); + } face_window.destroy(); canvas.new_surface.disconnect(prepare_ctx); // make sure the cursor isn't set to a modify indicator - canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + canvas.set_cursor(Gdk.CursorType.LEFT_PTR); } public static FaceShape from_serialized(EditingTools.PhotoCanvas canvas, string serialized) @@ -88,7 +92,15 @@ public abstract class FaceShape : Object { public bool get_known() { return known; } + + public void set_guess(double guess) { + this.guess = guess; + } + public double get_guess() { + return guess; + } + public void set_widget(FacesTool.FaceWidget face_widget) { this.face_widget = face_widget; } @@ -107,7 +119,7 @@ public abstract class FaceShape : Object { face_window.hide(); // make sure the cursor isn't set to a modify indicator - canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + canvas.set_cursor(Gdk.CursorType.LEFT_PTR); } public void show() { @@ -160,7 +172,7 @@ public abstract class FaceShape : Object { return true; } - public abstract string serialize(); + public abstract string serialize(bool geometry_only = false); public abstract void update_face_window_position(); public abstract void prepare_ctx(Cairo.Context ctx, Dimensions dim); public abstract void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled); @@ -170,6 +182,7 @@ public abstract class FaceShape : Object { public abstract bool cursor_is_over(int x, int y); public abstract bool equals(FaceShape face_shape); public abstract double get_distance(int x, int y); + public abstract double[] get_face_vec(); protected abstract void paint(); protected abstract void erase(); @@ -186,13 +199,17 @@ public class FaceRectangle : FaceShape { private BoxLocation in_manipulation = BoxLocation.OUTSIDE; private Cairo.Context wide_black_ctx = null; private Cairo.Context wide_white_ctx = null; - private Cairo.Context thin_white_ctx = null; private int last_grab_x = -1; private int last_grab_y = -1; public FaceRectangle(EditingTools.PhotoCanvas canvas, int x, int y, - int half_width = NULL_SIZE, int half_height = NULL_SIZE) { - base(canvas); + int half_width = NULL_SIZE, int half_height = NULL_SIZE, double[] vec = {}) { + double[] int_vec; + if (vec.length == 0) + int_vec = create_empty_vec(); + else + int_vec = vec; + base(canvas, int_vec); Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position(); x -= scaled_pixbuf_pos.x; @@ -219,6 +236,14 @@ public class FaceRectangle : FaceShape { if (!is_editable()) erase_label(); } + + public static double[] create_empty_vec() { + double[] empty_vec = new double[128]; + for (int i = 0; i < 128; i++) { + empty_vec[i] = 0; + } + return empty_vec; + } public static new FaceRectangle from_serialized(EditingTools.PhotoCanvas canvas, string[] args) throws FaceShapeError { @@ -226,7 +251,9 @@ public class FaceRectangle : FaceShape { Photo photo = canvas.get_photo(); Dimensions raw_dim = photo.get_raw_dimensions(); - + + // 1, 2 is the center of the rectangle, 3, 4 is the half width / height of the rectangle, + // normalized int x = (int) (raw_dim.width * double.parse(args[1])); int y = (int) (raw_dim.height * double.parse(args[2])); int half_width = (int) (raw_dim.width * double.parse(args[3])); @@ -265,9 +292,21 @@ public class FaceRectangle : FaceShape { if (half_width < FACE_MIN_SIZE || half_height < FACE_MIN_SIZE) throw new FaceShapeError.CANT_CREATE("FaceShape is out of cropped photo area"); - + + string[] vec_str; + if (args.length == 6) + vec_str = args[5].split(","); + else + vec_str = {}; + double[] vec = new double[128]; + for (int i = 0; i < 128; i++) { + if (vec_str.length > i) + vec[i] = double.parse(vec_str[i]); + else + vec[i] = 0; + } return new FaceRectangle(canvas, box.left + half_width, box.top + half_height, - half_width, half_height); + half_width, half_height, vec); } public override void update_face_window_position() { @@ -283,32 +322,35 @@ public class FaceRectangle : FaceShape { face_window.get_allocation(out face_window_alloc); - x += scaled_pixbuf_pos.x + box.left + ((box.get_width() - face_window_alloc.width) >> 1); - y += scaled_pixbuf_pos.y + box.bottom + FACE_WINDOW_MARGIN; + var scale = Application.get_scale(); + var left = (int)Math.lround((scaled_pixbuf_pos.x + box.left) / scale); + var width = (int)Math.lround(box.get_width() / scale); + var top = (int)Math.lround((scaled_pixbuf_pos.y + box.bottom) / scale); + x += (left + ((width - face_window_alloc.width) >> 1)); + y += top + FACE_WINDOW_MARGIN; face_window.move(x, y); } protected override void paint() { + // The box is in image coordinates. Need to scale down to device coordinates canvas.draw_box(wide_black_ctx, box); canvas.draw_box(wide_white_ctx, box.get_reduced(1)); canvas.draw_box(wide_white_ctx, box.get_reduced(2)); - canvas.invalidate_area(box); + //canvas.invalidate_area(box); if (!is_editable()) paint_label(); } protected override void erase() { - canvas.erase_box(box); - canvas.erase_box(box.get_reduced(1)); - canvas.erase_box(box.get_reduced(2)); - canvas.invalidate_area(box); if (!is_editable()) erase_label(); + +// canvas.repaint(); } private void paint_label() { @@ -317,6 +359,9 @@ public class FaceRectangle : FaceShape { ctx.save(); + ctx.select_font_face("Sans", Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL); + ctx.set_font_size(10.0 * Application.get_scale()); + Cairo.TextExtents text_extents = Cairo.TextExtents(); ctx.text_extents(get_name(), out text_extents); @@ -368,7 +413,7 @@ public class FaceRectangle : FaceShape { ctx.restore(); } - public override string serialize() { + public override string serialize(bool geometry_only = false) { if (serialized != null) return serialized; @@ -378,10 +423,15 @@ public class FaceRectangle : FaceShape { double half_height; get_geometry(out x, out y, out half_width, out half_height); - - serialized = "%s;%s;%s;%s;%s".printf(SHAPE_TYPE, x.to_string(), + serialized = "%s;%s;%s;%s;%s;".printf(SHAPE_TYPE, x.to_string(), y.to_string(), half_width.to_string(), half_height.to_string()); - + if (!geometry_only) { + string face_vec_str = ""; + foreach (var d in face_vec[0:-2]) + face_vec_str += d.to_string() + ","; + face_vec_str += face_vec[-1].to_string(); + serialized += face_vec_str; + } return serialized; } @@ -425,23 +475,23 @@ public class FaceRectangle : FaceShape { half_width = (width_right_end - width_left_end) / 2; half_height = (height_bottom_end - height_top_end) / 2; } + + public override double[] get_face_vec() { + return face_vec; + } public override bool equals(FaceShape face_shape) { - return serialize() == face_shape.serialize(); + return serialize(true) == face_shape.serialize(true); } public override void prepare_ctx(Cairo.Context ctx, Dimensions dim) { wide_black_ctx = new Cairo.Context(ctx.get_target()); set_source_color_from_string(wide_black_ctx, "#000"); - wide_black_ctx.set_line_width(1); + wide_black_ctx.set_line_width(1 * Application.get_scale()); wide_white_ctx = new Cairo.Context(ctx.get_target()); - set_source_color_from_string(wide_black_ctx, "#FFF"); - wide_white_ctx.set_line_width(1); - - thin_white_ctx = new Cairo.Context(ctx.get_target()); - set_source_color_from_string(wide_black_ctx, "#FFF"); - thin_white_ctx.set_line_width(0.5); + set_source_color_from_string(wide_white_ctx, "#FFF"); + wide_white_ctx.set_line_width(1 * Application.get_scale()); } private bool on_canvas_manipulation(int x, int y) { @@ -620,17 +670,20 @@ public class FaceRectangle : FaceShape { Box new_box = Box(left, top, right, bottom); if (!box.equals(new_box)) { - erase(); + canvas.invalidate_area(box); if (in_manipulation != BoxLocation.INSIDE) check_resized_box(new_box); box = new_box; paint(); + canvas.invalidate_area(new_box); } if (is_editable()) update_face_window_position(); + + canvas.repaint(); serialized = null; @@ -698,8 +751,7 @@ public class FaceRectangle : FaceShape { } if (cursor_type != current_cursor_type) { - Gdk.Cursor cursor = new Gdk.Cursor(cursor_type); - canvas.get_drawing_window().set_cursor(cursor); + canvas.set_cursor(cursor_type); current_cursor_type = cursor_type; } } diff --git a/src/faces/FacesTool.vala b/src/faces/FacesTool.vala index 9803787..d399b38 100644 --- a/src/faces/FacesTool.vala +++ b/src/faces/FacesTool.vala @@ -119,7 +119,7 @@ public class FacesTool : EditingTools.EditingTool { private EditingPhase editing_phase = EditingPhase.NOT_EDITING; private Gtk.Box help_layout = null; private Gtk.Box response_layout = null; - private Gtk.HSeparator buttons_text_separator = null; + private Gtk.Separator buttons_text_separator = null; private Gtk.Label help_text = null; private Gtk.Box face_widgets_layout = null; private Gtk.Box layout = null; @@ -163,7 +163,7 @@ public class FacesTool : EditingTools.EditingTool { layout = new Gtk.Box(Gtk.Orientation.VERTICAL, CONTROL_SPACING); layout.pack_start(face_widgets_layout, false); layout.pack_start(help_layout, false); - layout.pack_start(new Gtk.HSeparator(), false); + layout.pack_start(new Gtk.Separator(Gtk.Orientation.HORIZONTAL), false); layout.pack_start(response_layout, false); add(layout); @@ -178,7 +178,7 @@ public class FacesTool : EditingTools.EditingTool { case EditingPhase.CLICK_TO_EDIT: assert(face_shape != null); - help_text.set_markup(Markup.printf_escaped(_("Click to edit face <i>%s</i>"), + help_text.set_markup(Markup.printf_escaped(_("Click to edit face “%s”"), face_shape.get_name())); break; @@ -254,7 +254,7 @@ public class FacesTool : EditingTools.EditingTool { face_widgets_layout.pack_start(event_box, false); if (buttons_text_separator == null) { - buttons_text_separator = new Gtk.HSeparator(); + buttons_text_separator = new Gtk.Separator(Gtk.Orientation.HORIZONTAL); face_widgets_layout.pack_end(buttons_text_separator, false); } @@ -315,121 +315,49 @@ public class FacesTool : EditingTools.EditingTool { private class FaceDetectionJob : BackgroundJob { private Gee.Queue<string> faces = null; private string image_path; - private string output; - public SpawnError? spawnError; + private float scale; + public string? spawnError; - public FaceDetectionJob(FacesToolWindow owner, string image_path, + public FaceDetectionJob(FacesToolWindow owner, string image_path, float scale, CompletionCallback completion_callback, Cancellable cancellable, CancellationCallback cancellation_callback) { base(owner, completion_callback, cancellable, cancellation_callback); this.image_path = image_path; + this.scale = scale; } public override void execute() { + if (!FaceDetect.connected) { + spawnError = "Face detect process not connected!\n"; + return; + } + FaceRect[] rects; try { - string[] argv = { - AppDirs.get_facedetect_bin().get_path(), - "--cascade=" + AppDirs.get_haarcascade_file().get_path(), - "--scale=1.2", - image_path - }; - Process.spawn_sync(null, argv, null, SpawnFlags.STDERR_TO_DEV_NULL, null, out output); - - } catch (SpawnError e) { - spawnError = e; - critical(e.message); - + rects = FaceDetect.face_detect_proxy.detect_faces(image_path, + AppDirs.get_haarcascade_file().get_path(), scale, true); + } catch(Error e) { + spawnError = "DBus error: " + e.message + "!\n"; return; } - faces = new Gee.PriorityQueue<string>(); - string[] lines = output.split("\n"); - foreach (string line in lines) { - if (line.length == 0) - continue; - - debug("shotwell-facedetect: %s", line); - - string[] type_and_serialized = line.split(";"); - if (type_and_serialized.length != 2) { - // Pass on external helper log output as our debug log - continue; - } - - switch (type_and_serialized[0]) { - case "face": - StringBuilder serialized_geometry = new StringBuilder(); - serialized_geometry.append(FaceRectangle.SHAPE_TYPE); - serialized_geometry.append(";"); - serialized_geometry.append(parse_serialized_geometry(type_and_serialized[1])); - - faces.add(serialized_geometry.str); - break; - - case "warning": - warning("%s\n", type_and_serialized[1]); - break; - - case "error": - critical("%s\n", type_and_serialized[1]); - assert_not_reached(); - - default: - break; + for (int i = 0; i < rects.length; i++) { + double rect_x, rect_y, rect_w, rect_h; + string face_vec_str = ""; + rect_w = rects[i].width / 2; + rect_h = rects[i].height / 2; + rect_x = rects[i].x + rect_w; + rect_y = rects[i].y + rect_h; + if (rects[i].vec != null) { + foreach (var d in rects[i].vec) { face_vec_str += d.to_string() + ","; } } + string serialized = "%s;%f;%f;%f;%f;%s".printf(FaceRectangle.SHAPE_TYPE, + rect_x, rect_y, rect_w, rect_h, + face_vec_str); + faces.add(serialized); } } - private string parse_serialized_geometry(string serialized_geometry) { - string[] serialized_geometry_pieces = serialized_geometry.split("&"); - if (serialized_geometry_pieces.length != 4) { - critical("Wrong serialized line in face detection program output."); - assert_not_reached(); - } - - double x = 0; - double y = 0; - double width = 0; - double height = 0; - foreach (string piece in serialized_geometry_pieces) { - - string[] name_and_value = piece.split("="); - if (name_and_value.length != 2) { - critical("Wrong serialized line in face detection program output."); - assert_not_reached(); - } - - switch (name_and_value[0]) { - case "x": - x = name_and_value[1].to_double(); - break; - - case "y": - y = name_and_value[1].to_double(); - break; - - case "width": - width = name_and_value[1].to_double(); - break; - - case "height": - height = name_and_value[1].to_double(); - break; - - default: - critical("Wrong serialized line in face detection program output."); - assert_not_reached(); - } - } - - double half_width = width / 2; - double half_height = height / 2; - - return "%s;%s;%s;%s".printf((x + half_width).to_string(), (y + half_height).to_string(), - half_width.to_string(), half_height.to_string()); - } - public string? get_next() { if (faces == null) return null; @@ -450,6 +378,7 @@ public class FacesTool : EditingTools.EditingTool { private Workers workers; private FaceShape editing_face_shape = null; private FacesToolWindow faces_tool_window = null; + private const int FACE_DETECT_MAX_WIDTH = 1200; private FacesTool() { base("FacesTool"); @@ -481,8 +410,10 @@ public class FacesTool : EditingTools.EditingTool { foreach (Gee.Map.Entry<FaceID?, FaceLocation> entry in face_locations.entries) { FaceShape new_face_shape; string serialized_geometry = entry.value.get_serialized_geometry(); + string serialized_vec = entry.value.get_serialized_vec(); + string face_shape_str = serialized_geometry + ";" + serialized_vec; try { - new_face_shape = FaceShape.from_serialized(canvas, serialized_geometry); + new_face_shape = FaceShape.from_serialized(canvas, face_shape_str); } catch (FaceShapeError e) { if (e is FaceShapeError.CANT_CREATE) continue; @@ -502,9 +433,12 @@ public class FacesTool : EditingTools.EditingTool { face_detection_cancellable = new Cancellable(); workers = new Workers(1, false); + Dimensions dimensions = canvas.get_photo().get_dimensions(); + float scale_factor = (float)dimensions.width / FACE_DETECT_MAX_WIDTH; face_detection = new FaceDetectionJob(faces_tool_window, - canvas.get_photo().get_file().get_path(), on_faces_detected, - face_detection_cancellable, on_detection_cancelled); + canvas.get_photo().get_file().get_path(), scale_factor, + on_faces_detected, + face_detection_cancellable, on_detection_cancelled); bind_window_handlers(); @@ -591,6 +525,10 @@ public class FacesTool : EditingTools.EditingTool { } public override void on_left_click(int x, int y) { + var scale = Application.get_scale(); + x = (int) Math.lround(x * scale); + y = (int) Math.lround(y * scale); + if (editing_face_shape != null && editing_face_shape.on_left_click(x, y)) return; @@ -607,6 +545,10 @@ public class FacesTool : EditingTools.EditingTool { } public override void on_left_released(int x, int y) { + var scale = Application.get_scale(); + x = (int) Math.lround(x * scale); + y = (int) Math.lround(y * scale); + if (editing_face_shape != null) { editing_face_shape.on_left_released(x, y); @@ -616,6 +558,10 @@ public class FacesTool : EditingTools.EditingTool { } public override void on_motion(int x, int y, Gdk.ModifierType mask) { + var scale = Application.get_scale(); + x = (int) Math.lround(x * scale); + y = (int) Math.lround(y * scale); + if (editing_face_shape == null) { FaceShape to_show = null; double distance = 0; @@ -784,14 +730,21 @@ public class FacesTool : EditingTools.EditingTool { if (face_shapes == null) return; - Gee.Map<Face, string> new_faces = new Gee.HashMap<Face, string>(); + Gee.Map<Face, FaceLocationData?> new_faces = new Gee.HashMap<Face, FaceLocationData?>(); foreach (FaceShape face_shape in face_shapes.values) { if (!face_shape.get_known()) continue; Face new_face = Face.for_name(face_shape.get_name()); - - new_faces.set(new_face, face_shape.serialize()); + string[] face_string = face_shape.serialize().split(";"); + string face_vec_str, face_geometry; + face_geometry = string.joinv(";", face_string[0:5]); + face_vec_str = face_string[5]; + FaceLocationData face_data = + { + face_geometry, face_vec_str + }; + new_faces.set(new_face, face_data); } ModifyFacesCommand command = new ModifyFacesCommand(canvas.get_photo(), new_faces); @@ -848,7 +801,7 @@ public class FacesTool : EditingTools.EditingTool { private void delete_face(string face_name) { face_shapes.unset(face_name); - // It is posible to have two visible faces at the same time, this happens + // It is possible to have two visible faces at the same time, this happens // if you are editing one face and you move the pointer around the // FaceWidgets area in FacesToolWindow. And you can delete one of that // faces, so the other visible face must be repainted. @@ -908,7 +861,6 @@ public class FacesTool : EditingTools.EditingTool { private void detect_faces() { faces_tool_window.detection_button.set_sensitive(false); faces_tool_window.set_editing_phase(EditingPhase.DETECTING_FACES); - workers.enqueue(face_detection); } @@ -945,19 +897,71 @@ public class FacesTool : EditingTools.EditingTool { continue; c++; + // Reference faces to match with + Face? guess = get_face_match(face_shape, 0.7); - face_shape.set_name("Unknown face #%d".printf(c)); - face_shape.set_known(false); + if (guess == null) { + face_shape.set_name("Unknown face #%d".printf(c)); + face_shape.set_known(false); + } else { + string name_str; + name_str = "%s (%0.2f%%)".printf(guess.get_name(), face_shape.get_guess() * 100); + face_shape.set_name(name_str); + face_shape.set_known(true); + } add_face(face_shape); } } + private double dot_product(double[] vec1, double[] vec2) { + if (vec1.length != vec2.length) { + return 0; + } + + double ret = 0; + for (var i = 0; i < vec1.length; i++) { + ret += vec1[i] * vec2[i]; + } + return ret; + } + + private Face? get_face_match(FaceShape face_shape, double threshold) { + Gee.List<FaceLocationRow?> face_vecs; + try { + Gee.List<FaceRow?> face_rows = FaceTable.get_instance().get_ref_rows(); + face_vecs = FaceLocationTable.get_instance().get_face_ref_vecs(face_rows); + } catch(DatabaseError err) { + warning("Cannot get reference faces from DB"); + return null; + } + FaceID? guess_id = null; + double max_product = threshold; + foreach (var row in face_vecs) { + string[] vec_str = row.vec.split(","); + double[] vec = {}; + foreach (var d in vec_str) vec += double.parse(d); + double product = dot_product(face_shape.get_face_vec(), vec[0:128]); + if (product > max_product) { + max_product = product; + guess_id = row.face_id; + } + } + + Face? face = null; + if (guess_id != null) { + face = Face.global.fetch(guess_id); + face_shape.set_guess(max_product); + assert(face != null); + } + return face; + } + private void on_faces_detected() { face_detection_cancellable.reset(); if (face_detection.spawnError != null){ string spawnErrorMessage = _("Error trying to spawn face detection program:\n"); - AppWindow.error_message(spawnErrorMessage + face_detection.spawnError.message + "\n"); + AppWindow.error_message(spawnErrorMessage + face_detection.spawnError + "\n"); faces_tool_window.set_editing_phase(EditingPhase.DETECTING_FACES_FINISHED); } else pick_faces_from_autodetected(); diff --git a/src/import-roll/ImportRollBranch.vala b/src/import-roll/ImportRollBranch.vala index 32337cc..0c582ac 100644 --- a/src/import-roll/ImportRollBranch.vala +++ b/src/import-roll/ImportRollBranch.vala @@ -6,8 +6,7 @@ public class ImportRoll.Branch : Sidebar.Branch { Sidebar.Branch.Options.HIDE_IF_EMPTY, ImportRoll.Branch.comparator); - this.entries = new Gee.HashMap<int64?, ImportRoll.SidebarEntry>((Gee.HashDataFunc<int64?>)GLib.int64_hash, - (Gee.EqualDataFunc<int64?>)GLib.int64_equal); + this.entries = new Gee.HashMap<int64?, ImportRoll.SidebarEntry>(int64_hash, int64_equal); foreach (var source in MediaCollectionRegistry.get_instance().get_all()) { on_import_rolls_altered(source); diff --git a/src/library/BackgroundProgressBar.vala b/src/library/BackgroundProgressBar.vala new file mode 100644 index 0000000..8ad7185 --- /dev/null +++ b/src/library/BackgroundProgressBar.vala @@ -0,0 +1,109 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +internal class BackgroundProgressBar : Gtk.ProgressBar { + public enum Priority { + NONE = 0, + STARTUP_SCAN = 35, + REALTIME_UPDATE = 40, + REALTIME_IMPORT = 50, + METADATA_WRITER = 30 + } + + public bool should_be_visible { get; private set; default = false; } + +#if UNITY_SUPPORT + // UnityProgressBar: init + private UnityProgressBar uniprobar = UnityProgressBar.get_instance(); +#endif + + private const int PULSE_MSEC = 250; + + public BackgroundProgressBar() { + Object(show_text: true); + } + + private Priority current_priority = Priority.NONE; + private uint pulse_id = 0; + + public void start(string label, Priority priority) { + if (priority < current_priority) + return; + + stop(priority, false); + + current_priority = priority; + set_text(label); + pulse(); + should_be_visible = true; + pulse_id = Timeout.add(PULSE_MSEC, on_pulse_timeout); + } + + public void stop(Priority priority, bool clear) { + if (priority < current_priority) + return; + + if (pulse_id != 0) { + Source.remove(pulse_id); + pulse_id = 0; + } + + if (clear) + this.clear(priority); + } + + public bool update(string label, Priority priority, double count, double total) { + if (priority < current_priority) + return false; + + stop(priority, false); + + if (count <= 0.0 || total <= 0.0 || count >= total) { + clear(priority); + + return false; + } + + current_priority = priority; + + double fraction = count / total; + set_fraction(fraction); + set_text(_("%s (%d%%)").printf(label, (int) (fraction * 100.0))); + should_be_visible = true; + +#if UNITY_SUPPORT + // UnityProgressBar: try to draw & set progress + uniprobar.set_visible(true); + uniprobar.set_progress(fraction); +#endif + + return true; + } + + public void clear(Priority priority) { + if (priority < current_priority) + return; + + stop(priority, false); + + current_priority = 0; + + set_fraction(0.0); + set_text(""); + should_be_visible = false; + +#if UNITY_SUPPORT + // UnityProgressBar: reset + uniprobar.reset(); +#endif + } + + private bool on_pulse_timeout() { + pulse(); + + return true; + } +} diff --git a/src/library/LibraryWindow.vala b/src/library/LibraryWindow.vala index 53b3a7b..849ae2e 100644 --- a/src/library/LibraryWindow.vala +++ b/src/library/LibraryWindow.vala @@ -24,18 +24,10 @@ public class LibraryWindow : AppWindow { "mtp:" }; - private const int BACKGROUND_PROGRESS_PULSE_MSEC = 250; // If we're not operating on at least this many files, don't display the progress // bar at all; otherwise, it'll go by too quickly, giving the appearance of a glitch. - const int MIN_PROGRESS_BAR_FILES = 20; - - // these values reflect the priority various background operations have when reporting - // progress to the LibraryWindow progress bar ... higher values give priority to those reports - private const int STARTUP_SCAN_PROGRESS_PRIORITY = 35; - private const int REALTIME_UPDATE_PROGRESS_PRIORITY = 40; - private const int REALTIME_IMPORT_PROGRESS_PRIORITY = 50; - private const int METADATA_WRITER_PROGRESS_PRIORITY = 30; + const int MIN_PROGRESS_BAR_FILES = 1; // This lists the order of the toplevel items in the sidebar. New toplevel items should be // added here in the position they should appear in the sidebar. To re-order, simply move @@ -136,11 +128,12 @@ public class LibraryWindow : AppWindow { private SearchFilterToolbar search_toolbar; private Gtk.Box top_section = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); - private Gtk.Frame background_progress_frame = new Gtk.Frame(null); - private Gtk.ProgressBar background_progress_bar = new Gtk.ProgressBar(); - private bool background_progress_displayed = false; + private Gtk.Revealer background_progress_frame = new Gtk.Revealer(); + private BackgroundProgressBar background_progress_bar = new BackgroundProgressBar(); - private BasicProperties basic_properties = new BasicProperties(); + // Instantiate later in constructor because the map support loads its icons in there and we need + // to have the global app instance available for that + private BasicProperties basic_properties; private ExtendedProperties extended_properties = new ExtendedProperties(); private Gtk.Revealer extended_properties_revealer = new Gtk.Revealer(); @@ -149,14 +142,6 @@ public class LibraryWindow : AppWindow { private Gtk.Box right_vbox; private Gtk.Revealer toolbar_revealer = new Gtk.Revealer (); - private int current_progress_priority = 0; - private uint background_progress_pulse_id = 0; - -#if UNITY_SUPPORT - //UnityProgressBar: init - UnityProgressBar uniprobar = UnityProgressBar.get_instance(); -#endif - public LibraryWindow(ProgressMonitor progress_monitor) { base(); @@ -180,11 +165,12 @@ public class LibraryWindow : AppWindow { properties_scheduler = new OneShotScheduler("LibraryWindow properties", on_update_properties_now); - + // setup search bar and add its accelerators to the window search_toolbar = new SearchFilterToolbar(search_actions); // create the main layout & start at the Library page + basic_properties = new BasicProperties(); create_layout(library_branch.photos_entry.get_page()); // settings that should persist between sessions @@ -217,8 +203,6 @@ public class LibraryWindow : AppWindow { CameraTable.get_instance().camera_added.connect(on_camera_added); - background_progress_bar.set_show_text(true); - // Need to re-install F8 here as it will overwrite the binding created // by the menu const string[] accels = { "<Primary>f", "F8", null }; @@ -282,7 +266,7 @@ public class LibraryWindow : AppWindow { { "CommonFind", on_find }, { "CommonNewSearch", on_new_search }, - // Toogle actions + // Toggle actions { "CommonDisplayBasicProperties", on_action_toggle, null, "false", on_display_basic_properties }, { "CommonDisplayExtendedProperties", on_action_toggle, null, "false", on_display_extended_properties }, @@ -770,7 +754,7 @@ public class LibraryWindow : AppWindow { if (AppDirs.get_import_dir().get_path() == Environment.get_home_dir() && notify_library_is_home_dir) { Gtk.ResponseType response = AppWindow.affirm_cancel_question( _("Shotwell is configured to import photos to your home directory.\n" + - "We recommend changing this in <span weight=\"bold\">Edit %s Preferences</span>.\n" + + "We recommend changing this in Edit %s Preferences.\n" + "Do you want to continue importing photos?").printf("▸"), _("_Import"), _("Library Location"), AppWindow.get_instance()); @@ -949,15 +933,9 @@ public class LibraryWindow : AppWindow { return; ImportPage page = (ImportPage) entry.get_page(); - File uri_file = File.new_for_uri(camera.uri); // find the VFS mount point - Mount mount = null; - try { - mount = uri_file.find_enclosing_mount(null); - } catch (Error err) { - // error means not mounted - } + var mount = camera.get_mount(); // don't unmount mass storage cameras, as they are then unavailable to gPhoto if (mount != null && !camera.uri.has_prefix("file://")) { @@ -1012,135 +990,40 @@ public class LibraryWindow : AppWindow { sort_events_action.change_state (event_sort_val); } - - private void start_pulse_background_progress_bar(string label, int priority) { - if (priority < current_progress_priority) - return; - - stop_pulse_background_progress_bar(priority, false); - - current_progress_priority = priority; - - background_progress_bar.set_text(label); - background_progress_bar.pulse(); - show_background_progress_bar(); - - background_progress_pulse_id = Timeout.add(BACKGROUND_PROGRESS_PULSE_MSEC, - on_pulse_background_progress_bar); - } - - private bool on_pulse_background_progress_bar() { - background_progress_bar.pulse(); - - return true; - } - - private void stop_pulse_background_progress_bar(int priority, bool clear) { - if (priority < current_progress_priority) - return; - - if (background_progress_pulse_id != 0) { - Source.remove(background_progress_pulse_id); - background_progress_pulse_id = 0; - } - - if (clear) - clear_background_progress_bar(priority); - } - - private void update_background_progress_bar(string label, int priority, double count, - double total) { - if (priority < current_progress_priority) - return; - - stop_pulse_background_progress_bar(priority, false); - - if (count <= 0.0 || total <= 0.0 || count >= total) { - clear_background_progress_bar(priority); - - return; - } - - current_progress_priority = priority; - - double fraction = count / total; - background_progress_bar.set_fraction(fraction); - background_progress_bar.set_text(_("%s (%d%%)").printf(label, (int) (fraction * 100.0))); - show_background_progress_bar(); - -#if UNITY_SUPPORT - //UnityProgressBar: try to draw & set progress - uniprobar.set_visible(true); - uniprobar.set_progress(fraction); -#endif - } - - private void clear_background_progress_bar(int priority) { - if (priority < current_progress_priority) - return; - - stop_pulse_background_progress_bar(priority, false); - - current_progress_priority = 0; - - background_progress_bar.set_fraction(0.0); - background_progress_bar.set_text(""); - hide_background_progress_bar(); - -#if UNITY_SUPPORT - //UnityProgressBar: reset - uniprobar.reset(); -#endif - } - - private void show_background_progress_bar() { - if (!background_progress_displayed) { - top_section.pack_end(background_progress_frame, false, false, 0); - background_progress_frame.show_all(); - background_progress_displayed = true; - } - } - - private void hide_background_progress_bar() { - if (background_progress_displayed) { - top_section.remove(background_progress_frame); - background_progress_displayed = false; - } - } - + private void on_library_monitor_discovery_started() { - start_pulse_background_progress_bar(_("Updating library…"), STARTUP_SCAN_PROGRESS_PRIORITY); + background_progress_bar.start(_("Updating library…"), BackgroundProgressBar.Priority.STARTUP_SCAN); } private void on_library_monitor_discovery_completed() { - stop_pulse_background_progress_bar(STARTUP_SCAN_PROGRESS_PRIORITY, true); + background_progress_bar.stop(BackgroundProgressBar.Priority.STARTUP_SCAN, true); } private void on_library_monitor_auto_update_progress(int completed_files, int total_files) { if (total_files < MIN_PROGRESS_BAR_FILES) - clear_background_progress_bar(REALTIME_UPDATE_PROGRESS_PRIORITY); + background_progress_bar.clear(BackgroundProgressBar.Priority.REALTIME_UPDATE); else { - update_background_progress_bar(_("Updating library…"), REALTIME_UPDATE_PROGRESS_PRIORITY, + background_progress_bar.update(_("Updating library…"), BackgroundProgressBar.Priority.REALTIME_UPDATE, completed_files, total_files); } } private void on_library_monitor_auto_import_preparing() { - start_pulse_background_progress_bar(_("Preparing to auto-import photos…"), - REALTIME_IMPORT_PROGRESS_PRIORITY); + background_progress_bar.start(_("Preparing to auto-import photos…"), + BackgroundProgressBar.Priority.REALTIME_UPDATE); } private void on_library_monitor_auto_import_progress(uint64 completed_bytes, uint64 total_bytes) { - update_background_progress_bar(_("Auto-importing photos…"), - REALTIME_IMPORT_PROGRESS_PRIORITY, completed_bytes, total_bytes); + background_progress_bar.update(_("Auto-importing photos…"), + BackgroundProgressBar.Priority.REALTIME_UPDATE, completed_bytes, total_bytes); } private void on_metadata_writer_progress(uint completed, uint total) { if (total < MIN_PROGRESS_BAR_FILES) - clear_background_progress_bar(METADATA_WRITER_PROGRESS_PRIORITY); + background_progress_bar.clear(BackgroundProgressBar.Priority.METADATA_WRITER); else { - update_background_progress_bar(_("Writing metadata to files…"), - METADATA_WRITER_PROGRESS_PRIORITY, completed, total); + background_progress_bar.update(_("Writing metadata to files…"), + BackgroundProgressBar.Priority.METADATA_WRITER, completed, total); } } @@ -1153,17 +1036,22 @@ public class LibraryWindow : AppWindow { background_progress_frame.set_border_width(2); background_progress_frame.add(background_progress_bar); - background_progress_frame.get_style_context().remove_class("frame"); + background_progress_frame.set_transition_type(Gtk.RevealerTransitionType.SLIDE_UP); + background_progress_frame.halign = Gtk.Align.FILL; + background_progress_frame.valign = Gtk.Align.END; + background_progress_frame.vexpand = false; + background_progress_frame.hexpand = true; + background_progress_bar.bind_property("should-be-visible", background_progress_frame, "reveal-child", GLib.BindingFlags.DEFAULT); // pad the bottom frame (properties) basic_properties.halign = Gtk.Align.FILL; basic_properties.valign = Gtk.Align.CENTER; basic_properties.hexpand = true; - basic_properties.vexpand = false; + basic_properties.vexpand = true; basic_properties.margin_top = 10; basic_properties.margin_bottom = 10; basic_properties.margin_start = 6; - basic_properties.margin_end = 0; + basic_properties.margin_end = 6; bottom_frame.add(basic_properties); bottom_frame.get_style_context().remove_class("frame"); @@ -1171,6 +1059,7 @@ public class LibraryWindow : AppWindow { // "attach" the progress bar to the sidebar tree, so the movable ridge is to resize the // top two and the basic information pane top_section.pack_start(scrolled_sidebar, true, true, 0); + top_section.pack_end(background_progress_frame, false, false, 0); sidebar_paned.pack1(top_section, true, false); sidebar_paned.pack2(bottom_frame, false, false); diff --git a/src/library/TrashPage.vala b/src/library/TrashPage.vala index 35dee4e..1e72f07 100644 --- a/src/library/TrashPage.vala +++ b/src/library/TrashPage.vala @@ -105,6 +105,10 @@ public class TrashPage : CheckerboardPage { protected override string get_view_empty_message() { return _("Trash is empty"); } + + protected override string get_view_empty_icon() { + return "user-trash-symbolic"; + } private void on_delete() { remove_from_app((Gee.Collection<MediaSource>) get_view().get_selected_sources(), _("Delete"), diff --git a/src/libshotwell.deps b/src/libshotwell.deps deleted file mode 100644 index 62f5abe..0000000 --- a/src/libshotwell.deps +++ /dev/null @@ -1,20 +0,0 @@ -atk -gdk-3.0 -gee-0.8 -gexiv2 -gio-unix-2.0 -glib-2.0 -gmodule-2.0 -gnome-vfs-2.0 -gstreamer-0.10 -gstreamer-base-0.10 -gtk+-3.0 -gudev-1.0 -libexif -libraw -libgphoto2 -libsoup-2.4 -libxml-2.0 -sqlite3 -unique-1.0 -webkit-1.0 diff --git a/src/main.vala b/src/main.vala index d0cb246..cdc9b27 100644 --- a/src/main.vala +++ b/src/main.vala @@ -72,6 +72,10 @@ void library_exec(string[] mounts) { result.to_string()); break; } + + // Need to set this before anything else, but _after_ setting the profile + var use_dark = Config.Facade.get_instance().get_gtk_theme_variant(); + Gtk.Settings.get_default().gtk_application_prefer_dark_theme = use_dark; if (errormsg != null) { Gtk.MessageDialog dialog = new Gtk.MessageDialog(null, Gtk.DialogFlags.MODAL, @@ -107,7 +111,7 @@ void library_exec(string[] mounts) { progress_dialog.update_display_every(100); progress_dialog.set_minimum_on_screen_time_msec(250); try { - progress_dialog.icon = new Gdk.Pixbuf.from_resource("/org/gnome/Shotwell/icons/shotwell.svg"); + progress_dialog.icon = new Gdk.Pixbuf.from_resource("/org/gnome/Shotwell/icons/hicolor/scalable/org.gnome.Shotwell.svg"); } catch (Error err) { debug("Warning - could not load application icon for loading window: %s", err.message); } @@ -253,13 +257,43 @@ private void report_system_pictures_import(ImportManifest manifest, BatchImportR ImportUI.report_manifest(manifest, true); } +void dump_tags (GExiv2.Metadata metadata, string[] tags) throws Error { + foreach (string tag in tags) { + try { + print("%-64s%s\n", + tag, + metadata.try_get_tag_interpreted_string (tag)); + } catch (Error err) { + print("Failed to get tag %s: %s\n", tag, err.message); + } + } +} + +void dump_metadata (string filename) { + try { + var metadata = new GExiv2.Metadata(); + var file = File.new_for_commandline_arg(filename); + metadata.from_stream (file.read()); + + dump_tags(metadata, metadata.get_exif_tags()); + dump_tags(metadata, metadata.get_iptc_tags()); + dump_tags(metadata, metadata.get_xmp_tags()); + } catch (Error err) { + stderr.printf("Unable to dump metadata for %s: %s\n", filename, err.message); + } +} + void editing_exec(string filename, bool fullscreen) { File initial_file = File.new_for_commandline_arg(filename); // preconfigure units Direct.preconfigure(initial_file); Db.preconfigure(null); - + + // Need to set this before anything else, but _after_ setting the profile + var use_dark = Config.Facade.get_instance().get_gtk_theme_variant(); + Gtk.Settings.get_default().gtk_application_prefer_dark_theme = use_dark; + // initialize units for direct-edit mode try { Direct.app_init(); @@ -278,6 +312,7 @@ void editing_exec(string filename, bool fullscreen) { DirectWindow direct_window = new DirectWindow(initial_file); direct_window.show_all(); + direct_window.maximize(); debug("%lf seconds to Gtk.main()", startup_timer.elapsed()); @@ -299,43 +334,29 @@ void editing_exec(string filename, bool fullscreen) { namespace CommandlineOptions { bool no_startup_progress = false; -string data_dir = null; +string? data_dir = null; bool show_version = false; bool no_runtime_monitoring = false; bool fullscreen = false; - -private OptionEntry[]? entries = null; - -public OptionEntry[] get_options() { - if (entries != null) - return entries; - - OptionEntry datadir = { "datadir", 'd', 0, OptionArg.FILENAME, &data_dir, - _("Path to Shotwell’s private data"), _("DIRECTORY") }; - entries += datadir; - - OptionEntry no_monitoring = { "no-runtime-monitoring", 0, 0, OptionArg.NONE, &no_runtime_monitoring, - _("Do not monitor library directory at runtime for changes"), null }; - entries += no_monitoring; - - OptionEntry no_startup = { "no-startup-progress", 0, 0, OptionArg.NONE, &no_startup_progress, - _("Don’t display startup progress meter"), null }; - entries += no_startup; - - OptionEntry version = { "version", 'V', 0, OptionArg.NONE, &show_version, - _("Show the application’s version"), null }; - entries += version; - - OptionEntry fullscreen = { "fullscreen", 'f', 0, OptionArg.NONE, - &fullscreen, _("Start the application in fullscreen mode"), null }; - entries += fullscreen; - - OptionEntry terminator = { null, 0, 0, 0, null, null, null }; - entries += terminator; - - return entries; -} - +bool show_metadata = false; +string? profile = null; +bool create_profile = false; +bool list_profiles = false; +bool browse_profiles = false; + +const OptionEntry[] entries = { + { "datadir", 'd', 0, OptionArg.FILENAME, ref data_dir, N_("Path to Shotwell’s private data"), N_("DIRECTORY") }, + { "no-runtime-monitoring", 0, 0, OptionArg.NONE, ref no_runtime_monitoring, N_("Do not monitor library directory at runtime for changes"), null }, + { "no-startup-progress", 0, 0, OptionArg.NONE, ref no_startup_progress, N_("Don’t display startup progress meter"), null }, + { "version", 'V', 0, OptionArg.NONE, ref show_version, N_("Show the application’s version") }, + { "fullscreen", 'f', 0, OptionArg.NONE, ref fullscreen, N_("Start the application in fullscreen mode"), null }, + { "show-metadata", 'p', 0, OptionArg.NONE, ref show_metadata, N_("Print the metadata of the image file"), null }, + { "profile", 'i', 0, OptionArg.STRING, ref profile, N_("Name for a custom profile"), N_("PROFILE") }, + { "profile-browser", 'b', 0, OptionArg.NONE, ref browse_profiles, N_("Start with a browser of available profiles"), null }, + { "create", 'c', 0, OptionArg.NONE, ref create_profile, N_("If PROFILE given with --profile does not exist, create it"), null }, + { "list-profiles", 'l', 0, OptionArg.NONE, ref list_profiles, N_("Show available profiles"), null }, + { null, 0, 0, 0, null, null, null } +}; } void main(string[] args) { @@ -345,7 +366,7 @@ void main(string[] args) { // This has to be done before the AppWindow is created in order to ensure the XMP // parser is initialized in a thread-safe fashion; please see - // http://redmine.yorba.org/issues/4120 for details. + // https://bugzilla.gnome.org/show_bug.cgi?id=717931 for details. GExiv2.initialize(); GExiv2.log_use_glib_logging(); @@ -353,22 +374,30 @@ void main(string[] args) { // logging mechanisms GExiv2.log_set_level(GExiv2.LogLevel.DEBUG); + // If set to non-empty, initialize GdkPixbuf with an additional loader path + if (Resources.PIXBUF_LOADER_PATH != "") { + debug("Trying to set module path to %s", Resources.PIXBUF_LOADER_PATH); + try { + Gdk.Pixbuf.init_modules(Resources.PIXBUF_LOADER_PATH); + } catch (Error err) { + message("Failed to set additional pixbuf loader path: %s", err.message); + } + } + // following the GIO programming guidelines at http://developer.gnome.org/gio/2.26/ch03.html, // set the GSETTINGS_SCHEMA_DIR environment variable to allow us to load GSettings schemas from // the build directory. this allows us to access local GSettings schemas without having to // muck with the user's XDG_... directories, which is seriously frowned upon if (AppDirs.get_install_dir() == null) { GLib.Environment.set_variable("GSETTINGS_SCHEMA_DIR", AppDirs.get_lib_dir().get_path() + - "/misc", true); + "/data/gsettings", true); } - + // init GTK (valac has already called g_threads_init()) try { - Gtk.init_with_args(ref args, _("[FILE]"), CommandlineOptions.get_options(), + Gtk.init_with_args(ref args, _("[FILE]"), CommandlineOptions.entries, Resources.APP_GETTEXT_PACKAGE); - var use_dark = Config.Facade.get_instance().get_gtk_theme_variant(); - Gtk.Settings.get_default().gtk_application_prefer_dark_theme = use_dark; } catch (Error e) { print(e.message + "\n"); print(_("Run “%s --help” to see a full list of available command line options.\n"), args[0]); @@ -376,6 +405,41 @@ void main(string[] args) { return; } + if (CommandlineOptions.browse_profiles) { + var window = new Gtk.Dialog(); + window.set_title (_("Choose Shotwell's profile")); + var browser = new Shotwell.ProfileBrowser(); + browser.profile_activated.connect((profile) => { + CommandlineOptions.profile = profile; + window.response(Gtk.ResponseType.OK); + }); + window.get_content_area().add(browser); + window.set_size_request(430, 560); + var response = window.run(); + window.destroy(); + // Anything else than selecting an entry in the list will stop shotwell from starting + if (response != Gtk.ResponseType.OK) { + return; + } + } + + // Setup profile manager + if (CommandlineOptions.profile != null) { + var manager = Shotwell.ProfileManager.get_instance(); + if (!manager.has_profile (CommandlineOptions.profile)) { + if (!CommandlineOptions.create_profile) { + print(_("Profile %s does not exist. Did you mean to pass --create as well?"), + CommandlineOptions.profile); + AppDirs.terminate(); + return; + } + } + manager.set_profile(CommandlineOptions.profile); + CommandlineOptions.data_dir = manager.derive_data_dir(CommandlineOptions.data_dir); + } else { + message("Starting session with system profile"); + } + if (CommandlineOptions.show_version) { if (Resources.GIT_VERSION != "") print("%s %s (%s)\n", Resources.APP_TITLE, Resources.APP_VERSION, Resources.GIT_VERSION); @@ -386,7 +450,16 @@ void main(string[] args) { return; } - + + if (CommandlineOptions.list_profiles) { + var manager = Shotwell.ProfileManager.get_instance(); + manager.print_profiles(); + + AppDirs.terminate(); + + return; + } + // init debug prior to anything else (except Gtk, which it relies on, and AppDirs, which needs // to be set ASAP) ... since we need to know what mode we're in, examine the command-line // first @@ -397,15 +470,21 @@ void main(string[] args) { string[] mounts = new string[0]; string filename = null; - for (int ctr = 1; ctr < args.length; ctr++) { - string arg = args[ctr]; - + foreach (var arg in args[1:args.length]) { if (LibraryWindow.is_mount_uri_supported(arg)) { mounts += arg; } else if (is_string_empty(filename) && !arg.contains("://")) { filename = arg; } } + + if (CommandlineOptions.show_metadata) { + dump_metadata (filename); + + AppDirs.terminate(); + + return; + } Debug.init(is_string_empty(filename) ? Debug.LIBRARY_PREFIX : Debug.VIEWER_PREFIX); diff --git a/src/meson.build b/src/meson.build index a532eec..460092e 100644 --- a/src/meson.build +++ b/src/meson.build @@ -17,234 +17,272 @@ processor = executable('shotwell-graphics-processor', dependencies: [gio, gdk, gee], link_with: sw_graphics_processor) +shotwell_deps = [gio, gee, sqlite, gtk, sqlite, posix, gphoto2, + gstreamer_pbu, gudev, gexiv2, gmodule, + libraw, libexif, sw_plugin] + +shotwell_libs = [sw_graphics_processor] + face_sources = (['faces/FacesBranch.vala', - 'faces/FaceLocation.vala', 'faces/FacePage.vala', 'faces/FaceShape.vala', + 'faces/FaceDetect.vala', 'faces/Faces.vala', - 'faces/Face.vala', - 'db/FaceLocationTable.vala', - 'db/FaceTable.vala', 'faces/FacesTool.vala']) shotwell_deps = [gio, gee, sqlite, gtk, sqlite, posix, gphoto2, - gstreamer_pbu, gio_unix, gudev, gexiv2, gmodule, - libraw, libexif, sw_plugin, portal, version] -if unity_available - shotwell_deps += [unity] -endif -executable('shotwell', - ['unit/Unit.vala', - 'util/Util.vala', - 'util/file.vala', - 'util/image.vala', - 'util/misc.vala', - 'util/string.vala', - 'util/system.vala', - 'util/ui.vala', - 'threads/Threads.vala', - 'threads/Workers.vala', - 'threads/BackgroundJob.vala', - 'threads/Semaphore.vala', - 'db/Db.vala', - 'db/DatabaseTable.vala', - 'db/PhotoTable.vala', - 'db/EventTable.vala', - 'db/TagTable.vala', - 'db/TombstoneTable.vala', - 'db/VideoTable.vala', - 'db/VersionTable.vala', - 'db/SavedSearchDBTable.vala', - 'editing_tools/EditingTools.vala', - 'editing_tools/RGBHistogramManipulator.vala', - 'editing_tools/StraightenTool.vala', - 'slideshow/Slideshow.vala', - 'slideshow/TransitionEffects.vala', - 'photos/Photos.vala', - 'photos/PhotoFileAdapter.vala', - 'photos/PhotoFileFormat.vala', - 'photos/PhotoFileSniffer.vala', - 'photos/PhotoMetadata.vala', - 'photos/GRaw.vala', - 'photos/GdkSupport.vala', - 'photos/GifSupport.vala', - 'photos/JfifSupport.vala', - 'photos/BmpSupport.vala', - 'photos/RawSupport.vala', - 'photos/PngSupport.vala', - 'photos/TiffSupport.vala', - 'plugins/Plugins.vala', - 'plugins/StandardHostInterface.vala', - 'plugins/ManifestWidget.vala', - 'publishing/Publishing.vala', - 'publishing/PublishingUI.vala', - 'publishing/PublishingPluginHost.vala', - 'publishing/APIGlue.vala', - 'library/Library.vala', - 'library/LibraryWindow.vala', - 'library/LibraryBranch.vala', - 'library/TrashSidebarEntry.vala', - 'library/OfflineSidebarEntry.vala', - 'library/FlaggedSidebarEntry.vala', - 'library/LastImportSidebarEntry.vala', - 'library/ImportQueueSidebarEntry.vala', - 'library/FlaggedPage.vala', - 'library/ImportQueuePage.vala', - 'library/LastImportPage.vala', - 'library/OfflinePage.vala', - 'library/TrashPage.vala', - 'direct/Direct.vala', - 'direct/DirectWindow.vala', - 'direct/DirectPhoto.vala', - 'direct/DirectPhotoPage.vala', - 'direct/DirectView.vala', - 'core/Core.vala', - 'core/DataCollection.vala', - 'core/DataSet.vala', - 'core/util.vala', - 'core/SourceCollection.vala', - 'core/SourceHoldingTank.vala', - 'core/DatabaseSourceCollection.vala', - 'core/ContainerSourceCollection.vala', - 'core/ViewCollection.vala', - 'core/DataObject.vala', - 'core/Alteration.vala', - 'core/DataSource.vala', - 'core/DataSourceTypes.vala', - 'core/DataView.vala', - 'core/DataViewTypes.vala', - 'core/Tracker.vala', - 'core/SourceInterfaces.vala', - 'sidebar/Sidebar.vala', - 'sidebar/Branch.vala', - 'sidebar/Entry.vala', - 'sidebar/Tree.vala', - 'sidebar/common.vala', - 'events/Events.vala', - 'events/EventsBranch.vala', - 'events/EventsDirectoryPage.vala', - 'events/EventPage.vala', - 'events/EventDirectoryItem.vala', - 'tags/Tags.vala', - 'tags/TagsBranch.vala', - 'tags/TagPage.vala', - 'tags/HierarchicalTagIndex.vala', - 'tags/HierarchicalTagUtilities.vala', - 'camera/Camera.vala', - 'camera/CameraBranch.vala', - 'camera/CameraTable.vala', - 'camera/GPhoto.vala', - 'camera/ImportPage.vala', - 'searches/Searches.vala', - 'searches/SearchesBranch.vala', - 'searches/SearchBoolean.vala', - 'searches/SavedSearchPage.vala', - 'searches/SavedSearchDialog.vala', - 'config/Config.vala', - 'config/ConfigurationInterfaces.vala', - 'config/GSettingsEngine.vala', - 'data_imports/DataImports.vala', - 'data_imports/DataImportsPluginHost.vala', - 'data_imports/DataImportsUI.vala', - 'data_imports/DataImportJob.vala', - 'data_imports/DataImportSource.vala', - 'folders/Folders.vala', - 'folders/FoldersBranch.vala', - 'folders/FoldersPage.vala', - 'import-roll/ImportRollBranch.vala', - 'import-roll/ImportRollEntry.vala', - 'main.vala', - 'AppWindow.vala', - 'CollectionPage.vala', - 'NaturalCollate.vala', - 'Thumbnail.vala', - 'ThumbnailCache.vala', - 'CheckerboardLayout.vala', - 'PhotoPage.vala', - 'Page.vala', - 'SortedList.vala', - 'Dimensions.vala', - 'Box.vala', - 'Photo.vala', - 'Orientation.vala', - 'BatchImport.vala', - 'Dialogs.vala', - 'Resources.vala', - 'Debug.vala', - 'Properties.vala', - 'Event.vala', - 'International.vala', - 'AppDirs.vala', - 'PixbufCache.vala', - 'CommandManager.vala', - 'Commands.vala', - 'SlideshowPage.vala', - 'LibraryFiles.vala', - 'Printing.vala', - 'Tag.vala', - 'Screensaver.vala', - 'Exporter.vala', - 'DirectoryMonitor.vala', - 'LibraryMonitor.vala', - 'VideoSupport.vala', - 'Tombstone.vala', - 'MetadataWriter.vala', - 'Application.vala', - 'TimedQueue.vala', - 'MediaPage.vala', - 'MediaDataRepresentation.vala', - 'DesktopIntegration.vala', - 'MediaInterfaces.vala', - 'MediaMetadata.vala', - 'VideoMetadata.vala', - 'MediaMonitor.vala', - 'PhotoMonitor.vala', - 'VideoMonitor.vala', - 'SearchFilter.vala', - 'MediaViewTracker.vala', - 'UnityProgressBar.vala', - 'Upgrades.vala', - 'dialogs/AdjustDateTimeDialog.vala', - 'dialogs/EntryMultiCompletion.vala', - 'dialogs/ExportDialog.vala', - 'dialogs/MultiTextEntryDialog.vala', - 'dialogs/Preferences.vala', - 'dialogs/ProgressDialog.vala', - 'dialogs/SetBackgroundSlideshow.vala', - 'dialogs/SetBackground.vala', - 'dialogs/TextEntry.vala', - 'dialogs/WelcomeDialog.vala', - '.unitize/_UnitInternals.vala', - '.unitize/_UtilInternals.vala', - '.unitize/_ThreadsInternals.vala', - '.unitize/_DbInternals.vala', - '.unitize/_EditingToolsInternals.vala', - '.unitize/_PluginsInternals.vala', - '.unitize/_SlideshowInternals.vala', - '.unitize/_PhotosInternals.vala', - '.unitize/_PublishingInternals.vala', - '.unitize/_LibraryInternals.vala', - '.unitize/_DirectInternals.vala', - '.unitize/_CoreInternals.vala', - '.unitize/_SidebarInternals.vala', - '.unitize/_EventsInternals.vala', - '.unitize/_TagsInternals.vala', - '.unitize/_CameraInternals.vala', - '.unitize/_SearchesInternals.vala', - '.unitize/_ConfigInternals.vala', - '.unitize/_DataImportsInternals.vala', - '.unitize/_FoldersInternals.vala', - '.unitize/_Library_unitize_entry.vala', - '.unitize/_Direct_unitize_entry.vala'] + shotwell_resources + face_sources, - include_directories : vapi_incdir, - dependencies : shotwell_deps, - vala_args : ['--pkg', 'libgphoto2', - '--pkg', 'libraw', - '--pkg', 'libexif', - '--pkg', 'version', - '--gresources', - join_paths(meson.source_root(), - 'org.gnome.Shotwell.gresource.xml') - ], - link_with: [sw_graphics_processor], - install : true) + gstreamer_pbu, gudev, gexiv2, gmodule, unity, + libraw, libexif, sw_plugin, webpdemux, webp, version, + portal] + +subdir('metadata') +subdir('publishing') +subdir('video-support') + +executable( + 'shotwell', + [ + 'unit/Unit.vala', + 'util/Util.vala', + 'util/file.vala', + 'util/image.vala', + 'util/misc.vala', + 'util/string.vala', + 'util/system.vala', + 'util/ui.vala', + 'threads/Threads.vala', + 'threads/Workers.vala', + 'threads/BackgroundJob.vala', + 'threads/Semaphore.vala', + 'db/Db.vala', + 'db/DatabaseTable.vala', + 'db/PhotoTable.vala', + 'db/EventTable.vala', + 'db/FaceLocationTable.vala', + 'db/FaceTable.vala', + 'db/TagTable.vala', + 'db/TombstoneTable.vala', + 'db/VideoTable.vala', + 'db/VersionTable.vala', + 'db/SavedSearchDBTable.vala', + 'editing_tools/EditingTools.vala', + 'editing_tools/RGBHistogramManipulator.vala', + 'editing_tools/StraightenTool.vala', + 'faces/Face.vala', + 'faces/FaceLocation.vala', + 'slideshow/Slideshow.vala', + 'slideshow/TransitionEffects.vala', + 'photos/Photos.vala', + 'photos/PhotoFileAdapter.vala', + 'photos/PhotoFileFormat.vala', + 'photos/PhotoFileSniffer.vala', + 'photos/PhotoMetadata.vala', + 'photos/GRaw.vala', + 'photos/GdkSupport.vala', + 'photos/GifSupport.vala', + 'photos/JfifSupport.vala', + 'photos/BmpSupport.vala', + 'photos/RawSupport.vala', + 'photos/PngSupport.vala', + 'photos/TiffSupport.vala', + 'photos/WebPSupport.vala', + 'photos/AvifSupport.vala', + 'photos/HeifSupport.vala', + 'photos/JpegXLSupport.vala', + 'plugins/Plugins.vala', + 'plugins/StandardHostInterface.vala', + 'plugins/ManifestWidget.vala', + 'publishing/Publishing.vala', + 'publishing/PublishingUI.vala', + 'publishing/PublishingPluginHost.vala', + 'publishing/APIGlue.vala', + 'library/BackgroundProgressBar.vala', + 'library/Library.vala', + 'library/LibraryWindow.vala', + 'library/LibraryBranch.vala', + 'library/TrashSidebarEntry.vala', + 'library/OfflineSidebarEntry.vala', + 'library/FlaggedSidebarEntry.vala', + 'library/LastImportSidebarEntry.vala', + 'library/ImportQueueSidebarEntry.vala', + 'library/FlaggedPage.vala', + 'library/ImportQueuePage.vala', + 'library/LastImportPage.vala', + 'library/OfflinePage.vala', + 'library/TrashPage.vala', + 'direct/Direct.vala', + 'direct/DirectWindow.vala', + 'direct/DirectPhoto.vala', + 'direct/DirectPhotoPage.vala', + 'direct/DirectView.vala', + 'core/Core.vala', + 'core/DataCollection.vala', + 'core/DataSet.vala', + 'core/util.vala', + 'core/SourceCollection.vala', + 'core/SourceHoldingTank.vala', + 'core/DatabaseSourceCollection.vala', + 'core/ContainerSourceCollection.vala', + 'core/ViewCollection.vala', + 'core/DataObject.vala', + 'core/Alteration.vala', + 'core/DataSource.vala', + 'core/DataSourceTypes.vala', + 'core/DataView.vala', + 'core/DataViewTypes.vala', + 'core/Tracker.vala', + 'core/SourceInterfaces.vala', + 'sidebar/Sidebar.vala', + 'sidebar/Branch.vala', + 'sidebar/Entry.vala', + 'sidebar/Tree.vala', + 'sidebar/common.vala', + 'events/Events.vala', + 'events/EventsBranch.vala', + 'events/EventsDirectoryPage.vala', + 'events/EventPage.vala', + 'events/EventDirectoryItem.vala', + 'tags/Tags.vala', + 'tags/TagsBranch.vala', + 'tags/TagPage.vala', + 'tags/HierarchicalTagIndex.vala', + 'tags/HierarchicalTagUtilities.vala', + 'camera/Camera.vala', + 'camera/CameraBranch.vala', + 'camera/CameraTable.vala', + 'camera/DiscoveredCamera.vala', + 'camera/GPhoto.vala', + 'camera/ImportPage.vala', + 'searches/Searches.vala', + 'searches/SearchesBranch.vala', + 'searches/SearchBoolean.vala', + 'searches/SavedSearchPage.vala', + 'searches/SavedSearchDialog.vala', + 'config/Config.vala', + 'config/ConfigurationInterfaces.vala', + 'config/GSettingsEngine.vala', + 'data_imports/DataImports.vala', + 'data_imports/DataImportsPluginHost.vala', + 'data_imports/DataImportsUI.vala', + 'data_imports/DataImportJob.vala', + 'data_imports/DataImportSource.vala', + 'folders/Folders.vala', + 'folders/FoldersBranch.vala', + 'folders/FoldersPage.vala', + 'import-roll/ImportRollBranch.vala', + 'import-roll/ImportRollEntry.vala', + 'main.vala', + 'AppWindow.vala', + 'CollectionPage.vala', + 'NaturalCollate.vala', + 'Thumbnail.vala', + 'ThumbnailCache.vala', + 'CheckerboardItem.vala', + 'CheckerboardItemText.vala', + 'CheckerboardLayout.vala', + 'PhotoPage.vala', + 'Page.vala', + 'SinglePhotoPage.vala', + 'CheckerboardPage.vala', + 'DragAndDropHandler.vala', + 'PageMessagePane.vala', + 'SortedList.vala', + 'Dimensions.vala', + 'Box.vala', + 'Photo.vala', + 'Orientation.vala', + 'BatchImport.vala', + 'Dialogs.vala', + 'Resources.vala', + 'Debug.vala', + 'Properties.vala', + 'Event.vala', + 'International.vala', + 'AppDirs.vala', + 'PixbufCache.vala', + 'CommandManager.vala', + 'Commands.vala', + 'SlideshowPage.vala', + 'LibraryFiles.vala', + 'Printing.vala', + 'Tag.vala', + 'Screensaver.vala', + 'Exporter.vala', + 'DirectoryMonitor.vala', + 'LibraryMonitor.vala', + 'Tombstone.vala', + 'MetadataWriter.vala', + 'Application.vala', + 'TimedQueue.vala', + 'MediaPage.vala', + 'MediaDataRepresentation.vala', + 'DesktopIntegration.vala', + 'MediaInterfaces.vala', + 'MediaMonitor.vala', + 'PhotoMonitor.vala', + 'VideoMonitor.vala', + 'SearchFilter.vala', + 'MediaViewTracker.vala', + 'UnityProgressBar.vala', + 'Upgrades.vala', + 'dialogs/AdjustDateTimeDialog.vala', + 'dialogs/EntryMultiCompletion.vala', + 'dialogs/ExportDialog.vala', + 'dialogs/MultiTextEntryDialog.vala', + 'dialogs/Preferences.vala', + 'dialogs/ProgressDialog.vala', + 'dialogs/SetBackgroundSlideshow.vala', + 'dialogs/SetBackground.vala', + 'dialogs/TextEntry.vala', + 'dialogs/WelcomeDialog.vala', + 'Profiles.vala', + 'ProfileBrowser.vala', + '.unitize/_UnitInternals.vala', + '.unitize/_UtilInternals.vala', + '.unitize/_ThreadsInternals.vala', + '.unitize/_DbInternals.vala', + '.unitize/_EditingToolsInternals.vala', + '.unitize/_PluginsInternals.vala', + '.unitize/_SlideshowInternals.vala', + '.unitize/_PhotosInternals.vala', + '.unitize/_PublishingInternals.vala', + '.unitize/_LibraryInternals.vala', + '.unitize/_DirectInternals.vala', + '.unitize/_CoreInternals.vala', + '.unitize/_SidebarInternals.vala', + '.unitize/_EventsInternals.vala', + '.unitize/_TagsInternals.vala', + '.unitize/_CameraInternals.vala', + '.unitize/_SearchesInternals.vala', + '.unitize/_ConfigInternals.vala', + '.unitize/_DataImportsInternals.vala', + '.unitize/_FoldersInternals.vala', + '.unitize/_Library_unitize_entry.vala', + '.unitize/_Direct_unitize_entry.vala', + 'video-support/VideoReader.vala', + 'video-support/VideoImportParams.vala', + 'video-support/Video.vala', + 'video-support/VideoSourceCollection.vala', + 'video-support/VideoMetadata.vala' + ] + shotwell_resources + face_sources, + include_directories : vapi_incdir, + dependencies : [ + shotwell_deps, + sw_publishing_gui, + metadata, + metadata_handling + ], + vala_args : [ + '--pkg', 'libgphoto2', + '--pkg', 'libraw', + '--pkg', 'libexif', + '--pkg', 'version', + '--gresources', + join_paths(meson.project_source_root(), 'data', + 'org.gnome.Shotwell.gresource.xml') + ], + link_with: [ + sw_graphics_processor + ], + install : true +) diff --git a/src/metadata/MediaMetadata.vala b/src/metadata/MediaMetadata.vala new file mode 100644 index 0000000..a329cb1 --- /dev/null +++ b/src/metadata/MediaMetadata.vala @@ -0,0 +1,15 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public abstract class MediaMetadata { + public abstract void read_from_file(File file) throws Error; + + public abstract MetadataDateTime? get_creation_date_time(); + + public abstract string? get_title(); + + public abstract string? get_comment(); +} diff --git a/src/metadata/MetadataDateTime.vala b/src/metadata/MetadataDateTime.vala new file mode 100644 index 0000000..9dae99b --- /dev/null +++ b/src/metadata/MetadataDateTime.vala @@ -0,0 +1,78 @@ +public errordomain MetadataDateTimeError { + INVALID_FORMAT, + UNSUPPORTED_FORMAT +} + +public class MetadataDateTime { + + private DateTime timestamp; + + public MetadataDateTime(DateTime timestamp) { + this.timestamp = timestamp; + } + + public MetadataDateTime.from_exif(string label) throws MetadataDateTimeError { + if (!from_exif_date_time(label, out timestamp)) + throw new MetadataDateTimeError.INVALID_FORMAT("%s is not EXIF format date/time", label); + } + + public MetadataDateTime.from_iptc(string date, string time) throws MetadataDateTimeError { + // TODO: Support IPTC date/time format + throw new MetadataDateTimeError.UNSUPPORTED_FORMAT("IPTC date/time format not currently supported"); + } + + public MetadataDateTime.from_xmp(string label) throws MetadataDateTimeError { + var dt = new DateTime.from_iso8601(label, null); + if (dt == null) + throw new MetadataDateTimeError.INVALID_FORMAT("%s is not XMP format date/time", label); + + timestamp = dt; + } + + public DateTime? get_timestamp() { + return timestamp; + } + + public string get_exif_label() { + return to_exif_date_time(timestamp); + } + + // TODO: get_iptc_date() and get_iptc_time() + + public string get_xmp_label() { + return timestamp.format_iso8601(); + } + + public static bool from_exif_date_time(string date_time, out DateTime? timestamp) { + timestamp = null; + + Time tm = Time(); + + // Check standard EXIF format + if (date_time.scanf("%d:%d:%d %d:%d:%d", + &tm.year, &tm.month, &tm.day, &tm.hour, &tm.minute, &tm.second) != 6) { + // Fallback in a more generic format + string tmp = date_time.dup(); + tmp.canon("0123456789", ' '); + if (tmp.scanf("%4d%2d%2d%2d%2d%2d", + &tm.year, &tm.month, &tm.day, &tm.hour, &tm.minute,&tm.second) != 6) + return false; + } + + // watch for bogosity + if (tm.year <= 1900 || tm.month <= 0 || tm.day < 0 || tm.hour < 0 || tm.minute < 0 || tm.second < 0) + return false; + + timestamp = new DateTime.local(tm.year, tm.month, tm.day, tm.hour, tm.minute, tm.second); + + return true; + } + + public static string to_exif_date_time(DateTime timestamp) { + return timestamp.to_local().format("%Y:%m:%d %H:%M:%S"); + } + + public string to_string() { + return to_exif_date_time(timestamp); + } +} diff --git a/src/metadata/MetadataRational.vala b/src/metadata/MetadataRational.vala new file mode 100644 index 0000000..ec3ac17 --- /dev/null +++ b/src/metadata/MetadataRational.vala @@ -0,0 +1,26 @@ +public struct MetadataRational { + public int numerator; + public int denominator; + + public MetadataRational.invalid() { + this.numerator = -1; + this.denominator = -1; + } + + public MetadataRational(int numerator, int denominator) { + this.numerator = numerator; + this.denominator = denominator; + } + + private bool is_component_valid(int component) { + return (component >= 0) && (component <= 1000000); + } + + public bool is_valid() { + return (is_component_valid(numerator) && is_component_valid(denominator)); + } + + public string to_string() { + return (is_valid()) ? ("%d/%d".printf(numerator, denominator)) : ""; + } +} diff --git a/src/metadata/meson.build b/src/metadata/meson.build new file mode 100644 index 0000000..7f322ca --- /dev/null +++ b/src/metadata/meson.build @@ -0,0 +1,16 @@ +libmetadata = static_library( + 'metadata', + [ + 'MediaMetadata.vala', + 'MetadataDateTime.vala', + 'MetadataRational.vala' + ], + dependencies : [ + gio + ] +) + +metadata = declare_dependency( + include_directories : include_directories('.'), + link_with : libmetadata +) diff --git a/src/photos/AvifSupport.vala b/src/photos/AvifSupport.vala new file mode 100644 index 0000000..842f0fc --- /dev/null +++ b/src/photos/AvifSupport.vala @@ -0,0 +1,140 @@ +/* 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. + */ + +class AvifFileFormatProperties : PhotoFileFormatProperties { + private static string[] KNOWN_EXTENSIONS = { "avif" }; + private static string[] KNOWN_MIME_TYPES = { "image/avif" }; + + private static AvifFileFormatProperties instance = null; + + public static void init() { + instance = new AvifFileFormatProperties(); + } + + public static AvifFileFormatProperties get_instance() { + return instance; + } + + public override PhotoFileFormat get_file_format() { + return PhotoFileFormat.AVIF; + } + + public override PhotoFileFormatFlags get_flags() { + return PhotoFileFormatFlags.NONE; + } + + public override string get_user_visible_name() { + return _("AVIF"); + } + + public override string get_default_extension() { + return KNOWN_EXTENSIONS[0]; + } + + public override string[] get_known_extensions() { + return KNOWN_EXTENSIONS; + } + + public override string get_default_mime_type() { + return KNOWN_MIME_TYPES[0]; + } + + public override string[] get_mime_types() { + return KNOWN_MIME_TYPES; + } +} + +public class AvifSniffer : GdkSniffer { + public AvifSniffer(File file, PhotoFileSniffer.Options options) { + base (file, options); + } + + public override DetectedPhotoInformation? sniff(out bool is_corrupted) throws Error { + // Rely on GdkSniffer to detect corruption + is_corrupted = false; + + if (!is_supported_bmff_with_variants(file, {"avif", "avis"})) + return null; + + DetectedPhotoInformation? detected = base.sniff(out is_corrupted); + if (detected == null) + return null; + + return (detected.file_format == PhotoFileFormat.AVIF) ? detected : null; + } +} + +public class AvifReader : GdkReader { + public AvifReader(string filepath) { + base (filepath, PhotoFileFormat.AVIF); + } +} + +public class AvifWriter : PhotoFileWriter { + public AvifWriter(string filepath) { + base (filepath, PhotoFileFormat.AVIF); + } + + public override void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error { + pixbuf.save(get_filepath(), "avif", "quality", "90", null); + } +} + +public class AvifMetadataWriter : PhotoFileMetadataWriter { + public AvifMetadataWriter(string filepath) { + base (filepath, PhotoFileFormat.AVIF); + } + + public override void write_metadata(PhotoMetadata metadata) throws Error { + metadata.write_to_file(get_file()); + } +} + +public class AvifFileFormatDriver : PhotoFileFormatDriver { + private static AvifFileFormatDriver instance = null; + + public static void init() { + instance = new AvifFileFormatDriver(); + AvifFileFormatProperties.init(); + } + + public static AvifFileFormatDriver get_instance() { + return instance; + } + + public override PhotoFileFormatProperties get_properties() { + return AvifFileFormatProperties.get_instance(); + } + + public override PhotoFileReader create_reader(string filepath) { + return new AvifReader(filepath); + } + + public override bool can_write_image() { + return true; + } + + public override bool can_write_metadata() { + return true; + } + + public override PhotoFileWriter? create_writer(string filepath) { + return new AvifWriter(filepath); + } + + public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) { + return new AvifMetadataWriter(filepath); + } + + public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) { + return new AvifSniffer(file, options); + } + + public override PhotoMetadata create_metadata() { + return new PhotoMetadata(); + } +} + diff --git a/src/photos/BmpSupport.vala b/src/photos/BmpSupport.vala index a59a4d9..26ec911 100644 --- a/src/photos/BmpSupport.vala +++ b/src/photos/BmpSupport.vala @@ -90,33 +90,6 @@ public class BmpReader : GdkReader { public BmpReader(string filepath) { base (filepath, PhotoFileFormat.BMP); } - - public override Gdk.Pixbuf scaled_read(Dimensions full, Dimensions scaled) throws Error { - Gdk.Pixbuf result = null; - /* if we encounter a situation where there are two orders of magnitude or more of - difference between the full image size and the scaled size, and if the full image - size has five or more decimal digits of precision, Gdk.Pixbuf.from_file_at_scale( ) can - fail due to what appear to be floating-point round-off issues. This isn't surprising, - since 32-bit floats only have 6-7 decimal digits of precision in their mantissa. In - this case, we prefetch the image at a larger scale and then downsample it to the - desired scale as a post-process step. This short-circuits Gdk.Pixbuf's buggy - scaling code. */ - if (((full.width > 9999) || (full.height > 9999)) && ((scaled.width < 100) || - (scaled.height < 100))) { - Dimensions prefetch_dimensions = full.get_scaled_by_constraint(1000, - ScaleConstraint.DIMENSIONS); - - result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), prefetch_dimensions.width, - prefetch_dimensions.height, false); - - result = result.scale_simple(scaled.width, scaled.height, Gdk.InterpType.HYPER); - } else { - result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), scaled.width, - scaled.height, false); - } - - return result; - } } public class BmpWriter : PhotoFileWriter { diff --git a/src/photos/GdkSupport.vala b/src/photos/GdkSupport.vala index f7e18d5..64a08d6 100644 --- a/src/photos/GdkSupport.vala +++ b/src/photos/GdkSupport.vala @@ -21,7 +21,30 @@ public abstract class GdkReader : PhotoFileReader { } public override Gdk.Pixbuf scaled_read(Dimensions full, Dimensions scaled) throws Error { - return new Gdk.Pixbuf.from_file_at_scale(get_filepath(), scaled.width, scaled.height, false); + Gdk.Pixbuf result = null; + /* if we encounter a situation where there are two orders of magnitude or more of + difference between the full image size and the scaled size, and if the full image + size has five or more decimal digits of precision, Gdk.Pixbuf.from_file_at_scale( ) can + fail due to what appear to be floating-point round-off issues. This isn't surprising, + since 32-bit floats only have 6-7 decimal digits of precision in their mantissa. In + this case, we prefetch the image at a larger scale and then downsample it to the + desired scale as a post-process step. This short-circuits Gdk.Pixbuf's buggy + scaling code. */ + if (((full.width > 9999) || (full.height > 9999)) && ((scaled.width < 100) || + (scaled.height < 100))) { + Dimensions prefetch_dimensions = full.get_scaled_by_constraint(1000, + ScaleConstraint.DIMENSIONS); + + result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), prefetch_dimensions.width, + prefetch_dimensions.height, false); + + result = result.scale_simple(scaled.width, scaled.height, Gdk.InterpType.HYPER); + } else { + result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), scaled.width, + scaled.height, false); + } + + return result; } } @@ -112,13 +135,14 @@ public abstract class GdkSniffer : PhotoFileSniffer { Gdk.Pixbuf? pixbuf = pixbuf_loader.get_pixbuf(); if (pixbuf == null) return; - + detected.colorspace = pixbuf.get_colorspace(); detected.channels = pixbuf.get_n_channels(); detected.bits_per_channel = pixbuf.get_bits_per_sample(); unowned Gdk.PixbufFormat format = pixbuf_loader.get_format(); detected.format_name = format.get_name(); + debug("Pixbuf detected format name: %s", detected.format_name); detected.file_format = PhotoFileFormat.from_pixbuf_name(detected.format_name); area_prepared = true; diff --git a/src/photos/GifSupport.vala b/src/photos/GifSupport.vala index bd6ef6a..b49b4f2 100644 --- a/src/photos/GifSupport.vala +++ b/src/photos/GifSupport.vala @@ -86,33 +86,6 @@ public class GifReader : GdkReader { public GifReader(string filepath) { base (filepath, PhotoFileFormat.PNG); } - - public override Gdk.Pixbuf scaled_read(Dimensions full, Dimensions scaled) throws Error { - Gdk.Pixbuf result = null; - /* if we encounter a situation where there are two orders of magnitude or more of - difference between the full image size and the scaled size, and if the full image - size has five or more decimal digits of precision, Gdk.Pixbuf.from_file_at_scale( ) can - fail due to what appear to be floating-point round-off issues. This isn't surprising, - since 32-bit floats only have 6-7 decimal digits of precision in their mantissa. In - this case, we prefetch the image at a larger scale and then downsample it to the - desired scale as a post-process step. This short-circuits Gdk.Pixbuf's buggy - scaling code. */ - if (((full.width > 9999) || (full.height > 9999)) && ((scaled.width < 100) || - (scaled.height < 100))) { - Dimensions prefetch_dimensions = full.get_scaled_by_constraint(1000, - ScaleConstraint.DIMENSIONS); - - result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), prefetch_dimensions.width, - prefetch_dimensions.height, false); - - result = result.scale_simple(scaled.width, scaled.height, Gdk.InterpType.HYPER); - } else { - result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), scaled.width, - scaled.height, false); - } - - return result; - } } public class GifMetadataWriter : PhotoFileMetadataWriter { diff --git a/src/photos/HeifSupport.vala b/src/photos/HeifSupport.vala new file mode 100644 index 0000000..0c05e02 --- /dev/null +++ b/src/photos/HeifSupport.vala @@ -0,0 +1,150 @@ +/* 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. + */ + +class HeifFileFormatProperties : PhotoFileFormatProperties { + private static string[] KNOWN_EXTENSIONS = { "heif", "heic" }; + private static string[] KNOWN_MIME_TYPES = { "image/heif" }; + + private static HeifFileFormatProperties instance = null; + + public static void init() { + instance = new HeifFileFormatProperties(); + } + + public static HeifFileFormatProperties get_instance() { + return instance; + } + + public override PhotoFileFormat get_file_format() { + return PhotoFileFormat.HEIF; + } + + public override PhotoFileFormatFlags get_flags() { + return PhotoFileFormatFlags.NONE; + } + + public override string get_user_visible_name() { + return _("HEIF"); + } + + public override string get_default_extension() { + return KNOWN_EXTENSIONS[0]; + } + + public override string[] get_known_extensions() { + return KNOWN_EXTENSIONS; + } + + public override string get_default_mime_type() { + return KNOWN_MIME_TYPES[0]; + } + + public override string[] get_mime_types() { + return KNOWN_MIME_TYPES; + } +} + +public class HeifSniffer : GdkSniffer { + private const string[] MAGIC_SEQUENCES = { "heic", "heix", "hevc", "heim", "heis", "hevm", "hevs", "mif1", "msf1"}; + + public HeifSniffer(File file, PhotoFileSniffer.Options options) { + base (file, options); + } + + public override DetectedPhotoInformation? sniff(out bool is_corrupted) throws Error { + // Rely on GdkSniffer to detect corruption + is_corrupted = false; + + if (!is_supported_bmff_with_variants(file, MAGIC_SEQUENCES)) + return null; + + DetectedPhotoInformation? detected = base.sniff(out is_corrupted); + if (detected == null) + return null; + + if (detected.file_format == PhotoFileFormat.AVIF) + detected.file_format = PhotoFileFormat.HEIF; + + // Heif contains its own rotation information, so we need to ignore the EXIF rotation+ + if (detected.metadata != null) { + detected.metadata.set_orientation(Orientation.TOP_LEFT); + } + + return (detected.file_format == PhotoFileFormat.HEIF) ? detected : null; + } + +} + +public class HeifReader : GdkReader { + public HeifReader(string filepath) { + base (filepath, PhotoFileFormat.HEIF); + } + + public override PhotoMetadata read_metadata() throws Error { + PhotoMetadata metadata = new PhotoMetadata(); + metadata.read_from_file(get_file()); + // Heif contains its own rotation information, so we need to ignore the EXIF rotation + metadata.set_orientation(Orientation.TOP_LEFT); + return metadata; + } + +} + +public class HeifMetadataWriter : PhotoFileMetadataWriter { + public HeifMetadataWriter(string filepath) { + base (filepath, PhotoFileFormat.HEIF); + } + + public override void write_metadata(PhotoMetadata metadata) throws Error { + metadata.write_to_file(get_file()); + } +} + +public class HeifFileFormatDriver : PhotoFileFormatDriver { + private static HeifFileFormatDriver instance = null; + + public static void init() { + instance = new HeifFileFormatDriver(); + HeifFileFormatProperties.init(); + } + + public static HeifFileFormatDriver get_instance() { + return instance; + } + + public override PhotoFileFormatProperties get_properties() { + return HeifFileFormatProperties.get_instance(); + } + + public override PhotoFileReader create_reader(string filepath) { + return new HeifReader(filepath); + } + + public override bool can_write_image() { + return false; + } + + public override bool can_write_metadata() { + return true; + } + + public override PhotoFileWriter? create_writer(string filepath) { + return null; + } + + public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) { + return new HeifMetadataWriter(filepath); + } + + public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) { + return new HeifSniffer(file, options); + } + + public override PhotoMetadata create_metadata() { + return new PhotoMetadata(); + } +} + diff --git a/src/photos/JfifSupport.vala b/src/photos/JfifSupport.vala index 5ea64a5..0de45f8 100644 --- a/src/photos/JfifSupport.vala +++ b/src/photos/JfifSupport.vala @@ -103,17 +103,78 @@ public class JfifSniffer : GdkSniffer { } public override DetectedPhotoInformation? sniff(out bool is_corrupted) throws Error { - // Rely on GdkSniffer to detect corruption is_corrupted = false; - - if (!Jpeg.is_jpeg(file)) - return null; - - DetectedPhotoInformation? detected = base.sniff(out is_corrupted); - if (detected == null) + if (!calc_md5) { + return fast_sniff (out is_corrupted); + } else { + if (!Jpeg.is_jpeg(file)) { + return null; + } + + // Rely on GdkSniffer to detect corruption + + DetectedPhotoInformation? detected = base.sniff(out is_corrupted); + if (detected == null) + return null; + + return (detected.file_format == PhotoFileFormat.JFIF) ? detected : null; + } + } + + private DetectedPhotoInformation? fast_sniff(out bool is_corrupted) throws Error { + is_corrupted = false; + var detected = new DetectedPhotoInformation(); + + detected.metadata = new PhotoMetadata(); + try { + detected.metadata.read_from_file(file); + } catch (Error err) { + // no metadata detected + detected.metadata = null; + } + + var fins = file.read(null); + var dins = new DataInputStream(fins); + dins.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); + var seekable = (Seekable) dins; + + var marker = Jpeg.Marker.INVALID; + var length = Jpeg.read_marker_2(dins, out marker); + + if (marker != Jpeg.Marker.SOI) { return null; - - return (detected.file_format == PhotoFileFormat.JFIF) ? detected : null; + } + + length = Jpeg.read_marker_2(dins, out marker); + while (!marker.is_sof() && length > 0) { + seekable.seek(length, SeekType.CUR, null); + length = Jpeg.read_marker_2(dins, out marker); + } + + if (marker.is_sof()) { + if (length < 6) { + is_corrupted = true; + return null; + } + + // Skip precision + dins.read_byte(); + + // Next two 16 bytes are image dimensions + uint16 height = dins.read_uint16(); + uint16 width = dins.read_uint16(); + + detected.image_dim = Dimensions(width, height); + detected.colorspace = Gdk.Colorspace.RGB; + detected.channels = 3; + detected.bits_per_channel = 8; + detected.format_name = "jpeg"; + detected.file_format = PhotoFileFormat.from_pixbuf_name(detected.format_name); + } else { + is_corrupted = true; + } + + return detected; } } @@ -159,6 +220,16 @@ namespace Jpeg { public uint8 get_byte() { return (uint8) this; } + + public bool is_sof() { + // FFCn is SOF unless n is a multiple of 4 > 0 (FFC4, FFC8, FFCC) + if ((this & 0xC0) != 0xC0) { + return false; + } + + var variant = this & 0x0F; + return variant == 0 || variant % 4 != 0; + } } public enum Quality { @@ -219,12 +290,9 @@ namespace Jpeg { return is_jpeg_stream(mins); } - private int read_marker(InputStream fins, out Jpeg.Marker marker) throws Error { + private int32 read_marker_2(DataInputStream dins, out Jpeg.Marker marker) throws Error { marker = Jpeg.Marker.INVALID; - - DataInputStream dins = new DataInputStream(fins); - dins.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); - + if (dins.read_byte() != Jpeg.MARKER_PREFIX) return -1; @@ -235,9 +303,10 @@ namespace Jpeg { } uint16 length = dins.read_uint16(); - if (length < 2 && fins is Seekable) { + var seekable = dins as Seekable; + if (length < 2 && dins != null) { debug("Invalid length %Xh at ofs %" + int64.FORMAT + "Xh", length, - (fins as Seekable).tell() - 2); + seekable.tell() - 2); return -1; } @@ -245,5 +314,12 @@ namespace Jpeg { // account for two length bytes already read return length - 2; } + + private int read_marker(InputStream fins, out Jpeg.Marker marker) throws Error { + DataInputStream dins = new DataInputStream(fins); + dins.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); + + return read_marker_2(dins, out marker); + } } diff --git a/src/photos/JpegXLSupport.vala b/src/photos/JpegXLSupport.vala new file mode 100644 index 0000000..eed220c --- /dev/null +++ b/src/photos/JpegXLSupport.vala @@ -0,0 +1,149 @@ +/* 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. + */ + +class JpegXLFileFormatProperties : PhotoFileFormatProperties { + private static string[] KNOWN_EXTENSIONS = { "jxl", "jpegxl" }; + private static string[] KNOWN_MIME_TYPES = { "image/jxl" }; + + private static JpegXLFileFormatProperties instance = null; + + public static void init() { + instance = new JpegXLFileFormatProperties(); + } + + public static JpegXLFileFormatProperties get_instance() { + return instance; + } + + public override PhotoFileFormat get_file_format() { + return PhotoFileFormat.JPEGXL; + } + + public override PhotoFileFormatFlags get_flags() { + return PhotoFileFormatFlags.NONE; + } + + public override string get_user_visible_name() { + return _("JPEGXL"); + } + + public override string get_default_extension() { + return KNOWN_EXTENSIONS[0]; + } + + public override string[] get_known_extensions() { + return KNOWN_EXTENSIONS; + } + + public override string get_default_mime_type() { + return KNOWN_MIME_TYPES[0]; + } + + public override string[] get_mime_types() { + return KNOWN_MIME_TYPES; + } +} + +public class JpegXLSniffer : GdkSniffer { + // See https://github.com/ImageMagick/jpeg-xl/blob/main/doc/format_overview.md#file-format + private const uint8[] CODESTREAM_MAGIC_SEQUENCE = { 0xff, 0x0a }; + private const uint8[] BMFF_MAGIC_SEQUENCE = {0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A}; + + + public JpegXLSniffer(File file, PhotoFileSniffer.Options options) { + base (file, options); + } + + private static bool is_jpegxl_file(File file) throws Error { + FileInputStream instream = file.read(null); + + // Read out first four bytes + uint8[] file_lead_sequence = new uint8[BMFF_MAGIC_SEQUENCE.length]; + + var size = instream.read(file_lead_sequence, null); + + return size == BMFF_MAGIC_SEQUENCE.length && (Memory.cmp(CODESTREAM_MAGIC_SEQUENCE, file_lead_sequence, CODESTREAM_MAGIC_SEQUENCE.length) == 0 || + Memory.cmp(BMFF_MAGIC_SEQUENCE, file_lead_sequence, BMFF_MAGIC_SEQUENCE.length) == 0); + + } + + public override DetectedPhotoInformation? sniff(out bool is_corrupted) throws Error { + // Rely on GdkSniffer to detect corruption + is_corrupted = false; + + if (!is_jpegxl_file(file)) + return null; + + DetectedPhotoInformation? detected = base.sniff(out is_corrupted); + if (detected == null) + return null; + + return (detected.file_format == PhotoFileFormat.JPEGXL) ? detected : null; + } + +} + +public class JpegXLReader : GdkReader { + public JpegXLReader(string filepath) { + base (filepath, PhotoFileFormat.JPEGXL); + } +} + +public class JpegXLMetadataWriter : PhotoFileMetadataWriter { + public JpegXLMetadataWriter(string filepath) { + base (filepath, PhotoFileFormat.JPEGXL); + } + + public override void write_metadata(PhotoMetadata metadata) throws Error { + metadata.write_to_file(get_file()); + } +} + +public class JpegXLFileFormatDriver : PhotoFileFormatDriver { + private static JpegXLFileFormatDriver instance = null; + + public static void init() { + instance = new JpegXLFileFormatDriver(); + JpegXLFileFormatProperties.init(); + } + + public static JpegXLFileFormatDriver get_instance() { + return instance; + } + + public override PhotoFileFormatProperties get_properties() { + return JpegXLFileFormatProperties.get_instance(); + } + + public override PhotoFileReader create_reader(string filepath) { + return new JpegXLReader(filepath); + } + + public override bool can_write_image() { + return false; + } + + public override bool can_write_metadata() { + return true; + } + + public override PhotoFileWriter? create_writer(string filepath) { + return null; + } + + public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) { + return new JpegXLMetadataWriter(filepath); + } + + public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) { + return new JpegXLSniffer(file, options); + } + + public override PhotoMetadata create_metadata() { + return new PhotoMetadata(); + } +} + diff --git a/src/photos/PhotoFileFormat.vala b/src/photos/PhotoFileFormat.vala index e642008..4c69de3 100644 --- a/src/photos/PhotoFileFormat.vala +++ b/src/photos/PhotoFileFormat.vala @@ -58,12 +58,16 @@ public enum PhotoFileFormat { TIFF, BMP, GIF, + WEBP, + AVIF, + HEIF, + JPEGXL, UNKNOWN; // This is currently listed in the order of detection, that is, the file is examined from // left to right. (See PhotoFileInterrogator.) public static PhotoFileFormat[] get_supported() { - return { JFIF, RAW, PNG, TIFF, BMP, GIF }; + return { JFIF, RAW, PNG, TIFF, BMP, GIF, WEBP, AVIF, HEIF, JPEGXL }; } public static PhotoFileFormat[] get_writeable() { @@ -141,7 +145,19 @@ public enum PhotoFileFormat { case GIF: return 5; - + + case WEBP: + return 6; + + case AVIF: + return 7; + + case HEIF: + return 8; + + case JPEGXL: + return 9; + case UNKNOWN: default: return -1; @@ -169,6 +185,18 @@ public enum PhotoFileFormat { case 5: return GIF; + case 6: + return WEBP; + + case 7: + return AVIF; + + case 8: + return HEIF; + + case 9: + return JPEGXL; + default: return UNKNOWN; } @@ -217,7 +245,17 @@ public enum PhotoFileFormat { case "gif": return PhotoFileFormat.GIF; - + + case "heif/avif": + case "avif": + return PhotoFileFormat.AVIF; + + case "heif": + return PhotoFileFormat.HEIF; + + case "jxl": + return PhotoFileFormat.JPEGXL; + default: return PhotoFileFormat.UNKNOWN; } @@ -249,6 +287,22 @@ public enum PhotoFileFormat { Photos.GifFileFormatDriver.init(); break; + case WEBP: + Photos.WebpFileFormatDriver.init(); + break; + + case AVIF: + AvifFileFormatDriver.init(); + break; + + case HEIF: + HeifFileFormatDriver.init(); + break; + + case JPEGXL: + JpegXLFileFormatDriver.init(); + break; + default: error("Unsupported file format %s", this.to_string()); } @@ -274,6 +328,18 @@ public enum PhotoFileFormat { case GIF: return Photos.GifFileFormatDriver.get_instance(); + case WEBP: + return Photos.WebpFileFormatDriver.get_instance(); + + case AVIF: + return AvifFileFormatDriver.get_instance(); + + case HEIF: + return HeifFileFormatDriver.get_instance(); + + case JPEGXL: + return JpegXLFileFormatDriver.get_instance(); + default: error("Unsupported file format %s", this.to_string()); } diff --git a/src/photos/PhotoFileSniffer.vala b/src/photos/PhotoFileSniffer.vala index 7442fde..6358920 100644 --- a/src/photos/PhotoFileSniffer.vala +++ b/src/photos/PhotoFileSniffer.vala @@ -47,6 +47,34 @@ public abstract class PhotoFileSniffer { } public abstract DetectedPhotoInformation? sniff(out bool is_corrupted) throws Error; + + protected static bool is_supported_bmff_with_variants(File file, string[] variants) throws Error { + + FileInputStream instream = file.read(null); + + // Skip the first four bytes + if (instream.skip(4) != 4) { + return false; + } + + // The next four bytes need to be ftyp + var buf = new uint8[4]; + if (instream.read(buf, null) != 4) { + return false; + } + + if (Memory.cmp("ftyp".data, buf, 4) != 0) { + return false; + } + + if (instream.read(buf, null) != 4) { + return false; + } + + buf += '\0'; + + return (string)buf in variants; + } } // diff --git a/src/photos/PhotoMetadata.vala b/src/photos/PhotoMetadata.vala index a9b7457..3bf77d6 100644 --- a/src/photos/PhotoMetadata.vala +++ b/src/photos/PhotoMetadata.vala @@ -241,9 +241,13 @@ public class PhotoMetadata : MediaMetadata { public override Bytes flatten() throws Error { unowned GExiv2.PreviewProperties?[] props = owner.exiv2.get_preview_properties(); assert(props != null && props.length > number); - - return new - Bytes(owner.exiv2.get_preview_image(props[number]).get_data()); + + try { + return new + Bytes(owner.exiv2.try_get_preview_image(props[number]).get_data()); + } catch (Error err) { + return new Bytes(null); + } } } @@ -278,12 +282,8 @@ public class PhotoMetadata : MediaMetadata { exiv2 = new GExiv2.Metadata(); exif = null; -#if NEW_GEXIV2_API exiv2.open_buf(buffer[0:length]); -#else - exiv2.open_buf(buffer, length); -#endif - exif = Exif.Data.new_from_data(buffer); + exif = Exif.Data.new_from_data(buffer[0:length]); source_name = "<memory buffer %d bytes>".printf(length); } @@ -291,11 +291,8 @@ public class PhotoMetadata : MediaMetadata { exiv2 = new GExiv2.Metadata(); exif = null; -#if NEW_GEXIV2_API exiv2.from_app1_segment(buffer.get_data()); -#else exif = Exif.Data.new_from_data(buffer.get_data()); -#endif source_name = "<app1 segment %zu bytes>".printf(buffer.get_size()); } @@ -371,7 +368,11 @@ public class PhotoMetadata : MediaMetadata { } public bool has_tag(string tag) { - return exiv2.has_tag(tag); + try { + return exiv2.try_has_tag(tag); + } catch (Error error) { + return false; + } } private Gee.Set<string> create_string_set(owned CompareDataFunc<string>? compare_func) { @@ -397,6 +398,9 @@ public class PhotoMetadata : MediaMetadata { case MetadataDomain.IPTC: tags = exiv2.get_iptc_tags(); break; + default: + // Just ignore any other unknown tags + break; } if (tags == null || tags.length == 0) @@ -429,19 +433,35 @@ public class PhotoMetadata : MediaMetadata { } public string? get_tag_label(string tag) { - return GExiv2.Metadata.get_tag_label(tag); + try { + return GExiv2.Metadata.try_get_tag_label(tag); + } catch (Error error) { + return null; + } } public string? get_tag_description(string tag) { - return GExiv2.Metadata.get_tag_description(tag); + try { + return GExiv2.Metadata.try_get_tag_description(tag); + } catch (Error error) { + return null; + } } public string? get_string(string tag, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS) { - return prepare_input_text(exiv2.get_tag_string(tag), options, DEFAULT_USER_TEXT_INPUT_LENGTH); + try { + return prepare_input_text(exiv2.try_get_tag_string(tag), options, DEFAULT_USER_TEXT_INPUT_LENGTH); + } catch (Error error) { + return null; + } } public string? get_string_interpreted(string tag, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS) { - return prepare_input_text(exiv2.get_tag_interpreted_string(tag), options, DEFAULT_USER_TEXT_INPUT_LENGTH); + try { + return prepare_input_text(exiv2.try_get_tag_interpreted_string(tag), options, DEFAULT_USER_TEXT_INPUT_LENGTH); + } catch (Error error) { + return null; + } } public string? get_first_string(string[] tags) { @@ -469,26 +489,30 @@ public class PhotoMetadata : MediaMetadata { // NOTE: get_tag_multiple() in gexiv2 currently does not work with EXIF tags (as EXIF can // never return a list of strings). It will quietly return NULL if attempted. Until fixed // (there or here), don't use this function to access EXIF. See: - // http://trac.yorba.org/ticket/2966 + // https://gitlab.gnome.org/GNOME/gexiv2/issues/10 public Gee.List<string>? get_string_multiple(string tag) { - string[] values = exiv2.get_tag_multiple(tag); - if (values == null || values.length == 0) - return null; - - Gee.List<string> list = new Gee.ArrayList<string>(); - - Gee.HashSet<string> collection = new Gee.HashSet<string>(); - foreach (string value in values) { - string? prepped = prepare_input_text(value, PREPARE_STRING_OPTIONS, - DEFAULT_USER_TEXT_INPUT_LENGTH); - - if (prepped != null && !collection.contains(prepped)) { - list.add(prepped); - collection.add(prepped); + try { + string[] values = exiv2.try_get_tag_multiple(tag); + if (values == null || values.length == 0) + return null; + + Gee.List<string> list = new Gee.ArrayList<string>(); + + Gee.HashSet<string> collection = new Gee.HashSet<string>(); + foreach (string value in values) { + string? prepped = prepare_input_text(value, PREPARE_STRING_OPTIONS, + DEFAULT_USER_TEXT_INPUT_LENGTH); + + if (prepped != null && !collection.contains(prepped)) { + list.add(prepped); + collection.add(prepped); + } } + + return list.size > 0 ? list : null; + } catch (Error error) { + return null; } - - return list.size > 0 ? list : null; } // Returns a List that has been filtered through a Set, so no duplicates will be found. @@ -496,7 +520,7 @@ public class PhotoMetadata : MediaMetadata { // NOTE: get_tag_multiple() in gexiv2 currently does not work with EXIF tags (as EXIF can // never return a list of strings). It will quietly return NULL if attempted. Until fixed // (there or here), don't use this function to access EXIF. See: - // http://trac.yorba.org/ticket/2966 + // https://gitlab.gnome.org/GNOME/gexiv2/issues/10 public Gee.List<string>? get_first_string_multiple(string[] tags) { foreach (string tag in tags) { Gee.List<string>? values = get_string_multiple(tag); @@ -507,16 +531,20 @@ public class PhotoMetadata : MediaMetadata { return null; } - public void set_string(string tag, string value, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS) { - string? prepped = prepare_input_text(value, options, DEFAULT_USER_TEXT_INPUT_LENGTH); + public void set_string(string tag, string value, PrepareInputTextOptions options = PREPARE_STRING_OPTIONS, + int length = DEFAULT_USER_TEXT_INPUT_LENGTH) { + string? prepped = prepare_input_text(value, options, length); if (prepped == null) { warning("Not setting tag %s to string %s: invalid UTF-8", tag, value); return; } - if (!exiv2.set_tag_string(tag, prepped)) - warning("Unable to set tag %s to string %s from source %s", tag, value, source_name); + try { + exiv2.try_set_tag_string(tag, prepped); + } catch (Error error) { + warning("Unable to set tag %s to string %s from source %s: %s", tag, value, source_name, error.message); + } } private delegate void SetGenericValue(string tag); @@ -562,13 +590,16 @@ public class PhotoMetadata : MediaMetadata { return; // append a null pointer to the end of the string array -- this is a necessary - // workaround for http://trac.yorba.org/ticket/3264. See also - // http://trac.yorba.org/ticket/3257, which describes the user-visible behavior - // seen in the Flickr Connector as a result of the former bug. + // workaround for https://bugzilla.gnome.org/show_bug.cgi?id=712479. See also + // https://bugzilla.gnome.org/show_bug.cgi?id=717438, which describes the + // user-visible behavior seen in the Flickr Connector as a result of the former bug. values += null; - if (!exiv2.set_tag_multiple(tag, values)) - warning("Unable to set %d strings to tag %s from source %s", values.length, tag, source_name); + try { + exiv2.try_set_tag_multiple(tag, values); + } catch (Error err) { + warning("Unable to set %d strings to tag %s from source %s: %s", values.length, tag, source_name, err.message); + } } public void set_all_string_multiple(string[] tags, Gee.Collection<string> values, SetOption option) { @@ -576,13 +607,16 @@ public class PhotoMetadata : MediaMetadata { } public bool get_long(string tag, out long value) { + value = 0; if (!has_tag(tag)) { - value = 0; - return false; } - value = exiv2.get_tag_long(tag); + try { + value = exiv2.try_get_tag_long(tag); + } catch (Error error) { + return false; + } return true; } @@ -599,8 +633,11 @@ public class PhotoMetadata : MediaMetadata { } public void set_long(string tag, long value) { - if (!exiv2.set_tag_long(tag, value)) - warning("Unable to set tag %s to long %ld from source %s", tag, value, source_name); + try { + exiv2.try_set_tag_long(tag, value); + } catch (Error err) { + warning("Unable to set tag %s to long %ld from source %s: %s", tag, value, source_name, err.message); + } } public void set_all_long(string[] tags, long value, SetOption option) { @@ -609,11 +646,19 @@ public class PhotoMetadata : MediaMetadata { public bool get_rational(string tag, out MetadataRational rational) { int numerator, denominator; - bool result = exiv2.get_exif_tag_rational(tag, out numerator, out denominator); - - rational = MetadataRational(numerator, denominator); - - return result; + try { + if (exiv2.try_get_exif_tag_rational(tag, out numerator, out denominator)) { + rational = MetadataRational(numerator, denominator); + } else { + rational = MetadataRational.invalid(); + return false; + } + } catch (Error error) { + rational = MetadataRational.invalid(); + return false; + } + + return true; } public bool get_first_rational(string[] tags, out MetadataRational rational) { @@ -628,9 +673,11 @@ public class PhotoMetadata : MediaMetadata { } public void set_rational(string tag, MetadataRational rational) { - if (!exiv2.set_exif_tag_rational(tag, rational.numerator, rational.denominator)) { - warning("Unable to set tag %s to rational %s from source %s", tag, rational.to_string(), - source_name); + try { + exiv2.try_set_exif_tag_rational(tag, rational.numerator, rational.denominator); + } catch (Error err) { + warning("Unable to set tag %s to rational %s from source %s: %s", tag, rational.to_string(), + source_name, err.message); } } @@ -769,7 +816,10 @@ public class PhotoMetadata : MediaMetadata { } public void remove_exif_thumbnail() { - exiv2.erase_exif_thumbnail(); + try { + exiv2.try_erase_exif_thumbnail(); + } catch (Error err) { } + if (exif != null) { Exif.Mem.new_default().free(exif.data); exif.data = null; @@ -778,7 +828,9 @@ public class PhotoMetadata : MediaMetadata { } public void remove_tag(string tag) { - exiv2.clear_tag(tag); + try { + exiv2.try_clear_tag(tag); + } catch (Error err){} } public void remove_tags(string[] tags) { @@ -799,6 +851,9 @@ public class PhotoMetadata : MediaMetadata { case MetadataDomain.IPTC: exiv2.clear_iptc(); break; + default: + // Just ignore any unknown tags + break; } } @@ -881,7 +936,7 @@ public class PhotoMetadata : MediaMetadata { public static string[] HEIGHT_TAGS = { "Exif.Photo.PixelYDimension", "Xmp.exif.PixelYDimension", - "Xmp.tiff.ImageHeight", + "Xmp.tiff.ImageLength", "Xmp.exif.PixelYDimension" }; @@ -923,7 +978,7 @@ public class PhotoMetadata : MediaMetadata { // (sometimes) appropriate tag for the description. And there's general confusion about // whether Exif.Image.ImageDescription is a description (which is what the tag name // suggests) or a title (which is what the specification states). - // See: http://trac.yorba.org/wiki/PhotoTags + // See: https://wiki.gnome.org/Apps/Shotwell/PhotoTags // // Hence, the following logic tries to do the right thing in most of these cases. If // the iPhoto title tag is detected, it and the iPhoto description tag are used. Otherwise, @@ -997,8 +1052,9 @@ public class PhotoMetadata : MediaMetadata { * newlines from comments */ if (!is_string_empty(comment)) set_all_generic(COMMENT_TAGS, option, (tag) => { + // 4095 is coming from acdsee.notes which is limited to that set_string(tag, comment, PREPARE_STRING_OPTIONS & - ~PrepareInputTextOptions.STRIP_CRLF); + ~PrepareInputTextOptions.STRIP_CRLF, 4095); }); else remove_tags(COMMENT_TAGS); @@ -1139,24 +1195,37 @@ public class PhotoMetadata : MediaMetadata { } public bool has_orientation() { - return exiv2.get_orientation() == GExiv2.Orientation.UNSPECIFIED; + try { + return exiv2.try_get_orientation() == GExiv2.Orientation.UNSPECIFIED; + } catch (Error err) { + debug("Failed to get orientation: %s", err.message); + return false; + } } // If not present, returns Orientation.TOP_LEFT. public Orientation get_orientation() { // GExiv2.Orientation is the same value-wise as Orientation, with one exception: // GExiv2.Orientation.UNSPECIFIED must be handled - GExiv2.Orientation orientation = exiv2.get_orientation(); - if (orientation == GExiv2.Orientation.UNSPECIFIED || orientation < Orientation.MIN || - orientation > Orientation.MAX) + try { + GExiv2.Orientation orientation = exiv2.try_get_orientation(); + if (orientation == GExiv2.Orientation.UNSPECIFIED || orientation < Orientation.MIN || + orientation > Orientation.MAX) + return Orientation.TOP_LEFT; + else + return (Orientation) orientation; + } catch (Error error) { return Orientation.TOP_LEFT; - else - return (Orientation) orientation; + } } public void set_orientation(Orientation orientation) { // GExiv2.Orientation is the same value-wise as Orientation - exiv2.set_orientation((GExiv2.Orientation) orientation); + try { + exiv2.try_set_orientation((GExiv2.Orientation) orientation); + } catch (Error err) { + debug("Failed to set the orientation: %s", err.message); + } } public bool get_gps(out double longitude, out string long_ref, out double latitude, out string lat_ref, @@ -1164,14 +1233,22 @@ public class PhotoMetadata : MediaMetadata { longitude = 0.0; latitude = 0.0; altitude = 0.0; - if (!exiv2.get_gps_longitude(out longitude) || !exiv2.get_gps_latitude(out latitude)) { - long_ref = null; - lat_ref = null; - - return false; + try { + if (!exiv2.try_get_gps_longitude(out longitude) || !exiv2.try_get_gps_latitude(out latitude)) { + long_ref = null; + lat_ref = null; + + return false; + } + } catch (Error err) { + debug("Failed to get GPS lon/lat: %s", err.message); } - exiv2.get_gps_altitude(out altitude); + try { + exiv2.try_get_gps_altitude(out altitude); + } catch (Error err) { + debug("Failed to get GPS altitude: %s", err.message); + } long_ref = get_string("Exif.GPSInfo.GPSLongitudeRef"); lat_ref = get_string("Exif.GPSInfo.GPSLatitudeRef"); @@ -1179,6 +1256,37 @@ public class PhotoMetadata : MediaMetadata { return true; } + public GpsCoords get_gps_coords() { + GpsCoords gps_coords = GpsCoords(); + try { + double altitude; + gps_coords.has_gps = exiv2.try_get_gps_info(out gps_coords.longitude, out gps_coords.latitude, out altitude) ? 1 : 0; + if (gps_coords.has_gps > 0) { + if (get_string("Exif.GPSInfo.GPSLongitudeRef") == "W" && gps_coords.longitude > 0) + gps_coords.longitude = -gps_coords.longitude; + if (get_string("Exif.GPSInfo.GPSLatitudeRef") == "S" && gps_coords.latitude > 0) + gps_coords.latitude = -gps_coords.latitude; + } + } catch (Error err) { + gps_coords.has_gps = 0; + } + + return gps_coords; + } + + public void set_gps_coords(GpsCoords gps_coords) { + try { + if (gps_coords.has_gps > 0) { + var altitude = 0.0; + exiv2.try_get_gps_altitude(out altitude); + exiv2.try_set_gps_info(gps_coords.longitude, gps_coords.latitude, altitude); + } else + exiv2.try_delete_gps_info(); + } catch (Error err) { + debug("Failed to set or remove GPS info: %s", err.message); + } + } + public bool get_exposure(out MetadataRational exposure) { return get_rational("Exif.Photo.ExposureTime", out exposure); } @@ -1326,7 +1434,7 @@ public class PhotoMetadata : MediaMetadata { // Other photo managers, notably F-Spot, take hints from Urgency fields about what the rating // of an imported photo should be, and we have decided to do as well. Xmp.xmp.Rating is the only // field we've seen photo manages export ratings to, while Urgency fields seem to have a fundamentally - // different meaning. See http://trac.yorba.org/wiki/PhotoTags#Rating for more information. + // different meaning. See https://wiki.gnome.org/Apps/Shotwell/PhotoTags#Rating for more information. public void set_rating(Rating rating) { int int_rating = rating.serialize(); set_string("Xmp.xmp.Rating", int_rating.to_string()); diff --git a/src/photos/PngSupport.vala b/src/photos/PngSupport.vala index c891136..e154fc4 100644 --- a/src/photos/PngSupport.vala +++ b/src/photos/PngSupport.vala @@ -88,33 +88,6 @@ public class PngReader : GdkReader { public PngReader(string filepath) { base (filepath, PhotoFileFormat.PNG); } - - public override Gdk.Pixbuf scaled_read(Dimensions full, Dimensions scaled) throws Error { - Gdk.Pixbuf result = null; - /* if we encounter a situation where there are two orders of magnitude or more of - difference between the full image size and the scaled size, and if the full image - size has five or more decimal digits of precision, Gdk.Pixbuf.from_file_at_scale( ) can - fail due to what appear to be floating-point round-off issues. This isn't surprising, - since 32-bit floats only have 6-7 decimal digits of precision in their mantissa. In - this case, we prefetch the image at a larger scale and then downsample it to the - desired scale as a post-process step. This short-circuits Gdk.Pixbuf's buggy - scaling code. */ - if (((full.width > 9999) || (full.height > 9999)) && ((scaled.width < 100) || - (scaled.height < 100))) { - Dimensions prefetch_dimensions = full.get_scaled_by_constraint(1000, - ScaleConstraint.DIMENSIONS); - - result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), prefetch_dimensions.width, - prefetch_dimensions.height, false); - - result = result.scale_simple(scaled.width, scaled.height, Gdk.InterpType.HYPER); - } else { - result = new Gdk.Pixbuf.from_file_at_scale(get_filepath(), scaled.width, - scaled.height, false); - } - - return result; - } } public class PngWriter : PhotoFileWriter { diff --git a/src/photos/RawSupport.vala b/src/photos/RawSupport.vala index 8c23826..538c949 100644 --- a/src/photos/RawSupport.vala +++ b/src/photos/RawSupport.vala @@ -51,7 +51,7 @@ public class RawFileFormatDriver : PhotoFileFormatDriver { public class RawFileFormatProperties : PhotoFileFormatProperties { private static string[] KNOWN_EXTENSIONS = { - "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cap", "iiq", "eip", "dcs", "dcr", "drf", + "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cr3", "cap", "iiq", "eip", "dcs", "dcr", "drf", "k25", "kdc", "dng", "erf", "fff", "mef", "mos", "mrw", "nef", "nrw", "orf", "ptx", "pef", "pxn", "r3d", "raf", "raw", "rw2", "raw", "rwl", "rwz", "x3f", "srw" }; @@ -63,6 +63,7 @@ public class RawFileFormatProperties : PhotoFileFormatProperties { /* manufacturer blessed MIME types */ "image/x-canon-cr2", + "image/x-canon-cr3", "image/x-canon-crw", "image/x-fuji-raf", "image/x-adobe-dng", @@ -85,6 +86,7 @@ public class RawFileFormatProperties : PhotoFileFormatProperties { "image/x-bay", "image/x-crw", "image/x-cr2", + "image/x-cr3", "image/x-cap", "image/x-iiq", "image/x-eip", @@ -174,7 +176,6 @@ public class RawSniffer : PhotoFileSniffer { try { processor.open_file(file.get_path()); - processor.unpack(); processor.adjust_sizes_info_only(); } catch (GRaw.Exception exception) { if (exception is GRaw.Exception.UNSUPPORTED_FILE) @@ -195,7 +196,7 @@ public class RawSniffer : PhotoFileSniffer { // ignored } - if (detected.metadata != null) { + if (calc_md5 && detected.metadata != null) { detected.exif_md5 = detected.metadata.exif_hash(); detected.thumbnail_md5 = detected.metadata.thumbnail_hash(); } @@ -211,15 +212,19 @@ public class RawSniffer : PhotoFileSniffer { } public class RawReader : PhotoFileReader { + private PhotoMetadata? cached_metadata = null; + public RawReader(string filepath) { base (filepath, PhotoFileFormat.RAW); } public override PhotoMetadata read_metadata() throws Error { - PhotoMetadata metadata = new PhotoMetadata(); - metadata.read_from_file(get_file()); - - return metadata; + if (cached_metadata == null) { + PhotoMetadata metadata = new PhotoMetadata(); + metadata.read_from_file(get_file()); + cached_metadata = metadata; + } + return cached_metadata; } public override Gdk.Pixbuf unscaled_read() throws Error { diff --git a/src/photos/TiffSupport.vala b/src/photos/TiffSupport.vala index 7ed8b98..cadcd0e 100644 --- a/src/photos/TiffSupport.vala +++ b/src/photos/TiffSupport.vala @@ -151,6 +151,9 @@ private class TiffMetadataWriter : PhotoFileMetadataWriter { } } +private const uint16 FILE_MARKER_TIFF = 42; +private const uint16 FILE_MARKER_BIGTIFF = 43; + public bool is_tiff(File file, Cancellable? cancellable = null) throws Error { DataInputStream dins = new DataInputStream(file.read()); @@ -173,8 +176,9 @@ public bool is_tiff(File file, Cancellable? cancellable = null) throws Error { // second two bytes: some random number uint16 lue = dins.read_uint16(cancellable); - if (lue != 42) + if (lue != FILE_MARKER_TIFF && lue != FILE_MARKER_BIGTIFF) { return false; + } // remaining bytes are offset of first IFD, which doesn't matter for our purposes return true; diff --git a/src/photos/WebPSupport.vala b/src/photos/WebPSupport.vala new file mode 100644 index 0000000..2f4723c --- /dev/null +++ b/src/photos/WebPSupport.vala @@ -0,0 +1,240 @@ +/* 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. + */ + +namespace Photos { + +public class WebpFileFormatDriver : PhotoFileFormatDriver { + private static WebpFileFormatDriver instance = null; + + public static void init() { + instance = new WebpFileFormatDriver(); + WebpFileFormatProperties.init(); + } + + public static WebpFileFormatDriver get_instance() { + return instance; + } + + public override PhotoFileFormatProperties get_properties() { + return WebpFileFormatProperties.get_instance(); + } + + public override PhotoFileReader create_reader(string filepath) { + return new WebpReader(filepath); + } + + public override PhotoMetadata create_metadata() { + return new PhotoMetadata(); + } + + public override bool can_write_image() { + return false; + } + + public override bool can_write_metadata() { + return true; + } + + public override PhotoFileWriter? create_writer(string filepath) { + return null; + } + + public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) { + return new WebpMetadataWriter(filepath); + } + + public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) { + return new WebpSniffer(file, options); + } +} + +private class WebpFileFormatProperties : PhotoFileFormatProperties { + private static string[] KNOWN_EXTENSIONS = { + "webp" + }; + + private static string[] KNOWN_MIME_TYPES = { + "image/webp" + }; + + private static WebpFileFormatProperties instance = null; + + public static void init() { + instance = new WebpFileFormatProperties(); + } + + public static WebpFileFormatProperties get_instance() { + return instance; + } + + public override PhotoFileFormat get_file_format() { + return PhotoFileFormat.WEBP; + } + + public override PhotoFileFormatFlags get_flags() { + return PhotoFileFormatFlags.NONE; + } + + public override string get_default_extension() { + return "webp"; + } + + public override string get_user_visible_name() { + return _("WebP"); + } + + public override string[] get_known_extensions() { + return KNOWN_EXTENSIONS; + } + + public override string get_default_mime_type() { + return KNOWN_MIME_TYPES[0]; + } + + public override string[] get_mime_types() { + return KNOWN_MIME_TYPES; + } +} + +private class WebpSniffer : PhotoFileSniffer { + private DetectedPhotoInformation detected = null; + + public WebpSniffer(File file, PhotoFileSniffer.Options options) { + base (file, options); + detected = new DetectedPhotoInformation(); + } + + public override DetectedPhotoInformation? sniff(out bool is_corrupted) throws Error { + is_corrupted = false; + + if (!is_webp(file)) + return null; + + // valac chokes on the ternary operator here + Checksum? md5_checksum = null; + if (calc_md5) + md5_checksum = new Checksum(ChecksumType.MD5); + + detected.metadata = new PhotoMetadata(); + try { + detected.metadata.read_from_file(file); + } catch (Error err) { + debug("Failed to load meta-data from file: %s", err.message); + // no metadata detected + detected.metadata = null; + } + + if (calc_md5 && detected.metadata != null) { + detected.exif_md5 = detected.metadata.exif_hash(); + detected.thumbnail_md5 = detected.metadata.thumbnail_hash(); + } + + // if no MD5, don't read as much, as the needed info will probably be gleaned + // in the first 8K to 16K + uint8[] buffer = calc_md5 ? new uint8[64 * 1024] : new uint8[8 * 1024]; + size_t count = 0; + + // loop through until all conditions we're searching for are met + FileInputStream fins = file.read(null); + var ba = new ByteArray(); + for (;;) { + size_t bytes_read = fins.read(buffer, null); + if (bytes_read <= 0) + break; + + ba.append(buffer[0:bytes_read]); + + count += bytes_read; + + if (calc_md5) + md5_checksum.update(buffer, bytes_read); + + WebP.Data d = WebP.Data(); + d.bytes = ba.data; + + WebP.ParsingState state; + var demux = new WebP.Demuxer.partial(d, out state); + + if (state == WebP.ParsingState.PARSE_ERROR) { + is_corrupted = true; + break; + } + + if (state > WebP.ParsingState.PARSED_HEADER) { + detected.file_format = PhotoFileFormat.WEBP; + detected.format_name = "WebP"; + detected.channels = 4; + detected.bits_per_channel = 8; + detected.image_dim.width = (int) demux.get(WebP.FormatFeature.CANVAS_WIDTH); + detected.image_dim.height = (int) demux.get(WebP.FormatFeature.CANVAS_HEIGHT); + + // if not searching for anything else, exit + if (!calc_md5) + break; + } + } + + if (fins != null) + fins.close(null); + + if (calc_md5) + detected.md5 = md5_checksum.get_string(); + + return detected; + } +} + +private class WebpReader : PhotoFileReader { + public WebpReader(string filepath) { + base (filepath, PhotoFileFormat.WEBP); + } + + public override PhotoMetadata read_metadata() throws Error { + PhotoMetadata metadata = new PhotoMetadata(); + metadata.read_from_file(get_file()); + + return metadata; + } + + public override Gdk.Pixbuf unscaled_read() throws Error { + uint8[] buffer; + + FileUtils.get_data(this.get_filepath(), out buffer); + int width, height; + var pixdata = WebP.DecodeRGBA(buffer, out width, out height); + pixdata.length = width * height * 4; + + return new Gdk.Pixbuf.from_data(pixdata, Gdk.Colorspace.RGB, true, 8, width, height, width * 4); + } +} + +private class WebpMetadataWriter : PhotoFileMetadataWriter { + public WebpMetadataWriter(string filepath) { + base (filepath, PhotoFileFormat.WEBP); + } + + public override void write_metadata(PhotoMetadata metadata) throws Error { + metadata.write_to_file(get_file()); + } +} + +public bool is_webp(File file, Cancellable? cancellable = null) throws Error { + var ins = file.read(); + + uint8 buffer[12]; + try { + ins.read(buffer, null); + if (buffer[0] == 'R' && buffer[1] == 'I' && buffer[2] == 'F' && buffer[3] == 'F' && + buffer[8] == 'W' && buffer[9] == 'E' && buffer[10] == 'B' && buffer[11] == 'P') + return true; + } catch (Error error) { + debug ("Failed to read from file %s: %s", file.get_path (), error.message); + } + + return false; +} + +} diff --git a/src/plugins/DataImportsInterfaces.vala b/src/plugins/DataImportsInterfaces.vala index f2c8a53..518f8d0 100644 --- a/src/plugins/DataImportsInterfaces.vala +++ b/src/plugins/DataImportsInterfaces.vala @@ -120,7 +120,7 @@ public interface ImportableMediaItem : GLib.Object { public abstract string get_filename(); - public abstract time_t? get_exposure_time(); + public abstract DateTime? get_exposure_time(); } /** @@ -416,7 +416,7 @@ public interface PluginHost : GLib.Object, Spit.HostInterface { * @param host_progress_delta the amount of progress the host should update * the progress bar during import preparation. Plugins should ensure that * a proportion of progress for each media item is set aside for the host - * in oder to ensure a smoother update to the progress bar. + * in order to ensure a smoother update to the progress bar. * * @param progress_message the text to be displayed below the progress bar. If that * parameter is null, the message will be left unchanged. diff --git a/src/plugins/ManifestWidget.vala b/src/plugins/ManifestWidget.vala index 8fb0ba2..55ccdc3 100644 --- a/src/plugins/ManifestWidget.vala +++ b/src/plugins/ManifestWidget.vala @@ -10,10 +10,7 @@ namespace Plugins { [GtkTemplate (ui = "/org/gnome/Shotwell/ui/manifest_widget.ui")] public class ManifestWidgetMediator : Gtk.Box { [GtkChild] - private Gtk.Button about_button; - - [GtkChild] - private Gtk.ScrolledWindow list_bin; + private unowned Gtk.ScrolledWindow list_bin; private ManifestListView list = new ManifestListView(); @@ -21,247 +18,212 @@ public class ManifestWidgetMediator : Gtk.Box { Object(); list_bin.add(list); - - about_button.clicked.connect(on_about); - list.get_selection().changed.connect(on_selection_changed); - - set_about_button_sensitivity(); + } +} + +private class CollectionModel<G> : GLib.ListModel, Object { + private Gee.Collection<G> target; + private unowned Gee.List<G>? as_list = null; + + public CollectionModel(Gee.Collection<G> target) { + Object(); + this.target = target.read_only_view; + if (this.target is Gee.List) { + this.as_list = (Gee.List<G>)this.target; + } } - - private void on_about() { - string[] ids = list.get_selected_ids(); - if (ids.length == 0) - return; - - string id = ids[0]; - - Spit.PluggableInfo info = Spit.PluggableInfo(); - if (!get_pluggable_info(id, ref info)) { - warning("Unable to retrieve information for plugin %s", id); - - return; + + GLib.Object? get_item(uint position) { + if (position >= this.target.size) { + return null; } - - // prepare authors names (which are comma-delimited by the plugin) for the about box - // (which wants an array of names) - string[]? authors = null; - if (info.authors != null) { - string[] split = info.authors.split(","); - for (int ctr = 0; ctr < split.length; ctr++) { - string stripped = split[ctr].strip(); - if (!is_string_empty(stripped)) { - if (authors == null) - authors = new string[0]; - - authors += stripped; - } + + if (this.as_list != null) { + return (GLib.Object) this.as_list.@get((int) position); + } + + var count = 0U; + foreach (var g in this.target) { + if (count == position) { + return (GLib.Object)g; } + count++; } - - Gtk.AboutDialog about_dialog = new Gtk.AboutDialog(); - about_dialog.authors = authors; - about_dialog.comments = info.brief_description; - about_dialog.copyright = info.copyright; - about_dialog.license = info.license; - about_dialog.wrap_license = info.is_license_wordwrapped; - about_dialog.logo = (info.icons != null && info.icons.length > 0) ? info.icons[0] : - Resources.get_icon(Resources.ICON_GENERIC_PLUGIN); - about_dialog.program_name = get_pluggable_name(id); - about_dialog.translator_credits = info.translators; - about_dialog.version = info.version; - about_dialog.website = info.website_url; - about_dialog.website_label = info.website_name; - - about_dialog.run(); - - about_dialog.destroy(); + + return null; } - - private void on_selection_changed() { - set_about_button_sensitivity(); + + GLib.Type get_item_type() { + return typeof(G); } - - private void set_about_button_sensitivity() { - // have to get the array and then get its length rather than do so in one call due to a - // bug in Vala 0.10: - // list.get_selected_ids().length -> uninitialized value - // this appears to be fixed in Vala 0.11 - string[] ids = list.get_selected_ids(); - about_button.sensitive = (ids.length == 1); + + uint get_n_items() { + return this.target.size; } + } -private class ManifestListView : Gtk.TreeView { - private const int ICON_SIZE = 24; - private const int ICON_X_PADDING = 6; - private const int ICON_Y_PADDING = 2; - - private enum Column { - ENABLED, - CAN_ENABLE, - ICON, - NAME, - ID, - N_COLUMNS +private class Selection : Object { + public signal void changed(); +} + +private class PluggableRow : Gtk.Box { + public Spit.Pluggable pluggable { get; construct; } + public bool enabled {get; construct; } + + public PluggableRow(Spit.Pluggable pluggable_, bool enable_) { + Object(orientation: Gtk.Orientation.VERTICAL, pluggable: pluggable_, + enabled: enable_, margin_top: 6, margin_bottom:6, margin_start:6, margin_end:6); } - - private Gtk.TreeStore store = new Gtk.TreeStore(Column.N_COLUMNS, - typeof(bool), // ENABLED - typeof(bool), // CAN_ENABLE - typeof(Gdk.Pixbuf), // ICON - typeof(string), // NAME - typeof(string) // ID - ); - - public ManifestListView() { - set_model(store); - - Gtk.CellRendererToggle checkbox_renderer = new Gtk.CellRendererToggle(); - checkbox_renderer.radio = false; - checkbox_renderer.activatable = true; - - Gtk.CellRendererPixbuf icon_renderer = new Gtk.CellRendererPixbuf(); - icon_renderer.stock_size = Gtk.IconSize.MENU; - icon_renderer.xpad = ICON_X_PADDING; - icon_renderer.ypad = ICON_Y_PADDING; - - Gtk.CellRendererText text_renderer = new Gtk.CellRendererText(); - - Gtk.TreeViewColumn column = new Gtk.TreeViewColumn(); - column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE); - column.pack_start(checkbox_renderer, false); - column.pack_start(icon_renderer, false); - column.pack_end(text_renderer, true); - - column.add_attribute(checkbox_renderer, "active", Column.ENABLED); - column.add_attribute(checkbox_renderer, "visible", Column.CAN_ENABLE); - column.add_attribute(icon_renderer, "pixbuf", Column.ICON); - column.add_attribute(text_renderer, "text", Column.NAME); - - append_column(column); + + public override void constructed() { + base.constructed(); + var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6); + pack_start(content, true); + + var revealer = new Gtk.Revealer(); + revealer.margin_top = 6; + pack_end(revealer, true); - set_headers_visible(false); - set_enable_search(false); - set_show_expanders(true); - set_reorderable(false); - set_enable_tree_lines(false); - set_grid_lines(Gtk.TreeViewGridLines.NONE); - get_selection().set_mode(Gtk.SelectionMode.BROWSE); + var info = pluggable.get_info(); - Gtk.IconTheme icon_theme = Resources.get_icon_theme_engine(); + var image = new Gtk.Image.from_icon_name(info.icon_name, Gtk.IconSize.BUTTON); + content.pack_start(image, false, false, 6); + image.hexpand = false; + + var label = new Gtk.Label(pluggable.get_pluggable_name()); + label.halign = Gtk.Align.START; + content.pack_start(label, true, true, 6); + + var button = new Gtk.ToggleButton(); + button.get_style_context().add_class("flat"); + content.pack_end(button, false, false, 6); + button.bind_property("active", revealer, "reveal-child", BindingFlags.DEFAULT); + image = new Gtk.Image.from_icon_name("go-down-symbolic", Gtk.IconSize.SMALL_TOOLBAR); + button.add(image); + + var plugin_enabled = new Gtk.Switch(); + plugin_enabled.hexpand = false; + plugin_enabled.vexpand = false; + plugin_enabled.valign = Gtk.Align.CENTER; + plugin_enabled.set_active(enabled); + + content.pack_end(plugin_enabled, false, false, 6); + plugin_enabled.notify["active"].connect(() => { + var id = pluggable.get_id(); + set_pluggable_enabled(id, plugin_enabled.active); + }); + + if (pluggable is Spit.Publishing.Service) { +#if 0 + var manage = new Gtk.Button.from_icon_name("avatar-default-symbolic", Gtk.IconSize.SMALL_TOOLBAR); + manage.get_style_context().add_class("flat"); + // TRANSLATORS: %s is the name of an online service such as YouTube, Mastodon, ... + manage.set_tooltip_text(_("Manage accounts for %s").printf(pluggable.get_pluggable_name())); + content.pack_start(manage, false, false, 6); +#endif + } + + var grid = new Gtk.Grid(); + grid.get_style_context().add_class("content"); + grid.set_row_spacing(12); + grid.set_column_spacing(6); + revealer.add(grid); + label = new Gtk.Label(info.copyright); + label.hexpand = true; + label.halign = Gtk.Align.START; + grid.attach(label, 0, 0, 2, 1); + label = new Gtk.Label(_("Authors")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 1, 1, 1); + label = new Gtk.Label(info.authors); + label.halign = Gtk.Align.START; + label.hexpand = true; + grid.attach(label, 1, 1, 1, 1); + + label = new Gtk.Label(_("Version")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 2, 1, 1); + label = new Gtk.Label(info.version); + label.halign = Gtk.Align.START; + label.hexpand = true; + grid.attach(label, 1, 2, 1, 1); + + label = new Gtk.Label(_("License")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 3, 1, 1); + var link = new Gtk.LinkButton.with_label(info.license_url, info.license_blurp); + link.halign = Gtk.Align.START; + // remove the annoying padding around the link + link.get_style_context().remove_class("text-button"); + link.get_style_context().add_class("shotwell-plain-link"); + grid.attach(link, 1, 3, 1, 1); + + label = new Gtk.Label(_("Website")); + label.get_style_context().add_class("dim-label"); + label.halign = Gtk.Align.END; + label.margin_start = 12; + grid.attach(label, 0, 4, 1, 1); + link = new Gtk.LinkButton.with_label(info.website_url, info.website_name); + link.halign = Gtk.Align.START; + // remove the annoying padding around the link + link.get_style_context().remove_class("text-button"); + link.get_style_context().add_class("shotwell-plain-link"); + grid.attach(link, 1, 4, 1, 1); - // create a list of plugins (sorted by name) that are separated by extension points (sorted - // by name) - foreach (ExtensionPoint extension_point in get_extension_points(compare_extension_point_names)) { - Gtk.TreeIter category_iter; - store.append(out category_iter, null); - - Gdk.Pixbuf? icon = null; - if (extension_point.icon_name != null) { - Gtk.IconInfo? icon_info = icon_theme.lookup_by_gicon( - new ThemedIcon(extension_point.icon_name), ICON_SIZE, 0); - if (icon_info != null) { - try { - icon = icon_info.load_icon(); - } catch (Error err) { - warning("Unable to load icon %s: %s", extension_point.icon_name, err.message); - } - } - } - - store.set(category_iter, Column.NAME, extension_point.name, Column.CAN_ENABLE, false, - Column.ICON, icon); - - Gee.Collection<Spit.Pluggable> pluggables = get_pluggables_for_type( - extension_point.pluggable_type, compare_pluggable_names, true); - foreach (Spit.Pluggable pluggable in pluggables) { + } +} + +private class ManifestListView : Gtk.Box { + public ManifestListView() { + Object(orientation: Gtk.Orientation.VERTICAL, spacing: 6); + } + + public signal void row_selected(Spit.Pluggable? pluggable); + + public override void constructed() { + base.constructed(); + + foreach (var extension_point in get_extension_points(compare_extension_point_names)) { + var label = new Gtk.Label(null); + label.set_markup("<span weight=\"bold\">%s</span>".printf(extension_point.name)); + label.halign = Gtk.Align.START; + label.hexpand = true; + add(label); + + var pluggables = get_pluggables_for_type(extension_point.pluggable_type, compare_pluggable_names, true); + var box = new Gtk.ListBox(); + box.set_selection_mode(Gtk.SelectionMode.NONE); + box.hexpand = true; + box.margin_start = 12; + box.margin_end = 12; + + var added = 0; + foreach (var pluggable in pluggables) { bool enabled; + if (!get_pluggable_enabled(pluggable.get_id(), out enabled)) continue; - - Spit.PluggableInfo info = Spit.PluggableInfo(); - pluggable.get_info(ref info); - - icon = (info.icons != null && info.icons.length > 0) - ? info.icons[0] - : Resources.get_icon(Resources.ICON_GENERIC_PLUGIN, ICON_SIZE); - - Gtk.TreeIter plugin_iter; - store.append(out plugin_iter, category_iter); - - store.set(plugin_iter, Column.ENABLED, enabled, Column.NAME, pluggable.get_pluggable_name(), - Column.ID, pluggable.get_id(), Column.CAN_ENABLE, true, Column.ICON, icon); + + var pluggable_row = new PluggableRow(pluggable, enabled); + + added++; + box.insert(pluggable_row, -1); + } + if (added > 0) { + add(box); } } - - expand_all(); - } - - public string[] get_selected_ids() { - string[] ids = new string[0]; - - List<Gtk.TreePath> selected = get_selection().get_selected_rows(null); - foreach (Gtk.TreePath path in selected) { - Gtk.TreeIter iter; - string? id = get_id_at_path(path, out iter); - if (id != null) - ids += id; - } - - return ids; - } - - private string? get_id_at_path(Gtk.TreePath path, out Gtk.TreeIter iter) { - if (!store.get_iter(out iter, path)) - return null; - - unowned string id; - store.get(iter, Column.ID, out id); - - return id; - } - // Because we want each row to left-align and not for each column to line up in a grid - // (otherwise the checkboxes -- hidden or not -- would cause the rest of the row to line up - // along the icon's left edge), we put all the renderers into a single column. However, the - // checkbox renderer then triggers its "toggle" signal any time the row is single-clicked, - // whether or not the actual checkbox hit-tests. - // - // The only way found to work around this is to capture the button-down event and do our own - // hit-testing. - public override bool button_press_event(Gdk.EventButton event) { - Gtk.TreePath path; - Gtk.TreeViewColumn col; - int cellx; - int celly; - if (!get_path_at_pos((int) event.x, (int) event.y, out path, out col, out cellx, - out celly)) - return base.button_press_event(event); - - // Perform custom hit testing as described above. The first cell in the column is offset - // from the left edge by whatever size the group description icon is allocated (including - // padding). - if (cellx < (ICON_SIZE + ICON_X_PADDING) || cellx > (2 * (ICON_X_PADDING + ICON_SIZE))) - return base.button_press_event(event); - - Gtk.TreeIter iter; - string? id = get_id_at_path(path, out iter); - if (id == null) - return base.button_press_event(event); - - bool enabled; - if (!get_pluggable_enabled(id, out enabled)) - return base.button_press_event(event); - - // toggle and set - enabled = !enabled; - set_pluggable_enabled(id, enabled); - - store.set(iter, Column.ENABLED, enabled); - - return true; + show_all(); } -} +} } diff --git a/src/plugins/Plugins.vala b/src/plugins/Plugins.vala index 6aff461..cfab7e8 100644 --- a/src/plugins/Plugins.vala +++ b/src/plugins/Plugins.vala @@ -6,10 +6,6 @@ namespace Plugins { -// GModule doesn't have a truly generic way to determine if a file is a shared library by extension, -// so these are hard-coded -private const string[] SHARED_LIB_EXTS = { "so", "la" }; - // Although not expecting this system to last very long, these ranges declare what versions of this // interface are supported by the current implementation. private const int MIN_SPIT_INTERFACE = 0; @@ -39,8 +35,12 @@ private class ModuleRep { private ModuleRep(File file) { this.file = file; - + +#if VALA_0_46 + module = Module.open(file.get_path(), ModuleFlags.LAZY); +#else module = Module.open(file.get_path(), ModuleFlags.BIND_LAZY); +#endif } ~ModuleRep() { @@ -221,7 +221,7 @@ public string? get_pluggable_module_id(Spit.Pluggable needle) { return (module_rep != null) ? module_rep.spit_module.get_id() : null; } -public Gee.Collection<ExtensionPoint> get_extension_points(owned CompareDataFunc? compare_func = null) { +public Gee.Collection<ExtensionPoint> get_extension_points(owned CompareDataFunc<ExtensionPoint>? compare_func = null) { Gee.Collection<ExtensionPoint> sorted = new Gee.TreeSet<ExtensionPoint>((owned) compare_func); sorted.add_all(extension_points.values); @@ -229,7 +229,7 @@ public Gee.Collection<ExtensionPoint> get_extension_points(owned CompareDataFunc } public Gee.Collection<Spit.Pluggable> get_pluggables_for_type(Type type, - owned CompareDataFunc? compare_func = null, bool include_disabled = false) { + owned CompareDataFunc<Spit.Pluggable>? compare_func = null, bool include_disabled = false) { // if this triggers it means the extension point didn't register itself at init() time assert(extension_points.has_key(type)); @@ -252,12 +252,14 @@ public string? get_pluggable_name(string id) { ? pluggable_rep.pluggable.get_pluggable_name() : null; } -public bool get_pluggable_info(string id, ref Spit.PluggableInfo info) { +public bool get_pluggable_info(string id, out Spit.PluggableInfo info) { PluggableRep? pluggable_rep = pluggable_table.get(id); - if (pluggable_rep == null || !pluggable_rep.activated) + if (pluggable_rep == null || !pluggable_rep.activated) { + info = null; return false; + } - pluggable_rep.pluggable.get_info(ref info); + info = pluggable_rep.pluggable.get_info(); return true; } @@ -290,30 +292,19 @@ public File get_pluggable_module_file(Spit.Pluggable pluggable) { return (module_rep != null) ? module_rep.file : null; } -public int compare_pluggable_names(void *a, void *b) { - Spit.Pluggable *apluggable = (Spit.Pluggable *) a; - Spit.Pluggable *bpluggable = (Spit.Pluggable *) b; - - return apluggable->get_pluggable_name().collate(bpluggable->get_pluggable_name()); +public int compare_pluggable_names(Spit.Pluggable a, Spit.Pluggable b) { + return a.get_pluggable_name().collate(b.get_pluggable_name()); } -public int compare_extension_point_names(void *a, void *b) { - ExtensionPoint *apoint = (ExtensionPoint *) a; - ExtensionPoint *bpoint = (ExtensionPoint *) b; - - return apoint->name.collate(bpoint->name); +public int compare_extension_point_names(ExtensionPoint a, ExtensionPoint b) { + return a.name.collate(b.name); } private bool is_shared_library(File file) { string name, ext; disassemble_filename(file.get_basename(), out name, out ext); - - foreach (string shared_ext in SHARED_LIB_EXTS) { - if (ext == shared_ext) - return true; - } - - return false; + + return ext == Module.SUFFIX; } private void search_for_plugins(File dir) throws Error { diff --git a/src/plugins/PublishingInterfaces.vala b/src/plugins/PublishingInterfaces.vala index 6518142..05b161f 100644 --- a/src/plugins/PublishingInterfaces.vala +++ b/src/plugins/PublishingInterfaces.vala @@ -9,7 +9,7 @@ * * The Shotwell Pluggable Publishing API allows you to write plugins that upload * photos and videos to web services. The Shotwell distribution includes publishing - * support for four core services: Facebook, Flickr, Picasa Web Albums, and YouTube. + * support for three core services: Flickr, Google Photos, and YouTube. * To enable Shotwell to connect to additional services, developers like you write * publishing plugins, dynamically-loadable shared objects that are linked into the * Shotwell process at runtime. Publishing plugins are just one of several kinds of @@ -87,7 +87,7 @@ public errordomain PublishingError { /** * Indicates that a secure connection to the remote host cannot be * established. This might have various reasons such as expired - * certificats, invalid certificates, self-signed certificates... + * certificates, invalid certificates, self-signed certificates... */ SSL_FAILED } @@ -268,6 +268,8 @@ public interface PluginHost : GLib.Object, Spit.HostInterface { CANCEL = 1 } + public abstract string get_current_profile_id(); + /** * Notifies the user that an unrecoverable publishing error has occurred and halts * the publishing process. @@ -367,7 +369,7 @@ public interface PluginHost : GLib.Object, Spit.HostInterface { * The text displayed depends on the type of media the current publishing service * supports. To provide visual consistency across publishing services and to allow * Shotwell to handle internationalization, always use this convenience method; don’t - * contruct and install success panes manually. + * construct and install success panes manually. * * If an error has posted, the {@link PluginHost} will not honor * this request. @@ -413,7 +415,7 @@ public interface PluginHost : GLib.Object, Spit.HostInterface { * the callback 'on_login_clicked'. Every Publisher should provide a welcome pane to * introduce the service and explain service-specific features or restrictions. To provide * visual consistency across publishing services and to allow Shotwell to handle - * internationalization, always use this convenience method; don’t contruct and install + * internationalization, always use this convenience method; don’t construct and install * welcome panes manually. * * If an error has posted, the {@link PluginHost} will not honor this request. @@ -565,6 +567,11 @@ public interface Publishable : GLib.Object { */ public abstract GLib.DateTime get_exposure_date_time(); + /** + * Returns the rating on the file. + */ + public abstract uint get_rating(); + // // For future expansion. // @@ -578,6 +585,17 @@ public interface Publishable : GLib.Object { protected virtual void reserved7() {} } +public interface Account : Object { + public abstract string display_name(); +} + +public class DefaultAccount : Spit.Publishing.Account, Object { + public string display_name() { + return ""; + } +} + + /** * Describes the features and capabilities of a remote publishing service. * @@ -590,10 +608,26 @@ public interface Service : Object, Spit.Pluggable { */ public abstract Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host); + public virtual Spit.Publishing.Publisher create_publisher_with_account(Spit.Publishing.PluginHost host, + Spit.Publishing.Account? account) { + return this.create_publisher(host); + } + /** * Returns the kinds of media that this service can work with. */ public abstract Spit.Publishing.Publisher.MediaType get_supported_media(); + + /** + * Returns a list of accounts associated with the service + * Returns: null if there are no accounts, identifier + */ + public virtual Gee.List<Account>? get_accounts(string profile_id) { + var list = new Gee.ArrayList<Account>(); + list.add(new DefaultAccount()); + + return list; + } // // For future expansion. @@ -617,6 +651,8 @@ public interface Authenticator : Object { public abstract void logout(); public abstract void refresh(); + public abstract void set_accountname(string name); + public abstract GLib.HashTable<string, Variant> get_authentication_parameter(); } diff --git a/src/plugins/SpitInterfaces.vala b/src/plugins/SpitInterfaces.vala index 3e2c70e..94e6f95 100644 --- a/src/plugins/SpitInterfaces.vala +++ b/src/plugins/SpitInterfaces.vala @@ -4,6 +4,8 @@ * (version 2.1 or later). See the COPYING file in this distribution. */ +private extern const string _VERSION; + /** * Shotwell Pluggable Interface Technology (SPIT) * @@ -156,27 +158,26 @@ public interface Module : Object { protected virtual void reserved7() {} } + /** * A structure holding an assortment of information about a {@link Pluggable}. */ -public struct PluggableInfo { - public string? version; - public string? brief_description; +public class PluggableInfo : Object { + public string? version {get; set; default = _VERSION; } + public string? brief_description {get; set; } /** * A comma-delimited list of the authors of this {@link Pluggable}. */ - public string? authors; - public string? copyright; - public string? license; - public bool is_license_wordwrapped; - public string? website_url; - public string? website_name; - public string? translators; - /** - * An icon representing this plugin at one or more sizes. Shotwell may select an icon - * according to the size that closest fits the control its being drawn in. - */ - public Gdk.Pixbuf[]? icons; + public string? authors { get; set; } + public string? copyright {get; set; } + public string? license_blurp { get; set; default = _("LGPL v2.1 or later"); } + public string? license_url { get; set; default = "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"; } + public string? website_url {get; set; default = "https://wiki.gnome.org/Apps/Shotwell";} + public string? website_name { get; set; default = _("Visit the Shotwell home page");} + public string? translators {get; set; default = _("translator-credits"); } + + // Name of an icon in the theme, to be set in the Pluggable implementation + public string icon_name {get; set; default = "application-x-addon-symbolic"; } } /** @@ -225,7 +226,7 @@ public interface Pluggable : Object { /** * Returns extra information about the Pluggable that is used to identify it to the user. */ - public abstract void get_info(ref PluggableInfo info); + public abstract PluggableInfo get_info(); /** * Called when the Pluggable is enabled (activated) or disabled (deactivated). diff --git a/src/plugins/StandardHostInterface.vala b/src/plugins/StandardHostInterface.vala index d0f3ed4..aa012ef 100644 --- a/src/plugins/StandardHostInterface.vala +++ b/src/plugins/StandardHostInterface.vala @@ -16,20 +16,17 @@ public class StandardHostInterface : Object, Spit.HostInterface { this.config_domain = config_domain; config_id = parse_key(pluggable.get_id()); module_file = get_pluggable_module_file(pluggable); - pluggable.get_info(ref info); + info = pluggable.get_info(); } private static string parse_key(string id) { // special case: legacy plugins (Web publishers moved into SPIT) have special names // new plugins will use their full ID switch (id) { - case "org.yorba.shotwell.publishing.facebook": - return "facebook"; - - case "org.yorba.shotwell.publishing.flickr": + case "org.gnome.shotwell.publishing.flickr": return "flickr"; - case "org.yorba.shotwell.publishing.youtube": + case "org.gnome.shotwell.publishing.youtube": return "youtube"; default: diff --git a/src/publishing/APIGlue.vala b/src/publishing/APIGlue.vala index 23c4e8c..56013a2 100644 --- a/src/publishing/APIGlue.vala +++ b/src/publishing/APIGlue.vala @@ -126,7 +126,11 @@ public class MediaSourcePublishableWrapper : Spit.Publishing.Publishable, GLib.O } public GLib.DateTime get_exposure_date_time() { - return new GLib.DateTime.from_unix_local(wrapped.get_exposure_time()); + return wrapped.get_exposure_time().to_local(); + } + + public uint get_rating() { + return wrapped.get_rating(); } } diff --git a/src/publishing/LoginWelcomePaneWidget.vala b/src/publishing/LoginWelcomePaneWidget.vala new file mode 100644 index 0000000..3e9847b --- /dev/null +++ b/src/publishing/LoginWelcomePaneWidget.vala @@ -0,0 +1,45 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2019 Jens Georg <mail@jensge.org> + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +namespace PublishingUI { + +[GtkTemplate (ui = "/org/gnome/Shotwell/ui/login_welcome_pane_widget.ui")] +public class LoginWelcomePane : Spit.Publishing.DialogPane, Gtk.Box { + [GtkChild] + private unowned Gtk.Button login_button; + [GtkChild] + private unowned Gtk.Label not_logged_in_label; + + public Gtk.Widget get_widget() { + return this; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + } + + public void on_pane_uninstalled() { + } + + public signal void login_requested(); + + public LoginWelcomePane(string service_welcome_message) { + Object(); + + login_button.clicked.connect(on_login_clicked); + not_logged_in_label.set_use_markup(true); + not_logged_in_label.set_markup(service_welcome_message); + } + + private void on_login_clicked() { + login_requested(); + } +} +} // namespace PublishingUI diff --git a/src/publishing/ProgressPaneWidget.vala b/src/publishing/ProgressPaneWidget.vala new file mode 100644 index 0000000..0c89d77 --- /dev/null +++ b/src/publishing/ProgressPaneWidget.vala @@ -0,0 +1,44 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2019 Jens Georg <mail@jensge.org> + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +namespace PublishingUI { + +[GtkTemplate (ui = "/org/gnome/Shotwell/ui/progress_pane_widget.ui")] +public class ProgressPane : Spit.Publishing.DialogPane, Gtk.Box { + [GtkChild] + private unowned Gtk.ProgressBar progress_bar; + + public Gtk.Widget get_widget() { + return this; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + } + + public void on_pane_uninstalled() { + } + + public void set_text(string text) { + progress_bar.set_text(text); + } + + public void set_progress(double progress) { + progress_bar.set_fraction(progress); + } + + public void set_status(string status_text, double progress) { + if (status_text != progress_bar.get_text()) + progress_bar.set_text(status_text); + + set_progress(progress); + } +} +} // namespace PublishingUI diff --git a/src/publishing/Publishing.vala b/src/publishing/Publishing.vala index 455013c..c41e121 100644 --- a/src/publishing/Publishing.vala +++ b/src/publishing/Publishing.vala @@ -8,10 +8,9 @@ namespace Publishing { public void init() throws Error { string[] core_ids = new string[0]; - core_ids += "org.yorba.shotwell.publishing.facebook"; - core_ids += "org.yorba.shotwell.publishing.flickr"; - core_ids += "org.yorba.shotwell.publishing.youtube"; - core_ids += "org.yorba.shotwell.publishing.gnome-photos"; + core_ids += "org.gnome.shotwell.publishing.flickr"; + core_ids += "org.gnome.shotwell.publishing.youtube"; + core_ids += "org.gnome.shotwell.publishing.gnome-photos"; Plugins.register_extension_point(typeof(Spit.Publishing.Service), _("Publishing"), Resources.PUBLISH, core_ids); diff --git a/src/publishing/PublishingPluginHost.vala b/src/publishing/PublishingPluginHost.vala index ca935ab..7804924 100644 --- a/src/publishing/PublishingPluginHost.vala +++ b/src/publishing/PublishingPluginHost.vala @@ -22,7 +22,7 @@ public class ConcretePublishingHost : Plugins.StandardHostInterface, Spit.Publishing.Publisher.MediaType.NONE; public ConcretePublishingHost(Service service, PublishingUI.PublishingDialog dialog, - Publishable[] publishables) { + Publishable[] publishables, Account account) { base(service, "sharing"); this.dialog = dialog; this.publishables = publishables; @@ -30,7 +30,11 @@ public class ConcretePublishingHost : Plugins.StandardHostInterface, foreach (Publishable curr_publishable in publishables) this.media_type |= curr_publishable.get_media_type(); - this.active_publisher = service.create_publisher(this); + this.active_publisher = service.create_publisher_with_account(this, account); + } + + public string get_current_profile_id() { + return Shotwell.ProfileManager.get_instance().id(); } private void on_login_clicked() { diff --git a/src/publishing/PublishingUI.vala b/src/publishing/PublishingUI.vala index d3d4a69..de642a4 100644 --- a/src/publishing/PublishingUI.vala +++ b/src/publishing/PublishingUI.vala @@ -6,134 +6,6 @@ namespace PublishingUI { -public class ConcreteDialogPane : Spit.Publishing.DialogPane, GLib.Object { - protected Gtk.Box pane_widget = null; - protected Gtk.Builder builder = null; - - public ConcreteDialogPane() { - builder = AppWindow.create_builder(); - } - - public Gtk.Widget get_widget() { - return pane_widget; - } - - public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { - return Spit.Publishing.DialogPane.GeometryOptions.NONE; - } - - public void on_pane_installed() { - } - - public void on_pane_uninstalled() { - } -} - -public class StaticMessagePane : ConcreteDialogPane { - private Gtk.Label msg_label = null; - - public StaticMessagePane(string message_string, bool enable_markup = false) { - base(); - msg_label = builder.get_object("static_msg_label") as Gtk.Label; - pane_widget = builder.get_object("static_msg_pane_widget") as Gtk.Box; - - if (enable_markup) { - msg_label.set_markup(message_string); - msg_label.set_line_wrap(true); - msg_label.set_use_markup(true); - } else { - msg_label.set_label(message_string); - } - } -} - -public class LoginWelcomePane : ConcreteDialogPane { - private Gtk.Button login_button = null; - private Gtk.Label not_logged_in_label = null; - - public signal void login_requested(); - - public LoginWelcomePane(string service_welcome_message) { - base(); - pane_widget = builder.get_object("welcome_pane_widget") as Gtk.Box; - login_button = builder.get_object("login_button") as Gtk.Button; - not_logged_in_label = builder.get_object("not_logged_in_label") as Gtk.Label; - - login_button.clicked.connect(on_login_clicked); - not_logged_in_label.set_use_markup(true); - not_logged_in_label.set_markup(service_welcome_message); - } - - private void on_login_clicked() { - login_requested(); - } -} - -public class ProgressPane : ConcreteDialogPane { - private Gtk.ProgressBar progress_bar = null; - - public ProgressPane() { - base(); - pane_widget = (Gtk.Box) builder.get_object("progress_pane_widget"); - progress_bar = (Gtk.ProgressBar) builder.get_object("publishing_progress_bar"); - } - - public void set_text(string text) { - progress_bar.set_text(text); - } - - public void set_progress(double progress) { - progress_bar.set_fraction(progress); - } - - public void set_status(string status_text, double progress) { - if (status_text != progress_bar.get_text()) - progress_bar.set_text(status_text); - - set_progress(progress); - } -} - -public class SuccessPane : StaticMessagePane { - public SuccessPane(Spit.Publishing.Publisher.MediaType published_media, int num_uploaded = 1) { - string? message_string = null; - - // Here, we check whether more than one item is being uploaded, and if so, display - // an alternate message. - if (published_media == Spit.Publishing.Publisher.MediaType.VIDEO) { - message_string = ngettext ("The selected video was successfully published.", - "The selected videos were successfully published.", - num_uploaded); - } - else if (published_media == Spit.Publishing.Publisher.MediaType.PHOTO) { - message_string = ngettext ("The selected photo was successfully published.", - "The selected photos were successfully published.", - num_uploaded); - } - else if (published_media == (Spit.Publishing.Publisher.MediaType.PHOTO - | Spit.Publishing.Publisher.MediaType.VIDEO)) { - message_string = _("The selected photos/videos were successfully published."); - } - else { - assert_not_reached (); - } - - base(message_string); - } -} - -public class AccountFetchWaitPane : StaticMessagePane { - public AccountFetchWaitPane() { - base(_("Fetching account information…")); - } -} - -public class LoginWaitPane : StaticMessagePane { - public LoginWaitPane() { - base(_("Logging in…")); - } -} - public class PublishingDialog : Gtk.Dialog { private const int LARGE_WINDOW_WIDTH = 860; private const int LARGE_WINDOW_HEIGHT = 688; @@ -205,12 +77,13 @@ public class PublishingDialog : Gtk.Dialog { } set_title(title); - service_selector_box_model = new Gtk.ListStore(2, typeof(Gdk.Pixbuf), typeof(string)); + service_selector_box_model = new Gtk.ListStore(3, typeof(string), typeof(string), + typeof(Spit.Publishing.Account)); service_selector_box = new Gtk.ComboBox.with_model(service_selector_box_model); Gtk.CellRendererPixbuf renderer_pix = new Gtk.CellRendererPixbuf(); service_selector_box.pack_start(renderer_pix,true); - service_selector_box.add_attribute(renderer_pix, "pixbuf", 0); + service_selector_box.add_attribute(renderer_pix, "icon-name", 0); Gtk.CellRendererText renderer_text = new Gtk.CellRendererText(); service_selector_box.pack_start(renderer_text,true); @@ -226,30 +99,26 @@ public class PublishingDialog : Gtk.Dialog { Gtk.TreeIter iter; foreach (Spit.Publishing.Service service in loaded_services) { - service_selector_box_model.append(out iter); - string curr_service_id = service.get_id(); - service.get_info(ref info); + info = service.get_info(); - if (null != info.icons && 0 < info.icons.length) { - // check if the icons object is set -- if set use that icon - service_selector_box_model.set(iter, 0, info.icons[0], 1, - service.get_pluggable_name()); - - // in case the icons object is not set on the next iteration - info.icons[0] = Resources.get_icon(Resources.ICON_GENERIC_PLUGIN); - } else { - // if icons object is null or zero length use a generic icon - service_selector_box_model.set(iter, 0, Resources.get_icon( - Resources.ICON_GENERIC_PLUGIN), 1, service.get_pluggable_name()); - } - - if (last_used_service == null) { - service_selector_box.set_active_iter(iter); - last_used_service = service.get_id(); - } else if (last_used_service == curr_service_id) { - service_selector_box.set_active_iter(iter); + var accounts = service.get_accounts(Shotwell.ProfileManager.get_instance().id()); + + foreach (var account in accounts) { + service_selector_box_model.append(out iter); + + var account_name = account.display_name(); + var display_name = service.get_pluggable_name() + (account_name == "" ? "" : "/" + account_name); + + service_selector_box_model.set(iter, 0, info.icon_name, 1, display_name, 2, account); + + if (last_used_service == null) { + service_selector_box.set_active_iter(iter); + last_used_service = service.get_id(); + } else if (last_used_service == curr_service_id) { + service_selector_box.set_active_iter(iter); + } } } @@ -373,15 +242,17 @@ public class PublishingDialog : Gtk.Dialog { return filtered_services; } - // Because of this bug: http://trac.yorba.org/ticket/3623, we use some extreme measures. The - // bug occurs because, in some cases, when publishing is started asynchronous network - // transactions are performed. The mechanism inside libsoup that we use to perform asynchronous - // network transactions isn't based on threads but is instead based on the GLib event loop. So - // whenever we run a network transaction, the GLib event loop gets spun. One consequence of - // this is that PublishingDialog.go( ) can be called multiple times. Note that since events - // are processed sequentially, PublishingDialog.go( ) is never called re-entrantly. It just - // gets called twice back-to-back in quick succession. So use a timer to do a short circuit - // return if this call to go( ) follows immediately on the heels of another call to go( ). + // Because of this bug: https://bugzilla.gnome.org/show_bug.cgi?id=717505, we use some + // extreme measures. The bug occurs because, in some cases, when publishing is started + // asynchronous network transactions are performed. The mechanism inside libsoup that we + // use to perform asynchronous network transactions isn't based on threads but is instead + // based on the GLib event loop. So whenever we run a network transaction, the GLib event + // loop gets spun. One consequence of this is that PublishingDialog.go( ) can be called + // multiple times. Note that since events are processed sequentially, PublishingDialog.go() + // is never called re-entrantly. It just gets called twice back-to-back in quick + // succession. So use a timer to do a short circuit return if this call to go( ) follows + // immediately on the heels of another call to go( ) + // FIXME: Port publising to async libsoup, then there is no nested main loop anymore. private static Timer since_last_start = null; private static bool elapsed_is_valid = false; public static void go(Gee.Collection<MediaSource> to_publish) { @@ -412,7 +283,7 @@ public class PublishingDialog : Gtk.Dialog { // There are no enabled publishing services that accept this media type, // warn the user. AppWindow.error_message_with_title(_("Unable to publish"), - _("Shotwell cannot publish the selected items because you do not have a compatible publishing plugin enabled. To correct this, choose <b>Edit %s Preferences</b> and enable one or more of the publishing plugins on the <b>Plugins</b> tab.").printf("▸"), + _("Shotwell cannot publish the selected items because you do not have a compatible publishing plugin enabled. To correct this, choose Edit %s Preferences and enable one or more of the publishing plugins on the <b>Plugins</b> tab.").printf("▸"), null, false); return; @@ -458,14 +329,17 @@ public class PublishingDialog : Gtk.Dialog { } Value service_name_val; + Value account_val; service_selector_box_model.get_value(iter, 1, out service_name_val); + service_selector_box_model.get_value(iter, 2, out account_val); string service_name = (string) service_name_val; - + var service_account = (Spit.Publishing.Account) account_val; + Spit.Publishing.Service? selected_service = null; Spit.Publishing.Service[] services = load_all_services(); foreach (Spit.Publishing.Service service in services) { - if (service.get_pluggable_name() == service_name) { + if (service_name.has_prefix(service.get_pluggable_name())) { selected_service = service; break; } @@ -474,7 +348,7 @@ public class PublishingDialog : Gtk.Dialog { Config.Facade.get_instance().set_last_used_service(selected_service.get_id()); - host = new Spit.Publishing.ConcretePublishingHost(selected_service, this, publishables); + host = new Spit.Publishing.ConcretePublishingHost(selected_service, this, publishables, service_account); host.start_publishing(); } diff --git a/src/publishing/StaticMessagePaneWidget.vala b/src/publishing/StaticMessagePaneWidget.vala new file mode 100644 index 0000000..5f8de66 --- /dev/null +++ b/src/publishing/StaticMessagePaneWidget.vala @@ -0,0 +1,62 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2019 Jens Georg <mail@jensge.org> + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +namespace PublishingUI { + +[GtkTemplate (ui = "/org/gnome/Shotwell/ui/static_message_pane_widget.ui")] +public class StaticMessagePane : Spit.Publishing.DialogPane, Gtk.Box { + public bool show_spinner{get; construct; default=false; } + + [GtkChild] + private unowned Gtk.Label static_msg_label; + + [GtkChild] + private unowned Gtk.Spinner spinner; + + public Gtk.Widget get_widget() { + return this; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + } + + public void on_pane_uninstalled() { + } + + public StaticMessagePane(string message_string, bool enable_markup = false, bool show_spinner = false) { + Object(show_spinner: false); + + spinner.active = show_spinner; + + if (enable_markup) { + static_msg_label.set_markup(message_string); + static_msg_label.set_line_wrap(true); + static_msg_label.set_use_markup(true); + } else { + static_msg_label.set_label(message_string); + } + } +} + +public class AccountFetchWaitPane : StaticMessagePane { + public AccountFetchWaitPane() { + base(_("Fetching account information…"), false, true); + } +} + +public class LoginWaitPane : StaticMessagePane { + public LoginWaitPane() { + base(_("Logging in…"), false, true); + } +} + + +} // namespace PublishingUI diff --git a/src/publishing/SuccessPaneWidget.vala b/src/publishing/SuccessPaneWidget.vala new file mode 100644 index 0000000..05b0c16 --- /dev/null +++ b/src/publishing/SuccessPaneWidget.vala @@ -0,0 +1,39 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * Copyright 2019 Jens Georg <mail@jensge.org> + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +namespace PublishingUI { + +public class SuccessPane : StaticMessagePane { + public SuccessPane(Spit.Publishing.Publisher.MediaType published_media, int num_uploaded = 1) { + string? message_string = null; + + // Here, we check whether more than one item is being uploaded, and if so, display + // an alternate message. + if (published_media == Spit.Publishing.Publisher.MediaType.VIDEO) { + message_string = ngettext ("The selected video was successfully published.", + "The selected videos were successfully published.", + num_uploaded); + } + else if (published_media == Spit.Publishing.Publisher.MediaType.PHOTO) { + message_string = ngettext ("The selected photo was successfully published.", + "The selected photos were successfully published.", + num_uploaded); + } + else if (published_media == (Spit.Publishing.Publisher.MediaType.PHOTO + | Spit.Publishing.Publisher.MediaType.VIDEO)) { + message_string = _("The selected photos/videos were successfully published."); + } + else { + assert_not_reached (); + } + + base(message_string); + } +} +} + + diff --git a/src/publishing/meson.build b/src/publishing/meson.build new file mode 100644 index 0000000..38178d6 --- /dev/null +++ b/src/publishing/meson.build @@ -0,0 +1,27 @@ +libsw_publishing_gui = static_library( + 'publishing_gui', + [ + 'StaticMessagePaneWidget.vala', + 'ProgressPaneWidget.vala', + 'SuccessPaneWidget.vala', + 'LoginWelcomePaneWidget.vala', + ], + vala_header : 'shotwell-internal-publishing-gui.h', + vala_vapi : 'shotwell-internal-publishing-gui.vapi', + include_directories : config_incdir, + dependencies: [ + gtk, + gee, + sw_plugin + ], + vala_args : [ + '--gresources', + join_paths(meson.project_source_root(), 'data', + 'org.gnome.Shotwell.gresource.xml') + ] +) + +sw_publishing_gui = declare_dependency( + include_directories : include_directories('.'), + link_with : libsw_publishing_gui +) diff --git a/src/searches/SavedSearchDialog.vala b/src/searches/SavedSearchDialog.vala index 526da35..b08c8a8 100644 --- a/src/searches/SavedSearchDialog.vala +++ b/src/searches/SavedSearchDialog.vala @@ -641,18 +641,19 @@ public class SavedSearchDialog : Gtk.Dialog { } [GtkChild] - private Gtk.Button add_criteria; + private unowned Gtk.Button add_criteria; [GtkChild] - private Gtk.ComboBoxText operator; + private unowned Gtk.ComboBoxText operator; [GtkChild] - private Gtk.Entry search_title; + private unowned Gtk.Entry search_title; [GtkChild] - private Gtk.ListBox row_listbox; + private unowned Gtk.ListBox row_listbox; private Gee.ArrayList<SearchRowContainer> row_list = new Gee.ArrayList<SearchRowContainer>(); private bool edit_mode = false; private SavedSearch? previous_search = null; private bool valid = false; + private ulong notify_id = 0; public SavedSearchDialog() { Object (use_header_bar : Resources.use_header_bar()); @@ -701,6 +702,12 @@ public class SavedSearchDialog : Gtk.Dialog { add_criteria.clicked.connect(on_add_criteria); search_title.changed.connect(on_title_changed); + if (Resources.use_header_bar() == 1) { + var box = search_title.get_parent(); + box.remove(search_title); + box.get_parent().remove(box); + ((Gtk.HeaderBar) get_header_bar()).set_custom_title(search_title); + } } // Displays the dialog. @@ -709,9 +716,14 @@ public class SavedSearchDialog : Gtk.Dialog { destroy(); } + double upper; // Adds a row of search criteria. private void on_add_criteria() { + this.upper = row_listbox.get_adjustment().upper; + this.notify_id = row_listbox.get_adjustment().notify["upper"].connect(on_scroll); add_text_search(); + // Wait for upper to change. Then scroll to it, disconnect afterwards + // Otherwise the ListBox will randomly scroll to the bottom } private void add_text_search() { @@ -723,11 +735,26 @@ public class SavedSearchDialog : Gtk.Dialog { private void add_row(SearchRowContainer row) { if (row_list.size == 1) row_list.get(0).allow_removal(true); - row_listbox.add(row.get_widget()); + row_listbox.insert(row.get_widget(), row_list.size); row_list.add(row); row.remove.connect(on_remove_row); row.changed.connect(on_row_changed); set_valid(row.is_complete()); + + } + + private void on_scroll() { + var adj = row_listbox.get_adjustment(); + if (adj.upper < this.upper) { + return; + } + + if (this.notify_id != 0) { + adj.disconnect(this.notify_id); + this.notify_id = 0; + } + + adj.value = adj.upper; } // Removes a row of search criteria. diff --git a/src/searches/SearchBoolean.vala b/src/searches/SearchBoolean.vala index 5e69e57..fc83e04 100644 --- a/src/searches/SearchBoolean.vala +++ b/src/searches/SearchBoolean.vala @@ -776,11 +776,11 @@ public class SearchConditionDate : SearchCondition { // Determines whether the source is included. public override bool predicate(MediaSource source) { - time_t exposure_time = source.get_exposure_time(); - if (exposure_time == 0) + var exposure_time = source.get_exposure_time(); + if (exposure_time == null) return context == Context.IS_NOT_SET; - DateTime dt = new DateTime.from_unix_local(exposure_time); + var dt = exposure_time.to_local(); switch (context) { case Context.EXACT: DateTime second = date_one.add_days(1); diff --git a/src/sidebar/Tree.vala b/src/sidebar/Tree.vala index ea039ea..aae81a0 100644 --- a/src/sidebar/Tree.vala +++ b/src/sidebar/Tree.vala @@ -97,7 +97,6 @@ public class Sidebar.Tree : Gtk.TreeView { Gtk.TreeViewColumn text_column = new Gtk.TreeViewColumn(); text_column.set_expand(true); Gtk.CellRendererPixbuf icon_renderer = new Gtk.CellRendererPixbuf(); - icon_renderer.follow_state = true; text_column.pack_start (icon_renderer, false); text_column.add_attribute(icon_renderer, "gicon", Columns.ICON); text_column.set_cell_data_func(icon_renderer, icon_renderer_function); @@ -790,17 +789,6 @@ public class Sidebar.Tree : Gtk.TreeView { store.set(iter, Columns.ICON, icon); } - private void load_branch_icons(Gtk.TreeIter iter) { - load_entry_icons(iter); - - Gtk.TreeIter child_iter; - if (store.iter_children(out child_iter, iter)) { - do { - load_branch_icons(child_iter); - } while (store.iter_next(ref child_iter)); - } - } - private bool on_selection(Gtk.TreeSelection selection, Gtk.TreeModel model, Gtk.TreePath path, bool path_currently_selected) { // only allow selection if a page is selectable diff --git a/src/slideshow/Slideshow.vala b/src/slideshow/Slideshow.vala index 5d14b64..0ee9392 100644 --- a/src/slideshow/Slideshow.vala +++ b/src/slideshow/Slideshow.vala @@ -8,16 +8,16 @@ namespace Slideshow { public void init() throws Error { string[] core_ids = new string[0]; - core_ids += "org.yorba.shotwell.transitions.crumble"; - core_ids += "org.yorba.shotwell.transitions.fade"; - core_ids += "org.yorba.shotwell.transitions.slide"; - core_ids += "org.yorba.shotwell.transitions.blinds"; - core_ids += "org.yorba.shotwell.transitions.circle"; - core_ids += "org.yorba.shotwell.transitions.circles"; - core_ids += "org.yorba.shotwell.transitions.clock"; - core_ids += "org.yorba.shotwell.transitions.stripes"; - core_ids += "org.yorba.shotwell.transitions.squares"; - core_ids += "org.yorba.shotwell.transitions.chess"; + core_ids += "org.gnome.shotwell.transitions.crumble"; + core_ids += "org.gnome.shotwell.transitions.fade"; + core_ids += "org.gnome.shotwell.transitions.slide"; + core_ids += "org.gnome.shotwell.transitions.blinds"; + core_ids += "org.gnome.shotwell.transitions.circle"; + core_ids += "org.gnome.shotwell.transitions.circles"; + core_ids += "org.gnome.shotwell.transitions.clock"; + core_ids += "org.gnome.shotwell.transitions.stripes"; + core_ids += "org.gnome.shotwell.transitions.squares"; + core_ids += "org.gnome.shotwell.transitions.chess"; Plugins.register_extension_point(typeof(Spit.Transitions.Descriptor), _("Slideshow Transitions"), Resources.ICON_SLIDESHOW_EXTENSION_POINT, core_ids); diff --git a/src/slideshow/TransitionEffects.vala b/src/slideshow/TransitionEffects.vala index 5f7dc88..23c666a 100644 --- a/src/slideshow/TransitionEffects.vala +++ b/src/slideshow/TransitionEffects.vala @@ -71,7 +71,7 @@ public class TransitionEffectsManager { return effects.keys; } - public Gee.Collection<string> get_effect_names(owned CompareDataFunc? comparator = null) { + public Gee.Collection<string> get_effect_names(owned CompareDataFunc<string>? comparator = null) { Gee.Collection<string> effect_names = new Gee.TreeSet<string>((owned) comparator); foreach (Spit.Transitions.Descriptor desc in effects.values) effect_names.add(desc.get_pluggable_name()); @@ -273,7 +273,7 @@ public class TransitionClock { } public class NullTransitionDescriptor : Object, Spit.Pluggable, Spit.Transitions.Descriptor { - public const string EFFECT_ID = "org.yorba.shotwell.transitions.null"; + public const string EFFECT_ID = "org.gnome.shotwell.transitions.null"; public int get_pluggable_interface(int min_host_version, int max_host_version) { return Spit.Transitions.CURRENT_INTERFACE; @@ -287,7 +287,8 @@ public class NullTransitionDescriptor : Object, Spit.Pluggable, Spit.Transitions return _("None"); } - public void get_info(ref Spit.PluggableInfo info) { + public Spit.PluggableInfo get_info() { + return new Spit.PluggableInfo(); } public void activation(bool enabled) { @@ -325,7 +326,7 @@ public class NullEffect : Object, Spit.Transitions.Effect { } } public class RandomEffectDescriptor : Object, Spit.Pluggable, Spit.Transitions.Descriptor { - public const string EFFECT_ID = "org.yorba.shotwell.transitions.random"; + public const string EFFECT_ID = "org.gnome.shotwell.transitions.random"; public int get_pluggable_interface(int min_host_version, int max_host_version) { return Spit.Transitions.CURRENT_INTERFACE; @@ -339,7 +340,8 @@ public class RandomEffectDescriptor : Object, Spit.Pluggable, Spit.Transitions.D return _("Random"); } - public void get_info(ref Spit.PluggableInfo info) { + public Spit.PluggableInfo get_info() { + return new Spit.PluggableInfo(); } public void activation(bool enabled) { diff --git a/src/unit/rc/Unit.m4 b/src/unit/rc/Unit.m4 deleted file mode 100644 index 2665dd6..0000000 --- a/src/unit/rc/Unit.m4 +++ /dev/null @@ -1,29 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - -/* This file is the master unit file for the _UNIT_NAME_ unit. It should be edited to include - * whatever code is deemed necessary. - * - * The init() and terminate() methods are mandatory. - * - * If the unit needs to be configured prior to initialization, add the proper parameters to - * the preconfigure() method, implement it, and ensure in init() that it's been called. - */ - -namespace _UNIT_NAME_ { - -// preconfigure may be deleted if not used. -public void preconfigure() { -} - -public void init() throws Error { -} - -public void terminate() { -} - -} - diff --git a/src/unit/rc/UnitInternals.m4 b/src/unit/rc/UnitInternals.m4 deleted file mode 100644 index 71614d4..0000000 --- a/src/unit/rc/UnitInternals.m4 +++ /dev/null @@ -1,32 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - * - * Auto-generated file. Do not modify! - */ - -namespace _UNIT_NAME_ { - -private int _unit_init_count = 0; - -public void init_entry() throws Error { - if (_unit_init_count++ != 0) - return; - - _UNIT_USES_INITS_ - - _UNIT_NAME_.init(); -} - -public void terminate_entry() { - if (_unit_init_count == 0 || --_unit_init_count != 0) - return; - - _UNIT_NAME_.terminate(); - - _UNIT_USES_TERMINATORS_ -} - -} - diff --git a/src/unit/rc/template.vala b/src/unit/rc/template.vala deleted file mode 100644 index 31fc93d..0000000 --- a/src/unit/rc/template.vala +++ /dev/null @@ -1,7 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - */ - - diff --git a/src/unit/rc/unitize_entry.m4 b/src/unit/rc/unitize_entry.m4 deleted file mode 100644 index 31602b2..0000000 --- a/src/unit/rc/unitize_entry.m4 +++ /dev/null @@ -1,19 +0,0 @@ -/* Copyright 2016 Software Freedom Conservancy Inc. - * - * This software is licensed under the GNU Lesser General Public License - * (version 2.1 or later). See the COPYING file in this distribution. - * - * Auto-generated file. Do not modify! - */ - -namespace _APP_UNIT_ { - -public void app_init() throws Error { - _APP_UNIT_.init_entry(); -} - -public void app_terminate() { - _APP_UNIT_.terminate_entry(); -} - -} diff --git a/src/util/Util.vala b/src/util/Util.vala index b87ea3a..45943d7 100644 --- a/src/util/Util.vala +++ b/src/util/Util.vala @@ -8,6 +8,8 @@ namespace Util { // Use these file attributes when loading file information for a complete FileInfo objects public const string FILE_ATTRIBUTES = "standard::*,time::*,id::file,id::filesystem,etag::value"; + public const int64 USEC_PER_SEC = 1000000; + public void init() throws Error { } diff --git a/src/util/file.vala b/src/util/file.vala index c1ee06d..652a141 100644 --- a/src/util/file.vala +++ b/src/util/file.vala @@ -31,9 +31,11 @@ public bool claim_file(File file) throws Error { // same or similar as what has been requested (adds numerals to the end of the name until a unique // one has been found). The file may exist when this function returns, and it should be // overwritten. It does *not* attempt to create the parent directory, however. +// The used parameter allows you to pass in a collection of names which should be deemed to be +// already claimed but which may not yet exist in the file system. // // This function is thread-safe. -public File? generate_unique_file(File dir, string basename, out bool collision) throws Error { +public File? generate_unique_file(File dir, string basename, out bool collision, Gee.Collection<string>? used = null) throws Error { // create the file to atomically "claim" it File file = dir.get_child(basename); if (claim_file(file)) { @@ -51,7 +53,9 @@ public File? generate_unique_file(File dir, string basename, out bool collision) // generate a unique filename for (int ctr = 1; ctr < int.MAX; ctr++) { string new_name = (ext != null) ? "%s_%d.%s".printf(name, ctr, ext) : "%s_%d".printf(name, ctr); - + if (used != null && used.contains(new_name)) { + continue; + } file = dir.get_child(new_name); if (claim_file(file)) return file; @@ -151,11 +155,11 @@ public void delete_all_files(File dir, Gee.Set<string>? exceptions = null, Progr } } -public time_t query_file_modified(File file) throws Error { +public DateTime query_file_modified(File file) throws Error { FileInfo info = file.query_info(FileAttribute.TIME_MODIFIED, FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); - return info.get_modification_time().tv_sec; + return info.get_modification_date_time(); } public bool query_is_directory(File file) { @@ -199,20 +203,6 @@ public string? get_file_info_id(FileInfo info) { return info.get_attribute_string(FileAttribute.ID_FILE); } -// Breaks a uint64 skip amount into several smaller skips. -public void skip_uint64(InputStream input, uint64 skip_amount) throws GLib.Error { - while (skip_amount > 0) { - // skip() throws an error if the amount is too large, so check against ssize_t.MAX - if (skip_amount >= ssize_t.MAX) { - input.skip(ssize_t.MAX); - skip_amount -= ssize_t.MAX; - } else { - input.skip((size_t) skip_amount); - skip_amount = 0; - } - } -} - // Returns the number of files (and/or directories) within a directory. public uint64 count_files_in_directory(File dir) throws GLib.Error { if (!query_is_directory(dir)) diff --git a/src/util/image.vala b/src/util/image.vala index 0a30339..95ac998 100644 --- a/src/util/image.vala +++ b/src/util/image.vala @@ -343,7 +343,7 @@ private Cairo.Surface get_background_surface() { string color_b; var config = Config.Facade.get_instance(); - var type = config.get_transparent_background_type(); + var type = "checkered"; //config.get_transparent_background_type(); switch (type) { case "checkered": color_a = "#808080"; @@ -386,7 +386,8 @@ public void paint_pixmap_with_background (Cairo.Context ctx, Gdk.Pixbuf pixbuf, } Gdk.cairo_set_source_pixbuf(ctx, pixbuf, x, y); - ctx.paint(); + ctx.rectangle(x, y, pixbuf.width , pixbuf.height); + ctx.fill(); } // Force an axially-aligned box to be inside a rotated rectangle. @@ -422,3 +423,6 @@ Box clamp_inside_rotated_image(Box src, int img_w, int img_h, double angle_deg, src.right + right_offset, src.bottom + bottom_offset); } +double degrees_to_radians(double theta) { + return (theta * (GLib.Math.PI / 180.0)); +} diff --git a/src/util/misc.vala b/src/util/misc.vala index 6111ea3..2106621 100644 --- a/src/util/misc.vala +++ b/src/util/misc.vala @@ -54,22 +54,12 @@ public bool int_value_equals(Value a, Value b) { return (int) a == (int) b; } -public ulong timeval_to_ms(TimeVal time_val) { - return (((ulong) time_val.tv_sec) * 1000) + (((ulong) time_val.tv_usec) / 1000); -} - public ulong now_ms() { - return timeval_to_ms(TimeVal()); -} - -public ulong now_sec() { - TimeVal time_val = TimeVal(); - - return time_val.tv_sec; + return (ulong) (GLib.get_real_time() / 1000); } -public inline time_t now_time_t() { - return (time_t) now_sec(); +public int64 now_sec() { + return (ulong) (GLib.get_real_time() / Util.USEC_PER_SEC); } public string md5_file(File file) throws Error { @@ -216,14 +206,16 @@ public Gee.List<MediaSource>? unserialize_media_sources(uchar* serialized, int s return list; } -public string format_local_datespan(Time from_date, Time to_date) { +public string format_local_datespan(DateTime from_date, DateTime to_date) { string from_format, to_format; // Ticket #3240 - Change the way date ranges are pretty- // printed if the start and end date occur on consecutive days. - if (from_date.year == to_date.year) { + if (from_date.get_year() == to_date.get_year()) { // are these consecutive dates? - if ((from_date.month == to_date.month) && (from_date.day == (to_date.day - 1))) { + // get_day_of_year() looks like it saves a bit of code, but then we would + // not recognize the change of months + if ((from_date.get_month() == to_date.get_month()) && (from_date.get_day_of_month() == (to_date.get_day_of_month() - 1))) { // Yes; display like so: Sat, July 4 - 5, 20X6 from_format = Resources.get_start_multiday_span_format_string(); to_format = Resources.get_end_multiday_span_format_string(); @@ -244,7 +236,7 @@ public string format_local_datespan(Time from_date, Time to_date) { to_date.format(to_format))); } -public string format_local_date(Time date) { +public string format_local_date(DateTime date) { return String.strip_leading_zeroes(date.format(Resources.get_long_date_format_string())); } @@ -273,7 +265,9 @@ public class OneShotScheduler { } public void at_idle() { - at_priority_idle(Priority.DEFAULT_IDLE); + // needs to be lower (higher priority) than Clutter.PRIORITY_REDRAW which is + // set at Priority.HIGH_IDLE + 50 + at_priority_idle(Priority.HIGH_IDLE + 40); } public void at_priority_idle(int priority) { diff --git a/src/util/string.vala b/src/util/string.vala index bf7e605..89424d0 100644 --- a/src/util/string.vala +++ b/src/util/string.vala @@ -13,13 +13,13 @@ public inline bool is_string_empty(string? s) { } // utf8 case sensitive compare -public int utf8_cs_compare(void *a, void *b) { - return ((string) a).collate((string) b); +public int utf8_cs_compare(string a, string b) { + return a.collate(b); } // utf8 case insensitive compare -public int utf8_ci_compare(void *a, void *b) { - return ((string) a).down().collate(((string) b).down()); +public int utf8_ci_compare(string a, string b) { + return a.down().collate(b.down()); } // utf8 array to string @@ -145,7 +145,7 @@ public string? prepare_input_text(string? text, PrepareInputTextOptions options, // Using composed form rather than GLib's default (decomposed) as NFC is the preferred form in // Linux and WWW. More importantly, Pango seems to have serious problems displaying decomposed // forms of Korean language glyphs (and perhaps others). See: - // http://trac.yorba.org/ticket/2952 + // https://bugzilla.gnome.org/show_bug.cgi?id=716914 if ((options & PrepareInputTextOptions.NORMALIZE) != 0) prepped = prepped.normalize(-1, NormalizeMode.NFC); @@ -237,6 +237,8 @@ public string remove_diacritics(string istring) { case UnicodeType.ENCLOSING_MARK: // Ignore those continue; + default: + break; } builder.append_unichar(ch); } @@ -255,7 +257,7 @@ public string to_hex_string(string str) { // A note on the collated_* and precollated_* methods: // -// A bug report (http://trac.yorba.org/ticket/3152) indicated that two different Hirigana characters +// A bug report (https://bugzilla.gnome.org/show_bug.cgi?id=717135) indicated that two different Hirigana characters // as Tag names would trigger an assertion. Investigation showed that the characters' collation // keys computed as equal when the locale was set to anything but the default locale (C) or // Japanese. A related bug was that another hash table was using str_equal, which does not use diff --git a/src/util/system.vala b/src/util/system.vala index 1e69304..48e2cc9 100644 --- a/src/util/system.vala +++ b/src/util/system.vala @@ -6,7 +6,7 @@ // Return the directory in which Shotwell is installed, or null if uninstalled. File? get_sys_install_dir(File exec_dir) { - // Assume that if the ui folder lives next to the binary, we runn in-tree + // Assume that if the ui folder lives next to the binary, we run in-tree File child = exec_dir.get_child("ui"); if (!FileUtils.test(child.get_path(), FileTest.IS_DIR | FileTest.EXISTS)) { @@ -39,7 +39,8 @@ async void show_file_in_filemanager(File file) throws Error { DBusProxyFlags.DO_NOT_LOAD_PROPERTIES | DBusProxyFlags.DO_NOT_CONNECT_SIGNALS); var id = "%s_%s_%d_%s".printf(Environment.get_prgname(), Environment.get_host_name(), - Posix.getpid(), TimeVal().to_iso8601()); + Posix.getpid(), + GLib.get_monotonic_time().to_string()); yield manager.show_items({file.get_uri()}, id); } catch (Error e) { warning("Failed to launch file manager using DBus, using fall-back: %s", e.message); diff --git a/src/util/ui.vala b/src/util/ui.vala index 6d32738..bdc7157 100644 --- a/src/util/ui.vala +++ b/src/util/ui.vala @@ -60,7 +60,7 @@ public Gdk.Rectangle get_adjustment_page(Gtk.Adjustment hadj, Gtk.Adjustment vad } // Verifies that only the mask bits are set in the modifier field, disregarding mouse and -// key modifers that are not normally of concern (i.e. Num Lock, Caps Lock, etc.). Mask can be +// key modifiers that are not normally of concern (i.e. Num Lock, Caps Lock, etc.). Mask can be // one or more bits set, but should only consist of these values: // * Gdk.ModifierType.SHIFT_MASK // * Gdk.ModifierType.CONTROL_MASK @@ -87,16 +87,15 @@ public bool has_only_key_modifier(Gdk.ModifierType field, Gdk.ModifierType mask) } bool is_pointer_over(Gdk.Window window) { - Gdk.DeviceManager? devmgr = window.get_display().get_device_manager(); - if (devmgr == null) { - debug("No device for display"); + var seat = window.get_display().get_default_seat(); + if (seat == null) { + debug("No seat for display"); return false; } int x, y; - devmgr.get_client_pointer().get_position(null, out x, out y); - //gdk_device_get_position(devmgr.get_client_pointer(), null, out x, out y); + seat.get_pointer().get_position(null, out x, out y); return x >= 0 && y >= 0 && x < window.get_width() && y < window.get_height(); } diff --git a/src/video-support/AVIChunk.vala b/src/video-support/AVIChunk.vala new file mode 100644 index 0000000..970f443 --- /dev/null +++ b/src/video-support/AVIChunk.vala @@ -0,0 +1,121 @@ +private class AVIChunk { + private GLib.File file = null; + private string section_name = ""; + private uint64 section_size = 0; + private uint64 section_offset = 0; + private GLib.DataInputStream input = null; + private AVIChunk? parent = null; + private const int MAX_STRING_TO_SECTION_LENGTH = 1024; + + public AVIChunk(GLib.File file) { + this.file = file; + } + + private AVIChunk.with_input_stream(GLib.DataInputStream input, AVIChunk parent) { + this.input = input; + this.parent = parent; + } + + public void open_file() throws GLib.Error { + close_file(); + input = new GLib.DataInputStream(file.read()); + input.set_byte_order(DataStreamByteOrder.LITTLE_ENDIAN); + section_size = 0; + section_offset = 0; + section_name = ""; + } + + public void close_file() throws GLib.Error { + if (null != input) { + input.close(); + input = null; + } + } + + public void nonsection_skip(uint64 skip_amount) throws GLib.Error { + skip_uint64(input, skip_amount); + } + + public void skip(uint64 skip_amount) throws GLib.Error { + advance_section_offset(skip_amount); + skip_uint64(input, skip_amount); + } + + public AVIChunk get_first_child_chunk() { + return new AVIChunk.with_input_stream(input, this); + } + + private void advance_section_offset(uint64 amount) { + if ((section_offset + amount) > section_size) + amount = section_size - section_offset; + + section_offset += amount; + if (null != parent) { + parent.advance_section_offset(amount); + } + } + + public uchar read_byte() throws GLib.Error { + advance_section_offset(1); + return input.read_byte(); + } + + public uint16 read_uint16() throws GLib.Error { + advance_section_offset(2); + return input.read_uint16(); + } + + public void read_chunk() throws GLib.Error { + // don't use checked reads here because they advance the section offset, which we're trying + // to determine here + GLib.StringBuilder sb = new GLib.StringBuilder(); + sb.append_c((char) input.read_byte()); + sb.append_c((char) input.read_byte()); + sb.append_c((char) input.read_byte()); + sb.append_c((char) input.read_byte()); + section_name = sb.str; + section_size = input.read_uint32(); + section_offset = 0; + } + + public string read_name() throws GLib.Error { + GLib.StringBuilder sb = new GLib.StringBuilder(); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + return sb.str; + } + + public void next_chunk() throws GLib.Error { + skip(section_size_remaining()); + section_size = 0; + section_offset = 0; + } + + public string get_current_chunk_name() { + return section_name; + } + + public bool is_last_chunk() { + return section_size == 0; + } + + public uint64 section_size_remaining() { + assert(section_size >= section_offset); + return section_size - section_offset; + } + + // Reads section contents into a string. + public string section_to_string() throws GLib.Error { + GLib.StringBuilder sb = new GLib.StringBuilder(); + while (section_offset < section_size) { + sb.append_c((char) read_byte()); + if (sb.len > MAX_STRING_TO_SECTION_LENGTH) { + return sb.str; + } + } + return sb.str; + } + +} diff --git a/src/video-support/AVIMetadataLoader.vala b/src/video-support/AVIMetadataLoader.vala new file mode 100644 index 0000000..2b507e2 --- /dev/null +++ b/src/video-support/AVIMetadataLoader.vala @@ -0,0 +1,227 @@ +public class AVIMetadataLoader { + + private File file = null; + + // A numerical date string, i.e 2010:01:28 14:54:25 + private const int NUMERICAL_DATE_LENGTH = 19; + + // Marker for timestamp section in a Nikon nctg blob. + private const uint16 NIKON_NCTG_TIMESTAMP_MARKER = 0x13; + + // Size limit to ensure we don't parse forever on a bad file. + private const int MAX_STRD_LENGTH = 100; + + public AVIMetadataLoader(File file) { + this.file = file; + } + + public MetadataDateTime? get_creation_date_time() { + return new MetadataDateTime(get_creation_date_time_for_avi()); + } + + public string? get_title() { + // Not supported. + return null; + } + + // Checks if the given file is an AVI file. + public bool is_supported() { + AVIChunk chunk = new AVIChunk(file); + bool ret = false; + try { + chunk.open_file(); + chunk.read_chunk(); + // Look for the header and identifier. + if ("RIFF" == chunk.get_current_chunk_name() && + "AVI " == chunk.read_name()) { + ret = true; + } + } catch (GLib.Error e) { + debug("Error while testing for AVI file: %s", e.message); + } + + try { + chunk.close_file(); + } catch (GLib.Error e) { + debug("Error while closing AVI file: %s", e.message); + } + return ret; + } + + // Parses a Nikon nctg tag. Based losely on avi_read_nikon() in FFmpeg. + private string read_nikon_nctg_tag(AVIChunk chunk) throws GLib.Error { + bool found_date = false; + while (chunk.section_size_remaining() > sizeof(uint16)*2) { + uint16 tag = chunk.read_uint16(); + uint16 size = chunk.read_uint16(); + if (NIKON_NCTG_TIMESTAMP_MARKER == tag) { + found_date = true; + break; + } + chunk.skip(size); + } + + if (found_date) { + // Read numerical date string, example: 2010:01:28 14:54:25 + GLib.StringBuilder sb = new GLib.StringBuilder(); + for (int i = 0; i < NUMERICAL_DATE_LENGTH; i++) { + sb.append_c((char) chunk.read_byte()); + } + return sb.str; + } + return ""; + } + + // Parses a Fujifilm strd tag. Based on information from: + // http://www.eden-foundation.org/products/code/film_date_stamp/index.html + private string read_fuji_strd_tag(AVIChunk chunk) throws GLib.Error { + chunk.skip(98); // Ignore 98-byte binary blob. + chunk.skip(8); // Ignore the string "FUJIFILM" + // Read until we find four colons, then two more chars. + int colons = 0; + int post_colons = 0; + GLib.StringBuilder sb = new GLib.StringBuilder(); + // End of date is two chars past the fourth colon. + while (colons <= 4 && post_colons < 2) { + char c = (char) chunk.read_byte(); + if (4 == colons) { + post_colons++; + } + if (':' == c) { + colons++; + } + if (c.isprint()) { + sb.append_c(c); + } + if (sb.len > MAX_STRD_LENGTH) { + return ""; // Give up searching. + } + } + + if (sb.str.length < NUMERICAL_DATE_LENGTH) { + return ""; + } + // Date is now at the end of the string. + return sb.str.substring(sb.str.length - NUMERICAL_DATE_LENGTH); + } + + // Recursively read file until the section is found. + private string? read_section(AVIChunk chunk) throws GLib.Error { + while (true) { + chunk.read_chunk(); + string name = chunk.get_current_chunk_name(); + if ("IDIT" == name) { + return chunk.section_to_string(); + } else if ("nctg" == name) { + return read_nikon_nctg_tag(chunk); + } else if ("strd" == name) { + return read_fuji_strd_tag(chunk); + } + + if ("LIST" == name) { + chunk.read_name(); // Read past list name. + string result = read_section(chunk.get_first_child_chunk()); + if (null != result) { + return result; + } + } + + if (chunk.is_last_chunk()) { + break; + } + chunk.next_chunk(); + } + return null; + } + + // Parses a date from a string. + // Largely based on GStreamer's avi/gstavidemux.c + // and the information here: + // http://www.eden-foundation.org/products/code/film_date_stamp/index.html + private DateTime? parse_date(string sdate) { + if (sdate.length == 0) { + return null; + } + + int year, month, day, hour, min, sec; + char weekday[4]; + char monthstr[4]; + DateTime parsed_date; + + if (sdate[0].isdigit()) { + // Format is: 2005:08:17 11:42:43 + // Format is: 2010/11/30/ 19:42 + // Format is: 2010/11/30 19:42 + string tmp = sdate.dup(); + tmp.canon("0123456789 ", ' '); // strip everything but numbers and spaces + sec = 0; + int result = tmp.scanf("%d %d %d %d %d %d", out year, out month, out day, out hour, out min, out sec); + if(result < 5) { + return null; + } + + parsed_date = new DateTime.utc(year, month, day, hour, min, sec); + } else { + // Format is: Mon Mar 3 09:44:56 2008 + if(7 != sdate.scanf("%3s %3s %d %d:%d:%d %d", weekday, monthstr, out day, out hour, + out min, out sec, out year)) { + return null; // Error + } + parsed_date = new DateTime.local(year, month_from_string((string)monthstr), day, hour, min, sec); + } + + return parsed_date; + } + + private DateMonth month_from_string(string s) { + switch (s.down()) { + case "jan": + return DateMonth.JANUARY; + case "feb": + return DateMonth.FEBRUARY; + case "mar": + return DateMonth.MARCH; + case "apr": + return DateMonth.APRIL; + case "may": + return DateMonth.MAY; + case "jun": + return DateMonth.JUNE; + case "jul": + return DateMonth.JULY; + case "aug": + return DateMonth.AUGUST; + case "sep": + return DateMonth.SEPTEMBER; + case "oct": + return DateMonth.OCTOBER; + case "nov": + return DateMonth.NOVEMBER; + case "dec": + return DateMonth.DECEMBER; + } + return DateMonth.BAD_MONTH; + } + + private DateTime? get_creation_date_time_for_avi() { + AVIChunk chunk = new AVIChunk(file); + DateTime? timestamp = null; + try { + chunk.open_file(); + chunk.nonsection_skip(12); // Advance past 12 byte header. + string sdate = read_section(chunk); + if (null != sdate) { + timestamp = parse_date(sdate.strip()); + } + } catch (GLib.Error e) { + debug("Error while reading AVI file: %s", e.message); + } + + try { + chunk.close_file(); + } catch (GLib.Error e) { + debug("Error while closing AVI file: %s", e.message); + } + return timestamp; + } +} diff --git a/src/video-support/QuickTimeAtom.vala b/src/video-support/QuickTimeAtom.vala new file mode 100644 index 0000000..996046a --- /dev/null +++ b/src/video-support/QuickTimeAtom.vala @@ -0,0 +1,118 @@ +private class QuickTimeAtom { + private GLib.File file = null; + private string section_name = ""; + private uint64 section_size = 0; + private uint64 section_offset = 0; + private GLib.DataInputStream input = null; + private QuickTimeAtom? parent = null; + + public QuickTimeAtom(GLib.File file) { + this.file = file; + } + + private QuickTimeAtom.with_input_stream(GLib.DataInputStream input, QuickTimeAtom parent) { + this.input = input; + this.parent = parent; + } + + public void open_file() throws GLib.Error { + close_file(); + input = new GLib.DataInputStream(file.read()); + input.set_byte_order(DataStreamByteOrder.BIG_ENDIAN); + section_size = 0; + section_offset = 0; + section_name = ""; + } + + public void close_file() throws GLib.Error { + if (null != input) { + input.close(); + input = null; + } + } + + private void advance_section_offset(uint64 amount) { + section_offset += amount; + if (null != parent) { + parent.advance_section_offset(amount); + } + } + + public QuickTimeAtom get_first_child_atom() { + // Child will simply have the input stream + // but not the size/offset. This works because + // child atoms follow immediately after a header, + // so no skipping is required to access the child + // from the current position. + return new QuickTimeAtom.with_input_stream(input, this); + } + + public uchar read_byte() throws GLib.Error { + advance_section_offset(1); + return input.read_byte(); + } + + public uint32 read_uint32() throws GLib.Error { + advance_section_offset(4); + return input.read_uint32(); + } + + public uint64 read_uint64() throws GLib.Error { + advance_section_offset(8); + return input.read_uint64(); + } + + public void read_atom() throws GLib.Error { + // Read atom size. + section_size = read_uint32(); + + // Read atom name. + GLib.StringBuilder sb = new GLib.StringBuilder(); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + sb.append_c((char) read_byte()); + section_name = sb.str; + + // Check string. + if (section_name.length != 4) { + throw new IOError.NOT_SUPPORTED("QuickTime atom name length is invalid for %s", + file.get_path()); + } + for (int i = 0; i < section_name.length; i++) { + if (!section_name[i].isprint()) { + throw new IOError.NOT_SUPPORTED("Bad QuickTime atom in file %s", file.get_path()); + } + } + + if (1 == section_size) { + // This indicates the section size is a 64-bit + // value, specified below the atom name. + section_size = read_uint64(); + } + } + + private void skip(uint64 skip_amount) throws GLib.Error { + skip_uint64(input, skip_amount); + } + + public uint64 section_size_remaining() { + assert(section_size >= section_offset); + return section_size - section_offset; + } + + public void next_atom() throws GLib.Error { + skip(section_size_remaining()); + section_size = 0; + section_offset = 0; + } + + public string get_current_atom_name() { + return section_name; + } + + public bool is_last_atom() { + return 0 == section_size; + } + +} diff --git a/src/video-support/QuicktimeMetdataLoader.vala b/src/video-support/QuicktimeMetdataLoader.vala new file mode 100644 index 0000000..0a831d2 --- /dev/null +++ b/src/video-support/QuicktimeMetdataLoader.vala @@ -0,0 +1,127 @@ +public class QuickTimeMetadataLoader { + + // Quicktime calendar date/time format is number of seconds since January 1, 1904. + // This converts to UNIX time (66 years + 17 leap days). + public const int64 QUICKTIME_EPOCH_ADJUSTMENT = 2082844800; + + private File file = null; + + public QuickTimeMetadataLoader(File file) { + this.file = file; + } + + public MetadataDateTime? get_creation_date_time() { + var dt = get_creation_date_time_for_quicktime(); + if (dt == null) { + return null; + } else { + return new MetadataDateTime(dt); + } + } + + public string? get_title() { + // Not supported. + return null; + } + + // Checks if the given file is a QuickTime file. + public bool is_supported() { + QuickTimeAtom test = new QuickTimeAtom(file); + + bool ret = false; + try { + test.open_file(); + test.read_atom(); + + // Look for the header. + if ("ftyp" == test.get_current_atom_name()) { + ret = true; + } else { + // Some versions of QuickTime don't have + // an ftyp section, so we'll just look + // for the mandatory moov section. + while(true) { + if ("moov" == test.get_current_atom_name()) { + ret = true; + break; + } + test.next_atom(); + test.read_atom(); + if (test.is_last_atom()) { + break; + } + } + } + } catch (GLib.Error e) { + debug("Error while testing for QuickTime file for %s: %s", file.get_path(), e.message); + } + + try { + test.close_file(); + } catch (GLib.Error e) { + debug("Error while closing Quicktime file: %s", e.message); + } + return ret; + } + + private DateTime? get_creation_date_time_for_quicktime() { + QuickTimeAtom test = new QuickTimeAtom(file); + DateTime? timestamp = null; + + try { + test.open_file(); + bool done = false; + while(!done) { + // Look for "moov" section. + test.read_atom(); + if (test.is_last_atom()) break; + if ("moov" == test.get_current_atom_name()) { + QuickTimeAtom child = test.get_first_child_atom(); + while (!done) { + // Look for "mvhd" section, or break if none is found. + child.read_atom(); + if (child.is_last_atom() || 0 == child.section_size_remaining()) { + done = true; + break; + } + + if ("mvhd" == child.get_current_atom_name()) { + // Skip 4 bytes (version + flags) + child.read_uint32(); + // Grab the timestamp. + + // Some Android phones package videos recorded with their internal cameras in a 3GP + // container that looks suspiciously like a QuickTime container but really isn't -- for + // the timestamps of these Android 3GP videos are relative to the UNIX epoch + // (January 1, 1970) instead of the QuickTime epoch (January 1, 1904). So, if we detect a + // QuickTime movie with a negative timestamp, we can be pretty sure it isn't a valid + // QuickTime movie that was shot before 1904 but is instead a non-compliant 3GP video + // file. If we detect such a video, we correct its time. See this Redmine ticket + // (https://bugzilla.gnome.org/show_bug.cgi?id=717384) for more information. + + if ((child.read_uint32() - QUICKTIME_EPOCH_ADJUSTMENT) < 0) { + timestamp = new DateTime.from_unix_utc(child.read_uint32()); + } else { + timestamp = new DateTime.from_unix_utc(child.read_uint32() - QUICKTIME_EPOCH_ADJUSTMENT); + } + done = true; + break; + } + child.next_atom(); + } + } + test.next_atom(); + } + } catch (GLib.Error e) { + debug("Error while testing for QuickTime file: %s", e.message); + } + + try { + test.close_file(); + } catch (GLib.Error e) { + debug("Error while closing Quicktime file: %s", e.message); + } + + return timestamp; + } +} diff --git a/src/VideoSupport.vala b/src/video-support/Video.vala index ec827ea..0238d7f 100644 --- a/src/VideoSupport.vala +++ b/src/video-support/Video.vala @@ -4,352 +4,32 @@ * See the COPYING file in this distribution. */ -public errordomain VideoError { - FILE, // there's a problem reading the video container file (doesn't exist, no read - // permission, etc.) - - CONTENTS, // we can read the container file but its contents are indecipherable (no codec, - // malformed data, etc.) -} - -public class VideoImportParams { - // IN: - public File file; - public ImportID import_id = ImportID(); - public string? md5; - public time_t exposure_time_override; - - // IN/OUT: - public Thumbnails? thumbnails; - - // OUT: - public VideoRow row = new VideoRow(); - - public VideoImportParams(File file, ImportID import_id, string? md5, - Thumbnails? thumbnails = null, time_t exposure_time_override = 0) { - this.file = file; - this.import_id = import_id; - this.md5 = md5; - this.thumbnails = thumbnails; - this.exposure_time_override = exposure_time_override; - } -} - -public class VideoReader { - private const double UNKNOWN_CLIP_DURATION = -1.0; - private const uint THUMBNAILER_TIMEOUT = 10000; // In milliseconds. - - // File extensions for video containers that pack only metadata as per the AVCHD spec - private const string[] METADATA_ONLY_FILE_EXTENSIONS = { "bdm", "bdmv", "cpi", "mpl" }; - - private double clip_duration = UNKNOWN_CLIP_DURATION; - private Gdk.Pixbuf preview_frame = null; - private File file = null; - private GLib.Pid thumbnailer_pid = 0; - public DateTime? timestamp { get; private set; default = null; } - - public VideoReader(File file) { - this.file = file; - } - - public static bool is_supported_video_file(File file) { - var mime_type = ContentType.guess(file.get_basename(), new uchar[0], null); - // special case: deep-check content-type of files ending with .ogg - if (mime_type == "audio/ogg" && file.has_uri_scheme("file")) { - try { - var info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, - FileQueryInfoFlags.NONE); - var content_type = info.get_content_type(); - if (content_type != null && content_type.has_prefix ("video/")) { - return true; - } - } catch (Error error) { - debug("Failed to query content type: %s", error.message); - } - } - - return is_supported_video_filename(file.get_basename()); - } - - public static bool is_supported_video_filename(string filename) { - string mime_type; - mime_type = ContentType.guess(filename, new uchar[0], null); - // Guessed mp4 from filename has application/ as prefix, so check for mp4 in the end - if (mime_type.has_prefix ("video/") || mime_type.has_suffix("mp4")) { - string? extension = null; - string? name = null; - disassemble_filename(filename, out name, out extension); - - if (extension == null) - return true; - - foreach (string s in METADATA_ONLY_FILE_EXTENSIONS) { - if (utf8_ci_compare(s, extension) == 0) - return false; - } - - return true; - } else { - debug("Skipping %s, unsupported mime type %s", filename, mime_type); - return false; - } - } - - public static ImportResult prepare_for_import(VideoImportParams params) { -#if MEASURE_IMPORT - Timer total_time = new Timer(); -#endif - File file = params.file; - - FileInfo info = null; - try { - info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, - FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); - } catch (Error err) { - return ImportResult.FILE_ERROR; - } - - if (info.get_file_type() != FileType.REGULAR) - return ImportResult.NOT_A_FILE; - - if (!is_supported_video_file(file)) { - message("Not importing %s: file is marked as a video file but doesn't have a" + - "supported extension", file.get_path()); - - return ImportResult.UNSUPPORTED_FORMAT; - } - - TimeVal timestamp = info.get_modification_time(); - - // make sure params has a valid md5 - assert(params.md5 != null); - - time_t exposure_time = params.exposure_time_override; - string title = ""; - string comment = ""; - - VideoReader reader = new VideoReader(file); - bool is_interpretable = true; - double clip_duration = 0.0; - Gdk.Pixbuf preview_frame = reader.read_preview_frame(); - try { - clip_duration = reader.read_clip_duration(); - } catch (VideoError err) { - if (err is VideoError.FILE) { - return ImportResult.FILE_ERROR; - } else if (err is VideoError.CONTENTS) { - is_interpretable = false; - clip_duration = 0.0; - } else { - error("can't prepare video for import: an unknown kind of video error occurred"); - } - } - - try { - VideoMetadata metadata = reader.read_metadata(); - MetadataDateTime? creation_date_time = metadata.get_creation_date_time(); - - if (creation_date_time != null && creation_date_time.get_timestamp() != 0) - exposure_time = creation_date_time.get_timestamp(); - - string? video_title = metadata.get_title(); - string? video_comment = metadata.get_comment(); - if (video_title != null) - title = video_title; - if (video_comment != null) - comment = video_comment; - } catch (Error err) { - warning("Unable to read video metadata: %s", err.message); - } - - if (exposure_time == 0) { - // Use time reported by Gstreamer, if available. - exposure_time = (time_t) (reader.timestamp != null ? - reader.timestamp.to_unix() : 0); - } - - params.row.video_id = VideoID(); - params.row.filepath = file.get_path(); - params.row.filesize = info.get_size(); - params.row.timestamp = timestamp.tv_sec; - params.row.width = preview_frame.width; - params.row.height = preview_frame.height; - params.row.clip_duration = clip_duration; - params.row.is_interpretable = is_interpretable; - params.row.exposure_time = exposure_time; - params.row.import_id = params.import_id; - params.row.event_id = EventID(); - params.row.md5 = params.md5; - params.row.time_created = 0; - params.row.title = title; - params.row.comment = comment; - params.row.backlinks = ""; - params.row.time_reimported = 0; - params.row.flags = 0; - - if (params.thumbnails != null) { - params.thumbnails = new Thumbnails(); - ThumbnailCache.generate_for_video_frame(params.thumbnails, preview_frame); - } - -#if MEASURE_IMPORT - debug("IMPORT: total time to import video = %lf", total_time.elapsed()); -#endif - return ImportResult.SUCCESS; - } - - private void read_internal() throws VideoError { - if (!does_file_exist()) - throw new VideoError.FILE("video file '%s' does not exist or is inaccessible".printf( - file.get_path())); - - try { - Gst.PbUtils.Discoverer d = new Gst.PbUtils.Discoverer((Gst.ClockTime) (Gst.SECOND * 5)); - Gst.PbUtils.DiscovererInfo info = d.discover_uri(file.get_uri()); - - clip_duration = ((double) info.get_duration()) / 1000000000.0; - - // Get creation time. - // TODO: Note that TAG_DATE can be changed to TAG_DATE_TIME in the future - // (and the corresponding output struct) in order to implement #2836. - Date? video_date = null; - if (info.get_tags() != null && info.get_tags().get_date(Gst.Tags.DATE, out video_date)) { - // possible for get_date() to return true and a null Date - if (video_date != null) { - timestamp = new DateTime.local(video_date.get_year(), video_date.get_month(), - video_date.get_day(), 0, 0, 0); - } - } - } catch (Error e) { - debug("Video read error: %s", e.message); - throw new VideoError.CONTENTS("GStreamer couldn't extract clip information: %s" - .printf(e.message)); - } - } - - // Used by thumbnailer() to kill the external process if need be. - private bool on_thumbnailer_timer() { - debug("Thumbnailer timer called"); - if (thumbnailer_pid != 0) { - debug("Killing thumbnailer process: %d", thumbnailer_pid); -#if VALA_0_40 - Posix.kill(thumbnailer_pid, Posix.Signal.KILL); -#else - Posix.kill(thumbnailer_pid, Posix.SIGKILL); -#endif - } - return false; // Don't call again. - } - - // Performs video thumbnailing. - // Note: not thread-safe if called from the same instance of the class. - private Gdk.Pixbuf? thumbnailer(string video_file) { - // Use Shotwell's thumbnailer, redirect output to stdout. - debug("Launching thumbnailer process: %s", AppDirs.get_thumbnailer_bin().get_path()); - string[] argv = {AppDirs.get_thumbnailer_bin().get_path(), video_file}; - int child_stdout; - try { - GLib.Process.spawn_async_with_pipes(null, argv, null, GLib.SpawnFlags.SEARCH_PATH | - GLib.SpawnFlags.DO_NOT_REAP_CHILD, null, out thumbnailer_pid, null, out child_stdout, - null); - debug("Spawned thumbnailer, child pid: %d", (int) thumbnailer_pid); - } catch (Error e) { - debug("Error spawning process: %s", e.message); - if (thumbnailer_pid != 0) - GLib.Process.close_pid(thumbnailer_pid); - return null; - } - - // Start timer. - Timeout.add(THUMBNAILER_TIMEOUT, on_thumbnailer_timer); - - // Read pixbuf from stream. - Gdk.Pixbuf? buf = null; - try { - GLib.UnixInputStream unix_input = new GLib.UnixInputStream(child_stdout, true); - buf = new Gdk.Pixbuf.from_stream(unix_input, null); - } catch (Error e) { - debug("Error creating pixbuf: %s", e.message); - buf = null; - } - - // Make sure process exited properly. - int child_status = 0; - int ret_waitpid = Posix.waitpid(thumbnailer_pid, out child_status, 0); - if (ret_waitpid < 0) { - debug("waitpid returned error code: %d", ret_waitpid); - buf = null; - } else if (0 != Process.exit_status(child_status)) { - debug("Thumbnailer exited with error code: %d", - Process.exit_status(child_status)); - buf = null; - } - - GLib.Process.close_pid(thumbnailer_pid); - thumbnailer_pid = 0; - return buf; - } - - private bool does_file_exist() { - return FileUtils.test(file.get_path(), FileTest.EXISTS | FileTest.IS_REGULAR); - } - - public Gdk.Pixbuf? read_preview_frame() { - if (preview_frame != null) - return preview_frame; - - if (!does_file_exist()) - return null; - - // Get preview frame from thumbnailer. - preview_frame = thumbnailer(file.get_path()); - if (null == preview_frame) - preview_frame = Resources.get_noninterpretable_badge_pixbuf(); - - return preview_frame; - } - - public double read_clip_duration() throws VideoError { - if (clip_duration == UNKNOWN_CLIP_DURATION) - read_internal(); - - return clip_duration; - } - - public VideoMetadata read_metadata() throws Error { - VideoMetadata metadata = new VideoMetadata(); - metadata.read_from_file(File.new_for_path(file.get_path())); - - return metadata; - } -} - public class Video : VideoSource, Flaggable, Monitorable, Dateable { public const string TYPENAME = "video"; - + public const uint64 FLAG_TRASH = 0x0000000000000001; public const uint64 FLAG_OFFLINE = 0x0000000000000002; public const uint64 FLAG_FLAGGED = 0x0000000000000004; - + public class InterpretableResults { internal Video video; internal bool update_interpretable = false; internal bool is_interpretable = false; internal Gdk.Pixbuf? new_thumbnail = null; - + public InterpretableResults(Video video) { this.video = video; } - + public void foreground_finish() { if (update_interpretable) video.set_is_interpretable(is_interpretable); - + if (new_thumbnail != null) { try { ThumbnailCache.replace(video, ThumbnailCache.Size.BIG, new_thumbnail); ThumbnailCache.replace(video, ThumbnailCache.Size.MEDIUM, new_thumbnail); - + video.notify_thumbnail_altered(); } catch (Error err) { message("Unable to update video thumbnails for %s: %s", video.to_string(), @@ -358,19 +38,19 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { } } } - + private static bool normal_regen_complete; private static bool offline_regen_complete; public static VideoSourceCollection global; private VideoRow backing_row; - + public Video(VideoRow row) { this.backing_row = row; - + // normalize user text this.backing_row.title = prep_title(this.backing_row.title); - + if (((row.flags & FLAG_TRASH) != 0) || ((row.flags & FLAG_OFFLINE) != 0)) rehydrate_backlinks(global, row.backlinks); } @@ -381,7 +61,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { // https://bugzilla.gnome.org/show_bug.cgi?id=655594 normal_regen_complete = false; offline_regen_complete = false; - + // initialize GStreamer, but don't pass it our actual command line arguments -- we don't // want our end users to be able to parameterize the GStreamer configuration unowned string[] args = null; @@ -404,7 +84,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { } global = new VideoSourceCollection(); - + Gee.ArrayList<VideoRow?> all = VideoTable.get_instance().get_all(); Gee.ArrayList<Video> all_videos = new Gee.ArrayList<Video>(); Gee.ArrayList<Video> trashed_videos = new Gee.ArrayList<Video>(); @@ -412,14 +92,14 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { int count = all.size; for (int ctr = 0; ctr < count; ctr++) { Video video = new Video(all.get(ctr)); - + if (video.is_trashed()) trashed_videos.add(video); else if (video.is_offline()) offline_videos.add(video); else all_videos.add(video); - + if (monitor != null) monitor(ctr, count); } @@ -428,7 +108,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { global.add_many_to_offline(offline_videos); global.add_many(all_videos); } - + public static void notify_normal_thumbs_regenerated() { if (normal_regen_complete) return; @@ -449,12 +129,12 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public static void terminate() { } - + public static ExporterUI? export_many(Gee.Collection<Video> videos, Exporter.CompletionCallback done, - bool export_in_place = false) { + bool export_in_place = false) { if (videos.size == 0) return null; - + // in place export is relatively easy -- provide a fast, separate code path for it if (export_in_place) { ExporterUI temp_exporter = new ExporterUI(new Exporter.for_temp_file(videos, @@ -470,11 +150,11 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { video = v; break; } - + File save_as = ExportUI.choose_file(video.get_basename()); if (save_as == null) return null; - + try { AppWindow.get_instance().set_busy_cursor(); video.export(save_as); @@ -483,7 +163,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { AppWindow.get_instance().set_normal_cursor(); export_error_dialog(save_as, false); } - + return null; } @@ -491,7 +171,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { File export_dir = ExportUI.choose_dir(_("Export Videos")); if (export_dir == null) return null; - + ExporterUI exporter = new ExporterUI(new Exporter(videos, export_dir, Scaling.for_original(), ExportFormatParameters.unmodified())); exporter.export(done); @@ -499,7 +179,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { return exporter; } - protected override void commit_backlinks(SourceCollection? sources, string? backlinks) { + protected override void commit_backlinks(SourceCollection? sources, string? backlinks) { try { VideoTable.get_instance().update_backlinks(get_video_id(), backlinks); lock (backing_row) { @@ -529,10 +209,10 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { return false; #endif } - + public static ImportResult import_create(VideoImportParams params, out Video video) { video = null; - + // add to the database try { if (VideoTable.get_instance().add(params.row).is_invalid()) @@ -540,13 +220,13 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { } catch (DatabaseError err) { return ImportResult.DATABASE_ERROR; } - + // create local object but don't add to global until thumbnails generated video = new Video(params.row); return ImportResult.SUCCESS; } - + public static void import_failed(Video video) { try { VideoTable.get_instance().remove(video.get_video_id()); @@ -554,17 +234,17 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { AppWindow.database_error(err); } } - + public override BackingFileState[] get_backing_files_state() { BackingFileState[] backing = new BackingFileState[1]; lock (backing_row) { - backing[0] = new BackingFileState(backing_row.filepath, backing_row.filesize, + backing[0] = new BackingFileState(backing_row.filepath, backing_row.filesize, backing_row.timestamp, backing_row.md5); } - + return backing; } - + public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error { return ThumbnailCache.fetch(this, scale); } @@ -577,21 +257,21 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override Gdk.Pixbuf get_preview_pixbuf(Scaling scaling) throws Error { Gdk.Pixbuf pixbuf = get_thumbnail(ThumbnailCache.Size.BIG); - + return scaling.perform_on_pixbuf(pixbuf, Gdk.InterpType.NEAREST, true); } public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error { VideoReader reader = new VideoReader(get_file()); Gdk.Pixbuf? frame = reader.read_preview_frame(); - + return (frame != null) ? frame : Resources.get_noninterpretable_badge_pixbuf().copy(); } - + public override string get_typename() { return TYPENAME; } - + public override int64 get_instance_id() { return get_video_id().id; } @@ -605,7 +285,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override PhotoFileFormat get_preferred_thumbnail_format() { return PhotoFileFormat.get_system_default_format(); } - + public override string? get_title() { lock (backing_row) { return backing_row.title; @@ -614,7 +294,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override void set_title(string? title) { string? new_title = prep_title(title); - + lock (backing_row) { if (backing_row.title == new_title) return; @@ -641,7 +321,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override bool set_comment(string? comment) { string? new_comment = prep_title(comment); - + lock (backing_row) { if (backing_row.comment == new_comment) return true; @@ -656,7 +336,7 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { // successfully committed to the database, so update it in the in-memory row cache backing_row.comment = new_comment; } - + notify_altered(new Alteration("metadata", "comment")); return true; @@ -711,10 +391,10 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override void mark_offline() { add_flags(FLAG_OFFLINE); } - + public override void mark_online() { remove_flags(FLAG_OFFLINE); - + if ((!get_is_interpretable())) check_is_interpretable().foreground_finish(); } @@ -722,48 +402,48 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override void trash() { add_flags(FLAG_TRASH); } - + public override void untrash() { remove_flags(FLAG_TRASH); } - + public bool is_flagged() { return is_flag_set(FLAG_FLAGGED); } - + public void mark_flagged() { add_flags(FLAG_FLAGGED, new Alteration("metadata", "flagged")); } - + public void mark_unflagged() { remove_flags(FLAG_FLAGGED, new Alteration("metadata", "flagged")); } - + public override EventID get_event_id() { lock (backing_row) { return backing_row.event_id; } } - + public override string to_string() { lock (backing_row) { return "[%s] %s".printf(backing_row.video_id.id.to_string(), backing_row.filepath); } } - + public VideoID get_video_id() { lock (backing_row) { return backing_row.video_id; } } - - public override time_t get_exposure_time() { + + public override DateTime? get_exposure_time() { lock (backing_row) { return backing_row.exposure_time; } } - - public void set_exposure_time(time_t time) { + + public void set_exposure_time(DateTime time) { lock (backing_row) { try { VideoTable.get_instance().set_exposure_time(backing_row.video_id, time); @@ -772,10 +452,10 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { } backing_row.exposure_time = time; } - + notify_altered(new Alteration("metadata", "exposure-time")); - } - + } + public Dimensions get_frame_dimensions() { lock (backing_row) { return Dimensions(backing_row.width, backing_row.height); @@ -785,99 +465,99 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { public override Dimensions get_dimensions(Photo.Exception disallowed_steps = Photo.Exception.NONE) { return get_frame_dimensions(); } - + public override uint64 get_filesize() { return get_master_filesize(); } - + public override uint64 get_master_filesize() { lock (backing_row) { return backing_row.filesize; } } - - public override time_t get_timestamp() { + + public override DateTime? get_timestamp() { lock (backing_row) { return backing_row.timestamp; } } - + public void set_master_timestamp(FileInfo info) { - TimeVal time_val = info.get_modification_time(); - + var time_val = info.get_modification_date_time(); + try { lock (backing_row) { - if (backing_row.timestamp == time_val.tv_sec) + if (backing_row.timestamp.equal(time_val)) return; - - VideoTable.get_instance().set_timestamp(backing_row.video_id, time_val.tv_sec); - backing_row.timestamp = time_val.tv_sec; + + VideoTable.get_instance().set_timestamp(backing_row.video_id, time_val); + backing_row.timestamp = time_val; } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); - + return; } - + notify_altered(new Alteration("metadata", "master-timestamp")); } - + public string get_filename() { lock (backing_row) { return backing_row.filepath; } } - + public override File get_file() { return File.new_for_path(get_filename()); } - + public override File get_master_file() { return get_file(); } - + public void export(File dest_file) throws Error { File source_file = File.new_for_path(get_filename()); source_file.copy(dest_file, FileCopyFlags.OVERWRITE | FileCopyFlags.TARGET_DEFAULT_PERMS, null, null); } - + public double get_clip_duration() { lock (backing_row) { return backing_row.clip_duration; } } - + public bool get_is_interpretable() { lock (backing_row) { return backing_row.is_interpretable; } } - + private void set_is_interpretable(bool is_interpretable) { lock (backing_row) { if (backing_row.is_interpretable == is_interpretable) return; - + backing_row.is_interpretable = is_interpretable; } - + try { VideoTable.get_instance().update_is_interpretable(get_video_id(), is_interpretable); } catch (DatabaseError e) { AppWindow.database_error(e); } } - + // Intended to be called from a background thread but can be called from foreground as well. // Caller should call InterpretableResults.foreground_process() only from foreground thread, // however public InterpretableResults check_is_interpretable() { InterpretableResults results = new InterpretableResults(this); - + double clip_duration = -1.0; Gdk.Pixbuf? preview_frame = null; - + VideoReader backing_file_reader = new VideoReader(get_file()); try { clip_duration = backing_file_reader.read_clip_duration(); @@ -887,111 +567,111 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { // non-interpretable (e.g. its codec is not present on the users system). results.update_interpretable = get_is_interpretable(); results.is_interpretable = false; - + return results; } - + // if already marked interpretable, this is only confirming what we already knew if (get_is_interpretable()) { results.update_interpretable = false; results.is_interpretable = true; - + return results; } - + debug("video %s has become interpretable", get_file().get_basename()); - + // save this here, this can be done in background thread lock (backing_row) { backing_row.clip_duration = clip_duration; } - + results.update_interpretable = true; results.is_interpretable = true; results.new_thumbnail = preview_frame; - + return results; } - + public override void destroy() { VideoID video_id = get_video_id(); ThumbnailCache.remove(this); - + try { VideoTable.get_instance().remove(video_id); } catch (DatabaseError err) { error("failed to remove video %s from video table", to_string()); } - + base.destroy(); } protected override bool internal_delete_backing() throws Error { bool ret = delete_original_file(); - + // Return false if parent method failed. return base.internal_delete_backing() && ret; } - + private void notify_flags_altered(Alteration? additional_alteration) { Alteration alteration = new Alteration("metadata", "flags"); if (additional_alteration != null) alteration = alteration.compress(additional_alteration); - + notify_altered(alteration); } - + public uint64 add_flags(uint64 flags_to_add, Alteration? additional_alteration = null) { uint64 new_flags; lock (backing_row) { new_flags = internal_add_flags(backing_row.flags, flags_to_add); if (backing_row.flags == new_flags) return backing_row.flags; - + try { VideoTable.get_instance().set_flags(get_video_id(), new_flags); } catch (DatabaseError e) { AppWindow.database_error(e); return backing_row.flags; } - + backing_row.flags = new_flags; } - + notify_flags_altered(additional_alteration); - + return new_flags; } - + public uint64 remove_flags(uint64 flags_to_remove, Alteration? additional_alteration = null) { uint64 new_flags; lock (backing_row) { new_flags = internal_remove_flags(backing_row.flags, flags_to_remove); if (backing_row.flags == new_flags) return backing_row.flags; - + try { VideoTable.get_instance().set_flags(get_video_id(), new_flags); } catch (DatabaseError e) { AppWindow.database_error(e); return backing_row.flags; } - + backing_row.flags = new_flags; } - + notify_flags_altered(additional_alteration); - + return new_flags; } - + public bool is_flag_set(uint64 flag) { lock (backing_row) { return internal_is_flag_set(backing_row.flags, flag); } } - + public void set_master_file(File file) { string new_filepath = file.get_path(); string? old_filepath = null; @@ -999,195 +679,25 @@ public class Video : VideoSource, Flaggable, Monitorable, Dateable { lock (backing_row) { if (backing_row.filepath == new_filepath) return; - + old_filepath = backing_row.filepath; - + VideoTable.get_instance().set_filepath(backing_row.video_id, new_filepath); backing_row.filepath = new_filepath; } - } catch (DatabaseError err) { + } catch (Error err) { AppWindow.database_error(err); - + return; } - + assert(old_filepath != null); notify_master_replaced(File.new_for_path(old_filepath), file); - + notify_altered(new Alteration.from_list("backing:master,metadata:name")); } - + public VideoMetadata read_metadata() throws Error { return (new VideoReader(get_file())).read_metadata(); } } - -public class VideoSourceCollection : MediaSourceCollection { - public enum State { - UNKNOWN, - ONLINE, - OFFLINE, - TRASH - } - - public override TransactionController transaction_controller { - get { - if (_transaction_controller == null) - _transaction_controller = new MediaSourceTransactionController(this); - - return _transaction_controller; - } - } - - private TransactionController _transaction_controller = null; - private Gee.MultiMap<uint64?, Video> filesize_to_video = - new Gee.TreeMultiMap<uint64?, Video>(uint64_compare); - - public VideoSourceCollection() { - base("VideoSourceCollection", get_video_key); - - get_trashcan().contents_altered.connect(on_trashcan_contents_altered); - get_offline_bin().contents_altered.connect(on_offline_contents_altered); - } - - protected override MediaSourceHoldingTank create_trashcan() { - return new MediaSourceHoldingTank(this, is_video_trashed, get_video_key); - } - - protected override MediaSourceHoldingTank create_offline_bin() { - return new MediaSourceHoldingTank(this, is_video_offline, get_video_key); - } - - public override MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable) { - return new VideoMonitor(cancellable); - } - - public override bool holds_type_of_source(DataSource source) { - return source is Video; - } - - public override string get_typename() { - return Video.TYPENAME; - } - - public override bool is_file_recognized(File file) { - return VideoReader.is_supported_video_file(file); - } - - private void on_trashcan_contents_altered(Gee.Collection<DataSource>? added, - Gee.Collection<DataSource>? removed) { - trashcan_contents_altered((Gee.Collection<Video>?) added, - (Gee.Collection<Video>?) removed); - } - - private void on_offline_contents_altered(Gee.Collection<DataSource>? added, - Gee.Collection<DataSource>? removed) { - offline_contents_altered((Gee.Collection<Video>?) added, - (Gee.Collection<Video>?) removed); - } - - protected override MediaSource? fetch_by_numeric_id(int64 numeric_id) { - return fetch(VideoID(numeric_id)); - } - - public static int64 get_video_key(DataSource source) { - Video video = (Video) source; - VideoID video_id = video.get_video_id(); - - return video_id.id; - } - - public static bool is_video_trashed(DataSource source) { - return ((Video) source).is_trashed(); - } - - public static bool is_video_offline(DataSource source) { - return ((Video) source).is_offline(); - } - - public Video fetch(VideoID video_id) { - return (Video) fetch_by_key(video_id.id); - } - - public override Gee.Collection<string> get_event_source_ids(EventID event_id){ - return VideoTable.get_instance().get_event_source_ids(event_id); - } - - public Video? get_state_by_file(File file, out State state) { - Video? video = (Video?) fetch_by_master_file(file); - if (video != null) { - state = State.ONLINE; - - return video; - } - - video = (Video?) get_trashcan().fetch_by_master_file(file); - if (video != null) { - state = State.TRASH; - - return video; - } - - video = (Video?) get_offline_bin().fetch_by_master_file(file); - if (video != null) { - state = State.OFFLINE; - - return video; - } - - state = State.UNKNOWN; - - return null; - } - - private void compare_backing(Video video, FileInfo info, Gee.Collection<Video> matching_master) { - if (video.get_filesize() != info.get_size()) - return; - - if (video.get_timestamp() == info.get_modification_time().tv_sec) - matching_master.add(video); - } - - public void fetch_by_matching_backing(FileInfo info, Gee.Collection<Video> matching_master) { - foreach (DataObject object in get_all()) - compare_backing((Video) object, info, matching_master); - - foreach (MediaSource media in get_offline_bin_contents()) - compare_backing((Video) media, info, matching_master); - } - - protected override void notify_contents_altered(Gee.Iterable<DataObject>? added, - Gee.Iterable<DataObject>? removed) { - if (added != null) { - foreach (DataObject object in added) { - Video video = (Video) object; - - filesize_to_video.set(video.get_master_filesize(), video); - } - } - - if (removed != null) { - foreach (DataObject object in removed) { - Video video = (Video) object; - - filesize_to_video.remove(video.get_master_filesize(), video); - } - } - - base.notify_contents_altered(added, removed); - } - - public VideoID get_basename_filesize_duplicate(string basename, uint64 filesize) { - foreach (Video video in filesize_to_video.get(filesize)) { - if (utf8_ci_compare(video.get_master_file().get_basename(), basename) == 0) - return video.get_video_id(); - } - - return VideoID(); // the default constructor of the VideoID struct creates an invalid - // video id, which is just what we want in this case - } - - public bool has_basename_filesize_duplicate(string basename, uint64 filesize) { - return get_basename_filesize_duplicate(basename, filesize).is_valid(); - } -} diff --git a/src/video-support/VideoImportParams.vala b/src/video-support/VideoImportParams.vala new file mode 100644 index 0000000..6804c53 --- /dev/null +++ b/src/video-support/VideoImportParams.vala @@ -0,0 +1,28 @@ +/* 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 VideoImportParams { + // IN: + public File file; + public ImportID import_id = ImportID(); + public string? md5; + public DateTime? exposure_time_override; + + // IN/OUT: + public Thumbnails? thumbnails; + + // OUT: + public VideoRow row = new VideoRow(); + + public VideoImportParams(File file, ImportID import_id, string? md5, + Thumbnails? thumbnails = null, DateTime? exposure_time_override = null) { + this.file = file; + this.import_id = import_id; + this.md5 = md5; + this.thumbnails = thumbnails; + this.exposure_time_override = exposure_time_override; + } +} diff --git a/src/video-support/VideoMetadata.vala b/src/video-support/VideoMetadata.vala new file mode 100644 index 0000000..02580f8 --- /dev/null +++ b/src/video-support/VideoMetadata.vala @@ -0,0 +1,51 @@ +/* Copyright 2016 Software Freedom Conservancy Inc. + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + +public class VideoMetadata : MediaMetadata { + + private MetadataDateTime timestamp = null; + private string title = null; + private string comment = null; + + public VideoMetadata() { + } + + ~VideoMetadata() { + } + + public override void read_from_file(File file) throws Error { + QuickTimeMetadataLoader quicktime = new QuickTimeMetadataLoader(file); + if (quicktime.is_supported()) { + timestamp = quicktime.get_creation_date_time(); + title = quicktime.get_title(); + // TODO: is there an quicktime.get_comment ?? + comment = null; + return; + } + AVIMetadataLoader avi = new AVIMetadataLoader(file); + if (avi.is_supported()) { + timestamp = avi.get_creation_date_time(); + title = avi.get_title(); + comment = null; + return; + } + + throw new IOError.NOT_SUPPORTED("File %s is not a supported video format", file.get_path()); + } + + public override MetadataDateTime? get_creation_date_time() { + return timestamp; + } + + public override string? get_title() { + return title; + } + + public override string? get_comment() { + return comment; + } + +} diff --git a/src/video-support/VideoMetadataReaderProcess.vala b/src/video-support/VideoMetadataReaderProcess.vala new file mode 100644 index 0000000..26d61a6 --- /dev/null +++ b/src/video-support/VideoMetadataReaderProcess.vala @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +using Gst; +using Gst.PbUtils; + +int main(string[] args) { + Intl.setlocale(GLib.LocaleCategory.NUMERIC, "C"); + + var option_context = new OptionContext("- shotwell video metadata reader helper binary"); + option_context.set_help_enabled(true); + option_context.add_group(Gst.init_get_option_group()); + + double clip_duration; + GLib.DateTime timestamp = null; + + try { + option_context.parse(ref args); + + if (args.length < 2) + throw new IOError.INVALID_ARGUMENT("Missing URI"); + + var f = File.new_for_commandline_arg (args[1]); + + Gst.PbUtils.Discoverer d = new Gst.PbUtils.Discoverer((Gst.ClockTime) (Gst.SECOND * 5)); + Gst.PbUtils.DiscovererInfo info = d.discover_uri(f.get_uri()); + + clip_duration = ((double) info.get_duration()) / 1000000000.0; + + // Get creation time. + // TODO: Note that TAG_DATE can be changed to TAG_DATE_TIME in the future + // (and the corresponding output struct) in order to implement #2836. + Gst.DateTime? video_date = null; + + Gst.TagList? tags = null; + + var stream_info = info.get_stream_info(); + if (stream_info is Gst.PbUtils.DiscovererContainerInfo) { + tags = ((Gst.PbUtils.DiscovererContainerInfo)stream_info).get_tags(); + } + else if (stream_info is Gst.PbUtils.DiscovererStreamInfo) { + tags = ((Gst.PbUtils.DiscovererStreamInfo)stream_info).get_tags(); + } + + if (tags != null && tags.get_date_time(Gst.Tags.DATE_TIME, out video_date)) { + // possible for get_date() to return true and a null Date + if (video_date != null) { + timestamp = video_date.to_g_date_time().to_local(); + } + } + + print("%.3f\n", clip_duration); + if (timestamp != null) { + print("%s\n", timestamp.format_iso8601()); + } else { + print("none\n"); + } + } catch (Error error) { + critical("Failed to parse options: %s", error.message); + + return 1; + } + + return 0; +} diff --git a/src/video-support/VideoReader.vala b/src/video-support/VideoReader.vala new file mode 100644 index 0000000..11f11e1 --- /dev/null +++ b/src/video-support/VideoReader.vala @@ -0,0 +1,317 @@ +/* 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 errordomain VideoError { + FILE, // there's a problem reading the video container file (doesn't exist, no read + // permission, etc.) + + CONTENTS, // we can read the container file but its contents are indecipherable (no codec, + // malformed data, etc.) +} + +public class VideoReader { + private const double UNKNOWN_CLIP_DURATION = -1.0; + private const uint THUMBNAILER_TIMEOUT = 10000; // In milliseconds. + + // File extensions for video containers that pack only metadata as per the AVCHD spec + private const string[] METADATA_ONLY_FILE_EXTENSIONS = { "bdm", "bdmv", "cpi", "mpl" }; + + private double clip_duration = UNKNOWN_CLIP_DURATION; + private Gdk.Pixbuf preview_frame = null; + private File file = null; + private Subprocess thumbnailer_process = null; + public DateTime? timestamp { get; private set; default = null; } + + public VideoReader(File file) { + this.file = file; + } + + public static bool is_supported_video_file(File file) { + var mime_type = ContentType.guess(file.get_basename(), new uchar[0], null); + // special case: deep-check content-type of files ending with .ogg + if (mime_type == "audio/ogg" && file.has_uri_scheme("file")) { + try { + var info = file.query_info(FileAttribute.STANDARD_CONTENT_TYPE, + FileQueryInfoFlags.NONE); + var content_type = info.get_content_type(); + if (content_type != null && content_type.has_prefix ("video/")) { + return true; + } + } catch (Error error) { + debug("Failed to query content type: %s", error.message); + } + } + + return is_supported_video_filename(file.get_basename()); + } + + public static bool is_supported_video_filename(string filename) { + string mime_type; + mime_type = ContentType.guess(filename, new uchar[0], null); + // Guessed mp4/mxf from filename has application/ as prefix, so check for mp4/mxf in the end + if (mime_type.has_prefix ("video/") || + mime_type.has_suffix("mp4") || + mime_type.has_suffix("mxf")) { + string? extension = null; + string? name = null; + disassemble_filename(filename, out name, out extension); + + if (extension == null) + return true; + + foreach (string s in METADATA_ONLY_FILE_EXTENSIONS) { + if (utf8_ci_compare(s, extension) == 0) + return false; + } + + return true; + } else { + debug("Skipping %s, unsupported mime type %s", filename, mime_type); + return false; + } + } + + public static ImportResult prepare_for_import(VideoImportParams params) { +#if MEASURE_IMPORT + Timer total_time = new Timer(); +#endif + File file = params.file; + + FileInfo info = null; + try { + info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, + FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + } catch (Error err) { + return ImportResult.FILE_ERROR; + } + + if (info.get_file_type() != FileType.REGULAR) + return ImportResult.NOT_A_FILE; + + if (!is_supported_video_file(file)) { + message("Not importing %s: file is marked as a video file but doesn't have a" + + "supported extension", file.get_path()); + + return ImportResult.UNSUPPORTED_FORMAT; + } + + var timestamp = info.get_modification_date_time(); + + // make sure params has a valid md5 + assert(params.md5 != null); + + DateTime exposure_time = params.exposure_time_override; + string title = ""; + string comment = ""; + + VideoReader reader = new VideoReader(file); + bool is_interpretable = true; + double clip_duration = 0.0; + Gdk.Pixbuf preview_frame = reader.read_preview_frame(); + try { + clip_duration = reader.read_clip_duration(); + } catch (VideoError err) { + if (err is VideoError.FILE) { + return ImportResult.FILE_ERROR; + } else if (err is VideoError.CONTENTS) { + is_interpretable = false; + clip_duration = 0.0; + } else { + error("can't prepare video for import: an unknown kind of video error occurred"); + } + } + + try { + VideoMetadata metadata = reader.read_metadata(); + MetadataDateTime? creation_date_time = metadata.get_creation_date_time(); + + if (creation_date_time != null && creation_date_time.get_timestamp() != null) + exposure_time = creation_date_time.get_timestamp(); + + string? video_title = metadata.get_title(); + string? video_comment = metadata.get_comment(); + if (video_title != null) + title = video_title; + if (video_comment != null) + comment = video_comment; + } catch (Error err) { + warning("Unable to read video metadata: %s", err.message); + } + + if (exposure_time == null) { + // Use time reported by Gstreamer, if available. + exposure_time = reader.timestamp; + } + + params.row.video_id = VideoID(); + params.row.filepath = file.get_path(); + params.row.filesize = info.get_size(); + params.row.timestamp = timestamp; + params.row.width = preview_frame.width; + params.row.height = preview_frame.height; + params.row.clip_duration = clip_duration; + params.row.is_interpretable = is_interpretable; + params.row.exposure_time = exposure_time; + params.row.import_id = params.import_id; + params.row.event_id = EventID(); + params.row.md5 = params.md5; + params.row.time_created = 0; + params.row.title = title; + params.row.comment = comment; + params.row.backlinks = ""; + params.row.time_reimported = 0; + params.row.flags = 0; + + if (params.thumbnails != null) { + params.thumbnails = new Thumbnails(); + ThumbnailCache.generate_for_video_frame(params.thumbnails, preview_frame); + } + +#if MEASURE_IMPORT + debug("IMPORT: total time to import video = %lf", total_time.elapsed()); +#endif + return ImportResult.SUCCESS; + } + + private void read_internal() throws VideoError { + if (!does_file_exist()) + throw new VideoError.FILE("video file '%s' does not exist or is inaccessible".printf( + file.get_path())); + + uint id = 0; + try { + var cancellable = new Cancellable(); + + id = Timeout.add_seconds(10, () => { + cancellable.cancel(); + id = 0; + + return false; + }); + + Bytes stdout_buf = null; + Bytes stderr_buf = null; + + var process = new GLib.Subprocess(GLib.SubprocessFlags.STDOUT_PIPE, AppDirs.get_metadata_helper().get_path(), file.get_uri()); + var result = process.communicate(null, cancellable, out stdout_buf, out stderr_buf); + if (result && process.get_if_exited() && process.get_exit_status () == 0 && stdout_buf != null && stdout_buf.get_size() > 0) { + string[] lines = ((string) stdout_buf.get_data()).split("\n"); + + var old = Intl.setlocale(GLib.LocaleCategory.NUMERIC, "C"); + clip_duration = double.parse(lines[0]); + Intl.setlocale(GLib.LocaleCategory.NUMERIC, old); + if (lines[1] != "none") + timestamp = new DateTime.from_iso8601(lines[1], null); + } else { + string message = ""; + if (stderr_buf != null && stderr_buf.get_size() > 0) { + message = (string) stderr_buf.get_data(); + } + warning ("External Metadata helper failed"); + } + } catch (Error e) { + debug("Video read error: %s", e.message); + throw new VideoError.CONTENTS("GStreamer couldn't extract clip information: %s" + .printf(e.message)); + } + + if (id != 0) { + Source.remove(id); + } + } + + // Used by thumbnailer() to kill the external process if need be. + private bool on_thumbnailer_timer() { + debug("Thumbnailer timer called"); + if (thumbnailer_process != null) { + thumbnailer_process.force_exit(); + } + return false; // Don't call again. + } + + // Performs video thumbnailing. + // Note: not thread-safe if called from the same instance of the class. + private Gdk.Pixbuf? thumbnailer(string video_file) { + // Use Shotwell's thumbnailer, redirect output to stdout. + debug("Launching thumbnailer process: %s", AppDirs.get_thumbnailer_bin().get_path()); + FileIOStream stream; + File output_file; + try { + output_file = File.new_tmp(null, out stream); + } catch (Error e) { + debug("Failed to create temporary file: %s", e.message); + return null; + } + + try { + thumbnailer_process = new Subprocess(SubprocessFlags.NONE, + AppDirs.get_thumbnailer_bin().get_path(), video_file, output_file.get_path()); + } catch (Error e) { + debug("Error spawning process: %s", e.message); + return null; + } + + // Start timer. + Timeout.add(THUMBNAILER_TIMEOUT, on_thumbnailer_timer); + + // Make sure process exited properly. + try { + thumbnailer_process.wait_check(); + + // Read pixbuf from stream. + Gdk.Pixbuf? buf = null; + try { + buf = new Gdk.Pixbuf.from_stream(stream.get_input_stream(), null); + return buf; + } catch (Error e) { + debug("Error creating pixbuf: %s", e.message); + } + } catch (Error err) { + debug("Thumbnailer process exited with error: %s", err.message); + } + + try { + output_file.delete(null); + } catch (Error err) { + debug("Failed to remove temporary file: %s", err.message); + } + + return null; + } + + private bool does_file_exist() { + return FileUtils.test(file.get_path(), FileTest.EXISTS | FileTest.IS_REGULAR); + } + + public Gdk.Pixbuf? read_preview_frame() { + if (preview_frame != null) + return preview_frame; + + if (!does_file_exist()) + return null; + + // Get preview frame from thumbnailer. + preview_frame = thumbnailer(file.get_path()); + if (null == preview_frame) + preview_frame = Resources.get_noninterpretable_badge_pixbuf(); + + return preview_frame; + } + + public double read_clip_duration() throws VideoError { + if (clip_duration == UNKNOWN_CLIP_DURATION) + read_internal(); + + return clip_duration; + } + + public VideoMetadata read_metadata() throws Error { + VideoMetadata metadata = new VideoMetadata(); + metadata.read_from_file(File.new_for_path(file.get_path())); + + return metadata; + } +} diff --git a/src/video-support/VideoSourceCollection.vala b/src/video-support/VideoSourceCollection.vala new file mode 100644 index 0000000..89daad3 --- /dev/null +++ b/src/video-support/VideoSourceCollection.vala @@ -0,0 +1,175 @@ +/* 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 VideoSourceCollection : MediaSourceCollection { + public enum State { + UNKNOWN, + ONLINE, + OFFLINE, + TRASH + } + + public override TransactionController transaction_controller { + get { + if (_transaction_controller == null) + _transaction_controller = new MediaSourceTransactionController(this); + + return _transaction_controller; + } + } + + private TransactionController _transaction_controller = null; + private Gee.MultiMap<uint64?, Video> filesize_to_video = + new Gee.TreeMultiMap<uint64?, Video>(uint64_compare); + + public VideoSourceCollection() { + base("VideoSourceCollection", get_video_key); + + get_trashcan().contents_altered.connect(on_trashcan_contents_altered); + get_offline_bin().contents_altered.connect(on_offline_contents_altered); + } + + protected override MediaSourceHoldingTank create_trashcan() { + return new MediaSourceHoldingTank(this, is_video_trashed, get_video_key); + } + + protected override MediaSourceHoldingTank create_offline_bin() { + return new MediaSourceHoldingTank(this, is_video_offline, get_video_key); + } + + public override MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable) { + return new VideoMonitor(cancellable); + } + + public override bool holds_type_of_source(DataSource source) { + return source is Video; + } + + public override string get_typename() { + return Video.TYPENAME; + } + + public override bool is_file_recognized(File file) { + return VideoReader.is_supported_video_file(file); + } + + private void on_trashcan_contents_altered(Gee.Collection<DataSource>? added, + Gee.Collection<DataSource>? removed) { + trashcan_contents_altered((Gee.Collection<Video>?) added, + (Gee.Collection<Video>?) removed); + } + + private void on_offline_contents_altered(Gee.Collection<DataSource>? added, + Gee.Collection<DataSource>? removed) { + offline_contents_altered((Gee.Collection<Video>?) added, + (Gee.Collection<Video>?) removed); + } + + protected override MediaSource? fetch_by_numeric_id(int64 numeric_id) { + return fetch(VideoID(numeric_id)); + } + + public static int64 get_video_key(DataSource source) { + Video video = (Video) source; + VideoID video_id = video.get_video_id(); + + return video_id.id; + } + + public static bool is_video_trashed(DataSource source) { + return ((Video) source).is_trashed(); + } + + public static bool is_video_offline(DataSource source) { + return ((Video) source).is_offline(); + } + + public Video fetch(VideoID video_id) { + return (Video) fetch_by_key(video_id.id); + } + + public override Gee.Collection<string> get_event_source_ids(EventID event_id){ + return VideoTable.get_instance().get_event_source_ids(event_id); + } + + public Video? get_state_by_file(File file, out State state) { + Video? video = (Video?) fetch_by_master_file(file); + if (video != null) { + state = State.ONLINE; + + return video; + } + + video = (Video?) get_trashcan().fetch_by_master_file(file); + if (video != null) { + state = State.TRASH; + + return video; + } + + video = (Video?) get_offline_bin().fetch_by_master_file(file); + if (video != null) { + state = State.OFFLINE; + + return video; + } + + state = State.UNKNOWN; + + return null; + } + + private void compare_backing(Video video, FileInfo info, Gee.Collection<Video> matching_master) { + if (video.get_filesize() != info.get_size()) + return; + + if (video.get_timestamp().equal(info.get_modification_date_time())) + matching_master.add(video); + } + + public void fetch_by_matching_backing(FileInfo info, Gee.Collection<Video> matching_master) { + foreach (DataObject object in get_all()) + compare_backing((Video) object, info, matching_master); + + foreach (MediaSource media in get_offline_bin_contents()) + compare_backing((Video) media, info, matching_master); + } + + protected override void notify_contents_altered(Gee.Iterable<DataObject>? added, + Gee.Iterable<DataObject>? removed) { + if (added != null) { + foreach (DataObject object in added) { + Video video = (Video) object; + + filesize_to_video.set(video.get_master_filesize(), video); + } + } + + if (removed != null) { + foreach (DataObject object in removed) { + Video video = (Video) object; + + filesize_to_video.remove(video.get_master_filesize(), video); + } + } + + base.notify_contents_altered(added, removed); + } + + public VideoID get_basename_filesize_duplicate(string basename, uint64 filesize) { + foreach (Video video in filesize_to_video.get(filesize)) { + if (utf8_ci_compare(video.get_master_file().get_basename(), basename) == 0) + return video.get_video_id(); + } + + return VideoID(); // the default constructor of the VideoID struct creates an invalid + // video id, which is just what we want in this case + } + + public bool has_basename_filesize_duplicate(string basename, uint64 filesize) { + return get_basename_filesize_duplicate(basename, filesize).is_valid(); + } +} diff --git a/src/video-support/meson.build b/src/video-support/meson.build new file mode 100644 index 0000000..da3f9d7 --- /dev/null +++ b/src/video-support/meson.build @@ -0,0 +1,36 @@ +executable( + 'shotwell-video-metadata-handler', + [ + 'VideoMetadataReaderProcess.vala' + ], + dependencies : [ + gio, + gstreamer, + gstreamer_pbu + ], + c_args : '-DGST_PB_UTILS_IS_DISCOVERER_INFO=GST_IS_DISCOVERER_INFO' + # Work-around for wrong type-check macro generated by valac +) + +libvideometadata_handling = static_library( + 'video_metadata_handling', + [ + 'AVIChunk.vala', + 'AVIMetadataLoader.vala', + 'QuickTimeAtom.vala', + 'QuicktimeMetdataLoader.vala', + 'util.vala' + ], + vala_header : 'shotwell-internal-video-metadata-handling.h', + vala_vapi : 'shotwell-internal-video-metadata-handling.vapi', + include_directories : config_incdir, + dependencies : [ + gio, + metadata + ] +) + +metadata_handling = declare_dependency( + include_directories : include_directories('.'), + link_with : libvideometadata_handling +) diff --git a/src/video-support/util.vala b/src/video-support/util.vala new file mode 100644 index 0000000..ad06680 --- /dev/null +++ b/src/video-support/util.vala @@ -0,0 +1,13 @@ +// Breaks a uint64 skip amount into several smaller skips. +public void skip_uint64(InputStream input, uint64 skip_amount) throws GLib.Error { + while (skip_amount > 0) { + // skip() throws an error if the amount is too large, so check against ssize_t.MAX + if (skip_amount >= ssize_t.MAX) { + input.skip(ssize_t.MAX); + skip_amount -= ssize_t.MAX; + } else { + input.skip((size_t) skip_amount); + skip_amount = 0; + } + } +} |