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; +        } +    } +}  | 
