summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJörg Frings-Fürst <debian@jff-webhosting.net>2014-07-23 09:06:59 +0200
committerJörg Frings-Fürst <debian@jff-webhosting.net>2014-07-23 09:06:59 +0200
commit4ea2cc3bd4a7d9b1c54a9d33e6a1cf82e7c8c21d (patch)
treed2e54377d14d604356c86862a326f64ae64dadd6 /src
Imported Upstream version 0.18.1upstream/0.18.1
Diffstat (limited to 'src')
-rw-r--r--src/AppDirs.vala285
-rw-r--r--src/AppWindow.vala961
-rw-r--r--src/Application.vala228
-rw-r--r--src/BatchImport.vala2051
-rw-r--r--src/Box.vala403
-rw-r--r--src/CheckerboardLayout.vala1872
-rw-r--r--src/CollectionPage.vala765
-rw-r--r--src/ColorTransformation.vala1519
-rw-r--r--src/CommandManager.vala201
-rw-r--r--src/Commands.vala2497
-rw-r--r--src/CustomComponents.vala513
-rw-r--r--src/Debug.vala146
-rw-r--r--src/DesktopIntegration.vala308
-rw-r--r--src/Dialogs.vala2714
-rw-r--r--src/Dimensions.vala732
-rw-r--r--src/DirectoryMonitor.vala1454
-rw-r--r--src/Event.vala924
-rw-r--r--src/Exporter.vala343
-rw-r--r--src/International.vala32
-rw-r--r--src/LibraryFiles.vala104
-rw-r--r--src/LibraryMonitor.vala1013
-rw-r--r--src/MediaDataRepresentation.vala899
-rw-r--r--src/MediaInterfaces.vala215
-rw-r--r--src/MediaMetadata.vala128
-rw-r--r--src/MediaMonitor.vala418
-rw-r--r--src/MediaPage.vala1308
-rw-r--r--src/MediaViewTracker.vala114
-rw-r--r--src/MetadataWriter.vala675
-rw-r--r--src/Orientation.vala493
-rw-r--r--src/Page.vala2589
-rw-r--r--src/Photo.vala5381
-rw-r--r--src/PhotoMonitor.vala1156
-rw-r--r--src/PhotoPage.vala3361
-rw-r--r--src/PixbufCache.vala360
-rw-r--r--src/Printing.vala1156
-rw-r--r--src/Properties.vala700
-rw-r--r--src/Resources.vala1143
-rw-r--r--src/Screensaver.vala29
-rw-r--r--src/SearchFilter.vala1252
-rw-r--r--src/SlideshowPage.vala466
-rw-r--r--src/SortedList.vala429
-rw-r--r--src/Tag.vala1189
-rw-r--r--src/Thumbnail.vala400
-rw-r--r--src/ThumbnailCache.vala619
-rw-r--r--src/TimedQueue.vala284
-rw-r--r--src/Tombstone.vala336
-rw-r--r--src/UnityProgressBar.vala83
-rw-r--r--src/Upgrades.vala115
-rw-r--r--src/VideoMetadata.vala655
-rw-r--r--src/VideoMonitor.vala301
-rw-r--r--src/VideoSupport.vala1188
-rw-r--r--src/camera/Branch.vala116
-rw-r--r--src/camera/Camera.vala18
-rw-r--r--src/camera/CameraTable.vala417
-rw-r--r--src/camera/GPhoto.vala367
-rw-r--r--src/camera/ImportPage.vala1799
-rw-r--r--src/camera/mk/camera.mk32
-rw-r--r--src/config/Config.vala155
-rw-r--r--src/config/ConfigurationInterfaces.vala1609
-rw-r--r--src/config/GSettingsEngine.vala469
-rw-r--r--src/config/mk/config.mk29
-rw-r--r--src/core/Alteration.vala316
-rw-r--r--src/core/ContainerSourceCollection.vala237
-rw-r--r--src/core/Core.vala29
-rw-r--r--src/core/DataCollection.vala623
-rw-r--r--src/core/DataObject.vala137
-rw-r--r--src/core/DataSet.vala183
-rw-r--r--src/core/DataSource.vala679
-rw-r--r--src/core/DataSourceTypes.vala108
-rw-r--r--src/core/DataView.vala132
-rw-r--r--src/core/DataViewTypes.vala50
-rw-r--r--src/core/DatabaseSourceCollection.vala86
-rw-r--r--src/core/SourceCollection.vala221
-rw-r--r--src/core/SourceHoldingTank.vala209
-rw-r--r--src/core/SourceInterfaces.vala44
-rw-r--r--src/core/Tracker.vala216
-rw-r--r--src/core/ViewCollection.vala1287
-rw-r--r--src/core/mk/core.mk43
-rw-r--r--src/core/util.vala196
-rw-r--r--src/data_imports/DataImportJob.vala177
-rw-r--r--src/data_imports/DataImportSource.vala135
-rw-r--r--src/data_imports/DataImports.vala30
-rw-r--r--src/data_imports/DataImportsPluginHost.vala482
-rw-r--r--src/data_imports/DataImportsUI.vala445
-rw-r--r--src/data_imports/mk/data_imports.mk31
-rw-r--r--src/db/DatabaseTable.vala384
-rw-r--r--src/db/Db.vala366
-rw-r--r--src/db/EventTable.vala235
-rw-r--r--src/db/PhotoTable.vala1245
-rw-r--r--src/db/SavedSearchDBTable.vala641
-rw-r--r--src/db/TagTable.vala248
-rw-r--r--src/db/TombstoneTable.vala146
-rw-r--r--src/db/VersionTable.vala98
-rw-r--r--src/db/VideoTable.vala462
-rw-r--r--src/db/mk/db.mk35
-rw-r--r--src/direct/Direct.vala35
-rw-r--r--src/direct/DirectPhoto.vala316
-rw-r--r--src/direct/DirectPhotoPage.vala580
-rw-r--r--src/direct/DirectView.vala50
-rw-r--r--src/direct/DirectWindow.vala98
-rw-r--r--src/direct/mk/direct.mk36
-rw-r--r--src/editing_tools/EditingTools.vala2934
-rw-r--r--src/editing_tools/StraightenTool.vala559
-rw-r--r--src/editing_tools/mk/editing_tools.mk28
-rw-r--r--src/events/Branch.vala542
-rw-r--r--src/events/EventDirectoryItem.vala189
-rw-r--r--src/events/EventPage.vala162
-rw-r--r--src/events/Events.vala18
-rw-r--r--src/events/EventsDirectoryPage.vala313
-rw-r--r--src/events/mk/events.mk32
-rw-r--r--src/folders/Branch.vala198
-rw-r--r--src/folders/Folders.vala35
-rw-r--r--src/folders/Page.vala41
-rw-r--r--src/folders/mk/folders.mk31
-rw-r--r--src/library/Branch.vala54
-rw-r--r--src/library/FlaggedBranch.vala61
-rw-r--r--src/library/FlaggedPage.vala54
-rw-r--r--src/library/ImportQueueBranch.vala74
-rw-r--r--src/library/ImportQueuePage.vala208
-rw-r--r--src/library/LastImportBranch.vala47
-rw-r--r--src/library/LastImportPage.vala79
-rw-r--r--src/library/Library.vala19
-rw-r--r--src/library/LibraryWindow.vala1587
-rw-r--r--src/library/OfflineBranch.vala51
-rw-r--r--src/library/OfflinePage.vala130
-rw-r--r--src/library/TrashBranch.vala73
-rw-r--r--src/library/TrashPage.vala120
-rw-r--r--src/library/mk/library.mk54
-rw-r--r--src/main.vala441
-rw-r--r--src/photos/BmpSupport.vala184
-rw-r--r--src/photos/GRaw.vala307
-rw-r--r--src/photos/GdkSupport.vala129
-rw-r--r--src/photos/JfifSupport.vala236
-rw-r--r--src/photos/PhotoFileAdapter.vala112
-rw-r--r--src/photos/PhotoFileFormat.vala410
-rw-r--r--src/photos/PhotoFileSniffer.vala90
-rw-r--r--src/photos/PhotoMetadata.vala1169
-rw-r--r--src/photos/Photos.vala31
-rw-r--r--src/photos/PngSupport.vala181
-rw-r--r--src/photos/RawSupport.vala347
-rw-r--r--src/photos/TiffSupport.vala180
-rw-r--r--src/photos/mk/photos.mk38
-rw-r--r--src/plugins/DataImportsInterfaces.vala489
-rw-r--r--src/plugins/ManifestWidget.vala282
-rw-r--r--src/plugins/Plugins.vala436
-rw-r--r--src/plugins/PublishingInterfaces.vala605
-rw-r--r--src/plugins/SpitInterfaces.vala367
-rw-r--r--src/plugins/StandardHostInterface.vala84
-rw-r--r--src/plugins/TransitionsInterfaces.vala300
-rw-r--r--src/plugins/mk/interfaces.mk29
-rw-r--r--src/plugins/mk/plugins.mk35
-rw-r--r--src/publishing/APIGlue.vala133
-rw-r--r--src/publishing/Publishing.vala24
-rw-r--r--src/publishing/PublishingPluginHost.vala238
-rw-r--r--src/publishing/PublishingUI.vala552
-rw-r--r--src/publishing/mk/publishing.mk31
-rw-r--r--src/searches/Branch.vala150
-rw-r--r--src/searches/SavedSearchDialog.vala829
-rw-r--r--src/searches/SavedSearchPage.vala92
-rw-r--r--src/searches/SearchBoolean.vala971
-rw-r--r--src/searches/Searches.vala31
-rw-r--r--src/searches/mk/searches.mk31
-rw-r--r--src/sidebar/Branch.vala450
-rw-r--r--src/sidebar/Entry.vala70
-rw-r--r--src/sidebar/Sidebar.vala16
-rw-r--r--src/sidebar/Tree.vala1175
-rw-r--r--src/sidebar/common.vala114
-rw-r--r--src/sidebar/mk/sidebar.mk31
-rw-r--r--src/slideshow/Slideshow.vala32
-rw-r--r--src/slideshow/TransitionEffects.vala351
-rw-r--r--src/slideshow/mk/slideshow.mk29
-rw-r--r--src/tags/Branch.vala310
-rw-r--r--src/tags/HierarchicalTagIndex.vala90
-rw-r--r--src/tags/HierarchicalTagUtilities.vala184
-rw-r--r--src/tags/TagPage.vala125
-rw-r--r--src/tags/Tags.vala18
-rw-r--r--src/tags/mk/tags.mk32
-rw-r--r--src/threads/BackgroundJob.vala243
-rw-r--r--src/threads/Semaphore.vala160
-rw-r--r--src/threads/Threads.vala14
-rw-r--r--src/threads/Workers.vala104
-rw-r--r--src/threads/mk/threads.mk30
-rw-r--r--src/unit/Unit.vala14
-rw-r--r--src/unit/mk/unit.mk32
-rw-r--r--src/unit/rc/Unit.m429
-rw-r--r--src/unit/rc/UnitInternals.m432
-rw-r--r--src/unit/rc/template.mk27
-rw-r--r--src/unit/rc/template.vala7
-rw-r--r--src/unit/rc/unitize_entry.m419
-rw-r--r--src/util/KeyValueMap.vala118
-rw-r--r--src/util/Util.vala17
-rw-r--r--src/util/file.vala241
-rw-r--r--src/util/image.vala364
-rw-r--r--src/util/misc.vala377
-rw-r--r--src/util/mk/util.mk34
-rw-r--r--src/util/string.vala268
-rw-r--r--src/util/system.vala40
-rw-r--r--src/util/ui.vala98
198 files changed, 85141 insertions, 0 deletions
diff --git a/src/AppDirs.vala b/src/AppDirs.vala
new file mode 100644
index 0000000..6bf6759
--- /dev/null
+++ b/src/AppDirs.vala
@@ -0,0 +1,285 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+class AppDirs {
+ private const string DEFAULT_DATA_DIR = "shotwell";
+
+ private static File exec_dir;
+ private static File data_dir = null;
+ private static File tmp_dir = null;
+ private static File libexec_dir = null;
+
+ // Because this is called prior to Debug.init(), this function cannot do any logging calls
+ public static void init(string arg0) {
+ File exec_file = File.new_for_path(Posix.realpath(Environment.find_program_in_path(arg0)));
+ exec_dir = exec_file.get_parent();
+ }
+
+ // Because this *may* be called prior to Debug.init(), this function cannot do any logging
+ // calls
+ public static void terminate() {
+ }
+
+ public static File get_home_dir() {
+ return File.new_for_path(Environment.get_home_dir());
+ }
+
+ public static File get_cache_dir() {
+ return ((data_dir == null) ?
+ File.new_for_path(Environment.get_user_cache_dir()).get_child(DEFAULT_DATA_DIR) :
+ data_dir);
+ }
+
+ public static void try_migrate_data() {
+ File new_dir = get_data_dir();
+ File old_dir = get_home_dir().get_child(".shotwell");
+ if (new_dir.query_exists() || !old_dir.query_exists())
+ return;
+
+ File cache_dir = get_cache_dir();
+ Posix.mode_t mask = Posix.umask(0700);
+ if (!cache_dir.query_exists()) {
+ try {
+ cache_dir.make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create cache directory %s: %s").printf(cache_dir.get_path(),
+ err.message));
+ }
+ }
+ GLib.FileUtils.rename(old_dir.get_child("thumbs").get_path(), cache_dir.get_child("thumbs").get_path());
+
+ if (!new_dir.get_parent().query_exists()) {
+ try {
+ new_dir.get_parent().make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create data directory %s: %s").printf(new_dir.get_parent().get_path(),
+ err.message));
+ }
+ }
+ GLib.FileUtils.rename(old_dir.get_path(), new_dir.get_path());
+ GLib.FileUtils.chmod(new_dir.get_path(), 0700);
+
+ Posix.umask(mask);
+ }
+
+ // This can only be called once, and it better be called at startup
+ public static void set_data_dir(string user_data_dir) requires (!is_string_empty(user_data_dir)) {
+ assert(data_dir == null);
+
+ // fix up to absolute path
+ string path = strip_pretty_path(user_data_dir);
+ if (!Path.is_absolute(path))
+ data_dir = get_home_dir().get_child(path);
+ else
+ data_dir = File.new_for_path(path);
+
+ message("Setting private data directory to %s", data_dir.get_path());
+ }
+
+ public static void verify_data_dir() {
+ File data_dir = get_data_dir();
+ try {
+ if (!data_dir.query_exists(null))
+ data_dir.make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create data directory %s: %s").printf(data_dir.get_path(),
+ err.message));
+ }
+ }
+
+ public static void verify_cache_dir() {
+ File cache_dir = get_cache_dir();
+ try {
+ if (!cache_dir.query_exists(null))
+ cache_dir.make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create cache directory %s: %s").printf(cache_dir.get_path(),
+ err.message));
+ }
+ }
+
+ /**
+ * @brief Returns the build directory if not installed yet, or a path
+ * to where any helper applets we need will live if installed.
+ */
+ public static File get_libexec_dir() {
+ if (libexec_dir == null) {
+ if (get_install_dir() == null) {
+ // not installed yet - use wherever we were run from
+ libexec_dir = get_exec_dir();
+ } else {
+ libexec_dir = File.new_for_path(Resources.LIBEXECDIR);
+ }
+ }
+
+ return libexec_dir;
+ }
+
+ // Return the directory in which Shotwell is installed, or null if uninstalled.
+ public static File? get_install_dir() {
+ return get_sys_install_dir(exec_dir);
+ }
+
+ public static File get_data_dir() {
+ return (data_dir == null) ? File.new_for_path(Environment.get_user_data_dir()).get_child(DEFAULT_DATA_DIR) : data_dir;
+ }
+
+ // The "import directory" is the same as the library directory, and are often used
+ // interchangeably throughout the code.
+ public static File get_import_dir() {
+ string path = Config.Facade.get_instance().get_import_dir();
+ if (!is_string_empty(path)) {
+ // tilde -> home directory
+ path = strip_pretty_path(path);
+
+ // if non-empty and relative, make it relative to the user's home directory
+ if (!Path.is_absolute(path))
+ return get_home_dir().get_child(path);
+
+ // non-empty and absolute, it's golden
+ return File.new_for_path(path);
+ }
+
+ // Empty path, use XDG Pictures directory
+ path = Environment.get_user_special_dir(UserDirectory.PICTURES);
+ if (!is_string_empty(path))
+ return File.new_for_path(path);
+
+ // If XDG yarfed, use ~/Pictures
+ return get_home_dir().get_child(_("Pictures"));
+ }
+
+ // Library folder + photo folder, based on user's preferred directory pattern.
+ public static File get_baked_import_dir(time_t 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));
+ }
+
+ // Returns true if the File is in or is equal to the library/import directory.
+ public static bool is_in_import_dir(File file) {
+ File import_dir = get_import_dir();
+
+ return file.has_prefix(import_dir) || file.equal(import_dir);
+ }
+
+ public static void set_import_dir(string path) {
+ Config.Facade.get_instance().set_import_dir(path);
+ }
+
+ public static File get_exec_dir() {
+ return exec_dir;
+ }
+
+ public static File get_temp_dir() {
+ if (tmp_dir == null) {
+ tmp_dir = File.new_for_path(DirUtils.mkdtemp (Environment.get_tmp_dir() + "/shotwell-XXXXXX"));
+
+ try {
+ if (!tmp_dir.query_exists(null))
+ tmp_dir.make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create temporary directory %s: %s").printf(
+ tmp_dir.get_path(), err.message));
+ }
+ }
+
+ 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)
+ subdir = subdir.get_child(subname);
+
+ try {
+ if (!subdir.query_exists(null))
+ subdir.make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create data subdirectory %s: %s").printf(subdir.get_path(),
+ err.message));
+ }
+
+ return subdir;
+ }
+
+ public static File get_cache_subdir(string name, string? subname = null) {
+ File subdir = get_cache_dir().get_child(name);
+ if (subname != null)
+ subdir = subdir.get_child(subname);
+
+ try {
+ if (!subdir.query_exists(null))
+ subdir.make_directory_with_parents(null);
+ } catch (Error err) {
+ AppWindow.panic(_("Unable to create data subdirectory %s: %s").printf(subdir.get_path(),
+ err.message));
+ }
+
+ return subdir;
+ }
+
+ public static File get_resources_dir() {
+ File? install_dir = get_install_dir();
+
+ return (install_dir != null) ? install_dir.get_child("share").get_child("shotwell")
+ : get_exec_dir();
+ }
+
+ public static File get_lib_dir() {
+ File? install_dir = get_install_dir();
+
+ return (install_dir != null) ? install_dir.get_child(Resources.LIB).get_child("shotwell")
+ : get_exec_dir();
+ }
+
+ public static File get_system_plugins_dir() {
+ return get_lib_dir().get_child("plugins");
+ }
+
+ public static File get_user_plugins_dir() {
+ return get_home_dir().get_child(".gnome2").get_child("shotwell").get_child("plugins");
+ }
+
+ public static File? get_log_file() {
+ if (Environment.get_variable("SHOTWELL_LOG_FILE") != null) {
+ if (Environment.get_variable("SHOTWELL_LOG_FILE") == ":console:") {
+ return null;
+ } else {
+ return File.new_for_path(Environment.get_variable("SHOTWELL_LOG_FILE"));
+ }
+ } else {
+ return File.new_for_path(Environment.get_user_cache_dir()).
+ get_child("shotwell").get_child("shotwell.log");
+ }
+ }
+
+ public static File get_thumbnailer_bin() {
+ const string filename = "shotwell-video-thumbnailer";
+ File f = File.new_for_path(AppDirs.get_libexec_dir().get_path() + "/thumbnailer/" + filename);
+ if (!f.query_exists()) {
+ // If we're running installed.
+ f = File.new_for_path(AppDirs.get_libexec_dir().get_path() + "/" + filename);
+ }
+ return f;
+ }
+
+ public static File get_settings_migrator_bin() {
+ const string filename = "shotwell-settings-migrator";
+ File f = File.new_for_path(AppDirs.get_libexec_dir().get_path() + "/settings-migrator/" + filename);
+ if (!f.query_exists()) {
+ // If we're running installed.
+ f = File.new_for_path(AppDirs.get_libexec_dir().get_path() + "/" + filename);
+ }
+ return f;
+ }
+}
+
diff --git a/src/AppWindow.vala b/src/AppWindow.vala
new file mode 100644
index 0000000..9c1f2b4
--- /dev/null
+++ b/src/AppWindow.vala
@@ -0,0 +1,961 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class FullscreenWindow : PageWindow {
+ public const int TOOLBAR_INVOCATION_MSEC = 250;
+ public const int TOOLBAR_DISMISSAL_SEC = 2;
+ public const int TOOLBAR_CHECK_DISMISSAL_MSEC = 500;
+
+ private Gtk.Window toolbar_window = new Gtk.Window(Gtk.WindowType.POPUP);
+ private Gtk.ToolButton close_button = new Gtk.ToolButton.from_stock(Gtk.Stock.LEAVE_FULLSCREEN);
+ private Gtk.ToggleToolButton pin_button = new Gtk.ToggleToolButton.from_stock(Resources.PIN_TOOLBAR);
+ private bool is_toolbar_shown = false;
+ private bool waiting_for_invoke = false;
+ private time_t left_toolbar_time = 0;
+ private bool switched_to = false;
+ private bool is_toolbar_dismissal_enabled;
+
+ public FullscreenWindow(Page page) {
+ set_current_page(page);
+
+ File ui_file = Resources.get_ui("fullscreen.ui");
+
+ try {
+ ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ error("Error loading UI file %s: %s", ui_file.get_path(), err.message);
+ }
+
+ Gtk.ActionGroup action_group = new Gtk.ActionGroup("FullscreenActionGroup");
+ action_group.add_actions(create_actions(), this);
+ ui.insert_action_group(action_group, 0);
+ ui.ensure_update();
+
+ Gtk.AccelGroup accel_group = ui.get_accel_group();
+ if (accel_group != null)
+ add_accel_group(accel_group);
+
+ set_screen(AppWindow.get_instance().get_screen());
+
+ // Needed so fullscreen will occur on correct monitor in multi-monitor setups
+ Gdk.Rectangle monitor = get_monitor_geometry();
+ move(monitor.x, monitor.y);
+
+ set_border_width(0);
+
+ // restore pin state
+ is_toolbar_dismissal_enabled = Config.Facade.get_instance().get_pin_toolbar_state();
+
+ pin_button.set_label(_("Pin Toolbar"));
+ pin_button.set_tooltip_text(_("Pin the toolbar open"));
+ pin_button.set_active(!is_toolbar_dismissal_enabled);
+ pin_button.clicked.connect(update_toolbar_dismissal);
+
+ close_button.set_tooltip_text(_("Leave fullscreen"));
+ close_button.clicked.connect(on_close);
+
+ Gtk.Toolbar toolbar = page.get_toolbar();
+ toolbar.set_show_arrow(false);
+
+ if (page is SlideshowPage) {
+ // slideshow page doesn't own toolbar to hide it, subscribe to signal instead
+ ((SlideshowPage) page).hide_toolbar.connect(hide_toolbar);
+ } else {
+ // only non-slideshow pages should have pin button
+ toolbar.insert(pin_button, -1);
+ }
+
+ page.set_cursor_hide_time(TOOLBAR_DISMISSAL_SEC * 1000);
+ page.start_cursor_hiding();
+
+ toolbar.insert(close_button, -1);
+
+ // set up toolbar along bottom of screen
+ toolbar_window.set_screen(get_screen());
+ toolbar_window.set_border_width(0);
+ toolbar_window.add(toolbar);
+
+ toolbar_window.realize.connect(on_toolbar_realized);
+
+ add(page);
+
+ // call to set_default_size() saves one repaint caused by changing
+ // size from default to full screen. In slideshow mode, this change
+ // also causes pixbuf cache updates, so it really saves some work.
+ set_default_size(monitor.width, monitor.height);
+
+ // need to create a Gdk.Window to set masks
+ fullscreen();
+ show_all();
+
+ // capture motion events to show the toolbar
+ add_events(Gdk.EventMask.POINTER_MOTION_MASK);
+
+ // start off with toolbar invoked, as a clue for the user
+ invoke_toolbar();
+ }
+
+ public void disable_toolbar_dismissal() {
+ is_toolbar_dismissal_enabled = false;
+ }
+
+ public void update_toolbar_dismissal() {
+ is_toolbar_dismissal_enabled = !pin_button.get_active();
+ }
+
+ private Gdk.Rectangle get_monitor_geometry() {
+ Gdk.Rectangle monitor;
+
+ get_screen().get_monitor_geometry(
+ get_screen().get_monitor_at_window(AppWindow.get_instance().get_window()), out monitor);
+
+ return monitor;
+ }
+
+ public override bool configure_event(Gdk.EventConfigure event) {
+ bool result = base.configure_event(event);
+
+ if (!switched_to) {
+ get_current_page().switched_to();
+ switched_to = true;
+ }
+
+ return result;
+ }
+
+ private Gtk.ActionEntry[] create_actions() {
+ Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
+
+ Gtk.ActionEntry leave_fullscreen = { "LeaveFullscreen", Gtk.Stock.LEAVE_FULLSCREEN,
+ TRANSLATABLE, "F11", TRANSLATABLE, on_close };
+ leave_fullscreen.label = _("Leave _Fullscreen");
+ leave_fullscreen.tooltip = _("Leave fullscreen");
+ actions += leave_fullscreen;
+
+ return actions;
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ // check for an escape/abort
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ on_close();
+
+ return true;
+ }
+
+ // Make sure this event gets propagated to the underlying window...
+ AppWindow.get_instance().key_press_event(event);
+
+ // ...then let the base class take over
+ return (base.key_press_event != null) ? base.key_press_event(event) : false;
+ }
+
+ private void on_close() {
+ Config.Facade.get_instance().set_pin_toolbar_state(is_toolbar_dismissal_enabled);
+ hide_toolbar();
+ toolbar_window = null;
+
+ AppWindow.get_instance().end_fullscreen();
+ }
+
+ public new void close() {
+ on_close();
+ }
+
+ public override void destroy() {
+ Page? page = get_current_page();
+ clear_current_page();
+
+ if (page != null) {
+ page.stop_cursor_hiding();
+ page.switching_from();
+ }
+
+ base.destroy();
+ }
+
+ public override bool delete_event(Gdk.EventAny event) {
+ on_close();
+ AppWindow.get_instance().destroy();
+
+ return true;
+ }
+
+ public override bool motion_notify_event(Gdk.EventMotion event) {
+ if (!is_toolbar_shown) {
+ // if pointer is in toolbar height range without the mouse down (i.e. in the middle of
+ // an edit operation) and it stays there the necessary amount of time, invoke the
+ // toolbar
+ if (!waiting_for_invoke && is_pointer_in_toolbar()) {
+ Timeout.add(TOOLBAR_INVOCATION_MSEC, on_check_toolbar_invocation);
+ waiting_for_invoke = true;
+ }
+ }
+
+ return (base.motion_notify_event != null) ? base.motion_notify_event(event) : false;
+ }
+
+ private bool is_pointer_in_toolbar() {
+ Gdk.DeviceManager? devmgr = get_display().get_device_manager();
+ if (devmgr == null) {
+ debug("No device manager for display");
+
+ return false;
+ }
+
+ int py;
+ devmgr.get_client_pointer().get_position(null, null, out py);
+
+ int wy;
+ toolbar_window.get_window().get_geometry(null, out wy, null, null);
+
+ return (py >= wy);
+ }
+
+ private bool on_check_toolbar_invocation() {
+ waiting_for_invoke = false;
+
+ if (is_toolbar_shown)
+ return false;
+
+ if (!is_pointer_in_toolbar())
+ return false;
+
+ invoke_toolbar();
+
+ return false;
+ }
+
+ private void on_toolbar_realized() {
+ Gtk.Requisition req;
+ toolbar_window.get_preferred_size(null, out req);
+
+ // place the toolbar in the center of the monitor along the bottom edge
+ Gdk.Rectangle monitor = get_monitor_geometry();
+ int tx = monitor.x + (monitor.width - req.width) / 2;
+ if (tx < 0)
+ tx = 0;
+
+ int ty = monitor.y + monitor.height - req.height;
+ if (ty < 0)
+ ty = 0;
+
+ toolbar_window.move(tx, ty);
+ toolbar_window.set_opacity(Resources.TRANSIENT_WINDOW_OPACITY);
+ }
+
+ private void invoke_toolbar() {
+ toolbar_window.show_all();
+
+ is_toolbar_shown = true;
+
+ Timeout.add(TOOLBAR_CHECK_DISMISSAL_MSEC, on_check_toolbar_dismissal);
+ }
+
+ private bool on_check_toolbar_dismissal() {
+ if (!is_toolbar_shown)
+ return false;
+
+ if (toolbar_window == null)
+ return false;
+
+ // if dismissal is disabled, keep open but keep checking
+ if ((!is_toolbar_dismissal_enabled))
+ return true;
+
+ // if the pointer is in toolbar range, keep it alive, but keep checking
+ if (is_pointer_in_toolbar()) {
+ left_toolbar_time = 0;
+
+ return true;
+ }
+
+ // if this is the first time noticed, start the timer and keep checking
+ if (left_toolbar_time == 0) {
+ left_toolbar_time = time_t();
+
+ return true;
+ }
+
+ // see if enough time has elapsed
+ time_t now = time_t();
+ assert(now >= left_toolbar_time);
+
+ if (now - left_toolbar_time < TOOLBAR_DISMISSAL_SEC)
+ return true;
+
+ hide_toolbar();
+
+ return false;
+ }
+
+ private void hide_toolbar() {
+ toolbar_window.hide();
+ is_toolbar_shown = false;
+ }
+}
+
+// PageWindow is a Gtk.Window with essential functions for hosting a Page. There may be more than
+// one PageWindow in the system, and closing one does not imply exiting the application.
+//
+// PageWindow offers support for hosting a single Page; multiple Pages must be handled by the
+// subclass. A subclass should set current_page to the user-visible Page for it to receive
+// various notifications. It is the responsibility of the subclass to notify Pages when they're
+// switched to and from, and other aspects of the Page interface.
+public abstract class PageWindow : Gtk.Window {
+ protected Gtk.UIManager ui = new Gtk.UIManager();
+
+ private Page current_page = null;
+ private int busy_counter = 0;
+
+ protected virtual void switched_pages(Page? old_page, Page? new_page) {
+ }
+
+ public PageWindow() {
+ // the current page needs to know when modifier keys are pressed
+ add_events(Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK
+ | Gdk.EventMask.STRUCTURE_MASK);
+
+ set_has_resize_grip(false);
+ }
+
+ public Gtk.UIManager get_ui_manager() {
+ return ui;
+ }
+
+ public Page? get_current_page() {
+ return current_page;
+ }
+
+ public virtual void set_current_page(Page page) {
+ if (current_page != null)
+ current_page.clear_container();
+
+ Page? old_page = current_page;
+ current_page = page;
+ current_page.set_container(this);
+
+ switched_pages(old_page, page);
+ }
+
+ public virtual void clear_current_page() {
+ if (current_page != null)
+ current_page.clear_container();
+
+ Page? old_page = current_page;
+ current_page = null;
+
+ switched_pages(old_page, null);
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ if (get_focus() is Gtk.Entry && get_focus().key_press_event(event))
+ return true;
+
+ if (current_page != null && current_page.notify_app_key_pressed(event))
+ return true;
+
+ return (base.key_press_event != null) ? base.key_press_event(event) : false;
+ }
+
+ public override bool key_release_event(Gdk.EventKey event) {
+ if (get_focus() is Gtk.Entry && get_focus().key_release_event(event))
+ return true;
+
+ if (current_page != null && current_page.notify_app_key_released(event))
+ return true;
+
+ return (base.key_release_event != null) ? base.key_release_event(event) : false;
+ }
+
+ public override bool focus_in_event(Gdk.EventFocus event) {
+ if (current_page != null && current_page.notify_app_focus_in(event))
+ return true;
+
+ return (base.focus_in_event != null) ? base.focus_in_event(event) : false;
+ }
+
+ public override bool focus_out_event(Gdk.EventFocus event) {
+ if (current_page != null && current_page.notify_app_focus_out(event))
+ return true;
+
+ return (base.focus_out_event != null) ? base.focus_out_event(event) : false;
+ }
+
+ public override bool configure_event(Gdk.EventConfigure event) {
+ if (current_page != null) {
+ if (current_page.notify_configure_event(event))
+ return true;
+ }
+
+ return (base.configure_event != null) ? base.configure_event(event) : false;
+ }
+
+ public void set_busy_cursor() {
+ if (busy_counter++ > 0)
+ return;
+
+ get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH));
+ spin_event_loop();
+ }
+
+ public void set_normal_cursor() {
+ if (busy_counter <= 0) {
+ busy_counter = 0;
+ return;
+ } else if (--busy_counter > 0) {
+ return;
+ }
+
+ get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR));
+ spin_event_loop();
+ }
+
+}
+
+// AppWindow is the parent window for most windows in Shotwell (FullscreenWindow is the exception).
+// There are multiple types of AppWindows (LibraryWindow, DirectWindow) for different tasks, but only
+// one AppWindow may exist per process. Thus, if the user closes an AppWindow, the program exits.
+//
+// AppWindow also offers support for going into fullscreen mode. It handles the interface
+// notifications Page is expecting when switching back and forth.
+public abstract class AppWindow : PageWindow {
+ public const int DND_ICON_SCALE = 128;
+
+ protected static AppWindow instance = null;
+
+ private static FullscreenWindow fullscreen_window = null;
+ private static CommandManager command_manager = null;
+
+ // the AppWindow maintains its own UI manager because the first UIManager an action group is
+ // added to is the one that claims its accelerators
+ protected Gtk.ActionGroup[] common_action_groups;
+ protected bool maximized = false;
+ protected Dimensions dimensions;
+ protected int pos_x = 0;
+ protected int pos_y = 0;
+
+ private Gtk.ActionGroup common_action_group = new Gtk.ActionGroup("AppWindowGlobalActionGroup");
+
+ public AppWindow() {
+ // although there are multiple AppWindow types, only one may exist per-process
+ assert(instance == null);
+ instance = this;
+
+ title = Resources.APP_TITLE;
+
+ GLib.List<Gdk.Pixbuf> pixbuf_list = new GLib.List<Gdk.Pixbuf>();
+ foreach (string resource in Resources.APP_ICONS)
+ pixbuf_list.append(Resources.get_icon(resource, 0));
+ set_default_icon_list(pixbuf_list);
+
+ // restore previous size and maximization state
+ if (this is LibraryWindow) {
+ Config.Facade.get_instance().get_library_window_state(out maximized, out dimensions);
+ } else {
+ assert(this is DirectWindow);
+ Config.Facade.get_instance().get_direct_window_state(out maximized, out dimensions);
+ }
+
+ set_default_size(dimensions.width, dimensions.height);
+
+ if (maximized)
+ maximize();
+
+ assert(command_manager == null);
+ command_manager = new CommandManager();
+ command_manager.altered.connect(on_command_manager_altered);
+
+ // Because the first UIManager to associated with an ActionGroup claims the accelerators,
+ // need to create the AppWindow's ActionGroup early on and add it to an application-wide
+ // UIManager. In order to activate those accelerators, we need to create a dummy UI string
+ // that lists all the common actions. We build it on-the-fly from the actions associated
+ // with each ActionGroup while we're adding the groups to the UIManager.
+ common_action_groups = create_common_action_groups();
+ foreach (Gtk.ActionGroup group in common_action_groups)
+ ui.insert_action_group(group, 0);
+
+ try {
+ ui.add_ui_from_string(build_dummy_ui_string(common_action_groups), -1);
+ } catch (Error err) {
+ error("Unable to add AppWindow UI: %s", err.message);
+ }
+
+ ui.ensure_update();
+ add_accel_group(ui.get_accel_group());
+ }
+
+ private Gtk.ActionEntry[] create_common_actions() {
+ Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
+
+ Gtk.ActionEntry quit = { "CommonQuit", Gtk.Stock.QUIT, TRANSLATABLE, "<Ctrl>Q",
+ TRANSLATABLE, on_quit };
+ quit.label = _("_Quit");
+ actions += quit;
+
+ Gtk.ActionEntry about = { "CommonAbout", Gtk.Stock.ABOUT, TRANSLATABLE, null,
+ TRANSLATABLE, on_about };
+ about.label = _("_About");
+ actions += about;
+
+ Gtk.ActionEntry fullscreen = { "CommonFullscreen", Gtk.Stock.FULLSCREEN,
+ TRANSLATABLE, "F11", TRANSLATABLE, on_fullscreen };
+ fullscreen.label = _("Fulls_creen");
+ actions += fullscreen;
+
+ Gtk.ActionEntry help_contents = { "CommonHelpContents", Gtk.Stock.HELP,
+ TRANSLATABLE, "F1", TRANSLATABLE, on_help_contents };
+ help_contents.label = _("_Contents");
+ actions += help_contents;
+
+ Gtk.ActionEntry help_faq = { "CommonHelpFAQ", null, TRANSLATABLE, null,
+ TRANSLATABLE, on_help_faq };
+ help_faq.label = _("_Frequently Asked Questions");
+ actions += help_faq;
+
+ Gtk.ActionEntry help_report_problem = { "CommonHelpReportProblem", null, TRANSLATABLE, null,
+ TRANSLATABLE, on_help_report_problem };
+ help_report_problem.label = _("_Report a Problem...");
+ actions += help_report_problem;
+
+ Gtk.ActionEntry undo = { "CommonUndo", Gtk.Stock.UNDO, TRANSLATABLE, "<Ctrl>Z",
+ TRANSLATABLE, on_undo };
+ undo.label = Resources.UNDO_MENU;
+ actions += undo;
+
+ Gtk.ActionEntry redo = { "CommonRedo", Gtk.Stock.REDO, TRANSLATABLE, "<Ctrl><Shift>Z",
+ TRANSLATABLE, on_redo };
+ redo.label = Resources.REDO_MENU;
+ actions += redo;
+
+ Gtk.ActionEntry jump_to_file = { "CommonJumpToFile", Gtk.Stock.JUMP_TO, TRANSLATABLE,
+ "<Ctrl><Shift>M", TRANSLATABLE, on_jump_to_file };
+ jump_to_file.label = Resources.JUMP_TO_FILE_MENU;
+ actions += jump_to_file;
+
+ Gtk.ActionEntry select_all = { "CommonSelectAll", Gtk.Stock.SELECT_ALL, TRANSLATABLE,
+ "<Ctrl>A", TRANSLATABLE, on_select_all };
+ select_all.label = Resources.SELECT_ALL_MENU;
+ actions += select_all;
+
+ Gtk.ActionEntry select_none = { "CommonSelectNone", null, null,
+ "<Ctrl><Shift>A", TRANSLATABLE, on_select_none };
+ actions += select_none;
+
+ return actions;
+ }
+
+ protected abstract void on_fullscreen();
+
+ public static bool has_instance() {
+ return instance != null;
+ }
+
+ public static AppWindow get_instance() {
+ return instance;
+ }
+
+ public static FullscreenWindow get_fullscreen() {
+ return fullscreen_window;
+ }
+
+ public static Gtk.Builder create_builder(string glade_filename = "shotwell.glade", void *user = null) {
+ Gtk.Builder builder = new Gtk.Builder();
+ try {
+ builder.add_from_file(AppDirs.get_resources_dir().get_child("ui").get_child(
+ glade_filename).get_path());
+ } 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);
+ }
+
+ public static void error_message_with_title(string title, string message, Gtk.Window? parent = null, bool should_escape = true) {
+ // Per the Gnome HIG (http://library.gnome.org/devel/hig-book/2.32/windows-alert.html.en),
+ // alert-style dialogs mustn't have titles; we use the title as the primary text, and the
+ // existing message as the secondary text.
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog.with_markup((parent != null) ? parent : get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s", build_alert_body_text(title, message, should_escape));
+
+ // Occasionally, with_markup doesn't actually do anything, but set_markup always works.
+ dialog.set_markup(build_alert_body_text(title, message, should_escape));
+
+ dialog.use_markup = true;
+ dialog.run();
+ dialog.destroy();
+ }
+
+ public static bool negate_affirm_question(string message, string negative, string affirmative,
+ 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", build_alert_body_text(title, message));
+
+ dialog.set_markup(build_alert_body_text(title, message));
+ dialog.add_buttons(negative, Gtk.ResponseType.NO, affirmative, Gtk.ResponseType.YES);
+ dialog.set_urgency_hint(true);
+
+ bool response = (dialog.run() == Gtk.ResponseType.YES);
+
+ dialog.destroy();
+
+ return response;
+ }
+
+ public static Gtk.ResponseType negate_affirm_cancel_question(string message, string negative,
+ string affirmative, string? title = null, Gtk.Window? parent = null) {
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog.with_markup((parent != null) ? parent : get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", build_alert_body_text(title, message));
+
+ dialog.add_buttons(negative, Gtk.ResponseType.NO, affirmative, Gtk.ResponseType.YES,
+ _("_Cancel"), Gtk.ResponseType.CANCEL);
+
+ // Occasionally, with_markup doesn't actually enable markup, but set_markup always works.
+ dialog.set_markup(build_alert_body_text(title, message));
+ dialog.use_markup = true;
+
+ int response = dialog.run();
+
+ dialog.destroy();
+
+ return (Gtk.ResponseType) response;
+ }
+
+ public static Gtk.ResponseType affirm_cancel_question(string message, string affirmative,
+ string? title = null, Gtk.Window? parent = null) {
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog.with_markup((parent != null) ? parent : get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", message);
+ // Occasionally, with_markup doesn't actually enable markup...? Force the issue.
+ dialog.set_markup(message);
+ dialog.use_markup = true;
+ dialog.title = (title != null) ? title : Resources.APP_TITLE;
+ dialog.add_buttons(affirmative, Gtk.ResponseType.YES, _("_Cancel"),
+ Gtk.ResponseType.CANCEL);
+
+ int response = dialog.run();
+
+ dialog.destroy();
+
+ 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) {
+ 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);
+
+ int response = dialog.run();
+
+ dialog.destroy();
+
+ return (Gtk.ResponseType) response;
+ }
+
+ public static void database_error(DatabaseError 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);
+
+ Application.get_instance().panic();
+ }
+
+ public abstract string get_app_role();
+
+ protected void on_about() {
+ Gtk.show_about_dialog(this,
+ "version", Resources.APP_VERSION,
+ "comments", get_app_role(),
+ "copyright", Resources.COPYRIGHT,
+ "website", Resources.HOME_URL,
+ "license", Resources.LICENSE,
+ "website-label", _("Visit the Yorba web site"),
+ "authors", Resources.AUTHORS,
+ "logo", Resources.get_icon(Resources.ICON_ABOUT_LOGO, -1),
+ "translator-credits", _("translator-credits"),
+ null
+ );
+ }
+
+ private void on_help_contents() {
+ try {
+ Resources.launch_help(get_screen());
+ } catch (Error err) {
+ error_message(_("Unable to display help: %s").printf(err.message));
+ }
+ }
+
+ private void on_help_report_problem() {
+ try {
+ show_uri(Resources.BUG_DB_URL);
+ } catch (Error err) {
+ error_message(_("Unable to navigate to bug database: %s").printf(err.message));
+ }
+ }
+
+ private void on_help_faq() {
+ try {
+ show_uri(Resources.FAQ_URL);
+ } catch (Error err) {
+ error_message(_("Unable to display FAQ: %s").printf(err.message));
+ }
+ }
+
+ protected virtual void on_quit() {
+ Application.get_instance().exit();
+ }
+
+ protected void on_jump_to_file() {
+ if (get_current_page().get_view().get_selected_count() != 1)
+ return;
+
+ MediaSource? media = get_current_page().get_view().get_selected_at(0).get_source()
+ as MediaSource;
+ if (media == null)
+ return;
+
+ try {
+ AppWindow.get_instance().show_file_uri(media.get_master_file());
+ } catch (Error err) {
+ AppWindow.error_message(Resources.jump_to_file_failed(err));
+ }
+ }
+
+ protected override void destroy() {
+ on_quit();
+ }
+
+ public void show_file_uri(File file) throws Error {
+ string tmp;
+
+ // if file manager is nautilus then pass the full path to file; otherwise pass
+ // the enclosing directory
+ if(get_nautilus_install_location() != null) {
+ tmp = file.get_uri().replace("'","\\\'");
+ show_file_in_nautilus(tmp);
+ } else {
+ tmp = file.get_parent().get_uri().replace("'","\\\'");
+ show_uri(tmp);
+ }
+ }
+
+ public void show_uri(string url) throws Error {
+ sys_show_uri(get_window().get_screen(), url);
+ }
+
+ protected virtual Gtk.ActionGroup[] create_common_action_groups() {
+ Gtk.ActionGroup[] groups = new Gtk.ActionGroup[0];
+
+ common_action_group.add_actions(create_common_actions(), this);
+ groups += common_action_group;
+
+ return groups;
+ }
+
+ public Gtk.ActionGroup[] get_common_action_groups() {
+ return common_action_groups;
+ }
+
+ public virtual void replace_common_placeholders(Gtk.UIManager ui) {
+ }
+
+ public void go_fullscreen(Page page) {
+ // if already fullscreen, use that
+ if (fullscreen_window != null) {
+ fullscreen_window.present();
+
+ return;
+ }
+
+ get_position(out pos_x, out pos_y);
+ hide();
+
+ FullscreenWindow fsw = new FullscreenWindow(page);
+
+ if (get_current_page() != null)
+ get_current_page().switching_to_fullscreen(fsw);
+
+ fullscreen_window = fsw;
+ fullscreen_window.present();
+ }
+
+ public void end_fullscreen() {
+ if (fullscreen_window == null)
+ return;
+
+ move(pos_x, pos_y);
+
+ show_all();
+
+ if (get_current_page() != null)
+ get_current_page().returning_from_fullscreen(fullscreen_window);
+
+ fullscreen_window.hide();
+ fullscreen_window.destroy();
+ fullscreen_window = null;
+
+ present();
+ }
+
+ public Gtk.Action? get_common_action(string name) {
+ foreach (Gtk.ActionGroup group in common_action_groups) {
+ Gtk.Action? action = group.get_action(name);
+ if (action != null)
+ return action;
+ }
+
+ warning("No common action found: %s", name);
+
+ return null;
+ }
+
+ public void set_common_action_sensitive(string name, bool sensitive) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.sensitive = sensitive;
+ }
+
+ public void set_common_action_important(string name, bool important) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.is_important = important;
+ }
+
+ public void set_common_action_visible(string name, bool visible) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.visible = visible;
+ }
+
+ protected override void switched_pages(Page? old_page, Page? new_page) {
+ update_common_action_availability(old_page, new_page);
+
+ if (old_page != null) {
+ old_page.get_view().contents_altered.disconnect(on_update_common_actions);
+ old_page.get_view().selection_group_altered.disconnect(on_update_common_actions);
+ old_page.get_view().items_state_changed.disconnect(on_update_common_actions);
+ }
+
+ if (new_page != null) {
+ new_page.get_view().contents_altered.connect(on_update_common_actions);
+ new_page.get_view().selection_group_altered.connect(on_update_common_actions);
+ new_page.get_view().items_state_changed.connect(on_update_common_actions);
+
+ update_common_actions(new_page, new_page.get_view().get_selected_count(),
+ new_page.get_view().get_count());
+ }
+
+ base.switched_pages(old_page, new_page);
+ }
+
+ // This is called when a Page is switched out and certain common actions are simply
+ // unavailable for the new one. This is different than update_common_actions() in that that
+ // call is made when state within the Page has changed.
+ protected virtual void update_common_action_availability(Page? old_page, Page? new_page) {
+ bool is_checkerboard = new_page is CheckerboardPage;
+
+ set_common_action_sensitive("CommonSelectAll", is_checkerboard);
+ set_common_action_sensitive("CommonSelectNone", is_checkerboard);
+ }
+
+ // This is a counterpart to Page.update_actions(), but for common Gtk.Actions
+ // NOTE: Although CommonFullscreen is declared here, it's implementation is up to the subclasses,
+ // therefore they need to update its action.
+ protected virtual void update_common_actions(Page page, int selected_count, int count) {
+ if (page is CheckerboardPage)
+ set_common_action_sensitive("CommonSelectAll", count > 0);
+ set_common_action_sensitive("CommonJumpToFile", selected_count == 1);
+
+ decorate_undo_action();
+ decorate_redo_action();
+ }
+
+ private void on_update_common_actions() {
+ Page? page = get_current_page();
+ if (page != null)
+ update_common_actions(page, page.get_view().get_selected_count(), page.get_view().get_count());
+ }
+
+ public static CommandManager get_command_manager() {
+ return command_manager;
+ }
+
+ private void on_command_manager_altered() {
+ decorate_undo_action();
+ decorate_redo_action();
+ }
+
+ private void decorate_command_manager_action(string name, string prefix,
+ string default_explanation, CommandDescription? desc) {
+ Gtk.Action? action = get_common_action(name);
+ if (action == null)
+ return;
+
+ if (desc != null) {
+ action.label = "%s %s".printf(prefix, desc.get_name());
+ action.tooltip = desc.get_explanation();
+ action.sensitive = true;
+ } else {
+ action.label = prefix;
+ action.tooltip = default_explanation;
+ action.sensitive = false;
+ }
+ }
+
+ public void decorate_undo_action() {
+ decorate_command_manager_action("CommonUndo", Resources.UNDO_MENU, "",
+ get_command_manager().get_undo_description());
+ }
+
+ public void decorate_redo_action() {
+ decorate_command_manager_action("CommonRedo", Resources.REDO_MENU, "",
+ get_command_manager().get_redo_description());
+ }
+
+ private void on_undo() {
+ command_manager.undo();
+ }
+
+ private void on_redo() {
+ command_manager.redo();
+ }
+
+ private void on_select_all() {
+ Page? page = get_current_page() as CheckerboardPage;
+ if (page != null)
+ page.get_view().select_all();
+ }
+
+ private void on_select_none() {
+ Page? page = get_current_page() as CheckerboardPage;
+ if (page != null)
+ page.get_view().unselect_all();
+ }
+
+ public override bool configure_event(Gdk.EventConfigure event) {
+ maximized = (get_window().get_state() == Gdk.WindowState.MAXIMIZED);
+
+ if (!maximized)
+ get_size(out dimensions.width, out dimensions.height);
+
+ return base.configure_event(event);
+ }
+
+}
+
diff --git a/src/Application.vala b/src/Application.vala
new file mode 100644
index 0000000..95163cf
--- /dev/null
+++ b/src/Application.vala
@@ -0,0 +1,228 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 Application {
+ private static Application instance = null;
+ private Gtk.Application system_app = null;
+ private int system_app_run_retval = 0;
+ private bool direct;
+
+ public virtual signal void starting() {
+ }
+
+ public virtual signal void exiting(bool panicked) {
+ }
+
+ public virtual signal void init_done() {
+ }
+
+ private bool fixup_raw_thumbs = false;
+
+ public void set_raw_thumbs_fix_required(bool should_fixup) {
+ fixup_raw_thumbs = should_fixup;
+ }
+
+ public bool get_raw_thumbs_fix_required() {
+ return fixup_raw_thumbs;
+ }
+
+ private bool running = false;
+ private bool exiting_fired = false;
+
+ private Application(bool is_direct) {
+ if (is_direct) {
+ // we allow multiple instances of ourself in direct mode, so DON'T
+ // attempt to be unique. We don't request any command-line handling
+ // here because this is processed elsewhere, and we don't need to handle
+ // command lines from remote instances, since we don't care about them.
+ system_app = new Gtk.Application("org.yorba.shotwell-direct", GLib.ApplicationFlags.HANDLES_OPEN |
+ GLib.ApplicationFlags.NON_UNIQUE);
+ } else {
+ // we've been invoked in library mode; set up for uniqueness and handling
+ // of incoming command lines from remote instances (needed for getting
+ // storage device and camera mounts).
+ system_app = new Gtk.Application("org.yorba.shotwell", GLib.ApplicationFlags.HANDLES_OPEN |
+ GLib.ApplicationFlags.HANDLES_COMMAND_LINE);
+ }
+
+ // GLib will assert if we don't do this...
+ try {
+ system_app.register();
+ } catch (Error e) {
+ panic();
+ }
+
+ direct = is_direct;
+
+ if (!direct) {
+ system_app.command_line.connect(on_command_line);
+ }
+
+ system_app.activate.connect(on_activated);
+ system_app.startup.connect(on_activated);
+ }
+
+ /**
+ * @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
+ * instance.
+ */
+ public static void send_to_primary_instance(string[]? argv) {
+ get_instance().system_app.run(argv);
+ }
+
+ /**
+ * @brief A helper for library mode that tells the primary
+ * instance to bring its window to the foreground. This
+ * should only be called if we are _not_ the primary instance.
+ */
+ public static void present_primary_instance() {
+ get_instance().system_app.activate();
+ }
+
+ public static bool get_is_remote() {
+ return get_instance().system_app.get_is_remote();
+ }
+
+ public static bool get_is_direct() {
+ return get_instance().direct;
+ }
+
+ /**
+ * @brief Signal handler for GApplication's 'command-line' 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
+ * a camera/removeable-storage mount; in either case, the remote instance
+ * will trigger this and exit, and we'll need to bring the window back up...
+ */
+ public static void on_activated() {
+ get_instance();
+
+ LibraryWindow lw = AppWindow.get_instance() as LibraryWindow;
+ if ((lw != null) && (!get_is_direct())) {
+ LibraryWindow.get_app().present();
+ }
+ }
+
+ /**
+ * @brief Signal handler for GApplication's 'command-line' signal.
+ *
+ * Gets fired whenever a remote instance tries to run, usually
+ * with an incoming camera connection.
+ *
+ * @note This does _not_ get called in direct-edit mode.
+ */
+ public static int on_command_line(ApplicationCommandLine acl) {
+ string[]? argv = acl.get_arguments();
+
+ if (argv != null) {
+ foreach (string s in argv) {
+ LibraryWindow lw = AppWindow.get_instance() as LibraryWindow;
+ if (lw != null) {
+ lw.mounted_camera_shell_notification(s, false);
+ }
+ }
+ }
+ on_activated();
+ return 0;
+ }
+
+ /**
+ * @brief Initializes the Shotwell application object and prepares
+ * it for use.
+ *
+ * @param is_direct Whether the application was invoked in direct
+ * or in library mode; defaults to FALSE, that is, library mode.
+ *
+ * @note This MUST be called prior to calling get_instance(), as the
+ * application needs to know what mode it was brought up in; failure to
+ * call this first will lead to an assertion.
+ */
+ public static void init(bool is_direct = false) {
+ if (instance == null)
+ instance = new Application(is_direct);
+ }
+
+ public static void terminate() {
+ get_instance().exit();
+ }
+
+ public static Application get_instance() {
+ assert (instance != null);
+
+ return instance;
+ }
+
+ public void start(string[]? argv = null) {
+ if (running)
+ return;
+
+ running = true;
+
+ starting();
+
+ assert(AppWindow.get_instance() != null);
+ system_app.add_window(AppWindow.get_instance());
+ system_app_run_retval = system_app.run(argv);
+
+ if (!direct) {
+ system_app.command_line.disconnect(on_command_line);
+ }
+
+ system_app.activate.disconnect(on_activated);
+ system_app.startup.disconnect(on_activated);
+
+ running = false;
+ }
+
+ public void exit() {
+ // only fire this once, but thanks to terminate(), it will be fired at least once (even
+ // if start() is not called and "starting" is not fired)
+ if (exiting_fired || !running)
+ return;
+
+ exiting_fired = true;
+
+ exiting(false);
+
+ system_app.release();
+ }
+
+ // This will fire the exiting signal with panicked set to true, but only if exit() hasn't
+ // already been called. This call will immediately halt the application.
+ public void panic() {
+ if (!exiting_fired) {
+ exiting_fired = true;
+ exiting(true);
+ }
+ Posix.exit(1);
+ }
+
+ /**
+ * @brief Allows the caller to ask for some part of the desktop session's functionality to
+ * be prevented from running; wrapper for Gtk.Application.inhibit().
+ *
+ * @note The return value is a 'cookie' that needs to be passed to 'uninhibit' to turn
+ * off a requested inhibition and should be saved by the caller.
+ */
+ public uint inhibit(Gtk.ApplicationInhibitFlags what, string? reason="none given") {
+ return system_app.inhibit(AppWindow.get_instance(), what, reason);
+ }
+
+ /**
+ * @brief Turns off a previously-requested inhibition. Wrapper for
+ * Gtk.Application.uninhibit().
+ */
+ public void uninhibit(uint cookie) {
+ system_app.uninhibit(cookie);
+ }
+
+ public int get_run_return_value() {
+ return system_app_run_retval;
+ }
+}
+
diff --git a/src/BatchImport.vala b/src/BatchImport.vala
new file mode 100644
index 0000000..d4298ed
--- /dev/null
+++ b/src/BatchImport.vala
@@ -0,0 +1,2051 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public enum ImportResult {
+ SUCCESS,
+ FILE_ERROR,
+ DECODE_ERROR,
+ DATABASE_ERROR,
+ USER_ABORT,
+ NOT_A_FILE,
+ PHOTO_EXISTS,
+ UNSUPPORTED_FORMAT,
+ NOT_AN_IMAGE,
+ DISK_FAILURE,
+ DISK_FULL,
+ CAMERA_ERROR,
+ FILE_WRITE_ERROR,
+ PIXBUF_CORRUPT_IMAGE;
+
+ public string to_string() {
+ switch (this) {
+ case SUCCESS:
+ return _("Success");
+
+ case FILE_ERROR:
+ return _("File error");
+
+ case DECODE_ERROR:
+ return _("Unable to decode file");
+
+ case DATABASE_ERROR:
+ return _("Database error");
+
+ case USER_ABORT:
+ return _("User aborted import");
+
+ case NOT_A_FILE:
+ return _("Not a file");
+
+ case PHOTO_EXISTS:
+ return _("File already exists in database");
+
+ case UNSUPPORTED_FORMAT:
+ return _("Unsupported file format");
+
+ case NOT_AN_IMAGE:
+ return _("Not an image file");
+
+ case DISK_FAILURE:
+ return _("Disk failure");
+
+ case DISK_FULL:
+ return _("Disk full");
+
+ case CAMERA_ERROR:
+ return _("Camera error");
+
+ case FILE_WRITE_ERROR:
+ return _("File write error");
+
+ case PIXBUF_CORRUPT_IMAGE:
+ return _("Corrupt image file");
+
+ default:
+ return _("Imported failed (%d)").printf((int) this);
+ }
+ }
+
+ public bool is_abort() {
+ switch (this) {
+ case ImportResult.DISK_FULL:
+ case ImportResult.DISK_FAILURE:
+ case ImportResult.USER_ABORT:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ public bool is_nonuser_abort() {
+ switch (this) {
+ case ImportResult.DISK_FULL:
+ case ImportResult.DISK_FAILURE:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ public static ImportResult convert_error(Error err, ImportResult default_result) {
+ if (err is FileError) {
+ FileError ferr = (FileError) err;
+
+ if (ferr is FileError.NOSPC)
+ return ImportResult.DISK_FULL;
+ else if (ferr is FileError.IO)
+ return ImportResult.DISK_FAILURE;
+ else if (ferr is FileError.ISDIR)
+ return ImportResult.NOT_A_FILE;
+ else if (ferr is FileError.ACCES)
+ return ImportResult.FILE_WRITE_ERROR;
+ else if (ferr is FileError.PERM)
+ return ImportResult.FILE_WRITE_ERROR;
+ else
+ return ImportResult.FILE_ERROR;
+ } else if (err is IOError) {
+ IOError ioerr = (IOError) err;
+
+ if (ioerr is IOError.NO_SPACE)
+ return ImportResult.DISK_FULL;
+ else if (ioerr is IOError.FAILED)
+ return ImportResult.DISK_FAILURE;
+ else if (ioerr is IOError.IS_DIRECTORY)
+ return ImportResult.NOT_A_FILE;
+ else if (ioerr is IOError.CANCELLED)
+ return ImportResult.USER_ABORT;
+ else if (ioerr is IOError.READ_ONLY)
+ return ImportResult.FILE_WRITE_ERROR;
+ else if (ioerr is IOError.PERMISSION_DENIED)
+ return ImportResult.FILE_WRITE_ERROR;
+ else
+ return ImportResult.FILE_ERROR;
+ } else if (err is GPhotoError) {
+ return ImportResult.CAMERA_ERROR;
+ } else if (err is Gdk.PixbufError) {
+ Gdk.PixbufError pixbuferr = (Gdk.PixbufError) err;
+
+ if (pixbuferr is Gdk.PixbufError.CORRUPT_IMAGE)
+ return ImportResult.PIXBUF_CORRUPT_IMAGE;
+ else if (pixbuferr is Gdk.PixbufError.INSUFFICIENT_MEMORY)
+ return default_result;
+ else if (pixbuferr is Gdk.PixbufError.BAD_OPTION)
+ return default_result;
+ else if (pixbuferr is Gdk.PixbufError.UNKNOWN_TYPE)
+ return ImportResult.UNSUPPORTED_FORMAT;
+ else if (pixbuferr is Gdk.PixbufError.UNSUPPORTED_OPERATION)
+ return default_result;
+ else if (pixbuferr is Gdk.PixbufError.FAILED)
+ return default_result;
+ else
+ return default_result;
+ }
+
+ return default_result;
+ }
+}
+
+// A BatchImportJob describes a unit of work the BatchImport object should perform. It returns
+// a file to be imported. If the file is a directory, it is automatically recursed by BatchImport
+// to find all files that need to be imported into the library.
+//
+// NOTE: All methods may be called from the context of a background thread or the main GTK thread.
+// Implementations should be able to handle either situation. The prepare method will always be
+// called by the same thread context.
+public abstract class BatchImportJob {
+ public abstract string get_dest_identifier();
+
+ public abstract string get_source_identifier();
+
+ public abstract bool is_directory();
+
+ public abstract string get_basename();
+
+ public abstract string get_path();
+
+ public virtual DuplicatedFile? get_duplicated_file() {
+ return null;
+ }
+
+ // Attaches a sibling job (for RAW+JPEG)
+ public abstract void set_associated(BatchImportJob associated);
+
+ // Returns the file size of the BatchImportJob or returns a file/directory which can be queried
+ // by BatchImportJob to determine it. Returns true if the size is return, false if the File is
+ // specified.
+ //
+ // filesize should only be returned if BatchImportJob represents a single file.
+ public abstract bool determine_file_size(out uint64 filesize, out File file_or_dir);
+
+ // NOTE: prepare( ) is called from a background thread in the worker pool
+ public abstract bool prepare(out File file_to_import, out bool copy_to_library) throws Error;
+
+ // Completes the import for the new library photo once it's been imported.
+ // If the job is directory based, this method will be called for each photo
+ // discovered in the directory. This method is only called for photographs
+ // that have been successfully imported.
+ //
+ // Returns true if any action was taken, false otherwise.
+ //
+ // NOTE: complete( )is called from the foreground thread
+ public virtual bool complete(MediaSource source, BatchImportRoll import_roll) throws Error {
+ return false;
+ }
+
+ // returns a non-zero time_t value if this has a valid exposure time override, returns zero
+ // otherwise
+ public virtual time_t get_exposure_time_override() {
+ return 0;
+ }
+}
+
+public class FileImportJob : BatchImportJob {
+ private File file_or_dir;
+ private bool copy_to_library;
+ private FileImportJob? associated = null;
+
+ public FileImportJob(File file_or_dir, bool copy_to_library) {
+ this.file_or_dir = file_or_dir;
+ this.copy_to_library = copy_to_library;
+ }
+
+ public override string get_dest_identifier() {
+ return file_or_dir.get_path();
+ }
+
+ public override string get_source_identifier() {
+ return file_or_dir.get_path();
+ }
+
+ public override bool is_directory() {
+ return query_is_directory(file_or_dir);
+ }
+
+ public override string get_basename() {
+ return file_or_dir.get_basename();
+ }
+
+ public override string get_path() {
+ return is_directory() ? file_or_dir.get_path() : file_or_dir.get_parent().get_path();
+ }
+
+ public override void set_associated(BatchImportJob associated) {
+ this.associated = associated as FileImportJob;
+ }
+
+ public override bool determine_file_size(out uint64 filesize, out File file) {
+ filesize = 0;
+ file = file_or_dir;
+
+ return false;
+ }
+
+ public override bool prepare(out File file_to_import, out bool copy) {
+ file_to_import = file_or_dir;
+ copy = copy_to_library;
+
+ return true;
+ }
+
+ public File get_file() {
+ return file_or_dir;
+ }
+}
+
+// A BatchImportRoll represents important state for a group of imported media. If this is shared
+// among multiple BatchImport objects, the imported media will appear to have been imported all at
+// once.
+public class BatchImportRoll {
+ public ImportID import_id;
+ public ViewCollection generated_events = new ViewCollection("BatchImportRoll generated events");
+
+ public BatchImportRoll() {
+ this.import_id = ImportID.generate();
+ }
+}
+
+// A BatchImportResult associates a particular job with a File that an import was performed on
+// and the import result. A BatchImportJob can specify multiple files, so there is not necessarily
+// a one-to-one relationship beteen it and this object.
+//
+// Note that job may be null (in the case of a pre-failed job that must be reported) and file may
+// be null (for similar reasons).
+public class BatchImportResult {
+ public BatchImportJob job;
+ public File? file;
+ public string src_identifier; // Source path
+ public string dest_identifier; // Destination path
+ public ImportResult result;
+ public string? errmsg = null;
+ public DuplicatedFile? duplicate_of;
+
+ public BatchImportResult(BatchImportJob job, File? file, string src_identifier,
+ string dest_identifier, DuplicatedFile? duplicate_of, ImportResult result) {
+ this.job = job;
+ this.file = file;
+ this.src_identifier = src_identifier;
+ this.dest_identifier = dest_identifier;
+ this.duplicate_of = duplicate_of;
+ this.result = result;
+ }
+
+ public BatchImportResult.from_error(BatchImportJob job, File? file, string src_identifier,
+ string dest_identifier, Error err, ImportResult default_result) {
+ this.job = job;
+ this.file = file;
+ this.src_identifier = src_identifier;
+ this.dest_identifier = dest_identifier;
+ this.result = ImportResult.convert_error(err, default_result);
+ this.errmsg = err.message;
+ }
+}
+
+public class ImportManifest {
+ public Gee.List<MediaSource> imported = new Gee.ArrayList<MediaSource>();
+ public Gee.List<BatchImportResult> success = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> camera_failed = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> failed = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> write_failed = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> skipped_photos = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> skipped_files = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> aborted = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> already_imported = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> corrupt_files = new Gee.ArrayList<BatchImportResult>();
+ public Gee.List<BatchImportResult> all = new Gee.ArrayList<BatchImportResult>();
+
+ public ImportManifest(Gee.List<BatchImportJob>? prefailed = null,
+ Gee.List<BatchImportJob>? pre_already_imported = null) {
+ if (prefailed != null) {
+ foreach (BatchImportJob job in prefailed) {
+ BatchImportResult batch_result = new BatchImportResult(job, null,
+ job.get_source_identifier(), job.get_dest_identifier(), null,
+ ImportResult.FILE_ERROR);
+
+ add_result(batch_result);
+ }
+ }
+
+ if (pre_already_imported != null) {
+ foreach (BatchImportJob job in pre_already_imported) {
+ BatchImportResult batch_result = new BatchImportResult(job,
+ File.new_for_path(job.get_basename()),
+ job.get_source_identifier(), job.get_dest_identifier(),
+ job.get_duplicated_file(), ImportResult.PHOTO_EXISTS);
+
+ add_result(batch_result);
+ }
+ }
+ }
+
+ public void add_result(BatchImportResult batch_result) {
+ bool reported = true;
+ switch (batch_result.result) {
+ case ImportResult.SUCCESS:
+ success.add(batch_result);
+ break;
+
+ case ImportResult.USER_ABORT:
+ if (batch_result.file != null && !query_is_directory(batch_result.file))
+ aborted.add(batch_result);
+ else
+ reported = false;
+ break;
+
+ case ImportResult.UNSUPPORTED_FORMAT:
+ skipped_photos.add(batch_result);
+ break;
+
+ case ImportResult.NOT_A_FILE:
+ case ImportResult.NOT_AN_IMAGE:
+ skipped_files.add(batch_result);
+ break;
+
+ case ImportResult.PHOTO_EXISTS:
+ already_imported.add(batch_result);
+ break;
+
+ case ImportResult.CAMERA_ERROR:
+ camera_failed.add(batch_result);
+ break;
+
+ case ImportResult.FILE_WRITE_ERROR:
+ write_failed.add(batch_result);
+ break;
+
+ case ImportResult.PIXBUF_CORRUPT_IMAGE:
+ corrupt_files.add(batch_result);
+ break;
+
+ default:
+ failed.add(batch_result);
+ break;
+ }
+
+ if (reported)
+ all.add(batch_result);
+ }
+}
+
+// BatchImport performs the work of taking a file (supplied by BatchImportJob's) and properly importing
+// it into the system, including database additions and thumbnail creation. It can be monitored by
+// multiple observers, but only one ImportReporter can be registered.
+//
+// TODO: With background threads. the better way to implement this is via a FSM (finite state
+// machine) that exists in states and responds to various events thrown off by the background
+// jobs. However, getting this code to a point that it works with threads is task enough, so it
+// will have to wait (especially since we'll want to write a generic FSM engine).
+public class BatchImport : Object {
+ private const int WORK_SNIFFER_THROBBER_MSEC = 125;
+
+ public const int REPORT_EVERY_N_PREPARED_FILES = 100;
+ public const int REPORT_PREPARED_FILES_EVERY_N_MSEC = 3000;
+
+ private const int READY_SOURCES_COUNT_OVERFLOW = 10;
+
+ private const int DISPLAY_QUEUE_TIMER_MSEC = 125;
+ private const int DISPLAY_QUEUE_HYSTERESIS_OVERFLOW = (3 * 1000) / DISPLAY_QUEUE_TIMER_MSEC;
+
+ private static Workers feeder_workers = new Workers(1, false);
+ private static Workers import_workers = new Workers(Workers.thread_per_cpu_minus_one(), false);
+
+ private Gee.Iterable<BatchImportJob> jobs;
+ private BatchImportRoll import_roll;
+ private string name;
+ private uint64 completed_bytes = 0;
+ private uint64 total_bytes = 0;
+ private unowned ImportReporter reporter;
+ private ImportManifest manifest;
+ private bool scheduled = false;
+ private bool completed = false;
+ private int file_imports_to_perform = -1;
+ private int file_imports_completed = 0;
+ private Cancellable? cancellable = null;
+ private ulong last_preparing_ms = 0;
+ private Gee.HashSet<File> skipset;
+#if !NO_DUPE_DETECTION
+ private Gee.HashMap<string, File> imported_full_md5_table = new Gee.HashMap<string, File>();
+#endif
+ private uint throbber_id = 0;
+ private int max_outstanding_import_jobs = Workers.thread_per_cpu_minus_one();
+ private bool untrash_duplicates = true;
+ private bool mark_duplicates_online = true;
+
+ // These queues are staging queues, holding batches of work that must happen in the import
+ // process, working on them all at once to minimize overhead.
+ private Gee.List<PreparedFile> ready_files = new Gee.LinkedList<PreparedFile>();
+ private Gee.List<CompletedImportObject> ready_thumbnails =
+ new Gee.LinkedList<CompletedImportObject>();
+ private Gee.List<CompletedImportObject> display_imported_queue =
+ new Gee.LinkedList<CompletedImportObject>();
+ private Gee.List<CompletedImportObject> ready_sources = new Gee.LinkedList<CompletedImportObject>();
+
+ // Called at the end of the batched jobs. Can be used to report the result of the import
+ // to the user. This is called BEFORE import_complete is fired.
+ public delegate void ImportReporter(ImportManifest manifest, BatchImportRoll import_roll);
+
+ // Called once, when the scheduled task begins
+ public signal void starting();
+
+ // Called repeatedly while preparing the launched BatchImport
+ public signal void preparing();
+
+ // Called repeatedly to report the progress of the BatchImport (but only called after the
+ // last "preparing" signal)
+ public signal void progress(uint64 completed_bytes, uint64 total_bytes);
+
+ // Called for each Photo or Video imported to the system. For photos, the pixbuf is
+ // screen-sized and rotated. For videos, the pixbuf is a frame-grab of the first frame.
+ //
+ // The to_follow number is the number of queued-up sources to expect following this signal
+ // in one burst.
+ public signal void imported(MediaSource source, Gdk.Pixbuf pixbuf, int to_follow);
+
+ // Called when a fatal error occurs that stops the import entirely. Remaining jobs will be
+ // failed and import_complete() is still fired.
+ public signal void fatal_error(ImportResult result, string message);
+
+ // Called when a job fails. import_complete will also be called at the end of the batch
+ public signal void import_job_failed(BatchImportResult result);
+
+ // Called at the end of the batched jobs; this will be signalled exactly once for the batch
+ public signal void import_complete(ImportManifest manifest, BatchImportRoll import_roll);
+
+ public BatchImport(Gee.Iterable<BatchImportJob> jobs, string name, ImportReporter? reporter,
+ Gee.ArrayList<BatchImportJob>? prefailed = null,
+ Gee.ArrayList<BatchImportJob>? pre_already_imported = null,
+ Cancellable? cancellable = null, BatchImportRoll? import_roll = null,
+ ImportManifest? skip_manifest = null) {
+ this.jobs = jobs;
+ this.name = name;
+ this.reporter = reporter;
+ this.manifest = new ImportManifest(prefailed, pre_already_imported);
+ this.cancellable = (cancellable != null) ? cancellable : new Cancellable();
+ this.import_roll = import_roll != null ? import_roll : new BatchImportRoll();
+
+ if (skip_manifest != null) {
+ skipset = new Gee.HashSet<File>(file_hash, file_equal);
+ foreach (MediaSource source in skip_manifest.imported) {
+ skipset.add(source.get_file());
+ }
+ }
+
+ // watch for user exit in the application
+ Application.get_instance().exiting.connect(user_halt);
+
+ // Use a timer to report imported photos to observers
+ Timeout.add(DISPLAY_QUEUE_TIMER_MSEC, display_imported_timer);
+ }
+
+ ~BatchImport() {
+#if TRACE_DTORS
+ debug("DTOR: BatchImport (%s)", name);
+#endif
+ Application.get_instance().exiting.disconnect(user_halt);
+ }
+
+ public string get_name() {
+ return name;
+ }
+
+ public void user_halt() {
+ cancellable.cancel();
+ }
+
+ public bool get_untrash_duplicates() {
+ return untrash_duplicates;
+ }
+
+ public void set_untrash_duplicates(bool untrash_duplicates) {
+ this.untrash_duplicates = untrash_duplicates;
+ }
+
+ public bool get_mark_duplicates_online() {
+ return mark_duplicates_online;
+ }
+
+ public void set_mark_duplicates_online(bool mark_duplicates_online) {
+ this.mark_duplicates_online = mark_duplicates_online;
+ }
+
+ private void log_status(string where) {
+#if TRACE_IMPORT
+ debug("%s: to_perform=%d completed=%d ready_files=%d ready_thumbnails=%d display_queue=%d ready_sources=%d",
+ where, file_imports_to_perform, file_imports_completed, ready_files.size,
+ ready_thumbnails.size, display_imported_queue.size, ready_sources.size);
+ debug("%s workers: feeder=%d import=%d", where, feeder_workers.get_pending_job_count(),
+ import_workers.get_pending_job_count());
+#endif
+ }
+
+ private bool report_failure(BatchImportResult import_result) {
+ bool proceed = true;
+
+ manifest.add_result(import_result);
+
+ if (import_result.result != ImportResult.SUCCESS) {
+ import_job_failed(import_result);
+
+ if (import_result.file != null && !import_result.result.is_abort()) {
+ uint64 filesize = 0;
+ try {
+ // A BatchImportResult file is guaranteed to be a single file
+ filesize = query_total_file_size(import_result.file);
+ } catch (Error err) {
+ warning("Unable to query file size of %s: %s", import_result.file.get_path(),
+ err.message);
+ }
+
+ report_progress(filesize);
+ }
+ }
+
+ // fire this signal only once, and only on non-user aborts
+ if (import_result.result.is_nonuser_abort() && proceed) {
+ fatal_error(import_result.result, import_result.errmsg);
+ proceed = false;
+ }
+
+ return proceed;
+ }
+
+ private void report_progress(uint64 increment_of_progress) {
+ completed_bytes += increment_of_progress;
+
+ // only report "progress" if progress has been made (and enough time has progressed),
+ // otherwise still preparing
+ if (completed_bytes == 0) {
+ ulong now = now_ms();
+ if (now - last_preparing_ms > 250) {
+ last_preparing_ms = now;
+ preparing();
+ }
+ } else if (increment_of_progress > 0) {
+ ulong now = now_ms();
+ if (now - last_preparing_ms > 250) {
+ last_preparing_ms = now;
+ progress(completed_bytes, total_bytes);
+ }
+ }
+ }
+
+ private bool report_failures(BackgroundImportJob background_job) {
+ bool proceed = true;
+
+ foreach (BatchImportResult import_result in background_job.failed) {
+ if (!report_failure(import_result))
+ proceed = false;
+ }
+
+ return proceed;
+ }
+
+ private void report_completed(string where) {
+ if (completed)
+ error("Attempted to complete already-completed import: %s", where);
+
+ completed = true;
+
+ flush_ready_sources();
+
+ log_status("Import completed: %s".printf(where));
+
+ // report completed to the reporter (called prior to the "import_complete" signal)
+ if (reporter != null)
+ reporter(manifest, import_roll);
+
+ import_complete(manifest, import_roll);
+ }
+
+ // This should be called whenever a file's import process is complete, successful or otherwise
+ private void file_import_complete() {
+ // mark this job as completed
+ file_imports_completed++;
+ if (file_imports_to_perform != -1)
+ assert(file_imports_completed <= file_imports_to_perform);
+
+ // because notifications can come in after completions, have to watch if this is the
+ // last file
+ if (file_imports_to_perform != -1 && file_imports_completed == file_imports_to_perform)
+ report_completed("completed preparing files, all outstanding imports completed");
+ }
+
+ public void schedule() {
+ assert(scheduled == false);
+ scheduled = true;
+
+ starting();
+
+ // fire off a background job to generate all FileToPrepare work
+ feeder_workers.enqueue(new WorkSniffer(this, jobs, on_work_sniffed_out, cancellable,
+ on_sniffer_cancelled, skipset));
+ throbber_id = Timeout.add(WORK_SNIFFER_THROBBER_MSEC, on_sniffer_working);
+ }
+
+ //
+ // WorkSniffer stage
+ //
+
+ private bool on_sniffer_working() {
+ report_progress(0);
+
+ return true;
+ }
+
+ private void on_work_sniffed_out(BackgroundJob j) {
+ assert(!completed);
+
+ WorkSniffer sniffer = (WorkSniffer) j;
+
+ log_status("on_work_sniffed_out");
+
+ if (!report_failures(sniffer) || sniffer.files_to_prepare.size == 0) {
+ report_completed("work sniffed out: nothing to do");
+
+ return;
+ }
+
+ total_bytes = sniffer.total_bytes;
+
+ // submit single background job to go out and prepare all the files, reporting back when/if
+ // they're ready for import; this is important because gPhoto can't handle multiple accesses
+ // to a camera without fat locking, and it's just not worth it. Serializing the imports
+ // also means the user sees the photos coming in in (roughly) the order they selected them
+ // on the screen
+ PrepareFilesJob prepare_files_job = new PrepareFilesJob(this, sniffer.files_to_prepare,
+ on_file_prepared, on_files_prepared, cancellable, on_file_prepare_cancelled);
+
+ feeder_workers.enqueue(prepare_files_job);
+
+ if (throbber_id > 0) {
+ Source.remove(throbber_id);
+ throbber_id = 0;
+ }
+ }
+
+ private void on_sniffer_cancelled(BackgroundJob j) {
+ assert(!completed);
+
+ WorkSniffer sniffer = (WorkSniffer) j;
+
+ log_status("on_sniffer_cancelled");
+
+ report_failures(sniffer);
+ report_completed("work sniffer cancelled");
+
+ if (throbber_id > 0) {
+ Source.remove(throbber_id);
+ throbber_id = 0;
+ }
+ }
+
+ //
+ // PrepareFiles stage
+ //
+
+ private void flush_import_jobs() {
+ // flush ready thumbnails before ready files because PreparedFileImportJob is more intense
+ // than ThumbnailWriterJob; reversing this order causes work to back up in ready_thumbnails
+ // and takes longer for the user to see progress (which is only reported after the thumbnail
+ // has been written)
+ while (ready_thumbnails.size > 0 && import_workers.get_pending_job_count() < max_outstanding_import_jobs) {
+ import_workers.enqueue(new ThumbnailWriterJob(this, ready_thumbnails.remove_at(0),
+ on_thumbnail_writer_completed, cancellable, on_thumbnail_writer_cancelled));
+ }
+
+ while(ready_files.size > 0 && import_workers.get_pending_job_count() < max_outstanding_import_jobs) {
+ import_workers.enqueue(new PreparedFileImportJob(this, ready_files.remove_at(0),
+ import_roll.import_id, on_import_files_completed, cancellable,
+ on_import_files_cancelled));
+ }
+ }
+
+ // This checks for duplicates in the current import batch, which may not already be in the
+ // library and therefore not detected there.
+ private File? get_in_current_import(PreparedFile prepared_file) {
+#if !NO_DUPE_DETECTION
+ if (prepared_file.full_md5 != null
+ && imported_full_md5_table.has_key(prepared_file.full_md5)) {
+
+ return imported_full_md5_table.get(prepared_file.full_md5);
+ }
+
+ // add for next one
+ if (prepared_file.full_md5 != null)
+ imported_full_md5_table.set(prepared_file.full_md5, prepared_file.file);
+#endif
+ return null;
+ }
+
+ // Called when a cluster of files are located and deemed proper for import by PrepareFiledJob
+ private void on_file_prepared(BackgroundJob j, NotificationObject? user) {
+ assert(!completed);
+
+ PreparedFileCluster cluster = (PreparedFileCluster) user;
+
+ log_status("on_file_prepared (%d files)".printf(cluster.list.size));
+
+ process_prepared_files.begin(cluster.list);
+ }
+
+ // TODO: This logic can be cleaned up. Attempt to remove all calls to
+ // the database, as it's a blocking call (use in-memory lookups whenever possible)
+ private async void process_prepared_files(Gee.List<PreparedFile> list) {
+ foreach (PreparedFile prepared_file in list) {
+ Idle.add(process_prepared_files.callback);
+ yield;
+
+ BatchImportResult import_result = null;
+
+ // first check if file is already registered as a media object
+
+ LibraryPhotoSourceCollection.State photo_state;
+ LibraryPhoto? photo = LibraryPhoto.global.get_state_by_file(prepared_file.file,
+ out photo_state);
+ if (photo != null) {
+ switch (photo_state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ import_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(),
+ DuplicatedFile.create_from_file(photo.get_master_file()),
+ ImportResult.PHOTO_EXISTS);
+
+ if (photo_state == LibraryPhotoSourceCollection.State.OFFLINE)
+ photo.mark_online();
+ break;
+
+ case LibraryPhotoSourceCollection.State.TRASH:
+ // let the code below deal with it
+ break;
+
+ default:
+ error("Unknown LibraryPhotoSourceCollection state: %s", photo_state.to_string());
+ }
+ }
+
+ if (import_result != null) {
+ report_failure(import_result);
+ file_import_complete();
+
+ continue;
+ }
+
+ VideoSourceCollection.State video_state;
+ Video? video = Video.global.get_state_by_file(prepared_file.file, out video_state);
+ if (video != null) {
+ switch (video_state) {
+ case VideoSourceCollection.State.ONLINE:
+ case VideoSourceCollection.State.OFFLINE:
+ import_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(),
+ DuplicatedFile.create_from_file(video.get_master_file()),
+ ImportResult.PHOTO_EXISTS);
+
+ if (video_state == VideoSourceCollection.State.OFFLINE)
+ video.mark_online();
+ break;
+
+ case VideoSourceCollection.State.TRASH:
+ // let the code below deal with it
+ break;
+
+ default:
+ error("Unknown VideoSourceCollection state: %s", video_state.to_string());
+ }
+ }
+
+ if (import_result != null) {
+ report_failure(import_result);
+ file_import_complete();
+
+ continue;
+ }
+
+ // now check if the file is a duplicate
+
+ if (prepared_file.is_video && Video.is_duplicate(prepared_file.file, prepared_file.full_md5)) {
+ VideoID[] duplicate_ids =
+ VideoTable.get_instance().get_duplicate_ids(prepared_file.file,
+ prepared_file.full_md5);
+ assert(duplicate_ids.length > 0);
+
+ DuplicatedFile? duplicated_file =
+ DuplicatedFile.create_from_video_id(duplicate_ids[0]);
+
+ ImportResult result_code = ImportResult.PHOTO_EXISTS;
+ if (mark_duplicates_online) {
+ Video? dupe_video =
+ (Video) Video.global.get_offline_bin().fetch_by_master_file(prepared_file.file);
+ if (dupe_video == null)
+ dupe_video = (Video) Video.global.get_offline_bin().fetch_by_md5(prepared_file.full_md5);
+
+ if(dupe_video != null) {
+ debug("duplicate video found offline, marking as online: %s",
+ prepared_file.file.get_path());
+
+ dupe_video.set_master_file(prepared_file.file);
+ dupe_video.mark_online();
+
+ duplicated_file = null;
+
+ manifest.imported.add(dupe_video);
+ report_progress(dupe_video.get_filesize());
+ file_import_complete();
+
+ result_code = ImportResult.SUCCESS;
+ }
+ }
+
+ import_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(), duplicated_file,
+ result_code);
+
+ if (result_code == ImportResult.SUCCESS) {
+ manifest.add_result(import_result);
+
+ continue;
+ }
+ }
+
+ if (get_in_current_import(prepared_file) != null) {
+ // this looks for duplicates within the import set, since Photo.is_duplicate
+ // only looks within already-imported photos for dupes
+ import_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(),
+ DuplicatedFile.create_from_file(get_in_current_import(prepared_file)),
+ ImportResult.PHOTO_EXISTS);
+ } else if (Photo.is_duplicate(prepared_file.file, null, prepared_file.full_md5,
+ prepared_file.file_format)) {
+ if (untrash_duplicates) {
+ // If a file is being linked and has a dupe in the trash, we take it out of the trash
+ // and revert its edits.
+ photo = LibraryPhoto.global.get_trashed_by_file(prepared_file.file);
+
+ if (photo == null && prepared_file.full_md5 != null)
+ photo = LibraryPhoto.global.get_trashed_by_md5(prepared_file.full_md5);
+
+ if (photo != null) {
+ debug("duplicate linked photo found in trash, untrashing and removing transforms for %s",
+ prepared_file.file.get_path());
+
+ photo.set_master_file(prepared_file.file);
+ photo.untrash();
+ photo.remove_all_transformations();
+ }
+ }
+
+ if (photo == null && mark_duplicates_online) {
+ // if a duplicate is found marked offline, make it online
+ photo = LibraryPhoto.global.get_offline_by_file(prepared_file.file);
+
+ if (photo == null && prepared_file.full_md5 != null)
+ photo = LibraryPhoto.global.get_offline_by_md5(prepared_file.full_md5);
+
+ if (photo != null) {
+ debug("duplicate photo found marked offline, marking online: %s",
+ prepared_file.file.get_path());
+
+ photo.set_master_file(prepared_file.file);
+ photo.mark_online();
+ }
+ }
+
+ if (photo != null) {
+ import_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(), null,
+ ImportResult.SUCCESS);
+
+ manifest.imported.add(photo);
+ manifest.add_result(import_result);
+
+ report_progress(photo.get_filesize());
+ file_import_complete();
+
+ continue;
+ }
+
+ debug("duplicate photo detected, not importing %s", prepared_file.file.get_path());
+
+ PhotoID[] photo_ids =
+ PhotoTable.get_instance().get_duplicate_ids(prepared_file.file, null,
+ prepared_file.full_md5, prepared_file.file_format);
+ assert(photo_ids.length > 0);
+
+ DuplicatedFile duplicated_file = DuplicatedFile.create_from_photo_id(photo_ids[0]);
+
+ import_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(), duplicated_file,
+ ImportResult.PHOTO_EXISTS);
+ }
+
+ if (import_result != null) {
+ report_failure(import_result);
+ file_import_complete();
+
+ continue;
+ }
+
+ report_progress(0);
+ ready_files.add(prepared_file);
+ }
+
+ flush_import_jobs();
+ }
+
+ private void done_preparing_files(BackgroundJob j, string caller) {
+ assert(!completed);
+
+ PrepareFilesJob prepare_files_job = (PrepareFilesJob) j;
+
+ report_failures(prepare_files_job);
+
+ // mark this job as completed and record how many file imports must finish to be complete
+ file_imports_to_perform = prepare_files_job.prepared_files;
+ assert(file_imports_to_perform >= file_imports_completed);
+
+ log_status(caller);
+
+ // this call can result in report_completed() being called, so don't call twice
+ flush_import_jobs();
+
+ // if none prepared, then none outstanding (or will become outstanding, depending on how
+ // the notifications are queued)
+ if (file_imports_to_perform == 0 && !completed)
+ report_completed("no files prepared for import");
+ else if (file_imports_completed == file_imports_to_perform && !completed)
+ report_completed("completed preparing files, all outstanding imports completed");
+ }
+
+ private void on_files_prepared(BackgroundJob j) {
+ done_preparing_files(j, "on_files_prepared");
+ }
+
+ private void on_file_prepare_cancelled(BackgroundJob j) {
+ done_preparing_files(j, "on_file_prepare_cancelled");
+ }
+
+ //
+ // Files ready for import stage
+ //
+
+ private void on_import_files_completed(BackgroundJob j) {
+ assert(!completed);
+
+ PreparedFileImportJob job = (PreparedFileImportJob) j;
+
+ log_status("on_import_files_completed");
+
+ // should be ready in some form
+ assert(job.not_ready == null);
+
+ // mark failed photo
+ if (job.failed != null) {
+ assert(job.failed.result != ImportResult.SUCCESS);
+
+ report_failure(job.failed);
+ file_import_complete();
+ }
+
+ // resurrect ready photos before adding to database and rest of system ... this is more
+ // efficient than doing them one at a time
+ if (job.ready != null) {
+ assert(job.ready.batch_result.result == ImportResult.SUCCESS);
+
+ Tombstone? tombstone = Tombstone.global.locate(job.ready.final_file);
+ if (tombstone != null)
+ Tombstone.global.resurrect(tombstone);
+
+ // import ready photos into database
+ MediaSource? source = null;
+ if (job.ready.is_video) {
+ job.ready.batch_result.result = Video.import_create(job.ready.video_import_params,
+ out source);
+ } else {
+ job.ready.batch_result.result = LibraryPhoto.import_create(job.ready.photo_import_params,
+ out source);
+ Photo photo = source as Photo;
+
+ if (job.ready.photo_import_params.final_associated_file != null) {
+ // Associate RAW+JPEG in database.
+ BackingPhotoRow bpr = new BackingPhotoRow();
+ bpr.file_format = PhotoFileFormat.JFIF;
+ bpr.filepath = job.ready.photo_import_params.final_associated_file.get_path();
+ debug("Associating %s with sibling %s", ((Photo) source).get_file().get_path(),
+ bpr.filepath);
+ try {
+ ((Photo) source).add_backing_photo_for_development(RawDeveloper.CAMERA, bpr);
+ } catch (Error e) {
+ warning("Unable to associate JPEG with RAW. File: %s Error: %s",
+ bpr.filepath, e.message);
+ }
+ }
+
+ // Set the default developer for raw photos
+ if (photo.get_master_file_format() == PhotoFileFormat.RAW) {
+ RawDeveloper d = Config.Facade.get_instance().get_default_raw_developer();
+ if (d == RawDeveloper.CAMERA && !photo.is_raw_developer_available(d))
+ d = RawDeveloper.EMBEDDED;
+
+ photo.set_default_raw_developer(d);
+ photo.set_raw_developer(d);
+ }
+ }
+
+ if (job.ready.batch_result.result != ImportResult.SUCCESS) {
+ debug("on_import_file_completed: %s", job.ready.batch_result.result.to_string());
+
+ report_failure(job.ready.batch_result);
+ file_import_complete();
+ } else {
+ ready_thumbnails.add(new CompletedImportObject(source, job.ready.get_thumbnails(),
+ job.ready.prepared_file.job, job.ready.batch_result));
+ }
+ }
+
+ flush_import_jobs();
+ }
+
+ private void on_import_files_cancelled(BackgroundJob j) {
+ assert(!completed);
+
+ PreparedFileImportJob job = (PreparedFileImportJob) j;
+
+ log_status("on_import_files_cancelled");
+
+ if (job.not_ready != null) {
+ report_failure(new BatchImportResult(job.not_ready.job, job.not_ready.file,
+ job.not_ready.file.get_path(), job.not_ready.file.get_path(), null,
+ ImportResult.USER_ABORT));
+ file_import_complete();
+ }
+
+ if (job.failed != null) {
+ report_failure(job.failed);
+ file_import_complete();
+ }
+
+ if (job.ready != null) {
+ report_failure(job.ready.abort());
+ file_import_complete();
+ }
+
+ flush_import_jobs();
+ }
+
+ //
+ // ThumbnailWriter stage
+ //
+ // Because the LibraryPhoto has been created at this stage, any cancelled work must also
+ // destroy the LibraryPhoto.
+ //
+
+ private void on_thumbnail_writer_completed(BackgroundJob j) {
+ assert(!completed);
+
+ ThumbnailWriterJob job = (ThumbnailWriterJob) j;
+ CompletedImportObject completed = job.completed_import_source;
+
+ log_status("on_thumbnail_writer_completed");
+
+ if (completed.batch_result.result != ImportResult.SUCCESS) {
+ warning("Failed to import %s: unable to write thumbnails (%s)",
+ completed.source.to_string(), completed.batch_result.result.to_string());
+
+ if (completed.source is LibraryPhoto)
+ LibraryPhoto.import_failed(completed.source as LibraryPhoto);
+ else if (completed.source is Video)
+ Video.import_failed(completed.source as Video);
+
+ report_failure(completed.batch_result);
+ file_import_complete();
+ } else {
+ manifest.imported.add(completed.source);
+ manifest.add_result(completed.batch_result);
+
+ display_imported_queue.add(completed);
+ }
+
+ flush_import_jobs();
+ }
+
+ private void on_thumbnail_writer_cancelled(BackgroundJob j) {
+ assert(!completed);
+
+ ThumbnailWriterJob job = (ThumbnailWriterJob) j;
+ CompletedImportObject completed = job.completed_import_source;
+
+ log_status("on_thumbnail_writer_cancelled");
+
+ if (completed.source is LibraryPhoto)
+ LibraryPhoto.import_failed(completed.source as LibraryPhoto);
+ else if (completed.source is Video)
+ Video.import_failed(completed.source as Video);
+
+ report_failure(completed.batch_result);
+ file_import_complete();
+
+ flush_import_jobs();
+ }
+
+ //
+ // Display imported sources and integrate into system
+ //
+
+ private void flush_ready_sources() {
+ if (ready_sources.size == 0)
+ return;
+
+ // the user_preview and thumbnails in the CompletedImportObjects are not available at
+ // this stage
+
+ log_status("flush_ready_sources (%d)".printf(ready_sources.size));
+
+ Gee.ArrayList<MediaSource> all = new Gee.ArrayList<MediaSource>();
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
+ Gee.HashMap<MediaSource, BatchImportJob> completion_list =
+ new Gee.HashMap<MediaSource, BatchImportJob>();
+ foreach (CompletedImportObject completed in ready_sources) {
+ all.add(completed.source);
+
+ if (completed.source is LibraryPhoto)
+ photos.add((LibraryPhoto) completed.source);
+ else if (completed.source is Video)
+ videos.add((Video) completed.source);
+
+ completion_list.set(completed.source, completed.original_job);
+ }
+
+ MediaCollectionRegistry.get_instance().begin_transaction_on_all();
+ Event.global.freeze_notifications();
+ Tag.global.freeze_notifications();
+
+ LibraryPhoto.global.import_many(photos);
+ Video.global.import_many(videos);
+
+ // allow the BatchImportJob to perform final work on the MediaSource
+ foreach (MediaSource media in completion_list.keys) {
+ try {
+ completion_list.get(media).complete(media, import_roll);
+ } catch (Error err) {
+ warning("Completion error when finalizing import of %s: %s", media.to_string(),
+ err.message);
+ }
+ }
+
+ // generate events for MediaSources not yet assigned
+ Event.generate_many_events(all, import_roll.generated_events);
+
+ Tag.global.thaw_notifications();
+ Event.global.thaw_notifications();
+ MediaCollectionRegistry.get_instance().commit_transaction_on_all();
+
+ ready_sources.clear();
+ }
+
+ // This is called throughout the import process to notify watchers of imported photos in such
+ // a way that the GTK event queue gets a chance to operate.
+ private bool display_imported_timer() {
+ if (display_imported_queue.size == 0)
+ return !completed;
+
+ if (cancellable.is_cancelled())
+ debug("Importing %d photos at once", display_imported_queue.size);
+
+ log_status("display_imported_timer");
+
+ // only display one at a time, so the user can see them come into the library in order.
+ // however, if the queue backs up to the hysteresis point (currently defined as more than
+ // 3 seconds wait for the last photo on the queue), then begin doing them in increasingly
+ // larger chunks, to stop the queue from growing and then to get ahead of the other
+ // import cycles.
+ //
+ // if cancelled, want to do as many as possible, but want to relinquish the thread to
+ // keep the system active
+ int total = 1;
+ if (!cancellable.is_cancelled()) {
+ if (display_imported_queue.size > DISPLAY_QUEUE_HYSTERESIS_OVERFLOW)
+ total =
+ 1 << ((display_imported_queue.size / DISPLAY_QUEUE_HYSTERESIS_OVERFLOW) + 2).clamp(0, 16);
+ } else {
+ // do in overflow-sized chunks
+ total = DISPLAY_QUEUE_HYSTERESIS_OVERFLOW;
+ }
+
+ total = int.min(total, display_imported_queue.size);
+
+#if TRACE_IMPORT
+ if (total > 1) {
+ debug("DISPLAY IMPORT QUEUE: hysteresis, dumping %d/%d media sources", total,
+ display_imported_queue.size);
+ }
+#endif
+
+ // post-decrement because the 0-based total is used when firing "imported"
+ while (total-- > 0) {
+ CompletedImportObject completed_object = display_imported_queue.remove_at(0);
+
+ // stash preview for reporting progress
+ Gdk.Pixbuf user_preview = completed_object.user_preview;
+
+ // expensive pixbufs no longer needed
+ completed_object.user_preview = null;
+ completed_object.thumbnails = null;
+
+ // Stage the number of ready media objects to incorporate into the system rather than
+ // doing them one at a time, to keep the UI thread responsive.
+ // NOTE: completed_object must be added prior to file_import_complete()
+ ready_sources.add(completed_object);
+
+ imported(completed_object.source, user_preview, total);
+ report_progress(completed_object.source.get_filesize());
+ file_import_complete();
+ }
+
+ if (ready_sources.size >= READY_SOURCES_COUNT_OVERFLOW || cancellable.is_cancelled())
+ flush_ready_sources();
+
+ return true;
+ }
+} /* class BatchImport */
+
+public class DuplicatedFile : Object {
+ private VideoID? video_id;
+ private PhotoID? photo_id;
+ private File? file;
+
+ private DuplicatedFile() {
+ this.video_id = null;
+ this.photo_id = null;
+ this.file = null;
+ }
+
+ public static DuplicatedFile create_from_photo_id(PhotoID photo_id) {
+ assert(photo_id.is_valid());
+
+ DuplicatedFile result = new DuplicatedFile();
+ result.photo_id = photo_id;
+ return result;
+ }
+
+ public static DuplicatedFile create_from_video_id(VideoID video_id) {
+ assert(video_id.is_valid());
+
+ DuplicatedFile result = new DuplicatedFile();
+ result.video_id = video_id;
+ return result;
+ }
+
+ public static DuplicatedFile create_from_file(File file) {
+ DuplicatedFile result = new DuplicatedFile();
+
+ result.file = file;
+
+ return result;
+ }
+
+ public File get_file() {
+ if (file != null) {
+ return file;
+ } else if (photo_id != null) {
+ Photo photo_object = (Photo) LibraryPhoto.global.fetch(photo_id);
+ file = photo_object.get_master_file();
+ return file;
+ } else if (video_id != null) {
+ Video video_object = (Video) Video.global.fetch(video_id);
+ file = video_object.get_master_file();
+ return file;
+ } else {
+ assert_not_reached();
+ }
+ }
+}
+
+//
+// The order of the background jobs is important, both for how feedback is presented to the user
+// and to protect certain subsystems which don't work well in a multithreaded situation (i.e.
+// gPhoto).
+//
+// 1. WorkSniffer builds a list of all the work to do. If the BatchImportJob is a file, there's
+// not much more to do. If it represents a directory, the directory is traversed, with more work
+// generated for each file. Very little processing is done here on each file, however, and the
+// BatchImportJob.prepare is only called when a directory.
+//
+// 2. PrepareFilesJob walks the list WorkSniffer generated, preparing each file and examining it
+// for any obvious problems. This in turn generates a list of prepared files (i.e. downloaded from
+// camera).
+//
+// 3. Each file ready for importing is a separate background job. It is responsible for copying
+// the file (if required), examining it, and generating a pixbuf for preview and thumbnails.
+//
+
+private abstract class BackgroundImportJob : BackgroundJob {
+ public ImportResult abort_flag = ImportResult.SUCCESS;
+ public Gee.List<BatchImportResult> failed = new Gee.ArrayList<BatchImportResult>();
+
+ protected BackgroundImportJob(BatchImport owner, CompletionCallback callback,
+ Cancellable cancellable, CancellationCallback? cancellation) {
+ base (owner, callback, cancellable, cancellation);
+ }
+
+ // Subclasses should call this every iteration, and if the result is not SUCCESS, consider the
+ // operation (and therefore all after) aborted
+ protected ImportResult abort_check() {
+ if (abort_flag == ImportResult.SUCCESS && is_cancelled())
+ abort_flag = ImportResult.USER_ABORT;
+
+ return abort_flag;
+ }
+
+ protected void abort(ImportResult result) {
+ // only update the abort flag if not already set
+ if (abort_flag == ImportResult.SUCCESS)
+ abort_flag = result;
+ }
+
+ protected void report_failure(BatchImportJob job, File? file, string src_identifier,
+ string dest_identifier, ImportResult result) {
+ assert(result != ImportResult.SUCCESS);
+
+ // if fatal but the flag is not set, set it now
+ if (result.is_abort())
+ abort(result);
+ else
+ warning("Import failure %s: %s", src_identifier, result.to_string());
+
+ failed.add(new BatchImportResult(job, file, src_identifier, dest_identifier, null,
+ result));
+ }
+
+ protected void report_error(BatchImportJob job, File? file, string src_identifier,
+ string dest_identifier, Error err, ImportResult default_result) {
+ ImportResult result = ImportResult.convert_error(err, default_result);
+
+ warning("Import error %s: %s (%s)", src_identifier, err.message, result.to_string());
+
+ if (result.is_abort())
+ abort(result);
+
+ failed.add(new BatchImportResult.from_error(job, file, src_identifier, dest_identifier,
+ err, default_result));
+ }
+}
+
+private class FileToPrepare {
+ public BatchImportJob job;
+ public File? file;
+ public bool copy_to_library;
+ public FileToPrepare? associated = null;
+
+ public FileToPrepare(BatchImportJob job, File? file = null, bool copy_to_library = true) {
+ this.job = job;
+ this.file = file;
+ this.copy_to_library = copy_to_library;
+ }
+
+ public void set_associated(FileToPrepare? a) {
+ associated = a;
+ }
+
+ public string get_parent_path() {
+ return file != null ? file.get_parent().get_path() : job.get_path();
+ }
+
+ public string get_path() {
+ return file != null ? file.get_path() : (File.new_for_path(job.get_path()).get_child(
+ job.get_basename())).get_path();
+ }
+
+ public string get_basename() {
+ return file != null ? file.get_basename() : job.get_basename();
+ }
+
+ public bool is_directory() {
+ return file != null ? (file.query_file_type(FileQueryInfoFlags.NONE) == FileType.DIRECTORY) :
+ job.is_directory();
+ }
+}
+
+private class WorkSniffer : BackgroundImportJob {
+ public Gee.List<FileToPrepare> files_to_prepare = new Gee.ArrayList<FileToPrepare>();
+ public uint64 total_bytes = 0;
+
+ private Gee.Iterable<BatchImportJob> jobs;
+ private Gee.HashSet<File>? skipset;
+
+ public WorkSniffer(BatchImport owner, Gee.Iterable<BatchImportJob> jobs, CompletionCallback callback,
+ Cancellable cancellable, CancellationCallback cancellation, Gee.HashSet<File>? skipset = null) {
+ base (owner, callback, cancellable, cancellation);
+
+ this.jobs = jobs;
+ this.skipset = skipset;
+ }
+
+ public override void execute() {
+ // walk the list of jobs accumulating work for the background jobs; if submitted job
+ // is a directory, recurse into the directory picking up files to import (also creating
+ // work for the background jobs)
+ foreach (BatchImportJob job in jobs) {
+ ImportResult result = abort_check();
+ if (result != ImportResult.SUCCESS) {
+ report_failure(job, null, job.get_source_identifier(), job.get_dest_identifier(),
+ result);
+
+ continue;
+ }
+
+ try {
+ sniff_job(job);
+ } catch (Error err) {
+ report_error(job, null, job.get_source_identifier(), job.get_dest_identifier(), err,
+ ImportResult.FILE_ERROR);
+ }
+
+ if (is_cancelled())
+ break;
+ }
+
+ // Time to handle RAW+JPEG pairs!
+ // Now we build a new list of all the files (but not folders) we're
+ // importing and sort it by filename.
+ Gee.List<FileToPrepare> sorted = new Gee.ArrayList<FileToPrepare>();
+ foreach (FileToPrepare ftp in files_to_prepare) {
+ if (!ftp.is_directory())
+ sorted.add(ftp);
+ }
+ sorted.sort((a, b) => {
+ FileToPrepare file_a = (FileToPrepare) a;
+ FileToPrepare file_b = (FileToPrepare) b;
+ string sa = file_a.get_path();
+ string sb = file_b.get_path();
+ return utf8_cs_compare(sa, sb);
+ });
+
+ // For each file, check if the current file is RAW. If so, check the previous
+ // and next files to see if they're a "plus jpeg."
+ for (int i = 0; i < sorted.size; ++i) {
+ string name, ext;
+ FileToPrepare ftp = sorted.get(i);
+ disassemble_filename(ftp.get_basename(), out name, out ext);
+
+ if (is_string_empty(ext))
+ continue;
+
+ if (RawFileFormatProperties.get_instance().is_recognized_extension(ext)) {
+ // Got a raw file. See if it has a pair. If a pair is found, remove it
+ // from the list and link it to the RAW file.
+ if (i > 0 && is_paired(ftp, sorted.get(i - 1))) {
+ FileToPrepare associated_file = sorted.get(i - 1);
+ files_to_prepare.remove(associated_file);
+ ftp.set_associated(associated_file);
+ } else if (i < sorted.size - 1 && is_paired(ftp, sorted.get(i + 1))) {
+ FileToPrepare associated_file = sorted.get(i + 1);
+ files_to_prepare.remove(associated_file);
+ ftp.set_associated(associated_file);
+ }
+ }
+ }
+ }
+
+ // Check if a file is paired. The raw file must be a raw photo. A file
+ // is "paired" if it has the same basename as the raw file, is in the same
+ // directory, and is a JPEG.
+ private bool is_paired(FileToPrepare raw, FileToPrepare maybe_paired) {
+ if (raw.get_parent_path() != maybe_paired.get_parent_path())
+ return false;
+
+ string name, ext, test_name, test_ext;
+ disassemble_filename(maybe_paired.get_basename(), out test_name, out test_ext);
+
+ if (!JfifFileFormatProperties.get_instance().is_recognized_extension(test_ext))
+ return false;
+
+ disassemble_filename(raw.get_basename(), out name, out ext);
+
+ return name == test_name;
+ }
+
+ private void sniff_job(BatchImportJob job) throws Error {
+ uint64 size;
+ File file_or_dir;
+ bool determined_size = job.determine_file_size(out size, out file_or_dir);
+ if (determined_size)
+ total_bytes += size;
+
+ if (job.is_directory()) {
+ // safe to call job.prepare without it invoking extra I/O; this is merely a directory
+ // to search
+ File dir;
+ bool copy_to_library;
+ if (!job.prepare(out dir, out copy_to_library)) {
+ report_failure(job, null, job.get_source_identifier(), job.get_dest_identifier(),
+ ImportResult.FILE_ERROR);
+
+ return;
+ }
+ assert(query_is_directory(dir));
+
+ try {
+ search_dir(job, dir, copy_to_library);
+ } catch (Error err) {
+ report_error(job, dir, job.get_source_identifier(), dir.get_path(), err,
+ ImportResult.FILE_ERROR);
+ }
+ } else {
+ // if did not get the file size, do so now
+ if (!determined_size)
+ total_bytes += query_total_file_size(file_or_dir, get_cancellable());
+
+ // job is a direct file, so no need to search, prepare it directly
+ if ((file_or_dir != null) && skipset != null && skipset.contains(file_or_dir))
+ return; /* do a short-circuit return and don't enqueue if this file is to be
+ skipped */
+
+ files_to_prepare.add(new FileToPrepare(job));
+ }
+ }
+
+ public void search_dir(BatchImportJob job, File dir, bool copy_to_library) throws Error {
+ FileEnumerator enumerator = dir.enumerate_children("standard::*",
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+
+ FileInfo info = null;
+ while ((info = enumerator.next_file(get_cancellable())) != null) {
+ // next_file() doesn't always respect the cancellable
+ if (is_cancelled())
+ break;
+
+ File child = dir.get_child(info.get_name());
+ FileType file_type = info.get_file_type();
+
+ if (file_type == FileType.DIRECTORY) {
+ if (info.get_name().has_prefix("."))
+ continue;
+
+ try {
+ search_dir(job, child, copy_to_library);
+ } catch (Error err) {
+ report_error(job, child, child.get_path(), child.get_path(), err,
+ ImportResult.FILE_ERROR);
+ }
+ } else if (file_type == FileType.REGULAR) {
+ if ((skipset != null) && skipset.contains(child))
+ continue; /* don't enqueue if this file is to be skipped */
+
+ if ((Photo.is_file_image(child) && PhotoFileFormat.is_file_supported(child)) ||
+ VideoReader.is_supported_video_file(child)) {
+ total_bytes += info.get_size();
+ files_to_prepare.add(new FileToPrepare(job, child, copy_to_library));
+
+ continue;
+ }
+ } else {
+ warning("Ignoring import of %s file type %d", child.get_path(), (int) file_type);
+ }
+ }
+ }
+}
+
+private class PreparedFile {
+ public BatchImportJob job;
+ public ImportResult result;
+ public File file;
+ public File? associated_file = null;
+ public string source_id;
+ public string dest_id;
+ public bool copy_to_library;
+ public string? exif_md5;
+ public string? thumbnail_md5;
+ public string? full_md5;
+ public PhotoFileFormat file_format;
+ public uint64 filesize;
+ public bool is_video;
+
+ public PreparedFile(BatchImportJob job, File file, File? associated_file, string source_id, string dest_id,
+ bool copy_to_library, string? exif_md5, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format, uint64 filesize, bool is_video = false) {
+ this.job = job;
+ this.result = ImportResult.SUCCESS;
+ this.file = file;
+ this.associated_file = associated_file;
+ this.source_id = source_id;
+ this.dest_id = dest_id;
+ this.copy_to_library = copy_to_library;
+ this.exif_md5 = exif_md5;
+ this.thumbnail_md5 = thumbnail_md5;
+ this.full_md5 = full_md5;
+ this.file_format = file_format;
+ this.filesize = filesize;
+ this.is_video = is_video;
+ }
+}
+
+private class PreparedFileCluster : InterlockedNotificationObject {
+ public Gee.ArrayList<PreparedFile> list;
+
+ public PreparedFileCluster(Gee.ArrayList<PreparedFile> list) {
+ this.list = list;
+ }
+}
+
+private class PrepareFilesJob : BackgroundImportJob {
+ // Do not examine until the CompletionCallback has been called.
+ public int prepared_files = 0;
+
+ private Gee.List<FileToPrepare> files_to_prepare;
+ private unowned NotificationCallback notification;
+ private File library_dir;
+
+ // these are for debugging and testing only
+ private int import_file_count = 0;
+ private int fail_every = 0;
+ private int skip_every = 0;
+
+ public PrepareFilesJob(BatchImport owner, Gee.List<FileToPrepare> files_to_prepare,
+ NotificationCallback notification, CompletionCallback callback, Cancellable cancellable,
+ CancellationCallback cancellation) {
+ base (owner, callback, cancellable, cancellation);
+
+ this.files_to_prepare = files_to_prepare;
+ this.notification = notification;
+ library_dir = AppDirs.get_import_dir();
+ fail_every = get_test_variable("SHOTWELL_FAIL_EVERY");
+ skip_every = get_test_variable("SHOTWELL_SKIP_EVERY");
+
+ set_notification_priority(Priority.LOW);
+ }
+
+ private static int get_test_variable(string name) {
+ string value = Environment.get_variable(name);
+
+ return (value == null || value.length == 0) ? 0 : int.parse(value);
+ }
+
+ public override void execute() {
+ Timer timer = new Timer();
+
+ Gee.ArrayList<PreparedFile> list = new Gee.ArrayList<PreparedFile>();
+ foreach (FileToPrepare file_to_prepare in files_to_prepare) {
+ ImportResult result = abort_check();
+ if (result != ImportResult.SUCCESS) {
+ report_failure(file_to_prepare.job, null, file_to_prepare.job.get_dest_identifier(),
+ file_to_prepare.job.get_source_identifier(), result);
+
+ continue;
+ }
+
+ BatchImportJob job = file_to_prepare.job;
+ File? file = file_to_prepare.file;
+ File? associated = file_to_prepare.associated != null ? file_to_prepare.associated.file : null;
+ bool copy_to_library = file_to_prepare.copy_to_library;
+
+ // if no file seen, then it needs to be offered/generated by the BatchImportJob
+ if (file == null) {
+ if (!create_file(job, out file, out copy_to_library))
+ continue;
+ }
+
+ if (associated == null && file_to_prepare.associated != null) {
+ create_file(file_to_prepare.associated.job, out associated, out copy_to_library);
+ }
+
+ PreparedFile prepared_file;
+ result = prepare_file(job, file, associated, copy_to_library, out prepared_file);
+ if (result == ImportResult.SUCCESS) {
+ prepared_files++;
+ list.add(prepared_file);
+ } else {
+ report_failure(job, file, job.get_source_identifier(), file.get_path(),
+ result);
+ }
+
+ if (list.size >= BatchImport.REPORT_EVERY_N_PREPARED_FILES
+ || ((timer.elapsed() * 1000.0) > BatchImport.REPORT_PREPARED_FILES_EVERY_N_MSEC && list.size > 0)) {
+#if TRACE_IMPORT
+ debug("Notifying that %d prepared files are ready", list.size);
+#endif
+ PreparedFileCluster cluster = new PreparedFileCluster(list);
+ list = new Gee.ArrayList<PreparedFile>();
+ notify(notification, cluster);
+ timer.start();
+ }
+ }
+
+ if (list.size > 0) {
+ ImportResult result = abort_check();
+ if (result == ImportResult.SUCCESS) {
+ notify(notification, new PreparedFileCluster(list));
+ } else {
+ // subtract these, as they are not being submitted
+ assert(prepared_files >= list.size);
+ prepared_files -= list.size;
+
+ foreach (PreparedFile prepared_file in list) {
+ report_failure(prepared_file.job, prepared_file.file,
+ prepared_file.job.get_source_identifier(), prepared_file.file.get_path(),
+ result);
+ }
+ }
+ }
+ }
+
+ // If there's no file, call this function to get it from the batch import job.
+ private bool create_file(BatchImportJob job, out File file, out bool copy_to_library) {
+ try {
+ if (!job.prepare(out file, out copy_to_library)) {
+ report_failure(job, null, job.get_source_identifier(),
+ job.get_dest_identifier(), ImportResult.FILE_ERROR);
+
+ return false;
+ }
+ } catch (Error err) {
+ report_error(job, null, job.get_source_identifier(), job.get_dest_identifier(),
+ err, ImportResult.FILE_ERROR);
+
+ return false;
+ }
+ return true;
+ }
+
+ private ImportResult prepare_file(BatchImportJob job, File file, File? associated_file,
+ bool copy_to_library, out PreparedFile prepared_file) {
+ prepared_file = null;
+
+ bool is_video = VideoReader.is_supported_video_file(file);
+
+ if ((!is_video) && (!Photo.is_file_image(file)))
+ return ImportResult.NOT_AN_IMAGE;
+
+ if ((!is_video) && (!PhotoFileFormat.is_file_supported(file)))
+ return ImportResult.UNSUPPORTED_FORMAT;
+
+ import_file_count++;
+
+ // test case (can be set with SHOTWELL_FAIL_EVERY environment variable)
+ if (fail_every > 0) {
+ if (import_file_count % fail_every == 0)
+ return ImportResult.FILE_ERROR;
+ }
+
+ // test case (can be set with SHOTWELL_SKIP_EVERY environment variable)
+ if (skip_every > 0) {
+ if (import_file_count % skip_every == 0)
+ return ImportResult.NOT_A_FILE;
+ }
+
+ string exif_only_md5 = null;
+ string thumbnail_md5 = null;
+ string full_md5 = null;
+
+ try {
+ full_md5 = md5_file(file);
+#if TRACE_MD5
+ debug("import MD5 for file %s = %s", file.get_path(), full_md5);
+#endif
+ } catch (Error err) {
+ warning("Unable to perform MD5 checksum on file %s: %s", file.get_path(),
+ err.message);
+
+ return ImportResult.convert_error(err, ImportResult.FILE_ERROR);
+ }
+
+ // we only care about file extensions and metadata if we're importing a photo --
+ // we don't care about these things for video
+ PhotoFileFormat file_format = PhotoFileFormat.get_by_file_extension(file);
+ if (!is_video) {
+ if (file_format == PhotoFileFormat.UNKNOWN) {
+ warning("Skipping %s: unrecognized file extension", file.get_path());
+
+ return ImportResult.UNSUPPORTED_FORMAT;
+ }
+ PhotoFileReader reader = file_format.create_reader(file.get_path());
+ PhotoMetadata? metadata = null;
+ try {
+ metadata = reader.read_metadata();
+ } catch (Error err) {
+ warning("Unable to read metadata for %s (%s): continuing to attempt import",
+ file.get_path(), err.message);
+ }
+
+ if (metadata != null) {
+ uint8[]? flattened_sans_thumbnail = metadata.flatten_exif(false);
+ if (flattened_sans_thumbnail != null && flattened_sans_thumbnail.length > 0)
+ exif_only_md5 = md5_binary(flattened_sans_thumbnail, flattened_sans_thumbnail.length);
+
+ uint8[]? flattened_thumbnail = metadata.flatten_exif_preview();
+ if (flattened_thumbnail != null && flattened_thumbnail.length > 0)
+ thumbnail_md5 = md5_binary(flattened_thumbnail, flattened_thumbnail.length);
+ }
+ }
+
+ uint64 filesize = 0;
+ try {
+ filesize = query_total_file_size(file, get_cancellable());
+ } catch (Error err) {
+ warning("Unable to query file size of %s: %s", file.get_path(), err.message);
+
+ return ImportResult.convert_error(err, ImportResult.FILE_ERROR);
+ }
+
+ // never copy file if already in library directory
+ bool is_in_library_dir = file.has_prefix(library_dir);
+
+ // notify the BatchImport this is ready to go
+ prepared_file = new PreparedFile(job, file, associated_file, job.get_source_identifier(),
+ job.get_dest_identifier(), copy_to_library && !is_in_library_dir, exif_only_md5,
+ thumbnail_md5, full_md5, file_format, filesize, is_video);
+
+ return ImportResult.SUCCESS;
+ }
+}
+
+private class ReadyForImport {
+ public File final_file;
+ public PreparedFile prepared_file;
+ public PhotoImportParams? photo_import_params;
+ public VideoImportParams? video_import_params;
+ public BatchImportResult batch_result;
+ public bool is_video;
+
+ public ReadyForImport(File final_file, PreparedFile prepared_file,
+ PhotoImportParams? photo_import_params, VideoImportParams? video_import_params,
+ BatchImportResult batch_result) {
+ if (prepared_file.is_video)
+ assert((video_import_params != null) && (photo_import_params == null));
+ else
+ assert((video_import_params == null) && (photo_import_params != null));
+
+ this.final_file = final_file;
+ this.prepared_file = prepared_file;
+ this.batch_result = batch_result;
+ this.video_import_params = video_import_params;
+ this.photo_import_params = photo_import_params;
+ this.is_video = prepared_file.is_video;
+ }
+
+ public BatchImportResult abort() {
+ // if file copied, delete it
+ if (final_file != null && final_file != prepared_file.file) {
+ debug("Deleting aborted import copy %s", final_file.get_path());
+ try {
+ final_file.delete(null);
+ } catch (Error err) {
+ warning("Unable to delete copy of imported file (aborted import) %s: %s",
+ final_file.get_path(), err.message);
+ }
+ }
+
+ batch_result = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.job.get_source_identifier(), prepared_file.job.get_dest_identifier(),
+ null, ImportResult.USER_ABORT);
+
+ return batch_result;
+ }
+
+ public Thumbnails get_thumbnails() {
+ return (photo_import_params != null) ? photo_import_params.thumbnails :
+ video_import_params.thumbnails;
+ }
+}
+
+private class PreparedFileImportJob : BackgroundJob {
+ public PreparedFile? not_ready;
+ public ReadyForImport? ready = null;
+ public BatchImportResult? failed = null;
+
+ private ImportID import_id;
+
+ public PreparedFileImportJob(BatchImport owner, PreparedFile prepared_file, ImportID import_id,
+ CompletionCallback callback, Cancellable cancellable, CancellationCallback cancellation) {
+ base (owner, callback, cancellable, cancellation);
+
+ this.import_id = import_id;
+ not_ready = prepared_file;
+
+ set_completion_priority(Priority.LOW);
+ }
+
+ public override void execute() {
+ PreparedFile prepared_file = not_ready;
+ not_ready = null;
+
+ File final_file = prepared_file.file;
+ File? final_associated_file = prepared_file.associated_file;
+
+ if (prepared_file.copy_to_library) {
+ try {
+ // Copy file.
+ final_file = LibraryFiles.duplicate(prepared_file.file, null, true);
+ if (final_file == null) {
+ failed = new BatchImportResult(prepared_file.job, prepared_file.file,
+ prepared_file.file.get_path(), prepared_file.file.get_path(), null,
+ ImportResult.FILE_ERROR);
+
+ return;
+ }
+
+ // Copy associated file.
+ if (final_associated_file != null) {
+ final_associated_file = LibraryFiles.duplicate(prepared_file.associated_file, null, true);
+ }
+ } catch (Error err) {
+ string filename = final_file != null ? final_file.get_path() : prepared_file.source_id;
+ failed = new BatchImportResult.from_error(prepared_file.job, prepared_file.file,
+ filename, filename, err, ImportResult.FILE_ERROR);
+
+ return;
+ }
+ }
+
+ debug("Importing %s", final_file.get_path());
+
+ ImportResult result = ImportResult.SUCCESS;
+ VideoImportParams? video_import_params = null;
+ PhotoImportParams? photo_import_params = null;
+ if (prepared_file.is_video) {
+ video_import_params = new VideoImportParams(final_file, import_id,
+ prepared_file.full_md5, new Thumbnails(),
+ prepared_file.job.get_exposure_time_override());
+
+ result = VideoReader.prepare_for_import(video_import_params);
+ } else {
+ photo_import_params = new PhotoImportParams(final_file, final_associated_file, import_id,
+ PhotoFileSniffer.Options.GET_ALL, prepared_file.exif_md5,
+ prepared_file.thumbnail_md5, prepared_file.full_md5, new Thumbnails());
+
+ result = Photo.prepare_for_import(photo_import_params);
+ }
+
+ if (result != ImportResult.SUCCESS && final_file != prepared_file.file) {
+ debug("Deleting failed imported copy %s", final_file.get_path());
+ try {
+ final_file.delete(null);
+ } catch (Error err) {
+ // don't let this file error cause a failure
+ warning("Unable to delete copy of imported file %s: %s", final_file.get_path(),
+ err.message);
+ }
+ }
+
+ BatchImportResult batch_result = new BatchImportResult(prepared_file.job, final_file,
+ final_file.get_path(), final_file.get_path(), null, result);
+ if (batch_result.result != ImportResult.SUCCESS)
+ failed = batch_result;
+ else
+ ready = new ReadyForImport(final_file, prepared_file, photo_import_params,
+ video_import_params, batch_result);
+ }
+}
+
+private class CompletedImportObject {
+ public Thumbnails? thumbnails;
+ public BatchImportResult batch_result;
+ public MediaSource source;
+ public BatchImportJob original_job;
+ public Gdk.Pixbuf user_preview;
+
+ public CompletedImportObject(MediaSource source, Thumbnails thumbnails,
+ BatchImportJob original_job, BatchImportResult import_result) {
+ this.thumbnails = thumbnails;
+ this.batch_result = import_result;
+ this.source = source;
+ this.original_job = original_job;
+ user_preview = thumbnails.get(ThumbnailCache.Size.LARGEST);
+ }
+}
+
+private class ThumbnailWriterJob : BackgroundImportJob {
+ public CompletedImportObject completed_import_source;
+
+ public ThumbnailWriterJob(BatchImport owner, CompletedImportObject completed_import_source,
+ CompletionCallback callback, Cancellable cancellable, CancellationCallback cancel_callback) {
+ base (owner, callback, cancellable, cancel_callback);
+
+ assert(completed_import_source.thumbnails != null);
+ this.completed_import_source = completed_import_source;
+
+ set_completion_priority(Priority.LOW);
+ }
+
+ public override void execute() {
+ try {
+ ThumbnailCache.import_thumbnails(completed_import_source.source,
+ completed_import_source.thumbnails, true);
+ completed_import_source.batch_result.result = ImportResult.SUCCESS;
+ } catch (Error err) {
+ completed_import_source.batch_result.result = ImportResult.convert_error(err,
+ ImportResult.FILE_ERROR);
+ }
+
+ // destroy the thumbnails (but not the user preview) to free up memory
+ completed_import_source.thumbnails = null;
+ }
+}
+
diff --git a/src/Box.vala b/src/Box.vala
new file mode 100644
index 0000000..f48bcfb
--- /dev/null
+++ b/src/Box.vala
@@ -0,0 +1,403 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public enum BoxLocation {
+ OUTSIDE,
+ INSIDE,
+ TOP_SIDE,
+ LEFT_SIDE,
+ RIGHT_SIDE,
+ BOTTOM_SIDE,
+ TOP_LEFT,
+ BOTTOM_LEFT,
+ TOP_RIGHT,
+ BOTTOM_RIGHT
+}
+
+public enum BoxComplements {
+ NONE,
+ VERTICAL,
+ HORIZONTAL,
+ BOTH;
+
+ public static BoxComplements derive(bool horizontal_complement, bool vertical_complement) {
+ if (horizontal_complement && vertical_complement)
+ return BOTH;
+ else if(horizontal_complement)
+ return HORIZONTAL;
+ else if (vertical_complement)
+ return VERTICAL;
+
+ return NONE;
+ }
+}
+
+public struct Box {
+ public static const int HAND_GRENADES = 12;
+
+ public int left;
+ public int top;
+ public int right;
+ public int bottom;
+
+ public Box(int left = 0, int top = 0, int right = 0, int bottom = 0) {
+ // Sanity check on top left vertex.
+ left = left.clamp(0, int.MAX);
+ top = top.clamp(0, int.MAX);
+
+ // Sanity check on dimensions - force
+ // box to be at least 1 px by 1 px.
+ if (right <= left)
+ right = left + 1;
+
+ if (bottom <= top)
+ bottom = top + 1;
+
+ this.left = left;
+ this.top = top;
+ this.right = right;
+ this.bottom = bottom;
+ }
+
+ public static Box from_rectangle(Gdk.Rectangle rect) {
+ return Box(rect.x, rect.y, rect.x + rect.width - 1, rect.y + rect.height - 1);
+ }
+
+ public static Box from_allocation(Gtk.Allocation alloc) {
+ return Box(alloc.x, alloc.y, alloc.x + alloc.width - 1, alloc.y + alloc.height - 1);
+ }
+
+ // This ensures a proper box is built from the points supplied, no matter the relationship
+ // between the two points
+ public static Box from_points(Gdk.Point corner1, Gdk.Point corner2) {
+ return Box(int.min(corner1.x, corner2.x), int.min(corner1.y, corner2.y),
+ int.max(corner1.x, corner2.x), int.max(corner1.y, corner2.y));
+ }
+
+ public static Box from_center(Gdk.Point center, int width, int height) {
+ return Box(center.x - (width / 2), center.y - (height / 2),
+ center.x + (width / 2), center.y + (height / 2));
+ }
+
+ public int get_width() {
+ assert(right >= left);
+
+ return right - left + 1;
+ }
+
+ public int get_height() {
+ assert(bottom >= top);
+
+ return bottom - top + 1;
+ }
+
+ public bool is_valid() {
+ return (left >= 0) && (top >= 0) && (right >= left) && (bottom >= top);
+ }
+
+ public bool equals(Box box) {
+ return (left == box.left && top == box.top && right == box.right && bottom == box.bottom);
+ }
+
+ // Adjust width, preserving the box's center.
+ public void adjust_width(int width) {
+ int center_x = (left + right) / 2;
+ left = center_x - (width / 2);
+ right = center_x + (width / 2);
+ }
+
+ // Adjust height, preserving the box's center.
+ public void adjust_height(int height) {
+ int center_y = (top + bottom) / 2;
+ top = center_y - (height / 2);
+ bottom = center_y + (height / 2);
+ }
+
+ public Box get_scaled(Dimensions scaled) {
+ double x_scale, y_scale;
+ get_dimensions().get_scale_ratios(scaled, out x_scale, out y_scale);
+
+ int l = (int) Math.round((double) left * x_scale);
+ int t = (int) Math.round((double) top * y_scale);
+
+ // fix-up to match the scaled dimensions
+ int r = l + scaled.width - 1;
+ int b = t + scaled.height - 1;
+
+ Box box = Box(l, t, r, b);
+ assert(box.get_width() == scaled.width || box.get_height() == scaled.height);
+
+ return box;
+ }
+
+ public Box get_scaled_similar(Dimensions original, Dimensions scaled) {
+ double x_scale, y_scale;
+ original.get_scale_ratios(scaled, out x_scale, out y_scale);
+
+ int l = (int) Math.round((double) left * x_scale);
+ int t = (int) Math.round((double) top * y_scale);
+ int r = (int) Math.round((double) right * x_scale);
+ int b = (int) Math.round((double) bottom * y_scale);
+
+ // catch rounding errors
+ if (r >= scaled.width)
+ r = scaled.width - 1;
+
+ if (b >= scaled.height)
+ b = scaled.height - 1;
+
+ return Box(l, t, r, b);
+ }
+
+ public Box get_offset(int xofs, int yofs) {
+ return Box(left + xofs, top + yofs, right + xofs, bottom + yofs);
+ }
+
+ public Dimensions get_dimensions() {
+ return Dimensions(get_width(), get_height());
+ }
+
+ public void get_points(out Gdk.Point top_left, out Gdk.Point bottom_right) {
+ top_left = { left, top };
+ bottom_right = { right, bottom };
+ }
+
+ public Gdk.Rectangle get_rectangle() {
+ Gdk.Rectangle rect = Gdk.Rectangle();
+ rect.x = left;
+ rect.y = top;
+ rect.width = get_width();
+ rect.height = get_height();
+
+ return rect;
+ }
+
+ public Gdk.Point get_center() {
+ return { (left + right) / 2, (top + bottom) / 2 };
+ }
+
+ public Box rotate_clockwise(Dimensions space) {
+ int l = space.width - bottom - 1;
+ int t = left;
+ int r = space.width - top - 1;
+ int b = right;
+
+ return Box(l, t, r, b);
+ }
+
+ public Box rotate_counterclockwise(Dimensions space) {
+ int l = top;
+ int t = space.height - right - 1;
+ int r = bottom;
+ int b = space.height - left - 1;
+
+ return Box(l, t, r, b);
+ }
+
+ public Box flip_left_to_right(Dimensions space) {
+ int l = space.width - right - 1;
+ int r = space.width - left - 1;
+
+ return Box(l, top, r, bottom);
+ }
+
+ public Box flip_top_to_bottom(Dimensions space) {
+ int t = space.height - bottom - 1;
+ int b = space.height - top - 1;
+
+ return Box(left, t, right, b);
+ }
+
+ public bool intersects(Box compare) {
+ int left_intersect = int.max(left, compare.left);
+ int top_intersect = int.max(top, compare.top);
+ int right_intersect = int.min(right, compare.right);
+ int bottom_intersect = int.min(bottom, compare.bottom);
+
+ return (right_intersect >= left_intersect && bottom_intersect >= top_intersect);
+ }
+
+ public Box get_reduced(int amount) {
+ return Box(left + amount, top + amount, right - amount, bottom - amount);
+ }
+
+ public Box get_expanded(int amount) {
+ return Box(left - amount, top - amount, right + amount, bottom + amount);
+ }
+
+ public bool contains(Gdk.Point point) {
+ return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
+ }
+
+ // This specialized method is only concerned with resized comparisons between two Boxes,
+ // of which one is altered in up to two dimensions: (top or bottom) and/or (left or right).
+ // There may be overlap between the two returned Boxes.
+ public BoxComplements resized_complements(Box resized, out Box horizontal, out bool horizontal_enlarged,
+ out Box vertical, out bool vertical_enlarged) {
+
+ bool horizontal_complement = true;
+ if (resized.top < top) {
+ // enlarged from top
+ horizontal = Box(resized.left, resized.top, resized.right, top);
+ horizontal_enlarged = true;
+ } else if (resized.top > top) {
+ // shrunk from top
+ horizontal = Box(left, top, right, resized.top);
+ horizontal_enlarged = false;
+ } else if (resized.bottom < bottom) {
+ // shrunk from bottom
+ horizontal = Box(left, resized.bottom, right, bottom);
+ horizontal_enlarged = false;
+ } else if (resized.bottom > bottom) {
+ // enlarged from bottom
+ horizontal = Box(resized.left, bottom, resized.right, resized.bottom);
+ horizontal_enlarged = true;
+ } else {
+ horizontal = Box();
+ horizontal_enlarged = false;
+ horizontal_complement = false;
+ }
+
+ bool vertical_complement = true;
+ if (resized.left < left) {
+ // enlarged left
+ vertical = Box(resized.left, resized.top, left, resized.bottom);
+ vertical_enlarged = true;
+ } else if (resized.left > left) {
+ // shrunk left
+ vertical = Box(left, top, resized.left, bottom);
+ vertical_enlarged = false;
+ } else if (resized.right < right) {
+ // shrunk right
+ vertical = Box(resized.right, top, right, bottom);
+ vertical_enlarged = false;
+ } else if (resized.right > right) {
+ // enlarged right
+ vertical = Box(right, resized.top, resized.right, resized.bottom);
+ vertical_enlarged = true;
+ } else {
+ vertical = Box();
+ vertical_enlarged = false;
+ vertical_complement = false;
+ }
+
+ return BoxComplements.derive(horizontal_complement, vertical_complement);
+ }
+
+ // This specialized method is only concerned with the complements of identical Boxes in two
+ // different, spatial locations. There may be overlap between the four returned Boxes. However,
+ // no portion of any of the four boxes will be outside the scope of the two compared boxes.
+ public BoxComplements shifted_complements(Box shifted, out Box horizontal_this,
+ out Box vertical_this, out Box horizontal_shifted, out Box vertical_shifted) {
+ assert(get_width() == shifted.get_width());
+ assert(get_height() == shifted.get_height());
+
+ bool horizontal_complement = true;
+ if (shifted.top < top && shifted.bottom > top) {
+ // shifted up
+ horizontal_this = Box(left, shifted.bottom, right, bottom);
+ horizontal_shifted = Box(shifted.left, shifted.top, shifted.right, top);
+ } else if (shifted.top > top && shifted.top < bottom) {
+ // shifted down
+ horizontal_this = Box(left, top, right, shifted.top);
+ horizontal_shifted = Box(shifted.left, bottom, shifted.right, shifted.bottom);
+ } else {
+ // no vertical shift
+ horizontal_this = Box();
+ horizontal_shifted = Box();
+ horizontal_complement = false;
+ }
+
+ bool vertical_complement = true;
+ if (shifted.left < left && shifted.right > left) {
+ // shifted left
+ vertical_this = Box(shifted.right, top, right, bottom);
+ vertical_shifted = Box(shifted.left, shifted.top, left, shifted.bottom);
+ } else if (shifted.left > left && shifted.left < right) {
+ // shifted right
+ vertical_this = Box(left, top, shifted.left, bottom);
+ vertical_shifted = Box(right, shifted.top, shifted.right, shifted.bottom);
+ } else {
+ // no horizontal shift
+ vertical_this = Box();
+ vertical_shifted = Box();
+ vertical_complement = false;
+ }
+
+ return BoxComplements.derive(horizontal_complement, vertical_complement);
+ }
+
+ public Box rubber_band(Gdk.Point point) {
+ assert(point.x >= 0);
+ assert(point.y >= 0);
+
+ int t = int.min(top, point.y);
+ int b = int.max(bottom, point.y);
+ int l = int.min(left, point.x);
+ int r = int.max(right, point.x);
+
+ return Box(l, t, r, b);
+ }
+
+ public string to_string() {
+ return "%d,%d %d,%d (%s)".printf(left, top, right, bottom, get_dimensions().to_string());
+ }
+
+ private static bool in_zone(double pos, int zone) {
+ int top_zone = zone - HAND_GRENADES;
+ int bottom_zone = zone + HAND_GRENADES;
+
+ return in_between(pos, top_zone, bottom_zone);
+ }
+
+ private static bool in_between(double pos, int top, int bottom) {
+ int ipos = (int) pos;
+
+ return (ipos > top) && (ipos < bottom);
+ }
+
+ private static bool near_in_between(double pos, int top, int bottom) {
+ int ipos = (int) pos;
+ int top_zone = top - HAND_GRENADES;
+ int bottom_zone = bottom + HAND_GRENADES;
+
+ return (ipos > top_zone) && (ipos < bottom_zone);
+ }
+
+ public BoxLocation approx_location(int x, int y) {
+ bool near_width = near_in_between(x, left, right);
+ bool near_height = near_in_between(y, top, bottom);
+
+ if (in_zone(x, left) && near_height) {
+ if (in_zone(y, top)) {
+ return BoxLocation.TOP_LEFT;
+ } else if (in_zone(y, bottom)) {
+ return BoxLocation.BOTTOM_LEFT;
+ } else {
+ return BoxLocation.LEFT_SIDE;
+ }
+ } else if (in_zone(x, right) && near_height) {
+ if (in_zone(y, top)) {
+ return BoxLocation.TOP_RIGHT;
+ } else if (in_zone(y, bottom)) {
+ return BoxLocation.BOTTOM_RIGHT;
+ } else {
+ return BoxLocation.RIGHT_SIDE;
+ }
+ } else if (in_zone(y, top) && near_width) {
+ // if left or right was in zone, already caught top left & top right
+ return BoxLocation.TOP_SIDE;
+ } else if (in_zone(y, bottom) && near_width) {
+ // if left or right was in zone, already caught bottom left & bottom right
+ return BoxLocation.BOTTOM_SIDE;
+ } else if (in_between(x, left, right) && in_between(y, top, bottom)) {
+ return BoxLocation.INSIDE;
+ } else {
+ return BoxLocation.OUTSIDE;
+ }
+ }
+}
+
diff --git a/src/CheckerboardLayout.vala b/src/CheckerboardLayout.vala
new file mode 100644
index 0000000..398152e
--- /dev/null
+++ b/src/CheckerboardLayout.vala
@@ -0,0 +1,1872 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+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 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;
+
+ public 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 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 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 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();
+ }
+
+ 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) {
+ if (pixbuf.get_has_alpha()) {
+ ctx.rectangle(origin.x, origin.y, pixbuf.get_width(), pixbuf.get_height());
+ ctx.fill();
+ }
+ Gdk.cairo_set_source_pixbuf(ctx, pixbuf, origin.x, origin.y);
+ ctx.paint();
+ }
+
+ private int get_selection_border_width(int scale) {
+ return ((scale <= ((Thumbnail.MIN_SCALE + Thumbnail.MAX_SCALE) / 3)) ? 2 : 3)
+ + 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(Cairo.Context ctx, Gdk.RGBA bg_color, Gdk.RGBA selected_color,
+ Gdk.RGBA text_color, Gdk.RGBA? border_color) {
+ // calc the top-left point of the pixbuf
+ Gdk.Point pixbuf_origin = Gdk.Point();
+ pixbuf_origin.x = allocation.x + FRAME_WIDTH + BORDER_WIDTH;
+ pixbuf_origin.y = allocation.y + FRAME_WIDTH + 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 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();
+ }
+
+ // draw border
+ if (border_color != null) {
+ ctx.save();
+ ctx.set_source_rgba(border_color.red, border_color.green, border_color.blue,
+ border_color.alpha);
+ paint_border(ctx, pixbuf_dim, pixbuf_origin, BORDER_WIDTH);
+ 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();
+ }
+
+ ctx.set_source_rgba(text_color.red, text_color.green, text_color.blue, text_color.alpha);
+
+ // title and subtitles are LABEL_PADDING below bottom of pixbuf
+ int text_y = allocation.y + FRAME_WIDTH + pixbuf_dim.height + FRAME_WIDTH + LABEL_PADDING;
+ if (title != null && title_visible) {
+ // get the layout sized so its with is no more than the pixbuf's
+ // resize the text width to be no more than the pixbuf's
+ title.allocation.x = allocation.x + FRAME_WIDTH;
+ title.allocation.y = text_y;
+ title.allocation.width = pixbuf_dim.width;
+ title.allocation.height = title.get_height();
+
+ ctx.move_to(title.allocation.x, title.allocation.y);
+ Pango.cairo_show_layout(ctx, title.get_pango_layout(pixbuf_dim.width));
+
+ text_y += title.get_height() + LABEL_PADDING;
+ }
+
+ if (comment != null && comment_visible) {
+ comment.allocation.x = allocation.x + FRAME_WIDTH;
+ comment.allocation.y = text_y;
+ comment.allocation.width = pixbuf_dim.width;
+ comment.allocation.height = comment.get_height();
+
+ ctx.move_to(comment.allocation.x, comment.allocation.y);
+ Pango.cairo_show_layout(ctx, comment.get_pango_layout(pixbuf_dim.width));
+
+ text_y += comment.get_height() + LABEL_PADDING;
+ }
+
+ if (subtitle != null && subtitle_visible) {
+ subtitle.allocation.x = allocation.x + FRAME_WIDTH;
+ subtitle.allocation.y = text_y;
+ subtitle.allocation.width = pixbuf_dim.width;
+ subtitle.allocation.height = subtitle.get_height();
+
+ ctx.move_to(subtitle.allocation.x, subtitle.allocation.y);
+ Pango.cairo_show_layout(ctx, 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();
+ }
+ }
+
+ 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;
+ public const int ROW_GUTTER_PADDING = 24;
+
+ // the following are minimums, as the pads and gutters expand to fill up the window width
+ public const int COLUMN_GUTTER_PADDING = 24;
+
+ // For a 40% alpha channel
+ private const double SELECTION_ALPHA = 0.40;
+
+ // 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
+ private const int SCROLLBAR_PLACEHOLDER_WIDTH = 1;
+
+ private class LayoutRow {
+ public int y;
+ public int height;
+ public CheckerboardItem[] items;
+
+ public LayoutRow(int y, int height, int num_in_row) {
+ this.y = y;
+ this.height = height;
+ this.items = new CheckerboardItem[num_in_row];
+ }
+ }
+
+ private ViewCollection view;
+ private string page_name = "";
+ private LayoutRow[] item_rows = null;
+ 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 border_color;
+ private Gdk.RGBA bg_color;
+ private Gdk.Rectangle visible_page = Gdk.Rectangle();
+ private int last_width = 0;
+ private int columns = 0;
+ private int rows = 0;
+ private Gdk.Point drag_origin = Gdk.Point();
+ private Gdk.Point drag_endpoint = Gdk.Point();
+ private Gdk.Rectangle selection_band = Gdk.Rectangle();
+ private int scale = 0;
+ private bool flow_scheduled = false;
+ private bool exposure_dirty = true;
+ private CheckerboardItem? anchor = null;
+ private bool in_center_on_anchor = false;
+ private bool size_allocate_due_to_reflow = false;
+ private bool is_in_view = false;
+ private bool reflow_needed = false;
+
+ public CheckerboardLayout(ViewCollection view) {
+ this.view = view;
+
+ clear_drag_select();
+
+ // subscribe to the new collection
+ view.contents_altered.connect(on_contents_altered);
+ view.items_altered.connect(on_items_altered);
+ view.items_state_changed.connect(on_items_state_changed);
+ view.items_visibility_changed.connect(on_items_visibility_changed);
+ view.ordering_changed.connect(on_ordering_changed);
+ view.views_altered.connect(on_views_altered);
+ view.geometries_altered.connect(on_geometries_altered);
+ view.items_selected.connect(on_items_selection_changed);
+ view.items_unselected.connect(on_items_selection_changed);
+
+ override_background_color(Gtk.StateFlags.NORMAL, Config.Facade.get_instance().get_bg_color());
+
+ Config.Facade.get_instance().colors_changed.connect(on_colors_changed);
+
+ // CheckerboardItems offer tooltips
+ has_tooltip = true;
+ }
+
+ ~CheckerboardLayout() {
+#if TRACE_DTORS
+ debug("DTOR: CheckerboardLayout for %s", view.to_string());
+#endif
+
+ view.contents_altered.disconnect(on_contents_altered);
+ view.items_altered.disconnect(on_items_altered);
+ view.items_state_changed.disconnect(on_items_state_changed);
+ view.items_visibility_changed.disconnect(on_items_visibility_changed);
+ view.ordering_changed.disconnect(on_ordering_changed);
+ view.views_altered.disconnect(on_views_altered);
+ view.geometries_altered.disconnect(on_geometries_altered);
+ view.items_selected.disconnect(on_items_selection_changed);
+ view.items_unselected.disconnect(on_items_selection_changed);
+
+ if (hadjustment != null)
+ hadjustment.value_changed.disconnect(on_viewport_shifted);
+
+ if (vadjustment != null)
+ vadjustment.value_changed.disconnect(on_viewport_shifted);
+
+ if (parent != null)
+ parent.size_allocate.disconnect(on_viewport_resized);
+
+ Config.Facade.get_instance().colors_changed.disconnect(on_colors_changed);
+ }
+
+ public void set_adjustments(Gtk.Adjustment hadjustment, Gtk.Adjustment vadjustment) {
+ this.hadjustment = hadjustment;
+ this.vadjustment = vadjustment;
+
+ // monitor adjustment changes to report when the visible page shifts
+ hadjustment.value_changed.connect(on_viewport_shifted);
+ vadjustment.value_changed.connect(on_viewport_shifted);
+
+ // monitor parent's size changes for a similar reason
+ parent.size_allocate.connect(on_viewport_resized);
+ }
+
+ // This method allows for some optimizations to occur in reflow() by using the known max.
+ // width of all items in the layout.
+ public void set_scale(int scale) {
+ this.scale = scale;
+ }
+
+ public int get_scale() {
+ return scale;
+ }
+
+ public void set_name(string name) {
+ page_name = name;
+ }
+
+ private void on_viewport_resized() {
+ Gtk.Requisition req;
+ get_preferred_size(null, out req);
+
+ 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
+#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);
+#endif
+ 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);
+ }
+
+ // possible for this widget's size_allocate not to be called, so need to update the page
+ // rect here
+ viewport_resized();
+
+ if (!size_allocate_due_to_reflow)
+ clear_anchor();
+ else
+ size_allocate_due_to_reflow = false;
+ }
+
+ private void on_viewport_shifted() {
+ update_visible_page();
+ need_exposure("on_viewport_shift");
+
+ clear_anchor();
+ }
+
+ private void on_items_selection_changed() {
+ clear_anchor();
+ }
+
+ private void clear_anchor() {
+ if (in_center_on_anchor)
+ return;
+
+ anchor = null;
+ }
+
+ private void update_anchor() {
+ assert(!in_center_on_anchor);
+
+ Gee.List<CheckerboardItem> items_on_page = intersection(visible_page);
+ if (items_on_page.size == 0) {
+ anchor = null;
+ return;
+ }
+
+ foreach (CheckerboardItem item in items_on_page) {
+ if (item.is_selected()) {
+ anchor = item;
+ return;
+ }
+ }
+
+ if (vadjustment.get_value() == 0) {
+ anchor = null;
+ return;
+ }
+
+ // this could be improved to always find the visual center...in the case where only
+ // a few photos are in the last visible row, this can choose a photo near the right
+ anchor = items_on_page.get((int) items_on_page.size / 2);
+ }
+
+ private void center_on_anchor(double upper) {
+ if (anchor == null)
+ return;
+
+ in_center_on_anchor = true;
+
+ double anchor_pos = anchor.allocation.y + (anchor.allocation.height / 2) -
+ (vadjustment.get_page_size() / 2);
+ vadjustment.set_value(anchor_pos.clamp(vadjustment.get_lower(),
+ vadjustment.get_upper() - vadjustment.get_page_size()));
+
+ in_center_on_anchor = false;
+ }
+
+ 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)
+ exposed_items.remove((CheckerboardItem) object);
+ }
+
+ // release spatial data structure ... contents_altered means a reflow is required, and since
+ // items may be removed, this ensures we're not holding the ref on a removed view
+ item_rows = null;
+
+ need_reflow("on_contents_altered");
+ }
+
+ private void on_items_altered() {
+ need_reflow("on_items_altered");
+ }
+
+ private void on_items_state_changed(Gee.Iterable<DataView> changed) {
+ items_dirty("on_items_state_changed", changed);
+ }
+
+ private void on_items_visibility_changed(Gee.Iterable<DataView> changed) {
+ need_reflow("on_items_visibility_changed");
+ }
+
+ private void on_ordering_changed() {
+ need_reflow("on_ordering_changed");
+ }
+
+ private void on_views_altered(Gee.Collection<DataView> altered) {
+ items_dirty("on_views_altered", altered);
+ }
+
+ private void on_geometries_altered() {
+ need_reflow("on_geometries_altered");
+ }
+
+ private void need_reflow(string caller) {
+ if (flow_scheduled)
+ return;
+
+ if (!is_in_view) {
+ reflow_needed = true;
+ return;
+ }
+
+#if TRACE_REFLOW
+ debug("need_reflow %s: %s", page_name, caller);
+#endif
+ flow_scheduled = true;
+ Idle.add_full(Priority.HIGH, do_reflow);
+ }
+
+ private bool do_reflow() {
+ reflow("do_reflow");
+ need_exposure("do_reflow");
+
+ flow_scheduled = false;
+
+ return false;
+ }
+
+ private void need_exposure(string caller) {
+#if TRACE_REFLOW
+ debug("need_exposure %s: %s", page_name, caller);
+#endif
+ exposure_dirty = true;
+ 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);
+ }
+
+ public void set_in_view(bool in_view) {
+ is_in_view = in_view;
+
+ if (in_view) {
+ if (reflow_needed)
+ need_reflow("set_in_view (true)");
+ else
+ need_exposure("set_in_view (true)");
+ } else
+ unexpose_items("set_in_view (false)");
+ }
+
+ public CheckerboardItem? get_item_at_pixel(double xd, double yd) {
+ if (message != null || item_rows == null)
+ return null;
+
+ int x = (int) xd;
+ int y = (int) yd;
+
+ // binary search the rows for the one in range of the pixel
+ LayoutRow in_range = null;
+ int min = 0;
+ int max = item_rows.length;
+ for(;;) {
+ int mid = min + ((max - min) / 2);
+ LayoutRow row = item_rows[mid];
+
+ if (row == null || y < row.y) {
+ // undershot
+ // row == null happens when there is an exact number of elements to fill the last row
+ max = mid - 1;
+ } else if (y > (row.y + row.height)) {
+ // undershot
+ min = mid + 1;
+ } else {
+ // bingo
+ in_range = row;
+
+ break;
+ }
+
+ if (min > max)
+ break;
+ }
+
+ if (in_range == null)
+ return null;
+
+ // look for item in row's column in range of the pixel
+ foreach (CheckerboardItem item in in_range.items) {
+ // this happens on an incompletely filled-in row (usually the last one with empty
+ // space remaining)
+ if (item == null)
+ continue;
+
+ if (x < item.allocation.x) {
+ // overshot ... this happens because there's gaps in the columns
+ break;
+ }
+
+ // need to verify actually over item's full dimensions, since they vary in size inside
+ // a row
+ if (x <= (item.allocation.x + item.allocation.width) && y >= item.allocation.y
+ && y <= (item.allocation.y + item.allocation.height))
+ return item;
+ }
+
+ return null;
+ }
+
+ public Gee.List<CheckerboardItem> get_visible_items() {
+ return intersection(visible_page);
+ }
+
+ public Gee.List<CheckerboardItem> intersection(Gdk.Rectangle area) {
+ Gee.ArrayList<CheckerboardItem> intersects = new Gee.ArrayList<CheckerboardItem>();
+
+ Gtk.Allocation allocation;
+ get_allocation(out allocation);
+
+ Gdk.Rectangle bitbucket = Gdk.Rectangle();
+ foreach (LayoutRow row in item_rows) {
+ if (row == null)
+ continue;
+
+ if ((area.y + area.height) < row.y) {
+ // overshoot
+ break;
+ }
+
+ if ((row.y + row.height) < area.y) {
+ // haven't reached it yet
+ continue;
+ }
+
+ // see if the row intersects the area
+ Gdk.Rectangle row_rect = Gdk.Rectangle();
+ row_rect.x = 0;
+ row_rect.y = row.y;
+ row_rect.width = allocation.width;
+ row_rect.height = row.height;
+
+ if (area.intersect(row_rect, out bitbucket)) {
+ // see what elements, if any, intersect the area
+ foreach (CheckerboardItem item in row.items) {
+ if (item == null)
+ continue;
+
+ if (area.intersect(item.allocation, out bitbucket))
+ intersects.add(item);
+ }
+ }
+ }
+
+ return intersects;
+ }
+
+ public CheckerboardItem? get_item_relative_to(CheckerboardItem item, CompassPoint point) {
+ if (view.get_count() == 0)
+ return null;
+
+ assert(columns > 0);
+ assert(rows > 0);
+
+ int col = item.get_column();
+ int row = item.get_row();
+
+ if (col < 0 || row < 0) {
+ critical("Attempting to locate item not placed in layout: %s", item.get_title());
+
+ return null;
+ }
+
+ switch (point) {
+ case CompassPoint.NORTH:
+ if (--row < 0)
+ row = 0;
+ break;
+
+ case CompassPoint.SOUTH:
+ if (++row >= rows)
+ row = rows - 1;
+ break;
+
+ case CompassPoint.EAST:
+ if (++col >= columns) {
+ if(++row >= rows) {
+ row = rows - 1;
+ col = columns - 1;
+ } else {
+ col = 0;
+ }
+ }
+ break;
+
+ case CompassPoint.WEST:
+ if (--col < 0) {
+ if (--row < 0) {
+ row = 0;
+ col = 0;
+ } else {
+ col = columns - 1;
+ }
+ }
+ break;
+
+ default:
+ error("Bad compass point %d", (int) point);
+ }
+
+ CheckerboardItem? new_item = get_item_at_coordinate(col, row);
+
+ if (new_item == null && point == CompassPoint.SOUTH) {
+ // nothing directly below, get last item on next row
+ new_item = (CheckerboardItem?) view.get_last();
+ if (new_item.get_row() <= item.get_row())
+ new_item = null;
+ }
+
+ return (new_item != null) ? new_item : item;
+ }
+
+ public CheckerboardItem? get_item_at_coordinate(int col, int row) {
+ if (row >= item_rows.length)
+ return null;
+
+ LayoutRow item_row = item_rows[row];
+ if (item_row == null)
+ return null;
+
+ if (col >= item_row.items.length)
+ return null;
+
+ return item_row.items[col];
+ }
+
+ public void set_drag_select_origin(int x, int y) {
+ clear_drag_select();
+
+ Gtk.Allocation allocation;
+ get_allocation(out allocation);
+
+ drag_origin.x = x.clamp(0, allocation.width);
+ drag_origin.y = y.clamp(0, allocation.height);
+ }
+
+ public void set_drag_select_endpoint(int x, int y) {
+ Gtk.Allocation allocation;
+ get_allocation(out allocation);
+
+ drag_endpoint.x = x.clamp(0, allocation.width);
+ drag_endpoint.y = y.clamp(0, allocation.height);
+
+ // drag_origin and drag_endpoint are maintained only to generate selection_band; all reporting
+ // and drawing functions refer to it, not drag_origin and drag_endpoint
+ Gdk.Rectangle old_selection_band = selection_band;
+ selection_band = Box.from_points(drag_origin, drag_endpoint).get_rectangle();
+
+ // force repaint of the union of the old and new, which covers the band reducing in size
+ if (get_window() != null) {
+ Gdk.Rectangle union;
+ selection_band.union(old_selection_band, out union);
+
+ queue_draw_area(union.x, union.y, union.width, union.height);
+ }
+ }
+
+ public Gee.List<CheckerboardItem>? items_in_selection_band() {
+ if (!Dimensions.for_rectangle(selection_band).has_area())
+ return null;
+
+ return intersection(selection_band);
+ }
+
+ public bool is_drag_select_active() {
+ return drag_origin.x >= 0 && drag_origin.y >= 0;
+ }
+
+ public void clear_drag_select() {
+ selection_band = Gdk.Rectangle();
+ drag_origin.x = -1;
+ drag_origin.y = -1;
+ drag_endpoint.x = -1;
+ drag_endpoint.y = -1;
+
+ // force a total repaint to clear the selection band
+ queue_draw();
+ }
+
+ private void viewport_resized() {
+ // update visible page rect
+ update_visible_page();
+
+ // only reflow() if the width has changed
+ if (visible_page.width != last_width) {
+ int old_width = last_width;
+ last_width = visible_page.width;
+
+ need_reflow("viewport_resized (%d -> %d)".printf(old_width, visible_page.width));
+ } else {
+ // don't need to reflow but exposure may have changed
+ need_exposure("viewport_resized (same width=%d)".printf(last_width));
+ }
+ }
+
+ private void expose_items(string caller) {
+ // create a new hash set of exposed items that represents an intersection of the old set
+ // and the new
+ Gee.HashSet<CheckerboardItem> new_exposed_items = new Gee.HashSet<CheckerboardItem>();
+
+ view.freeze_notifications();
+
+ Gee.List<CheckerboardItem> items = get_visible_items();
+ foreach (CheckerboardItem item in items) {
+ new_exposed_items.add(item);
+
+ // if not in the old list, then need to expose
+ if (!exposed_items.remove(item))
+ item.exposed();
+ }
+
+ // everything remaining in the old exposed list is now unexposed
+ foreach (CheckerboardItem item in exposed_items)
+ item.unexposed();
+
+ // swap out lists
+ exposed_items = new_exposed_items;
+ exposure_dirty = false;
+
+#if TRACE_REFLOW
+ debug("expose_items %s: exposed %d items, thawing", page_name, exposed_items.size);
+#endif
+ view.thaw_notifications();
+#if TRACE_REFLOW
+ debug("expose_items %s: thaw finished", page_name);
+#endif
+ }
+
+ private void unexpose_items(string caller) {
+ view.freeze_notifications();
+
+ foreach (CheckerboardItem item in exposed_items)
+ item.unexposed();
+
+ exposed_items.clear();
+ exposure_dirty = false;
+
+#if TRACE_REFLOW
+ debug("unexpose_items %s: thawing", page_name);
+#endif
+ view.thaw_notifications();
+#if TRACE_REFLOW
+ debug("unexpose_items %s: thawed", page_name);
+#endif
+ }
+
+ 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);
+
+ int visible_width = (visible_page.width > 0) ? visible_page.width : allocation.width;
+
+#if TRACE_REFLOW
+ debug("reflow: Using visible page width of %d (allocated: %d)", visible_width,
+ allocation.width);
+#endif
+
+ // don't bother until layout is of some appreciable size (even this is too low)
+ if (visible_width <= 1)
+ return;
+
+ int total_items = view.get_count();
+
+ // need to set_size in case all items were removed and the viewport size has changed
+ if (total_items == 0) {
+ set_size_request(visible_width, 0);
+ item_rows = new LayoutRow[0];
+
+ return;
+ }
+
+#if TRACE_REFLOW
+ debug("reflow %s: %s (%d items)", page_name, caller, total_items);
+#endif
+
+ // look for anchor if there is none currently
+ if (anchor == null || !anchor.is_visible())
+ update_anchor();
+
+ // clear the rows data structure, as the reflow will completely rearrange it
+ item_rows = null;
+
+ // Step 1: Determine the widest row in the layout, and from it the number of columns.
+ // If owner supplies an image scaling for all items in the layout, then this can be
+ // calculated quickly.
+ int max_cols = 0;
+ if (scale > 0) {
+ // calculate interior width
+ int remaining_width = visible_width - (COLUMN_GUTTER_PADDING * 2);
+ int max_item_width = CheckerboardItem.get_max_width(scale);
+ max_cols = remaining_width / max_item_width;
+ if (max_cols <= 0)
+ max_cols = 1;
+
+ // if too large with gutters, decrease until columns fit
+ while (max_cols > 1
+ && ((max_cols * max_item_width) + ((max_cols - 1) * COLUMN_GUTTER_PADDING) > remaining_width)) {
+#if TRACE_REFLOW
+ debug("reflow %s: scaled cols estimate: reducing max_cols from %d to %d", page_name,
+ max_cols, max_cols - 1);
+#endif
+ max_cols--;
+ }
+
+ // special case: if fewer items than columns, they are the columns
+ if (total_items < max_cols)
+ max_cols = total_items;
+
+#if TRACE_REFLOW
+ debug("reflow %s: scaled cols estimate: max_cols=%d remaining_width=%d max_item_width=%d",
+ page_name, max_cols, remaining_width, max_item_width);
+#endif
+ } else {
+ int x = COLUMN_GUTTER_PADDING;
+ int col = 0;
+ int row_width = 0;
+ int widest_row = 0;
+
+ for (int ctr = 0; ctr < total_items; ctr++) {
+ CheckerboardItem item = (CheckerboardItem) view.get_at(ctr);
+ Dimensions req = item.requisition;
+
+ // the items must be requisitioned for this code to work
+ assert(req.has_area());
+
+ // carriage return (i.e. this item will overflow the view)
+ if ((x + req.width + COLUMN_GUTTER_PADDING) > visible_width) {
+ if (row_width > widest_row) {
+ widest_row = row_width;
+ max_cols = col;
+ }
+
+ col = 0;
+ x = COLUMN_GUTTER_PADDING;
+ row_width = 0;
+ }
+
+ x += req.width + COLUMN_GUTTER_PADDING;
+ row_width += req.width;
+
+ col++;
+ }
+
+ // account for dangling last row
+ if (row_width > widest_row)
+ max_cols = col;
+
+#if TRACE_REFLOW
+ debug("reflow %s: manual cols estimate: max_cols=%d widest_row=%d", page_name, max_cols,
+ widest_row);
+#endif
+ }
+
+ assert(max_cols > 0);
+ int max_rows = (total_items / max_cols) + 1;
+
+ // Step 2: Now that the number of columns is known, find the maximum height for each row
+ // and the maximum width for each column
+ int row = 0;
+ int tallest = 0;
+ int widest = 0;
+ int row_alignment_point = 0;
+ int total_width = 0;
+ int col = 0;
+ int[] column_widths = new int[max_cols];
+ int[] row_heights = new int[max_rows];
+ int[] alignment_points = new int[max_rows];
+ int gutter = 0;
+
+ for (;;) {
+ for (int ctr = 0; ctr < total_items; ctr++ ) {
+ CheckerboardItem item = (CheckerboardItem) view.get_at(ctr);
+ Dimensions req = item.requisition;
+ int alignment_point = item.get_alignment_point();
+
+ // alignment point better be sane
+ assert(alignment_point < req.height);
+
+ if (req.height > tallest)
+ tallest = req.height;
+
+ if (req.width > widest)
+ widest = req.width;
+
+ if (alignment_point > row_alignment_point)
+ row_alignment_point = alignment_point;
+
+ // store largest thumb size of each column as well as track the total width of the
+ // layout (which is the sum of the width of each column)
+ if (column_widths[col] < req.width) {
+ total_width -= column_widths[col];
+ column_widths[col] = req.width;
+ total_width += req.width;
+ }
+
+ if (++col >= max_cols) {
+ alignment_points[row] = row_alignment_point;
+ row_heights[row++] = tallest;
+
+ col = 0;
+ row_alignment_point = 0;
+ tallest = 0;
+ }
+ }
+
+ // account for final dangling row
+ if (col != 0) {
+ alignment_points[row] = row_alignment_point;
+ row_heights[row] = tallest;
+ }
+
+ // Step 3: Calculate the gutter between the items as being equidistant of the
+ // remaining space (adding one gutter to account for the right-hand one)
+ gutter = (visible_width - total_width) / (max_cols + 1);
+
+ // if only one column, gutter size could be less than minimums
+ if (max_cols == 1)
+ break;
+
+ // have to reassemble if the gutter is too small ... this happens because Step One
+ // takes a guess at the best column count, but when the max. widths of the columns are
+ // added up, they could overflow
+ if (gutter < COLUMN_GUTTER_PADDING) {
+ max_cols--;
+ max_rows = (total_items / max_cols) + 1;
+
+#if TRACE_REFLOW
+ debug("reflow %s: readjusting columns: alloc.width=%d total_width=%d widest=%d gutter=%d max_cols now=%d",
+ page_name, visible_width, total_width, widest, gutter, max_cols);
+#endif
+
+ col = 0;
+ row = 0;
+ tallest = 0;
+ widest = 0;
+ total_width = 0;
+ row_alignment_point = 0;
+ column_widths = new int[max_cols];
+ row_heights = new int[max_rows];
+ alignment_points = new int[max_rows];
+ } else {
+ break;
+ }
+ }
+
+#if TRACE_REFLOW
+ debug("reflow %s: width:%d total_width:%d max_cols:%d gutter:%d", page_name, visible_width,
+ total_width, max_cols, gutter);
+#endif
+
+ // Step 4: Recalculate the height of each row according to the row's alignment point (which
+ // may cause shorter items to extend below the bottom of the tallest one, extending the
+ // height of the row)
+ col = 0;
+ row = 0;
+
+ for (int ctr = 0; ctr < total_items; ctr++) {
+ CheckerboardItem item = (CheckerboardItem) view.get_at(ctr);
+ Dimensions req = item.requisition;
+
+ // this determines how much padding the item requires to be bottom-alignment along the
+ // alignment point; add to the height and you have the item's "true" height on the
+ // laid-down row
+ int true_height = req.height + (alignment_points[row] - item.get_alignment_point());
+ assert(true_height >= req.height);
+
+ // add that to its height to determine it's actual height on the laid-down row
+ if (true_height > row_heights[row]) {
+#if TRACE_REFLOW
+ debug("reflow %s: Adjusting height of row %d from %d to %d", page_name, row,
+ row_heights[row], true_height);
+#endif
+ row_heights[row] = true_height;
+ }
+
+ // carriage return
+ if (++col >= max_cols) {
+ col = 0;
+ row++;
+ }
+ }
+
+ // for the spatial structure
+ item_rows = new LayoutRow[max_rows];
+
+ // Step 5: Lay out the items in the space using all the information gathered
+ int x = gutter;
+ int y = TOP_PADDING;
+ col = 0;
+ row = 0;
+ LayoutRow current_row = null;
+
+ for (int ctr = 0; ctr < total_items; ctr++) {
+ CheckerboardItem item = (CheckerboardItem) view.get_at(ctr);
+ Dimensions req = item.requisition;
+
+ // this centers the item in the column
+ int xpadding = (column_widths[col] - req.width) / 2;
+ assert(xpadding >= 0);
+
+ // this bottom-aligns the item along the discovered alignment point
+ int ypadding = alignment_points[row] - item.get_alignment_point();
+ assert(ypadding >= 0);
+
+ // save pixel and grid coordinates
+ item.allocation.x = x + xpadding;
+ item.allocation.y = y + ypadding;
+ item.allocation.width = req.width;
+ item.allocation.height = req.height;
+ item.set_grid_coordinates(col, row);
+
+ // add to current row in spatial data structure
+ if (current_row == null)
+ current_row = new LayoutRow(y, row_heights[row], max_cols);
+
+ current_row.items[col] = item;
+
+ x += column_widths[col] + gutter;
+
+ // carriage return
+ if (++col >= max_cols) {
+ assert(current_row != null);
+ item_rows[row] = current_row;
+ current_row = null;
+
+ x = gutter;
+ y += row_heights[row] + ROW_GUTTER_PADDING;
+ col = 0;
+ row++;
+ }
+ }
+
+ // add last row to spatial data structure
+ if (current_row != null)
+ item_rows[row] = current_row;
+
+ // save dimensions of checkerboard
+ columns = max_cols;
+ rows = row + 1;
+ assert(rows == max_rows);
+
+ // Step 6: Define the total size of the page as the size of the visible width (to avoid
+ // the horizontal scrollbar from appearing) and the height of all the items plus padding
+ int total_height = y + row_heights[row] + BOTTOM_PADDING;
+ if (visible_width != allocation.width || total_height != allocation.height) {
+#if TRACE_REFLOW
+ debug("reflow %s: Changing layout dimensions from %dx%d to %dx%d", page_name,
+ allocation.width, allocation.height, visible_width, total_height);
+#endif
+ set_size_request(visible_width, total_height);
+ size_allocate_due_to_reflow = true;
+
+ // when height changes, center on the anchor to minimize amount of visual change
+ center_on_anchor(total_height);
+ }
+ }
+
+ private void items_dirty(string reason, Gee.Iterable<DataView> items) {
+ Gdk.Rectangle dirty = Gdk.Rectangle();
+ foreach (DataView data_view in items) {
+ CheckerboardItem item = (CheckerboardItem) data_view;
+
+ if (!item.is_visible())
+ continue;
+
+ assert(view.contains(item));
+
+ // if not allocated, need to reflow the entire layout; don't bother queueing a draw
+ // for any of these, reflow will handle that
+ if (item.allocation.width <= 0 || item.allocation.height <= 0) {
+ need_reflow("items_dirty: %s".printf(reason));
+
+ return;
+ }
+
+ // only mark area as dirty if visible in viewport
+ Gdk.Rectangle intersection = Gdk.Rectangle();
+ if (!visible_page.intersect(item.allocation, out intersection))
+ continue;
+
+ // grow the dirty area
+ if (dirty.width == 0 || dirty.height == 0)
+ dirty = intersection;
+ else
+ dirty.union(intersection, out dirty);
+ }
+
+ if (dirty.width > 0 && dirty.height > 0) {
+#if TRACE_REFLOW
+ debug("items_dirty %s (%s): Queuing draw of dirty area %s on visible_page %s",
+ page_name, reason, rectangle_to_string(dirty), rectangle_to_string(visible_page));
+#endif
+ queue_draw_area(dirty.x, dirty.y, dirty.width, dirty.height);
+ }
+ }
+
+ public override void map() {
+ base.map();
+
+ set_colors();
+ }
+
+ private void set_colors(bool in_focus = true) {
+ // set up selected/unselected colors
+ selected_color = Config.Facade.get_instance().get_selected_color(in_focus);
+ unselected_color = Config.Facade.get_instance().get_unselected_color();
+ border_color = Config.Facade.get_instance().get_border_color();
+ bg_color = get_style_context().get_background_color(Gtk.StateFlags.NORMAL);
+ }
+
+ public override void size_allocate(Gtk.Allocation allocation) {
+ base.size_allocate(allocation);
+
+ viewport_resized();
+ }
+
+ public override bool draw(Cairo.Context ctx) {
+ // Note: It's possible for draw to be called when in_view is false; this happens
+ // when pages are switched prior to switched_to() being called, and some of the other
+ // controls allow for events to be processed while they are orienting themselves. Since
+ // we want switched_to() to be the final call in the process (indicating that the page is
+ // now in place and should do its thing to update itself), have to be be prepared for
+ // GTK/GDK calls between the widgets being actually present on the screen and "switched to"
+
+ // watch for message mode
+ if (message == null) {
+#if TRACE_REFLOW
+ 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(ctx, bg_color, item.is_selected() ? selected_color : unselected_color,
+ unselected_color, border_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);
+
+ Gtk.Allocation allocation;
+ 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;
+
+ ctx.set_source_rgb(unselected_color.red, unselected_color.green, unselected_color.blue);
+ ctx.move_to(x, y);
+ Pango.cairo_show_layout(ctx, pango_layout);
+ }
+
+ bool result = (base.draw != null) ? base.draw(ctx) : true;
+
+ // draw the selection band last, so it appears floating over everything else
+ draw_selection_band(ctx);
+
+ return result;
+ }
+
+ private void draw_selection_band(Cairo.Context ctx) {
+ // no selection band, nothing to draw
+ if (selection_band.width <= 1 || selection_band.height <= 1)
+ return;
+
+ // This requires adjustments
+ if (hadjustment == null || vadjustment == null)
+ return;
+
+ // find the visible intersection of the viewport and the selection band
+ Gdk.Rectangle visible_page = get_adjustment_page(hadjustment, vadjustment);
+ Gdk.Rectangle visible_band = Gdk.Rectangle();
+ visible_page.intersect(selection_band, out visible_band);
+
+ // pixelate selection rectangle interior
+ if (visible_band.width > 1 && visible_band.height > 1) {
+ ctx.set_source_rgba(selected_color.red, selected_color.green, selected_color.blue,
+ SELECTION_ALPHA);
+ ctx.rectangle(visible_band.x, visible_band.y, visible_band.width,
+ visible_band.height);
+ ctx.fill();
+ }
+
+ // border
+ // See this for an explanation of the adjustments to the band's dimensions
+ // http://cairographics.org/FAQ/#sharp_lines
+ ctx.set_line_width(1.0);
+ ctx.set_line_cap(Cairo.LineCap.SQUARE);
+ ctx.set_source_rgb(selected_color.red, selected_color.green, selected_color.blue);
+ ctx.rectangle((double) selection_band.x + 0.5, (double) selection_band.y + 0.5,
+ (double) selection_band.width - 1.0, (double) selection_band.height - 1.0);
+ ctx.stroke();
+ }
+
+ 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;
+ }
+
+ private void on_colors_changed() {
+ override_background_color(Gtk.StateFlags.NORMAL, Config.Facade.get_instance().get_bg_color());
+ set_colors();
+ }
+
+ public override bool focus_in_event(Gdk.EventFocus event) {
+ set_colors(true);
+ items_dirty("focus_in_event", view.get_selected());
+
+ return base.focus_in_event(event);
+ }
+
+ public override bool focus_out_event(Gdk.EventFocus event) {
+ set_colors(false);
+ items_dirty("focus_out_event", view.get_selected());
+
+ return base.focus_out_event(event);
+ }
+}
diff --git a/src/CollectionPage.vala b/src/CollectionPage.vala
new file mode 100644
index 0000000..070452c
--- /dev/null
+++ b/src/CollectionPage.vala
@@ -0,0 +1,765 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class CollectionViewManager : ViewManager {
+ private CollectionPage page;
+
+ public CollectionViewManager(CollectionPage page) {
+ this.page = page;
+ }
+
+ public override DataView create_view(DataSource source) {
+ return page.create_thumbnail(source);
+ }
+}
+
+public abstract class CollectionPage : MediaPage {
+ private const double DESKTOP_SLIDESHOW_TRANSITION_SEC = 2.0;
+
+ protected class CollectionSearchViewFilter : DefaultSearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT | SearchFilterCriteria.FLAG |
+ SearchFilterCriteria.MEDIA | SearchFilterCriteria.RATING;
+ }
+ }
+
+ private ExporterUI exporter = null;
+ private CollectionSearchViewFilter search_filter = new CollectionSearchViewFilter();
+
+ public CollectionPage(string page_name) {
+ base (page_name);
+
+ get_view().items_altered.connect(on_photos_altered);
+
+ init_item_context_menu("/CollectionContextMenu");
+ init_toolbar("/CollectionToolbar");
+
+ show_all();
+
+ // watch for updates to the external app settings
+ Config.Facade.get_instance().external_app_changed.connect(on_external_app_changed);
+ }
+
+ public override Gtk.Toolbar get_toolbar() {
+ if (toolbar == null) {
+ base.get_toolbar();
+
+ // separator to force slider to right side of toolbar
+ Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
+ separator.set_expand(true);
+ separator.set_draw(false);
+ get_toolbar().insert(separator, -1);
+
+ Gtk.SeparatorToolItem drawn_separator = new Gtk.SeparatorToolItem();
+ drawn_separator.set_expand(false);
+ drawn_separator.set_draw(true);
+
+ get_toolbar().insert(drawn_separator, -1);
+
+ // zoom slider assembly
+ MediaPage.ZoomSliderAssembly zoom_slider_assembly = create_zoom_slider_assembly();
+ connect_slider(zoom_slider_assembly);
+ get_toolbar().insert(zoom_slider_assembly, -1);
+ }
+
+ return toolbar;
+ }
+
+ private static InjectionGroup create_file_menu_injectables() {
+ InjectionGroup group = new InjectionGroup("/MenuBar/FileMenu/FileExtrasPlaceholder");
+
+ group.add_menu_item("Print");
+ group.add_separator();
+ group.add_menu_item("Publish");
+ group.add_menu_item("SendTo");
+ group.add_menu_item("SetBackground");
+
+ return group;
+ }
+
+ private static InjectionGroup create_edit_menu_injectables() {
+ InjectionGroup group = new InjectionGroup("/MenuBar/EditMenu/EditExtrasPlaceholder");
+
+ group.add_menu_item("Duplicate");
+
+ return group;
+ }
+
+ private static InjectionGroup create_view_menu_fullscreen_injectables() {
+ InjectionGroup group = new InjectionGroup("/MenuBar/ViewMenu/ViewExtrasFullscreenSlideshowPlaceholder");
+
+ group.add_menu_item("Fullscreen", "CommonFullscreen");
+ group.add_separator();
+ group.add_menu_item("Slideshow");
+
+ return group;
+ }
+
+ private static InjectionGroup create_photos_menu_edits_injectables() {
+ InjectionGroup group = new InjectionGroup("/MenuBar/PhotosMenu/PhotosExtrasEditsPlaceholder");
+
+ group.add_menu_item("RotateClockwise");
+ group.add_menu_item("RotateCounterclockwise");
+ group.add_menu_item("FlipHorizontally");
+ group.add_menu_item("FlipVertically");
+ group.add_separator();
+ group.add_menu_item("Enhance");
+ group.add_menu_item("Revert");
+ group.add_separator();
+ group.add_menu_item("CopyColorAdjustments");
+ group.add_menu_item("PasteColorAdjustments");
+
+ return group;
+ }
+
+ private static InjectionGroup create_photos_menu_date_injectables() {
+ InjectionGroup group = new InjectionGroup("/MenuBar/PhotosMenu/PhotosExtrasDateTimePlaceholder");
+
+ group.add_menu_item("AdjustDateTime");
+
+ return group;
+ }
+
+ private static InjectionGroup create_photos_menu_externals_injectables() {
+ InjectionGroup group = new InjectionGroup("/MenuBar/PhotosMenu/PhotosExtrasExternalsPlaceholder");
+
+ group.add_menu_item("ExternalEdit");
+ group.add_menu_item("ExternalEditRAW");
+ group.add_menu_item("PlayVideo");
+
+ return group;
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("collection.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry print = { "Print", Gtk.Stock.PRINT, TRANSLATABLE, "<Ctrl>P",
+ TRANSLATABLE, on_print };
+ print.label = Resources.PRINT_MENU;
+ actions += print;
+
+ Gtk.ActionEntry publish = { "Publish", Resources.PUBLISH, TRANSLATABLE, "<Ctrl><Shift>P",
+ TRANSLATABLE, on_publish };
+ publish.label = Resources.PUBLISH_MENU;
+ publish.tooltip = Resources.PUBLISH_TOOLTIP;
+ actions += publish;
+
+ Gtk.ActionEntry rotate_right = { "RotateClockwise", Resources.CLOCKWISE,
+ TRANSLATABLE, "<Ctrl>R", TRANSLATABLE, on_rotate_clockwise };
+ rotate_right.label = Resources.ROTATE_CW_MENU;
+ rotate_right.tooltip = Resources.ROTATE_CW_TOOLTIP;
+ actions += rotate_right;
+
+ Gtk.ActionEntry rotate_left = { "RotateCounterclockwise", Resources.COUNTERCLOCKWISE,
+ TRANSLATABLE, "<Ctrl><Shift>R", TRANSLATABLE, on_rotate_counterclockwise };
+ rotate_left.label = Resources.ROTATE_CCW_MENU;
+ rotate_left.tooltip = Resources.ROTATE_CCW_TOOLTIP;
+ actions += rotate_left;
+
+ Gtk.ActionEntry hflip = { "FlipHorizontally", Resources.HFLIP, TRANSLATABLE, null,
+ TRANSLATABLE, on_flip_horizontally };
+ hflip.label = Resources.HFLIP_MENU;
+ actions += hflip;
+
+ Gtk.ActionEntry vflip = { "FlipVertically", Resources.VFLIP, TRANSLATABLE, null,
+ TRANSLATABLE, on_flip_vertically };
+ vflip.label = Resources.VFLIP_MENU;
+ actions += vflip;
+
+ Gtk.ActionEntry enhance = { "Enhance", Resources.ENHANCE, TRANSLATABLE, "<Ctrl>E",
+ TRANSLATABLE, on_enhance };
+ enhance.label = Resources.ENHANCE_MENU;
+ enhance.tooltip = Resources.ENHANCE_TOOLTIP;
+ actions += enhance;
+
+ Gtk.ActionEntry copy_adjustments = { "CopyColorAdjustments", null, TRANSLATABLE,
+ "<Ctrl><Shift>C", TRANSLATABLE, on_copy_adjustments};
+ copy_adjustments.label = Resources.COPY_ADJUSTMENTS_MENU;
+ copy_adjustments.tooltip = Resources.COPY_ADJUSTMENTS_TOOLTIP;
+ actions += copy_adjustments;
+
+ Gtk.ActionEntry paste_adjustments = { "PasteColorAdjustments", null, TRANSLATABLE,
+ "<Ctrl><Shift>V", TRANSLATABLE, on_paste_adjustments};
+ paste_adjustments.label = Resources.PASTE_ADJUSTMENTS_MENU;
+ paste_adjustments.tooltip = Resources.PASTE_ADJUSTMENTS_TOOLTIP;
+ actions += paste_adjustments;
+
+ Gtk.ActionEntry revert = { "Revert", Gtk.Stock.REVERT_TO_SAVED, TRANSLATABLE, null,
+ TRANSLATABLE, on_revert };
+ revert.label = Resources.REVERT_MENU;
+ actions += revert;
+
+ Gtk.ActionEntry set_background = { "SetBackground", null, TRANSLATABLE, "<Ctrl>B",
+ TRANSLATABLE, on_set_background };
+ set_background.label = Resources.SET_BACKGROUND_MENU;
+ set_background.tooltip = Resources.SET_BACKGROUND_TOOLTIP;
+ actions += set_background;
+
+ Gtk.ActionEntry duplicate = { "Duplicate", null, TRANSLATABLE, "<Ctrl>D", TRANSLATABLE,
+ on_duplicate_photo };
+ duplicate.label = Resources.DUPLICATE_PHOTO_MENU;
+ duplicate.tooltip = Resources.DUPLICATE_PHOTO_TOOLTIP;
+ actions += duplicate;
+
+ Gtk.ActionEntry adjust_date_time = { "AdjustDateTime", null, TRANSLATABLE, null,
+ TRANSLATABLE, on_adjust_date_time };
+ adjust_date_time.label = Resources.ADJUST_DATE_TIME_MENU;
+ actions += adjust_date_time;
+
+ Gtk.ActionEntry external_edit = { "ExternalEdit", Gtk.Stock.EDIT, TRANSLATABLE, "<Ctrl>Return",
+ TRANSLATABLE, on_external_edit };
+ external_edit.label = Resources.EXTERNAL_EDIT_MENU;
+ actions += external_edit;
+
+ Gtk.ActionEntry edit_raw = { "ExternalEditRAW", null, TRANSLATABLE, "<Ctrl><Shift>Return",
+ TRANSLATABLE, on_external_edit_raw };
+ edit_raw.label = Resources.EXTERNAL_EDIT_RAW_MENU;
+ actions += edit_raw;
+
+ Gtk.ActionEntry slideshow = { "Slideshow", null, TRANSLATABLE, "F5", TRANSLATABLE,
+ on_slideshow };
+ slideshow.label = _("S_lideshow");
+ slideshow.tooltip = _("Play a slideshow");
+ actions += slideshow;
+
+ return actions;
+ }
+
+ protected override InjectionGroup[] init_collect_injection_groups() {
+ InjectionGroup[] groups = base.init_collect_injection_groups();
+
+ groups += create_file_menu_injectables();
+ groups += create_edit_menu_injectables();
+ groups += create_view_menu_fullscreen_injectables();
+ groups += create_photos_menu_edits_injectables();
+ groups += create_photos_menu_date_injectables();
+ groups += create_photos_menu_externals_injectables();
+
+ return groups;
+ }
+
+ private bool selection_has_video() {
+ return MediaSourceCollection.has_video((Gee.Collection<MediaSource>) get_view().get_selected_sources());
+ }
+
+ private bool page_has_photo() {
+ return MediaSourceCollection.has_photo((Gee.Collection<MediaSource>) get_view().get_sources());
+ }
+
+ private bool selection_has_photo() {
+ return MediaSourceCollection.has_photo((Gee.Collection<MediaSource>) get_view().get_selected_sources());
+ }
+
+ protected override void init_actions(int selected_count, int count) {
+ base.init_actions(selected_count, count);
+
+ set_action_short_label("RotateClockwise", Resources.ROTATE_CW_LABEL);
+ set_action_short_label("RotateCounterclockwise", Resources.ROTATE_CCW_LABEL);
+ set_action_short_label("Publish", Resources.PUBLISH_LABEL);
+
+ set_action_important("RotateClockwise", true);
+ set_action_important("RotateCounterclockwise", true);
+ set_action_important("Enhance", true);
+ set_action_important("Publish", true);
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ base.update_actions(selected_count, count);
+
+ bool one_selected = selected_count == 1;
+ bool has_selected = selected_count > 0;
+
+ bool primary_is_video = false;
+ if (has_selected)
+ if (get_view().get_selected_at(0).get_source() is Video)
+ primary_is_video = true;
+
+ bool selection_has_videos = selection_has_video();
+ bool page_has_photos = page_has_photo();
+
+ // don't allow duplication of the selection if it contains a video -- videos are huge and
+ // and they're not editable anyway, so there seems to be no use case for duplicating them
+ set_action_sensitive("Duplicate", has_selected && (!selection_has_videos));
+ set_action_visible("ExternalEdit", (!primary_is_video));
+ set_action_sensitive("ExternalEdit",
+ one_selected && !is_string_empty(Config.Facade.get_instance().get_external_photo_app()));
+ set_action_visible("ExternalEditRAW",
+ one_selected && (!primary_is_video)
+ && ((Photo) get_view().get_selected_at(0).get_source()).get_master_file_format() ==
+ PhotoFileFormat.RAW
+ && !is_string_empty(Config.Facade.get_instance().get_external_raw_app()));
+ set_action_sensitive("Revert", (!selection_has_videos) && can_revert_selected());
+ set_action_sensitive("Enhance", (!selection_has_videos) && has_selected);
+ set_action_sensitive("CopyColorAdjustments", (!selection_has_videos) && one_selected &&
+ ((Photo) get_view().get_selected_at(0).get_source()).has_color_adjustments());
+ set_action_sensitive("PasteColorAdjustments", (!selection_has_videos) && has_selected &&
+ PixelTransformationBundle.has_copied_color_adjustments());
+ set_action_sensitive("RotateClockwise", (!selection_has_videos) && has_selected);
+ set_action_sensitive("RotateCounterclockwise", (!selection_has_videos) && has_selected);
+ set_action_sensitive("FlipHorizontally", (!selection_has_videos) && has_selected);
+ set_action_sensitive("FlipVertically", (!selection_has_videos) && has_selected);
+
+ // Allow changing of exposure time, even if there's a video in the current
+ // selection.
+ set_action_sensitive("AdjustDateTime", has_selected);
+
+ set_action_sensitive("NewEvent", has_selected);
+ set_action_sensitive("AddTags", has_selected);
+ set_action_sensitive("ModifyTags", one_selected);
+ set_action_sensitive("Slideshow", page_has_photos && (!primary_is_video));
+ set_action_sensitive("Print", (!selection_has_videos) && has_selected);
+ set_action_sensitive("Publish", has_selected);
+
+ set_action_sensitive("SetBackground", (!selection_has_videos) && has_selected );
+ if (has_selected) {
+ Gtk.Action? set_background = get_action("SetBackground");
+ if (set_background != null) {
+ set_background.label = one_selected
+ ? Resources.SET_BACKGROUND_MENU
+ : Resources.SET_BACKGROUND_SLIDESHOW_MENU;
+ }
+ }
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> altered) {
+ // only check for revert if the media object is a photo and its image has changed in some
+ // way and it's in the selection
+ foreach (DataObject object in altered.keys) {
+ DataView view = (DataView) object;
+
+ if (!view.is_selected() || !altered.get(view).has_subject("image"))
+ continue;
+
+ LibraryPhoto? photo = view.get_source() as LibraryPhoto;
+ if (photo == null)
+ continue;
+
+ // since the photo can be altered externally to Shotwell now, need to make the revert
+ // command available appropriately, even if the selection doesn't change
+ set_action_sensitive("Revert", can_revert_selected());
+ set_action_sensitive("CopyColorAdjustments", photo.has_color_adjustments());
+
+ break;
+ }
+ }
+
+ private void on_print() {
+ if (get_view().get_selected_count() > 0) {
+ PrintManager.get_instance().spool_photo(
+ (Gee.Collection<Photo>) get_view().get_selected_sources_of_type(typeof(Photo)));
+ }
+ }
+
+ private void on_external_app_changed() {
+ int selected_count = get_view().get_selected_count();
+
+ set_action_sensitive("ExternalEdit", selected_count == 1 && Config.Facade.get_instance().get_external_photo_app() != "");
+ }
+
+ // see #2020
+ // double clcik = switch to photo page
+ // Super + double click = open in external editor
+ // Enter = switch to PhotoPage
+ // Ctrl + Enter = open in external editor (handled with accelerators)
+ // Shift + Ctrl + Enter = open in external RAW editor (handled with accelerators)
+ protected override void on_item_activated(CheckerboardItem item, CheckerboardPage.Activator
+ activator, CheckerboardPage.KeyboardModifiers modifiers) {
+ Thumbnail thumbnail = (Thumbnail) item;
+
+ // none of the fancy Super, Ctrl, Shift, etc., keyboard accelerators apply to videos,
+ // since they can't be RAW files or be opened in an external editor, etc., so if this is
+ // a video, just play it and do a short-circuit return
+ if (thumbnail.get_media_source() is Video) {
+ on_play_video();
+ return;
+ }
+
+ LibraryPhoto? photo = thumbnail.get_media_source() as LibraryPhoto;
+ if (photo == null)
+ return;
+
+ // switch to full-page view or open in external editor
+ debug("activating %s", photo.to_string());
+
+ if (activator == CheckerboardPage.Activator.MOUSE) {
+ if (modifiers.super_pressed)
+ on_external_edit();
+ else
+ LibraryWindow.get_app().switch_to_photo_page(this, photo);
+ } else if (activator == CheckerboardPage.Activator.KEYBOARD) {
+ if (!modifiers.shift_pressed && !modifiers.ctrl_pressed)
+ LibraryWindow.get_app().switch_to_photo_page(this, photo);
+ }
+ }
+
+ protected override bool on_app_key_pressed(Gdk.EventKey event) {
+ bool handled = true;
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Page_Up":
+ case "KP_Page_Up":
+ case "Page_Down":
+ case "KP_Page_Down":
+ case "Home":
+ case "KP_Home":
+ case "End":
+ case "KP_End":
+ key_press_event(event);
+ break;
+
+ case "bracketright":
+ activate_action("RotateClockwise");
+ break;
+
+ case "bracketleft":
+ activate_action("RotateCounterclockwise");
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ return handled ? true : base.on_app_key_pressed(event);
+ }
+
+ protected override void on_export() {
+ if (exporter != null)
+ return;
+
+ Gee.Collection<MediaSource> export_list =
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources();
+ if (export_list.size == 0)
+ return;
+
+ bool has_some_photos = selection_has_photo();
+ bool has_some_videos = selection_has_video();
+ assert(has_some_photos || has_some_videos);
+
+ // if we don't have any photos, then everything is a video, so skip displaying the Export
+ // dialog and go right to the video export operation
+ if (!has_some_photos) {
+ exporter = Video.export_many((Gee.Collection<Video>) export_list, on_export_completed);
+ return;
+ }
+
+ string title = null;
+ if (has_some_videos)
+ title = (export_list.size == 1) ? _("Export Photo/Video") : _("Export Photos/Videos");
+ else
+ title = (export_list.size == 1) ? _("Export Photo") : _("Export Photos");
+ ExportDialog export_dialog = new ExportDialog(title);
+
+ // Setting up the parameters object requires a bit of thinking about what the user wants.
+ // If the selection contains only photos, then we do what we've done in previous versions
+ // of Shotwell -- we use whatever settings the user selected on his last export operation
+ // (the thinking here being that if you've been exporting small PNGs for your blog
+ // for the last n export operations, then it's likely that for your (n + 1)-th export
+ // operation you'll also be exporting a small PNG for your blog). However, if the selection
+ // contains any videos, then we set the parameters to the "Current" operating mode, since
+ // videos can't be saved as PNGs (or any other specific photo format).
+ ExportFormatParameters export_params = (has_some_videos) ? ExportFormatParameters.current() :
+ ExportFormatParameters.last();
+
+ int scale;
+ ScaleConstraint constraint;
+ if (!export_dialog.execute(out scale, out constraint, ref export_params))
+ return;
+
+ Scaling scaling = Scaling.for_constraint(constraint, scale, false);
+
+ // handle the single-photo case, which is treated like a Save As file operation
+ if (export_list.size == 1) {
+ LibraryPhoto photo = null;
+ foreach (LibraryPhoto p in (Gee.Collection<LibraryPhoto>) export_list) {
+ photo = p;
+ break;
+ }
+
+ File save_as =
+ ExportUI.choose_file(photo.get_export_basename_for_parameters(export_params));
+ if (save_as == null)
+ return;
+
+ try {
+ AppWindow.get_instance().set_busy_cursor();
+ photo.export(save_as, scaling, export_params.quality,
+ photo.get_export_format_for_parameters(export_params), export_params.mode ==
+ ExportFormatMode.UNMODIFIED, export_params.export_metadata);
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ export_error_dialog(save_as, false);
+ }
+
+ return;
+ }
+
+ // multiple photos or videos
+ File export_dir = ExportUI.choose_dir(title);
+ if (export_dir == null)
+ return;
+
+ exporter = new ExporterUI(new Exporter(export_list, export_dir, scaling, export_params));
+ exporter.export(on_export_completed);
+ }
+
+ private void on_export_completed() {
+ exporter = null;
+ }
+
+ private bool can_revert_selected() {
+ foreach (DataSource source in get_view().get_selected_sources()) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null && (photo.has_transformations() || photo.has_editable()))
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool can_revert_editable_selected() {
+ foreach (DataSource source in get_view().get_selected_sources()) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null && photo.has_editable())
+ return true;
+ }
+
+ return false;
+ }
+
+ private void on_rotate_clockwise() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ RotateMultipleCommand command = new RotateMultipleCommand(get_view().get_selected(),
+ Rotation.CLOCKWISE, Resources.ROTATE_CW_FULL_LABEL, Resources.ROTATE_CW_TOOLTIP,
+ _("Rotating"), _("Undoing Rotate"));
+ get_command_manager().execute(command);
+ }
+
+ private void on_publish() {
+ if (get_view().get_selected_count() > 0)
+ PublishingUI.PublishingDialog.go(
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources());
+ }
+
+ private void on_rotate_counterclockwise() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ RotateMultipleCommand command = new RotateMultipleCommand(get_view().get_selected(),
+ Rotation.COUNTERCLOCKWISE, Resources.ROTATE_CCW_FULL_LABEL, Resources.ROTATE_CCW_TOOLTIP,
+ _("Rotating"), _("Undoing Rotate"));
+ get_command_manager().execute(command);
+ }
+
+ private void on_flip_horizontally() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ RotateMultipleCommand command = new RotateMultipleCommand(get_view().get_selected(),
+ Rotation.MIRROR, Resources.HFLIP_LABEL, "", _("Flipping Horizontally"),
+ _("Undoing Flip Horizontally"));
+ get_command_manager().execute(command);
+ }
+
+ private void on_flip_vertically() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ RotateMultipleCommand command = new RotateMultipleCommand(get_view().get_selected(),
+ Rotation.UPSIDE_DOWN, Resources.VFLIP_LABEL, "", _("Flipping Vertically"),
+ _("Undoing Flip Vertically"));
+ get_command_manager().execute(command);
+ }
+
+ private void on_revert() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ if (can_revert_editable_selected()) {
+ if (!revert_editable_dialog(AppWindow.get_instance(),
+ (Gee.Collection<Photo>) get_view().get_selected_sources())) {
+ return;
+ }
+
+ foreach (DataObject object in get_view().get_selected_sources())
+ ((Photo) object).revert_to_master();
+ }
+
+ RevertMultipleCommand command = new RevertMultipleCommand(get_view().get_selected());
+ get_command_manager().execute(command);
+ }
+
+ public void on_copy_adjustments() {
+ if (get_view().get_selected_count() != 1)
+ return;
+ Photo photo = (Photo) get_view().get_selected_at(0).get_source();
+ PixelTransformationBundle.set_copied_color_adjustments(photo.get_color_adjustments());
+ set_action_sensitive("PasteColorAdjustments", true);
+ }
+
+ public void on_paste_adjustments() {
+ PixelTransformationBundle? copied_adjustments = PixelTransformationBundle.get_copied_color_adjustments();
+ if (get_view().get_selected_count() == 0 || copied_adjustments == null)
+ return;
+
+ AdjustColorsMultipleCommand command = new AdjustColorsMultipleCommand(get_view().get_selected(),
+ copied_adjustments, Resources.PASTE_ADJUSTMENTS_LABEL, Resources.PASTE_ADJUSTMENTS_TOOLTIP);
+ get_command_manager().execute(command);
+ }
+
+ private void on_enhance() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ EnhanceMultipleCommand command = new EnhanceMultipleCommand(get_view().get_selected());
+ get_command_manager().execute(command);
+ }
+
+ private void on_duplicate_photo() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ DuplicateMultiplePhotosCommand command = new DuplicateMultiplePhotosCommand(
+ get_view().get_selected());
+ get_command_manager().execute(command);
+ }
+
+ private void on_adjust_date_time() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ bool selected_has_videos = false;
+ bool only_videos_selected = true;
+
+ foreach (DataView dv in get_view().get_selected()) {
+ if (dv.get_source() is Video)
+ selected_has_videos = true;
+ else
+ only_videos_selected = false;
+ }
+
+ Dateable photo_source = (Dateable) get_view().get_selected_at(0).get_source();
+
+ AdjustDateTimeDialog dialog = new AdjustDateTimeDialog(photo_source,
+ get_view().get_selected_count(), true, selected_has_videos, only_videos_selected);
+
+ int64 time_shift;
+ bool keep_relativity, modify_originals;
+ if (dialog.execute(out time_shift, out keep_relativity, out modify_originals)) {
+ AdjustDateTimePhotosCommand command = new AdjustDateTimePhotosCommand(
+ get_view().get_selected(), time_shift, keep_relativity, modify_originals);
+ get_command_manager().execute(command);
+ }
+ }
+
+ private void on_external_edit() {
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ Photo photo = (Photo) get_view().get_selected_at(0).get_source();
+ try {
+ AppWindow.get_instance().set_busy_cursor();
+ photo.open_with_external_editor();
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ open_external_editor_error_dialog(err, photo);
+ }
+ }
+
+ private void on_external_edit_raw() {
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ Photo photo = (Photo) get_view().get_selected_at(0).get_source();
+ if (photo.get_master_file_format() != PhotoFileFormat.RAW)
+ return;
+
+ try {
+ AppWindow.get_instance().set_busy_cursor();
+ photo.open_with_raw_external_editor();
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ AppWindow.error_message(Resources.launch_editor_failed(err));
+ }
+ }
+
+ public void on_set_background() {
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ MediaSourceCollection.filter_media((Gee.Collection<MediaSource>) get_view().get_selected_sources(),
+ photos, null);
+
+ if (photos.size == 1) {
+ AppWindow.get_instance().set_busy_cursor();
+ DesktopIntegration.set_background(photos[0]);
+ AppWindow.get_instance().set_normal_cursor();
+ } else if (photos.size > 1) {
+ SetBackgroundSlideshowDialog dialog = new SetBackgroundSlideshowDialog();
+ int delay;
+ if (dialog.execute(out delay)) {
+ AppWindow.get_instance().set_busy_cursor();
+ DesktopIntegration.set_background_slideshow(photos, delay,
+ DESKTOP_SLIDESHOW_TRANSITION_SEC);
+ AppWindow.get_instance().set_normal_cursor();
+ }
+ }
+ }
+
+ private void on_slideshow() {
+ if (get_view().get_count() == 0)
+ return;
+
+ // use first selected photo, else use first photo
+ Gee.List<DataSource>? sources = (get_view().get_selected_count() > 0)
+ ? get_view().get_selected_sources_of_type(typeof(LibraryPhoto))
+ : get_view().get_sources_of_type(typeof(LibraryPhoto));
+ if (sources == null || sources.size == 0)
+ return;
+
+ Thumbnail? thumbnail = (Thumbnail?) get_view().get_view_for_source(sources[0]);
+ if (thumbnail == null)
+ return;
+
+ LibraryPhoto? photo = thumbnail.get_media_source() as LibraryPhoto;
+ if (photo == null)
+ return;
+
+ AppWindow.get_instance().go_fullscreen(new SlideshowPage(LibraryPhoto.global, get_view(),
+ photo));
+ }
+
+ protected override bool on_ctrl_pressed(Gdk.EventKey? event) {
+ Gtk.ToolButton? rotate_button = ui.get_widget("/CollectionToolbar/ToolRotate")
+ as Gtk.ToolButton;
+ if (rotate_button != null)
+ rotate_button.set_related_action(get_action("RotateCounterclockwise"));
+
+ return base.on_ctrl_pressed(event);
+ }
+
+ protected override bool on_ctrl_released(Gdk.EventKey? event) {
+ Gtk.ToolButton? rotate_button = ui.get_widget("/CollectionToolbar/ToolRotate")
+ as Gtk.ToolButton;
+ if (rotate_button != null)
+ rotate_button.set_related_action(get_action("RotateClockwise"));
+
+ return base.on_ctrl_released(event);
+ }
+
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
diff --git a/src/ColorTransformation.vala b/src/ColorTransformation.vala
new file mode 100644
index 0000000..74e4cf2
--- /dev/null
+++ b/src/ColorTransformation.vala
@@ -0,0 +1,1519 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public struct RGBAnalyticPixel {
+ public float red;
+ public float green;
+ public float blue;
+
+ private const float INV_255 = 1.0f / 255.0f;
+
+ public RGBAnalyticPixel() {
+ red = 0.0f;
+ green = 0.0f;
+ blue = 0.0f;
+ }
+
+ public RGBAnalyticPixel.from_components(float red, float green,
+ float blue) {
+ this.red = red.clamp(0.0f, 1.0f);
+ this.green = green.clamp(0.0f, 1.0f);
+ this.blue = blue.clamp(0.0f, 1.0f);
+ }
+
+ public RGBAnalyticPixel.from_quantized_components(uchar red_quantized,
+ uchar green_quantized, uchar blue_quantized) {
+ this.red = ((float) red_quantized) * INV_255;
+ this.green = ((float) green_quantized) * INV_255;
+ this.blue = ((float) blue_quantized) * INV_255;
+ }
+
+ public RGBAnalyticPixel.from_hsv(HSVAnalyticPixel hsv_pixel) {
+ RGBAnalyticPixel from_hsv = hsv_pixel.to_rgb();
+ red = from_hsv.red;
+ green = from_hsv.green;
+ blue = from_hsv.blue;
+ }
+
+ public uchar quantized_red() {
+ return (uchar)(red * 255.0f);
+ }
+
+ public uchar quantized_green() {
+ return (uchar)(green * 255.0f);
+ }
+
+ public uchar quantized_blue() {
+ return (uchar)(blue * 255.0f);
+ }
+
+ public bool equals(RGBAnalyticPixel? rhs) {
+ return ((red == rhs.red) && (green == rhs.green) && (blue == rhs.blue));
+ }
+
+ public uint hash_code() {
+ return (((uint)(red * 255.0f)) << 16) + (((uint)(green * 255.0f)) << 8) +
+ ((uint)(blue * 255.0f));
+ }
+
+ public HSVAnalyticPixel to_hsv() {
+ return HSVAnalyticPixel.from_rgb(this);
+ }
+}
+
+public struct HSVAnalyticPixel {
+ public float hue;
+ public float saturation;
+ public float light_value;
+
+ private const float INV_255 = 1.0f / 255.0f;
+
+ public HSVAnalyticPixel() {
+ hue = 0.0f;
+ saturation = 0.0f;
+ light_value = 0.0f;
+ }
+
+ public HSVAnalyticPixel.from_components(float hue, float saturation,
+ float light_value) {
+ this.hue = hue.clamp(0.0f, 1.0f);
+ this.saturation = saturation.clamp(0.0f, 1.0f);
+ this.light_value = light_value.clamp(0.0f, 1.0f);
+ }
+
+ public HSVAnalyticPixel.from_quantized_components(uchar hue_quantized,
+ uchar saturation_quantized, uchar light_value_quantized) {
+ this.hue = ((float) hue_quantized) * INV_255;
+ this.saturation = ((float) saturation_quantized) * INV_255;
+ this.light_value = ((float) light_value_quantized) * INV_255;
+ }
+
+ public HSVAnalyticPixel.from_rgb(RGBAnalyticPixel p) {
+ float max_component = float.max(float.max(p.red, p.green), p.blue);
+ float min_component = float.min(float.min(p.red, p.green), p.blue);
+
+ light_value = max_component;
+ saturation = (max_component != 0.0f) ? ((max_component - min_component) /
+ max_component) : 0.0f;
+
+ if (saturation == 0.0f) {
+ hue = 0.0f; /* hue is undefined in the zero saturation case */
+ } else {
+ float delta = max_component - min_component;
+ if (p.red == max_component) {
+ hue = (p.green - p.blue) / delta;
+ } else if (p.green == max_component) {
+ hue = 2.0f + ((p.blue - p.red) / delta);
+ } else if (p.blue == max_component) {
+ hue = 4.0f + ((p.red - p.green) / delta);
+ }
+
+ hue *= 60.0f;
+ if (hue < 0.0f)
+ hue += 360.0f;
+
+ hue /= 360.0f; /* normalize hue */
+ }
+
+ hue = hue.clamp(0.0f, 1.0f);
+ saturation = saturation.clamp(0.0f, 1.0f);
+ light_value = light_value.clamp(0.0f, 1.0f);
+ }
+
+ public RGBAnalyticPixel to_rgb() {
+ RGBAnalyticPixel result = RGBAnalyticPixel();
+
+ if (saturation == 0.0f) {
+ result.red = light_value;
+ result.green = light_value;
+ result.blue = light_value;
+ } else {
+ float hue_denorm = hue * 360.0f;
+ if (hue_denorm == 360.0f)
+ hue_denorm = 0.0f;
+
+ float hue_hexant = hue_denorm / 60.0f;
+
+ int hexant_i_part = (int) hue_hexant;
+
+ float hexant_f_part = hue_hexant - ((float) hexant_i_part);
+
+ /* the p, q, and t quantities from section 13.3 of Foley, et. al. */
+ float p = light_value * (1.0f - saturation);
+ float q = light_value * (1.0f - (saturation * hexant_f_part));
+ float t = light_value * (1.0f - (saturation * (1.0f - hexant_f_part)));
+ switch (hexant_i_part) {
+ /* the (r, g, b) components of the output pixel are computed
+ from the light_value, p, q, and t quantities differently
+ depending on which "hexant" (1/6 of a full rotation) of the
+ HSV color cone the hue lies in. For example, if the hue lies
+ in the yellow hexant, the dominant channels in the output
+ are red and green, so we map relatively more of the light_value
+ into these colors than if, say, the hue were to lie in the
+ cyan hexant. See chapter 13 of Foley, et. al. for more
+ information. */
+ case 0:
+ result.red = light_value;
+ result.green = t;
+ result.blue = p;
+ break;
+
+ case 1:
+ result.red = q;
+ result.green = light_value;
+ result.blue = p;
+ break;
+
+ case 2:
+ result.red = p;
+ result.green = light_value;
+ result.blue = t;
+ break;
+
+ case 3:
+ result.red = p;
+ result.green = q;
+ result.blue = light_value;
+ break;
+
+ case 4:
+ result.red = t;
+ result.green = p;
+ result.blue = light_value;
+ break;
+
+ case 5:
+ result.red = light_value;
+ result.green = p;
+ result.blue = q;
+ break;
+
+ default:
+ error("bad color hexant in HSV-to-RGB conversion");
+ }
+ }
+
+ return result;
+ }
+
+ public bool equals(ref HSVAnalyticPixel rhs) {
+ return ((hue == rhs.hue) && (saturation == rhs.saturation) &&
+ (light_value == rhs.light_value));
+ }
+
+ public uint hash_code() {
+ return (((uint)(hue * 255.0f)) << 16) + (((uint)(saturation * 255.0f)) << 8) +
+ ((uint)(light_value * 255.0f));
+ }
+}
+
+public enum CompositionMode {
+ NONE,
+ RGB_MATRIX
+}
+
+public enum PixelFormat {
+ RGB,
+ HSV
+}
+
+public enum PixelTransformationType {
+ TONE_EXPANSION,
+ SHADOWS,
+ HIGHLIGHTS,
+ TEMPERATURE,
+ TINT,
+ SATURATION,
+ EXPOSURE
+}
+
+public class PixelTransformationBundle {
+ private static PixelTransformationBundle? copied_color_adjustments = null;
+
+ private Gee.HashMap<int, PixelTransformation> map = new Gee.HashMap<int, PixelTransformation>(
+ Gee.Functions.get_hash_func_for(typeof(int)), Gee.Functions.get_equal_func_for(typeof(int)));
+
+ public PixelTransformationBundle() {
+ }
+
+ public static PixelTransformationBundle? get_copied_color_adjustments() {
+ return copied_color_adjustments;
+ }
+
+ public static void set_copied_color_adjustments(PixelTransformationBundle adjustments) {
+ copied_color_adjustments = adjustments;
+ }
+
+ public static bool has_copied_color_adjustments() {
+ return copied_color_adjustments != null;
+ }
+
+ public void set(PixelTransformation transformation) {
+ map.set((int) transformation.get_transformation_type(), transformation);
+ }
+
+ public void set_to_identity() {
+ set(new ExpansionTransformation.from_extrema(0, 255));
+ set(new ShadowDetailTransformation(0.0f));
+ set(new HighlightDetailTransformation(0.0f));
+ set(new TemperatureTransformation(0.0f));
+ set(new TintTransformation(0.0f));
+ set(new SaturationTransformation(0.0f));
+ set(new ExposureTransformation(0.0f));
+ }
+
+ public void load(KeyValueMap store) {
+ string expansion_params_encoded = store.get_string("expansion", "-");
+ if (expansion_params_encoded == "-")
+ set(new ExpansionTransformation.from_extrema(0, 255));
+ else
+ set(new ExpansionTransformation.from_string(expansion_params_encoded));
+
+ set(new ShadowDetailTransformation(store.get_float("shadows", 0.0f)));
+ set(new HighlightDetailTransformation(store.get_float("highlights", 0.0f)));
+ set(new TemperatureTransformation(store.get_float("temperature", 0.0f)));
+ set(new TintTransformation(store.get_float("tint", 0.0f)));
+ set(new SaturationTransformation(store.get_float("saturation", 0.0f)));
+ set(new ExposureTransformation(store.get_float("exposure", 0.0f)));
+ }
+
+ public KeyValueMap save(string group) {
+ KeyValueMap store = new KeyValueMap(group);
+
+ ExpansionTransformation? new_expansion_trans =
+ (ExpansionTransformation) get_transformation(PixelTransformationType.TONE_EXPANSION);
+ assert(new_expansion_trans != null);
+ store.set_string("expansion", new_expansion_trans.to_string());
+
+ ShadowDetailTransformation? new_shadows_trans =
+ (ShadowDetailTransformation) get_transformation(PixelTransformationType.SHADOWS);
+ assert(new_shadows_trans != null);
+ store.set_float("shadows", new_shadows_trans.get_parameter());
+
+ HighlightDetailTransformation? new_highlight_trans =
+ (HighlightDetailTransformation) get_transformation(PixelTransformationType.HIGHLIGHTS);
+ assert(new_highlight_trans != null);
+ store.set_float("highlights", new_highlight_trans.get_parameter());
+
+ TemperatureTransformation? new_temp_trans =
+ (TemperatureTransformation) get_transformation(PixelTransformationType.TEMPERATURE);
+ assert(new_temp_trans != null);
+ store.set_float("temperature", new_temp_trans.get_parameter());
+
+ TintTransformation? new_tint_trans =
+ (TintTransformation) get_transformation(PixelTransformationType.TINT);
+ assert(new_tint_trans != null);
+ store.set_float("tint", new_tint_trans.get_parameter());
+
+ SaturationTransformation? new_sat_trans =
+ (SaturationTransformation) get_transformation(PixelTransformationType.SATURATION);
+ assert(new_sat_trans != null);
+ store.set_float("saturation", new_sat_trans.get_parameter());
+
+ ExposureTransformation? new_exposure_trans =
+ (ExposureTransformation) get_transformation(PixelTransformationType.EXPOSURE);
+ assert(new_exposure_trans != null);
+ store.set_float("exposure", new_exposure_trans.get_parameter());
+
+ return store;
+ }
+
+ public int get_count() {
+ return map.size;
+ }
+
+ public PixelTransformation? get_transformation(PixelTransformationType type) {
+ return map.get((int) type);
+ }
+
+ public Gee.Iterable<PixelTransformation> get_transformations() {
+ return map.values;
+ }
+
+ public bool is_identity() {
+ foreach (PixelTransformation adjustment in get_transformations()) {
+ if (!adjustment.is_identity())
+ return false;
+ }
+
+ return true;
+ }
+
+ public PixelTransformer generate_transformer() {
+ PixelTransformer transformer = new PixelTransformer();
+ foreach (PixelTransformation transformation in get_transformations())
+ transformer.attach_transformation(transformation);
+
+ return transformer;
+ }
+
+ public PixelTransformationBundle copy() {
+ PixelTransformationBundle bundle = new PixelTransformationBundle();
+ foreach (PixelTransformation transformation in get_transformations())
+ bundle.set(transformation);
+
+ return bundle;
+ }
+}
+
+public abstract class PixelTransformation {
+ private PixelTransformationType type;
+
+ public PixelTransformation(PixelTransformationType type) {
+ this.type = type;
+ }
+
+ public PixelTransformationType get_transformation_type() {
+ return type;
+ }
+
+ public abstract PixelFormat get_preferred_format();
+
+ public virtual CompositionMode get_composition_mode() {
+ return CompositionMode.NONE;
+ }
+
+ public virtual void compose_with(PixelTransformation other) {
+ error("PixelTransformation: compose_with( ): this type of pixel " +
+ "transformation doesn't support composition.");
+ }
+
+ public virtual bool is_identity() {
+ return true;
+ }
+
+ public virtual HSVAnalyticPixel transform_pixel_hsv(HSVAnalyticPixel p) {
+ return p;
+ }
+
+ public virtual RGBAnalyticPixel transform_pixel_rgb(RGBAnalyticPixel p) {
+ return p;
+ }
+
+ public virtual string to_string() {
+ return "PixelTransformation";
+ }
+
+ public abstract PixelTransformation copy();
+}
+
+public class RGBTransformation : PixelTransformation {
+ /* matrix entries are stored in row-major order; by default, the matrix formed
+ by matrix_entries is the 4x4 identity matrix */
+ protected float[] matrix_entries;
+
+ protected const int MATRIX_SIZE = 16;
+
+ protected bool identity = true;
+
+ public RGBTransformation(PixelTransformationType type) {
+ base(type);
+
+ // Can't initialize these in their member declarations because of a valac bug that
+ // I've been unable to produce a minimal test case for to report (JN). May be
+ // related to this bug:
+ // https://bugzilla.gnome.org/show_bug.cgi?id=570821
+ matrix_entries = {
+ 1.0f, 0.0f, 0.0f, 0.0f,
+ 0.0f, 1.0f, 0.0f, 0.0f,
+ 0.0f, 0.0f, 1.0f, 0.0f,
+ 0.0f, 0.0f, 0.0f, 1.0f };
+ }
+
+ public override PixelFormat get_preferred_format() {
+ return PixelFormat.RGB;
+ }
+
+ public override CompositionMode get_composition_mode() {
+ return CompositionMode.RGB_MATRIX;
+ }
+
+ public override void compose_with(PixelTransformation other) {
+ if (other.get_composition_mode() != CompositionMode.RGB_MATRIX)
+ error("RGBTransformation: compose_with( ): 'other' transformation " +
+ "does not support RGB_MATRIX composition mode");
+
+ RGBTransformation transform = (RGBTransformation) other;
+
+ float[] result_matrix_entries = new float[16];
+
+ /* row 0 */
+ result_matrix_entries[0] =
+ (transform.matrix_entries[0] * matrix_entries[0]) +
+ (transform.matrix_entries[1] * matrix_entries[4]) +
+ (transform.matrix_entries[2] * matrix_entries[8]) +
+ (transform.matrix_entries[3] * matrix_entries[12]);
+
+ result_matrix_entries[1] =
+ (transform.matrix_entries[0] * matrix_entries[1]) +
+ (transform.matrix_entries[1] * matrix_entries[5]) +
+ (transform.matrix_entries[2] * matrix_entries[9]) +
+ (transform.matrix_entries[3] * matrix_entries[13]);
+
+ result_matrix_entries[2] =
+ (transform.matrix_entries[0] * matrix_entries[2]) +
+ (transform.matrix_entries[1] * matrix_entries[6]) +
+ (transform.matrix_entries[2] * matrix_entries[10]) +
+ (transform.matrix_entries[3] * matrix_entries[14]);
+
+ result_matrix_entries[3] =
+ (transform.matrix_entries[0] * matrix_entries[3]) +
+ (transform.matrix_entries[1] * matrix_entries[7]) +
+ (transform.matrix_entries[2] * matrix_entries[11]) +
+ (transform.matrix_entries[3] * matrix_entries[15]);
+
+ /* row 1 */
+ result_matrix_entries[4] =
+ (transform.matrix_entries[4] * matrix_entries[0]) +
+ (transform.matrix_entries[5] * matrix_entries[4]) +
+ (transform.matrix_entries[6] * matrix_entries[8]) +
+ (transform.matrix_entries[7] * matrix_entries[12]);
+
+ result_matrix_entries[5] =
+ (transform.matrix_entries[4] * matrix_entries[1]) +
+ (transform.matrix_entries[5] * matrix_entries[5]) +
+ (transform.matrix_entries[6] * matrix_entries[9]) +
+ (transform.matrix_entries[7] * matrix_entries[13]);
+
+ result_matrix_entries[6] =
+ (transform.matrix_entries[4] * matrix_entries[2]) +
+ (transform.matrix_entries[5] * matrix_entries[6]) +
+ (transform.matrix_entries[6] * matrix_entries[10]) +
+ (transform.matrix_entries[7] * matrix_entries[14]);
+
+ result_matrix_entries[7] =
+ (transform.matrix_entries[4] * matrix_entries[3]) +
+ (transform.matrix_entries[5] * matrix_entries[7]) +
+ (transform.matrix_entries[6] * matrix_entries[11]) +
+ (transform.matrix_entries[7] * matrix_entries[15]);
+
+ /* row 2 */
+ result_matrix_entries[8] =
+ (transform.matrix_entries[8] * matrix_entries[0]) +
+ (transform.matrix_entries[9] * matrix_entries[4]) +
+ (transform.matrix_entries[10] * matrix_entries[8]) +
+ (transform.matrix_entries[11] * matrix_entries[12]);
+
+ result_matrix_entries[9] =
+ (transform.matrix_entries[8] * matrix_entries[1]) +
+ (transform.matrix_entries[9] * matrix_entries[5]) +
+ (transform.matrix_entries[10] * matrix_entries[9]) +
+ (transform.matrix_entries[11] * matrix_entries[13]);
+
+ result_matrix_entries[10] =
+ (transform.matrix_entries[8] * matrix_entries[2]) +
+ (transform.matrix_entries[9] * matrix_entries[6]) +
+ (transform.matrix_entries[10] * matrix_entries[10]) +
+ (transform.matrix_entries[11] * matrix_entries[14]);
+
+ result_matrix_entries[11] =
+ (transform.matrix_entries[8] * matrix_entries[3]) +
+ (transform.matrix_entries[9] * matrix_entries[7]) +
+ (transform.matrix_entries[10] * matrix_entries[11]) +
+ (transform.matrix_entries[11] * matrix_entries[15]);
+
+ /* row 3 */
+ result_matrix_entries[12] =
+ (transform.matrix_entries[12] * matrix_entries[0]) +
+ (transform.matrix_entries[13] * matrix_entries[4]) +
+ (transform.matrix_entries[14] * matrix_entries[8]) +
+ (transform.matrix_entries[15] * matrix_entries[12]);
+
+ result_matrix_entries[13] =
+ (transform.matrix_entries[12] * matrix_entries[1]) +
+ (transform.matrix_entries[13] * matrix_entries[5]) +
+ (transform.matrix_entries[14] * matrix_entries[9]) +
+ (transform.matrix_entries[15] * matrix_entries[13]);
+
+ result_matrix_entries[14] =
+ (transform.matrix_entries[12] * matrix_entries[2]) +
+ (transform.matrix_entries[13] * matrix_entries[6]) +
+ (transform.matrix_entries[14] * matrix_entries[10]) +
+ (transform.matrix_entries[15] * matrix_entries[14]);
+
+ result_matrix_entries[15] =
+ (transform.matrix_entries[12] * matrix_entries[3]) +
+ (transform.matrix_entries[13] * matrix_entries[7]) +
+ (transform.matrix_entries[14] * matrix_entries[11]) +
+ (transform.matrix_entries[15] * matrix_entries[15]);
+
+ for (int i = 0; i < MATRIX_SIZE; i++)
+ matrix_entries[i] = result_matrix_entries[i];
+
+ identity = (identity && transform.identity);
+ }
+
+ public override HSVAnalyticPixel transform_pixel_hsv(HSVAnalyticPixel p) {
+ return (transform_pixel_rgb(p.to_rgb())).to_hsv();
+ }
+
+ public override RGBAnalyticPixel transform_pixel_rgb(RGBAnalyticPixel p) {
+ float red_out = (p.red * matrix_entries[0]) +
+ (p.green * matrix_entries[1]) +
+ (p.blue * matrix_entries[2]) +
+ matrix_entries[3];
+ red_out = red_out.clamp(0.0f, 1.0f);
+
+ float green_out = (p.red * matrix_entries[4]) +
+ (p.green * matrix_entries[5]) +
+ (p.blue * matrix_entries[6]) +
+ matrix_entries[7];
+ green_out = green_out.clamp(0.0f, 1.0f);
+
+ float blue_out = (p.red * matrix_entries[8]) +
+ (p.green * matrix_entries[9]) +
+ (p.blue * matrix_entries[10]) +
+ matrix_entries[11];
+ blue_out = blue_out.clamp(0.0f, 1.0f);
+
+ return RGBAnalyticPixel.from_components(red_out, green_out, blue_out);
+ }
+
+ public override bool is_identity() {
+ return identity;
+ }
+
+ public override PixelTransformation copy() {
+ RGBTransformation result = new RGBTransformation(get_transformation_type());
+
+ for (int i = 0; i < MATRIX_SIZE; i++) {
+ result.matrix_entries[i] = matrix_entries[i];
+ }
+
+ return result;
+ }
+}
+
+public abstract class HSVTransformation : PixelTransformation {
+ public HSVTransformation(PixelTransformationType type) {
+ base(type);
+ }
+
+ public override PixelFormat get_preferred_format() {
+ return PixelFormat.HSV;
+ }
+
+ public override RGBAnalyticPixel transform_pixel_rgb(RGBAnalyticPixel p) {
+ return (transform_pixel_hsv(p.to_hsv())).to_rgb();
+ }
+}
+
+public class TintTransformation : RGBTransformation {
+ private const float INTENSITY_FACTOR = 0.25f;
+ public const float MIN_PARAMETER = -16.0f;
+ public const float MAX_PARAMETER = 16.0f;
+
+ private float parameter;
+
+ public TintTransformation(float client_param) {
+ base(PixelTransformationType.TINT);
+
+ parameter = client_param.clamp(MIN_PARAMETER, MAX_PARAMETER);
+
+ if (parameter != 0.0f) {
+ float adjusted_param = parameter / MAX_PARAMETER;
+ adjusted_param *= INTENSITY_FACTOR;
+
+ matrix_entries[11] -= (adjusted_param / 2);
+ matrix_entries[7] += adjusted_param;
+ matrix_entries[3] -= (adjusted_param / 2);
+
+ identity = false;
+ }
+ }
+
+ public float get_parameter() {
+ return parameter;
+ }
+}
+
+public class TemperatureTransformation : RGBTransformation {
+ private const float INTENSITY_FACTOR = 0.33f;
+ public const float MIN_PARAMETER = -16.0f;
+ public const float MAX_PARAMETER = 16.0f;
+
+ private float parameter;
+
+ public TemperatureTransformation(float client_parameter) {
+ base(PixelTransformationType.TEMPERATURE);
+
+ parameter = client_parameter.clamp(MIN_PARAMETER, MAX_PARAMETER);
+
+ if (parameter != 0.0f) {
+ float adjusted_param = parameter / MAX_PARAMETER;
+ adjusted_param *= INTENSITY_FACTOR;
+
+ matrix_entries[11] -= adjusted_param;
+ matrix_entries[7] += (adjusted_param / 2);
+ matrix_entries[3] += (adjusted_param / 2);
+
+ identity = false;
+ }
+ }
+
+ public float get_parameter() {
+ return parameter;
+ }
+}
+
+public class SaturationTransformation : RGBTransformation {
+ public const float MIN_PARAMETER = -16.0f;
+ public const float MAX_PARAMETER = 16.0f;
+
+ private float parameter;
+
+ public SaturationTransformation(float client_parameter) {
+ base(PixelTransformationType.SATURATION);
+
+ parameter = client_parameter.clamp(MIN_PARAMETER, MAX_PARAMETER);
+
+ if (parameter != 0.0f) {
+ float adjusted_param = parameter / MAX_PARAMETER;
+ adjusted_param += 1.0f;
+
+ float one_third = 0.3333333f;
+
+ matrix_entries[0] = ((1.0f - adjusted_param) * one_third) +
+ adjusted_param;
+ matrix_entries[1] = (1.0f - adjusted_param) * one_third;
+ matrix_entries[2] = (1.0f - adjusted_param) * one_third;
+
+ matrix_entries[4] = (1.0f - adjusted_param) * one_third;
+ matrix_entries[5] = ((1.0f - adjusted_param) * one_third) +
+ adjusted_param;
+ matrix_entries[6] = (1.0f - adjusted_param) * one_third;
+
+ matrix_entries[8] = (1.0f - adjusted_param) * one_third;
+ matrix_entries[9] = (1.0f - adjusted_param) * one_third;
+ matrix_entries[10] = ((1.0f - adjusted_param) * one_third) +
+ adjusted_param;
+
+ identity = false;
+ }
+ }
+
+ public float get_parameter() {
+ return parameter;
+ }
+}
+
+public class ExposureTransformation : RGBTransformation {
+ public const float MIN_PARAMETER = -16.0f;
+ public const float MAX_PARAMETER = 16.0f;
+
+ float parameter;
+
+ public ExposureTransformation(float client_parameter) {
+ base(PixelTransformationType.EXPOSURE);
+
+ parameter = client_parameter.clamp(MIN_PARAMETER, MAX_PARAMETER);
+
+ if (parameter != 0.0f) {
+
+ float adjusted_param = ((parameter + 16.0f) / 32.0f) + 0.5f;
+
+ matrix_entries[0] = adjusted_param;
+ matrix_entries[5] = adjusted_param;
+ matrix_entries[10] = adjusted_param;
+
+ identity = false;
+ }
+ }
+
+ public float get_parameter() {
+ return parameter;
+ }
+}
+
+public class PixelTransformer {
+ private Gee.ArrayList<PixelTransformation> transformations =
+ new Gee.ArrayList<PixelTransformation>();
+ private PixelTransformation[] optimized_transformations = null;
+ private int optimized_slots_used = 0;
+
+ public PixelTransformer() {
+ }
+
+ public PixelTransformer copy() {
+ PixelTransformer clone = new PixelTransformer();
+
+ foreach (PixelTransformation transformation in transformations)
+ clone.transformations.add(transformation);
+
+ return clone;
+ }
+
+ private void build_optimized_transformations() {
+ optimized_transformations = new PixelTransformation[transformations.size];
+
+ PixelTransformation pre_trans = null;
+ optimized_slots_used = 0;
+ for (int i = 0; i < transformations.size; i++) {
+ PixelTransformation trans = transformations.get(i);
+
+ if (trans.is_identity())
+ continue;
+
+ PixelTransformation this_trans = null;
+ if (trans.get_composition_mode() == CompositionMode.NONE)
+ this_trans = trans;
+ else
+ this_trans = trans.copy();
+
+ if ((pre_trans != null) && (this_trans.get_composition_mode() != CompositionMode.NONE)
+ && (this_trans.get_composition_mode() == pre_trans.get_composition_mode())) {
+ pre_trans.compose_with(this_trans);
+ } else {
+ optimized_transformations[optimized_slots_used++] = this_trans;
+ pre_trans = this_trans;
+ }
+ }
+ }
+
+ private RGBAnalyticPixel apply_transformations(RGBAnalyticPixel p) {
+ PixelFormat current_format = PixelFormat.RGB;
+ RGBAnalyticPixel p_rgb = p;
+ HSVAnalyticPixel p_hsv = HSVAnalyticPixel();
+
+ for (int i = 0; i < optimized_slots_used; i++) {
+ PixelTransformation trans = optimized_transformations[i];
+ if (trans.get_preferred_format() == PixelFormat.RGB) {
+ if (current_format == PixelFormat.HSV) {
+ p_rgb = p_hsv.to_rgb();
+ current_format = PixelFormat.RGB;
+ }
+ p_rgb = trans.transform_pixel_rgb(p_rgb);
+ } else {
+ if (current_format == PixelFormat.RGB) {
+ p_hsv = p_rgb.to_hsv();
+ current_format = PixelFormat.HSV;
+ }
+ p_hsv = trans.transform_pixel_hsv(p_hsv);
+ }
+ }
+
+ if (current_format == PixelFormat.HSV)
+ p_rgb = p_hsv.to_rgb();
+
+ return p_rgb;
+ }
+
+ /* NOTE: this method allows the same transformation to be added multiple
+ times. There's nothing wrong with this behavior as of today,
+ but it may be a policy that we want to change in the future */
+ public void attach_transformation(PixelTransformation trans) {
+ transformations.add(trans);
+ optimized_transformations = null;
+ }
+
+ /* NOTE: if a transformation has been added multiple times, only the first
+ instance of it will be removed */
+ public void detach_transformation(PixelTransformation victim) {
+ transformations.remove(victim);
+ optimized_transformations = null;
+ }
+
+ /* NOTE: if a transformation has been added multiple times, only the first
+ instance of it will be replaced with 'new' */
+ public void replace_transformation(PixelTransformation old_trans,
+ PixelTransformation new_trans) {
+ for (int i = 0; i < transformations.size; i++) {
+ if (transformations.get(i) == old_trans) {
+ transformations.set(i, new_trans);
+
+ optimized_transformations = null;
+ return;
+ }
+ }
+ error("PixelTransformer: replace_transformation( ): old_trans is not present in " +
+ "transformation collection");
+ }
+
+ public void transform_pixbuf(Gdk.Pixbuf pixbuf, Cancellable? cancellable = null) {
+ transform_to_other_pixbuf(pixbuf, pixbuf, cancellable);
+ }
+
+ public void transform_from_fp(ref float[] fp_pixel_cache, Gdk.Pixbuf dest) {
+ if (optimized_transformations == null)
+ build_optimized_transformations();
+
+ int dest_width = dest.get_width();
+ int dest_height = dest.get_height();
+ int dest_num_channels = dest.get_n_channels();
+ int dest_rowstride = dest.get_rowstride();
+ unowned uchar[] dest_pixels = dest.get_pixels();
+
+ int cache_pixel_ticker = 0;
+
+ for (int j = 0; j < dest_height; j++) {
+ int row_start_index = j * dest_rowstride;
+ int row_end_index = row_start_index + (dest_width * dest_num_channels);
+ for (int i = row_start_index; i < row_end_index; i += dest_num_channels) {
+ RGBAnalyticPixel pixel = RGBAnalyticPixel.from_components(
+ fp_pixel_cache[cache_pixel_ticker],
+ fp_pixel_cache[cache_pixel_ticker + 1],
+ fp_pixel_cache[cache_pixel_ticker + 2]);
+
+ cache_pixel_ticker += 3;
+
+ pixel = apply_transformations(pixel);
+
+ dest_pixels[i] = (uchar) (pixel.red * 255.0f);
+ dest_pixels[i + 1] = (uchar) (pixel.green * 255.0f);
+ dest_pixels[i + 2] = (uchar) (pixel.blue * 255.0f);
+ }
+ }
+ }
+
+ public void transform_to_other_pixbuf(Gdk.Pixbuf source, Gdk.Pixbuf dest,
+ Cancellable? cancellable = null) {
+ if (source.width != dest.width)
+ error("PixelTransformer: source and destination pixbufs must have the same width");
+
+ if (source.height != dest.height)
+ error("PixelTransformer: source and destination pixbufs must have the same height");
+
+ if (source.n_channels != dest.n_channels)
+ error("PixelTransformer: source and destination pixbufs must have the same number " +
+ "of channels");
+
+ if (optimized_transformations == null)
+ build_optimized_transformations();
+
+ int n_channels = source.get_n_channels();
+ int rowstride = source.get_rowstride();
+ int width = source.get_width();
+ int height = source.get_height();
+ int rowbytes = n_channels * width;
+ unowned uchar[] source_pixels = source.get_pixels();
+ unowned uchar[] dest_pixels = dest.get_pixels();
+ for (int j = 0; j < height; j++) {
+ int row_start_index = j * rowstride;
+ int row_end_index = row_start_index + rowbytes;
+ for (int i = row_start_index; i < row_end_index; i += n_channels) {
+ RGBAnalyticPixel current_pixel = RGBAnalyticPixel.from_quantized_components(
+ source_pixels[i], source_pixels[i + 1], source_pixels[i + 2]);
+
+ current_pixel = apply_transformations(current_pixel);
+
+ dest_pixels[i] = current_pixel.quantized_red();
+ dest_pixels[i + 1] = current_pixel.quantized_green();
+ dest_pixels[i + 2] = current_pixel.quantized_blue();
+ }
+
+ if ((cancellable != null) && (cancellable.is_cancelled())) {
+ return;
+ }
+ }
+ }
+}
+
+class RGBHistogram {
+ private const uchar MARKED_BACKGROUND = 30;
+ private const uchar MARKED_FOREGROUND = 210;
+ private const uchar UNMARKED_BACKGROUND = 120;
+
+ public const int GRAPHIC_WIDTH = 256;
+ public const int GRAPHIC_HEIGHT = 100;
+
+ private int[] red_counts = new int[256];
+ private int[] green_counts = new int[256];
+ private int[] blue_counts = new int[256];
+ private int[] qualitative_red_counts = null;
+ private int[] qualitative_green_counts = null;
+ private int[] qualitative_blue_counts = null;
+ private Gdk.Pixbuf graphic = null;
+
+ public RGBHistogram(Gdk.Pixbuf pixbuf) {
+ int sample_bytes = pixbuf.get_bits_per_sample() / 8;
+ int pixel_bytes = sample_bytes * pixbuf.get_n_channels();
+ int row_length_bytes = pixel_bytes * pixbuf.width;
+
+ unowned uchar[] pixel_data = pixbuf.get_pixels();
+
+ for (int y = 0; y < pixbuf.height; y++) {
+ int row_start_offset = y * pixbuf.rowstride;
+
+ int r_offset = row_start_offset;
+ int g_offset = row_start_offset + sample_bytes;
+ int b_offset = row_start_offset + sample_bytes + sample_bytes;
+
+ while (b_offset < (row_start_offset + row_length_bytes)) {
+ red_counts[pixel_data[r_offset]] += 1;
+ green_counts[pixel_data[g_offset]] += 1;
+ blue_counts[pixel_data[b_offset]] += 1;
+
+ r_offset += pixel_bytes;
+ g_offset += pixel_bytes;
+ b_offset += pixel_bytes;
+ }
+ }
+ }
+
+ private int correct_snap_to_quantization(int[] buckets, int i) {
+ assert(buckets.length == 256);
+ assert((i >= 0) && (i <= 255));
+
+ if (i == 0) {
+ if (buckets[i] > 0)
+ if (buckets[i + 1] > 0)
+ if (buckets[i] > (2 * buckets[i + 1]))
+ return buckets[i + 1];
+ } else if (i == 255) {
+ if (buckets[i] > 0)
+ if (buckets[i - 1] > 0)
+ if (buckets[i] > (2 * buckets[i - 1]))
+ return buckets[i - 1];
+ } else {
+ if (buckets[i] > 0)
+ if (buckets[i] > ((buckets[i - 1] + buckets[i + 1]) / 2))
+ return (buckets[i - 1] + buckets[i + 1]) / 2;
+ }
+
+ return buckets[i];
+ }
+
+ private int correct_snap_from_quantization(int[] buckets, int i) {
+ assert(buckets.length == 256);
+ assert((i >= 0) && (i <= 255));
+
+ if (i == 0) {
+ return buckets[i];
+ } else if (i == 255) {
+ return buckets[i];
+ } else {
+ if (buckets[i] == 0)
+ if (buckets[i - 1] > 0)
+ if (buckets[i + 1] > 0)
+ return (buckets[i - 1] + buckets[i + 1]) / 2;
+ }
+
+ return buckets[i];
+ }
+
+ private void smooth_extrema(ref int[] count_data) {
+ assert(count_data.length == 256);
+
+ /* the blocks of code below are unrolled loops that replace values at the extrema
+ (buckets 0-4 and 251-255, inclusive) of the histogram with a weighted
+ average of their neighbors. This mitigates quantization and pooling artifacts */
+
+ count_data[0] = (5 * count_data[0] + 3 * count_data[1] + 2 * count_data[2]) /
+ 10;
+ count_data[1] = (3 * count_data[0] + 5 * count_data[1] + 3 * count_data[2] +
+ 2 * count_data[3]) / 13;
+ count_data[2] = (2 * count_data[0] + 3 * count_data[1] + 5 * count_data[2] +
+ 3 * count_data[3] + 2 * count_data[4]) / 15;
+ count_data[3] = (2 * count_data[1] + 3 * count_data[2] + 5 * count_data[3] +
+ 3 * count_data[4] + 2 * count_data[5]) / 15;
+ count_data[4] = (2 * count_data[2] + 3 * count_data[3] + 5 * count_data[4] +
+ 3 * count_data[5] + 2 * count_data[6]) / 15;
+
+ count_data[255] = (5 * count_data[255] + 3 * count_data[254] + 2 * count_data[253]) /
+ 10;
+ count_data[254] = (3 * count_data[255] + 5 * count_data[254] + 3 * count_data[253] +
+ 2 * count_data[252]) / 13;
+ count_data[253] = (2 * count_data[255] + 3 * count_data[254] + 5 * count_data[253] +
+ 3 * count_data[252] + 2 * count_data[251]) / 15;
+ count_data[252] = (2 * count_data[254] + 3 * count_data[253] + 5 * count_data[252] +
+ 3 * count_data[251] + 2 * count_data[250]) / 15;
+ count_data[251] = (2 * count_data[253] + 3 * count_data[252] + 5 * count_data[251] +
+ 3 * count_data[250] + 2 * count_data[249]) / 15;
+ }
+
+ private void prepare_qualitative_counts() {
+ if ((qualitative_red_counts != null) && (qualitative_green_counts != null) &&
+ (qualitative_blue_counts != null))
+ return;
+
+ qualitative_red_counts = new int[256];
+ qualitative_green_counts = new int[256];
+ qualitative_blue_counts = new int[256];
+
+ int[] temp_red_counts = new int[256];
+ int[] temp_green_counts = new int[256];
+ int[] temp_blue_counts = new int[256];
+
+ /* Remove snap-away-from-value quantization artifacts from the qualitative
+ histogram. While these are indeed present in the underlying data as a
+ consequence of sampling, transformation, and reconstruction, they lead
+ to an unpleasant looking histogram, so we detect and eliminate them here */
+ for (int i = 0; i < 256; i++) {
+ qualitative_red_counts[i] =
+ correct_snap_from_quantization(red_counts, i);
+ qualitative_green_counts[i] =
+ correct_snap_from_quantization(green_counts, i);
+ qualitative_blue_counts[i] =
+ correct_snap_from_quantization(blue_counts, i);
+ }
+
+ for (int i = 0; i < 256; i++) {
+ temp_red_counts[i] = qualitative_red_counts[i];
+ temp_green_counts[i] = qualitative_green_counts[i];
+ temp_blue_counts[i] = qualitative_blue_counts[i];
+ }
+
+ /* Remove snap-to-value quantization artifacts from the qualitative
+ histogram */
+ for (int i = 0; i < 256; i++) {
+ qualitative_red_counts[i] =
+ correct_snap_to_quantization(temp_red_counts, i);
+ qualitative_green_counts[i] =
+ correct_snap_to_quantization(temp_green_counts, i);
+ qualitative_blue_counts[i] =
+ correct_snap_to_quantization(temp_blue_counts, i);
+ }
+
+ /* constrain the peaks in the qualitative histogram so that no peak can be more
+ than 8 times higher than the mean height of the entire image */
+ int mean_qual_count = 0;
+ for (int i = 0; i < 256; i++) {
+ mean_qual_count += (qualitative_red_counts[i] + qualitative_green_counts[i] +
+ qualitative_blue_counts[i]);
+ }
+ mean_qual_count /= (256 * 3);
+ int constrained_max_qual_count = 8 * mean_qual_count;
+ for (int i = 0; i < 256; i++) {
+ if (qualitative_red_counts[i] > constrained_max_qual_count)
+ qualitative_red_counts[i] = constrained_max_qual_count;
+
+ if (qualitative_green_counts[i] > constrained_max_qual_count)
+ qualitative_green_counts[i] = constrained_max_qual_count;
+
+ if (qualitative_blue_counts[i] > constrained_max_qual_count)
+ qualitative_blue_counts[i] = constrained_max_qual_count;
+ }
+
+ smooth_extrema(ref qualitative_red_counts);
+ smooth_extrema(ref qualitative_green_counts);
+ smooth_extrema(ref qualitative_blue_counts);
+ }
+
+ public Gdk.Pixbuf get_graphic() {
+ if (graphic == null) {
+ prepare_qualitative_counts();
+ int max_count = 0;
+ for (int i = 0; i < 256; i++) {
+ if (qualitative_red_counts[i] > max_count)
+ max_count = qualitative_red_counts[i];
+ if (qualitative_green_counts[i] > max_count)
+ max_count = qualitative_green_counts[i];
+ if (qualitative_blue_counts[i] > max_count)
+ max_count = qualitative_blue_counts[i];
+ }
+
+ graphic = new Gdk.Pixbuf(Gdk.Colorspace.RGB, false, 8,
+ GRAPHIC_WIDTH, GRAPHIC_HEIGHT);
+
+ int rowstride = graphic.rowstride;
+ int sample_bytes = graphic.get_bits_per_sample() / 8;
+ int pixel_bytes = sample_bytes * graphic.get_n_channels();
+
+ double scale_bar = 0.98 * ((double) GRAPHIC_HEIGHT) /
+ ((double) max_count);
+
+ unowned uchar[] pixel_data = graphic.get_pixels();
+
+ /* detect pathological case of bilevel black & white images -- in this case, draw
+ a blank histogram and return it to the caller */
+ if (max_count == 0) {
+ for (int i = 0; i < (pixel_bytes * graphic.width * graphic.height); i++) {
+ pixel_data[i] = UNMARKED_BACKGROUND;
+ }
+ return graphic;
+ }
+
+ for (int x = 0; x < 256; x++) {
+ int red_bar_height = (int)(((double) qualitative_red_counts[x]) *
+ scale_bar);
+ int green_bar_height = (int)(((double) qualitative_green_counts[x]) *
+ scale_bar);
+ int blue_bar_height = (int)(((double) qualitative_blue_counts[x]) *
+ scale_bar);
+
+ int max_bar_height = int.max(int.max(red_bar_height,
+ green_bar_height), blue_bar_height);
+
+ int y = GRAPHIC_HEIGHT - 1;
+ int pixel_index = (x * pixel_bytes) + (y * rowstride);
+ for ( ; y >= (GRAPHIC_HEIGHT - max_bar_height); y--) {
+ pixel_data[pixel_index] = MARKED_BACKGROUND;
+ pixel_data[pixel_index + 1] = MARKED_BACKGROUND;
+ pixel_data[pixel_index + 2] = MARKED_BACKGROUND;
+
+ if (y >= (GRAPHIC_HEIGHT - red_bar_height - 1))
+ pixel_data[pixel_index] = MARKED_FOREGROUND;
+ if (y >= (GRAPHIC_HEIGHT - green_bar_height - 1))
+ pixel_data[pixel_index + 1] = MARKED_FOREGROUND;
+ if (y >= (GRAPHIC_HEIGHT - blue_bar_height - 1))
+ pixel_data[pixel_index + 2] = MARKED_FOREGROUND;
+
+ pixel_index -= rowstride;
+ }
+
+ for ( ; y >= 0; y--) {
+ pixel_data[pixel_index] = UNMARKED_BACKGROUND;
+ pixel_data[pixel_index + 1] = UNMARKED_BACKGROUND;
+ pixel_data[pixel_index + 2] = UNMARKED_BACKGROUND;
+
+ pixel_index -= rowstride;
+ }
+ }
+ }
+
+ return graphic;
+ }
+}
+
+public class IntensityHistogram {
+ private int[] counts = new int[256];
+ private float[] probabilities = new float[256];
+ private float[] cumulative_probabilities = new float[256];
+
+ public IntensityHistogram(Gdk.Pixbuf pixbuf) {
+ int n_channels = pixbuf.get_n_channels();
+ int rowstride = pixbuf.get_rowstride();
+ int width = pixbuf.get_width();
+ int height = pixbuf.get_height();
+ int rowbytes = n_channels * width;
+ unowned uchar[] pixels = pixbuf.get_pixels();
+ for (int j = 0; j < height; j++) {
+ int row_start_index = j * rowstride;
+ int row_end_index = row_start_index + rowbytes;
+ for (int i = row_start_index; i < row_end_index; i += n_channels) {
+ RGBAnalyticPixel pix_rgb = RGBAnalyticPixel.from_quantized_components(
+ pixels[i], pixels[i + 1], pixels[i + 2]);
+ HSVAnalyticPixel pix_hsi = HSVAnalyticPixel.from_rgb(pix_rgb);
+ int quantized_light_value = (int)(pix_hsi.light_value * 255.0f);
+ counts[quantized_light_value] += 1;
+ }
+ }
+
+ float pixel_count = (float)(pixbuf.width * pixbuf.height);
+ float accumulator = 0.0f;
+ for (int i = 0; i < 256; i++) {
+ probabilities[i] = ((float) counts[i]) / pixel_count;
+ accumulator += probabilities[i];
+ cumulative_probabilities[i] = accumulator;
+ }
+ }
+
+ public float get_cumulative_probability(int level) {
+ // clamp out-of-range pixels to prevent crashing.
+ level = level.clamp(0, 255);
+ return cumulative_probabilities[level];
+ }
+}
+
+public class ExpansionTransformation : HSVTransformation {
+ private float[] remap_table = null;
+ private const float LOW_DISCARD_MASS = 0.02f;
+ private const float HIGH_DISCARD_MASS = 0.02f;
+
+ private int low_kink;
+ private int high_kink;
+
+ public ExpansionTransformation(IntensityHistogram histogram) {
+ base(PixelTransformationType.TONE_EXPANSION);
+
+ remap_table = new float[256];
+
+ float LOW_KINK_MASS = LOW_DISCARD_MASS;
+ low_kink = 0;
+ while (histogram.get_cumulative_probability(low_kink) < LOW_KINK_MASS)
+ low_kink++;
+
+ float HIGH_KINK_MASS = 1.0f - HIGH_DISCARD_MASS;
+ high_kink = 255;
+ while ((histogram.get_cumulative_probability(high_kink) > HIGH_KINK_MASS) && (high_kink > 0))
+ high_kink--;
+
+ build_remap_table();
+ }
+
+ public ExpansionTransformation.from_extrema(int black_point, int white_point) {
+ base(PixelTransformationType.TONE_EXPANSION);
+
+ white_point = white_point.clamp(0, 255);
+ black_point = black_point.clamp(0, 255);
+
+ if (black_point == white_point) {
+ if (black_point == 0)
+ white_point = 1;
+ else if (white_point == 255)
+ black_point = 254;
+ else
+ black_point = white_point - 1;
+ }
+
+ low_kink = black_point;
+ high_kink = white_point;
+
+ build_remap_table();
+ }
+
+ public ExpansionTransformation.from_string(string encoded_transformation) {
+ base(PixelTransformationType.TONE_EXPANSION);
+
+ encoded_transformation.canon("0123456789. ", ' ');
+ encoded_transformation.chug();
+ encoded_transformation.chomp();
+
+ int num_captured = encoded_transformation.scanf("%d %d", &low_kink,
+ &high_kink);
+
+ assert(num_captured == 2);
+
+ build_remap_table();
+ }
+
+ private void build_remap_table() {
+ if (remap_table == null)
+ remap_table = new float[256];
+
+ float low_kink_f = ((float) low_kink) / 255.0f;
+ float high_kink_f = ((float) high_kink) / 255.0f;
+
+ float slope = 1.0f / (high_kink_f - low_kink_f);
+ float intercept = -(low_kink_f / (high_kink_f - low_kink_f));
+
+ int i = 0;
+ for ( ; i <= low_kink; i++)
+ remap_table[i] = 0.0f;
+
+ for ( ; i < high_kink; i++)
+ remap_table[i] = slope * (((float) i) / 255.0f) + intercept;
+
+ for ( ; i < 256; i++)
+ remap_table[i] = 1.0f;
+ }
+
+ public override HSVAnalyticPixel transform_pixel_hsv(HSVAnalyticPixel pixel) {
+ int remap_index = (int)(pixel.light_value * 255.0f);
+
+ HSVAnalyticPixel result = pixel;
+ result.light_value = remap_table[remap_index];
+
+ result.light_value = result.light_value.clamp(0.0f, 1.0f);
+
+ return result;
+ }
+
+ public override string to_string() {
+ return "{ %d, %d }".printf(low_kink, high_kink);
+ }
+
+ public int get_white_point() {
+ return high_kink;
+ }
+
+ public int get_black_point() {
+ return low_kink;
+ }
+
+ public override bool is_identity() {
+ return ((low_kink == 0) && (high_kink == 255));
+ }
+
+ public override PixelTransformation copy() {
+ return new ExpansionTransformation.from_extrema(low_kink, high_kink);
+ }
+}
+
+public class ShadowDetailTransformation : HSVTransformation {
+ private const float MAX_EFFECT_SHIFT = 0.5f;
+ private const float MIN_TONAL_WIDTH = 0.1f;
+ private const float MAX_TONAL_WIDTH = 1.0f;
+ private const float TONAL_WIDTH = 1.0f;
+
+ private float intensity = 0.0f;
+ private float[] remap_table = null;
+
+ public const float MIN_PARAMETER = 0.0f;
+ public const float MAX_PARAMETER = 32.0f;
+
+ public ShadowDetailTransformation(float user_intensity) {
+ base(PixelTransformationType.SHADOWS);
+
+ intensity = user_intensity;
+ float intensity_adj = (intensity / MAX_PARAMETER).clamp(0.0f, 1.0f);
+
+ float effect_shift = MAX_EFFECT_SHIFT * intensity_adj;
+ HermiteGammaApproximationFunction func =
+ new HermiteGammaApproximationFunction(TONAL_WIDTH);
+
+ remap_table = new float[256];
+ for (int i = 0; i < 256; i++) {
+ float x = ((float) i) / 255.0f;
+ float weight = func.evaluate(x);
+ remap_table[i] = (weight * (x + effect_shift)) + ((1.0f - weight) * x);
+ }
+ }
+
+ public override HSVAnalyticPixel transform_pixel_hsv(HSVAnalyticPixel pixel) {
+ HSVAnalyticPixel result = pixel;
+ result.light_value = (remap_table[(int)(pixel.light_value * 255.0f)]).clamp(0.0f, 1.0f);
+ return result;
+ }
+
+ public override PixelTransformation copy() {
+ return new ShadowDetailTransformation(intensity);
+ }
+
+ public override bool is_identity() {
+ return (intensity == 0.0f);
+ }
+
+ public float get_parameter() {
+ return intensity;
+ }
+}
+
+public class HermiteGammaApproximationFunction {
+ private float x_scale = 1.0f;
+ private float nonzero_interval_upper = 1.0f;
+
+ public HermiteGammaApproximationFunction(float user_interval_upper) {
+ nonzero_interval_upper = user_interval_upper.clamp(0.1f, 1.0f);
+ x_scale = 1.0f / nonzero_interval_upper;
+ }
+
+ public float evaluate(float x) {
+ if (x < 0.0f)
+ return 0.0f;
+ else if (x > nonzero_interval_upper)
+ return 0.0f;
+ else {
+ float indep_var = x_scale * x;
+
+ float dep_var = 6.0f * ((indep_var * indep_var * indep_var) -
+ (2.0f * (indep_var * indep_var)) + (indep_var));
+
+ return dep_var.clamp(0.0f, 1.0f);
+ }
+ }
+}
+
+public class HighlightDetailTransformation : HSVTransformation {
+ private const float MAX_EFFECT_SHIFT = 0.5f;
+ private const float MIN_TONAL_WIDTH = 0.1f;
+ private const float MAX_TONAL_WIDTH = 1.0f;
+ private const float TONAL_WIDTH = 1.0f;
+
+ private float intensity = 0.0f;
+ private float[] remap_table = null;
+
+ public const float MIN_PARAMETER = -32.0f;
+ public const float MAX_PARAMETER = 0.0f;
+
+ public HighlightDetailTransformation(float user_intensity) {
+ base(PixelTransformationType.HIGHLIGHTS);
+
+ intensity = user_intensity;
+ float intensity_adj = (intensity / MIN_PARAMETER).clamp(0.0f, 1.0f);
+
+ float effect_shift = MAX_EFFECT_SHIFT * intensity_adj;
+ HermiteGammaApproximationFunction func =
+ new HermiteGammaApproximationFunction(TONAL_WIDTH);
+
+ remap_table = new float[256];
+ for (int i = 0; i < 256; i++) {
+ float x = ((float) i) / 255.0f;
+ float weight = func.evaluate(1.0f - x);
+ remap_table[i] = (weight * (x - effect_shift)) + ((1.0f - weight) * x);
+ }
+ }
+
+ public override HSVAnalyticPixel transform_pixel_hsv(HSVAnalyticPixel pixel) {
+ HSVAnalyticPixel result = pixel;
+ result.light_value = (remap_table[(int)(pixel.light_value * 255.0f)]).clamp(0.0f, 1.0f);
+ return result;
+ }
+
+ public override PixelTransformation copy() {
+ return new HighlightDetailTransformation(intensity);
+ }
+
+ public override bool is_identity() {
+ return (intensity == 0.0f);
+ }
+
+ public float get_parameter() {
+ return intensity;
+ }
+}
+
+namespace AutoEnhance {
+ const int SHADOW_DETECT_MIN_INTENSITY = 8;
+ const int SHADOW_DETECT_MAX_INTENSITY = 100;
+ const int SHADOW_DETECT_INTENSITY_RANGE = SHADOW_DETECT_MAX_INTENSITY -
+ SHADOW_DETECT_MIN_INTENSITY;
+ const float SHADOW_MODE_HIGH_DISCARD_MASS = 0.02f;
+ const float SHADOW_AGGRESSIVENESS_MUL = 0.4f;
+ const int EMPIRICAL_DARK = 30;
+
+public PixelTransformationBundle create_auto_enhance_adjustments(Gdk.Pixbuf pixbuf) {
+ PixelTransformationBundle adjustments = new PixelTransformationBundle();
+
+ IntensityHistogram analysis_histogram = new IntensityHistogram(pixbuf);
+ /* compute the percentage of pixels in the image that fall into the shadow range --
+ this measures "of the pixels in the image, how many of them are in shadow?" */
+ float pct_in_range =
+ 100.0f *(analysis_histogram.get_cumulative_probability(SHADOW_DETECT_MAX_INTENSITY) -
+ analysis_histogram.get_cumulative_probability(SHADOW_DETECT_MIN_INTENSITY));
+
+ /* compute the mean intensity of the pixels that are in the shadow range -- this measures
+ "of those pixels that are in shadow, just how dark are they?" */
+ float shadow_range_mean_prob_val =
+ (analysis_histogram.get_cumulative_probability(SHADOW_DETECT_MIN_INTENSITY) +
+ analysis_histogram.get_cumulative_probability(SHADOW_DETECT_MAX_INTENSITY)) * 0.5f;
+ int shadow_mean_intensity = SHADOW_DETECT_MIN_INTENSITY;
+ for ( ; shadow_mean_intensity <= SHADOW_DETECT_MAX_INTENSITY; shadow_mean_intensity++) {
+ if (analysis_histogram.get_cumulative_probability(shadow_mean_intensity) >= shadow_range_mean_prob_val)
+ break;
+ }
+
+ /* if more than 40 percent of the pixels in the image are in the shadow detection range,
+ or if the mean intensity within the shadow range is less than 50 (an empirically
+ determined threshold below which pixels appear very dark), regardless of the
+ percent of pixels in it, then perform shadow detail enhancement. Otherwise,
+ skip shadow detail enhancement and perform a traditional contrast expansion */
+ if ((pct_in_range > 40.0f) || (pct_in_range > 20.0f) && (shadow_mean_intensity < EMPIRICAL_DARK)) {
+ float shadow_trans_effect_size = ((((float) SHADOW_DETECT_MAX_INTENSITY) -
+ ((float) shadow_mean_intensity)) / ((float) SHADOW_DETECT_INTENSITY_RANGE)) *
+ ShadowDetailTransformation.MAX_PARAMETER;
+
+ shadow_trans_effect_size *= SHADOW_AGGRESSIVENESS_MUL;
+
+ adjustments.set(new ShadowDetailTransformation(shadow_trans_effect_size));
+
+ /* if shadow detail expansion is being performed, we still perform contrast expansion,
+ but only on the top end */
+ int discard_point = 255;
+ for ( ; discard_point > -1; discard_point--) {
+ if ((1.0f - analysis_histogram.get_cumulative_probability(discard_point)) >
+ SHADOW_MODE_HIGH_DISCARD_MASS)
+ break;
+ }
+
+ adjustments.set(new ExpansionTransformation.from_extrema(0, discard_point));
+ }
+ else {
+ adjustments.set(new ExpansionTransformation(analysis_histogram));
+ adjustments.set(new ShadowDetailTransformation(0));
+ }
+ /* zero out any existing color transformations as these may conflict with
+ auto-enhancement */
+ adjustments.set(new HighlightDetailTransformation(0.0f));
+ adjustments.set(new TemperatureTransformation(0.0f));
+ adjustments.set(new TintTransformation(0.0f));
+ adjustments.set(new ExposureTransformation(0.0f));
+ adjustments.set(new SaturationTransformation(0.0f));
+
+ return adjustments;
+}
+}
+
diff --git a/src/CommandManager.vala b/src/CommandManager.vala
new file mode 100644
index 0000000..070bc73
--- /dev/null
+++ b/src/CommandManager.vala
@@ -0,0 +1,201 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public interface CommandDescription : Object {
+ public abstract string get_name();
+
+ public abstract string get_explanation();
+}
+
+// Command's overrideable action calls are guaranteed to be called in this order:
+//
+// * prepare()
+// * execute() (once and only once)
+// * prepare()
+// * undo()
+// * prepare()
+// * redo()
+// * prepare()
+// * undo()
+// * prepare()
+// * redo() ...
+//
+// redo()'s default implementation is to call execute, which in many cases is appropriate.
+public abstract class Command : Object, CommandDescription {
+ private string name;
+ private string explanation;
+ private weak CommandManager manager = null;
+
+ public Command(string name, string explanation) {
+ this.name = name;
+ this.explanation = explanation;
+ }
+
+ ~Command() {
+#if TRACE_DTORS
+ debug("DTOR: Command %s (%s)", name, explanation);
+#endif
+ }
+
+ public virtual void prepare() {
+ }
+
+ public abstract void execute();
+
+ public abstract void undo();
+
+ public virtual void redo() {
+ execute();
+ }
+
+ // Command compression, allowing multiple commands of similar type to be undone/redone at the
+ // same time. If this method returns true, it's assumed the passed Command has been executed.
+ public virtual bool compress(Command command) {
+ return false;
+ }
+
+ public virtual string get_name() {
+ return name;
+ }
+
+ public virtual string get_explanation() {
+ return explanation;
+ }
+
+ public CommandManager? get_command_manager() {
+ return manager;
+ }
+
+ // This should only be called by CommandManager.
+ public void internal_set_command_manager(CommandManager manager) {
+ assert(this.manager == null);
+
+ this.manager = manager;
+ }
+}
+
+public class CommandManager {
+ public const int DEFAULT_DEPTH = 20;
+
+ private int depth;
+ private Gee.ArrayList<Command> undo_stack = new Gee.ArrayList<Command>();
+ private Gee.ArrayList<Command> redo_stack = new Gee.ArrayList<Command>();
+
+ public signal void altered(bool can_undo, bool can_redo);
+
+ public CommandManager(int depth = DEFAULT_DEPTH) {
+ assert(depth > 0);
+
+ this.depth = depth;
+ }
+
+ public void reset() {
+ undo_stack.clear();
+ redo_stack.clear();
+
+ altered(false, false);
+ }
+
+ public void execute(Command command) {
+ // assign command to this manager
+ command.internal_set_command_manager(this);
+
+ // clear redo stack; executing a command implies not going to undo an undo
+ redo_stack.clear();
+
+ // see if this command can be compressed (merged) with the topmost command
+ Command? top_command = top(undo_stack);
+ if (top_command != null) {
+ if (top_command.compress(command))
+ return;
+ }
+
+ // update state before executing command
+ push(undo_stack, command);
+
+ command.prepare();
+ command.execute();
+
+ // notify after execution
+ altered(can_undo(), can_redo());
+ }
+
+ public bool can_undo() {
+ return undo_stack.size > 0;
+ }
+
+ public CommandDescription? get_undo_description() {
+ return top(undo_stack);
+ }
+
+ public bool undo() {
+ Command? command = pop(undo_stack);
+ if (command == null)
+ return false;
+
+ // update state before execution
+ push(redo_stack, command);
+
+ // undo command with state ready
+ command.prepare();
+ command.undo();
+
+ // report state changed after command has executed
+ altered(can_undo(), can_redo());
+
+ return true;
+ }
+
+ public bool can_redo() {
+ return redo_stack.size > 0;
+ }
+
+ public CommandDescription? get_redo_description() {
+ return top(redo_stack);
+ }
+
+ public bool redo() {
+ Command? command = pop(redo_stack);
+ if (command == null)
+ return false;
+
+ // update state before execution
+ push(undo_stack, command);
+
+ // redo command with state ready
+ command.prepare();
+ command.redo();
+
+ // report state changed after command has executed
+ altered(can_undo(), can_redo());
+
+ return true;
+ }
+
+ private Command? top(Gee.ArrayList<Command> stack) {
+ return (stack.size > 0) ? stack.get(stack.size - 1) : null;
+ }
+
+ private void push(Gee.ArrayList<Command> stack, Command command) {
+ stack.add(command);
+
+ // maintain a max depth
+ while (stack.size >= depth)
+ stack.remove_at(0);
+ }
+
+ private Command? pop(Gee.ArrayList<Command> stack) {
+ if (stack.size <= 0)
+ return null;
+
+ Command command = stack.get(stack.size - 1);
+ bool removed = stack.remove(command);
+ assert(removed);
+
+ return command;
+ }
+}
+
diff --git a/src/Commands.vala b/src/Commands.vala
new file mode 100644
index 0000000..0ad8ecb
--- /dev/null
+++ b/src/Commands.vala
@@ -0,0 +1,2497 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// PageCommand stores the current page when a Command is created. Subclasses can call return_to_page()
+// if it's appropriate to return to that page when executing an undo() or redo().
+public abstract class PageCommand : Command {
+ private Page? page;
+ private bool auto_return = true;
+ private Photo library_photo = null;
+ private CollectionPage collection_page = null;
+
+ public PageCommand(string name, string explanation) {
+ base (name, explanation);
+
+ page = AppWindow.get_instance().get_current_page();
+
+ if (page != null) {
+ page.destroy.connect(on_page_destroyed);
+
+ // If the command occurred on a LibaryPhotoPage, the PageCommand must record additional
+ // objects to be restore it to its old state: a specific photo to focus on, a page to return
+ // to, and a view collection to operate over. Note that these objects can be cleared if
+ // the page goes into the background. The required objects are stored below.
+ LibraryPhotoPage photo_page = page as LibraryPhotoPage;
+ if (photo_page != null) {
+ library_photo = photo_page.get_photo();
+ collection_page = photo_page.get_controller_page();
+
+ if (library_photo != null && collection_page != null) {
+ library_photo.destroyed.connect(on_photo_destroyed);
+ collection_page.destroy.connect(on_controller_destroyed);
+ } else {
+ library_photo = null;
+ collection_page = null;
+ }
+ }
+ }
+ }
+
+ ~PageCommand() {
+ if (page != null)
+ page.destroy.disconnect(on_page_destroyed);
+
+ if (library_photo != null)
+ library_photo.destroyed.disconnect(on_photo_destroyed);
+
+ if (collection_page != null)
+ collection_page.destroy.disconnect(on_controller_destroyed);
+ }
+
+ public void set_auto_return_to_page(bool auto_return) {
+ this.auto_return = auto_return;
+ }
+
+ public override void prepare() {
+ if (auto_return)
+ return_to_page();
+
+ base.prepare();
+ }
+
+ public void return_to_page() {
+ LibraryPhotoPage photo_page = page as LibraryPhotoPage;
+
+ if (photo_page != null) {
+ if (library_photo != null && collection_page != null) {
+ bool photo_in_collection = false;
+ int count = collection_page.get_view().get_count();
+ for (int i = 0; i < count; i++) {
+ if ( ((Thumbnail) collection_page.get_view().get_at(i)).get_media_source() == library_photo) {
+ photo_in_collection = true;
+ break;
+ }
+ }
+
+ if (photo_in_collection)
+ LibraryWindow.get_app().switch_to_photo_page(collection_page, library_photo);
+ }
+ } else if (page != null)
+ AppWindow.get_instance().set_current_page(page);
+ }
+
+ private void on_page_destroyed() {
+ page.destroy.disconnect(on_page_destroyed);
+ page = null;
+ }
+
+ private void on_photo_destroyed() {
+ library_photo.destroyed.disconnect(on_photo_destroyed);
+ library_photo = null;
+ }
+
+ private void on_controller_destroyed() {
+ collection_page.destroy.disconnect(on_controller_destroyed);
+ collection_page = null;
+ }
+
+}
+
+public abstract class SingleDataSourceCommand : PageCommand {
+ protected DataSource source;
+
+ public SingleDataSourceCommand(DataSource source, string name, string explanation) {
+ base(name, explanation);
+
+ this.source = source;
+
+ source.destroyed.connect(on_source_destroyed);
+ }
+
+ ~SingleDataSourceCommand() {
+ source.destroyed.disconnect(on_source_destroyed);
+ }
+
+ public DataSource get_source() {
+ return source;
+ }
+
+ private void on_source_destroyed() {
+ // too much risk in simply removing this from the CommandManager; if this is considered too
+ // broad a brushstroke, can return to this later
+ get_command_manager().reset();
+ }
+}
+
+public abstract class SimpleProxyableCommand : PageCommand {
+ private SourceProxy proxy;
+ private Gee.HashSet<SourceProxy> proxies = new Gee.HashSet<SourceProxy>();
+
+ public SimpleProxyableCommand(Proxyable proxyable, string name, string explanation) {
+ base (name, explanation);
+
+ proxy = proxyable.get_proxy();
+ proxy.broken.connect(on_proxy_broken);
+ }
+
+ ~SimpleProxyableCommand() {
+ proxy.broken.disconnect(on_proxy_broken);
+ clear_added_proxies();
+ }
+
+ public override void execute() {
+ execute_on_source(proxy.get_source());
+ }
+
+ protected abstract void execute_on_source(DataSource source);
+
+ public override void undo() {
+ undo_on_source(proxy.get_source());
+ }
+
+ protected abstract void undo_on_source(DataSource source);
+
+ // If the Command deals with other Proxyables during processing, it can add them here and the
+ // SimpleProxyableCommand will deal with created a SourceProxy and if it signals it's broken.
+ // Note that these cannot be removed programatically, but only cleared en masse; it's expected
+ // this is fine for the nature of a Command.
+ protected void add_proxyables(Gee.Collection<Proxyable> proxyables) {
+ foreach (Proxyable proxyable in proxyables) {
+ SourceProxy added_proxy = proxyable.get_proxy();
+ added_proxy.broken.connect(on_proxy_broken);
+ proxies.add(added_proxy);
+ }
+ }
+
+ // See add_proxyables() for a note on use.
+ protected void clear_added_proxies() {
+ foreach (SourceProxy added_proxy in proxies)
+ added_proxy.broken.disconnect(on_proxy_broken);
+
+ proxies.clear();
+ }
+
+ private void on_proxy_broken() {
+ debug("on_proxy_broken");
+ get_command_manager().reset();
+ }
+}
+
+public abstract class SinglePhotoTransformationCommand : SingleDataSourceCommand {
+ private PhotoTransformationState state;
+
+ public SinglePhotoTransformationCommand(Photo photo, string name, string explanation) {
+ base(photo, name, explanation);
+
+ state = photo.save_transformation_state();
+ state.broken.connect(on_state_broken);
+ }
+
+ ~SinglePhotoTransformationCommand() {
+ state.broken.disconnect(on_state_broken);
+ }
+
+ public override void undo() {
+ ((Photo) source).load_transformation_state(state);
+ }
+
+ private void on_state_broken() {
+ get_command_manager().reset();
+ }
+}
+
+public abstract class GenericPhotoTransformationCommand : SingleDataSourceCommand {
+ private PhotoTransformationState original_state = null;
+ private PhotoTransformationState transformed_state = null;
+
+ public GenericPhotoTransformationCommand(Photo photo, string name, string explanation) {
+ base(photo, name, explanation);
+ }
+
+ ~GenericPhotoTransformationState() {
+ if (original_state != null)
+ original_state.broken.disconnect(on_state_broken);
+
+ if (transformed_state != null)
+ transformed_state.broken.disconnect(on_state_broken);
+ }
+
+ public override void execute() {
+ Photo photo = (Photo) source;
+
+ original_state = photo.save_transformation_state();
+ original_state.broken.connect(on_state_broken);
+
+ execute_on_photo(photo);
+
+ transformed_state = photo.save_transformation_state();
+ transformed_state.broken.connect(on_state_broken);
+ }
+
+ public abstract void execute_on_photo(Photo photo);
+
+ public override void undo() {
+ // use the original state of the photo
+ ((Photo) source).load_transformation_state(original_state);
+ }
+
+ public override void redo() {
+ // use the state of the photo after transformation
+ ((Photo) source).load_transformation_state(transformed_state);
+ }
+
+ protected virtual bool can_compress(Command command) {
+ return false;
+ }
+
+ public override bool compress(Command command) {
+ if (!can_compress(command))
+ return false;
+
+ GenericPhotoTransformationCommand generic = command as GenericPhotoTransformationCommand;
+ if (generic == null)
+ return false;
+
+ if (generic.source != source)
+ return false;
+
+ // execute this new (and successive) command
+ generic.execute();
+
+ // save it's new transformation state as ours
+ transformed_state = generic.transformed_state;
+
+ return true;
+ }
+
+ private void on_state_broken() {
+ get_command_manager().reset();
+ }
+}
+
+public abstract class MultipleDataSourceCommand : PageCommand {
+ protected const int MIN_OPS_FOR_PROGRESS_WINDOW = 5;
+
+ protected Gee.ArrayList<DataSource> source_list = new Gee.ArrayList<DataSource>();
+
+ private string progress_text;
+ private string undo_progress_text;
+ private Gee.ArrayList<DataSource> acted_upon = new Gee.ArrayList<DataSource>();
+ private Gee.HashSet<SourceCollection> hooked_collections = new Gee.HashSet<SourceCollection>();
+
+ public MultipleDataSourceCommand(Gee.Iterable<DataView> iter, string progress_text,
+ string undo_progress_text, string name, string explanation) {
+ base(name, explanation);
+
+ this.progress_text = progress_text;
+ this.undo_progress_text = undo_progress_text;
+
+ foreach (DataView view in iter) {
+ DataSource source = view.get_source();
+ SourceCollection? collection = (SourceCollection) source.get_membership();
+
+ if (collection != null) {
+ hooked_collections.add(collection);
+ }
+ source_list.add(source);
+ }
+
+ foreach (SourceCollection current_collection in hooked_collections) {
+ current_collection.item_destroyed.connect(on_source_destroyed);
+ }
+ }
+
+ ~MultipleDataSourceCommand() {
+ foreach (SourceCollection current_collection in hooked_collections) {
+ current_collection.item_destroyed.disconnect(on_source_destroyed);
+ }
+ }
+
+ public Gee.Iterable<DataSource> get_sources() {
+ return source_list;
+ }
+
+ public int get_source_count() {
+ return source_list.size;
+ }
+
+ private void on_source_destroyed(DataSource source) {
+ // as with SingleDataSourceCommand, too risky to selectively remove commands from the stack,
+ // although this could be reconsidered in the future
+ if (source_list.contains(source))
+ get_command_manager().reset();
+ }
+
+ public override void execute() {
+ acted_upon.clear();
+
+ start_transaction();
+ execute_all(true, true, source_list, acted_upon);
+ commit_transaction();
+ }
+
+ public abstract void execute_on_source(DataSource source);
+
+ public override void undo() {
+ if (acted_upon.size > 0) {
+ start_transaction();
+ execute_all(false, false, acted_upon, null);
+ commit_transaction();
+
+ acted_upon.clear();
+ }
+ }
+
+ public abstract void undo_on_source(DataSource source);
+
+ private void start_transaction() {
+ foreach (SourceCollection sources in hooked_collections) {
+ MediaSourceCollection? media_collection = sources as MediaSourceCollection;
+ if (media_collection != null)
+ media_collection.transaction_controller.begin();
+ }
+ }
+
+ private void commit_transaction() {
+ foreach (SourceCollection sources in hooked_collections) {
+ MediaSourceCollection? media_collection = sources as MediaSourceCollection;
+ if (media_collection != null)
+ media_collection.transaction_controller.commit();
+ }
+ }
+
+ private void execute_all(bool exec, bool can_cancel, Gee.ArrayList<DataSource> todo,
+ Gee.ArrayList<DataSource>? completed) {
+ AppWindow.get_instance().set_busy_cursor();
+
+ int count = 0;
+ int total = todo.size;
+ int two_percent = (int) ((double) total / 50.0);
+ if (two_percent <= 0)
+ two_percent = 1;
+
+ string text = exec ? progress_text : undo_progress_text;
+
+ Cancellable cancellable = null;
+ ProgressDialog progress = null;
+ if (total >= MIN_OPS_FOR_PROGRESS_WINDOW) {
+ cancellable = can_cancel ? new Cancellable() : null;
+ progress = new ProgressDialog(AppWindow.get_instance(), text, cancellable);
+ }
+
+ foreach (DataSource source in todo) {
+ if (exec)
+ execute_on_source(source);
+ else
+ undo_on_source(source);
+
+ if (completed != null)
+ completed.add(source);
+
+ if (progress != null) {
+ if ((++count % two_percent) == 0) {
+ progress.set_fraction(count, total);
+ spin_event_loop();
+ }
+
+ if (cancellable != null && cancellable.is_cancelled())
+ break;
+ }
+ }
+
+ if (progress != null)
+ progress.close();
+
+ AppWindow.get_instance().set_normal_cursor();
+ }
+}
+
+// TODO: Upgrade MultipleDataSourceAtOnceCommand to use TransactionControllers.
+public abstract class MultipleDataSourceAtOnceCommand : PageCommand {
+ private Gee.HashSet<DataSource> sources = new Gee.HashSet<DataSource>();
+ private Gee.HashSet<SourceCollection> hooked_collections = new Gee.HashSet<SourceCollection>();
+
+ public MultipleDataSourceAtOnceCommand(Gee.Collection<DataSource> sources, string name,
+ string explanation) {
+ base (name, explanation);
+
+ this.sources.add_all(sources);
+
+ foreach (DataSource source in this.sources) {
+ SourceCollection? membership = source.get_membership() as SourceCollection;
+ if (membership != null)
+ hooked_collections.add(membership);
+ }
+
+ foreach (SourceCollection source_collection in hooked_collections)
+ source_collection.items_destroyed.connect(on_sources_destroyed);
+ }
+
+ ~MultipleDataSourceAtOnceCommand() {
+ foreach (SourceCollection source_collection in hooked_collections)
+ source_collection.items_destroyed.disconnect(on_sources_destroyed);
+ }
+
+ public override void execute() {
+ AppWindow.get_instance().set_busy_cursor();
+
+ DatabaseTable.begin_transaction();
+ MediaCollectionRegistry.get_instance().freeze_all();
+
+ execute_on_all(sources);
+
+ MediaCollectionRegistry.get_instance().thaw_all();
+ try {
+ DatabaseTable.commit_transaction();
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ } finally {
+ AppWindow.get_instance().set_normal_cursor();
+ }
+ }
+
+ protected abstract void execute_on_all(Gee.Collection<DataSource> sources);
+
+ public override void undo() {
+ AppWindow.get_instance().set_busy_cursor();
+
+ DatabaseTable.begin_transaction();
+ MediaCollectionRegistry.get_instance().freeze_all();
+
+ undo_on_all(sources);
+
+ MediaCollectionRegistry.get_instance().thaw_all();
+ try {
+ DatabaseTable.commit_transaction();
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ } finally {
+ AppWindow.get_instance().set_normal_cursor();
+ }
+ }
+
+ protected abstract void undo_on_all(Gee.Collection<DataSource> sources);
+
+ private void on_sources_destroyed(Gee.Collection<DataSource> destroyed) {
+ foreach (DataSource source in destroyed) {
+ if (sources.contains(source)) {
+ get_command_manager().reset();
+
+ break;
+ }
+ }
+ }
+}
+
+public abstract class MultiplePhotoTransformationCommand : MultipleDataSourceCommand {
+ private Gee.HashMap<Photo, PhotoTransformationState> map = new Gee.HashMap<
+ Photo, PhotoTransformationState>();
+
+ public MultiplePhotoTransformationCommand(Gee.Iterable<DataView> iter, string progress_text,
+ string undo_progress_text, string name, string explanation) {
+ base(iter, progress_text, undo_progress_text, name, explanation);
+
+ foreach (DataSource source in source_list) {
+ Photo photo = (Photo) source;
+ PhotoTransformationState state = photo.save_transformation_state();
+ state.broken.connect(on_state_broken);
+
+ map.set(photo, state);
+ }
+ }
+
+ ~MultiplePhotoTransformationCommand() {
+ foreach (PhotoTransformationState state in map.values)
+ state.broken.disconnect(on_state_broken);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ Photo photo = (Photo) source;
+
+ PhotoTransformationState state = map.get(photo);
+ assert(state != null);
+
+ photo.load_transformation_state(state);
+ }
+
+ private void on_state_broken() {
+ get_command_manager().reset();
+ }
+}
+
+public class RotateSingleCommand : SingleDataSourceCommand {
+ private Rotation rotation;
+
+ public RotateSingleCommand(Photo photo, Rotation rotation, string name, string explanation) {
+ base(photo, name, explanation);
+
+ this.rotation = rotation;
+ }
+
+ public override void execute() {
+ ((Photo) source).rotate(rotation);
+ }
+
+ public override void undo() {
+ ((Photo) source).rotate(rotation.opposite());
+ }
+}
+
+public class RotateMultipleCommand : MultipleDataSourceCommand {
+ private Rotation rotation;
+
+ public RotateMultipleCommand(Gee.Iterable<DataView> iter, Rotation rotation, string name,
+ string explanation, string progress_text, string undo_progress_text) {
+ base(iter, progress_text, undo_progress_text, name, explanation);
+
+ this.rotation = rotation;
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((Photo) source).rotate(rotation);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ ((Photo) source).rotate(rotation.opposite());
+ }
+}
+
+public class EditTitleCommand : SingleDataSourceCommand {
+ private string new_title;
+ private string? old_title;
+
+ public EditTitleCommand(MediaSource source, string new_title) {
+ base(source, Resources.EDIT_TITLE_LABEL, "");
+
+ this.new_title = new_title;
+ old_title = source.get_title();
+ }
+
+ public override void execute() {
+ ((MediaSource) source).set_title(new_title);
+ }
+
+ public override void undo() {
+ ((MediaSource) source).set_title(old_title);
+ }
+}
+
+public class EditCommentCommand : SingleDataSourceCommand {
+ private string new_comment;
+ private string? old_comment;
+
+ public EditCommentCommand(MediaSource source, string new_comment) {
+ base(source, Resources.EDIT_COMMENT_LABEL, "");
+
+ this.new_comment = new_comment;
+ old_comment = source.get_comment();
+ }
+
+ public override void execute() {
+ ((MediaSource) source).set_comment(new_comment);
+ }
+
+ public override void undo() {
+ ((MediaSource) source).set_comment(old_comment);
+ }
+}
+
+public class EditMultipleTitlesCommand : MultipleDataSourceAtOnceCommand {
+ public string new_title;
+ public Gee.HashMap<MediaSource, string?> old_titles = new Gee.HashMap<MediaSource, string?>();
+
+ public EditMultipleTitlesCommand(Gee.Collection<MediaSource> media_sources, string new_title) {
+ base (media_sources, Resources.EDIT_TITLE_LABEL, "");
+
+ this.new_title = new_title;
+ foreach (MediaSource media in media_sources)
+ old_titles.set(media, media.get_title());
+ }
+
+ public override void execute_on_all(Gee.Collection<DataSource> sources) {
+ foreach (DataSource source in sources)
+ ((MediaSource) source).set_title(new_title);
+ }
+
+ public override void undo_on_all(Gee.Collection<DataSource> sources) {
+ foreach (DataSource source in sources)
+ ((MediaSource) source).set_title(old_titles.get((MediaSource) source));
+ }
+}
+
+public class EditMultipleCommentsCommand : MultipleDataSourceAtOnceCommand {
+ public string new_comment;
+ public Gee.HashMap<MediaSource, string?> old_comments = new Gee.HashMap<MediaSource, string?>();
+
+ public EditMultipleCommentsCommand(Gee.Collection<MediaSource> media_sources, string new_comment) {
+ base (media_sources, Resources.EDIT_COMMENT_LABEL, "");
+
+ this.new_comment = new_comment;
+ foreach (MediaSource media in media_sources)
+ old_comments.set(media, media.get_comment());
+ }
+
+ public override void execute_on_all(Gee.Collection<DataSource> sources) {
+ foreach (DataSource source in sources)
+ ((MediaSource) source).set_comment(new_comment);
+ }
+
+ public override void undo_on_all(Gee.Collection<DataSource> sources) {
+ foreach (DataSource source in sources)
+ ((MediaSource) source).set_comment(old_comments.get((MediaSource) source));
+ }
+}
+
+public class RenameEventCommand : SimpleProxyableCommand {
+ private string new_name;
+ private string? old_name;
+
+ public RenameEventCommand(Event event, string new_name) {
+ base(event, Resources.RENAME_EVENT_LABEL, "");
+
+ this.new_name = new_name;
+ old_name = event.get_raw_name();
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((Event) source).rename(new_name);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ ((Event) source).rename(old_name);
+ }
+}
+
+public class EditEventCommentCommand : SimpleProxyableCommand {
+ private string new_comment;
+ private string? old_comment;
+
+ public EditEventCommentCommand(Event event, string new_comment) {
+ base(event, Resources.EDIT_COMMENT_LABEL, "");
+
+ this.new_comment = new_comment;
+ old_comment = event.get_comment();
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((Event) source).set_comment(new_comment);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ ((Event) source).set_comment(old_comment);
+ }
+}
+
+public class SetKeyPhotoCommand : SingleDataSourceCommand {
+ private MediaSource new_primary_source;
+ private MediaSource old_primary_source;
+
+ public SetKeyPhotoCommand(Event event, MediaSource new_primary_source) {
+ base(event, Resources.MAKE_KEY_PHOTO_LABEL, "");
+
+ this.new_primary_source = new_primary_source;
+ old_primary_source = event.get_primary_source();
+ }
+
+ public override void execute() {
+ ((Event) source).set_primary_source(new_primary_source);
+ }
+
+ public override void undo() {
+ ((Event) source).set_primary_source(old_primary_source);
+ }
+}
+
+public class RevertSingleCommand : GenericPhotoTransformationCommand {
+ public RevertSingleCommand(Photo photo) {
+ base(photo, Resources.REVERT_LABEL, "");
+ }
+
+ public override void execute_on_photo(Photo photo) {
+ photo.remove_all_transformations();
+ }
+
+ public override bool compress(Command command) {
+ RevertSingleCommand revert_single_command = command as RevertSingleCommand;
+ if (revert_single_command == null)
+ return false;
+
+ if (revert_single_command.source != source)
+ return false;
+
+ // no need to execute anything; multiple successive reverts on the same photo are as good
+ // as one
+ return true;
+ }
+}
+
+public class RevertMultipleCommand : MultiplePhotoTransformationCommand {
+ public RevertMultipleCommand(Gee.Iterable<DataView> iter) {
+ base(iter, _("Reverting"), _("Undoing Revert"), Resources.REVERT_LABEL,
+ "");
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((Photo) source).remove_all_transformations();
+ }
+}
+
+public class EnhanceSingleCommand : GenericPhotoTransformationCommand {
+ public EnhanceSingleCommand(Photo photo) {
+ base(photo, Resources.ENHANCE_LABEL, Resources.ENHANCE_TOOLTIP);
+ }
+
+ public override void execute_on_photo(Photo photo) {
+ AppWindow.get_instance().set_busy_cursor();
+#if MEASURE_ENHANCE
+ Timer overall_timer = new Timer();
+#endif
+
+ photo.enhance();
+
+#if MEASURE_ENHANCE
+ overall_timer.stop();
+ debug("Auto-Enhance overall time: %f sec", overall_timer.elapsed());
+#endif
+ AppWindow.get_instance().set_normal_cursor();
+ }
+
+ public override bool compress(Command command) {
+ EnhanceSingleCommand enhance_single_command = command as EnhanceSingleCommand;
+ if (enhance_single_command == null)
+ return false;
+
+ if (enhance_single_command.source != source)
+ return false;
+
+ // multiple successive enhances on the same photo are as good as a single
+ return true;
+ }
+}
+
+public class EnhanceMultipleCommand : MultiplePhotoTransformationCommand {
+ public EnhanceMultipleCommand(Gee.Iterable<DataView> iter) {
+ base(iter, _("Enhancing"), _("Undoing Enhance"), Resources.ENHANCE_LABEL,
+ Resources.ENHANCE_TOOLTIP);
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((Photo) source).enhance();
+ }
+}
+
+public class StraightenCommand : GenericPhotoTransformationCommand {
+ private double theta;
+ private Box crop; // straightening can change the crop rectangle
+
+ public StraightenCommand(Photo photo, double theta, Box crop, string name, string explanation) {
+ base(photo, name, explanation);
+
+ this.theta = theta;
+ this.crop = crop;
+ }
+
+ public override void execute_on_photo(Photo photo) {
+ photo.set_straighten(theta);
+ photo.set_crop(crop);
+ }
+}
+
+public class CropCommand : GenericPhotoTransformationCommand {
+ private Box crop;
+
+ public CropCommand(Photo photo, Box crop, string name, string explanation) {
+ base(photo, name, explanation);
+
+ this.crop = crop;
+ }
+
+ public override void execute_on_photo(Photo photo) {
+ photo.set_crop(crop);
+ }
+}
+
+public class AdjustColorsSingleCommand : GenericPhotoTransformationCommand {
+ private PixelTransformationBundle transformations;
+
+ public AdjustColorsSingleCommand(Photo photo, PixelTransformationBundle transformations,
+ string name, string explanation) {
+ base(photo, name, explanation);
+
+ this.transformations = transformations;
+ }
+
+ public override void execute_on_photo(Photo photo) {
+ AppWindow.get_instance().set_busy_cursor();
+
+ photo.set_color_adjustments(transformations);
+
+ AppWindow.get_instance().set_normal_cursor();
+ }
+
+ public override bool can_compress(Command command) {
+ return command is AdjustColorsSingleCommand;
+ }
+}
+
+public class AdjustColorsMultipleCommand : MultiplePhotoTransformationCommand {
+ private PixelTransformationBundle transformations;
+
+ public AdjustColorsMultipleCommand(Gee.Iterable<DataView> iter,
+ PixelTransformationBundle transformations, string name, string explanation) {
+ base(iter, _("Applying Color Transformations"), _("Undoing Color Transformations"),
+ name, explanation);
+
+ this.transformations = transformations;
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((Photo) source).set_color_adjustments(transformations);
+ }
+}
+
+public class RedeyeCommand : GenericPhotoTransformationCommand {
+ private EditingTools.RedeyeInstance redeye_instance;
+
+ public RedeyeCommand(Photo photo, EditingTools.RedeyeInstance redeye_instance, string name,
+ string explanation) {
+ base(photo, name, explanation);
+
+ this.redeye_instance = redeye_instance;
+ }
+
+ public override void execute_on_photo(Photo photo) {
+ photo.add_redeye_instance(redeye_instance);
+ }
+}
+
+public abstract class MovePhotosCommand : Command {
+ // Piggyback on a private command so that processing to determine new_event can occur before
+ // construction, if needed
+ protected class RealMovePhotosCommand : MultipleDataSourceCommand {
+ private SourceProxy new_event_proxy = null;
+ private Gee.HashMap<MediaSource, SourceProxy?> old_events = new Gee.HashMap<
+ MediaSource, SourceProxy?>();
+
+ public RealMovePhotosCommand(Event? new_event, Gee.Iterable<DataView> source_views,
+ string progress_text, string undo_progress_text, string name, string explanation) {
+ base(source_views, progress_text, undo_progress_text, name, explanation);
+
+ // get proxies for each media source's event
+ foreach (DataSource source in source_list) {
+ MediaSource current_media = (MediaSource) source;
+ Event? old_event = current_media.get_event();
+ SourceProxy? old_event_proxy = (old_event != null) ? old_event.get_proxy() : null;
+
+ // if any of the proxies break, the show's off
+ if (old_event_proxy != null)
+ old_event_proxy.broken.connect(on_proxy_broken);
+
+ old_events.set(current_media, old_event_proxy);
+ }
+
+ // stash the proxy of the new event
+ new_event_proxy = new_event.get_proxy();
+ new_event_proxy.broken.connect(on_proxy_broken);
+ }
+
+ ~RealMovePhotosCommand() {
+ new_event_proxy.broken.disconnect(on_proxy_broken);
+
+ foreach (SourceProxy? proxy in old_events.values) {
+ if (proxy != null)
+ proxy.broken.disconnect(on_proxy_broken);
+ }
+ }
+
+ public override void execute() {
+ // Are we at an event page already?
+ if ((LibraryWindow.get_app().get_current_page() is EventPage)) {
+ Event evt = ((EventPage) LibraryWindow.get_app().get_current_page()).get_event();
+
+ // Will moving these empty this event?
+ if (evt.get_media_count() == source_list.size) {
+ // Yes - jump away from this event, since it will have zero
+ // entries and is going to be removed.
+ LibraryWindow.get_app().switch_to_event((Event) new_event_proxy.get_source());
+ }
+ } else {
+ // We're in a library or tag page.
+
+ // Are we moving these to a newly-created (and therefore empty) event?
+ if (((Event) new_event_proxy.get_source()).get_media_count() == 0) {
+ // Yes - jump to the new event.
+ LibraryWindow.get_app().switch_to_event((Event) new_event_proxy.get_source());
+ }
+ }
+
+ // Otherwise - don't jump; users found the jumping disconcerting.
+
+ // create the new event
+ base.execute();
+ }
+
+ public override void execute_on_source(DataSource source) {
+ ((MediaSource) source).set_event((Event?) new_event_proxy.get_source());
+ }
+
+ public override void undo_on_source(DataSource source) {
+ MediaSource current_media = (MediaSource) source;
+ SourceProxy? event_proxy = old_events.get(current_media);
+
+ current_media.set_event(event_proxy != null ? (Event?) event_proxy.get_source() : null);
+ }
+
+ private void on_proxy_broken() {
+ get_command_manager().reset();
+ }
+ }
+
+ protected RealMovePhotosCommand real_command;
+
+ public MovePhotosCommand(string name, string explanation) {
+ base(name, explanation);
+ }
+
+ public override void prepare() {
+ assert(real_command != null);
+ real_command.prepare();
+ }
+
+ public override void execute() {
+ assert(real_command != null);
+ real_command.execute();
+ }
+
+ public override void undo() {
+ assert(real_command != null);
+ real_command.undo();
+ }
+}
+
+public class NewEventCommand : MovePhotosCommand {
+ public NewEventCommand(Gee.Iterable<DataView> iter) {
+ base(Resources.NEW_EVENT_LABEL, "");
+
+ // get the primary or "key" source for the new event (which is simply the first one)
+ MediaSource key_source = null;
+ foreach (DataView view in iter) {
+ MediaSource current_source = (MediaSource) view.get_source();
+
+ if (key_source == null) {
+ key_source = current_source;
+ break;
+ }
+ }
+
+ // key photo is required for an event
+ assert(key_source != null);
+
+ Event new_event = Event.create_empty_event(key_source);
+
+ real_command = new RealMovePhotosCommand(new_event, iter, _("Creating New Event"),
+ _("Removing Event"), Resources.NEW_EVENT_LABEL,
+ "");
+ }
+}
+
+public class SetEventCommand : MovePhotosCommand {
+ public SetEventCommand(Gee.Iterable<DataView> iter, Event new_event) {
+ base(Resources.SET_PHOTO_EVENT_LABEL, Resources.SET_PHOTO_EVENT_TOOLTIP);
+
+ real_command = new RealMovePhotosCommand(new_event, iter, _("Moving Photos to New Event"),
+ _("Setting Photos to Previous Event"), Resources.SET_PHOTO_EVENT_LABEL,
+ "");
+ }
+}
+
+public class MergeEventsCommand : MovePhotosCommand {
+ public MergeEventsCommand(Gee.Iterable<DataView> iter) {
+ base (Resources.MERGE_LABEL, "");
+
+ // Because it requires fewer operations to merge small events onto large ones,
+ // rather than the other way round, we try to choose the event with the most
+ // sources as the 'master', preferring named events over unnamed ones so that
+ // names can persist.
+ Event master_event = null;
+ int named_evt_src_count = 0;
+ int unnamed_evt_src_count = 0;
+ Gee.ArrayList<ThumbnailView> media_thumbs = new Gee.ArrayList<ThumbnailView>();
+
+ foreach (DataView view in iter) {
+ Event event = (Event) view.get_source();
+
+ // First event we've examined?
+ if (master_event == null) {
+ // Yes. Make it the master for now and remember it as
+ // having the most sources (out of what we've seen so far).
+ master_event = event;
+ unnamed_evt_src_count = master_event.get_media_count();
+ if (event.has_name())
+ named_evt_src_count = master_event.get_media_count();
+ } else {
+ // No. Check whether this event has a name and whether
+ // it has more sources than any other we've seen...
+ if (event.has_name()) {
+ if (event.get_media_count() > named_evt_src_count) {
+ named_evt_src_count = event.get_media_count();
+ master_event = event;
+ }
+ } else if (named_evt_src_count == 0) {
+ // Per the original app design, named events -always- trump
+ // unnamed ones, so only choose an unnamed one if we haven't
+ // seen any named ones yet.
+ if (event.get_media_count() > unnamed_evt_src_count) {
+ unnamed_evt_src_count = event.get_media_count();
+ master_event = event;
+ }
+ }
+ }
+
+ // store all media sources in this operation; they will be moved to the master event
+ // (keep proxies of their original event for undo)
+ foreach (MediaSource media_source in event.get_media())
+ media_thumbs.add(new ThumbnailView(media_source));
+ }
+
+ assert(master_event != null);
+ assert(media_thumbs.size > 0);
+
+ real_command = new RealMovePhotosCommand(master_event, media_thumbs, _("Merging"),
+ _("Unmerging"), Resources.MERGE_LABEL, "");
+ }
+}
+
+public class DuplicateMultiplePhotosCommand : MultipleDataSourceCommand {
+ private Gee.HashMap<LibraryPhoto, LibraryPhoto> dupes = new Gee.HashMap<LibraryPhoto, LibraryPhoto>();
+ private int failed = 0;
+
+ public DuplicateMultiplePhotosCommand(Gee.Iterable<DataView> iter) {
+ base (iter, _("Duplicating photos"), _("Removing duplicated photos"),
+ Resources.DUPLICATE_PHOTO_LABEL, Resources.DUPLICATE_PHOTO_TOOLTIP);
+
+ LibraryPhoto.global.item_destroyed.connect(on_photo_destroyed);
+ }
+
+ ~DuplicateMultiplePhotosCommand() {
+ LibraryPhoto.global.item_destroyed.disconnect(on_photo_destroyed);
+ }
+
+ private void on_photo_destroyed(DataSource source) {
+ // if one of the duplicates is destroyed, can no longer undo it (which destroys it again)
+ if (dupes.values.contains((LibraryPhoto) source))
+ get_command_manager().reset();
+ }
+
+ public override void execute() {
+ dupes.clear();
+ failed = 0;
+
+ base.execute();
+
+ if (failed > 0) {
+ string error_string = (ngettext("Unable to duplicate one photo due to a file error",
+ "Unable to duplicate %d photos due to file errors", failed)).printf(failed);
+ AppWindow.error_message(error_string);
+ }
+ }
+
+ public override void execute_on_source(DataSource source) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+
+ try {
+ LibraryPhoto dupe = photo.duplicate();
+ dupes.set(photo, dupe);
+ } catch (Error err) {
+ critical("Unable to duplicate file %s: %s", photo.get_file().get_path(), err.message);
+ failed++;
+ }
+ }
+
+ public override void undo() {
+ // disconnect from monitoring the duplicates' destruction, as undo() does exactly that
+ LibraryPhoto.global.item_destroyed.disconnect(on_photo_destroyed);
+
+ base.undo();
+
+ // be sure to drop everything that was destroyed
+ dupes.clear();
+ failed = 0;
+
+ // re-monitor for duplicates' destruction
+ LibraryPhoto.global.item_destroyed.connect(on_photo_destroyed);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+
+ Marker marker = LibraryPhoto.global.mark(dupes.get(photo));
+ LibraryPhoto.global.destroy_marked(marker, true);
+ }
+}
+
+public class SetRatingSingleCommand : SingleDataSourceCommand {
+ private Rating last_rating;
+ private Rating new_rating;
+ private bool set_direct;
+ private bool incrementing;
+
+ public SetRatingSingleCommand(DataSource source, Rating rating) {
+ base (source, Resources.rating_label(rating), "");
+ set_direct = true;
+ new_rating = rating;
+
+ last_rating = ((LibraryPhoto)source).get_rating();
+ }
+
+ public SetRatingSingleCommand.inc_dec(DataSource source, bool is_incrementing) {
+ base (source, is_incrementing ? Resources.INCREASE_RATING_LABEL :
+ Resources.DECREASE_RATING_LABEL, "");
+ set_direct = false;
+ incrementing = is_incrementing;
+
+ last_rating = ((MediaSource) source).get_rating();
+ }
+
+ public override void execute() {
+ if (set_direct)
+ ((MediaSource) source).set_rating(new_rating);
+ else {
+ if (incrementing)
+ ((MediaSource) source).increase_rating();
+ else
+ ((MediaSource) source).decrease_rating();
+ }
+ }
+
+ public override void undo() {
+ ((MediaSource) source).set_rating(last_rating);
+ }
+}
+
+public class SetRatingCommand : MultipleDataSourceCommand {
+ private Gee.HashMap<DataSource, Rating> last_rating_map;
+ private Rating new_rating;
+ private bool set_direct;
+ private bool incrementing;
+ private int action_count = 0;
+
+ public SetRatingCommand(Gee.Iterable<DataView> iter, Rating rating) {
+ base (iter, Resources.rating_progress(rating), _("Restoring previous rating"),
+ Resources.rating_label(rating), "");
+ set_direct = true;
+ new_rating = rating;
+
+ save_source_states(iter);
+ }
+
+ public SetRatingCommand.inc_dec(Gee.Iterable<DataView> iter, bool is_incrementing) {
+ base (iter,
+ is_incrementing ? _("Increasing ratings") : _("Decreasing ratings"),
+ is_incrementing ? _("Decreasing ratings") : _("Increasing ratings"),
+ is_incrementing ? Resources.INCREASE_RATING_LABEL : Resources.DECREASE_RATING_LABEL,
+ "");
+ set_direct = false;
+ incrementing = is_incrementing;
+
+ save_source_states(iter);
+ }
+
+ private void save_source_states(Gee.Iterable<DataView> iter) {
+ last_rating_map = new Gee.HashMap<DataSource, Rating>();
+
+ foreach (DataView view in iter) {
+ DataSource source = view.get_source();
+ last_rating_map[source] = ((MediaSource) source).get_rating();
+ }
+ }
+
+ public override void execute() {
+ action_count = 0;
+ base.execute();
+ }
+
+ public override void undo() {
+ action_count = 0;
+ base.undo();
+ }
+
+ public override void execute_on_source(DataSource source) {
+ if (set_direct)
+ ((MediaSource) source).set_rating(new_rating);
+ else {
+ if (incrementing)
+ ((MediaSource) source).increase_rating();
+ else
+ ((MediaSource) source).decrease_rating();
+ }
+ }
+
+ public override void undo_on_source(DataSource source) {
+ ((MediaSource) source).set_rating(last_rating_map[source]);
+ }
+}
+
+public class SetRawDeveloperCommand : MultipleDataSourceCommand {
+ private Gee.HashMap<Photo, RawDeveloper> last_developer_map;
+ private Gee.HashMap<Photo, PhotoTransformationState> last_transformation_map;
+ private RawDeveloper new_developer;
+
+ public SetRawDeveloperCommand(Gee.Iterable<DataView> iter, RawDeveloper developer) {
+ base (iter, _("Setting RAW developer"), _("Restoring previous RAW developer"),
+ _("Set Developer"), "");
+ new_developer = developer;
+ save_source_states(iter);
+ }
+
+ private void save_source_states(Gee.Iterable<DataView> iter) {
+ last_developer_map = new Gee.HashMap<Photo, RawDeveloper>();
+ last_transformation_map = new Gee.HashMap<Photo, PhotoTransformationState>();
+
+ foreach (DataView view in iter) {
+ Photo? photo = view.get_source() as Photo;
+ if (is_raw_photo(photo)) {
+ last_developer_map[photo] = photo.get_raw_developer();
+ last_transformation_map[photo] = photo.save_transformation_state();
+ }
+ }
+ }
+
+ public override void execute() {
+ base.execute();
+ }
+
+ public override void undo() {
+ base.undo();
+ }
+
+ public override void execute_on_source(DataSource source) {
+ Photo? photo = source as Photo;
+ if (is_raw_photo(photo)) {
+ if (new_developer == RawDeveloper.CAMERA && !photo.is_raw_developer_available(RawDeveloper.CAMERA))
+ photo.set_raw_developer(RawDeveloper.EMBEDDED);
+ else
+ photo.set_raw_developer(new_developer);
+ }
+ }
+
+ public override void undo_on_source(DataSource source) {
+ Photo? photo = source as Photo;
+ if (is_raw_photo(photo)) {
+ photo.set_raw_developer(last_developer_map[photo]);
+ photo.load_transformation_state(last_transformation_map[photo]);
+ }
+ }
+
+ private bool is_raw_photo(Photo? photo) {
+ return photo != null && photo.get_master_file_format() == PhotoFileFormat.RAW;
+ }
+}
+
+public class AdjustDateTimePhotoCommand : SingleDataSourceCommand {
+ private Dateable dateable;
+ private Event? prev_event;
+ private int64 time_shift;
+ private bool modify_original;
+
+ public AdjustDateTimePhotoCommand(Dateable dateable, int64 time_shift, bool modify_original) {
+ base(dateable, Resources.ADJUST_DATE_TIME_LABEL, "");
+
+ this.dateable = dateable;
+ this.time_shift = time_shift;
+ this.modify_original = modify_original;
+ }
+
+ public override void execute() {
+ set_time(dateable, dateable.get_exposure_time() + (time_t) time_shift);
+
+ prev_event = dateable.get_event();
+
+ ViewCollection all_events = new ViewCollection("tmp");
+
+ foreach (DataObject dobj in Event.global.get_all()) {
+ Event event = dobj as Event;
+ if (event != null) {
+ all_events.add(new EventView(event));
+ }
+ }
+ Event.generate_single_event(dateable, all_events, null);
+ }
+
+ public override void undo() {
+ set_time(dateable, dateable.get_exposure_time() - (time_t) time_shift);
+
+ dateable.set_event(prev_event);
+ }
+
+ private void set_time(Dateable dateable, time_t exposure_time) {
+ if (modify_original && dateable is Photo) {
+ try {
+ ((Photo)dateable).set_exposure_time_persistent(exposure_time);
+ } catch(GLib.Error err) {
+ AppWindow.error_message(_("Original photo could not be adjusted."));
+ }
+ } else {
+ dateable.set_exposure_time(exposure_time);
+ }
+ }
+}
+
+public class AdjustDateTimePhotosCommand : MultipleDataSourceCommand {
+ private int64 time_shift;
+ private bool keep_relativity;
+ private bool modify_originals;
+ 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 Gee.ArrayList<Dateable> error_list;
+
+ public AdjustDateTimePhotosCommand(Gee.Iterable<DataView> iter, int64 time_shift,
+ bool keep_relativity, bool modify_originals) {
+ base(iter, _("Adjusting Date and Time"), _("Undoing Date and Time Adjustment"),
+ Resources.ADJUST_DATE_TIME_LABEL, "");
+
+ this.time_shift = time_shift;
+ this.keep_relativity = keep_relativity;
+ this.modify_originals = modify_originals;
+
+ // TODO: implement modify originals option
+
+ prev_events = new Gee.HashMap<Dateable, Event?>();
+
+ // 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());
+
+ if (new_time == null) {
+ new_time = ((Dateable) view.get_source()).get_exposure_time() +
+ (time_t) time_shift;
+ break;
+ }
+ }
+
+ old_times = new Gee.HashMap<Dateable, time_t?>();
+ }
+
+ public override void execute() {
+ error_list = new Gee.ArrayList<Dateable>();
+ base.execute();
+
+ if (error_list.size > 0) {
+ multiple_object_error_dialog(error_list,
+ ngettext("One original photo could not be adjusted.",
+ "The following original photos could not be adjusted.", error_list.size),
+ _("Time Adjustment Error"));
+ }
+
+ ViewCollection all_events = new ViewCollection("tmp");
+
+ foreach (Dateable d in prev_events.keys) {
+ foreach (DataObject dobj in Event.global.get_all()) {
+ Event event = dobj as Event;
+ if (event != null) {
+ all_events.add(new EventView(event));
+ }
+ }
+ Event.generate_single_event(d, all_events, null);
+ }
+ }
+
+ public override void undo() {
+ error_list = new Gee.ArrayList<Dateable>();
+ base.undo();
+
+ if (error_list.size > 0) {
+ multiple_object_error_dialog(error_list,
+ ngettext("Time adjustments could not be undone on the following photo file.",
+ "Time adjustments could not be undone on the following photo files.",
+ error_list.size), _("Time Adjustment Error"));
+ }
+ }
+
+ private void set_time(Dateable dateable, time_t 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
+ if (modify_originals && (dateable is Photo)) {
+ try {
+ ((Photo) dateable).set_exposure_time_persistent(exposure_time);
+ } catch(GLib.Error err) {
+ error_list.add(dateable);
+ }
+ } else {
+ // modifying originals is disabled, or this is a
+ // video
+ dateable.set_exposure_time(exposure_time);
+ }
+ }
+
+ 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);
+ } else {
+ old_times.set(dateable, dateable.get_exposure_time());
+ set_time(dateable, new_time);
+ }
+
+ ViewCollection all_events = new ViewCollection("tmp");
+
+ foreach (DataObject dobj in Event.global.get_all()) {
+ Event event = dobj as Event;
+ if (event != null) {
+ all_events.add(new EventView(event));
+ }
+ }
+ Event.generate_single_event(dateable, all_events, null);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ Dateable photo = ((Dateable) source);
+
+ if (old_times.has_key(photo)) {
+ set_time(photo, old_times.get(photo));
+ old_times.unset(photo);
+ } else {
+ set_time(photo, photo.get_exposure_time() - (time_t) time_shift);
+ }
+
+ (source as MediaSource).set_event(prev_events.get(source as Dateable));
+ }
+}
+
+public class AddTagsCommand : PageCommand {
+ private Gee.HashMap<SourceProxy, Gee.ArrayList<MediaSource>> map =
+ new Gee.HashMap<SourceProxy, Gee.ArrayList<MediaSource>>();
+
+ public AddTagsCommand(string[] paths, Gee.Collection<MediaSource> sources) {
+ base (Resources.add_tags_label(paths), "");
+
+ // load/create the tags here rather than in execute() so that we can merely use the proxy
+ // to access it ... this is important with the redo() case, where the tags may have been
+ // created by another proxy elsewhere
+ foreach (string path in paths) {
+ Gee.List<string> paths_to_create =
+ HierarchicalTagUtilities.enumerate_parent_paths(path);
+ paths_to_create.add(path);
+
+ foreach (string create_path in paths_to_create) {
+ Tag tag = Tag.for_path(create_path);
+ SourceProxy tag_proxy = tag.get_proxy();
+
+ // for each Tag, only attach sources which are not already attached, otherwise undo()
+ // will not be symmetric
+ Gee.ArrayList<MediaSource> add_sources = new Gee.ArrayList<MediaSource>();
+ foreach (MediaSource source in sources) {
+ if (!tag.contains(source))
+ add_sources.add(source);
+ }
+
+ if (add_sources.size > 0) {
+ tag_proxy.broken.connect(on_proxy_broken);
+ map.set(tag_proxy, add_sources);
+ }
+ }
+ }
+
+ LibraryPhoto.global.item_destroyed.connect(on_source_destroyed);
+ Video.global.item_destroyed.connect(on_source_destroyed);
+ }
+
+ ~AddTagsCommand() {
+ foreach (SourceProxy tag_proxy in map.keys)
+ tag_proxy.broken.disconnect(on_proxy_broken);
+
+ LibraryPhoto.global.item_destroyed.disconnect(on_source_destroyed);
+ Video.global.item_destroyed.disconnect(on_source_destroyed);
+ }
+
+ public override void execute() {
+ foreach (SourceProxy tag_proxy in map.keys)
+ ((Tag) tag_proxy.get_source()).attach_many(map.get(tag_proxy));
+ }
+
+ public override void undo() {
+ foreach (SourceProxy tag_proxy in map.keys) {
+ Tag tag = (Tag) tag_proxy.get_source();
+
+ tag.detach_many(map.get(tag_proxy));
+ }
+ }
+
+ private void on_source_destroyed(DataSource source) {
+ foreach (Gee.ArrayList<MediaSource> sources in map.values) {
+ if (sources.contains((MediaSource) source)) {
+ get_command_manager().reset();
+
+ return;
+ }
+ }
+ }
+
+ private void on_proxy_broken() {
+ get_command_manager().reset();
+ }
+}
+
+public class RenameTagCommand : SimpleProxyableCommand {
+ private string old_name;
+ private string new_name;
+
+ // NOTE: new_name should be a name, not a path
+ public RenameTagCommand(Tag tag, string new_name) {
+ base (tag, Resources.rename_tag_label(tag.get_user_visible_name(), new_name),
+ tag.get_name());
+
+ old_name = tag.get_user_visible_name();
+ this.new_name = new_name;
+ }
+
+ protected override void execute_on_source(DataSource source) {
+ if (!((Tag) source).rename(new_name))
+ AppWindow.error_message(Resources.rename_tag_exists_message(new_name));
+ }
+
+ protected override void undo_on_source(DataSource source) {
+ if (!((Tag) source).rename(old_name))
+ AppWindow.error_message(Resources.rename_tag_exists_message(old_name));
+ }
+}
+
+public class DeleteTagCommand : SimpleProxyableCommand {
+ Gee.List<SourceProxy>? recursive_victim_proxies = null;
+
+ public DeleteTagCommand(Tag tag) {
+ base (tag, Resources.delete_tag_label(tag.get_user_visible_name()), tag.get_name());
+ }
+
+ protected override void execute_on_source(DataSource source) {
+ Tag tag = (Tag) source;
+
+ // process children first, if any
+ Gee.List<Tag> recursive_victims = tag.get_hierarchical_children();
+ if (recursive_victims.size > 0) {
+ // save proxies for these Tags and then delete, in order .. can't use mark_many() or
+ // add_proxyables() here because they make no guarantee of order
+ recursive_victim_proxies = new Gee.ArrayList<SourceProxy>();
+ foreach (Tag victim in recursive_victims) {
+ SourceProxy proxy = victim.get_proxy();
+ proxy.broken.connect(on_proxy_broken);
+ recursive_victim_proxies.add(proxy);
+
+ Tag.global.destroy_marked(Tag.global.mark(victim), false);
+ }
+ }
+
+ // destroy parent tag, which is already proxied
+ Tag.global.destroy_marked(Tag.global.mark(source), false);
+ }
+
+ protected override void undo_on_source(DataSource source) {
+ // merely instantiating the Tag will rehydrate it ... should always work, because the
+ // undo stack is cleared if the proxy ever breaks
+ assert(source is Tag);
+
+ // rehydrate the children, in reverse order
+ if (recursive_victim_proxies != null) {
+ for (int i = recursive_victim_proxies.size - 1; i >= 0; i--) {
+ SourceProxy proxy = recursive_victim_proxies.get(i);
+
+ DataSource victim_source = proxy.get_source();
+ assert(victim_source is Tag);
+
+ proxy.broken.disconnect(on_proxy_broken);
+ }
+
+ recursive_victim_proxies = null;
+ }
+ }
+
+ private void on_proxy_broken() {
+ get_command_manager().reset();
+ }
+}
+
+public class NewChildTagCommand : SimpleProxyableCommand {
+ Tag? created_child = null;
+
+ public NewChildTagCommand(Tag tag) {
+ base (tag, _("Create Tag"), tag.get_name());
+ }
+
+ protected override void execute_on_source(DataSource source) {
+ Tag tag = (Tag) source;
+ created_child = tag.create_new_child();
+ }
+
+ protected override void undo_on_source(DataSource source) {
+ Tag.global.destroy_marked(Tag.global.mark(created_child), true);
+ }
+
+ public Tag get_created_child() {
+ assert(created_child != null);
+
+ return created_child;
+ }
+}
+
+public class NewRootTagCommand : PageCommand {
+ SourceProxy? created_proxy = null;
+
+ public NewRootTagCommand() {
+ base (_("Create Tag"), "");
+ }
+
+ protected override void execute() {
+ if (created_proxy == null)
+ created_proxy = Tag.create_new_root().get_proxy();
+ else
+ created_proxy.get_source();
+ }
+
+ protected override void undo() {
+ Tag.global.destroy_marked(Tag.global.mark(created_proxy.get_source()), true);
+ }
+
+ public Tag get_created_tag() {
+ return (Tag) created_proxy.get_source();
+ }
+}
+
+public class ReparentTagCommand : PageCommand {
+ string from_path;
+ string to_path;
+ string? to_path_parent_path;
+ Gee.List<SourceProxy>? src_before_state = null;
+ Gee.List<SourceProxy>? dest_before_state = null;
+ Gee.List<SourceProxy>? after_state = null;
+ Gee.HashSet<MediaSource> sources_in_play = new Gee.HashSet<MediaSource>();
+ Gee.Map<string, Gee.Set<MediaSource>> dest_parent_attachments = null;
+ Gee.Map<string, Gee.Set<MediaSource>> src_parent_detachments = null;
+ Gee.Map<string, Gee.Set<MediaSource>> in_play_child_structure = null;
+ Gee.Map<string, Gee.Set<MediaSource>> existing_dest_child_structure = null;
+ Gee.Set<MediaSource>? existing_dest_membership = null;
+ bool to_path_exists = false;
+
+ public ReparentTagCommand(Tag tag, string new_parent_path) {
+ base (_("Move Tag \"%s\"").printf(tag.get_user_visible_name()), "");
+
+ this.from_path = tag.get_path();
+
+ bool has_children = (tag.get_hierarchical_children().size > 0);
+ string basename = tag.get_user_visible_name();
+
+ if (new_parent_path == Tag.PATH_SEPARATOR_STRING)
+ this.to_path = (has_children) ? (Tag.PATH_SEPARATOR_STRING + basename) : basename;
+ else if (new_parent_path.has_prefix(Tag.PATH_SEPARATOR_STRING))
+ this.to_path = new_parent_path + Tag.PATH_SEPARATOR_STRING + basename;
+ else
+ this.to_path = Tag.PATH_SEPARATOR_STRING + new_parent_path + Tag.PATH_SEPARATOR_STRING +
+ basename;
+
+ string? new_to_path = HierarchicalTagUtilities.get_root_path_form(to_path);
+ if (new_to_path != null)
+ this.to_path = new_to_path;
+
+ if (Tag.global.exists(this.to_path))
+ to_path_exists = true;
+
+ sources_in_play.add_all(tag.get_sources());
+
+ LibraryPhoto.global.items_destroyed.connect(on_items_destroyed);
+ Video.global.items_destroyed.connect(on_items_destroyed);
+ }
+
+ ~ReparentTagCommand() {
+ LibraryPhoto.global.items_destroyed.disconnect(on_items_destroyed);
+ Video.global.items_destroyed.disconnect(on_items_destroyed);
+ }
+
+ private void on_items_destroyed(Gee.Collection<DataSource> destroyed) {
+ foreach (DataSource source in destroyed) {
+ if (sources_in_play.contains((MediaSource) source))
+ get_command_manager().reset();
+ }
+ }
+
+ private Gee.Map<string, Gee.Set<MediaSource>> get_child_structure_at(string client_path) {
+ string? path = HierarchicalTagUtilities.get_root_path_form(client_path);
+ path = (path != null) ? path : client_path;
+
+ Gee.Map<string, Gee.Set<MediaSource>> result =
+ new Gee.HashMap<string, Gee.Set<MediaSource>>();
+
+ if (!Tag.global.exists(path))
+ return result;
+
+ Tag tag = Tag.for_path(path);
+
+ string path_prefix = tag.get_path() + Tag.PATH_SEPARATOR_STRING;
+ foreach (Tag t in tag.get_hierarchical_children()) {
+ string child_subpath = t.get_path().replace(path_prefix, "");
+
+ result.set(child_subpath, new Gee.HashSet<MediaSource>());
+ result.get(child_subpath).add_all(t.get_sources());
+ }
+
+ return result;
+ }
+
+ private void restore_child_attachments_at(string client_path,
+ Gee.Map<string, Gee.Set<MediaSource>> child_structure) {
+
+ string? new_path = HierarchicalTagUtilities.get_root_path_form(client_path);
+ string path = (new_path != null) ? new_path : client_path;
+
+ assert(Tag.global.exists(path));
+ Tag tag = Tag.for_path(path);
+
+ foreach (string child_subpath in child_structure.keys) {
+ string child_path = tag.get_path() + Tag.PATH_SEPARATOR_STRING + child_subpath;
+
+ if (!tag.get_path().has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ tag.promote();
+ child_path = tag.get_path() + Tag.PATH_SEPARATOR_STRING + child_subpath;
+ }
+
+ assert(Tag.global.exists(child_path));
+
+ foreach (MediaSource s in child_structure.get(child_subpath))
+ Tag.for_path(child_path).attach(s);
+ }
+ }
+
+ private void reattach_in_play_sources_at(string client_path) {
+ string? new_path = HierarchicalTagUtilities.get_root_path_form(client_path);
+ string path = (new_path != null) ? new_path : client_path;
+
+ assert(Tag.global.exists(path));
+
+ Tag tag = Tag.for_path(path);
+
+ foreach (MediaSource s in sources_in_play)
+ tag.attach(s);
+ }
+
+ private void save_before_state() {
+ assert(src_before_state == null);
+ assert(dest_before_state == null);
+
+ src_before_state = new Gee.ArrayList<SourceProxy>();
+ dest_before_state = new Gee.ArrayList<SourceProxy>();
+
+ // capture the child structure of the from tag
+ assert(in_play_child_structure == null);
+ in_play_child_structure = get_child_structure_at(from_path);
+
+ // save the state of the from tag
+ assert(Tag.global.exists(from_path));
+ Tag from_tag = Tag.for_path(from_path);
+ src_before_state.add(from_tag.get_proxy());
+
+ // capture the child structure of the parent of the to tag, if the to tag has a parent
+ Gee.List<string> parent_paths = HierarchicalTagUtilities.enumerate_parent_paths(to_path);
+ if (parent_paths.size > 0)
+ to_path_parent_path = parent_paths.get(parent_paths.size - 1);
+ if (to_path_parent_path != null) {
+ assert(existing_dest_child_structure == null);
+ existing_dest_child_structure = get_child_structure_at(to_path_parent_path);
+ }
+
+ // if the to tag doesn't have a parent, then capture the structure of the to tag itself
+ if (to_path_parent_path == null) {
+ assert(existing_dest_child_structure == null);
+ assert(existing_dest_membership == null);
+ existing_dest_child_structure = get_child_structure_at(to_path);
+ existing_dest_membership = new Gee.HashSet<MediaSource>();
+ existing_dest_membership.add_all(Tag.for_path(to_path).get_sources());
+ }
+
+ // save the state of the to tag's parent
+ if (to_path_parent_path != null) {
+ string? new_tpp = HierarchicalTagUtilities.get_root_path_form(to_path_parent_path);
+ to_path_parent_path = (new_tpp != null) ? new_tpp : to_path_parent_path;
+ assert(Tag.global.exists(to_path_parent_path));
+ dest_before_state.add(Tag.for_path(to_path_parent_path).get_proxy());
+ }
+
+ // if the to tag doesn't have a parent, save the state of the to tag itself
+ if (to_path_parent_path == null) {
+ dest_before_state.add(Tag.for_path(to_path).get_proxy());
+ }
+
+ // save the state of the children of the from tag in order from most basic to most derived
+ Gee.List<Tag> from_children = from_tag.get_hierarchical_children();
+ for (int i = from_children.size - 1; i >= 0; i--)
+ src_before_state.add(from_children.get(i).get_proxy());
+
+ // save the state of the children of the to tag's parent in order from most basic to most
+ // derived
+ if (to_path_parent_path != null) {
+ Gee.List<Tag> to_children = Tag.for_path(to_path_parent_path).get_hierarchical_children();
+ for (int i = to_children.size - 1; i >= 0; i--)
+ dest_before_state.add(to_children.get(i).get_proxy());
+ }
+
+ // if the to tag doesn't have a parent, then save the state of the to tag's direct
+ // children, if any
+ if (to_path_parent_path == null) {
+ Gee.List<Tag> to_children = Tag.for_path(to_path).get_hierarchical_children();
+ for (int i = to_children.size - 1; i >= 0; i--)
+ dest_before_state.add(to_children.get(i).get_proxy());
+ }
+ }
+
+ private void restore_before_state() {
+ assert(src_before_state != null);
+ assert(existing_dest_child_structure != null);
+
+ // unwind the destination tree to its pre-merge state
+ if (to_path_parent_path != null) {
+ string? new_tpp = HierarchicalTagUtilities.get_root_path_form(to_path_parent_path);
+ to_path_parent_path = (new_tpp != null) ? new_tpp : to_path_parent_path;
+ }
+
+ string unwind_target = (to_path_parent_path != null) ? to_path_parent_path : to_path;
+ foreach (Tag t in Tag.for_path(unwind_target).get_hierarchical_children()) {
+ string child_subpath = t.get_path().replace(unwind_target, "");
+ if (child_subpath.has_prefix(Tag.PATH_SEPARATOR_STRING))
+ child_subpath = child_subpath.substring(1);
+
+ if (!existing_dest_child_structure.has_key(child_subpath)) {
+ Tag.global.destroy_marked(Tag.global.mark(t), true);
+ } else {
+ Gee.Set<MediaSource> starting_sources = new Gee.HashSet<MediaSource>();
+ starting_sources.add_all(t.get_sources());
+ foreach (MediaSource source in starting_sources)
+ if (!(existing_dest_child_structure.get(child_subpath).contains(source)))
+ t.detach(source);
+ }
+ }
+
+ for (int i = 0; i < src_before_state.size; i++)
+ src_before_state.get(i).get_source();
+
+ for (int i = 0; i < dest_before_state.size; i++)
+ dest_before_state.get(i).get_source();
+
+ if (to_path_parent_path != null) {
+ string? new_path = HierarchicalTagUtilities.get_root_path_form(to_path_parent_path);
+ string path = (new_path != null) ? new_path : to_path_parent_path;
+
+ assert(Tag.global.exists(path));
+
+ Tag t = Tag.for_path(path);
+
+ Gee.List<Tag> kids = t.get_hierarchical_children();
+ foreach (Tag kidtag in kids)
+ kidtag.detach_many(kidtag.get_sources());
+
+ restore_child_attachments_at(path, existing_dest_child_structure);
+ } else {
+ assert(existing_dest_membership != null);
+ Tag.for_path(to_path).detach_many(Tag.for_path(to_path).get_sources());
+ Tag.for_path(to_path).attach_many(existing_dest_membership);
+
+ Gee.List<Tag> kids = Tag.for_path(to_path).get_hierarchical_children();
+ foreach (Tag kidtag in kids)
+ kidtag.detach_many(kidtag.get_sources());
+
+ restore_child_attachments_at(to_path, existing_dest_child_structure);
+ }
+ }
+
+ private void save_after_state() {
+ assert(after_state == null);
+
+ after_state = new Gee.ArrayList<SourceProxy>();
+
+ // save the state of the to tag
+ assert(Tag.global.exists(to_path));
+ Tag to_tag = Tag.for_path(to_path);
+ after_state.add(to_tag.get_proxy());
+
+ // save the state of the children of the to tag in order from most basic to most derived
+ Gee.List<Tag> to_children = to_tag.get_hierarchical_children();
+ for (int i = to_children.size - 1; i >= 0; i--)
+ after_state.add(to_children.get(i).get_proxy());
+ }
+
+ private void restore_after_state() {
+ assert(after_state != null);
+
+ for (int i = 0; i < after_state.size; i++)
+ after_state.get(i).get_source();
+ }
+
+ private void prepare_parent(string path) {
+ // find our new parent tag (if one exists) and promote it
+ Tag? new_parent = null;
+ if (path.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ Gee.List<string> parent_paths = HierarchicalTagUtilities.enumerate_parent_paths(path);
+ if (parent_paths.size > 0) {
+ string immediate_parent_path = parent_paths.get(parent_paths.size - 1);
+ if (Tag.global.exists(immediate_parent_path))
+ new_parent = Tag.for_path(immediate_parent_path);
+ else if (Tag.global.exists(immediate_parent_path.substring(1)))
+ new_parent = Tag.for_path(immediate_parent_path.substring(1));
+ else
+ assert_not_reached();
+ }
+ }
+ if (new_parent != null)
+ new_parent.promote();
+ }
+
+ private void do_source_parent_detachments() {
+ assert(Tag.global.exists(from_path));
+ Tag from_tag = Tag.for_path(from_path);
+
+ // see if this copy operation will detach any media items from the source tag's parents
+ if (src_parent_detachments == null) {
+ src_parent_detachments = new Gee.HashMap<string, Gee.Set<MediaSource>>();
+ foreach (MediaSource source in from_tag.get_sources()) {
+ Tag? current_parent = from_tag.get_hierarchical_parent();
+ int running_attach_count = from_tag.get_attachment_count(source) + 1;
+ while (current_parent != null) {
+ string current_parent_path = current_parent.get_path();
+ if (!src_parent_detachments.has_key(current_parent_path))
+ src_parent_detachments.set(current_parent_path, new Gee.HashSet<MediaSource>());
+
+ int curr_parent_attach_count = current_parent.get_attachment_count(source);
+
+ assert (curr_parent_attach_count >= running_attach_count);
+
+ // if this parent tag has no other child tags that the current media item is
+ // attached to
+ if (curr_parent_attach_count == running_attach_count)
+ src_parent_detachments.get(current_parent_path).add(source);
+
+ running_attach_count++;
+ current_parent = current_parent.get_hierarchical_parent();
+ }
+ }
+ }
+
+ // perform collected detachments
+ foreach (string p in src_parent_detachments.keys)
+ foreach (MediaSource s in src_parent_detachments.get(p))
+ Tag.for_path(p).detach(s);
+ }
+
+ private void do_source_parent_reattachments() {
+ assert(src_parent_detachments != null);
+
+ foreach (string p in src_parent_detachments.keys)
+ foreach (MediaSource s in src_parent_detachments.get(p))
+ Tag.for_path(p).attach(s);
+ }
+
+ private void do_destination_parent_detachments() {
+ assert(dest_parent_attachments != null);
+
+ foreach (string p in dest_parent_attachments.keys)
+ foreach (MediaSource s in dest_parent_attachments.get(p))
+ Tag.for_path(p).detach(s);
+ }
+
+ private void do_destination_parent_reattachments() {
+ assert(dest_parent_attachments != null);
+
+ foreach (string p in dest_parent_attachments.keys)
+ foreach (MediaSource s in dest_parent_attachments.get(p))
+ Tag.for_path(p).attach(s);
+ }
+
+ private void copy_subtree(string from, string to) {
+ assert(Tag.global.exists(from));
+ Tag from_tag = Tag.for_path(from);
+
+ // get (or create) a tag for the destination path
+ Tag to_tag = Tag.for_path(to);
+
+ // see if this copy operation will attach any new media items to the destination's parents,
+ // if so, record them for later undo/redo
+ dest_parent_attachments = new Gee.HashMap<string, Gee.Set<MediaSource>>();
+ foreach (MediaSource source in from_tag.get_sources()) {
+ Tag? current_parent = to_tag.get_hierarchical_parent();
+ while (current_parent != null) {
+ string current_parent_path = current_parent.get_path();
+ if (!dest_parent_attachments.has_key(current_parent_path))
+ dest_parent_attachments.set(current_parent_path, new Gee.HashSet<MediaSource>());
+
+ if (!current_parent.contains(source))
+ dest_parent_attachments.get(current_parent_path).add(source);
+
+ current_parent = current_parent.get_hierarchical_parent();
+ }
+ }
+
+ foreach (MediaSource source in from_tag.get_sources())
+ to_tag.attach(source);
+
+ // loop through the children of the from tag in order from most basic to most derived,
+ // creating corresponding child tags on the to tag and attaching corresponding sources
+ Gee.List<Tag> from_children = from_tag.get_hierarchical_children();
+ for (int i = from_children.size - 1; i >= 0; i--) {
+ Tag from_child = from_children.get(i);
+
+ string child_subpath = from_child.get_path().replace(from + Tag.PATH_SEPARATOR_STRING,
+ "");
+
+ Tag to_child = Tag.for_path(to_tag.get_path() + Tag.PATH_SEPARATOR_STRING +
+ child_subpath);
+
+ foreach (MediaSource source in from_child.get_sources())
+ to_child.attach(source);
+ }
+ }
+
+ private void destroy_subtree(string client_path) {
+ string? victim_path = HierarchicalTagUtilities.get_root_path_form(client_path);
+ if (victim_path == null)
+ victim_path = client_path;
+
+ if (!Tag.global.exists(victim_path))
+ return;
+
+ Tag victim = Tag.for_path(victim_path);
+
+ // destroy the children of the victim in order from most derived to most basic
+ Gee.List<Tag> victim_children = victim.get_hierarchical_children();
+ for (int i = 0; i < victim_children.size; i++)
+ Tag.global.destroy_marked(Tag.global.mark(victim_children.get(i)), true);
+
+ // destroy the victim itself
+ Tag.global.destroy_marked(Tag.global.mark(victim), true);
+ }
+
+ public override void execute() {
+ if (after_state == null) {
+ save_before_state();
+
+ prepare_parent(to_path);
+
+ copy_subtree(from_path, to_path);
+
+ save_after_state();
+
+ do_source_parent_detachments();
+
+ destroy_subtree(from_path);
+ } else {
+ prepare_parent(to_path);
+
+ restore_after_state();
+
+ restore_child_attachments_at(to_path, in_play_child_structure);
+ reattach_in_play_sources_at(to_path);
+
+ do_source_parent_detachments();
+ do_destination_parent_reattachments();
+
+ destroy_subtree(from_path);
+ }
+ }
+
+ public override void undo() {
+ assert(src_before_state != null);
+
+ prepare_parent(from_path);
+
+ restore_before_state();
+
+ if (!to_path_exists)
+ destroy_subtree(to_path);
+
+ restore_child_attachments_at(from_path, in_play_child_structure);
+ reattach_in_play_sources_at(from_path);
+
+ do_source_parent_reattachments();
+ do_destination_parent_detachments();
+
+ HierarchicalTagUtilities.cleanup_root_path(to_path);
+ HierarchicalTagUtilities.cleanup_root_path(from_path);
+ if (to_path_parent_path != null)
+ HierarchicalTagUtilities.cleanup_root_path(to_path_parent_path);
+ }
+}
+
+public class ModifyTagsCommand : SingleDataSourceCommand {
+ private MediaSource media;
+ private Gee.ArrayList<SourceProxy> to_add = new Gee.ArrayList<SourceProxy>();
+ private Gee.ArrayList<SourceProxy> to_remove = new Gee.ArrayList<SourceProxy>();
+
+ public ModifyTagsCommand(MediaSource media, Gee.Collection<Tag> new_tag_list) {
+ base (media, Resources.MODIFY_TAGS_LABEL, "");
+
+ this.media = media;
+
+ // Prepare to remove all existing tags, if any, from the current media source.
+ Gee.List<Tag>? original_tags = Tag.global.fetch_for_source(media);
+ if (original_tags != null) {
+ foreach (Tag tag in original_tags) {
+ SourceProxy proxy = tag.get_proxy();
+ to_remove.add(proxy);
+ proxy.broken.connect(on_proxy_broken);
+ }
+ }
+
+ // Prepare to add all new tags; remember, if a tag is added, its parent must be
+ // added as well. So enumerate all paths to add and then get the tags for them.
+ Gee.SortedSet<string> new_paths = new Gee.TreeSet<string>();
+ foreach (Tag new_tag in new_tag_list) {
+ string new_tag_path = new_tag.get_path();
+
+ new_paths.add(new_tag_path);
+ new_paths.add_all(HierarchicalTagUtilities.enumerate_parent_paths(new_tag_path));
+ }
+
+ 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);
+ }
+ }
+
+ ~ModifyTagsCommand() {
+ foreach (SourceProxy proxy in to_add)
+ proxy.broken.disconnect(on_proxy_broken);
+
+ foreach (SourceProxy proxy in to_remove)
+ proxy.broken.disconnect(on_proxy_broken);
+ }
+
+ public override void execute() {
+ foreach (SourceProxy proxy in to_remove)
+ ((Tag) proxy.get_source()).detach(media);
+
+ foreach (SourceProxy proxy in to_add)
+ ((Tag) proxy.get_source()).attach(media);
+ }
+
+ public override void undo() {
+ foreach (SourceProxy proxy in to_add)
+ ((Tag) proxy.get_source()).detach(media);
+
+ foreach (SourceProxy proxy in to_remove)
+ ((Tag) proxy.get_source()).attach(media);
+ }
+
+ private void on_proxy_broken() {
+ get_command_manager().reset();
+ }
+}
+
+public class TagUntagPhotosCommand : SimpleProxyableCommand {
+ private Gee.Collection<MediaSource> sources;
+ private bool attach;
+ private Gee.MultiMap<Tag, MediaSource>? detached_from = null;
+ private Gee.List<Tag>? attached_to = null;
+
+ public TagUntagPhotosCommand(Tag tag, Gee.Collection<MediaSource> sources, int count, bool attach) {
+ base (tag,
+ attach ? Resources.tag_photos_label(tag.get_user_visible_name(), count)
+ : Resources.untag_photos_label(tag.get_user_visible_name(), count),
+ tag.get_name());
+
+ this.sources = sources;
+ this.attach = attach;
+
+ LibraryPhoto.global.item_destroyed.connect(on_source_destroyed);
+ Video.global.item_destroyed.connect(on_source_destroyed);
+ }
+
+ ~TagUntagPhotosCommand() {
+ LibraryPhoto.global.item_destroyed.disconnect(on_source_destroyed);
+ Video.global.item_destroyed.disconnect(on_source_destroyed);
+ }
+
+ public override void execute_on_source(DataSource source) {
+ if (attach)
+ do_attach((Tag) source);
+ else
+ do_detach((Tag) source);
+ }
+
+ public override void undo_on_source(DataSource source) {
+ if (attach)
+ do_detach((Tag) source);
+ else
+ do_attach((Tag) source);
+ }
+
+ private void do_attach(Tag tag) {
+ // if not attaching previously detached Tags, attach and done
+ if (detached_from == null) {
+ tag.attach_many(sources);
+
+ attached_to = new Gee.ArrayList<Tag>();
+
+ Tag curr_tmp = tag;
+
+ while (curr_tmp != null) {
+ attached_to.add(curr_tmp);
+ curr_tmp = curr_tmp.get_hierarchical_parent();
+ }
+
+ return;
+ }
+
+ // reattach
+ foreach (Tag detached_tag in detached_from.get_all_keys())
+ detached_tag.attach_many(detached_from.get(detached_tag));
+
+ detached_from = null;
+ clear_added_proxies();
+ }
+
+ private void do_detach(Tag tag) {
+ if (attached_to == null) {
+ // detaching a MediaSource from a Tag may result in the MediaSource being detached from
+ // many tags (due to heirarchical tagging), so save the MediaSources for each detached
+ // Tag for reversing the process
+ detached_from = tag.detach_many(sources);
+
+ // since the "master" Tag (supplied in the ctor) is not necessarily the only one being
+ // saved, add proxies for all of the other ones as well
+ add_proxyables(detached_from.get_keys());
+ } else {
+ foreach (Tag t in attached_to) {
+ foreach (MediaSource ms in sources) {
+ // is this photo/video attached to this tag elsewhere?
+ if (t.get_attachment_count(ms) < 2) {
+ //no, remove it.
+ t.detach(ms);
+ }
+ }
+ }
+ }
+ }
+
+ private void on_source_destroyed(DataSource source) {
+ debug("on_source_destroyed: %s", source.to_string());
+ if (sources.contains((MediaSource) source))
+ get_command_manager().reset();
+ }
+}
+
+public class RenameSavedSearchCommand : SingleDataSourceCommand {
+ private SavedSearch search;
+ private string old_name;
+ private string new_name;
+
+ public RenameSavedSearchCommand(SavedSearch search, string new_name) {
+ base (search, Resources.rename_search_label(search.get_name(), new_name), search.get_name());
+
+ this.search = search;
+ old_name = search.get_name();
+ this.new_name = new_name;
+ }
+
+ public override void execute() {
+ if (!search.rename(new_name))
+ AppWindow.error_message(Resources.rename_search_exists_message(new_name));
+ }
+
+ public override void undo() {
+ if (!search.rename(old_name))
+ AppWindow.error_message(Resources.rename_search_exists_message(old_name));
+ }
+}
+
+public class DeleteSavedSearchCommand : SingleDataSourceCommand {
+ private SavedSearch search;
+
+ public DeleteSavedSearchCommand(SavedSearch search) {
+ base (search, Resources.delete_search_label(search.get_name()), search.get_name());
+
+ this.search = search;
+ }
+
+ public override void execute() {
+ SavedSearchTable.get_instance().remove(search);
+ }
+
+ public override void undo() {
+ search.reconstitute();
+ }
+}
+
+public class TrashUntrashPhotosCommand : PageCommand {
+ private Gee.Collection<MediaSource> sources;
+ private bool to_trash;
+
+ public TrashUntrashPhotosCommand(Gee.Collection<MediaSource> sources, bool to_trash) {
+ base (
+ to_trash ? _("Move Photos to Trash") : _("Restore Photos from Trash"),
+ to_trash ? _("Move the photos to the Shotwell trash") : _("Restore the photos back to the Shotwell library"));
+
+ this.sources = sources;
+ this.to_trash = to_trash;
+
+ LibraryPhoto.global.item_destroyed.connect(on_photo_destroyed);
+ Video.global.item_destroyed.connect(on_photo_destroyed);
+ }
+
+ ~TrashUntrashPhotosCommand() {
+ LibraryPhoto.global.item_destroyed.disconnect(on_photo_destroyed);
+ Video.global.item_destroyed.disconnect(on_photo_destroyed);
+ }
+
+ private ProgressDialog? get_progress_dialog(bool to_trash) {
+ if (sources.size <= 5)
+ return null;
+
+ ProgressDialog dialog = new ProgressDialog(AppWindow.get_instance(),
+ to_trash ? _("Moving Photos to Trash") : _("Restoring Photos From Trash"));
+ dialog.update_display_every((sources.size / 5).clamp(2, 10));
+
+ return dialog;
+ }
+
+ public override void execute() {
+ ProgressDialog? dialog = get_progress_dialog(to_trash);
+
+ ProgressMonitor monitor = null;
+ if (dialog != null)
+ monitor = dialog.monitor;
+
+ if (to_trash)
+ trash(monitor);
+ else
+ untrash(monitor);
+
+ if (dialog != null)
+ dialog.close();
+ }
+
+ public override void undo() {
+ ProgressDialog? dialog = get_progress_dialog(!to_trash);
+
+ ProgressMonitor monitor = null;
+ if (dialog != null)
+ monitor = dialog.monitor;
+
+ if (to_trash)
+ untrash(monitor);
+ else
+ trash(monitor);
+
+ if (dialog != null)
+ dialog.close();
+ }
+
+ private void trash(ProgressMonitor? monitor) {
+ int ctr = 0;
+ int count = sources.size;
+
+ LibraryPhoto.global.transaction_controller.begin();
+ Video.global.transaction_controller.begin();
+
+ foreach (MediaSource source in sources) {
+ source.trash();
+ if (monitor != null)
+ monitor(++ctr, count);
+ }
+
+ LibraryPhoto.global.transaction_controller.commit();
+ Video.global.transaction_controller.commit();
+ }
+
+ private void untrash(ProgressMonitor? monitor) {
+ int ctr = 0;
+ int count = sources.size;
+
+ LibraryPhoto.global.transaction_controller.begin();
+ Video.global.transaction_controller.begin();
+
+ foreach (MediaSource source in sources) {
+ source.untrash();
+ if (monitor != null)
+ monitor(++ctr, count);
+ }
+
+ LibraryPhoto.global.transaction_controller.commit();
+ Video.global.transaction_controller.commit();
+ }
+
+ private void on_photo_destroyed(DataSource source) {
+ // in this case, don't need to reset the command manager, simply remove the photo from the
+ // internal list and allow the others to be moved to and from the trash
+ sources.remove((MediaSource) source);
+
+ // however, if all photos missing, then remove this from the command stack, and there's
+ // only one way to do that
+ if (sources.size == 0)
+ get_command_manager().reset();
+ }
+}
+
+public class FlagUnflagCommand : MultipleDataSourceAtOnceCommand {
+ private const int MIN_PROGRESS_BAR_THRESHOLD = 1000;
+ private const string FLAG_SELECTED_STRING = _("Flag selected photos");
+ private const string UNFLAG_SELECTED_STRING = _("Unflag selected photos");
+ private const string FLAG_PROGRESS = _("Flagging selected photos");
+ private const string UNFLAG_PROGRESS = _("Unflagging selected photos");
+
+ private bool flag;
+ private ProgressDialog progress_dialog = null;
+
+ public FlagUnflagCommand(Gee.Collection<MediaSource> sources, bool flag) {
+ base (sources,
+ flag ? _("Flag") : _("Unflag"),
+ flag ? FLAG_SELECTED_STRING : UNFLAG_SELECTED_STRING);
+
+ this.flag = flag;
+
+ if (sources.size >= MIN_PROGRESS_BAR_THRESHOLD) {
+ progress_dialog = new ProgressDialog(null,
+ flag ? FLAG_PROGRESS : UNFLAG_PROGRESS);
+
+ progress_dialog.show_all();
+ }
+ }
+
+ public override void execute_on_all(Gee.Collection<DataSource> sources) {
+ int num_processed = 0;
+
+ foreach (DataSource source in sources) {
+ flag_unflag(source, flag);
+
+ num_processed++;
+
+ if (progress_dialog != null) {
+ progress_dialog.set_fraction(num_processed, sources.size);
+ progress_dialog.queue_draw();
+ spin_event_loop();
+ }
+ }
+
+ if (progress_dialog != null)
+ progress_dialog.hide();
+ }
+
+ public override void undo_on_all(Gee.Collection<DataSource> sources) {
+ foreach (DataSource source in sources)
+ flag_unflag(source, !flag);
+ }
+
+ private void flag_unflag(DataSource source, bool flag) {
+ Flaggable? flaggable = source as Flaggable;
+ if (flaggable != null) {
+ if (flag)
+ flaggable.mark_flagged();
+ else
+ flaggable.mark_unflagged();
+ }
+ }
+}
diff --git a/src/CustomComponents.vala b/src/CustomComponents.vala
new file mode 100644
index 0000000..4ace52c
--- /dev/null
+++ b/src/CustomComponents.vala
@@ -0,0 +1,513 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+extern void qsort(void *p, size_t num, size_t size, GLib.CompareFunc func);
+
+public class ThemeLoader {
+ private struct LightweightColor {
+ public uchar red;
+ public uchar green;
+ public uchar blue;
+
+ public LightweightColor() {
+ red = green = blue = 0;
+ }
+ }
+
+ private const int NUM_SUPPORTED_INTENSITIES = 6;
+ private const int THEME_OUTLINE_COLOR = 0;
+ private const int THEME_BEVEL_DARKER_COLOR = 1;
+ private const int THEME_BEVEL_DARK_COLOR = 2;
+ private const int THEME_BASE_COLOR = 3;
+ private const int THEME_BEVEL_LIGHT_COLOR = 4;
+ private const int THEME_BEVEL_LIGHTER_COLOR = 5;
+
+ private static LightweightColor[] theme_colors = null;
+
+ private static void populate_theme_params() {
+ if (theme_colors != null)
+ return;
+
+ theme_colors = new LightweightColor[NUM_SUPPORTED_INTENSITIES];
+
+ Gtk.Settings settings = Gtk.Settings.get_default();
+ HashTable<string, Gdk.Color?> color_table = settings.color_hash;
+ Gdk.Color? base_color = color_table.lookup("bg_color");
+ if (base_color == null && !Gdk.Color.parse("#fff", out base_color))
+ error("can't parse color");
+
+ RGBAnalyticPixel base_color_analytic_rgb =
+ RGBAnalyticPixel.from_quantized_components(base_color.red >> 8,
+ base_color.green >> 8, base_color.blue >> 8);
+ HSVAnalyticPixel base_color_analytic_hsv =
+ HSVAnalyticPixel.from_rgb(base_color_analytic_rgb);
+
+ HSVAnalyticPixel bevel_light_analytic_hsv = base_color_analytic_hsv;
+ bevel_light_analytic_hsv.light_value *= 1.15f;
+ bevel_light_analytic_hsv.light_value =
+ bevel_light_analytic_hsv.light_value.clamp(0.0f, 1.0f);
+
+ HSVAnalyticPixel bevel_lighter_analytic_hsv = bevel_light_analytic_hsv;
+ bevel_lighter_analytic_hsv.light_value *= 1.15f;
+ bevel_lighter_analytic_hsv.light_value =
+ bevel_lighter_analytic_hsv.light_value.clamp(0.0f, 1.0f);
+
+ HSVAnalyticPixel bevel_dark_analytic_hsv = base_color_analytic_hsv;
+ bevel_dark_analytic_hsv.light_value *= 0.85f;
+ bevel_dark_analytic_hsv.light_value =
+ bevel_dark_analytic_hsv.light_value.clamp(0.0f, 1.0f);
+
+ HSVAnalyticPixel bevel_darker_analytic_hsv = bevel_dark_analytic_hsv;
+ bevel_darker_analytic_hsv.light_value *= 0.85f;
+ bevel_darker_analytic_hsv.light_value =
+ bevel_darker_analytic_hsv.light_value.clamp(0.0f, 1.0f);
+
+ HSVAnalyticPixel outline_analytic_hsv = bevel_darker_analytic_hsv;
+ outline_analytic_hsv.light_value *= 0.66f;
+ outline_analytic_hsv.light_value =
+ outline_analytic_hsv.light_value.clamp(0.0f, 1.0f);
+
+ RGBAnalyticPixel outline_analytic_rgb = outline_analytic_hsv.to_rgb();
+ theme_colors[THEME_OUTLINE_COLOR] =
+ populate_one_theme_param(outline_analytic_rgb);
+
+ RGBAnalyticPixel bevel_darker_analytic_rgb = bevel_darker_analytic_hsv.to_rgb();
+ theme_colors[THEME_BEVEL_DARKER_COLOR] =
+ populate_one_theme_param(bevel_darker_analytic_rgb);
+
+ RGBAnalyticPixel bevel_dark_analytic_rgb = bevel_dark_analytic_hsv.to_rgb();
+ theme_colors[THEME_BEVEL_DARK_COLOR] =
+ populate_one_theme_param(bevel_dark_analytic_rgb);
+
+ theme_colors[THEME_BASE_COLOR] =
+ populate_one_theme_param(base_color_analytic_rgb);
+
+ RGBAnalyticPixel bevel_light_analytic_rgb = bevel_light_analytic_hsv.to_rgb();
+ theme_colors[THEME_BEVEL_LIGHT_COLOR] =
+ populate_one_theme_param(bevel_light_analytic_rgb);
+
+ RGBAnalyticPixel bevel_lighter_analytic_rgb = bevel_light_analytic_hsv.to_rgb();
+ theme_colors[THEME_BEVEL_LIGHTER_COLOR] =
+ populate_one_theme_param(bevel_lighter_analytic_rgb);
+ }
+
+ private static LightweightColor populate_one_theme_param(RGBAnalyticPixel from) {
+ LightweightColor into = LightweightColor();
+
+ into.red = (uchar)(from.red * 255.0f);
+ into.green = (uchar)(from.green * 255.0f);
+ into.blue = (uchar)(from.blue * 255.0f);
+
+ return into;
+ }
+
+ public static Gdk.Pixbuf load_icon(string source_basename) {
+ populate_theme_params();
+
+ Gdk.Pixbuf loaded_pixbuf = Resources.get_icon(source_basename, 0).copy();
+
+ /* Sweep through the icon image data loaded from disk and determine how many
+ unique colors are in it. We do this with the aid of a HashSet. */
+ Gee.HashSet<RGBAnalyticPixel?> colors =
+ new Gee.HashSet<RGBAnalyticPixel?>(rgb_pixel_hash_func,
+ rgb_pixel_equal_func);
+ unowned uchar[] pixel_data = loaded_pixbuf.get_pixels();
+ for (int j = 0; j < loaded_pixbuf.height; j++) {
+ for (int i = 0; i < loaded_pixbuf.width; i++) {
+ int pixel_index = (j * loaded_pixbuf.rowstride) + (i * loaded_pixbuf.n_channels);
+
+ RGBAnalyticPixel pixel_color = RGBAnalyticPixel.from_quantized_components(
+ pixel_data[pixel_index], pixel_data[pixel_index + 1],
+ pixel_data[pixel_index + 2]);
+ colors.add(pixel_color);
+ }
+ }
+
+ /* If the image data loaded from disk didn't contain NUM_SUPPORTED_INTENSITIES
+ colors, then we can't unambiguously map the colors in the loaded image data
+ to theme colors on the user's system, so propagate an error */
+ if (colors.size != NUM_SUPPORTED_INTENSITIES)
+ error("ThemeLoader: load_icon: pixbuf does not contain the correct number " +
+ "of unique colors");
+
+ /* sort the colors in the loaded image data in order of increasing intensity; this
+ means that we have to convert the loaded colors from RGB to HSV format */
+ HSVAnalyticPixel[] hsv_pixels = new HSVAnalyticPixel[6];
+ int pixel_ticker = 0;
+ foreach (RGBAnalyticPixel rgb_pixel in colors)
+ hsv_pixels[pixel_ticker++] = HSVAnalyticPixel.from_rgb(rgb_pixel);
+ qsort(hsv_pixels, hsv_pixels.length, sizeof(HSVAnalyticPixel), hsv_pixel_compare_func);
+
+ /* step through each pixel in the image data loaded from disk and map its color
+ to one of the user's theme colors */
+ for (int j = 0; j < loaded_pixbuf.height; j++) {
+ for (int i = 0; i < loaded_pixbuf.width; i++) {
+ int pixel_index = (j * loaded_pixbuf.rowstride) + (i * loaded_pixbuf.n_channels);
+ RGBAnalyticPixel pixel_color = RGBAnalyticPixel.from_quantized_components(
+ pixel_data[pixel_index], pixel_data[pixel_index + 1],
+ pixel_data[pixel_index + 2]);
+ HSVAnalyticPixel pixel_color_hsv = HSVAnalyticPixel.from_rgb(pixel_color);
+ int this_intensity = 0;
+ for (int k = 0; k < NUM_SUPPORTED_INTENSITIES; k++) {
+ if (hsv_pixels[k].light_value == pixel_color_hsv.light_value) {
+ this_intensity = k;
+ break;
+ }
+ }
+ pixel_data[pixel_index] = theme_colors[this_intensity].red;
+ pixel_data[pixel_index + 1] = theme_colors[this_intensity].green;
+ pixel_data[pixel_index + 2] = theme_colors[this_intensity].blue;
+ }
+ }
+
+ return loaded_pixbuf;
+ }
+
+ private static int hsv_pixel_compare_func(void* pixval1, void* pixval2) {
+ HSVAnalyticPixel pixel_val_1 = * ((HSVAnalyticPixel*) pixval1);
+ HSVAnalyticPixel pixel_val_2 = * ((HSVAnalyticPixel*) pixval2);
+
+ return (int) (255.0f * (pixel_val_1.light_value - pixel_val_2.light_value));
+ }
+
+ private static bool rgb_pixel_equal_func(RGBAnalyticPixel? p1, RGBAnalyticPixel? p2) {
+ return (p1.equals(p2));
+ }
+
+ private static uint rgb_pixel_hash_func(RGBAnalyticPixel? pixel_val) {
+ return pixel_val.hash_code();
+ }
+}
+
+public class RGBHistogramManipulator : Gtk.DrawingArea {
+ private enum LocationCode { LEFT_NUB, RIGHT_NUB, LEFT_TROUGH, RIGHT_TROUGH,
+ INSENSITIVE_AREA }
+ private const int NUB_SIZE = 13;
+ private const int NUB_HALF_WIDTH = NUB_SIZE / 2;
+ private const int NUB_V_NUDGE = 4;
+ private const int TROUGH_WIDTH = 256 + (2 * NUB_HALF_WIDTH);
+ private const int TROUGH_HEIGHT = 4;
+ private const int TROUGH_BOTTOM_OFFSET = 1;
+ private const int CONTROL_WIDTH = TROUGH_WIDTH + 2;
+ private const int CONTROL_HEIGHT = 118;
+ private const int NUB_V_POSITION = CONTROL_HEIGHT - TROUGH_HEIGHT - TROUGH_BOTTOM_OFFSET
+ - (NUB_SIZE - TROUGH_HEIGHT) / 2 - NUB_V_NUDGE - 2;
+ private int left_nub_max = 255 - NUB_SIZE - 1;
+ private int right_nub_min = NUB_SIZE + 1;
+
+ private static Gtk.Widget dummy_slider = null;
+ private static Gtk.Widget dummy_frame = null;
+ private static Gtk.WidgetPath slider_draw_path = new Gtk.WidgetPath();
+ private static Gtk.WidgetPath frame_draw_path = new Gtk.WidgetPath();
+ private static bool paths_setup = false;
+
+ private RGBHistogram histogram = null;
+ private int left_nub_position = 0;
+ private int right_nub_position = 255;
+ private Gdk.Pixbuf nub_pixbuf = ThemeLoader.load_icon("drag_nub.png");
+ private bool is_left_nub_tracking = false;
+ private bool is_right_nub_tracking = false;
+ private int track_start_x = 0;
+ private int track_nub_start_position = 0;
+
+ public RGBHistogramManipulator( ) {
+ set_size_request(CONTROL_WIDTH, CONTROL_HEIGHT);
+
+ if (dummy_slider == null)
+ dummy_slider = new Gtk.Scale(Gtk.Orientation.HORIZONTAL, null);
+
+ if (dummy_frame == null)
+ dummy_frame = new Gtk.Frame(null);
+
+ if (!paths_setup) {
+ slider_draw_path.append_type(typeof(Gtk.Scale));
+ slider_draw_path.iter_add_class(0, "scale");
+ slider_draw_path.iter_add_class(0, "range");
+
+ frame_draw_path.append_type(typeof(Gtk.Frame));
+ frame_draw_path.iter_add_class(0, "default");
+
+ paths_setup = true;
+ }
+
+ add_events(Gdk.EventMask.BUTTON_PRESS_MASK);
+ add_events(Gdk.EventMask.BUTTON_RELEASE_MASK);
+ add_events(Gdk.EventMask.BUTTON_MOTION_MASK);
+
+ button_press_event.connect(on_button_press);
+ button_release_event.connect(on_button_release);
+ motion_notify_event.connect(on_button_motion);
+ }
+
+ private LocationCode hit_test_point(int x, int y) {
+ if (y < NUB_V_POSITION)
+ return LocationCode.INSENSITIVE_AREA;
+
+ if ((x > left_nub_position) && (x < left_nub_position + NUB_SIZE))
+ return LocationCode.LEFT_NUB;
+
+ if ((x > right_nub_position) && (x < right_nub_position + NUB_SIZE))
+ return LocationCode.RIGHT_NUB;
+
+ if (y < (NUB_V_POSITION + NUB_V_NUDGE + 1))
+ return LocationCode.INSENSITIVE_AREA;
+
+ if ((x - left_nub_position) * (x - left_nub_position) <
+ (x - right_nub_position) * (x - right_nub_position))
+ return LocationCode.LEFT_TROUGH;
+ else
+ return LocationCode.RIGHT_TROUGH;
+ }
+
+ private bool on_button_press(Gdk.EventButton event_record) {
+ LocationCode loc = hit_test_point((int) event_record.x, (int) event_record.y);
+
+ switch (loc) {
+ case LocationCode.LEFT_NUB:
+ track_start_x = ((int) event_record.x);
+ track_nub_start_position = left_nub_position;
+ is_left_nub_tracking = true;
+ return true;
+
+ case LocationCode.RIGHT_NUB:
+ track_start_x = ((int) event_record.x);
+ track_nub_start_position = right_nub_position;
+ is_right_nub_tracking = true;
+ return true;
+
+ case LocationCode.LEFT_TROUGH:
+ left_nub_position = ((int) event_record.x) - NUB_HALF_WIDTH;
+ left_nub_position = left_nub_position.clamp(0, left_nub_max);
+ force_update();
+ nub_position_changed();
+ update_nub_extrema();
+ return true;
+
+ case LocationCode.RIGHT_TROUGH:
+ right_nub_position = ((int) event_record.x) - NUB_HALF_WIDTH;
+ right_nub_position = right_nub_position.clamp(right_nub_min, 255);
+ force_update();
+ nub_position_changed();
+ update_nub_extrema();
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ private bool on_button_release(Gdk.EventButton event_record) {
+ if (is_left_nub_tracking || is_right_nub_tracking) {
+ nub_position_changed();
+ update_nub_extrema();
+ }
+
+ is_left_nub_tracking = false;
+ is_right_nub_tracking = false;
+
+ return false;
+ }
+
+ private bool on_button_motion(Gdk.EventMotion event_record) {
+ if ((!is_left_nub_tracking) && (!is_right_nub_tracking))
+ return false;
+
+ if (is_left_nub_tracking) {
+ int track_x_delta = ((int) event_record.x) - track_start_x;
+ left_nub_position = (track_nub_start_position + track_x_delta);
+ left_nub_position = left_nub_position.clamp(0, left_nub_max);
+ } else { /* right nub is tracking */
+ int track_x_delta = ((int) event_record.x) - track_start_x;
+ right_nub_position = (track_nub_start_position + track_x_delta);
+ right_nub_position = right_nub_position.clamp(right_nub_min, 255);
+ }
+
+ force_update();
+ return true;
+ }
+
+ public override bool draw(Cairo.Context ctx) {
+ Gtk.Border padding = get_style_context().get_padding(Gtk.StateFlags.NORMAL);
+
+ Gdk.Rectangle area = Gdk.Rectangle();
+ area.x = padding.left;
+ area.y = padding.top;
+ area.width = RGBHistogram.GRAPHIC_WIDTH + padding.right;
+ area.height = RGBHistogram.GRAPHIC_HEIGHT + padding.bottom;
+
+ draw_histogram_frame(ctx, area);
+ draw_histogram(ctx, area);
+ draw_trough(ctx, area);
+ draw_nub(ctx, area, left_nub_position);
+ draw_nub(ctx, area, right_nub_position);
+
+ return true;
+ }
+
+ private void draw_histogram_frame(Cairo.Context ctx, Gdk.Rectangle area) {
+ // the framed area is inset and slightly smaller than the overall histogram
+ // control area
+ Gdk.Rectangle framed_area = area;
+ framed_area.x += 5;
+ framed_area.y += 1;
+ framed_area.width -= 8;
+ framed_area.height -= 12;
+
+ Gtk.StyleContext stylectx = dummy_frame.get_style_context();
+ stylectx.save();
+
+ stylectx.get_path().append_type(typeof(Gtk.Frame));
+ stylectx.get_path().iter_add_class(0, "default");
+ stylectx.add_class(Gtk.STYLE_CLASS_TROUGH);
+ stylectx.set_junction_sides(Gtk.JunctionSides.TOP | Gtk.JunctionSides.BOTTOM |
+ Gtk.JunctionSides.LEFT | Gtk.JunctionSides.RIGHT);
+
+ stylectx.render_frame(ctx, framed_area.x, framed_area.y, framed_area.width,
+ framed_area.height);
+
+ stylectx.restore();
+ }
+
+ private void draw_histogram(Cairo.Context ctx, Gdk.Rectangle area) {
+ if (histogram == null)
+ return;
+
+ Gdk.Pixbuf histogram_graphic = histogram.get_graphic().copy();
+ unowned uchar[] pixel_data = histogram_graphic.get_pixels();
+
+ int edge_blend_red = 0;
+ int edge_blend_green = 0;
+ int edge_blend_blue = 0;
+ int body_blend_red = 20;
+ int body_blend_green = 20;
+ int body_blend_blue = 20;
+
+ if (left_nub_position > 0) {
+ int edge_pixel_index = histogram_graphic.n_channels * left_nub_position;
+ for (int i = 0; i < histogram_graphic.height; i++) {
+ int body_pixel_index = i * histogram_graphic.rowstride;
+ int row_last_pixel = body_pixel_index + histogram_graphic.n_channels *
+ left_nub_position;
+ while (body_pixel_index < row_last_pixel) {
+ pixel_data[body_pixel_index] =
+ (uchar) ((pixel_data[body_pixel_index] + body_blend_red) / 2);
+ pixel_data[body_pixel_index + 1] =
+ (uchar) ((pixel_data[body_pixel_index + 1] + body_blend_green) / 2);
+ pixel_data[body_pixel_index + 2] =
+ (uchar) ((pixel_data[body_pixel_index + 2] + body_blend_blue) / 2);
+
+ body_pixel_index += histogram_graphic.n_channels;
+ }
+
+ pixel_data[edge_pixel_index] =
+ (uchar) ((pixel_data[edge_pixel_index] + edge_blend_red) / 2);
+ pixel_data[edge_pixel_index + 1] =
+ (uchar) ((pixel_data[edge_pixel_index + 1] + edge_blend_green) / 2);
+ pixel_data[edge_pixel_index + 2] =
+ (uchar) ((pixel_data[edge_pixel_index + 2] + edge_blend_blue) / 2);
+
+ edge_pixel_index += histogram_graphic.rowstride;
+ }
+ }
+
+ edge_blend_red = 250;
+ edge_blend_green = 250;
+ edge_blend_blue = 250;
+ body_blend_red = 200;
+ body_blend_green = 200;
+ body_blend_blue = 200;
+
+ if (right_nub_position < 255) {
+ int edge_pixel_index = histogram_graphic.n_channels * right_nub_position;
+ for (int i = 0; i < histogram_graphic.height; i++) {
+ int body_pixel_index = i * histogram_graphic.rowstride +
+ histogram_graphic.n_channels * 255;
+ int row_last_pixel = i * histogram_graphic.rowstride +
+ histogram_graphic.n_channels * right_nub_position;
+ while (body_pixel_index > row_last_pixel) {
+ pixel_data[body_pixel_index] =
+ (uchar) ((pixel_data[body_pixel_index] + body_blend_red) / 2);
+ pixel_data[body_pixel_index + 1] =
+ (uchar) ((pixel_data[body_pixel_index + 1] + body_blend_green) / 2);
+ pixel_data[body_pixel_index + 2] =
+ (uchar) ((pixel_data[body_pixel_index + 2] + body_blend_blue) / 2);
+
+ body_pixel_index -= histogram_graphic.n_channels;
+ }
+ pixel_data[edge_pixel_index] =
+ (uchar) ((pixel_data[edge_pixel_index] + edge_blend_red) / 2);
+ pixel_data[edge_pixel_index + 1] =
+ (uchar) ((pixel_data[edge_pixel_index + 1] + edge_blend_green) / 2);
+ pixel_data[edge_pixel_index + 2] =
+ (uchar) ((pixel_data[edge_pixel_index + 2] + edge_blend_blue) / 2);
+
+ edge_pixel_index += histogram_graphic.rowstride;
+ }
+ }
+
+ Gdk.cairo_set_source_pixbuf(ctx, histogram_graphic, area.x + NUB_HALF_WIDTH, area.y + 2);
+ ctx.paint();
+ }
+
+ private void draw_trough(Cairo.Context ctx, Gdk.Rectangle area) {
+ int trough_x = area.x;
+ int trough_y = area.y + (CONTROL_HEIGHT - TROUGH_HEIGHT - TROUGH_BOTTOM_OFFSET - 3);
+
+ Gtk.StyleContext stylectx = dummy_slider.get_style_context();
+ stylectx.save();
+
+ stylectx.get_path().append_type(typeof(Gtk.Scale));
+ stylectx.get_path().iter_add_class(0, "scale");
+ stylectx.add_class(Gtk.STYLE_CLASS_TROUGH);
+
+ stylectx.render_activity(ctx, trough_x, trough_y, TROUGH_WIDTH, TROUGH_HEIGHT);
+
+ stylectx.restore();
+ }
+
+ private void draw_nub(Cairo.Context ctx, Gdk.Rectangle area, int position) {
+ Gdk.cairo_set_source_pixbuf(ctx, nub_pixbuf, area.x + position, area.y + NUB_V_POSITION);
+ ctx.paint();
+ }
+
+ private void force_update() {
+ get_window().invalidate_rect(null, true);
+ get_window().process_updates(true);
+ }
+
+ private void update_nub_extrema() {
+ right_nub_min = left_nub_position + NUB_SIZE + 1;
+ left_nub_max = right_nub_position - NUB_SIZE - 1;
+ }
+
+ public signal void nub_position_changed();
+
+ public void update_histogram(Gdk.Pixbuf source_pixbuf) {
+ histogram = new RGBHistogram(source_pixbuf);
+ force_update();
+ }
+
+ public int get_left_nub_position() {
+ return left_nub_position;
+ }
+
+ public int get_right_nub_position() {
+ return right_nub_position;
+ }
+
+ public void set_left_nub_position(int user_nub_pos) {
+ assert ((user_nub_pos >= 0) && (user_nub_pos <= 255));
+ left_nub_position = user_nub_pos.clamp(0, left_nub_max);
+ update_nub_extrema();
+ }
+
+ public void set_right_nub_position(int user_nub_pos) {
+ assert ((user_nub_pos >= 0) && (user_nub_pos <= 255));
+ right_nub_position = user_nub_pos.clamp(right_nub_min, 255);
+ update_nub_extrema();
+ }
+}
+
diff --git a/src/Debug.vala b/src/Debug.vala
new file mode 100644
index 0000000..ad626f4
--- /dev/null
+++ b/src/Debug.vala
@@ -0,0 +1,146 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+namespace Debug {
+ private const LogLevelFlags DEFAULT_LOG_MASK =
+ LogLevelFlags.LEVEL_CRITICAL |
+ LogLevelFlags.LEVEL_WARNING |
+ LogLevelFlags.LEVEL_MESSAGE;
+
+ public const string VIEWER_PREFIX = "V";
+ public const string LIBRARY_PREFIX = "L";
+
+ // Ideally, there would be a LogLevelFlags.NONE constant to use as
+ // empty value but failing that, 0 works as well
+ private LogLevelFlags log_mask = 0;
+ private string log_app_version_prefix;
+ // log_file_stream is the canonical reference to the file stream (and owns
+ // it), while log_out and log_err are indirections that can point to
+ // log_file_stream or stdout and stderr respectively
+ private unowned FileStream log_out = null;
+ private unowned FileStream log_err = null;
+ private FileStream log_file_stream = null;
+
+ public static void init(string app_version_prefix) {
+ log_app_version_prefix = app_version_prefix;
+
+ // default to stdout/stderr if file cannot be opened or console is specified
+ log_out = stdout;
+ log_err = stderr;
+
+ string log_file_error_msg = null;
+
+ // logging to disk is currently off for viewer more; see http://trac.yorba.org/ticket/2078
+ 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();
+ try {
+ if (log_dir.query_exists(null) == false) {
+ if (!log_dir.make_directory_with_parents(null)) {
+ log_file_error_msg = "Unable to create data directory %s".printf(log_dir.get_path());
+ }
+ }
+ } catch (Error err) {
+ log_file_error_msg = err.message;
+ }
+ // overwrite the log file every time the application is started
+ // to ensure it doesn't grow too large; if there is a need for
+ // keeping the log, the 'w' should be replaced by 'a' and some sort
+ // of log rotation implemented
+ log_file_stream = FileStream.open(log_file.get_path(), "w");
+ if(log_file_stream != null) {
+ log_out = log_file_stream;
+ log_err = log_file_stream;
+ } else {
+ log_file_error_msg = "Unable to open or create log file %s".printf(log_file.get_path());
+ }
+ }
+
+ if (Environment.get_variable("SHOTWELL_LOG") != null) {
+ log_mask = LogLevelFlags.LEVEL_MASK;
+ } else {
+ log_mask = ((Environment.get_variable("SHOTWELL_INFO") != null) ?
+ log_mask | LogLevelFlags.LEVEL_INFO :
+ log_mask);
+ log_mask = ((Environment.get_variable("SHOTWELL_DEBUG") != null) ?
+ log_mask | LogLevelFlags.LEVEL_DEBUG :
+ log_mask);
+ log_mask = ((Environment.get_variable("SHOTWELL_MESSAGE") != null) ?
+ log_mask | LogLevelFlags.LEVEL_MESSAGE :
+ log_mask);
+ log_mask = ((Environment.get_variable("SHOTWELL_WARNING") != null) ?
+ log_mask | LogLevelFlags.LEVEL_WARNING :
+ log_mask);
+ log_mask = ((Environment.get_variable("SHOTWELL_CRITICAL") != null) ?
+ log_mask | LogLevelFlags.LEVEL_CRITICAL :
+ log_mask);
+ }
+
+ Log.set_handler(null, LogLevelFlags.LEVEL_INFO, info_handler);
+ Log.set_handler(null, LogLevelFlags.LEVEL_DEBUG, debug_handler);
+ Log.set_handler(null, LogLevelFlags.LEVEL_MESSAGE, message_handler);
+ Log.set_handler(null, LogLevelFlags.LEVEL_WARNING, warning_handler);
+ Log.set_handler(null, LogLevelFlags.LEVEL_CRITICAL, critical_handler);
+
+ if(log_mask == 0 && log_file != null) {
+ // if the log mask is still 0 and we have a log file, set the
+ // mask to the default
+ log_mask = DEFAULT_LOG_MASK;
+ }
+
+ if(log_file_error_msg != null) {
+ warning("%s", log_file_error_msg);
+ }
+ }
+
+ public static void terminate() {
+ }
+
+ private bool is_enabled(LogLevelFlags flag) {
+ return ((log_mask & flag) > 0);
+ }
+
+ 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(),
+ prefix,
+ message
+ );
+ stream.flush();
+ }
+
+ private void info_handler(string? domain, LogLevelFlags flags, string message) {
+ if (is_enabled(LogLevelFlags.LEVEL_INFO))
+ log(log_out, "INF", message);
+ }
+
+ private void debug_handler(string? domain, LogLevelFlags flags, string message) {
+ if (is_enabled(LogLevelFlags.LEVEL_DEBUG))
+ log(log_out, "DBG", message);
+ }
+
+ private void message_handler(string? domain, LogLevelFlags flags, string message) {
+ if (is_enabled(LogLevelFlags.LEVEL_MESSAGE))
+ log(log_err, "MSG", message);
+ }
+
+ private void warning_handler(string? domain, LogLevelFlags flags, string message) {
+ if (is_enabled(LogLevelFlags.LEVEL_WARNING))
+ log(log_err, "WRN", message);
+ }
+
+ private void critical_handler(string? domain, LogLevelFlags flags, string message) {
+ if (is_enabled(LogLevelFlags.LEVEL_CRITICAL)) {
+ log(log_err, "CRT", message);
+ if (log_file_stream != null)
+ log(stderr, "CRT", message); // also log to console
+ }
+ }
+}
+
diff --git a/src/DesktopIntegration.vala b/src/DesktopIntegration.vala
new file mode 100644
index 0000000..ebdc45e
--- /dev/null
+++ b/src/DesktopIntegration.vala
@@ -0,0 +1,308 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace DesktopIntegration {
+
+private const string SENDTO_EXEC = "nautilus-sendto";
+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;
+private double desktop_slideshow_duration = 0.0;
+
+public void init() {
+ if (init_count++ != 0)
+ return;
+
+ send_to_installed = Environment.find_program_in_path(SENDTO_EXEC) != null;
+}
+
+public void terminate() {
+ if (--init_count == 0)
+ return;
+}
+
+public AppInfo? get_default_app_for_mime_types(string[] mime_types,
+ Gee.ArrayList<string> preferred_apps) {
+ SortedList<AppInfo> external_apps = get_apps_for_mime_types(mime_types);
+
+ foreach (string preferred_app in preferred_apps) {
+ foreach (AppInfo external_app in external_apps) {
+ if (external_app.get_name().contains(preferred_app))
+ return external_app;
+ }
+ }
+
+ return null;
+}
+
+// compare the app names, case insensitive
+public static int64 app_info_comparator(void *a, void *b) {
+ return ((AppInfo) a).get_name().down().collate(((AppInfo) b).get_name().down());
+}
+
+public SortedList<AppInfo> get_apps_for_mime_types(string[] mime_types) {
+ SortedList<AppInfo> external_apps = new SortedList<AppInfo>(app_info_comparator);
+
+ if (mime_types.length == 0)
+ return external_apps;
+
+ // 3 loops because SortedList.contains() wasn't paying nicely with AppInfo,
+ // probably because it has a special equality function
+ foreach (string mime_type in mime_types) {
+ string content_type = ContentType.from_mime_type(mime_type);
+ if (content_type == null)
+ break;
+
+ foreach (AppInfo external_app in
+ AppInfo.get_all_for_type(content_type)) {
+ bool already_contains = false;
+
+ foreach (AppInfo app in external_apps) {
+ if (app.get_name() == external_app.get_name()) {
+ already_contains = true;
+ break;
+ }
+ }
+
+ // dont add Shotwell to app list
+ if (!already_contains && !external_app.get_name().contains(Resources.APP_TITLE))
+ external_apps.add(external_app);
+ }
+ }
+
+ return external_apps;
+}
+
+public string? get_app_open_command(AppInfo app_info) {
+ string? str = app_info.get_commandline();
+
+ return str != null ? str : app_info.get_executable();
+}
+
+public bool is_send_to_installed() {
+ return send_to_installed;
+}
+
+public void files_send_to(File[] files) {
+ if (files.length == 0)
+ return;
+
+ string[] argv = new string[files.length + 1];
+ argv[0] = SENDTO_EXEC;
+
+ for (int ctr = 0; ctr < files.length; ctr++)
+ argv[ctr + 1] = files[ctr].get_path();
+
+ try {
+ AppWindow.get_instance().set_busy_cursor();
+
+ Pid child_pid;
+ Process.spawn_async(
+ "/",
+ argv,
+ null, // environment
+ SpawnFlags.SEARCH_PATH,
+ null, // child setup
+ out child_pid);
+
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ AppWindow.error_message(_("Unable to launch Nautilus Send-To: %s").printf(err.message));
+ }
+}
+
+public void send_to(Gee.Collection<MediaSource> media) {
+ if (media.size == 0 || send_to_exporter != null)
+ return;
+
+ ExportDialog dialog = new ExportDialog(_("Send To"));
+
+ // determine the mix of media in the export collection -- if it contains only
+ // videos then we can use the Video.export_many( ) fast path and not have to
+ // worry about ExportFormatParameters or the Export... dialog
+ if (MediaSourceCollection.has_video(media) && !MediaSourceCollection.has_photo(media)) {
+ send_to_exporter = Video.export_many((Gee.Collection<Video>) media,
+ on_send_to_export_completed, true);
+ return;
+ }
+
+ int scale;
+ ScaleConstraint constraint;
+ ExportFormatParameters export_params = ExportFormatParameters.current();
+ if (!dialog.execute(out scale, out constraint, ref export_params))
+ return;
+
+ send_to_exporter = new ExporterUI(new Exporter.for_temp_file(media,
+ Scaling.for_constraint(constraint, scale, false), export_params));
+ send_to_exporter.export(on_send_to_export_completed);
+}
+
+private void on_send_to_export_completed(Exporter exporter, bool is_cancelled) {
+ if (!is_cancelled)
+ files_send_to(exporter.get_exported_files());
+
+ send_to_exporter = null;
+}
+
+public void set_background(Photo photo) {
+ // attempt to set the wallpaper to the photo's native format, but if not writeable, go to the
+ // system default
+ PhotoFileFormat file_format = photo.get_best_export_file_format();
+
+ File save_as = AppDirs.get_data_subdir("wallpaper").get_child(
+ file_format.get_default_basename("wallpaper"));
+
+ if (Config.Facade.get_instance().get_desktop_background() == save_as.get_path()) {
+ save_as = AppDirs.get_data_subdir("wallpaper").get_child(
+ file_format.get_default_basename("wallpaper_alt"));
+ }
+
+ try {
+ photo.export(save_as, Scaling.for_original(), Jpeg.Quality.HIGH, file_format);
+ } catch (Error err) {
+ AppWindow.error_message(_("Unable to export background to %s: %s").printf(save_as.get_path(),
+ err.message));
+
+ return;
+ }
+
+ Config.Facade.get_instance().set_desktop_background(save_as.get_path());
+
+ GLib.FileUtils.chmod(save_as.get_parse_name(), 0644);
+}
+
+// Helper class for set_background_slideshow()
+// Used to build xml file that describes background
+// slideshow for Gnome
+private class BackgroundSlideshowXMLBuilder {
+ private File destination;
+ private double duration;
+ private double transition;
+ private File tmp_file;
+ private DataOutputStream? outs = null;
+ private File? first_file = null;
+ private File? last_file = null;
+
+ public BackgroundSlideshowXMLBuilder(File destination, double duration, double transition) {
+ this.destination = destination;
+ this.duration = duration;
+ this.transition = transition;
+
+ tmp_file = destination.get_parent().get_child(destination.get_basename() + ".tmp");
+ }
+
+ public void open() throws Error {
+ outs = new DataOutputStream(tmp_file.replace(null, false, FileCreateFlags.NONE, null));
+ outs.put_string("<background>\n");
+ }
+
+ private void write_transition(File from, File to) throws Error {
+ outs.put_string(" <transition>\n");
+ outs.put_string(" <duration>%2.2f</duration>\n".printf(transition));
+ outs.put_string(" <from>%s</from>\n".printf(from.get_path()));
+ outs.put_string(" <to>%s</to>\n".printf(to.get_path()));
+ outs.put_string(" </transition>\n");
+ }
+
+ private void write_static(File file) throws Error {
+ outs.put_string(" <static>\n");
+ outs.put_string(" <duration>%2.2f</duration>\n".printf(duration));
+ outs.put_string(" <file>%s</file>\n".printf(file.get_path()));
+ outs.put_string(" </static>\n");
+ }
+
+ public void add_photo(File file) throws Error {
+ assert(outs != null);
+
+ if (first_file == null)
+ first_file = file;
+
+ if (last_file != null)
+ write_transition(last_file, file);
+
+ write_static(file);
+
+ last_file = file;
+ }
+
+ public File? close() throws Error {
+ if (outs == null)
+ return null;
+
+ // transition back to first file
+ if (first_file != null && last_file != null)
+ write_transition(last_file, first_file);
+
+ outs.put_string("</background>\n");
+
+ outs.close();
+ outs = null;
+
+ // move to destination name
+ tmp_file.move(destination, FileCopyFlags.OVERWRITE);
+ GLib.FileUtils.chmod(destination.get_parse_name(), 0644);
+
+ return destination;
+ }
+}
+
+public void set_background_slideshow(Gee.Collection<Photo> photos, double duration, double transition) {
+ if (desktop_slideshow_exporter != null)
+ return;
+
+ File wallpaper_dir = AppDirs.get_data_subdir("wallpaper");
+
+ Gee.Set<string> exceptions = new Gee.HashSet<string>();
+ exceptions.add(DESKTOP_SLIDESHOW_XML_FILENAME);
+ try {
+ delete_all_files(wallpaper_dir, exceptions);
+ } catch (Error err) {
+ warning("Error attempting to clear wallpaper directory: %s", err.message);
+ }
+
+ desktop_slideshow_duration = duration;
+ desktop_slideshow_transition = transition;
+
+ Exporter exporter = new Exporter(photos, wallpaper_dir,
+ Scaling.to_fill_screen(AppWindow.get_instance()), ExportFormatParameters.current(),
+ true);
+ desktop_slideshow_exporter = new ExporterUI(exporter);
+ desktop_slideshow_exporter.export(on_desktop_slideshow_exported);
+}
+
+private void on_desktop_slideshow_exported(Exporter exporter, bool is_cancelled) {
+ desktop_slideshow_exporter = null;
+
+ if (is_cancelled)
+ return;
+
+ File? xml_file = null;
+ BackgroundSlideshowXMLBuilder xml_builder = new BackgroundSlideshowXMLBuilder(
+ AppDirs.get_data_subdir("wallpaper").get_child(DESKTOP_SLIDESHOW_XML_FILENAME),
+ desktop_slideshow_duration, desktop_slideshow_transition);
+ try {
+ xml_builder.open();
+
+ foreach (File file in exporter.get_exported_files())
+ xml_builder.add_photo(file);
+
+ xml_file = xml_builder.close();
+ } catch (Error err) {
+ AppWindow.error_message(_("Unable to prepare desktop slideshow: %s").printf(
+ err.message));
+
+ return;
+ }
+
+ Config.Facade.get_instance().set_desktop_background(xml_file.get_path());
+}
+
+}
diff --git a/src/Dialogs.vala b/src/Dialogs.vala
new file mode 100644
index 0000000..149a6de
--- /dev/null
+++ b/src/Dialogs.vala
@@ -0,0 +1,2714 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// namespace for future migration of AppWindow alert and other question dialogs into single
+// place: http://trac.yorba.org/ticket/3452
+namespace Dialogs {
+
+public bool confirm_delete_tag(Tag tag) {
+ int count = tag.get_sources_count();
+ if (count == 0)
+ return true;
+ string msg = ngettext(
+ "This will remove the tag \"%s\" from one photo. Continue?",
+ "This will remove the tag \"%s\" from %d photos. Continue?",
+ count).printf(tag.get_user_visible_name(), count);
+
+ return AppWindow.negate_affirm_question(msg, _("_Cancel"), _("_Delete"),
+ Resources.DELETE_TAG_TITLE);
+}
+
+public bool confirm_delete_saved_search(SavedSearch search) {
+ string msg = _("This will remove the saved search \"%s\". Continue?")
+ .printf(search.get_name());
+
+ return AppWindow.negate_affirm_question(msg, _("_Cancel"), _("_Delete"),
+ Resources.DELETE_SAVED_SEARCH_DIALOG_TITLE);
+}
+
+public bool confirm_warn_developer_changed(int number) {
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog.with_markup(AppWindow.get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, "%s",
+ "<span weight=\"bold\" size=\"larger\">%s</span>".printf(ngettext("Switching developers will undo all changes you have made to this photo in Shotwell",
+ "Switching developers will undo all changes you have made to these photos in Shotwell", number)));
+
+ dialog.add_buttons(Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL);
+ dialog.add_buttons(_("_Switch Developer"), Gtk.ResponseType.YES);
+
+ int response = dialog.run();
+
+ dialog.destroy();
+
+ return response == Gtk.ResponseType.YES;
+}
+
+}
+
+namespace ExportUI {
+private static File current_export_dir = null;
+
+public File? choose_file(string current_file_basename) {
+ if (current_export_dir == null)
+ 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");
+
+ Gtk.FileChooserDialog chooser = new Gtk.FileChooserDialog(file_chooser_title,
+ AppWindow.get_instance(), Gtk.FileChooserAction.SAVE, Gtk.Stock.CANCEL,
+ Gtk.ResponseType.CANCEL, Gtk.Stock.SAVE, Gtk.ResponseType.ACCEPT, null);
+ chooser.set_do_overwrite_confirmation(true);
+ chooser.set_current_folder(current_export_dir.get_path());
+ chooser.set_current_name(current_file_basename);
+ chooser.set_local_only(false);
+
+ // The log handler reset should be removed once GTK 3.4 becomes widely available;
+ // please see https://bugzilla.gnome.org/show_bug.cgi?id=662814 for details.
+ Log.set_handler("Gtk", LogLevelFlags.LEVEL_WARNING, suppress_warnings);
+ File file = null;
+ if (chooser.run() == Gtk.ResponseType.ACCEPT) {
+ file = File.new_for_path(chooser.get_filename());
+ current_export_dir = file.get_parent();
+ }
+ Log.set_handler("Gtk", LogLevelFlags.LEVEL_WARNING, Log.default_handler);
+ chooser.destroy();
+
+ return file;
+}
+
+public File? choose_dir(string? user_title = null) {
+ if (current_export_dir == null)
+ current_export_dir = File.new_for_path(Environment.get_home_dir());
+
+ if (user_title == null)
+ user_title = _("Export Photos");
+
+ Gtk.FileChooserDialog chooser = new Gtk.FileChooserDialog(user_title,
+ AppWindow.get_instance(), Gtk.FileChooserAction.SELECT_FOLDER, Gtk.Stock.CANCEL,
+ Gtk.ResponseType.CANCEL, Gtk.Stock.OK, Gtk.ResponseType.ACCEPT, null);
+ chooser.set_current_folder(current_export_dir.get_path());
+ chooser.set_local_only(false);
+
+ File dir = null;
+ if (chooser.run() == Gtk.ResponseType.ACCEPT) {
+ dir = File.new_for_path(chooser.get_filename());
+ current_export_dir = dir;
+ }
+
+ chooser.destroy();
+
+ return dir;
+}
+}
+
+// Ticket #3023
+// Attempt to replace the system error with something friendlier
+// if we can't copy an image over for editing in an external tool.
+public void open_external_editor_error_dialog(Error err, Photo photo) {
+ // Did we fail because we can't write to this directory?
+ if (err is IOError.PERMISSION_DENIED || err is FileError.PERM) {
+ // Yes - display an alternate error message here.
+ AppWindow.error_message(
+ _("Shotwell couldn't create a file for editing this photo because you do not have permission to write to %s.").printf(photo.get_master_file().get_parent().get_path()));
+ } else {
+ // No - something else is wrong, display the error message
+ // the system gave us.
+ AppWindow.error_message(Resources.launch_editor_failed(err));
+ }
+}
+
+public Gtk.ResponseType export_error_dialog(File dest, bool photos_remaining) {
+ string message = _("Unable to export the following photo due to a file error.\n\n") +
+ dest.get_path();
+
+ Gtk.ResponseType response = Gtk.ResponseType.NONE;
+
+ if (photos_remaining) {
+ message += _("\n\nWould you like to continue exporting?");
+ response = AppWindow.affirm_cancel_question(message, _("Con_tinue"));
+ } else {
+ AppWindow.error_message(message);
+ }
+
+ return response;
+}
+
+
+public class ExportDialog : Gtk.Dialog {
+ public const int DEFAULT_SCALE = 1200;
+
+ // "Unmodified" and "Current," though they appear in the "Format:" popup menu, really
+ // aren't formats so much as they are operating modes that determine specific formats.
+ // Hereafter we'll refer to these as "special formats."
+ public const int NUM_SPECIAL_FORMATS = 2;
+ public const string UNMODIFIED_FORMAT_LABEL = _("Unmodified");
+ public const string CURRENT_FORMAT_LABEL = _("Current");
+
+ public const ScaleConstraint[] CONSTRAINT_ARRAY = { ScaleConstraint.ORIGINAL,
+ ScaleConstraint.DIMENSIONS, ScaleConstraint.WIDTH, ScaleConstraint.HEIGHT };
+
+ public const Jpeg.Quality[] QUALITY_ARRAY = { Jpeg.Quality.LOW, Jpeg.Quality.MEDIUM,
+ Jpeg.Quality.HIGH, Jpeg.Quality.MAXIMUM };
+
+ private static ScaleConstraint current_constraint = ScaleConstraint.ORIGINAL;
+ private static ExportFormatParameters current_parameters = ExportFormatParameters.current();
+ private static int current_scale = DEFAULT_SCALE;
+
+ private Gtk.Grid table = new Gtk.Grid();
+ private Gtk.ComboBoxText quality_combo;
+ private Gtk.ComboBoxText constraint_combo;
+ private Gtk.ComboBoxText format_combo;
+ private Gtk.CheckButton export_metadata;
+ private Gee.ArrayList<string> format_options = new Gee.ArrayList<string>();
+ private Gtk.Entry pixels_entry;
+ private Gtk.Widget ok_button;
+ private bool in_insert = false;
+
+ public ExportDialog(string title) {
+ this.title = title;
+ resizable = false;
+
+ quality_combo = new Gtk.ComboBoxText();
+ int ctr = 0;
+ foreach (Jpeg.Quality quality in QUALITY_ARRAY) {
+ quality_combo.append_text(quality.to_string());
+ if (quality == current_parameters.quality)
+ quality_combo.set_active(ctr);
+ ctr++;
+ }
+
+ constraint_combo = new Gtk.ComboBoxText();
+ ctr = 0;
+ foreach (ScaleConstraint constraint in CONSTRAINT_ARRAY) {
+ constraint_combo.append_text(constraint.to_string());
+ if (constraint == current_constraint)
+ constraint_combo.set_active(ctr);
+ ctr++;
+ }
+
+ format_combo = new Gtk.ComboBoxText();
+ format_add_option(UNMODIFIED_FORMAT_LABEL);
+ format_add_option(CURRENT_FORMAT_LABEL);
+ foreach (PhotoFileFormat format in PhotoFileFormat.get_writeable()) {
+ format_add_option(format.get_properties().get_user_visible_name());
+ }
+
+ pixels_entry = new Gtk.Entry();
+ pixels_entry.set_max_length(6);
+ pixels_entry.set_size_request(60, -1);
+ pixels_entry.set_text("%d".printf(current_scale));
+
+ // register after preparation to avoid signals during init
+ constraint_combo.changed.connect(on_constraint_changed);
+ format_combo.changed.connect(on_format_changed);
+ pixels_entry.changed.connect(on_pixels_changed);
+ pixels_entry.insert_text.connect(on_pixels_insert_text);
+ pixels_entry.activate.connect(on_activate);
+
+ // layout controls
+ add_label(_("_Format:"), 0, 0, format_combo);
+ add_control(format_combo, 1, 0);
+
+ add_label(_("_Quality:"), 0, 1, quality_combo);
+ add_control(quality_combo, 1, 1);
+
+ add_label(_("_Scaling constraint:"), 0, 2, constraint_combo);
+ add_control(constraint_combo, 1, 2);
+
+ Gtk.Label pixels_label = new Gtk.Label.with_mnemonic(_(" _pixels"));
+ pixels_label.set_mnemonic_widget(pixels_entry);
+
+ Gtk.Box pixels_box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+ pixels_box.pack_start(pixels_entry, false, false, 0);
+ pixels_box.pack_end(pixels_label, false, false, 0);
+ add_control(pixels_box, 1, 3);
+
+ export_metadata = new Gtk.CheckButton.with_label(_("Export metadata"));
+ add_control(export_metadata, 1, 4);
+ export_metadata.active = true;
+
+ table.set_row_spacing(4);
+ table.set_column_spacing(4);
+ table.set_margin_top(4);
+ table.set_margin_bottom(4);
+ table.set_margin_left(4);
+ table.set_margin_right(4);
+
+ ((Gtk.Box) get_content_area()).add(table);
+
+ // add buttons to action area
+ add_button(Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL);
+ ok_button = add_button(Gtk.Stock.OK, Gtk.ResponseType.OK);
+
+ ok_button.set_can_default(true);
+ ok_button.has_default = true;
+ set_default(ok_button);
+
+ if (current_constraint == ScaleConstraint.ORIGINAL) {
+ pixels_entry.sensitive = false;
+ quality_combo.sensitive = false;
+ }
+
+ ok_button.grab_focus();
+ }
+
+ private void format_add_option(string format_name) {
+ format_options.add(format_name);
+ format_combo.append_text(format_name);
+ }
+
+ private void format_set_active_text(string text) {
+ int selection_ticker = 0;
+
+ foreach (string current_text in format_options) {
+ if (current_text == text) {
+ format_combo.set_active(selection_ticker);
+ return;
+ }
+ selection_ticker++;
+ }
+
+ error("format_set_active_text: text '%s' isn't in combo box", text);
+ }
+
+ private PhotoFileFormat get_specified_format() {
+ int index = format_combo.get_active();
+ if (index < NUM_SPECIAL_FORMATS)
+ index = NUM_SPECIAL_FORMATS;
+
+ index -= NUM_SPECIAL_FORMATS;
+ PhotoFileFormat[] writeable_formats = PhotoFileFormat.get_writeable();
+ return writeable_formats[index];
+ }
+
+ private string get_label_for_parameters(ExportFormatParameters params) {
+ switch(params.mode) {
+ case ExportFormatMode.UNMODIFIED:
+ return UNMODIFIED_FORMAT_LABEL;
+
+ case ExportFormatMode.CURRENT:
+ return CURRENT_FORMAT_LABEL;
+
+ case ExportFormatMode.SPECIFIED:
+ return params.specified_format.get_properties().get_user_visible_name();
+
+ default:
+ error("get_label_for_parameters: unrecognized export format mode");
+ }
+ }
+
+ // unlike other parameters, which should be persisted across dialog executions, the
+ // format parameters must be set each time the dialog is executed -- this is why
+ // it's passed qualified as ref and not as out
+ public bool execute(out int scale, out ScaleConstraint constraint,
+ ref ExportFormatParameters parameters) {
+ show_all();
+
+ // if the export format mode isn't set to last (i.e., don't use the persisted settings),
+ // reset the scale constraint to original size
+ if (parameters.mode != ExportFormatMode.LAST) {
+ current_constraint = constraint = ScaleConstraint.ORIGINAL;
+ constraint_combo.set_active(0);
+ }
+
+ if (parameters.mode == ExportFormatMode.LAST)
+ parameters = current_parameters;
+ else if (parameters.mode == ExportFormatMode.SPECIFIED && !parameters.specified_format.can_write())
+ parameters.specified_format = PhotoFileFormat.get_system_default_format();
+
+ format_set_active_text(get_label_for_parameters(parameters));
+ on_format_changed();
+
+ bool ok = (run() == Gtk.ResponseType.OK);
+ if (ok) {
+ int index = constraint_combo.get_active();
+ assert(index >= 0);
+ constraint = CONSTRAINT_ARRAY[index];
+ current_constraint = constraint;
+
+ scale = int.parse(pixels_entry.get_text());
+ if (constraint != ScaleConstraint.ORIGINAL)
+ assert(scale > 0);
+ current_scale = scale;
+
+ parameters.export_metadata = export_metadata.sensitive ? export_metadata.active : false;
+
+ if (format_combo.get_active_text() == UNMODIFIED_FORMAT_LABEL) {
+ parameters.mode = current_parameters.mode = ExportFormatMode.UNMODIFIED;
+ } else if (format_combo.get_active_text() == CURRENT_FORMAT_LABEL) {
+ parameters.mode = current_parameters.mode = ExportFormatMode.CURRENT;
+ } else {
+ parameters.mode = current_parameters.mode = ExportFormatMode.SPECIFIED;
+ parameters.specified_format = current_parameters.specified_format = get_specified_format();
+ if (current_parameters.specified_format == PhotoFileFormat.JFIF)
+ parameters.quality = current_parameters.quality = QUALITY_ARRAY[quality_combo.get_active()];
+ }
+ } else {
+ scale = 0;
+ constraint = ScaleConstraint.ORIGINAL;
+ }
+
+ destroy();
+
+ return ok;
+ }
+
+ private void add_label(string text, int x, int y, Gtk.Widget? widget = null) {
+ Gtk.Alignment left_aligned = new Gtk.Alignment(0.0f, 0.5f, 0, 0);
+
+ Gtk.Label new_label = new Gtk.Label.with_mnemonic(text);
+ new_label.set_use_underline(true);
+
+ if (widget != null)
+ new_label.set_mnemonic_widget(widget);
+
+ left_aligned.add(new_label);
+
+ table.attach(left_aligned, x, y, 1, 1);
+ }
+
+ private void add_control(Gtk.Widget widget, int x, int y) {
+ Gtk.Alignment left_aligned = new Gtk.Alignment(0, 0.5f, 0, 0);
+ left_aligned.add(widget);
+
+ table.attach(left_aligned, x, y, 1, 1);
+ }
+
+ private void on_constraint_changed() {
+ bool original = CONSTRAINT_ARRAY[constraint_combo.get_active()] == ScaleConstraint.ORIGINAL;
+ bool jpeg = format_combo.get_active_text() ==
+ PhotoFileFormat.JFIF.get_properties().get_user_visible_name();
+ pixels_entry.sensitive = !original;
+ quality_combo.sensitive = !original && jpeg;
+ if (original)
+ ok_button.sensitive = true;
+ else
+ on_pixels_changed();
+ }
+
+ private void on_format_changed() {
+ bool original = CONSTRAINT_ARRAY[constraint_combo.get_active()] == ScaleConstraint.ORIGINAL;
+
+ if (format_combo.get_active_text() == UNMODIFIED_FORMAT_LABEL) {
+ // if the user wishes to export the media unmodified, then we just copy the original
+ // files, so parameterizing size, quality, etc. is impossible -- these are all
+ // just as they are in the original file. In this case, we set the scale constraint to
+ // original and lock out all the controls
+ constraint_combo.set_active(0); /* 0 == original size */
+ constraint_combo.set_sensitive(false);
+ quality_combo.set_sensitive(false);
+ pixels_entry.sensitive = false;
+ export_metadata.active = false;
+ export_metadata.sensitive = false;
+ } else if (format_combo.get_active_text() == CURRENT_FORMAT_LABEL) {
+ // if the user wishes to export the media in its current format, we allow sizing but
+ // not JPEG quality customization, because in a batch of many photos, it's not
+ // guaranteed that all of them will be JPEGs or RAWs that get converted to JPEGs. Some
+ // could be PNGs, and PNG has no notion of quality. So lock out the quality control.
+ // If the user wants to set JPEG quality, he or she can explicitly specify the JPEG
+ // format.
+ constraint_combo.set_sensitive(true);
+ quality_combo.set_sensitive(false);
+ pixels_entry.sensitive = !original;
+ export_metadata.sensitive = true;
+ } else {
+ // if the user has chosen a specific format, then allow JPEG quality customization if
+ // the format is JPEG and the user is re-sizing the image, otherwise, disallow JPEG
+ // quality customization; always allow scaling.
+ constraint_combo.set_sensitive(true);
+ bool jpeg = get_specified_format() == PhotoFileFormat.JFIF;
+ quality_combo.sensitive = !original && jpeg;
+ export_metadata.sensitive = true;
+ }
+ }
+
+ private void on_activate() {
+ response(Gtk.ResponseType.OK);
+ }
+
+ private void on_pixels_changed() {
+ ok_button.sensitive = (pixels_entry.get_text_length() > 0) && (int.parse(pixels_entry.get_text()) > 0);
+ }
+
+ private void on_pixels_insert_text(string text, int length, ref int position) {
+ // This is necessary because SignalHandler.block_by_func() is not properly bound
+ if (in_insert)
+ return;
+
+ in_insert = true;
+
+ if (length == -1)
+ length = (int) text.length;
+
+ // only permit numeric text
+ string new_text = "";
+ for (int ctr = 0; ctr < length; ctr++) {
+ if (text[ctr].isdigit()) {
+ new_text += ((char) text[ctr]).to_string();
+ }
+ }
+
+ if (new_text.length > 0)
+ pixels_entry.insert_text(new_text, (int) new_text.length, ref position);
+
+ Signal.stop_emission_by_name(pixels_entry, "insert-text");
+
+ in_insert = false;
+ }
+}
+
+namespace ImportUI {
+private const int REPORT_FAILURE_COUNT = 4;
+internal const string SAVE_RESULTS_BUTTON_NAME = _("Save Details...");
+internal const string SAVE_RESULTS_FILE_CHOOSER_TITLE = _("Save Details");
+internal const int SAVE_RESULTS_RESPONSE_ID = 1024;
+
+private string? generate_import_failure_list(Gee.List<BatchImportResult> failed, bool show_dest_id) {
+ if (failed.size == 0)
+ return null;
+
+ string list = "";
+ for (int ctr = 0; ctr < REPORT_FAILURE_COUNT && ctr < failed.size; ctr++) {
+ list += "%s\n".printf(show_dest_id ? failed.get(ctr).dest_identifier :
+ failed.get(ctr).src_identifier);
+ }
+
+ int remaining = failed.size - REPORT_FAILURE_COUNT;
+ if (remaining > 0)
+ list += _("(and %d more)\n").printf(remaining);
+
+ return list;
+}
+
+public class QuestionParams {
+ public string question;
+ public string yes_button;
+ public string no_button;
+
+ public QuestionParams(string question, string yes_button, string no_button) {
+ this.question = question;
+ this.yes_button = yes_button;
+ this.no_button = no_button;
+ }
+}
+
+public bool import_has_photos(Gee.Collection<BatchImportResult> import_collection) {
+ foreach (BatchImportResult current_result in import_collection) {
+ if (current_result.file != null
+ && PhotoFileFormat.get_by_file_extension(current_result.file) != PhotoFileFormat.UNKNOWN) {
+ return true;
+ }
+ }
+ return false;
+}
+
+public bool import_has_videos(Gee.Collection<BatchImportResult> import_collection) {
+ foreach (BatchImportResult current_result in import_collection) {
+ if (current_result.file != null && VideoReader.is_supported_video_file(current_result.file))
+ return true;
+ }
+ return false;
+}
+
+public string get_media_specific_string(Gee.Collection<BatchImportResult> import_collection,
+ string photos_msg, string videos_msg, string both_msg, string neither_msg) {
+ bool has_photos = import_has_photos(import_collection);
+ bool has_videos = import_has_videos(import_collection);
+
+ if (has_photos && has_videos)
+ return both_msg;
+ else if (has_photos)
+ return photos_msg;
+ else if (has_videos)
+ return videos_msg;
+ else
+ return neither_msg;
+}
+
+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";
+ builder.append(header);
+
+ string subhead = (ngettext("Attempted to import %d file.", "Attempted to import %d files.",
+ manifest.all.size)).printf(manifest.all.size);
+ subhead += " ";
+ subhead += (ngettext("Of these, %d file was successfully imported.",
+ "Of these, %d files were successfully imported.", manifest.success.size)).printf(
+ manifest.success.size);
+ subhead += "\n\n";
+ builder.append(subhead);
+
+ string current_file_summary = "";
+
+ //
+ // Duplicates
+ //
+ if (manifest.already_imported.size > 0) {
+ builder.append(_("Duplicate Photos/Videos Not Imported:") + "\n\n");
+
+ foreach (BatchImportResult result in manifest.already_imported) {
+ current_file_summary = result.src_identifier + " " +
+ _("duplicates existing media item") + "\n\t" +
+ result.duplicate_of.get_file().get_path() + "\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ //
+ // Files Not Imported Due to Camera Errors
+ //
+ if (manifest.camera_failed.size > 0) {
+ builder.append(_("Photos/Videos Not Imported Due to Camera Errors:") + "\n\n");
+
+ foreach (BatchImportResult result in manifest.camera_failed) {
+ current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
+ result.errmsg + "\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ //
+ // Files Not Imported Because They Weren't Recognized as Photos or Videos
+ //
+ if (manifest.skipped_files.size > 0) {
+ builder.append(_("Files Not Imported Because They Weren't Recognized as Photos or Videos:")
+ + "\n\n");
+
+ foreach (BatchImportResult result in manifest.skipped_files) {
+ current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
+ result.errmsg + "\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ //
+ // Photos/Videos Not Imported Because They Weren't in a Format Shotwell Understands
+ //
+ if (manifest.skipped_photos.size > 0) {
+ builder.append(_("Photos/Videos Not Imported Because They Weren't in a Format Shotwell Understands:")
+ + "\n\n");
+
+ foreach (BatchImportResult result in manifest.skipped_photos) {
+ current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
+ result.errmsg + "\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ //
+ // Photos/Videos Not Imported Because Shotwell Couldn't Copy Them into its Library
+ //
+ if (manifest.write_failed.size > 0) {
+ builder.append(_("Photos/Videos Not Imported Because Shotwell Couldn't Copy Them into its Library:")
+ + "\n\n");
+
+ foreach (BatchImportResult result in manifest.write_failed) {
+ current_file_summary = (_("couldn't copy %s\n\tto %s")).printf(result.src_identifier,
+ result.dest_identifier) + "\n\t" + _("error message:") + " " +
+ result.errmsg + "\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ //
+ // Photos/Videos Not Imported Because GDK Pixbuf Library Identified them as Corrupt
+ //
+ if (manifest.corrupt_files.size > 0) {
+ builder.append(_("Photos/Videos Not Imported Because Files Are Corrupt:")
+ + "\n\n");
+
+ foreach (BatchImportResult result in manifest.corrupt_files) {
+ current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " |" +
+ result.errmsg + "|\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ //
+ // Photos/Videos Not Imported for Other Reasons
+ //
+ if (manifest.failed.size > 0) {
+ builder.append(_("Photos/Videos Not Imported for Other Reasons:") + "\n\n");
+
+ foreach (BatchImportResult result in manifest.failed) {
+ current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
+ result.errmsg + "\n\n";
+
+ builder.append(current_file_summary);
+ }
+ }
+
+ return builder.str;
+}
+
+// Summarizes the contents of an import manifest in an on-screen message window. Returns
+// true if the user selected the yes action, false otherwise.
+public bool report_manifest(ImportManifest manifest, bool show_dest_id,
+ QuestionParams? question = null) {
+ string message = "";
+
+ if (manifest.already_imported.size > 0) {
+ string photos_message = (ngettext("1 duplicate photo was not imported:\n",
+ "%d duplicate photos were not imported:\n",
+ manifest.already_imported.size)).printf(manifest.already_imported.size);
+ string videos_message = (ngettext("1 duplicate video was not imported:\n",
+ "%d duplicate videos were not imported:\n",
+ manifest.already_imported.size)).printf(manifest.already_imported.size);
+ string both_message = (ngettext("1 duplicate photo/video was not imported:\n",
+ "%d duplicate photos/videos were not imported:\n",
+ manifest.already_imported.size)).printf(manifest.already_imported.size);
+
+ message += get_media_specific_string(manifest.already_imported, photos_message,
+ videos_message, both_message, both_message);
+
+ message += generate_import_failure_list(manifest.already_imported, show_dest_id);
+ }
+
+ if (manifest.failed.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ string photos_message = (ngettext("1 photo failed to import due to a file or hardware error:\n",
+ "%d photos failed to import due to a file or hardware error:\n",
+ manifest.failed.size)).printf(manifest.failed.size);
+ string videos_message = (ngettext("1 video failed to import due to a file or hardware error:\n",
+ "%d videos failed to import due to a file or hardware error:\n",
+ manifest.failed.size)).printf(manifest.failed.size);
+ string both_message = (ngettext("1 photo/video failed to import due to a file or hardware error:\n",
+ "%d photos/videos failed to import due to a file or hardware error:\n",
+ manifest.failed.size)).printf(manifest.failed.size);
+ string neither_message = (ngettext("1 file failed to import due to a file or hardware error:\n",
+ "%d files failed to import due to a file or hardware error:\n",
+ manifest.failed.size)).printf(manifest.failed.size);
+
+ message += get_media_specific_string(manifest.failed, photos_message, videos_message,
+ both_message, neither_message);
+
+ message += generate_import_failure_list(manifest.failed, show_dest_id);
+ }
+
+ if (manifest.write_failed.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ string photos_message = (ngettext("1 photo failed to import because the photo library folder was not writable:\n",
+ "%d photos failed to import because the photo library folder was not writable:\n",
+ manifest.write_failed.size)).printf(manifest.write_failed.size);
+ string videos_message = (ngettext("1 video failed to import because the photo library folder was not writable:\n",
+ "%d videos failed to import because the photo library folder was not writable:\n",
+ manifest.write_failed.size)).printf(manifest.write_failed.size);
+ string both_message = (ngettext("1 photo/video failed to import because the photo library folder was not writable:\n",
+ "%d photos/videos failed to import because the photo library folder was not writable:\n",
+ manifest.write_failed.size)).printf(manifest.write_failed.size);
+ string neither_message = (ngettext("1 file failed to import because the photo library folder was not writable:\n",
+ "%d files failed to import because the photo library folder was not writable:\n",
+ manifest.write_failed.size)).printf(manifest.write_failed.size);
+
+ message += get_media_specific_string(manifest.write_failed, photos_message, videos_message,
+ both_message, neither_message);
+
+ message += generate_import_failure_list(manifest.write_failed, show_dest_id);
+ }
+
+ if (manifest.camera_failed.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ string photos_message = (ngettext("1 photo failed to import due to a camera error:\n",
+ "%d photos failed to import due to a camera error:\n",
+ manifest.camera_failed.size)).printf(manifest.camera_failed.size);
+ string videos_message = (ngettext("1 video failed to import due to a camera error:\n",
+ "%d videos failed to import due to a camera error:\n",
+ manifest.camera_failed.size)).printf(manifest.camera_failed.size);
+ string both_message = (ngettext("1 photo/video failed to import due to a camera error:\n",
+ "%d photos/videos failed to import due to a camera error:\n",
+ manifest.camera_failed.size)).printf(manifest.camera_failed.size);
+ string neither_message = (ngettext("1 file failed to import due to a camera error:\n",
+ "%d files failed to import due to a camera error:\n",
+ manifest.camera_failed.size)).printf(manifest.camera_failed.size);
+
+ message += get_media_specific_string(manifest.camera_failed, photos_message, videos_message,
+ both_message, neither_message);
+
+ message += generate_import_failure_list(manifest.camera_failed, show_dest_id);
+ }
+
+ if (manifest.corrupt_files.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ string photos_message = (ngettext("1 photo failed to import because it was corrupt:\n",
+ "%d photos failed to import because they were corrupt:\n",
+ manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
+ string videos_message = (ngettext("1 video failed to import because it was corrupt:\n",
+ "%d videos failed to import because they were corrupt:\n",
+ manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
+ string both_message = (ngettext("1 photo/video failed to import because it was corrupt:\n",
+ "%d photos/videos failed to import because they were corrupt:\n",
+ manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
+ string neither_message = (ngettext("1 file failed to import because it was corrupt:\n",
+ "%d files failed to import because it was corrupt:\n",
+ manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
+
+ message += get_media_specific_string(manifest.corrupt_files, photos_message, videos_message,
+ both_message, neither_message);
+
+ message += generate_import_failure_list(manifest.corrupt_files, show_dest_id);
+ }
+
+ if (manifest.skipped_photos.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+ // we have no notion of "unsupported" video files right now in Shotwell (all
+ // standard container formats are supported, it's just that the streams in them
+ // might or might not be interpretable), so this message does not need to be
+ // media specific
+ string skipped_photos_message = (ngettext("1 unsupported photo skipped:\n",
+ "%d unsupported photos skipped:\n", manifest.skipped_photos.size)).printf(
+ manifest.skipped_photos.size);
+
+ message += skipped_photos_message;
+
+ message += generate_import_failure_list(manifest.skipped_photos, show_dest_id);
+ }
+
+ if (manifest.skipped_files.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ // we have no notion of "non-video" video files right now in Shotwell, so this
+ // message doesn't need to be media specific
+ string skipped_files_message = (ngettext("1 non-image file skipped.\n",
+ "%d non-image files skipped.\n", manifest.skipped_files.size)).printf(
+ manifest.skipped_files.size);
+
+ message += skipped_files_message;
+ }
+
+ if (manifest.aborted.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ string photos_message = (ngettext("1 photo skipped due to user cancel:\n",
+ "%d photos skipped due to user cancel:\n",
+ manifest.aborted.size)).printf(manifest.aborted.size);
+ string videos_message = (ngettext("1 video skipped due to user cancel:\n",
+ "%d videos skipped due to user cancel:\n",
+ manifest.aborted.size)).printf(manifest.aborted.size);
+ string both_message = (ngettext("1 photo/video skipped due to user cancel:\n",
+ "%d photos/videos skipped due to user cancel:\n",
+ manifest.aborted.size)).printf(manifest.aborted.size);
+ string neither_message = (ngettext("1 file skipped due to user cancel:\n",
+ "%d file skipped due to user cancel:\n",
+ manifest.aborted.size)).printf(manifest.aborted.size);
+
+ message += get_media_specific_string(manifest.aborted, photos_message, videos_message,
+ both_message, neither_message);
+
+ message += generate_import_failure_list(manifest.aborted, show_dest_id);
+ }
+
+ if (manifest.success.size > 0) {
+ if (message.length > 0)
+ message += "\n";
+
+ string photos_message = (ngettext("1 photo successfully imported.\n",
+ "%d photos successfully imported.\n",
+ manifest.success.size)).printf(manifest.success.size);
+ string videos_message = (ngettext("1 video successfully imported.\n",
+ "%d videos successfully imported.\n",
+ manifest.success.size)).printf(manifest.success.size);
+ string both_message = (ngettext("1 photo/video successfully imported.\n",
+ "%d photos/videos successfully imported.\n",
+ manifest.success.size)).printf(manifest.success.size);
+
+ message += get_media_specific_string(manifest.success, photos_message, videos_message,
+ both_message, "");
+ }
+
+ int total = manifest.success.size + manifest.failed.size + manifest.camera_failed.size
+ + manifest.skipped_photos.size + manifest.skipped_files.size + manifest.corrupt_files.size
+ + manifest.already_imported.size + manifest.aborted.size + manifest.write_failed.size;
+ assert(total == manifest.all.size);
+
+ // if no media items were imported at all (i.e. an empty directory attempted), need to at least
+ // report that nothing was imported
+ if (total == 0)
+ message += _("No photos or videos imported.\n");
+
+ Gtk.MessageDialog dialog = null;
+ int dialog_response = Gtk.ResponseType.NONE;
+ if (question == null) {
+ dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.INFO, Gtk.ButtonsType.NONE, "%s", message);
+ dialog.title = _("Import Complete");
+ Gtk.Widget save_results_button = dialog.add_button(ImportUI.SAVE_RESULTS_BUTTON_NAME,
+ ImportUI.SAVE_RESULTS_RESPONSE_ID);
+ save_results_button.set_visible(manifest.success.size < manifest.all.size);
+ Gtk.Widget ok_button = dialog.add_button(Gtk.Stock.OK, Gtk.ResponseType.OK);
+ dialog.set_default(ok_button);
+
+ Gtk.Window dialog_parent = (Gtk.Window) dialog.get_parent();
+ dialog_response = dialog.run();
+ dialog.destroy();
+
+ if (dialog_response == ImportUI.SAVE_RESULTS_RESPONSE_ID)
+ save_import_results(dialog_parent, create_result_report_from_manifest(manifest));
+
+ } else {
+ message += ("\n" + question.question);
+
+ dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", message);
+ dialog.title = _("Import Complete");
+ Gtk.Widget save_results_button = dialog.add_button(ImportUI.SAVE_RESULTS_BUTTON_NAME,
+ ImportUI.SAVE_RESULTS_RESPONSE_ID);
+ save_results_button.set_visible(manifest.success.size < manifest.all.size);
+ Gtk.Widget no_button = dialog.add_button(question.no_button, Gtk.ResponseType.NO);
+ dialog.add_button(question.yes_button, Gtk.ResponseType.YES);
+ dialog.set_default(no_button);
+
+ dialog_response = dialog.run();
+ while (dialog_response == ImportUI.SAVE_RESULTS_RESPONSE_ID) {
+ save_import_results(dialog, create_result_report_from_manifest(manifest));
+ dialog_response = dialog.run();
+ }
+
+ dialog.hide();
+ dialog.destroy();
+ }
+
+ return (dialog_response == Gtk.ResponseType.YES);
+}
+
+internal void save_import_results(Gtk.Window? chooser_dialog_parent, string results_log) {
+ Gtk.FileChooserDialog chooser_dialog = new Gtk.FileChooserDialog(
+ ImportUI.SAVE_RESULTS_FILE_CHOOSER_TITLE, chooser_dialog_parent, Gtk.FileChooserAction.SAVE,
+ Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL, Gtk.Stock.SAVE, Gtk.ResponseType.ACCEPT, null);
+ chooser_dialog.set_do_overwrite_confirmation(true);
+ chooser_dialog.set_current_folder(Environment.get_home_dir());
+ chooser_dialog.set_current_name("Shotwell Import Log.txt");
+ chooser_dialog.set_local_only(false);
+
+ int dialog_result = chooser_dialog.run();
+ File? chosen_file = chooser_dialog.get_file();
+ chooser_dialog.hide();
+ chooser_dialog.destroy();
+
+ if (dialog_result == Gtk.ResponseType.ACCEPT && chosen_file != null) {
+ try {
+ FileOutputStream outstream = chosen_file.replace(null, false, FileCreateFlags.NONE);
+ outstream.write(results_log.data);
+ outstream.close();
+ } catch (Error err) {
+ critical("couldn't save import results to log file %s: %s", chosen_file.get_path(),
+ err.message);
+ }
+ }
+}
+
+}
+
+public abstract class TextEntryDialogMediator {
+ private TextEntryDialog dialog;
+
+ public TextEntryDialogMediator(string title, string label, string? initial_text = null,
+ Gee.Collection<string>? completion_list = null, string? completion_delimiter = null) {
+ Gtk.Builder builder = AppWindow.create_builder();
+ dialog = new TextEntryDialog();
+ dialog.get_content_area().add((Gtk.Box) builder.get_object("dialog-vbox2"));
+ dialog.set_builder(builder);
+ dialog.setup(on_modify_validate, title, label, initial_text, completion_list, completion_delimiter);
+ }
+
+ protected virtual bool on_modify_validate(string text) {
+ return true;
+ }
+
+ protected string? _execute() {
+ return dialog.execute();
+ }
+}
+
+public abstract class MultiTextEntryDialogMediator {
+ private MultiTextEntryDialog dialog;
+
+ public MultiTextEntryDialogMediator(string title, string label, string? initial_text = null) {
+ Gtk.Builder builder = AppWindow.create_builder();
+ dialog = new MultiTextEntryDialog();
+ dialog.get_content_area().add((Gtk.Box) builder.get_object("dialog-vbox4"));
+ dialog.set_builder(builder);
+ dialog.setup(on_modify_validate, title, label, initial_text);
+ }
+
+ protected virtual bool on_modify_validate(string text) {
+ return true;
+ }
+
+ protected string? _execute() {
+ return dialog.execute();
+ }
+}
+
+
+// This method takes primary and secondary texts and returns ready-to-use pango markup
+// for a HIG-compliant alert dialog. Please see
+// http://library.gnome.org/devel/hig-book/2.32/windows-alert.html.en for details.
+public string build_alert_body_text(string? primary_text, string? secondary_text, bool should_escape = true) {
+ if (should_escape) {
+ return "<span weight=\"Bold\" size=\"larger\">%s</span>\n%s".printf(
+ guarded_markup_escape_text(primary_text), guarded_markup_escape_text(secondary_text));
+ }
+
+ return "<span weight=\"Bold\" size=\"larger\">%s</span>\n%s".printf(
+ guarded_markup_escape_text(primary_text), secondary_text);
+}
+
+// Entry completion for values separated by separators (e.g. comma in the case of tags)
+// Partly inspired by the class of the same name in gtkmm-utils by Marko Anastasov
+public class EntryMultiCompletion : Gtk.EntryCompletion {
+ private string delimiter;
+
+ public EntryMultiCompletion(Gee.Collection<string> completion_list, string? delimiter) {
+ assert(delimiter == null || delimiter.length == 1);
+ this.delimiter = delimiter;
+
+ set_model(create_completion_store(completion_list));
+ set_text_column(0);
+ set_match_func(match_func);
+ }
+
+ private static Gtk.ListStore create_completion_store(Gee.Collection<string> completion_list) {
+ Gtk.ListStore completion_store = new Gtk.ListStore(1, typeof(string));
+ Gtk.TreeIter store_iter;
+ Gee.Iterator<string> completion_iter = completion_list.iterator();
+ while (completion_iter.next()) {
+ completion_store.append(out store_iter);
+ completion_store.set(store_iter, 0, completion_iter.get(), -1);
+ }
+
+ return completion_store;
+ }
+
+ private bool match_func(Gtk.EntryCompletion completion, string key, Gtk.TreeIter iter) {
+ Gtk.TreeModel model = completion.get_model();
+ string possible_match;
+ model.get(iter, 0, out possible_match);
+
+ // Normalize key and possible matches to allow comparison of non-ASCII characters.
+ // Use a "COMPOSE" normalization to allow comparison to the position value returned by
+ // Gtk.Entry, i.e. one character=one position. Using the default normalization a character
+ // like "é" or "ö" would have a length of two.
+ possible_match = possible_match.casefold().normalize(-1, NormalizeMode.ALL_COMPOSE);
+ string normed_key = key.normalize(-1, NormalizeMode.ALL_COMPOSE);
+
+ if (delimiter == null) {
+ return possible_match.has_prefix(normed_key.strip());
+ } else {
+ if (normed_key.contains(delimiter)) {
+ // check whether cursor is before last delimiter
+ int offset = normed_key.char_count(normed_key.last_index_of_char(delimiter[0]));
+ int position = ((Gtk.Entry) get_entry()).get_position();
+ if (position <= offset)
+ return false; // TODO: Autocompletion for tags not last in list
+ }
+
+ string last_part = get_last_part(normed_key.strip(), delimiter);
+
+ if (last_part.length == 0)
+ return false; // need at least one character to show matches
+
+ return possible_match.has_prefix(last_part.strip());
+ }
+ }
+
+ public override bool match_selected(Gtk.TreeModel model, Gtk.TreeIter iter) {
+ string match;
+ model.get(iter, 0, out match);
+
+ Gtk.Entry entry = (Gtk.Entry)get_entry();
+
+ string old_text = entry.get_text().normalize(-1, NormalizeMode.ALL_COMPOSE);
+ if (old_text.length > 0) {
+ if (old_text.contains(delimiter)) {
+ old_text = old_text.substring(0, old_text.last_index_of_char(delimiter[0]) + 1) + (delimiter != " " ? " " : "");
+ } else
+ old_text = "";
+ }
+
+ string new_text = old_text + match + delimiter + (delimiter != " " ? " " : "");
+ entry.set_text(new_text);
+ entry.set_position((int) new_text.length);
+
+ return true;
+ }
+
+ // Find last string after any delimiter
+ private static string get_last_part(string s, string delimiter) {
+ string[] split = s.split(delimiter);
+
+ if((split != null) && (split[0] != null)) {
+ return split[split.length - 1];
+ } else {
+ return "";
+ }
+ }
+}
+
+public class SetBackgroundSlideshowDialog {
+ private Gtk.Dialog dialog;
+ private Gtk.Label delay_value_label;
+ private Gtk.Scale delay_scale;
+ private int delay_value = 0;
+
+ public SetBackgroundSlideshowDialog() {
+ Gtk.Builder builder = AppWindow.create_builder("set_background_dialog.glade", this);
+
+ dialog = builder.get_object("dialog1") as Gtk.Dialog;
+ dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG);
+ dialog.set_parent_window(AppWindow.get_instance().get_parent_window());
+ dialog.set_transient_for(AppWindow.get_instance());
+ dialog.set_default_response(Gtk.ResponseType.OK);
+
+ delay_value_label = builder.get_object("delay_value_label") as Gtk.Label;
+
+ delay_scale = builder.get_object("delay_scale") as Gtk.Scale;
+ delay_scale.value_changed.connect(on_delay_scale_value_changed);
+ delay_scale.adjustment.value = 50;
+ }
+
+ private void on_delay_scale_value_changed() {
+ double value = delay_scale.adjustment.value;
+
+ // f(x)=x^5 allows to have fine-grained values (seconds) to the left
+ // and very coarse-grained values (hours) to the right of the slider.
+ // We limit maximum value to 1 day and minimum to 5 seconds.
+ delay_value = (int) (Math.pow(value, 5) / Math.pow(90, 5) * 60 * 60 * 24 + 5);
+
+ // convert to text and remove fractions from values > 1 minute
+ string text;
+ if (delay_value < 60) {
+ text = ngettext("%d second", "%d seconds", delay_value).printf(delay_value);
+ } else if (delay_value < 60 * 60) {
+ int minutes = delay_value / 60;
+ text = ngettext("%d minute", "%d minutes", minutes).printf(minutes);
+ delay_value = minutes * 60;
+ } else if (delay_value < 60 * 60 * 24) {
+ int hours = delay_value / (60 * 60);
+ text = ngettext("%d hour", "%d hours", hours).printf(hours);
+ delay_value = hours * (60 * 60);
+ } else {
+ text = _("1 day");
+ delay_value = 60 * 60 * 24;
+ }
+
+ delay_value_label.label = text;
+ }
+
+ public bool execute(out int delay_value) {
+ dialog.show_all();
+
+ bool result = dialog.run() == Gtk.ResponseType.OK;
+
+ dialog.destroy();
+
+ delay_value = this.delay_value;
+
+ return result;
+ }
+}
+
+public class TextEntryDialog : Gtk.Dialog {
+ public delegate bool OnModifyValidateType(string text);
+
+ private unowned OnModifyValidateType on_modify_validate;
+ private Gtk.Entry entry;
+ private Gtk.Builder builder;
+ private Gtk.Button button1;
+ private Gtk.Button button2;
+ private Gtk.ButtonBox action_area_box;
+
+ public void set_builder(Gtk.Builder builder) {
+ this.builder = builder;
+ }
+
+ public void setup(OnModifyValidateType? modify_validate, string title, string label,
+ string? initial_text, Gee.Collection<string>? completion_list, string? completion_delimiter) {
+ set_title(title);
+ set_resizable(true);
+ set_default_size (350, 104);
+ set_parent_window(AppWindow.get_instance().get_parent_window());
+ set_transient_for(AppWindow.get_instance());
+ on_modify_validate = modify_validate;
+
+ Gtk.Label name_label = builder.get_object("label") as Gtk.Label;
+ name_label.set_text(label);
+
+ entry = builder.get_object("entry") as Gtk.Entry;
+ entry.set_text(initial_text != null ? initial_text : "");
+ entry.grab_focus();
+ entry.changed.connect(on_entry_changed);
+
+ action_area_box = (Gtk.ButtonBox) get_action_area();
+ action_area_box.set_layout(Gtk.ButtonBoxStyle.END);
+
+ button1 = (Gtk.Button) add_button(Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL);
+ button2 = (Gtk.Button) add_button(Gtk.Stock.SAVE, Gtk.ResponseType.OK);
+ set_default_response(Gtk.ResponseType.OK);
+
+ if (completion_list != null) { // Textfield with autocompletion
+ EntryMultiCompletion completion = new EntryMultiCompletion(completion_list,
+ completion_delimiter);
+ entry.set_completion(completion);
+ }
+
+ set_default_response(Gtk.ResponseType.OK);
+ set_has_resize_grip(false);
+ }
+
+ public string? execute() {
+ string? text = null;
+
+ // validate entry to start with
+ set_response_sensitive(Gtk.ResponseType.OK, on_modify_validate(entry.get_text()));
+
+ show_all();
+
+ if (run() == Gtk.ResponseType.OK)
+ text = entry.get_text();
+
+ entry.changed.disconnect(on_entry_changed);
+ destroy();
+
+ return text;
+ }
+
+ public void on_entry_changed() {
+ set_response_sensitive(Gtk.ResponseType.OK, on_modify_validate(entry.get_text()));
+ }
+}
+
+public class MultiTextEntryDialog : Gtk.Dialog {
+ public delegate bool OnModifyValidateType(string text);
+
+ private unowned OnModifyValidateType on_modify_validate;
+ private Gtk.TextView entry;
+ private Gtk.Builder builder;
+ private Gtk.Button button1;
+ private Gtk.Button button2;
+ private Gtk.ButtonBox action_area_box;
+
+ public void set_builder(Gtk.Builder builder) {
+ this.builder = builder;
+ }
+
+ public void setup(OnModifyValidateType? modify_validate, string title, string label, string? initial_text) {
+ set_title(title);
+ set_resizable(true);
+ set_default_size(500,300);
+ set_parent_window(AppWindow.get_instance().get_parent_window());
+ set_transient_for(AppWindow.get_instance());
+ on_modify_validate = modify_validate;
+
+ Gtk.Label name_label = builder.get_object("label9") as Gtk.Label;
+ name_label.set_text(label);
+
+ Gtk.ScrolledWindow scrolled = builder.get_object("scrolledwindow1") as Gtk.ScrolledWindow;
+ scrolled.set_shadow_type (Gtk.ShadowType.ETCHED_IN);
+
+ entry = builder.get_object("textview1") as Gtk.TextView;
+ entry.set_wrap_mode (Gtk.WrapMode.WORD);
+ entry.buffer = new Gtk.TextBuffer(null);
+ entry.buffer.text = (initial_text != null ? initial_text : "");
+
+ entry.grab_focus();
+
+ action_area_box = (Gtk.ButtonBox) get_action_area();
+ action_area_box.set_layout(Gtk.ButtonBoxStyle.END);
+
+ button1 = (Gtk.Button) add_button(Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL);
+ button2 = (Gtk.Button) add_button(Gtk.Stock.SAVE, Gtk.ResponseType.OK);
+
+ set_has_resize_grip(true);
+ }
+
+ public string? execute() {
+ string? text = null;
+
+ show_all();
+
+ if (run() == Gtk.ResponseType.OK)
+ text = entry.buffer.text;
+
+ destroy();
+
+ return text;
+ }
+}
+
+public class EventRenameDialog : TextEntryDialogMediator {
+ public EventRenameDialog(string? event_name) {
+ base (_("Rename Event"), _("Name:"), event_name);
+ }
+
+ public virtual string? execute() {
+ return Event.prep_event_name(_execute());
+ }
+}
+
+public class EditTitleDialog : TextEntryDialogMediator {
+ public EditTitleDialog(string? photo_title) {
+ base (_("Edit Title"), _("Title:"), photo_title);
+ }
+
+ public virtual string? execute() {
+ return MediaSource.prep_title(_execute());
+ }
+
+ protected override bool on_modify_validate(string text) {
+ return true;
+ }
+}
+
+public class EditCommentDialog : MultiTextEntryDialogMediator {
+ public EditCommentDialog(string? comment, bool is_event = false) {
+ string title_tmp = (is_event) ? _("Edit Event Comment") : _("Edit Photo/Video Comment");
+ base(title_tmp, _("Comment:"), comment);
+ }
+
+ public virtual string? execute() {
+ return MediaSource.prep_comment(_execute());
+ }
+
+ protected override bool on_modify_validate(string text) {
+ return true;
+ }
+}
+
+// Returns: Gtk.ResponseType.YES (trash photos), Gtk.ResponseType.NO (only remove photos) and
+// Gtk.ResponseType.CANCEL.
+public Gtk.ResponseType remove_from_library_dialog(Gtk.Window owner, string title,
+ string user_message, int count) {
+ string trash_action = ngettext("_Trash File", "_Trash Files", count);
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.WARNING, Gtk.ButtonsType.CANCEL, "%s", user_message);
+ dialog.add_button(_("Only _Remove"), Gtk.ResponseType.NO);
+ dialog.add_button(trash_action, Gtk.ResponseType.YES);
+
+ // This dialog was previously created outright; we now 'hijack'
+ // dialog's old title and use it as the primary text, along with
+ // using the message as the secondary text.
+ dialog.set_markup(build_alert_body_text(title, user_message));
+
+ Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
+
+ dialog.destroy();
+
+ return result;
+}
+
+// Returns: Gtk.ResponseType.YES (delete photos), Gtk.ResponseType.NO (keep photos)
+public Gtk.ResponseType remove_from_filesystem_dialog(Gtk.Window owner, string title,
+ string user_message) {
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", user_message);
+ dialog.add_button(_("_Keep"), Gtk.ResponseType.NO);
+ dialog.add_button(_("_Delete"), Gtk.ResponseType.YES);
+ dialog.set_default_response( Gtk.ResponseType.NO);
+
+ dialog.set_markup(build_alert_body_text(title, user_message));
+
+ Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
+
+ dialog.destroy();
+
+ return result;
+}
+
+public bool revert_editable_dialog(Gtk.Window owner, Gee.Collection<Photo> photos) {
+ int count = 0;
+ foreach (Photo photo in photos) {
+ if (photo.has_editable())
+ count++;
+ }
+
+ if (count == 0)
+ return false;
+
+ string headline = (count == 1) ? _("Revert External Edit?") : _("Revert External Edits?");
+ string msg = ngettext(
+ "This will destroy all changes made to the external file. Continue?",
+ "This will destroy all changes made to %d external files. Continue?",
+ count).printf(count);
+
+ string action = (count == 1) ? _("Re_vert External Edit") : _("Re_vert External Edits");
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, "%s", msg);
+ dialog.add_button(_("_Cancel"), Gtk.ResponseType.CANCEL);
+ dialog.add_button(action, Gtk.ResponseType.YES);
+
+ dialog.set_markup(build_alert_body_text(headline, msg));
+
+ Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
+
+ dialog.destroy();
+
+ return result == Gtk.ResponseType.YES;
+}
+
+public bool remove_offline_dialog(Gtk.Window owner, int count) {
+ if (count == 0)
+ return false;
+
+ string msg = ngettext(
+ "This will remove the photo from the library. Continue?",
+ "This will remove %d photos from the library. Continue?",
+ count).printf(count);
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, "%s", msg);
+ dialog.add_button(_("_Cancel"), Gtk.ResponseType.CANCEL);
+ dialog.add_button(_("_Remove"), Gtk.ResponseType.OK);
+ dialog.title = (count == 1) ? _("Remove Photo From Library") : _("Remove Photos From Library");
+
+ Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
+
+ dialog.destroy();
+
+ return result == Gtk.ResponseType.OK;
+}
+
+public class ProgressDialog : Gtk.Window {
+ private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar();
+ private Gtk.Button cancel_button = null;
+ private Cancellable cancellable;
+ private uint64 last_count = uint64.MAX;
+ private int update_every = 1;
+ private int minimum_on_screen_time_msec = 500;
+ private ulong time_started;
+#if UNITY_SUPPORT
+ UnityProgressBar uniprobar = UnityProgressBar.get_instance();
+#endif
+
+ public ProgressDialog(Gtk.Window? owner, string text, Cancellable? cancellable = null) {
+ this.cancellable = cancellable;
+
+ set_title(text);
+ set_resizable(false);
+ if (owner != null)
+ set_transient_for(owner);
+ set_modal(true);
+ set_type_hint(Gdk.WindowTypeHint.DIALOG);
+
+ progress_bar.set_size_request(300, -1);
+ progress_bar.set_show_text(true);
+
+ Gtk.Box vbox_bar = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ vbox_bar.pack_start(progress_bar, true, false, 0);
+
+ if (cancellable != null) {
+ cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL);
+ cancel_button.clicked.connect(on_cancel);
+ delete_event.connect(on_window_closed);
+ }
+
+ Gtk.Box hbox = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ hbox.pack_start(vbox_bar, true, false, 0);
+ if (cancel_button != null)
+ hbox.pack_end(cancel_button, false, false, 0);
+
+ Gtk.Label primary_text_label = new Gtk.Label("");
+ primary_text_label.set_markup("<span weight=\"bold\">%s</span>".printf(text));
+ primary_text_label.set_alignment(0, 0.5f);
+
+ Gtk.Box vbox = new Gtk.Box(Gtk.Orientation.VERTICAL, 12);
+ vbox.pack_start(primary_text_label, false, false, 0);
+ vbox.pack_start(hbox, true, false, 0);
+
+ Gtk.Alignment alignment = new Gtk.Alignment(0.5f, 0.5f, 1.0f, 1.0f);
+ alignment.set_padding(12, 12, 12, 12);
+ alignment.add(vbox);
+
+ add(alignment);
+
+ time_started = now_ms();
+ }
+
+ public override void realize() {
+ base.realize();
+
+ // if unable to cancel the progress bar, remove the close button
+ if (cancellable == null)
+ get_window().set_functions(Gdk.WMFunction.MOVE);
+ }
+
+ public void update_display_every(int update_every) {
+ assert(update_every >= 1);
+
+ this.update_every = update_every;
+ }
+
+ public void set_minimum_on_screen_time_msec(int minimum_on_screen_time_msec) {
+ this.minimum_on_screen_time_msec = minimum_on_screen_time_msec;
+ }
+
+ public void set_fraction(int current, int total) {
+ set_percentage((double) current / (double) total);
+ }
+
+ public void set_percentage(double pct) {
+ pct = pct.clamp(0.0, 1.0);
+
+ maybe_show_all(pct);
+
+ progress_bar.set_fraction(pct);
+ progress_bar.set_text(_("%d%%").printf((int) (pct * 100.0)));
+
+#if UNITY_SUPPORT
+ //UnityProgressBar: set progress
+ uniprobar.set_progress(pct);
+#endif
+ }
+
+ public void set_status(string text) {
+ progress_bar.set_text(text);
+
+#if UNITY_SUPPORT
+ //UnityProgressBar: try to draw progress bar
+ uniprobar.set_visible(true);
+#endif
+ show_all();
+ }
+
+ // This can be used as a ProgressMonitor delegate.
+ public bool monitor(uint64 count, uint64 total, bool do_event_loop = true) {
+ if ((last_count == uint64.MAX) || (count - last_count) >= update_every) {
+ set_percentage((double) count / (double) total);
+ last_count = count;
+ }
+
+ bool keep_going = (cancellable != null) ? !cancellable.is_cancelled() : true;
+
+ // TODO: get rid of this. non-trivial, as some progress-monitor operations are blocking
+ // and need to allow the event loop to spin
+ //
+ // Important: Since it's possible the progress dialog might be destroyed inside this call,
+ // avoid referring to "this" afterwards at all costs (in case all refs have been dropped)
+
+ if (do_event_loop)
+ spin_event_loop();
+
+ return keep_going;
+ }
+
+ public new void close() {
+#if UNITY_SUPPORT
+ //UnityProgressBar: reset
+ uniprobar.reset();
+#endif
+ hide();
+ destroy();
+ }
+
+ private bool on_window_closed() {
+ on_cancel();
+ return false; // return false so that the system handler will remove the window from
+ // the screen
+ }
+
+ private void on_cancel() {
+ if (cancellable != null)
+ cancellable.cancel();
+
+ cancel_button.sensitive = false;
+ }
+
+ private void maybe_show_all(double pct) {
+ // Appear only after a while because some jobs may take only a
+ // fraction of second to complete so there's no point in showing progress.
+ if (!this.visible && now_ms() - time_started > minimum_on_screen_time_msec) {
+ // calculate percents completed in one ms
+ double pps = pct * 100.0 / minimum_on_screen_time_msec;
+ // calculate [very rough] estimate of time to complete in ms
+ double ttc = 100.0 / pps;
+ // If there is still more work to do for at least MINIMUM_ON_SCREEN_TIME_MSEC,
+ // finally display the dialog.
+ if (ttc > minimum_on_screen_time_msec) {
+#if UNITY_SUPPORT
+ //UnityProgressBar: try to draw progress bar
+ uniprobar.set_visible(true);
+#endif
+ show_all();
+ spin_event_loop();
+ }
+ }
+ }
+}
+
+public class AdjustDateTimeDialog : Gtk.Dialog {
+ private const int64 SECONDS_IN_DAY = 60 * 60 * 24;
+ private const int64 SECONDS_IN_HOUR = 60 * 60;
+ private const int64 SECONDS_IN_MINUTE = 60;
+ private const int YEAR_OFFSET = 1900;
+ private bool no_original_time = false;
+
+ private const int CALENDAR_THUMBNAIL_SCALE = 1;
+
+ time_t original_time;
+ Gtk.Label original_time_label;
+ Gtk.Calendar calendar;
+ Gtk.SpinButton hour;
+ Gtk.SpinButton minute;
+ Gtk.SpinButton second;
+ Gtk.ComboBoxText system;
+ Gtk.RadioButton relativity_radio_button;
+ Gtk.RadioButton batch_radio_button;
+ Gtk.CheckButton modify_originals_check_button;
+ Gtk.Label notification;
+
+ private enum TimeSystem {
+ AM,
+ PM,
+ 24HR;
+ }
+
+ TimeSystem previous_time_system;
+
+ public AdjustDateTimeDialog(Dateable source, int photo_count, bool display_options = true,
+ bool contains_video = false, bool only_video = false) {
+ assert(source != null);
+
+ set_modal(true);
+ set_resizable(false);
+ set_transient_for(AppWindow.get_instance());
+
+ add_buttons(Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL,
+ Gtk.Stock.OK, Gtk.ResponseType.OK);
+ set_title(Resources.ADJUST_DATE_TIME_LABEL);
+
+ calendar = new Gtk.Calendar();
+ calendar.day_selected.connect(on_time_changed);
+ calendar.month_changed.connect(on_time_changed);
+ calendar.next_year.connect(on_time_changed);
+ calendar.prev_year.connect(on_time_changed);
+
+ if (Config.Facade.get_instance().get_use_24_hour_time())
+ hour = new Gtk.SpinButton.with_range(0, 23, 1);
+ else
+ hour = new Gtk.SpinButton.with_range(1, 12, 1);
+
+ hour.output.connect(on_spin_button_output);
+ hour.set_width_chars(2);
+
+ minute = new Gtk.SpinButton.with_range(0, 59, 1);
+ minute.set_width_chars(2);
+ minute.output.connect(on_spin_button_output);
+
+ second = new Gtk.SpinButton.with_range(0, 59, 1);
+ second.set_width_chars(2);
+ second.output.connect(on_spin_button_output);
+
+ system = new Gtk.ComboBoxText();
+ system.append_text(_("AM"));
+ system.append_text(_("PM"));
+ system.append_text(_("24 Hr"));
+ system.changed.connect(on_time_system_changed);
+
+ Gtk.Box clock = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+
+ clock.pack_start(hour, false, false, 3);
+ clock.pack_start(new Gtk.Label(":"), false, false, 3); // internationalize?
+ clock.pack_start(minute, false, false, 3);
+ clock.pack_start(new Gtk.Label(":"), false, false, 3);
+ clock.pack_start(second, false, false, 3);
+ clock.pack_start(system, false, false, 3);
+
+ set_default_response(Gtk.ResponseType.OK);
+
+ relativity_radio_button = new Gtk.RadioButton.with_mnemonic(null,
+ _("_Shift photos/videos by the same amount"));
+ relativity_radio_button.set_active(Config.Facade.get_instance().get_keep_relativity());
+ relativity_radio_button.sensitive = display_options && photo_count > 1;
+
+ batch_radio_button = new Gtk.RadioButton.with_mnemonic(relativity_radio_button.get_group(),
+ _("Set _all photos/videos to this time"));
+ batch_radio_button.set_active(!Config.Facade.get_instance().get_keep_relativity());
+ batch_radio_button.sensitive = display_options && photo_count > 1;
+ batch_radio_button.toggled.connect(on_time_changed);
+
+ if (contains_video) {
+ modify_originals_check_button = new Gtk.CheckButton.with_mnemonic((photo_count == 1) ?
+ _("_Modify original photo file") : _("_Modify original photo files"));
+ } else {
+ modify_originals_check_button = new Gtk.CheckButton.with_mnemonic((photo_count == 1) ?
+ _("_Modify original file") : _("_Modify original files"));
+ }
+
+ modify_originals_check_button.set_active(Config.Facade.get_instance().get_commit_metadata_to_masters() &&
+ display_options);
+ modify_originals_check_button.sensitive = (!only_video) &&
+ (!Config.Facade.get_instance().get_commit_metadata_to_masters() && display_options);
+
+ Gtk.Box time_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+
+ time_content.pack_start(calendar, true, false, 3);
+ time_content.pack_start(clock, true, false, 3);
+
+ if (display_options) {
+ time_content.pack_start(relativity_radio_button, true, false, 3);
+ time_content.pack_start(batch_radio_button, true, false, 3);
+ time_content.pack_start(modify_originals_check_button, true, false, 3);
+ }
+
+ Gdk.Pixbuf preview = null;
+ try {
+ // Instead of calling get_pixbuf() here, we use the thumbnail instead;
+ // this was needed for Videos, since they don't support get_pixbuf().
+ preview = source.get_thumbnail(CALENDAR_THUMBNAIL_SCALE);
+ } catch (Error err) {
+ warning("Unable to fetch preview for %s", source.to_string());
+ }
+
+ Gtk.Box image_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ Gtk.Image image = (preview != null) ? new Gtk.Image.from_pixbuf(preview) : new Gtk.Image();
+ original_time_label = new Gtk.Label(null);
+ image_content.pack_start(image, true, false, 3);
+ image_content.pack_start(original_time_label, true, false, 3);
+
+ Gtk.Box hbox = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+ hbox.pack_start(image_content, true, false, 6);
+ hbox.pack_start(time_content, true, false, 6);
+
+ Gtk.Alignment hbox_alignment = new Gtk.Alignment(0.5f, 0.5f, 0, 0);
+ hbox_alignment.set_padding(6, 3, 6, 6);
+ hbox_alignment.add(hbox);
+
+ ((Gtk.Box) get_content_area()).pack_start(hbox_alignment, true, false, 6);
+
+ notification = new Gtk.Label("");
+ notification.set_line_wrap(true);
+ notification.set_justify(Gtk.Justification.CENTER);
+ notification.set_size_request(-1, -1);
+ notification.set_padding(12, 6);
+
+ ((Gtk.Box) get_content_area()).pack_start(notification, true, true, 0);
+
+ original_time = source.get_exposure_time();
+
+ if (original_time == 0) {
+ original_time = time_t();
+ no_original_time = true;
+ }
+
+ set_time(Time.local(original_time));
+ 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);
+
+ if (Config.Facade.get_instance().get_use_24_hour_time()) {
+ hour.set_value(time.hour);
+ system.set_active(TimeSystem.24HR);
+ } else {
+ int AMPM_hour = time.hour % 12;
+ hour.set_value((AMPM_hour == 0) ? 12 : AMPM_hour);
+ system.set_active((time.hour >= 12) ? TimeSystem.PM : TimeSystem.AM);
+ }
+
+ minute.set_value(time.minute);
+ second.set_value(time.second);
+
+ previous_time_system = (TimeSystem) system.get_active();
+ }
+
+ private void set_original_time_label(bool use_24_hr_format) {
+ if (no_original_time)
+ return;
+
+ original_time_label.set_text(_("Original: ") +
+ Time.local(original_time).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();
+
+ // 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);
+
+ 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();
+ }
+
+ public bool execute(out int64 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);
+
+ keep_relativity = relativity_radio_button.get_active();
+
+ if (relativity_radio_button.sensitive)
+ Config.Facade.get_instance().set_keep_relativity(keep_relativity);
+
+ modify_originals = modify_originals_check_button.get_active();
+
+ if (modify_originals_check_button.sensitive)
+ Config.Facade.get_instance().set_modify_originals(modify_originals);
+
+ response = true;
+ } else {
+ time_shift = 0;
+ keep_relativity = true;
+ modify_originals = false;
+ }
+
+ destroy();
+
+ return response;
+ }
+
+ private bool on_spin_button_output(Gtk.SpinButton button) {
+ button.set_text("%02d".printf((int) button.get_value()));
+
+ on_time_changed();
+
+ return true;
+ }
+
+ private void on_time_changed() {
+ int64 time_shift = ((int64) get_time() - (int64) original_time);
+
+ previous_time_system = (TimeSystem) system.get_active();
+
+ if (time_shift == 0 || no_original_time || (batch_radio_button.get_active() &&
+ batch_radio_button.sensitive)) {
+ notification.hide();
+ } else {
+ bool forward = time_shift > 0;
+ int days, hours, minutes, seconds;
+
+ 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);
+
+ string shift_status = (forward) ?
+ _("Exposure time will be shifted forward by\n%d %s, %d %s, %d %s, and %d %s.") :
+ _("Exposure time will be shifted backward by\n%d %s, %d %s, %d %s, and %d %s.");
+
+ notification.set_text(shift_status.printf(days, ngettext("day", "days", days),
+ hours, ngettext("hour", "hours", hours), minutes,
+ ngettext("minute", "minutes", minutes), seconds,
+ ngettext("second", "seconds", seconds)));
+
+ notification.show();
+ }
+ }
+
+ private void on_time_system_changed() {
+ if (previous_time_system == system.get_active())
+ return;
+
+ Config.Facade.get_instance().set_use_24_hour_time(system.get_active() == TimeSystem.24HR);
+
+ if (system.get_active() == TimeSystem.24HR) {
+ int time = (hour.get_value() == 12.0) ? 0 : (int) hour.get_value();
+ time = time + ((previous_time_system == TimeSystem.PM) ? 12 : 0);
+
+ hour.set_range(0, 23);
+ set_original_time_label(true);
+
+ hour.set_value(time);
+ } else {
+ int AMPM_hour = ((int) hour.get_value()) % 12;
+
+ hour.set_range(1, 12);
+ set_original_time_label(false);
+
+ hour.set_value((AMPM_hour == 0) ? 12 : AMPM_hour);
+ }
+
+ on_time_changed();
+ }
+}
+
+public const int MAX_OBJECTS_DISPLAYED = 3;
+public void multiple_object_error_dialog(Gee.ArrayList<DataObject> objects, string message,
+ string title) {
+ string dialog_message = message + "\n";
+
+ //add objects
+ for(int i = 0; i < MAX_OBJECTS_DISPLAYED && objects.size > i; i++)
+ dialog_message += "\n" + objects.get(i).to_string();
+
+ int remainder = objects.size - MAX_OBJECTS_DISPLAYED;
+ if (remainder > 0) {
+ dialog_message += ngettext("\n\nAnd %d other.", "\n\nAnd %d others.",
+ remainder).printf(remainder);
+ }
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s", dialog_message);
+
+ dialog.title = title;
+
+ dialog.run();
+ dialog.destroy();
+}
+
+public abstract class TagsDialog : TextEntryDialogMediator {
+ public TagsDialog(string title, string label, string? initial_text = null) {
+ base (title, label, initial_text, HierarchicalTagIndex.get_global_index().get_all_tags(),
+ ",");
+ }
+}
+
+public class AddTagsDialog : TagsDialog {
+ public AddTagsDialog() {
+ base (Resources.ADD_TAGS_TITLE, _("Tags (separated by commas):"));
+ }
+
+ public string[]? execute() {
+ string? text = _execute();
+ if (text == null)
+ return null;
+
+ // only want to return null if the user chose cancel, however, on_modify_validate ensures
+ // that Tag.prep_tag_names won't return a zero-length array (and it never returns null)
+ return Tag.prep_tag_names(text.split(","));
+ }
+
+ 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;
+ }
+}
+
+public class ModifyTagsDialog : TagsDialog {
+ public ModifyTagsDialog(MediaSource source) {
+ base (Resources.MODIFY_TAGS_LABEL, _("Tags (separated by commas):"),
+ get_initial_text(source));
+ }
+
+ private static string? get_initial_text(MediaSource source) {
+ Gee.Collection<Tag>? source_tags = Tag.global.fetch_for_source(source);
+ if (source_tags == null)
+ return null;
+
+ Gee.Collection<Tag> terminal_tags = Tag.get_terminal_tags(source_tags);
+
+ Gee.SortedSet<string> tag_basenames = new Gee.TreeSet<string>();
+ foreach (Tag tag in terminal_tags)
+ tag_basenames.add(HierarchicalTagUtilities.get_basename(tag.get_path()));
+
+ string? text = null;
+ foreach (string name in tag_basenames) {
+ if (text == null)
+ text = "";
+ else
+ text += ", ";
+
+ text += name;
+ }
+
+ return text;
+ }
+
+ public Gee.ArrayList<Tag>? execute() {
+ string? text = _execute();
+ if (text == null)
+ return null;
+
+ Gee.ArrayList<Tag> new_tags = new Gee.ArrayList<Tag>();
+
+ // return empty list if no tags specified
+ if (is_string_empty(text))
+ return new_tags;
+
+ // break up by comma-delimiter, prep for use, and separate into list
+ string[] tag_names = Tag.prep_tag_names(text.split(","));
+
+ tag_names = HierarchicalTagIndex.get_global_index().get_paths_for_names_array(tag_names);
+
+ foreach (string name in tag_names)
+ new_tags.add(Tag.for_path(name));
+
+ return new_tags;
+ }
+
+ protected override bool on_modify_validate(string text) {
+ return (!text.contains(Tag.PATH_SEPARATOR_STRING));
+ }
+
+}
+
+public interface WelcomeServiceEntry : GLib.Object {
+ public abstract string get_service_name();
+
+ public abstract void execute();
+}
+
+public class WelcomeDialog : Gtk.Dialog {
+ Gtk.CheckButton hide_button;
+ Gtk.CheckButton? system_pictures_import_check = null;
+ Gtk.CheckButton[] external_import_checks = new Gtk.CheckButton[0];
+ WelcomeServiceEntry[] external_import_entries = new WelcomeServiceEntry[0];
+ Gtk.Label secondary_text;
+ Gtk.Label instruction_header;
+ Gtk.Box import_content;
+ Gtk.Box import_action_checkbox_packer;
+ Gtk.Box external_import_action_checkbox_packer;
+ Spit.DataImports.WelcomeImportMetaHost import_meta_host;
+ bool import_content_already_installed = false;
+ bool ok_clicked = false;
+
+ public WelcomeDialog(Gtk.Window owner) {
+ import_meta_host = new Spit.DataImports.WelcomeImportMetaHost(this);
+ bool show_system_pictures_import = is_system_pictures_import_possible();
+ Gtk.Widget ok_button = add_button(Gtk.Stock.OK, Gtk.ResponseType.OK);
+ set_title(_("Welcome!"));
+ set_resizable(false);
+ set_type_hint(Gdk.WindowTypeHint.DIALOG);
+ set_transient_for(owner);
+
+ Gtk.Label primary_text = new Gtk.Label("");
+ primary_text.set_markup(
+ "<span size=\"large\" weight=\"bold\">%s</span>".printf(_("Welcome to Shotwell!")));
+ primary_text.set_alignment(0, 0.5f);
+ secondary_text = new Gtk.Label("");
+ secondary_text.set_markup("<span weight=\"normal\">%s</span>".printf(
+ _("To get started, import photos in any of these ways:")));
+ secondary_text.set_alignment(0, 0.5f);
+ Gtk.Image image = new Gtk.Image.from_pixbuf(Resources.get_icon(Resources.ICON_APP, 50));
+
+ Gtk.Box header_text = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ header_text.pack_start(primary_text, false, false, 5);
+ header_text.pack_start(secondary_text, false, false, 0);
+
+ Gtk.Box header_content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 12);
+ header_content.pack_start(image, false, false, 0);
+ header_content.pack_start(header_text, false, false, 0);
+
+ 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 + "&#8226; %s\n") + (indent_prefix + "&#8226; %s\n")
+ + (indent_prefix + "&#8226; %s")).printf(
+ _("Choose <span weight=\"bold\">File %s Import From Folder</span>").printf(arrow_glyph),
+ _("Drag and drop photos onto the Shotwell window"),
+ _("Connect a camera to your computer and import")));
+ instructions.set_alignment(0, 0.5f);
+
+ import_action_checkbox_packer = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
+
+ external_import_action_checkbox_packer = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
+ import_action_checkbox_packer.add(external_import_action_checkbox_packer);
+
+ if (show_system_pictures_import) {
+ system_pictures_import_check = new Gtk.CheckButton.with_mnemonic(
+ _("_Import photos from your %s folder").printf(
+ get_display_pathname(AppDirs.get_import_dir())));
+ import_action_checkbox_packer.add(system_pictures_import_check);
+ system_pictures_import_check.set_active(true);
+ }
+
+ instruction_header = new Gtk.Label(
+ _("You can also import photos in any of these ways:"));
+ instruction_header.set_alignment(0.0f, 0.5f);
+ instruction_header.set_margin_top(20);
+
+ Gtk.Box content = new Gtk.Box(Gtk.Orientation.VERTICAL, 16);
+ content.pack_start(header_content, true, true, 0);
+ import_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
+ content.add(import_content);
+ content.pack_start(instructions, false, false, 0);
+
+ hide_button = new Gtk.CheckButton.with_mnemonic(_("_Don't show this message again"));
+ hide_button.set_active(true);
+ content.pack_start(hide_button, false, false, 6);
+
+ Gtk.Alignment alignment = new Gtk.Alignment(0, 0, 0, 0);
+ alignment.set_padding(12, 0, 12, 12);
+ alignment.add(content);
+
+ ((Gtk.Box) get_content_area()).pack_start(alignment, false, false, 0);
+
+ set_has_resize_grip(false);
+
+ ok_button.grab_focus();
+
+ install_import_content();
+
+ import_meta_host.start();
+ }
+
+ private void install_import_content() {
+ if (
+ (external_import_checks.length > 0 || system_pictures_import_check != null) &&
+ (import_content_already_installed == false)
+ ) {
+ secondary_text.set_markup("");
+ import_content.add(import_action_checkbox_packer);
+ import_content.add(instruction_header);
+ import_content_already_installed = true;
+ }
+ }
+
+ public void install_service_entry(WelcomeServiceEntry entry) {
+ debug("WelcomeDialog: Installing service entry for %s".printf(entry.get_service_name()));
+ external_import_entries += entry;
+ Gtk.CheckButton entry_check = new Gtk.CheckButton.with_label(
+ _("Import photos from your %s library").printf(entry.get_service_name()));
+ external_import_checks += entry_check;
+ entry_check.set_active(true);
+ external_import_action_checkbox_packer.add(entry_check);
+ install_import_content();
+ }
+
+ /**
+ * Connected to the 'response' signal. This is part of a workaround
+ * for the fact that run()-ning this dialog can interfere with displaying
+ * images from a camera; please see #4997 for details.
+ */
+ private void on_dismiss(int resp) {
+ if (resp == Gtk.ResponseType.OK) {
+ ok_clicked = true;
+ }
+ hide();
+ Gtk.main_quit();
+ }
+
+ public bool execute(out WelcomeServiceEntry[] selected_import_entries, out bool do_system_pictures_import) {
+ // it's unsafe to call run() here - it interferes with displaying
+ // images from a camera - so we process the dialog ourselves.
+ response.connect(on_dismiss);
+ show_all();
+ show();
+
+ // this will block the thread we're in until a matching call
+ // to main_quit() is encountered; this happens when either the window
+ // is closed or OK is clicked.
+ Gtk.main();
+
+ // at this point, the inner main loop will have been exited.
+ // we've got the response, so we don't need this signal anymore.
+ response.disconnect(on_dismiss);
+
+ bool ok = ok_clicked;
+ bool show_dialog = true;
+
+ if (ok)
+ show_dialog = !hide_button.get_active();
+
+ // Use a temporary variable as += cannot be used on parameters
+ WelcomeServiceEntry[] result = new WelcomeServiceEntry[0];
+ for (int i = 0; i < external_import_entries.length; i++) {
+ if (external_import_checks[i].get_active() == true)
+ result += external_import_entries[i];
+ }
+ selected_import_entries = result;
+ do_system_pictures_import =
+ (system_pictures_import_check != null) ? system_pictures_import_check.get_active() : false;
+
+ destroy();
+
+ return show_dialog;
+ }
+
+ private static bool is_system_pictures_import_possible() {
+ File system_pictures = AppDirs.get_import_dir();
+ if (!system_pictures.query_exists(null))
+ return false;
+
+ if (!(system_pictures.query_file_type(FileQueryInfoFlags.NONE, null) == FileType.DIRECTORY))
+ return false;
+
+ try {
+ FileEnumerator syspics_child_enum = system_pictures.enumerate_children("standard::*",
+ FileQueryInfoFlags.NONE, null);
+ return (syspics_child_enum.next_file(null) != null);
+ } catch (Error e) {
+ return false;
+ }
+ }
+}
+
+public class PreferencesDialog {
+ private class PathFormat {
+ public PathFormat(string name, string? pattern) {
+ this.name = name;
+ this.pattern = pattern;
+ }
+ public string name;
+ public string? pattern;
+ }
+
+ private static PreferencesDialog preferences_dialog;
+
+ private Gtk.Dialog dialog;
+ private Gtk.Builder builder;
+ private Gtk.Adjustment bg_color_adjustment;
+ private Gtk.Scale bg_color_slider;
+ private Gtk.ComboBox photo_editor_combo;
+ private Gtk.ComboBox raw_editor_combo;
+ private SortedList<AppInfo> external_raw_apps;
+ private SortedList<AppInfo> external_photo_apps;
+ private Gtk.FileChooserButton library_dir_button;
+ private Gtk.ComboBoxText dir_pattern_combo;
+ private Gtk.Entry dir_pattern_entry;
+ private 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);
+ private Gtk.CheckButton lowercase;
+ private Gtk.Button close_button;
+ private Plugins.ManifestWidgetMediator plugins_mediator = new Plugins.ManifestWidgetMediator();
+ private Gtk.ComboBoxText default_raw_developer_combo;
+
+ private PreferencesDialog() {
+ builder = AppWindow.create_builder();
+
+ dialog = builder.get_object("preferences_dialog") as Gtk.Dialog;
+ dialog.set_parent_window(AppWindow.get_instance().get_parent_window());
+ dialog.set_transient_for(AppWindow.get_instance());
+ dialog.delete_event.connect(on_delete);
+ dialog.response.connect(on_close);
+ dialog.set_has_resize_grip(false);
+
+ bg_color_adjustment = builder.get_object("bg_color_adjustment") as Gtk.Adjustment;
+ bg_color_adjustment.set_value(bg_color_adjustment.get_upper() -
+ (Config.Facade.get_instance().get_bg_color().red * 65535.0));
+ bg_color_adjustment.value_changed.connect(on_value_changed);
+
+ bg_color_slider = builder.get_object("bg_color_slider") as Gtk.Scale;
+ bg_color_slider.button_press_event.connect(on_bg_color_reset);
+
+ library_dir_button = builder.get_object("library_dir_button") as Gtk.FileChooserButton;
+
+ close_button = builder.get_object("close_button") as Gtk.Button;
+
+ photo_editor_combo = builder.get_object("external_photo_editor_combo") as Gtk.ComboBox;
+ raw_editor_combo = builder.get_object("external_raw_editor_combo") as Gtk.ComboBox;
+
+ Gtk.Label pattern_help = builder.get_object("pattern_help") as Gtk.Label;
+
+ // Ticket #3162 - Move dir pattern blurb into Gnome help.
+ // Because specifying a particular snippet of the help requires
+ // us to know where its located, we can't hardcode a URL anymore;
+ // instead, we ask for the help path, and if we find it, we tell
+ // yelp to read from there, otherwise, we read from system-wide.
+ string help_path = Resources.get_help_path();
+
+ 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>");
+ } else {
+ // We're being run from the build directory; we'll have to handle clicks to this
+ // link manually ourselves, due to a limitation ghelp: URIs.
+ pattern_help.set_markup("<a href=\"dummy:\">" + _("(Help)") + "</a>");
+ pattern_help.activate_link.connect(on_local_pattern_help);
+ }
+
+ dir_pattern_combo = new Gtk.ComboBoxText();
+ Gtk.Alignment dir_choser_align = builder.get_object("dir choser") as Gtk.Alignment;
+ dir_choser_align.add(dir_pattern_combo);
+ dir_pattern_entry = builder.get_object("dir_pattern_entry") as Gtk.Entry;
+ dir_pattern_example = builder.get_object("dynamic example") as Gtk.Label;
+ add_to_dir_formats(_("Year%sMonth%sDay").printf(Path.DIR_SEPARATOR_S, Path.DIR_SEPARATOR_S),
+ "%Y" + Path.DIR_SEPARATOR_S + "%m" + Path.DIR_SEPARATOR_S + "%d");
+ add_to_dir_formats(_("Year%sMonth").printf(Path.DIR_SEPARATOR_S), "%Y" +
+ Path.DIR_SEPARATOR_S + "%m");
+ add_to_dir_formats(_("Year%sMonth-Day").printf(Path.DIR_SEPARATOR_S),
+ "%Y" + Path.DIR_SEPARATOR_S + "%m-%d");
+ add_to_dir_formats(_("Year-Month-Day"), "%Y-%m-%d");
+ add_to_dir_formats(_("Custom"), null); // Custom must always be last.
+ dir_pattern_combo.changed.connect(on_dir_pattern_combo_changed);
+ dir_pattern_entry.changed.connect(on_dir_pattern_entry_changed);
+
+ (builder.get_object("dir_structure_label") as Gtk.Label).set_mnemonic_widget(dir_pattern_combo);
+
+ lowercase = builder.get_object("lowercase") as Gtk.CheckButton;
+ lowercase.toggled.connect(on_lowercase_toggled);
+
+ Gtk.Bin plugin_manifest_container = builder.get_object("plugin-manifest-bin") as Gtk.Bin;
+ plugin_manifest_container.add(plugins_mediator.widget);
+
+ populate_preference_options();
+
+ photo_editor_combo.changed.connect(on_photo_editor_changed);
+ raw_editor_combo.changed.connect(on_raw_editor_changed);
+
+ Gtk.CheckButton auto_import_button = builder.get_object("autoimport") as Gtk.CheckButton;
+ auto_import_button.set_active(Config.Facade.get_instance().get_auto_import_from_library());
+
+ Gtk.CheckButton commit_metadata_button = builder.get_object("write_metadata") as Gtk.CheckButton;
+ commit_metadata_button.set_active(Config.Facade.get_instance().get_commit_metadata_to_masters());
+
+ default_raw_developer_combo = builder.get_object("default_raw_developer") as Gtk.ComboBoxText;
+ default_raw_developer_combo.append_text(RawDeveloper.CAMERA.get_label());
+ default_raw_developer_combo.append_text(RawDeveloper.SHOTWELL.get_label());
+ set_raw_developer_combo(Config.Facade.get_instance().get_default_raw_developer());
+ default_raw_developer_combo.changed.connect(on_default_raw_developer_changed);
+
+ dialog.map_event.connect(map_event);
+ }
+
+ public void populate_preference_options() {
+ populate_app_combo_box(photo_editor_combo, PhotoFileFormat.get_editable_mime_types(),
+ Config.Facade.get_instance().get_external_photo_app(), out external_photo_apps);
+
+ populate_app_combo_box(raw_editor_combo, PhotoFileFormat.RAW.get_mime_types(),
+ Config.Facade.get_instance().get_external_raw_app(), out external_raw_apps);
+
+ setup_dir_pattern(dir_pattern_combo, dir_pattern_entry);
+
+ lowercase.set_active(Config.Facade.get_instance().get_use_lowercase_filenames());
+ }
+
+ // Ticket #3162, part II - if we're not yet installed, then we have to manually launch
+ // the help viewer and specify the full path to the subsection we want...
+ private bool on_local_pattern_help(string ignore) {
+ try {
+ Resources.launch_help(AppWindow.get_instance().get_screen(), "?other-files");
+ } catch (Error e) {
+ message("Unable to launch help: %s", e.message);
+ }
+ return true;
+ }
+
+ private void populate_app_combo_box(Gtk.ComboBox combo_box, string[] mime_types,
+ string current_app_executable, out SortedList<AppInfo> external_apps) {
+ // get list of all applications for the given mime types
+ assert(mime_types.length != 0);
+ external_apps = DesktopIntegration.get_apps_for_mime_types(mime_types);
+
+ if (external_apps.size == 0)
+ return;
+
+ // populate application ComboBox with app names and icons
+ Gtk.CellRendererPixbuf pixbuf_renderer = new Gtk.CellRendererPixbuf();
+ Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
+ combo_box.clear();
+ combo_box.pack_start(pixbuf_renderer, false);
+ combo_box.pack_start(text_renderer, false);
+ combo_box.add_attribute(pixbuf_renderer, "pixbuf", 0);
+ combo_box.add_attribute(text_renderer, "text", 1);
+
+ // TODO: need more space between icons and text
+ Gtk.ListStore combo_store = new Gtk.ListStore(2, typeof(Gdk.Pixbuf), typeof(string));
+ Gtk.TreeIter iter;
+
+ int current_app = -1;
+
+ foreach (AppInfo app in external_apps) {
+ combo_store.append(out iter);
+
+ Icon app_icon = app.get_icon();
+ try {
+ if (app_icon is FileIcon) {
+ combo_store.set_value(iter, 0, scale_pixbuf(new Gdk.Pixbuf.from_file(
+ ((FileIcon) app_icon).get_file().get_path()), Resources.DEFAULT_ICON_SCALE,
+ Gdk.InterpType.BILINEAR, false));
+ } else if (app_icon is ThemedIcon) {
+ Gdk.Pixbuf icon_pixbuf =
+ Gtk.IconTheme.get_default().load_icon(((ThemedIcon) app_icon).get_names()[0],
+ Resources.DEFAULT_ICON_SCALE, Gtk.IconLookupFlags.FORCE_SIZE);
+
+ combo_store.set_value(iter, 0, icon_pixbuf);
+ }
+ } catch (GLib.Error error) {
+ warning("Error loading icon pixbuf: " + error.message);
+ }
+
+ combo_store.set_value(iter, 1, app.get_name());
+
+ if (app.get_commandline() == current_app_executable)
+ current_app = external_apps.index_of(app);
+ }
+
+ // TODO: allow users to choose unlisted applications like Nautilus's "Open with -> Other Application..."
+
+ combo_box.set_model(combo_store);
+
+ if (current_app != -1)
+ combo_box.set_active(current_app);
+ }
+
+ private void setup_dir_pattern(Gtk.ComboBox combo_box, Gtk.Entry entry) {
+ string? pattern = Config.Facade.get_instance().get_directory_pattern();
+ bool found = false;
+ if (null != pattern) {
+ // Locate pre-built text.
+ int i = 0;
+ foreach (PathFormat pf in path_formats) {
+ if (pf.pattern == pattern) {
+ combo_box.set_active(i);
+ found = true;
+ break;
+ }
+ i++;
+ }
+ } else {
+ // Custom path.
+ string? s = Config.Facade.get_instance().get_directory_pattern_custom();
+ if (!is_string_empty(s)) {
+ combo_box.set_active(path_formats.size - 1); // Assume "custom" is last.
+ found = true;
+ }
+ }
+
+ if (!found) {
+ combo_box.set_active(0);
+ }
+
+ on_dir_pattern_combo_changed();
+ }
+
+ public static void show() {
+ if (preferences_dialog == null)
+ preferences_dialog = new PreferencesDialog();
+
+ preferences_dialog.populate_preference_options();
+ preferences_dialog.dialog.show_all();
+ preferences_dialog.library_dir_button.set_current_folder(AppDirs.get_import_dir().get_path());
+
+ // Ticket #3001: Cause the dialog to become active if the user chooses 'Preferences'
+ // from the menus a second time.
+ preferences_dialog.dialog.present();
+ }
+
+ // For items that should only be committed when the dialog is closed, not as soon as the change
+ // is made.
+ private void commit_on_close() {
+ Config.Facade.get_instance().commit_bg_color();
+
+ Gtk.CheckButton? autoimport = builder.get_object("autoimport") as Gtk.CheckButton;
+ if (autoimport != null)
+ Config.Facade.get_instance().set_auto_import_from_library(autoimport.active);
+
+ Gtk.CheckButton? commit_metadata = builder.get_object("write_metadata") as Gtk.CheckButton;
+ if (commit_metadata != null)
+ Config.Facade.get_instance().set_commit_metadata_to_masters(commit_metadata.active);
+
+ if (lib_dir != null)
+ AppDirs.set_import_dir(lib_dir);
+
+ PathFormat pf = path_formats.get(dir_pattern_combo.get_active());
+ if (null == pf.pattern) {
+ Config.Facade.get_instance().set_directory_pattern_custom(dir_pattern_entry.text);
+ Config.Facade.get_instance().set_directory_pattern(null);
+ } else {
+ Config.Facade.get_instance().set_directory_pattern(pf.pattern);
+ }
+ }
+
+ private bool on_delete() {
+ if (!get_allow_closing())
+ return true;
+
+ commit_on_close();
+ return dialog.hide_on_delete(); //prevent widgets from getting destroyed
+ }
+
+ private void on_close() {
+ if (!get_allow_closing())
+ return;
+
+ dialog.hide();
+ commit_on_close();
+ }
+
+ private void on_value_changed() {
+ set_background_color((double)(bg_color_adjustment.get_upper() -
+ bg_color_adjustment.get_value()) / 65535.0);
+ }
+
+ private bool on_bg_color_reset(Gdk.EventButton event) {
+ if (event.button == 1 && event.type == Gdk.EventType.BUTTON_PRESS
+ && has_only_key_modifier(event.state, Gdk.ModifierType.CONTROL_MASK)) {
+ // Left Mouse Button and CTRL pressed
+ bg_color_slider.set_value(bg_color_adjustment.get_upper() -
+ (parse_color(Config.Facade.DEFAULT_BG_COLOR).red * 65536.0f));
+ on_value_changed();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private void on_dir_pattern_combo_changed() {
+ PathFormat pf = path_formats.get(dir_pattern_combo.get_active());
+ if (null == pf.pattern) {
+ // Custom format.
+ string? dir_pattern = Config.Facade.get_instance().get_directory_pattern_custom();
+ if (is_string_empty(dir_pattern))
+ dir_pattern = "";
+ dir_pattern_entry.set_text(dir_pattern);
+ dir_pattern_entry.editable = true;
+ dir_pattern_entry.sensitive = true;
+ } else {
+ dir_pattern_entry.set_text(pf.pattern);
+ dir_pattern_entry.editable = false;
+ dir_pattern_entry.sensitive = false;
+ }
+ }
+
+ private void on_dir_pattern_entry_changed() {
+ string example = example_date.format(dir_pattern_entry.text);
+ if (is_string_empty(example) && !is_string_empty(dir_pattern_entry.text)) {
+ // Invalid pattern.
+ dir_pattern_example.set_text(_("Invalid pattern"));
+ dir_pattern_entry.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, Gtk.Stock.DIALOG_ERROR);
+ dir_pattern_entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, false);
+ set_allow_closing(false);
+ } else {
+ // Valid pattern.
+ dir_pattern_example.set_text(example);
+ dir_pattern_entry.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, null);
+ set_allow_closing(true);
+ }
+ }
+
+ private void set_allow_closing(bool allow) {
+ dialog.set_deletable(allow);
+ close_button.set_sensitive(allow);
+ allow_closing = allow;
+ }
+
+ private bool get_allow_closing() {
+ return allow_closing;
+ }
+
+ private void set_background_color(double bg_color_value) {
+ Config.Facade.get_instance().set_bg_color(to_grayscale(bg_color_value));
+ }
+
+ private Gdk.RGBA to_grayscale(double color_value) {
+ Gdk.RGBA color = Gdk.RGBA();
+
+ color.red = color_value;
+ color.green = color_value;
+ color.blue = color_value;
+ color.alpha = 1.0;
+
+ return color;
+ }
+
+ private void on_photo_editor_changed() {
+ int photo_app_choice_index = (photo_editor_combo.get_active() < external_photo_apps.size) ?
+ photo_editor_combo.get_active() : external_photo_apps.size;
+
+ AppInfo app = external_photo_apps.get_at(photo_app_choice_index);
+
+ Config.Facade.get_instance().set_external_photo_app(DesktopIntegration.get_app_open_command(app));
+
+ debug("setting external photo editor to: %s", DesktopIntegration.get_app_open_command(app));
+ }
+
+ private void on_raw_editor_changed() {
+ int raw_app_choice_index = (raw_editor_combo.get_active() < external_raw_apps.size) ?
+ raw_editor_combo.get_active() : external_raw_apps.size;
+
+ AppInfo app = external_raw_apps.get_at(raw_app_choice_index);
+
+ Config.Facade.get_instance().set_external_raw_app(app.get_commandline());
+
+ debug("setting external raw editor to: %s", app.get_commandline());
+ }
+
+ private RawDeveloper raw_developer_from_combo() {
+ if (default_raw_developer_combo.get_active() == 0)
+ return RawDeveloper.CAMERA;
+ return RawDeveloper.SHOTWELL;
+ }
+
+ private void set_raw_developer_combo(RawDeveloper d) {
+ if (d == RawDeveloper.CAMERA)
+ default_raw_developer_combo.set_active(0);
+ else
+ default_raw_developer_combo.set_active(1);
+ }
+
+ private void on_default_raw_developer_changed() {
+ Config.Facade.get_instance().set_default_raw_developer(raw_developer_from_combo());
+ }
+
+ private void on_current_folder_changed() {
+ lib_dir = library_dir_button.get_filename();
+ }
+
+ private bool map_event() {
+ // Set the signal for the lib dir button after the dialog is displayed,
+ // because the FileChooserButton has a nasty habbit of selecting a
+ // different folder when displayed if the provided path doesn't exist.
+ // See ticket #3000 for more info.
+ library_dir_button.current_folder_changed.connect(on_current_folder_changed);
+ return true;
+ }
+
+ private void add_to_dir_formats(string name, string? pattern) {
+ PathFormat pf = new PathFormat(name, pattern);
+ path_formats.add(pf);
+ dir_pattern_combo.append_text(name);
+ }
+
+ private void on_lowercase_toggled() {
+ Config.Facade.get_instance().set_use_lowercase_filenames(lowercase.get_active());
+ }
+}
+
+// This function is used to determine whether or not files should be copied or linked when imported.
+// Returns ACCEPT for copy, REJECT for link, and CANCEL for (drum-roll) cancel.
+public Gtk.ResponseType copy_files_dialog() {
+ string msg = _("Shotwell can copy the photos into your library folder or it can import them without copying.");
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.QUESTION, Gtk.ButtonsType.CANCEL, "%s", msg);
+
+ dialog.add_button(_("Co_py Photos"), Gtk.ResponseType.ACCEPT);
+ dialog.add_button(_("_Import in Place"), Gtk.ResponseType.REJECT);
+ dialog.title = _("Import to Library");
+
+ Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
+
+ dialog.destroy();
+
+ return result;
+}
+
+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"));
+}
+
+public void remove_from_app(Gee.Collection<MediaSource> sources, string dialog_title,
+ string progress_dialog_text) {
+ if (sources.size == 0)
+ return;
+
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
+ MediaSourceCollection.filter_media(sources, photos, videos);
+
+ string? user_message = null;
+ if ((!photos.is_empty) && (!videos.is_empty)) {
+ user_message = ngettext("This will remove the photo/video from your Shotwell library. Would you also like to move the file to your desktop trash?\n\nThis action cannot be undone.",
+ "This will remove %d photos/videos from your Shotwell library. Would you also like to move the files to your desktop trash?\n\nThis action cannot be undone.",
+ sources.size).printf(sources.size);
+ } else if (!videos.is_empty) {
+ user_message = ngettext("This will remove the video from your Shotwell library. Would you also like to move the file to your desktop trash?\n\nThis action cannot be undone.",
+ "This will remove %d videos from your Shotwell library. Would you also like to move the files to your desktop trash?\n\nThis action cannot be undone.",
+ sources.size).printf(sources.size);
+ } else {
+ user_message = ngettext("This will remove the photo from your Shotwell library. Would you also like to move the file to your desktop trash?\n\nThis action cannot be undone.",
+ "This will remove %d photos from your Shotwell library. Would you also like to move the files to your desktop trash?\n\nThis action cannot be undone.",
+ sources.size).printf(sources.size);
+ }
+
+ Gtk.ResponseType result = remove_from_library_dialog(AppWindow.get_instance(), dialog_title,
+ user_message, sources.size);
+ if (result != Gtk.ResponseType.YES && result != Gtk.ResponseType.NO)
+ return;
+
+ bool delete_backing = (result == Gtk.ResponseType.YES);
+
+ AppWindow.get_instance().set_busy_cursor();
+
+ ProgressDialog progress = null;
+ ProgressMonitor monitor = null;
+ if (sources.size >= 20) {
+ progress = new ProgressDialog(AppWindow.get_instance(), progress_dialog_text);
+ monitor = progress.monitor;
+ }
+
+ Gee.ArrayList<LibraryPhoto> not_removed_photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> not_removed_videos = new Gee.ArrayList<Video>();
+
+ // Remove and attempt to trash.
+ LibraryPhoto.global.remove_from_app(photos, delete_backing, monitor, not_removed_photos);
+ Video.global.remove_from_app(videos, delete_backing, monitor, not_removed_videos);
+
+ // Check for files we couldn't trash.
+ int num_not_removed = not_removed_photos.size + not_removed_videos.size;
+ if (delete_backing && num_not_removed > 0) {
+ string not_deleted_message =
+ ngettext("The photo or video cannot be moved to your desktop trash. Delete this file?",
+ "%d photos/videos cannot be moved to your desktop trash. Delete these files?",
+ num_not_removed).printf(num_not_removed);
+ Gtk.ResponseType result_delete = remove_from_filesystem_dialog(AppWindow.get_instance(),
+ dialog_title, not_deleted_message);
+
+ if (Gtk.ResponseType.YES == result_delete) {
+ // Attempt to delete the files.
+ Gee.ArrayList<LibraryPhoto> not_deleted_photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> not_deleted_videos = new Gee.ArrayList<Video>();
+ LibraryPhoto.global.delete_backing_files(not_removed_photos, monitor, not_deleted_photos);
+ Video.global.delete_backing_files(not_removed_videos, monitor, not_deleted_videos);
+
+ int num_not_deleted = not_deleted_photos.size + not_deleted_videos.size;
+ if (num_not_deleted > 0) {
+ // Alert the user that the files were not removed.
+ string delete_failed_message =
+ ngettext("The photo or video cannot be deleted.",
+ "%d photos/videos cannot be deleted.",
+ num_not_deleted).printf(num_not_deleted);
+ AppWindow.error_message_with_title(dialog_title, delete_failed_message, AppWindow.get_instance());
+ }
+ }
+ }
+
+ if (progress != null)
+ progress.close();
+
+ AppWindow.get_instance().set_normal_cursor();
+}
+
diff --git a/src/Dimensions.vala b/src/Dimensions.vala
new file mode 100644
index 0000000..0c8c895
--- /dev/null
+++ b/src/Dimensions.vala
@@ -0,0 +1,732 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public enum ScaleConstraint {
+ ORIGINAL,
+ DIMENSIONS,
+ WIDTH,
+ HEIGHT,
+ FILL_VIEWPORT;
+
+ public string? to_string() {
+ switch (this) {
+ case ORIGINAL:
+ return _("Original size");
+
+ case DIMENSIONS:
+ return _("Width or height");
+
+ case WIDTH:
+ return _("Width");
+
+ case HEIGHT:
+ return _("Height");
+
+ case FILL_VIEWPORT:
+ // TODO: Translate (not used in UI at this point)
+ return "Fill Viewport";
+ }
+
+ warn_if_reached();
+
+ return null;
+ }
+}
+
+public struct Dimensions {
+ public int width;
+ public int height;
+
+ public Dimensions(int width = 0, int height = 0) {
+ if ((width < 0) || (height < 0))
+ warning("Tried to construct a Dimensions object with negative width or height - forcing sensible default values.");
+
+ this.width = width.clamp(0, width);
+ this.height = height.clamp(0, height);
+ }
+
+ public static Dimensions for_pixbuf(Gdk.Pixbuf pixbuf) {
+ return Dimensions(pixbuf.get_width(), pixbuf.get_height());
+ }
+
+ public static Dimensions for_allocation(Gtk.Allocation allocation) {
+ return Dimensions(allocation.width, allocation.height);
+ }
+
+ public static Dimensions for_widget_allocation(Gtk.Widget widget) {
+ Gtk.Allocation allocation;
+ widget.get_allocation(out allocation);
+
+ return Dimensions(allocation.width, allocation.height);
+ }
+
+ public static Dimensions for_rectangle(Gdk.Rectangle rect) {
+ return Dimensions(rect.width, rect.height);
+ }
+
+ public bool has_area() {
+ return (width > 0 && height > 0);
+ }
+
+ public Dimensions floor(Dimensions min = Dimensions(1, 1)) {
+ return Dimensions((width > min.width) ? width : min.width,
+ (height > min.height) ? height : min.height);
+ }
+
+ public string to_string() {
+ return "%dx%d".printf(width, height);
+ }
+
+ public bool equals(Dimensions dim) {
+ return (width == dim.width && height == dim.height);
+ }
+
+ // sometimes a pixel or two is okay
+ public bool approx_equals(Dimensions dim, int fudge = 1) {
+ return (width - dim.width).abs() <= fudge && (height - dim.height).abs() <= fudge;
+ }
+
+ public bool approx_scaled(int scale, int fudge = 1) {
+ return (width <= (scale + fudge)) && (height <= (scale + fudge));
+ }
+
+ public int major_axis() {
+ return int.max(width, height);
+ }
+
+ public int minor_axis() {
+ return int.min(width, height);
+ }
+
+ public Dimensions with_min(int min_width, int min_height) {
+ return Dimensions(int.max(width, min_width), int.max(height, min_height));
+ }
+
+ public Dimensions with_max(int max_width, int max_height) {
+ return Dimensions(int.min(width, max_width), int.min(height, max_height));
+ }
+
+ public Dimensions get_scaled(int scale, bool scale_up) {
+ assert(scale > 0);
+
+ // check for existing best-fit
+ if ((width == scale && height < scale) || (height == scale && width < scale))
+ return Dimensions(width, height);
+
+ // watch for scaling up
+ if (!scale_up && (width < scale && height < scale))
+ return Dimensions(width, height);
+
+ if ((width - scale) > (height - scale))
+ return get_scaled_by_width(scale);
+ else
+ return get_scaled_by_height(scale);
+ }
+
+ public void get_scale_ratios(Dimensions scaled, out double width_ratio, out double height_ratio) {
+ width_ratio = (double) scaled.width / (double) width;
+ height_ratio = (double) scaled.height / (double) height;
+ }
+
+ public double get_aspect_ratio() {
+ return ((double) width) / height;
+ }
+
+ public Dimensions get_scaled_proportional(Dimensions viewport) {
+ double width_ratio, height_ratio;
+ get_scale_ratios(viewport, out width_ratio, out height_ratio);
+
+ double scaled_width, scaled_height;
+ if (width_ratio < height_ratio) {
+ scaled_width = viewport.width;
+ scaled_height = (double) height * width_ratio;
+ } else {
+ scaled_width = (double) width * height_ratio;
+ scaled_height = viewport.height;
+ }
+
+ Dimensions scaled = Dimensions((int) Math.round(scaled_width),
+ (int) Math.round(scaled_height)).floor();
+ assert(scaled.height <= viewport.height);
+ assert(scaled.width <= viewport.width);
+
+ return scaled;
+ }
+
+ public Dimensions get_scaled_to_fill_viewport(Dimensions viewport) {
+ double width_ratio, height_ratio;
+ get_scale_ratios(viewport, out width_ratio, out height_ratio);
+
+ double scaled_width, scaled_height;
+ if (width < viewport.width && height >= viewport.height) {
+ // too narrow
+ scaled_width = viewport.width;
+ scaled_height = (double) height * width_ratio;
+ } else if (width >= viewport.width && height < viewport.height) {
+ // too short
+ scaled_width = (double) width * height_ratio;
+ scaled_height = viewport.height;
+ } else {
+ // both are smaller or larger
+ double ratio = double.max(width_ratio, height_ratio);
+
+ scaled_width = (double) width * ratio;
+ scaled_height = (double) height * ratio;
+ }
+
+ return Dimensions((int) Math.round(scaled_width), (int) Math.round(scaled_height)).floor();
+ }
+
+ public Gdk.Rectangle get_scaled_rectangle(Dimensions scaled, Gdk.Rectangle rect) {
+ double x_scale, y_scale;
+ get_scale_ratios(scaled, out x_scale, out y_scale);
+
+ Gdk.Rectangle scaled_rect = Gdk.Rectangle();
+ scaled_rect.x = (int) Math.round((double) rect.x * x_scale);
+ scaled_rect.y = (int) Math.round((double) rect.y * y_scale);
+ scaled_rect.width = (int) Math.round((double) rect.width * x_scale);
+ scaled_rect.height = (int) Math.round((double) rect.height * y_scale);
+
+ if (scaled_rect.width <= 0)
+ scaled_rect.width = 1;
+
+ if (scaled_rect.height <= 0)
+ scaled_rect.height = 1;
+
+ return scaled_rect;
+ }
+
+ // Returns the current dimensions scaled in a similar proportion as the two suppled dimensions
+ public Dimensions get_scaled_similar(Dimensions original, Dimensions scaled) {
+ double x_scale, y_scale;
+ original.get_scale_ratios(scaled, out x_scale, out y_scale);
+
+ double scale = double.min(x_scale, y_scale);
+
+ return Dimensions((int) Math.round((double) width * scale),
+ (int) Math.round((double) height * scale)).floor();
+ }
+
+ public Dimensions get_scaled_by_width(int scale) {
+ assert(scale > 0);
+
+ double ratio = (double) scale / (double) width;
+
+ return Dimensions(scale, (int) Math.round((double) height * ratio)).floor();
+ }
+
+ public Dimensions get_scaled_by_height(int scale) {
+ assert(scale > 0);
+
+ double ratio = (double) scale / (double) height;
+
+ return Dimensions((int) Math.round((double) width * ratio), scale).floor();
+ }
+
+ public Dimensions get_scaled_by_constraint(int scale, ScaleConstraint constraint) {
+ switch (constraint) {
+ case ScaleConstraint.ORIGINAL:
+ return Dimensions(width, height);
+
+ case ScaleConstraint.DIMENSIONS:
+ return (width >= height) ? get_scaled_by_width(scale) : get_scaled_by_height(scale);
+
+ case ScaleConstraint.WIDTH:
+ return get_scaled_by_width(scale);
+
+ case ScaleConstraint.HEIGHT:
+ return get_scaled_by_height(scale);
+
+ default:
+ error("Bad constraint: %d", (int) constraint);
+ }
+ }
+}
+
+public struct Scaling {
+ private const int NO_SCALE = 0;
+
+ private ScaleConstraint constraint;
+ private int scale;
+ private Dimensions viewport;
+ private bool scale_up;
+
+ private Scaling(ScaleConstraint constraint, int scale, Dimensions viewport, bool scale_up) {
+ this.constraint = constraint;
+ this.scale = scale;
+ this.viewport = viewport;
+ this.scale_up = scale_up;
+ }
+
+ public static Scaling for_original() {
+ return Scaling(ScaleConstraint.ORIGINAL, NO_SCALE, Dimensions(), false);
+ }
+
+ public static Scaling for_screen(Gtk.Window window, bool scale_up) {
+ return for_viewport(get_screen_dimensions(window), scale_up);
+ }
+
+ public static Scaling for_best_fit(int pixels, bool scale_up) {
+ assert(pixels > 0);
+
+ return Scaling(ScaleConstraint.DIMENSIONS, pixels, Dimensions(), scale_up);
+ }
+
+ public static Scaling for_viewport(Dimensions viewport, bool scale_up) {
+ assert(viewport.has_area());
+
+ return Scaling(ScaleConstraint.DIMENSIONS, NO_SCALE, viewport, scale_up);
+ }
+
+ public static Scaling for_widget(Gtk.Widget widget, bool scale_up) {
+ Dimensions viewport = Dimensions.for_widget_allocation(widget);
+
+ // Because it seems that Gtk.Application realizes the main window and its
+ // attendant widgets lazily, it's possible to get here with the PhotoPage's
+ // canvas believing it is 1px by 1px, which can lead to a scaling that
+ // gdk_pixbuf_scale_simple can't handle.
+ //
+ // If we get here, and the widget we're being drawn into is 1x1, then, most likely,
+ // it's not fully realized yet (since nothing in Shotwell requires this), so just
+ // ignore it and return something safe instead.
+ if ((viewport.width <= 1) || (viewport.height <= 1))
+ return for_original();
+
+ return Scaling(ScaleConstraint.DIMENSIONS, NO_SCALE, viewport, scale_up);
+ }
+
+ public static Scaling to_fill_viewport(Dimensions viewport) {
+ // Please see the comment in Scaling.for_widget as to why this is
+ // required.
+ if ((viewport.width <= 1) || (viewport.height <= 1))
+ return for_original();
+
+ return Scaling(ScaleConstraint.FILL_VIEWPORT, NO_SCALE, viewport, true);
+ }
+
+ public static Scaling to_fill_screen(Gtk.Window window) {
+ return to_fill_viewport(get_screen_dimensions(window));
+ }
+
+ public static Scaling for_constraint(ScaleConstraint constraint, int scale, bool scale_up) {
+ return Scaling(constraint, scale, Dimensions(), scale_up);
+ }
+
+ private static Dimensions get_screen_dimensions(Gtk.Window window) {
+ Gdk.Screen screen = window.get_screen();
+
+ return Dimensions(screen.get_width(), screen.get_height());
+ }
+
+ private int scale_to_pixels() {
+ return (scale >= 0) ? scale : 0;
+ }
+
+ public bool is_unscaled() {
+ return constraint == ScaleConstraint.ORIGINAL;
+ }
+
+ public bool is_best_fit(Dimensions original, out int pixels) {
+ pixels = 0;
+
+ if (scale == NO_SCALE)
+ return false;
+
+ switch (constraint) {
+ case ScaleConstraint.ORIGINAL:
+ case ScaleConstraint.FILL_VIEWPORT:
+ return false;
+
+ default:
+ pixels = scale_to_pixels();
+ assert(pixels > 0);
+
+ return true;
+ }
+ }
+
+ public bool is_best_fit_dimensions(Dimensions original, out Dimensions scaled) {
+ scaled = Dimensions();
+
+ if (scale == NO_SCALE)
+ return false;
+
+ switch (constraint) {
+ case ScaleConstraint.ORIGINAL:
+ case ScaleConstraint.FILL_VIEWPORT:
+ return false;
+
+ default:
+ int pixels = scale_to_pixels();
+ assert(pixels > 0);
+
+ scaled = original.get_scaled_by_constraint(pixels, constraint);
+
+ return true;
+ }
+ }
+
+ public bool is_for_viewport(Dimensions original, out Dimensions scaled) {
+ scaled = Dimensions();
+
+ if (scale != NO_SCALE)
+ return false;
+
+ switch (constraint) {
+ case ScaleConstraint.ORIGINAL:
+ case ScaleConstraint.FILL_VIEWPORT:
+ return false;
+
+ default:
+ assert(viewport.has_area());
+
+ if (!scale_up && original.width < viewport.width && original.height < viewport.height)
+ scaled = original;
+ else
+ scaled = original.get_scaled_proportional(viewport);
+
+ return true;
+ }
+ }
+
+ public bool is_fill_viewport(Dimensions original, out Dimensions scaled) {
+ scaled = Dimensions();
+
+ if (constraint != ScaleConstraint.FILL_VIEWPORT)
+ return false;
+
+ assert(viewport.has_area());
+ scaled = original.get_scaled_to_fill_viewport(viewport);
+
+ return true;
+ }
+
+ public Dimensions get_scaled_dimensions(Dimensions original) {
+ if (is_unscaled())
+ return original;
+
+ Dimensions scaled;
+ if (is_fill_viewport(original, out scaled))
+ return scaled;
+
+ if (is_best_fit_dimensions(original, out scaled))
+ return scaled;
+
+ bool is_viewport = is_for_viewport(original, out scaled);
+ assert(is_viewport);
+
+ return scaled;
+ }
+
+ public Gdk.Pixbuf perform_on_pixbuf(Gdk.Pixbuf pixbuf, Gdk.InterpType interp, bool scale_up) {
+ if (is_unscaled())
+ return pixbuf;
+
+ Dimensions pixbuf_dim = Dimensions.for_pixbuf(pixbuf);
+
+ int pixels;
+ if (is_best_fit(pixbuf_dim, out pixels))
+ return scale_pixbuf(pixbuf, pixels, interp, scale_up);
+
+ Dimensions scaled;
+ if (is_fill_viewport(pixbuf_dim, out scaled))
+ return resize_pixbuf(pixbuf, scaled, interp);
+
+ bool is_viewport = is_for_viewport(pixbuf_dim, out scaled);
+ assert(is_viewport);
+
+ return resize_pixbuf(pixbuf, scaled, interp);
+ }
+
+ public string to_string() {
+ if (constraint == ScaleConstraint.ORIGINAL)
+ return "scaling: UNSCALED";
+ else if (constraint == ScaleConstraint.FILL_VIEWPORT)
+ return "scaling: fill viewport %s".printf(viewport.to_string());
+ else if (scale != NO_SCALE)
+ return "scaling: best-fit (%s %d pixels %s)".printf(constraint.to_string(),
+ scale_to_pixels(), scale_up ? "scaled up" : "not scaled up");
+ else
+ return "scaling: viewport %s (%s)".printf(viewport.to_string(),
+ scale_up ? "scaled up" : "not scaled up");
+ }
+
+ public bool equals(Scaling scaling) {
+ return (constraint == scaling.constraint) && (scale == scaling.scale)
+ && viewport.equals(scaling.viewport);
+ }
+}
+
+public struct ZoomState {
+ private Dimensions content_dimensions;
+ private Dimensions viewport_dimensions;
+ private double zoom_factor;
+ private double interpolation_factor;
+ private double min_factor;
+ private double max_factor;
+ private Gdk.Point viewport_center;
+
+ public ZoomState(Dimensions content_dimensions, Dimensions viewport_dimensions,
+ double slider_val = 0.0, Gdk.Point? viewport_center = null) {
+ this.content_dimensions = content_dimensions;
+ this.viewport_dimensions = viewport_dimensions;
+ this.interpolation_factor = slider_val;
+
+ compute_zoom_factors();
+
+ if ((viewport_center == null) || ((viewport_center.x == 0) && (viewport_center.y == 0)) ||
+ (slider_val == 0.0)) {
+ center_viewport();
+ } else {
+ this.viewport_center = viewport_center;
+ clamp_viewport_center();
+ }
+ }
+
+ public ZoomState.rescale(ZoomState existing, double new_slider_val) {
+ this.content_dimensions = existing.content_dimensions;
+ this.viewport_dimensions = existing.viewport_dimensions;
+ this.interpolation_factor = new_slider_val;
+
+ compute_zoom_factors();
+
+ if (new_slider_val == 0.0) {
+ center_viewport();
+ } else {
+ viewport_center.x = (int) (zoom_factor * (existing.viewport_center.x /
+ existing.zoom_factor));
+ viewport_center.y = (int) (zoom_factor * (existing.viewport_center.y /
+ existing.zoom_factor));
+ clamp_viewport_center();
+ }
+ }
+
+ public ZoomState.rescale_to_isomorphic(ZoomState existing) {
+ this.content_dimensions = existing.content_dimensions;
+ this.viewport_dimensions = existing.viewport_dimensions;
+ this.interpolation_factor = Math.log(1.0 / existing.min_factor) /
+ (Math.log(existing.max_factor / existing.min_factor));
+
+ compute_zoom_factors();
+
+ if (this.interpolation_factor == 0.0) {
+ center_viewport();
+ } else {
+ viewport_center.x = (int) (zoom_factor * (existing.viewport_center.x /
+ existing.zoom_factor));
+ viewport_center.y = (int) (zoom_factor * (existing.viewport_center.y /
+ existing.zoom_factor));
+ clamp_viewport_center();
+ }
+ }
+
+ public ZoomState.pan(ZoomState existing, Gdk.Point new_viewport_center) {
+ this.content_dimensions = existing.content_dimensions;
+ this.viewport_dimensions = existing.viewport_dimensions;
+ this.interpolation_factor = existing.interpolation_factor;
+
+ compute_zoom_factors();
+
+ this.viewport_center = new_viewport_center;
+
+ clamp_viewport_center();
+ }
+
+ private void clamp_viewport_center() {
+ int zoomed_width = get_zoomed_width();
+ int zoomed_height = get_zoomed_height();
+
+ viewport_center.x = viewport_center.x.clamp(viewport_dimensions.width / 2,
+ zoomed_width - (viewport_dimensions.width / 2) - 1);
+ viewport_center.y = viewport_center.y.clamp(viewport_dimensions.height / 2,
+ zoomed_height - (viewport_dimensions.height / 2) - 1);
+ }
+
+ private void center_viewport() {
+ viewport_center.x = get_zoomed_width() / 2;
+ viewport_center.y = get_zoomed_height() / 2;
+ }
+
+ private void compute_zoom_factors() {
+ max_factor = 2.0;
+
+ double viewport_to_content_x;
+ double viewport_to_content_y;
+ content_dimensions.get_scale_ratios(viewport_dimensions, out viewport_to_content_x,
+ out viewport_to_content_y);
+ min_factor = double.min(viewport_to_content_x, viewport_to_content_y);
+ if (min_factor > 1.0)
+ min_factor = 1.0;
+
+ zoom_factor = min_factor * Math.pow(max_factor / min_factor, interpolation_factor);
+ }
+
+ public double get_interpolation_factor() {
+ return interpolation_factor;
+ }
+
+ /* gets the viewing rectangle with respect to the zoomed content */
+ public Gdk.Rectangle get_viewing_rectangle_wrt_content() {
+ int zoomed_width = get_zoomed_width();
+ int zoomed_height = get_zoomed_height();
+
+ Gdk.Rectangle result = Gdk.Rectangle();
+
+ if (viewport_dimensions.width < zoomed_width) {
+ result.x = viewport_center.x - (viewport_dimensions.width / 2);
+ } else {
+ result.x = (zoomed_width - viewport_dimensions.width) / 2;
+ }
+ if (result.x < 0)
+ result.x = 0;
+
+ if (viewport_dimensions.height < zoomed_height) {
+ result.y = viewport_center.y - (viewport_dimensions.height / 2);
+ } else {
+ result.y = (zoomed_height - viewport_dimensions.height) / 2;
+ }
+ if (result.y < 0)
+ result.y = 0;
+
+ int right = result.x + viewport_dimensions.width;
+ if (right > zoomed_width)
+ right = zoomed_width;
+ result.width = right - result.x;
+
+ int bottom = result.y + viewport_dimensions.height;
+ if (bottom > zoomed_height)
+ bottom = zoomed_height;
+ result.height = bottom - result.y;
+
+ result.width = result.width.clamp(1, int.MAX);
+ result.height = result.height.clamp(1, int.MAX);
+
+ return result;
+ }
+
+ /* gets the viewing rectangle with respect to the on-screen canvas where zoomed content is
+ drawn */
+ public Gdk.Rectangle get_viewing_rectangle_wrt_screen() {
+ Gdk.Rectangle wrt_content = get_viewing_rectangle_wrt_content();
+
+ Gdk.Rectangle result = Gdk.Rectangle();
+ result.x = (viewport_dimensions.width / 2) - (wrt_content.width / 2);
+ if (result.x < 0)
+ result.x = 0;
+ result.y = (viewport_dimensions.height / 2) - (wrt_content.height / 2);
+ if (result.y < 0)
+ result.y = 0;
+ result.width = wrt_content.width;
+ result.height = wrt_content.height;
+
+ return result;
+ }
+
+ /* gets the projection of the viewing rectangle into the arbitrary pixbuf 'for_pixbuf' */
+ public Gdk.Rectangle get_viewing_rectangle_projection(Gdk.Pixbuf for_pixbuf) {
+ double zoomed_width = get_zoomed_width();
+ double zoomed_height = get_zoomed_height();
+
+ double horiz_scale = for_pixbuf.width / zoomed_width;
+ double vert_scale = for_pixbuf.height / zoomed_height;
+ double scale = (horiz_scale + vert_scale) / 2.0;
+
+ Gdk.Rectangle viewing_rectangle = get_viewing_rectangle_wrt_content();
+
+ Gdk.Rectangle result = Gdk.Rectangle();
+ result.x = (int) (viewing_rectangle.x * scale);
+ result.x = result.x.clamp(0, for_pixbuf.width);
+ result.y = (int) (viewing_rectangle.y * scale);
+ result.y = result.y.clamp(0, for_pixbuf.height);
+ int right = (int) ((viewing_rectangle.x + viewing_rectangle.width) * scale);
+ right = right.clamp(0, for_pixbuf.width);
+ int bottom = (int) ((viewing_rectangle.y + viewing_rectangle.height) * scale);
+ bottom = bottom.clamp(0, for_pixbuf.height);
+ result.width = right - result.x;
+ result.height = bottom - result.y;
+
+ return result;
+ }
+
+
+ public double get_zoom_factor() {
+ return zoom_factor;
+ }
+
+ public int get_zoomed_width() {
+ return (int) (content_dimensions.width * zoom_factor);
+ }
+
+ public int get_zoomed_height() {
+ return (int) (content_dimensions.height * zoom_factor);
+ }
+
+ public Gdk.Point get_viewport_center() {
+ return viewport_center;
+ }
+
+ public string to_string() {
+ string named_modes = "";
+ if (is_min())
+ named_modes = named_modes + ((named_modes == "") ? "MIN" : ", MIN");
+ if (is_default())
+ named_modes = named_modes + ((named_modes == "") ? "DEFAULT" : ", DEFAULT");
+ if (is_isomorphic())
+ named_modes = named_modes + ((named_modes =="") ? "ISOMORPHIC" : ", ISOMORPHIC");
+ if (is_max())
+ named_modes = named_modes + ((named_modes =="") ? "MAX" : ", MAX");
+ if (named_modes == "")
+ named_modes = "(none)";
+
+ Gdk.Rectangle viewing_rect = get_viewing_rectangle_wrt_content();
+
+ return (("ZoomState {\n content dimensions = %d x %d;\n viewport dimensions = " +
+ "%d x %d;\n min factor = %f;\n max factor = %f;\n current factor = %f;" +
+ "\n zoomed width = %d;\n zoomed height = %d;\n named modes = %s;" +
+ "\n viewing rectangle = { x: %d, y: %d, width: %d, height: %d };" +
+ "\n viewport center = (%d, %d);\n}\n").printf(
+ content_dimensions.width, content_dimensions.height, viewport_dimensions.width,
+ viewport_dimensions.height, min_factor, max_factor, zoom_factor, get_zoomed_width(),
+ get_zoomed_height(), named_modes, viewing_rect.x, viewing_rect.y, viewing_rect.width,
+ viewing_rect.height, viewport_center.x, viewport_center.y));
+ }
+
+ public bool is_min() {
+ return (zoom_factor == min_factor);
+ }
+
+ public bool is_default() {
+ return is_min();
+ }
+
+ public bool is_max() {
+ return (zoom_factor == max_factor);
+ }
+
+ public bool is_isomorphic() {
+ return (zoom_factor == 1.0);
+ }
+
+ public bool equals(ZoomState other) {
+ if (!content_dimensions.equals(other.content_dimensions))
+ return false;
+ if (!viewport_dimensions.equals(other.viewport_dimensions))
+ return false;
+ if (zoom_factor != other.zoom_factor)
+ return false;
+ if (min_factor != other.min_factor)
+ return false;
+ if (max_factor != other.max_factor)
+ return false;
+ if (viewport_center.x != other.viewport_center.x)
+ return false;
+ if (viewport_center.y != other.viewport_center.y)
+ return false;
+
+ return true;
+ }
+}
+
diff --git a/src/DirectoryMonitor.vala b/src/DirectoryMonitor.vala
new file mode 100644
index 0000000..1778fe4
--- /dev/null
+++ b/src/DirectoryMonitor.vala
@@ -0,0 +1,1454 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+//
+// DirectoryMonitor will monitor an entire directory for changes to all files and directories
+// within it. It uses FileMonitor to monitor all directories it discovers at initialization
+// and reports changes to the files and directories just as FileMonitor reports them. Subclasses
+// can override the notify_* methods to filter or monitor events before the signal is fired,
+// or can override the signals themselves to be notified afterwards.
+//
+// start_discovery() must be called to initiate monitoring. Directories and files will be reported
+// as they're discovered. Directories will be monitored as they're discovered as well. Discovery
+// can only be initiated once.
+//
+// All signals are virtual and have a corresponding notify_* protected virtual function.
+// Subclasses can either override the notify or the signal to decide when they want to process
+// the event.
+//
+// DirectoryMonitor also adds a level of intelligence to GLib's monitoring API.Because certain
+// file/directory events are decomposed by FileMonitor into more atomic events, it's difficult
+// to know when these "composed" events have occurred. (For example, a file move is reported
+// as a DELETED event followed by a CREATED event, with no clue that the two are related.) Later
+// versions added the MOVE event, but we can't rely on those being installed. Also, documentation
+// suggests it's only available with certain back-ends.
+//
+// DirectoryMonitor attempts to solve this by deducing when a set of events actually equals
+// a composite event. It requires more memory in order to do this (i.e. it stores all files and
+// their information), but the trade-off is easier file/directory monitoring via familiar
+// semantics.
+//
+// DirectoryMonitor also will synthesize events when normal monitor events don't produce expected
+// results. For example, if a directory is moved out of DirectoryMonitor's root, it is reported
+// as a delete event, but none of its children are reported as deleted. Similarly, a directory
+// rename can be captured as a move, but notifications for all its children are not fired and
+// are synthesized by DirectoryMonitor. DirectoryMonitor will fire delete and move notifications
+// for all the directory's children in depth-first order.
+//
+// In general, DirectoryMonitor attempts to preserve ordering of events, so that (for example) a
+// file-altered event doesn't fire before a file-created, and so on.
+//
+// Because of these requirements, DirectoryMonitor maintains a FileInfo struct on all directories
+// and files being monitored. (It maintains the attributes gather during the discovery phase, i.e.
+// SUPPLIED_ATTRIBUTES.) This information can be retrieved via get_info(), get_file_id(), and
+// get_etag(). These calls can be made at any time; the information is stored before any signal
+// is fired.
+//
+// Note that DirectoryMonitor currently only supports files and directories. Other file types
+// (special, symbolic links, shortcuts, and mount points) are not supported. It has been seen
+// when a temporary file is created for its file type to be reported as "unknown" and when it's
+// altered/deleted to be reported as a regular file. This means it's possible for a file not to
+// be reported as discovered or created but to be reported as altered and/or deleted.
+//
+// DirectoryMonitor can be configured to not recurse (in which case it only discovers/monitors
+// the root directory) and to not monitor (in which case only discovery occurs).
+//
+
+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;
+
+ // 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().
+ public const FileQueryInfoFlags UNKNOWN_INFO_FLAGS = FileQueryInfoFlags.NONE;
+ public const bool SUPPORT_DIR_SYMLINKS = true;
+ public const bool SUPPORT_FILE_SYMLINKS = false;
+
+ public const string SUPPLIED_ATTRIBUTES = Util.FILE_ATTRIBUTES;
+
+ private const FileMonitorFlags FILE_MONITOR_FLAGS = FileMonitorFlags.SEND_MOVED;
+ private const uint DELETED_EXPIRATION_MSEC = 500;
+ private const int MAX_EXPLORATION_DIRS = 5;
+
+ private enum FType {
+ FILE,
+ DIRECTORY,
+ UNSUPPORTED
+ }
+
+ private class QueryInfoQueueElement {
+ private static uint current = 0;
+
+ public DirectoryMonitor owner;
+ public File file;
+ public File? other_file;
+ public FileMonitorEvent event;
+ public uint position;
+ public ulong time_created_msec;
+ public FileInfo? info = null;
+ public Error? err = null;
+ public bool completed = false;
+
+ public QueryInfoQueueElement(DirectoryMonitor owner, File file, File? other_file,
+ FileMonitorEvent event) {
+ this.owner = owner;
+ this.file = file;
+ this.other_file = other_file;
+ this.event = event;
+ this.position = current++;
+ this.time_created_msec = now_ms();
+ }
+
+ public void on_completed(Object? source, AsyncResult aresult) {
+ File source_file = (File) source;
+
+ // finish the async operation to get the result
+ try {
+ info = source_file.query_info_async.end(aresult);
+ } catch (Error err) {
+ this.err = err;
+ }
+
+ // mark as completed
+ completed = true;
+
+ // notify owner this job is finished, to process the queue
+ owner.process_query_queue(this);
+ }
+ }
+
+ // The FileInfoMap solves several related problems while maintaining FileInfo's in memory
+ // so they're available to users of DirectoryMonitor as well as DirectoryMonitor itself,
+ // which uses them to detect certain conditions. FileInfoMap uses a File ID to maintain
+ // only unique references to each File (and thus can be used to detect symlinked files).
+ private class FileInfoMap {
+ private Gee.HashMap<File, FileInfo> map = new Gee.HashMap<File, FileInfo>(file_hash,
+ file_equal);
+ private Gee.HashMap<string, File> id_map = new Gee.HashMap<string, File>(null, null,
+ file_equal);
+
+ public FileInfoMap() {
+ }
+
+ protected bool normalize_file(File file, FileInfo? info, out File normalized, out string id) {
+ // if no info is supplied, see if file straight-up corresponds .. if not, we're out of
+ // luck
+ FileInfo? local_info = info;
+ if (local_info == null) {
+ local_info = map.get(file);
+ if (local_info == null) {
+ normalized = null;
+ id = null;
+
+ return false;
+ }
+ }
+
+ string? file_id = get_file_info_id(local_info);
+ if (file_id == null) {
+ normalized = null;
+ id = null;
+
+ return false;
+ }
+
+ File? known_file = id_map.get(file_id);
+
+ id = (string) file_id;
+ normalized = (known_file != null) ? known_file : file;
+
+ return true;
+ }
+
+ public bool update(File file, FileInfo info) {
+ // if the file out-and-out exists, remove it now
+ if (map.has_key(file)) {
+ bool removed = map.unset(file);
+ assert(removed);
+ }
+
+ // if the id exists, remove it too
+ string? existing_id = get_file_info_id(info);
+ if (existing_id != null && id_map.has_key(existing_id)) {
+ bool removed = id_map.unset(existing_id);
+ assert(removed);
+ }
+
+ string id;
+ File normalized;
+ if (!normalize_file(file, info, out normalized, out id))
+ return false;
+
+ map.set(normalized, info);
+ id_map.set(id, normalized);
+
+ return true;
+ }
+
+ public bool remove(File file, FileInfo? info) {
+ string id;
+ File normalized;
+ if (!normalize_file(file, info, out normalized, out id))
+ return false;
+
+ map.unset(normalized);
+ id_map.unset(id);
+
+ return true;
+ }
+
+ // This calls the virtual function remove() for all files, so overriding it is sufficient
+ // (but not necessarily most efficient)
+ public void remove_all(Gee.Collection<File> files) {
+ foreach (File file in files)
+ remove(file, null);
+ }
+
+ public bool contains(File file, FileInfo? info) {
+ string id;
+ File normalized;
+ if (!normalize_file(file, info, out normalized, out id))
+ return false;
+
+ return id_map.has_key(id);
+ }
+
+ public string? get_id(File file, FileInfo? info) {
+ // if FileInfo is valid, easy pickings
+ if (info != null)
+ return get_file_info_id(info);
+
+ string id;
+ File normalized;
+ if (!normalize_file(file, null, out normalized, out id))
+ return null;
+
+ return id;
+ }
+
+ public Gee.Collection<File> get_all() {
+ return map.keys;
+ }
+
+ public FileInfo? get_info(File file) {
+ // if file is known as-is, use that
+ FileInfo? info = map.get(file);
+ if (info != null)
+ return info;
+
+ string id;
+ File normalized;
+ if (!normalize_file(file, null, out normalized, out id))
+ return null;
+
+ return map.get(normalized);
+ }
+
+ public FileInfo? query_info(File file, Cancellable? cancellable) {
+ FileInfo? info = get_info(file);
+ if (info != null)
+ return info;
+
+ // This *only* retrieves the file ID, which is then used to obtain the in-memory file
+ // information.
+ try {
+ info = file.query_info(FileAttribute.ID_FILE, UNKNOWN_INFO_FLAGS, cancellable);
+ } catch (Error err) {
+ warning("Unable to query file ID of %s: %s", file.get_path(), err.message);
+
+ return null;
+ }
+
+ if (!is_file_symlink_supported(info))
+ return null;
+
+ string? id = info.get_attribute_string(FileAttribute.ID_FILE);
+ if (id == null)
+ return null;
+
+ File? normalized = id_map.get(id);
+ if (normalized == null)
+ return null;
+
+ return map.get(file);
+ }
+
+ public File? find_match(FileInfo match) {
+ string? match_id = get_file_info_id(match);
+ if (match_id == null)
+ return null;
+
+ // get all the interesting matchable items from the supplied FileInfo
+ int64 match_size = match.get_size();
+ TimeVal match_time = match.get_modification_time();
+
+ foreach (File file in map.keys) {
+ FileInfo info = map.get(file);
+
+ // file id match is instant match
+ if (get_file_info_id(info) == match_id)
+ return file;
+
+ // if file size *and* modification time match, stop
+ if (match_size != info.get_size())
+ continue;
+
+ TimeVal time = info.get_modification_time();
+
+ if (time.tv_sec != match_time.tv_sec)
+ continue;
+
+ return file;
+ }
+
+ return null;
+ }
+
+ public void remove_descendents(File root, FileInfoMap descendents) {
+ Gee.ArrayList<File> pruned = null;
+ foreach (File file in map.keys) {
+ File? parent = file.get_parent();
+ while (parent != null) {
+ if (parent.equal(root)) {
+ if (pruned == null)
+ pruned = new Gee.ArrayList<File>();
+
+ pruned.add(file);
+ descendents.update(file, map.get(file));
+
+ break;
+ }
+
+ parent = parent.get_parent();
+ }
+ }
+
+ if (pruned != null)
+ remove_all(pruned);
+ }
+
+ // This returns only the immediate descendents of the root sorted by type. Returns the
+ // total number of children located.
+ public int get_children(File root, Gee.Collection<File> files, Gee.Collection<File> dirs) {
+ int count = 0;
+ foreach (File file in map.keys) {
+ File? parent = file.get_parent();
+ if (parent == null || !parent.equal(root))
+ continue;
+
+ FType ftype = get_ftype(map.get(file));
+ switch (ftype) {
+ case FType.FILE:
+ files.add(file);
+ count++;
+ break;
+
+ case FType.DIRECTORY:
+ dirs.add(file);
+ count++;
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ }
+
+ return count;
+ }
+ }
+
+ private File root;
+ private bool recurse;
+ private bool monitoring;
+ private Gee.HashMap<string, FileMonitor> monitors = new Gee.HashMap<string, FileMonitor>();
+ private Gee.Queue<QueryInfoQueueElement> query_info_queue = new Gee.LinkedList<
+ QueryInfoQueueElement>();
+ private FileInfoMap files = new FileInfoMap();
+ private FileInfoMap parent_moved = new FileInfoMap();
+ private Cancellable cancellable = new Cancellable();
+ private int outstanding_exploration_dirs = 0;
+ private bool started = false;
+ private bool has_discovery_started = false;
+ private uint delete_timer_id = 0;
+
+ // This signal will be fired *after* directory-moved has been fired.
+ public virtual signal void root_moved(File old_root, File new_root, FileInfo new_root_info) {
+ }
+
+ // If the root is deleted, then the DirectoryMonitor is essentially dead; it has no monitor
+ // to wait for the root to be re-created, and everything beneath the root is obviously blown
+ // away as well.
+ //
+ // This signal will be fired *after* directory-deleted has been fired.
+ public virtual signal void root_deleted(File root) {
+ }
+
+ public virtual signal void discovery_started() {
+ }
+
+ public virtual signal void file_discovered(File file, FileInfo info) {
+ }
+
+ public virtual signal void directory_discovered(File file, FileInfo info) {
+ }
+
+ // reason is a user-visible string. May be called more than once during discovery.
+ // Discovery always completes with discovery-completed.
+ public virtual signal void discovery_failed(string reason) {
+ }
+
+ public virtual signal void discovery_completed() {
+ has_discovery_started = false;
+ mdbg("discovery completed");
+ }
+
+ public virtual signal void file_created(File file, FileInfo info) {
+ }
+
+ public virtual signal void file_moved(File old_file, File new_file, FileInfo new_file_info) {
+ }
+
+ // FileInfo is not updated for each file-altered signal.
+ public virtual signal void file_altered(File file) {
+ }
+
+ // This is called when the monitor detects that alteration (attributes or otherwise) to the
+ // file has completed.
+ public virtual signal void file_alteration_completed(File file, FileInfo info) {
+ }
+
+ public virtual signal void file_attributes_altered(File file) {
+ }
+
+ public virtual signal void file_deleted(File file) {
+ }
+
+ // This implies that the directory is now being monitored.
+ public virtual signal void directory_created(File dir, FileInfo info) {
+ }
+
+ // This implies that the old directory is no longer being monitored and the new one is.
+ public virtual signal void directory_moved(File old_dir, File new_dir, FileInfo new_dir_info) {
+ }
+
+ // FileInfo is not updated for each directory-altered signal.
+ public virtual signal void directory_altered(File dir) {
+ }
+
+ // This is called when the monitor detects that alteration (attributes or otherwise) to the
+ // directory has completed.
+ public virtual signal void directory_alteration_completed(File dir, FileInfo info) {
+ }
+
+ public virtual signal void directory_attributes_altered(File dir) {
+ }
+
+ // This implies that the directory is now no longer be monitored (unsurprisingly).
+ public virtual signal void directory_deleted(File dir) {
+ }
+
+ public virtual signal void closed() {
+ }
+
+ public DirectoryMonitor(File root, bool recurse, bool monitoring) {
+ this.root = root;
+ this.recurse = recurse;
+ this.monitoring = monitoring;
+ }
+
+ protected static void mdbg(string msg) {
+#if TRACE_MONITORING
+ debug("%s", msg);
+#endif
+ }
+
+ public bool is_recursive() {
+ return recurse;
+ }
+
+ public bool is_monitoring() {
+ return monitoring;
+ }
+
+ protected virtual void notify_root_deleted(File root) {
+ assert(this.root.equal(root));
+
+ mdbg("root deleted");
+ root_deleted(root);
+ }
+
+ private void internal_notify_root_moved(File old_root, File new_root, FileInfo new_root_info) {
+ bool removed = files.remove(old_root, null);
+ assert(removed);
+
+ bool updated = files.update(new_root, new_root_info);
+ assert(updated);
+
+ root = new_root;
+
+ notify_root_moved(old_root, new_root, new_root_info);
+ }
+
+ protected virtual void notify_root_moved(File old_root, File new_root, FileInfo new_root_info) {
+ assert(this.root.equal(old_root));
+
+ mdbg("root moved: %s -> %s".printf(old_root.get_path(), new_root.get_path()));
+ root_moved(old_root, new_root, new_root_info);
+ }
+
+ protected virtual void notify_discovery_started() {
+ mdbg("discovery started");
+ discovery_started();
+ }
+
+ protected virtual void internal_notify_file_discovered(File file, FileInfo info) {
+ if (!files.update(file, info)) {
+ debug("DirectoryMonitor.internal_notify_file_discovered: %s discovered but not added to file map",
+ file.get_path());
+
+ return;
+ }
+
+ notify_file_discovered(file, info);
+ }
+
+ protected virtual void notify_file_discovered(File file, FileInfo info) {
+ mdbg("file discovered: %s".printf(file.get_path()));
+ file_discovered(file, info);
+ }
+
+ protected virtual void internal_notify_directory_discovered(File dir, FileInfo info) {
+ bool updated = files.update(dir, info);
+ assert(updated);
+
+ notify_directory_discovered(dir, info);
+ }
+
+ protected virtual void notify_directory_discovered(File dir, FileInfo info) {
+ mdbg("directory discovered: %s".printf(dir.get_path()));
+ directory_discovered(dir, info);
+ }
+
+ protected virtual void notify_discovery_failed(string reason) {
+ warning("discovery failed: %s", reason);
+ discovery_failed(reason);
+ }
+
+ protected virtual void notify_discovery_completed() {
+ discovery_completed();
+ }
+
+ private void internal_notify_file_created(File file, FileInfo info) {
+ File old_file;
+ FileInfo old_file_info;
+ if (is_file_create_move(file, info, out old_file, out old_file_info)) {
+ internal_notify_file_moved(old_file, file, info);
+ } else {
+ bool updated = files.update(file, info);
+ assert(updated);
+
+ notify_file_created(file, info);
+ }
+ }
+
+ protected virtual void notify_file_created(File file, FileInfo info) {
+ mdbg("file created: %s".printf(file.get_path()));
+ file_created(file, info);
+ }
+
+ private void internal_notify_file_moved(File old_file, File new_file, FileInfo new_file_info) {
+ // don't assert because it's possible this call was generated via a deleted-created
+ // sequence, in which case the old_file won't be in files
+ files.remove(old_file, null);
+
+ bool updated = files.update(new_file, new_file_info);
+ assert(updated);
+
+ notify_file_moved(old_file, new_file, new_file_info);
+ }
+
+ protected virtual void notify_file_moved(File old_file, File new_file, FileInfo new_file_info) {
+ mdbg("file moved: %s -> %s".printf(old_file.get_path(), new_file.get_path()));
+ file_moved(old_file, new_file, new_file_info);
+ }
+
+ protected virtual void notify_file_altered(File file) {
+ mdbg("file altered: %s".printf(file.get_path()));
+ file_altered(file);
+ }
+
+ private void internal_notify_file_alteration_completed(File file, FileInfo info) {
+ bool updated = files.update(file, info);
+ assert(updated);
+
+ notify_file_alteration_completed(file, info);
+ }
+
+ protected virtual void notify_file_alteration_completed(File file, FileInfo info) {
+ mdbg("file alteration completed: %s".printf(file.get_path()));
+ file_alteration_completed(file, info);
+ }
+
+ protected virtual void notify_file_attributes_altered(File file) {
+ mdbg("file attributes altered: %s".printf(file.get_path()));
+ file_attributes_altered(file);
+ }
+
+ private void internal_notify_file_deleted(File file) {
+ bool removed = files.remove(file, null);
+ assert(removed);
+
+ notify_file_deleted(file);
+ }
+
+ protected virtual void notify_file_deleted(File file) {
+ mdbg("file deleted: %s".printf(file.get_path()));
+ file_deleted(file);
+ }
+
+ private void internal_notify_directory_created(File dir, FileInfo info) {
+ File old_dir;
+ FileInfo old_dir_info;
+ if (is_file_create_move(dir, info, out old_dir, out old_dir_info)) {
+ // A directory move, like a file move, is actually a directory-deleted followed
+ // by a directory-created. Unlike a file move, what follows directory-created
+ // is a file/directory-created for each file and directory inside the folder
+ // (although the matching deletes are never fired). We want to issue moves for
+ // all those files as well and suppress the create calls.
+ files.remove_descendents(old_dir, parent_moved);
+
+ internal_notify_directory_moved(old_dir, old_dir_info, dir, info);
+ } else {
+ bool updated = files.update(dir, info);
+ assert(updated);
+
+ notify_directory_created(dir, info);
+ }
+ }
+
+ protected virtual void notify_directory_created(File dir, FileInfo info) {
+ mdbg("directory created: %s".printf(dir.get_path()));
+ directory_created(dir, info);
+ }
+
+ private void internal_notify_directory_moved(File old_dir, FileInfo old_dir_info, File new_dir,
+ FileInfo new_dir_info) {
+ async_internal_notify_directory_moved.begin(old_dir, old_dir_info, new_dir, new_dir_info);
+ }
+
+ private async void async_internal_notify_directory_moved(File old_dir, FileInfo old_dir_info,
+ File new_dir, FileInfo new_dir_info) {
+ Gee.ArrayList<File> file_children = new Gee.ArrayList<File>(file_equal);
+ Gee.ArrayList<File> dir_children = new Gee.ArrayList<File>(file_equal);
+ int count = files.get_children(old_dir, file_children, dir_children);
+ if (count > 0) {
+ // descend into directories and let them notify their children on the way up
+ // (if files.get_info() returns null, that indicates the directory/file was already
+ // deleted in recurse)
+ foreach (File dir_child in dir_children) {
+ FileInfo? dir_info = files.get_info(dir_child);
+ if (dir_info == null) {
+ warning("Unable to retrieve directory-moved info for %s", dir_child.get_path());
+
+ continue;
+ }
+
+ yield async_internal_notify_directory_moved(dir_child, dir_info,
+ new_dir.get_child(dir_child.get_basename()), dir_info);
+ }
+
+ // then move the children
+ foreach (File file_child in file_children) {
+ FileInfo? file_info = files.get_info(file_child);
+ if (file_info == null) {
+ warning("Unable to retrieve directory-moved info for %s", file_child.get_path());
+
+ continue;
+ }
+
+ internal_notify_file_moved(file_child, new_dir.get_child(file_child.get_basename()),
+ file_info);
+
+ Idle.add(async_internal_notify_directory_moved.callback, DEFAULT_PRIORITY);
+ yield;
+ }
+ }
+
+ // Don't assert here because it's possible this call was made due to a deleted-created
+ // sequence, in which case the directory has already been removed from files
+ files.remove(old_dir, null);
+
+ bool updated = files.update(new_dir, new_dir_info);
+ assert(updated);
+
+ // remove the old monitor and add the new one
+ remove_monitor(old_dir, old_dir_info);
+ add_monitor(new_dir, new_dir_info);
+
+ notify_directory_moved(old_dir, new_dir, new_dir_info);
+ }
+
+ protected virtual void notify_directory_moved(File old_dir, File new_dir, FileInfo new_dir_info) {
+ mdbg("directory moved: %s -> %s".printf(old_dir.get_path(), new_dir.get_path()));
+ directory_moved(old_dir, new_dir, new_dir_info);
+
+ if (old_dir.equal(root))
+ internal_notify_root_moved(old_dir, new_dir, new_dir_info);
+ }
+
+ protected virtual void notify_directory_altered(File dir) {
+ mdbg("directory altered: %s".printf(dir.get_path()));
+ directory_altered(dir);
+ }
+
+ private void internal_notify_directory_alteration_completed(File dir, FileInfo info) {
+ bool updated = files.update(dir, info);
+ assert(updated);
+
+ notify_directory_alteration_completed(dir, info);
+ }
+
+ protected virtual void notify_directory_alteration_completed(File dir, FileInfo info) {
+ mdbg("directory alteration completed: %s".printf(dir.get_path()));
+ directory_alteration_completed(dir, info);
+ }
+
+ protected virtual void notify_directory_attributes_altered(File dir) {
+ mdbg("directory attributes altered: %s".printf(dir.get_path()));
+ directory_attributes_altered(dir);
+ }
+
+ private void internal_notify_directory_deleted(File dir) {
+ FileInfo? info = files.get_info(dir);
+ assert(info != null);
+
+ // stop monitoring this directory
+ remove_monitor(dir, info);
+
+ async_notify_directory_deleted.begin(dir, false);
+ }
+
+ private async void async_notify_directory_deleted(File dir, bool already_removed) {
+ // Note that in this function no assertion checking is done ... there are many
+ // reasons a deleted file may not be known to the internal bookkeeping; if
+ // the file is gone and we didn't know about it, then it's no problem.
+
+ // because a directory can be deleted without its children being deleted first (probably
+ // means it has been moved to a location outside of the monitored root), need to
+ // synthesize notifications for all its children
+ Gee.ArrayList<File> file_children = new Gee.ArrayList<File>(file_equal);
+ Gee.ArrayList<File> dir_children = new Gee.ArrayList<File>(file_equal);
+ int count = files.get_children(dir, file_children, dir_children);
+ if (count > 0) {
+ // don't use internal_* variants as they deal with "real" and not synthesized
+ // notifications. also note that files.get_info() can return null because items are
+ // being deleted on the way back up the tree ... when files.get_info() returns null,
+ // it means the file/directory was already deleted in a recursed method, and so no
+ // assertions on them
+
+ // descend first into directories, deleting files and directories on the way up
+ foreach (File dir_child in dir_children)
+ yield async_notify_directory_deleted(dir_child, false);
+
+ // now notify deletions on all immediate children files ... don't notify directory
+ // deletion because that's handled right before exiting this method
+ foreach (File file_child in file_children) {
+ files.remove(file_child, null);
+
+ notify_file_deleted(file_child);
+
+ Idle.add(async_notify_directory_deleted.callback, DEFAULT_PRIORITY);
+ yield;
+ }
+ }
+
+ if (!already_removed)
+ files.remove(dir, null);
+
+ notify_directory_deleted(dir);
+ }
+
+ protected virtual void notify_directory_deleted(File dir) {
+ mdbg("directory deleted: %s".printf(dir.get_path()));
+ directory_deleted(dir);
+
+ if (dir.equal(root))
+ notify_root_deleted(dir);
+ }
+
+ protected virtual void notify_closed() {
+ mdbg("monitoring of %s closed".printf(root.get_path()));
+ closed();
+ }
+
+ public File get_root() {
+ return root;
+ }
+
+ public bool is_in_root(File file) {
+ return file.has_prefix(root);
+ }
+
+ public bool has_started() {
+ return started;
+ }
+
+ public void start_discovery() {
+ assert(!started);
+
+ has_discovery_started = true;
+ started = true;
+
+ notify_discovery_started();
+
+ // start exploring the directory, adding monitors as the directories are discovered
+ outstanding_exploration_dirs = 1;
+ explore_async.begin(root, null, true);
+ }
+
+ // This should be called when a DirectoryMonitor needs to be destroyed or released. This
+ // will halt background exploration and close all resources.
+ public virtual void close() {
+ // cancel any outstanding async I/O
+ cancellable.cancel();
+
+ // cancel all monitors
+ foreach (FileMonitor monitor in monitors.values)
+ cancel_monitor(monitor);
+
+ monitors.clear();
+
+ notify_closed();
+ }
+
+ private static FType get_ftype(FileInfo info) {
+ FileType file_type = info.get_file_type();
+ switch (file_type) {
+ case FileType.REGULAR:
+ return FType.FILE;
+
+ case FileType.DIRECTORY:
+ return FType.DIRECTORY;
+
+ default:
+ mdbg("query_ftype: Unknown file type %s".printf(file_type.to_string()));
+ return FType.UNSUPPORTED;
+ }
+ }
+
+ private async void explore_async(File dir, FileInfo? dir_info, bool in_discovery) {
+ if (files.contains(dir, dir_info)) {
+ warning("Directory loop detected at %s, not exploring", dir.get_path());
+
+ explore_directory_completed(in_discovery);
+
+ return;
+ }
+
+ // if FileInfo wasn't supplied by caller, fetch it now
+ FileInfo? local_dir_info = dir_info;
+ if (local_dir_info == null) {
+ try {
+ local_dir_info = yield dir.query_info_async(SUPPLIED_ATTRIBUTES, DIR_INFO_FLAGS,
+ DEFAULT_PRIORITY, cancellable);
+ } catch (Error err) {
+ warning("Unable to retrieve info on %s: %s", dir.get_path(), err.message);
+
+ explore_directory_completed(in_discovery);
+
+ return;
+ }
+ }
+
+ if (local_dir_info.get_is_hidden()) {
+ warning("Ignoring hidden directory %s", dir.get_path());
+
+ explore_directory_completed(in_discovery);
+
+ return;
+ }
+
+ // File ID is required for directory monitoring. No ID, no ride!
+ // TODO: Replace the warning with notify_discovery_failed() and provide a user-visible
+ // string.
+ if (get_file_info_id(local_dir_info) == null) {
+ warning("Unable to retrieve file ID on %s: skipping", dir.get_path());
+
+ explore_directory_completed(in_discovery);
+
+ return;
+ }
+
+ // verify this is a directory
+ if (local_dir_info.get_file_type() != FileType.DIRECTORY) {
+ notify_discovery_failed(_("Unable to monitor %s: Not a directory (%s)").printf(
+ dir.get_path(), local_dir_info.get_file_type().to_string()));
+
+ explore_directory_completed(in_discovery);
+
+ return;
+ }
+
+ // collect all directories and files in the directory, to consolidate reporting them as
+ // well as traversing the subdirectories -- but to avoid a lot of unnecessary resource
+ // allocations (think empty directories, or leaf nodes with only files), only allocate
+ // the maps when necessary
+ Gee.HashMap<File, FileInfo> dir_map = null;
+ Gee.HashMap<File, FileInfo> file_map = null;
+
+ try {
+ FileEnumerator enumerator = yield dir.enumerate_children_async(SUPPLIED_ATTRIBUTES,
+ UNKNOWN_INFO_FLAGS, DEFAULT_PRIORITY, cancellable);
+ for (;;) {
+ List<FileInfo>? infos = yield enumerator.next_files_async(10, DEFAULT_PRIORITY,
+ cancellable);
+ if (infos == null)
+ break;
+
+ foreach (FileInfo info in infos) {
+ // we don't deal with hidden files or directories
+ if (info.get_is_hidden()) {
+ warning("Skipping hidden file/directory %s",
+ dir.get_child(info.get_name()).get_path());
+
+ continue;
+ }
+
+ // check for symlink support
+ if (!is_file_symlink_supported(info))
+ continue;
+
+ switch (info.get_file_type()) {
+ case FileType.REGULAR:
+ if (file_map == null)
+ file_map = new Gee.HashMap<File, FileInfo>(file_hash, file_equal);
+
+ file_map.set(dir.get_child(info.get_name()), info);
+ break;
+
+ case FileType.DIRECTORY:
+ if (dir_map == null)
+ dir_map = new Gee.HashMap<File, FileInfo>(file_hash, file_equal);
+
+ dir_map.set(dir.get_child(info.get_name()), info);
+ break;
+
+ default:
+ // ignored
+ break;
+ }
+ }
+ }
+ } catch (Error err2) {
+ warning("Aborted directory traversal of %s: %s", dir.get_path(), err2.message);
+
+ explore_directory_completed(in_discovery);
+
+ return;
+ }
+
+ // report the local (caller-supplied) directory as discovered *before* reporting its files
+ if (in_discovery)
+ internal_notify_directory_discovered(dir, local_dir_info);
+ else
+ internal_notify_directory_created(dir, local_dir_info);
+
+ // now with everything snarfed up and the directory reported as discovered, begin
+ // monitoring the directory
+ add_monitor(dir, local_dir_info);
+
+ // report files in local directory
+ if (file_map != null)
+ yield notify_directory_files(file_map, in_discovery);
+
+ // post all the subdirectory traversals, allowing them to report themselves as discovered
+ if (recurse && dir_map != null) {
+ foreach (File subdir in dir_map.keys) {
+ if (++outstanding_exploration_dirs > MAX_EXPLORATION_DIRS)
+ yield explore_async(subdir, dir_map.get(subdir), in_discovery);
+ else
+ explore_async.begin(subdir, dir_map.get(subdir), in_discovery);
+ }
+ }
+
+ explore_directory_completed(in_discovery);
+ }
+
+ private async void notify_directory_files(Gee.Map<File, FileInfo> map, bool in_discovery) {
+ Gee.MapIterator<File, FileInfo> iter = map.map_iterator();
+ while (iter.next()) {
+ if (in_discovery)
+ internal_notify_file_discovered(iter.get_key(), iter.get_value());
+ else
+ internal_notify_file_created(iter.get_key(), iter.get_value());
+
+ Idle.add(notify_directory_files.callback, DEFAULT_PRIORITY);
+ yield;
+ }
+ }
+
+ // called whenever exploration of a directory is completed, to know when to signal that
+ // discovery has ended
+ private void explore_directory_completed(bool in_discovery) {
+ assert(outstanding_exploration_dirs > 0);
+ outstanding_exploration_dirs--;
+
+ if (in_discovery && outstanding_exploration_dirs == 0)
+ notify_discovery_completed();
+ }
+
+ // Only submit directories ... file monitoring is wasteful when a single directory monitor can
+ // do all the work. Returns true if monitor added, false if already monitored (or not
+ // monitoring, or unable to monitor due to error).
+ private bool add_monitor(File dir, FileInfo info) {
+ if (!monitoring)
+ return false;
+
+ string? id = files.get_id(dir, info);
+ if (id == null)
+ return false;
+
+ // if one already exists, nop
+ if (monitors.has_key(id))
+ return false;
+
+ FileMonitor monitor = null;
+ try {
+ monitor = dir.monitor_directory(FILE_MONITOR_FLAGS, null);
+ } catch (Error err) {
+ warning("Unable to monitor %s: %s", dir.get_path(), err.message);
+
+ return false;
+ }
+
+ monitors.set(id, monitor);
+ monitor.changed.connect(on_monitor_notification);
+
+ mdbg("Added monitor for %s".printf(dir.get_path()));
+
+ return true;
+ }
+
+ // Returns true if the directory is removed (i.e. was being monitored).
+ private bool remove_monitor(File dir, FileInfo info) {
+ if (!monitoring)
+ return false;
+
+ string? id = files.get_id(dir, info);
+ if (id == null)
+ return false;
+
+ FileMonitor? monitor = monitors.get(id);
+ if (monitor == null)
+ return false;
+
+ bool removed = monitors.unset(id);
+ assert(removed);
+
+ cancel_monitor(monitor);
+
+ mdbg("Removed monitor for %s".printf(dir.get_path()));
+
+ return true;
+ }
+
+ private void cancel_monitor(FileMonitor monitor) {
+ monitor.changed.disconnect(on_monitor_notification);
+ monitor.cancel();
+ }
+
+ private void on_monitor_notification(File file, File? other_file, FileMonitorEvent event) {
+ mdbg("NOTIFY %s: file=%s other_file=%s".printf(event.to_string(), file.get_path(),
+ other_file != null ? other_file.get_path() : "(none)"));
+
+ // The problem: Having basic file information about each file is valuable (and necessary
+ // in certain situations), but it is a blocking operation, no matter how "quick" it
+ // may seem. Async I/O is perfect to handle this, but it can complete out of order, and
+ // it's highly desirous to report events in the same order they're received. FileInfo
+ // queries are queued up then and processed in order as they're completed.
+
+ // Every event needs to be queued, but not all events generates query I/O
+ QueryInfoQueueElement query_info = new QueryInfoQueueElement(this, file, other_file, event);
+ query_info_queue.offer(query_info);
+
+ switch (event) {
+ case FileMonitorEvent.CREATED:
+ case FileMonitorEvent.CHANGES_DONE_HINT:
+ file.query_info_async.begin(SUPPLIED_ATTRIBUTES, UNKNOWN_INFO_FLAGS,
+ DEFAULT_PRIORITY, cancellable, query_info.on_completed);
+ break;
+
+ case FileMonitorEvent.DELETED:
+ // don't complete it yet, it might be followed by a CREATED event indicating a
+ // move ... instead, let it sit on the queue and allow the timer (or a coming
+ // CREATED event) complete it
+ if (delete_timer_id == 0)
+ delete_timer_id = Timeout.add(DELETED_EXPIRATION_MSEC / 2, check_for_expired_delete_events);
+ break;
+
+ case FileMonitorEvent.MOVED:
+ // unlike the others, other_file is the destination of the move, and therefore the
+ // one we need to get info on
+ if (other_file != null) {
+ other_file.query_info_async.begin(SUPPLIED_ATTRIBUTES, UNKNOWN_INFO_FLAGS,
+ DEFAULT_PRIORITY, cancellable, query_info.on_completed);
+ } else {
+ warning("Unable to process MOVED event: no other_file");
+ query_info_queue.remove(query_info);
+ }
+ break;
+
+ default:
+ // artificially complete it
+ query_info.completed = true;
+ process_query_queue(query_info);
+ break;
+ }
+ }
+
+ private void process_query_queue(QueryInfoQueueElement? query_info) {
+ // if the completed element was a CREATE event, attempt to match it to a DELETE (which
+ // then converts into a MOVED) and remove the CREATE event
+ if (query_info != null && query_info.info != null && query_info.event == FileMonitorEvent.CREATED) {
+ // if there's no match in the files table for this created file, then it can't be
+ // matched to a previously deleted file
+ File? match = files.find_match(query_info.info);
+ if (match != null) {
+ bool matched = false;
+ foreach (QueryInfoQueueElement enqueued in query_info_queue) {
+ if (enqueued.event != FileMonitorEvent.DELETED
+ || enqueued.completed
+ || !match.equal(enqueued.file)) {
+ continue;
+ }
+
+ mdbg("Matching CREATED %s to DELETED %s for MOVED".printf(query_info.file.get_path(),
+ enqueued.file.get_path()));
+
+ enqueued.event = FileMonitorEvent.MOVED;
+ enqueued.other_file = query_info.file;
+ enqueued.info = query_info.info;
+ enqueued.completed = true;
+
+ matched = true;
+
+ break;
+ }
+
+ if (matched)
+ query_info_queue.remove(query_info);
+ }
+ }
+
+ // peel off completed events from the queue in order
+ for (;;) {
+ // check if empty or waiting for completion on the next event
+ QueryInfoQueueElement? next = query_info_queue.peek();
+ if (next == null || !next.completed)
+ break;
+
+ // remove
+ QueryInfoQueueElement? n = query_info_queue.poll();
+ assert(next == n);
+
+ mdbg("Completed info query %u for %s on %s".printf(next.position, next.event.to_string(),
+ next.file.get_path()));
+
+ if (next.err != null) {
+ mdbg("Unable to retrieve file information for %s, dropping %s: %s".printf(
+ next.file.get_path(), next.event.to_string(), next.err.message));
+
+ continue;
+ }
+
+ // Directory monitoring requires file ID. No ID, no ride!
+ if (next.info != null && get_file_info_id(next.info) == null) {
+ mdbg("Unable to retrieve file ID for %s, dropping %s".printf(next.file.get_path(),
+ next.event.to_string()));
+
+ continue;
+ }
+
+ // watch for symlink support
+ if (next.info != null && !is_file_symlink_supported(next.info)) {
+ mdbg("No symlink support for %s, dropping %s".printf(next.file.get_path(),
+ next.event.to_string()));
+
+ continue;
+ }
+
+ on_monitor_notification_ready(next.file, next.other_file, next.info, next.event);
+ }
+ }
+
+ private void on_monitor_notification_ready(File file, File? other_file, FileInfo? info,
+ FileMonitorEvent event) {
+ mdbg("READY %s: file=%s other_file=%s".printf(event.to_string(), file.get_path(),
+ other_file != null ? other_file.get_path() : "(null)"));
+
+ // Nasty, nasty switches-in-a-switch construct, but this demuxes the possibilities into
+ // easily digestible upcalls and signals
+ switch (event) {
+ case FileMonitorEvent.CREATED:
+ assert(info != null);
+
+ FType ftype = get_ftype(info);
+ switch (ftype) {
+ case FType.FILE:
+ internal_notify_file_created(file, info);
+ break;
+
+ case FType.DIRECTORY:
+ // other files may have been created under this new directory before we have
+ // a chance to register a monitor, so scan it now looking for new additions
+ // (this call will notify of creation and monitor this new directory once
+ // it's been scanned)
+ outstanding_exploration_dirs++;
+ explore_async.begin(file, info, false);
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ break;
+
+ case FileMonitorEvent.CHANGED:
+ // don't query info for each change, but only when done hint comes down the pipe
+ assert(info == null);
+
+ FileInfo local_info = get_file_info(file);
+ if (local_info == null) {
+ mdbg("Changed event for unknown file %s".printf(file.get_path()));
+
+ break;
+ }
+
+ FType ftype = get_ftype(local_info);
+ switch (ftype) {
+ case FType.FILE:
+ notify_file_altered(file);
+ break;
+
+ case FType.DIRECTORY:
+ notify_directory_altered(file);
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ break;
+
+ case FileMonitorEvent.CHANGES_DONE_HINT:
+ assert(info != null);
+
+ FType ftype = get_ftype(info);
+ switch (ftype) {
+ case FType.FILE:
+ internal_notify_file_alteration_completed(file, info);
+ break;
+
+ case FType.DIRECTORY:
+ internal_notify_directory_alteration_completed(file, info);
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ break;
+
+ case FileMonitorEvent.MOVED:
+ assert(info != null);
+ assert(other_file != null);
+
+ // in the moved case, file info is for other file (the destination in the move
+ // operation)
+ FType ftype = get_ftype(info);
+ switch (ftype) {
+ case FType.FILE:
+ internal_notify_file_moved(file, other_file, info);
+ break;
+
+ case FType.DIRECTORY:
+ // get the old FileInfo (contained in files)
+ FileInfo? old_dir_info = files.get_info(file);
+ if (old_dir_info == null) {
+ warning("Directory moved event for unknown file %s", file.get_path());
+
+ break;
+ }
+
+ internal_notify_directory_moved(file, old_dir_info, other_file, info);
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ break;
+
+ case FileMonitorEvent.DELETED:
+ assert(info == null);
+
+ FileInfo local_info = get_file_info(file);
+ if (local_info == null) {
+ warning("Deleted event for unknown file %s", file.get_path());
+
+ break;
+ }
+
+ FType ftype = get_ftype(local_info);
+ switch (ftype) {
+ case FType.FILE:
+ internal_notify_file_deleted(file);
+ break;
+
+ case FType.DIRECTORY:
+ internal_notify_directory_deleted(file);
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ break;
+
+ case FileMonitorEvent.ATTRIBUTE_CHANGED:
+ // doesn't fetch attributes until CHANGES_DONE_HINT comes down the pipe
+ assert(info == null);
+
+ FileInfo local_info = get_file_info(file);
+ if (local_info == null) {
+ warning("Attribute changed event for unknown file %s", file.get_path());
+
+ break;
+ }
+
+ FType ftype = get_ftype(local_info);
+ switch (ftype) {
+ case FType.FILE:
+ notify_file_attributes_altered(file);
+ break;
+
+ case FType.DIRECTORY:
+ notify_directory_attributes_altered(file);
+ break;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+ break;
+ }
+ break;
+
+ case FileMonitorEvent.PRE_UNMOUNT:
+ case FileMonitorEvent.UNMOUNTED:
+ // not currently handling these events
+ break;
+
+ default:
+ warning("Unknown directory monitor event %s", event.to_string());
+ break;
+ }
+ }
+
+ // Returns true if a move occurred. Internal state is modified to recognize the
+ // situation (i.e. the move should be reported).
+ private bool is_file_create_move(File file, FileInfo info, out File old_file,
+ out FileInfo old_file_info) {
+ // look for created file whose parent was actually moved
+ File? match = parent_moved.find_match(info);
+ if (match != null) {
+ old_file = match;
+ old_file_info = parent_moved.get_info(match);
+
+ parent_moved.remove(match, info);
+
+ return true;
+ }
+
+ old_file = null;
+ old_file_info = null;
+
+ return false;
+ }
+
+ private bool check_for_expired_delete_events() {
+ ulong expiration = now_ms() - DELETED_EXPIRATION_MSEC;
+
+ bool any_deleted = false;
+ bool any_expired = false;
+ foreach (QueryInfoQueueElement element in query_info_queue) {
+ if (element.event != FileMonitorEvent.DELETED)
+ continue;
+
+ any_deleted = true;
+
+ if (element.time_created_msec > expiration)
+ continue;
+
+ // synthesize the completion
+ element.completed = true;
+ any_expired = true;
+ }
+
+ if (any_expired)
+ process_query_queue(null);
+
+ if (!any_deleted)
+ delete_timer_id = 0;
+
+ return any_deleted;
+ }
+
+ // This method does its best to return FileInfo for the file. It performs no I/O.
+ public FileInfo? get_file_info(File file) {
+ return files.get_info(file);
+ }
+
+ // This method returns all files and directories that the DirectoryMonitor knows of. This
+ // call is only useful when runtime monitoring is enabled. It performs no I/O.
+ public Gee.Collection<File> get_files() {
+ return files.get_all();
+ }
+
+ // This method will attempt to find the in-memory FileInfo for the file, but if it cannot
+ // be found it will query the file for it's ID and obtain in-memory file information from
+ // there.
+ public FileInfo? query_file_info(File file) {
+ return files.query_info(file, cancellable);
+ }
+
+ // This checks if the FileInfo is for a symlinked file/directory and if symlinks for the file
+ // type are supported by DirectoryMonitor. Note that this requires the FileInfo have support
+ // for the "standard::is-symlink" and "standard::type" file attributes, which SUPPLIED_ATTRIBUTES
+ // provides.
+ //
+ // Returns true if the file is not a symlink or if symlinks are supported for the file type,
+ // false otherwise. If an unsupported file type, returns false.
+ public static bool is_file_symlink_supported(FileInfo info) {
+ if (!info.get_is_symlink())
+ return true;
+
+ FType ftype = get_ftype(info);
+ switch (ftype) {
+ case FType.DIRECTORY:
+ return SUPPORT_DIR_SYMLINKS;
+
+ case FType.FILE:
+ return SUPPORT_FILE_SYMLINKS;
+
+ default:
+ assert(ftype == FType.UNSUPPORTED);
+
+ return false;
+ }
+ }
+}
+
diff --git a/src/Event.vala b/src/Event.vala
new file mode 100644
index 0000000..ed0af76
--- /dev/null
+++ b/src/Event.vala
@@ -0,0 +1,924 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class EventSourceCollection : ContainerSourceCollection {
+ public signal void no_event_collection_altered();
+
+ private ViewCollection no_event;
+
+ private class NoEventViewManager : ViewManager {
+ public override bool include_in_view(DataSource source) {
+ // Note: this is not threadsafe
+ return (((MediaSource) source).get_event_id().id != EventID.INVALID) ? false :
+ base.include_in_view(source);
+ }
+
+ public override DataView create_view(DataSource source) {
+ return new ThumbnailView((MediaSource) source);
+ }
+ }
+
+ public EventSourceCollection() {
+ base(Event.TYPENAME, "EventSourceCollection", get_event_key);
+
+ attach_collection(LibraryPhoto.global);
+ attach_collection(Video.global);
+ }
+
+ public void init() {
+ no_event = new ViewCollection("No Event View Collection");
+
+ NoEventViewManager view_manager = new NoEventViewManager();
+ Alteration filter_alteration = new Alteration("metadata", "event");
+
+ no_event.monitor_source_collection(LibraryPhoto.global, view_manager, filter_alteration);
+ no_event.monitor_source_collection(Video.global, view_manager, filter_alteration);
+
+ no_event.contents_altered.connect(on_no_event_collection_altered);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is Event;
+ }
+
+ private static int64 get_event_key(DataSource source) {
+ Event event = (Event) source;
+ EventID event_id = event.get_event_id();
+
+ return event_id.id;
+ }
+
+ public Event? fetch(EventID event_id) {
+ return (Event) fetch_by_key(event_id.id);
+ }
+
+ protected override Gee.Collection<ContainerSource>? get_containers_holding_source(DataSource source) {
+ Event? event = ((MediaSource) source).get_event();
+ if (event == null)
+ return null;
+
+ Gee.ArrayList<ContainerSource> list = new Gee.ArrayList<ContainerSource>();
+ list.add(event);
+
+ return list;
+ }
+
+ protected override ContainerSource? convert_backlink_to_container(SourceBacklink backlink) {
+ EventID event_id = EventID(backlink.instance_id);
+
+ Event? event = fetch(event_id);
+ if (event != null)
+ return event;
+
+ foreach (ContainerSource container in get_holding_tank()) {
+ if (((Event) container).get_event_id().id == event_id.id)
+ return container;
+ }
+
+ return null;
+ }
+
+ public Gee.Collection<DataObject> get_no_event_objects() {
+ return no_event.get_sources();
+ }
+
+ private void on_no_event_collection_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ no_event_collection_altered();
+ }
+}
+
+public class Event : EventSource, ContainerSource, Proxyable, Indexable {
+ public const string TYPENAME = "event";
+
+ // SHOW_COMMENTS (bool)
+ public const string PROP_SHOW_COMMENTS = "show-comments";
+
+ // In 24-hour time.
+ public const int EVENT_BOUNDARY_HOUR = 4;
+
+ private const time_t TIME_T_DAY = 24 * 60 * 60;
+
+ private class EventSnapshot : SourceSnapshot {
+ private EventRow row;
+ private MediaSource primary_source;
+ private Gee.ArrayList<MediaSource> attached_sources = new Gee.ArrayList<MediaSource>();
+
+ public EventSnapshot(Event event) {
+ // save current state of event
+ row = EventTable.get_instance().get_row(event.get_event_id());
+ primary_source = event.get_primary_source();
+
+ // stash all the media sources in the event ... these are not used when reconstituting
+ // the event, but need to know when they're destroyed, as that means the event cannot
+ // be restored
+ foreach (MediaSource source in event.get_media())
+ attached_sources.add(source);
+
+ LibraryPhoto.global.item_destroyed.connect(on_attached_source_destroyed);
+ Video.global.item_destroyed.connect(on_attached_source_destroyed);
+ }
+
+ ~EventSnapshot() {
+ LibraryPhoto.global.item_destroyed.disconnect(on_attached_source_destroyed);
+ Video.global.item_destroyed.disconnect(on_attached_source_destroyed);
+ }
+
+ public EventRow get_row() {
+ return row;
+ }
+
+ public override void notify_broken() {
+ row = new EventRow();
+ primary_source = null;
+ attached_sources.clear();
+
+ base.notify_broken();
+ }
+
+ private void on_attached_source_destroyed(DataSource source) {
+ MediaSource media_source = (MediaSource) source;
+
+ // if one of the media sources in the event goes away, reconstitution is impossible
+ if (media_source != null && primary_source.equals(media_source))
+ notify_broken();
+ else if (attached_sources.contains(media_source))
+ notify_broken();
+ }
+ }
+
+ private class EventProxy : SourceProxy {
+ public EventProxy(Event event) {
+ base (event);
+ }
+
+ public override DataSource reconstitute(int64 object_id, SourceSnapshot snapshot) {
+ EventSnapshot event_snapshot = snapshot as EventSnapshot;
+ assert(event_snapshot != null);
+
+ return Event.reconstitute(object_id, event_snapshot.get_row());
+ }
+
+ }
+
+ public static EventSourceCollection global = null;
+
+ private static EventTable event_table = null;
+
+ private EventID event_id;
+ private string? raw_name;
+ private MediaSource primary_source;
+ private ViewCollection view;
+ private bool unlinking = false;
+ private bool relinking = false;
+ private string? indexable_keywords = null;
+ private string? comment = null;
+
+ private Event(EventRow event_row, int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+
+ // normalize user text
+ event_row.name = prep_event_name(event_row.name);
+
+ this.event_id = event_row.event_id;
+ this.raw_name = event_row.name;
+ this.comment = event_row.comment;
+
+ Gee.Collection<string> event_source_ids =
+ MediaCollectionRegistry.get_instance().get_source_ids_for_event_id(event_id);
+ Gee.ArrayList<ThumbnailView> event_thumbs = new Gee.ArrayList<ThumbnailView>();
+ foreach (string current_source_id in event_source_ids) {
+ MediaSource? media =
+ MediaCollectionRegistry.get_instance().fetch_media(current_source_id);
+ if (media != null)
+ event_thumbs.add(new ThumbnailView(media));
+ }
+
+ view = new ViewCollection("ViewCollection for Event %s".printf(event_id.id.to_string()));
+ view.set_comparator(view_comparator, view_comparator_predicate);
+ view.add_many(event_thumbs);
+
+ // need to do this manually here because only want to monitor ViewCollection contents after
+ // initial batch has been added, but need to keep EventSourceCollection apprised
+ if (event_thumbs.size > 0) {
+ global.notify_container_contents_added(this, event_thumbs, false);
+ global.notify_container_contents_altered(this, event_thumbs, false, null, false);
+ }
+
+ // get the primary source for monitoring; if not available, use the first unrejected
+ // source in the event
+ primary_source = MediaCollectionRegistry.get_instance().fetch_media(event_row.primary_source_id);
+ if (primary_source == null && view.get_count() > 0) {
+ primary_source = (MediaSource) ((DataView) view.get_first_unrejected()).get_source();
+ event_table.set_primary_source_id(event_id, primary_source.get_source_id());
+ }
+
+ // watch the primary source to reflect thumbnail changes
+ if (primary_source != null)
+ primary_source.thumbnail_altered.connect(on_primary_thumbnail_altered);
+
+ // watch for for addition, removal, and alteration of photos and videos
+ view.items_added.connect(on_media_added);
+ view.items_removed.connect(on_media_removed);
+ view.items_altered.connect(on_media_altered);
+
+ // because we're no longer using source monitoring (for performance reasons), need to watch
+ // for media destruction (but not removal, which is handled automatically in any case)
+ LibraryPhoto.global.item_destroyed.connect(on_media_destroyed);
+ Video.global.item_destroyed.connect(on_media_destroyed);
+
+ update_indexable_keywords();
+ }
+
+ ~Event() {
+ if (primary_source != null)
+ primary_source.thumbnail_altered.disconnect(on_primary_thumbnail_altered);
+
+ view.items_altered.disconnect(on_media_altered);
+ view.items_removed.disconnect(on_media_removed);
+ view.items_added.disconnect(on_media_added);
+
+ LibraryPhoto.global.item_destroyed.disconnect(on_media_destroyed);
+ Video.global.item_destroyed.disconnect(on_media_destroyed);
+ }
+
+ public override string get_typename() {
+ return TYPENAME;
+ }
+
+ public override int64 get_instance_id() {
+ return get_event_id().id;
+ }
+
+ public override string get_representative_id() {
+ return (primary_source != null) ? primary_source.get_source_id() : get_source_id();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return (primary_source != null) ? primary_source.get_preferred_thumbnail_format() :
+ PhotoFileFormat.get_system_default_format();
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ return (primary_source != null) ? primary_source.create_thumbnail(scale) : null;
+ }
+
+ public static void init(ProgressMonitor? monitor = null) {
+ event_table = EventTable.get_instance();
+ global = new EventSourceCollection();
+ global.init();
+
+ // add all events to the global collection
+ Gee.ArrayList<Event> events = new Gee.ArrayList<Event>();
+ Gee.ArrayList<Event> unlinked = new Gee.ArrayList<Event>();
+
+ Gee.ArrayList<EventRow?> event_rows = event_table.get_events();
+ int count = event_rows.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ Event event = new Event(event_rows[ctr]);
+ if (monitor != null)
+ monitor(ctr, count);
+
+ if (event.get_media_count() != 0) {
+ events.add(event);
+
+ continue;
+ }
+
+ // TODO: If event has no backlinks, destroy (empty Event stored in database) ... this
+ // is expensive to check at startup time, however, should happen in background or
+ // during a "clean" operation
+ event.rehydrate_backlinks(global, null);
+ unlinked.add(event);
+ }
+
+ global.add_many(events);
+ global.init_add_many_unlinked(unlinked);
+ }
+
+ public static void terminate() {
+ }
+
+ private static int64 view_comparator(void *a, void *b) {
+ return ((MediaSource) ((ThumbnailView *) a)->get_source()).get_exposure_time()
+ - ((MediaSource) ((ThumbnailView *) b)->get_source()).get_exposure_time() ;
+ }
+
+ private static bool view_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "exposure-time");
+ }
+
+ public static string? prep_event_name(string? name) {
+ // Ticket #3218 - we tell prepare_input_text to
+ // allow empty strings, and if the rest of the app sees
+ // one, it already knows to rename it to
+ // one of the default event names.
+ return prepare_input_text(name,
+ PrepareInputTextOptions.NORMALIZE | PrepareInputTextOptions.VALIDATE |
+ PrepareInputTextOptions.INVALID_IS_NULL | PrepareInputTextOptions.STRIP |
+ PrepareInputTextOptions.STRIP_CRLF, DEFAULT_USER_TEXT_INPUT_LENGTH);
+ }
+
+ // This is used by MediaSource to notify Event when it's joined. Don't use this to manually attach a
+ // a photo or video to an Event, use MediaSource.set_event().
+ public void attach(MediaSource source) {
+ view.add(new ThumbnailView(source));
+ }
+
+ public void attach_many(Gee.Collection<MediaSource> media) {
+ Gee.ArrayList<ThumbnailView> views = new Gee.ArrayList<ThumbnailView>();
+ foreach (MediaSource current_source in media)
+ views.add(new ThumbnailView(current_source));
+
+ view.add_many(views);
+ }
+
+ // This is used by internally by Photos and Videos to notify their parent Event as to when
+ // they're leaving. Don't use this manually to detach a MediaSource; instead use
+ // MediaSource.set_event( )
+ public void detach(MediaSource source) {
+ view.remove_marked(view.mark(view.get_view_for_source(source)));
+ }
+
+ public void detach_many(Gee.Collection<MediaSource> media) {
+ Gee.ArrayList<ThumbnailView> views = new Gee.ArrayList<ThumbnailView>();
+ foreach (MediaSource current_source in media) {
+ ThumbnailView? view = (ThumbnailView?) view.get_view_for_source(current_source);
+ if (view != null)
+ views.add(view);
+ }
+
+ view.remove_marked(view.mark_many(views));
+ }
+
+ // TODO: A preferred way to do this is for ContainerSource to have an abstract interface for
+ // obtaining the DataCollection in the ContainerSource of all the media objects. Then,
+ // ContinerSource could offer this helper class.
+ public bool contains_media_type(string media_type) {
+ foreach (MediaSource media in get_media()) {
+ if (media.get_typename() == media_type)
+ return true;
+ }
+
+ return false;
+ }
+
+ private Gee.ArrayList<MediaSource> views_to_media(Gee.Iterable<DataObject> views) {
+ Gee.ArrayList<MediaSource> media = new Gee.ArrayList<MediaSource>();
+ foreach (DataObject object in views)
+ media.add((MediaSource) ((DataView) object).get_source());
+
+ return media;
+ }
+
+ private void on_media_added(Gee.Iterable<DataObject> added) {
+ Gee.Collection<MediaSource> media = views_to_media(added);
+ global.notify_container_contents_added(this, media, relinking);
+ global.notify_container_contents_altered(this, media, relinking, null, false);
+
+ notify_altered(new Alteration.from_list("contents:added, metadata:time"));
+ }
+
+ // Event needs to know whenever a media source is removed from the system to update the event
+ private void on_media_removed(Gee.Iterable<DataObject> removed) {
+ Gee.ArrayList<MediaSource> media = views_to_media(removed);
+
+ global.notify_container_contents_removed(this, media, unlinking);
+ global.notify_container_contents_altered(this, null, false, media, unlinking);
+
+ // update primary source if it's been removed (and there's one to take its place)
+ foreach (MediaSource current_source in media) {
+ if (current_source == primary_source) {
+ if (get_media_count() > 0)
+ set_primary_source((MediaSource) view.get_first_unrejected().get_source());
+ else
+ release_primary_source();
+
+ break;
+ }
+ }
+
+ // evaporate event if no more media in it; do not touch thereafter
+ if (get_media_count() == 0) {
+ global.evaporate(this);
+
+ // as it's possible (highly likely, in fact) that all refs to the Event object have
+ // gone out of scope now, do NOT touch this, but exit immediately
+ return;
+ }
+
+ notify_altered(new Alteration.from_list("contents:removed, metadata:time"));
+ }
+
+ private void on_media_destroyed(DataSource source) {
+ ThumbnailView? thumbnail_view = (ThumbnailView) view.get_view_for_source(source);
+ if (thumbnail_view != null)
+ view.remove_marked(view.mark(thumbnail_view));
+ }
+
+ public override void notify_relinking(SourceCollection sources) {
+ assert(get_media_count() > 0);
+
+ // If the primary source was lost in the unlink, reestablish it now.
+ if (primary_source == null)
+ set_primary_source((MediaSource) view.get_first_unrejected().get_source());
+
+ base.notify_relinking(sources);
+ }
+
+ /** @brief This gets called when one or more media items inside this
+ * event gets modified in some fashion. If the media item's date changes
+ * and the event was previously undated, the name of the event needs to
+ * change as well; all of that happens automatically in here.
+ *
+ * In addition, if the _rating_ of one or more media items has changed,
+ * the thumbnail of this event may need to change, as the primary
+ * image may have been rejected and should not be the thumbnail anymore.
+ */
+ private void on_media_altered(Gee.Map<DataObject, Alteration> items) {
+ bool should_remake_thumb = false;
+
+ foreach (Alteration alteration in items.values) {
+ if (alteration.has_detail("metadata", "exposure-time")) {
+
+ string alt_list = "metadata:time";
+
+ if(!has_name())
+ alt_list += (", metadata:name");
+
+ notify_altered(new Alteration.from_list(alt_list));
+
+ break;
+ }
+
+ if (alteration.has_detail("metadata", "rating"))
+ should_remake_thumb = true;
+ }
+
+ if (should_remake_thumb) {
+ // check whether we actually need to remake this thumbnail...
+ if ((get_primary_source() == null) || (get_primary_source().get_rating() == Rating.REJECTED)) {
+ // yes, rejected - drop it and get a new one...
+ set_primary_source((MediaSource) view.get_first_unrejected().get_source());
+ }
+
+ // ...otherwise, if the primary source wasn't rejected, just leave it alone.
+ }
+ }
+
+ // This creates an empty event with a primary source. NOTE: This does not add the source to
+ // the event. That must be done manually.
+ public static Event? create_empty_event(MediaSource source) {
+ try {
+ Event event = new Event(EventTable.get_instance().create(source.get_source_id(), null));
+ global.add(event);
+
+ debug("Created empty event %s", event.to_string());
+
+ return event;
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+
+ return null;
+ }
+ }
+
+ // This will create an event using the fields supplied in EventRow. The event_id is ignored.
+ private static Event reconstitute(int64 object_id, EventRow row) {
+ row.event_id = EventTable.get_instance().create_from_row(row);
+ Event event = new Event(row, object_id);
+ global.add(event);
+ assert(global.contains(event));
+
+ debug("Reconstituted event %s", event.to_string());
+
+ return event;
+ }
+
+ public bool has_links() {
+ return (LibraryPhoto.global.has_backlink(get_backlink()) ||
+ Video.global.has_backlink(get_backlink()));
+ }
+
+ public SourceBacklink get_backlink() {
+ return new SourceBacklink.from_source(this);
+ }
+
+ public void break_link(DataSource source) {
+ unlinking = true;
+
+ ((MediaSource) source).set_event(null);
+
+ unlinking = false;
+ }
+
+ public void break_link_many(Gee.Collection<DataSource> sources) {
+ unlinking = true;
+
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
+ MediaSourceCollection.filter_media((Gee.Collection<MediaSource>) sources, photos, videos);
+
+ try {
+ MediaSource.set_many_to_event(photos, null, LibraryPhoto.global.transaction_controller);
+ } catch (Error err) {
+ AppWindow.error_message("%s".printf(err.message));
+ }
+
+ try {
+ MediaSource.set_many_to_event(videos, null, Video.global.transaction_controller);
+ } catch (Error err) {
+ AppWindow.error_message("%s".printf(err.message));
+ }
+
+ unlinking = false;
+ }
+
+ public void establish_link(DataSource source) {
+ relinking = true;
+
+ ((MediaSource) source).set_event(this);
+
+ relinking = false;
+ }
+
+ public void establish_link_many(Gee.Collection<DataSource> sources) {
+ relinking = true;
+
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
+ MediaSourceCollection.filter_media((Gee.Collection<MediaSource>) sources, photos, videos);
+
+ try {
+ MediaSource.set_many_to_event(photos, this, LibraryPhoto.global.transaction_controller);
+ } catch (Error err) {
+ AppWindow.error_message("%s".printf(err.message));
+ }
+
+ try {
+ MediaSource.set_many_to_event(videos, this, Video.global.transaction_controller);
+ } catch (Error err) {
+ AppWindow.error_message("%s".printf(err.message));
+ }
+
+ relinking = false;
+ }
+
+ private void update_indexable_keywords() {
+ string[] components = new string[3];
+ int i = 0;
+
+ string? rawname = get_raw_name();
+ if (rawname != null)
+ components[i++] = rawname;
+
+ string? comment = get_comment();
+ if (comment != null)
+ components[i++] = comment;
+
+ if (i == 0)
+ indexable_keywords = null;
+ else {
+ components[i] = null;
+ indexable_keywords = prepare_indexable_string(string.joinv(" ", components));
+ }
+ }
+
+ public unowned string? get_indexable_keywords() {
+ return indexable_keywords;
+ }
+
+ public bool is_in_starting_day(time_t time) {
+ // it's possible the Event ref is held although it's been emptied
+ // (such as the user removing items during an import, when events
+ // are being generate on-the-fly) ... return false here and let
+ // the caller make a new one
+ if (view.get_count() == 0)
+ return false;
+
+ // media sources are stored in ViewCollection from earliest to latest
+ MediaSource earliest_media = (MediaSource) ((DataView) view.get_at(0)).get_source();
+ Time earliest_tm = Time.local(earliest_media.get_exposure_time());
+
+ // use earliest to generate the boundary hour for that day
+ Time start_boundary_tm = Time();
+ start_boundary_tm.second = 0;
+ start_boundary_tm.minute = 0;
+ start_boundary_tm.hour = EVENT_BOUNDARY_HOUR;
+ start_boundary_tm.day = earliest_tm.day;
+ start_boundary_tm.month = earliest_tm.month;
+ start_boundary_tm.year = earliest_tm.year;
+ start_boundary_tm.isdst = -1;
+
+ time_t start_boundary = start_boundary_tm.mktime();
+
+ // if the earliest's exposure time was on the day but *before* the boundary hour,
+ // step it back a day to the prior day's boundary
+ if (earliest_tm.hour < EVENT_BOUNDARY_HOUR)
+ start_boundary -= TIME_T_DAY;
+
+ time_t end_boundary = (start_boundary + TIME_T_DAY - 1);
+
+ return time >= start_boundary && time <= end_boundary;
+ }
+
+ // This method attempts to add a media source to an event in the supplied list that it would
+ // naturally fit into (i.e. its exposure is within the boundary day of the earliest event
+ // photo). Otherwise, a new Event is generated and the source is added to it and the list.
+ private static Event? generate_event(MediaSource media, ViewCollection events_so_far,
+ string? event_name, out bool new_event) {
+ time_t exposure_time = media.get_exposure_time();
+
+ if (exposure_time == 0 && event_name == null) {
+ debug("Skipping event assignment to %s: no exposure time and no event name", media.to_string());
+ new_event = false;
+
+ return null;
+ }
+
+ int count = events_so_far.get_count();
+ for (int ctr = 0; ctr < count; ctr++) {
+ Event event = (Event) ((EventView) events_so_far.get_at(ctr)).get_source();
+
+ if ((event_name != null && event.has_name() && event_name == event.get_name())
+ || event.is_in_starting_day(exposure_time)) {
+ new_event = false;
+
+ return event;
+ }
+ }
+
+ // no Event so far fits the bill for this photo or video, so create a new one
+ try {
+ Event event = new Event(EventTable.get_instance().create(media.get_source_id(), null));
+ if (event_name != null)
+ event.rename(event_name);
+
+ events_so_far.add(new EventView(event));
+
+ new_event = true;
+ return event;
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ new_event = false;
+
+ return null;
+ }
+
+ public static void generate_single_event(MediaSource media, ViewCollection events_so_far,
+ string? event_name = null) {
+ // do not replace existing assignments
+ if (media.get_event() != null)
+ return;
+
+ bool new_event;
+ Event? event = generate_event(media, events_so_far, event_name, out new_event);
+ if (event == null)
+ return;
+
+ media.set_event(event);
+
+ if (new_event)
+ global.add(event);
+ }
+
+ public static void generate_many_events(Gee.Collection<MediaSource> sources, ViewCollection events_so_far) {
+ Gee.Collection<Event> to_add = new Gee.ArrayList<Event>();
+ foreach (MediaSource media in sources) {
+ // do not replace existing assignments
+ if (media.get_event() != null)
+ continue;
+
+ bool new_event;
+ Event? event = generate_event(media, events_so_far, null, out new_event);
+ if (event == null)
+ continue;
+
+ media.set_event(event);
+
+ if (new_event)
+ to_add.add(event);
+ }
+
+ if (to_add.size > 0)
+ global.add_many(to_add);
+ }
+
+ public EventID get_event_id() {
+ return event_id;
+ }
+
+ public override SourceSnapshot? save_snapshot() {
+ return new EventSnapshot(this);
+ }
+
+ public SourceProxy get_proxy() {
+ return new EventProxy(this);
+ }
+
+ public override bool equals(DataSource? source) {
+ // Validate primary key is unique, which is vital to all this working
+ Event? event = source as Event;
+ if (event != null) {
+ if (this != event) {
+ assert(event_id.id != event.event_id.id);
+ }
+ }
+
+ return base.equals(source);
+ }
+
+ public override string to_string() {
+ return "Event [%s/%s] %s".printf(event_id.id.to_string(), get_object_id().to_string(), get_name());
+ }
+
+ public bool has_name() {
+ return raw_name != null && raw_name.length > 0;
+ }
+
+ public override string get_name() {
+ if (has_name())
+ return get_raw_name();
+
+ // if no name, pretty up the start time
+ string? datestring = get_formatted_daterange();
+
+ return !is_string_empty(datestring) ? datestring : _("Event %s").printf(event_id.id.to_string());
+ }
+
+ public string? get_formatted_daterange() {
+ time_t start_time = get_start_time();
+ time_t end_time = get_end_time();
+
+ if (end_time == 0 && start_time == 0)
+ return null;
+
+ if (end_time == 0 && start_time != 0)
+ return format_local_date(Time.local(start_time));
+
+ Time start = Time.local(start_time);
+ Time end = Time.local(end_time);
+
+ if (start.day == end.day && start.month == end.month && start.day == end.day)
+ return format_local_date(Time.local(start_time));
+
+ return format_local_datespan(start, end);
+ }
+
+ public string? get_raw_name() {
+ return raw_name;
+ }
+
+ public override string? get_comment() {
+ return comment;
+ }
+
+ public bool rename(string? name) {
+ string? new_name = prep_event_name(name);
+
+ // Allow rename to date but it should go dynamic, so set name to ""
+ if (new_name == get_formatted_daterange()) {
+ new_name = "";
+ }
+
+ bool renamed = event_table.rename(event_id, new_name);
+ if (renamed) {
+ raw_name = new_name;
+ update_indexable_keywords();
+ notify_altered(new Alteration.from_list("metadata:name, indexable:keywords"));
+ }
+
+ return renamed;
+ }
+
+ public override bool set_comment(string? comment) {
+ string? new_comment = MediaSource.prep_comment(comment);
+
+ bool committed = event_table.set_comment(event_id, new_comment);
+ if (committed) {
+ this.comment = new_comment;
+ update_indexable_keywords();
+ notify_altered(new Alteration.from_list("metadata:comment, indexable:keywords"));
+ }
+
+ return committed;
+ }
+
+ public time_t get_creation_time() {
+ return event_table.get_time_created(event_id);
+ }
+
+ public override time_t get_start_time() {
+ // Because the ViewCollection is sorted by a DateComparator, the start time is the
+ // first item. However, we keep looking if it has no start time.
+ int count = view.get_count();
+ for (int i = 0; i < count; i++) {
+ time_t time = ((MediaSource) (((DataView) view.get_at(i)).get_source())).get_exposure_time();
+ if (time != 0)
+ return time;
+ }
+
+ return 0;
+ }
+
+ public override time_t get_end_time() {
+ int count = view.get_count();
+
+ // Because the ViewCollection is sorted by a DateComparator, the end time is the
+ // last item--no matter what.
+ if (count == 0)
+ return 0;
+
+ return ((MediaSource) (((DataView) view.get_at(count - 1)).get_source())).get_exposure_time();
+ }
+
+ public override uint64 get_total_filesize() {
+ uint64 total = 0;
+ foreach (MediaSource current_source in get_media()) {
+ total += current_source.get_filesize();
+ }
+
+ return total;
+ }
+
+ public override int get_media_count() {
+ return view.get_count();
+ }
+
+ public override Gee.Collection<MediaSource> get_media() {
+ return (Gee.Collection<MediaSource>) view.get_sources();
+ }
+
+ public void mirror_photos(ViewCollection view, CreateView mirroring_ctor) {
+ view.mirror(this.view, mirroring_ctor, null);
+ }
+
+ private void on_primary_thumbnail_altered() {
+ notify_thumbnail_altered();
+ }
+
+ public MediaSource get_primary_source() {
+ return primary_source;
+ }
+
+ public bool set_primary_source(MediaSource source) {
+ assert(view.has_view_for_source(source));
+
+ bool committed = event_table.set_primary_source_id(event_id, source.get_source_id());
+ if (committed) {
+ // switch to the new media source
+ if (primary_source != null)
+ primary_source.thumbnail_altered.disconnect(on_primary_thumbnail_altered);
+
+ primary_source = source;
+ primary_source.thumbnail_altered.connect(on_primary_thumbnail_altered);
+
+ notify_thumbnail_altered();
+ }
+
+ return committed;
+ }
+
+ private void release_primary_source() {
+ if (primary_source == null)
+ return;
+
+ primary_source.thumbnail_altered.disconnect(on_primary_thumbnail_altered);
+ primary_source = null;
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return primary_source != null ? primary_source.get_thumbnail(scale) : null;
+ }
+
+ public Gdk.Pixbuf? get_preview_pixbuf(Scaling scaling) {
+ try {
+ return get_primary_source().get_preview_pixbuf(scaling);
+ } catch (Error err) {
+ return null;
+ }
+ }
+
+ public override void destroy() {
+ // stop monitoring the photos collection
+ view.halt_all_monitoring();
+
+ // remove from the database
+ try {
+ event_table.remove(event_id);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ // mark all photos and videos for this event as now event-less
+ PhotoTable.get_instance().drop_event(event_id);
+ VideoTable.get_instance().drop_event(event_id);
+
+ base.destroy();
+ }
+}
diff --git a/src/Exporter.vala b/src/Exporter.vala
new file mode 100644
index 0000000..a930c4c
--- /dev/null
+++ b/src/Exporter.vala
@@ -0,0 +1,343 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public enum ExportFormatMode {
+ UNMODIFIED,
+ CURRENT,
+ SPECIFIED, /* use an explicitly specified format like PNG or JPEG */
+ LAST /* use whatever format was used in the previous export operation */
+}
+
+public struct ExportFormatParameters {
+ public ExportFormatMode mode;
+ public PhotoFileFormat specified_format;
+ public Jpeg.Quality quality;
+ public bool export_metadata;
+
+ private ExportFormatParameters(ExportFormatMode mode, PhotoFileFormat specified_format,
+ Jpeg.Quality quality) {
+ this.mode = mode;
+ this.specified_format = specified_format;
+ this.quality = quality;
+ this.export_metadata = true;
+ }
+
+ public static ExportFormatParameters current() {
+ return ExportFormatParameters(ExportFormatMode.CURRENT,
+ PhotoFileFormat.get_system_default_format(), Jpeg.Quality.HIGH);
+ }
+
+ public static ExportFormatParameters unmodified() {
+ return ExportFormatParameters(ExportFormatMode.UNMODIFIED,
+ PhotoFileFormat.get_system_default_format(), Jpeg.Quality.HIGH);
+ }
+
+ public static ExportFormatParameters for_format(PhotoFileFormat format) {
+ return ExportFormatParameters(ExportFormatMode.SPECIFIED, format, Jpeg.Quality.HIGH);
+ }
+
+ public static ExportFormatParameters last() {
+ return ExportFormatParameters(ExportFormatMode.LAST,
+ PhotoFileFormat.get_system_default_format(), Jpeg.Quality.HIGH);
+ }
+
+ public static ExportFormatParameters for_JPEG(Jpeg.Quality quality) {
+ return ExportFormatParameters(ExportFormatMode.SPECIFIED, PhotoFileFormat.JFIF,
+ quality);
+ }
+}
+
+public class Exporter : Object {
+ public enum Overwrite {
+ YES,
+ NO,
+ CANCEL,
+ REPLACE_ALL
+ }
+
+ public delegate void CompletionCallback(Exporter exporter, bool is_cancelled);
+
+ public delegate Overwrite OverwriteCallback(Exporter exporter, File file);
+
+ public delegate bool ExportFailedCallback(Exporter exporter, File file, int remaining,
+ Error err);
+
+ private class ExportJob : BackgroundJob {
+ public MediaSource media;
+ public File dest;
+ public Scaling? scaling;
+ public Jpeg.Quality? quality;
+ public PhotoFileFormat? format;
+ public Error? err = null;
+ public bool direct_copy_unmodified = false;
+ public bool export_metadata = true;
+
+ public ExportJob(Exporter owner, MediaSource media, File dest, Scaling? scaling,
+ Jpeg.Quality? quality, PhotoFileFormat? format, Cancellable cancellable,
+ bool direct_copy_unmodified = false, bool export_metadata = true) {
+ base (owner, owner.on_exported, cancellable, owner.on_export_cancelled);
+
+ assert(media is Photo || media is Video);
+
+ this.media = media;
+ this.dest = dest;
+ this.scaling = scaling;
+ this.quality = quality;
+ this.format = format;
+ this.direct_copy_unmodified = direct_copy_unmodified;
+ this.export_metadata = export_metadata;
+ }
+
+ public override void execute() {
+ try {
+ if (media is Photo) {
+ ((Photo) media).export(dest, scaling, quality, format, direct_copy_unmodified, export_metadata);
+ } else if (media is Video) {
+ ((Video) media).export(dest);
+ }
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+ }
+
+ private Gee.Collection<MediaSource> to_export = new Gee.ArrayList<MediaSource>();
+ private File[] exported_files;
+ private File? dir;
+ private Scaling scaling;
+ private int completed_count = 0;
+ private Workers workers = new Workers(Workers.threads_per_cpu(1, 4), false);
+ private unowned CompletionCallback? completion_callback = null;
+ private unowned ExportFailedCallback? error_callback = null;
+ private unowned OverwriteCallback? overwrite_callback = null;
+ private unowned ProgressMonitor? monitor = null;
+ private Cancellable cancellable;
+ private bool replace_all = false;
+ private bool aborted = false;
+ private ExportFormatParameters export_params;
+
+ public Exporter(Gee.Collection<MediaSource> to_export, File? dir, Scaling scaling,
+ ExportFormatParameters export_params, bool auto_replace_all = false) {
+ this.to_export.add_all(to_export);
+ this.dir = dir;
+ this.scaling = scaling;
+ this.export_params = export_params;
+ this.replace_all = auto_replace_all;
+ }
+
+ 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.scaling = scaling;
+ this.export_params = export_params;
+ }
+
+ // This should be called only once; the object does not reset its internal state when completed.
+ public void export(CompletionCallback completion_callback, ExportFailedCallback error_callback,
+ OverwriteCallback overwrite_callback, Cancellable? cancellable, ProgressMonitor? monitor) {
+ this.completion_callback = completion_callback;
+ this.error_callback = error_callback;
+ this.overwrite_callback = overwrite_callback;
+ this.monitor = monitor;
+ this.cancellable = cancellable ?? new Cancellable();
+
+ if (!process_queue())
+ export_completed(true);
+ }
+
+ private void on_exported(BackgroundJob j) {
+ ExportJob job = (ExportJob) j;
+
+ completed_count++;
+
+ // because the monitor spins the event loop, and so it's possible this function will be
+ // re-entered, decide now if this is the last job
+ bool completed = completed_count == to_export.size;
+
+ if (!aborted && job.err != null) {
+ if (!error_callback(this, job.dest, to_export.size - completed_count, job.err)) {
+ aborted = true;
+
+ if (!completed)
+ return;
+ }
+ }
+
+ if (!aborted && monitor != null) {
+ if (!monitor(completed_count, to_export.size, false)) {
+ aborted = true;
+
+ if (!completed)
+ return;
+ } else {
+ exported_files += job.dest;
+ }
+ }
+
+ if (completed)
+ export_completed(false);
+ }
+
+ private void on_export_cancelled(BackgroundJob j) {
+ if (++completed_count == to_export.size)
+ export_completed(true);
+ }
+
+ public File[] get_exported_files() {
+ return exported_files;
+ }
+
+ private bool process_queue() {
+ int submitted = 0;
+ foreach (MediaSource source in to_export) {
+ File? use_source_file = null;
+ PhotoFileFormat real_export_format = PhotoFileFormat.get_system_default_format();
+ string? basename = null;
+ if (source is Photo) {
+ Photo photo = (Photo) source;
+ real_export_format = photo.get_export_format_for_parameters(export_params);
+ basename = photo.get_export_basename_for_parameters(export_params);
+ } else if (source is Video) {
+ basename = ((Video) source).get_basename();
+ }
+ assert(basename != null);
+
+ if (use_source_file != null) {
+ exported_files += use_source_file;
+
+ completed_count++;
+ if (monitor != null) {
+ if (!monitor(completed_count, to_export.size)) {
+ cancellable.cancel();
+
+ return false;
+ }
+ }
+
+ continue;
+ }
+
+ File? export_dir = dir;
+ File? dest = null;
+
+ if (export_dir == null) {
+ try {
+ bool collision;
+ dest = generate_unique_file(AppDirs.get_temp_dir(), basename, out collision);
+ } catch (Error err) {
+ AppWindow.error_message(_("Unable to generate a temporary file for %s: %s").printf(
+ source.get_file().get_basename(), err.message));
+
+ break;
+ }
+ } else {
+ dest = dir.get_child(basename);
+
+ if (!replace_all && dest.query_exists(null)) {
+ switch (overwrite_callback(this, dest)) {
+ case Overwrite.YES:
+ // continue
+ break;
+
+ case Overwrite.REPLACE_ALL:
+ replace_all = true;
+ break;
+
+ case Overwrite.CANCEL:
+ cancellable.cancel();
+
+ return false;
+
+ case Overwrite.NO:
+ default:
+ completed_count++;
+ if (monitor != null) {
+ if (!monitor(completed_count, to_export.size)) {
+ cancellable.cancel();
+
+ return false;
+ }
+ }
+
+ continue;
+ }
+ }
+ }
+
+ 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++;
+ }
+
+ return submitted > 0;
+ }
+
+ private void export_completed(bool is_cancelled) {
+ completion_callback(this, is_cancelled);
+ }
+}
+
+public class ExporterUI {
+ private Exporter exporter;
+ private Cancellable cancellable = new Cancellable();
+ private ProgressDialog? progress_dialog = null;
+ private unowned Exporter.CompletionCallback? completion_callback = null;
+
+ public ExporterUI(Exporter exporter) {
+ this.exporter = exporter;
+ }
+
+ public void export(Exporter.CompletionCallback completion_callback) {
+ this.completion_callback = completion_callback;
+
+ AppWindow.get_instance().set_busy_cursor();
+
+ progress_dialog = new ProgressDialog(AppWindow.get_instance(), _("Exporting"), cancellable);
+ exporter.export(on_export_completed, on_export_failed, on_export_overwrite, cancellable,
+ progress_dialog.monitor);
+ }
+
+ private void on_export_completed(Exporter exporter, bool is_cancelled) {
+ if (progress_dialog != null) {
+ progress_dialog.close();
+ progress_dialog = null;
+ }
+
+ AppWindow.get_instance().set_normal_cursor();
+
+ completion_callback(exporter, is_cancelled);
+ }
+
+ 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"));
+
+ progress_dialog.set_modal(true);
+
+ switch (response) {
+ case Gtk.ResponseType.APPLY:
+ return Exporter.Overwrite.REPLACE_ALL;
+
+ case Gtk.ResponseType.YES:
+ return Exporter.Overwrite.YES;
+
+ case Gtk.ResponseType.CANCEL:
+ return Exporter.Overwrite.CANCEL;
+
+ case Gtk.ResponseType.NO:
+ default:
+ return Exporter.Overwrite.NO;
+ }
+ }
+
+ private bool on_export_failed(Exporter exporter, File file, int remaining, Error err) {
+ return export_error_dialog(file, remaining > 0) != Gtk.ResponseType.CANCEL;
+ }
+}
+
diff --git a/src/International.vala b/src/International.vala
new file mode 100644
index 0000000..1bf242b
--- /dev/null
+++ b/src/International.vala
@@ -0,0 +1,32 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+extern const string _LANG_SUPPORT_DIR;
+
+public const string TRANSLATABLE = "translatable";
+
+namespace InternationalSupport {
+const string SYSTEM_LOCALE = "";
+const string LANGUAGE_SUPPORT_DIRECTORY = _LANG_SUPPORT_DIR;
+
+void init(string package_name, string[] args, string locale = SYSTEM_LOCALE) {
+ Intl.setlocale(LocaleCategory.ALL, locale);
+
+ Intl.bindtextdomain(package_name, get_langpack_dir_path(args));
+ Intl.bind_textdomain_codeset(package_name, "UTF-8");
+ Intl.textdomain(package_name);
+}
+
+private string get_langpack_dir_path(string[] args) {
+ File local_langpack_dir =
+ File.new_for_path(Environment.find_program_in_path(args[0])).get_parent().get_child(
+ "locale-langpack");
+
+ return (local_langpack_dir.query_exists(null)) ? local_langpack_dir.get_path() :
+ LANGUAGE_SUPPORT_DIRECTORY;
+}
+}
+
diff --git a/src/LibraryFiles.vala b/src/LibraryFiles.vala
new file mode 100644
index 0000000..4cffe94
--- /dev/null
+++ b/src/LibraryFiles.vala
@@ -0,0 +1,104 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace LibraryFiles {
+
+// This method uses global::generate_unique_file_at in order to "claim" a file in the filesystem.
+// 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)
+ 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;
+ 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();
+ }
+
+ // build a directory tree inside the library
+ File dir = AppDirs.get_baked_import_dir(timestamp);
+ try {
+ dir.make_directory_with_parents(null);
+ } catch (Error err) {
+ if (!(err is IOError.EXISTS))
+ throw err;
+
+ // silently ignore not creating a directory that already exists
+ }
+
+ // Optionally convert to lower-case.
+ string newbasename = basename;
+ if (Config.Facade.get_instance().get_use_lowercase_filenames())
+ newbasename = newbasename.down();
+
+ return global::generate_unique_file(dir, newbasename, out collision);
+}
+
+// This function is thread-safe.
+private File duplicate(File src, FileProgressCallback? progress_callback, bool blacklist) throws Error {
+ time_t timestamp = 0;
+ try {
+ timestamp = query_file_modified(src);
+ } catch (Error err) {
+ critical("Unable to access file modification for %s: %s", src.get_path(), err.message);
+ }
+
+ MediaMetadata? metadata = null;
+ if (VideoReader.is_supported_video_file(src)) {
+ VideoReader reader = new VideoReader(src);
+ try {
+ metadata = reader.read_metadata();
+ } catch (Error err) {
+ // ignored, leave metadata as null
+ }
+ } else {
+ PhotoFileReader reader = PhotoFileFormat.get_by_file_extension(src).create_reader(
+ src.get_path());
+ try {
+ metadata = reader.read_metadata();
+ } catch (Error err) {
+ // ignored, leave metadata as null
+ }
+ }
+
+ bool collision;
+ File? dest = generate_unique_file(src.get_basename(), metadata, timestamp, out collision);
+ if (dest == null)
+ throw new FileError.FAILED("Unable to generate unique pathname for destination");
+
+ if (blacklist)
+ LibraryMonitor.blacklist_file(dest, "LibraryFiles.duplicate");
+
+ try {
+ src.copy(dest, FileCopyFlags.ALL_METADATA | FileCopyFlags.OVERWRITE, null, progress_callback);
+ if (blacklist)
+ LibraryMonitor.unblacklist_file(dest);
+ } catch (Error err) {
+ message("There was a problem copying %s: %s", src.get_path(), err.message);
+ if (blacklist && (md5_file(src) != md5_file(dest)))
+ LibraryMonitor.unblacklist_file(dest);
+ }
+
+ // Make file writable by getting current Unix mode and or it with 600 (user read/write)
+ try {
+ FileInfo info = dest.query_info(FileAttribute.UNIX_MODE, FileQueryInfoFlags.NONE);
+ uint32 mode = info.get_attribute_uint32(FileAttribute.UNIX_MODE) | 0600;
+ if (!dest.set_attribute_uint32(FileAttribute.UNIX_MODE, mode, FileQueryInfoFlags.NONE)) {
+ warning("Could not make file writable");
+ }
+ } catch (Error err) {
+ warning("Could not make file writable: %s", err.message);
+ }
+
+ return dest;
+}
+}
diff --git a/src/LibraryMonitor.vala b/src/LibraryMonitor.vala
new file mode 100644
index 0000000..363213b
--- /dev/null
+++ b/src/LibraryMonitor.vala
@@ -0,0 +1,1013 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+//
+// LibraryMonitor uses DirectoryMonitor to track assets in the user's library directory and make
+// sure they're reflected in the application.
+//
+// NOTE: There appears to be a bug where prior versions of Shotwell (<= 0.6.x) were not
+// properly loading the file modification timestamp during import. This was no issue
+// before but becomes imperative now with file monitoring. A "proper" algorithm is
+// to reimport an entire photo if the modification time in the database is different
+// than the file's, but that's Real Bad when the user first turns on monitoring, as it
+// will cause a lot of reimports (think of a 10,000 photo database) and will blow away
+// ALL transformations, as they are now suspect.
+//
+// So: If the modification time is zero and filesize is the same, simply update the
+// timestamp in the database and move on.
+//
+// TODO: Although it seems highly unlikely that a file's timestamp could change but the file size
+// has not and the file really be "changed", it *is* possible, even in the case of complex little
+// animals like photo files. We could be more liberal and treat this case as a metadata-changed
+// situation (since that's a likely case).
+//
+
+public class LibraryMonitorPool {
+ private static LibraryMonitorPool? instance = null;
+
+ private LibraryMonitor? monitor = null;
+ private uint timer_id = 0;
+
+ public signal void monitor_installed(LibraryMonitor monitor);
+
+ public signal void monitor_destroyed(LibraryMonitor monitor);
+
+ private LibraryMonitorPool() {
+ }
+
+ public static void init() {
+ }
+
+ public static void terminate() {
+ if (instance != null)
+ instance.close();
+
+ instance = null;
+ }
+
+ public static LibraryMonitorPool get_instance() {
+ if (instance == null)
+ instance = new LibraryMonitorPool();
+
+ return instance;
+ }
+
+ public LibraryMonitor? get_monitor() {
+ return monitor;
+ }
+
+ // This closes and destroys the old monitor, if any, and replaces it with the new one.
+ public void replace(LibraryMonitor replacement, int start_msec_delay = 0) {
+ close();
+
+ monitor = replacement;
+ if (start_msec_delay > 0 && timer_id == 0)
+ timer_id = Timeout.add(start_msec_delay, on_start_monitor);
+
+ monitor_installed(monitor);
+ }
+
+ private void close() {
+ if (monitor == null)
+ return;
+
+ monitor.close();
+ LibraryMonitor closed = monitor;
+ monitor = null;
+
+ monitor_destroyed(closed);
+ }
+
+ private bool on_start_monitor() {
+ // can set to zero because this function always returns false
+ timer_id = 0;
+
+ if (monitor == null)
+ return false;
+
+ monitor.start_discovery();
+
+ return false;
+ }
+}
+
+public class LibraryMonitor : DirectoryMonitor {
+ private const int FLUSH_IMPORT_QUEUE_SEC = 3;
+ private const int IMPORT_ROLL_QUIET_SEC = 5 * 60;
+ private const int MIN_BLACKLIST_DURATION_MSEC = 5 * 1000;
+ private const int MAX_VERIFY_EXISTING_MEDIA_JOBS = 5;
+
+ private class FindMoveJob : BackgroundJob {
+ public File file;
+ public Gee.Collection<Monitorable> candidates;
+ public Monitorable? match = null;
+ public Gee.ArrayList<Monitorable>? losers = null;
+ public Error? err = null;
+
+ public FindMoveJob(LibraryMonitor owner, File file, Gee.Collection<Monitorable> candidates) {
+ base (owner, owner.on_find_move_completed, owner.cancellable, owner.on_find_move_cancelled);
+
+ this.file = file;
+ this.candidates = candidates;
+
+ set_completion_priority(Priority.LOW);
+ }
+
+ public override void execute() {
+ // weed out any candidates that already have a backing master
+ Gee.Iterator<Monitorable> iter = candidates.iterator();
+ while (iter.next()) {
+ if (iter.get().get_master_file().query_exists())
+ iter.remove();
+ }
+
+ // if no more, done
+ if (candidates.size == 0)
+ return;
+
+ string? md5 = null;
+ try {
+ md5 = md5_file(file);
+ } catch (Error err) {
+ this.err = err;
+
+ return;
+ }
+
+ foreach (Monitorable candidate in candidates) {
+ if (candidate.get_master_md5() != md5)
+ continue;
+
+ if (match != null) {
+ warning("Found more than one media match for %s: %s and %s", file.get_path(),
+ match.to_string(), candidate.to_string());
+
+ if (losers == null)
+ losers = new Gee.ArrayList<Monitorable>();
+
+ losers.add(candidate);
+
+ continue;
+ }
+
+ match = candidate;
+ }
+ }
+ }
+
+ private class RuntimeFindMoveJob : BackgroundJob {
+ public File file;
+ public Gee.Collection<Monitorable> candidates;
+ public Monitorable? match = null;
+ public Error? err = null;
+
+ public RuntimeFindMoveJob(LibraryMonitor owner, File file, Gee.Collection<Monitorable> candidates) {
+ base (owner, owner.on_runtime_find_move_completed, owner.cancellable);
+
+ this.file = file;
+ this.candidates = candidates;
+
+ set_completion_priority(Priority.LOW);
+ }
+
+ public override void execute() {
+ string? md5 = null;
+ try {
+ md5 = md5_file(file);
+ } catch (Error err) {
+ this.err = err;
+
+ return;
+ }
+
+ foreach (Monitorable candidate in candidates) {
+ if (candidate.get_master_md5() == md5) {
+ match = candidate;
+
+ break;
+ }
+ }
+ }
+ }
+
+ private class VerifyJob {
+ public Monitorable monitorable;
+ public MediaMonitor monitor;
+
+ public VerifyJob(Monitorable monitorable, MediaMonitor monitor) {
+ this.monitorable = monitorable;
+ this.monitor = monitor;
+ }
+ }
+
+ private static Gee.HashSet<File> blacklist = new Gee.HashSet<File>(file_hash, file_equal);
+ private static HashTimedQueue<File> to_unblacklist = new HashTimedQueue<File>(
+ MIN_BLACKLIST_DURATION_MSEC, on_unblacklist_file, file_hash, file_equal, Priority.LOW);
+
+ private Workers workers = new Workers(Workers.thread_per_cpu_minus_one(), false);
+ private Cancellable cancellable = new Cancellable();
+ private bool auto_import = false;
+ private Gee.HashSet<File> unknown_files = null;
+ private Gee.List<MediaMonitor> monitors = new Gee.ArrayList<MediaMonitor>();
+ private Gee.HashMap<MediaMonitor, Gee.Set<Monitorable>> discovered = null;
+ private Gee.HashSet<File> import_queue = new Gee.HashSet<File>(file_hash, file_equal);
+ 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 BatchImport current_batch_import = null;
+ private int checksums_completed = 0;
+ private int checksums_total = 0;
+ private uint import_queue_timer_id = 0;
+ private Gee.Queue<VerifyJob> verify_queue = new Gee.LinkedList<VerifyJob>();
+ private int outstanding_verify_jobs = 0;
+ private int completed_monitorable_verifies = 0;
+ private int total_monitorable_verifies = 0;
+
+ public signal void auto_update_progress(int completed_files, int total_files);
+
+ public signal void auto_import_preparing();
+
+ public signal void auto_import_progress(uint64 completed_bytes, uint64 total_bytes);
+
+ public LibraryMonitor(File root, bool recurse, bool monitoring) {
+ base (root, recurse, monitoring);
+
+ // synchronize with configuration system
+ auto_import = Config.Facade.get_instance().get_auto_import_from_library();
+ Config.Facade.get_instance().auto_import_from_library_changed.connect(on_config_changed);
+
+ import_queue_timer_id = Timeout.add_seconds(FLUSH_IMPORT_QUEUE_SEC, on_flush_import_queue);
+ }
+
+ ~LibraryMonitor() {
+ Config.Facade.get_instance().auto_import_from_library_changed.disconnect(on_config_changed);
+ }
+
+ public override void close() {
+ cancellable.cancel();
+
+ foreach (MediaMonitor monitor in monitors)
+ monitor.close();
+
+ if (import_queue_timer_id != 0) {
+ Source.remove(import_queue_timer_id);
+ import_queue_timer_id = 0;
+ }
+
+ base.close();
+ }
+
+ private void add_to_discovered_list(MediaMonitor monitor, Monitorable monitorable) {
+ if (!discovered.has_key(monitor))
+ discovered.set(monitor, new Gee.HashSet<Monitorable>());
+
+ discovered.get(monitor).add(monitorable);
+ }
+
+ private MediaMonitor get_monitor_for_monitorable(Monitorable monitorable) {
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.get_media_source_collection().holds_type_of_source(monitorable))
+ return monitor;
+ }
+
+ error("Unable to locate MediaMonitor for %s", monitorable.to_string());
+ }
+
+ public override void discovery_started() {
+ foreach (MediaSourceCollection collection in MediaCollectionRegistry.get_instance().get_all())
+ monitors.add(collection.create_media_monitor(workers, cancellable));
+
+ foreach (MediaMonitor monitor in monitors)
+ monitor.notify_discovery_started();
+
+ discovered = new Gee.HashMap<MediaMonitor, Gee.Set<Monitorable>>();
+ unknown_files = new Gee.HashSet<File>(file_hash, file_equal);
+
+ base.discovery_started();
+ }
+
+ public override void file_discovered(File file, FileInfo info) {
+ Monitorable? representation = null;
+ MediaMonitor representing = null;
+ bool ignore = false;
+ foreach (MediaMonitor monitor in monitors) {
+ MediaMonitor.DiscoveredFile result = monitor.notify_file_discovered(file, info,
+ out representation);
+ if (result == MediaMonitor.DiscoveredFile.REPRESENTED) {
+ representing = monitor;
+
+ break;
+ } else if (result == MediaMonitor.DiscoveredFile.IGNORE) {
+ // known but not to be worried about (for purposes of discovery)
+ ignore = true;
+
+ break;
+ }
+ }
+
+ if (representing != null) {
+ assert(representation != null && !ignore);
+ add_to_discovered_list(representing, representation);
+ } else if (!ignore && !Tombstone.global.matches(file) && is_supported_filetype(file)) {
+ unknown_files.add(file);
+ }
+
+ base.file_discovered(file, info);
+ }
+
+ public override void discovery_completed() {
+ async_discovery_completed.begin();
+ }
+
+ private async void async_discovery_completed() {
+ // before marking anything online/offline, reimporting changed files, or auto-importing new
+ // files, want to see if the unknown files are actually renamed files. Do this by examining
+ // their FileInfo and calculating their MD5 in the background ... when all this is sorted
+ // out, then go on and finish the other tasks
+ if (unknown_files.size == 0) {
+ discovery_stage_completed();
+
+ return;
+ }
+
+ Gee.ArrayList<Monitorable> all_candidates = new Gee.ArrayList<Monitorable>();
+ Gee.ArrayList<File> adopted = new Gee.ArrayList<File>(file_equal);
+ foreach (File file in unknown_files) {
+ FileInfo? info = get_file_info(file);
+ if (info == null)
+ continue;
+
+ // clear before using (reused as accumulator)
+ all_candidates.clear();
+
+ Gee.Collection<Monitorable>? candidates = null;
+ bool associated = false;
+ foreach (MediaMonitor monitor in monitors) {
+ MediaMonitor.DiscoveredFile result;
+ candidates = monitor.candidates_for_unknown_file(file, info, out result);
+ if (result == MediaMonitor.DiscoveredFile.REPRESENTED
+ || result == MediaMonitor.DiscoveredFile.IGNORE) {
+ associated = true;
+
+ break;
+ } else if (candidates != null) {
+ all_candidates.add_all(candidates);
+ }
+ }
+
+ if (associated) {
+ adopted.add(file);
+
+ continue;
+ }
+
+ // verify the matches with an MD5 comparison
+ if (all_candidates.size > 0) {
+ // copy for background thread
+ Gee.ArrayList<Monitorable> job_candidates = all_candidates;
+ all_candidates = new Gee.ArrayList<Monitorable>();
+
+ checksums_total++;
+ workers.enqueue(new FindMoveJob(this, file, job_candidates));
+ }
+
+ Idle.add(async_discovery_completed.callback);
+ yield;
+ }
+
+ // remove all adopted files from the unknown list
+ unknown_files.remove_all(adopted);
+
+ checksums_completed = 0;
+
+ if (checksums_total == 0) {
+ discovery_stage_completed();
+ } else {
+ mdbg("%d checksum jobs initiated to verify unknown photo files".printf(checksums_total));
+ auto_update_progress(checksums_completed, checksums_total);
+ }
+ }
+
+ private void report_checksum_job_completed() {
+ assert(checksums_completed < checksums_total);
+ checksums_completed++;
+
+ auto_update_progress(checksums_completed, checksums_total);
+
+ if (checksums_completed == checksums_total)
+ discovery_stage_completed();
+ }
+
+ private void on_find_move_completed(BackgroundJob j) {
+ FindMoveJob job = (FindMoveJob) j;
+
+ // if match was found, give file to the media and removed from both the unknown list and
+ // add to the discovered list ... do NOT mark losers as offline as other jobs may discover
+ // files that belong to them; discovery_stage_completed() will work this out in the end
+ if (job.match != null) {
+ mdbg("Found moved master file: %s matches %s".printf(job.file.get_path(),
+ job.match.to_string()));
+
+ MediaMonitor monitor = get_monitor_for_monitorable(job.match);
+ monitor.update_master_file(job.match, job.file);
+ unknown_files.remove(job.file);
+ add_to_discovered_list(monitor, job.match);
+ }
+
+ if (job.err != null)
+ warning("Unable to checksum unknown media file %s: %s", job.file.get_path(), job.err.message);
+
+ report_checksum_job_completed();
+ }
+
+ private void on_find_move_cancelled(BackgroundJob j) {
+ report_checksum_job_completed();
+ }
+
+ private void discovery_stage_completed() {
+ foreach (MediaMonitor monitor in monitors) {
+ Gee.Set<Monitorable>? monitorables = discovered.get(monitor);
+ if (monitorables != null) {
+ foreach (Monitorable monitorable in monitorables)
+ enqueue_verify_monitorable(monitorable, monitor);
+ }
+
+ foreach (DataObject object in monitor.get_media_source_collection().get_all()) {
+ Monitorable monitorable = (Monitorable) object;
+
+ if (monitorables != null && monitorables.contains(monitorable))
+ continue;
+
+ enqueue_verify_monitorable(monitorable, monitor);
+ }
+
+ foreach (DataSource source in
+ monitor.get_media_source_collection().get_offline_bin().get_all()) {
+ Monitorable monitorable = (Monitorable) source;
+
+ if (monitorables != null && monitorables.contains(monitorable))
+ continue;
+
+ enqueue_verify_monitorable(monitorable, monitor);
+ }
+ }
+
+ // enqueue all remaining unknown photo files for import
+ if (auto_import)
+ enqueue_import_many(unknown_files);
+
+ // release refs
+ discovered = null;
+ unknown_files = null;
+
+ // Now that the discovery is completed, launch a scan of the tombstoned files and see if
+ // they can be resurrected
+ Tombstone.global.launch_scan(this, cancellable);
+
+ // Only report discovery completed here, after all the other background work is done
+ base.discovery_completed();
+ }
+
+ private void enqueue_verify_monitorable(Monitorable monitorable, MediaMonitor monitor) {
+ bool offered = verify_queue.offer(new VerifyJob(monitorable, monitor));
+ assert(offered);
+
+ execute_next_verify_job();
+ }
+
+ private void execute_next_verify_job() {
+ if (outstanding_verify_jobs >= MAX_VERIFY_EXISTING_MEDIA_JOBS || verify_queue.size == 0)
+ return;
+
+ VerifyJob? job = verify_queue.poll();
+ assert(job != null);
+
+ outstanding_verify_jobs++;
+ verify_monitorable.begin(job.monitorable, job.monitor);
+ }
+
+ private async void verify_monitorable(Monitorable monitorable, MediaMonitor monitor) {
+ File[] files = new File[1];
+ files[0] = monitor.get_master_file(monitorable);
+
+ File[]? aux_files = monitor.get_auxilliary_backing_files(monitorable);
+ if (aux_files != null) {
+ foreach (File aux_file in aux_files)
+ files += aux_file;
+ }
+
+ for (int ctr = 0; ctr < files.length; ctr++) {
+ File file = files[ctr];
+
+ FileInfo? info = get_file_info(file);
+ if (info == null) {
+ try {
+ info = yield file.query_info_async(SUPPLIED_ATTRIBUTES, FILE_INFO_FLAGS,
+ DEFAULT_PRIORITY, cancellable);
+ } catch (Error err) {
+ // ignore, this happens when file is not found
+ }
+ }
+
+ // if master file, control online/offline state
+ if (ctr == 0) {
+ if (info != null && monitor.is_offline(monitorable))
+ monitor.update_online(monitorable);
+ else if (info == null && !monitor.is_offline(monitorable))
+ monitor.update_offline(monitorable);
+ }
+
+ monitor.update_backing_file_info(monitorable, file, info);
+ }
+
+ completed_monitorable_verifies++;
+ auto_update_progress(completed_monitorable_verifies, total_monitorable_verifies);
+
+ Idle.add(verify_monitorable.callback, DEFAULT_PRIORITY);
+ yield;
+
+ // finished, move on to the next job in the queue
+ assert(outstanding_verify_jobs > 0);
+ outstanding_verify_jobs--;
+
+ execute_next_verify_job();
+ }
+
+ private void on_config_changed() {
+ bool value = Config.Facade.get_instance().get_auto_import_from_library();
+
+ if (auto_import == value)
+ return;
+
+ auto_import = value;
+ if (auto_import) {
+ if (!CommandlineOptions.no_runtime_monitoring)
+ import_unrepresented_files();
+ } else {
+ cancel_batch_imports();
+ }
+ }
+
+ private void enqueue_import(File file) {
+ if (!pending_imports.contains(file) && is_supported_filetype(file) && !is_blacklisted(file))
+ import_queue.add(file);
+ }
+
+ private void enqueue_import_many(Gee.Collection<File> files) {
+ foreach (File file in files)
+ enqueue_import(file);
+ }
+
+ private void remove_queued_import(File file) {
+ import_queue.remove(file);
+ }
+
+ private bool on_flush_import_queue() {
+ if (cancellable.is_cancelled())
+ return false;
+
+ if (import_queue.size == 0)
+ return true;
+
+ // if currently importing, wait for it to finish before starting next one; this maximizes
+ // the number of items submitted each time
+ if (current_batch_import != null)
+ return true;
+
+ mdbg("Auto-importing %d files".printf(import_queue.size));
+
+ // If no import roll, or it's been over IMPORT_ROLL_QUIET_SEC since using the last one,
+ // create a new one. This allows for multiple files to come in back-to-back and be
+ // imported on the same roll.
+ time_t now = (time_t) now_sec();
+ if (current_import_roll == null || (now - last_import_roll_use) >= IMPORT_ROLL_QUIET_SEC)
+ current_import_roll = new BatchImportRoll();
+ last_import_roll_use = now;
+
+ Gee.ArrayList<BatchImportJob> jobs = new Gee.ArrayList<BatchImportJob>();
+ foreach (File file in import_queue) {
+ if (is_blacklisted(file))
+ continue;
+
+ jobs.add(new FileImportJob(file, false));
+ pending_imports.add(file);
+ }
+
+ import_queue.clear();
+
+ BatchImport importer = new BatchImport(jobs, "LibraryMonitor autoimport",
+ null, null, null, null, current_import_roll);
+ importer.set_untrash_duplicates(false);
+ importer.set_mark_duplicates_online(false);
+ batch_import_queue.add(importer);
+
+ schedule_next_batch_import();
+
+ return true;
+ }
+
+ private void schedule_next_batch_import() {
+ if (current_batch_import != null || batch_import_queue.size == 0)
+ return;
+
+ current_batch_import = batch_import_queue[0];
+ current_batch_import.preparing.connect(on_import_preparing);
+ current_batch_import.progress.connect(on_import_progress);
+ current_batch_import.import_complete.connect(on_import_complete);
+ current_batch_import.schedule();
+ }
+
+ private void discard_current_batch_import() {
+ assert(current_batch_import != null);
+
+ bool removed = batch_import_queue.remove(current_batch_import);
+ assert(removed);
+ current_batch_import.preparing.disconnect(on_import_preparing);
+ current_batch_import.progress.disconnect(on_import_progress);
+ current_batch_import.import_complete.disconnect(on_import_complete);
+ current_batch_import = null;
+
+ // a "proper" way to do this would be a complex data structure that stores the association
+ // of every file to its BatchImport and removes it from the pending_imports Set when
+ // the BatchImport completes, cancelled or not (the removal using manifest.all in
+ // on_import_completed doesn't catch files not imported due to cancellation) ... but, since
+ // individual BatchImports can't be cancelled, only all of them, this works
+ if (batch_import_queue.size == 0)
+ pending_imports.clear();
+ }
+
+ private void cancel_batch_imports() {
+ // clear everything queued up (that is not the current batch import)
+ int ctr = 0;
+ while (ctr < batch_import_queue.size) {
+ if (batch_import_queue[ctr] == current_batch_import) {
+ ctr++;
+
+ continue;
+ }
+
+ batch_import_queue.remove(batch_import_queue[ctr]);
+ }
+
+ // cancel the current import and remove it when the completion is called
+ if (current_batch_import != null)
+ current_batch_import.user_halt();
+
+ // remove all pending so if a new import comes in, it won't be skipped
+ pending_imports.clear();
+ }
+
+ private void on_import_preparing() {
+ auto_import_preparing();
+ }
+
+ private void on_import_progress(uint64 completed_bytes, uint64 total_bytes) {
+ auto_import_progress(completed_bytes, total_bytes);
+ }
+
+ private void on_import_complete(BatchImport batch_import, ImportManifest manifest,
+ BatchImportRoll import_roll) {
+ assert(batch_import == current_batch_import);
+
+ mdbg("auto-import batch completed %d".printf(manifest.all.size));
+ auto_import_progress(0, 0);
+
+ foreach (BatchImportResult result in manifest.all) {
+ // don't verify the pending_imports file is removed, it can be removed if the import
+ // was cancelled
+ if (result.file != null)
+ pending_imports.remove(result.file);
+ }
+
+ if (manifest.already_imported.size > 0) {
+ Gee.ArrayList<TombstonedFile> to_tombstone = new Gee.ArrayList<TombstonedFile>();
+ foreach (BatchImportResult result in manifest.already_imported) {
+ FileInfo? info = get_file_info(result.file);
+ if (info == null) {
+ warning("Unable to get info for duplicate file %s", result.file.get_path());
+
+ continue;
+ }
+
+ to_tombstone.add(new TombstonedFile(result.file, info.get_size(), null));
+ }
+
+ try {
+ Tombstone.entomb_many_files(to_tombstone, Tombstone.Reason.AUTO_DETECTED_DUPLICATE);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+ }
+
+ mdbg("%d files remain pending for auto-import".printf(pending_imports.size));
+
+ discard_current_batch_import();
+ schedule_next_batch_import();
+ }
+
+ //
+ // Real-time monitoring & auto-import
+ //
+
+ // USE WITH CARE. Because changes to the photo's state will not be updated as its backing
+ // file(s) change, it's possible for the library to diverge with what's on disk while the
+ // media source is blacklisted. If the media source is removed from the blacklist and
+ // unexpected state changes occur (such as file-altered being detected but not the file-create),
+ // the change will either be dropped on the floor or the state of the library will be
+ // indeterminate.
+ //
+ // Use of this method should be avoided at all costs (otherwise the point of the real-time
+ // monitor is negated).
+ public static void blacklist_file(File file, string reason) {
+ mdbg("[%s] Blacklisting %s".printf(reason, file.get_path()));
+ lock (blacklist) {
+ blacklist.add(file);
+ }
+ }
+
+ public static void unblacklist_file(File file) {
+ // don't want to immediately remove the blacklisted file because the monitoring events
+ // can come in much later
+ lock (blacklist) {
+ if (blacklist.contains(file) && !to_unblacklist.contains(file))
+ to_unblacklist.enqueue(file);
+ }
+ }
+
+ private static void on_unblacklist_file(File file) {
+ bool removed;
+ lock (blacklist) {
+ removed = blacklist.remove(file);
+ }
+
+ if (removed)
+ mdbg("Blacklist for %s removed".printf(file.get_path()));
+ else
+ warning("File %s was not blacklisted but unblacklisted", file.get_path());
+ }
+
+ public static bool is_blacklisted(File file) {
+ lock (blacklist) {
+ return blacklist.contains(file);
+ }
+ }
+
+ private bool is_supported_filetype(File file) {
+ return MediaCollectionRegistry.get_instance().get_collection_for_file(file) != null;
+ }
+
+ // NOTE: This only works when runtime monitoring is enabled. Otherwise, DirectoryMonitor will
+ // not be tracking files.
+ private void import_unrepresented_files() {
+ if (!auto_import)
+ return;
+
+ Gee.ArrayList<File> to_import = null;
+ foreach (File file in get_files()) {
+ FileInfo? info = get_file_info(file);
+ if (info == null || info.get_file_type() != FileType.REGULAR)
+ continue;
+
+ if (pending_imports.contains(file))
+ continue;
+
+ if (Tombstone.global.matches(file))
+ continue;
+
+ bool represented = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.is_file_represented(file)) {
+ represented = true;
+
+ break;
+ }
+ }
+
+ if (represented)
+ continue;
+
+ if (!is_supported_filetype(file))
+ continue;
+
+ if (to_import == null)
+ to_import = new Gee.ArrayList<File>(file_equal);
+
+ to_import.add(file);
+ }
+
+ if (to_import != null)
+ enqueue_import_many(to_import);
+ }
+
+ // It's possible for the monitor to miss a file create but report other activities, which we
+ // can use to pick up new files
+ private void runtime_unknown_file_discovered(File file) {
+ if (auto_import && is_supported_filetype(file) && !Tombstone.global.matches(file)) {
+ mdbg("Unknown file %s discovered, enqueuing for import".printf(file.get_path()));
+ enqueue_import(file);
+ }
+ }
+
+ protected override void notify_file_created(File file, FileInfo info) {
+ if (is_blacklisted(file)) {
+ base.notify_file_created(file, info);
+
+ return;
+ }
+
+ bool known = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.notify_file_created(file, info)) {
+ known = true;
+
+ break;
+ }
+ }
+
+ if (!known) {
+ // attempt to match the new file with a Monitorable that is offline
+ Gee.HashSet<Monitorable> all_candidates = null;
+ foreach (MediaMonitor monitor in monitors) {
+ MediaMonitor.DiscoveredFile result;
+ Gee.Collection<Monitorable>? candidates = monitor.candidates_for_unknown_file(file,
+ info, out result);
+ if (result == MediaMonitor.DiscoveredFile.REPRESENTED ||
+ result == MediaMonitor.DiscoveredFile.IGNORE) {
+ mdbg("%s %s created file %s".printf(monitor.to_string(), result.to_string(),
+ file.get_path()));
+
+ known = true;
+
+ break;
+ } else if (candidates != null && candidates.size > 0) {
+ mdbg("%s suggests %d candidates for created file %s".printf(monitor.to_string(),
+ candidates.size, file.get_path()));
+
+ if (all_candidates == null)
+ all_candidates = new Gee.HashSet<Monitorable>();
+
+ foreach (Monitorable candidate in candidates) {
+ if (monitor.is_offline(candidate))
+ all_candidates.add(candidate);
+ }
+ }
+ }
+
+ if (!known && all_candidates != null && all_candidates.size > 0) {
+ mdbg("%d candidates for created file %s being checksummed".printf(all_candidates.size,
+ file.get_path()));
+
+ workers.enqueue(new RuntimeFindMoveJob(this, file, all_candidates));
+ // mark as known to avoid adding file for possible import
+ known = true;
+ }
+ }
+
+ if (!known)
+ runtime_unknown_file_discovered(file);
+
+ base.notify_file_created(file, info);
+ }
+
+ private void on_runtime_find_move_completed(BackgroundJob j) {
+ RuntimeFindMoveJob job = (RuntimeFindMoveJob) j;
+
+ if (job.err != null) {
+ critical("Error attempting to find a match at runtime for %s: %s", job.file.get_path(),
+ job.err.message);
+ }
+
+ if (job.match != null) {
+ MediaMonitor monitor = get_monitor_for_monitorable(job.match);
+ monitor.update_master_file(job.match, job.file);
+ monitor.update_online(job.match);
+ } else {
+ // no match found, mark file for possible import
+ runtime_unknown_file_discovered(job.file);
+ }
+ }
+
+ protected override void notify_file_moved(File old_file, File new_file, FileInfo new_info) {
+ if (is_blacklisted(old_file) || is_blacklisted(new_file)) {
+ base.notify_file_moved(old_file, new_file, new_info);
+
+ return;
+ }
+
+ bool known = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.notify_file_moved(old_file, new_file, new_info)) {
+ known = true;
+
+ break;
+ }
+ }
+
+ if (!known)
+ runtime_unknown_file_discovered(new_file);
+
+ base.notify_file_moved(old_file, new_file, new_info);
+ }
+
+ protected override void notify_file_altered(File file) {
+ if (is_blacklisted(file)) {
+ base.notify_file_altered(file);
+
+ return;
+ }
+
+ bool known = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.notify_file_altered(file)) {
+ known = true;
+
+ break;
+ }
+ }
+
+ if (!known)
+ runtime_unknown_file_discovered(file);
+
+ base.notify_file_altered(file);
+ }
+
+ protected override void notify_file_attributes_altered(File file) {
+ if (is_blacklisted(file)) {
+ base.notify_file_attributes_altered(file);
+
+ return;
+ }
+
+ bool known = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.notify_file_attributes_altered(file)) {
+ known = true;
+
+ break;
+ }
+ }
+
+ if (!known)
+ runtime_unknown_file_discovered(file);
+
+ base.notify_file_attributes_altered(file);
+ }
+
+ protected override void notify_file_alteration_completed(File file, FileInfo info) {
+ if (is_blacklisted(file)) {
+ base.notify_file_alteration_completed(file, info);
+
+ return;
+ }
+
+ bool known = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.notify_file_alteration_completed(file, info)) {
+ known = true;
+
+ break;
+ }
+ }
+
+ if (!known)
+ runtime_unknown_file_discovered(file);
+
+ base.notify_file_alteration_completed(file, info);
+ }
+
+ protected override void notify_file_deleted(File file) {
+ if (is_blacklisted(file)) {
+ base.notify_file_deleted(file);
+
+ return;
+ }
+
+ bool known = false;
+ foreach (MediaMonitor monitor in monitors) {
+ if (monitor.notify_file_deleted(file)) {
+ known = true;
+
+ break;
+ }
+ }
+
+ if (!known) {
+ // ressurrect tombstone if deleted
+ Tombstone? tombstone = Tombstone.global.locate(file);
+ if (tombstone != null) {
+ debug("Resurrecting tombstoned file %s", file.get_path());
+ Tombstone.global.resurrect(tombstone);
+ }
+
+ // remove from import queue
+ remove_queued_import(file);
+ }
+
+ base.notify_file_deleted(file);
+ }
+}
+
diff --git a/src/MediaDataRepresentation.vala b/src/MediaDataRepresentation.vala
new file mode 100644
index 0000000..6a54718
--- /dev/null
+++ b/src/MediaDataRepresentation.vala
@@ -0,0 +1,899 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class BackingFileState {
+ public string filepath;
+ public int64 filesize;
+ public time_t modification_time;
+ public string? md5;
+
+ public BackingFileState(string filepath, int64 filesize, time_t modification_time, string? md5) {
+ this.filepath = filepath;
+ this.filesize = filesize;
+ this.modification_time = modification_time;
+ this.md5 = md5;
+ }
+
+ public BackingFileState.from_photo_row(BackingPhotoRow photo_row, string? md5) {
+ this.filepath = photo_row.filepath;
+ this.filesize = photo_row.filesize;
+ this.modification_time = photo_row.timestamp;
+ this.md5 = md5;
+ }
+
+ public File get_file() {
+ return File.new_for_path(filepath);
+ }
+}
+
+public abstract class MediaSource : ThumbnailSource, Indexable {
+ public virtual signal void master_replaced(File old_file, File new_file) {
+ }
+
+ private Event? event = null;
+ private string? indexable_keywords = null;
+
+ public MediaSource(int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+ }
+
+ protected static inline uint64 internal_add_flags(uint64 flags, uint64 selector) {
+ return (flags | selector);
+ }
+
+ protected static inline uint64 internal_remove_flags(uint64 flags, uint64 selector) {
+ return (flags & ~selector);
+ }
+
+ protected static inline bool internal_is_flag_set(uint64 flags, uint64 selector) {
+ return ((flags & selector) != 0);
+ }
+
+ protected virtual void notify_master_replaced(File old_file, File new_file) {
+ master_replaced(old_file, new_file);
+ }
+
+ protected override void notify_altered(Alteration alteration) {
+ Alteration local = alteration;
+
+ if (local.has_detail("metadata", "name") || local.has_detail("backing", "master")) {
+ update_indexable_keywords();
+ local = local.compress(new Alteration("indexable", "keywords"));
+ }
+
+ base.notify_altered(local);
+ }
+
+ // use this method as a kind of post-constructor initializer; it means the DataSource has been
+ // added or removed to a SourceCollection.
+ protected override void notify_membership_changed(DataCollection? collection) {
+ if (collection != null && indexable_keywords == null) {
+ // don't fire the alteration here, as the MediaSource is only being added to its
+ // SourceCollection
+ update_indexable_keywords();
+ }
+
+ base.notify_membership_changed(collection);
+ }
+
+ private void update_indexable_keywords() {
+ string[] indexables = new string[3];
+ indexables[0] = get_title();
+ indexables[1] = get_basename();
+ indexables[2] = get_comment();
+
+ indexable_keywords = prepare_indexable_strings(indexables);
+ }
+
+ public unowned string? get_indexable_keywords() {
+ return indexable_keywords;
+ }
+
+ protected abstract bool set_event_id(EventID id);
+
+ protected bool delete_original_file() {
+ bool ret = false;
+ File file = get_master_file();
+
+ try {
+ ret = file.trash(null);
+ } catch (Error err) {
+ // log error but don't abend, as this is not fatal to operation (also, could be
+ // the photo is removed because it could not be found during a verify)
+ message("Unable to move original photo %s to trash: %s", file.get_path(), err.message);
+ }
+
+ // remove empty directories corresponding to imported path, but only if file is located
+ // inside the user's Pictures directory
+ if (file.has_prefix(AppDirs.get_import_dir())) {
+ File parent = file;
+ while (!parent.equal(AppDirs.get_import_dir())) {
+ parent = parent.get_parent();
+ if ((parent == null) || (parent.equal(AppDirs.get_import_dir())))
+ break;
+
+ try {
+ if (!query_is_directory_empty(parent))
+ break;
+ } catch (Error err) {
+ warning("Unable to query file info for %s: %s", parent.get_path(), err.message);
+
+ break;
+ }
+
+ try {
+ parent.delete(null);
+ debug("Deleted empty directory %s", parent.get_path());
+ } catch (Error err) {
+ // again, log error but don't abend
+ message("Unable to delete empty directory %s: %s", parent.get_path(),
+ err.message);
+ }
+ }
+ }
+
+ return ret;
+ }
+
+ public override string get_name() {
+ string? title = get_title();
+
+ return is_string_empty(title) ? get_basename() : title;
+ }
+
+ public virtual string get_basename() {
+ return get_file().get_basename();
+ }
+
+ public abstract File get_file();
+ public abstract File get_master_file();
+ public abstract uint64 get_master_filesize();
+ public abstract uint64 get_filesize();
+ public abstract time_t get_timestamp();
+
+ // Must return at least one, for the master file.
+ public abstract BackingFileState[] get_backing_files_state();
+
+ public abstract string? get_title();
+ public abstract string? get_comment();
+ public abstract void set_title(string? title);
+ public abstract bool set_comment(string? comment);
+
+ public static string? prep_title(string? title) {
+ return prepare_input_text(title,
+ PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.EMPTY_IS_NULL, DEFAULT_USER_TEXT_INPUT_LENGTH);
+ }
+
+ public static string? prep_comment(string? comment) {
+ return prepare_input_text(comment,
+ PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.STRIP_CRLF & ~PrepareInputTextOptions.EMPTY_IS_NULL, -1);
+ }
+
+ public abstract Rating get_rating();
+ public abstract void set_rating(Rating rating);
+ public abstract void increase_rating();
+ public abstract void decrease_rating();
+
+ public abstract Dimensions get_dimensions(Photo.Exception disallowed_steps = Photo.Exception.NONE);
+
+ // A preview pixbuf is one that can be quickly generated and scaled as a preview. For media
+ // type that support transformations (i.e. photos) it is fully transformed.
+ //
+ // Note that an unscaled scaling is not considered a performance-killer for this method,
+ // although the quality of the pixbuf may be quite poor compared to the actual unscaled
+ // transformed pixbuf.
+ public abstract Gdk.Pixbuf get_preview_pixbuf(Scaling scaling) throws Error;
+
+ public abstract bool is_trashed();
+ public abstract void trash();
+ public abstract void untrash();
+
+ public abstract bool is_offline();
+ public abstract void mark_offline();
+ public abstract void mark_online();
+
+ public abstract string get_master_md5();
+
+ // WARNING: some child classes of MediaSource (e.g. Photo) implement this method in a
+ // non-thread safe manner for efficiency.
+ public abstract EventID get_event_id();
+
+ public Event? get_event() {
+ if (event != null)
+ return event;
+
+ EventID event_id = get_event_id();
+ if (!event_id.is_valid())
+ return null;
+
+ event = Event.global.fetch(event_id);
+
+ return event;
+ }
+
+ public bool set_event(Event? new_event) {
+ EventID event_id = (new_event != null) ? new_event.get_event_id() : EventID();
+ if (get_event_id().id == event_id.id)
+ return true;
+
+ bool committed = set_event_id(event_id);
+ if (committed) {
+ if (event != null)
+ event.detach(this);
+
+ if (new_event != null)
+ new_event.attach(this);
+
+ event = new_event;
+
+ notify_altered(new Alteration("metadata", "event"));
+ }
+
+ return committed;
+ }
+
+ public static void set_many_to_event(Gee.Collection<MediaSource> media_sources, Event? event,
+ TransactionController controller) throws Error {
+ EventID event_id = (event != null) ? event.get_event_id() : EventID();
+
+ controller.begin();
+
+ foreach (MediaSource media in media_sources) {
+ Event? old_event = media.get_event();
+ if (old_event != null)
+ old_event.detach(media);
+
+ media.set_event_id(event_id);
+ media.event = event;
+ }
+
+ if (event != null)
+ event.attach_many(media_sources);
+
+ Alteration alteration = new Alteration("metadata", "event");
+ foreach (MediaSource media in media_sources)
+ media.notify_altered(alteration);
+
+ controller.commit();
+ }
+
+ public abstract time_t get_exposure_time();
+
+ public abstract ImportID get_import_id();
+}
+
+public class MediaSourceHoldingTank : DatabaseSourceHoldingTank {
+ private Gee.HashMap<File, MediaSource> master_file_map = new Gee.HashMap<File, MediaSource>(
+ file_hash, file_equal);
+
+ public MediaSourceHoldingTank(MediaSourceCollection sources,
+ SourceHoldingTank.CheckToKeep check_to_keep, GetSourceDatabaseKey get_key) {
+ base (sources, check_to_keep, get_key);
+ }
+
+ public MediaSource? fetch_by_master_file(File file) {
+ return master_file_map.get(file);
+ }
+
+ public MediaSource? fetch_by_md5(string md5) {
+ foreach (MediaSource source in master_file_map.values) {
+ if (source.get_master_md5() == md5) {
+ return source;
+ }
+ }
+
+ return null;
+ }
+
+ protected override void notify_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ if (added != null) {
+ foreach (DataSource source in added) {
+ MediaSource media_source = (MediaSource) source;
+ master_file_map.set(media_source.get_master_file(), media_source);
+ media_source.master_replaced.connect(on_master_source_replaced);
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataSource source in removed) {
+ MediaSource media_source = (MediaSource) source;
+ bool is_removed = master_file_map.unset(media_source.get_master_file());
+ assert(is_removed);
+ media_source.master_replaced.disconnect(on_master_source_replaced);
+ }
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ private void on_master_source_replaced(MediaSource media_source, File old_file, File new_file) {
+ bool removed = master_file_map.unset(old_file);
+ assert(removed);
+
+ master_file_map.set(new_file, media_source);
+ }
+}
+
+// This class is good for any MediaSourceCollection that is backed by a DatabaseTable (which should
+// be all of them, but if not, they should construct their own implementation).
+public class MediaSourceTransactionController : TransactionController {
+ private MediaSourceCollection sources;
+
+ public MediaSourceTransactionController(MediaSourceCollection sources) {
+ this.sources = sources;
+ }
+
+ protected override void begin_impl() throws Error {
+ DatabaseTable.begin_transaction();
+ sources.freeze_notifications();
+ }
+
+ protected override void commit_impl() throws Error {
+ sources.thaw_notifications();
+ DatabaseTable.commit_transaction();
+ }
+}
+
+public abstract class MediaSourceCollection : DatabaseSourceCollection {
+ public abstract TransactionController transaction_controller { get; }
+
+ private MediaSourceHoldingTank trashcan = null;
+ private MediaSourceHoldingTank offline_bin = null;
+ private Gee.HashMap<File, MediaSource> by_master_file = new Gee.HashMap<File, MediaSource>(
+ file_hash, file_equal);
+ private Gee.MultiMap<ImportID?, MediaSource> import_rolls =
+ new Gee.TreeMultiMap<ImportID?, MediaSource>(ImportID.compare_func);
+ private Gee.TreeSet<ImportID?> sorted_import_ids = new Gee.TreeSet<ImportID?>(ImportID.compare_func);
+ private Gee.Set<MediaSource> flagged = new Gee.HashSet<MediaSource>();
+
+ // This signal is fired when MediaSources are added to the collection due to a successful import.
+ // "items-added" and "contents-altered" will follow.
+ public virtual signal void media_import_starting(Gee.Collection<MediaSource> media) {
+ }
+
+ // This signal is fired when MediaSources have been added to the collection due to a successful
+ // import and import postprocessing has completed (such as adding an import Photo to its Tags).
+ // Thus, signals that have already been fired (in this order) are "media-imported", "items-added",
+ // "contents-altered" before this signal.
+ public virtual signal void media_import_completed(Gee.Collection<MediaSource> media) {
+ }
+
+ public virtual signal void master_file_replaced(MediaSource media, File old_file, File new_file) {
+ }
+
+ public virtual signal void trashcan_contents_altered(Gee.Collection<MediaSource>? added,
+ Gee.Collection<MediaSource>? removed) {
+ }
+
+ public virtual signal void import_roll_altered() {
+ }
+
+ public virtual signal void offline_contents_altered(Gee.Collection<MediaSource>? added,
+ Gee.Collection<MediaSource>? removed) {
+ }
+
+ public virtual signal void flagged_contents_altered() {
+ }
+
+ public MediaSourceCollection(string name, GetSourceDatabaseKey source_key_func) {
+ base(name, source_key_func);
+
+ trashcan = create_trashcan();
+ offline_bin = create_offline_bin();
+ }
+
+ public static void filter_media(Gee.Collection<MediaSource> media,
+ Gee.Collection<LibraryPhoto>? photos, Gee.Collection<Video>? videos) {
+ foreach (MediaSource source in media) {
+ if (photos != null && source is LibraryPhoto)
+ photos.add((LibraryPhoto) source);
+ else if (videos != null && source is Video)
+ videos.add((Video) source);
+ else if (photos != null || videos != null)
+ warning("Unrecognized media: %s", source.to_string());
+ }
+ }
+
+ public static void count_media(Gee.Collection<MediaSource> media, out int photo_count,
+ out int video_count) {
+ Gee.ArrayList<MediaSource> photos = new Gee.ArrayList<MediaSource>();
+ Gee.ArrayList<MediaSource> videos = new Gee.ArrayList<MediaSource>();
+
+ filter_media(media, photos, videos);
+
+ photo_count = photos.size;
+ video_count = videos.size;
+ }
+
+ public static bool has_photo(Gee.Collection<MediaSource> media) {
+ foreach (MediaSource current_media in media) {
+ if (current_media is Photo) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static bool has_video(Gee.Collection<MediaSource> media) {
+ foreach (MediaSource current_media in media) {
+ if (current_media is Video) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected abstract MediaSourceHoldingTank create_trashcan();
+
+ protected abstract MediaSourceHoldingTank create_offline_bin();
+
+ public abstract MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable);
+
+ public abstract string get_typename();
+
+ public abstract bool is_file_recognized(File file);
+
+ public MediaSourceHoldingTank get_trashcan() {
+ return trashcan;
+ }
+
+ public MediaSourceHoldingTank get_offline_bin() {
+ return offline_bin;
+ }
+
+ // NOTE: numeric id's are not unique throughout the system -- they're only unique
+ // per media type. So a MediaSourceCollection should only ever hold media
+ // of the same type.
+ protected abstract MediaSource? fetch_by_numeric_id(int64 numeric_id);
+
+ protected virtual void notify_import_roll_altered() {
+ import_roll_altered();
+ }
+
+ protected virtual void notify_flagged_contents_altered() {
+ flagged_contents_altered();
+ }
+
+ protected virtual void notify_media_import_starting(Gee.Collection<MediaSource> media) {
+ media_import_starting(media);
+ }
+
+ protected virtual void notify_media_import_completed(Gee.Collection<MediaSource> media) {
+ media_import_completed(media);
+ }
+
+ protected override void items_altered(Gee.Map<DataObject, Alteration> items) {
+ Gee.ArrayList<MediaSource> to_trashcan = null;
+ Gee.ArrayList<MediaSource> to_offline = null;
+ bool flagged_altered = false;
+ foreach (DataObject object in items.keys) {
+ Alteration alteration = items.get(object);
+ MediaSource source = (MediaSource) object;
+
+ if (!alteration.has_subject("metadata"))
+ continue;
+
+ if (source.is_trashed() && !get_trashcan().contains(source)) {
+ if (to_trashcan == null)
+ to_trashcan = new Gee.ArrayList<MediaSource>();
+
+ to_trashcan.add(source);
+
+ // sources can only be in trashcan or offline -- not both
+ continue;
+ }
+
+ if (source.is_offline() && !get_offline_bin().contains(source)) {
+ if (to_offline == null)
+ to_offline = new Gee.ArrayList<MediaSource>();
+
+ to_offline.add(source);
+ }
+
+ Flaggable? flaggable = source as Flaggable;
+ if (flaggable != null) {
+ if (flaggable.is_flagged())
+ flagged_altered = flagged.add(source) || flagged_altered;
+ else
+ flagged_altered = flagged.remove(source) || flagged_altered;
+ }
+ }
+
+ if (to_trashcan != null)
+ get_trashcan().unlink_and_hold(to_trashcan);
+
+ if (to_offline != null)
+ get_offline_bin().unlink_and_hold(to_offline);
+
+ if (flagged_altered)
+ notify_flagged_contents_altered();
+
+ base.items_altered(items);
+ }
+
+ protected override void notify_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ bool import_roll_changed = false;
+ bool flagged_altered = false;
+ if (added != null) {
+ foreach (DataObject object in added) {
+ MediaSource media = (MediaSource) object;
+
+ by_master_file.set(media.get_master_file(), media);
+ media.master_replaced.connect(on_master_replaced);
+
+ ImportID import_id = media.get_import_id();
+ if (import_id.is_valid()) {
+ sorted_import_ids.add(import_id);
+ import_rolls.set(import_id, media);
+
+ import_roll_changed = true;
+ }
+
+ Flaggable? flaggable = media as Flaggable;
+ if (flaggable != null ) {
+ if (flaggable.is_flagged())
+ flagged_altered = flagged.add(media) || flagged_altered;
+ else
+ flagged_altered = flagged.remove(media) || flagged_altered;
+ }
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed) {
+ MediaSource media = (MediaSource) object;
+
+ bool is_removed = by_master_file.unset(media.get_master_file());
+ assert(is_removed);
+ media.master_replaced.disconnect(on_master_replaced);
+
+ ImportID import_id = media.get_import_id();
+ if (import_id.is_valid()) {
+ is_removed = import_rolls.remove(import_id, media);
+ assert(is_removed);
+ if (!import_rolls.contains(import_id))
+ sorted_import_ids.remove(import_id);
+
+ import_roll_changed = true;
+ }
+
+ flagged_altered = flagged.remove(media) || flagged_altered;
+ }
+ }
+
+ if (import_roll_changed)
+ notify_import_roll_altered();
+
+ if (flagged_altered)
+ notify_flagged_contents_altered();
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ private void on_master_replaced(MediaSource media, File old_file, File new_file) {
+ bool is_removed = by_master_file.unset(old_file);
+ assert(is_removed);
+
+ by_master_file.set(new_file, media);
+
+ master_file_replaced(media, old_file, new_file);
+ }
+
+ public MediaSource? fetch_by_master_file(File file) {
+ return by_master_file.get(file);
+ }
+
+ public virtual MediaSource? fetch_by_source_id(string source_id) {
+ string[] components = source_id.split("-");
+ assert(components.length == 2);
+
+ return fetch_by_numeric_id(parse_int64(components[1], 16));
+ }
+
+ public abstract Gee.Collection<string> get_event_source_ids(EventID event_id);
+
+ public Gee.Collection<MediaSource> get_trashcan_contents() {
+ return (Gee.Collection<MediaSource>) get_trashcan().get_all();
+ }
+
+ public Gee.Collection<MediaSource> get_offline_bin_contents() {
+ return (Gee.Collection<MediaSource>) get_offline_bin().get_all();
+ }
+
+ public Gee.Collection<MediaSource> get_flagged() {
+ return flagged.read_only_view;
+ }
+
+ // The returned set of ImportID's is sorted from oldest to newest.
+ public Gee.SortedSet<ImportID?> get_import_roll_ids() {
+ return sorted_import_ids;
+ }
+
+ public ImportID? get_last_import_id() {
+ return sorted_import_ids.size != 0 ? sorted_import_ids.last() : null;
+ }
+
+ public Gee.Collection<MediaSource?>? get_import_roll(ImportID import_id) {
+ return import_rolls.get(import_id);
+ }
+
+ public void add_many_to_trash(Gee.Collection<MediaSource> sources) {
+ get_trashcan().add_many(sources);
+ }
+
+ public void add_many_to_offline(Gee.Collection<MediaSource> sources) {
+ get_offline_bin().add_many(sources);
+ }
+
+ public int get_trashcan_count() {
+ return get_trashcan().get_count();
+ }
+
+ // This method should be used in place of add_many() when adding MediaSources due to a successful
+ // import. This function fires appropriate signals and calls add_many(), so the signals
+ // associated with that call will be fired too.
+ public virtual void import_many(Gee.Collection<MediaSource> media) {
+ notify_media_import_starting(media);
+
+ add_many(media);
+
+ postprocess_imported_media(media);
+
+ notify_media_import_completed(media);
+ }
+
+ // Child classes can override this method to perform postprocessing on a imported media, such
+ // as associating them with tags or events.
+ protected virtual void postprocess_imported_media(Gee.Collection<MediaSource> media) {
+ }
+
+ // This operation cannot be cancelled; the return value of the ProgressMonitor is ignored.
+ // Note that delete_backing dictates whether or not the photos are tombstoned (if deleted,
+ // tombstones are not created).
+ public void remove_from_app(Gee.Collection<MediaSource>? sources, bool delete_backing,
+ ProgressMonitor? monitor = null, Gee.List<MediaSource>? not_removed = null) {
+ assert(sources != null);
+ // only tombstone if the backing is not being deleted
+ Gee.HashSet<MediaSource> to_tombstone = !delete_backing ? new Gee.HashSet<MediaSource>() : null;
+
+ // separate photos into two piles: those in the trash and those not
+ Gee.ArrayList<MediaSource> trashed = new Gee.ArrayList<MediaSource>();
+ Gee.ArrayList<MediaSource> offlined = new Gee.ArrayList<MediaSource>();
+ Gee.ArrayList<MediaSource> not_trashed = new Gee.ArrayList<MediaSource>();
+ foreach (MediaSource source in sources) {
+ if (source.is_trashed())
+ trashed.add(source);
+ else if (source.is_offline())
+ offlined.add(source);
+ else
+ not_trashed.add(source);
+
+ if (to_tombstone != null)
+ to_tombstone.add(source);
+ }
+
+ int total_count = sources.size;
+ assert(total_count == (trashed.size + offlined.size + not_trashed.size));
+
+ // use an aggregate progress monitor, as it's possible there are three steps here
+ AggregateProgressMonitor agg_monitor = null;
+ if (monitor != null) {
+ agg_monitor = new AggregateProgressMonitor(total_count, monitor);
+ monitor = agg_monitor.monitor;
+ }
+
+ if (trashed.size > 0)
+ get_trashcan().destroy_orphans(trashed, delete_backing, monitor, not_removed);
+
+ if (offlined.size > 0)
+ get_offline_bin().destroy_orphans(offlined, delete_backing, monitor, not_removed);
+
+ // untrashed media sources may be destroyed outright
+ if (not_trashed.size > 0)
+ destroy_marked(mark_many(not_trashed), delete_backing, monitor, not_removed);
+
+ if (to_tombstone != null && to_tombstone.size > 0) {
+ try {
+ Tombstone.entomb_many_sources(to_tombstone, Tombstone.Reason.REMOVED_BY_USER);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+ }
+ }
+
+ // Deletes (i.e. not trashes) the backing files.
+ // Note: must be removed from DB first.
+ public void delete_backing_files(Gee.Collection<MediaSource> sources,
+ ProgressMonitor? monitor = null, Gee.List<MediaSource>? not_deleted = null) {
+ int total_count = sources.size;
+ int i = 1;
+
+ foreach (MediaSource source in sources) {
+ File file = source.get_file();
+ try {
+ file.delete(null);
+ } catch (Error err) {
+ // Note: we may get an exception even though the delete succeeded.
+ debug("Exception deleting file %s: %s", file.get_path(), err.message);
+ }
+
+ bool deleted = !file.query_exists();
+ if (!deleted && null != not_deleted) {
+ not_deleted.add(source);
+ }
+
+ if (monitor != null) {
+ monitor(i, total_count);
+ }
+ i++;
+ }
+ }
+}
+
+public class MediaCollectionRegistry {
+ private const int LIBRARY_MONITOR_START_DELAY_MSEC = 1000;
+
+ private static MediaCollectionRegistry? instance = null;
+
+ private Gee.ArrayList<MediaSourceCollection> all = new Gee.ArrayList<MediaSourceCollection>();
+ private Gee.HashMap<string, MediaSourceCollection> by_typename =
+ new Gee.HashMap<string, MediaSourceCollection>();
+
+ private MediaCollectionRegistry() {
+ Application.get_instance().init_done.connect(on_init_done);
+ }
+
+ ~MediaCollectionRegistry() {
+ Application.get_instance().init_done.disconnect(on_init_done);
+ }
+
+ private void on_init_done() {
+ // install the default library monitor
+ LibraryMonitor library_monitor = new LibraryMonitor(AppDirs.get_import_dir(), true,
+ !CommandlineOptions.no_runtime_monitoring);
+
+ LibraryMonitorPool.get_instance().replace(library_monitor, LIBRARY_MONITOR_START_DELAY_MSEC);
+ }
+
+ public static void init() {
+ instance = new MediaCollectionRegistry();
+ Config.Facade.get_instance().import_directory_changed.connect(on_import_directory_changed);
+ }
+
+ public static void terminate() {
+ Config.Facade.get_instance().import_directory_changed.disconnect(on_import_directory_changed);
+ }
+
+ private static void on_import_directory_changed() {
+ File import_dir = AppDirs.get_import_dir();
+
+ LibraryMonitor? current = LibraryMonitorPool.get_instance().get_monitor();
+ if (current != null && current.get_root().equal(import_dir))
+ return;
+
+ LibraryMonitor replacement = new LibraryMonitor(import_dir, true,
+ !CommandlineOptions.no_runtime_monitoring);
+ LibraryMonitorPool.get_instance().replace(replacement, LIBRARY_MONITOR_START_DELAY_MSEC);
+ }
+
+ public static MediaCollectionRegistry get_instance() {
+ return instance;
+ }
+
+ public static string get_typename_from_source_id(string source_id) {
+ // we have to special-case photos because their source id format is non-standard. this
+ // is due to a historical quirk.
+ if (source_id.has_prefix(Photo.TYPENAME)) {
+ return Photo.TYPENAME;
+ } else {
+ string[] components = source_id.split("-");
+ assert(components.length == 2);
+
+ return components[0];
+ }
+ }
+
+ public void register_collection(MediaSourceCollection collection) {
+ all.add(collection);
+ by_typename.set(collection.get_typename(), collection);
+ }
+
+ // NOTE: going forward, please use get_collection( ) and get_all_collections( ) to get the
+ // collection associated with a specific media type or to get all registered collections,
+ // respectively, instead of explicitly referencing Video.global and LibraryPhoto.global.
+ // This will make it *much* easier to add new media types in the future.
+ public MediaSourceCollection? get_collection(string typename) {
+ return by_typename.get(typename);
+ }
+
+ public Gee.Collection<MediaSourceCollection> get_all() {
+ return all.read_only_view;
+ }
+
+ public void freeze_all() {
+ foreach (MediaSourceCollection sources in get_all())
+ sources.freeze_notifications();
+ }
+
+ public void thaw_all() {
+ foreach (MediaSourceCollection sources in get_all())
+ sources.thaw_notifications();
+ }
+
+ public void begin_transaction_on_all() {
+ foreach (MediaSourceCollection sources in get_all())
+ sources.transaction_controller.begin();
+ }
+
+ public void commit_transaction_on_all() {
+ foreach (MediaSourceCollection sources in get_all())
+ sources.transaction_controller.commit();
+ }
+
+ public MediaSource? fetch_media(string source_id) {
+ string typename = get_typename_from_source_id(source_id);
+
+ MediaSourceCollection? collection = get_collection(typename);
+ if (collection == null) {
+ critical("source id '%s' has unrecognized media type '%s'", source_id, typename);
+ return null;
+ }
+
+ return collection.fetch_by_source_id(source_id);
+ }
+
+ public ImportID? get_last_import_id() {
+ ImportID last_import_id = ImportID();
+
+ foreach (MediaSourceCollection current_collection in get_all()) {
+ ImportID? current_import_id = current_collection.get_last_import_id();
+
+ if (current_import_id == null)
+ continue;
+
+ if (current_import_id.id > last_import_id.id)
+ last_import_id = current_import_id;
+ }
+
+ // VALA: can't use the ternary operator here because of bug 616897 : "Mixed nullability in
+ // ternary operator fails"
+ if (last_import_id.id == ImportID.INVALID)
+ return null;
+ else
+ return last_import_id;
+ }
+
+ public Gee.Collection<string> get_source_ids_for_event_id(EventID event_id) {
+ Gee.ArrayList<string> result = new Gee.ArrayList<string>();
+
+ foreach (MediaSourceCollection current_collection in get_all()) {
+ result.add_all(current_collection.get_event_source_ids(event_id));
+ }
+
+ return result;
+ }
+
+ public MediaSourceCollection? get_collection_for_file(File file) {
+ foreach (MediaSourceCollection collection in get_all()) {
+ if (collection.is_file_recognized(file))
+ return collection;
+ }
+
+ return null;
+ }
+
+ public bool is_valid_source_id(string? source_id) {
+ if (is_string_empty(source_id)) {
+ return false;
+ }
+ return (source_id.has_prefix(Photo.TYPENAME) || source_id.has_prefix(Video.TYPENAME + "-"));
+ }
+}
+
diff --git a/src/MediaInterfaces.vala b/src/MediaInterfaces.vala
new file mode 100644
index 0000000..96c5d49
--- /dev/null
+++ b/src/MediaInterfaces.vala
@@ -0,0 +1,215 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+//
+// Going forward, Shotwell will use MediaInterfaces, which allow for various operations and features
+// to be added only to the MediaSources that support them (or make sense for). For example, adding
+// a library-mode photo or video to an Event makes perfect sense, but does not make sense for a
+// direct-mode photo. All three are MediaSources, and to make DirectPhoto descend from another
+// base class is only inviting chaos and a tremendous amount of replicated code.
+//
+// A key point to make of all MediaInterfaces is that they require MediaSource as a base class.
+// Thus, any code dealing with one of these interfaces knows they are also dealing with a
+// MediaSource.
+//
+// TODO: Make Eventable and Taggable interfaces, which are the only types Event and Tag will deal
+// with (rather than MediaSources).
+//
+// TODO: Make Trashable interface, which are much like Flaggable.
+//
+// TODO: ContainerSources may also have specific needs in the future; an interface-based system
+// may make sense as well when that need arises.
+//
+
+//
+// TransactionController
+//
+// Because many operations in Shotwell need to be performed on collections of objects all at once,
+// and that most of these objects are backed by a database, the TransactionController object gives
+// a way to generically group a series of operations on one or more similar objects into a single
+// transaction. This class is listed here because it's used by the various media interfaces to offer
+// multiple operations.
+//
+// begin() and commit() may be called multiple times in layering fashion. The implementation
+// accounts for this. If either throws an exception it should be assumed that the object is in
+// a "clean" state; that is, if begin() throws an exception, there is no need to call commit(),
+// and if commit() throws an exception, it does not need to be called again to revert the object
+// state.
+//
+// This means that any user who calls begin() *must* match it with a corresponding commit(), even
+// if there is an error during the transaction. It is up to the user to back out any undesired
+// changes.
+//
+// Because of the nature of this object, it's assumed that every object type will share one
+// between all callers.
+//
+// The object is thread-safe. There is no guarantee that the underlying persistent store is,
+// however.
+public abstract class TransactionController {
+ private int count = 0;
+
+ public TransactionController() {
+ }
+
+ ~TransactionController() {
+ lock (count) {
+ assert(count == 0);
+ }
+ }
+
+ public void begin() {
+ lock (count) {
+ if (count++ != 0)
+ return;
+
+ try {
+ begin_impl();
+ } catch (Error err) {
+ // unwind
+ count--;
+
+ if (err is DatabaseError)
+ AppWindow.database_error((DatabaseError) err);
+ else
+ AppWindow.panic("%s".printf(err.message));
+ }
+ }
+ }
+
+ // For thread safety, this method will only be called under the protection of a mutex.
+ public abstract void begin_impl() throws Error;
+
+ public void commit() {
+ lock (count) {
+ assert(count > 0);
+ if (--count != 0)
+ return;
+
+ // no need to unwind the count here; it's already unwound.
+ try {
+ commit_impl();
+ } catch (Error err) {
+ if (err is DatabaseError)
+ AppWindow.database_error((DatabaseError) err);
+ else
+ AppWindow.panic("%s".printf(err.message));
+ }
+ }
+ }
+
+ // For thread safety, this method will only be called under the protection of a mutex.
+ public abstract void commit_impl() throws Error;
+}
+
+//
+// Flaggable
+//
+// Flaggable media can be marked for later use in batch operations.
+//
+// The mark_flagged() and mark_unflagged() methods should fire "metadata:flags" and "metadata:flagged"
+// alterations if the flag has changed.
+public interface Flaggable : MediaSource {
+ public abstract bool is_flagged();
+
+ public abstract void mark_flagged();
+
+ public abstract void mark_unflagged();
+
+ public static void mark_many_flagged_unflagged(Gee.Collection<Flaggable>? flag,
+ Gee.Collection<Flaggable>? unflag, TransactionController controller) throws Error {
+ controller.begin();
+
+ if (flag != null) {
+ foreach (Flaggable flaggable in flag)
+ flaggable.mark_flagged();
+ }
+
+ if (unflag != null) {
+ foreach (Flaggable flaggable in unflag)
+ flaggable.mark_unflagged();
+ }
+
+ controller.commit();
+ }
+}
+
+//
+// Monitorable
+//
+// Monitorable media can be updated at startup or run-time about changes to their backing file(s).
+//
+// The mark_online() and mark_offline() methods should fire "metadata:flags" and "metadata:online-state"
+// alterations if the flag has changed.
+//
+// The set_master_file() method should fire "backing:master" alteration and "metadata:name" if
+// the name of the file is determined by the filename (which is default behavior). It should also
+// call notify_master_file_replaced().
+//
+// The set_master_timestamp() method should fire "metadata:master-timestamp" alteration.
+public interface Monitorable : MediaSource {
+ public abstract bool is_offline();
+
+ public abstract void mark_online();
+
+ public abstract void mark_offline();
+
+ public static void mark_many_online_offline(Gee.Collection<Monitorable>? online,
+ Gee.Collection<Monitorable>? offline, TransactionController controller) throws Error {
+ controller.begin();
+
+ if (online != null) {
+ foreach (Monitorable monitorable in online)
+ monitorable.mark_online();
+ }
+
+ if (offline != null) {
+ foreach (Monitorable monitorable in offline)
+ monitorable.mark_offline();
+ }
+
+ controller.commit();
+ }
+
+ public abstract void set_master_file(File file);
+
+ public static void set_many_master_file(Gee.Map<Monitorable, File> map,
+ TransactionController controller) throws Error {
+ controller.begin();
+
+ Gee.MapIterator<Monitorable, File> map_iter = map.map_iterator();
+ while (map_iter.next())
+ map_iter.get_key().set_master_file(map_iter.get_value());
+
+ controller.commit();
+ }
+
+ public abstract void set_master_timestamp(FileInfo info);
+
+ public static void set_many_master_timestamp(Gee.Map<Monitorable, FileInfo> map,
+ TransactionController controller) throws Error {
+ controller.begin();
+
+ Gee.MapIterator<Monitorable, FileInfo> map_iter = map.map_iterator();
+ while (map_iter.next())
+ map_iter.get_key().set_master_timestamp(map_iter.get_value());
+
+ controller.commit();
+ }
+}
+
+//
+// Dateable
+//
+// Dateable media may have their exposure date and time set arbitrarily.
+//
+// The set_exposure_time() method refactors the existing set_exposure_time()
+// 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 time_t get_exposure_time();
+}
diff --git a/src/MediaMetadata.vala b/src/MediaMetadata.vala
new file mode 100644
index 0000000..ad0d719
--- /dev/null
+++ b/src/MediaMetadata.vala
@@ -0,0 +1,128 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 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/MediaMonitor.vala b/src/MediaMonitor.vala
new file mode 100644
index 0000000..aeb2952
--- /dev/null
+++ b/src/MediaMonitor.vala
@@ -0,0 +1,418 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 MonitorableUpdates {
+ public Monitorable monitorable;
+
+ private File? master_file = null;
+ private bool master_file_info_altered = false;
+ private FileInfo? master_file_info = null;
+ private bool master_in_alteration = false;
+ private bool online = false;
+ private bool offline = false;
+
+ public MonitorableUpdates(Monitorable monitorable) {
+ this.monitorable = monitorable;
+ }
+
+ public File? get_master_file() {
+ return master_file;
+ }
+
+ public FileInfo? get_master_file_info() {
+ return master_file_info;
+ }
+
+ public virtual bool is_in_alteration() {
+ return master_in_alteration;
+ }
+
+ public bool is_set_offline() {
+ return offline;
+ }
+
+ public bool is_set_online() {
+ return online;
+ }
+
+ public virtual void set_master_file(File? file) {
+ master_file = file;
+
+ if (file != null)
+ mark_online();
+ }
+
+ public virtual void set_master_file_info_altered(bool altered) {
+ master_file_info_altered = altered;
+
+ if (altered)
+ mark_online();
+ }
+
+ public virtual void set_master_file_info(FileInfo? info) {
+ master_file_info = info;
+
+ if (master_file_info == null)
+ set_master_file_info_altered(false);
+ }
+
+ public virtual void set_master_in_alteration(bool in_alteration) {
+ master_in_alteration = in_alteration;
+ }
+
+ public virtual void set_master_alterations_complete(FileInfo info) {
+ set_master_in_alteration(false);
+ set_master_file_info(info);
+ mark_online();
+ }
+
+ public virtual void mark_offline() {
+ online = false;
+ offline = true;
+
+ master_file_info_altered = false;
+ master_file_info = null;
+ master_in_alteration = false;
+ }
+
+ public virtual void mark_online() {
+ online = true;
+ offline = false;
+ }
+
+ public virtual void reset_online_offline() {
+ online = false;
+ offline = false;
+ }
+
+ public virtual bool is_all_updated() {
+ return master_file == null
+ && master_file_info_altered == false
+ && master_file_info == null
+ && master_in_alteration == false
+ && online == false
+ && offline == false;
+ }
+}
+
+public abstract class MediaMonitor : Object {
+ public enum DiscoveredFile {
+ REPRESENTED,
+ IGNORE,
+ UNKNOWN
+ }
+
+ protected const int MAX_OPERATIONS_PER_CYCLE = 100;
+
+ private const int FLUSH_PENDING_UPDATES_MSEC = 500;
+
+ private MediaSourceCollection sources;
+ private Cancellable cancellable;
+ private Gee.HashMap<Monitorable, MonitorableUpdates> pending_updates = new Gee.HashMap<Monitorable,
+ MonitorableUpdates>();
+ private uint pending_updates_timer_id = 0;
+
+ public MediaMonitor(MediaSourceCollection sources, Cancellable cancellable) {
+ this.sources = sources;
+ this.cancellable = cancellable;
+
+ sources.item_destroyed.connect(on_media_source_destroyed);
+ sources.unlinked_destroyed.connect(on_media_source_destroyed);
+
+ pending_updates_timer_id = Timeout.add(FLUSH_PENDING_UPDATES_MSEC, on_flush_pending_updates,
+ Priority.LOW);
+ }
+
+ ~MediaMonitor() {
+ sources.item_destroyed.disconnect(on_media_source_destroyed);
+ sources.unlinked_destroyed.disconnect(on_media_source_destroyed);
+ }
+
+ public abstract MediaSourceCollection get_media_source_collection();
+
+ public virtual void close() {
+ }
+
+ public virtual string to_string() {
+ return "MediaMonitor for %s".printf(get_media_source_collection().to_string());
+ }
+
+ protected virtual MonitorableUpdates create_updates(Monitorable monitorable) {
+ return new MonitorableUpdates(monitorable);
+ }
+
+ protected virtual void on_media_source_destroyed(DataSource source) {
+ remove_updates((Monitorable) source);
+ }
+
+ //
+ // The following are called when the startup scan is initiated.
+ //
+
+ public virtual void notify_discovery_started() {
+ }
+
+ // Returns the Monitorable represented in some form by the monitors' MediaSourceCollection.
+ // If DiscoveredFile.REPRESENTED is returns, monitorable should be set.
+ public abstract DiscoveredFile notify_file_discovered(File file, FileInfo info,
+ out Monitorable monitorable);
+
+ // Returns REPRESENTED if the file has been *definitively* associated with a Monitorable,
+ // in which case the file will no longer be considered unknown. Returns IGNORE if the file
+ // is known in some other case and should not be considered unknown. Returns UNKNOWN otherwise,
+ // with potentially a collection of candidates for the file. The collection may be zero-length.
+ //
+ // NOTE: This method may be called after the startup scan as well.
+ public abstract Gee.Collection<Monitorable>? candidates_for_unknown_file(File file, FileInfo info,
+ out DiscoveredFile result);
+
+ public virtual File[]? get_auxilliary_backing_files(Monitorable monitorable) {
+ return null;
+ }
+
+ // info is null if the file was not found. Note that master online/offline state is already
+ // set by LibraryMonitor.
+ public virtual void update_backing_file_info(Monitorable monitorable, File file, FileInfo? info) {
+ }
+
+ // Not that discovery has completed, but the MediaMonitor's role in it has finished.
+ public virtual void notify_discovery_completing() {
+ }
+
+ //
+ // The following are called after the startup scan for runtime monitoring.
+ //
+
+ public abstract bool is_file_represented(File file);
+
+ public abstract bool notify_file_created(File file, FileInfo info);
+
+ public abstract bool notify_file_moved(File old_file, File new_file, FileInfo new_file_info);
+
+ public abstract bool notify_file_altered(File file);
+
+ public abstract bool notify_file_attributes_altered(File file);
+
+ public abstract bool notify_file_alteration_completed(File file, FileInfo info);
+
+ public abstract bool notify_file_deleted(File file);
+
+ protected static void mdbg(string msg) {
+#if TRACE_MONITORING
+ debug("%s", msg);
+#endif
+ }
+
+ public bool has_pending_updates() {
+ return pending_updates.size > 0;
+ }
+
+ public Gee.Collection<Monitorable> get_monitorables() {
+ return pending_updates.keys;
+ }
+
+ // This will create a MonitorableUpdates and register it with this updater if not already
+ // exists.
+ public MonitorableUpdates fetch_updates(Monitorable monitorable) {
+ MonitorableUpdates? updates = pending_updates.get(monitorable);
+ if (updates != null)
+ return updates;
+
+ updates = create_updates(monitorable);
+ pending_updates.set(monitorable, updates);
+
+ return updates;
+ }
+
+ public MonitorableUpdates? get_existing_updates(Monitorable monitorable) {
+ return pending_updates.get(monitorable);
+ }
+
+ public void remove_updates(Monitorable monitorable) {
+ pending_updates.unset(monitorable);
+ }
+
+ public bool is_online(Monitorable monitorable) {
+ MonitorableUpdates? updates = get_existing_updates(monitorable);
+
+ return (updates != null) ? updates.is_set_online() : !monitorable.is_offline();
+ }
+
+ public bool is_offline(Monitorable monitorable) {
+ MonitorableUpdates? updates = get_existing_updates(monitorable);
+
+ return (updates != null) ? updates.is_set_offline() : monitorable.is_offline();
+ }
+
+ public File get_master_file(Monitorable monitorable) {
+ MonitorableUpdates? updates = get_existing_updates(monitorable);
+
+ return (updates != null && updates.get_master_file() != null) ? updates.get_master_file()
+ : monitorable.get_master_file();
+ }
+
+ public void update_master_file(Monitorable monitorable, File file) {
+ fetch_updates(monitorable).set_master_file(file);
+ }
+
+ public void update_master_file_info_altered(Monitorable monitorable) {
+ fetch_updates(monitorable).set_master_file_info_altered(true);
+ }
+
+ public void update_master_file_in_alteration(Monitorable monitorable, bool in_alteration) {
+ fetch_updates(monitorable).set_master_in_alteration(in_alteration);
+ }
+
+ public void update_master_file_alterations_completed(Monitorable monitorable, FileInfo info) {
+ fetch_updates(monitorable).set_master_alterations_complete(info);
+ }
+
+ public void update_online(Monitorable monitorable) {
+ fetch_updates(monitorable).mark_online();
+ }
+
+ public void update_offline(Monitorable monitorable) {
+ fetch_updates(monitorable).mark_offline();
+ }
+
+ // Children should call this method before doing their own processing. Every operation should
+ // be recorded by incrementing op_count. If it is greater than MAX_OPERATIONS_PER_CYCLE,
+ // the method should process what has been done and exit to let the operations be handled in
+ // the next cycle.
+ protected virtual void process_updates(Gee.Collection<MonitorableUpdates> all_updates,
+ TransactionController controller, ref int op_count) throws Error {
+ Gee.Map<Monitorable, File> set_master_file = null;
+ Gee.Map<Monitorable, FileInfo> set_master_file_info = null;
+ Gee.ArrayList<MediaSource> to_offline = null;
+ Gee.ArrayList<MediaSource> to_online = null;
+
+ foreach (MonitorableUpdates updates in all_updates) {
+ if (op_count >= MAX_OPERATIONS_PER_CYCLE)
+ break;
+
+ if (updates.get_master_file() != null) {
+ if (set_master_file == null)
+ set_master_file = new Gee.HashMap<Monitorable, File>();
+
+ set_master_file.set(updates.monitorable, updates.get_master_file());
+ updates.set_master_file(null);
+ op_count++;
+ }
+
+ if (updates.get_master_file_info() != null) {
+ if (set_master_file_info == null)
+ set_master_file_info = new Gee.HashMap<Monitorable, FileInfo>();
+
+ set_master_file_info.set(updates.monitorable, updates.get_master_file_info());
+ updates.set_master_file_info(null);
+ op_count++;
+ }
+
+ if (updates.is_set_offline()) {
+ if (to_offline == null)
+ to_offline = new Gee.ArrayList<LibraryPhoto>();
+
+ to_offline.add(updates.monitorable);
+ updates.reset_online_offline();
+ op_count++;
+ }
+
+ if (updates.is_set_online()) {
+ if (to_online == null)
+ to_online = new Gee.ArrayList<LibraryPhoto>();
+
+ to_online.add(updates.monitorable);
+ updates.reset_online_offline();
+ op_count++;
+ }
+ }
+
+ if (set_master_file != null) {
+ mdbg("Changing master file of %d objects in %s".printf(set_master_file.size, to_string()));
+
+ Monitorable.set_many_master_file(set_master_file, controller);
+ }
+
+ if (set_master_file_info != null) {
+ mdbg("Updating %d master files timestamps in %s".printf(set_master_file_info.size,
+ to_string()));
+
+ Monitorable.set_many_master_timestamp(set_master_file_info, controller);
+ }
+
+ if (to_offline != null || to_online != null) {
+ mdbg("Marking %d online, %d offline in %s".printf(
+ (to_online != null) ? to_online.size : 0,
+ (to_offline != null) ? to_offline.size : 0,
+ to_string()));
+
+ Monitorable.mark_many_online_offline(to_online, to_offline, controller);
+ }
+ }
+
+ private bool on_flush_pending_updates() {
+ if (cancellable.is_cancelled())
+ return false;
+
+ if (pending_updates.size == 0)
+ return true;
+
+ Timer timer = new Timer();
+
+ // build two lists: one, of MonitorableUpdates that are not in_alteration() (which
+ // simplifies matters), and two, of completed MonitorableUpdates that should be removed
+ // from the list (which would have happened after the last pass)
+ Gee.ArrayList<MonitorableUpdates> to_process = null;
+ Gee.ArrayList<Monitorable> to_remove = null;
+ foreach (MonitorableUpdates updates in pending_updates.values) {
+ if (updates.is_in_alteration())
+ continue;
+
+ if (updates.is_all_updated()) {
+ if (to_remove == null)
+ to_remove = new Gee.ArrayList<Monitorable>();
+
+ to_remove.add(updates.monitorable);
+ continue;
+ }
+
+ if (to_process == null)
+ to_process = new Gee.ArrayList<MonitorableUpdates>();
+
+ to_process.add(updates);
+ }
+
+ int op_count = 0;
+ if (to_process != null) {
+ TransactionController controller = get_media_source_collection().transaction_controller;
+
+ try {
+ controller.begin();
+ process_updates(to_process, controller, ref op_count);
+ controller.commit();
+ } catch (Error err) {
+ if (err is DatabaseError)
+ AppWindow.database_error((DatabaseError) err);
+ else
+ AppWindow.panic(_("Unable to process monitoring updates: %s").printf(err.message));
+ }
+ }
+
+ if (to_remove != null) {
+ foreach (Monitorable monitorable in to_remove)
+ remove_updates(monitorable);
+ }
+
+ double elapsed = timer.elapsed();
+ if (elapsed > 0.01 || op_count > 0) {
+ mdbg("Total pending queue time for %s: %lf (%d ops)".printf(to_string(), elapsed,
+ op_count));
+ }
+
+ return true;
+ }
+}
+
diff --git a/src/MediaPage.vala b/src/MediaPage.vala
new file mode 100644
index 0000000..4d7ee2a
--- /dev/null
+++ b/src/MediaPage.vala
@@ -0,0 +1,1308 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class MediaSourceItem : CheckerboardItem {
+ private static Gdk.Pixbuf basis_sprocket_pixbuf = null;
+ private static Gdk.Pixbuf current_sprocket_pixbuf = null;
+
+ private bool enable_sprockets = false;
+
+ // preserve the same constructor arguments and semantics as CheckerboardItem so that we're
+ // a drop-in replacement
+ public MediaSourceItem(ThumbnailSource source, Dimensions initial_pixbuf_dim, string title,
+ string? comment, bool marked_up = false, Pango.Alignment alignment = Pango.Alignment.LEFT) {
+ base(source, initial_pixbuf_dim, title, comment, marked_up, alignment);
+ if (basis_sprocket_pixbuf == null)
+ basis_sprocket_pixbuf = Resources.load_icon("sprocket.png", 0);
+ }
+
+ protected override void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf,
+ Gdk.Point origin) {
+ Dimensions pixbuf_dim = Dimensions.for_pixbuf(pixbuf);
+ // sprocket geometry calculation (and possible adjustment) has to occur before we call
+ // base.paint_image( ) because the base-class method needs the correct trinket horizontal
+ // offset
+
+ if (!enable_sprockets) {
+ set_horizontal_trinket_offset(0);
+ } else {
+ double reduction_factor = ((double) pixbuf_dim.major_axis()) /
+ ((double) ThumbnailCache.Size.LARGEST);
+ int reduced_size = (int) (reduction_factor * basis_sprocket_pixbuf.width);
+
+ if (current_sprocket_pixbuf == null || reduced_size != current_sprocket_pixbuf.width) {
+ current_sprocket_pixbuf = basis_sprocket_pixbuf.scale_simple(reduced_size,
+ reduced_size, Gdk.InterpType.HYPER);
+ }
+
+ set_horizontal_trinket_offset(current_sprocket_pixbuf.width);
+ }
+
+ base.paint_image(ctx, pixbuf, origin);
+
+ if (enable_sprockets) {
+ paint_sprockets(ctx, origin, pixbuf_dim);
+ }
+ }
+
+ protected void paint_one_sprocket(Cairo.Context ctx, Gdk.Point origin) {
+ ctx.save();
+ Gdk.cairo_set_source_pixbuf(ctx, current_sprocket_pixbuf, origin.x, origin.y);
+ ctx.paint();
+ ctx.restore();
+ }
+
+ protected void paint_sprockets(Cairo.Context ctx, Gdk.Point item_origin,
+ Dimensions item_dimensions) {
+ int num_sprockets = item_dimensions.height / current_sprocket_pixbuf.height;
+
+ Gdk.Point left_paint_location = item_origin;
+ Gdk.Point right_paint_location = item_origin;
+ right_paint_location.x += (item_dimensions.width - current_sprocket_pixbuf.width);
+ for (int i = 0; i < num_sprockets; i++) {
+ paint_one_sprocket(ctx, left_paint_location);
+ paint_one_sprocket(ctx, right_paint_location);
+
+ left_paint_location.y += current_sprocket_pixbuf.height;
+ right_paint_location.y += current_sprocket_pixbuf.height;
+ }
+
+ int straggler_pixels = item_dimensions.height % current_sprocket_pixbuf.height;
+ if (straggler_pixels > 0) {
+ ctx.save();
+
+ Gdk.cairo_set_source_pixbuf(ctx, current_sprocket_pixbuf, left_paint_location.x,
+ left_paint_location.y);
+ ctx.rectangle(left_paint_location.x, left_paint_location.y,
+ current_sprocket_pixbuf.get_width(), straggler_pixels);
+ ctx.fill();
+
+ Gdk.cairo_set_source_pixbuf(ctx, current_sprocket_pixbuf, right_paint_location.x,
+ right_paint_location.y);
+ ctx.rectangle(right_paint_location.x, right_paint_location.y,
+ current_sprocket_pixbuf.get_width(), straggler_pixels);
+ ctx.fill();
+
+ ctx.restore();
+ }
+ }
+
+ public void set_enable_sprockets(bool enable_sprockets) {
+ this.enable_sprockets = enable_sprockets;
+ }
+}
+
+public abstract class MediaPage : CheckerboardPage {
+ public const int SORT_ORDER_ASCENDING = 0;
+ public const int SORT_ORDER_DESCENDING = 1;
+
+ // steppings should divide evenly into (Thumbnail.MAX_SCALE - Thumbnail.MIN_SCALE)
+ public const int MANUAL_STEPPING = 16;
+ public const int SLIDER_STEPPING = 4;
+
+ public enum SortBy {
+ MIN = 1,
+ TITLE = 1,
+ EXPOSURE_DATE = 2,
+ RATING = 3,
+ MAX = 3
+ }
+
+ protected class ZoomSliderAssembly : Gtk.ToolItem {
+ private Gtk.Scale slider;
+ private Gtk.Adjustment adjustment;
+
+ public signal void zoom_changed();
+
+ public ZoomSliderAssembly() {
+ Gtk.Box zoom_group = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+
+ Gtk.Image zoom_out = new Gtk.Image.from_pixbuf(Resources.load_icon(
+ Resources.ICON_ZOOM_OUT, Resources.ICON_ZOOM_SCALE));
+ Gtk.EventBox zoom_out_box = new Gtk.EventBox();
+ zoom_out_box.set_above_child(true);
+ zoom_out_box.set_visible_window(false);
+ zoom_out_box.add(zoom_out);
+ zoom_out_box.button_press_event.connect(on_zoom_out_pressed);
+
+ zoom_group.pack_start(zoom_out_box, false, false, 0);
+
+ // virgin ZoomSliderAssemblies are created such that they have whatever value is
+ // persisted in the configuration system for the photo thumbnail scale
+ int persisted_scale = Config.Facade.get_instance().get_photo_thumbnail_scale();
+ adjustment = new Gtk.Adjustment(ZoomSliderAssembly.scale_to_slider(persisted_scale), 0,
+ ZoomSliderAssembly.scale_to_slider(Thumbnail.MAX_SCALE), 1, 10, 0);
+
+ slider = new Gtk.Scale(Gtk.Orientation.HORIZONTAL, adjustment);
+ slider.value_changed.connect(on_slider_changed);
+ slider.set_draw_value(false);
+ slider.set_size_request(200, -1);
+ slider.set_tooltip_text(_("Adjust the size of the thumbnails"));
+
+ zoom_group.pack_start(slider, false, false, 0);
+
+ Gtk.Image zoom_in = new Gtk.Image.from_pixbuf(Resources.load_icon(
+ Resources.ICON_ZOOM_IN, Resources.ICON_ZOOM_SCALE));
+ Gtk.EventBox zoom_in_box = new Gtk.EventBox();
+ zoom_in_box.set_above_child(true);
+ zoom_in_box.set_visible_window(false);
+ zoom_in_box.add(zoom_in);
+ zoom_in_box.button_press_event.connect(on_zoom_in_pressed);
+
+ zoom_group.pack_start(zoom_in_box, false, false, 0);
+
+ add(zoom_group);
+ }
+
+ 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);
+ }
+
+ public static int slider_to_scale(double value) {
+ int res = ((int) (value * SLIDER_STEPPING)) + Thumbnail.MIN_SCALE;
+
+ assert(res >= Thumbnail.MIN_SCALE);
+ assert(res <= Thumbnail.MAX_SCALE);
+
+ return res;
+ }
+
+ private bool on_zoom_out_pressed(Gdk.EventButton event) {
+ snap_to_min();
+ return true;
+ }
+
+ private bool on_zoom_in_pressed(Gdk.EventButton event) {
+ snap_to_max();
+ return true;
+ }
+
+ private void on_slider_changed() {
+ zoom_changed();
+ }
+
+ public void snap_to_min() {
+ slider.set_value(scale_to_slider(Thumbnail.MIN_SCALE));
+ }
+
+ public void snap_to_max() {
+ slider.set_value(scale_to_slider(Thumbnail.MAX_SCALE));
+ }
+
+ public void increase_step() {
+ int new_scale = compute_zoom_scale_increase(get_scale());
+
+ if (get_scale() == new_scale)
+ return;
+
+ slider.set_value(scale_to_slider(new_scale));
+ }
+
+ public void decrease_step() {
+ int new_scale = compute_zoom_scale_decrease(get_scale());
+
+ if (get_scale() == new_scale)
+ return;
+
+ slider.set_value(scale_to_slider(new_scale));
+ }
+
+ public int get_scale() {
+ return slider_to_scale(slider.get_value());
+ }
+
+ public void set_scale(int scale) {
+ if (get_scale() == scale)
+ return;
+
+ slider.set_value(scale_to_slider(scale));
+ }
+ }
+
+ private ZoomSliderAssembly? connected_slider = null;
+ private DragAndDropHandler dnd_handler = null;
+ private MediaViewTracker tracker;
+
+ public MediaPage(string page_name) {
+ base (page_name);
+
+ tracker = new MediaViewTracker(get_view());
+
+ get_view().items_altered.connect(on_media_altered);
+
+ get_view().freeze_notifications();
+ get_view().set_property(CheckerboardItem.PROP_SHOW_TITLES,
+ Config.Facade.get_instance().get_display_photo_titles());
+ get_view().set_property(CheckerboardItem.PROP_SHOW_COMMENTS,
+ Config.Facade.get_instance().get_display_photo_comments());
+ get_view().set_property(Thumbnail.PROP_SHOW_TAGS,
+ Config.Facade.get_instance().get_display_photo_tags());
+ get_view().set_property(Thumbnail.PROP_SIZE, get_thumb_size());
+ get_view().set_property(Thumbnail.PROP_SHOW_RATINGS,
+ Config.Facade.get_instance().get_display_photo_ratings());
+ get_view().thaw_notifications();
+
+ // enable drag-and-drop export of media
+ dnd_handler = new DragAndDropHandler(this);
+ }
+
+ private static int compute_zoom_scale_increase(int current_scale) {
+ int new_scale = current_scale + MANUAL_STEPPING;
+ return new_scale.clamp(Thumbnail.MIN_SCALE, Thumbnail.MAX_SCALE);
+ }
+
+ private static int compute_zoom_scale_decrease(int current_scale) {
+ int new_scale = current_scale - MANUAL_STEPPING;
+ return new_scale.clamp(Thumbnail.MIN_SCALE, Thumbnail.MAX_SCALE);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("media.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry export = { "Export", Gtk.Stock.SAVE_AS, TRANSLATABLE, "<Ctrl><Shift>E",
+ TRANSLATABLE, on_export };
+ export.label = Resources.EXPORT_MENU;
+ actions += export;
+
+ Gtk.ActionEntry send_to = { "SendTo", "document-send", TRANSLATABLE, null,
+ TRANSLATABLE, on_send_to };
+ send_to.label = Resources.SEND_TO_MENU;
+ actions += send_to;
+
+ // This is identical to the above action, except that it has different
+ // mnemonics and is _only_ for use in the context menu.
+ Gtk.ActionEntry send_to_context_menu = { "SendToContextMenu", "document-send", TRANSLATABLE, null,
+ TRANSLATABLE, on_send_to };
+ send_to_context_menu.label = Resources.SEND_TO_CONTEXT_MENU;
+ actions += send_to_context_menu;
+
+ Gtk.ActionEntry remove_from_library = { "RemoveFromLibrary", Gtk.Stock.REMOVE, TRANSLATABLE,
+ "<Shift>Delete", TRANSLATABLE, on_remove_from_library };
+ remove_from_library.label = Resources.REMOVE_FROM_LIBRARY_MENU;
+ actions += remove_from_library;
+
+ Gtk.ActionEntry move_to_trash = { "MoveToTrash", "user-trash-full", TRANSLATABLE, "Delete",
+ TRANSLATABLE, on_move_to_trash };
+ move_to_trash.label = Resources.MOVE_TO_TRASH_MENU;
+ actions += move_to_trash;
+
+ Gtk.ActionEntry new_event = { "NewEvent", Gtk.Stock.NEW, TRANSLATABLE, "<Ctrl>N",
+ TRANSLATABLE, on_new_event };
+ new_event.label = Resources.NEW_EVENT_MENU;
+ actions += new_event;
+
+ Gtk.ActionEntry add_tags = { "AddTags", null, TRANSLATABLE, "<Ctrl>T", TRANSLATABLE,
+ on_add_tags };
+ add_tags.label = Resources.ADD_TAGS_MENU;
+ actions += add_tags;
+
+ // This is identical to the above action, except that it has different
+ // mnemonics and is _only_ for use in the context menu.
+ Gtk.ActionEntry add_tags_context_menu = { "AddTagsContextMenu", null, TRANSLATABLE, "<Ctrl>A", TRANSLATABLE,
+ on_add_tags };
+ add_tags_context_menu.label = Resources.ADD_TAGS_CONTEXT_MENU;
+ actions += add_tags_context_menu;
+
+ Gtk.ActionEntry modify_tags = { "ModifyTags", null, TRANSLATABLE, "<Ctrl>M", TRANSLATABLE,
+ on_modify_tags };
+ modify_tags.label = Resources.MODIFY_TAGS_MENU;
+ actions += modify_tags;
+
+ Gtk.ActionEntry increase_size = { "IncreaseSize", Gtk.Stock.ZOOM_IN, TRANSLATABLE,
+ "<Ctrl>plus", TRANSLATABLE, on_increase_size };
+ increase_size.label = _("Zoom _In");
+ increase_size.tooltip = _("Increase the magnification of the thumbnails");
+ actions += increase_size;
+
+ Gtk.ActionEntry decrease_size = { "DecreaseSize", Gtk.Stock.ZOOM_OUT, TRANSLATABLE,
+ "<Ctrl>minus", TRANSLATABLE, on_decrease_size };
+ decrease_size.label = _("Zoom _Out");
+ decrease_size.tooltip = _("Decrease the magnification of the thumbnails");
+ actions += decrease_size;
+
+ Gtk.ActionEntry flag = { "Flag", null, TRANSLATABLE, "<Ctrl>G", TRANSLATABLE, on_flag_unflag };
+ flag.label = Resources.FLAG_MENU;
+ actions += flag;
+
+ Gtk.ActionEntry set_rating = { "Rate", null, TRANSLATABLE, null, null, null };
+ set_rating.label = Resources.RATING_MENU;
+ actions += set_rating;
+
+ Gtk.ActionEntry increase_rating = { "IncreaseRating", null, TRANSLATABLE,
+ "greater", TRANSLATABLE, on_increase_rating };
+ increase_rating.label = Resources.INCREASE_RATING_MENU;
+ actions += increase_rating;
+
+ Gtk.ActionEntry decrease_rating = { "DecreaseRating", null, TRANSLATABLE,
+ "less", TRANSLATABLE, on_decrease_rating };
+ decrease_rating.label = Resources.DECREASE_RATING_MENU;
+ actions += decrease_rating;
+
+ Gtk.ActionEntry rate_rejected = { "RateRejected", null, TRANSLATABLE,
+ "9", TRANSLATABLE, on_rate_rejected };
+ rate_rejected.label = Resources.rating_menu(Rating.REJECTED);
+ actions += rate_rejected;
+
+ Gtk.ActionEntry rate_unrated = { "RateUnrated", null, TRANSLATABLE,
+ "0", TRANSLATABLE, on_rate_unrated };
+ rate_unrated.label = Resources.rating_menu(Rating.UNRATED);
+ actions += rate_unrated;
+
+ Gtk.ActionEntry rate_one = { "RateOne", null, TRANSLATABLE,
+ "1", TRANSLATABLE, on_rate_one };
+ rate_one.label = Resources.rating_menu(Rating.ONE);
+ actions += rate_one;
+
+ Gtk.ActionEntry rate_two = { "RateTwo", null, TRANSLATABLE,
+ "2", TRANSLATABLE, on_rate_two };
+ rate_two.label = Resources.rating_menu(Rating.TWO);
+ actions += rate_two;
+
+ Gtk.ActionEntry rate_three = { "RateThree", null, TRANSLATABLE,
+ "3", TRANSLATABLE, on_rate_three };
+ rate_three.label = Resources.rating_menu(Rating.THREE);
+ actions += rate_three;
+
+ Gtk.ActionEntry rate_four = { "RateFour", null, TRANSLATABLE,
+ "4", TRANSLATABLE, on_rate_four };
+ rate_four.label = Resources.rating_menu(Rating.FOUR);
+ actions += rate_four;
+
+ Gtk.ActionEntry rate_five = { "RateFive", null, TRANSLATABLE,
+ "5", TRANSLATABLE, on_rate_five };
+ rate_five.label = Resources.rating_menu(Rating.FIVE);
+ actions += rate_five;
+
+ Gtk.ActionEntry edit_title = { "EditTitle", null, TRANSLATABLE, "F2", TRANSLATABLE,
+ on_edit_title };
+ edit_title.label = Resources.EDIT_TITLE_MENU;
+ actions += edit_title;
+
+ Gtk.ActionEntry edit_comment = { "EditComment", null, TRANSLATABLE, "F3", TRANSLATABLE,
+ on_edit_comment };
+ edit_comment.label = Resources.EDIT_COMMENT_MENU;
+ actions += edit_comment;
+
+ Gtk.ActionEntry sort_photos = { "SortPhotos", null, TRANSLATABLE, null, null, null };
+ sort_photos.label = _("Sort _Photos");
+ actions += sort_photos;
+
+ Gtk.ActionEntry filter_photos = { "FilterPhotos", null, TRANSLATABLE, null, null, null };
+ filter_photos.label = Resources.FILTER_PHOTOS_MENU;
+ actions += filter_photos;
+
+ Gtk.ActionEntry play = { "PlayVideo", Gtk.Stock.MEDIA_PLAY, TRANSLATABLE, "<Ctrl>Y",
+ TRANSLATABLE, on_play_video };
+ play.label = _("_Play Video");
+ play.tooltip = _("Open the selected videos in the system video player");
+ actions += play;
+
+ Gtk.ActionEntry raw_developer = { "RawDeveloper", null, TRANSLATABLE, null, null, null };
+ raw_developer.label = _("_Developer");
+ actions += raw_developer;
+
+ // RAW developers.
+
+ Gtk.ActionEntry dev_shotwell = { "RawDeveloperShotwell", null, TRANSLATABLE, null, TRANSLATABLE,
+ on_raw_developer_shotwell };
+ dev_shotwell.label = _("Shotwell");
+ actions += dev_shotwell;
+
+ Gtk.ActionEntry dev_camera = { "RawDeveloperCamera", null, TRANSLATABLE, null, TRANSLATABLE,
+ on_raw_developer_camera };
+ dev_camera.label = _("Camera");
+ actions += dev_camera;
+
+ return actions;
+ }
+
+ protected override Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ Gtk.ToggleActionEntry[] toggle_actions = base.init_collect_toggle_action_entries();
+
+ Gtk.ToggleActionEntry titles = { "ViewTitle", null, TRANSLATABLE, "<Ctrl><Shift>T",
+ TRANSLATABLE, on_display_titles, Config.Facade.get_instance().get_display_photo_titles() };
+ titles.label = _("_Titles");
+ titles.tooltip = _("Display the title of each photo");
+ toggle_actions += titles;
+
+ Gtk.ToggleActionEntry comments = { "ViewComment", null, TRANSLATABLE, "<Ctrl><Shift>C",
+ TRANSLATABLE, on_display_comments, Config.Facade.get_instance().get_display_photo_comments() };
+ comments.label = _("_Comments");
+ comments.tooltip = _("Display the comment of each photo");
+ toggle_actions += comments;
+
+ Gtk.ToggleActionEntry ratings = { "ViewRatings", null, TRANSLATABLE, "<Ctrl><Shift>N",
+ TRANSLATABLE, on_display_ratings, Config.Facade.get_instance().get_display_photo_ratings() };
+ ratings.label = Resources.VIEW_RATINGS_MENU;
+ ratings.tooltip = Resources.VIEW_RATINGS_TOOLTIP;
+ toggle_actions += ratings;
+
+ Gtk.ToggleActionEntry tags = { "ViewTags", null, TRANSLATABLE, "<Ctrl><Shift>G",
+ TRANSLATABLE, on_display_tags, Config.Facade.get_instance().get_display_photo_tags() };
+ tags.label = _("Ta_gs");
+ tags.tooltip = _("Display each photo's tags");
+ toggle_actions += tags;
+
+ return toggle_actions;
+ }
+
+ protected override void register_radio_actions(Gtk.ActionGroup action_group) {
+ bool sort_order;
+ int sort_by;
+ get_config_photos_sort(out sort_order, out sort_by);
+
+ // Sort criteria.
+ Gtk.RadioActionEntry[] sort_crit_actions = new Gtk.RadioActionEntry[0];
+
+ Gtk.RadioActionEntry by_title = { "SortByTitle", null, TRANSLATABLE, null, TRANSLATABLE,
+ SortBy.TITLE };
+ by_title.label = _("By _Title");
+ by_title.tooltip = _("Sort photos by title");
+ sort_crit_actions += by_title;
+
+ Gtk.RadioActionEntry by_date = { "SortByExposureDate", null, TRANSLATABLE, null,
+ TRANSLATABLE, SortBy.EXPOSURE_DATE };
+ by_date.label = _("By Exposure _Date");
+ by_date.tooltip = _("Sort photos by exposure date");
+ sort_crit_actions += by_date;
+
+ Gtk.RadioActionEntry by_rating = { "SortByRating", null, TRANSLATABLE, null,
+ TRANSLATABLE, SortBy.RATING };
+ by_rating.label = _("By _Rating");
+ by_rating.tooltip = _("Sort photos by rating");
+ sort_crit_actions += by_rating;
+
+ action_group.add_radio_actions(sort_crit_actions, sort_by, on_sort_changed);
+
+ // Sort order.
+ Gtk.RadioActionEntry[] sort_order_actions = new Gtk.RadioActionEntry[0];
+
+ Gtk.RadioActionEntry ascending = { "SortAscending", Gtk.Stock.SORT_ASCENDING,
+ TRANSLATABLE, null, TRANSLATABLE, SORT_ORDER_ASCENDING };
+ ascending.label = _("_Ascending");
+ ascending.tooltip = _("Sort photos in an ascending order");
+ sort_order_actions += ascending;
+
+ Gtk.RadioActionEntry descending = { "SortDescending", Gtk.Stock.SORT_DESCENDING,
+ TRANSLATABLE, null, TRANSLATABLE, SORT_ORDER_DESCENDING };
+ descending.label = _("D_escending");
+ descending.tooltip = _("Sort photos in a descending order");
+ sort_order_actions += descending;
+
+ action_group.add_radio_actions(sort_order_actions,
+ sort_order ? SORT_ORDER_ASCENDING : SORT_ORDER_DESCENDING, on_sort_changed);
+
+ base.register_radio_actions(action_group);
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ set_action_sensitive("Export", selected_count > 0);
+ set_action_sensitive("EditTitle", selected_count > 0);
+ set_action_sensitive("EditComment", selected_count > 0);
+ set_action_sensitive("IncreaseSize", get_thumb_size() < Thumbnail.MAX_SCALE);
+ set_action_sensitive("DecreaseSize", get_thumb_size() > Thumbnail.MIN_SCALE);
+ set_action_sensitive("RemoveFromLibrary", selected_count > 0);
+ set_action_sensitive("MoveToTrash", selected_count > 0);
+
+ if (DesktopIntegration.is_send_to_installed())
+ set_action_sensitive("SendTo", selected_count > 0);
+ else
+ set_action_visible("SendTo", false);
+
+ set_action_sensitive("Rate", selected_count > 0);
+ update_rating_sensitivities();
+
+ update_development_menu_item_sensitivity();
+
+ set_action_sensitive("PlayVideo", selected_count == 1
+ && get_view().get_selected_source_at(0) is Video);
+
+ update_flag_action(selected_count);
+
+ base.update_actions(selected_count, count);
+ }
+
+ private void on_media_altered(Gee.Map<DataObject, Alteration> altered) {
+ foreach (DataObject object in altered.keys) {
+ if (altered.get(object).has_detail("metadata", "flagged")) {
+ update_flag_action(get_view().get_selected_count());
+
+ break;
+ }
+ }
+ }
+
+ private void update_rating_sensitivities() {
+ set_action_sensitive("RateRejected", can_rate_selected(Rating.REJECTED));
+ set_action_sensitive("RateUnrated", can_rate_selected(Rating.UNRATED));
+ set_action_sensitive("RateOne", can_rate_selected(Rating.ONE));
+ set_action_sensitive("RateTwo", can_rate_selected(Rating.TWO));
+ set_action_sensitive("RateThree", can_rate_selected(Rating.THREE));
+ set_action_sensitive("RateFour", can_rate_selected(Rating.FOUR));
+ set_action_sensitive("RateFive", can_rate_selected(Rating.FIVE));
+ set_action_sensitive("IncreaseRating", can_increase_selected_rating());
+ set_action_sensitive("DecreaseRating", can_decrease_selected_rating());
+ }
+
+ private void update_development_menu_item_sensitivity() {
+ if (get_view().get_selected().size == 0) {
+ set_action_sensitive("RawDeveloper", false);
+ return;
+ }
+
+ // Collect some stats about what's selected.
+ bool avail_shotwell = false; // True if Shotwell developer is available.
+ bool avail_camera = false; // True if camera developer is available.
+ bool is_raw = false; // True if any RAW photos are selected
+ foreach (DataView view in get_view().get_selected()) {
+ Photo? photo = ((Thumbnail) view).get_media_source() as Photo;
+ if (photo != null && photo.get_master_file_format() == PhotoFileFormat.RAW) {
+ is_raw = true;
+
+ if (!avail_shotwell && photo.is_raw_developer_available(RawDeveloper.SHOTWELL))
+ avail_shotwell = true;
+
+ if (!avail_camera && (photo.is_raw_developer_available(RawDeveloper.CAMERA) ||
+ photo.is_raw_developer_available(RawDeveloper.EMBEDDED)))
+ avail_camera = true;
+
+ if (avail_shotwell && avail_camera)
+ break; // optimization: break out of loop when all options available
+
+ }
+ }
+
+ // Enable/disable menu.
+ set_action_sensitive("RawDeveloper", is_raw);
+
+ if (is_raw) {
+ // Set which developers are available.
+ set_action_sensitive("RawDeveloperShotwell", avail_shotwell);
+ set_action_sensitive("RawDeveloperCamera", avail_camera);
+ }
+ }
+
+ private void update_flag_action(int selected_count) {
+ set_action_sensitive("Flag", selected_count > 0);
+
+ string flag_label = Resources.FLAG_MENU;
+
+ if (selected_count > 0) {
+ bool all_flagged = true;
+ foreach (DataSource source in get_view().get_selected_sources()) {
+ Flaggable? flaggable = source as Flaggable;
+ if (flaggable != null && !flaggable.is_flagged()) {
+ all_flagged = false;
+
+ break;
+ }
+ }
+
+ if (all_flagged) {
+ flag_label = Resources.UNFLAG_MENU;
+ }
+ }
+
+ Gtk.Action? flag_action = get_action("Flag");
+ if (flag_action != null) {
+ flag_action.label = flag_label;
+ }
+ }
+
+ public override Core.ViewTracker? get_view_tracker() {
+ return tracker;
+ }
+
+ public void set_display_ratings(bool display) {
+ get_view().freeze_notifications();
+ get_view().set_property(Thumbnail.PROP_SHOW_RATINGS, display);
+ get_view().thaw_notifications();
+
+ Gtk.ToggleAction? action = get_action("ViewRatings") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ private bool can_rate_selected(Rating rating) {
+ foreach (DataView view in get_view().get_selected()) {
+ if(((Thumbnail) view).get_media_source().get_rating() != rating)
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool can_increase_selected_rating() {
+ foreach (DataView view in get_view().get_selected()) {
+ if(((Thumbnail) view).get_media_source().get_rating().can_increase())
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool can_decrease_selected_rating() {
+ foreach (DataView view in get_view().get_selected()) {
+ if(((Thumbnail) view).get_media_source().get_rating().can_decrease())
+ return true;
+ }
+
+ return false;
+ }
+
+ public ZoomSliderAssembly create_zoom_slider_assembly() {
+ return new ZoomSliderAssembly();
+ }
+
+ protected override bool on_mousewheel_up(Gdk.EventScroll event) {
+ if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
+ increase_zoom_level();
+ return true;
+ } else {
+ return base.on_mousewheel_up(event);
+ }
+ }
+
+ protected override bool on_mousewheel_down(Gdk.EventScroll event) {
+ if ((event.state & Gdk.ModifierType.CONTROL_MASK) != 0) {
+ decrease_zoom_level();
+ return true;
+ } else {
+ return base.on_mousewheel_down(event);
+ }
+ }
+
+ private void on_send_to() {
+ DesktopIntegration.send_to((Gee.Collection<MediaSource>) get_view().get_selected_sources());
+ }
+
+ protected void on_play_video() {
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ Video? video = get_view().get_selected_at(0).get_source() as Video;
+ if (video == null)
+ return;
+
+ try {
+ AppInfo.launch_default_for_uri(video.get_file().get_uri(), null);
+ } catch (Error e) {
+ AppWindow.error_message(_("Shotwell was unable to play the selected video:\n%s").printf(
+ e.message));
+ }
+ }
+
+ protected override bool on_app_key_pressed(Gdk.EventKey event) {
+ bool handled = true;
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "equal":
+ case "plus":
+ case "KP_Add":
+ activate_action("IncreaseSize");
+ break;
+
+ case "minus":
+ case "underscore":
+ case "KP_Subtract":
+ activate_action("DecreaseSize");
+ break;
+
+ case "period":
+ activate_action("IncreaseRating");
+ break;
+
+ case "comma":
+ activate_action("DecreaseRating");
+ break;
+
+ case "KP_1":
+ activate_action("RateOne");
+ break;
+
+ case "KP_2":
+ activate_action("RateTwo");
+ break;
+
+ case "KP_3":
+ activate_action("RateThree");
+ break;
+
+ case "KP_4":
+ activate_action("RateFour");
+ break;
+
+ case "KP_5":
+ activate_action("RateFive");
+ break;
+
+ case "KP_0":
+ activate_action("RateUnrated");
+ break;
+
+ case "KP_9":
+ activate_action("RateRejected");
+ break;
+
+ case "exclam":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.ONE_OR_HIGHER);
+ break;
+
+ case "at":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.TWO_OR_HIGHER);
+ break;
+
+ case "numbersign":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.THREE_OR_HIGHER);
+ break;
+
+ case "dollar":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.FOUR_OR_HIGHER);
+ break;
+
+ case "percent":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.FIVE_OR_HIGHER);
+ break;
+
+ case "parenright":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.UNRATED_OR_HIGHER);
+ break;
+
+ case "parenleft":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.REJECTED_OR_HIGHER);
+ break;
+
+ case "asterisk":
+ if (get_ctrl_pressed())
+ get_search_view_filter().set_rating_filter(RatingFilter.REJECTED_ONLY);
+ break;
+
+ case "slash":
+ activate_action("Flag");
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ return handled ? true : base.on_app_key_pressed(event);
+ }
+
+ public override void switched_to() {
+ base.switched_to();
+
+ // set display options to match Configuration toggles (which can change while switched away)
+ get_view().freeze_notifications();
+ set_display_titles(Config.Facade.get_instance().get_display_photo_titles());
+ set_display_comments(Config.Facade.get_instance().get_display_photo_comments());
+ set_display_ratings(Config.Facade.get_instance().get_display_photo_ratings());
+ set_display_tags(Config.Facade.get_instance().get_display_photo_tags());
+ get_view().thaw_notifications();
+
+ sync_sort();
+ }
+
+ public override void switching_from() {
+ disconnect_slider();
+
+ base.switching_from();
+ }
+
+ protected void connect_slider(ZoomSliderAssembly slider) {
+ connected_slider = slider;
+ connected_slider.zoom_changed.connect(on_zoom_changed);
+ load_persistent_thumbnail_scale();
+ }
+
+ private void save_persistent_thumbnail_scale() {
+ if (connected_slider == null)
+ return;
+
+ Config.Facade.get_instance().set_photo_thumbnail_scale(connected_slider.get_scale());
+ }
+
+ private void load_persistent_thumbnail_scale() {
+ if (connected_slider == null)
+ return;
+
+ int persistent_scale = Config.Facade.get_instance().get_photo_thumbnail_scale();
+
+ connected_slider.set_scale(persistent_scale);
+ set_thumb_size(persistent_scale);
+ }
+
+ protected void disconnect_slider() {
+ if (connected_slider == null)
+ return;
+
+ connected_slider.zoom_changed.disconnect(on_zoom_changed);
+ connected_slider = null;
+ }
+
+ protected virtual void on_zoom_changed() {
+ if (connected_slider != null)
+ set_thumb_size(connected_slider.get_scale());
+
+ save_persistent_thumbnail_scale();
+ }
+
+ protected abstract void on_export();
+
+ protected virtual void on_increase_size() {
+ increase_zoom_level();
+ }
+
+ protected virtual void on_decrease_size() {
+ decrease_zoom_level();
+ }
+
+ private void on_add_tags() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ AddTagsDialog dialog = new AddTagsDialog();
+ string[]? names = dialog.execute();
+
+ if (names != null) {
+ get_command_manager().execute(new AddTagsCommand(
+ HierarchicalTagIndex.get_global_index().get_paths_for_names_array(names),
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources()));
+ }
+ }
+
+ private void on_modify_tags() {
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ MediaSource media = (MediaSource) get_view().get_selected_at(0).get_source();
+
+ ModifyTagsDialog dialog = new ModifyTagsDialog(media);
+ Gee.ArrayList<Tag>? new_tags = dialog.execute();
+
+ if (new_tags == null)
+ return;
+
+ get_command_manager().execute(new ModifyTagsCommand(media, new_tags));
+ }
+
+ private void set_display_tags(bool display) {
+ get_view().freeze_notifications();
+ get_view().set_property(Thumbnail.PROP_SHOW_TAGS, display);
+ get_view().thaw_notifications();
+
+ Gtk.ToggleAction? action = get_action("ViewTags") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ private void on_new_event() {
+ if (get_view().get_selected_count() > 0)
+ get_command_manager().execute(new NewEventCommand(get_view().get_selected()));
+ }
+
+ private void on_flag_unflag() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ Gee.Collection<MediaSource> sources =
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources_of_type(typeof(MediaSource));
+
+ // If all are flagged, then unflag, otherwise flag
+ bool flag = false;
+ foreach (MediaSource source in sources) {
+ Flaggable? flaggable = source as Flaggable;
+ if (flaggable != null && !flaggable.is_flagged()) {
+ flag = true;
+
+ break;
+ }
+ }
+
+ get_command_manager().execute(new FlagUnflagCommand(sources, flag));
+ }
+
+ protected virtual void on_increase_rating() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ SetRatingCommand command = new SetRatingCommand.inc_dec(get_view().get_selected(), true);
+ get_command_manager().execute(command);
+
+ update_rating_sensitivities();
+ }
+
+ protected virtual void on_decrease_rating() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ SetRatingCommand command = new SetRatingCommand.inc_dec(get_view().get_selected(), false);
+ get_command_manager().execute(command);
+
+ update_rating_sensitivities();
+ }
+
+ protected virtual void on_set_rating(Rating rating) {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ SetRatingCommand command = new SetRatingCommand(get_view().get_selected(), rating);
+ get_command_manager().execute(command);
+
+ update_rating_sensitivities();
+ }
+
+ protected virtual void on_rate_rejected() {
+ on_set_rating(Rating.REJECTED);
+ }
+
+ protected virtual void on_rate_unrated() {
+ on_set_rating(Rating.UNRATED);
+ }
+
+ protected virtual void on_rate_one() {
+ on_set_rating(Rating.ONE);
+ }
+
+ protected virtual void on_rate_two() {
+ on_set_rating(Rating.TWO);
+ }
+
+ protected virtual void on_rate_three() {
+ on_set_rating(Rating.THREE);
+ }
+
+ protected virtual void on_rate_four() {
+ on_set_rating(Rating.FOUR);
+ }
+
+ protected virtual void on_rate_five() {
+ on_set_rating(Rating.FIVE);
+ }
+
+ private void on_remove_from_library() {
+ remove_photos_from_library((Gee.Collection<LibraryPhoto>) get_view().get_selected_sources());
+ }
+
+ protected virtual void on_move_to_trash() {
+ CheckerboardItem? restore_point = null;
+
+ if (cursor != null) {
+ restore_point = get_view().get_next(cursor) as CheckerboardItem;
+ }
+
+ if (get_view().get_selected_count() > 0) {
+ get_command_manager().execute(new TrashUntrashPhotosCommand(
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources(), true));
+ }
+
+ if ((restore_point != null) && (get_view().contains(restore_point))) {
+ set_cursor(restore_point);
+ }
+ }
+
+ protected virtual void on_edit_title() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ Gee.List<MediaSource> media_sources = (Gee.List<MediaSource>) get_view().get_selected_sources();
+
+ EditTitleDialog edit_title_dialog = new EditTitleDialog(media_sources[0].get_title());
+ string? new_title = edit_title_dialog.execute();
+ if (new_title != null)
+ get_command_manager().execute(new EditMultipleTitlesCommand(media_sources, new_title));
+ }
+
+ protected virtual void on_edit_comment() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ Gee.List<MediaSource> media_sources = (Gee.List<MediaSource>) get_view().get_selected_sources();
+
+ EditCommentDialog edit_comment_dialog = new EditCommentDialog(media_sources[0].get_comment());
+ string? new_comment = edit_comment_dialog.execute();
+ if (new_comment != null)
+ get_command_manager().execute(new EditMultipleCommentsCommand(media_sources, new_comment));
+ }
+
+ protected virtual void on_display_titles(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_titles(display);
+
+ Config.Facade.get_instance().set_display_photo_titles(display);
+ }
+
+ protected virtual void on_display_comments(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_comments(display);
+
+ Config.Facade.get_instance().set_display_photo_comments(display);
+ }
+
+ protected virtual void on_display_ratings(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_ratings(display);
+
+ Config.Facade.get_instance().set_display_photo_ratings(display);
+ }
+
+ protected virtual void on_display_tags(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_tags(display);
+
+ Config.Facade.get_instance().set_display_photo_tags(display);
+ }
+
+ protected abstract void get_config_photos_sort(out bool sort_order, out int sort_by);
+
+ protected abstract void set_config_photos_sort(bool sort_order, int sort_by);
+
+ public virtual void on_sort_changed() {
+ int sort_by = get_menu_sort_by();
+ bool sort_order = get_menu_sort_order();
+
+ set_view_comparator(sort_by, sort_order);
+ set_config_photos_sort(sort_order, sort_by);
+ }
+
+ public void on_raw_developer_shotwell(Gtk.Action action) {
+ developer_changed(RawDeveloper.SHOTWELL);
+ }
+
+ public void on_raw_developer_camera(Gtk.Action action) {
+ developer_changed(RawDeveloper.CAMERA);
+ }
+
+ protected virtual void developer_changed(RawDeveloper rd) {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ // Check if any photo has edits
+
+ // Display warning only when edits could be destroyed
+ bool need_warn = false;
+
+ // Make a list of all photos that need their developer changed.
+ Gee.ArrayList<DataView> to_set = new Gee.ArrayList<DataView>();
+ foreach (DataView view in get_view().get_selected()) {
+ Photo? p = view.get_source() as Photo;
+ if (p != null && (!rd.is_equivalent(p.get_raw_developer()))) {
+ to_set.add(view);
+
+ if (p.has_transformations()) {
+ need_warn = true;
+ }
+ }
+ }
+
+ if (!need_warn || Dialogs.confirm_warn_developer_changed(to_set.size)) {
+ SetRawDeveloperCommand command = new SetRawDeveloperCommand(to_set, rd);
+ get_command_manager().execute(command);
+
+ update_development_menu_item_sensitivity();
+ }
+ }
+
+ protected override void set_display_titles(bool display) {
+ base.set_display_titles(display);
+
+ Gtk.ToggleAction? action = get_action("ViewTitle") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ protected override void set_display_comments(bool display) {
+ base.set_display_comments(display);
+
+ Gtk.ToggleAction? action = get_action("ViewComment") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ private Gtk.RadioAction sort_by_title_action() {
+ Gtk.RadioAction action = (Gtk.RadioAction) get_action("SortByTitle");
+ assert(action != null);
+ return action;
+ }
+
+ private Gtk.RadioAction sort_ascending_action() {
+ Gtk.RadioAction action = (Gtk.RadioAction) get_action("SortAscending");
+ assert(action != null);
+ return action;
+ }
+
+ protected int get_menu_sort_by() {
+ // any member of the group knows the current value
+ return sort_by_title_action().get_current_value();
+ }
+
+ protected void set_menu_sort_by(int val) {
+ sort_by_title_action().set_current_value(val);
+ }
+
+ protected bool get_menu_sort_order() {
+ // any member of the group knows the current value
+ return sort_ascending_action().get_current_value() == SORT_ORDER_ASCENDING;
+ }
+
+ protected void set_menu_sort_order(bool ascending) {
+ sort_ascending_action().set_current_value(
+ ascending ? SORT_ORDER_ASCENDING : SORT_ORDER_DESCENDING);
+ }
+
+ void set_view_comparator(int sort_by, bool ascending) {
+ Comparator comparator;
+ ComparatorPredicate predicate;
+
+ switch (sort_by) {
+ case SortBy.TITLE:
+ if (ascending)
+ comparator = Thumbnail.title_ascending_comparator;
+ else comparator = Thumbnail.title_descending_comparator;
+ predicate = Thumbnail.title_comparator_predicate;
+ break;
+
+ case SortBy.EXPOSURE_DATE:
+ if (ascending)
+ comparator = Thumbnail.exposure_time_ascending_comparator;
+ else comparator = Thumbnail.exposure_time_desending_comparator;
+ predicate = Thumbnail.exposure_time_comparator_predicate;
+ break;
+
+ case SortBy.RATING:
+ if (ascending)
+ comparator = Thumbnail.rating_ascending_comparator;
+ else comparator = Thumbnail.rating_descending_comparator;
+ predicate = Thumbnail.rating_comparator_predicate;
+ break;
+
+ default:
+ debug("Unknown sort criteria: %s", get_menu_sort_by().to_string());
+ comparator = Thumbnail.title_descending_comparator;
+ predicate = Thumbnail.title_comparator_predicate;
+ break;
+ }
+
+ get_view().set_comparator(comparator, predicate);
+ }
+
+ protected string get_sortby_path(int sort_by) {
+ switch(sort_by) {
+ case SortBy.TITLE:
+ return "/MenuBar/ViewMenu/SortPhotos/SortByTitle";
+
+ case SortBy.EXPOSURE_DATE:
+ return "/MenuBar/ViewMenu/SortPhotos/SortByExposureDate";
+
+ case SortBy.RATING:
+ return "/MenuBar/ViewMenu/SortPhotos/SortByRating";
+
+ default:
+ debug("Unknown sort criteria: %d", sort_by);
+ return "/MenuBar/ViewMenu/SortPhotos/SortByTitle";
+ }
+ }
+
+ protected void sync_sort() {
+ // It used to be that the config and UI could both agree on what
+ // sort order and criteria were selected, but the sorting wouldn't
+ // match them, due to the current view's comparator not actually
+ // being set to match, and since there was a check to see if the
+ // config and UI matched that would frequently succeed in this case,
+ // the sorting was often wrong until the user went in and changed
+ // it. Because there is no tidy way to query the current view's
+ // comparator, we now set it any time we even think the sorting
+ // might have changed to force them to always stay in sync.
+ //
+ // Although this means we pay for a re-sort every time, in practice,
+ // this isn't terribly expensive - it _might_ take as long as .5 sec.
+ // with a media page containing over 15000 items on a modern CPU.
+
+ bool sort_ascending;
+ int sort_by;
+ get_config_photos_sort(out sort_ascending, out sort_by);
+
+ set_menu_sort_by(sort_by);
+ set_menu_sort_order(sort_ascending);
+
+ set_view_comparator(sort_by, sort_ascending);
+ }
+
+ public override void destroy() {
+ disconnect_slider();
+
+ base.destroy();
+ }
+
+ public void increase_zoom_level() {
+ if (connected_slider != null) {
+ connected_slider.increase_step();
+ } else {
+ int new_scale = compute_zoom_scale_increase(get_thumb_size());
+ save_persistent_thumbnail_scale();
+ set_thumb_size(new_scale);
+ }
+ }
+
+ public void decrease_zoom_level() {
+ if (connected_slider != null) {
+ connected_slider.decrease_step();
+ } else {
+ int new_scale = compute_zoom_scale_decrease(get_thumb_size());
+ save_persistent_thumbnail_scale();
+ set_thumb_size(new_scale);
+ }
+ }
+
+ public virtual DataView create_thumbnail(DataSource source) {
+ return new Thumbnail((MediaSource) source, get_thumb_size());
+ }
+
+ // this is a view-level operation on this page only; it does not affect the persistent global
+ // thumbnail scale
+ public void set_thumb_size(int new_scale) {
+ if (get_thumb_size() == new_scale || !is_in_view())
+ return;
+
+ new_scale = new_scale.clamp(Thumbnail.MIN_SCALE, Thumbnail.MAX_SCALE);
+ get_checkerboard_layout().set_scale(new_scale);
+
+ // when doing mass operations on LayoutItems, freeze individual notifications
+ get_view().freeze_notifications();
+ get_view().set_property(Thumbnail.PROP_SIZE, new_scale);
+ get_view().thaw_notifications();
+
+ set_action_sensitive("IncreaseSize", new_scale < Thumbnail.MAX_SCALE);
+ set_action_sensitive("DecreaseSize", new_scale > Thumbnail.MIN_SCALE);
+ }
+
+ public int get_thumb_size() {
+ if (get_checkerboard_layout().get_scale() <= 0)
+ get_checkerboard_layout().set_scale(Config.Facade.get_instance().get_photo_thumbnail_scale());
+
+ return get_checkerboard_layout().get_scale();
+ }
+}
+
diff --git a/src/MediaViewTracker.vala b/src/MediaViewTracker.vala
new file mode 100644
index 0000000..879dc84
--- /dev/null
+++ b/src/MediaViewTracker.vala
@@ -0,0 +1,114 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 MediaViewTracker : Core.ViewTracker {
+ public MediaAccumulator all = new MediaAccumulator();
+ public MediaAccumulator visible = new MediaAccumulator();
+ public MediaAccumulator selected = new MediaAccumulator();
+
+ public MediaViewTracker(ViewCollection collection) {
+ base (collection);
+
+ start(all, visible, selected);
+ }
+}
+
+public class MediaAccumulator : Object, Core.TrackerAccumulator {
+ public int total = 0;
+ public int photos = 0;
+ public int videos = 0;
+ public int raw = 0;
+ public int flagged = 0;
+
+ public bool include(DataObject object) {
+ DataSource source = ((DataView) object).get_source();
+
+ total++;
+
+ Photo? photo = source as Photo;
+ if (photo != null) {
+ if (photo.get_master_file_format() == PhotoFileFormat.RAW) {
+ raw++;
+ }
+
+ if (photo.get_master_file_format() != PhotoFileFormat.RAW ||
+ photo.is_raw_developer_available(RawDeveloper.CAMERA)) {
+ photos++;
+ }
+ } else if (source is VideoSource) {
+ videos++;
+ }
+
+ Flaggable? flaggable = source as Flaggable;
+ if (flaggable != null && flaggable.is_flagged())
+ flagged++;
+
+ // because of total, always fire "updated"
+ return true;
+ }
+
+ public bool uninclude(DataObject object) {
+ DataSource source = ((DataView) object).get_source();
+
+ if (total < 1) {
+ warning("Tried to remove DataObject %s from empty %s (%s)".printf(object.to_string(),
+ get_type().name(), to_string()));
+ return false;
+ }
+ total--;
+
+ Photo? photo = source as Photo;
+ if (photo != null) {
+ if (photo.get_master_file_format() == PhotoFileFormat.RAW) {
+ assert(raw > 0);
+ raw--;
+ }
+
+ if (photo.get_master_file_format() != PhotoFileFormat.RAW ||
+ photo.is_raw_developer_available(RawDeveloper.CAMERA)) {
+ assert(photos > 0);
+ photos--;
+ }
+ } else if (source is Video) {
+ assert(videos > 0);
+ videos--;
+ }
+
+ Flaggable? flaggable = source as Flaggable;
+ if (flaggable != null && flaggable.is_flagged()) {
+ assert(flagged > 0);
+ flagged--;
+ }
+
+ // because of total, always fire "updated"
+ return true;
+ }
+
+ public bool altered(DataObject object, Alteration alteration) {
+ // the only alteration that can happen to MediaSources this accumulator is concerned with is
+ // flagging; typeness and raw-ness don't change at runtime
+ if (!alteration.has_detail("metadata", "flagged"))
+ return false;
+
+ Flaggable? flaggable = ((DataView) object).get_source() as Flaggable;
+ if (flaggable == null)
+ return false;
+
+ if (flaggable.is_flagged()) {
+ flagged++;
+ } else {
+ assert(flagged > 0);
+ flagged--;
+ }
+
+ return true;
+ }
+
+ public string to_string() {
+ return "%d photos/%d videos/%d raw/%d flagged".printf(photos, videos, raw, flagged);
+ }
+}
+
diff --git a/src/MetadataWriter.vala b/src/MetadataWriter.vala
new file mode 100644
index 0000000..aee5855
--- /dev/null
+++ b/src/MetadataWriter.vala
@@ -0,0 +1,675 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// MetadataWriter tracks LibraryPhotos for alterations to their metadata and commits those changes
+// in a timely manner to their backing files. Because only the MetadataWriter knows when the
+// metadata has been properly committed, it is also responsible for updating the metadata-dirty
+// flag in Photo. Thus, MetadataWriter should *always* be running, even if the user has turned off
+// the feature, so if they turn it on MetadataWriter can properly go out and update the backing
+// files.
+
+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 class CommitJob : BackgroundJob {
+ public LibraryPhoto photo;
+ public Gee.Set<string>? current_keywords;
+ public Photo.ReimportMasterState reimport_master_state = null;
+ public Photo.ReimportEditableState reimport_editable_state = null;
+ public Error? err = null;
+
+ public CommitJob(MetadataWriter owner, LibraryPhoto photo, Gee.Set<string>? keywords) {
+ base (owner, owner.on_update_completed, new Cancellable(), owner.on_update_cancelled);
+
+ this.photo = photo;
+ current_keywords = keywords;
+ }
+
+ public override void execute() {
+ try {
+ commit_master();
+ commit_editable();
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+
+ private void commit_master() throws Error {
+ // If we have an editable, any orientation changes should be written only to it;
+ // otherwise, we'll end up ruining the original, and as such, breaking the
+ // ability to revert to it.
+ bool skip_orientation = photo.has_editable();
+
+ if (!photo.get_master_file_format().can_write_metadata())
+ return;
+
+ PhotoMetadata metadata = photo.get_master_metadata();
+ if (update_metadata(metadata, skip_orientation)) {
+ LibraryMonitor.blacklist_file(photo.get_master_file(), "MetadataWriter.commit_master");
+ try {
+ photo.persist_master_metadata(metadata, out reimport_master_state);
+ } finally {
+ LibraryMonitor.unblacklist_file(photo.get_master_file());
+ }
+ }
+ }
+
+ private void commit_editable() throws Error {
+ if (!photo.has_editable() || !photo.get_editable_file_format().can_write_metadata())
+ return;
+
+ PhotoMetadata? metadata = photo.get_editable_metadata();
+ assert(metadata != null);
+
+ if (update_metadata(metadata)) {
+ LibraryMonitor.blacklist_file(photo.get_editable_file(), "MetadataWriter.commit_editable");
+ try {
+ photo.persist_editable_metadata(metadata, out reimport_editable_state);
+ } finally {
+ LibraryMonitor.unblacklist_file(photo.get_editable_file());
+ }
+ }
+ }
+
+ private bool update_metadata(PhotoMetadata metadata, bool skip_orientation = false) {
+ bool changed = false;
+
+ // title (caption)
+ string? current_title = photo.get_title();
+ if (current_title != metadata.get_title()) {
+ metadata.set_title(current_title);
+ changed = true;
+ }
+
+ // comment
+ string? current_comment = photo.get_comment();
+ if (current_comment != metadata.get_comment()) {
+ metadata.set_comment(current_comment);
+ changed = true;
+ }
+
+ // rating
+ Rating current_rating = photo.get_rating();
+ if (current_rating != metadata.get_rating()) {
+ metadata.set_rating(current_rating);
+ changed = true;
+ }
+
+ // exposure date/time
+ time_t current_exposure_time = photo.get_exposure_time();
+ time_t metadata_exposure_time = 0;
+ 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
+ ? new MetadataDateTime(current_exposure_time)
+ : null);
+ changed = true;
+ }
+
+ // tags (keywords) ... replace (or clear) entirely rather than union or intersection
+ Gee.Set<string> safe_keywords = new Gee.HashSet<string>();
+
+ // Since the tags are stored in an image file's `keywords' field in
+ // non-hierarchical format, before checking whether the tags that
+ // should be associated with this image have been written, we'll need
+ // to produce non-hierarchical versions of the tags to be tested.
+ // get_user_visible_name() does this by returning the most deeply-nested
+ // portion of a given hierarchical tag; that is, for a tag "/a/b/c",
+ // it'll return "c", which is exactly the form we want here.
+ if (current_keywords != null) {
+ foreach(string tmp in current_keywords) {
+ Tag tag = Tag.for_path(tmp);
+ safe_keywords.add(tag.get_user_visible_name());
+ }
+ }
+
+ if (!equal_sets(safe_keywords, metadata.get_keywords())) {
+ metadata.set_keywords(current_keywords);
+ changed = true;
+ }
+
+ // orientation
+ if (!skip_orientation) {
+ Orientation current_orientation = photo.get_orientation();
+ if (current_orientation != metadata.get_orientation()) {
+ metadata.set_orientation(current_orientation);
+ changed = true;
+ }
+ }
+
+ // add the software name/version only if updating the metadata in the file
+ if (changed)
+ metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION);
+
+ return changed;
+ }
+ }
+
+ private static MetadataWriter instance = null;
+
+ private Workers workers = new Workers(1, false);
+ private bool enabled = false;
+ private HashTimedQueue<LibraryPhoto> dirty;
+ private Gee.HashMap<LibraryPhoto, CommitJob> pending = new Gee.HashMap<LibraryPhoto, CommitJob>();
+ private Gee.HashSet<string> interested_photo_details = new Gee.HashSet<string>();
+ private LibraryPhoto? ignore_photo_alteration = null;
+ private uint outstanding_total = 0;
+ private uint outstanding_completed = 0;
+ private bool closed = false;
+ private int pause_count = 0;
+ private Gee.HashSet<LibraryPhoto> importing_photos = new Gee.HashSet<LibraryPhoto>();
+
+ public signal void progress(uint completed, uint total);
+
+ private MetadataWriter() {
+ dirty = new HashTimedQueue<LibraryPhoto>(COMMIT_DELAY_MSEC, on_photo_dequeued);
+ dirty.set_dequeue_spacing_msec(COMMIT_SPACING_MSEC);
+
+ // start with the writer paused, waiting for the LibraryMonitor initial discovery to
+ // complete (note that if the LibraryMonitor is ever disabled, the MetadataWriter will not
+ // start on its own)
+ pause();
+
+ // convert all interested metadata Alteration details into lookup hash
+ foreach (string detail in INTERESTED_PHOTO_METADATA_DETAILS)
+ interested_photo_details.add(detail);
+
+ // sync up with the configuration system
+ enabled = Config.Facade.get_instance().get_commit_metadata_to_masters();
+ Config.Facade.get_instance().commit_metadata_to_masters_changed.connect(on_config_changed);
+
+ // add all current photos to look for ones that are dirty and need updating
+ force_rescan();
+
+ LibraryPhoto.global.media_import_starting.connect(on_importing_photos);
+ LibraryPhoto.global.media_import_completed.connect(on_photos_imported);
+ LibraryPhoto.global.contents_altered.connect(on_photos_added_removed);
+ LibraryPhoto.global.items_altered.connect(on_photos_altered);
+ LibraryPhoto.global.frozen.connect(on_collection_frozen);
+ LibraryPhoto.global.thawed.connect(on_collection_thawed);
+ LibraryPhoto.global.items_destroyed.connect(on_photos_destroyed);
+
+ Tag.global.items_altered.connect(on_tags_altered);
+ Tag.global.container_contents_altered.connect(on_tag_contents_altered);
+ Tag.global.backlink_to_container_removed.connect(on_tag_backlink_removed);
+ Tag.global.frozen.connect(on_collection_frozen);
+ Tag.global.thawed.connect(on_collection_thawed);
+
+ Application.get_instance().exiting.connect(on_application_exiting);
+
+ LibraryMonitorPool.get_instance().monitor_installed.connect(on_monitor_installed);
+ LibraryMonitorPool.get_instance().monitor_destroyed.connect(on_monitor_destroyed);
+ }
+
+ ~MetadataWriter() {
+ Config.Facade.get_instance().commit_metadata_to_masters_changed.disconnect(on_config_changed);
+
+ LibraryPhoto.global.media_import_starting.disconnect(on_importing_photos);
+ LibraryPhoto.global.media_import_completed.disconnect(on_photos_imported);
+ LibraryPhoto.global.contents_altered.disconnect(on_photos_added_removed);
+ LibraryPhoto.global.items_altered.disconnect(on_photos_altered);
+ LibraryPhoto.global.frozen.disconnect(on_collection_frozen);
+ LibraryPhoto.global.thawed.disconnect(on_collection_thawed);
+ LibraryPhoto.global.items_destroyed.disconnect(on_photos_destroyed);
+
+ Tag.global.items_altered.disconnect(on_tags_altered);
+ Tag.global.container_contents_altered.disconnect(on_tag_contents_altered);
+ Tag.global.backlink_to_container_removed.disconnect(on_tag_backlink_removed);
+ Tag.global.frozen.disconnect(on_collection_frozen);
+ Tag.global.thawed.disconnect(on_collection_thawed);
+
+ Application.get_instance().exiting.disconnect(on_application_exiting);
+
+ LibraryMonitorPool.get_instance().monitor_installed.disconnect(on_monitor_installed);
+ LibraryMonitorPool.get_instance().monitor_destroyed.disconnect(on_monitor_destroyed);
+ }
+
+ public static void init() {
+ instance = new MetadataWriter();
+ }
+
+ public static void terminate() {
+ if (instance != null)
+ instance.close();
+
+ instance = null;
+ }
+
+ public static MetadataWriter get_instance() {
+ return instance;
+ }
+
+ // This will examine all photos for dirty metadata and schedule commits if enabled.
+ public void force_rescan() {
+ schedule_if_dirty((Gee.Collection<LibraryPhoto>) LibraryPhoto.global.get_all(), "force rescan");
+ }
+
+ public void pause() {
+ if (pause_count++ != 0)
+ return;
+
+ dirty.pause();
+
+ progress(0, 0);
+ }
+
+ public void unpause() {
+ if (pause_count == 0 || --pause_count != 0)
+ return;
+
+ dirty.unpause();
+ }
+
+ public void close() {
+ if (closed)
+ return;
+
+ cancel_all(true);
+
+ closed = true;
+ }
+
+ private void on_config_changed() {
+ bool value = Config.Facade.get_instance().get_commit_metadata_to_masters();
+
+ if (enabled == value)
+ return;
+
+ enabled = value;
+ if (enabled)
+ force_rescan();
+ else
+ cancel_all(false);
+ }
+
+ private void on_application_exiting() {
+ close();
+ }
+
+ private void on_monitor_installed(LibraryMonitor monitor) {
+ monitor.discovery_completed.connect(on_discovery_completed);
+ }
+
+ private void on_monitor_destroyed(LibraryMonitor monitor) {
+ monitor.discovery_completed.disconnect(on_discovery_completed);
+ }
+
+ private void on_discovery_completed() {
+ unpause();
+ }
+
+ private void on_collection_frozen() {
+ pause();
+ }
+
+ private void on_collection_thawed() {
+ unpause();
+ }
+
+ private void on_importing_photos(Gee.Collection<MediaSource> media_sources) {
+ importing_photos.add_all((Gee.Collection<LibraryPhoto>) media_sources);
+ }
+
+ private void on_photos_imported(Gee.Collection<MediaSource> media_sources) {
+ importing_photos.remove_all((Gee.Collection<LibraryPhoto>) media_sources);
+ }
+
+ private void on_photos_added_removed(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ // no reason to go through this exercise if auto-commit is disabled
+ if (added != null && enabled)
+ schedule_if_dirty((Gee.Iterable<LibraryPhoto>) added, "added to LibraryPhoto.global");
+
+ // want to cancel jobs no matter what, however
+ if (removed != null) {
+ bool cancelled = false;
+ foreach (DataObject object in removed)
+ cancelled = cancel_job((LibraryPhoto) object) || cancelled;
+
+ if (cancelled)
+ progress(outstanding_completed, outstanding_total);
+ }
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> items) {
+ Gee.HashSet<LibraryPhoto> photos = null;
+ foreach (DataObject object in items.keys) {
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ // ignore this signal on this photo (means it's coming up from completing the metadata
+ // update)
+ if (photo == ignore_photo_alteration)
+ continue;
+
+ Alteration alteration = items.get(object);
+
+ // if an image:orientation detail, write that out
+ if (alteration.has_detail("image", "orientation")) {
+ if (photos == null)
+ photos = new Gee.HashSet<LibraryPhoto>();
+
+ photos.add(photo);
+
+ continue;
+ }
+
+ // get all "metadata" details for this alteration
+ Gee.Collection<string>? details = alteration.get_details("metadata");
+ if (details == null)
+ continue;
+
+ // only enqueue an update if an alteration of metadata actually written out occurs
+ foreach (string detail in details) {
+ if (interested_photo_details.contains(detail)) {
+ if (photos == null)
+ photos = new Gee.HashSet<LibraryPhoto>();
+
+ photos.add(photo);
+
+ break;
+ }
+ }
+ }
+
+ if (photos != null)
+ photos_are_dirty(photos, "alteration", false);
+ }
+
+ private void on_photos_destroyed(Gee.Collection<DataSource> destroyed) {
+ foreach (DataSource source in destroyed) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+ cancel_job(photo);
+ importing_photos.remove(photo);
+ }
+ }
+
+ private void on_tags_altered(Gee.Map<DataObject, Alteration> map) {
+ Gee.HashSet<LibraryPhoto>? photos = null;
+ foreach (DataObject object in map.keys) {
+ if (!map.get(object).has_detail("metadata", "name"))
+ continue;
+
+ if (photos == null)
+ photos = new Gee.HashSet<LibraryPhoto>();
+
+ foreach (MediaSource media in ((Tag) object).get_sources()) {
+ LibraryPhoto? photo = media as LibraryPhoto;
+ if (photo != null)
+ photos.add(photo);
+ }
+ }
+
+ if (photos != null)
+ photos_are_dirty(photos, "tag renamed", false);
+ }
+
+ private void on_tag_contents_altered(ContainerSource container, Gee.Collection<DataSource>? added,
+ bool relinking, Gee.Collection<DataSource>? removed, bool unlinking) {
+ Tag tag = (Tag) container;
+
+ if (added != null && !relinking) {
+ Gee.ArrayList<LibraryPhoto> added_photos = new Gee.ArrayList<LibraryPhoto>();
+ foreach (DataSource source in added) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null && !importing_photos.contains(photo))
+ added_photos.add(photo);
+ }
+
+ photos_are_dirty(added_photos, "added to %s".printf(tag.to_string()), false);
+ }
+
+ if (removed != null && !unlinking) {
+ Gee.ArrayList<LibraryPhoto> removed_photos = new Gee.ArrayList<LibraryPhoto>();
+ foreach (DataSource source in removed) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null)
+ removed_photos.add(photo);
+ }
+
+ photos_are_dirty(removed_photos, "removed from %s".printf(tag.to_string()), false);
+ }
+ }
+
+ private void on_tag_backlink_removed(ContainerSource container, Gee.Collection<DataSource> sources) {
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ foreach (DataSource source in sources) {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo != null)
+ photos.add(photo);
+ }
+
+ photos_are_dirty(photos, "backlink removed from %s".printf(container.to_string()), false);
+ }
+
+ private void count_enqueued_work(int count, bool report) {
+ outstanding_total += count;
+
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] %d metadata jobs enqueued", outstanding_completed, outstanding_total, count);
+#endif
+
+ if (report)
+ progress(outstanding_completed, outstanding_total);
+ }
+
+ private void count_cancelled_work(int count, bool report) {
+ outstanding_total = (outstanding_total >= count) ? outstanding_total - count : 0;
+ if (outstanding_completed >= outstanding_total) {
+ outstanding_completed = 0;
+ outstanding_total = 0;
+ }
+
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] %d metadata jobs cancelled", outstanding_completed, outstanding_total, count);
+#endif
+
+ if (report)
+ progress(outstanding_completed, outstanding_total);
+ }
+
+ private void count_completed_work(int count, bool report) {
+ outstanding_completed += count;
+ if (outstanding_completed >= outstanding_total) {
+ outstanding_completed = 0;
+ outstanding_total = 0;
+ }
+
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] %d metadata jobs completed", outstanding_completed, outstanding_total, count);
+#endif
+
+ if (report)
+ progress(outstanding_completed, outstanding_total);
+ }
+
+ private void schedule_if_dirty(Gee.Iterable<MediaSource> media_sources, string reason) {
+ Gee.ArrayList<LibraryPhoto> photos = null;
+ foreach (MediaSource media in media_sources) {
+ LibraryPhoto? photo = media as LibraryPhoto;
+ if (photo == null)
+ continue;
+
+ // if in the importing stage, do not schedule for commit
+ if (importing_photos.contains(photo))
+ continue;
+
+ if (photo.is_master_metadata_dirty()) {
+ if (photos == null)
+ photos = new Gee.ArrayList<LibraryPhoto>();
+
+ photos.add(photo);
+ }
+ }
+
+ if (photos != null)
+ photos_are_dirty(photos, reason, true);
+ }
+
+ // No photos are dirty. The human body is a thing of beauty and grace.
+ private void photos_are_dirty(Gee.Collection<LibraryPhoto> photos, string reason, bool already_marked) {
+ if (photos.size == 0)
+ return;
+
+ // cancel all outstanding and pending jobs
+ foreach (LibraryPhoto photo in photos)
+ cancel_job(photo);
+
+ // mark all the photos as dirty
+ if (!already_marked) {
+ try {
+ LibraryPhoto.global.transaction_controller.begin();
+
+ foreach (LibraryPhoto photo in photos)
+ photo.set_master_metadata_dirty(true);
+
+ LibraryPhoto.global.transaction_controller.commit();
+ } catch (Error err) {
+ if (err is DatabaseError)
+ AppWindow.database_error((DatabaseError) err);
+ else
+ error("Unable to mark metadata as dirty: %s", err.message);
+ }
+ }
+
+ // ok to drop this on the floor, now that they're marked dirty (will attempt to write them
+ // out the next time MetadataWriter runs)
+ if (closed || !enabled)
+ return;
+
+#if TRACE_METADATA_WRITER
+ debug("[%s] adding %d photos to dirty list", reason, photos.size);
+#endif
+
+ foreach (LibraryPhoto photo in photos) {
+ bool enqueued = dirty.enqueue(photo);
+ assert(enqueued);
+ }
+
+ count_enqueued_work(photos.size, true);
+ }
+
+ private void cancel_all(bool wait) {
+ dirty.clear();
+
+ foreach (CommitJob job in pending.values)
+ job.cancel();
+
+ if (wait)
+ workers.wait_for_empty_queue();
+
+ count_cancelled_work(int.MAX, true);
+ }
+
+ private bool cancel_job(LibraryPhoto photo) {
+ bool cancelled = false;
+
+ if (pending.has_key(photo)) {
+ pending.get(photo).cancel();
+ cancelled = true;
+ }
+
+ if (dirty.contains(photo)) {
+ bool removed = dirty.remove_first(photo);
+ assert(removed);
+
+ assert(!dirty.contains(photo));
+
+ count_cancelled_work(1, false);
+ cancelled = true;
+ }
+
+ return cancelled;
+ }
+
+ private void on_photo_dequeued(LibraryPhoto photo) {
+ if (!enabled) {
+ count_cancelled_work(1, true);
+
+ return;
+ }
+
+ Gee.Set<string>? keywords = null;
+ Gee.Collection<Tag>? tags = Tag.global.fetch_for_source(photo);
+ if (tags != null) {
+ keywords = new Gee.HashSet<string>();
+ foreach (Tag tag in tags)
+ keywords.add(tag.get_name());
+ }
+
+ CommitJob job = new CommitJob(this, photo, keywords);
+ pending.set(photo, job);
+
+#if TRACE_METADATA_WRITER
+ debug("%s dequeued for metadata commit, %d pending", photo.to_string(), pending.size);
+#endif
+
+ workers.enqueue(job);
+ }
+
+ private void on_update_completed(BackgroundJob j) {
+ CommitJob job = (CommitJob) j;
+
+ if (job.err != null)
+ warning("Unable to update metadata for %s: %s", job.photo.to_string(), job.err.message);
+ else
+ message("Completed writing metadata for %s", job.photo.to_string());
+
+ bool removed = pending.unset(job.photo);
+ assert(removed);
+
+ // since there's potentially multiple state-change operations here, use the transaction
+ // controller
+ LibraryPhoto.global.transaction_controller.begin();
+
+ if (job.reimport_master_state != null || job.reimport_editable_state != null) {
+ // finish_update_*_metadata are going to issue an "altered" signal, and we want to
+ // ignore it
+ assert(ignore_photo_alteration == null);
+ ignore_photo_alteration = job.photo;
+ try {
+ if (job.reimport_master_state != null)
+ job.photo.finish_update_master_metadata(job.reimport_master_state);
+
+ if (job.reimport_editable_state != null)
+ job.photo.finish_update_editable_metadata(job.reimport_editable_state);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ } finally {
+ // this assertion guards against reentrancy
+ assert(ignore_photo_alteration == job.photo);
+ ignore_photo_alteration = null;
+ }
+ } else {
+#if TRACE_METADATA_WRITER
+ debug("[%u/%u] No metadata changes for %s", outstanding_completed, outstanding_total,
+ job.photo.to_string());
+#endif
+ }
+
+ try {
+ job.photo.set_master_metadata_dirty(false);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ LibraryPhoto.global.transaction_controller.commit();
+
+ count_completed_work(1, true);
+ }
+
+ private void on_update_cancelled(BackgroundJob j) {
+ bool removed = pending.unset(((CommitJob) j).photo);
+ assert(removed);
+
+ count_cancelled_work(1, true);
+ }
+}
+
diff --git a/src/Orientation.vala b/src/Orientation.vala
new file mode 100644
index 0000000..b31c22d
--- /dev/null
+++ b/src/Orientation.vala
@@ -0,0 +1,493 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public enum Orientation {
+ MIN = 1,
+ TOP_LEFT = 1,
+ TOP_RIGHT = 2,
+ BOTTOM_RIGHT = 3,
+ BOTTOM_LEFT = 4,
+ LEFT_TOP = 5,
+ RIGHT_TOP = 6,
+ RIGHT_BOTTOM = 7,
+ LEFT_BOTTOM = 8,
+ MAX = 8;
+
+ public string to_string() {
+ switch (this) {
+ case TOP_LEFT:
+ return "top-left";
+
+ case TOP_RIGHT:
+ return "top-right";
+
+ case BOTTOM_RIGHT:
+ return "bottom-right";
+
+ case BOTTOM_LEFT:
+ return "bottom-left";
+
+ case LEFT_TOP:
+ return "left-top";
+
+ case RIGHT_TOP:
+ return "right-top";
+
+ case RIGHT_BOTTOM:
+ return "right-bottom";
+
+ case LEFT_BOTTOM:
+ return "left-bottom";
+
+ default:
+ return "unknown orientation %d".printf((int) this);
+ }
+ }
+
+ public Orientation rotate_clockwise() {
+ switch (this) {
+ case TOP_LEFT:
+ return RIGHT_TOP;
+
+ case TOP_RIGHT:
+ return RIGHT_BOTTOM;
+
+ case BOTTOM_RIGHT:
+ return LEFT_BOTTOM;
+
+ case BOTTOM_LEFT:
+ return LEFT_TOP;
+
+ case LEFT_TOP:
+ return TOP_RIGHT;
+
+ case RIGHT_TOP:
+ return BOTTOM_RIGHT;
+
+ case RIGHT_BOTTOM:
+ return BOTTOM_LEFT;
+
+ case LEFT_BOTTOM:
+ return TOP_LEFT;
+
+ default:
+ error("rotate_clockwise: %d", this);
+ }
+ }
+
+ public Orientation rotate_counterclockwise() {
+ switch (this) {
+ case TOP_LEFT:
+ return LEFT_BOTTOM;
+
+ case TOP_RIGHT:
+ return LEFT_TOP;
+
+ case BOTTOM_RIGHT:
+ return RIGHT_TOP;
+
+ case BOTTOM_LEFT:
+ return RIGHT_BOTTOM;
+
+ case LEFT_TOP:
+ return BOTTOM_LEFT;
+
+ case RIGHT_TOP:
+ return TOP_LEFT;
+
+ case RIGHT_BOTTOM:
+ return TOP_RIGHT;
+
+ case LEFT_BOTTOM:
+ return BOTTOM_RIGHT;
+
+ default:
+ error("rotate_counterclockwise: %d", this);
+ }
+ }
+
+ public Orientation flip_top_to_bottom() {
+ switch (this) {
+ case TOP_LEFT:
+ return BOTTOM_LEFT;
+
+ case TOP_RIGHT:
+ return BOTTOM_RIGHT;
+
+ case BOTTOM_RIGHT:
+ return TOP_RIGHT;
+
+ case BOTTOM_LEFT:
+ return TOP_LEFT;
+
+ case LEFT_TOP:
+ return LEFT_BOTTOM;
+
+ case RIGHT_TOP:
+ return RIGHT_BOTTOM;
+
+ case RIGHT_BOTTOM:
+ return RIGHT_TOP;
+
+ case LEFT_BOTTOM:
+ return LEFT_TOP;
+
+ default:
+ error("flip_top_to_bottom: %d", this);
+ }
+ }
+
+ public Orientation flip_left_to_right() {
+ switch (this) {
+ case TOP_LEFT:
+ return TOP_RIGHT;
+
+ case TOP_RIGHT:
+ return TOP_LEFT;
+
+ case BOTTOM_RIGHT:
+ return BOTTOM_LEFT;
+
+ case BOTTOM_LEFT:
+ return BOTTOM_RIGHT;
+
+ case LEFT_TOP:
+ return RIGHT_TOP;
+
+ case RIGHT_TOP:
+ return LEFT_TOP;
+
+ case RIGHT_BOTTOM:
+ return LEFT_BOTTOM;
+
+ case LEFT_BOTTOM:
+ return RIGHT_BOTTOM;
+
+ default:
+ error("flip_left_to_right: %d", this);
+ }
+ }
+
+ public Orientation perform(Rotation rotation) {
+ switch (rotation) {
+ case Rotation.CLOCKWISE:
+ return rotate_clockwise();
+
+ case Rotation.COUNTERCLOCKWISE:
+ return rotate_counterclockwise();
+
+ case Rotation.MIRROR:
+ return flip_left_to_right();
+
+ case Rotation.UPSIDE_DOWN:
+ return flip_top_to_bottom();
+
+ default:
+ error("perform: %d", (int) rotation);
+ }
+ }
+
+ public Rotation[] to_rotations() {
+ switch (this) {
+ case TOP_LEFT:
+ // identity orientation
+ return { };
+
+ case TOP_RIGHT:
+ return { Rotation.MIRROR };
+
+ case BOTTOM_RIGHT:
+ return { Rotation.UPSIDE_DOWN };
+
+ case BOTTOM_LEFT:
+ // flip top-to-bottom
+ return { Rotation.MIRROR, Rotation.UPSIDE_DOWN };
+
+ case LEFT_TOP:
+ return { Rotation.COUNTERCLOCKWISE, Rotation.UPSIDE_DOWN };
+
+ case RIGHT_TOP:
+ return { Rotation.CLOCKWISE };
+
+ case RIGHT_BOTTOM:
+ return { Rotation.CLOCKWISE, Rotation.UPSIDE_DOWN };
+
+ case LEFT_BOTTOM:
+ return { Rotation.COUNTERCLOCKWISE };
+
+ default:
+ error("to_rotations: %d", this);
+ }
+ }
+
+ public Dimensions rotate_dimensions(Dimensions dim) {
+ switch (this) {
+ case Orientation.TOP_LEFT:
+ case Orientation.TOP_RIGHT:
+ case Orientation.BOTTOM_RIGHT:
+ case Orientation.BOTTOM_LEFT:
+ // fine just as it is
+ return dim;
+
+ case Orientation.LEFT_TOP:
+ case Orientation.RIGHT_TOP:
+ case Orientation.RIGHT_BOTTOM:
+ case Orientation.LEFT_BOTTOM:
+ // swap
+ return Dimensions(dim.height, dim.width);
+
+ default:
+ error("rotate_dimensions: %d", this);
+ }
+ }
+
+ public Dimensions derotate_dimensions(Dimensions dim) {
+ return rotate_dimensions(dim);
+ }
+
+ public Gdk.Pixbuf rotate_pixbuf(Gdk.Pixbuf pixbuf) {
+ Gdk.Pixbuf rotated;
+ switch (this) {
+ case TOP_LEFT:
+ // fine just as it is
+ rotated = pixbuf;
+ break;
+
+ case TOP_RIGHT:
+ // mirror
+ rotated = pixbuf.flip(true);
+ break;
+
+ case BOTTOM_RIGHT:
+ rotated = pixbuf.rotate_simple(Gdk.PixbufRotation.UPSIDEDOWN);
+ break;
+
+ case BOTTOM_LEFT:
+ // flip top-to-bottom
+ rotated = pixbuf.flip(false);
+ break;
+
+ case LEFT_TOP:
+ rotated = pixbuf.rotate_simple(Gdk.PixbufRotation.COUNTERCLOCKWISE).flip(false);
+ break;
+
+ case RIGHT_TOP:
+ rotated = pixbuf.rotate_simple(Gdk.PixbufRotation.CLOCKWISE);
+ break;
+
+ case RIGHT_BOTTOM:
+ rotated = pixbuf.rotate_simple(Gdk.PixbufRotation.CLOCKWISE).flip(false);
+ break;
+
+ case LEFT_BOTTOM:
+ rotated = pixbuf.rotate_simple(Gdk.PixbufRotation.COUNTERCLOCKWISE);
+ break;
+
+ default:
+ error("rotate_pixbuf: %d", this);
+ }
+
+ return rotated;
+ }
+
+ // space is the unrotated dimensions the point is rotating with
+ public Gdk.Point rotate_point(Dimensions space, Gdk.Point point) {
+ assert(space.has_area());
+ assert(point.x >= 0);
+ assert(point.x < space.width);
+ assert(point.y >= 0);
+ assert(point.y < space.height);
+
+ Gdk.Point rotated = Gdk.Point();
+
+ switch (this) {
+ case TOP_LEFT:
+ // fine as-is
+ rotated = point;
+ break;
+
+ case TOP_RIGHT:
+ // mirror
+ rotated.x = space.width - point.x - 1;
+ rotated.y = point.y;
+ break;
+
+ case BOTTOM_RIGHT:
+ // rotate 180
+ rotated.x = space.width - point.x - 1;
+ rotated.y = space.height - point.y - 1;
+ break;
+
+ case BOTTOM_LEFT:
+ // flip top-to-bottom
+ rotated.x = point.x;
+ rotated.y = space.height - point.y - 1;
+ break;
+
+ case LEFT_TOP:
+ // rotate 90, flip top-to-bottom
+ rotated.x = point.y;
+ rotated.y = point.x;
+ break;
+
+ case RIGHT_TOP:
+ // rotate 270
+ rotated.x = space.height - point.y - 1;
+ rotated.y = point.x;
+ break;
+
+ case RIGHT_BOTTOM:
+ // rotate 270, flip top-to-bottom
+ rotated.x = space.height - point.y - 1;
+ rotated.y = space.width - point.x - 1;
+ break;
+
+ case LEFT_BOTTOM:
+ // rotate 90
+ rotated.x = point.y;
+ rotated.y = space.width - point.x - 1;
+ break;
+
+ default:
+ error("rotate_point: %d", this);
+ }
+
+ return rotated;
+ }
+
+ // space is the unrotated dimensions the point is return to
+ public Gdk.Point derotate_point(Dimensions space, Gdk.Point point) {
+ assert(space.has_area());
+
+ Gdk.Point derotated = Gdk.Point();
+
+ switch (this) {
+ case TOP_LEFT:
+ // fine as-is
+ derotated = point;
+ break;
+
+ case TOP_RIGHT:
+ // mirror
+ derotated.x = space.width - point.x - 1;
+ derotated.y = point.y;
+ break;
+
+ case BOTTOM_RIGHT:
+ // rotate 180
+ derotated.x = space.width - point.x - 1;
+ derotated.y = space.height - point.y - 1;
+ break;
+
+ case BOTTOM_LEFT:
+ // flip top-to-bottom
+ derotated.x = point.x;
+ derotated.y = space.height - point.y - 1;
+ break;
+
+ case LEFT_TOP:
+ // rotate 90, flip top-to-bottom
+ derotated.x = point.y;
+ derotated.y = point.x;
+ break;
+
+ case RIGHT_TOP:
+ // rotate 270
+ derotated.x = point.y;
+ derotated.y = space.height - point.x - 1;
+ break;
+
+ case RIGHT_BOTTOM:
+ // rotate 270, flip top-to-bottom
+ derotated.x = space.width - point.y - 1;
+ derotated.y = space.height - point.x - 1;
+ break;
+
+ case LEFT_BOTTOM:
+ // rotate 90
+ derotated.x = space.width - point.y - 1;
+ derotated.y = point.x;
+ break;
+
+ default:
+ error("rotate_point: %d", this);
+ }
+
+ return derotated;
+ }
+
+ // space is the unrotated dimensions the point is rotating with
+ public Box rotate_box(Dimensions space, Box box) {
+ Gdk.Point top_left, bottom_right;
+ box.get_points(out top_left, out bottom_right);
+
+ top_left.x = top_left.x.clamp(0, space.width - 1);
+ top_left.y = top_left.y.clamp(0, space.height - 1);
+
+ bottom_right.x = bottom_right.x.clamp(0, space.width - 1);
+ bottom_right.y = bottom_right.y.clamp(0, space.height - 1);
+
+ top_left = rotate_point(space, top_left);
+ bottom_right = rotate_point(space, bottom_right);
+
+ return Box.from_points(top_left, bottom_right);
+ }
+
+ // space is the unrotated dimensions the point is return to
+ public Box derotate_box(Dimensions space, Box box) {
+ Gdk.Point top_left, bottom_right;
+ box.get_points(out top_left, out bottom_right);
+
+ top_left = derotate_point(space, top_left);
+ bottom_right = derotate_point(space, bottom_right);
+
+ return Box.from_points(top_left, bottom_right);
+ }
+}
+
+public enum Rotation {
+ CLOCKWISE,
+ COUNTERCLOCKWISE,
+ MIRROR,
+ UPSIDE_DOWN;
+
+ public Gdk.Pixbuf perform(Gdk.Pixbuf pixbuf) {
+ switch (this) {
+ case CLOCKWISE:
+ return pixbuf.rotate_simple(Gdk.PixbufRotation.CLOCKWISE);
+
+ case COUNTERCLOCKWISE:
+ return pixbuf.rotate_simple(Gdk.PixbufRotation.COUNTERCLOCKWISE);
+
+ case MIRROR:
+ return pixbuf.flip(true);
+
+ case UPSIDE_DOWN:
+ return pixbuf.flip(false);
+
+ default:
+ error("Unknown rotation: %d", (int) this);
+ }
+ }
+
+ public Rotation opposite() {
+ switch (this) {
+ case CLOCKWISE:
+ return COUNTERCLOCKWISE;
+
+ case COUNTERCLOCKWISE:
+ return CLOCKWISE;
+
+ case MIRROR:
+ case UPSIDE_DOWN:
+ return this;
+
+ default:
+ error("Unknown rotation: %d", (int) this);
+ }
+ }
+}
+
diff --git a/src/Page.vala b/src/Page.vala
new file mode 100644
index 0000000..fd69431
--- /dev/null
+++ b/src/Page.vala
@@ -0,0 +1,2589 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class InjectionGroup {
+ public class Element {
+ public string name;
+ public string action;
+ public Gtk.UIManagerItemType kind;
+
+ public Element(string name, string? action, Gtk.UIManagerItemType kind) {
+ this.name = name;
+ this.action = action != null ? action : name;
+ this.kind = kind;
+ }
+ }
+
+ private string path;
+ private Gee.ArrayList<Element?> elements = new Gee.ArrayList<Element?>();
+ private int separator_id = 0;
+
+ public InjectionGroup(string path) {
+ this.path = path;
+ }
+
+ public string get_path() {
+ return path;
+ }
+
+ public Gee.List<Element?> get_elements() {
+ return elements;
+ }
+
+ public void add_menu_item(string name, string? action = null) {
+ elements.add(new Element(name, action, Gtk.UIManagerItemType.MENUITEM));
+ }
+
+ public void add_menu(string name, string? action = null) {
+ elements.add(new Element(name, action, Gtk.UIManagerItemType.MENU));
+ }
+
+ public void add_separator() {
+ elements.add(new Element("%d-separator".printf(separator_id++), null, Gtk.UIManagerItemType.SEPARATOR));
+ }
+}
+
+public abstract class Page : Gtk.ScrolledWindow {
+ private const int CONSIDER_CONFIGURE_HALTED_MSEC = 400;
+
+ protected Gtk.UIManager ui;
+ protected Gtk.Toolbar toolbar;
+ protected bool in_view = false;
+
+ private string page_name;
+ private ViewCollection view = null;
+ private Gtk.Window container = null;
+ 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;
+ private Gdk.Point last_down = Gdk.Point();
+ private bool is_destroyed = false;
+ private bool ctrl_pressed = false;
+ private bool alt_pressed = false;
+ private bool shift_pressed = false;
+ private bool super_pressed = false;
+ private Gdk.CursorType last_cursor = Gdk.CursorType.LEFT_PTR;
+ private bool cursor_hidden = false;
+ private int cursor_hide_msec = 0;
+ private uint last_timeout_id = 0;
+ private int cursor_hide_time_cached = 0;
+ private bool are_actions_attached = false;
+ private OneShotScheduler? update_actions_scheduler = null;
+ private Gtk.ActionGroup? action_group = null;
+ private Gtk.ActionGroup[]? common_action_groups = null;
+
+ private uint[] merge_ids = new uint[0];
+
+ protected Page(string page_name) {
+ this.page_name = page_name;
+
+ view = new ViewCollection("ViewCollection for Page %s".printf(page_name));
+
+ last_down = { -1, -1 };
+
+ set_can_focus(true);
+
+ popup_menu.connect(on_context_keypress);
+
+ init_ui();
+
+ realize.connect(attach_view_signals);
+
+ Resources.style_widget(this, Resources.SCROLL_FRAME_STYLESHEET);
+ }
+
+ ~Page() {
+#if TRACE_DTORS
+ debug("DTOR: Page %s", page_name);
+#endif
+ }
+
+ // This is called by the page controller when it has removed this page ... pages should override
+ // this (or the signal) to clean up
+ public override void destroy() {
+ if (is_destroyed)
+ return;
+
+ // untie signals
+ detach_event_source();
+ detach_view_signals();
+ view.close();
+
+ // remove refs to external objects which may be pointing to the Page
+ clear_container();
+
+ if (toolbar != null)
+ toolbar.destroy();
+
+ // halt any pending callbacks
+ if (update_actions_scheduler != null)
+ update_actions_scheduler.cancel();
+
+ is_destroyed = true;
+
+ base.destroy();
+
+ debug("Page %s Destroyed", get_page_name());
+ }
+
+ public string get_page_name() {
+ return page_name;
+ }
+
+ public virtual void set_page_name(string page_name) {
+ this.page_name = page_name;
+ }
+
+ public string to_string() {
+ return page_name;
+ }
+
+ public ViewCollection get_view() {
+ return view;
+ }
+
+ public Gtk.Window? get_container() {
+ return container;
+ }
+
+ public virtual void set_container(Gtk.Window container) {
+ assert(this.container == null);
+
+ this.container = container;
+ ui = ((PageWindow) container).get_ui_manager();
+ }
+
+ public virtual void clear_container() {
+ container = null;
+ }
+
+ public void set_event_source(Gtk.Widget event_source) {
+ assert(this.event_source == null);
+
+ this.event_source = event_source;
+ event_source.set_can_focus(true);
+
+ // interested in mouse button and motion events on the event source
+ event_source.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK
+ | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.POINTER_MOTION_HINT_MASK
+ | Gdk.EventMask.BUTTON_MOTION_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK
+ | Gdk.EventMask.SCROLL_MASK);
+ event_source.button_press_event.connect(on_button_pressed_internal);
+ event_source.button_release_event.connect(on_button_released_internal);
+ event_source.motion_notify_event.connect(on_motion_internal);
+ event_source.leave_notify_event.connect(on_leave_notify_event);
+ event_source.scroll_event.connect(on_mousewheel_internal);
+ event_source.realize.connect(on_event_source_realize);
+ }
+
+ private void detach_event_source() {
+ if (event_source == null)
+ return;
+
+ event_source.button_press_event.disconnect(on_button_pressed_internal);
+ event_source.button_release_event.disconnect(on_button_released_internal);
+ event_source.motion_notify_event.disconnect(on_motion_internal);
+ event_source.leave_notify_event.disconnect(on_leave_notify_event);
+ event_source.scroll_event.disconnect(on_mousewheel_internal);
+
+ disable_drag_source();
+
+ event_source = null;
+ }
+
+ public Gtk.Widget? get_event_source() {
+ return event_source;
+ }
+
+ public virtual Gtk.MenuBar get_menubar() {
+ Gtk.MenuBar? menubar = ui.get_widget("/MenuBar") as Gtk.MenuBar;
+ assert(menubar != null);
+
+ return menubar;
+ }
+
+ public virtual unowned Gtk.Widget get_page_ui_widget(string path) {
+ return ui.get_widget(path);
+ }
+
+ public virtual Gtk.Toolbar get_toolbar() {
+ if (toolbar == null) {
+ toolbar = toolbar_path == null ? new Gtk.Toolbar() :
+ ui.get_widget(toolbar_path) as Gtk.Toolbar;
+ toolbar.get_style_context().add_class("bottom-toolbar"); // for elementary theme
+ }
+ return toolbar;
+ }
+
+ public virtual Gtk.Menu? get_page_context_menu() {
+ return null;
+ }
+
+ public virtual void switching_from() {
+ in_view = false;
+ remove_ui();
+ if (toolbar_path != null)
+ toolbar = null;
+ }
+
+ public virtual void switched_to() {
+ in_view = true;
+ add_ui();
+ update_modifiers();
+ }
+
+ public virtual void ready() {
+ }
+
+ public bool is_in_view() {
+ return in_view;
+ }
+
+ public virtual void switching_to_fullscreen(FullscreenWindow fsw) {
+ }
+
+ public virtual void returning_from_fullscreen(FullscreenWindow fsw) {
+ }
+
+ public Gtk.Action? get_action(string name) {
+ if (action_group == null)
+ return null;
+
+ Gtk.Action? action = action_group.get_action(name);
+ if (action == null)
+ action = get_common_action(name, false);
+
+ if (action == null)
+ warning("Page %s: Unable to locate action %s", get_page_name(), name);
+
+ return action;
+ }
+
+ public void set_action_sensitive(string name, bool sensitive) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.sensitive = sensitive;
+ }
+
+ public void set_action_important(string name, bool important) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.is_important = important;
+ }
+
+ public void set_action_visible(string name, bool visible) {
+ Gtk.Action? action = get_action(name);
+ if (action == null)
+ return;
+
+ action.visible = visible;
+ action.sensitive = visible;
+ }
+
+ public void set_action_short_label(string name, string short_label) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.short_label = short_label;
+ }
+
+ public void set_action_details(string name, string? label, string? tooltip, bool sensitive) {
+ Gtk.Action? action = get_action(name);
+ if (action == null)
+ return;
+
+ if (label != null)
+ action.label = label;
+
+ if (tooltip != null)
+ action.tooltip = tooltip;
+
+ action.sensitive = sensitive;
+ }
+
+ public void activate_action(string name) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.activate();
+ }
+
+ public Gtk.Action? get_common_action(string name, bool log_warning = true) {
+ if (common_action_groups == null)
+ return null;
+
+ foreach (Gtk.ActionGroup group in common_action_groups) {
+ Gtk.Action? action = group.get_action(name);
+ if (action != null)
+ return action;
+ }
+
+ if (log_warning)
+ warning("Page %s: Unable to locate common action %s", get_page_name(), name);
+
+ return null;
+ }
+
+ public void set_common_action_sensitive(string name, bool sensitive) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.sensitive = sensitive;
+ }
+
+ public void set_common_action_label(string name, string label) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.set_label(label);
+ }
+
+ public void set_common_action_important(string name, bool important) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.is_important = important;
+ }
+
+ public void activate_common_action(string name) {
+ Gtk.Action? action = get_common_action(name);
+ if (action != null)
+ action.activate();
+ }
+
+ public bool get_ctrl_pressed() {
+ return ctrl_pressed;
+ }
+
+ public bool get_alt_pressed() {
+ return alt_pressed;
+ }
+
+ public bool get_shift_pressed() {
+ return shift_pressed;
+ }
+
+ public bool get_super_pressed() {
+ return super_pressed;
+ }
+
+ private bool get_modifiers(out bool ctrl, out bool alt, out bool shift, out bool super) {
+ if (AppWindow.get_instance().get_window() == null) {
+ ctrl = false;
+ alt = false;
+ shift = false;
+ super = false;
+
+ return false;
+ }
+
+ int x, y;
+ Gdk.ModifierType mask;
+ AppWindow.get_instance().get_window().get_device_position(Gdk.Display.get_default().
+ get_device_manager().get_client_pointer(), out x, out y, out mask);
+
+ ctrl = (mask & Gdk.ModifierType.CONTROL_MASK) != 0;
+ alt = (mask & Gdk.ModifierType.MOD1_MASK) != 0;
+ shift = (mask & Gdk.ModifierType.SHIFT_MASK) != 0;
+ super = (mask & Gdk.ModifierType.MOD4_MASK) != 0; // not SUPER_MASK
+
+ return true;
+ }
+
+ private void update_modifiers() {
+ bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
+ super_currently_pressed;
+ if (!get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
+ out shift_currently_pressed, out super_currently_pressed)) {
+ return;
+ }
+
+ if (ctrl_pressed && !ctrl_currently_pressed)
+ on_ctrl_released(null);
+ else if (!ctrl_pressed && ctrl_currently_pressed)
+ on_ctrl_pressed(null);
+
+ if (alt_pressed && !alt_currently_pressed)
+ on_alt_released(null);
+ else if (!alt_pressed && alt_currently_pressed)
+ on_alt_pressed(null);
+
+ if (shift_pressed && !shift_currently_pressed)
+ on_shift_released(null);
+ else if (!shift_pressed && shift_currently_pressed)
+ on_shift_pressed(null);
+
+ if(super_pressed && !super_currently_pressed)
+ on_super_released(null);
+ else if (!super_pressed && super_currently_pressed)
+ on_super_pressed(null);
+
+ ctrl_pressed = ctrl_currently_pressed;
+ alt_pressed = alt_currently_pressed;
+ shift_pressed = shift_currently_pressed;
+ super_pressed = super_currently_pressed;
+ }
+
+ public PageWindow? get_page_window() {
+ Gtk.Widget p = parent;
+ while (p != null) {
+ if (p is PageWindow)
+ return (PageWindow) p;
+
+ p = p.parent;
+ }
+
+ return null;
+ }
+
+ public CommandManager get_command_manager() {
+ return AppWindow.get_command_manager();
+ }
+
+ private void init_ui() {
+ action_group = new Gtk.ActionGroup("PageActionGroup");
+
+ // Collect all Gtk.Actions and add them to the Page's Gtk.ActionGroup
+ Gtk.ActionEntry[] action_entries = init_collect_action_entries();
+ if (action_entries.length > 0)
+ action_group.add_actions(action_entries, this);
+
+ // Collect all Gtk.ToggleActionEntries and add them to the Gtk.ActionGroup
+ Gtk.ToggleActionEntry[] toggle_entries = init_collect_toggle_action_entries();
+ if (toggle_entries.length > 0)
+ action_group.add_toggle_actions(toggle_entries, this);
+
+ // Collect all Gtk.RadioActionEntries and add them to the Gtk.ActionGroup
+ // (Would use a similar collection scheme as the other calls, but there is a binding
+ // problem with Gtk.RadioActionCallback that doesn't allow it to be stored in a struct)
+ register_radio_actions(action_group);
+
+ // Get global (common) action groups from the application window
+ common_action_groups = AppWindow.get_instance().get_common_action_groups();
+ }
+
+ private void add_ui() {
+ // Collect all UI filenames and load them into the UI manager
+ Gee.List<string> ui_filenames = new Gee.ArrayList<string>();
+ init_collect_ui_filenames(ui_filenames);
+ if (ui_filenames.size == 0)
+ message("No UI file specified for %s", get_page_name());
+
+ foreach (string ui_filename in ui_filenames)
+ init_load_ui(ui_filename);
+
+ ui.insert_action_group(action_group, 0);
+
+ // Collect injected UI elements and add them to the UI manager
+ InjectionGroup[] injection_groups = init_collect_injection_groups();
+ foreach (InjectionGroup group in injection_groups) {
+ foreach (InjectionGroup.Element element in group.get_elements()) {
+ uint merge_id = ui.new_merge_id();
+ ui.add_ui(merge_id, group.get_path(), element.name, element.action,
+ element.kind, false);
+ merge_ids += merge_id;
+ }
+ }
+
+ AppWindow.get_instance().replace_common_placeholders(ui);
+
+ ui.ensure_update();
+ }
+
+ private void remove_ui() {
+ for (int i = merge_ids.length - 1 ; i >= 0 ; --i)
+ ui.remove_ui(merge_ids[i]);
+ ui.remove_action_group(action_group);
+ merge_ids.resize(0);
+
+ ui.ensure_update();
+ }
+
+ public void init_toolbar(string path) {
+ toolbar_path = path;
+ }
+
+ // Called from "realize"
+ private void attach_view_signals() {
+ if (are_actions_attached)
+ return;
+
+ // initialize the Gtk.Actions according to current state
+ int selected_count = get_view().get_selected_count();
+ int count = get_view().get_count();
+ init_actions(selected_count, count);
+ update_actions(selected_count, count);
+
+ // monitor state changes to update actions
+ get_view().items_state_changed.connect(on_update_actions);
+ get_view().selection_group_altered.connect(on_update_actions);
+ get_view().items_visibility_changed.connect(on_update_actions);
+ get_view().contents_altered.connect(on_update_actions);
+
+ are_actions_attached = true;
+ }
+
+ // Called from destroy()
+ private void detach_view_signals() {
+ if (!are_actions_attached)
+ return;
+
+ get_view().items_state_changed.disconnect(on_update_actions);
+ get_view().selection_group_altered.disconnect(on_update_actions);
+ get_view().items_visibility_changed.disconnect(on_update_actions);
+ get_view().contents_altered.disconnect(on_update_actions);
+
+ are_actions_attached = false;
+ }
+
+ private void on_update_actions() {
+ if (update_actions_scheduler == null) {
+ update_actions_scheduler = new OneShotScheduler(
+ "Update actions scheduler for %s".printf(get_page_name()),
+ on_update_actions_on_idle);
+ }
+
+ update_actions_scheduler.at_priority_idle(Priority.LOW);
+ }
+
+ private void on_update_actions_on_idle() {
+ if (is_destroyed)
+ return;
+
+ update_actions(get_view().get_selected_count(), get_view().get_count());
+ }
+
+ private void init_load_ui(string ui_filename) {
+ File ui_file = Resources.get_ui(ui_filename);
+
+ try {
+ merge_ids += ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ AppWindow.error_message("Error loading UI file %s: %s".printf(
+ ui_file.get_path(), err.message));
+ Application.get_instance().panic();
+ }
+ }
+
+ // This is called during init_ui() to collect all the UI files to be loaded into the UI
+ // manager. Because order is important here, call the base method *first*, then add the
+ // classes' filename.
+ protected virtual void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ }
+
+ // This is called during init_ui() to collect all Gtk.ActionEntries for the page.
+ protected virtual Gtk.ActionEntry[] init_collect_action_entries() {
+ return new Gtk.ActionEntry[0];
+ }
+
+ // This is called during init_ui() to collect all Gtk.ToggleActionEntries for the page
+ protected virtual Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ return new Gtk.ToggleActionEntry[0];
+ }
+
+ // This is called during init_ui() to collect all Gtk.RadioActionEntries for the page
+ protected virtual void register_radio_actions(Gtk.ActionGroup action_group) {
+ }
+
+ // This is called during init_ui() to collect all Page.InjectedUIElements for the page. They
+ // should be added to the MultiSet using the injection path as the key.
+ protected virtual InjectionGroup[] init_collect_injection_groups() {
+ return new InjectionGroup[0];
+ }
+
+ // This is called during "map" allowing for Gtk.Actions to be updated at
+ // initialization time.
+ protected virtual void init_actions(int selected_count, int count) {
+ }
+
+ // This is called during "map" and during ViewCollection selection, visibility,
+ // and collection content altered events. This can be used to both initialize Gtk.Actions and
+ // update them when selection or visibility has been altered.
+ 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).
+ //
+ // For more information, see: https://bugzilla.gnome.org/show_bug.cgi?id=599937
+ public bool get_event_source_pointer(out int x, out int y, out Gdk.ModifierType mask) {
+ if (event_source == null) {
+ x = 0;
+ y = 0;
+ mask = 0;
+
+ return false;
+ }
+
+ event_source.get_window().get_device_position(Gdk.Display.get_default().get_device_manager()
+ .get_client_pointer(), out x, out y, out mask);
+
+ if (last_down.x < 0 || last_down.y < 0)
+ return true;
+
+ // check for bogus values inside a drag which goes outside the window
+ // caused by (most likely) X windows signed 16-bit int overflow and fixup
+ // (https://bugzilla.gnome.org/show_bug.cgi?id=599937)
+
+ if ((x - last_down.x).abs() >= 0x7FFF)
+ x += 0xFFFF;
+
+ if ((y - last_down.y).abs() >= 0x7FFF)
+ y += 0xFFFF;
+
+ return true;
+ }
+
+ protected virtual bool on_left_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_middle_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_right_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_left_released(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_middle_released(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_right_released(Gdk.EventButton event) {
+ return false;
+ }
+
+ private bool on_button_pressed_internal(Gdk.EventButton event) {
+ switch (event.button) {
+ case 1:
+ if (event_source != null)
+ event_source.grab_focus();
+
+ // stash location of mouse down for drag fixups
+ last_down.x = (int) event.x;
+ last_down.y = (int) event.y;
+
+ return on_left_click(event);
+
+ case 2:
+ return on_middle_click(event);
+
+ case 3:
+ return on_right_click(event);
+
+ default:
+ return false;
+ }
+ }
+
+ private bool on_button_released_internal(Gdk.EventButton event) {
+ switch (event.button) {
+ case 1:
+ // clear when button released, only for drag fixups
+ last_down = { -1, -1 };
+
+ return on_left_released(event);
+
+ case 2:
+ return on_middle_released(event);
+
+ case 3:
+ return on_right_released(event);
+
+ default:
+ return false;
+ }
+ }
+
+ protected virtual bool on_ctrl_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_ctrl_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_alt_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_alt_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_shift_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_shift_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_super_pressed(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_super_released(Gdk.EventKey? event) {
+ return false;
+ }
+
+ protected virtual bool on_app_key_pressed(Gdk.EventKey event) {
+ return false;
+ }
+
+ protected virtual bool on_app_key_released(Gdk.EventKey event) {
+ return false;
+ }
+
+ public bool notify_app_key_pressed(Gdk.EventKey event) {
+ bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
+ super_currently_pressed;
+ get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
+ out shift_currently_pressed, out super_currently_pressed);
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Control_L":
+ case "Control_R":
+ if (!ctrl_currently_pressed || ctrl_pressed)
+ return false;
+
+ ctrl_pressed = true;
+
+ return on_ctrl_pressed(event);
+
+ case "Meta_L":
+ case "Meta_R":
+ case "Alt_L":
+ case "Alt_R":
+ if (!alt_currently_pressed || alt_pressed)
+ return false;
+
+ alt_pressed = true;
+
+ return on_alt_pressed(event);
+
+ case "Shift_L":
+ case "Shift_R":
+ if (!shift_currently_pressed || shift_pressed)
+ return false;
+
+ shift_pressed = true;
+
+ return on_shift_pressed(event);
+
+ case "Super_L":
+ case "Super_R":
+ if (!super_currently_pressed || super_pressed)
+ return false;
+
+ super_pressed = true;
+
+ return on_super_pressed(event);
+ }
+
+ return on_app_key_pressed(event);
+ }
+
+ public bool notify_app_key_released(Gdk.EventKey event) {
+ bool ctrl_currently_pressed, alt_currently_pressed, shift_currently_pressed,
+ super_currently_pressed;
+ get_modifiers(out ctrl_currently_pressed, out alt_currently_pressed,
+ out shift_currently_pressed, out super_currently_pressed);
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Control_L":
+ case "Control_R":
+ if (ctrl_currently_pressed || !ctrl_pressed)
+ return false;
+
+ ctrl_pressed = false;
+
+ return on_ctrl_released(event);
+
+ case "Meta_L":
+ case "Meta_R":
+ case "Alt_L":
+ case "Alt_R":
+ if (alt_currently_pressed || !alt_pressed)
+ return false;
+
+ alt_pressed = false;
+
+ return on_alt_released(event);
+
+ case "Shift_L":
+ case "Shift_R":
+ if (shift_currently_pressed || !shift_pressed)
+ return false;
+
+ shift_pressed = false;
+
+ return on_shift_released(event);
+
+ case "Super_L":
+ case "Super_R":
+ if (super_currently_pressed || !super_pressed)
+ return false;
+
+ super_pressed = false;
+
+ return on_super_released(event);
+ }
+
+ return on_app_key_released(event);
+ }
+
+ public bool notify_app_focus_in(Gdk.EventFocus event) {
+ update_modifiers();
+
+ return false;
+ }
+
+ public bool notify_app_focus_out(Gdk.EventFocus event) {
+ return false;
+ }
+
+ protected virtual void on_move(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_move_start(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_move_finished(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_resize(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_resize_start(Gdk.Rectangle rect) {
+ }
+
+ protected virtual void on_resize_finished(Gdk.Rectangle rect) {
+ }
+
+ protected virtual bool on_configure(Gdk.EventConfigure event, Gdk.Rectangle rect) {
+ return false;
+ }
+
+ public bool notify_configure_event(Gdk.EventConfigure event) {
+ Gdk.Rectangle rect = Gdk.Rectangle();
+ rect.x = event.x;
+ rect.y = event.y;
+ rect.width = event.width;
+ rect.height = event.height;
+
+ // special case events, to report when a configure first starts (and appears to end)
+ if (last_configure_ms == 0) {
+ if (last_position.x != rect.x || last_position.y != rect.y) {
+ on_move_start(rect);
+ report_move_finished = true;
+ }
+
+ if (last_position.width != rect.width || last_position.height != rect.height) {
+ on_resize_start(rect);
+ report_resize_finished = true;
+ }
+
+ // need to check more often then the timeout, otherwise it could be up to twice the
+ // wait time before it's noticed
+ Timeout.add(CONSIDER_CONFIGURE_HALTED_MSEC / 8, check_configure_halted);
+ }
+
+ if (last_position.x != rect.x || last_position.y != rect.y)
+ on_move(rect);
+
+ if (last_position.width != rect.width || last_position.height != rect.height)
+ on_resize(rect);
+
+ last_position = rect;
+ last_configure_ms = now_ms();
+
+ return on_configure(event, rect);
+ }
+
+ private bool check_configure_halted() {
+ if (is_destroyed)
+ return false;
+
+ if ((now_ms() - last_configure_ms) < CONSIDER_CONFIGURE_HALTED_MSEC)
+ return true;
+
+ Gtk.Allocation allocation;
+ get_allocation(out allocation);
+
+ if (report_move_finished)
+ on_move_finished((Gdk.Rectangle) allocation);
+
+ if (report_resize_finished)
+ on_resize_finished((Gdk.Rectangle) allocation);
+
+ last_configure_ms = 0;
+ report_move_finished = false;
+ report_resize_finished = false;
+
+ return false;
+ }
+
+ protected virtual bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
+ check_cursor_hiding();
+
+ return false;
+ }
+
+ protected virtual bool on_leave_notify_event() {
+ return false;
+ }
+
+ private bool on_motion_internal(Gdk.EventMotion event) {
+ int x, y;
+ Gdk.ModifierType mask;
+ if (event.is_hint == 1) {
+ get_event_source_pointer(out x, out y, out mask);
+ } else {
+ x = (int) event.x;
+ y = (int) event.y;
+ mask = event.state;
+ }
+
+ return on_motion(event, x, y, mask);
+ }
+
+ private bool on_mousewheel_internal(Gdk.EventScroll event) {
+ switch (event.direction) {
+ case Gdk.ScrollDirection.UP:
+ return on_mousewheel_up(event);
+
+ case Gdk.ScrollDirection.DOWN:
+ return on_mousewheel_down(event);
+
+ case Gdk.ScrollDirection.LEFT:
+ return on_mousewheel_left(event);
+
+ case Gdk.ScrollDirection.RIGHT:
+ return on_mousewheel_right(event);
+
+ default:
+ return false;
+ }
+ }
+
+ protected virtual bool on_mousewheel_up(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_mousewheel_down(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_mousewheel_left(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_mousewheel_right(Gdk.EventScroll event) {
+ return false;
+ }
+
+ protected virtual bool on_context_keypress() {
+ return false;
+ }
+
+ protected virtual bool on_context_buttonpress(Gdk.EventButton event) {
+ return false;
+ }
+
+ protected virtual bool on_context_invoked() {
+ return true;
+ }
+
+ protected bool popup_context_menu(Gtk.Menu? context_menu,
+ Gdk.EventButton? event = null) {
+
+ if (context_menu == null || !on_context_invoked())
+ return false;
+
+ if (event == null)
+ context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
+ else
+ context_menu.popup(null, null, null, event.button, event.time);
+
+ return true;
+ }
+
+ protected void on_event_source_realize() {
+ assert(event_source.get_window() != null); // the realize event means the Widget has a window
+
+ if (event_source.get_window().get_cursor() != null) {
+ last_cursor = event_source.get_window().get_cursor().get_cursor_type();
+ return;
+ }
+
+ // no custom cursor defined, check parents
+ Gdk.Window? parent_window = event_source.get_window();
+ do {
+ parent_window = parent_window.get_parent();
+ } while (parent_window != null && parent_window.get_cursor() == null);
+
+ if (parent_window != null)
+ last_cursor = parent_window.get_cursor().get_cursor_type();
+ }
+
+ public void set_cursor_hide_time(int hide_time) {
+ cursor_hide_msec = hide_time;
+ }
+
+ public void start_cursor_hiding() {
+ check_cursor_hiding();
+ }
+
+ public void stop_cursor_hiding() {
+ if (last_timeout_id != 0)
+ Source.remove(last_timeout_id);
+ }
+
+ public void suspend_cursor_hiding() {
+ cursor_hide_time_cached = cursor_hide_msec;
+
+ if (last_timeout_id != 0)
+ Source.remove(last_timeout_id);
+
+ cursor_hide_msec = 0;
+ }
+
+ public void restore_cursor_hiding() {
+ cursor_hide_msec = cursor_hide_time_cached;
+ check_cursor_hiding();
+ }
+
+ // Use this method to set the cursor for a page, NOT window.set_cursor(...)
+ protected virtual void set_page_cursor(Gdk.CursorType cursor_type) {
+ last_cursor = cursor_type;
+
+ if (!cursor_hidden && event_source != null)
+ event_source.get_window().set_cursor(new Gdk.Cursor(cursor_type));
+ }
+
+ private void check_cursor_hiding() {
+ if (cursor_hidden) {
+ cursor_hidden = false;
+ set_page_cursor(last_cursor);
+ }
+
+ if (cursor_hide_msec != 0) {
+ if (last_timeout_id != 0)
+ Source.remove(last_timeout_id);
+ last_timeout_id = Timeout.add(cursor_hide_msec, on_hide_cursor);
+ }
+ }
+
+ private bool on_hide_cursor() {
+ cursor_hidden = true;
+
+ if (event_source != null)
+ event_source.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.BLANK_CURSOR));
+
+ return false;
+ }
+}
+
+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 highlighted = 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;
+ }
+
+ public 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);
+
+ Resources.style_widget(viewport, Resources.VIEWPORT_STYLESHEET);
+
+ 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);
+
+ Resources.style_widget(this, Resources.PAGE_STYLESHEET);
+ }
+
+ 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();
+ }
+
+ public virtual Gtk.Menu? get_item_context_menu() {
+ Gtk.Menu menu = (Gtk.Menu) ui.get_widget(item_context_menu_path);
+ assert(menu != null);
+ return menu;
+ }
+
+ public override Gtk.Menu? get_page_context_menu() {
+ if (page_context_menu_path == null)
+ return null;
+ Gtk.Menu menu = (Gtk.Menu) ui.get_widget(page_context_menu_path);
+ assert(menu != null);
+ return 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");
+ }
+
+ 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 (highlighted == item)
+ highlighted = 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;
+
+ 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) {
+ 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;
+ }
+ } 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 hovering over the last hovered item, or both are null (nothing highlighted and
+ // hovering over empty space), do nothing
+ if (item == highlighted)
+ return true;
+
+ // either something new is highlighted or now hovering over empty space, so dim old item
+ if (highlighted != null) {
+ highlighted.unbrighten();
+ highlighted = null;
+ }
+
+ // if over empty space, done
+ if (item == null)
+ return true;
+
+ // brighten the new item
+ item.brighten();
+ highlighted = item;
+
+ 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;
+
+ get_view().unselect_all();
+
+ Marker marker = get_view().mark(item);
+ get_view().select_marked(marker);
+
+ // 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 nothing is selected, simply select the first and exit
+ if (get_view().get_selected_count() == 0 || cursor == null) {
+ CheckerboardItem item = layout.get_item_at_coordinate(0, 0);
+ cursor_to_item(item);
+ anchor = item;
+
+ return;
+ }
+
+ // 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;
+
+ public 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);
+
+ // We used to disable GTK double buffering here. We've had to reenable it
+ // due to this bug: http://redmine.yorba.org/issues/4775 .
+ //
+ // all painting happens in pixmap, and is sent to the window wholesale in on_canvas_expose
+ // canvas.set_double_buffered(false);
+
+ 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);
+
+ // style the viewport
+ Resources.style_widget(viewport, Resources.VIEWPORT_STYLESHEET);
+ }
+
+ 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);
+
+ Gdk.cairo_set_source_pixbuf(pixmap_ctx, zoomed, draw_x, draw_y);
+ pixmap_ctx.paint();
+ }
+
+ protected void on_interactive_zoom(ZoomState interactive_zoom_state) {
+ assert(is_zoom_supported());
+ Cairo.Context canvas_ctx = Gdk.cairo_create(canvas.get_window());
+
+ 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_ctx.set_source_surface(pixmap, 0, 0);
+ canvas_ctx.paint();
+ }
+
+ protected void on_interactive_pan(ZoomState interactive_zoom_state) {
+ assert(is_zoom_supported());
+ Cairo.Context canvas_ctx = Gdk.cairo_create(canvas.get_window());
+
+ 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_ctx.set_source_surface(pixmap, 0, 0);
+ canvas_ctx.paint();
+ }
+
+ 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(get_container(), 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();
+
+ Gdk.cairo_set_source_pixbuf(ctx, scaled, scaled_pos.x, scaled_pos.y);
+ ctx.paint();
+ }
+ }
+
+ 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;
+ }
+}
+
+//
+// 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/Photo.vala b/src/Photo.vala
new file mode 100644
index 0000000..ab449dc
--- /dev/null
+++ b/src/Photo.vala
@@ -0,0 +1,5381 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// Specifies how pixel data is fetched from the backing file on disk. MASTER is the original
+// backing photo of any supported photo file format; SOURCE is either the master or the editable
+// file, that is, the appropriate reference file for user display; BASELINE is an appropriate
+// file with the proviso that it may be a suitable substitute for the master and/or the editable.
+// UNMODIFIED represents the photo with no edits, i.e. the head of the pipeline.
+//
+// In general, callers want to use the BASELINE unless requirements are specific.
+public enum BackingFetchMode {
+ SOURCE,
+ BASELINE,
+ MASTER,
+ UNMODIFIED
+}
+
+public class PhotoImportParams {
+ // IN:
+ public File file;
+ public File final_associated_file = null;
+ public ImportID import_id;
+ public PhotoFileSniffer.Options sniffer_options;
+ public string? exif_md5;
+ public string? thumbnail_md5;
+ public string? full_md5;
+
+ // IN/OUT:
+ public Thumbnails? thumbnails;
+
+ // OUT:
+ public PhotoRow row = new PhotoRow();
+ public Gee.Collection<string>? keywords = null;
+
+ public PhotoImportParams(File file, File? final_associated_file, ImportID import_id,
+ PhotoFileSniffer.Options sniffer_options, string? exif_md5, string? thumbnail_md5, string? full_md5,
+ Thumbnails? thumbnails = null) {
+ this.file = file;
+ this.final_associated_file = final_associated_file;
+ this.import_id = import_id;
+ this.sniffer_options = sniffer_options;
+ this.exif_md5 = exif_md5;
+ this.thumbnail_md5 = thumbnail_md5;
+ this.full_md5 = full_md5;
+ this.thumbnails = thumbnails;
+ }
+
+ // Creates a placeholder import.
+ public PhotoImportParams.create_placeholder(File file, ImportID import_id) {
+ this.file = file;
+ this.import_id = import_id;
+ this.sniffer_options = PhotoFileSniffer.Options.NO_MD5;
+ this.exif_md5 = null;
+ this.thumbnail_md5 = null;
+ this.full_md5 = null;
+ this.thumbnails = null;
+ }
+}
+
+public abstract class PhotoTransformationState : Object {
+ private bool is_broke = false;
+
+ // This signal is fired when the Photo object can no longer accept it and reliably return to
+ // this state.
+ public virtual signal void broken() {
+ is_broke = true;
+ }
+
+ protected PhotoTransformationState() {
+ }
+
+ public bool is_broken() {
+ return is_broke;
+ }
+}
+
+public enum Rating {
+ REJECTED = -1,
+ UNRATED = 0,
+ ONE = 1,
+ TWO = 2,
+ THREE = 3,
+ FOUR = 4,
+ FIVE = 5;
+
+ public bool can_increase() {
+ return this < FIVE;
+ }
+
+ public bool can_decrease() {
+ return this > REJECTED;
+ }
+
+ public bool is_valid() {
+ return this >= REJECTED && this <= FIVE;
+ }
+
+ public Rating increase() {
+ return can_increase() ? this + 1 : this;
+ }
+
+ public Rating decrease() {
+ return can_decrease() ? this - 1 : this;
+ }
+
+ public int serialize() {
+ switch (this) {
+ case REJECTED:
+ return -1;
+ case UNRATED:
+ return 0;
+ case ONE:
+ return 1;
+ case TWO:
+ return 2;
+ case THREE:
+ return 3;
+ case FOUR:
+ return 4;
+ case FIVE:
+ return 5;
+ default:
+ return 0;
+ }
+ }
+
+ public static Rating unserialize(int value) {
+ if (value > FIVE)
+ return FIVE;
+ else if (value < REJECTED)
+ return REJECTED;
+
+ switch (value) {
+ case -1:
+ return REJECTED;
+ case 0:
+ return UNRATED;
+ case 1:
+ return ONE;
+ case 2:
+ return TWO;
+ case 3:
+ return THREE;
+ case 4:
+ return FOUR;
+ case 5:
+ return FIVE;
+ default:
+ return UNRATED;
+ }
+ }
+}
+
+// Photo is an abstract class that allows for applying transformations on-the-fly to a
+// 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 {
+ // 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";
+
+ private const string[] IMAGE_EXTENSIONS = {
+ // raster formats
+ "jpg", "jpeg", "jpe",
+ "tiff", "tif",
+ "png",
+ "gif",
+ "bmp",
+ "ppm", "pgm", "pbm", "pnm",
+
+ // THM are JPEG thumbnails produced by some RAW cameras ... want to support the RAW
+ // image but not import their thumbnails
+ "thm",
+
+ // less common
+ "tga", "ilbm", "pcx", "ecw", "img", "sid", "cd5", "fits", "pgf",
+
+ // vector
+ "cgm", "svg", "odg", "eps", "pdf", "swf", "wmf", "emf", "xps",
+
+ // 3D
+ "pns", "jps", "mpo",
+
+ // RAW extensions
+ "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "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"
+ };
+
+ // There are assertions in the photo pipeline to verify that the generated (or loaded) pixbuf
+ // is scaled properly. We have to allow for some wobble here because of rounding errors and
+ // precision limitations of various subsystems. Pixel-accuracy would be best, but barring that,
+ // need to just make sure the pixbuf is in the ballpark.
+ private const int SCALING_FUDGE = 64;
+
+ // The number of seconds we should hold onto a precached copy of the original image; if
+ // it hasn't been accessed in this many seconds, discard it to conserve memory.
+ private const int PRECACHE_TIME_TO_LIVE = 180;
+
+ // Minimum raw embedded preview size we're willing to accept; any smaller than this, and
+ // it's probably intended primarily for use only as a thumbnail and won't look good on the
+ // PhotoPage.
+ private const int MIN_EMBEDDED_SIZE = 1024;
+
+ // 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;
+
+ public enum Exception {
+ NONE = 0,
+ ORIENTATION = 1 << 0,
+ CROP = 1 << 1,
+ REDEYE = 1 << 2,
+ ADJUST = 1 << 3,
+ STRAIGHTEN = 1 << 4,
+ ALL = 0xFFFFFFFF;
+
+ public bool prohibits(Exception exception) {
+ return ((this & exception) != 0);
+ }
+
+ public bool allows(Exception exception) {
+ return ((this & exception) == 0);
+ }
+ }
+
+ // NOTE: This class should only be instantiated when row is locked.
+ private class PhotoTransformationStateImpl : PhotoTransformationState {
+ private Photo photo;
+ private Orientation orientation;
+ private Gee.HashMap<string, KeyValueMap>? transformations;
+ private PixelTransformer? transformer;
+ private PixelTransformationBundle? adjustments;
+
+ public PhotoTransformationStateImpl(Photo photo, Orientation orientation,
+ Gee.HashMap<string, KeyValueMap>? transformations, PixelTransformer? transformer,
+ PixelTransformationBundle? adjustments) {
+ this.photo = photo;
+ this.orientation = orientation;
+ this.transformations = copy_transformations(transformations);
+ this.transformer = transformer;
+ this.adjustments = adjustments;
+
+ photo.baseline_replaced.connect(on_photo_baseline_replaced);
+ }
+
+ ~PhotoTransformationStateImpl() {
+ photo.baseline_replaced.disconnect(on_photo_baseline_replaced);
+ }
+
+ public Orientation get_orientation() {
+ return orientation;
+ }
+
+ public Gee.HashMap<string, KeyValueMap>? get_transformations() {
+ return copy_transformations(transformations);
+ }
+
+ public PixelTransformer? get_transformer() {
+ return (transformer != null) ? transformer.copy() : null;
+ }
+
+ public PixelTransformationBundle? get_color_adjustments() {
+ return (adjustments != null) ? adjustments.copy() : null;
+ }
+
+ private static Gee.HashMap<string, KeyValueMap>? copy_transformations(
+ Gee.HashMap<string, KeyValueMap>? original) {
+ if (original == null)
+ return null;
+
+ Gee.HashMap<string, KeyValueMap>? clone = new Gee.HashMap<string, KeyValueMap>();
+ foreach (string object in original.keys)
+ clone.set(object, original.get(object).copy());
+
+ return clone;
+ }
+
+ private void on_photo_baseline_replaced() {
+ if (!is_broken())
+ broken();
+ }
+ }
+
+ private class BackingReaders {
+ public PhotoFileReader master;
+ public PhotoFileReader developer;
+ public PhotoFileReader editable;
+ }
+
+ // because fetching individual items from the database is high-overhead, store all of
+ // the photo row in memory
+ protected PhotoRow row;
+ private BackingPhotoRow editable = new BackingPhotoRow();
+ private BackingReaders readers = new BackingReaders();
+ private PixelTransformer transformer = null;
+ private PixelTransformationBundle adjustments = null;
+ // because file_title is determined by data in row, it should only be accessed when row is locked
+ private string file_title = null;
+ private FileMonitor editable_monitor = null;
+ private OneShotScheduler reimport_editable_scheduler = null;
+ private OneShotScheduler update_editable_attributes_scheduler = null;
+ private OneShotScheduler remove_editable_scheduler = null;
+
+ protected bool can_rotate_now = true;
+
+ // The first time we have to run the pipeline on an image, we'll precache
+ // a copy of the unscaled, unmodified version; this allows us to operate
+ // directly on the image data quickly without re-fetching it at the top
+ // of the pipeline, which can cause significant lag with larger images.
+ //
+ // This adds a small amount of (automatically garbage-collected) memory
+ // overhead, but greatly simplifies the pipeline, since scaling can now
+ // be blithely ignored, and most of the pixel operations are fast enough
+ // that the app remains responsive, even with 10MP images.
+ //
+ // In order to make sure we discard unneeded precaches in a timely fashion,
+ // we spawn a timer when the unmodified pixbuf is first precached; if the
+ // timer elapses and the pixbuf hasn't been needed again since then, we'll
+ // discard it and free up the memory.
+ private Gdk.Pixbuf unmodified_precached = null;
+ private GLib.Timer secs_since_access = null;
+
+ // RAW only: developed backing photos.
+ private Gee.HashMap<RawDeveloper, BackingPhotoRow?>? developments = null;
+
+ // Set to true if we want to develop RAW photos into new files.
+ public static bool develop_raw_photos_to_files { get; set; default = false; }
+
+ // This pointer is used to determine which BackingPhotoRow in the PhotoRow to be using at
+ // any time. It should only be accessed -- read or write -- when row is locked.
+ protected BackingPhotoRow? backing_photo_row = null;
+
+ // This is fired when the photo's editable file is replaced. The image it generates may or
+ // may not be the same; the altered signal is best for that. null is passed if the editable
+ // is being added, replaced, or removed (in the appropriate places)
+ public virtual signal void editable_replaced(File? old_file, File? new_file) {
+ }
+
+ // Fired when one or more of the photo's RAW developments has been changed. This will only
+ // be fired on RAW photos, and only when a development has been added or removed.
+ public virtual signal void raw_development_modified() {
+ }
+
+ // This is fired when the photo's baseline file (the file that generates images at the head
+ // of the pipeline) is replaced. Photo will make every sane effort to only fire this signal
+ // if the new baseline is the same image-wise (i.e. the pixbufs it generates are essentially
+ // the same).
+ public virtual signal void baseline_replaced() {
+ }
+
+ // This is fired when the photo's master is reimported in place. It's fired after all changes
+ // to the Photo's state have been incorporated into the object and the "altered" signal has
+ // been fired notifying of the various details that have changed.
+ public virtual signal void master_reimported(PhotoMetadata? metadata) {
+ }
+
+ // Like "master-reimported", but when a photo's editable has been reimported.
+ public virtual signal void editable_reimported(PhotoMetadata? metadata) {
+ }
+
+ // Like "master-reimported" but when the baseline file has been reimported. Note that this
+ // could be the master file OR the editable file.
+ //
+ // See BackingFetchMode for more details.
+ public virtual signal void baseline_reimported(PhotoMetadata? metadata) {
+ }
+
+ // Like "master-reimported" but when the source file has been reimported. Note that this could
+ // be the master file OR the editable file.
+ //
+ // See BackingFetchMode for more details.
+ public virtual signal void source_reimported(PhotoMetadata? metadata) {
+ }
+
+ // The key to this implementation is that multiple instances of Photo with the
+ // same PhotoID cannot exist; it is up to the subclasses to ensure this.
+ protected Photo(PhotoRow row) {
+ this.row = row;
+
+ // normalize user text
+ this.row.title = prep_title(this.row.title);
+ this.row.comment = prep_comment(this.row.comment);
+
+ // don't need to lock the struct in the constructor (and to do so would hurt startup
+ // time)
+ readers.master = row.master.file_format.create_reader(row.master.filepath);
+
+ // get the file title of the Photo without using a File object, skipping the separator itself
+ string? basename = String.sliced_at_last_char(row.master.filepath, Path.DIR_SEPARATOR);
+ if (basename != null)
+ file_title = String.sliced_at(basename, 1);
+
+ if (is_string_empty(file_title))
+ file_title = row.master.filepath;
+
+ if (row.editable_id.id != BackingPhotoID.INVALID) {
+ BackingPhotoRow? e = get_backing_row(row.editable_id);
+ if (e != null) {
+ editable = e;
+ readers.editable = editable.file_format.create_reader(editable.filepath);
+ } else {
+ try {
+ PhotoTable.get_instance().detach_editable(this.row);
+ } catch (DatabaseError err) {
+ // ignored
+ }
+
+ // need to remove all transformations as they're keyed to the editable's
+ // coordinate system
+ internal_remove_all_transformations(false);
+ }
+ }
+
+ if (row.master.file_format == PhotoFileFormat.RAW) {
+ // Fetch development backing photos for RAW.
+ developments = new Gee.HashMap<RawDeveloper, BackingPhotoRow?>();
+ foreach (RawDeveloper d in RawDeveloper.as_array()) {
+ BackingPhotoID id = row.development_ids[d];
+ if (id.id != BackingPhotoID.INVALID) {
+ BackingPhotoRow? bpr = get_backing_row(id);
+ if (bpr != null)
+ developments.set(d, bpr);
+ }
+ }
+ }
+
+ // Set up reader for developer.
+ if (row.master.file_format == PhotoFileFormat.RAW && developments.has_key(row.developer)) {
+ BackingPhotoRow r = developments.get(row.developer);
+ readers.developer = r.file_format.create_reader(r.filepath);
+ }
+
+ // Set the backing photo state appropriately.
+ if (readers.editable != null) {
+ backing_photo_row = this.editable;
+ } else if (row.master.file_format != PhotoFileFormat.RAW) {
+ backing_photo_row = this.row.master;
+ } else {
+ // For RAW photos, the backing photo is either the editable (above) or
+ // the selected raw development.
+ if (developments.has_key(row.developer)) {
+ backing_photo_row = developments.get(row.developer);
+ } else {
+ // Use row's backing photo.
+ backing_photo_row = this.row.master;
+ }
+ }
+
+ cached_exposure_time = this.row.exposure_time;
+ }
+
+ protected virtual void notify_editable_replaced(File? old_file, File? new_file) {
+ editable_replaced(old_file, new_file);
+ }
+
+ protected virtual void notify_raw_development_modified() {
+ raw_development_modified();
+ }
+
+ protected virtual void notify_baseline_replaced() {
+ baseline_replaced();
+ }
+
+ protected virtual void notify_master_reimported(PhotoMetadata? metadata) {
+ master_reimported(metadata);
+ }
+
+ protected virtual void notify_editable_reimported(PhotoMetadata? metadata) {
+ editable_reimported(metadata);
+ }
+
+ protected virtual void notify_source_reimported(PhotoMetadata? metadata) {
+ source_reimported(metadata);
+ }
+
+ protected virtual void notify_baseline_reimported(PhotoMetadata? metadata) {
+ baseline_reimported(metadata);
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ bool ret = true;
+ File file = null;
+ lock (readers) {
+ if (readers.editable != null)
+ file = readers.editable.get_file();
+ }
+
+ detach_editable(true, false);
+
+ if (get_master_file_format() == PhotoFileFormat.RAW) {
+ foreach (RawDeveloper d in RawDeveloper.as_array()) {
+ delete_raw_development(d);
+ }
+ }
+
+ if (file != null) {
+ try {
+ ret = file.trash(null);
+ } catch (Error err) {
+ ret = false;
+ message("Unable to move editable %s for %s to trash: %s", file.get_path(),
+ to_string(), err.message);
+ }
+ }
+
+ // Return false if parent method failed.
+ return base.internal_delete_backing() && ret;
+ }
+
+ // Fetches the backing state. If it can't be read, the ID is flushed from the database
+ // for safety. If the ID is invalid or any error occurs, null is returned.
+ private BackingPhotoRow? get_backing_row(BackingPhotoID id) {
+ if (id.id == BackingPhotoID.INVALID)
+ return null;
+
+ BackingPhotoRow? backing_row = null;
+ try {
+ backing_row = BackingPhotoTable.get_instance().fetch(id);
+ } catch (DatabaseError err) {
+ warning("Unable to fetch backing state for %s: %s", to_string(), err.message);
+ }
+
+ if (backing_row == null) {
+ try {
+ BackingPhotoTable.get_instance().remove(id);
+ } catch (DatabaseError err) {
+ // ignored
+ }
+ return null;
+ }
+
+ return backing_row;
+ }
+
+ // Returns true if the given raw development was already made and the developed image
+ // exists on disk.
+ public bool is_raw_developer_complete(RawDeveloper d) {
+ lock (developments) {
+ return developments.has_key(d) &&
+ FileUtils.test(developments.get(d).filepath, FileTest.EXISTS);
+ }
+ }
+
+ // Determines whether a given RAW developer is available for this photo.
+ public bool is_raw_developer_available(RawDeveloper d) {
+ lock (developments) {
+ if (developments.has_key(d))
+ return true;
+ }
+
+ switch (d) {
+ case RawDeveloper.SHOTWELL:
+ return true;
+
+ case RawDeveloper.CAMERA:
+ return false;
+
+ case RawDeveloper.EMBEDDED:
+ try {
+ PhotoMetadata meta = get_master_metadata();
+ uint num_previews = meta.get_preview_count();
+
+ if (num_previews > 0) {
+ PhotoPreview? prev = meta.get_preview(num_previews - 1);
+
+ // Embedded preview could not be fetched?
+ if (prev == null)
+ return false;
+
+ Dimensions dims = prev.get_pixel_dimensions();
+
+ // Largest embedded preview was an unacceptable size?
+ int preview_major_axis = (dims.width > dims.height) ? dims.width : dims.height;
+ if (preview_major_axis < MIN_EMBEDDED_SIZE)
+ return false;
+
+ // Preview was a supported size, use it.
+ return true;
+ }
+
+ // Image has no embedded preview at all.
+ return false;
+ } catch (Error e) {
+ debug("Error accessing embedded preview. Message: %s", e.message);
+ }
+ return false;
+
+ default:
+ assert_not_reached();
+ }
+ }
+
+ // Reads info on a backing photo and adds it.
+ // Note: this function was created for importing new photos. It will not
+ // notify of changes to the developments.
+ public void add_backing_photo_for_development(RawDeveloper d, BackingPhotoRow bpr) throws Error {
+ import_developed_backing_photo(row, d, bpr);
+ lock (developments) {
+ developments.set(d, bpr);
+ }
+ notify_altered(new Alteration("image", "developer"));
+ }
+
+ public static void import_developed_backing_photo(PhotoRow row, RawDeveloper d,
+ BackingPhotoRow bpr) throws Error {
+ 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();
+
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(
+ file, PhotoFileSniffer.Options.GET_ALL);
+ interrogator.interrogate();
+
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ bpr.dim = detected.image_dim;
+ bpr.filesize = info.get_size();
+ bpr.timestamp = timestamp.tv_sec;
+ bpr.original_orientation = detected.metadata != null ? detected.metadata.get_orientation() :
+ Orientation.TOP_LEFT;
+
+ // Add to DB.
+ BackingPhotoTable.get_instance().add(bpr);
+ PhotoTable.get_instance().update_raw_development(row, d, bpr.id);
+ }
+
+ // "Develops" a raw photo
+ // Not thread-safe.
+ private void develop_photo(RawDeveloper d) {
+ bool wrote_img_to_disk = false;
+ BackingPhotoRow bps = null;
+
+ switch (d) {
+ case RawDeveloper.SHOTWELL:
+ try {
+ // Create file and prep.
+ bps = d.create_backing_row_for_development(row.master.filepath);
+ Gdk.Pixbuf? pix = null;
+ lock (readers) {
+ // Don't rotate this pixbuf before writing it out. We don't
+ // need to because we'll display it using the orientation
+ // from the parent raw file, so rotating it here would cause
+ // portrait images to rotate _twice_...
+ pix = get_master_pixbuf(Scaling.for_original(), false);
+ }
+
+ if (pix == null) {
+ debug("Could not get preview pixbuf");
+ return;
+ }
+
+ // Write out the JPEG.
+ PhotoFileWriter writer = PhotoFileFormat.JFIF.create_writer(bps.filepath);
+ writer.write(pix, Jpeg.Quality.HIGH);
+
+ // Remember that we wrote it (we'll only get here if writing
+ // the jpeg doesn't throw an exception). We do this because
+ // some cameras' output has non-spec-compliant exif segments
+ // larger than 64k (exiv2 can't cope with this), so saving
+ // metadata to the development could fail, but we want to use
+ // it anyway since the image portion is still valid...
+ wrote_img_to_disk = true;
+
+ // Write out metadata. An exception could get thrown here as
+ // well, hence the separate check for being able to save the
+ // image above...
+ PhotoMetadata meta = get_master_metadata();
+ PhotoFileMetadataWriter mwriter = PhotoFileFormat.JFIF.create_metadata_writer(bps.filepath);
+ mwriter.write_metadata(meta);
+ } catch (Error err) {
+ debug("Error developing photo: %s", err.message);
+ } finally {
+ if (wrote_img_to_disk) {
+ try {
+ // Read in backing photo info, add to DB.
+ add_backing_photo_for_development(d, bps);
+
+ notify_raw_development_modified();
+ } catch (Error e) {
+ debug("Error adding backing photo as development. Message: %s",
+ e.message);
+ }
+ }
+ }
+
+ break;
+
+ case RawDeveloper.CAMERA:
+ // No development needed.
+ break;
+
+ case RawDeveloper.EMBEDDED:
+ try {
+ // Read in embedded JPEG.
+ PhotoMetadata meta = get_master_metadata();
+ uint c = meta.get_preview_count();
+ if (c <= 0)
+ return;
+ PhotoPreview? prev = meta.get_preview(c - 1);
+ if (prev == null) {
+ debug("Could not get preview from metadata");
+ return;
+ }
+
+ Gdk.Pixbuf? pix = prev.get_pixbuf();
+ if (pix == null) {
+ debug("Could not get preview pixbuf");
+ return;
+ }
+
+ // Write out file.
+ bps = d.create_backing_row_for_development(row.master.filepath);
+ PhotoFileWriter writer = PhotoFileFormat.JFIF.create_writer(bps.filepath);
+ writer.write(pix, Jpeg.Quality.HIGH);
+
+ // Remember that we wrote it (see above
+ // case for why this is necessary).
+ wrote_img_to_disk = true;
+
+ // Write out metadata
+ PhotoFileMetadataWriter mwriter = PhotoFileFormat.JFIF.create_metadata_writer(bps.filepath);
+ mwriter.write_metadata(meta);
+ } catch (Error e) {
+ debug("Error accessing embedded preview. Message: %s", e.message);
+ return;
+ } finally {
+ if (wrote_img_to_disk) {
+ try {
+ // Read in backing photo info, add to DB.
+ add_backing_photo_for_development(d, bps);
+
+ notify_raw_development_modified();
+ } catch (Error e) {
+ debug("Error adding backing photo as development. Message: %s",
+ e.message);
+ }
+ }
+ }
+ break;
+
+ default:
+ assert_not_reached();
+ }
+ }
+
+ // Sets the developer internally, but does not actually develop the backing file.
+ public void set_default_raw_developer(RawDeveloper d) {
+ lock (row) {
+ row.developer = d;
+ }
+ }
+
+ // Sets the developer and develops the photo.
+ public void set_raw_developer(RawDeveloper d) {
+ if (get_master_file_format() != PhotoFileFormat.RAW)
+ return;
+
+ // If the caller has asked for 'embedded', but there's a camera development
+ // available, always prefer that instead, as it's likely to be of higher
+ // quality and resolution.
+ if (is_raw_developer_available(RawDeveloper.CAMERA) && (d == RawDeveloper.EMBEDDED))
+ d = RawDeveloper.CAMERA;
+
+ // If the embedded preview is too small to be used in the PhotoPage, don't
+ // allow EMBEDDED to be chosen.
+ if (!is_raw_developer_available(RawDeveloper.EMBEDDED))
+ d = RawDeveloper.SHOTWELL;
+
+ lock (developments) {
+ RawDeveloper stale_raw_developer = row.developer;
+
+ // Perform development, bail out if it doesn't work.
+ if (!is_raw_developer_complete(d)) {
+ develop_photo(d);
+ try {
+ populate_prefetched();
+ } catch (Error e) {
+ // couldn't reload the freshly-developed image, nothing to display
+ return;
+ }
+ }
+ if (!developments.has_key(d))
+ return; // we tried!
+
+ // Disgard changes.
+ revert_to_master(false);
+
+ // Switch master to the new photo.
+ row.developer = d;
+ backing_photo_row = developments.get(d);
+ readers.developer = backing_photo_row.file_format.create_reader(backing_photo_row.filepath);
+
+ set_orientation(backing_photo_row.original_orientation);
+
+ try {
+ PhotoTable.get_instance().update_raw_development(row, d, backing_photo_row.id);
+ } catch (Error e) {
+ warning("Error updating database: %s", e.message);
+ }
+
+ // Is the 'stale' development _NOT_ a camera-supplied one?
+ //
+ // NOTE: When a raw is first developed, both 'stale' and 'incoming' developers
+ // will be the same, so the second test is required for correct operation.
+ if ((stale_raw_developer != RawDeveloper.CAMERA) &&
+ (stale_raw_developer != row.developer)) {
+ // The 'stale' non-Shotwell development we're using was
+ // created by us, not the camera, so discard it...
+ delete_raw_development(stale_raw_developer);
+ }
+
+ // Otherwise, don't delete the paired JPEG, since it is user/camera-created
+ // and is to be preserved.
+ }
+
+ notify_altered(new Alteration("image", "developer"));
+ discard_prefetched(true);
+ }
+
+ public RawDeveloper get_raw_developer() {
+ return row.developer;
+ }
+
+ // Removes a development from the database, filesystem, etc.
+ // Returns true if a development was removed, otherwise false.
+ private bool delete_raw_development(RawDeveloper d) {
+ bool ret = false;
+
+ lock (developments) {
+ if (!developments.has_key(d))
+ return false;
+
+ // Remove file. If this is a camera-generated JPEG, we trash it;
+ // otherwise, it was generated by us and should be deleted outright.
+ debug("Delete raw development: %s %s", this.to_string(), d.to_string());
+ BackingPhotoRow bpr = developments.get(d);
+ if (bpr.filepath != null) {
+ File f = File.new_for_path(bpr.filepath);
+ try {
+ if (d == RawDeveloper.CAMERA)
+ f.trash();
+ else
+ f.delete();
+ } catch (Error e) {
+ warning("Unable to delete RAW development: %s error: %s", bpr.filepath, e.message);
+ }
+ }
+
+ // Delete references in DB.
+ try {
+ PhotoTable.get_instance().remove_development(row, d);
+ BackingPhotoTable.get_instance().remove(bpr.id);
+ } catch (Error e) {
+ warning("Database error while deleting RAW development: %s", e.message);
+ }
+
+ ret = developments.unset(d);
+ }
+
+ notify_raw_development_modified();
+ return ret;
+ }
+
+ // Re-do development for photo.
+ public void redevelop_raw(RawDeveloper d) {
+ lock (developments) {
+ delete_raw_development(d);
+ RawDeveloper dev = d;
+ if (dev == RawDeveloper.CAMERA)
+ dev = RawDeveloper.EMBEDDED;
+
+ set_raw_developer(dev);
+ }
+ }
+
+ public override BackingFileState[] get_backing_files_state() {
+ BackingFileState[] backing = new BackingFileState[0];
+ lock (row) {
+ backing += new BackingFileState.from_photo_row(row.master, row.md5);
+ if (has_editable())
+ backing += new BackingFileState.from_photo_row(editable, null);
+
+ if (is_developed()) {
+ Gee.Collection<BackingPhotoRow>? dev_rows = get_raw_development_photo_rows();
+ if (dev_rows != null) {
+ foreach (BackingPhotoRow r in dev_rows) {
+ debug("adding: %s", r.filepath);
+ backing += new BackingFileState.from_photo_row(r, null);
+ }
+ }
+ }
+ }
+
+ return backing;
+ }
+
+ private PhotoFileReader get_backing_reader(BackingFetchMode mode) {
+ switch (mode) {
+ case BackingFetchMode.MASTER:
+ return get_master_reader();
+
+ case BackingFetchMode.BASELINE:
+ return get_baseline_reader();
+
+ case BackingFetchMode.SOURCE:
+ return get_source_reader();
+
+ case BackingFetchMode.UNMODIFIED:
+ if (this.get_master_file_format() == PhotoFileFormat.RAW)
+ return get_raw_developer_reader();
+ else
+ return get_master_reader();
+
+ default:
+ error("Unknown backing fetch mode %s", mode.to_string());
+ }
+ }
+
+ private PhotoFileReader get_master_reader() {
+ lock (readers) {
+ return readers.master;
+ }
+ }
+
+ protected PhotoFileReader? get_editable_reader() {
+ lock (readers) {
+ return readers.editable;
+ }
+ }
+
+ // Returns a reader for the head of the pipeline.
+ private PhotoFileReader get_baseline_reader() {
+ lock (readers) {
+ if (readers.editable != null)
+ return readers.editable;
+
+ if (readers.developer != null)
+ return readers.developer;
+
+ return readers.master;
+ }
+ }
+
+ // Returns a reader for the photo file that is the source of the image.
+ private PhotoFileReader get_source_reader() {
+ lock (readers) {
+ if (readers.editable != null)
+ return readers.editable;
+
+ if (readers.developer != null)
+ return readers.developer;
+
+ return readers.master;
+ }
+ }
+
+ // Returns the reader used for reading the RAW development.
+ private PhotoFileReader get_raw_developer_reader() {
+ lock (readers) {
+ return readers.developer;
+ }
+ }
+
+ public bool is_developed() {
+ lock (readers) {
+ return readers.developer != null;
+ }
+ }
+
+ public bool has_editable() {
+ lock (readers) {
+ return readers.editable != null;
+ }
+ }
+
+ public bool does_master_exist() {
+ lock (readers) {
+ return readers.master.file_exists();
+ }
+ }
+
+ // Returns false if the backing editable does not exist OR the photo does not have an editable
+ public bool does_editable_exist() {
+ lock (readers) {
+ return readers.editable != null ? readers.editable.file_exists() : false;
+ }
+ }
+
+ public bool is_master_baseline() {
+ lock (readers) {
+ return readers.editable == null;
+ }
+ }
+
+ public bool is_master_source() {
+ return !has_editable();
+ }
+
+ public bool is_editable_baseline() {
+ lock (readers) {
+ return readers.editable != null;
+ }
+ }
+
+ public bool is_editable_source() {
+ return has_editable();
+ }
+
+ public BackingPhotoRow get_master_photo_row() {
+ lock (row) {
+ return row.master;
+ }
+ }
+
+ public BackingPhotoRow? get_editable_photo_row() {
+ lock (row) {
+ // ternary doesn't work here
+ if (row.editable_id.is_valid())
+ return editable;
+ else
+ return null;
+ }
+ }
+
+ public Gee.Collection<BackingPhotoRow>? get_raw_development_photo_rows() {
+ lock (row) {
+ return developments != null ? developments.values : null;
+ }
+ }
+
+ public BackingPhotoRow? get_raw_development_photo_row(RawDeveloper d) {
+ lock (row) {
+ return developments != null ? developments.get(d) : null;
+ }
+ }
+
+ public PhotoFileFormat? get_editable_file_format() {
+ PhotoFileReader? reader = get_editable_reader();
+ if (reader == null)
+ return null;
+
+ // ternary operator doesn't work here
+ return reader.get_file_format();
+ }
+
+ public PhotoFileFormat get_export_format_for_parameters(ExportFormatParameters params) {
+ PhotoFileFormat result = PhotoFileFormat.get_system_default_format();
+
+ switch (params.mode) {
+ case ExportFormatMode.UNMODIFIED:
+ result = get_master_file_format();
+ break;
+
+ case ExportFormatMode.CURRENT:
+ result = get_best_export_file_format();
+ break;
+
+ case ExportFormatMode.SPECIFIED:
+ result = params.specified_format;
+ break;
+
+ default:
+ error("get_export_format_for_parameters: unsupported export format mode");
+ }
+
+ return result;
+ }
+
+ public string get_export_basename_for_parameters(ExportFormatParameters params) {
+ string? result = null;
+
+ switch (params.mode) {
+ case ExportFormatMode.UNMODIFIED:
+ result = get_master_file().get_basename();
+ break;
+
+ case ExportFormatMode.CURRENT:
+ case ExportFormatMode.SPECIFIED:
+ return get_export_basename(get_export_format_for_parameters(params));
+
+ default:
+ error("get_export_basename_for_parameters: unsupported export format mode");
+ }
+
+ assert (result != null);
+ return result;
+ }
+
+ // This method interrogates the specified file and returns a PhotoRow with all relevant
+ // information about it. It uses the PhotoFileInterrogator to do so. The caller should create
+ // a PhotoFileInterrogator with the proper options prior to calling. prepare_for_import()
+ // will determine what's been discovered and fill out in the PhotoRow or return the relevant
+ // objects and information. If Thumbnails is not null, thumbnails suitable for caching or
+ // framing will be returned as well. Note that this method will call interrogate() and
+ // perform all error-handling; the caller simply needs to construct the object.
+ //
+ // This is the acid-test; if unable to generate a pixbuf or thumbnails, that indicates the
+ // photo itself is bogus and should be discarded.
+ //
+ // NOTE: This method is thread-safe.
+ public static ImportResult prepare_for_import(PhotoImportParams 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_file_image(file)) {
+ message("Not importing %s: Not an image file", file.get_path());
+
+ return ImportResult.NOT_AN_IMAGE;
+ }
+
+ if (!PhotoFileFormat.is_file_supported(file)) {
+ message("Not importing %s: Unsupported extension", file.get_path());
+
+ return ImportResult.UNSUPPORTED_FORMAT;
+ }
+
+ TimeVal timestamp = info.get_modification_time();
+
+ // if all MD5s supplied, don't sniff for them
+ if (params.exif_md5 != null && params.thumbnail_md5 != null && params.full_md5 != null)
+ params.sniffer_options |= PhotoFileSniffer.Options.NO_MD5;
+
+ // interrogate file for photo information
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file, params.sniffer_options);
+ try {
+ interrogator.interrogate();
+ } catch (Error err) {
+ warning("Unable to interrogate photo file %s: %s", file.get_path(), err.message);
+
+ return ImportResult.DECODE_ERROR;
+ }
+
+ // if not detected photo information, unsupported
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected == null)
+ return ImportResult.UNSUPPORTED_FORMAT;
+
+ // copy over supplied MD5s if provided
+ if ((params.sniffer_options & PhotoFileSniffer.Options.NO_MD5) != 0) {
+ detected.exif_md5 = params.exif_md5;
+ detected.thumbnail_md5 = params.thumbnail_md5;
+ detected.md5 = params.full_md5;
+ }
+
+ Orientation orientation = Orientation.TOP_LEFT;
+ time_t exposure_time = 0;
+ string title = "";
+ string comment = "";
+ Rating rating = Rating.UNRATED;
+
+#if TRACE_MD5
+ debug("importing MD5 %s: exif=%s preview=%s full=%s", file.get_path(), detected.exif_md5,
+ detected.thumbnail_md5, detected.md5);
+#endif
+
+ if (detected.metadata != null) {
+ MetadataDateTime? date_time = detected.metadata.get_exposure_date_time();
+ if (date_time != null)
+ exposure_time = date_time.get_timestamp();
+
+ orientation = detected.metadata.get_orientation();
+ title = detected.metadata.get_title();
+ comment = detected.metadata.get_comment();
+ params.keywords = detected.metadata.get_keywords();
+ rating = detected.metadata.get_rating();
+ }
+
+ // verify basic mechanics of photo: RGB 8-bit encoding
+ if (detected.colorspace != Gdk.Colorspace.RGB
+ || detected.channels < 3
+ || detected.bits_per_channel != 8) {
+ message("Not importing %s: Unsupported color format", file.get_path());
+
+ return ImportResult.UNSUPPORTED_FORMAT;
+ }
+
+ // photo information is initially stored in database in raw, non-modified format ... this is
+ // especially important dealing with dimensions and orientation ... Don't trust EXIF
+ // dimensions, they can lie or not be present
+ params.row.photo_id = PhotoID();
+ 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.exposure_time = exposure_time;
+ params.row.orientation = orientation;
+ params.row.master.original_orientation = orientation;
+ params.row.import_id = params.import_id;
+ params.row.event_id = EventID();
+ params.row.transformations = null;
+ params.row.md5 = detected.md5;
+ params.row.thumbnail_md5 = detected.thumbnail_md5;
+ params.row.exif_md5 = detected.exif_md5;
+ params.row.time_created = 0;
+ params.row.flags = 0;
+ params.row.master.file_format = detected.file_format;
+ params.row.title = title;
+ params.row.comment = comment;
+ params.row.rating = rating;
+
+ if (params.thumbnails != null) {
+ PhotoFileReader reader = params.row.master.file_format.create_reader(
+ params.row.master.filepath);
+ try {
+ ThumbnailCache.generate_for_photo(params.thumbnails, reader, params.row.orientation,
+ params.row.master.dim);
+ } catch (Error err) {
+ return ImportResult.convert_error(err, ImportResult.FILE_ERROR);
+ }
+ }
+
+#if MEASURE_IMPORT
+ debug("IMPORT: total=%lf", total_time.elapsed());
+#endif
+ return ImportResult.SUCCESS;
+ }
+
+ public static void create_pre_import(PhotoImportParams params) {
+ File file = params.file;
+ params.row.photo_id = PhotoID();
+ 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.orientation = Orientation.TOP_LEFT;
+ params.row.master.original_orientation = Orientation.TOP_LEFT;
+ params.row.import_id = params.import_id;
+ params.row.event_id = EventID();
+ params.row.transformations = null;
+ params.row.md5 = null;
+ params.row.thumbnail_md5 = null;
+ params.row.exif_md5 = null;
+ params.row.time_created = 0;
+ params.row.flags = 0;
+ params.row.master.file_format = PhotoFileFormat.JFIF;
+ params.row.title = null;
+ params.row.comment = null;
+ params.row.rating = Rating.UNRATED;
+
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(params.file, params.sniffer_options);
+ try {
+ interrogator.interrogate();
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected != null)
+ params.row.master.file_format = detected.file_format;
+ } catch (Error err) {
+ debug("Unable to interrogate photo file %s: %s", file.get_path(), err.message);
+ }
+ }
+
+ protected BackingPhotoRow? query_backing_photo_row(File file, PhotoFileSniffer.Options options,
+ out DetectedPhotoInformation detected) throws Error {
+ detected = null;
+
+ BackingPhotoRow backing = new BackingPhotoRow();
+ // get basic file information
+ FileInfo info = null;
+ try {
+ info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ } catch (Error err) {
+ critical("Unable to read file information for %s: %s", file.get_path(), err.message);
+
+ return null;
+ }
+
+ // sniff photo information
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file, options);
+ interrogator.interrogate();
+ detected = interrogator.get_detected_photo_information();
+ if (detected == null) {
+ critical("Photo update: %s no longer a recognized image", to_string());
+
+ return null;
+ }
+
+ TimeVal modification_time = info.get_modification_time();
+
+ backing.filepath = file.get_path();
+ backing.timestamp = modification_time.tv_sec;
+ backing.filesize = info.get_size();
+ backing.file_format = detected.file_format;
+ backing.dim = detected.image_dim;
+ backing.original_orientation = detected.metadata != null
+ ? detected.metadata.get_orientation() : Orientation.TOP_LEFT;
+
+ return backing;
+ }
+
+ public abstract class ReimportMasterState {
+ }
+
+ private class ReimportMasterStateImpl : ReimportMasterState {
+ public PhotoRow row = new PhotoRow();
+ public PhotoMetadata? metadata;
+ public string[] alterations;
+ public bool metadata_only = false;
+
+ public ReimportMasterStateImpl(PhotoRow row, PhotoMetadata? metadata, string[] alterations) {
+ this.row = row;
+ this.metadata = metadata;
+ this.alterations = alterations;
+ }
+ }
+
+ public abstract class ReimportEditableState {
+ }
+
+ private class ReimportEditableStateImpl : ReimportEditableState {
+ public BackingPhotoRow backing_state = new BackingPhotoRow();
+ public PhotoMetadata? metadata;
+ public bool metadata_only = false;
+
+ public ReimportEditableStateImpl(BackingPhotoRow backing_state, PhotoMetadata? metadata) {
+ this.backing_state = backing_state;
+ this.metadata = metadata;
+ }
+ }
+
+ public abstract class ReimportRawDevelopmentState {
+ }
+
+ private class ReimportRawDevelopmentStateImpl : ReimportRawDevelopmentState {
+ public class DevToReimport {
+ public BackingPhotoRow backing = new BackingPhotoRow();
+ public PhotoMetadata? metadata;
+
+ public DevToReimport(BackingPhotoRow backing, PhotoMetadata? metadata) {
+ this.backing = backing;
+ this.metadata = metadata;
+ }
+ }
+
+ public Gee.Collection<DevToReimport> list = new Gee.ArrayList<DevToReimport>();
+ public bool metadata_only = false;
+
+ public ReimportRawDevelopmentStateImpl() {
+ }
+
+ public void add(BackingPhotoRow backing, PhotoMetadata? metadata) {
+ list.add(new DevToReimport(backing, metadata));
+ }
+
+ public int get_size() {
+ return list.size;
+ }
+ }
+
+ // This method is thread-safe. If returns false the photo should be marked offline (in the
+ // main UI thread).
+ public bool prepare_for_reimport_master(out ReimportMasterState reimport_state) throws Error {
+ reimport_state = null;
+
+ File file = get_master_reader().get_file();
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow? backing = query_backing_photo_row(file, PhotoFileSniffer.Options.GET_ALL,
+ out detected);
+ if (backing == null) {
+ warning("Unable to retrieve photo state from %s for reimport", file.get_path());
+ return false;
+ }
+
+ // verify basic mechanics of photo: RGB 8-bit encoding
+ if (detected.colorspace != Gdk.Colorspace.RGB
+ || detected.channels < 3
+ || detected.bits_per_channel != 8) {
+ warning("Not re-importing %s: Unsupported color format", file.get_path());
+
+ return false;
+ }
+
+ // start with existing row and update appropriate fields
+ PhotoRow updated_row = new PhotoRow();
+ lock (row) {
+ updated_row = row;
+ }
+
+ // build an Alteration list for the relevant changes
+ string[] list = new string[0];
+
+ if (updated_row.md5 != detected.md5)
+ list += "metadata:md5";
+
+ if (updated_row.master.original_orientation != backing.original_orientation) {
+ list += "image:orientation";
+ updated_row.master.original_orientation = backing.original_orientation;
+ }
+
+ 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())
+ list += "metadata:exposure-time";
+
+ if (updated_row.title != detected.metadata.get_title())
+ list += "metadata:name";
+
+ if (updated_row.comment != detected.metadata.get_comment())
+ list += "metadata:comment";
+
+ if (updated_row.rating != detected.metadata.get_rating())
+ list += "metadata:rating";
+ }
+
+ updated_row.master = backing;
+ updated_row.md5 = detected.md5;
+ updated_row.exif_md5 = detected.exif_md5;
+ updated_row.thumbnail_md5 = detected.thumbnail_md5;
+
+ PhotoMetadata? metadata = null;
+ if (detected.metadata != null) {
+ metadata = detected.metadata;
+
+ MetadataDateTime? date_time = detected.metadata.get_exposure_date_time();
+ if (date_time != null)
+ updated_row.exposure_time = date_time.get_timestamp();
+
+ updated_row.title = detected.metadata.get_title();
+ updated_row.comment = detected.metadata.get_comment();
+ updated_row.rating = detected.metadata.get_rating();
+ }
+
+ reimport_state = new ReimportMasterStateImpl(updated_row, metadata, list);
+
+ return true;
+ }
+
+ protected abstract void apply_user_metadata_for_reimport(PhotoMetadata metadata);
+
+ // This method is not thread-safe and should be called in the main thread.
+ public void finish_reimport_master(ReimportMasterState state) throws DatabaseError {
+ ReimportMasterStateImpl reimport_state = (ReimportMasterStateImpl) state;
+
+ PhotoTable.get_instance().reimport(reimport_state.row);
+
+ lock (row) {
+ // Copy row while preserving reference to master.
+ BackingPhotoRow original_master = row.master;
+ row = reimport_state.row;
+ row.master = original_master;
+ row.master.copy_from(reimport_state.row.master);
+ if (!reimport_state.metadata_only)
+ internal_remove_all_transformations(false);
+ }
+
+ if (reimport_state.metadata != null)
+ apply_user_metadata_for_reimport(reimport_state.metadata);
+
+ if (!reimport_state.metadata_only) {
+ reimport_state.alterations += "image:master";
+ if (is_master_baseline())
+ reimport_state.alterations += "image:baseline";
+ }
+
+ if (reimport_state.alterations.length > 0)
+ notify_altered(new Alteration.from_array(reimport_state.alterations));
+
+ notify_master_reimported(reimport_state.metadata);
+
+ if (is_master_baseline())
+ notify_baseline_reimported(reimport_state.metadata);
+
+ if (is_master_source())
+ notify_source_reimported(reimport_state.metadata);
+ }
+
+ // Verifies a file for reimport. Returns the file's detected photo info.
+ private bool verify_file_for_reimport(File file, out BackingPhotoRow backing,
+ out DetectedPhotoInformation detected) throws Error {
+ backing = query_backing_photo_row(file, PhotoFileSniffer.Options.NO_MD5,
+ out detected);
+ if (backing == null) {
+ return false;
+ }
+
+ // verify basic mechanics of photo: RGB 8-bit encoding
+ if (detected.colorspace != Gdk.Colorspace.RGB
+ || detected.channels < 3
+ || detected.bits_per_channel != 8) {
+ warning("Not re-importing %s: Unsupported color format", file.get_path());
+
+ return false;
+ }
+
+ return true;
+ }
+
+ // This method is thread-safe. Returns false if the photo has no associated editable.
+ public bool prepare_for_reimport_editable(out ReimportEditableState state) throws Error {
+ state = null;
+
+ File? file = get_editable_file();
+ if (file == null)
+ return false;
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow backing;
+ if (!verify_file_for_reimport(file, out backing, out detected))
+ return false;
+
+ state = new ReimportEditableStateImpl(backing, detected.metadata);
+
+ return true;
+ }
+
+ // This method is not thread-safe. It should be called by the main thread.
+ public void finish_reimport_editable(ReimportEditableState state) throws DatabaseError {
+ BackingPhotoID editable_id = get_editable_id();
+ if (editable_id.is_invalid())
+ return;
+
+ ReimportEditableStateImpl reimport_state = (ReimportEditableStateImpl) state;
+
+ if (!reimport_state.metadata_only) {
+ BackingPhotoTable.get_instance().update(reimport_state.backing_state);
+
+ lock (row) {
+ editable = reimport_state.backing_state;
+ set_orientation(reimport_state.backing_state.original_orientation);
+ internal_remove_all_transformations(false);
+ }
+ } else {
+ set_orientation(reimport_state.backing_state.original_orientation);
+ }
+
+ if (reimport_state.metadata != null) {
+ set_title(reimport_state.metadata.get_title());
+ set_comment(reimport_state.metadata.get_comment());
+ set_rating(reimport_state.metadata.get_rating());
+ apply_user_metadata_for_reimport(reimport_state.metadata);
+ }
+
+ string list = "metadata:name,image:orientation,metadata:rating,metadata:exposure-time";
+ if (!reimport_state.metadata_only)
+ list += "image:editable,image:baseline";
+
+ notify_altered(new Alteration.from_list(list));
+
+ notify_editable_reimported(reimport_state.metadata);
+
+ if (is_editable_baseline())
+ notify_baseline_reimported(reimport_state.metadata);
+
+ if (is_editable_source())
+ notify_source_reimported(reimport_state.metadata);
+ }
+
+ // This method is thread-safe. Returns false if the photo has no associated RAW developments.
+ public bool prepare_for_reimport_raw_development(out ReimportRawDevelopmentState state) throws Error {
+ state = null;
+
+ Gee.Collection<File>? files = get_raw_developer_files();
+ if (files == null)
+ return false;
+
+ ReimportRawDevelopmentStateImpl reimport_state = new ReimportRawDevelopmentStateImpl();
+
+ foreach (File file in files) {
+ DetectedPhotoInformation detected;
+ BackingPhotoRow backing;
+ if (!verify_file_for_reimport(file, out backing, out detected))
+ continue;
+
+ reimport_state.add(backing, detected.metadata);
+ }
+
+ state = reimport_state;
+ return reimport_state.get_size() > 0;
+ }
+
+ // This method is not thread-safe. It should be called by the main thread.
+ public void finish_reimport_raw_development(ReimportRawDevelopmentState state) throws DatabaseError {
+ if (this.get_master_file_format() != PhotoFileFormat.RAW)
+ return;
+
+ ReimportRawDevelopmentStateImpl reimport_state = (ReimportRawDevelopmentStateImpl) state;
+
+ foreach (ReimportRawDevelopmentStateImpl.DevToReimport dev in reimport_state.list) {
+ if (!reimport_state.metadata_only) {
+ BackingPhotoTable.get_instance().update(dev.backing);
+
+ lock (row) {
+ // Refresh raw developments.
+ foreach (RawDeveloper d in RawDeveloper.as_array()) {
+ BackingPhotoID id = row.development_ids[d];
+ if (id.id != BackingPhotoID.INVALID) {
+ BackingPhotoRow? bpr = get_backing_row(id);
+ if (bpr != null)
+ developments.set(d, bpr);
+ }
+ }
+ }
+ }
+ }
+
+ string list = "metadata:name,image:orientation,metadata:rating,metadata:exposure-time";
+ if (!reimport_state.metadata_only)
+ list += "image:editable,image:baseline";
+
+ notify_altered(new Alteration.from_list(list));
+
+ notify_raw_development_modified();
+ }
+
+ public override string get_typename() {
+ return TYPENAME;
+ }
+
+ public override int64 get_instance_id() {
+ return get_photo_id().id;
+ }
+
+ public override string get_source_id() {
+ // Because of historical reasons, need to format Photo's source ID without a dash for
+ // ThumbnailCache. Note that any future routine designed to tear a source ID apart and
+ // locate by typename will need to account for this exception.
+ return ("%s%016" + int64.FORMAT_MODIFIER + "x").printf(get_typename(), get_instance_id());
+ }
+
+ // 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();
+
+ try {
+ lock (row) {
+ if (row.master.timestamp == modification.tv_sec)
+ return;
+
+ PhotoTable.get_instance().update_timestamp(row.photo_id, modification.tv_sec);
+ row.master.timestamp = modification.tv_sec;
+ }
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+
+ return;
+ }
+
+ if (is_master_baseline())
+ notify_altered(new Alteration.from_list("metadata:master-timestamp,metadata:baseline-timestamp"));
+ else
+ notify_altered(new Alteration("metadata", "master-timestamp"));
+ }
+
+ // 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();
+
+ bool altered = false;
+ lock (row) {
+ if (row.editable_id.is_valid() && editable.timestamp != modification.tv_sec) {
+ BackingPhotoTable.get_instance().update_timestamp(row.editable_id,
+ modification.tv_sec);
+ editable.timestamp = modification.tv_sec;
+ altered = true;
+ }
+ }
+
+ if (altered)
+ notify_altered(new Alteration.from_list("metadata:editable-timestamp,metadata:baseline-timestamp"));
+ }
+
+ // Most useful if the appropriate SourceCollection is frozen while calling this.
+ 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));
+ DatabaseTable.commit_transaction();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return (get_file_format().can_write_image()) ? get_file_format() :
+ PhotoFileFormat.get_system_default_format();
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ return get_pixbuf(Scaling.for_best_fit(scale, true));
+ }
+
+ public static bool is_file_image(File file) {
+ // if it's a supported image file, by definition it's an image file, otherwise check the
+ // master list of common image extensions (checking this way allows for extensions to be
+ // added to various PhotoFileFormats without having to also add them to IMAGE_EXTENSIONS)
+ return PhotoFileFormat.is_file_supported(file)
+ ? true : is_extension_found(file.get_basename(), IMAGE_EXTENSIONS);
+ }
+
+ private static bool is_extension_found(string basename, string[] extensions) {
+ string name, ext;
+ disassemble_filename(basename, out name, out ext);
+ if (ext == null)
+ return false;
+
+ // treat extensions as case-insensitive
+ ext = ext.down();
+
+ // search supported list
+ foreach (string extension in extensions) {
+ if (ext == extension)
+ return true;
+ }
+
+ return false;
+ }
+
+ // This is not thread-safe. Obviously, at least one field must be non-null for this to be
+ // effective, although there is no guarantee that any one will be sufficient. file_format
+ // should be UNKNOWN if not to require matching file formats.
+ public static bool is_duplicate(File? file, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format) {
+#if !NO_DUPE_DETECTION
+ return PhotoTable.get_instance().has_duplicate(file, thumbnail_md5, full_md5, file_format);
+#else
+ return false;
+#endif
+ }
+
+ protected static PhotoID[]? get_duplicate_ids(File? file, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format) {
+#if !NO_DUPE_DETECTION
+ return PhotoTable.get_instance().get_duplicate_ids(file, thumbnail_md5, full_md5, file_format);
+#else
+ return null;
+#endif
+ }
+
+ // Conforms to GetDatabaseSourceKey
+ public static int64 get_photo_key(DataSource source) {
+ return ((LibraryPhoto) source).get_photo_id().id;
+ }
+
+ // Data element accessors ... by making these thread-safe, and by the remainder of this class
+ // (and subclasses) accessing row *only* through these, helps ensure this object is suitable
+ // for threads. This implementation is specifically for PixbufCache to work properly.
+ //
+ // Much of the setter's thread-safety (especially in regard to writing to the database) is
+ // that there is a single Photo object per row of the database. The PhotoTable is accessed
+ // elsewhere in the system (usually for aggregate and search functions). Those would need to
+ // be factored and locked in order to guarantee full thread safety.
+ //
+ // Also note there is a certain amount of paranoia here. Many of PhotoRow's elements are
+ // currently static, with no setters to change them. However, since some of these may become
+ // mutable in the future, the entire structure is locked. If performance becomes an issue,
+ // more fine-tuned locking may be implemented -- another reason to *only* use these getters
+ // and setters inside this class.
+
+ public override File get_file() {
+ return get_source_reader().get_file();
+ }
+
+ // This should only be used when the photo's master backing file has been renamed; if it's been
+ // altered, use update().
+ public void set_master_file(File file) {
+ string filepath = file.get_path();
+
+ bool altered = false;
+ bool is_baseline = false;
+ bool is_source = false;
+ bool name_changed = false;
+ File? old_file = null;
+ try {
+ lock (row) {
+ lock (readers) {
+ old_file = readers.master.get_file();
+ if (!file.equal(old_file)) {
+ PhotoTable.get_instance().set_filepath(get_photo_id(), filepath);
+
+ row.master.filepath = filepath;
+ file_title = file.get_basename();
+ readers.master = row.master.file_format.create_reader(filepath);
+
+ altered = true;
+ is_baseline = is_master_baseline();
+ is_source = is_master_source();
+ name_changed = is_string_empty(row.title)
+ && old_file.get_basename() != file.get_basename();
+ }
+ }
+ }
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ if (altered) {
+ notify_master_replaced(old_file, file);
+
+ if (is_baseline)
+ notify_baseline_replaced();
+
+ string[] alteration_list = new string[0];
+ alteration_list += "backing:master";
+
+ // because the name of the photo is determined by its file title if no user title is present,
+ // signal metadata has altered
+ if (name_changed)
+ alteration_list += "metadata:name";
+
+ if (is_source)
+ alteration_list += "backing:source";
+
+ if (is_baseline)
+ alteration_list += "backing:baseline";
+
+ notify_altered(new Alteration.from_array(alteration_list));
+ }
+ }
+
+ // This should only be used when the photo's editable file has been renamed. If it's been
+ // altered, use update(). DO NOT USE THIS TO ATTACH A NEW EDITABLE FILE TO THE PHOTO.
+ public void set_editable_file(File file) {
+ string filepath = file.get_path();
+
+ bool altered = false;
+ bool is_baseline = false;
+ bool is_source = false;
+ File? old_file = null;
+ try {
+ lock (row) {
+ lock (readers) {
+ old_file = (readers.editable != null) ? readers.editable.get_file() : null;
+ if (old_file != null && !old_file.equal(file)) {
+ BackingPhotoTable.get_instance().set_filepath(row.editable_id, filepath);
+
+ editable.filepath = filepath;
+ readers.editable = editable.file_format.create_reader(filepath);
+
+ altered = true;
+ is_baseline = is_editable_baseline();
+ is_source = is_editable_source();
+ }
+ }
+ }
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ if (altered) {
+ notify_editable_replaced(old_file, file);
+
+ if (is_baseline)
+ notify_baseline_replaced();
+
+ string[] alteration_list = new string[0];
+ alteration_list += "backing:editable";
+
+ if (is_baseline)
+ alteration_list += "backing:baseline";
+
+ if (is_source)
+ alteration_list += "backing:source";
+
+ notify_altered(new Alteration.from_array(alteration_list));
+ }
+ }
+
+ // Also makes sense to freeze the SourceCollection during this operation.
+ public static void set_many_editable_file(Gee.Map<Photo, File> map) throws DatabaseError {
+ DatabaseTable.begin_transaction();
+
+ Gee.MapIterator<Photo, File> map_iter = map.map_iterator();
+ while (map_iter.next())
+ map_iter.get_key().set_editable_file(map_iter.get_value());
+
+ DatabaseTable.commit_transaction();
+ }
+
+ // Returns the file generating pixbufs, that is, the baseline if present, the backing
+ // file if not.
+ public File get_actual_file() {
+ return get_baseline_reader().get_file();
+ }
+
+ public override File get_master_file() {
+ return get_master_reader().get_file();
+ }
+
+ public File? get_editable_file() {
+ PhotoFileReader? reader = get_editable_reader();
+
+ return reader != null ? reader.get_file() : null;
+ }
+
+ public Gee.Collection<File>? get_raw_developer_files() {
+ if (get_master_file_format() != PhotoFileFormat.RAW)
+ return null;
+
+ Gee.ArrayList<File> ret = new Gee.ArrayList<File>();
+ lock (row) {
+ foreach (BackingPhotoRow row in developments.values)
+ ret.add(File.new_for_path(row.filepath));
+ }
+
+ return ret;
+ }
+
+ public File get_source_file() {
+ return get_source_reader().get_file();
+ }
+
+ public PhotoFileFormat get_file_format() {
+ lock (row) {
+ return backing_photo_row.file_format;
+ }
+ }
+
+ public PhotoFileFormat get_best_export_file_format() {
+ PhotoFileFormat file_format = get_file_format();
+ if (!file_format.can_write())
+ file_format = PhotoFileFormat.get_system_default_format();
+
+ return file_format;
+ }
+
+ public PhotoFileFormat get_master_file_format() {
+ lock (row) {
+ return readers.master.get_file_format();
+ }
+ }
+
+ public override time_t get_timestamp() {
+ lock (row) {
+ return backing_photo_row.timestamp;
+ }
+ }
+
+ public PhotoID get_photo_id() {
+ lock (row) {
+ return row.photo_id;
+ }
+ }
+
+ // This is NOT thread-safe.
+ public override inline EventID get_event_id() {
+ return row.event_id;
+ }
+
+ // This is NOT thread-safe.
+ public inline int64 get_raw_event_id() {
+ return row.event_id.id;
+ }
+
+ public override ImportID get_import_id() {
+ lock (row) {
+ return row.import_id;
+ }
+ }
+
+ protected BackingPhotoID get_editable_id() {
+ lock (row) {
+ return row.editable_id;
+ }
+ }
+
+ public override string get_master_md5() {
+ lock (row) {
+ return row.md5;
+ }
+ }
+
+ // Flags' meanings are determined by subclasses. Top 16 flags (0xFFFF000000000000) reserved
+ // for Photo.
+ public uint64 get_flags() {
+ lock (row) {
+ return row.flags;
+ }
+ }
+
+ 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 replace_flags(uint64 flags, Alteration? additional_alteration = null) {
+ bool committed;
+ lock (row) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public bool is_flag_set(uint64 mask) {
+ lock (row) {
+ return internal_is_flag_set(row.flags, mask);
+ }
+ }
+
+ public uint64 add_flags(uint64 mask, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = internal_add_flags(row.flags, mask);
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public uint64 remove_flags(uint64 mask, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = internal_remove_flags(row.flags, mask);
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public uint64 add_remove_flags(uint64 add, uint64 remove, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = (row.flags | add) & ~remove;
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public static void add_remove_many_flags(Gee.Collection<Photo>? add, uint64 add_mask,
+ Alteration? additional_add_alteration, Gee.Collection<Photo>? remove, uint64 remove_mask,
+ Alteration? additional_remove_alteration) throws DatabaseError {
+ DatabaseTable.begin_transaction();
+
+ if (add != null) {
+ foreach (Photo photo in add)
+ photo.add_flags(add_mask, additional_add_alteration);
+ }
+
+ if (remove != null) {
+ foreach (Photo photo in remove)
+ photo.remove_flags(remove_mask, additional_remove_alteration);
+ }
+
+ DatabaseTable.commit_transaction();
+ }
+
+ public uint64 toggle_flags(uint64 mask, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = row.flags ^ mask;
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public bool is_master_metadata_dirty() {
+ lock (row) {
+ return row.metadata_dirty;
+ }
+ }
+
+ public void set_master_metadata_dirty(bool dirty) throws DatabaseError {
+ bool committed = false;
+ lock (row) {
+ if (row.metadata_dirty != dirty) {
+ PhotoTable.get_instance().set_metadata_dirty(get_photo_id(), dirty);
+ row.metadata_dirty = dirty;
+ committed = true;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "master-dirty"));
+ }
+
+ public override Rating get_rating() {
+ lock (row) {
+ return row.rating;
+ }
+ }
+
+ public override void set_rating(Rating rating) {
+ bool committed = false;
+
+ lock (row) {
+ if (rating != row.rating && rating.is_valid()) {
+ committed = PhotoTable.get_instance().set_rating(get_photo_id(), rating);
+ if (committed)
+ row.rating = rating;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "rating"));
+ }
+
+ public override void increase_rating() {
+ lock (row) {
+ set_rating(row.rating.increase());
+ }
+ }
+
+ public override void decrease_rating() {
+ lock (row) {
+ set_rating(row.rating.decrease());
+ }
+ }
+
+ protected override void commit_backlinks(SourceCollection? sources, string? backlinks) {
+ // For now, only one link state may be stored in the database ... if this turns into a
+ // problem, will use SourceCollection to determine where to store it.
+
+ try {
+ PhotoTable.get_instance().update_backlinks(get_photo_id(), backlinks);
+ lock (row) {
+ row.backlinks = backlinks;
+ }
+ } catch (DatabaseError err) {
+ warning("Unable to update link state for %s: %s", to_string(), err.message);
+ }
+
+ // Note: *Not* firing altered or metadata_altered signal because link_state is not a
+ // property that's available to users of Photo. Persisting it as a mechanism for deaing
+ // with unlink/relink properly.
+ }
+
+ protected override bool set_event_id(EventID event_id) {
+ lock (row) {
+ bool committed = PhotoTable.get_instance().set_event(row.photo_id, event_id);
+
+ if (committed)
+ row.event_id = event_id;
+
+ return committed;
+ }
+ }
+
+ public override string to_string() {
+ return "[%s] %s%s".printf(get_photo_id().id.to_string(), get_master_reader().get_filepath(),
+ !is_master_baseline() ? " (" + get_actual_file().get_path() + ")" : "");
+ }
+
+ public override bool equals(DataSource? source) {
+ // Primary key is where the rubber hits the road
+ Photo? photo = source as Photo;
+ if (photo != null) {
+ PhotoID photo_id = get_photo_id();
+ PhotoID other_photo_id = photo.get_photo_id();
+
+ if (this != photo && photo_id.id != PhotoID.INVALID) {
+ assert(photo_id.id != other_photo_id.id);
+ }
+ }
+
+ return base.equals(source);
+ }
+
+ // used to update the database after an internal metadata exif write
+ private void file_exif_updated() {
+ File file = get_file();
+
+ FileInfo info = null;
+ try {
+ info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ } catch (Error err) {
+ error("Unable to read file information for %s: %s", to_string(), err.message);
+ }
+
+ TimeVal timestamp = info.get_modification_time();
+
+ // interrogate file for photo information
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file);
+ try {
+ interrogator.interrogate();
+ } catch (Error err) {
+ warning("Unable to interrogate photo file %s: %s", file.get_path(), err.message);
+ }
+
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected == null) {
+ critical("file_exif_updated: %s no longer an image", to_string());
+
+ return;
+ }
+
+ 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);
+ }
+
+ if (success)
+ notify_altered(new Alteration.from_list("metadata:exif,metadata:md5"));
+ }
+
+ // PhotoSource
+
+ public override uint64 get_filesize() {
+ lock (row) {
+ return backing_photo_row.filesize;
+ }
+ }
+
+ public override uint64 get_master_filesize() {
+ lock (row) {
+ return row.master.filesize;
+ }
+ }
+
+ public uint64 get_editable_filesize() {
+ lock (row) {
+ return editable.filesize;
+ }
+ }
+
+ public override time_t get_exposure_time() {
+ return cached_exposure_time;
+ }
+
+ public override string get_basename() {
+ lock (row) {
+ return file_title;
+ }
+ }
+
+ public override string? get_title() {
+ lock (row) {
+ return row.title;
+ }
+ }
+
+ public override string? get_comment() {
+ lock (row) {
+ return row.comment;
+ }
+ }
+
+ public override void set_title(string? title) {
+ string? new_title = prep_title(title);
+
+ bool committed = false;
+ lock (row) {
+ if (new_title == row.title)
+ return;
+
+ committed = PhotoTable.get_instance().set_title(row.photo_id, new_title);
+ if (committed)
+ row.title = new_title;
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "name"));
+ }
+
+ public override bool set_comment(string? comment) {
+ string? new_comment = prep_comment(comment);
+
+ bool committed = false;
+ lock (row) {
+ if (new_comment == row.comment)
+ return true;
+
+ committed = PhotoTable.get_instance().set_comment(row.photo_id, new_comment);
+ if (committed)
+ row.comment = new_comment;
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "comment"));
+
+ return committed;
+ }
+
+ public void set_import_id(ImportID import_id) {
+ DatabaseError dberr = null;
+ lock (row) {
+ try {
+ PhotoTable.get_instance().set_import_id(row.photo_id, import_id);
+ row.import_id = import_id;
+ } catch (DatabaseError err) {
+ dberr = err;
+ }
+ }
+
+ if (dberr == null)
+ notify_altered(new Alteration("metadata", "import-id"));
+ else
+ warning("Unable to write import ID for %s: %s", to_string(), dberr.message);
+ }
+
+ public void set_title_persistent(string? title) throws Error {
+ PhotoFileReader source = get_source_reader();
+
+ // Try to write to backing file
+ if (!source.get_file_format().can_write_metadata()) {
+ warning("No photo file writer available for %s", source.get_filepath());
+
+ set_title(title);
+
+ return;
+ }
+
+ PhotoMetadata metadata = source.read_metadata();
+ metadata.set_title(title);
+
+ PhotoFileMetadataWriter writer = source.create_metadata_writer();
+ LibraryMonitor.blacklist_file(source.get_file(), "Photo.set_persistent_title");
+ try {
+ writer.write_metadata(metadata);
+ } finally {
+ LibraryMonitor.unblacklist_file(source.get_file());
+ }
+
+ set_title(title);
+
+ file_exif_updated();
+ }
+
+ public void set_comment_persistent(string? comment) throws Error {
+ PhotoFileReader source = get_source_reader();
+
+ // Try to write to backing file
+ if (!source.get_file_format().can_write_metadata()) {
+ warning("No photo file writer available for %s", source.get_filepath());
+
+ set_comment(comment);
+
+ return;
+ }
+
+ PhotoMetadata metadata = source.read_metadata();
+ metadata.set_comment(comment);
+
+ PhotoFileMetadataWriter writer = source.create_metadata_writer();
+ LibraryMonitor.blacklist_file(source.get_file(), "Photo.set_persistent_comment");
+ try {
+ writer.write_metadata(metadata);
+ } finally {
+ LibraryMonitor.unblacklist_file(source.get_file());
+ }
+
+ set_comment(comment);
+
+ file_exif_updated();
+ }
+
+ public void set_exposure_time(time_t time) {
+ bool committed;
+ lock (row) {
+ committed = PhotoTable.get_instance().set_exposure_time(row.photo_id, time);
+ if (committed) {
+ row.exposure_time = time;
+ cached_exposure_time = time;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "exposure-time"));
+ }
+
+ public void set_exposure_time_persistent(time_t time) throws Error {
+ PhotoFileReader source = get_source_reader();
+
+ // Try to write to backing file
+ if (!source.get_file_format().can_write_metadata()) {
+ warning("No photo file writer available for %s", source.get_filepath());
+
+ set_exposure_time(time);
+
+ return;
+ }
+
+ PhotoMetadata metadata = source.read_metadata();
+ metadata.set_exposure_date_time(new MetadataDateTime(time));
+
+ PhotoFileMetadataWriter writer = source.create_metadata_writer();
+ LibraryMonitor.blacklist_file(source.get_file(), "Photo.set_exposure_time_persistent");
+ try {
+ writer.write_metadata(metadata);
+ } finally {
+ LibraryMonitor.unblacklist_file(source.get_file());
+ }
+
+ set_exposure_time(time);
+
+ file_exif_updated();
+ }
+
+ /**
+ * @brief Returns the width and height of the Photo after various
+ * arbitrary stages of the pipeline have been applied in
+ * the same order they're applied in get_pixbuf_with_options.
+ * With no argument passed, it works exactly like the
+ * previous incarnation did.
+ *
+ * @param disallowed_steps Which pipeline steps should NOT
+ * be taken into account when computing image dimensions
+ * (matching the convention set by get_pixbuf_with_options()).
+ * Pipeline steps that do not affect the image geometry are
+ * ignored.
+ */
+ public override Dimensions get_dimensions(Exception disallowed_steps = Exception.NONE) {
+ // The raw dimensions of the incoming image prior to the pipeline.
+ Dimensions returned_dims = get_raw_dimensions();
+
+ // Compute how much the image would be resized by after rotating and/or mirroring.
+ if (disallowed_steps.allows(Exception.ORIENTATION)) {
+ Orientation ori_tmp = get_orientation();
+
+ // Is this image rotated 90 or 270 degrees?
+ switch (ori_tmp) {
+ case Orientation.LEFT_TOP:
+ case Orientation.RIGHT_TOP:
+ case Orientation.LEFT_BOTTOM:
+ case Orientation.RIGHT_BOTTOM:
+ // Yes, swap width and height of raw dimensions.
+ int width_tmp = returned_dims.width;
+
+ returned_dims.width = returned_dims.height;
+ returned_dims.height = width_tmp;
+ break;
+
+ default:
+ // No, only mirrored or rotated 180; do nothing.
+ break;
+ }
+ }
+
+ // Compute how much the image would be resized by after straightening.
+ if (disallowed_steps.allows(Exception.STRAIGHTEN)) {
+ double x_size, y_size;
+ double angle = 0.0;
+
+ get_straighten(out angle);
+
+ compute_arb_rotated_size(returned_dims.width, returned_dims.height, angle, out x_size, out y_size);
+
+ returned_dims.width = (int) (x_size);
+ returned_dims.height = (int) (y_size);
+ }
+
+ // Compute how much the image would be resized by after cropping.
+ if (disallowed_steps.allows(Exception.CROP)) {
+ Box crop;
+ if (get_crop(out crop, disallowed_steps)) {
+ returned_dims = crop.get_dimensions();
+ }
+ }
+ return returned_dims;
+ }
+
+ // This method *must* be called with row locked.
+ private void locked_create_adjustments_from_data() {
+ adjustments = new PixelTransformationBundle();
+
+ KeyValueMap map = get_transformation("adjustments");
+ if (map == null)
+ adjustments.set_to_identity();
+ else
+ adjustments.load(map);
+
+ transformer = adjustments.generate_transformer();
+ }
+
+ // Returns a copy of the color adjustments array. Use set_color_adjustments to persist.
+ public PixelTransformationBundle get_color_adjustments() {
+ lock (row) {
+ if (adjustments == null)
+ locked_create_adjustments_from_data();
+
+ return adjustments.copy();
+ }
+ }
+
+ public PixelTransformer get_pixel_transformer() {
+ lock (row) {
+ if (transformer == null)
+ locked_create_adjustments_from_data();
+
+ return transformer.copy();
+ }
+ }
+
+ public bool has_color_adjustments() {
+ return has_transformation("adjustments");
+ }
+
+ public PixelTransformation? get_color_adjustment(PixelTransformationType type) {
+ return get_color_adjustments().get_transformation(type);
+ }
+
+ public void set_color_adjustments(PixelTransformationBundle new_adjustments) {
+ /* if every transformation in 'new_adjustments' is the identity, then just remove all
+ adjustments from the database */
+ if (new_adjustments.is_identity()) {
+ bool result;
+ lock (row) {
+ result = remove_transformation("adjustments");
+ adjustments = null;
+ transformer = null;
+ }
+
+ if (result)
+ notify_altered(new Alteration("image", "color-adjustments"));
+
+ return;
+ }
+
+ // convert bundle to KeyValueMap, which can be saved in the database
+ KeyValueMap map = new_adjustments.save("adjustments");
+
+ bool committed;
+ lock (row) {
+ if (transformer == null || adjustments == null) {
+ // create new
+ adjustments = new_adjustments.copy();
+ transformer = new_adjustments.generate_transformer();
+ } else {
+ // replace existing
+ foreach (PixelTransformation transformation in new_adjustments.get_transformations()) {
+ transformer.replace_transformation(
+ adjustments.get_transformation(transformation.get_transformation_type()),
+ transformation);
+ }
+
+ adjustments = new_adjustments.copy();
+ }
+
+ committed = set_transformation(map);
+ }
+
+ if (committed)
+ notify_altered(new Alteration("image", "color-adjustments"));
+ }
+
+ // This is thread-safe. Returns the source file's metadata.
+ public override PhotoMetadata? get_metadata() {
+ try {
+ return get_source_reader().read_metadata();
+ } catch (Error err) {
+ warning("Unable to load metadata: %s", err.message);
+
+ return null;
+ }
+ }
+
+ public PhotoMetadata get_master_metadata() throws Error {
+ return get_master_reader().read_metadata();
+ }
+
+ public PhotoMetadata? get_editable_metadata() throws Error {
+ PhotoFileReader? reader = get_editable_reader();
+
+ return (reader != null) ? reader.read_metadata() : null;
+ }
+
+ // This is thread-safe. This must be followed by a call to finish_update_master_metadata() in
+ // the main thread. Returns false if unable to write metadata (because operation is
+ // unsupported) or the file is unavailable.
+ public bool persist_master_metadata(PhotoMetadata metadata, out ReimportMasterState state)
+ throws Error {
+ state = null;
+
+ PhotoFileReader master_reader = get_master_reader();
+
+ if (!master_reader.get_file_format().can_write_metadata())
+ return false;
+
+ master_reader.create_metadata_writer().write_metadata(metadata);
+
+ if (!prepare_for_reimport_master(out state))
+ return false;
+
+ ((ReimportMasterStateImpl) state).metadata_only = true;
+
+ return true;
+ }
+
+ public void finish_update_master_metadata(ReimportMasterState state) throws DatabaseError {
+ finish_reimport_master(state);
+ }
+
+ public bool persist_editable_metadata(PhotoMetadata metadata, out ReimportEditableState state)
+ throws Error {
+ state = null;
+
+ PhotoFileReader? editable_reader = get_editable_reader();
+ if (editable_reader == null)
+ return false;
+
+ if (!editable_reader.get_file_format().can_write_metadata())
+ return false;
+
+ editable_reader.create_metadata_writer().write_metadata(metadata);
+
+ if (!prepare_for_reimport_editable(out state))
+ return false;
+
+ ((ReimportEditableStateImpl) state).metadata_only = true;
+
+ return true;
+ }
+
+ public void finish_update_editable_metadata(ReimportEditableState state) throws DatabaseError {
+ finish_reimport_editable(state);
+ }
+
+ // Transformation storage and exporting
+
+ public Dimensions get_raw_dimensions() {
+ lock (row) {
+ return backing_photo_row.dim;
+ }
+ }
+
+ public bool has_transformations() {
+ lock (row) {
+ return (row.orientation != backing_photo_row.original_orientation)
+ ? true
+ : (row.transformations != null);
+ }
+ }
+
+ public bool only_metadata_changed() {
+ MetadataDateTime? date_time = null;
+
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata != null)
+ date_time = metadata.get_exposure_date_time();
+
+ lock (row) {
+ return row.transformations == null
+ && (row.orientation != backing_photo_row.original_orientation
+ || (date_time != null && row.exposure_time != date_time.get_timestamp()));
+ }
+ }
+
+ public bool has_alterations() {
+ MetadataDateTime? date_time = null;
+ string? title = null;
+ string? comment = null;
+
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata != null) {
+ date_time = metadata.get_exposure_date_time();
+ title = metadata.get_title();
+ comment = metadata.get_comment();
+ }
+
+ // Does this photo contain any date/time info?
+ if (date_time == null) {
+ // No, use file timestamp as date/time.
+ lock (row) {
+ // Did we manually set an exposure date?
+ if(backing_photo_row.timestamp != row.exposure_time) {
+ // Yes, we need to save this.
+ return true;
+ }
+ }
+ }
+
+ lock (row) {
+ return row.transformations != null
+ || row.orientation != backing_photo_row.original_orientation
+ || (date_time != null && row.exposure_time != date_time.get_timestamp())
+ || (get_comment() != comment)
+ || (get_title() != title);
+ }
+
+ }
+
+ public PhotoTransformationState save_transformation_state() {
+ lock (row) {
+ return new PhotoTransformationStateImpl(this, row.orientation,
+ row.transformations,
+ transformer != null ? transformer.copy() : null,
+ adjustments != null ? adjustments.copy() : null);
+ }
+ }
+
+ public bool load_transformation_state(PhotoTransformationState state) {
+ PhotoTransformationStateImpl state_impl = state as PhotoTransformationStateImpl;
+ if (state_impl == null)
+ return false;
+
+ Orientation saved_orientation = state_impl.get_orientation();
+ Gee.HashMap<string, KeyValueMap>? saved_transformations = state_impl.get_transformations();
+ PixelTransformer? saved_transformer = state_impl.get_transformer();
+ PixelTransformationBundle? saved_adjustments = state_impl.get_color_adjustments();
+
+ bool committed;
+ lock (row) {
+ committed = PhotoTable.get_instance().set_transformation_state(row.photo_id,
+ saved_orientation, saved_transformations);
+ if (committed) {
+ row.orientation = saved_orientation;
+ row.transformations = saved_transformations;
+ transformer = saved_transformer;
+ adjustments = saved_adjustments;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("image", "transformation-state"));
+
+ return committed;
+ }
+
+ public void remove_all_transformations() {
+ internal_remove_all_transformations(true);
+ }
+
+ private void internal_remove_all_transformations(bool notify) {
+ bool is_altered = false;
+ lock (row) {
+ is_altered = PhotoTable.get_instance().remove_all_transformations(row.photo_id);
+ row.transformations = null;
+
+ transformer = null;
+ adjustments = null;
+
+ if (row.orientation != backing_photo_row.original_orientation) {
+ PhotoTable.get_instance().set_orientation(row.photo_id,
+ backing_photo_row.original_orientation);
+ row.orientation = backing_photo_row.original_orientation;
+ is_altered = true;
+ }
+ }
+
+ if (is_altered && notify)
+ notify_altered(new Alteration("image", "revert"));
+ }
+
+ public Orientation get_original_orientation() {
+ lock (row) {
+ return backing_photo_row.original_orientation;
+ }
+ }
+
+ public Orientation get_orientation() {
+ lock (row) {
+ return row.orientation;
+ }
+ }
+
+ public bool set_orientation(Orientation orientation) {
+ bool committed = false;
+ lock (row) {
+ if (row.orientation != orientation) {
+ committed = PhotoTable.get_instance().set_orientation(row.photo_id, orientation);
+ if (committed)
+ row.orientation = orientation;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("image", "orientation"));
+
+ return committed;
+ }
+
+ public bool check_can_rotate() {
+ return can_rotate_now;
+ }
+
+ public virtual void rotate(Rotation rotation) {
+ lock (row) {
+ set_orientation(get_orientation().perform(rotation));
+ }
+ }
+
+ private bool has_transformation(string name) {
+ lock (row) {
+ return (row.transformations != null) ? row.transformations.has_key(name) : false;
+ }
+ }
+
+ // Note that obtaining the proper map is thread-safe here. The returned map is a copy of
+ // the original, so it is thread-safe as well. However: modifying the returned map
+ // does not modify the original; set_transformation() must be used.
+ private KeyValueMap? get_transformation(string name) {
+ KeyValueMap map = null;
+ lock (row) {
+ if (row.transformations != null) {
+ map = row.transformations.get(name);
+ if (map != null)
+ map = map.copy();
+ }
+ }
+
+ return map;
+ }
+
+ private bool set_transformation(KeyValueMap trans) {
+ lock (row) {
+ if (row.transformations == null)
+ row.transformations = new Gee.HashMap<string, KeyValueMap>();
+
+ row.transformations.set(trans.get_group(), trans);
+
+ return PhotoTable.get_instance().set_transformation(row.photo_id, trans);
+ }
+ }
+
+ private bool remove_transformation(string name) {
+ bool altered_cache, altered_persistent;
+ lock (row) {
+ if (row.transformations != null) {
+ altered_cache = row.transformations.unset(name);
+ if (row.transformations.size == 0)
+ row.transformations = null;
+ } else {
+ altered_cache = false;
+ }
+
+ altered_persistent = PhotoTable.get_instance().remove_transformation(row.photo_id,
+ name);
+ }
+
+ return (altered_cache || altered_persistent);
+ }
+
+ public bool has_crop() {
+ return has_transformation("crop");
+ }
+
+ // Returns the crop in the raw photo's coordinate system
+ public bool get_raw_crop(out Box crop) {
+ crop = Box();
+
+ KeyValueMap map = get_transformation("crop");
+ if (map == null)
+ return false;
+
+ int left = map.get_int("left", -1);
+ int top = map.get_int("top", -1);
+ int right = map.get_int("right", -1);
+ int bottom = map.get_int("bottom", -1);
+
+ if (left == -1 || top == -1 || right == -1 || bottom == -1)
+ return false;
+
+ crop = Box(left, top, right, bottom);
+
+ return true;
+ }
+
+ // Sets the crop using the raw photo's unrotated coordinate system
+ private void set_raw_crop(Box crop) {
+ KeyValueMap map = new KeyValueMap("crop");
+ map.set_int("left", crop.left);
+ map.set_int("top", crop.top);
+ map.set_int("right", crop.right);
+ map.set_int("bottom", crop.bottom);
+
+ if (set_transformation(map))
+ notify_altered(new Alteration("image", "crop"));
+ }
+
+ private bool get_raw_straighten(out double angle) {
+ KeyValueMap map = get_transformation("straighten");
+ if (map == null) {
+ angle = 0.0;
+
+ return false;
+ }
+
+ angle = map.get_double("angle", 0.0);
+
+ return true;
+ }
+
+ private void set_raw_straighten(double theta) {
+ KeyValueMap map = new KeyValueMap("straighten");
+ map.set_double("angle", theta);
+
+ if (set_transformation(map)) {
+ notify_altered(new Alteration("image", "straighten"));
+ }
+ }
+
+ // All instances are against the coordinate system of the unscaled, unrotated photo.
+ private EditingTools.RedeyeInstance[] get_raw_redeye_instances() {
+ KeyValueMap map = get_transformation("redeye");
+ if (map == null)
+ return new EditingTools.RedeyeInstance[0];
+
+ int num_points = map.get_int("num_points", -1);
+ assert(num_points > 0);
+
+ EditingTools.RedeyeInstance[] res = new EditingTools.RedeyeInstance[num_points];
+
+ Gdk.Point default_point = {0};
+ default_point.x = -1;
+ default_point.y = -1;
+
+ for (int i = 0; i < num_points; i++) {
+ string center_key = "center%d".printf(i);
+ string radius_key = "radius%d".printf(i);
+
+ res[i].center = map.get_point(center_key, default_point);
+ assert(res[i].center.x != default_point.x);
+ assert(res[i].center.y != default_point.y);
+
+ res[i].radius = map.get_int(radius_key, -1);
+ assert(res[i].radius != -1);
+ }
+
+ return res;
+ }
+
+ public bool has_redeye_transformations() {
+ return has_transformation("redeye");
+ }
+
+ // All instances are against the coordinate system of the unrotated photo.
+ public void add_redeye_instance(EditingTools.RedeyeInstance redeye) {
+ KeyValueMap map = get_transformation("redeye");
+ if (map == null) {
+ map = new KeyValueMap("redeye");
+ map.set_int("num_points", 0);
+ }
+
+ int num_points = map.get_int("num_points", -1);
+ assert(num_points >= 0);
+
+ num_points++;
+
+ string radius_key = "radius%d".printf(num_points - 1);
+ string center_key = "center%d".printf(num_points - 1);
+
+ map.set_int(radius_key, redeye.radius);
+ map.set_point(center_key, redeye.center);
+
+ map.set_int("num_points", num_points);
+
+ if (set_transformation(map))
+ notify_altered(new Alteration("image", "redeye"));
+ }
+
+ // Pixbuf generation
+
+ // Returns dimensions for the pixbuf at various stages of the pipeline.
+ //
+ // scaled_image is the dimensions of the image after a scaled load-and-decode.
+ // scaled_to_viewport is the dimensions of the image sized according to the scaling parameter.
+ // scaled_image and scaled_to_viewport may be different if the photo is cropped.
+ //
+ // Returns true if scaling is to occur, false otherwise. If false, scaled_image will be set to
+ // the raw image dimensions and scaled_to_viewport will be the dimensions of the image scaled
+ // to the Scaling viewport.
+ private bool calculate_pixbuf_dimensions(Scaling scaling, Exception exceptions,
+ out Dimensions scaled_image, out Dimensions scaled_to_viewport) {
+ lock (row) {
+ // this function needs to access various elements of the Photo atomically
+ return locked_calculate_pixbuf_dimensions(scaling, exceptions,
+ out scaled_image, out scaled_to_viewport);
+ }
+ }
+
+ // Must be called with row locked.
+ private bool locked_calculate_pixbuf_dimensions(Scaling scaling, Exception exceptions,
+ out Dimensions scaled_image, out Dimensions scaled_to_viewport) {
+ Dimensions raw = get_raw_dimensions();
+
+ if (scaling.is_unscaled()) {
+ scaled_image = raw;
+ scaled_to_viewport = raw;
+
+ return false;
+ }
+
+ Orientation orientation = get_orientation();
+
+ // If no crop, the scaled_image is simply raw scaled to fit into the viewport. Otherwise,
+ // the image is scaled enough so the cropped region fits the viewport.
+
+ scaled_image = Dimensions();
+ scaled_to_viewport = Dimensions();
+
+ if (exceptions.allows(Exception.CROP)) {
+ Box crop;
+ if (get_raw_crop(out crop)) {
+ // rotate the crop and raw space accordingly ... order is important here, rotate_box
+ // works with the unrotated dimensions in space
+ Dimensions rotated_raw = raw;
+ if (exceptions.allows(Exception.ORIENTATION)) {
+ crop = orientation.rotate_box(raw, crop);
+ rotated_raw = orientation.rotate_dimensions(raw);
+ }
+
+ // scale the rotated crop to fit in the viewport
+ Box scaled_crop = crop.get_scaled(scaling.get_scaled_dimensions(crop.get_dimensions()));
+
+ // the viewport size is the size of the scaled crop
+ scaled_to_viewport = scaled_crop.get_dimensions();
+
+ // only scale the image if the crop is larger than the viewport
+ if (crop.get_width() <= scaled_crop.get_width()
+ && crop.get_height() <= scaled_crop.get_height()) {
+ scaled_image = raw;
+ scaled_to_viewport = crop.get_dimensions();
+
+ return false;
+ }
+ // resize the total pixbuf so the crop slices directly from the scaled pixbuf,
+ // with no need for resizing thereafter. The decoded size is determined by the
+ // proportion of the actual size to the crop size
+ scaled_image = rotated_raw.get_scaled_similar(crop.get_dimensions(),
+ scaled_crop.get_dimensions());
+
+ // derotate, as the loader knows nothing about orientation
+ if (exceptions.allows(Exception.ORIENTATION))
+ scaled_image = orientation.derotate_dimensions(scaled_image);
+ }
+ }
+
+ // if scaled_image not set, merely scale the raw pixbuf
+ if (!scaled_image.has_area()) {
+ // rotate for the scaler
+ Dimensions rotated_raw = raw;
+ if (exceptions.allows(Exception.ORIENTATION))
+ rotated_raw = orientation.rotate_dimensions(raw);
+
+ scaled_image = scaling.get_scaled_dimensions(rotated_raw);
+ scaled_to_viewport = scaled_image;
+
+ // derotate the scaled dimensions, as the loader knows nothing about orientation
+ if (exceptions.allows(Exception.ORIENTATION))
+ scaled_image = orientation.derotate_dimensions(scaled_image);
+ }
+
+ // do not scale up
+ if (scaled_image.width >= raw.width && scaled_image.height >= raw.height) {
+ scaled_image = raw;
+
+ return false;
+ }
+
+ assert(scaled_image.has_area());
+ assert(scaled_to_viewport.has_area());
+
+ return true;
+ }
+
+ // Returns a raw, untransformed, unrotated pixbuf directly from the source. Scaling provides
+ // asked for a scaled-down image, which has certain performance benefits if the resized
+ // JPEG is scaled down by a factor of a power of two (one-half, one-fourth, etc.).
+ private Gdk.Pixbuf load_raw_pixbuf(Scaling scaling, Exception exceptions,
+ BackingFetchMode fetch_mode = BackingFetchMode.BASELINE) throws Error {
+
+ PhotoFileReader loader = get_backing_reader(fetch_mode);
+
+ // no scaling, load and get out
+ if (scaling.is_unscaled()) {
+#if MEASURE_PIPELINE
+ debug("LOAD_RAW_PIXBUF UNSCALED %s: requested", loader.get_filepath());
+#endif
+
+ return loader.unscaled_read();
+ }
+
+ // Need the dimensions of the image to load
+ Dimensions scaled_image, scaled_to_viewport;
+ bool is_scaled = calculate_pixbuf_dimensions(scaling, exceptions, out scaled_image,
+ out scaled_to_viewport);
+ if (!is_scaled) {
+#if MEASURE_PIPELINE
+ debug("LOAD_RAW_PIXBUF UNSCALED %s: scaling unavailable", loader.get_filepath());
+#endif
+
+ return loader.unscaled_read();
+ }
+
+ Gdk.Pixbuf pixbuf = loader.scaled_read(get_raw_dimensions(), scaled_image);
+
+#if MEASURE_PIPELINE
+ debug("LOAD_RAW_PIXBUF %s %s: %s -> %s (actual: %s)", scaling.to_string(), loader.get_filepath(),
+ get_raw_dimensions().to_string(), scaled_image.to_string(),
+ Dimensions.for_pixbuf(pixbuf).to_string());
+#endif
+
+ assert(scaled_image.approx_equals(Dimensions.for_pixbuf(pixbuf), SCALING_FUDGE));
+
+ return pixbuf;
+ }
+
+ // Returns a raw, untransformed, scaled pixbuf from the master that has been optionally rotated
+ // according to its original EXIF settings.
+ public Gdk.Pixbuf get_master_pixbuf(Scaling scaling, bool rotate = true) throws Error {
+ return get_untransformed_pixbuf(scaling, rotate, BackingFetchMode.MASTER);
+ }
+
+ // Returns a pixbuf that hasn't been modified (head of the pipeline.)
+ public Gdk.Pixbuf get_unmodified_pixbuf(Scaling scaling, bool rotate = true) throws Error {
+ return get_untransformed_pixbuf(scaling, rotate, BackingFetchMode.UNMODIFIED);
+ }
+
+ // Returns an untransformed pixbuf with optional scaling, rotation, and fetch mode.
+ private Gdk.Pixbuf get_untransformed_pixbuf(Scaling scaling, bool rotate, BackingFetchMode fetch_mode)
+ throws Error {
+#if MEASURE_PIPELINE
+ Timer timer = new Timer();
+ Timer total_timer = new Timer();
+ double orientation_time = 0.0;
+
+ total_timer.start();
+#endif
+
+ // get required fields all at once, to avoid holding the row lock
+ Dimensions scaled_image, scaled_to_viewport;
+ Orientation original_orientation;
+
+ lock (row) {
+ calculate_pixbuf_dimensions(scaling, Exception.NONE, out scaled_image,
+ out scaled_to_viewport);
+ original_orientation = get_original_orientation();
+ }
+
+ // load-and-decode and scale
+ Gdk.Pixbuf pixbuf = load_raw_pixbuf(scaling, Exception.NONE, fetch_mode);
+
+ // orientation
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (rotate)
+ pixbuf = original_orientation.rotate_pixbuf(pixbuf);
+
+#if MEASURE_PIPELINE
+ orientation_time = timer.elapsed();
+
+ debug("MASTER PIPELINE %s (%s): orientation=%lf total=%lf", to_string(), scaling.to_string(),
+ orientation_time, total_timer.elapsed());
+#endif
+
+ return pixbuf;
+ }
+
+ public override Gdk.Pixbuf get_pixbuf(Scaling scaling) throws Error {
+ return get_pixbuf_with_options(scaling);
+ }
+
+ /**
+ * @brief Populates the cached version of the unmodified image.
+ */
+ public void populate_prefetched() throws Error {
+ lock (unmodified_precached) {
+ // If we don't have it already, precache the original...
+ if (unmodified_precached == null) {
+ unmodified_precached = load_raw_pixbuf(Scaling.for_original(), Exception.ALL, BackingFetchMode.SOURCE);
+ secs_since_access = new GLib.Timer();
+ GLib.Timeout.add_seconds(5, (GLib.SourceFunc)discard_prefetched);
+ debug("spawning new precache timeout for %s", this.to_string());
+ }
+ }
+ }
+
+ /**
+ * @brief Get a copy of what's in the cache.
+ *
+ * @return A Pixbuf with the image data from unmodified_precached.
+ */
+ public Gdk.Pixbuf? get_prefetched_copy() {
+ lock (unmodified_precached) {
+ if (unmodified_precached == null) {
+ try {
+ populate_prefetched();
+ } catch (Error e) {
+ warning("raw pixbuf for %s could not be loaded", this.to_string());
+ return null;
+ }
+ }
+
+ return unmodified_precached.copy();
+ }
+ }
+
+ /**
+ * @brief Discards the cached version of the unmodified image.
+ *
+ * @param immed Whether the cached version should be discarded now, or not.
+ */
+ public bool discard_prefetched(bool immed = false) {
+ lock (unmodified_precached) {
+ if (secs_since_access == null)
+ return false;
+
+ double tmp;
+ if ((secs_since_access.elapsed(out tmp) > PRECACHE_TIME_TO_LIVE) || (immed)) {
+ debug("pipeline not run in over %d seconds or got immediate command, discarding " +
+ "cached original for %s",
+ PRECACHE_TIME_TO_LIVE, to_string());
+ unmodified_precached = null;
+ secs_since_access = null;
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ /**
+ * @brief Returns a fully transformed and scaled pixbuf. Transformations may be excluded via
+ * the mask. If the image is smaller than the scaling, it will be returned in its actual size.
+ * The caller is responsible for scaling thereafter.
+ *
+ * @param scaling A scaling object that describes the size the output pixbuf should be.
+ * @param exceptions The parts of the pipeline that should be skipped; defaults to NONE if
+ * left unset.
+ * @param fetch_mode The fetch mode; if left unset, defaults to BASELINE so that
+ * we get the image exactly as it is in the file.
+ */
+ public Gdk.Pixbuf get_pixbuf_with_options(Scaling scaling, Exception exceptions =
+ Exception.NONE, BackingFetchMode fetch_mode = BackingFetchMode.BASELINE) throws Error {
+
+#if MEASURE_PIPELINE
+ Timer timer = new Timer();
+ Timer total_timer = new Timer();
+ double redeye_time = 0.0, crop_time = 0.0, adjustment_time = 0.0, orientation_time = 0.0,
+ straighten_time = 0.0;
+
+ total_timer.start();
+#endif
+
+ // If this is a RAW photo, ensure the development is ready.
+ if (Photo.develop_raw_photos_to_files &&
+ get_master_file_format() == PhotoFileFormat.RAW &&
+ (fetch_mode == BackingFetchMode.BASELINE || fetch_mode == BackingFetchMode.UNMODIFIED
+ || fetch_mode == BackingFetchMode.SOURCE) &&
+ !is_raw_developer_complete(get_raw_developer()))
+ set_raw_developer(get_raw_developer());
+
+ // to minimize holding the row lock, fetch everything needed for the pipeline up-front
+ bool is_scaled, is_cropped, is_straightened;
+ Dimensions scaled_to_viewport;
+ Dimensions original = Dimensions();
+ Dimensions scaled = Dimensions();
+ EditingTools.RedeyeInstance[] redeye_instances = null;
+ Box crop;
+ double straightening_angle;
+ PixelTransformer transformer = null;
+ Orientation orientation;
+
+ lock (row) {
+ original = get_dimensions(Exception.ALL);
+ scaled = scaling.get_scaled_dimensions(get_dimensions(exceptions));
+ scaled_to_viewport = scaled;
+
+ is_scaled = !(get_dimensions().equals(scaled));
+
+ redeye_instances = get_raw_redeye_instances();
+
+ is_cropped = get_raw_crop(out crop);
+
+ is_straightened = get_raw_straighten(out straightening_angle);
+
+ if (has_color_adjustments())
+ transformer = get_pixel_transformer();
+
+ orientation = get_orientation();
+ }
+
+ //
+ // Image load-and-decode
+ //
+ populate_prefetched();
+
+ Gdk.Pixbuf pixbuf = get_prefetched_copy();
+
+ // remember to delete the cached copy if it isn't being used.
+ secs_since_access.start();
+ debug("pipeline being run against %s, timer restarted.", this.to_string());
+
+ assert(pixbuf != null);
+
+ //
+ // Image transformation pipeline
+ //
+
+ // redeye reduction
+ if (exceptions.allows(Exception.REDEYE)) {
+
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ foreach (EditingTools.RedeyeInstance instance in redeye_instances) {
+ pixbuf = do_redeye(pixbuf, instance);
+ }
+#if MEASURE_PIPELINE
+ redeye_time = timer.elapsed();
+#endif
+ }
+
+ // angle photograph so in-image horizon is aligned with horizontal
+ if (exceptions.allows(Exception.STRAIGHTEN)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (is_straightened) {
+ pixbuf = rotate_arb(pixbuf, straightening_angle);
+ }
+
+#if MEASURE_PIPELINE
+ straighten_time = timer.elapsed();
+#endif
+ }
+
+ // crop
+ if (exceptions.allows(Exception.CROP)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (is_cropped) {
+
+ // ensure the crop region stays inside the scaled image boundaries and is
+ // at least 1 px by 1 px; this is needed as a work-around for inaccuracies
+ // which can occur when zooming.
+ crop.left = crop.left.clamp(0, pixbuf.width - 2);
+ crop.top = crop.top.clamp(0, pixbuf.height - 2);
+
+ crop.right = crop.right.clamp(crop.left + 1, pixbuf.width - 1);
+ crop.bottom = crop.bottom.clamp(crop.top + 1, pixbuf.height - 1);
+
+ pixbuf = new Gdk.Pixbuf.subpixbuf(pixbuf, crop.left, crop.top, crop.get_width(),
+ crop.get_height());
+ }
+
+#if MEASURE_PIPELINE
+ crop_time = timer.elapsed();
+#endif
+ }
+
+ // orientation (all modifications are stored in unrotated coordinate system)
+ if (exceptions.allows(Exception.ORIENTATION)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ pixbuf = orientation.rotate_pixbuf(pixbuf);
+#if MEASURE_PIPELINE
+ orientation_time = timer.elapsed();
+#endif
+ }
+
+#if MEASURE_PIPELINE
+ debug("PIPELINE %s (%s): redeye=%lf crop=%lf adjustment=%lf orientation=%lf total=%lf",
+ to_string(), scaling.to_string(), redeye_time, crop_time, adjustment_time,
+ orientation_time, total_timer.elapsed());
+#endif
+
+ // scale the scratch image, as needed.
+ if (is_scaled) {
+ pixbuf = pixbuf.scale_simple(scaled_to_viewport.width, scaled_to_viewport.height, Gdk.InterpType.BILINEAR);
+ }
+
+ // color adjustment; we do this dead last, since, if an image has been scaled down,
+ // it may allow us to reduce the amount of pixel arithmetic, increasing responsiveness.
+ if (exceptions.allows(Exception.ADJUST)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (transformer != null)
+ transformer.transform_pixbuf(pixbuf);
+#if MEASURE_PIPELINE
+ adjustment_time = timer.elapsed();
+#endif
+ }
+
+ // This is to verify the generated pixbuf matches the scale requirements; crop, straighten
+ // and orientation are all transformations that change the dimensions or aspect ratio of
+ // the pixbuf, and must be accounted for the test to be valid.
+ if ((is_scaled) && (!is_straightened))
+ assert(scaled_to_viewport.approx_equals(Dimensions.for_pixbuf(pixbuf), SCALING_FUDGE));
+
+ return pixbuf;
+ }
+
+
+ //
+ // File export
+ //
+
+ protected abstract bool has_user_generated_metadata();
+
+ // Sets the metadata values for any user generated metadata, only called if
+ // has_user_generated_metadata returns true
+ protected abstract void set_user_metadata_for_export(PhotoMetadata metadata);
+
+ // Returns the basename of the file if it were to be exported in format 'file_format'; if
+ // 'file_format' is null, then return the basename of the file if it were to be exported in the
+ // native backing format of the photo (i.e. no conversion is performed). If 'file_format' is
+ // null and the native backing format is not writeable (i.e. RAW), then use the system
+ // default file format, as defined in PhotoFileFormat
+ public string get_export_basename(PhotoFileFormat? file_format = null) {
+ if (file_format != null) {
+ return file_format.get_properties().convert_file_extension(get_master_file()).get_basename();
+ } else {
+ if (get_file_format().can_write()) {
+ return get_file_format().get_properties().convert_file_extension(
+ get_master_file()).get_basename();
+ } else {
+ return PhotoFileFormat.get_system_default_format().get_properties().convert_file_extension(
+ get_master_file()).get_basename();
+ }
+ }
+ }
+
+ private bool export_fullsized_backing(File file, bool export_metadata = true) throws Error {
+ // See if the native reader supports writing ... if no matches, need to fall back
+ // on a "regular" export, which requires decoding then encoding
+ PhotoFileReader export_reader = null;
+ bool is_master = true;
+ lock (readers) {
+ if (readers.editable != null && readers.editable.get_file_format().can_write_metadata()) {
+ export_reader = readers.editable;
+ is_master = false;
+ } else if (readers.developer != null && readers.developer.get_file_format().can_write_metadata()) {
+ export_reader = readers.developer;
+ is_master = false;
+ } else if (readers.master.get_file_format().can_write_metadata()) {
+ export_reader = readers.master;
+ }
+ }
+
+ if (export_reader == null)
+ return false;
+
+ PhotoFileFormatProperties format_properties = export_reader.get_file_format().get_properties();
+
+ // Build a destination file with the caller's name but the appropriate extension
+ File dest_file = format_properties.convert_file_extension(file);
+
+ // Create a PhotoFileMetadataWriter that matches the PhotoFileReader's file format
+ PhotoFileMetadataWriter writer = export_reader.get_file_format().create_metadata_writer(
+ dest_file.get_path());
+
+ debug("Exporting full-sized copy of %s to %s", to_string(), writer.get_filepath());
+
+ export_reader.get_file().copy(dest_file,
+ FileCopyFlags.OVERWRITE | FileCopyFlags.TARGET_DEFAULT_PERMS, null, null);
+
+ // If asking for an full-sized file and there are no alterations (transformations or EXIF)
+ // *and* this is a copy of the original backing *and* there's no user metadata or title *and* metadata should be exported, then done
+ if (!has_alterations() && is_master && !has_user_generated_metadata() &&
+ (get_title() == null) && (get_comment() == null) && export_metadata)
+ return true;
+
+ // copy over relevant metadata if possible, otherwise generate new metadata
+ PhotoMetadata? metadata = export_reader.read_metadata();
+ if (metadata == null)
+ metadata = export_reader.get_file_format().create_metadata();
+
+ debug("Updating metadata of %s", writer.get_filepath());
+
+ if (get_exposure_time() != 0)
+ metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time()));
+ else
+ metadata.set_exposure_date_time(null);
+
+ if(export_metadata) {
+ //set metadata
+ metadata.set_title(get_title());
+ metadata.set_comment(get_comment());
+ metadata.set_pixel_dimensions(get_dimensions()); // created by sniffing pixbuf not metadata
+ metadata.set_orientation(get_orientation());
+ metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION);
+
+ if (get_orientation() != get_original_orientation())
+ metadata.remove_exif_thumbnail();
+
+ set_user_metadata_for_export(metadata);
+ }
+ else
+ //delete metadata
+ metadata.clear();
+
+ writer.write_metadata(metadata);
+
+ return true;
+ }
+
+ // Returns true if there's any reason that an export is required to fully represent the photo
+ // on disk. False essentially means that the source file (NOT NECESSARILY the master file)
+ // *is* the full representation of the photo and its metadata.
+ public bool is_export_required(Scaling scaling, PhotoFileFormat export_format) {
+ return (!scaling.is_unscaled() || has_alterations() || has_user_generated_metadata()
+ || export_format != get_file_format());
+ }
+
+ // TODO: Lossless transformations, especially for mere rotations of JFIF files.
+ //
+ // This method is thread-safe.
+ public void export(File dest_file, Scaling scaling, Jpeg.Quality quality,
+ PhotoFileFormat export_format, bool direct_copy_unmodified = false, bool export_metadata = true) throws Error {
+ if (direct_copy_unmodified) {
+ get_master_file().copy(dest_file, FileCopyFlags.OVERWRITE |
+ FileCopyFlags.TARGET_DEFAULT_PERMS, null, null);
+ return;
+ }
+
+ // Attempt to avoid decode/encoding cycle when exporting original-sized photos for lossy
+ // formats, as that degrades image quality. If alterations exist, but only EXIF has
+ // changed and the user hasn't requested conversion between image formats, then just copy
+ // the original file and update relevant EXIF.
+ if (scaling.is_unscaled() && (!has_alterations() || only_metadata_changed()) &&
+ (export_format == get_file_format()) && (get_file_format() == PhotoFileFormat.JFIF)) {
+ if (export_fullsized_backing(dest_file, export_metadata))
+ return;
+ }
+
+ // Copy over existing metadata from source if available, or create new metadata and
+ // save it for later export below. This has to happen before the format writer writes
+ // out the modified image, as that write will strip the existing exif data.
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata == null)
+ metadata = export_format.create_metadata();
+
+ if (!export_format.can_write())
+ export_format = PhotoFileFormat.get_system_default_format();
+
+ PhotoFileWriter writer = export_format.create_writer(dest_file.get_path());
+
+ debug("Saving transformed version of %s to %s in file format %s", to_string(),
+ writer.get_filepath(), export_format.to_string());
+
+ Gdk.Pixbuf pixbuf;
+
+ // Since JPEGs can store their own orientation, we save the pixels
+ // directly and let the orientation field do the rotation...
+ if ((get_file_format() == PhotoFileFormat.JFIF) ||
+ (get_file_format() == PhotoFileFormat.RAW)) {
+ pixbuf = get_pixbuf_with_options(scaling, Exception.ORIENTATION,
+ BackingFetchMode.SOURCE);
+ } else {
+ // Non-JPEG image - we'll need to save the rotated pixels.
+ pixbuf = get_pixbuf_with_options(scaling, Exception.NONE,
+ BackingFetchMode.SOURCE);
+ }
+
+ writer.write(pixbuf, quality);
+
+ debug("Setting EXIF for %s", writer.get_filepath());
+
+ // Do we need to save metadata to this file?
+ if (export_metadata) {
+ //Yes, set metadata obtained above.
+ metadata.set_title(get_title());
+ metadata.set_comment(get_comment());
+ metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION);
+
+ if (get_exposure_time() != 0)
+ metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time()));
+ else
+ metadata.set_exposure_date_time(null);
+
+ metadata.remove_tag("Exif.Iop.RelatedImageWidth");
+ metadata.remove_tag("Exif.Iop.RelatedImageHeight");
+ metadata.remove_exif_thumbnail();
+
+ if (has_user_generated_metadata())
+ set_user_metadata_for_export(metadata);
+ }
+ else {
+ //No, delete metadata.
+ metadata.clear();
+ }
+
+ // Even if we were told to trash camera-identifying data, we need
+ // to make sure the orientation propagates. Also, because JPEGs
+ // can store their own orientation, we'll save the original dimensions
+ // directly and let the orientation field do the rotation there.
+ if ((get_file_format() == PhotoFileFormat.JFIF) ||
+ (get_file_format() == PhotoFileFormat.RAW)) {
+ metadata.set_pixel_dimensions(get_dimensions(Exception.ORIENTATION));
+ metadata.set_orientation(get_orientation());
+ } else {
+ // Non-JPEG image - we'll need to save the rotated dimensions.
+ metadata.set_pixel_dimensions(Dimensions.for_pixbuf(pixbuf));
+ metadata.set_orientation(Orientation.TOP_LEFT);
+ }
+
+ export_format.create_metadata_writer(dest_file.get_path()).write_metadata(metadata);
+ }
+
+ private File generate_new_editable_file(out PhotoFileFormat file_format) throws Error {
+ File backing;
+ lock (row) {
+ file_format = get_file_format();
+ backing = get_file();
+ }
+
+ if (!file_format.can_write())
+ file_format = PhotoFileFormat.get_system_default_format();
+
+ string name, ext;
+ disassemble_filename(backing.get_basename(), out name, out ext);
+
+ if (ext == null || !file_format.get_properties().is_recognized_extension(ext))
+ ext = file_format.get_properties().get_default_extension();
+
+ string editable_basename = "%s_%s.%s".printf(name, _("modified"), ext);
+
+ bool collision;
+ return generate_unique_file(backing.get_parent(), editable_basename, out collision);
+ }
+
+ private static bool launch_editor(File file, PhotoFileFormat file_format) throws Error {
+ string commandline = file_format == PhotoFileFormat.RAW ? Config.Facade.get_instance().get_external_raw_app() :
+ Config.Facade.get_instance().get_external_photo_app();
+
+ if (is_string_empty(commandline))
+ return false;
+
+ AppInfo? app;
+ try {
+ app = AppInfo.create_from_commandline(commandline, "",
+ AppInfoCreateFlags.NONE);
+ } catch (Error er) {
+ app = null;
+ }
+
+ List<File> files = new List<File>();
+ files.insert(file, -1);
+
+ if (app != null)
+ return app.launch(files, null);
+
+ string[] argv = new string[2];
+ argv[0] = commandline;
+ argv[1] = file.get_path();
+
+ Pid child_pid;
+
+ return Process.spawn_async(
+ "/",
+ argv,
+ null, // environment
+ SpawnFlags.SEARCH_PATH,
+ null, // child setup
+ out child_pid);
+ }
+
+ // Opens with Ufraw, etc.
+ public void open_with_raw_external_editor() throws Error {
+ launch_editor(get_master_file(), get_master_file_format());
+ }
+
+ // Opens with GIMP, etc.
+ public void open_with_external_editor() throws Error {
+ File current_editable_file = null;
+ File create_editable_file = null;
+ PhotoFileFormat editable_file_format;
+ lock (readers) {
+ if (readers.editable != null)
+ current_editable_file = readers.editable.get_file();
+
+ if (current_editable_file == null)
+ create_editable_file = generate_new_editable_file(out editable_file_format);
+ else
+ editable_file_format = readers.editable.get_file_format();
+ }
+
+ // if this isn't the first time but the file does not exist OR there are transformations
+ // that need to be represented there, create a new one
+ if (create_editable_file == null && current_editable_file != null &&
+ (!current_editable_file.query_exists(null) || has_transformations()))
+ create_editable_file = current_editable_file;
+
+ // if creating a new edited file and can write to it, stop watching the old one
+ if (create_editable_file != null && editable_file_format.can_write()) {
+ halt_monitoring_editable();
+
+ try {
+ export(create_editable_file, Scaling.for_original(), Jpeg.Quality.MAXIMUM,
+ editable_file_format);
+ } catch (Error err) {
+ // if an error is thrown creating the file, clean it up
+ try {
+ create_editable_file.delete(null);
+ } catch (Error delete_err) {
+ // ignored
+ warning("Unable to delete editable file %s after export error: %s",
+ create_editable_file.get_path(), delete_err.message);
+ }
+
+ throw err;
+ }
+
+ // attach the editable file to the photo
+ attach_editable(editable_file_format, create_editable_file);
+
+ current_editable_file = create_editable_file;
+ }
+
+ assert(current_editable_file != null);
+
+ // if not already monitoring, monitor now
+ if (editable_monitor == null)
+ start_monitoring_editable(current_editable_file);
+
+ launch_editor(current_editable_file, get_file_format());
+ }
+
+ public void revert_to_master(bool notify = true) {
+ detach_editable(true, true, notify);
+ }
+
+ private void start_monitoring_editable(File file) throws Error {
+ halt_monitoring_editable();
+
+ // tell the LibraryMonitor not to monitor this file
+ LibraryMonitor.blacklist_file(file, "Photo.start_monitoring_editable");
+
+ editable_monitor = file.monitor(FileMonitorFlags.NONE, null);
+ editable_monitor.changed.connect(on_editable_file_changed);
+ }
+
+ private void halt_monitoring_editable() {
+ if (editable_monitor == null)
+ return;
+
+ // tell the LibraryMonitor a-ok to watch this file again
+ File? file = get_editable_file();
+ if (file != null)
+ LibraryMonitor.unblacklist_file(file);
+
+ editable_monitor.changed.disconnect(on_editable_file_changed);
+ editable_monitor.cancel();
+ editable_monitor = null;
+ }
+
+ private void attach_editable(PhotoFileFormat file_format, File file) throws Error {
+ // remove the transformations ... this must be done before attaching the editable, as these
+ // transformations are in the master's coordinate system, not the editable's ... don't
+ // notify photo is altered *yet* because update_editable will notify, and want to avoid
+ // stacking them up
+ internal_remove_all_transformations(false);
+ update_editable(false, file_format.create_reader(file.get_path()));
+ }
+
+ private void update_editable_attributes() throws Error {
+ update_editable(true, null);
+ }
+
+ public void reimport_editable() throws Error {
+ update_editable(false, null);
+ }
+
+ // In general, because of the fragility of the order of operations and what's required where,
+ // use one of the above wrapper functions to call this rather than call this directly.
+ private void update_editable(bool only_attributes, PhotoFileReader? new_reader = null) throws Error {
+ // only_attributes only available for updating existing editable
+ assert((only_attributes && new_reader == null) || (!only_attributes));
+
+ PhotoFileReader? old_reader = get_editable_reader();
+
+ PhotoFileReader reader = new_reader ?? old_reader;
+ if (reader == null) {
+ detach_editable(false, true);
+
+ return;
+ }
+
+ bool timestamp_changed = false;
+ bool filesize_changed = false;
+ bool is_new_editable = false;
+
+ BackingPhotoID editable_id = get_editable_id();
+ File file = reader.get_file();
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow? backing = query_backing_photo_row(file, PhotoFileSniffer.Options.NO_MD5,
+ out detected);
+
+ // Have we _not_ got an editable attached yet?
+ if (editable_id.is_invalid()) {
+ // Yes, try to create and attach one.
+ if (backing != null) {
+ BackingPhotoTable.get_instance().add(backing);
+ lock (row) {
+ timestamp_changed = true;
+ filesize_changed = true;
+
+ PhotoTable.get_instance().attach_editable(row, backing.id);
+ editable = backing;
+ backing_photo_row = editable;
+ set_orientation(backing_photo_row.original_orientation);
+ }
+ }
+ is_new_editable = true;
+ }
+
+ if (only_attributes) {
+ // This should only be possible if the editable exists already.
+ assert(editable_id.is_valid());
+
+ FileInfo info;
+ try {
+ info = file.query_filesystem_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, null);
+ } catch (Error err) {
+ warning("Unable to read editable filesystem info for %s: %s", to_string(), err.message);
+ detach_editable(false, true);
+
+ return;
+ }
+
+ TimeVal timestamp = info.get_modification_time();
+
+ BackingPhotoTable.get_instance().update_attributes(editable_id, timestamp.tv_sec,
+ info.get_size());
+ lock (row) {
+ timestamp_changed = editable.timestamp != timestamp.tv_sec;
+ filesize_changed = editable.filesize != info.get_size();
+
+ editable.timestamp = timestamp.tv_sec;
+ editable.filesize = info.get_size();
+ }
+ } else {
+ // Not just a file-attribute-only change.
+ if (editable_id.is_valid() && !is_new_editable) {
+ // Only check these if we didn't just have to create
+ // this editable, since, with a newly-created editable,
+ // the file size and modification time are by definition
+ // freshly-changed.
+ backing.id = editable_id;
+ BackingPhotoTable.get_instance().update(backing);
+ lock (row) {
+ timestamp_changed = editable.timestamp != backing.timestamp;
+ filesize_changed = editable.filesize != backing.filesize;
+
+ editable = backing;
+ backing_photo_row = editable;
+ set_orientation(backing_photo_row.original_orientation);
+ }
+ }
+ }
+
+ // if a new reader was specified, install that and begin using it
+ if (new_reader != null) {
+ lock (readers) {
+ readers.editable = new_reader;
+ }
+ }
+
+ if (!only_attributes && reader != old_reader) {
+ notify_baseline_replaced();
+ notify_editable_replaced(old_reader != null ? old_reader.get_file() : null,
+ new_reader != null ? new_reader.get_file() : null);
+ }
+
+ string[] alteration_list = new string[0];
+ if (timestamp_changed) {
+ alteration_list += "metadata:editable-timestamp";
+ alteration_list += "metadata:baseline-timestamp";
+
+ if (is_editable_source())
+ alteration_list += "metadata:source-timestamp";
+ }
+
+ if (filesize_changed || new_reader != null) {
+ alteration_list += "image:editable";
+ alteration_list += "image:baseline";
+
+ if (is_editable_source())
+ alteration_list += "image:source";
+ }
+
+ if (alteration_list.length > 0)
+ notify_altered(new Alteration.from_array(alteration_list));
+ }
+
+ private void detach_editable(bool delete_editable, bool remove_transformations, bool notify = true) {
+ halt_monitoring_editable();
+
+ bool has_editable = false;
+ File? editable_file = null;
+ lock (readers) {
+ if (readers.editable != null) {
+ editable_file = readers.editable.get_file();
+ readers.editable = null;
+ has_editable = true;
+ }
+ }
+
+ if (has_editable) {
+ BackingPhotoID editable_id = BackingPhotoID();
+ try {
+ lock (row) {
+ editable_id = row.editable_id;
+ if (editable_id.is_valid())
+ PhotoTable.get_instance().detach_editable(row);
+ backing_photo_row = row.master;
+ }
+ } catch (DatabaseError err) {
+ warning("Unable to remove editable from PhotoTable: %s", err.message);
+ }
+
+ try {
+ if (editable_id.is_valid())
+ BackingPhotoTable.get_instance().remove(editable_id);
+ } catch (DatabaseError err) {
+ warning("Unable to remove editable from BackingPhotoTable: %s", err.message);
+ }
+ }
+
+ if (remove_transformations)
+ internal_remove_all_transformations(false);
+
+ if (has_editable) {
+ notify_baseline_replaced();
+ notify_editable_replaced(editable_file, null);
+ }
+
+ if (delete_editable && editable_file != null) {
+ try {
+ editable_file.trash(null);
+ } catch (Error err) {
+ warning("Unable to trash editable %s for %s: %s", editable_file.get_path(), to_string(),
+ err.message);
+ }
+ }
+
+ if ((has_editable || remove_transformations) && notify)
+ notify_altered(new Alteration("image", "revert"));
+ }
+
+ private void on_editable_file_changed(File file, File? other_file, FileMonitorEvent event) {
+ // This has some expense, but this assertion is important for a lot of sanity reasons.
+ lock (readers) {
+ assert(readers.editable != null && file.equal(readers.editable.get_file()));
+ }
+
+ debug("EDITABLE %s: %s", event.to_string(), file.get_path());
+
+ switch (event) {
+ case FileMonitorEvent.CHANGED:
+ case FileMonitorEvent.CREATED:
+ if (reimport_editable_scheduler == null) {
+ reimport_editable_scheduler = new OneShotScheduler("Photo.reimport_editable",
+ on_reimport_editable);
+ }
+
+ reimport_editable_scheduler.after_timeout(1000, true);
+ break;
+
+ case FileMonitorEvent.ATTRIBUTE_CHANGED:
+ if (update_editable_attributes_scheduler == null) {
+ update_editable_attributes_scheduler = new OneShotScheduler(
+ "Photo.update_editable_attributes", on_update_editable_attributes);
+ }
+
+ update_editable_attributes_scheduler.after_timeout(1000, true);
+ break;
+
+ case FileMonitorEvent.DELETED:
+ if (remove_editable_scheduler == null) {
+ remove_editable_scheduler = new OneShotScheduler("Photo.remove_editable",
+ on_remove_editable);
+ }
+
+ remove_editable_scheduler.after_timeout(3000, true);
+ break;
+
+ case FileMonitorEvent.CHANGES_DONE_HINT:
+ default:
+ // ignored
+ break;
+ }
+
+ // at this point, any image date we have cached is stale,
+ // so delete it and force the pipeline to re-fetch it
+ discard_prefetched(true);
+ }
+
+ private void on_reimport_editable() {
+ // delete old image data and force the pipeline to load new from file.
+ discard_prefetched(true);
+
+ debug("Reimporting editable for %s", to_string());
+ try {
+ reimport_editable();
+ } catch (Error err) {
+ warning("Unable to reimport photo %s changed by external editor: %s",
+ to_string(), err.message);
+ }
+ }
+
+ private void on_update_editable_attributes() {
+ debug("Updating editable attributes for %s", to_string());
+ try {
+ update_editable_attributes();
+ } catch (Error err) {
+ warning("Unable to update editable attributes: %s", err.message);
+ }
+ }
+
+ private void on_remove_editable() {
+ PhotoFileReader? reader = get_editable_reader();
+ if (reader == null)
+ return;
+
+ File file = reader.get_file();
+ if (file.query_exists(null)) {
+ debug("Not removing editable for %s: file exists", to_string());
+
+ return;
+ }
+
+ debug("Removing editable for %s: file no longer exists", to_string());
+ detach_editable(false, true);
+ }
+
+ //
+ // Aggregate/helper/translation functions
+ //
+
+ // Returns uncropped (but rotated) dimensions
+ public Dimensions get_original_dimensions() {
+ Dimensions dim = get_raw_dimensions();
+ Orientation orientation = get_orientation();
+
+ return orientation.rotate_dimensions(dim);
+ }
+
+ // Returns uncropped dimensions rotated only to reflect the original orientation
+ public Dimensions get_master_dimensions() {
+ return get_original_orientation().rotate_dimensions(get_raw_dimensions());
+ }
+
+ // Returns the crop against the coordinate system of the rotated photo
+ public bool get_crop(out Box crop, Exception exceptions = Exception.NONE) {
+ Box raw;
+ if (!get_raw_crop(out raw)) {
+ crop = Box();
+
+ return false;
+ }
+
+ Dimensions dim = get_dimensions(Exception.CROP | Exception.ORIENTATION);
+ Orientation orientation = get_orientation();
+
+ if(exceptions.allows(Exception.ORIENTATION))
+ crop = orientation.rotate_box(dim, raw);
+ else
+ crop = raw;
+
+ return true;
+ }
+
+ // Sets the crop against the coordinate system of the rotated photo
+ public void set_crop(Box crop) {
+ Dimensions dim = get_dimensions(Exception.CROP | Exception.ORIENTATION);
+ Orientation orientation = get_orientation();
+
+ Box derotated = orientation.derotate_box(dim, crop);
+
+ derotated.left = derotated.left.clamp(0, dim.width - 2);
+ derotated.right = derotated.right.clamp(derotated.left, dim.width - 1);
+
+ derotated.top = derotated.top.clamp(0, dim.height - 2);
+ derotated.bottom = derotated.bottom.clamp(derotated.top, dim.height - 1);
+
+ set_raw_crop(derotated);
+ }
+
+ public bool get_straighten(out double theta) {
+ if (!get_raw_straighten(out theta))
+ return false;
+
+ return true;
+ }
+
+ public void set_straighten(double theta) {
+ set_raw_straighten(theta);
+ }
+
+ private Gdk.Pixbuf do_redeye(Gdk.Pixbuf pixbuf, EditingTools.RedeyeInstance inst) {
+ /* we remove redeye within a circular region called the "effect
+ extent." the effect extent is inscribed within its "bounding
+ rectangle." */
+
+ /* for each scanline in the top half-circle of the effect extent,
+ compute the number of pixels by which the effect extent is inset
+ from the edges of its bounding rectangle. note that we only have
+ to do this for the first quadrant because the second quadrant's
+ insets can be derived by symmetry */
+ double r = (double) inst.radius;
+ int[] x_insets_first_quadrant = new int[inst.radius + 1];
+
+ int i = 0;
+ for (double y = r; y >= 0.0; y -= 1.0) {
+ double theta = Math.asin(y / r);
+ int x = (int)((r * Math.cos(theta)) + 0.5);
+ x_insets_first_quadrant[i] = inst.radius - x;
+
+ i++;
+ }
+
+ int x_bounds_min = inst.center.x - inst.radius;
+ int x_bounds_max = inst.center.x + inst.radius;
+ int ymin = inst.center.y - inst.radius;
+ ymin = (ymin < 0) ? 0 : ymin;
+ int ymax = inst.center.y;
+ ymax = (ymax > (pixbuf.height - 1)) ? (pixbuf.height - 1) : ymax;
+
+ /* iterate over all the pixels in the top half-circle of the effect
+ extent from top to bottom */
+ int inset_index = 0;
+ for (int y_it = ymin; y_it <= ymax; y_it++) {
+ int xmin = x_bounds_min + x_insets_first_quadrant[inset_index];
+ xmin = (xmin < 0) ? 0 : xmin;
+ int xmax = x_bounds_max - x_insets_first_quadrant[inset_index];
+ xmax = (xmax > (pixbuf.width - 1)) ? (pixbuf.width - 1) : xmax;
+
+ for (int x_it = xmin; x_it <= xmax; x_it++) {
+ red_reduce_pixel(pixbuf, x_it, y_it);
+ }
+ inset_index++;
+ }
+
+ /* iterate over all the pixels in the top half-circle of the effect
+ extent from top to bottom */
+ ymin = inst.center.y;
+ ymax = inst.center.y + inst.radius;
+ inset_index = x_insets_first_quadrant.length - 1;
+ for (int y_it = ymin; y_it <= ymax; y_it++) {
+ int xmin = x_bounds_min + x_insets_first_quadrant[inset_index];
+ xmin = (xmin < 0) ? 0 : xmin;
+ int xmax = x_bounds_max - x_insets_first_quadrant[inset_index];
+ xmax = (xmax > (pixbuf.width - 1)) ? (pixbuf.width - 1) : xmax;
+
+ for (int x_it = xmin; x_it <= xmax; x_it++) {
+ red_reduce_pixel(pixbuf, x_it, y_it);
+ }
+ inset_index--;
+ }
+
+ return pixbuf;
+ }
+
+ private Gdk.Pixbuf red_reduce_pixel(Gdk.Pixbuf pixbuf, int x, int y) {
+ int px_start_byte_offset = (y * pixbuf.get_rowstride()) +
+ (x * pixbuf.get_n_channels());
+
+ /* Due to inaccuracies in the scaler, we can occasionally
+ * get passed a coordinate pair outside the image, causing
+ * us to walk off the array and into segfault territory.
+ * Check coords prior to drawing to prevent this... */
+ if ((x >= 0) && (y >= 0) && (x < pixbuf.width) && (y < pixbuf.height)) {
+ unowned uchar[] pixel_data = pixbuf.get_pixels();
+
+ /* The pupil of the human eye has no pigment, so we expect all
+ color channels to be of about equal intensity. This means that at
+ any point within the effects region, the value of the red channel
+ should be about the same as the values of the green and blue
+ channels. So set the value of the red channel to be the mean of the
+ values of the red and blue channels. This preserves achromatic
+ intensity across all channels while eliminating any extraneous flare
+ affecting the red channel only (i.e. the red-eye effect). */
+ uchar g = pixel_data[px_start_byte_offset + 1];
+ uchar b = pixel_data[px_start_byte_offset + 2];
+
+ uchar r = (g + b) / 2;
+
+ pixel_data[px_start_byte_offset] = r;
+ }
+
+ return pixbuf;
+ }
+
+ public Gdk.Point unscaled_to_raw_point(Gdk.Point unscaled_point) {
+ Orientation unscaled_orientation = get_orientation();
+
+ Dimensions unscaled_dims =
+ unscaled_orientation.rotate_dimensions(get_dimensions());
+
+ int unscaled_x_offset_raw = 0;
+ int unscaled_y_offset_raw = 0;
+
+ Box crop_box;
+ if (get_raw_crop(out crop_box)) {
+ unscaled_x_offset_raw = crop_box.left;
+ unscaled_y_offset_raw = crop_box.top;
+ }
+
+ Gdk.Point derotated_point =
+ unscaled_orientation.derotate_point(unscaled_dims,
+ unscaled_point);
+
+ derotated_point.x += unscaled_x_offset_raw;
+ derotated_point.y += unscaled_y_offset_raw;
+
+ return derotated_point;
+ }
+
+ public Gdk.Rectangle unscaled_to_raw_rect(Gdk.Rectangle unscaled_rect) {
+ Gdk.Point upper_left = {0};
+ Gdk.Point lower_right = {0};
+ upper_left.x = unscaled_rect.x;
+ upper_left.y = unscaled_rect.y;
+ lower_right.x = upper_left.x + unscaled_rect.width;
+ lower_right.y = upper_left.y + unscaled_rect.height;
+
+ upper_left = unscaled_to_raw_point(upper_left);
+ lower_right = unscaled_to_raw_point(lower_right);
+
+ if (upper_left.x > lower_right.x) {
+ int temp = upper_left.x;
+ upper_left.x = lower_right.x;
+ lower_right.x = temp;
+ }
+ if (upper_left.y > lower_right.y) {
+ int temp = upper_left.y;
+ upper_left.y = lower_right.y;
+ lower_right.y = temp;
+ }
+
+ Gdk.Rectangle raw_rect = Gdk.Rectangle();
+ raw_rect.x = upper_left.x;
+ raw_rect.y = upper_left.y;
+ raw_rect.width = lower_right.x - upper_left.x;
+ raw_rect.height = lower_right.y - upper_left.y;
+
+ return raw_rect;
+ }
+
+ public PixelTransformationBundle? get_enhance_transformations() {
+ Gdk.Pixbuf pixbuf = null;
+
+#if MEASURE_ENHANCE
+ Timer fetch_timer = new Timer();
+#endif
+
+ try {
+ pixbuf = get_pixbuf_with_options(Scaling.for_best_fit(360, false),
+ Photo.Exception.ALL);
+
+#if MEASURE_ENHANCE
+ fetch_timer.stop();
+#endif
+ } catch (Error e) {
+ warning("Photo: get_enhance_transformations: couldn't obtain pixbuf to build " +
+ "transform histogram");
+ return null;
+ }
+
+#if MEASURE_ENHANCE
+ Timer analyze_timer = new Timer();
+#endif
+
+ PixelTransformationBundle transformations = AutoEnhance.create_auto_enhance_adjustments(pixbuf);
+
+#if MEASURE_ENHANCE
+ analyze_timer.stop();
+ debug("Auto-Enhance fetch time: %f sec; analyze time: %f sec", fetch_timer.elapsed(),
+ analyze_timer.elapsed());
+#endif
+
+ return transformations;
+ }
+
+ public bool enhance() {
+ PixelTransformationBundle transformations = get_enhance_transformations();
+
+ if (transformations == null)
+ return false;
+
+#if MEASURE_ENHANCE
+ Timer apply_timer = new Timer();
+#endif
+ lock (row) {
+ set_color_adjustments(transformations);
+ }
+
+#if MEASURE_ENHANCE
+ apply_timer.stop();
+ debug("Auto-Enhance apply time: %f sec", apply_timer.elapsed());
+#endif
+ return true;
+ }
+}
+
+public class LibraryPhotoSourceCollection : MediaSourceCollection {
+ public enum State {
+ UNKNOWN,
+ ONLINE,
+ OFFLINE,
+ TRASH,
+ EDITABLE,
+ DEVELOPER
+ }
+
+ 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.HashMap<File, LibraryPhoto> by_editable_file = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.HashMap<File, LibraryPhoto> by_raw_development_file = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.MultiMap<int64?, LibraryPhoto> filesize_to_photo =
+ new Gee.TreeMultiMap<int64?, LibraryPhoto>(int64_compare);
+ private Gee.HashMap<LibraryPhoto, int64?> photo_to_master_filesize =
+ new Gee.HashMap<LibraryPhoto, int64?>(null, null, int64_equal);
+ private Gee.HashMap<LibraryPhoto, int64?> photo_to_editable_filesize =
+ new Gee.HashMap<LibraryPhoto, int64?>(null, null, int64_equal);
+ private Gee.MultiMap<LibraryPhoto, int64?> photo_to_raw_development_filesize =
+ new Gee.TreeMultiMap<LibraryPhoto, int64?>();
+
+ public virtual signal void master_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public virtual signal void editable_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public virtual signal void baseline_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public virtual signal void source_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public LibraryPhotoSourceCollection() {
+ base ("LibraryPhotoSourceCollection", Photo.get_photo_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 LibraryPhotoSourceHoldingTank(this, check_if_trashed_photo, Photo.get_photo_key);
+ }
+
+ protected override MediaSourceHoldingTank create_offline_bin() {
+ return new LibraryPhotoSourceHoldingTank(this, check_if_offline_photo, Photo.get_photo_key);
+ }
+
+ public override MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable) {
+ return new PhotoMonitor(workers, cancellable);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is LibraryPhoto;
+ }
+
+ public override string get_typename() {
+ return Photo.TYPENAME;
+ }
+
+ public override bool is_file_recognized(File file) {
+ return PhotoFileFormat.is_file_supported(file);
+ }
+
+ protected override void notify_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ if (added != null) {
+ foreach (DataObject object in added) {
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ File? editable = photo.get_editable_file();
+ if (editable != null)
+ by_editable_file.set(editable, photo);
+ photo.editable_replaced.connect(on_editable_replaced);
+
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null)
+ foreach (File f in raw_list)
+ by_raw_development_file.set(f, photo);
+ photo.raw_development_modified.connect(on_raw_development_modified);
+
+ int64 master_filesize = photo.get_master_photo_row().filesize;
+ int64 editable_filesize = photo.get_editable_photo_row() != null
+ ? photo.get_editable_photo_row().filesize
+ : -1;
+ filesize_to_photo.set(master_filesize, photo);
+ photo_to_master_filesize.set(photo, master_filesize);
+ if (editable_filesize >= 0) {
+ filesize_to_photo.set(editable_filesize, photo);
+ photo_to_editable_filesize.set(photo, editable_filesize);
+ }
+
+ Gee.Collection<BackingPhotoRow>? raw_rows = photo.get_raw_development_photo_rows();
+ if (raw_rows != null) {
+ foreach (BackingPhotoRow row in raw_rows) {
+ if (row.filesize >= 0) {
+ filesize_to_photo.set(row.filesize, photo);
+ photo_to_raw_development_filesize.set(photo, row.filesize);
+ }
+ }
+ }
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed) {
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ File? editable = photo.get_editable_file();
+ if (editable != null) {
+ bool is_removed = by_editable_file.unset(photo.get_editable_file());
+ assert(is_removed);
+ }
+ photo.editable_replaced.disconnect(on_editable_replaced);
+
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null)
+ foreach (File f in raw_list)
+ by_raw_development_file.unset(f);
+ photo.raw_development_modified.disconnect(on_raw_development_modified);
+
+ int64 master_filesize = photo.get_master_photo_row().filesize;
+ int64 editable_filesize = photo.get_editable_photo_row() != null
+ ? photo.get_editable_photo_row().filesize
+ : -1;
+ filesize_to_photo.remove(master_filesize, photo);
+ photo_to_master_filesize.unset(photo);
+ if (editable_filesize >= 0) {
+ filesize_to_photo.remove(editable_filesize, photo);
+ photo_to_editable_filesize.unset(photo);
+ }
+
+ Gee.Collection<BackingPhotoRow>? raw_rows = photo.get_raw_development_photo_rows();
+ if (raw_rows != null) {
+ foreach (BackingPhotoRow row in raw_rows) {
+ if (row.filesize >= 0) {
+ filesize_to_photo.remove(row.filesize, photo);
+ photo_to_raw_development_filesize.remove(photo, row.filesize);
+ }
+ }
+ }
+ }
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ private void on_editable_replaced(Photo photo, File? old_file, File? new_file) {
+ if (old_file != null) {
+ bool is_removed = by_editable_file.unset(old_file);
+ assert(is_removed);
+ }
+
+ if (new_file != null)
+ by_editable_file.set(new_file, (LibraryPhoto) photo);
+ }
+
+ private void on_raw_development_modified(Photo _photo) {
+ LibraryPhoto? photo = _photo as LibraryPhoto;
+ if (photo == null)
+ return;
+
+ // Unset existing files.
+ if (photo_to_raw_development_filesize.contains(photo)) {
+ foreach (int64 s in photo_to_raw_development_filesize.get(photo))
+ filesize_to_photo.remove(s, photo);
+ photo_to_raw_development_filesize.remove_all(photo);
+ }
+
+ // Add new ones.
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null)
+ foreach (File f in raw_list)
+ by_raw_development_file.set(f, photo);
+
+ Gee.Collection<BackingPhotoRow>? raw_rows = photo.get_raw_development_photo_rows();
+ if (raw_rows != null) {
+ foreach (BackingPhotoRow row in raw_rows) {
+ if (row.filesize > 0) {
+ filesize_to_photo.set(row.filesize, photo);
+ photo_to_raw_development_filesize.set(photo, row.filesize);
+ }
+ }
+ }
+ }
+
+ protected override void items_altered(Gee.Map<DataObject, Alteration> items) {
+ foreach (DataObject object in items.keys) {
+ Alteration alteration = items.get(object);
+
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ if (alteration.has_detail("image", "master") || alteration.has_detail("image", "editable")) {
+ int64 old_master_filesize = photo_to_master_filesize.get(photo);
+ int64 old_editable_filesize = photo_to_editable_filesize.has_key(photo)
+ ? photo_to_editable_filesize.get(photo)
+ : -1;
+
+ photo_to_master_filesize.unset(photo);
+ filesize_to_photo.remove(old_master_filesize, photo);
+ if (old_editable_filesize >= 0) {
+ photo_to_editable_filesize.unset(photo);
+ filesize_to_photo.remove(old_editable_filesize, photo);
+ }
+
+ int64 master_filesize = photo.get_master_photo_row().filesize;
+ int64 editable_filesize = photo.get_editable_photo_row() != null
+ ? photo.get_editable_photo_row().filesize
+ : -1;
+ photo_to_master_filesize.set(photo, master_filesize);
+ filesize_to_photo.set(master_filesize, photo);
+ if (editable_filesize >= 0) {
+ photo_to_editable_filesize.set(photo, editable_filesize);
+ filesize_to_photo.set(editable_filesize, photo);
+ }
+ }
+ }
+
+ base.items_altered(items);
+ }
+
+ // This method adds the photos to the Tags (keywords) that were discovered during import.
+ public override void postprocess_imported_media(Gee.Collection<MediaSource> media_sources) {
+ Gee.HashMultiMap<Tag, LibraryPhoto> map = new Gee.HashMultiMap<Tag, LibraryPhoto>();
+ foreach (MediaSource media in media_sources) {
+ LibraryPhoto photo = (LibraryPhoto) media;
+ PhotoMetadata metadata = photo.get_metadata();
+
+ // get an index of all the htags in the application
+ HierarchicalTagIndex global_index = HierarchicalTagIndex.get_global_index();
+
+ // if any hierarchical tag information is available, process it first. hierarchical tag
+ // information must be processed first to avoid tag duplication, since most photo
+ // management applications that support hierarchical tags also "flatten" the
+ // hierarchical tag information as plain old tags. If a tag name appears as part of
+ // a hierarchical path, it needs to be excluded from being processed as a flat tag
+ HierarchicalTagIndex? htag_index = null;
+ if (metadata.has_hierarchical_keywords()) {
+ htag_index = HierarchicalTagUtilities.process_hierarchical_import_keywords(
+ metadata.get_hierarchical_keywords());
+ }
+
+ if (photo.get_import_keywords() != null) {
+ foreach (string keyword in photo.get_import_keywords()) {
+ if (htag_index != null && htag_index.is_tag_in_index(keyword))
+ continue;
+
+ string? name = Tag.prep_tag_name(keyword);
+
+ if (global_index.is_tag_in_index(name)) {
+ string most_derived_path = global_index.get_path_for_name(name);
+ map.set(Tag.for_path(most_derived_path), photo);
+ continue;
+ }
+
+ if (name != null)
+ map.set(Tag.for_path(name), photo);
+ }
+ }
+
+ if (metadata.has_hierarchical_keywords()) {
+ foreach (string path in htag_index.get_all_paths()) {
+ string? name = Tag.prep_tag_name(path);
+ if (name != null)
+ map.set(Tag.for_path(name), photo);
+ }
+ }
+ }
+
+ foreach (MediaSource media in media_sources) {
+ LibraryPhoto photo = (LibraryPhoto) media;
+ photo.clear_import_keywords();
+ }
+
+ foreach (Tag tag in map.get_keys())
+ tag.attach_many(map.get(tag));
+
+ base.postprocess_imported_media(media_sources);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_master_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ master_reimported(photo, metadata);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_editable_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ editable_reimported(photo, metadata);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_source_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ source_reimported(photo, metadata);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_baseline_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ baseline_reimported(photo, metadata);
+ }
+
+ protected override MediaSource? fetch_by_numeric_id(int64 numeric_id) {
+ return fetch(PhotoID(numeric_id));
+ }
+
+ private void on_trashcan_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ trashcan_contents_altered((Gee.Collection<LibraryPhoto>?) added,
+ (Gee.Collection<LibraryPhoto>?) removed);
+ }
+
+ private bool check_if_trashed_photo(DataSource source, Alteration alteration) {
+ return ((LibraryPhoto) source).is_trashed();
+ }
+
+ private void on_offline_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ offline_contents_altered((Gee.Collection<LibraryPhoto>?) added,
+ (Gee.Collection<LibraryPhoto>?) removed);
+ }
+
+ private bool check_if_offline_photo(DataSource source, Alteration alteration) {
+ return ((LibraryPhoto) source).is_offline();
+ }
+
+ public override MediaSource? fetch_by_source_id(string source_id) {
+ assert(source_id.has_prefix(Photo.TYPENAME));
+ string numeric_only = source_id.substring(Photo.TYPENAME.length, -1);
+
+ return fetch_by_numeric_id(parse_int64(numeric_only, 16));
+ }
+
+ public override Gee.Collection<string> get_event_source_ids(EventID event_id){
+ return PhotoTable.get_instance().get_event_source_ids(event_id);
+ }
+
+ public LibraryPhoto fetch(PhotoID photo_id) {
+ return (LibraryPhoto) fetch_by_key(photo_id.id);
+ }
+
+ public LibraryPhoto? fetch_by_editable_file(File file) {
+ return by_editable_file.get(file);
+ }
+
+ public LibraryPhoto? fetch_by_raw_development_file(File file) {
+ return by_raw_development_file.get(file);
+ }
+
+ private void compare_backing(LibraryPhoto photo, FileInfo info,
+ Gee.Collection<LibraryPhoto> matches_master, Gee.Collection<LibraryPhoto> matches_editable,
+ Gee.Collection<LibraryPhoto> matches_development) {
+ if (photo.get_master_photo_row().matches_file_info(info))
+ matches_master.add(photo);
+
+ BackingPhotoRow? editable = photo.get_editable_photo_row();
+ if (editable != null && editable.matches_file_info(info))
+ matches_editable.add(photo);
+
+ Gee.Collection<BackingPhotoRow>? development = photo.get_raw_development_photo_rows();
+ if (development != null) {
+ foreach (BackingPhotoRow row in development) {
+ if (row.matches_file_info(info)) {
+ matches_development.add(photo);
+
+ break;
+ }
+ }
+ }
+ }
+
+ // Adds photos to both collections if their filesize and timestamp match. Note that it's possible
+ // for a single photo to be added to both collections.
+ public void fetch_by_matching_backing(FileInfo info, Gee.Collection<LibraryPhoto> matches_master,
+ Gee.Collection<LibraryPhoto> matches_editable, Gee.Collection<LibraryPhoto> matched_development) {
+ foreach (LibraryPhoto photo in filesize_to_photo.get(info.get_size()))
+ compare_backing(photo, info, matches_master, matches_editable, matched_development);
+
+ foreach (MediaSource media in get_offline_bin_contents())
+ compare_backing((LibraryPhoto) media, info, matches_master, matches_editable, matched_development);
+ }
+
+ public PhotoID get_basename_filesize_duplicate(string basename, int64 filesize) {
+ foreach (LibraryPhoto photo in filesize_to_photo.get(filesize)) {
+ if (utf8_ci_compare(photo.get_master_file().get_basename(), basename) == 0)
+ return photo.get_photo_id();
+ }
+
+ return PhotoID(); // default constructor for PhotoIDs will create an invalid ID --
+ // this is just the behavior that we want
+ }
+
+ public bool has_basename_filesize_duplicate(string basename, int64 filesize) {
+ return get_basename_filesize_duplicate(basename, filesize).is_valid();
+ }
+
+ public LibraryPhoto? get_trashed_by_file(File file) {
+ LibraryPhoto? photo = (LibraryPhoto?) get_trashcan().fetch_by_master_file(file);
+ if (photo == null)
+ photo = (LibraryPhoto?) ((LibraryPhotoSourceHoldingTank) get_trashcan()).
+ fetch_by_backing_file(file);
+
+ return photo;
+ }
+
+ public LibraryPhoto? get_trashed_by_md5(string md5) {
+ return (LibraryPhoto?) get_trashcan().fetch_by_md5(md5);
+ }
+
+ public LibraryPhoto? get_offline_by_file(File file) {
+ LibraryPhoto? photo = (LibraryPhoto?) get_offline_bin().fetch_by_master_file(file);
+ if (photo == null)
+ photo = (LibraryPhoto?) ((LibraryPhotoSourceHoldingTank) get_offline_bin()).
+ fetch_by_backing_file(file);
+
+ return photo;
+ }
+
+ public LibraryPhoto? get_offline_by_md5(string md5) {
+ return (LibraryPhoto?) get_offline_bin().fetch_by_md5(md5);
+ }
+
+ public int get_offline_count() {
+ return get_offline_bin().get_count();
+ }
+
+ public LibraryPhoto? get_state_by_file(File file, out State state) {
+ LibraryPhoto? photo = (LibraryPhoto?) fetch_by_master_file(file);
+ if (photo != null) {
+ state = State.ONLINE;
+
+ return photo;
+ }
+
+ photo = fetch_by_editable_file(file);
+ if (photo != null) {
+ state = State.EDITABLE;
+
+ return photo;
+ }
+
+ photo = fetch_by_raw_development_file(file);
+ if (photo != null) {
+ state = State.DEVELOPER;
+
+ return photo;
+ }
+
+ photo = get_trashed_by_file(file) as LibraryPhoto;
+ if (photo != null) {
+ state = State.TRASH;
+
+ return photo;
+ }
+
+ photo = get_offline_by_file(file) as LibraryPhoto;
+ if (photo != null) {
+ state = State.OFFLINE;
+
+ return photo;
+ }
+
+ state = State.UNKNOWN;
+
+ return null;
+ }
+
+ public override bool has_backlink(SourceBacklink backlink) {
+ if (base.has_backlink(backlink))
+ return true;
+
+ if (get_trashcan().has_backlink(backlink))
+ return true;
+
+ if (get_offline_bin().has_backlink(backlink))
+ return true;
+
+ return false;
+ }
+
+ public override void remove_backlink(SourceBacklink backlink) {
+ get_trashcan().remove_backlink(backlink);
+ get_offline_bin().remove_backlink(backlink);
+
+ base.remove_backlink(backlink);
+ }
+}
+
+//
+// LibraryPhoto
+//
+
+public class LibraryPhoto : Photo, Flaggable, Monitorable {
+ // Top 16 bits are reserved for Photo
+ // Warning: FLAG_HIDDEN and FLAG_FAVORITE have been deprecated for ratings and rating filters.
+ private const uint64 FLAG_HIDDEN = 0x0000000000000001;
+ private const uint64 FLAG_FAVORITE = 0x0000000000000002;
+ private const uint64 FLAG_TRASH = 0x0000000000000004;
+ private const uint64 FLAG_OFFLINE = 0x0000000000000008;
+ private const uint64 FLAG_FLAGGED = 0x0000000000000010;
+
+ public static LibraryPhotoSourceCollection global = null;
+
+ private bool block_thumbnail_generation = false;
+ private OneShotScheduler thumbnail_scheduler = null;
+ private Gee.Collection<string>? import_keywords;
+
+ private LibraryPhoto(PhotoRow row) {
+ base (row);
+
+ this.import_keywords = null;
+
+ thumbnail_scheduler = new OneShotScheduler("LibraryPhoto", generate_thumbnails);
+
+ // 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);
+
+ if ((row.flags & (FLAG_HIDDEN | FLAG_FAVORITE)) != 0)
+ upgrade_rating_flags(row.flags);
+ }
+
+ private LibraryPhoto.from_import_params(PhotoImportParams import_params) {
+ base (import_params.row);
+
+ this.import_keywords = import_params.keywords;
+ thumbnail_scheduler = new OneShotScheduler("LibraryPhoto", generate_thumbnails);
+
+ // if marked in a state where they're held in an orphanage, rehydrate their backlinks
+ if ((import_params.row.flags & (FLAG_TRASH | FLAG_OFFLINE)) != 0)
+ rehydrate_backlinks(global, import_params.row.backlinks);
+
+ if ((import_params.row.flags & (FLAG_HIDDEN | FLAG_FAVORITE)) != 0)
+ upgrade_rating_flags(import_params.row.flags);
+ }
+
+ public static void init(ProgressMonitor? monitor = null) {
+ global = new LibraryPhotoSourceCollection();
+
+ // prefetch all the photos from the database and add them to the global collection ...
+ // do in batches to take advantage of add_many()
+ Gee.ArrayList<PhotoRow?> all = PhotoTable.get_instance().get_all();
+ Gee.ArrayList<LibraryPhoto> all_photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<LibraryPhoto> trashed_photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<LibraryPhoto> offline_photos = new Gee.ArrayList<LibraryPhoto>();
+ int count = all.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ PhotoRow row = all.get(ctr);
+ LibraryPhoto photo = new LibraryPhoto(row);
+ uint64 flags = row.flags;
+
+ if ((flags & FLAG_TRASH) != 0)
+ trashed_photos.add(photo);
+ else if ((flags & FLAG_OFFLINE) != 0)
+ offline_photos.add(photo);
+ else
+ all_photos.add(photo);
+
+ if (monitor != null)
+ monitor(ctr, count);
+ }
+
+ global.add_many(all_photos);
+ global.add_many_to_trash(trashed_photos);
+ global.add_many_to_offline(offline_photos);
+ }
+
+ public static void terminate() {
+ }
+
+ // This accepts a PhotoRow that was prepared with Photo.prepare_for_import and
+ // has not already been inserted in the database. See PhotoTable.add() for which fields are
+ // used and which are ignored. The PhotoRow itself will be modified with the remaining values
+ // as they are stored in the database.
+ public static ImportResult import_create(PhotoImportParams params, out LibraryPhoto photo) {
+ // add to the database
+ PhotoID photo_id = PhotoTable.get_instance().add(params.row);
+ if (photo_id.is_invalid()) {
+ photo = null;
+
+ return ImportResult.DATABASE_ERROR;
+ }
+
+ // create local object but don't add to global until thumbnails generated
+ photo = new LibraryPhoto.from_import_params(params);
+
+ return ImportResult.SUCCESS;
+ }
+
+ public static void import_failed(LibraryPhoto photo) {
+ try {
+ PhotoTable.get_instance().remove(photo.get_photo_id());
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+ }
+
+ protected override void notify_master_reimported(PhotoMetadata? metadata) {
+ base.notify_master_reimported(metadata);
+
+ global.notify_master_reimported(this, metadata);
+ }
+
+ protected override void notify_editable_reimported(PhotoMetadata? metadata) {
+ base.notify_editable_reimported(metadata);
+
+ global.notify_editable_reimported(this, metadata);
+ }
+
+ protected override void notify_source_reimported(PhotoMetadata? metadata) {
+ base.notify_source_reimported(metadata);
+
+ global.notify_source_reimported(this, metadata);
+ }
+
+ protected override void notify_baseline_reimported(PhotoMetadata? metadata) {
+ base.notify_baseline_reimported(metadata);
+
+ global.notify_baseline_reimported(this, metadata);
+ }
+
+ private void generate_thumbnails() {
+ try {
+ ThumbnailCache.import_from_source(this, true);
+ } catch (Error err) {
+ warning("Unable to generate thumbnails for %s: %s", to_string(), err.message);
+ }
+
+ // fire signal that thumbnails have changed
+ notify_thumbnail_altered();
+ }
+
+ // These keywords are only used during import and should not be relied upon elsewhere.
+ public Gee.Collection<string>? get_import_keywords() {
+ return import_keywords;
+ }
+
+ public void clear_import_keywords() {
+ import_keywords = null;
+ }
+
+ public override void notify_altered(Alteration alteration) {
+ // generate new thumbnails in the background
+ if (!block_thumbnail_generation && alteration.has_subject("image"))
+ thumbnail_scheduler.at_priority_idle(Priority.LOW);
+
+ base.notify_altered(alteration);
+ }
+
+ 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.BILINEAR, true);
+ }
+
+ public override void rotate(Rotation rotation) {
+ // block thumbnail generation for this operation; taken care of below
+ block_thumbnail_generation = true;
+ base.rotate(rotation);
+ block_thumbnail_generation = false;
+
+ // because rotations are (a) common and available everywhere in the app, (b) the user expects
+ // a level of responsiveness not necessarily required by other modifications, (c) can be
+ // performed on multiple images simultaneously, and (d) can't cache a lot of full-sized
+ // pixbufs for rotate-and-scale ops, perform the rotation directly on the already-modified
+ // thumbnails.
+ try {
+ ThumbnailCache.rotate(this, rotation);
+ } catch (Error err) {
+ // TODO: Mark thumbnails as dirty in database
+ warning("Unable to update thumbnails for %s: %s", to_string(), err.message);
+ }
+
+ notify_thumbnail_altered();
+ }
+
+ // Returns unscaled thumbnail with all modifications applied applicable to the scale
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return ThumbnailCache.fetch(this, scale);
+ }
+
+ // Duplicates a backing photo row, returning the ID.
+ // An invalid ID will be returned if the backing photo row is not set or is invalid.
+ private BackingPhotoID duplicate_backing_photo(BackingPhotoRow? backing) throws Error {
+ BackingPhotoID backing_id = BackingPhotoID();
+ if (backing == null || backing.filepath == null)
+ return backing_id; // empty, invalid ID
+
+ File file = File.new_for_path(backing.filepath);
+ if (file.query_exists()) {
+ File dupe_file = LibraryFiles.duplicate(file, on_duplicate_progress, true);
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow? state = query_backing_photo_row(dupe_file, PhotoFileSniffer.Options.NO_MD5,
+ out detected);
+ if (state != null) {
+ BackingPhotoTable.get_instance().add(state);
+ backing_id = state.id;
+ }
+ }
+
+ return backing_id;
+ }
+
+ public LibraryPhoto duplicate() throws Error {
+ // clone the master file
+ File dupe_file = LibraryFiles.duplicate(get_master_file(), on_duplicate_progress, true);
+
+ // Duplicate editable and raw developments (if they exist)
+ BackingPhotoID dupe_editable_id = duplicate_backing_photo(get_editable_photo_row());
+ BackingPhotoID dupe_raw_shotwell_id = duplicate_backing_photo(
+ get_raw_development_photo_row(RawDeveloper.SHOTWELL));
+ BackingPhotoID dupe_raw_camera_id = duplicate_backing_photo(
+ get_raw_development_photo_row(RawDeveloper.CAMERA));
+ BackingPhotoID dupe_raw_embedded_id = duplicate_backing_photo(
+ get_raw_development_photo_row(RawDeveloper.EMBEDDED));
+
+ // clone the row in the database for these new backing files
+ PhotoID dupe_id = PhotoTable.get_instance().duplicate(get_photo_id(), dupe_file.get_path(),
+ dupe_editable_id, dupe_raw_shotwell_id, dupe_raw_camera_id, dupe_raw_embedded_id);
+ PhotoRow dupe_row = PhotoTable.get_instance().get_row(dupe_id);
+
+ // build the DataSource for the duplicate
+ LibraryPhoto dupe = new LibraryPhoto(dupe_row);
+
+ // clone thumbnails
+ ThumbnailCache.duplicate(this, dupe);
+
+ // add it to the SourceCollection; this notifies everyone interested of its presence
+ global.add(dupe);
+
+ // if it is not in "No Event" attach to event
+ if (dupe.get_event() != null)
+ dupe.get_event().attach(dupe);
+
+ // attach tags
+ Gee.Collection<Tag>? tags = Tag.global.fetch_for_source(this);
+ if (tags != null) {
+ foreach (Tag tag in tags) {
+ tag.attach(dupe);
+ }
+ }
+
+ return dupe;
+ }
+
+ private void on_duplicate_progress(int64 current, int64 total) {
+ spin_event_loop();
+ }
+
+ private void upgrade_rating_flags(uint64 flags) {
+ if ((flags & FLAG_HIDDEN) != 0) {
+ set_rating(Rating.REJECTED);
+ remove_flags(FLAG_HIDDEN);
+ }
+
+ if ((flags & FLAG_FAVORITE) != 0) {
+ set_rating(Rating.FIVE);
+ remove_flags(FLAG_FAVORITE);
+ }
+ }
+
+ // Blotto even!
+ public override bool is_trashed() {
+ return is_flag_set(FLAG_TRASH);
+ }
+
+ public override void trash() {
+ add_flags(FLAG_TRASH);
+ }
+
+ public override void untrash() {
+ remove_flags(FLAG_TRASH);
+ }
+
+ public override bool is_offline() {
+ return is_flag_set(FLAG_OFFLINE);
+ }
+
+ public override void mark_offline() {
+ add_flags(FLAG_OFFLINE);
+ }
+
+ public override void mark_online() {
+ remove_flags(FLAG_OFFLINE);
+ }
+
+ 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 bool internal_delete_backing() throws Error {
+ // allow the base classes to work first because delete_original_file() will attempt to
+ // remove empty directories as well
+ if (!base.internal_delete_backing())
+ return false;
+
+ return delete_original_file();
+ }
+
+ public override void destroy() {
+ PhotoID photo_id = get_photo_id();
+
+ // remove all cached thumbnails
+ ThumbnailCache.remove(this);
+
+ // remove from photo table -- should be wiped from storage now (other classes may have added
+ // photo_id to other parts of the database ... it's their responsibility to remove them
+ // when removed() is called)
+ try {
+ PhotoTable.get_instance().remove(photo_id);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ base.destroy();
+ }
+
+ public static bool has_nontrash_duplicate(File? file, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format) {
+ return get_nontrash_duplicate(file, thumbnail_md5, full_md5, file_format).is_valid();
+ }
+
+ public static PhotoID get_nontrash_duplicate(File? file, string? thumbnail_md5,
+ string? full_md5, PhotoFileFormat file_format) {
+ PhotoID[]? ids = get_duplicate_ids(file, thumbnail_md5, full_md5, file_format);
+
+ if (ids == null || ids.length == 0)
+ return PhotoID(); // return an invalid PhotoID
+
+ foreach (PhotoID id in ids) {
+ LibraryPhoto photo = LibraryPhoto.global.fetch(id);
+ if (photo != null && !photo.is_trashed())
+ return id;
+ }
+
+ return PhotoID();
+ }
+
+ protected override bool has_user_generated_metadata() {
+ Gee.List<Tag>? tags = Tag.global.fetch_for_source(this);
+
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata == null)
+ return tags != null || tags.size > 0 || get_rating() != Rating.UNRATED;
+
+ if (get_rating() != metadata.get_rating())
+ return true;
+
+ Gee.Set<string>? keywords = metadata.get_keywords();
+ int tags_count = (tags != null) ? tags.size : 0;
+ int keywords_count = (keywords != null) ? keywords.size : 0;
+
+ if (tags_count != keywords_count)
+ return true;
+
+ if (tags != null && keywords != null) {
+ foreach (Tag tag in tags) {
+ if (!keywords.contains(tag.get_name().normalize()))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected override void set_user_metadata_for_export(PhotoMetadata metadata) {
+ Gee.List<Tag>? photo_tags = Tag.global.fetch_for_source(this);
+ if(photo_tags != null) {
+ Gee.Collection<string> string_tags = new Gee.ArrayList<string>();
+ foreach (Tag tag in photo_tags) {
+ string_tags.add(tag.get_name());
+ }
+ metadata.set_keywords(string_tags);
+ } else
+ metadata.set_keywords(null);
+
+ metadata.set_rating(get_rating());
+ }
+
+ protected override void apply_user_metadata_for_reimport(PhotoMetadata metadata) {
+ HierarchicalTagIndex? new_htag_index = null;
+
+ if (metadata.has_hierarchical_keywords()) {
+ new_htag_index = HierarchicalTagUtilities.process_hierarchical_import_keywords(
+ metadata.get_hierarchical_keywords());
+ }
+
+ Gee.Collection<string>? keywords = metadata.get_keywords();
+ if (keywords != null) {
+ foreach (string keyword in keywords) {
+ if (new_htag_index != null && new_htag_index.is_tag_in_index(keyword))
+ continue;
+
+ string safe_keyword = HierarchicalTagUtilities.make_flat_tag_safe(keyword);
+ string promoted_keyword = HierarchicalTagUtilities.flat_to_hierarchical(
+ safe_keyword);
+
+ if (Tag.global.exists(safe_keyword)) {
+ Tag.for_path(safe_keyword).attach(this);
+ continue;
+ }
+
+ if (Tag.global.exists(promoted_keyword)) {
+ Tag.for_path(promoted_keyword).attach(this);
+ continue;
+ }
+
+ Tag.for_path(keyword).attach(this);
+ }
+ }
+
+ if (new_htag_index != null) {
+ foreach (string path in new_htag_index.get_all_paths())
+ Tag.for_path(path).attach(this);
+ }
+ }
+}
+
+// Used for trash and offline bin of LibraryPhotoSourceCollection
+public class LibraryPhotoSourceHoldingTank : MediaSourceHoldingTank {
+ private Gee.HashMap<File, LibraryPhoto> editable_file_map = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.HashMap<File, LibraryPhoto> development_file_map = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.MultiMap<LibraryPhoto, File> reverse_editable_file_map
+ = new Gee.HashMultiMap<LibraryPhoto, File>(null, null, file_hash, file_equal);
+ private Gee.MultiMap<LibraryPhoto, File> reverse_development_file_map
+ = new Gee.HashMultiMap<LibraryPhoto, File>(null, null, file_hash, file_equal);
+
+ public LibraryPhotoSourceHoldingTank(LibraryPhotoSourceCollection sources,
+ SourceHoldingTank.CheckToKeep check_to_keep, GetSourceDatabaseKey get_key) {
+ base (sources, check_to_keep, get_key);
+ }
+
+ public LibraryPhoto? fetch_by_backing_file(File file) {
+ LibraryPhoto? ret = null;
+ ret = editable_file_map.get(file);
+ if (ret != null)
+ return ret;
+
+ return development_file_map.get(file);
+ }
+
+ protected override void notify_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ if (added != null) {
+ foreach (DataSource source in added) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+
+ // Editable files.
+ if (photo.get_editable_file() != null) {
+ editable_file_map.set(photo.get_editable_file(), photo);
+ reverse_editable_file_map.set(photo, photo.get_editable_file());
+ }
+
+ // RAW developments.
+ Gee.Collection<File>? raw_files = photo.get_raw_developer_files();
+ if (raw_files != null) {
+ foreach (File f in raw_files) {
+ development_file_map.set(f, photo);
+ reverse_development_file_map.set(photo, f);
+ }
+ }
+
+ photo.editable_replaced.connect(on_editable_replaced);
+ photo.raw_development_modified.connect(on_raw_development_modified);
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataSource source in removed) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+ foreach (File f in reverse_editable_file_map.get(photo))
+ editable_file_map.unset(f);
+
+ foreach (File f in reverse_development_file_map.get(photo))
+ development_file_map.unset(f);
+
+ reverse_editable_file_map.remove_all(photo);
+ reverse_development_file_map.remove_all(photo);
+
+ photo.editable_replaced.disconnect(on_editable_replaced);
+ photo.raw_development_modified.disconnect(on_raw_development_modified);
+ }
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ private void on_editable_replaced(Photo _photo, File? old_file, File? new_file) {
+ LibraryPhoto? photo = _photo as LibraryPhoto;
+ assert(photo != null);
+
+ if (old_file != null) {
+ editable_file_map.unset(old_file);
+ reverse_editable_file_map.remove(photo, old_file);
+ }
+
+ if (new_file != null)
+ editable_file_map.set(new_file, photo);
+ reverse_editable_file_map.set(photo, new_file);
+ }
+
+ private void on_raw_development_modified(Photo _photo) {
+ LibraryPhoto? photo = _photo as LibraryPhoto;
+ assert(photo != null);
+
+ // Unset existing files.
+ if (reverse_development_file_map.contains(photo)) {
+ foreach (File f in reverse_development_file_map.get(photo))
+ development_file_map.unset(f);
+ reverse_development_file_map.remove_all(photo);
+ }
+
+ // Add new ones.
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null) {
+ foreach (File f in raw_list) {
+ development_file_map.set(f, photo);
+ reverse_development_file_map.set(photo, f);
+ }
+ }
+ }
+}
+
diff --git a/src/PhotoMonitor.vala b/src/PhotoMonitor.vala
new file mode 100644
index 0000000..d7f2929
--- /dev/null
+++ b/src/PhotoMonitor.vala
@@ -0,0 +1,1156 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+private class PhotoUpdates : MonitorableUpdates {
+ public LibraryPhoto photo;
+
+ public bool reimport_master = false;
+ public bool reimport_editable = false;
+ public bool reimport_raw_developments = false;
+ public File? editable_file = null;
+ public bool editable_file_info_altered = false;
+ public bool raw_developer_file_info_altered = false;
+ public FileInfo? editable_file_info = null;
+ public bool editable_in_alteration = false;
+ public bool raw_development_in_alteration = false;
+ public bool revert_to_master = false;
+ public Gee.Collection<File> developer_files = new Gee.ArrayList<File>();
+
+ public PhotoUpdates(LibraryPhoto photo) {
+ base (photo);
+
+ this.photo = photo;
+ }
+
+ public override void mark_offline() {
+ base.mark_offline();
+
+ reimport_master = false;
+ reimport_editable = false;
+ reimport_raw_developments = false;
+ }
+
+ public bool is_reimport_master() {
+ return reimport_master;
+ }
+
+ public bool is_reimport_editable() {
+ return reimport_editable;
+ }
+
+ public File? get_editable_file() {
+ return editable_file;
+ }
+
+ public FileInfo? get_editable_file_info() {
+ return editable_file_info;
+ }
+
+ public Gee.Collection<File> get_raw_developer_files() {
+ return developer_files;
+ }
+
+ public override bool is_in_alteration() {
+ return base.is_in_alteration() || editable_in_alteration;
+ }
+
+ public bool is_revert_to_master() {
+ return revert_to_master;
+ }
+
+ public virtual void set_editable_file(File? file) {
+ // if reverting, don't bother
+ if (file != null && revert_to_master)
+ return;
+
+ editable_file = file;
+ }
+
+ public virtual void set_editable_file_info(FileInfo? info) {
+ // if reverting, don't bother
+ if (info != null && revert_to_master)
+ return;
+
+ editable_file_info = info;
+ if (info == null)
+ editable_file_info_altered = false;
+ }
+
+ public virtual void set_editable_file_info_altered(bool altered) {
+ // if reverting, don't bother
+ if (altered && revert_to_master)
+ return;
+
+ editable_file_info_altered = altered;
+ }
+
+ public virtual void set_editable_in_alteration(bool in_alteration) {
+ editable_in_alteration = in_alteration;
+ }
+
+ public virtual void set_raw_development_in_alteration(bool in_alteration) {
+ raw_development_in_alteration = in_alteration;
+ }
+
+ public virtual void set_raw_developer_file_info_altered(bool altered) {
+ raw_developer_file_info_altered = altered;
+ }
+
+ public virtual void set_revert_to_master(bool revert) {
+ if (revert) {
+ // this means nothing any longer
+ reimport_editable = false;
+ editable_file = null;
+ editable_file_info = null;
+ }
+
+ revert_to_master = revert;
+ }
+
+ public virtual void add_raw_developer_file(File file) {
+ developer_files.add(file);
+ }
+
+ public virtual void clear_raw_developer_files() {
+ developer_files.clear();
+ }
+
+ public virtual void set_reimport_master(bool reimport) {
+ reimport_master = reimport;
+
+ if (reimport)
+ mark_online();
+ }
+
+ public virtual void set_reimport_editable(bool reimport) {
+ // if reverting or going offline, don't bother
+ if (reimport && (revert_to_master || is_set_offline()))
+ return;
+
+ reimport_editable = reimport;
+ }
+
+ public virtual void set_reimport_raw_developments(bool reimport) {
+ reimport_raw_developments = reimport;
+
+ if (reimport)
+ mark_online();
+ }
+
+ public override bool is_all_updated() {
+ return base.is_all_updated()
+ && reimport_master == false
+ && reimport_editable == false
+ && editable_file == null
+ && editable_file_info_altered == false
+ && editable_file_info == null
+ && editable_in_alteration == false
+ && developer_files.size == 0
+ && raw_developer_file_info_altered == false
+ && revert_to_master == false;
+ }
+}
+
+private class PhotoMonitor : MediaMonitor {
+ private const int MAX_REIMPORT_JOBS_PER_CYCLE = 20;
+ private const int MAX_REVERTS_PER_CYCLE = 5;
+
+ private class ReimportMasterJob : BackgroundJob {
+ public LibraryPhoto photo;
+ public Photo.ReimportMasterState reimport_state = null;
+ public bool mark_online = false;
+ public Error err = null;
+
+ public ReimportMasterJob(PhotoMonitor owner, LibraryPhoto photo) {
+ base (owner, owner.on_master_reimported, new Cancellable(),
+ owner.on_master_reimport_cancelled);
+
+ this.photo = photo;
+ }
+
+ public override void execute() {
+ try {
+ mark_online = photo.prepare_for_reimport_master(out reimport_state);
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+ }
+
+ private class ReimportEditableJob : BackgroundJob {
+ public LibraryPhoto photo;
+ public Photo.ReimportEditableState state = null;
+ public bool success = false;
+ public Error err = null;
+
+ public ReimportEditableJob(PhotoMonitor owner, LibraryPhoto photo) {
+ base (owner, owner.on_editable_reimported, new Cancellable(),
+ owner.on_editable_reimport_cancelled);
+
+ this.photo = photo;
+ }
+
+ public override void execute() {
+ try {
+ success = photo.prepare_for_reimport_editable(out state);
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+ }
+
+ private class ReimportRawDevelopmentJob : BackgroundJob {
+ public LibraryPhoto photo;
+ public Photo.ReimportRawDevelopmentState state = null;
+ public bool success = false;
+ public Error err = null;
+
+ public ReimportRawDevelopmentJob(PhotoMonitor owner, LibraryPhoto photo) {
+ base (owner, owner.on_raw_development_reimported, new Cancellable(),
+ owner.on_raw_development_reimport_cancelled);
+
+ this.photo = photo;
+ }
+
+ public override void execute() {
+ try {
+ success = photo.prepare_for_reimport_raw_development(out state);
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+ }
+
+ private Workers workers;
+ private Gee.ArrayList<LibraryPhoto> matched_editables = new Gee.ArrayList<LibraryPhoto>();
+ private Gee.ArrayList<LibraryPhoto> matched_developments = new Gee.ArrayList<LibraryPhoto>();
+ private Gee.HashMap<LibraryPhoto, ReimportMasterJob> master_reimport_pending = new Gee.HashMap<
+ LibraryPhoto, ReimportMasterJob>();
+ private Gee.HashMap<LibraryPhoto, ReimportEditableJob> editable_reimport_pending =
+ new Gee.HashMap<LibraryPhoto, ReimportEditableJob>();
+ private Gee.HashMap<LibraryPhoto, ReimportRawDevelopmentJob> raw_developments_reimport_pending =
+ new Gee.HashMap<LibraryPhoto, ReimportRawDevelopmentJob>();
+
+ public PhotoMonitor(Workers workers, Cancellable cancellable) {
+ base (LibraryPhoto.global, cancellable);
+
+ this.workers = workers;
+ }
+
+ protected override MonitorableUpdates create_updates(Monitorable monitorable) {
+ assert(monitorable is LibraryPhoto);
+
+ return new PhotoUpdates((LibraryPhoto) monitorable);
+ }
+
+ public override MediaSourceCollection get_media_source_collection() {
+ return LibraryPhoto.global;
+ }
+
+ public override bool is_file_represented(File file) {
+ LibraryPhotoSourceCollection.State state;
+ return get_photo_state_by_file(file, out state) != null;
+ }
+
+ public override void close() {
+ foreach (ReimportMasterJob job in master_reimport_pending.values)
+ job.cancel();
+
+ foreach (ReimportEditableJob job in editable_reimport_pending.values)
+ job.cancel();
+
+ foreach (ReimportRawDevelopmentJob job in raw_developments_reimport_pending.values)
+ job.cancel();
+
+ base.close();
+ }
+
+ private void cancel_reimports(LibraryPhoto photo) {
+ ReimportMasterJob? master_job = master_reimport_pending.get(photo);
+ if (master_job != null)
+ master_job.cancel();
+
+ ReimportEditableJob? editable_job = editable_reimport_pending.get(photo);
+ if (editable_job != null)
+ editable_job.cancel();
+ }
+
+ public override MediaMonitor.DiscoveredFile notify_file_discovered(File file, FileInfo info,
+ out Monitorable monitorable) {
+ LibraryPhotoSourceCollection.State state;
+ LibraryPhoto? photo = get_photo_state_by_file(file, out state);
+ if (photo == null) {
+ monitorable = null;
+
+ return MediaMonitor.DiscoveredFile.UNKNOWN;
+ }
+
+ switch (state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ monitorable = photo;
+
+ return MediaMonitor.DiscoveredFile.REPRESENTED;
+
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ default:
+ // ignored ... trash always stays in trash, offline or not, and editables are
+ // simply attached to online/offline photos
+ monitorable = null;
+
+ return MediaMonitor.DiscoveredFile.IGNORE;
+ }
+ }
+
+ public override Gee.Collection<Monitorable>? candidates_for_unknown_file(File file, FileInfo info,
+ out MediaMonitor.DiscoveredFile result) {
+ // reset with each call
+ matched_editables.clear();
+ matched_developments.clear();
+
+ Gee.Collection<LibraryPhoto> matched_masters = new Gee.ArrayList<LibraryPhoto>();
+ LibraryPhoto.global.fetch_by_matching_backing(info, matched_masters, matched_editables,
+ matched_developments);
+ if (matched_masters.size > 0) {
+ result = MediaMonitor.DiscoveredFile.UNKNOWN;
+
+ return matched_masters;
+ }
+
+ if (matched_editables.size == 0 && matched_developments.size == 0) {
+ result = MediaMonitor.DiscoveredFile.UNKNOWN;
+
+ return null;
+ }
+
+ // for editable files and raw developments, trust file characteristics alone
+ if (matched_editables.size > 0) {
+ LibraryPhoto match = matched_editables[0];
+ if (matched_editables.size > 1) {
+ warning("Unknown file %s could be matched with %d photos; giving to %s, dropping others",
+ file.get_path(), matched_editables.size, match.to_string());
+ for (int ctr = 1; ctr < matched_editables.size; ctr++) {
+ if (!matched_editables[ctr].does_editable_exist())
+ matched_editables[ctr].revert_to_master();
+ }
+ }
+
+ update_editable_file(match, file);
+ }
+
+ if (matched_developments.size > 0) {
+ LibraryPhoto match_raw = matched_developments[0];
+ if (matched_developments.size > 1) {
+ warning("Unknown file %s could be matched with %d photos; giving to %s, dropping others",
+ file.get_path(), matched_developments.size, match_raw.to_string());
+ }
+
+ update_raw_development_file(match_raw, file);
+ }
+
+ result = MediaMonitor.DiscoveredFile.IGNORE;
+
+ return null;
+ }
+
+ public override File[]? get_auxilliary_backing_files(Monitorable monitorable) {
+ LibraryPhoto photo = (LibraryPhoto) monitorable;
+ File[] files = new File[0];
+
+ // Editable.
+ if (photo.has_editable())
+ files += photo.get_editable_file();
+
+ // Raw developments.
+ Gee.Collection<File>? raw_files = photo.get_raw_developer_files();
+ if (raw_files != null)
+ foreach (File f in raw_files)
+ files += f;
+
+ // Return null if no files.
+ return files.length > 0 ? files : null;
+ }
+
+ public override void update_backing_file_info(Monitorable monitorable, File file, FileInfo? info) {
+ LibraryPhoto photo = (LibraryPhoto) monitorable;
+
+ if (get_master_file(photo).equal(file))
+ check_for_master_changes(photo, info);
+ else if (get_editable_file(photo) != null && get_editable_file(photo).equal(file))
+ check_for_editable_changes(photo, info);
+ else if (get_raw_development_files(photo) != null) {
+ foreach (File f in get_raw_development_files(photo)) {
+ if (f.equal(file))
+ check_for_raw_development_changes(photo, info);
+ }
+ }
+ }
+
+ public override void notify_discovery_completing() {
+ matched_editables.clear();
+ }
+
+ // If filesize has changed, treat that as a full-blown modification
+ // and reimport ... this is problematic if only the metadata has changed, but so be it.
+ //
+ // TODO: We could do an MD5 check for more accuracy.
+ private void check_for_master_changes(LibraryPhoto photo, FileInfo? info) {
+ // if not present, offline state is already taken care of by LibraryMonitor
+ if (info == null)
+ return;
+
+ BackingPhotoRow state = photo.get_master_photo_row();
+ if (state.matches_file_info(info))
+ return;
+
+ if (state.is_touched(info)) {
+ update_master_file_info_altered(photo);
+ update_master_file_alterations_completed(photo, info);
+ } else {
+ update_reimport_master(photo);
+ }
+ }
+
+ private void check_for_editable_changes(LibraryPhoto photo, FileInfo? info) {
+ if (info == null) {
+ update_revert_to_master(photo);
+
+ return;
+ }
+
+ // If state matches, done -- editables have no bearing on a photo's offline status.
+ BackingPhotoRow? state = photo.get_editable_photo_row();
+ if (state == null || state.matches_file_info(info))
+ return;
+
+ if (state.is_touched(info)) {
+ update_editable_file_info_altered(photo);
+ update_editable_file_alterations_completed(photo, info);
+ } else {
+ update_reimport_editable(photo);
+ }
+ }
+
+ private void check_for_raw_development_changes(LibraryPhoto photo, FileInfo? info) {
+ if (info == null) {
+ // Switch back to default for safety.
+ photo.set_raw_developer(RawDeveloper.SHOTWELL);
+
+ return;
+ }
+
+ Gee.Collection<BackingPhotoRow>? rows = photo.get_raw_development_photo_rows();
+ if (rows == null)
+ return;
+
+ // Look through all possible rows, if we find a file with a matching name or info,
+ // assume we found our man.
+ foreach (BackingPhotoRow row in rows) {
+ if (row.matches_file_info(info))
+ return;
+ if (info.get_name() == row.filepath) {
+ if (row.is_touched(info)) {
+ update_raw_development_file_info_altered(photo);
+ update_raw_development_file_alterations_completed(photo);
+ } else {
+ update_reimport_raw_developments(photo);
+ }
+
+ break;
+ }
+ }
+ }
+
+ public override bool notify_file_created(File file, FileInfo info) {
+ LibraryPhotoSourceCollection.State state;
+ LibraryPhoto? photo = get_photo_state_by_file(file, out state);
+ if (photo == null)
+ return false;
+
+ switch (state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ // do nothing, although this is unexpected
+ warning("File %s created in %s state", file.get_path(), state.to_string());
+ break;
+
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ mdbg("Will mark %s online".printf(photo.to_string()));
+ update_online(photo);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", state.to_string());
+ }
+
+ return true;
+ }
+
+ public override bool notify_file_moved(File old_file, File new_file, FileInfo info) {
+ LibraryPhotoSourceCollection.State old_state;
+ LibraryPhoto? old_photo = get_photo_state_by_file(old_file, out old_state);
+
+ LibraryPhotoSourceCollection.State new_state;
+ LibraryPhoto? new_photo = get_photo_state_by_file(new_file, out new_state);
+
+ // Four possibilities:
+ //
+ // 1. Moving an existing photo file to a location where no photo is represented
+ // Operation: have the Photo object move with the file.
+ // 2. Moving a file with no representative photo to a location where a photo is represented
+ // (i.e. is offline). Operation: Update the photo (backing has changed).
+ // 3. Moving a file with no representative photo to a location with no representative
+ // photo. Operation: Enqueue for import (if appropriate).
+ // 4. Move a file with a representative photo to a location where a photo is represented
+ // Operation: Mark the old photo as offline (or drop editable) and update new photo
+ // (the backing has changed).
+
+ if (old_photo != null && new_photo == null) {
+ // 1.
+ switch (old_state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ mdbg("Will set new master file for %s to %s".printf(old_photo.to_string(),
+ new_file.get_path()));
+ update_master_file(old_photo, new_file);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will set new editable file for %s to %s".printf(old_photo.to_string(),
+ new_file.get_path()));
+ update_editable_file(old_photo, new_file);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ mdbg("Will set new raw development file for %s to %s".printf(old_photo.to_string(),
+ new_file.get_path()));
+ update_raw_development_file(old_photo, new_file);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", old_state.to_string());
+ }
+ } else if (old_photo == null && new_photo != null) {
+ // 2.
+ switch (new_state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ mdbg("Will reimport master file for %s".printf(new_photo.to_string()));
+ update_reimport_master(new_photo);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will reimport editable file for %s".printf(new_photo.to_string()));
+ update_reimport_editable(new_photo);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ mdbg("Will reimport raw development file for %s".printf(new_photo.to_string()));
+ update_reimport_raw_developments(new_photo);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", new_state.to_string());
+ }
+ } else if (old_photo == null && new_photo == null) {
+ // 3.
+ return false;
+ } else {
+ assert(old_photo != null && new_photo != null);
+ // 4.
+ switch (old_state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ mdbg("Will mark offline %s".printf(old_photo.to_string()));
+ update_offline(old_photo);
+ break;
+
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ // do nothing
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will revert %s to master".printf(old_photo.to_string()));
+ update_revert_to_master(old_photo);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ // do nothing
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", old_state.to_string());
+ }
+
+ switch (new_state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ mdbg("Will reimport master file for %s".printf(new_photo.to_string()));
+ update_reimport_master(new_photo);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will reimport editable file for %s".printf(new_photo.to_string()));
+ update_reimport_editable(new_photo);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ mdbg("Will reimport raw development file for %s".printf(new_photo.to_string()));
+ update_reimport_raw_developments(new_photo);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", new_state.to_string());
+ }
+ }
+
+ return true;
+ }
+
+ public override bool notify_file_altered(File file) {
+ LibraryPhotoSourceCollection.State state;
+ LibraryPhoto? photo = get_photo_state_by_file(file, out state);
+ if (photo == null)
+ return false;
+
+ switch (state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ mdbg("Will reimport master for %s".printf(photo.to_string()));
+ update_reimport_master(photo);
+ update_master_file_in_alteration(photo, true);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will reimport editable for %s".printf(photo.to_string()));
+ update_reimport_editable(photo);
+ update_editable_file_in_alteration(photo, true);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ mdbg("Will reimport raw development for %s".printf(photo.to_string()));
+ update_reimport_raw_developments(photo);
+ update_raw_development_file_in_alteration(photo, true);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", state.to_string());
+ }
+
+ return true;
+ }
+
+ public override bool notify_file_attributes_altered(File file) {
+ LibraryPhotoSourceCollection.State state;
+ LibraryPhoto? photo = get_photo_state_by_file(file, out state);
+ if (photo == null)
+ return false;
+
+ switch (state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ mdbg("Will update master file info for %s".printf(photo.to_string()));
+ update_master_file_info_altered(photo);
+ update_master_file_in_alteration(photo, true);
+ break;
+
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ // do nothing, but unexpected
+ warning("File %s attributes altered in %s state", file.get_path(),
+ state.to_string());
+ update_master_file_in_alteration(photo, true);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will update editable file info for %s".printf(photo.to_string()));
+ update_editable_file_info_altered(photo);
+ update_editable_file_in_alteration(photo, true);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ mdbg("Will update raw development file info for %s".printf(photo.to_string()));
+ update_raw_development_file_info_altered(photo);
+ update_raw_development_file_in_alteration(photo, true);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", state.to_string());
+ }
+
+ return true;
+ }
+
+ public override bool notify_file_alteration_completed(File file, FileInfo info) {
+ LibraryPhotoSourceCollection.State state;
+ LibraryPhoto? photo = get_photo_state_by_file(file, out state);
+ if (photo == null)
+ return false;
+
+ switch (state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ update_master_file_alterations_completed(photo, info);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ update_editable_file_alterations_completed(photo, info);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ update_raw_development_file_alterations_completed(photo);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", state.to_string());
+ }
+
+ return true;
+ }
+
+ public override bool notify_file_deleted(File file) {
+ LibraryPhotoSourceCollection.State state;
+ LibraryPhoto? photo = get_photo_state_by_file(file, out state);
+ if (photo == null)
+ return false;
+
+ switch (state) {
+ case LibraryPhotoSourceCollection.State.ONLINE:
+ mdbg("Will mark %s offline".printf(photo.to_string()));
+ update_offline(photo);
+ update_master_file_in_alteration(photo, false);
+ break;
+
+ case LibraryPhotoSourceCollection.State.TRASH:
+ case LibraryPhotoSourceCollection.State.OFFLINE:
+ // do nothing / already knew this
+ update_master_file_in_alteration(photo, false);
+ break;
+
+ case LibraryPhotoSourceCollection.State.EDITABLE:
+ mdbg("Will revert %s to master".printf(photo.to_string()));
+ update_revert_to_master(photo);
+ update_editable_file_in_alteration(photo, false);
+ break;
+
+ case LibraryPhotoSourceCollection.State.DEVELOPER:
+ mdbg("Will revert %s to master".printf(photo.to_string()));
+ update_revert_to_master(photo);
+ update_editable_file_in_alteration(photo, false);
+ update_raw_development_file_in_alteration(photo, false);
+ break;
+
+ default:
+ error("Unknown LibraryPhoto collection state %s", state.to_string());
+ }
+
+ return true;
+ }
+
+ protected override void on_media_source_destroyed(DataSource source) {
+ base.on_media_source_destroyed(source);
+
+ cancel_reimports((LibraryPhoto) source);
+ }
+
+ private LibraryPhoto? get_photo_state_by_file(File file, out LibraryPhotoSourceCollection.State state) {
+ File? real_file = null;
+ if (has_pending_updates()) {
+ foreach (Monitorable monitorable in get_monitorables()) {
+ LibraryPhoto photo = (LibraryPhoto) monitorable;
+
+ PhotoUpdates? updates = get_existing_photo_updates(photo);
+ if (updates == null)
+ continue;
+
+ if (updates.get_master_file() != null && updates.get_master_file().equal(file)) {
+ real_file = photo.get_master_file();
+
+ break;
+ }
+
+ if (updates.get_editable_file() != null && updates.get_editable_file().equal(file)) {
+ real_file = photo.get_editable_file();
+
+ // if the photo's "real" editable file is null, then this file hasn't been
+ // associated with it (yet) so fake the call
+ if (real_file == null) {
+ state = LibraryPhotoSourceCollection.State.EDITABLE;
+
+ return photo;
+ }
+
+ break;
+ }
+
+ if (updates.get_raw_developer_files() != null) {
+ bool found = false;
+ foreach (File raw in updates.get_raw_developer_files()) {
+ if (raw.equal(file)) {
+ found = true;
+
+ break;
+ }
+ }
+
+ if (found) {
+ Gee.Collection<File>? developed = photo.get_raw_developer_files();
+ if (developed != null) {
+ foreach (File f in developed) {
+ if (f.equal(file)) {
+ real_file = f;
+ state = LibraryPhotoSourceCollection.State.DEVELOPER;
+
+ break;
+ }
+ }
+
+ }
+
+ break;
+ }
+ }
+ }
+ }
+
+ return LibraryPhoto.global.get_state_by_file(real_file ?? file, out state);
+ }
+
+ public PhotoUpdates fetch_photo_updates(LibraryPhoto photo) {
+ return (PhotoUpdates) fetch_updates(photo);
+ }
+
+ public PhotoUpdates? get_existing_photo_updates(LibraryPhoto photo) {
+ return get_existing_updates(photo) as PhotoUpdates;
+ }
+
+ public void update_reimport_master(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_reimport_master(true);
+
+ // cancel outstanding reimport
+ if (master_reimport_pending.has_key(photo))
+ master_reimport_pending.get(photo).cancel();
+ }
+
+ public void update_reimport_editable(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_reimport_editable(true);
+
+ // cancel outstanding reimport
+ if (editable_reimport_pending.has_key(photo))
+ editable_reimport_pending.get(photo).cancel();
+ }
+
+ public void update_reimport_raw_developments(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_reimport_raw_developments(true);
+
+ // cancel outstanding reimport
+ if (raw_developments_reimport_pending.has_key(photo))
+ raw_developments_reimport_pending.get(photo).cancel();
+ }
+
+ public File? get_editable_file(LibraryPhoto photo) {
+ PhotoUpdates? updates = get_existing_photo_updates(photo);
+
+ return (updates != null && updates.get_editable_file() != null) ? updates.get_editable_file()
+ : photo.get_editable_file();
+ }
+
+ public Gee.Collection<File>? get_raw_development_files(LibraryPhoto photo) {
+ PhotoUpdates? updates = get_existing_photo_updates(photo);
+
+ return (updates != null && updates.get_raw_developer_files() != null) ?
+ updates.get_raw_developer_files() : photo.get_raw_developer_files();
+ }
+
+ public void update_editable_file(LibraryPhoto photo, File file) {
+ fetch_photo_updates(photo).set_editable_file(file);
+ }
+
+ public void update_editable_file_info_altered(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_editable_file_info_altered(true);
+ }
+
+ public void update_raw_development_file(LibraryPhoto photo, File file) {
+ fetch_photo_updates(photo).add_raw_developer_file(file);
+ }
+
+ public void update_raw_development_file_info_altered(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_raw_developer_file_info_altered(true);
+ }
+
+ public void update_editable_file_in_alteration(LibraryPhoto photo, bool in_alteration) {
+ fetch_photo_updates(photo).set_editable_in_alteration(in_alteration);
+ }
+
+ public void update_editable_file_alterations_completed(LibraryPhoto photo, FileInfo info) {
+ fetch_photo_updates(photo).set_editable_file_info(info);
+ fetch_photo_updates(photo).set_editable_in_alteration(false);
+ }
+
+ public void update_raw_development_file_in_alteration(LibraryPhoto photo, bool in_alteration) {
+ fetch_photo_updates(photo).set_raw_development_in_alteration(in_alteration);
+ }
+
+ public void update_raw_development_file_alterations_completed(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_raw_development_in_alteration(false);
+ }
+
+ public void update_revert_to_master(LibraryPhoto photo) {
+ fetch_photo_updates(photo).set_revert_to_master(true);
+ }
+
+ protected override void process_updates(Gee.Collection<MonitorableUpdates> all_updates,
+ TransactionController controller, ref int op_count) throws Error {
+ base.process_updates(all_updates, controller, ref op_count);
+
+ Gee.Map<LibraryPhoto, File> set_editable_file = null;
+ Gee.Map<LibraryPhoto, FileInfo> set_editable_file_info = null;
+ Gee.Map<LibraryPhoto, Gee.Collection<File>> set_raw_developer_files = null;
+ Gee.ArrayList<LibraryPhoto> revert_to_master = null;
+ Gee.ArrayList<LibraryPhoto> reimport_master = null;
+ Gee.ArrayList<LibraryPhoto> reimport_editable = null;
+ Gee.ArrayList<LibraryPhoto> reimport_raw_developments = null;
+ int reimport_job_count = 0;
+
+ foreach (MonitorableUpdates monitorable_updates in all_updates) {
+ if (op_count >= MAX_OPERATIONS_PER_CYCLE)
+ break;
+
+ PhotoUpdates? updates = monitorable_updates as PhotoUpdates;
+ if (updates == null)
+ continue;
+
+ if (updates.get_editable_file() != null) {
+ if (set_editable_file == null)
+ set_editable_file = new Gee.HashMap<LibraryPhoto, File>();
+
+ set_editable_file.set(updates.photo, updates.get_editable_file());
+ updates.set_editable_file(null);
+ op_count++;
+ }
+
+ if (updates.get_editable_file_info() != null) {
+ if (set_editable_file_info == null)
+ set_editable_file_info = new Gee.HashMap<LibraryPhoto, FileInfo>();
+
+ set_editable_file_info.set(updates.photo, updates.get_editable_file_info());
+ updates.set_editable_file_info(null);
+ op_count++;
+ }
+
+ if (updates.get_raw_developer_files() != null) {
+ if (set_raw_developer_files == null)
+ set_raw_developer_files = new Gee.HashMap<LibraryPhoto, Gee.Collection<File>>();
+
+ set_raw_developer_files.set(updates.photo, updates.get_raw_developer_files());
+ updates.clear_raw_developer_files();
+ op_count++;
+ }
+
+ if (updates.is_revert_to_master()) {
+ if (revert_to_master == null)
+ revert_to_master = new Gee.ArrayList<LibraryPhoto>();
+
+ if (revert_to_master.size < MAX_REVERTS_PER_CYCLE) {
+ revert_to_master.add(updates.photo);
+ updates.set_revert_to_master(false);
+ }
+ op_count++;
+ }
+
+ if (updates.is_reimport_master() && reimport_job_count < MAX_REIMPORT_JOBS_PER_CYCLE) {
+ if (reimport_master == null)
+ reimport_master = new Gee.ArrayList<LibraryPhoto>();
+
+ reimport_master.add(updates.photo);
+ updates.set_reimport_master(false);
+ reimport_job_count++;
+ op_count++;
+ }
+
+ if (updates.is_reimport_editable() && reimport_job_count < MAX_REIMPORT_JOBS_PER_CYCLE) {
+ if (reimport_editable == null)
+ reimport_editable = new Gee.ArrayList<LibraryPhoto>();
+
+ reimport_editable.add(updates.photo);
+ updates.set_reimport_editable(false);
+ reimport_job_count++;
+ op_count++;
+ }
+ }
+
+ if (set_editable_file != null) {
+ mdbg("Changing editable file of %d photos".printf(set_editable_file.size));
+
+ try {
+ Photo.set_many_editable_file(set_editable_file);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+ }
+
+ if (set_editable_file_info != null) {
+ mdbg("Updating %d editable files timestamps".printf(set_editable_file_info.size));
+
+ try {
+ Photo.update_many_editable_timestamps(set_editable_file_info);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+ }
+
+ if (revert_to_master != null) {
+ mdbg("Reverting %d photos to master".printf(revert_to_master.size));
+
+ foreach (LibraryPhoto photo in revert_to_master)
+ photo.revert_to_master();
+ }
+
+ //
+ // Now that the metadata has been updated, deal with imports and reimports
+ //
+
+ if (reimport_master != null) {
+ mdbg("Reimporting %d masters".printf(reimport_master.size));
+
+ foreach (LibraryPhoto photo in reimport_master) {
+ assert(!master_reimport_pending.has_key(photo));
+
+ ReimportMasterJob job = new ReimportMasterJob(this, photo);
+ master_reimport_pending.set(photo, job);
+ workers.enqueue(job);
+ }
+ }
+
+ if (reimport_editable != null) {
+ mdbg("Reimporting %d editables".printf(reimport_editable.size));
+
+ foreach (LibraryPhoto photo in reimport_editable) {
+ assert(!editable_reimport_pending.has_key(photo));
+
+ ReimportEditableJob job = new ReimportEditableJob(this, photo);
+ editable_reimport_pending.set(photo, job);
+ workers.enqueue(job);
+ }
+ }
+
+ if (reimport_raw_developments != null) {
+ mdbg("Reimporting %d raw developments".printf(reimport_raw_developments.size));
+
+ foreach (LibraryPhoto photo in reimport_raw_developments) {
+ assert(!raw_developments_reimport_pending.has_key(photo));
+
+ ReimportRawDevelopmentJob job = new ReimportRawDevelopmentJob(this, photo);
+ raw_developments_reimport_pending.set(photo, job);
+ workers.enqueue(job);
+ }
+ }
+ }
+
+ private void on_master_reimported(BackgroundJob j) {
+ ReimportMasterJob job = (ReimportMasterJob) j;
+
+ // no longer pending
+ bool removed = master_reimport_pending.unset(job.photo);
+ assert(removed);
+
+ if (job.err != null) {
+ critical("Unable to reimport %s due to master file changing: %s", job.photo.to_string(),
+ job.err.message);
+
+ update_offline(job.photo);
+
+ return;
+ }
+
+ if (!job.mark_online) {
+ // the prepare_for_reimport_master failed, photo is now considered offline
+ update_offline(job.photo);
+
+ return;
+ }
+
+ try {
+ job.photo.finish_reimport_master(job.reimport_state);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ // now considered online
+ if (job.photo.is_offline())
+ update_online(job.photo);
+
+ mdbg("Reimported master for %s".printf(job.photo.to_string()));
+ }
+
+ private void on_master_reimport_cancelled(BackgroundJob j) {
+ bool removed = master_reimport_pending.unset(((ReimportMasterJob) j).photo);
+ assert(removed);
+ }
+
+ private void on_editable_reimported(BackgroundJob j) {
+ ReimportEditableJob job = (ReimportEditableJob) j;
+
+ // no longer pending
+ bool removed = editable_reimport_pending.unset(job.photo);
+ assert(removed);
+
+ if (job.err != null) {
+ critical("Unable to reimport editable %s: %s", job.photo.to_string(), job.err.message);
+
+ return;
+ }
+
+ try {
+ job.photo.finish_reimport_editable(job.state);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ mdbg("Reimported editable for %s".printf(job.photo.to_string()));
+ }
+
+ private void on_editable_reimport_cancelled(BackgroundJob j) {
+ bool removed = editable_reimport_pending.unset(((ReimportEditableJob) j).photo);
+ assert(removed);
+ }
+
+ private void on_raw_development_reimported(BackgroundJob j) {
+ ReimportRawDevelopmentJob job = (ReimportRawDevelopmentJob) j;
+
+ // no longer pending
+ bool removed = raw_developments_reimport_pending.unset(job.photo);
+ assert(removed);
+
+ if (job.err != null) {
+ critical("Unable to reimport raw development %s: %s", job.photo.to_string(), job.err.message);
+
+ return;
+ }
+
+ try {
+ job.photo.finish_reimport_raw_development(job.state);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ mdbg("Reimported raw development for %s".printf(job.photo.to_string()));
+ }
+
+ private void on_raw_development_reimport_cancelled(BackgroundJob j) {
+ bool removed = raw_developments_reimport_pending.unset(((ReimportRawDevelopmentJob) j).photo);
+ assert(removed);
+ }
+}
+
diff --git a/src/PhotoPage.vala b/src/PhotoPage.vala
new file mode 100644
index 0000000..d74d004
--- /dev/null
+++ b/src/PhotoPage.vala
@@ -0,0 +1,3361 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class ZoomBuffer : Object {
+ private enum ObjectState {
+ SOURCE_NOT_LOADED,
+ SOURCE_LOAD_IN_PROGRESS,
+ SOURCE_NOT_TRANSFORMED,
+ TRANSFORMED_READY
+ }
+
+ private class IsoSourceFetchJob : BackgroundJob {
+ private Photo to_fetch;
+
+ public Gdk.Pixbuf? fetched = null;
+
+ public IsoSourceFetchJob(ZoomBuffer owner, Photo to_fetch,
+ CompletionCallback completion_callback) {
+ base(owner, completion_callback);
+
+ this.to_fetch = to_fetch;
+ }
+
+ public override void execute() {
+ try {
+ fetched = to_fetch.get_pixbuf_with_options(Scaling.for_original(),
+ Photo.Exception.ADJUST);
+ } catch (Error fetch_error) {
+ critical("IsoSourceFetchJob: execute( ): can't get pixbuf from backing photo");
+ }
+ }
+ }
+
+ // it's worth noting that there are two different kinds of transformation jobs (though this
+ // single class supports them both). There are "isomorphic" (or "iso") transformation jobs that
+ // operate over full-size pixbufs and are relatively long-running and then there are
+ // "demand" transformation jobs that occur over much smaller pixbufs as needed; these are
+ // relatively quick to run.
+ private class TransformationJob : BackgroundJob {
+ private Gdk.Pixbuf to_transform;
+ private PixelTransformer? transformer;
+ private Cancellable cancellable;
+
+ public Gdk.Pixbuf transformed = null;
+
+ public TransformationJob(ZoomBuffer owner, Gdk.Pixbuf to_transform, PixelTransformer?
+ transformer, CompletionCallback completion_callback, Cancellable cancellable) {
+ base(owner, completion_callback, cancellable);
+
+ this.cancellable = cancellable;
+ this.to_transform = to_transform;
+ this.transformer = transformer;
+ this.transformed = to_transform.copy();
+ }
+
+ public override void execute() {
+ if (transformer != null) {
+ transformer.transform_to_other_pixbuf(to_transform, transformed, cancellable);
+ }
+ }
+ }
+
+ private const int MEGAPIXEL = 1048576;
+ private const int USE_REDUCED_THRESHOLD = (int) 2.0 * MEGAPIXEL;
+
+ private Gdk.Pixbuf iso_source_image = null;
+ private Gdk.Pixbuf? reduced_source_image = null;
+ private Gdk.Pixbuf iso_transformed_image = null;
+ private Gdk.Pixbuf? reduced_transformed_image = null;
+ private Gdk.Pixbuf preview_image = null;
+ private Photo backing_photo = null;
+ private ObjectState object_state = ObjectState.SOURCE_NOT_LOADED;
+ private Gdk.Pixbuf? demand_transform_cached_pixbuf = null;
+ private ZoomState demand_transform_zoom_state;
+ private TransformationJob? demand_transform_job = null; // only 1 demand transform job can be
+ // active at a time
+ private Workers workers = null;
+ private SinglePhotoPage parent_page;
+ private bool is_interactive_redraw_in_progress = false;
+
+ public ZoomBuffer(SinglePhotoPage parent_page, Photo backing_photo,
+ Gdk.Pixbuf preview_image) {
+ this.parent_page = parent_page;
+ this.preview_image = preview_image;
+ this.backing_photo = backing_photo;
+ this.workers = new Workers(2, false);
+ }
+
+ private void on_iso_source_fetch_complete(BackgroundJob job) {
+ IsoSourceFetchJob fetch_job = (IsoSourceFetchJob) job;
+ if (fetch_job.fetched == null) {
+ critical("ZoomBuffer: iso_source_fetch_complete( ): fetch job has null image member");
+ return;
+ }
+
+ iso_source_image = fetch_job.fetched;
+ if ((iso_source_image.width * iso_source_image.height) > USE_REDUCED_THRESHOLD) {
+ reduced_source_image = iso_source_image.scale_simple(iso_source_image.width / 2,
+ iso_source_image.height / 2, Gdk.InterpType.BILINEAR);
+ }
+ object_state = ObjectState.SOURCE_NOT_TRANSFORMED;
+
+ if (!is_interactive_redraw_in_progress)
+ parent_page.repaint();
+
+ BackgroundJob transformation_job = new TransformationJob(this, iso_source_image,
+ backing_photo.get_pixel_transformer(), on_iso_transformation_complete,
+ new Cancellable());
+ workers.enqueue(transformation_job);
+ }
+
+ private void on_iso_transformation_complete(BackgroundJob job) {
+ TransformationJob transform_job = (TransformationJob) job;
+ if (transform_job.transformed == null) {
+ critical("ZoomBuffer: on_iso_transformation_complete( ): completed job has null " +
+ "image");
+ return;
+ }
+
+ iso_transformed_image = transform_job.transformed;
+ if ((iso_transformed_image.width * iso_transformed_image.height) > USE_REDUCED_THRESHOLD) {
+ reduced_transformed_image = iso_transformed_image.scale_simple(
+ iso_transformed_image.width / 2, iso_transformed_image.height / 2,
+ Gdk.InterpType.BILINEAR);
+ }
+ object_state = ObjectState.TRANSFORMED_READY;
+ }
+
+ private void on_demand_transform_complete(BackgroundJob job) {
+ TransformationJob transform_job = (TransformationJob) job;
+ if (transform_job.transformed == null) {
+ critical("ZoomBuffer: on_demand_transform_complete( ): completed job has null " +
+ "image");
+ return;
+ }
+
+ demand_transform_cached_pixbuf = transform_job.transformed;
+ demand_transform_job = null;
+
+ parent_page.repaint();
+ }
+
+ // passing a 'reduced_pixbuf' that has one-quarter the number of pixels as the 'iso_pixbuf' is
+ // optional, but including one can dramatically increase performance obtaining projection
+ // pixbufs at for ZoomStates with zoom factors less than 0.5
+ private Gdk.Pixbuf get_view_projection_pixbuf(ZoomState zoom_state, Gdk.Pixbuf iso_pixbuf,
+ Gdk.Pixbuf? reduced_pixbuf = null) {
+ Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
+ Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(
+ iso_pixbuf);
+ Gdk.Pixbuf sample_source_pixbuf = iso_pixbuf;
+
+ if ((reduced_pixbuf != null) && (zoom_state.get_zoom_factor() < 0.5)) {
+ sample_source_pixbuf = reduced_pixbuf;
+ view_rect_proj.x /= 2;
+ view_rect_proj.y /= 2;
+ view_rect_proj.width /= 2;
+ view_rect_proj.height /= 2;
+ }
+
+ // On very small images, it's possible for these to
+ // be 0, and GTK doesn't like sampling a region 0 px
+ // across.
+ view_rect_proj.width = view_rect_proj.width.clamp(1, int.MAX);
+ view_rect_proj.height = view_rect_proj.height.clamp(1, int.MAX);
+
+ view_rect.width = view_rect.width.clamp(1, int.MAX);
+ view_rect.height = view_rect.height.clamp(1, int.MAX);
+
+ Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(sample_source_pixbuf, view_rect_proj.x,
+ view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);
+
+ Gdk.Pixbuf zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
+ Gdk.InterpType.BILINEAR);
+
+ assert(zoomed != null);
+
+ return zoomed;
+ }
+
+ private Gdk.Pixbuf get_zoomed_image_source_not_transformed(ZoomState zoom_state) {
+ if (demand_transform_cached_pixbuf != null) {
+ if (zoom_state.equals(demand_transform_zoom_state)) {
+ // if a cached pixbuf from a previous on-demand transform operation exists and
+ // its zoom state is the same as the currently requested zoom state, then we
+ // don't need to do any work -- just return the cached copy
+ return demand_transform_cached_pixbuf;
+ } else if (zoom_state.get_zoom_factor() ==
+ demand_transform_zoom_state.get_zoom_factor()) {
+ // if a cached pixbuf from a previous on-demand transform operation exists and
+ // its zoom state is different from the currently requested zoom state, then we
+ // can't just use the cached pixbuf as-is. However, we might be able to use *some*
+ // of the information in the previously cached pixbuf. Specifically, if the zoom
+ // state of the previously cached pixbuf is merely a translation of the currently
+ // requested zoom state (the zoom states are not equal but the zoom factors are the
+ // same), then all that has happened is that the user has panned the viewing
+ // window. So keep all the pixels from the cached pixbuf that are still on-screen
+ // in the current view.
+ Gdk.Rectangle curr_rect = zoom_state.get_viewing_rectangle_wrt_content();
+ Gdk.Rectangle pre_rect =
+ demand_transform_zoom_state.get_viewing_rectangle_wrt_content();
+ Gdk.Rectangle transfer_src_rect = Gdk.Rectangle();
+ Gdk.Rectangle transfer_dest_rect = Gdk.Rectangle();
+
+ transfer_src_rect.x = (curr_rect.x - pre_rect.x).clamp(0, pre_rect.width);
+ transfer_src_rect.y = (curr_rect.y - pre_rect.y).clamp(0, pre_rect.height);
+ int transfer_src_right = ((curr_rect.x + curr_rect.width) - pre_rect.width).clamp(0,
+ pre_rect.width);
+ transfer_src_rect.width = transfer_src_right - transfer_src_rect.x;
+ int transfer_src_bottom = ((curr_rect.y + curr_rect.height) - pre_rect.width).clamp(
+ 0, pre_rect.height);
+ transfer_src_rect.height = transfer_src_bottom - transfer_src_rect.y;
+
+ transfer_dest_rect.x = (pre_rect.x - curr_rect.x).clamp(0, curr_rect.width);
+ transfer_dest_rect.y = (pre_rect.y - curr_rect.y).clamp(0, curr_rect.height);
+ int transfer_dest_right = (transfer_dest_rect.x + transfer_src_rect.width).clamp(0,
+ curr_rect.width);
+ transfer_dest_rect.width = transfer_dest_right - transfer_dest_rect.x;
+ int transfer_dest_bottom = (transfer_dest_rect.y + transfer_src_rect.height).clamp(0,
+ curr_rect.height);
+ transfer_dest_rect.height = transfer_dest_bottom - transfer_dest_rect.y;
+
+ Gdk.Pixbuf composited_result = get_zoom_preview_image_internal(zoom_state);
+ demand_transform_cached_pixbuf.copy_area (transfer_src_rect.x,
+ transfer_src_rect.y, transfer_dest_rect.width, transfer_dest_rect.height,
+ composited_result, transfer_dest_rect.x, transfer_dest_rect.y);
+
+ return composited_result;
+ }
+ }
+
+ // ok -- the cached pixbuf didn't help us -- so check if there is a demand
+ // transformation background job currently in progress. if such a job is in progress,
+ // then check if it's for the same zoom state as the one requested here. If the
+ // zoom states are the same, then just return the preview image for now -- we won't
+ // get a crisper one until the background job completes. If the zoom states are not the
+ // same however, then cancel the existing background job and initiate a new one for the
+ // currently requested zoom state.
+ if (demand_transform_job != null) {
+ if (zoom_state.equals(demand_transform_zoom_state)) {
+ return get_zoom_preview_image_internal(zoom_state);
+ } else {
+ demand_transform_job.cancel();
+ demand_transform_job = null;
+
+ Gdk.Pixbuf zoomed = get_view_projection_pixbuf(zoom_state, iso_source_image,
+ reduced_source_image);
+
+ demand_transform_job = new TransformationJob(this, zoomed,
+ backing_photo.get_pixel_transformer(), on_demand_transform_complete,
+ new Cancellable());
+ demand_transform_zoom_state = zoom_state;
+ workers.enqueue(demand_transform_job);
+
+ return get_zoom_preview_image_internal(zoom_state);
+ }
+ }
+
+ // if no on-demand background transform job is in progress at all, then start one
+ if (demand_transform_job == null) {
+ Gdk.Pixbuf zoomed = get_view_projection_pixbuf(zoom_state, iso_source_image,
+ reduced_source_image);
+
+ demand_transform_job = new TransformationJob(this, zoomed,
+ backing_photo.get_pixel_transformer(), on_demand_transform_complete,
+ new Cancellable());
+
+ demand_transform_zoom_state = zoom_state;
+
+ workers.enqueue(demand_transform_job);
+
+ return get_zoom_preview_image_internal(zoom_state);
+ }
+
+ // execution should never reach this point -- the various nested conditionals above should
+ // account for every possible case that can occur when the ZoomBuffer is in the
+ // SOURCE-NOT-TRANSFORMED state. So if execution does reach this point, print a critical
+ // warning to the console and just zoom using the preview image (the preview image, since
+ // it's managed by the SinglePhotoPage that created us, is assumed to be good).
+ critical("ZoomBuffer: get_zoomed_image( ): in SOURCE-NOT-TRANSFORMED but can't transform " +
+ "on-screen projection on-demand; using preview image");
+ return get_zoom_preview_image_internal(zoom_state);
+ }
+
+ public Gdk.Pixbuf get_zoom_preview_image_internal(ZoomState zoom_state) {
+ if (object_state == ObjectState.SOURCE_NOT_LOADED) {
+ BackgroundJob iso_source_fetch_job = new IsoSourceFetchJob(this, backing_photo,
+ on_iso_source_fetch_complete);
+ workers.enqueue(iso_source_fetch_job);
+
+ object_state = ObjectState.SOURCE_LOAD_IN_PROGRESS;
+ }
+ Gdk.Rectangle view_rect = zoom_state.get_viewing_rectangle_wrt_content();
+ Gdk.Rectangle view_rect_proj = zoom_state.get_viewing_rectangle_projection(
+ preview_image);
+
+ view_rect_proj.width = view_rect_proj.width.clamp(1, int.MAX);
+ view_rect_proj.height = view_rect_proj.height.clamp(1, int.MAX);
+
+ Gdk.Pixbuf proj_subpixbuf = new Gdk.Pixbuf.subpixbuf(preview_image,
+ view_rect_proj.x, view_rect_proj.y, view_rect_proj.width, view_rect_proj.height);
+
+ Gdk.Pixbuf zoomed = proj_subpixbuf.scale_simple(view_rect.width, view_rect.height,
+ Gdk.InterpType.BILINEAR);
+
+ return zoomed;
+ }
+
+ public Photo get_backing_photo() {
+ return backing_photo;
+ }
+
+ public void update_preview_image(Gdk.Pixbuf preview_image) {
+ this.preview_image = preview_image;
+ }
+
+ // invoke with no arguments or with null to merely flush the cache or alternatively pass in a
+ // single zoom state argument to re-seed the cache for that zoom state after it's been flushed
+ public void flush_demand_cache(ZoomState? initial_zoom_state = null) {
+ demand_transform_cached_pixbuf = null;
+ if (initial_zoom_state != null)
+ get_zoomed_image(initial_zoom_state);
+ }
+
+ public Gdk.Pixbuf get_zoomed_image(ZoomState zoom_state) {
+ is_interactive_redraw_in_progress = false;
+ // if request is for a zoomed image with an interpolation factor of zero (i.e., no zooming
+ // needs to be performed since the zoom slider is all the way to the left), then just
+ // return the zoom preview image
+ if (zoom_state.get_interpolation_factor() == 0.0) {
+ return get_zoom_preview_image_internal(zoom_state);
+ }
+
+ switch (object_state) {
+ case ObjectState.SOURCE_NOT_LOADED:
+ case ObjectState.SOURCE_LOAD_IN_PROGRESS:
+ return get_zoom_preview_image_internal(zoom_state);
+
+ case ObjectState.SOURCE_NOT_TRANSFORMED:
+ return get_zoomed_image_source_not_transformed(zoom_state);
+
+ case ObjectState.TRANSFORMED_READY:
+ // if an isomorphic, transformed pixbuf is ready, then just sample the projection of
+ // current viewing window from it and return that.
+ return get_view_projection_pixbuf(zoom_state, iso_transformed_image,
+ reduced_transformed_image);
+
+ default:
+ critical("ZoomBuffer: get_zoomed_image( ): object is an inconsistent state");
+ return get_zoom_preview_image_internal(zoom_state);
+ }
+ }
+
+ public Gdk.Pixbuf get_zoom_preview_image(ZoomState zoom_state) {
+ is_interactive_redraw_in_progress = true;
+
+ return get_zoom_preview_image_internal(zoom_state);
+ }
+}
+
+public abstract class EditingHostPage : SinglePhotoPage {
+ public const int TRINKET_SCALE = 20;
+ public const int TRINKET_PADDING = 1;
+
+ public const double ZOOM_INCREMENT_SIZE = 0.1;
+ public const int PAN_INCREMENT_SIZE = 64; /* in pixels */
+ public const int TOOL_WINDOW_SEPARATOR = 8;
+ public const int PIXBUF_CACHE_COUNT = 5;
+ public const int ORIGINAL_PIXBUF_CACHE_COUNT = 5;
+
+ private class EditingHostCanvas : EditingTools.PhotoCanvas {
+ private EditingHostPage host_page;
+
+ public EditingHostCanvas(EditingHostPage host_page) {
+ base(host_page.get_container(), host_page.canvas.get_window(), host_page.get_photo(),
+ host_page.get_cairo_context(), host_page.get_surface_dim(), host_page.get_scaled_pixbuf(),
+ host_page.get_scaled_pixbuf_position());
+
+ this.host_page = host_page;
+ }
+
+ public override void repaint() {
+ host_page.repaint();
+ }
+ }
+
+ private SourceCollection sources;
+ private ViewCollection? parent_view = null;
+ private Gdk.Pixbuf swapped = null;
+ private bool pixbuf_dirty = true;
+ private Gtk.ToolButton rotate_button = null;
+ private Gtk.ToggleToolButton crop_button = null;
+ private Gtk.ToggleToolButton redeye_button = null;
+ private Gtk.ToggleToolButton adjust_button = null;
+ private Gtk.ToggleToolButton straighten_button = null;
+ private Gtk.ToolButton enhance_button = null;
+ private Gtk.Scale zoom_slider = null;
+ private Gtk.ToolButton prev_button = new Gtk.ToolButton.from_stock(Gtk.Stock.GO_BACK);
+ private Gtk.ToolButton next_button = new Gtk.ToolButton.from_stock(Gtk.Stock.GO_FORWARD);
+ private EditingTools.EditingTool current_tool = null;
+ private Gtk.ToggleToolButton current_editing_toggle = null;
+ private Gdk.Pixbuf cancel_editing_pixbuf = null;
+ private bool photo_missing = false;
+ private PixbufCache cache = null;
+ private PixbufCache master_cache = null;
+ private DragAndDropHandler dnd_handler = null;
+ private bool enable_interactive_zoom_refresh = false;
+ private Gdk.Point zoom_pan_start_point;
+ private bool is_pan_in_progress = false;
+ private double saved_slider_val = 0.0;
+ private ZoomBuffer? zoom_buffer = null;
+ private Gee.HashMap<string, int> last_locations = new Gee.HashMap<string, int>();
+
+ public EditingHostPage(SourceCollection sources, string name) {
+ base(name, false);
+
+ this.sources = sources;
+
+ // when photo is altered need to update it here
+ sources.items_altered.connect(on_photos_altered);
+
+ // monitor when the ViewCollection's contents change
+ get_view().contents_altered.connect(on_view_contents_ordering_altered);
+ get_view().ordering_changed.connect(on_view_contents_ordering_altered);
+
+ // the viewport can change size independent of the window being resized (when the searchbar
+ // disappears, for example)
+ viewport.size_allocate.connect(on_viewport_resized);
+
+ // set up page's toolbar (used by AppWindow for layout and FullscreenWindow as a popup)
+ Gtk.Toolbar toolbar = get_toolbar();
+
+ // rotate tool
+ rotate_button = new Gtk.ToolButton.from_stock("");
+ rotate_button.set_icon_name(Resources.CLOCKWISE);
+ rotate_button.set_label(Resources.ROTATE_CW_LABEL);
+ rotate_button.set_tooltip_text(Resources.ROTATE_CW_TOOLTIP);
+ rotate_button.clicked.connect(on_rotate_clockwise);
+ rotate_button.is_important = true;
+ toolbar.insert(rotate_button, -1);
+
+ // crop tool
+ crop_button = new Gtk.ToggleToolButton.from_stock(Resources.CROP);
+ crop_button.set_label(Resources.CROP_LABEL);
+ crop_button.set_tooltip_text(Resources.CROP_TOOLTIP);
+ crop_button.toggled.connect(on_crop_toggled);
+ crop_button.is_important = true;
+ toolbar.insert(crop_button, -1);
+
+ // straightening tool
+ straighten_button = new Gtk.ToggleToolButton.from_stock(Resources.STRAIGHTEN);
+ straighten_button.set_label(Resources.STRAIGHTEN_LABEL);
+ straighten_button.set_tooltip_text(Resources.STRAIGHTEN_TOOLTIP);
+ straighten_button.toggled.connect(on_straighten_toggled);
+ straighten_button.is_important = true;
+ toolbar.insert(straighten_button, -1);
+
+ // redeye reduction tool
+ redeye_button = new Gtk.ToggleToolButton.from_stock(Resources.REDEYE);
+ redeye_button.set_label(Resources.RED_EYE_LABEL);
+ redeye_button.set_tooltip_text(Resources.RED_EYE_TOOLTIP);
+ redeye_button.toggled.connect(on_redeye_toggled);
+ redeye_button.is_important = true;
+ toolbar.insert(redeye_button, -1);
+
+ // adjust tool
+ adjust_button = new Gtk.ToggleToolButton.from_stock(Resources.ADJUST);
+ adjust_button.set_label(Resources.ADJUST_LABEL);
+ adjust_button.set_tooltip_text(Resources.ADJUST_TOOLTIP);
+ adjust_button.toggled.connect(on_adjust_toggled);
+ adjust_button.is_important = true;
+ toolbar.insert(adjust_button, -1);
+
+ // enhance tool
+ enhance_button = new Gtk.ToolButton.from_stock(Resources.ENHANCE);
+ enhance_button.set_label(Resources.ENHANCE_LABEL);
+ enhance_button.set_tooltip_text(Resources.ENHANCE_TOOLTIP);
+ enhance_button.clicked.connect(on_enhance);
+ enhance_button.is_important = true;
+ toolbar.insert(enhance_button, -1);
+
+ // separator to force next/prev buttons to right side of toolbar
+ Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
+ separator.set_expand(true);
+ separator.set_draw(false);
+ toolbar.insert(separator, -1);
+
+ Gtk.Box zoom_group = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+
+ Gtk.Image zoom_out = new Gtk.Image.from_pixbuf(Resources.load_icon(Resources.ICON_ZOOM_OUT,
+ Resources.ICON_ZOOM_SCALE));
+ Gtk.EventBox zoom_out_box = new Gtk.EventBox();
+ zoom_out_box.set_above_child(true);
+ zoom_out_box.set_visible_window(false);
+ zoom_out_box.add(zoom_out);
+
+ zoom_out_box.button_press_event.connect(on_zoom_out_pressed);
+
+ zoom_group.pack_start(zoom_out_box, false, false, 0);
+
+ // zoom slider
+ zoom_slider = new Gtk.Scale(Gtk.Orientation.HORIZONTAL, new Gtk.Adjustment(0.0, 0.0, 1.1, 0.1, 0.1, 0.1));
+ zoom_slider.set_draw_value(false);
+ zoom_slider.set_size_request(120, -1);
+ zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
+ zoom_slider.button_press_event.connect(on_zoom_slider_drag_begin);
+ zoom_slider.button_release_event.connect(on_zoom_slider_drag_end);
+ zoom_slider.key_press_event.connect(on_zoom_slider_key_press);
+
+ zoom_group.pack_start(zoom_slider, false, false, 0);
+
+ Gtk.Image zoom_in = new Gtk.Image.from_pixbuf(Resources.load_icon(Resources.ICON_ZOOM_IN,
+ Resources.ICON_ZOOM_SCALE));
+ Gtk.EventBox zoom_in_box = new Gtk.EventBox();
+ zoom_in_box.set_above_child(true);
+ zoom_in_box.set_visible_window(false);
+ zoom_in_box.add(zoom_in);
+
+ zoom_in_box.button_press_event.connect(on_zoom_in_pressed);
+
+ zoom_group.pack_start(zoom_in_box, false, false, 0);
+
+ Gtk.ToolItem group_wrapper = new Gtk.ToolItem();
+ group_wrapper.add(zoom_group);
+
+ toolbar.insert(group_wrapper, -1);
+
+ // previous button
+ prev_button.set_tooltip_text(_("Previous photo"));
+ prev_button.clicked.connect(on_previous_photo);
+ toolbar.insert(prev_button, -1);
+
+ // next button
+ next_button.set_tooltip_text(_("Next photo"));
+ next_button.clicked.connect(on_next_photo);
+ toolbar.insert(next_button, -1);
+ }
+
+ ~EditingHostPage() {
+ sources.items_altered.disconnect(on_photos_altered);
+
+ get_view().contents_altered.disconnect(on_view_contents_ordering_altered);
+ get_view().ordering_changed.disconnect(on_view_contents_ordering_altered);
+ }
+
+ private void on_zoom_slider_value_changed() {
+ ZoomState new_zoom_state = ZoomState.rescale(get_zoom_state(), zoom_slider.get_value());
+
+ if (enable_interactive_zoom_refresh) {
+ on_interactive_zoom(new_zoom_state);
+
+ if (new_zoom_state.is_default())
+ set_zoom_state(new_zoom_state);
+ } else {
+ if (new_zoom_state.is_default()) {
+ cancel_zoom();
+ } else {
+ set_zoom_state(new_zoom_state);
+ }
+ repaint();
+ }
+
+ update_cursor_for_zoom_context();
+ }
+
+ private bool on_zoom_slider_drag_begin(Gdk.EventButton event) {
+ enable_interactive_zoom_refresh = true;
+
+ if (get_container() is FullscreenWindow)
+ ((FullscreenWindow) get_container()).disable_toolbar_dismissal();
+
+ return false;
+ }
+
+ private bool on_zoom_slider_drag_end(Gdk.EventButton event) {
+ enable_interactive_zoom_refresh = false;
+
+ if (get_container() is FullscreenWindow)
+ ((FullscreenWindow) get_container()).update_toolbar_dismissal();
+
+ ZoomState zoom_state = ZoomState.rescale(get_zoom_state(), zoom_slider.get_value());
+ set_zoom_state(zoom_state);
+
+ repaint();
+
+ return false;
+ }
+
+ private bool on_zoom_out_pressed(Gdk.EventButton event) {
+ snap_zoom_to_min();
+ return true;
+ }
+
+ private bool on_zoom_in_pressed(Gdk.EventButton event) {
+ snap_zoom_to_max();
+ return true;
+ }
+
+ private Gdk.Point get_cursor_wrt_viewport(Gdk.EventScroll event) {
+ Gdk.Point cursor_wrt_canvas = {0};
+ cursor_wrt_canvas.x = (int) event.x;
+ cursor_wrt_canvas.y = (int) event.y;
+
+ Gdk.Rectangle viewport_wrt_canvas = get_zoom_state().get_viewing_rectangle_wrt_screen();
+ Gdk.Point result = {0};
+ result.x = cursor_wrt_canvas.x - viewport_wrt_canvas.x;
+ result.x = result.x.clamp(0, viewport_wrt_canvas.width);
+ result.y = cursor_wrt_canvas.y - viewport_wrt_canvas.y;
+ result.y = result.y.clamp(0, viewport_wrt_canvas.height);
+
+ return result;
+ }
+
+ private Gdk.Point get_cursor_wrt_viewport_center(Gdk.EventScroll event) {
+ Gdk.Point cursor_wrt_viewport = get_cursor_wrt_viewport(event);
+ Gdk.Rectangle viewport_wrt_canvas = get_zoom_state().get_viewing_rectangle_wrt_screen();
+
+ Gdk.Point viewport_center = {0};
+ viewport_center.x = viewport_wrt_canvas.width / 2;
+ viewport_center.y = viewport_wrt_canvas.height / 2;
+
+ return subtract_points(cursor_wrt_viewport, viewport_center);
+ }
+
+ private Gdk.Point get_iso_pixel_under_cursor(Gdk.EventScroll event) {
+ Gdk.Point viewport_center_iso = scale_point(get_zoom_state().get_viewport_center(),
+ 1.0 / get_zoom_state().get_zoom_factor());
+
+ Gdk.Point cursor_wrt_center_iso = scale_point(get_cursor_wrt_viewport_center(event),
+ 1.0 / get_zoom_state().get_zoom_factor());
+
+ return add_points(viewport_center_iso, cursor_wrt_center_iso);
+ }
+
+ private double snap_interpolation_factor(double interp) {
+ if (interp < 0.03)
+ interp = 0.0;
+ else if (interp > 0.97)
+ interp = 1.0;
+
+ return interp;
+ }
+
+ private double adjust_interpolation_factor(double adjustment) {
+ return snap_interpolation_factor(get_zoom_state().get_interpolation_factor() + adjustment);
+ }
+
+ private void zoom_about_event_cursor_point(Gdk.EventScroll event, double zoom_increment) {
+ if (photo_missing)
+ return;
+
+ Gdk.Point cursor_wrt_viewport_center = get_cursor_wrt_viewport_center(event);
+ Gdk.Point iso_pixel_under_cursor = get_iso_pixel_under_cursor(event);
+
+ double interp = adjust_interpolation_factor(zoom_increment);
+ zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
+ zoom_slider.set_value(interp);
+ zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
+
+ ZoomState new_zoom_state = ZoomState.rescale(get_zoom_state(), interp);
+
+ if (new_zoom_state.is_min()) {
+ cancel_zoom();
+ update_cursor_for_zoom_context();
+ repaint();
+ return;
+ }
+
+ Gdk.Point new_zoomed_old_cursor = scale_point(iso_pixel_under_cursor,
+ new_zoom_state.get_zoom_factor());
+ Gdk.Point desired_new_viewport_center = subtract_points(new_zoomed_old_cursor,
+ cursor_wrt_viewport_center);
+
+ new_zoom_state = ZoomState.pan(new_zoom_state, desired_new_viewport_center);
+
+ set_zoom_state(new_zoom_state);
+ repaint();
+
+ update_cursor_for_zoom_context();
+ }
+
+ protected void snap_zoom_to_min() {
+ zoom_slider.set_value(0.0);
+ }
+
+ protected void snap_zoom_to_max() {
+ zoom_slider.set_value(1.0);
+ }
+
+ protected void snap_zoom_to_isomorphic() {
+ ZoomState iso_state = ZoomState.rescale_to_isomorphic(get_zoom_state());
+ zoom_slider.set_value(iso_state.get_interpolation_factor());
+ }
+
+ protected virtual bool on_zoom_slider_key_press(Gdk.EventKey event) {
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "equal":
+ case "plus":
+ case "KP_Add":
+ activate_action("IncreaseSize");
+ return true;
+
+ case "minus":
+ case "underscore":
+ case "KP_Subtract":
+ activate_action("DecreaseSize");
+ return true;
+
+ case "KP_Divide":
+ activate_action("Zoom100");
+ return true;
+
+ case "KP_Multiply":
+ activate_action("ZoomFit");
+ return true;
+ }
+
+ return false;
+ }
+
+ protected virtual void on_increase_size() {
+ zoom_slider.set_value(adjust_interpolation_factor(ZOOM_INCREMENT_SIZE));
+ }
+
+ protected virtual void on_decrease_size() {
+ zoom_slider.set_value(adjust_interpolation_factor(-ZOOM_INCREMENT_SIZE));
+ }
+
+ protected override void save_zoom_state() {
+ base.save_zoom_state();
+ saved_slider_val = zoom_slider.get_value();
+ }
+
+ protected override ZoomBuffer? get_zoom_buffer() {
+ return zoom_buffer;
+ }
+
+ protected override bool on_mousewheel_up(Gdk.EventScroll event) {
+ if (get_zoom_state().is_max() || !zoom_slider.get_sensitive())
+ return false;
+
+ zoom_about_event_cursor_point(event, ZOOM_INCREMENT_SIZE);
+ return false;
+ }
+
+ protected override bool on_mousewheel_down(Gdk.EventScroll event) {
+ if (get_zoom_state().is_min() || !zoom_slider.get_sensitive())
+ return false;
+
+ zoom_about_event_cursor_point(event, -ZOOM_INCREMENT_SIZE);
+ return false;
+ }
+
+ protected override void restore_zoom_state() {
+ base.restore_zoom_state();
+
+ zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
+ zoom_slider.set_value(saved_slider_val);
+ zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
+ }
+
+ public override bool is_zoom_supported() {
+ return true;
+ }
+
+ public override void set_container(Gtk.Window container) {
+ base.set_container(container);
+
+ // DnD not available in fullscreen mode
+ if (!(container is FullscreenWindow))
+ dnd_handler = new DragAndDropHandler(this);
+ }
+
+ public ViewCollection? get_parent_view() {
+ return parent_view;
+ }
+
+ public bool has_photo() {
+ return get_photo() != null;
+ }
+
+ public Photo? get_photo() {
+ // If there is currently no selected photo, return null.
+ if (get_view().get_selected_count() == 0)
+ return null;
+
+ // Use the selected photo. There should only ever be one selected photo,
+ // which is the currently displayed photo.
+ assert(get_view().get_selected_count() == 1);
+ return (Photo) get_view().get_selected_at(0).get_source();
+ }
+
+ // Called before the photo changes.
+ protected virtual void photo_changing(Photo new_photo) {
+ // If this is a raw image with a missing development, we can regenerate it,
+ // so don't mark it as missing.
+ if (new_photo.get_file_format() == PhotoFileFormat.RAW)
+ set_photo_missing(false);
+ else
+ set_photo_missing(!new_photo.get_file().query_exists());
+
+ update_ui(photo_missing);
+ }
+
+ private void set_photo(Photo photo) {
+ zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
+ zoom_slider.set_value(0.0);
+ zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
+
+ photo_changing(photo);
+ DataView view = get_view().get_view_for_source(photo);
+ assert(view != null);
+
+ // Select photo.
+ get_view().unselect_all();
+ Marker marker = get_view().mark(view);
+ get_view().select_marked(marker);
+
+ // also select it in the parent view's collection, so when the user returns to that view
+ // it's apparent which one was being viewed here
+ if (parent_view != null) {
+ parent_view.unselect_all();
+ DataView? view_in_parent = parent_view.get_view_for_source_filtered(photo);
+ if (null != view_in_parent)
+ parent_view.select_marked(parent_view.mark(view_in_parent));
+ }
+ }
+
+ public override void realize() {
+ base.realize();
+
+ rebuild_caches("realize");
+ }
+
+ public override void switched_to() {
+ base.switched_to();
+
+ rebuild_caches("switched_to");
+
+ // check if the photo altered while away
+ if (has_photo() && pixbuf_dirty)
+ replace_photo(get_photo());
+ }
+
+ public override void switching_from() {
+ base.switching_from();
+
+ cancel_zoom();
+ is_pan_in_progress = false;
+
+ deactivate_tool();
+
+ // Ticket #3255 - Checkerboard page didn't `remember` what was selected
+ // when the user went into and out of the photo page without navigating
+ // to next or previous.
+ // Since the base class intentionally unselects everything in the parent
+ // view, reselect the currently marked photo here...
+ if ((has_photo()) && (parent_view != null)) {
+ parent_view.select_marked(parent_view.mark(parent_view.get_view_for_source(get_photo())));
+ }
+
+ parent_view = null;
+ get_view().clear();
+ }
+
+ public override void switching_to_fullscreen(FullscreenWindow fsw) {
+ base.switching_to_fullscreen(fsw);
+
+ deactivate_tool();
+
+ cancel_zoom();
+ is_pan_in_progress = false;
+
+ Page page = fsw.get_current_page();
+ if (page != null)
+ page.get_view().items_selected.connect(on_selection_changed);
+ }
+
+ public override void returning_from_fullscreen(FullscreenWindow fsw) {
+ base.returning_from_fullscreen(fsw);
+
+ repaint();
+
+ Page page = fsw.get_current_page();
+ if (page != null)
+ page.get_view().items_selected.disconnect(on_selection_changed);
+ }
+
+ private void on_selection_changed(Gee.Iterable<DataView> selected) {
+ foreach (DataView view in selected) {
+ replace_photo((Photo) view.get_source());
+ break;
+ }
+ }
+
+ protected void enable_rotate(bool should_enable) {
+ rotate_button.set_sensitive(should_enable);
+ }
+
+ // This function should be called if the viewport has changed and the pixbuf cache needs to be
+ // regenerated. Use refresh_caches() if the contents of the ViewCollection have changed
+ // but not the viewport.
+ private void rebuild_caches(string caller) {
+ Scaling scaling = get_canvas_scaling();
+
+ // only rebuild if not the same scaling
+ if (cache != null && cache.get_scaling().equals(scaling))
+ return;
+
+ debug("Rebuild pixbuf caches: %s (%s)", caller, scaling.to_string());
+
+ // if dropping an old cache, clear the signal handler so currently executing requests
+ // don't complete and cancel anything queued up
+ if (cache != null) {
+ cache.fetched.disconnect(on_pixbuf_fetched);
+ cache.cancel_all();
+ }
+
+ cache = new PixbufCache(sources, PixbufCache.PhotoType.BASELINE, scaling, PIXBUF_CACHE_COUNT);
+ cache.fetched.connect(on_pixbuf_fetched);
+
+ master_cache = new PixbufCache(sources, PixbufCache.PhotoType.MASTER, scaling,
+ ORIGINAL_PIXBUF_CACHE_COUNT, master_cache_filter);
+
+ refresh_caches(caller);
+ }
+
+ // See note at rebuild_caches() for usage.
+ private void refresh_caches(string caller) {
+ if (has_photo()) {
+ debug("Refresh pixbuf caches (%s): prefetching neighbors of %s", caller,
+ get_photo().to_string());
+ prefetch_neighbors(get_view(), get_photo());
+ } else {
+ debug("Refresh pixbuf caches (%s): (no photo)", caller);
+ }
+ }
+
+ private bool master_cache_filter(Photo photo) {
+ return photo.has_transformations() || photo.has_editable();
+ }
+
+ private void on_pixbuf_fetched(Photo photo, Gdk.Pixbuf? pixbuf, Error? err) {
+ // if not of the current photo, nothing more to do
+ if (!photo.equals(get_photo()))
+ return;
+
+ if (pixbuf != null) {
+ // update the preview image in the zoom buffer
+ if ((zoom_buffer != null) && (zoom_buffer.get_backing_photo() == photo))
+ zoom_buffer = new ZoomBuffer(this, photo, pixbuf);
+
+ // if no tool, use the pixbuf directly, otherwise, let the tool decide what should be
+ // displayed
+ Dimensions max_dim = photo.get_dimensions();
+ if (current_tool != null) {
+ try {
+ Dimensions tool_pixbuf_dim;
+ Gdk.Pixbuf? tool_pixbuf = current_tool.get_display_pixbuf(get_canvas_scaling(),
+ photo, out tool_pixbuf_dim);
+
+ if (tool_pixbuf != null) {
+ pixbuf = tool_pixbuf;
+ max_dim = tool_pixbuf_dim;
+ }
+ } catch(Error err) {
+ warning("Unable to fetch tool pixbuf for %s: %s", photo.to_string(), err.message);
+ set_photo_missing(true);
+
+ return;
+ }
+ }
+
+ set_pixbuf(pixbuf, max_dim);
+ pixbuf_dirty = false;
+
+ notify_photo_backing_missing((Photo) photo, false);
+ } else if (err != null) {
+ // this call merely updates the UI, and can be called indiscriminantly, whether or not
+ // the photo is actually missing
+ set_photo_missing(true);
+
+ // this call should only be used when we're sure the photo is missing
+ notify_photo_backing_missing((Photo) photo, true);
+ }
+ }
+
+ private void prefetch_neighbors(ViewCollection controller, Photo photo) {
+ PixbufCache.PixbufCacheBatch normal_batch = new PixbufCache.PixbufCacheBatch();
+ PixbufCache.PixbufCacheBatch master_batch = new PixbufCache.PixbufCacheBatch();
+
+ normal_batch.set(BackgroundJob.JobPriority.HIGHEST, photo);
+ master_batch.set(BackgroundJob.JobPriority.LOW, photo);
+
+ DataSource next_source, prev_source;
+ if (!controller.get_immediate_neighbors(photo, out next_source, out prev_source, Photo.TYPENAME))
+ return;
+
+ Photo next = (Photo) next_source;
+ Photo prev = (Photo) prev_source;
+
+ // prefetch the immediate neighbors and their outer neighbors, for plenty of readahead
+ foreach (DataSource neighbor_source in controller.get_extended_neighbors(photo, Photo.TYPENAME)) {
+ Photo neighbor = (Photo) neighbor_source;
+
+ BackgroundJob.JobPriority priority = BackgroundJob.JobPriority.NORMAL;
+ if (neighbor.equals(next) || neighbor.equals(prev))
+ priority = BackgroundJob.JobPriority.HIGH;
+
+ normal_batch.set(priority, neighbor);
+ master_batch.set(BackgroundJob.JobPriority.LOWEST, neighbor);
+ }
+
+ cache.prefetch_batch(normal_batch);
+ master_cache.prefetch_batch(master_batch);
+ }
+
+ // Cancels prefetches of old neighbors, but does not cancel them if they are the new
+ // neighbors
+ private void cancel_prefetch_neighbors(ViewCollection old_controller, Photo old_photo,
+ ViewCollection new_controller, Photo new_photo) {
+ Gee.Set<Photo> old_neighbors = (Gee.Set<Photo>)
+ old_controller.get_extended_neighbors(old_photo, Photo.TYPENAME);
+ Gee.Set<Photo> new_neighbors = (Gee.Set<Photo>)
+ new_controller.get_extended_neighbors(new_photo, Photo.TYPENAME);
+
+ foreach (Photo old_neighbor in old_neighbors) {
+ // cancel prefetch and drop from cache if old neighbor is not part of the new
+ // neighborhood
+ if (!new_neighbors.contains(old_neighbor) && !new_photo.equals(old_neighbor)) {
+ cache.drop(old_neighbor);
+ master_cache.drop(old_neighbor);
+ }
+ }
+
+ // do same for old photo
+ if (!new_neighbors.contains(old_photo) && !new_photo.equals(old_photo)) {
+ cache.drop(old_photo);
+ master_cache.drop(old_photo);
+ }
+ }
+
+ protected virtual DataView create_photo_view(DataSource source) {
+ return new PhotoView((PhotoSource) source);
+ }
+
+ private bool is_photo(DataSource source) {
+ return source is PhotoSource;
+ }
+
+ protected void display_copy_of(ViewCollection controller, Photo starting_photo) {
+ assert(controller.get_view_for_source(starting_photo) != null);
+
+ if (controller != get_view() && controller != parent_view) {
+ get_view().clear();
+ get_view().copy_into(controller, create_photo_view, is_photo);
+ parent_view = controller;
+ }
+
+ replace_photo(starting_photo);
+ }
+
+ protected void display_mirror_of(ViewCollection controller, Photo starting_photo) {
+ assert(controller.get_view_for_source(starting_photo) != null);
+
+ if (controller != get_view() && controller != parent_view) {
+ get_view().clear();
+ get_view().mirror(controller, create_photo_view, is_photo);
+ parent_view = controller;
+ }
+
+ replace_photo(starting_photo);
+ }
+
+ protected virtual void update_ui(bool missing) {
+ bool sensitivity = !missing;
+
+ rotate_button.sensitive = sensitivity;
+ crop_button.sensitive = sensitivity;
+ straighten_button.sensitive = sensitivity;
+ redeye_button.sensitive = sensitivity;
+ adjust_button.sensitive = sensitivity;
+ enhance_button.sensitive = sensitivity;
+ zoom_slider.sensitive = sensitivity;
+
+ deactivate_tool();
+ }
+
+ // This should only be called when it's known that the photo is actually missing.
+ protected virtual void notify_photo_backing_missing(Photo photo, bool missing) {
+ }
+
+ private void draw_message(string message) {
+ // 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);
+
+ Gtk.Allocation allocation;
+ 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;
+
+ paint_text(pango_layout, x, y);
+ }
+
+ // This method can be called indiscriminantly, whether or not the backing is actually present.
+ protected void set_photo_missing(bool missing) {
+ if (photo_missing == missing)
+ return;
+
+ photo_missing = missing;
+
+ Photo? photo = get_photo();
+ if (photo == null)
+ return;
+
+ update_ui(missing);
+
+ if (photo_missing) {
+ try {
+ Gdk.Pixbuf pixbuf = photo.get_preview_pixbuf(get_canvas_scaling());
+
+ pixbuf = pixbuf.composite_color_simple(pixbuf.get_width(), pixbuf.get_height(),
+ Gdk.InterpType.NEAREST, 100, 2, 0, 0);
+
+ set_pixbuf(pixbuf, photo.get_dimensions());
+ } catch (GLib.Error err) {
+ warning("%s", err.message);
+ }
+ }
+ }
+
+ public bool get_photo_missing() {
+ return photo_missing;
+ }
+
+ protected virtual bool confirm_replace_photo(Photo? old_photo, Photo new_photo) {
+ return true;
+ }
+
+ private Gdk.Pixbuf get_zoom_pixbuf(Photo new_photo) {
+ Gdk.Pixbuf? pixbuf = cache.get_ready_pixbuf(new_photo);
+ if (pixbuf == null) {
+ try {
+ pixbuf = new_photo.get_preview_pixbuf(get_canvas_scaling());
+ } catch (Error err) {
+ warning("%s", err.message);
+ }
+ }
+ if (pixbuf == null) {
+ // Create empty pixbuf.
+ pixbuf = AppWindow.get_instance().render_icon(Gtk.Stock.MISSING_IMAGE,
+ Gtk.IconSize.DIALOG, null);
+ get_canvas_scaling().perform_on_pixbuf(pixbuf, Gdk.InterpType.NEAREST, true);
+
+ }
+ return pixbuf;
+ }
+
+ private void replace_photo(Photo new_photo) {
+ // if it's the same Photo object, the scaling hasn't changed, and the photo's file
+ // has not gone missing or re-appeared, there's nothing to do otherwise,
+ // just need to reload the image for the proper scaling. Of course, the photo's pixels
+ // might've changed, so rebuild the zoom buffer.
+ if (new_photo.equals(get_photo()) && !pixbuf_dirty && !photo_missing) {
+ zoom_buffer = new ZoomBuffer(this, new_photo, get_zoom_pixbuf(new_photo));
+ return;
+ }
+
+ // only check if okay to replace if there's something to replace and someone's concerned
+ if (has_photo() && !new_photo.equals(get_photo()) && confirm_replace_photo != null) {
+ if (!confirm_replace_photo(get_photo(), new_photo))
+ return;
+ }
+
+ deactivate_tool();
+
+ // swap out new photo and old photo and process change
+ Photo old_photo = get_photo();
+ set_photo(new_photo);
+ set_page_name(new_photo.get_name());
+
+ // clear out the swap buffer
+ swapped = null;
+
+ // reset flags
+ set_photo_missing(!new_photo.get_file().query_exists());
+ pixbuf_dirty = true;
+
+ // it's possible for this to be called prior to the page being realized, however, the
+ // underlying canvas has a scaling, so use that (hence rebuild rather than refresh)
+ rebuild_caches("replace_photo");
+
+ if (old_photo != null)
+ cancel_prefetch_neighbors(get_view(), old_photo, get_view(), new_photo);
+
+ cancel_zoom();
+
+ zoom_buffer = new ZoomBuffer(this, new_photo, get_zoom_pixbuf(new_photo));
+
+ quick_update_pixbuf();
+
+ // now refresh the caches, which ensures that the neighbors get pulled into memory
+ refresh_caches("replace_photo");
+ }
+
+ protected override void cancel_zoom() {
+ base.cancel_zoom();
+
+ zoom_slider.value_changed.disconnect(on_zoom_slider_value_changed);
+ zoom_slider.set_value(0.0);
+ zoom_slider.value_changed.connect(on_zoom_slider_value_changed);
+
+ if (get_photo() != null)
+ set_zoom_state(ZoomState(get_photo().get_dimensions(), get_surface_dim(), 0.0));
+
+ // when cancelling zoom, panning becomes impossible, so set the cursor back to
+ // a left pointer in case it had been a hand-grip cursor indicating that panning
+ // was possible; the null guards are required because zoom can be cancelled at
+ // any time
+ if (canvas != null && canvas.get_window() != null)
+ set_page_cursor(Gdk.CursorType.LEFT_PTR);
+
+ repaint();
+ }
+
+ private void quick_update_pixbuf() {
+ Gdk.Pixbuf? pixbuf = cache.get_ready_pixbuf(get_photo());
+ if (pixbuf != null) {
+ set_pixbuf(pixbuf, get_photo().get_dimensions());
+ pixbuf_dirty = false;
+
+ return;
+ }
+
+ Scaling scaling = get_canvas_scaling();
+
+ debug("Using progressive load for %s (%s)", get_photo().to_string(), scaling.to_string());
+
+ // throw a resized large thumbnail up to get an image on the screen quickly,
+ // and when ready decode and display the full image
+ try {
+ set_pixbuf(get_photo().get_preview_pixbuf(scaling), get_photo().get_dimensions());
+ } catch (Error err) {
+ warning("%s", err.message);
+ }
+
+ cache.prefetch(get_photo(), BackgroundJob.JobPriority.HIGHEST);
+
+ // although final pixbuf not in place, it's on its way, so set this to clean so later calls
+ // don't reload again
+ pixbuf_dirty = false;
+ }
+
+ private bool update_pixbuf() {
+#if MEASURE_PIPELINE
+ Timer timer = new Timer();
+#endif
+
+ Photo? photo = get_photo();
+ if (photo == null)
+ return false;
+
+ Gdk.Pixbuf pixbuf = null;
+ Dimensions max_dim = photo.get_dimensions();
+
+ try {
+ Dimensions tool_pixbuf_dim = {0};
+ if (current_tool != null)
+ pixbuf = current_tool.get_display_pixbuf(get_canvas_scaling(), photo, out tool_pixbuf_dim);
+
+ if (pixbuf != null)
+ max_dim = tool_pixbuf_dim;
+ } catch (Error err) {
+ warning("%s", err.message);
+ set_photo_missing(true);
+ }
+
+ if (!photo_missing) {
+ // if no pixbuf, see if it's waiting in the cache
+ if (pixbuf == null)
+ pixbuf = cache.get_ready_pixbuf(photo);
+
+ // if still no pixbuf, background fetch and let the signal handler update the display
+ if (pixbuf == null)
+ cache.prefetch(photo);
+ }
+
+ if (!photo_missing && pixbuf != null) {
+ set_pixbuf(pixbuf, max_dim);
+ pixbuf_dirty = false;
+ }
+
+#if MEASURE_PIPELINE
+ debug("UPDATE_PIXBUF: total=%lf", timer.elapsed());
+#endif
+
+ return false;
+ }
+
+ protected override void on_resize(Gdk.Rectangle rect) {
+ base.on_resize(rect);
+
+ track_tool_window();
+ }
+
+ protected override void on_resize_finished(Gdk.Rectangle rect) {
+ // because we've loaded SinglePhotoPage with an image scaled to window size, as the window
+ // is resized it scales that, which pixellates, especially scaling upward. Once the window
+ // resize is complete, we get a fresh image for the new window's size
+ rebuild_caches("on_resize_finished");
+ pixbuf_dirty = true;
+
+ update_pixbuf();
+ }
+
+ private void on_viewport_resized() {
+ // this means the viewport (the display area) has changed, but not necessarily the
+ // toplevel window's dimensions
+ rebuild_caches("on_viewport_resized");
+ pixbuf_dirty = true;
+
+ update_pixbuf();
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ bool multiple_photos = get_view().get_sources_of_type_count(typeof(Photo)) > 1;
+
+ prev_button.sensitive = multiple_photos;
+ next_button.sensitive = multiple_photos;
+
+ Photo? photo = get_photo();
+ Scaling scaling = get_canvas_scaling();
+
+ rotate_button.sensitive = ((photo != null) && (!photo_missing) && photo.check_can_rotate()) ?
+ is_rotate_available(photo) : false;
+ crop_button.sensitive = ((photo != null) && (!photo_missing)) ?
+ EditingTools.CropTool.is_available(photo, scaling) : false;
+ redeye_button.sensitive = ((photo != null) && (!photo_missing)) ?
+ EditingTools.RedeyeTool.is_available(photo, scaling) : false;
+ adjust_button.sensitive = ((photo != null) && (!photo_missing)) ?
+ EditingTools.AdjustTool.is_available(photo, scaling) : false;
+ enhance_button.sensitive = ((photo != null) && (!photo_missing)) ?
+ is_enhance_available(photo) : false;
+ straighten_button.sensitive = ((photo != null) && (!photo_missing)) ?
+ EditingTools.StraightenTool.is_available(photo, scaling) : false;
+
+ base.update_actions(selected_count, count);
+ }
+
+ protected override bool on_shift_pressed(Gdk.EventKey? event) {
+ // show quick compare of original only if no tool is in use, the original pixbuf is handy
+ if (current_tool == null && !get_ctrl_pressed() && !get_alt_pressed())
+ swap_in_original();
+
+ return base.on_shift_pressed(event);
+ }
+
+ protected override bool on_shift_released(Gdk.EventKey? event) {
+ if (current_tool == null)
+ swap_out_original();
+
+ return base.on_shift_released(event);
+ }
+
+ protected override bool on_alt_pressed(Gdk.EventKey? event) {
+ if (current_tool == null)
+ swap_out_original();
+
+ return base.on_alt_pressed(event);
+ }
+
+ protected override bool on_alt_released(Gdk.EventKey? event) {
+ if (current_tool == null && get_shift_pressed() && !get_ctrl_pressed())
+ swap_in_original();
+
+ return base.on_alt_released(event);
+ }
+
+ private void swap_in_original() {
+ Gdk.Pixbuf? original;
+
+ original =
+ get_photo().get_original_orientation().rotate_pixbuf(get_photo().get_prefetched_copy());
+
+ if (original == null)
+ return;
+
+ // store what's currently displayed only for the duration of the shift pressing
+ swapped = get_unscaled_pixbuf();
+
+ // save the zoom state and cancel zoom so that the user can see all of the original
+ // photo
+ if (zoom_slider.get_value() != 0.0) {
+ save_zoom_state();
+ cancel_zoom();
+ }
+
+ set_pixbuf(original, get_photo().get_master_dimensions());
+ }
+
+ private void swap_out_original() {
+ if (swapped != null) {
+ set_pixbuf(swapped, get_photo().get_dimensions());
+
+ restore_zoom_state();
+ update_cursor_for_zoom_context();
+
+ // only store swapped once; it'll be set the next on_shift_pressed
+ swapped = null;
+ }
+ }
+
+ private void activate_tool(EditingTools.EditingTool tool) {
+ // cancel any zoom -- we don't currently allow tools to be used when an image is zoomed,
+ // though we may at some point in the future.
+ save_zoom_state();
+ cancel_zoom();
+
+ // deactivate current tool ... current implementation is one tool at a time. In the future,
+ // tools may be allowed to be executing at the same time.
+ deactivate_tool();
+
+ // save current pixbuf to use if user cancels operation
+ cancel_editing_pixbuf = get_unscaled_pixbuf();
+
+ // see if the tool wants a different pixbuf displayed and what its max dimensions should be
+ Gdk.Pixbuf unscaled;
+ Dimensions max_dim = get_photo().get_dimensions();
+ try {
+ Dimensions tool_pixbuf_dim = {0};
+ unscaled = tool.get_display_pixbuf(get_canvas_scaling(), get_photo(), out tool_pixbuf_dim);
+
+ if (unscaled != null)
+ max_dim = tool_pixbuf_dim;
+ } catch (Error err) {
+ warning("%s", err.message);
+ set_photo_missing(true);
+
+ // untoggle tool button (usually done after deactivate, but tool never deactivated)
+ assert(current_editing_toggle != null);
+ current_editing_toggle.active = false;
+
+ return;
+ }
+
+ 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);
+
+ // hook tool into event system and activate it
+ current_tool = tool;
+ current_tool.activate(photo_canvas);
+
+ // if the tool has an auxiliary window, move it properly on the screen
+ place_tool_window();
+
+ // repaint entire view, with the tool now hooked in
+ repaint();
+ }
+
+ private void deactivate_tool(Command? command = null, Gdk.Pixbuf? new_pixbuf = null,
+ Dimensions new_max_dim = Dimensions(), bool needs_improvement = false) {
+ if (current_tool == null)
+ return;
+
+ EditingTools.EditingTool tool = current_tool;
+ current_tool = null;
+
+ // save the position of the tool
+ EditingTools.EditingToolWindow? tool_window = tool.get_tool_window();
+ if (tool_window != null && tool_window.has_user_moved()) {
+ int last_location_x, last_location_y;
+ tool_window.get_position(out last_location_x, out last_location_y);
+ last_locations[tool.name + "_x"] = last_location_x;
+ last_locations[tool.name + "_y"] = last_location_y;
+ }
+
+ // deactivate with the tool taken out of the hooks and
+ // disconnect any signals we may have connected on activating
+ tool.deactivate();
+
+ tool.activated.disconnect(on_tool_activated);
+ tool.deactivated.disconnect(on_tool_deactivated);
+ tool.applied.disconnect(on_tool_applied);
+ tool.cancelled.disconnect(on_tool_cancelled);
+ tool.aborted.disconnect(on_tool_aborted);
+
+ tool = null;
+
+ // only null the toggle when the tool is completely deactivated; that is, deactive the tool
+ // before updating the UI
+ current_editing_toggle = null;
+
+ // display the (possibly) new photo
+ Gdk.Pixbuf replacement = null;
+ if (new_pixbuf != null) {
+ replacement = new_pixbuf;
+ } else if (cancel_editing_pixbuf != null) {
+ replacement = cancel_editing_pixbuf;
+ new_max_dim = Dimensions.for_pixbuf(replacement);
+ needs_improvement = false;
+ } else {
+ needs_improvement = true;
+ }
+
+ if (replacement != null)
+ set_pixbuf(replacement, new_max_dim);
+ cancel_editing_pixbuf = null;
+
+ // if this is a rough pixbuf, schedule an improvement
+ if (needs_improvement) {
+ pixbuf_dirty = true;
+ Idle.add(update_pixbuf);
+ }
+
+ // execute the tool's command
+ if (command != null)
+ get_command_manager().execute(command);
+ }
+
+ // This virtual method is called only when the user double-clicks on the page and no tool
+ // is active
+ protected virtual bool on_double_click(Gdk.EventButton event) {
+ return false;
+ }
+
+ // Return true to block the DnD handler from activating a drag
+ protected override bool on_left_click(Gdk.EventButton event) {
+ // report double-click if no tool is active, otherwise all double-clicks are eaten
+ if (event.type == Gdk.EventType.2BUTTON_PRESS)
+ return (current_tool == null) ? on_double_click(event) : false;
+
+ int x = (int) event.x;
+ int y = (int) event.y;
+
+ // if no editing tool, then determine whether we should start a pan operation over the
+ // zoomed photo or fall through to the default DnD behavior if we're not zoomed
+ if ((current_tool == null) && (zoom_slider.get_value() != 0.0)) {
+ zoom_pan_start_point.x = (int) event.x;
+ zoom_pan_start_point.y = (int) event.y;
+ is_pan_in_progress = true;
+ suspend_cursor_hiding();
+
+ return true;
+ }
+
+ // default behavior when photo isn't zoomed -- return false to start DnD operation
+ if (current_tool == null) {
+ return false;
+ }
+
+ // only concerned about mouse-downs on the pixbuf ... return true prevents DnD when the
+ // user drags outside the displayed photo
+ if (!is_inside_pixbuf(x, y))
+ return true;
+
+ current_tool.on_left_click(x, y);
+
+ // block DnD handlers if tool is enabled
+ return true;
+ }
+
+ protected override bool on_left_released(Gdk.EventButton event) {
+ if (is_pan_in_progress) {
+ Gdk.Point viewport_center = get_zoom_state().get_viewport_center();
+ int delta_x = ((int) event.x) - zoom_pan_start_point.x;
+ int delta_y = ((int) event.y) - zoom_pan_start_point.y;
+ viewport_center.x -= delta_x;
+ viewport_center.y -= delta_y;
+
+ ZoomState zoom_state = ZoomState.pan(get_zoom_state(), viewport_center);
+ set_zoom_state(zoom_state);
+ get_zoom_buffer().flush_demand_cache(zoom_state);
+
+ is_pan_in_progress = false;
+ restore_cursor_hiding();
+ }
+
+ // report all releases, as it's possible the user click and dragged from inside the
+ // pixbuf to the gutters
+ if (current_tool == null)
+ return false;
+
+ current_tool.on_left_released((int) event.x, (int) event.y);
+
+ if (current_tool.get_tool_window() != null)
+ current_tool.get_tool_window().present();
+
+ return false;
+ }
+
+ protected override bool on_right_click(Gdk.EventButton event) {
+ return on_context_buttonpress(event);
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> map) {
+ if (!map.has_key(get_photo()))
+ return;
+
+ pixbuf_dirty = true;
+
+ // if transformed, want to prefetch the original pixbuf for this photo, but after the
+ // signal is completed as PixbufCache may remove it in this round of fired signals
+ if (get_photo().has_transformations())
+ Idle.add(on_fetch_original);
+
+ update_actions(get_view().get_selected_count(), get_view().get_count());
+ }
+
+ private void on_view_contents_ordering_altered() {
+ refresh_caches("on_view_contents_ordering_altered");
+ }
+
+ private bool on_fetch_original() {
+ if (has_photo())
+ master_cache.prefetch(get_photo(), BackgroundJob.JobPriority.LOW);
+
+ return false;
+ }
+
+ private bool is_panning_possible() {
+ // panning is impossible if all the content to be drawn completely fits on the drawing
+ // canvas
+ Dimensions content_dim = {0};
+ content_dim.width = get_zoom_state().get_zoomed_width();
+ content_dim.height = get_zoom_state().get_zoomed_height();
+ Dimensions canvas_dim = get_surface_dim();
+
+ return (!(canvas_dim.width >= content_dim.width && canvas_dim.height >= content_dim.height));
+ }
+
+ private void update_cursor_for_zoom_context() {
+ if (is_panning_possible())
+ set_page_cursor(Gdk.CursorType.FLEUR);
+ else
+ set_page_cursor(Gdk.CursorType.LEFT_PTR);
+ }
+
+ // Return true to block the DnD handler from activating a drag
+ protected override bool on_motion(Gdk.EventMotion event, int x, int y, Gdk.ModifierType mask) {
+ if (current_tool != null) {
+ current_tool.on_motion(x, y, mask);
+
+ // this requests more events after "hints"
+ Gdk.Event.request_motions(event);
+
+ return true;
+ }
+
+ update_cursor_for_zoom_context();
+
+ if (is_pan_in_progress) {
+ int delta_x = ((int) event.x) - zoom_pan_start_point.x;
+ int delta_y = ((int) event.y) - zoom_pan_start_point.y;
+
+ Gdk.Point viewport_center = get_zoom_state().get_viewport_center();
+ viewport_center.x -= delta_x;
+ viewport_center.y -= delta_y;
+
+ ZoomState zoom_state = ZoomState.pan(get_zoom_state(), viewport_center);
+
+ on_interactive_pan(zoom_state);
+ return true;
+ }
+
+ return base.on_motion(event, x, y, mask);
+ }
+
+ protected override bool on_leave_notify_event() {
+ if (current_tool != null)
+ return current_tool.on_leave_notify_event();
+
+ return base.on_leave_notify_event();
+ }
+
+ private void track_tool_window() {
+ // if editing tool window is present and the user hasn't touched it, it moves with the window
+ if (current_tool != null) {
+ EditingTools.EditingToolWindow tool_window = current_tool.get_tool_window();
+ if (tool_window != null && !tool_window.has_user_moved())
+ place_tool_window();
+ }
+ }
+
+ protected override void on_move(Gdk.Rectangle rect) {
+ track_tool_window();
+
+ base.on_move(rect);
+ }
+
+ protected override void on_move_finished(Gdk.Rectangle rect) {
+ last_locations.clear();
+
+ base.on_move_finished(rect);
+ }
+
+ private bool on_keyboard_pan_event(Gdk.EventKey event) {
+ ZoomState current_zoom_state = get_zoom_state();
+ Gdk.Point viewport_center = current_zoom_state.get_viewport_center();
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Left":
+ case "KP_Left":
+ case "KP_4":
+ viewport_center.x -= PAN_INCREMENT_SIZE;
+ break;
+
+ case "Right":
+ case "KP_Right":
+ case "KP_6":
+ viewport_center.x += PAN_INCREMENT_SIZE;
+ break;
+
+ case "Down":
+ case "KP_Down":
+ case "KP_2":
+ viewport_center.y += PAN_INCREMENT_SIZE;
+ break;
+
+ case "Up":
+ case "KP_Up":
+ case "KP_8":
+ viewport_center.y -= PAN_INCREMENT_SIZE;
+ break;
+
+ default:
+ return false;
+ }
+
+ ZoomState new_zoom_state = ZoomState.pan(current_zoom_state, viewport_center);
+ set_zoom_state(new_zoom_state);
+ repaint();
+
+ return true;
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ // editing tool gets first crack at the keypress
+ if (current_tool != null) {
+ if (current_tool.on_keypress(event))
+ return true;
+ }
+
+ // if panning is possible, the pan handler (on MUNI?) gets second crack at the keypress
+ if (is_panning_possible()) {
+ if (on_keyboard_pan_event(event))
+ return true;
+ }
+
+ // if the user pressed the "0", "1" or "2" keys then handle the event as if were
+ // directed at the zoom slider ("0", "1" and "2" are hotkeys that jump to preset
+ // zoom levels
+ if (on_zoom_slider_key_press(event))
+ return true;
+
+ bool handled = true;
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ // this block is only here to prevent base from moving focus to toolbar
+ case "Down":
+ case "KP_Down":
+ ;
+ break;
+
+ case "equal":
+ case "plus":
+ case "KP_Add":
+ activate_action("IncreaseSize");
+ break;
+
+ // underscore is the keysym generated by SHIFT-[minus sign] -- this means zoom out
+ case "minus":
+ case "underscore":
+ case "KP_Subtract":
+ activate_action("DecreaseSize");
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ if (handled)
+ return true;
+
+ return (base.key_press_event != null) ? base.key_press_event(event) : true;
+ }
+
+ protected override void new_surface(Cairo.Context default_ctx, Dimensions dim) {
+ // if tool is open, update its canvas object
+ if (current_tool != null)
+ current_tool.canvas.set_surface(default_ctx, dim);
+ }
+
+ protected override void updated_pixbuf(Gdk.Pixbuf pixbuf, SinglePhotoPage.UpdateReason reason,
+ Dimensions old_dim) {
+ // only purpose here is to inform editing tool of change and drop the cancelled
+ // pixbuf, which is now sized incorrectly
+ if (current_tool != null && reason != SinglePhotoPage.UpdateReason.QUALITY_IMPROVEMENT) {
+ current_tool.canvas.resized_pixbuf(old_dim, pixbuf, get_scaled_pixbuf_position());
+ cancel_editing_pixbuf = null;
+ }
+ }
+
+ protected virtual Gdk.Pixbuf? get_bottom_left_trinket(int scale) {
+ return null;
+ }
+
+ 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_right_trinket(int scale) {
+ return null;
+ }
+
+ protected override void paint(Cairo.Context ctx, Dimensions ctx_dim) {
+ if (current_tool != null) {
+ current_tool.paint(ctx);
+
+ return;
+ }
+
+ if (photo_missing && has_photo()) {
+ set_source_color_from_string(ctx, "#000");
+ ctx.rectangle(0, 0, get_surface_dim().width, get_surface_dim().height);
+ ctx.fill();
+ ctx.paint();
+ draw_message(_("Photo source file missing: %s").printf(get_photo().get_file().get_path()));
+ return;
+ }
+
+ base.paint(ctx, ctx_dim);
+
+ if (!get_zoom_state().is_default())
+ return;
+
+ // paint trinkets last
+ Gdk.Rectangle scaled_rect = get_scaled_pixbuf_position();
+
+ Gdk.Pixbuf? trinket = get_bottom_left_trinket(TRINKET_SCALE);
+ if (trinket != null) {
+ int x = scaled_rect.x + TRINKET_PADDING;
+ int y = scaled_rect.y + scaled_rect.height - trinket.height - TRINKET_PADDING;
+ Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y);
+ ctx.rectangle(x, y, trinket.width, trinket.height);
+ ctx.fill();
+ }
+
+ trinket = get_top_left_trinket(TRINKET_SCALE);
+ if (trinket != null) {
+ int x = scaled_rect.x + TRINKET_PADDING;
+ int y = scaled_rect.y + TRINKET_PADDING;
+ Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y);
+ ctx.rectangle(x, y, trinket.width, trinket.height);
+ ctx.fill();
+ }
+
+ trinket = get_top_right_trinket(TRINKET_SCALE);
+ if (trinket != null) {
+ int x = scaled_rect.x + scaled_rect.width - trinket.width - TRINKET_PADDING;
+ int y = scaled_rect.y + TRINKET_PADDING;
+ Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y);
+ ctx.rectangle(x, y, trinket.width, trinket.height);
+ ctx.fill();
+ }
+
+ trinket = get_bottom_right_trinket(TRINKET_SCALE);
+ if (trinket != null) {
+ int x = scaled_rect.x + scaled_rect.width - trinket.width - TRINKET_PADDING;
+ int y = scaled_rect.y + scaled_rect.height - trinket.height - TRINKET_PADDING;
+ Gdk.cairo_set_source_pixbuf(ctx, trinket, x, y);
+ ctx.rectangle(x, y, trinket.width, trinket.height);
+ ctx.fill();
+ }
+ }
+
+ public bool is_rotate_available(Photo photo) {
+ return !photo_missing;
+ }
+
+ private void rotate(Rotation rotation, string name, string description) {
+ cancel_zoom();
+
+ deactivate_tool();
+
+ if (!has_photo())
+ return;
+
+ RotateSingleCommand command = new RotateSingleCommand(get_photo(), rotation, name,
+ description);
+ get_command_manager().execute(command);
+ }
+
+ public void on_rotate_clockwise() {
+ rotate(Rotation.CLOCKWISE, Resources.ROTATE_CW_FULL_LABEL, Resources.ROTATE_CW_TOOLTIP);
+ }
+
+ public void on_rotate_counterclockwise() {
+ rotate(Rotation.COUNTERCLOCKWISE, Resources.ROTATE_CCW_FULL_LABEL, Resources.ROTATE_CCW_TOOLTIP);
+ }
+
+ public void on_flip_horizontally() {
+ rotate(Rotation.MIRROR, Resources.HFLIP_LABEL, "");
+ }
+
+ public void on_flip_vertically() {
+ rotate(Rotation.UPSIDE_DOWN, Resources.VFLIP_LABEL, "");
+ }
+
+ public void on_revert() {
+ if (photo_missing)
+ return;
+
+ deactivate_tool();
+
+ if (!has_photo())
+ return;
+
+ if (get_photo().has_editable()) {
+ if (!revert_editable_dialog(AppWindow.get_instance(),
+ (Gee.Collection<Photo>) get_view().get_sources())) {
+ return;
+ }
+
+ get_photo().revert_to_master();
+ }
+
+ cancel_zoom();
+
+ set_photo_missing(false);
+
+ RevertSingleCommand command = new RevertSingleCommand(get_photo());
+ get_command_manager().execute(command);
+ }
+
+ public void on_edit_title() {
+ LibraryPhoto item;
+ if (get_photo() is LibraryPhoto)
+ item = get_photo() as LibraryPhoto;
+ else
+ return;
+
+ EditTitleDialog edit_title_dialog = new EditTitleDialog(item.get_title());
+ string? new_title = edit_title_dialog.execute();
+ if (new_title == null)
+ return;
+
+ EditTitleCommand command = new EditTitleCommand(item, new_title);
+ get_command_manager().execute(command);
+ }
+
+ public void on_edit_comment() {
+ LibraryPhoto item;
+ if (get_photo() is LibraryPhoto)
+ item = get_photo() as LibraryPhoto;
+ else
+ return;
+
+ EditCommentDialog edit_comment_dialog = new EditCommentDialog(item.get_comment());
+ string? new_comment = edit_comment_dialog.execute();
+ if (new_comment == null)
+ return;
+
+ EditCommentCommand command = new EditCommentCommand(item, new_comment);
+ get_command_manager().execute(command);
+ }
+
+ public void on_adjust_date_time() {
+ if (!has_photo())
+ return;
+
+ AdjustDateTimeDialog dialog = new AdjustDateTimeDialog(get_photo(), 1, !(this is DirectPhotoPage));
+
+ int64 time_shift;
+ bool keep_relativity, modify_originals;
+ if (dialog.execute(out time_shift, out keep_relativity, out modify_originals)) {
+ get_view().get_selected();
+
+ AdjustDateTimePhotoCommand command = new AdjustDateTimePhotoCommand(get_photo(),
+ time_shift, modify_originals);
+ get_command_manager().execute(command);
+ }
+ }
+
+ public void on_set_background() {
+ if (has_photo())
+ DesktopIntegration.set_background(get_photo());
+ }
+
+ protected override bool on_ctrl_pressed(Gdk.EventKey? event) {
+ rotate_button.set_icon_name(Resources.COUNTERCLOCKWISE);
+ rotate_button.set_label(Resources.ROTATE_CCW_LABEL);
+ rotate_button.set_tooltip_text(Resources.ROTATE_CCW_TOOLTIP);
+ rotate_button.clicked.disconnect(on_rotate_clockwise);
+ rotate_button.clicked.connect(on_rotate_counterclockwise);
+
+ if (current_tool == null)
+ swap_out_original();
+
+ return base.on_ctrl_pressed(event);
+ }
+
+ protected override bool on_ctrl_released(Gdk.EventKey? event) {
+ rotate_button.set_icon_name(Resources.CLOCKWISE);
+ rotate_button.set_label(Resources.ROTATE_CW_LABEL);
+ rotate_button.set_tooltip_text(Resources.ROTATE_CW_TOOLTIP);
+ rotate_button.clicked.disconnect(on_rotate_counterclockwise);
+ rotate_button.clicked.connect(on_rotate_clockwise);
+
+ if (current_tool == null && get_shift_pressed() && !get_alt_pressed())
+ swap_in_original();
+
+ return base.on_ctrl_released(event);
+ }
+
+ protected void on_tool_button_toggled(Gtk.ToggleToolButton toggle, EditingTools.EditingTool.Factory factory) {
+ // if the button is an activate, deactivate any current tool running; if the button is
+ // a deactivate, deactivate the current tool and exit
+ bool deactivating_only = (!toggle.active && current_editing_toggle == toggle);
+ deactivate_tool();
+
+ if (deactivating_only) {
+ restore_cursor_hiding();
+ return;
+ }
+
+ suspend_cursor_hiding();
+
+ current_editing_toggle = toggle;
+
+ // create the tool, hook its signals, and activate
+ EditingTools.EditingTool tool = factory();
+ tool.activated.connect(on_tool_activated);
+ tool.deactivated.connect(on_tool_deactivated);
+ tool.applied.connect(on_tool_applied);
+ tool.cancelled.connect(on_tool_cancelled);
+ tool.aborted.connect(on_tool_aborted);
+
+ activate_tool(tool);
+ }
+
+ private void on_tool_activated() {
+ assert(current_editing_toggle != null);
+ zoom_slider.set_sensitive(false);
+ current_editing_toggle.active = true;
+ }
+
+ private void on_tool_deactivated() {
+ assert(current_editing_toggle != null);
+ zoom_slider.set_sensitive(true);
+ current_editing_toggle.active = false;
+ }
+
+ private void on_tool_applied(Command? command, Gdk.Pixbuf? new_pixbuf, Dimensions new_max_dim,
+ bool needs_improvement) {
+ deactivate_tool(command, new_pixbuf, new_max_dim, needs_improvement);
+ }
+
+ private void on_tool_cancelled() {
+ deactivate_tool();
+
+ restore_zoom_state();
+ repaint();
+ }
+
+ private void on_tool_aborted() {
+ deactivate_tool();
+ set_photo_missing(true);
+ }
+
+ protected void toggle_crop() {
+ crop_button.set_active(!crop_button.get_active());
+ }
+
+ protected void toggle_straighten() {
+ straighten_button.set_active(!straighten_button.get_active());
+ }
+
+ protected void toggle_redeye() {
+ redeye_button.set_active(!redeye_button.get_active());
+ }
+
+ protected void toggle_adjust() {
+ adjust_button.set_active(!adjust_button.get_active());
+ }
+
+ private void on_straighten_toggled() {
+ on_tool_button_toggled(straighten_button, EditingTools.StraightenTool.factory);
+ }
+
+ private void on_crop_toggled() {
+ on_tool_button_toggled(crop_button, EditingTools.CropTool.factory);
+ }
+
+ private void on_redeye_toggled() {
+ on_tool_button_toggled(redeye_button, EditingTools.RedeyeTool.factory);
+ }
+
+ private void on_adjust_toggled() {
+ on_tool_button_toggled(adjust_button, EditingTools.AdjustTool.factory);
+ }
+
+ public bool is_enhance_available(Photo photo) {
+ return !photo_missing;
+ }
+
+ public void on_enhance() {
+ // because running multiple tools at once is not currently supported, deactivate any current
+ // tool; however, there is a special case of running enhancement while the AdjustTool is
+ // open, so allow for that
+ if (!(current_tool is EditingTools.AdjustTool)) {
+ deactivate_tool();
+
+ cancel_zoom();
+ }
+
+ if (!has_photo())
+ return;
+
+ EditingTools.AdjustTool adjust_tool = current_tool as EditingTools.AdjustTool;
+ if (adjust_tool != null) {
+ adjust_tool.enhance();
+
+ return;
+ }
+
+ EnhanceSingleCommand command = new EnhanceSingleCommand(get_photo());
+ get_command_manager().execute(command);
+ }
+
+ public void on_copy_adjustments() {
+ if (!has_photo())
+ return;
+ PixelTransformationBundle.set_copied_color_adjustments(get_photo().get_color_adjustments());
+ set_action_sensitive("PasteColorAdjustments", true);
+ }
+
+ public void on_paste_adjustments() {
+ PixelTransformationBundle? copied_adjustments = PixelTransformationBundle.get_copied_color_adjustments();
+ if (!has_photo() || copied_adjustments == null)
+ return;
+
+ AdjustColorsSingleCommand command = new AdjustColorsSingleCommand(get_photo(), copied_adjustments,
+ Resources.PASTE_ADJUSTMENTS_LABEL, Resources.PASTE_ADJUSTMENTS_TOOLTIP);
+ get_command_manager().execute(command);
+ }
+
+ private void place_tool_window() {
+ if (current_tool == null)
+ return;
+
+ EditingTools.EditingToolWindow tool_window = current_tool.get_tool_window();
+ if (tool_window == null)
+ return;
+
+ // do this so window size is properly allocated, but window not shown
+ tool_window.show_all();
+ tool_window.hide();
+
+ Gtk.Allocation tool_alloc;
+ tool_window.get_allocation(out tool_alloc);
+ int x, y;
+
+ // Check if the last location of the adjust tool is stored.
+ if (last_locations.has_key(current_tool.name + "_x")) {
+ x = last_locations[current_tool.name + "_x"];
+ y = last_locations[current_tool.name + "_y"];
+ } else {
+ // No stored position
+ if (get_container() == AppWindow.get_instance()) {
+
+ // Normal: position crop tool window centered on viewport/canvas at the bottom,
+ // straddling the canvas and the toolbar
+ int rx, ry;
+ get_container().get_window().get_root_origin(out rx, out ry);
+
+ Gtk.Allocation viewport_allocation;
+ viewport.get_allocation(out viewport_allocation);
+
+ int cx, cy, cwidth, cheight;
+ cx = viewport_allocation.x;
+ cy = viewport_allocation.y;
+ cwidth = viewport_allocation.width;
+ cheight = viewport_allocation.height;
+
+ // it isn't clear why, but direct mode seems to want to position tool windows
+ // differently than library mode...
+ x = (this is DirectPhotoPage) ? (rx + cx + (cwidth / 2) - (tool_alloc.width / 2)) :
+ (rx + cx + (cwidth / 2));
+ y = ry + cy + cheight - ((tool_alloc.height / 4) * 3);
+ } else {
+ assert(get_container() is FullscreenWindow);
+
+ // Fullscreen: position crop tool window centered on screen at the bottom, just above the
+ // toolbar
+ Gtk.Allocation toolbar_alloc;
+ get_toolbar().get_allocation(out toolbar_alloc);
+
+ Gdk.Screen screen = get_container().get_screen();
+ x = screen.get_width();
+ y = screen.get_height() - toolbar_alloc.height -
+ tool_alloc.height - TOOL_WINDOW_SEPARATOR;
+
+ // put larger adjust tool off to the side
+ if (current_tool is EditingTools.AdjustTool) {
+ x = x * 3 / 4;
+ } else {
+ x = (x - tool_alloc.width) / 2;
+ }
+ }
+ }
+
+ // however, clamp the window so it's never off-screen initially
+ Gdk.Screen screen = get_container().get_screen();
+ x = x.clamp(0, screen.get_width() - tool_alloc.width);
+ y = y.clamp(0, screen.get_height() - tool_alloc.height);
+
+ tool_window.move(x, y);
+ tool_window.show();
+ tool_window.present();
+ }
+
+ protected override void on_next_photo() {
+ deactivate_tool();
+
+ if (!has_photo())
+ return;
+
+ Photo? current_photo = get_photo();
+ assert(current_photo != null);
+
+ DataView current = get_view().get_view_for_source(get_photo());
+ if (current == null)
+ return;
+
+ // search through the collection until the next photo is found or back at the starting point
+ DataView? next = current;
+ for (;;) {
+ next = get_view().get_next(next);
+ if (next == null)
+ break;
+
+ Photo? next_photo = next.get_source() as Photo;
+ if (next_photo == null)
+ continue;
+
+ if (next_photo == current_photo)
+ break;
+
+ replace_photo(next_photo);
+
+ break;
+ }
+ }
+
+ protected override void on_previous_photo() {
+ deactivate_tool();
+
+ if (!has_photo())
+ return;
+
+ Photo? current_photo = get_photo();
+ assert(current_photo != null);
+
+ DataView current = get_view().get_view_for_source(get_photo());
+ if (current == null)
+ return;
+
+ // loop until a previous photo is found or back at the starting point
+ DataView? previous = current;
+ for (;;) {
+ previous = get_view().get_previous(previous);
+ if (previous == null)
+ break;
+
+ Photo? previous_photo = previous.get_source() as Photo;
+ if (previous_photo == null)
+ continue;
+
+ if (previous_photo == current_photo)
+ break;
+
+ replace_photo(previous_photo);
+
+ break;
+ }
+ }
+
+ public bool has_current_tool() {
+ return (current_tool != null);
+ }
+
+ protected void unset_view_collection() {
+ parent_view = null;
+ }
+}
+
+//
+// LibraryPhotoPage
+//
+
+public class LibraryPhotoPage : EditingHostPage {
+
+ private class LibraryPhotoPageViewFilter : ViewFilter {
+ public override bool predicate (DataView view) {
+ return !((MediaSource) view.get_source()).is_trashed();
+ }
+ }
+
+ private CollectionPage? return_page = null;
+ private bool return_to_collection_on_release = false;
+ private LibraryPhotoPageViewFilter filter = new LibraryPhotoPageViewFilter();
+
+ public LibraryPhotoPage() {
+ base(LibraryPhoto.global, "Photo");
+
+ // monitor view to update UI elements
+ get_view().items_altered.connect(on_photos_altered);
+
+ // watch for photos being destroyed or altered, either here or in other pages
+ LibraryPhoto.global.item_destroyed.connect(on_photo_destroyed);
+ LibraryPhoto.global.items_altered.connect(on_metadata_altered);
+
+ // watch for updates to the external app settings
+ Config.Facade.get_instance().external_app_changed.connect(on_external_app_changed);
+
+ // Filter out trashed files.
+ get_view().install_view_filter(filter);
+ LibraryPhoto.global.items_unlinking.connect(on_photo_unlinking);
+ LibraryPhoto.global.items_relinked.connect(on_photo_relinked);
+ }
+
+ ~LibraryPhotoPage() {
+ LibraryPhoto.global.item_destroyed.disconnect(on_photo_destroyed);
+ LibraryPhoto.global.items_altered.disconnect(on_metadata_altered);
+ Config.Facade.get_instance().external_app_changed.disconnect(on_external_app_changed);
+ }
+
+ public bool not_trashed_view_filter(DataView view) {
+ return !((MediaSource) view.get_source()).is_trashed();
+ }
+
+ private void on_photo_unlinking(Gee.Collection<DataSource> unlinking) {
+ filter.refresh();
+ }
+
+ private void on_photo_relinked(Gee.Collection<DataSource> relinked) {
+ filter.refresh();
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("photo_context.ui");
+ ui_filenames.add("photo.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry export = { "Export", Gtk.Stock.SAVE_AS, TRANSLATABLE, "<Ctrl><Shift>E",
+ TRANSLATABLE, on_export };
+ export.label = Resources.EXPORT_MENU;
+ actions += export;
+
+ Gtk.ActionEntry print = { "Print", Gtk.Stock.PRINT, TRANSLATABLE, "<Ctrl>P",
+ TRANSLATABLE, on_print };
+ print.label = Resources.PRINT_MENU;
+ actions += print;
+
+ Gtk.ActionEntry publish = { "Publish", Resources.PUBLISH, TRANSLATABLE, "<Ctrl><Shift>P",
+ TRANSLATABLE, on_publish };
+ publish.label = Resources.PUBLISH_MENU;
+ publish.tooltip = Resources.PUBLISH_TOOLTIP;
+ actions += publish;
+
+ Gtk.ActionEntry remove_from_library = { "RemoveFromLibrary", Gtk.Stock.REMOVE, TRANSLATABLE,
+ "<Shift>Delete", TRANSLATABLE, on_remove_from_library };
+ remove_from_library.label = Resources.REMOVE_FROM_LIBRARY_MENU;
+ actions += remove_from_library;
+
+ Gtk.ActionEntry move_to_trash = { "MoveToTrash", "user-trash-full", TRANSLATABLE, "Delete",
+ TRANSLATABLE, on_move_to_trash };
+ move_to_trash.label = Resources.MOVE_TO_TRASH_MENU;
+ actions += move_to_trash;
+
+ Gtk.ActionEntry view = { "ViewMenu", null, TRANSLATABLE, null, null, on_view_menu };
+ view.label = _("_View");
+ actions += view;
+
+ Gtk.ActionEntry tools = { "Tools", null, TRANSLATABLE, null, null, null };
+ tools.label = _("T_ools");
+ actions += tools;
+
+ Gtk.ActionEntry prev = { "PrevPhoto", Gtk.Stock.GO_BACK, TRANSLATABLE, null,
+ TRANSLATABLE, on_previous_photo };
+ prev.label = _("_Previous Photo");
+ prev.tooltip = _("Previous Photo");
+ actions += prev;
+
+ Gtk.ActionEntry next = { "NextPhoto", Gtk.Stock.GO_FORWARD, TRANSLATABLE, null,
+ TRANSLATABLE, on_next_photo };
+ next.label = _("_Next Photo");
+ next.tooltip = _("Next Photo");
+ actions += next;
+
+ Gtk.ActionEntry rotate_right = { "RotateClockwise", Resources.CLOCKWISE, TRANSLATABLE,
+ "<Ctrl>R", TRANSLATABLE, on_rotate_clockwise };
+ rotate_right.label = Resources.ROTATE_CW_MENU;
+ rotate_right.tooltip = Resources.ROTATE_CW_TOOLTIP;
+ actions += rotate_right;
+
+ Gtk.ActionEntry rotate_left = { "RotateCounterclockwise", Resources.COUNTERCLOCKWISE,
+ TRANSLATABLE, "<Ctrl><Shift>R", TRANSLATABLE, on_rotate_counterclockwise };
+ rotate_left.label = Resources.ROTATE_CCW_MENU;
+ rotate_left.tooltip = Resources.ROTATE_CCW_TOOLTIP;
+ actions += rotate_left;
+
+ Gtk.ActionEntry hflip = { "FlipHorizontally", Resources.HFLIP, TRANSLATABLE, null,
+ TRANSLATABLE, on_flip_horizontally };
+ hflip.label = Resources.HFLIP_MENU;
+ actions += hflip;
+
+ Gtk.ActionEntry vflip = { "FlipVertically", Resources.VFLIP, TRANSLATABLE, null,
+ TRANSLATABLE, on_flip_vertically };
+ vflip.label = Resources.VFLIP_MENU;
+ actions += vflip;
+
+ Gtk.ActionEntry enhance = { "Enhance", Resources.ENHANCE, TRANSLATABLE, "<Ctrl>E",
+ TRANSLATABLE, on_enhance };
+ enhance.label = Resources.ENHANCE_MENU;
+ enhance.tooltip = Resources.ENHANCE_TOOLTIP;
+ actions += enhance;
+
+ Gtk.ActionEntry copy_adjustments = { "CopyColorAdjustments", null, TRANSLATABLE,
+ "<Ctrl><Shift>C", TRANSLATABLE, on_copy_adjustments};
+ copy_adjustments.label = Resources.COPY_ADJUSTMENTS_MENU;
+ copy_adjustments.tooltip = Resources.COPY_ADJUSTMENTS_TOOLTIP;
+ actions += copy_adjustments;
+
+ Gtk.ActionEntry paste_adjustments = { "PasteColorAdjustments", null, TRANSLATABLE,
+ "<Ctrl><Shift>V", TRANSLATABLE, on_paste_adjustments};
+ paste_adjustments.label = Resources.PASTE_ADJUSTMENTS_MENU;
+ paste_adjustments.tooltip = Resources.PASTE_ADJUSTMENTS_TOOLTIP;
+ actions += paste_adjustments;
+
+ Gtk.ActionEntry crop = { "Crop", Resources.CROP, TRANSLATABLE, "<Ctrl>O",
+ TRANSLATABLE, toggle_crop };
+ crop.label = Resources.CROP_MENU;
+ crop.tooltip = Resources.CROP_TOOLTIP;
+ actions += crop;
+
+ Gtk.ActionEntry straighten = { "Straighten", Gtk.Stock.REFRESH, TRANSLATABLE, "<Ctrl>A",
+ TRANSLATABLE, toggle_straighten };
+ straighten.label = Resources.STRAIGHTEN_MENU;
+ straighten.tooltip = Resources.STRAIGHTEN_TOOLTIP;
+ actions += straighten;
+
+ Gtk.ActionEntry red_eye = { "RedEye", Resources.REDEYE, TRANSLATABLE, "<Ctrl>Y",
+ TRANSLATABLE, toggle_redeye };
+ red_eye.label = Resources.RED_EYE_MENU;
+ red_eye.tooltip = Resources.RED_EYE_TOOLTIP;
+ actions += red_eye;
+
+ Gtk.ActionEntry adjust = { "Adjust", Resources.ADJUST, TRANSLATABLE, "<Ctrl>D",
+ TRANSLATABLE, toggle_adjust };
+ adjust.label = Resources.ADJUST_MENU;
+ adjust.tooltip = Resources.ADJUST_TOOLTIP;
+ actions += adjust;
+
+ Gtk.ActionEntry revert = { "Revert", Gtk.Stock.REVERT_TO_SAVED, TRANSLATABLE,
+ null, TRANSLATABLE, on_revert };
+ revert.label = Resources.REVERT_MENU;
+ actions += revert;
+
+ Gtk.ActionEntry edit_title = { "EditTitle", null, TRANSLATABLE, "F2", TRANSLATABLE,
+ on_edit_title };
+ edit_title.label = Resources.EDIT_TITLE_MENU;
+ actions += edit_title;
+
+ Gtk.ActionEntry edit_comment = { "EditComment", null, TRANSLATABLE, "F3", TRANSLATABLE,
+ on_edit_comment };
+ edit_comment.label = Resources.EDIT_COMMENT_MENU;
+ actions += edit_comment;
+
+ Gtk.ActionEntry adjust_date_time = { "AdjustDateTime", null, TRANSLATABLE, null,
+ TRANSLATABLE, on_adjust_date_time };
+ adjust_date_time.label = Resources.ADJUST_DATE_TIME_MENU;
+ actions += adjust_date_time;
+
+ Gtk.ActionEntry external_edit = { "ExternalEdit", Gtk.Stock.EDIT, TRANSLATABLE,
+ "<Ctrl>Return", TRANSLATABLE, on_external_edit };
+ external_edit.label = Resources.EXTERNAL_EDIT_MENU;
+ actions += external_edit;
+
+ Gtk.ActionEntry edit_raw = { "ExternalEditRAW", null, TRANSLATABLE, "<Ctrl><Shift>Return",
+ TRANSLATABLE, on_external_edit_raw };
+ edit_raw.label = Resources.EXTERNAL_EDIT_RAW_MENU;
+ actions += edit_raw;
+
+ Gtk.ActionEntry send_to = { "SendTo", "document-send", TRANSLATABLE, null,
+ TRANSLATABLE, on_send_to };
+ send_to.label = Resources.SEND_TO_MENU;
+ actions += send_to;
+
+ Gtk.ActionEntry set_background = { "SetBackground", null, TRANSLATABLE, "<Ctrl>B",
+ TRANSLATABLE, on_set_background };
+ set_background.label = Resources.SET_BACKGROUND_MENU;
+ set_background.tooltip = Resources.SET_BACKGROUND_TOOLTIP;
+ actions += set_background;
+
+ Gtk.ActionEntry flag = { "Flag", null, TRANSLATABLE, "<Ctrl>G", TRANSLATABLE, on_flag_unflag };
+ flag.label = Resources.FLAG_MENU;
+ actions += flag;
+
+ Gtk.ActionEntry set_rating = { "Rate", null, TRANSLATABLE, null, null, null };
+ set_rating.label = Resources.RATING_MENU;
+ actions += set_rating;
+
+ Gtk.ActionEntry increase_rating = { "IncreaseRating", null, TRANSLATABLE,
+ "greater", TRANSLATABLE, on_increase_rating };
+ increase_rating.label = Resources.INCREASE_RATING_MENU;
+ actions += increase_rating;
+
+ Gtk.ActionEntry decrease_rating = { "DecreaseRating", null, TRANSLATABLE,
+ "less", TRANSLATABLE, on_decrease_rating };
+ decrease_rating.label = Resources.DECREASE_RATING_MENU;
+ actions += decrease_rating;
+
+ Gtk.ActionEntry rate_rejected = { "RateRejected", null, TRANSLATABLE,
+ "9", TRANSLATABLE, on_rate_rejected };
+ rate_rejected.label = Resources.rating_menu(Rating.REJECTED);
+ actions += rate_rejected;
+
+ Gtk.ActionEntry rate_unrated = { "RateUnrated", null, TRANSLATABLE,
+ "0", TRANSLATABLE, on_rate_unrated };
+ rate_unrated.label = Resources.rating_menu(Rating.UNRATED);
+ actions += rate_unrated;
+
+ Gtk.ActionEntry rate_one = { "RateOne", null, TRANSLATABLE,
+ "1", TRANSLATABLE, on_rate_one };
+ rate_one.label = Resources.rating_menu(Rating.ONE);
+ actions += rate_one;
+
+ Gtk.ActionEntry rate_two = { "RateTwo", null, TRANSLATABLE,
+ "2", TRANSLATABLE, on_rate_two };
+ rate_two.label = Resources.rating_menu(Rating.TWO);
+ actions += rate_two;
+
+ Gtk.ActionEntry rate_three = { "RateThree", null, TRANSLATABLE,
+ "3", TRANSLATABLE, on_rate_three };
+ rate_three.label = Resources.rating_menu(Rating.THREE);
+ actions += rate_three;
+
+ Gtk.ActionEntry rate_four = { "RateFour", null, TRANSLATABLE,
+ "4", TRANSLATABLE, on_rate_four };
+ rate_four.label = Resources.rating_menu(Rating.FOUR);
+ actions += rate_four;
+
+ Gtk.ActionEntry rate_five = { "RateFive", null, TRANSLATABLE,
+ "5", TRANSLATABLE, on_rate_five };
+ rate_five.label = Resources.rating_menu(Rating.FIVE);
+ actions += rate_five;
+
+ Gtk.ActionEntry increase_size = { "IncreaseSize", Gtk.Stock.ZOOM_IN, TRANSLATABLE,
+ "<Ctrl>plus", TRANSLATABLE, on_increase_size };
+ increase_size.label = _("Zoom _In");
+ increase_size.tooltip = _("Increase the magnification of the photo");
+ actions += increase_size;
+
+ Gtk.ActionEntry decrease_size = { "DecreaseSize", Gtk.Stock.ZOOM_OUT, TRANSLATABLE,
+ "<Ctrl>minus", TRANSLATABLE, on_decrease_size };
+ decrease_size.label = _("Zoom _Out");
+ decrease_size.tooltip = _("Decrease the magnification of the photo");
+ actions += decrease_size;
+
+ Gtk.ActionEntry best_fit = { "ZoomFit", Gtk.Stock.ZOOM_FIT, TRANSLATABLE,
+ "<Ctrl>0", TRANSLATABLE, snap_zoom_to_min };
+ best_fit.label = _("Fit to _Page");
+ best_fit.tooltip = _("Zoom the photo to fit on the screen");
+ actions += best_fit;
+
+ Gtk.ActionEntry actual_size = { "Zoom100", Gtk.Stock.ZOOM_100, TRANSLATABLE,
+ "<Ctrl>1", TRANSLATABLE, snap_zoom_to_isomorphic };
+ /// xgettext:no-c-format
+ actual_size.label = _("Zoom _100%");
+ /// xgettext:no-c-format
+ actual_size.tooltip = _("Zoom the photo to 100% magnification");
+ actions += actual_size;
+
+ Gtk.ActionEntry max_size = { "Zoom200", null, TRANSLATABLE,
+ "<Ctrl>2", TRANSLATABLE, snap_zoom_to_max };
+ /// xgettext:no-c-format
+ max_size.label = _("Zoom _200%");
+ /// xgettext:no-c-format
+ max_size.tooltip = _("Zoom the photo to 200% magnification");
+ actions += max_size;
+
+ Gtk.ActionEntry add_tags = { "AddTags", null, TRANSLATABLE, "<Ctrl>T", TRANSLATABLE,
+ on_add_tags };
+ add_tags.label = Resources.ADD_TAGS_MENU;
+ actions += add_tags;
+
+ Gtk.ActionEntry modify_tags = { "ModifyTags", null, TRANSLATABLE, "<Ctrl>M", TRANSLATABLE,
+ on_modify_tags };
+ modify_tags.label = Resources.MODIFY_TAGS_MENU;
+ actions += modify_tags;
+
+ Gtk.ActionEntry slideshow = { "Slideshow", null, TRANSLATABLE, "F5", TRANSLATABLE,
+ on_slideshow };
+ slideshow.label = _("S_lideshow");
+ slideshow.tooltip = _("Play a slideshow");
+ actions += slideshow;
+
+ Gtk.ActionEntry raw_developer = { "RawDeveloper", null, TRANSLATABLE, null, null, null };
+ raw_developer.label = _("_Developer");
+ actions += raw_developer;
+
+ // These are identical to add_tags and send_to, except that they have
+ // different mnemonics and are _only_ for use in the context menu.
+ Gtk.ActionEntry send_to_context_menu = { "SendToContextMenu", "document-send", TRANSLATABLE, null,
+ TRANSLATABLE, on_send_to };
+ send_to_context_menu.label = Resources.SEND_TO_CONTEXT_MENU;
+ actions += send_to_context_menu;
+
+ Gtk.ActionEntry add_tags_context_menu = { "AddTagsContextMenu", null, TRANSLATABLE, "<Ctrl>A", TRANSLATABLE,
+ on_add_tags };
+ add_tags_context_menu.label = Resources.ADD_TAGS_CONTEXT_MENU;
+ actions += add_tags_context_menu;
+
+ return actions;
+ }
+
+ protected override Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ Gtk.ToggleActionEntry[] toggle_actions = base.init_collect_toggle_action_entries();
+
+ Gtk.ToggleActionEntry ratings = { "ViewRatings", null, TRANSLATABLE, "<Ctrl><Shift>N",
+ TRANSLATABLE, on_display_ratings, Config.Facade.get_instance().get_display_photo_ratings() };
+ ratings.label = Resources.VIEW_RATINGS_MENU;
+ ratings.tooltip = Resources.VIEW_RATINGS_TOOLTIP;
+ toggle_actions += ratings;
+
+ return toggle_actions;
+ }
+
+ protected override InjectionGroup[] init_collect_injection_groups() {
+ InjectionGroup[] groups = base.init_collect_injection_groups();
+
+ InjectionGroup print_group = new InjectionGroup("/MenuBar/FileMenu/PrintPlaceholder");
+ print_group.add_menu_item("Print");
+
+ groups += print_group;
+
+ InjectionGroup publish_group = new InjectionGroup("/MenuBar/FileMenu/PublishPlaceholder");
+ publish_group.add_menu_item("Publish");
+
+ groups += publish_group;
+
+ InjectionGroup bg_group = new InjectionGroup("/MenuBar/FileMenu/SetBackgroundPlaceholder");
+ bg_group.add_menu_item("SetBackground");
+
+ groups += bg_group;
+
+ return groups;
+ }
+
+ protected override void register_radio_actions(Gtk.ActionGroup action_group) {
+ // RAW developer.
+ //get_config_photos_sort(out sort_order, out sort_by); // TODO: fetch default from config
+
+ Gtk.RadioActionEntry[] developer_actions = new Gtk.RadioActionEntry[0];
+
+ Gtk.RadioActionEntry dev_shotwell = { "RawDeveloperShotwell", null, TRANSLATABLE, null, TRANSLATABLE,
+ RawDeveloper.SHOTWELL };
+ string label_shotwell = RawDeveloper.SHOTWELL.get_label();
+ dev_shotwell.label = label_shotwell;
+ developer_actions += dev_shotwell;
+
+ Gtk.RadioActionEntry dev_camera = { "RawDeveloperCamera", null, TRANSLATABLE, null, TRANSLATABLE,
+ RawDeveloper.CAMERA };
+ string label_camera = RawDeveloper.CAMERA.get_label();
+ dev_camera.label = label_camera;
+ developer_actions += dev_camera;
+
+ action_group.add_radio_actions(developer_actions, RawDeveloper.SHOTWELL, on_raw_developer_changed);
+
+ base.register_radio_actions(action_group);
+ }
+
+ private void on_display_ratings(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_ratings(display);
+
+ Config.Facade.get_instance().set_display_photo_ratings(display);
+ repaint();
+ }
+
+ private void set_display_ratings(bool display) {
+ Gtk.ToggleAction? action = get_action("ViewRatings") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ bool multiple = get_view().get_count() > 1;
+ bool rotate_possible = has_photo() ? is_rotate_available(get_photo()) : false;
+ bool is_raw = has_photo() && get_photo().get_master_file_format() == PhotoFileFormat.RAW;
+
+ set_action_sensitive("ExternalEdit",
+ has_photo() && Config.Facade.get_instance().get_external_photo_app() != "");
+
+ set_action_sensitive("Revert", has_photo() ?
+ (get_photo().has_transformations() || get_photo().has_editable()) : false);
+
+ if (has_photo() && !get_photo_missing()) {
+ update_rating_menu_item_sensitivity();
+ update_development_menu_item_sensitivity();
+ }
+
+ set_action_sensitive("SetBackground", has_photo());
+
+ set_action_sensitive("CopyColorAdjustments", (has_photo() && get_photo().has_color_adjustments()));
+ set_action_sensitive("PasteColorAdjustments", PixelTransformationBundle.has_copied_color_adjustments());
+
+ set_action_sensitive("PrevPhoto", multiple);
+ set_action_sensitive("NextPhoto", multiple);
+ set_action_sensitive("RotateClockwise", rotate_possible);
+ set_action_sensitive("RotateCounterclockwise", rotate_possible);
+ set_action_sensitive("FlipHorizontally", rotate_possible);
+ set_action_sensitive("FlipVertically", rotate_possible);
+
+ if (has_photo()) {
+ set_action_sensitive("Crop", EditingTools.CropTool.is_available(get_photo(), Scaling.for_original()));
+ set_action_sensitive("RedEye", EditingTools.RedeyeTool.is_available(get_photo(),
+ Scaling.for_original()));
+ }
+
+ update_flag_action();
+
+ set_action_visible("ExternalEditRAW",
+ is_raw && Config.Facade.get_instance().get_external_raw_app() != "");
+
+ base.update_actions(selected_count, count);
+ }
+
+ private void on_photos_altered() {
+ set_action_sensitive("Revert", has_photo() ?
+ (get_photo().has_transformations() || get_photo().has_editable()) : false);
+ update_flag_action();
+ }
+
+ private void on_raw_developer_changed(Gtk.Action action, Gtk.Action current) {
+ developer_changed((RawDeveloper) ((Gtk.RadioAction) current).get_current_value());
+ }
+
+ protected virtual void developer_changed(RawDeveloper rd) {
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ Photo? photo = get_view().get_selected().get(0).get_source() as Photo;
+ if (photo == null || rd.is_equivalent(photo.get_raw_developer()))
+ return;
+
+ // Check if any photo has edits
+ // Display warning only when edits could be destroyed
+ if (!photo.has_transformations() || Dialogs.confirm_warn_developer_changed(1)) {
+ SetRawDeveloperCommand command = new SetRawDeveloperCommand(get_view().get_selected(),
+ rd);
+ get_command_manager().execute(command);
+
+ update_development_menu_item_sensitivity();
+ }
+ }
+
+ private void update_flag_action() {
+ if (has_photo()) {
+ Gtk.Action? action = get_action("Flag");
+ assert(action != null);
+
+ bool is_flagged = ((LibraryPhoto) get_photo()).is_flagged();
+
+ action.label = is_flagged ? Resources.UNFLAG_MENU : Resources.FLAG_MENU;
+ action.sensitive = true;
+ } else {
+ set_action_sensitive("Flag", false);
+ }
+ }
+
+ // Displays a photo from a specific CollectionPage. When the user exits this view,
+ // they will be sent back to the return_page. The optional view paramters is for using
+ // a ViewCollection other than the one inside return_page; this is necessary if the
+ // view and return_page have different filters.
+ public void display_for_collection(CollectionPage return_page, Photo photo,
+ ViewCollection? view = null) {
+ this.return_page = return_page;
+ return_page.destroy.connect(on_page_destroyed);
+
+ display_copy_of(view != null ? view : return_page.get_view(), photo);
+ }
+
+ public void on_page_destroyed() {
+ // The parent page was removed, so drop the reference to the page and
+ // its view collection.
+ return_page = null;
+ unset_view_collection();
+ }
+
+ public CollectionPage? get_controller_page() {
+ return return_page;
+ }
+
+ public override void switched_to() {
+ // since LibraryPhotoPages often rest in the background, their stored photo can be deleted by
+ // another page. this checks to make sure a display photo has been established before the
+ // switched_to call.
+ assert(get_photo() != null);
+
+ base.switched_to();
+
+ update_zoom_menu_item_sensitivity();
+ update_rating_menu_item_sensitivity();
+
+ set_display_ratings(Config.Facade.get_instance().get_display_photo_ratings());
+ }
+
+ protected override Gdk.Pixbuf? get_bottom_left_trinket(int scale) {
+ if (!has_photo() || !Config.Facade.get_instance().get_display_photo_ratings())
+ return null;
+
+ return Resources.get_rating_trinket(((LibraryPhoto) get_photo()).get_rating(), scale);
+ }
+
+ protected override Gdk.Pixbuf? get_top_right_trinket(int scale) {
+ if (!has_photo() || !((LibraryPhoto) get_photo()).is_flagged())
+ return null;
+
+ return Resources.get_icon(Resources.ICON_FLAGGED_TRINKET);
+ }
+
+ private void on_slideshow() {
+ LibraryPhoto? photo = (LibraryPhoto?) get_photo();
+ if (photo == null)
+ return;
+
+ AppWindow.get_instance().go_fullscreen(new SlideshowPage(LibraryPhoto.global, get_view(),
+ photo));
+ }
+
+ private void update_zoom_menu_item_sensitivity() {
+ set_action_sensitive("IncreaseSize", !get_zoom_state().is_max() && !get_photo_missing());
+ set_action_sensitive("DecreaseSize", !get_zoom_state().is_default() && !get_photo_missing());
+ }
+
+ protected override void on_increase_size() {
+ base.on_increase_size();
+
+ update_zoom_menu_item_sensitivity();
+ }
+
+ protected override void on_decrease_size() {
+ base.on_decrease_size();
+
+ update_zoom_menu_item_sensitivity();
+ }
+
+ protected override bool on_zoom_slider_key_press(Gdk.EventKey event) {
+ if (base.on_zoom_slider_key_press(event))
+ return true;
+
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ return_to_collection();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ protected override void update_ui(bool missing) {
+ bool sensitivity = !missing;
+
+ set_action_sensitive("SendTo", sensitivity);
+ set_action_sensitive("Publish", sensitivity);
+ set_action_sensitive("Print", sensitivity);
+ set_action_sensitive("CommonJumpToFile", sensitivity);
+
+ set_action_sensitive("CommonUndo", sensitivity);
+ set_action_sensitive("CommonRedo", sensitivity);
+
+ set_action_sensitive("IncreaseSize", sensitivity);
+ set_action_sensitive("DecreaseSize", sensitivity);
+ set_action_sensitive("ZoomFit", sensitivity);
+ set_action_sensitive("Zoom100", sensitivity);
+ set_action_sensitive("Zoom200", sensitivity);
+ set_action_sensitive("Slideshow", sensitivity);
+
+ set_action_sensitive("RotateClockwise", sensitivity);
+ set_action_sensitive("RotateCounterclockwise", sensitivity);
+ set_action_sensitive("FlipHorizontally", sensitivity);
+ set_action_sensitive("FlipVertically", sensitivity);
+ set_action_sensitive("Enhance", sensitivity);
+ set_action_sensitive("Crop", sensitivity);
+ set_action_sensitive("RedEye", sensitivity);
+ set_action_sensitive("Adjust", sensitivity);
+ set_action_sensitive("EditTitle", sensitivity);
+ set_action_sensitive("AdjustDateTime", sensitivity);
+ set_action_sensitive("ExternalEdit", sensitivity);
+ set_action_sensitive("ExternalEditRAW", sensitivity);
+ set_action_sensitive("Revert", sensitivity);
+
+ set_action_sensitive("Rate", sensitivity);
+ set_action_sensitive("Flag", sensitivity);
+ set_action_sensitive("AddTags", sensitivity);
+ set_action_sensitive("ModifyTags", sensitivity);
+
+ set_action_sensitive("SetBackground", sensitivity);
+
+ base.update_ui(missing);
+ }
+
+ protected override void notify_photo_backing_missing(Photo photo, bool missing) {
+ if (missing)
+ ((LibraryPhoto) photo).mark_offline();
+ else
+ ((LibraryPhoto) photo).mark_online();
+
+ base.notify_photo_backing_missing(photo, missing);
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ if (base.key_press_event != null && base.key_press_event(event) == true)
+ return true;
+
+ bool handled = true;
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Escape":
+ case "Return":
+ case "KP_Enter":
+ if (!(get_container() is FullscreenWindow))
+ return_to_collection();
+ break;
+
+ case "Delete":
+ // although bound as an accelerator in the menu, accelerators are currently
+ // unavailable in fullscreen mode (a variant of #324), so we do this manually
+ // here
+ activate_action("MoveToTrash");
+ break;
+
+ case "period":
+ case "greater":
+ activate_action("IncreaseRating");
+ break;
+
+ case "comma":
+ case "less":
+ activate_action("DecreaseRating");
+ break;
+
+ case "KP_1":
+ activate_action("RateOne");
+ break;
+
+ case "KP_2":
+ activate_action("RateTwo");
+ break;
+
+ case "KP_3":
+ activate_action("RateThree");
+ break;
+
+ case "KP_4":
+ activate_action("RateFour");
+ break;
+
+ case "KP_5":
+ activate_action("RateFive");
+ break;
+
+ case "KP_0":
+ activate_action("RateUnrated");
+ break;
+
+ case "KP_9":
+ activate_action("RateRejected");
+ break;
+
+ case "bracketright":
+ activate_action("RotateClockwise");
+ break;
+
+ case "bracketleft":
+ activate_action("RotateCounterclockwise");
+ break;
+
+ case "slash":
+ activate_action("Flag");
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ return handled;
+ }
+
+ protected override bool on_double_click(Gdk.EventButton event) {
+ if (!(get_container() is FullscreenWindow)) {
+ return_to_collection_on_release = true;
+
+ return true;
+ }
+
+ AppWindow.get_instance().end_fullscreen();
+
+ return base.on_double_click(event);
+ }
+
+ protected override bool on_left_released(Gdk.EventButton event) {
+ if (return_to_collection_on_release) {
+ return_to_collection_on_release = false;
+ return_to_collection();
+
+ return true;
+ }
+
+ return base.on_left_released(event);
+ }
+
+ private Gtk.Menu get_context_menu() {
+ Gtk.Menu menu = (Gtk.Menu) ui.get_widget("/PhotoContextMenu");
+ assert(menu != null);
+ return menu;
+ }
+
+ protected override bool on_context_buttonpress(Gdk.EventButton event) {
+ popup_context_menu(get_context_menu(), event);
+
+ return true;
+ }
+
+ protected override bool on_context_keypress() {
+ popup_context_menu(get_context_menu());
+
+ return true;
+ }
+
+ private void return_to_collection() {
+ // Return to the previous page if it exists.
+ if (null != return_page)
+ LibraryWindow.get_app().switch_to_page(return_page);
+ else
+ LibraryWindow.get_app().switch_to_library_page();
+ }
+
+ private void on_remove_from_library() {
+ LibraryPhoto photo = (LibraryPhoto) get_photo();
+
+ Gee.Collection<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ photos.add(photo);
+
+ remove_from_app(photos, _("Remove From Library"), _("Removing Photo From Library"));
+ }
+
+ private void on_move_to_trash() {
+ if (!has_photo())
+ return;
+
+ // Temporarily prevent the application from switching pages if we're viewing
+ // the current photo from within an Event page. This is needed because the act of
+ // trashing images from an Event causes it to be renamed, which causes it to change
+ // positions in the sidebar, and the selection moves with it, causing the app to
+ // inappropriately switch to the Event page.
+ if (return_page is EventPage) {
+ LibraryWindow.get_app().set_page_switching_enabled(false);
+ }
+
+ LibraryPhoto photo = (LibraryPhoto) get_photo();
+
+ Gee.Collection<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ photos.add(photo);
+
+ // move on to next photo before executing
+ on_next_photo();
+
+ // this indicates there is only one photo in the controller, or about to be zero, so switch
+ // to the library page, which is guaranteed to be there when this disappears
+ if (photo.equals(get_photo())) {
+ // If this is the last photo in an Event, then trashing it
+ // _should_ cause us to switch pages, so re-enable it here.
+ LibraryWindow.get_app().set_page_switching_enabled(true);
+
+ if (get_container() is FullscreenWindow)
+ ((FullscreenWindow) get_container()).close();
+
+ LibraryWindow.get_app().switch_to_library_page();
+ }
+
+ get_command_manager().execute(new TrashUntrashPhotosCommand(photos, true));
+ LibraryWindow.get_app().set_page_switching_enabled(true);
+ }
+
+ private void on_flag_unflag() {
+ if (has_photo()) {
+ Gee.ArrayList<DataSource> photo_list = new Gee.ArrayList<DataSource>();
+ photo_list.add(get_photo());
+ get_command_manager().execute(new FlagUnflagCommand(photo_list,
+ !((LibraryPhoto) get_photo()).is_flagged()));
+ }
+ }
+
+ private void on_photo_destroyed(DataSource source) {
+ on_photo_removed((LibraryPhoto) source);
+ }
+
+ private void on_photo_removed(LibraryPhoto photo) {
+ // only interested in current photo
+ if (photo == null || !photo.equals(get_photo()))
+ return;
+
+ // move on to the next one in the collection
+ on_next_photo();
+ if (photo.equals(get_photo())) {
+ // this indicates there is only one photo in the controller, or now zero, so switch
+ // to the Photos page, which is guaranteed to be there
+ LibraryWindow.get_app().switch_to_library_page();
+ }
+ }
+
+ private void on_print() {
+ if (get_view().get_selected_count() > 0) {
+ PrintManager.get_instance().spool_photo(
+ (Gee.Collection<Photo>) get_view().get_selected_sources_of_type(typeof(Photo)));
+ }
+ }
+
+ private void on_external_app_changed() {
+ set_action_sensitive("ExternalEdit", has_photo() &&
+ Config.Facade.get_instance().get_external_photo_app() != "");
+ }
+
+ private void on_external_edit() {
+ if (!has_photo())
+ return;
+
+ try {
+ AppWindow.get_instance().set_busy_cursor();
+ get_photo().open_with_external_editor();
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ open_external_editor_error_dialog(err, get_photo());
+ }
+
+ }
+
+ private void on_external_edit_raw() {
+ if (!has_photo())
+ return;
+
+ if (get_photo().get_master_file_format() != PhotoFileFormat.RAW)
+ return;
+
+ try {
+ AppWindow.get_instance().set_busy_cursor();
+ get_photo().open_with_raw_external_editor();
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ AppWindow.error_message(Resources.launch_editor_failed(err));
+ }
+ }
+
+ private void on_send_to() {
+ if (has_photo())
+ DesktopIntegration.send_to((Gee.Collection<Photo>) get_view().get_selected_sources());
+ }
+
+ private void on_export() {
+ if (!has_photo())
+ return;
+
+ ExportDialog export_dialog = new ExportDialog(_("Export Photo"));
+
+ int scale;
+ ScaleConstraint constraint;
+ ExportFormatParameters export_params = ExportFormatParameters.last();
+ if (!export_dialog.execute(out scale, out constraint, ref export_params))
+ return;
+
+ File save_as =
+ ExportUI.choose_file(get_photo().get_export_basename_for_parameters(export_params));
+ if (save_as == null)
+ return;
+
+ Scaling scaling = Scaling.for_constraint(constraint, scale, false);
+
+ try {
+ get_photo().export(save_as, scaling, export_params.quality,
+ get_photo().get_export_format_for_parameters(export_params),
+ export_params.mode == ExportFormatMode.UNMODIFIED, export_params.export_metadata);
+ } catch (Error err) {
+ AppWindow.error_message(_("Unable to export %s: %s").printf(save_as.get_path(), err.message));
+ }
+ }
+
+ private void on_publish() {
+ if (get_view().get_count() > 0)
+ PublishingUI.PublishingDialog.go(
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources());
+ }
+
+ private void on_view_menu() {
+ update_zoom_menu_item_sensitivity();
+ }
+
+ private void on_increase_rating() {
+ if (!has_photo() || get_photo_missing())
+ return;
+
+ SetRatingSingleCommand command = new SetRatingSingleCommand.inc_dec(get_photo(), true);
+ get_command_manager().execute(command);
+
+ update_rating_menu_item_sensitivity();
+ }
+
+ private void on_decrease_rating() {
+ if (!has_photo() || get_photo_missing())
+ return;
+
+ SetRatingSingleCommand command = new SetRatingSingleCommand.inc_dec(get_photo(), false);
+ get_command_manager().execute(command);
+
+ update_rating_menu_item_sensitivity();
+ }
+
+ private void on_set_rating(Rating rating) {
+ if (!has_photo() || get_photo_missing())
+ return;
+
+ SetRatingSingleCommand command = new SetRatingSingleCommand(get_photo(), rating);
+ get_command_manager().execute(command);
+
+ update_rating_menu_item_sensitivity();
+ }
+
+ private void on_rate_rejected() {
+ on_set_rating(Rating.REJECTED);
+ }
+
+ private void on_rate_unrated() {
+ on_set_rating(Rating.UNRATED);
+ }
+
+ private void on_rate_one() {
+ on_set_rating(Rating.ONE);
+ }
+
+ private void on_rate_two() {
+ on_set_rating(Rating.TWO);
+ }
+
+ private void on_rate_three() {
+ on_set_rating(Rating.THREE);
+ }
+
+ private void on_rate_four() {
+ on_set_rating(Rating.FOUR);
+ }
+
+ private void on_rate_five() {
+ on_set_rating(Rating.FIVE);
+ }
+
+ private void update_rating_menu_item_sensitivity() {
+ set_action_sensitive("RateRejected", get_photo().get_rating() != Rating.REJECTED);
+ set_action_sensitive("RateUnrated", get_photo().get_rating() != Rating.UNRATED);
+ set_action_sensitive("RateOne", get_photo().get_rating() != Rating.ONE);
+ set_action_sensitive("RateTwo", get_photo().get_rating() != Rating.TWO);
+ set_action_sensitive("RateThree", get_photo().get_rating() != Rating.THREE);
+ set_action_sensitive("RateFour", get_photo().get_rating() != Rating.FOUR);
+ set_action_sensitive("RateFive", get_photo().get_rating() != Rating.FIVE);
+ set_action_sensitive("IncreaseRating", get_photo().get_rating().can_increase());
+ set_action_sensitive("DecreaseRating", get_photo().get_rating().can_decrease());
+ }
+
+ private void update_development_menu_item_sensitivity() {
+ PhotoFileFormat format = get_photo().get_master_file_format() ;
+ set_action_sensitive("RawDeveloper", format == PhotoFileFormat.RAW);
+
+ if (format == PhotoFileFormat.RAW) {
+ // Set which developers are available.
+ set_action_sensitive("RawDeveloperShotwell",
+ get_photo().is_raw_developer_available(RawDeveloper.SHOTWELL));
+ set_action_sensitive("RawDeveloperCamera",
+ get_photo().is_raw_developer_available(RawDeveloper.EMBEDDED) ||
+ get_photo().is_raw_developer_available(RawDeveloper.CAMERA));;
+
+ // Set active developer in menu.
+ switch (get_photo().get_raw_developer()) {
+ case RawDeveloper.SHOTWELL:
+ activate_action("RawDeveloperShotwell");
+ break;
+
+ case RawDeveloper.CAMERA:
+ case RawDeveloper.EMBEDDED:
+ activate_action("RawDeveloperCamera");
+ break;
+
+ default:
+ assert_not_reached();
+ }
+ }
+ }
+
+ private void on_metadata_altered(Gee.Map<DataObject, Alteration> map) {
+ if (map.has_key(get_photo()) && map.get(get_photo()).has_subject("metadata"))
+ repaint();
+ }
+
+ private void on_add_tags() {
+ AddTagsDialog dialog = new AddTagsDialog();
+ string[]? names = dialog.execute();
+ if (names != null) {
+ get_command_manager().execute(new AddTagsCommand(
+ HierarchicalTagIndex.get_global_index().get_paths_for_names_array(names),
+ (Gee.Collection<LibraryPhoto>) get_view().get_selected_sources()));
+ }
+ }
+
+ private void on_modify_tags() {
+ LibraryPhoto photo = (LibraryPhoto) get_view().get_selected_at(0).get_source();
+
+ ModifyTagsDialog dialog = new ModifyTagsDialog(photo);
+ Gee.ArrayList<Tag>? new_tags = dialog.execute();
+
+ if (new_tags == null)
+ return;
+
+ get_command_manager().execute(new ModifyTagsCommand(photo, new_tags));
+ }
+
+}
+
diff --git a/src/PixbufCache.vala b/src/PixbufCache.vala
new file mode 100644
index 0000000..8b8f276
--- /dev/null
+++ b/src/PixbufCache.vala
@@ -0,0 +1,360 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class PixbufCache : Object {
+ public delegate bool CacheFilter(Photo photo);
+
+ public enum PhotoType {
+ BASELINE,
+ MASTER
+ }
+
+ public class PixbufCacheBatch : Gee.TreeMultiMap<BackgroundJob.JobPriority, Photo> {
+ public PixbufCacheBatch() {
+ base (BackgroundJob.JobPriority.compare_func);
+ }
+ }
+
+ private abstract class FetchJob : BackgroundJob {
+ public BackgroundJob.JobPriority priority;
+ public Photo photo;
+ public Scaling scaling;
+ public Gdk.Pixbuf pixbuf = null;
+ public Error err = null;
+
+ public FetchJob(PixbufCache owner, BackgroundJob.JobPriority priority, Photo photo,
+ Scaling scaling, CompletionCallback callback) {
+ base(owner, callback, new Cancellable(), null, new Semaphore());
+
+ this.priority = priority;
+ this.photo = photo;
+ this.scaling = scaling;
+ }
+
+ public override BackgroundJob.JobPriority get_priority() {
+ return priority;
+ }
+ }
+
+ private class BaselineFetchJob : FetchJob {
+ public BaselineFetchJob(PixbufCache owner, BackgroundJob.JobPriority priority, Photo photo,
+ Scaling scaling, CompletionCallback callback) {
+ base(owner, priority, photo, scaling, callback);
+ }
+
+ public override void execute() {
+ try {
+ pixbuf = photo.get_pixbuf(scaling);
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+ }
+
+ private class MasterFetchJob : FetchJob {
+ public MasterFetchJob(PixbufCache owner, BackgroundJob.JobPriority priority, Photo photo,
+ Scaling scaling, CompletionCallback callback) {
+ base(owner, priority, photo, scaling, callback);
+ }
+
+ public override void execute() {
+ try {
+ pixbuf = photo.get_master_pixbuf(scaling);
+ } catch (Error err) {
+ this.err = err;
+ }
+ }
+ }
+
+ private static Workers background_workers = null;
+
+ private SourceCollection sources;
+ private PhotoType type;
+ private int max_count;
+ private Scaling scaling;
+ private unowned CacheFilter? filter;
+ private Gee.HashMap<Photo, Gdk.Pixbuf> cache = new Gee.HashMap<Photo, Gdk.Pixbuf>();
+ private Gee.ArrayList<Photo> lru = new Gee.ArrayList<Photo>();
+ private Gee.HashMap<Photo, FetchJob> in_progress = new Gee.HashMap<Photo, FetchJob>();
+
+ public signal void fetched(Photo photo, Gdk.Pixbuf? pixbuf, Error? err);
+
+ public PixbufCache(SourceCollection sources, PhotoType type, Scaling scaling, int max_count,
+ CacheFilter? filter = null) {
+ this.sources = sources;
+ this.type = type;
+ this.scaling = scaling;
+ this.max_count = max_count;
+ this.filter = filter;
+
+ assert(max_count > 0);
+
+ if (background_workers == null)
+ background_workers = new Workers(Workers.thread_per_cpu_minus_one(), false);
+
+ // monitor changes in the photos to discard from cache ... only interested in changes if
+ // not master files
+ if (type != PhotoType.MASTER)
+ sources.items_altered.connect(on_sources_altered);
+ sources.items_removed.connect(on_sources_removed);
+ }
+
+ ~PixbufCache() {
+#if TRACE_PIXBUF_CACHE
+ debug("Freeing %d pixbufs and cancelling %d jobs", cache.size, in_progress.size);
+#endif
+
+ if (type != PhotoType.MASTER)
+ sources.items_altered.disconnect(on_sources_altered);
+ sources.items_removed.disconnect(on_sources_removed);
+
+ foreach (FetchJob job in in_progress.values)
+ job.cancel();
+ }
+
+ public Scaling get_scaling() {
+ return scaling;
+ }
+
+ // This call never blocks. Returns null if the pixbuf is not present.
+ public Gdk.Pixbuf? get_ready_pixbuf(Photo photo) {
+ return get_cached(photo);
+ }
+
+ // This call can potentially block if the pixbuf is not in the cache. Once loaded, it will
+ // be cached. No signal is fired.
+ public Gdk.Pixbuf? fetch(Photo photo) throws Error {
+ if (!photo.get_actual_file().query_exists(null))
+ decache(photo);
+
+ Gdk.Pixbuf pixbuf = get_cached(photo);
+ if (pixbuf != null) {
+#if TRACE_PIXBUF_CACHE
+ debug("Fetched in-memory pixbuf for %s @ %s", photo.to_string(), scaling.to_string());
+#endif
+
+ return pixbuf;
+ }
+
+ FetchJob? job = in_progress.get(photo);
+ if (job != null) {
+ job.wait_for_completion();
+ if (job.err != null)
+ throw job.err;
+
+ return job.pixbuf;
+ }
+
+#if TRACE_PIXBUF_CACHE
+ debug("Forced to make a blocking fetch of %s @ %s", photo.to_string(), scaling.to_string());
+#endif
+
+ pixbuf = photo.get_pixbuf(scaling);
+
+ encache(photo, pixbuf);
+
+ return pixbuf;
+ }
+
+ // This can be used to clear specific pixbufs from the cache, allowing finer control over what
+ // pixbufs remain and avoid being dropped when other fetches follow. It implicitly cancels
+ // any outstanding prefetches for the photo.
+ public void drop(Photo photo) {
+ cancel_prefetch(photo);
+ decache(photo);
+ }
+
+ // This call signals the cache to pre-load the pixbuf for the photo. When loaded the fetched
+ // signal is fired.
+ public void prefetch(Photo photo,
+ BackgroundJob.JobPriority priority = BackgroundJob.JobPriority.NORMAL, bool force = false) {
+ if (!photo.get_actual_file().query_exists(null))
+ decache(photo);
+
+ if (!force && cache.has_key(photo)) {
+ prioritize(photo);
+
+ return;
+ }
+
+ if (in_progress.has_key(photo))
+ return;
+
+ if (filter != null && !filter(photo))
+ return;
+
+ FetchJob job = null;
+ switch (type) {
+ case PhotoType.BASELINE:
+ job = new BaselineFetchJob(this, priority, photo, scaling, on_fetched);
+ break;
+
+ case PhotoType.MASTER:
+ job = new MasterFetchJob(this, priority, photo, scaling, on_fetched);
+ break;
+
+ default:
+ error("Unknown photo type: %d", (int) type);
+ }
+
+ in_progress.set(photo, job);
+
+ background_workers.enqueue(job);
+ }
+
+ // This call signals the cache to pre-load the pixbufs for all supplied photos. Each fires
+ // the fetch signal as they arrive.
+ public void prefetch_many(Gee.Collection<Photo> photos,
+ BackgroundJob.JobPriority priority = BackgroundJob.JobPriority.NORMAL, bool force = false) {
+ foreach (Photo photo in photos)
+ prefetch(photo, priority, force);
+ }
+
+ // Like prefetch_many, but allows for priorities to be set for each photo
+ public void prefetch_batch(PixbufCacheBatch batch, bool force = false) {
+ foreach (BackgroundJob.JobPriority priority in batch.get_keys()) {
+ foreach (Photo photo in batch.get(priority))
+ prefetch(photo, priority, force);
+ }
+ }
+
+ public bool cancel_prefetch(Photo photo) {
+ FetchJob job = in_progress.get(photo);
+ if (job == null)
+ return false;
+
+ // remove here because if fully cancelled the callback is never called
+ bool removed = in_progress.unset(photo);
+ assert(removed);
+
+ job.cancel();
+
+#if TRACE_PIXBUF_CACHE
+ debug("Cancelled prefetch of %s @ %s", photo.to_string(), scaling.to_string());
+#endif
+
+ return true;
+ }
+
+ public void cancel_all() {
+#if TRACE_PIXBUF_CACHE
+ debug("Cancelling prefetch of %d photos at %s", in_progress.values.size, scaling.to_string());
+#endif
+ foreach (FetchJob job in in_progress.values)
+ job.cancel();
+
+ in_progress.clear();
+ }
+
+ private void on_fetched(BackgroundJob j) {
+ FetchJob job = (FetchJob) j;
+
+ // remove Cancellable from in_progress list, but don't assert on it because it's possible
+ // the cancel was called after the task completed
+ in_progress.unset(job.photo);
+
+ if (job.err != null) {
+ assert(job.pixbuf == null);
+
+ critical("Unable to readahead %s: %s", job.photo.to_string(), job.err.message);
+ fetched(job.photo, null, job.err);
+
+ return;
+ }
+
+ encache(job.photo, job.pixbuf);
+
+ // fire signal
+ fetched(job.photo, job.pixbuf, null);
+ }
+
+ private void on_sources_altered(Gee.Map<DataObject, Alteration> map) {
+ foreach (DataObject object in map.keys) {
+ if (!map.get(object).has_subject("image"))
+ continue;
+
+ Photo photo = (Photo) object;
+
+ if (in_progress.has_key(photo)) {
+ // Load is in progress, must cancel.
+ in_progress.get(photo).cancel();
+ in_progress.unset(photo);
+ continue;
+ }
+
+ // only interested if in this cache
+ if (!cache.has_key(photo))
+ continue;
+
+ decache(photo);
+
+#if TRACE_PIXBUF_CACHE
+ debug("Re-fetching altered pixbuf from cache: %s @ %s", photo.to_string(),
+ scaling.to_string());
+#endif
+
+ prefetch(photo, BackgroundJob.JobPriority.HIGH);
+ }
+ }
+
+ private void on_sources_removed(Gee.Iterable<DataObject> removed) {
+ foreach (DataObject object in removed) {
+ Photo photo = object as Photo;
+ assert(photo != null);
+
+ decache(photo);
+ }
+ }
+
+ private Gdk.Pixbuf? get_cached(Photo photo) {
+ Gdk.Pixbuf pixbuf = cache.get(photo);
+ if (pixbuf != null)
+ prioritize(photo);
+
+ return pixbuf;
+ }
+
+ // Moves the photo up in the cache LRU. Assumes photo is actually in cache.
+ private void prioritize(Photo photo) {
+ int index = lru.index_of(photo);
+ assert(index >= 0);
+
+ if (index > 0) {
+ lru.remove_at(index);
+ lru.insert(0, photo);
+ }
+ }
+
+ private void encache(Photo photo, Gdk.Pixbuf pixbuf) {
+ // if already in cache, remove (means it was re-fetched, probably due to modification)
+ decache(photo);
+
+ cache.set(photo, pixbuf);
+ lru.insert(0, photo);
+
+ while (lru.size > max_count) {
+ Photo cached_photo = lru.remove_at(lru.size - 1);
+ assert(cached_photo != null);
+
+ bool removed = cache.unset(cached_photo);
+ assert(removed);
+ }
+
+ assert(lru.size == cache.size);
+ }
+
+ private void decache(Photo photo) {
+ if (!cache.unset(photo)) {
+ assert(!lru.contains(photo));
+
+ return;
+ }
+
+ bool removed = lru.remove(photo);
+ assert(removed);
+ }
+}
+
diff --git a/src/Printing.vala b/src/Printing.vala
new file mode 100644
index 0000000..8e37997
--- /dev/null
+++ b/src/Printing.vala
@@ -0,0 +1,1156 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public enum ContentLayout {
+ STANDARD_SIZE,
+ CUSTOM_SIZE,
+ IMAGE_PER_PAGE
+}
+
+public class PrintSettings {
+ public const int MIN_CONTENT_PPI = 72; /* 72 ppi is the pixel resolution of a 14" VGA
+ display -- it's standard for historical reasons */
+ public const int MAX_CONTENT_PPI = 1200; /* 1200 ppi is appropriate for a 3600 dpi imagesetter
+ used to produce photographic plates for commercial
+ printing -- it's the highest pixel resolution
+ commonly used */
+ private ContentLayout content_layout;
+ private Measurement content_width;
+ private Measurement content_height;
+ private int content_ppi;
+ private int image_per_page_selection;
+ private int size_selection;
+ private bool match_aspect_ratio;
+ private bool print_titles;
+ private string print_titles_font;
+
+ public PrintSettings() {
+ Config.Facade config = Config.Facade.get_instance();
+
+ MeasurementUnit units = (MeasurementUnit) config.get_printing_content_units();
+
+ content_width = Measurement(config.get_printing_content_width(), units);
+ content_height = Measurement(config.get_printing_content_height(), units);
+ size_selection = config.get_printing_size_selection();
+ content_layout = (ContentLayout) config.get_printing_content_layout();
+ match_aspect_ratio = config.get_printing_match_aspect_ratio();
+ print_titles = config.get_printing_print_titles();
+ print_titles_font = config.get_printing_titles_font();
+ image_per_page_selection = config.get_printing_images_per_page();
+ content_ppi = config.get_printing_content_ppi();
+ }
+
+ public void save() {
+ Config.Facade config = Config.Facade.get_instance();
+
+ config.set_printing_content_units(content_width.unit);
+ config.set_printing_content_width(content_width.value);
+ config.set_printing_content_height(content_height.value);
+ config.set_printing_size_selection(size_selection);
+ config.set_printing_content_layout(content_layout);
+ config.set_printing_match_aspect_ratio(match_aspect_ratio);
+ config.set_printing_print_titles(print_titles);
+ config.set_printing_titles_font(print_titles_font);
+ config.set_printing_images_per_page(image_per_page_selection);
+ config.set_printing_content_ppi(content_ppi);
+ }
+
+
+ public Measurement get_content_width() {
+ switch (get_content_layout()) {
+ case ContentLayout.STANDARD_SIZE:
+ case ContentLayout.IMAGE_PER_PAGE:
+ return (PrintManager.get_instance().get_standard_sizes()[
+ get_size_selection()]).width;
+
+ case ContentLayout.CUSTOM_SIZE:
+ return content_width;
+
+ default:
+ error("unknown ContentLayout enumeration value");
+ }
+ }
+
+ public Measurement get_content_height() {
+ switch (get_content_layout()) {
+ case ContentLayout.STANDARD_SIZE:
+ case ContentLayout.IMAGE_PER_PAGE:
+ return (PrintManager.get_instance().get_standard_sizes()[
+ get_size_selection()]).height;
+
+ case ContentLayout.CUSTOM_SIZE:
+ return content_height;
+
+ default:
+ error("unknown ContentLayout enumeration value");
+ }
+ }
+
+ public Measurement get_minimum_content_dimension() {
+ return Measurement(0.5, MeasurementUnit.INCHES);
+ }
+
+ public Measurement get_maximum_content_dimension() {
+ return Measurement(30, MeasurementUnit.INCHES);
+ }
+
+ public bool is_match_aspect_ratio_enabled() {
+ return match_aspect_ratio;
+ }
+
+ public bool is_print_titles_enabled() {
+ return print_titles;
+ }
+
+ public int get_content_ppi() {
+ return content_ppi;
+ }
+
+ public int get_image_per_page_selection() {
+ return image_per_page_selection;
+ }
+
+ public int get_size_selection() {
+ return size_selection;
+ }
+
+ public ContentLayout get_content_layout() {
+ return content_layout;
+ }
+
+ public void set_content_layout(ContentLayout content_layout) {
+ this.content_layout = content_layout;
+ }
+
+ public void set_content_width(Measurement content_width) {
+ this.content_width = content_width;
+ }
+
+ public void set_content_height(Measurement content_height) {
+ this.content_height = content_height;
+ }
+
+ public void set_content_ppi(int content_ppi) {
+ this.content_ppi = content_ppi;
+ }
+
+ public void set_image_per_page_selection(int image_per_page_selection) {
+ this.image_per_page_selection = image_per_page_selection;
+ }
+
+ public void set_size_selection(int size_selection) {
+ this.size_selection = size_selection;
+ }
+
+ public void set_match_aspect_ratio_enabled(bool enable_state) {
+ this.match_aspect_ratio = enable_state;
+ }
+
+ public void set_print_titles_enabled(bool print_titles) {
+ this.print_titles = print_titles;
+ }
+
+ public void set_print_titles_font(string fontname) {
+ this.print_titles_font = fontname;
+ }
+
+ public string get_print_titles_font() {
+ return this.print_titles_font;
+ }
+}
+
+/* we define our own measurement enum instead of using the Gtk.Unit enum
+ provided by Gtk+ 2.0 because Gtk.Unit doesn't define a CENTIMETERS
+ constant (thout it does define an MM for millimeters). This is
+ unfortunate, because in metric countries people like to think about
+ paper sizes for printing in CM not MM. so, to avoid having to
+ multiply and divide everything by 10 (which is error prone) to convert
+ from CM to MM and vice-versa whenever we read or write measurements, we
+ eschew Gtk.Unit and substitute our own */
+public enum MeasurementUnit {
+ INCHES,
+ CENTIMETERS
+}
+
+public struct Measurement {
+ private const double CENTIMETERS_PER_INCH = 2.54;
+ private const double INCHES_PER_CENTIMETER = (1.0 / 2.54);
+
+ public double value;
+ public MeasurementUnit unit;
+
+ public Measurement(double value, MeasurementUnit unit) {
+ this.value = value;
+ this.unit = unit;
+ }
+
+ public Measurement convert_to(MeasurementUnit to_unit) {
+ if (unit == to_unit)
+ return this;
+
+ if (to_unit == MeasurementUnit.INCHES) {
+ return Measurement(value * INCHES_PER_CENTIMETER, MeasurementUnit.INCHES);
+ } else if (to_unit == MeasurementUnit.CENTIMETERS) {
+ return Measurement(value * CENTIMETERS_PER_INCH, MeasurementUnit.CENTIMETERS);
+ } else {
+ error("unrecognized unit");
+ }
+ }
+
+ public bool is_less_than(Measurement rhs) {
+ Measurement converted_rhs = (unit == rhs.unit) ? rhs : rhs.convert_to(unit);
+ return (value < converted_rhs.value);
+ }
+
+ public bool is_greater_than(Measurement rhs) {
+ Measurement converted_rhs = (unit == rhs.unit) ? rhs : rhs.convert_to(unit);
+ return (value > converted_rhs.value);
+ }
+}
+
+private enum PrintLayout {
+ ENTIRE_PAGE,
+ TWO_PER_PAGE,
+ FOUR_PER_PAGE,
+ SIX_PER_PAGE,
+ EIGHT_PER_PAGE,
+ SIXTEEN_PER_PAGE,
+ THIRTY_TWO_PER_PAGE;
+
+ public static PrintLayout[] get_all() {
+ return {
+ ENTIRE_PAGE,
+ TWO_PER_PAGE,
+ FOUR_PER_PAGE,
+ SIX_PER_PAGE,
+ EIGHT_PER_PAGE,
+ SIXTEEN_PER_PAGE,
+ THIRTY_TWO_PER_PAGE
+ };
+ }
+
+ public int get_per_page() {
+ int[] per_page = { 1, 2, 4, 6, 8, 16, 32 };
+
+ return per_page[this];
+ }
+
+ public int get_x() {
+ int[] x = { 1, 1, 2, 2, 2, 4, 4 };
+
+ return x[this];
+ }
+
+ public int get_y() {
+ int[] y = { 1, 2, 2, 3, 4, 4, 8 };
+
+ return y[this];
+ }
+
+ public string to_string() {
+ string[] labels = {
+ _("Fill the entire page"),
+ _("2 images per page"),
+ _("4 images per page"),
+ _("6 images per page"),
+ _("8 images per page"),
+ _("16 images per page"),
+ _("32 images per page")
+ };
+
+ return labels[this];
+ }
+}
+
+public class CustomPrintTab : Gtk.Fixed {
+ private const int INCHES_COMBO_CHOICE = 0;
+ private const int CENTIMETERS_COMBO_CHOICE = 1;
+
+ private Gtk.Box custom_image_settings_pane = null;
+ private Gtk.RadioButton standard_size_radio = null;
+ private Gtk.RadioButton custom_size_radio = null;
+ private Gtk.RadioButton image_per_page_radio = null;
+ private Gtk.ComboBox image_per_page_combo = null;
+ private Gtk.ComboBox standard_sizes_combo = null;
+ private Gtk.ComboBoxText units_combo = null;
+ private Gtk.Entry custom_width_entry = null;
+ private Gtk.Entry custom_height_entry = null;
+ private Gtk.Entry ppi_entry;
+ private Gtk.CheckButton aspect_ratio_check = null;
+ private Gtk.CheckButton title_print_check = null;
+ private Gtk.FontButton title_print_font = null;
+ private Measurement local_content_width = Measurement(5.0, MeasurementUnit.INCHES);
+ private Measurement local_content_height = Measurement(5.0, MeasurementUnit.INCHES);
+ private int local_content_ppi;
+ private bool is_text_insertion_in_progress = false;
+ private PrintJob source_job;
+
+ public CustomPrintTab(PrintJob source_job) {
+ this.source_job = source_job;
+ Gtk.Builder builder = AppWindow.create_builder();
+
+ // an enclosing box for every widget on this tab...
+ custom_image_settings_pane = builder.get_object("box_ImgSettingsPane") as Gtk.Box;
+
+ standard_size_radio = builder.get_object("radio_UseStandardSize") as Gtk.RadioButton;
+ standard_size_radio.clicked.connect(on_radio_group_click);
+
+ custom_size_radio = builder.get_object("radio_UseCustomSize") as Gtk.RadioButton;
+ custom_size_radio.clicked.connect(on_radio_group_click);
+
+ image_per_page_radio = builder.get_object("radio_Autosize") as Gtk.RadioButton;
+ image_per_page_radio.clicked.connect(on_radio_group_click);
+
+ image_per_page_combo = builder.get_object("combo_Autosize") as Gtk.ComboBox;
+ Gtk.CellRendererText image_per_page_combo_text_renderer =
+ new Gtk.CellRendererText();
+ image_per_page_combo.pack_start(image_per_page_combo_text_renderer, true);
+ image_per_page_combo.add_attribute(image_per_page_combo_text_renderer,
+ "text", 0);
+ Gtk.ListStore image_per_page_combo_store = new Gtk.ListStore(2, typeof(string),
+ typeof(string));
+ foreach (PrintLayout layout in PrintLayout.get_all()) {
+ Gtk.TreeIter iter;
+ image_per_page_combo_store.append(out iter);
+ image_per_page_combo_store.set_value(iter, 0, layout.to_string());
+ }
+ image_per_page_combo.set_model(image_per_page_combo_store);
+
+ StandardPrintSize[] standard_sizes = PrintManager.get_instance().get_standard_sizes();
+ standard_sizes_combo = builder.get_object("combo_StdSizes") as Gtk.ComboBox;
+ Gtk.CellRendererText standard_sizes_combo_text_renderer =
+ new Gtk.CellRendererText();
+ standard_sizes_combo.pack_start(standard_sizes_combo_text_renderer, true);
+ standard_sizes_combo.add_attribute(standard_sizes_combo_text_renderer,
+ "text", 0);
+ standard_sizes_combo.set_row_separator_func(standard_sizes_combo_separator_func);
+ Gtk.ListStore standard_sizes_combo_store = new Gtk.ListStore(1, typeof(string),
+ typeof(string));
+ foreach (StandardPrintSize size in standard_sizes) {
+ Gtk.TreeIter iter;
+ standard_sizes_combo_store.append(out iter);
+ standard_sizes_combo_store.set_value(iter, 0, size.name);
+ }
+ standard_sizes_combo.set_model(standard_sizes_combo_store);
+
+ custom_width_entry = builder.get_object("entry_CustomWidth") as Gtk.Entry;
+ custom_width_entry.insert_text.connect(on_entry_insert_text);
+ custom_width_entry.focus_out_event.connect(on_width_entry_focus_out);
+
+ custom_height_entry = builder.get_object("entry_CustomHeight") as Gtk.Entry;
+ custom_height_entry.insert_text.connect(on_entry_insert_text);
+ custom_height_entry.focus_out_event.connect(on_height_entry_focus_out);
+
+ units_combo = builder.get_object("combo_Units") as Gtk.ComboBoxText;
+ units_combo.append_text(_("in."));
+ units_combo.append_text(_("cm"));
+ units_combo.set_active(0);
+ units_combo.changed.connect(on_units_combo_changed);
+
+ aspect_ratio_check = builder.get_object("check_MatchAspectRatio") as Gtk.CheckButton;
+ title_print_check = builder.get_object("check_PrintImageTitle") as Gtk.CheckButton;
+ title_print_font = builder.get_object("fntbn_TitleFont") as Gtk.FontButton;
+
+ ppi_entry = builder.get_object("entry_PixelsPerInch") as Gtk.Entry;
+ ppi_entry.insert_text.connect(on_ppi_entry_insert_text);
+ ppi_entry.focus_out_event.connect(on_ppi_entry_focus_out);
+
+ this.add(custom_image_settings_pane);
+
+ sync_state_from_job(source_job);
+
+ show_all();
+
+ /* connect this signal after state is sync'd */
+ aspect_ratio_check.clicked.connect(on_aspect_ratio_check_clicked);
+ }
+
+ private void on_aspect_ratio_check_clicked() {
+ if (aspect_ratio_check.get_active()) {
+ local_content_width =
+ Measurement(local_content_height.value * source_job.get_source_aspect_ratio(),
+ local_content_height.unit);
+ custom_width_entry.set_text(format_measurement(local_content_width));
+ }
+ }
+
+ private bool on_width_entry_focus_out(Gdk.EventFocus event) {
+ if (custom_width_entry.get_text() == (format_measurement_as(local_content_width,
+ get_user_unit_choice())))
+ return false;
+
+ Measurement new_width = get_width_entry_value();
+ Measurement min_width = source_job.get_local_settings().get_minimum_content_dimension();
+ Measurement max_width = source_job.get_local_settings().get_maximum_content_dimension();
+
+ if (new_width.is_less_than(min_width) || new_width.is_greater_than(max_width)) {
+ custom_width_entry.set_text(format_measurement(local_content_width));
+ return false;
+ }
+
+ if (is_match_aspect_ratio_enabled()) {
+ Measurement new_height =
+ Measurement(new_width.value / source_job.get_source_aspect_ratio(),
+ new_width.unit);
+ local_content_height = new_height;
+ custom_height_entry.set_text(format_measurement(new_height));
+ }
+
+ local_content_width = new_width;
+ custom_width_entry.set_text(format_measurement(new_width));
+ return false;
+ }
+
+ private string format_measurement(Measurement measurement) {
+ return "%.2f".printf(measurement.value);
+ }
+
+ private string format_measurement_as(Measurement measurement, MeasurementUnit to_unit) {
+ Measurement converted_measurement = (measurement.unit == to_unit) ? measurement :
+ measurement.convert_to(to_unit);
+ return format_measurement(converted_measurement);
+ }
+
+ private bool on_ppi_entry_focus_out(Gdk.EventFocus event) {
+ set_content_ppi(int.parse(ppi_entry.get_text()));
+ return false;
+ }
+
+ private void on_ppi_entry_insert_text(Gtk.Editable editable, string text, int length,
+ ref int position) {
+ Gtk.Entry sender = (Gtk.Entry) editable;
+
+ if (is_text_insertion_in_progress)
+ return;
+
+ is_text_insertion_in_progress = true;
+
+ if (length == -1)
+ length = (int) text.length;
+
+ string new_text = "";
+ for (int ctr = 0; ctr < length; ctr++) {
+ if (text[ctr].isdigit())
+ new_text += ((char) text[ctr]).to_string();
+ }
+
+ if (new_text.length > 0)
+ sender.insert_text(new_text, (int) new_text.length, ref position);
+
+ Signal.stop_emission_by_name(sender, "insert-text");
+
+ is_text_insertion_in_progress = false;
+ }
+
+ private bool on_height_entry_focus_out(Gdk.EventFocus event) {
+ if (custom_height_entry.get_text() == (format_measurement_as(local_content_height,
+ get_user_unit_choice())))
+ return false;
+
+ Measurement new_height = get_height_entry_value();
+ Measurement min_height = source_job.get_local_settings().get_minimum_content_dimension();
+ Measurement max_height = source_job.get_local_settings().get_maximum_content_dimension();
+
+ if (new_height.is_less_than(min_height) || new_height.is_greater_than(max_height)) {
+ custom_height_entry.set_text(format_measurement(local_content_height));
+ return false;
+ }
+
+ if (is_match_aspect_ratio_enabled()) {
+ Measurement new_width =
+ Measurement(new_height.value * source_job.get_source_aspect_ratio(),
+ new_height.unit);
+ local_content_width = new_width;
+ custom_width_entry.set_text(format_measurement(new_width));
+ }
+
+ local_content_height = new_height;
+ custom_height_entry.set_text(format_measurement(new_height));
+ return false;
+ }
+
+ private MeasurementUnit get_user_unit_choice() {
+ if (units_combo.get_active() == INCHES_COMBO_CHOICE) {
+ return MeasurementUnit.INCHES;
+ } else if (units_combo.get_active() == CENTIMETERS_COMBO_CHOICE) {
+ return MeasurementUnit.CENTIMETERS;
+ } else {
+ error("unknown unit combo box choice");
+ }
+ }
+
+ private void set_user_unit_choice(MeasurementUnit unit) {
+ if (unit == MeasurementUnit.INCHES) {
+ units_combo.set_active(INCHES_COMBO_CHOICE);
+ } else if (unit == MeasurementUnit.CENTIMETERS) {
+ units_combo.set_active(CENTIMETERS_COMBO_CHOICE);
+ } else {
+ error("unknown MeasurementUnit enumeration");
+ }
+ }
+
+ private Measurement get_width_entry_value() {
+ return Measurement(double.parse(custom_width_entry.get_text()), get_user_unit_choice());
+ }
+
+ private Measurement get_height_entry_value() {
+ return Measurement(double.parse(custom_height_entry.get_text()), get_user_unit_choice());
+ }
+
+ private void on_entry_insert_text(Gtk.Editable editable, string text, int length,
+ ref int position) {
+
+ Gtk.Entry sender = (Gtk.Entry) editable;
+
+ if (is_text_insertion_in_progress)
+ return;
+
+ is_text_insertion_in_progress = true;
+
+ if (length == -1)
+ length = (int) text.length;
+
+ string decimal_point = Intl.localeconv().decimal_point;
+ bool contains_decimal_point = sender.get_text().contains(decimal_point);
+
+ string new_text = "";
+ for (int ctr = 0; ctr < length; ctr++) {
+ if (text[ctr].isdigit()) {
+ new_text += ((char) text[ctr]).to_string();
+ } else if ((!contains_decimal_point) && (text[ctr] == decimal_point[0])) {
+ new_text += ((char) text[ctr]).to_string();
+ }
+ }
+
+ if (new_text.length > 0)
+ sender.insert_text(new_text, (int) new_text.length, ref position);
+
+ Signal.stop_emission_by_name(sender, "insert-text");
+
+ is_text_insertion_in_progress = false;
+ }
+
+ private void sync_state_from_job(PrintJob job) {
+ assert(job.get_local_settings().get_content_width().unit ==
+ job.get_local_settings().get_content_height().unit);
+
+ Measurement constrained_width = job.get_local_settings().get_content_width();
+ if (job.get_local_settings().is_match_aspect_ratio_enabled())
+ constrained_width = Measurement(job.get_local_settings().get_content_height().value *
+ job.get_source_aspect_ratio(), job.get_local_settings().get_content_height().unit);
+ set_content_width(constrained_width);
+ set_content_height(job.get_local_settings().get_content_height());
+ set_content_layout(job.get_local_settings().get_content_layout());
+ set_content_ppi(job.get_local_settings().get_content_ppi());
+ set_image_per_page_selection(job.get_local_settings().get_image_per_page_selection());
+ set_size_selection(job.get_local_settings().get_size_selection());
+ set_match_aspect_ratio_enabled(job.get_local_settings().is_match_aspect_ratio_enabled());
+ set_print_titles_enabled(job.get_local_settings().is_print_titles_enabled());
+ set_print_titles_font(job.get_local_settings().get_print_titles_font());
+ }
+
+ private void on_radio_group_click(Gtk.Button b) {
+ Gtk.RadioButton sender = (Gtk.RadioButton) b;
+
+ if (sender == standard_size_radio) {
+ set_content_layout_control_state(ContentLayout.STANDARD_SIZE);
+ standard_sizes_combo.grab_focus();
+ } else if (sender == custom_size_radio) {
+ set_content_layout_control_state(ContentLayout.CUSTOM_SIZE);
+ custom_height_entry.grab_focus();
+ } else if (sender == image_per_page_radio) {
+ set_content_layout_control_state(ContentLayout.IMAGE_PER_PAGE);
+ }
+ }
+
+ private void on_units_combo_changed() {
+ custom_height_entry.set_text(format_measurement_as(local_content_height,
+ get_user_unit_choice()));
+ custom_width_entry.set_text(format_measurement_as(local_content_width,
+ get_user_unit_choice()));
+ }
+
+ private void set_content_layout_control_state(ContentLayout layout) {
+ switch (layout) {
+ case ContentLayout.STANDARD_SIZE:
+ standard_sizes_combo.set_sensitive(true);
+ units_combo.set_sensitive(false);
+ custom_width_entry.set_sensitive(false);
+ custom_height_entry.set_sensitive(false);
+ aspect_ratio_check.set_sensitive(false);
+ image_per_page_combo.set_sensitive(false);
+ break;
+
+ case ContentLayout.CUSTOM_SIZE:
+ standard_sizes_combo.set_sensitive(false);
+ units_combo.set_sensitive(true);
+ custom_width_entry.set_sensitive(true);
+ custom_height_entry.set_sensitive(true);
+ aspect_ratio_check.set_sensitive(true);
+ image_per_page_combo.set_sensitive(false);
+ break;
+
+ case ContentLayout.IMAGE_PER_PAGE:
+ standard_sizes_combo.set_sensitive(false);
+ units_combo.set_sensitive(false);
+ custom_width_entry.set_sensitive(false);
+ custom_height_entry.set_sensitive(false);
+ aspect_ratio_check.set_sensitive(false);
+ image_per_page_combo.set_sensitive(true);
+ break;
+
+ default:
+ error("unknown ContentLayout enumeration value");
+ }
+ }
+
+ private static bool standard_sizes_combo_separator_func(Gtk.TreeModel model,
+ Gtk.TreeIter iter) {
+ Value val;
+ model.get_value(iter, 0, out val);
+
+ return (val.dup_string() == "-");
+ }
+
+ private void set_content_layout(ContentLayout content_layout) {
+ set_content_layout_control_state(content_layout);
+ switch (content_layout) {
+ case ContentLayout.STANDARD_SIZE:
+ standard_size_radio.set_active(true);
+ break;
+
+ case ContentLayout.CUSTOM_SIZE:
+ custom_size_radio.set_active(true);
+ break;
+
+ case ContentLayout.IMAGE_PER_PAGE:
+ image_per_page_radio.set_active(true);
+ break;
+
+ default:
+ error("unknown ContentLayout enumeration value");
+ }
+ }
+
+ private ContentLayout get_content_layout() {
+ if (standard_size_radio.get_active())
+ return ContentLayout.STANDARD_SIZE;
+ if (custom_size_radio.get_active())
+ return ContentLayout.CUSTOM_SIZE;
+ if (image_per_page_radio.get_active())
+ return ContentLayout.IMAGE_PER_PAGE;
+
+ error("inconsistent content layout radio button group state");
+ }
+
+ private void set_content_width(Measurement content_width) {
+ if (content_width.unit != local_content_height.unit) {
+ set_user_unit_choice(content_width.unit);
+ local_content_height = local_content_height.convert_to(content_width.unit);
+ custom_height_entry.set_text(format_measurement(local_content_height));
+ }
+ local_content_width = content_width;
+ custom_width_entry.set_text(format_measurement(content_width));
+ }
+
+ private Measurement get_content_width() {
+ return local_content_width;
+ }
+
+ private void set_content_height(Measurement content_height) {
+ if (content_height.unit != local_content_width.unit) {
+ set_user_unit_choice(content_height.unit);
+ local_content_width = local_content_width.convert_to(content_height.unit);
+ custom_width_entry.set_text(format_measurement(local_content_width));
+ }
+ local_content_height = content_height;
+ custom_height_entry.set_text(format_measurement(content_height));
+ }
+
+ private Measurement get_content_height() {
+ return local_content_height;
+ }
+
+ private void set_content_ppi(int content_ppi) {
+ local_content_ppi = content_ppi.clamp(PrintSettings.MIN_CONTENT_PPI,
+ PrintSettings.MAX_CONTENT_PPI);
+
+ ppi_entry.set_text("%d".printf(local_content_ppi));
+ }
+
+ private int get_content_ppi() {
+ return local_content_ppi;
+ }
+
+ private void set_image_per_page_selection(int image_per_page) {
+ image_per_page_combo.set_active(image_per_page);
+ }
+
+ private int get_image_per_page_selection() {
+ return image_per_page_combo.get_active();
+ }
+
+ private void set_size_selection(int size_selection) {
+ standard_sizes_combo.set_active(size_selection);
+ }
+
+ private int get_size_selection() {
+ return standard_sizes_combo.get_active();
+ }
+
+ private void set_match_aspect_ratio_enabled(bool enable_state) {
+ aspect_ratio_check.set_active(enable_state);
+ }
+
+ private void set_print_titles_enabled(bool print_titles) {
+ title_print_check.set_active(print_titles);
+ }
+
+ private void set_print_titles_font(string fontname) {
+ title_print_font.set_font_name(fontname);
+ }
+
+
+ private bool is_match_aspect_ratio_enabled() {
+ return aspect_ratio_check.get_active();
+ }
+
+ private bool is_print_titles_enabled() {
+ return title_print_check.get_active();
+ }
+
+ private string get_print_titles_font() {
+ return title_print_font.get_font_name();
+ }
+
+ public PrintJob get_source_job() {
+ return source_job;
+ }
+
+ public PrintSettings get_local_settings() {
+ PrintSettings result = new PrintSettings();
+
+ result.set_content_width(get_content_width());
+ result.set_content_height(get_content_height());
+ result.set_content_layout(get_content_layout());
+ result.set_content_ppi(get_content_ppi());
+ result.set_image_per_page_selection(get_image_per_page_selection());
+ result.set_size_selection(get_size_selection());
+ result.set_match_aspect_ratio_enabled(is_match_aspect_ratio_enabled());
+ result.set_print_titles_enabled(is_print_titles_enabled());
+ result.set_print_titles_font(get_print_titles_font());
+
+ return result;
+ }
+}
+
+public class PrintJob : Gtk.PrintOperation {
+ private PrintSettings settings;
+ private Gee.ArrayList<Photo> photos = new Gee.ArrayList<Photo>();
+
+ public PrintJob(Gee.Collection<Photo> to_print) {
+ this.settings = PrintManager.get_instance().get_global_settings();
+ photos.add_all(to_print);
+
+ set_embed_page_setup (true);
+ double photo_aspect_ratio = photos[0].get_dimensions().get_aspect_ratio();
+ if (photo_aspect_ratio < 1.0)
+ photo_aspect_ratio = 1.0 / photo_aspect_ratio;
+ }
+
+ public Gee.List<Photo> get_photos() {
+ return photos;
+ }
+
+ public Photo get_source_photo() {
+ return photos[0];
+ }
+
+ public double get_source_aspect_ratio() {
+ double aspect_ratio = photos[0].get_dimensions().get_aspect_ratio();
+ return (aspect_ratio < 1.0) ? (1.0 / aspect_ratio) : aspect_ratio;
+ }
+
+ public PrintSettings get_local_settings() {
+ return settings;
+ }
+
+ public void set_local_settings(PrintSettings settings) {
+ this.settings = settings;
+ }
+}
+
+public class StandardPrintSize {
+ public StandardPrintSize(string name, Measurement width, Measurement height) {
+ this.name = name;
+ this.width = width;
+ this.height = height;
+ }
+
+ public string name;
+ public Measurement width;
+ public Measurement height;
+}
+
+public class PrintManager {
+ private const double IMAGE_DISTANCE = 0.24;
+
+ private static PrintManager instance = null;
+
+ private PrintSettings settings;
+ private Gtk.PageSetup user_page_setup;
+ private CustomPrintTab custom_tab;
+ private ProgressDialog? progress_dialog = null;
+ private Cancellable? cancellable = null;
+
+ private PrintManager() {
+ user_page_setup = new Gtk.PageSetup();
+ settings = new PrintSettings();
+ }
+
+ public StandardPrintSize[] get_standard_sizes() {
+ StandardPrintSize[] result = new StandardPrintSize[0];
+
+ result += new StandardPrintSize(_("Wallet (2 x 3 in.)"),
+ Measurement(3, MeasurementUnit.INCHES),
+ Measurement(2, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("Notecard (3 x 5 in.)"),
+ Measurement(5, MeasurementUnit.INCHES),
+ Measurement(3, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("4 x 6 in."),
+ Measurement(6, MeasurementUnit.INCHES),
+ Measurement(4, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("5 x 7 in."),
+ Measurement(7, MeasurementUnit.INCHES),
+ Measurement(5, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("8 x 10 in."),
+ Measurement(10, MeasurementUnit.INCHES),
+ Measurement(8, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("11 x 14 in."),
+ Measurement(14, MeasurementUnit.INCHES),
+ Measurement(11, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("16 x 20 in."),
+ Measurement(20, MeasurementUnit.INCHES),
+ Measurement(16, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(("-"),
+ Measurement(0, MeasurementUnit.INCHES),
+ Measurement(0, MeasurementUnit.INCHES));
+ result += new StandardPrintSize(_("Metric Wallet (9 x 13 cm)"),
+ Measurement(13, MeasurementUnit.CENTIMETERS),
+ Measurement(9, MeasurementUnit.CENTIMETERS));
+ result += new StandardPrintSize(_("Postcard (10 x 15 cm)"),
+ Measurement(15, MeasurementUnit.CENTIMETERS),
+ Measurement(10, MeasurementUnit.CENTIMETERS));
+ result += new StandardPrintSize(_("13 x 18 cm"),
+ Measurement(18, MeasurementUnit.CENTIMETERS),
+ Measurement(13, MeasurementUnit.CENTIMETERS));
+ result += new StandardPrintSize(_("18 x 24 cm"),
+ Measurement(24, MeasurementUnit.CENTIMETERS),
+ Measurement(18, MeasurementUnit.CENTIMETERS));
+ result += new StandardPrintSize(_("20 x 30 cm"),
+ Measurement(30, MeasurementUnit.CENTIMETERS),
+ Measurement(20, MeasurementUnit.CENTIMETERS));
+ result += new StandardPrintSize(_("24 x 40 cm"),
+ Measurement(40, MeasurementUnit.CENTIMETERS),
+ Measurement(24, MeasurementUnit.CENTIMETERS));
+ result += new StandardPrintSize(_("30 x 40 cm"),
+ Measurement(40, MeasurementUnit.CENTIMETERS),
+ Measurement(30, MeasurementUnit.CENTIMETERS));
+
+ return result;
+ }
+
+ public static PrintManager get_instance() {
+ if (instance == null)
+ instance = new PrintManager();
+
+ return instance;
+ }
+
+ public void spool_photo(Gee.Collection<Photo> to_print) {
+ PrintJob job = new PrintJob(to_print);
+ job.set_custom_tab_label(_("Image Settings"));
+ job.set_unit(Gtk.Unit.INCH);
+ job.set_n_pages(1);
+ job.set_job_name(job.get_source_photo().get_name());
+ job.set_default_page_setup(user_page_setup);
+ job.begin_print.connect(on_begin_print);
+ job.draw_page.connect(on_draw_page);
+ job.create_custom_widget.connect(on_create_custom_widget);
+ job.status_changed.connect(on_status_changed);
+
+ AppWindow.get_instance().set_busy_cursor();
+
+ cancellable = new Cancellable();
+ progress_dialog = new ProgressDialog(AppWindow.get_instance(), _("Printing..."), cancellable);
+
+ string? err_msg = null;
+ try {
+ Gtk.PrintOperationResult result = job.run(Gtk.PrintOperationAction.PRINT_DIALOG,
+ AppWindow.get_instance());
+ if (result == Gtk.PrintOperationResult.APPLY)
+ user_page_setup = job.get_default_page_setup();
+ } catch (Error e) {
+ job.cancel();
+ err_msg = e.message;
+ }
+
+ progress_dialog.close();
+ progress_dialog = null;
+ cancellable = null;
+
+ AppWindow.get_instance().set_normal_cursor();
+
+ if (err_msg != null)
+ AppWindow.error_message(_("Unable to print photo:\n\n%s").printf(err_msg));
+ }
+
+ private void on_begin_print(Gtk.PrintOperation emitting_object, Gtk.PrintContext job_context) {
+ debug("on_begin_print");
+
+ PrintJob job = (PrintJob) emitting_object;
+
+ // cancel() can only be called from "begin-print", "paginate", or "draw-page"
+ if (cancellable != null && cancellable.is_cancelled()) {
+ job.cancel();
+
+ return;
+ }
+
+ Gee.List<Photo> photos = job.get_photos();
+ if (job.get_local_settings().get_content_layout() == ContentLayout.IMAGE_PER_PAGE){
+ PrintLayout layout = (PrintLayout) job.get_local_settings().get_image_per_page_selection();
+ job.set_n_pages((int) Math.ceil((double) photos.size / (double) layout.get_per_page()));
+ } else {
+ job.set_n_pages(photos.size);
+ }
+
+ spin_event_loop();
+ }
+
+ private void on_status_changed(Gtk.PrintOperation job) {
+ debug("on_status_changed: %s", job.get_status_string());
+
+ if (progress_dialog != null) {
+ progress_dialog.set_status(job.get_status_string());
+ spin_event_loop();
+ }
+ }
+
+ private void on_draw_page(Gtk.PrintOperation emitting_object, Gtk.PrintContext job_context,
+ int page_num) {
+ debug("on_draw_page");
+
+ PrintJob job = (PrintJob) emitting_object;
+
+ // cancel() can only be called from "begin-print", "paginate", or "draw-page"
+ if (cancellable != null && cancellable.is_cancelled()) {
+ job.cancel();
+
+ return;
+ }
+
+ spin_event_loop();
+
+ Gtk.PageSetup page_setup = job_context.get_page_setup();
+ double page_width = page_setup.get_page_width(Gtk.Unit.INCH);
+ double page_height = page_setup.get_page_height(Gtk.Unit.INCH);
+
+ double dpi = job.get_local_settings().get_content_ppi();
+ double inv_dpi = 1.0 / dpi;
+ Cairo.Context dc = job_context.get_cairo_context();
+ dc.scale(inv_dpi, inv_dpi);
+ Gee.List<Photo> photos = job.get_photos();
+
+ ContentLayout content_layout = job.get_local_settings().get_content_layout();
+ switch (content_layout) {
+ case ContentLayout.STANDARD_SIZE:
+ case ContentLayout.CUSTOM_SIZE:
+ double canvas_width, canvas_height;
+ if (content_layout == ContentLayout.STANDARD_SIZE) {
+ canvas_width = get_standard_sizes()[job.get_local_settings().get_size_selection()].width.convert_to(
+ MeasurementUnit.INCHES).value;
+ canvas_height = get_standard_sizes()[job.get_local_settings().get_size_selection()].height.convert_to(
+ MeasurementUnit.INCHES).value;
+ } else {
+ assert(content_layout == ContentLayout.CUSTOM_SIZE);
+ canvas_width = job.get_local_settings().get_content_width().convert_to(
+ MeasurementUnit.INCHES).value;
+ canvas_height = job.get_local_settings().get_content_height().convert_to(
+ MeasurementUnit.INCHES).value;
+ }
+
+ if (page_num < photos.size) {
+ Dimensions photo_dimensions = photos[page_num].get_dimensions();
+ double photo_aspect_ratio = photo_dimensions.get_aspect_ratio();
+ double canvas_aspect_ratio = ((double) canvas_width) / canvas_height;
+ if (Math.floor(canvas_aspect_ratio) != Math.floor(photo_aspect_ratio)) {
+ double canvas_tmp = canvas_width;
+ canvas_width = canvas_height;
+ canvas_height = canvas_tmp;
+ }
+
+ double dx = (page_width - canvas_width) / 2.0;
+ double dy = (page_height - canvas_height) / 2.0;
+ fit_image_to_canvas(photos[page_num], dx, dy, canvas_width, canvas_height, true,
+ job, job_context);
+ if (job.get_local_settings().is_print_titles_enabled()) {
+ add_title_to_canvas(page_width / 2, page_height, photos[page_num].get_name(),
+ job, job_context);
+ }
+ }
+
+ if (progress_dialog != null)
+ progress_dialog.monitor(page_num, photos.size);
+ break;
+
+ case ContentLayout.IMAGE_PER_PAGE:
+ PrintLayout layout = (PrintLayout) job.get_local_settings().get_image_per_page_selection();
+ int nx = layout.get_x();
+ int ny = layout.get_y();
+ int start = page_num * layout.get_per_page();
+ double canvas_width = (double) (page_width - IMAGE_DISTANCE * (nx - 1)) / nx;
+ double canvas_height = (double) (page_height - IMAGE_DISTANCE * (ny - 1)) / ny;
+ for (int y = 0; y < ny; y++){
+ for (int x = 0; x < nx; x++){
+ int i = start + y * nx + x;
+ if (i < photos.size) {
+ double dx = x * (canvas_width) + x * IMAGE_DISTANCE;
+ double dy = y * (canvas_height) + y * IMAGE_DISTANCE;
+ fit_image_to_canvas(photos[i], dx, dy, canvas_width, canvas_height, false,
+ job, job_context);
+ if (job.get_local_settings().is_print_titles_enabled()) {
+ add_title_to_canvas(dx + canvas_width / 2, dy + canvas_height,
+ photos[i].get_name(), job, job_context);
+ }
+ }
+
+ if (progress_dialog != null)
+ progress_dialog.monitor(i, photos.size);
+ }
+ }
+ break;
+
+ default:
+ error("unknown or unsupported layout mode");
+ }
+ }
+
+ private unowned Object on_create_custom_widget(Gtk.PrintOperation emitting_object) {
+ custom_tab = new CustomPrintTab((PrintJob) emitting_object);
+ ((PrintJob) emitting_object).custom_widget_apply.connect(on_custom_widget_apply);
+ return custom_tab;
+ }
+
+ private void on_custom_widget_apply(Gtk.Widget custom_widget) {
+ CustomPrintTab tab = (CustomPrintTab) custom_widget;
+ tab.get_source_job().set_local_settings(tab.get_local_settings());
+ set_global_settings(tab.get_local_settings());
+ }
+
+ private void fit_image_to_canvas(Photo photo, double x, double y, double canvas_width, double canvas_height, bool crop, PrintJob job, Gtk.PrintContext job_context) {
+ Cairo.Context dc = job_context.get_cairo_context();
+ Dimensions photo_dimensions = photo.get_dimensions();
+ double photo_aspect_ratio = photo_dimensions.get_aspect_ratio();
+ double canvas_aspect_ratio = ((double) canvas_width) / canvas_height;
+
+ double target_width = 0.0;
+ double target_height = 0.0;
+ double dpi = job.get_local_settings().get_content_ppi();
+
+ if (!crop) {
+ if (canvas_aspect_ratio < photo_aspect_ratio) {
+ target_width = canvas_width;
+ target_height = target_width * (1.0 / photo_aspect_ratio);
+ } else {
+ target_height = canvas_height;
+ target_width = target_height * photo_aspect_ratio;
+ }
+ x += (canvas_width - target_width) / 2.0;
+ y += (canvas_height - target_height) / 2.0;
+ }
+
+ double x_offset = dpi * x;
+ double y_offset = dpi * y;
+ dc.save();
+ dc.translate(x_offset, y_offset);
+
+ int w = (int) (dpi * canvas_width);
+ int h = (int) (dpi * canvas_height);
+ Dimensions viewport = Dimensions(w, h);
+
+ try {
+ if (crop && !are_approximately_equal(canvas_aspect_ratio, photo_aspect_ratio)) {
+ Scaling pixbuf_scaling = Scaling.to_fill_viewport(viewport);
+ Gdk.Pixbuf photo_pixbuf = photo.get_pixbuf(pixbuf_scaling);
+ Dimensions scaled_photo_dimensions = Dimensions.for_pixbuf(photo_pixbuf);
+ int shave_vertical = 0;
+ int shave_horizontal = 0;
+ if (canvas_aspect_ratio < photo_aspect_ratio) {
+ shave_vertical = (int) ((scaled_photo_dimensions.width - (scaled_photo_dimensions.height * canvas_aspect_ratio)) / 2.0);
+ } else {
+ shave_horizontal = (int) ((scaled_photo_dimensions.height - (scaled_photo_dimensions.width * (1.0 / canvas_aspect_ratio))) / 2.0);
+ }
+ Gdk.Pixbuf shaved_pixbuf = new Gdk.Pixbuf.subpixbuf(photo_pixbuf, shave_vertical,shave_horizontal, scaled_photo_dimensions.width - (2 * shave_vertical), scaled_photo_dimensions.height - (2 * shave_horizontal));
+
+ photo_pixbuf = pixbuf_scaling.perform_on_pixbuf(shaved_pixbuf, Gdk.InterpType.HYPER, true);
+ Gdk.cairo_set_source_pixbuf(dc, photo_pixbuf, 0.0, 0.0);
+ } else {
+ Scaling pixbuf_scaling = Scaling.for_viewport(viewport, true);
+ Gdk.Pixbuf photo_pixbuf = photo.get_pixbuf(pixbuf_scaling);
+ photo_pixbuf = pixbuf_scaling.perform_on_pixbuf(photo_pixbuf, Gdk.InterpType.HYPER, true);
+ Gdk.cairo_set_source_pixbuf(dc, photo_pixbuf, 0.0, 0.0);
+ }
+ dc.paint();
+
+ } catch (Error e) {
+ job.cancel();
+ AppWindow.error_message(_("Unable to print photo:\n\n%s").printf(e.message));
+ }
+ dc.restore();
+ }
+
+ private void add_title_to_canvas(double x, double y, string title, PrintJob job, Gtk.PrintContext job_context) {
+ Cairo.Context dc = job_context.get_cairo_context();
+ double dpi = job.get_local_settings().get_content_ppi();
+ var title_font_description = Pango.FontDescription.from_string(job.get_local_settings().get_print_titles_font());
+ var title_layout = Pango.cairo_create_layout(dc);
+ Pango.Context context = title_layout.get_context();
+ Pango.cairo_context_set_resolution (context, dpi);
+ title_layout.set_font_description(title_font_description);
+ title_layout.set_text(title, -1);
+ int title_width, title_height;
+ title_layout.get_pixel_size(out title_width, out title_height);
+ double tx = dpi * x - title_width / 2;
+ double ty = dpi * y - title_height;
+
+ // Transparent title text background
+ dc.rectangle(tx - 10, ty + 2, title_width + 20, title_height);
+ dc.set_source_rgba(1, 1, 1, 1);
+ dc.set_line_width(2);
+ dc.stroke_preserve();
+ dc.set_source_rgba(1, 1, 1, 0.5);
+ dc.fill();
+ dc.set_source_rgba(0, 0, 0, 1);
+
+ dc.move_to(tx, ty + 2);
+ Pango.cairo_show_layout(dc, title_layout);
+ }
+
+ private bool are_approximately_equal(double val1, double val2) {
+ double accept_err = 0.005;
+ return (Math.fabs(val1 - val2) <= accept_err);
+ }
+
+ public PrintSettings get_global_settings() {
+ return settings;
+ }
+
+ public void set_global_settings(PrintSettings settings) {
+ this.settings = settings;
+ settings.save();
+ }
+}
diff --git a/src/Properties.vala b/src/Properties.vala
new file mode 100644
index 0000000..9edb4fb
--- /dev/null
+++ b/src/Properties.vala
@@ -0,0 +1,700 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+private abstract class Properties : Gtk.Grid {
+ uint line_count = 0;
+
+ public Properties() {
+ row_spacing = 0;
+ column_spacing = 6;
+ }
+
+ protected void add_line(string label_text, string info_text, bool multi_line = false) {
+ Gtk.Label label = new Gtk.Label("");
+ Gtk.Widget info;
+
+ label.set_justify(Gtk.Justification.RIGHT);
+
+ label.set_markup(GLib.Markup.printf_escaped("<span font_weight=\"bold\">%s</span>", label_text));
+
+ if (multi_line) {
+ Gtk.ScrolledWindow info_scroll = new Gtk.ScrolledWindow(null, null);
+ info_scroll.shadow_type = Gtk.ShadowType.ETCHED_IN;
+ Gtk.TextView view = new Gtk.TextView();
+ // by default TextView widgets have a white background, which
+ // makes sense during editing. In this instance we only *show*
+ // the content and thus want that the parent's background color
+ // is inherited to the TextView
+ Gtk.StyleContext context = info_scroll.get_style_context();
+ view.override_background_color (Gtk.StateFlags.NORMAL,
+ context.get_background_color(Gtk.StateFlags.NORMAL));
+ view.set_wrap_mode(Gtk.WrapMode.WORD);
+ view.set_cursor_visible(false);
+ view.set_editable(false);
+ view.buffer.text = is_string_empty(info_text) ? "" : info_text;
+ info_scroll.add(view);
+ label.set_alignment(1, 0);
+ info = (Gtk.Widget) info_scroll;
+ } else {
+ Gtk.Label info_label = new Gtk.Label("");
+ info_label.set_markup(is_string_empty(info_text) ? "" : info_text);
+ info_label.set_alignment(0, (float) 5e-1);
+ info_label.set_ellipsize(Pango.EllipsizeMode.END);
+ info_label.set_selectable(true);
+ label.set_alignment(1, (float) 5e-1);
+ info = (Gtk.Widget) info_label;
+ }
+
+ attach(label, 0, (int) line_count, 1, 1);
+
+ if (multi_line) {
+ attach(info, 1, (int) line_count, 1, 2);
+ } else {
+ attach(info, 1, (int) line_count, 1, 1);
+ }
+
+ line_count++;
+ }
+
+ protected string get_prettyprint_time(Time time) {
+ string timestring = time.format(Resources.get_hh_mm_format_string());
+
+ if (timestring[0] == '0')
+ timestring = timestring.substring(1, -1);
+
+ return timestring;
+ }
+
+ protected string get_prettyprint_time_with_seconds(Time time) {
+ string timestring = time.format(Resources.get_hh_mm_ss_format_string());
+
+ if (timestring[0] == '0')
+ timestring = timestring.substring(1, -1);
+
+ return timestring;
+ }
+
+ protected string get_prettyprint_date(Time date) {
+ string date_string = null;
+ Time today = Time.local(time_t());
+ if (date.day_of_year == today.day_of_year && date.year == today.year) {
+ date_string = _("Today");
+ } else if (date.day_of_year == (today.day_of_year - 1) && date.year == today.year) {
+ date_string = _("Yesterday");
+ } else {
+ date_string = format_local_date(date);
+ }
+
+ return date_string;
+ }
+
+ protected virtual void get_single_properties(DataView view) {
+ }
+
+ protected virtual void get_multiple_properties(Gee.Iterable<DataView>? iter) {
+ }
+
+ protected virtual void get_properties(Page current_page) {
+ ViewCollection view = current_page.get_view();
+ if (view == null)
+ return;
+
+ // summarize selected items, if none selected, summarize all
+ int count = view.get_selected_count();
+ Gee.Iterable<DataView> iter = null;
+ if (count != 0) {
+ iter = view.get_selected();
+ } else {
+ count = view.get_count();
+ iter = (Gee.Iterable<DataView>) view.get_all();
+ }
+
+ if (iter == null || count == 0)
+ return;
+
+ if (count == 1) {
+ foreach (DataView item in iter) {
+ get_single_properties(item);
+ break;
+ }
+ } else {
+ get_multiple_properties(iter);
+ }
+ }
+
+ protected virtual void clear_properties() {
+ foreach (Gtk.Widget child in get_children())
+ remove(child);
+
+ line_count = 0;
+ }
+
+ public void update_properties(Page page) {
+ clear_properties();
+ internal_update_properties(page);
+ show_all();
+ }
+
+ public virtual void internal_update_properties(Page page) {
+ get_properties(page);
+ }
+
+ public void unselect_text() {
+ foreach (Gtk.Widget child in get_children()) {
+ if (child is Gtk.Label)
+ ((Gtk.Label) child).select_region(0, 0);
+ }
+ }
+}
+
+private class BasicProperties : Properties {
+ private string title;
+ private time_t start_time = time_t();
+ private time_t end_time = time_t();
+ private Dimensions dimensions;
+ private int photo_count;
+ private int event_count;
+ private int video_count;
+ private string exposure;
+ private string aperture;
+ private string iso;
+ private double clip_duration;
+ private string raw_developer;
+ private string raw_assoc;
+
+ public BasicProperties() {
+ }
+
+ protected override void clear_properties() {
+ base.clear_properties();
+ title = "";
+ start_time = 0;
+ end_time = 0;
+ dimensions = Dimensions(0,0);
+ photo_count = -1;
+ event_count = -1;
+ video_count = -1;
+ exposure = "";
+ aperture = "";
+ iso = "";
+ clip_duration = 0.0;
+ raw_developer = "";
+ raw_assoc = "";
+ }
+
+ protected override void get_single_properties(DataView view) {
+ base.get_single_properties(view);
+
+ DataSource source = view.get_source();
+
+ title = source.get_name();
+
+ if (source is PhotoSource || source is PhotoImportSource) {
+ start_time = (source is PhotoSource) ? ((PhotoSource) source).get_exposure_time() :
+ ((PhotoImportSource) source).get_exposure_time();
+ end_time = start_time;
+
+ PhotoMetadata? metadata = (source is PhotoSource) ? ((PhotoSource) source).get_metadata() :
+ ((PhotoImportSource) source).get_metadata();
+
+ if (metadata != null) {
+ exposure = metadata.get_exposure_string();
+ if (exposure == null)
+ exposure = "";
+
+ aperture = metadata.get_aperture_string(true);
+ if (aperture == null)
+ aperture = "";
+
+ iso = metadata.get_iso_string();
+ if (iso == null)
+ iso = "";
+
+ dimensions = (metadata.get_pixel_dimensions() != null) ?
+ metadata.get_orientation().rotate_dimensions(metadata.get_pixel_dimensions()) :
+ Dimensions(0, 0);
+ }
+
+ if (source is PhotoSource)
+ dimensions = ((PhotoSource) source).get_dimensions();
+
+ if (source is Photo && ((Photo) source).get_master_file_format() == PhotoFileFormat.RAW) {
+ Photo photo = source as Photo;
+ raw_developer = photo.get_raw_developer().get_label();
+ raw_assoc = photo.is_raw_developer_available(RawDeveloper.CAMERA) ? _("RAW+JPEG") : "";
+ }
+ } else if (source is EventSource) {
+ EventSource event_source = (EventSource) source;
+
+ start_time = event_source.get_start_time();
+ end_time = event_source.get_end_time();
+
+ int event_photo_count;
+ int event_video_count;
+ MediaSourceCollection.count_media(event_source.get_media(), out event_photo_count,
+ out event_video_count);
+
+ photo_count = event_photo_count;
+ video_count = event_video_count;
+ } else if (source is VideoSource || source is VideoImportSource) {
+ if (source is VideoSource) {
+ Video video = (Video) source;
+ clip_duration = video.get_clip_duration();
+
+ if (video.get_is_interpretable())
+ dimensions = video.get_frame_dimensions();
+
+ start_time = video.get_exposure_time();
+ } else {
+ start_time = ((VideoImportSource) source).get_exposure_time();
+ }
+ end_time = start_time;
+ }
+ }
+
+ protected override void get_multiple_properties(Gee.Iterable<DataView>? iter) {
+ base.get_multiple_properties(iter);
+
+ photo_count = 0;
+ 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) ?
+ ((PhotoSource) source).get_exposure_time() :
+ ((PhotoImportSource) source).get_exposure_time();
+
+ if (exposure_time != 0) {
+ if (start_time == 0 || exposure_time < start_time)
+ start_time = exposure_time;
+
+ if (end_time == 0 || exposure_time > end_time)
+ end_time = exposure_time;
+ }
+
+ photo_count++;
+ } else if (source is EventSource) {
+ EventSource event_source = (EventSource) source;
+
+ if (event_count == -1)
+ event_count = 0;
+
+ if ((start_time == 0 || event_source.get_start_time() < start_time) &&
+ event_source.get_start_time() != 0 ) {
+ start_time = event_source.get_start_time();
+ }
+ if ((end_time == 0 || event_source.get_end_time() > end_time) &&
+ event_source.get_end_time() != 0 ) {
+ end_time = event_source.get_end_time();
+ } else if (end_time == 0 || event_source.get_start_time() > end_time) {
+ end_time = event_source.get_start_time();
+ }
+
+ int event_photo_count;
+ int event_video_count;
+ MediaSourceCollection.count_media(event_source.get_media(), out event_photo_count,
+ out event_video_count);
+
+ photo_count += event_photo_count;
+ video_count += event_video_count;
+ event_count++;
+ } else if (source is VideoSource || source is VideoImportSource) {
+ time_t 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)
+ start_time = exposure_time;
+
+ if (end_time == 0 || exposure_time > end_time)
+ end_time = exposure_time;
+ }
+
+ video_count++;
+ }
+ }
+ }
+
+ protected override void get_properties(Page current_page) {
+ base.get_properties(current_page);
+
+ if (end_time == 0)
+ end_time = start_time;
+ if (start_time == 0)
+ start_time = end_time;
+ }
+
+ protected override void internal_update_properties(Page page) {
+ base.internal_update_properties(page);
+
+ // display the title if a Tag page
+ if (title == "" && page is TagPage)
+ title = ((TagPage) page).get_tag().get_user_visible_name();
+
+ if (title != "")
+ add_line(_("Title:"), guarded_markup_escape_text(title));
+
+ if (photo_count >= 0 || video_count >= 0) {
+ string label = _("Items:");
+
+ if (event_count >= 0) {
+ string event_num_string = (ngettext("%d Event", "%d Events", event_count)).printf(
+ event_count);
+
+ add_line(label, event_num_string);
+ label = "";
+ }
+
+ string photo_num_string = (ngettext("%d Photo", "%d Photos", photo_count)).printf(
+ photo_count);
+ string video_num_string = (ngettext("%d Video", "%d Videos", video_count)).printf(
+ video_count);
+
+ if (photo_count == 0 && video_count > 0) {
+ add_line(label, video_num_string);
+ return;
+ }
+
+ add_line(label, photo_num_string);
+
+ if (video_count > 0)
+ 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_date == end_date) {
+ // display only one date if start and end are the same
+ add_line(_("Date:"), start_date);
+
+ if (start_time == end_time) {
+ // display only one time if start and end are the same
+ add_line(_("Time:"), start_time);
+ } else {
+ // display time range
+ add_line(_("From:"), start_time);
+ add_line(_("To:"), end_time);
+ }
+ } else {
+ // display date range
+ add_line(_("From:"), start_date);
+ add_line(_("To:"), end_date);
+ }
+ }
+
+ if (dimensions.has_area()) {
+ string label = _("Size:");
+
+ if (dimensions.has_area()) {
+ add_line(label, "%d &#215; %d".printf(dimensions.width, dimensions.height));
+ label = "";
+ }
+ }
+
+ if (clip_duration > 0.0) {
+ add_line(_("Duration:"), _("%.1f seconds").printf(clip_duration));
+ }
+
+ if (raw_developer != "") {
+ add_line(_("Developer:"), raw_developer);
+ }
+
+ // RAW+JPEG flag.
+ if (raw_assoc != "")
+ add_line("", raw_assoc);
+
+ if (exposure != "" || aperture != "" || iso != "") {
+ string line = null;
+
+ // attempt to put exposure and aperture on the same line
+ if (exposure != "")
+ line = exposure;
+
+ if (aperture != "") {
+ if (line != null)
+ line += ", " + aperture;
+ else
+ line = aperture;
+ }
+
+ // if not both available but ISO is, add it to the first line
+ if ((exposure == "" || aperture == "") && iso != "") {
+ if (line != null)
+ line += ", " + "ISO " + iso;
+ else
+ line = "ISO " + iso;
+
+ add_line(_("Exposure:"), line);
+ } else {
+ // fit both on the top line, emit and move on
+ if (line != null)
+ add_line(_("Exposure:"), line);
+
+ // emit ISO on a second unadorned line
+ if (iso != "") {
+ if (line != null)
+ add_line("","ISO " + iso);
+ else
+ add_line(_("Exposure:"), "ISO " + iso);
+ }
+ }
+ }
+ }
+}
+
+private class ExtendedPropertiesWindow : Gtk.Dialog {
+ private ExtendedProperties properties = null;
+ private const int FRAME_BORDER = 6;
+ private Gtk.Button close_button;
+
+ private class ExtendedProperties : Properties {
+ private const string NO_VALUE = "";
+ // Photo stuff
+ private string file_path;
+ private uint64 filesize;
+ private Dimensions? original_dim;
+ private string camera_make;
+ private string camera_model;
+ private string flash;
+ private string focal_length;
+ private double gps_lat;
+ private string gps_lat_ref;
+ private double gps_long;
+ private string gps_long_ref;
+ private double gps_alt;
+ private string artist;
+ private string copyright;
+ private string software;
+ private string exposure_bias;
+ private string exposure_date;
+ private string exposure_time;
+ private bool is_raw;
+ private string? development_path;
+
+ // Event stuff
+ // nothing here which is not already shown in the BasicProperties but
+ // comments, which are common, see below
+
+ // common stuff
+ private string comment;
+
+ protected override void clear_properties() {
+ base.clear_properties();
+
+ file_path = "";
+ development_path = "";
+ is_raw = false;
+ filesize = 0;
+ original_dim = Dimensions(0, 0);
+ camera_make = "";
+ camera_model = "";
+ flash = "";
+ focal_length = "";
+ gps_lat = -1;
+ gps_lat_ref = "";
+ gps_long = -1;
+ gps_long_ref = "";
+ artist = "";
+ copyright = "";
+ software = "";
+ exposure_bias = "";
+ exposure_date = "";
+ exposure_time = "";
+ comment = "";
+ }
+
+ protected override void get_single_properties(DataView view) {
+ base.get_single_properties(view);
+
+ DataSource source = view.get_source();
+ if (source == null)
+ return;
+
+ if (source is MediaSource) {
+ MediaSource media = (MediaSource) source;
+ file_path = media.get_master_file().get_path();
+ development_path = media.get_file().get_path();
+ filesize = media.get_master_filesize();
+
+ // as of right now, all extended properties other than filesize, filepath & comment aren't
+ // applicable to non-photo media types, so if the current media source isn't a photo,
+ // just do a short-circuit return
+ Photo photo = media as Photo;
+ if (photo == null)
+ return;
+
+ PhotoMetadata? metadata;
+
+ try {
+ // For some raw files, the developments may not contain metadata (please
+ // see the comment about cameras generating 'crazy' exif segments in
+ // Photo.develop_photo() for why), and so we'll want to display what was
+ // in the original raw file instead.
+ metadata = photo.get_master_metadata();
+ } catch (Error e) {
+ metadata = photo.get_metadata();
+ }
+
+ if (metadata == null)
+ return;
+
+ // Fix up any timestamp weirdness.
+ //
+ // If the exposure date wasn't properly set (the most likely cause of this
+ // is a raw with a metadataless development), use the one from the photo
+ // row.
+ if (metadata.get_exposure_date_time() == null)
+ metadata.set_exposure_date_time(new MetadataDateTime(photo.get_timestamp()));
+
+ is_raw = (photo.get_master_file_format() == PhotoFileFormat.RAW);
+ original_dim = metadata.get_pixel_dimensions();
+ camera_make = metadata.get_camera_make();
+ camera_model = metadata.get_camera_model();
+ flash = metadata.get_flash_string();
+ focal_length = metadata.get_focal_length_string();
+ metadata.get_gps(out gps_long, out gps_long_ref, out gps_lat, out gps_lat_ref, out gps_alt);
+ artist = metadata.get_artist();
+ 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));
+ comment = media.get_comment();
+ } else if (source is EventSource) {
+ Event event = (Event) source;
+ comment = event.get_comment();
+ }
+ }
+
+ public override void internal_update_properties(Page page) {
+ base.internal_update_properties(page);
+
+ if (page is EventsDirectoryPage) {
+ // nothing special to be done for now for Events
+ } else {
+ add_line(_("Location:"), (file_path != "" && file_path != null) ?
+ file_path.replace("&", "&amp;") : NO_VALUE);
+
+ add_line(_("File size:"), (filesize > 0) ?
+ format_size((int64) filesize) : NO_VALUE);
+
+ if (is_raw)
+ add_line(_("Current Development:"), development_path);
+
+ add_line(_("Original dimensions:"), (original_dim != null && original_dim.has_area()) ?
+ "%d &#215; %d".printf(original_dim.width, original_dim.height) : NO_VALUE);
+
+ add_line(_("Camera make:"), (camera_make != "" && camera_make != null) ?
+ camera_make : NO_VALUE);
+
+ add_line(_("Camera model:"), (camera_model != "" && camera_model != null) ?
+ camera_model : NO_VALUE);
+
+ add_line(_("Flash:"), (flash != "" && flash != null) ? flash : NO_VALUE);
+
+ add_line(_("Focal length:"), (focal_length != "" && focal_length != null) ?
+ focal_length : NO_VALUE);
+
+ add_line(_("Exposure date:"), (exposure_date != "" && exposure_date != null) ?
+ exposure_date : NO_VALUE);
+
+ add_line(_("Exposure time:"), (exposure_time != "" && exposure_time != null) ?
+ exposure_time : NO_VALUE);
+
+ add_line(_("Exposure bias:"), (exposure_bias != "" && exposure_bias != null) ? exposure_bias : NO_VALUE);
+
+ add_line(_("GPS latitude:"), (gps_lat != -1 && gps_lat_ref != "" &&
+ gps_lat_ref != null) ? "%f °%s".printf(gps_lat, gps_lat_ref) : NO_VALUE);
+
+ add_line(_("GPS longitude:"), (gps_long != -1 && gps_long_ref != "" &&
+ gps_long_ref != null) ? "%f °%s".printf(gps_long, gps_long_ref) : NO_VALUE);
+
+ add_line(_("Artist:"), (artist != "" && artist != null) ? artist : NO_VALUE);
+
+ add_line(_("Copyright:"), (copyright != "" && copyright != null) ? copyright : NO_VALUE);
+
+ add_line(_("Software:"), (software != "" && software != null) ? software : NO_VALUE);
+ }
+
+ bool has_comment = (comment != "" && comment != null);
+ add_line(_("Comment:"), has_comment ? comment : NO_VALUE, has_comment);
+ }
+ }
+
+ public ExtendedPropertiesWindow(Gtk.Window owner) {
+ add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.KEY_PRESS_MASK);
+ focus_on_map = true;
+ set_accept_focus(true);
+ set_can_focus(true);
+ set_title(_("Extended Information"));
+ set_size_request(300,-1);
+ set_default_size(520, -1);
+ set_position(Gtk.WindowPosition.CENTER);
+ set_transient_for(owner);
+ set_type_hint(Gdk.WindowTypeHint.DIALOG);
+
+ delete_event.connect(hide_on_delete);
+
+ properties = new ExtendedProperties();
+ Gtk.Alignment alignment = new Gtk.Alignment(0.5f,0.5f,1,1);
+ alignment.add(properties);
+ alignment.set_padding(4, 4, 4, 4);
+ ((Gtk.Box) get_content_area()).add(alignment);
+ close_button = new Gtk.Button.from_stock(Gtk.Stock.CLOSE);
+ close_button.clicked.connect(on_close_clicked);
+
+ Gtk.Alignment action_alignment = new Gtk.Alignment(1, 0.5f, 1, 1);
+ action_alignment.add(close_button);
+ ((Gtk.Container) get_action_area()).add(action_alignment);
+
+ set_has_resize_grip(false);
+ }
+
+ ~ExtendedPropertiesWindow() {
+ close_button.clicked.disconnect(on_close_clicked);
+ }
+
+ public override bool button_press_event(Gdk.EventButton event) {
+ // LMB only
+ if (event.button != 1)
+ return (base.button_press_event != null) ? base.button_press_event(event) : true;
+
+ begin_move_drag((int) event.button, (int) event.x_root, (int) event.y_root, event.time);
+
+ return true;
+ }
+
+ private void on_close_clicked() {
+ hide();
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ // hide properties
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ hide();
+ return true;
+ }
+ // or send through to AppWindow
+ return AppWindow.get_instance().key_press_event(event);
+ }
+
+ public void update_properties(Page page) {
+ properties.update_properties(page);
+ }
+
+ public override void show_all() {
+ base.show_all();
+ properties.unselect_text();
+ grab_focus();
+ }
+}
diff --git a/src/Resources.vala b/src/Resources.vala
new file mode 100644
index 0000000..c8f02c4
--- /dev/null
+++ b/src/Resources.vala
@@ -0,0 +1,1143 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// defined by ./configure or Makefile and included by gcc -D
+extern const string _PREFIX;
+extern const string _VERSION;
+extern const string GETTEXT_PACKAGE;
+extern const string _LIB;
+extern const string _LIBEXECDIR;
+extern const string? _GIT_VERSION;
+
+namespace Resources {
+ public const string APP_TITLE = "Shotwell";
+ public const string APP_LIBRARY_ROLE = _("Photo Manager");
+ public const string APP_DIRECT_ROLE = _("Photo Viewer");
+ public const string APP_VERSION = _VERSION;
+
+#if _GITVERSION
+ public const string? GIT_VERSION = _GIT_VERSION;
+#else
+ public const string? GIT_VERSION = null;
+#endif
+
+ public const string COPYRIGHT = _("Copyright 2009-2014 Yorba Foundation");
+ public const string APP_GETTEXT_PACKAGE = GETTEXT_PACKAGE;
+
+ public const string HOME_URL = "https://wiki.gnome.org/Apps/Shotwell";
+ public const string FAQ_URL = "https://wiki.gnome.org/Apps/Shotwell/FAQ";
+ public const string BUG_DB_URL = "https://wiki.gnome.org/Apps/Shotwell/ReportingABug";
+ public const string DIR_PATTERN_URI_SYSWIDE = "ghelp:shotwell?other-files";
+
+ private const string LIB = _LIB;
+ private const string LIBEXECDIR = _LIBEXECDIR;
+
+ public const string PREFIX = _PREFIX;
+
+ public const double TRANSIENT_WINDOW_OPACITY = 0.90;
+
+ public const int DEFAULT_ICON_SCALE = 24;
+
+ public const string[] AUTHORS = {
+ "Jim Nelson <jim@yorba.org>",
+ "Lucas Beeler <lucas@yorba.org>",
+ "Allison Barlow <allison@yorba.org>",
+ "Eric Gregory <eric@yorba.org>",
+ "Clinton Rogers <clinton@yorba.org>",
+ null
+ };
+
+ public const string LICENSE = """
+Shotwell is free software; you can redistribute it and/or modify it under the
+terms of the GNU Lesser General Public License as published by the Free
+Software Foundation; either version 2.1 of the License, or (at your option)
+any later version.
+
+Shotwell is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for
+more details.
+
+You should have received a copy of the GNU Lesser General Public License
+along with Shotwell; if not, write to the Free Software Foundation, Inc.,
+51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+""";
+
+ public const string CLOCKWISE = "object-rotate-right";
+ public const string COUNTERCLOCKWISE = "object-rotate-left";
+ public const string HFLIP = "object-flip-horizontal";
+ public const string VFLIP = "object-flip-vertical";
+ public const string CROP = "shotwell-crop";
+ public const string STRAIGHTEN = "shotwell-straighten";
+ public const string REDEYE = "shotwell-redeye";
+ public const string ADJUST = "shotwell-adjust";
+ public const string PIN_TOOLBAR = "shotwell-pin-toolbar";
+ public const string MAKE_PRIMARY = "shotwell-make-primary";
+ public const string IMPORT = "shotwell-import";
+ public const string IMPORT_ALL = "shotwell-import-all";
+ public const string ENHANCE = "shotwell-auto-enhance";
+ public const string CROP_PIVOT_RETICLE = "shotwell-crop-pivot-reticle";
+ public const string PUBLISH = "applications-internet";
+ public const string MERGE = "shotwell-merge-events";
+
+ public const string ICON_APP = "shotwell.svg";
+ public const string ICON_APP16 = "shotwell-16.svg";
+ public const string ICON_APP24 = "shotwell-24.svg";
+
+ public const string APP_ICONS[] = { ICON_APP, ICON_APP16, ICON_APP24 };
+
+ public const string ICON_ABOUT_LOGO = "shotwell-street.jpg";
+ public const string ICON_GENERIC_PLUGIN = "generic-plugin.png";
+ public const string ICON_SLIDESHOW_EXTENSION_POINT = "slideshow-extension-point";
+ public const string ICON_RATING_REJECTED = "rejected.svg";
+ public const string ICON_RATING_ONE = "one-star.svg";
+ public const string ICON_RATING_TWO = "two-stars.svg";
+ public const string ICON_RATING_THREE = "three-stars.svg";
+ public const string ICON_RATING_FOUR = "four-stars.svg";
+ public const string ICON_RATING_FIVE = "five-stars.svg";
+ public const string ICON_FILTER_REJECTED_OR_BETTER = "all-rejected.png";
+ public const int ICON_FILTER_REJECTED_OR_BETTER_FIXED_SIZE = 32;
+ public const string ICON_FILTER_UNRATED_OR_BETTER = "shotwell-16.svg";
+ public const int ICON_FILTER_UNRATED_OR_BETTER_FIXED_SIZE = 16;
+ public const string ICON_FILTER_ONE_OR_BETTER = "one-star-filter-plus.svg";
+ public const string ICON_FILTER_TWO_OR_BETTER = "two-star-filter-plus.svg";
+ public const string ICON_FILTER_THREE_OR_BETTER = "three-star-filter-plus.svg";
+ public const string ICON_FILTER_FOUR_OR_BETTER = "four-star-filter-plus.svg";
+ public const string ICON_FILTER_FIVE = "five-star-filter.svg";
+ public const string ICON_ZOOM_IN = "zoom-in.png";
+ public const string ICON_ZOOM_OUT = "zoom-out.png";
+ public const int ICON_ZOOM_SCALE = 16;
+
+ public const string ICON_CAMERAS = "camera-photo";
+ public const string ICON_EVENTS = "multiple-events";
+ public const string ICON_ONE_EVENT = "one-event";
+ public const string ICON_NO_EVENT = "no-event";
+ public const string ICON_ONE_TAG = "one-tag";
+ public const string ICON_TAGS = "multiple-tags";
+ public const string ICON_FOLDER_CLOSED = "folder";
+ public const string ICON_FOLDER_OPEN = "folder-open";
+ public const string ICON_FOLDER_DOCUMENTS = "folder-documents";
+ public const string ICON_IMPORTING = "go-down";
+ public const string ICON_LAST_IMPORT = "document-open-recent";
+ public const string ICON_MISSING_FILES = "process-stop";
+ public const string ICON_PHOTOS = "shotwell-16";
+ public const string ICON_SINGLE_PHOTO = "image-x-generic";
+ public const string ICON_FILTER_PHOTOS = "filter-photos";
+ public const string ICON_FILTER_PHOTOS_DISABLED = "filter-photos-disabled";
+ public const string ICON_FILTER_VIDEOS = "filter-videos";
+ public const string ICON_FILTER_VIDEOS_DISABLED = "filter-videos-disabled";
+ public const string ICON_FILTER_RAW = "filter-raw";
+ public const string ICON_FILTER_RAW_DISABLED = "filter-raw-disabled";
+ public const string ICON_FILTER_FLAGGED = "filter-flagged";
+ public const string ICON_FILTER_FLAGGED_DISABLED = "filter-flagged-disabled";
+ public const string ICON_TRASH_EMPTY = "user-trash";
+ public const string ICON_TRASH_FULL = "user-trash-full";
+ public const string ICON_VIDEOS_PAGE = "videos-page";
+ public const string ICON_FLAGGED_PAGE = "flag-page";
+ public const string ICON_FLAGGED_TRINKET = "flag-trinket.png";
+
+ public const string ROTATE_CW_MENU = _("Rotate _Right");
+ public const string ROTATE_CW_LABEL = _("Rotate");
+ public const string ROTATE_CW_FULL_LABEL = _("Rotate Right");
+ public const string ROTATE_CW_TOOLTIP = _("Rotate the photos right (press Ctrl to rotate left)");
+
+ public const string ROTATE_CCW_MENU = _("Rotate _Left");
+ public const string ROTATE_CCW_LABEL = _("Rotate");
+ public const string ROTATE_CCW_FULL_LABEL = _("Rotate Left");
+ public const string ROTATE_CCW_TOOLTIP = _("Rotate the photos left");
+
+ public const string HFLIP_MENU = _("Flip Hori_zontally");
+ public const string HFLIP_LABEL = _("Flip Horizontally");
+
+ public const string VFLIP_MENU = _("Flip Verti_cally");
+ public const string VFLIP_LABEL = _("Flip Vertically");
+
+ public const string ENHANCE_MENU = _("_Enhance");
+ public const string ENHANCE_LABEL = _("Enhance");
+ public const string ENHANCE_TOOLTIP = _("Automatically improve the photo's appearance");
+
+ public const string COPY_ADJUSTMENTS_MENU = _("_Copy Color Adjustments");
+ public const string COPY_ADJUSTMENTS_LABEL = _("Copy Color Adjustments");
+ public const string COPY_ADJUSTMENTS_TOOLTIP = _("Copy the color adjustments applied to the photo");
+
+ public const string PASTE_ADJUSTMENTS_MENU = _("_Paste Color Adjustments");
+ public const string PASTE_ADJUSTMENTS_LABEL = _("Paste Color Adjustments");
+ public const string PASTE_ADJUSTMENTS_TOOLTIP = _("Apply copied color adjustments to the selected photos");
+
+ public const string CROP_MENU = _("_Crop");
+ public const string CROP_LABEL = _("Crop");
+ public const string CROP_TOOLTIP = _("Crop the photo's size");
+
+ public const string STRAIGHTEN_MENU = _("_Straighten");
+ public const string STRAIGHTEN_LABEL = _("Straighten");
+ public const string STRAIGHTEN_TOOLTIP = _("Straighten the photo");
+
+ public const string RED_EYE_MENU = _("_Red-eye");
+ public const string RED_EYE_LABEL = _("Red-eye");
+ public const string RED_EYE_TOOLTIP = _("Reduce or eliminate any red-eye effects in the photo");
+
+ public const string ADJUST_MENU = _("_Adjust");
+ public const string ADJUST_LABEL = _("Adjust");
+ public const string ADJUST_TOOLTIP = _("Adjust the photo's color and tone");
+
+ public const string REVERT_MENU = _("Re_vert to Original");
+ public const string REVERT_LABEL = _("Revert to Original");
+
+ public const string REVERT_EDITABLE_MENU = _("Revert External E_dits");
+ public const string REVERT_EDITABLE_TOOLTIP = _("Revert to the master photo");
+
+ public const string SET_BACKGROUND_MENU = _("Set as _Desktop Background");
+ public const string SET_BACKGROUND_TOOLTIP = _("Set selected image to be the new desktop background");
+ public const string SET_BACKGROUND_SLIDESHOW_MENU = _("Set as _Desktop Slideshow...");
+
+ public const string UNDO_MENU = _("_Undo");
+ public const string UNDO_LABEL = _("Undo");
+
+ public const string REDO_MENU = _("_Redo");
+ public const string REDO_LABEL = _("Redo");
+
+ public const string RENAME_EVENT_MENU = _("Re_name Event...");
+ public const string RENAME_EVENT_LABEL = _("Rename Event");
+
+ public const string MAKE_KEY_PHOTO_MENU = _("Make _Key Photo for Event");
+ public const string MAKE_KEY_PHOTO_LABEL = _("Make Key Photo for Event");
+
+ public const string NEW_EVENT_MENU = _("_New Event");
+ public const string NEW_EVENT_LABEL = _("New Event");
+
+ public const string SET_PHOTO_EVENT_LABEL = _("Move Photos");
+ public const string SET_PHOTO_EVENT_TOOLTIP = _("Move photos to an event");
+
+ public const string MERGE_MENU = _("_Merge Events");
+ public const string MERGE_LABEL = _("Merge");
+ public const string MERGE_TOOLTIP = _("Combine events into a single event");
+
+ public const string RATING_MENU = _("_Set Rating");
+ public const string RATING_LABEL = _("Set Rating");
+ public const string RATING_TOOLTIP = _("Change the rating of your photo");
+
+ public const string INCREASE_RATING_MENU = _("_Increase");
+ public const string INCREASE_RATING_LABEL = _("Increase Rating");
+
+ public const string DECREASE_RATING_MENU = _("_Decrease");
+ public const string DECREASE_RATING_LABEL = _("Decrease Rating");
+
+ public const string RATE_UNRATED_MENU = _("_Unrated");
+ public const string RATE_UNRATED_COMBO_BOX = _("Unrated");
+ public const string RATE_UNRATED_LABEL = _("Rate Unrated");
+ public const string RATE_UNRATED_PROGRESS = _("Setting as unrated");
+ public const string RATE_UNRATED_TOOLTIP = _("Remove any ratings");
+
+ public const string RATE_REJECTED_MENU = _("_Rejected");
+ public const string RATE_REJECTED_COMBO_BOX = _("Rejected");
+ public const string RATE_REJECTED_LABEL = _("Rate Rejected");
+ public const string RATE_REJECTED_PROGRESS = _("Setting as rejected");
+ public const string RATE_REJECTED_TOOLTIP = _("Set rating to rejected");
+
+ public const string DISPLAY_REJECTED_ONLY_MENU = _("Rejected _Only");
+ public const string DISPLAY_REJECTED_ONLY_LABEL = _("Rejected Only");
+ public const string DISPLAY_REJECTED_ONLY_TOOLTIP = _("Show only rejected photos");
+
+ public const string DISPLAY_REJECTED_OR_HIGHER_MENU = _("All + _Rejected");
+ public const string DISPLAY_REJECTED_OR_HIGHER_LABEL = _("Show all photos, including rejected");
+ public const string DISPLAY_REJECTED_OR_HIGHER_TOOLTIP = _("Show all photos, including rejected");
+
+ public const string DISPLAY_UNRATED_OR_HIGHER_MENU = _("_All Photos");
+ public const string DISPLAY_UNRATED_OR_HIGHER_LABEL = _("Show all photos");
+ public const string DISPLAY_UNRATED_OR_HIGHER_TOOLTIP = _("Show all photos");
+
+ public const string VIEW_RATINGS_MENU = _("_Ratings");
+ public const string VIEW_RATINGS_TOOLTIP = _("Display each photo's rating");
+
+ public const string FILTER_PHOTOS_MENU = _("_Filter Photos");
+ public const string FILTER_PHOTOS_LABEL = _("Filter Photos");
+ public const string FILTER_PHOTOS_TOOLTIP = _("Limit the number of photos displayed based on a filter");
+
+ public const string DUPLICATE_PHOTO_MENU = _("_Duplicate");
+ public const string DUPLICATE_PHOTO_LABEL = _("Duplicate");
+ public const string DUPLICATE_PHOTO_TOOLTIP = _("Make a duplicate of the photo");
+
+ public const string EXPORT_MENU = _("_Export...");
+
+ public const string PRINT_MENU = _("_Print...");
+
+ public const string PUBLISH_MENU = _("Pu_blish...");
+ public const string PUBLISH_LABEL = _("Publish");
+ public const string PUBLISH_TOOLTIP = _("Publish to various websites");
+
+ public const string EDIT_TITLE_MENU = _("Edit _Title...");
+ public const string EDIT_TITLE_LABEL = _("Edit Title");
+
+ public const string EDIT_COMMENT_MENU = _("Edit _Comment...");
+ public const string EDIT_COMMENT_LABEL = _("Edit Comment");
+
+ public const string EDIT_EVENT_COMMENT_MENU = _("Edit Event _Comment...");
+ public const string EDIT_EVENT_COMMENT_LABEL = _("Edit Event Comment");
+
+ public const string ADJUST_DATE_TIME_MENU = _("_Adjust Date and Time...");
+ public const string ADJUST_DATE_TIME_LABEL = _("Adjust Date and Time");
+
+ public const string ADD_TAGS_MENU = _("Add _Tags...");
+ public const string ADD_TAGS_CONTEXT_MENU = _("_Add Tags...");
+ public const string ADD_TAGS_TITLE = _("Add Tags");
+
+ public const string PREFERENCES_MENU = _("_Preferences");
+
+ public const string EXTERNAL_EDIT_MENU = _("Open With E_xternal Editor");
+
+ public const string EXTERNAL_EDIT_RAW_MENU = _("Open With RA_W Editor");
+
+ public const string SEND_TO_MENU = _("Send _To...");
+ public const string SEND_TO_CONTEXT_MENU = _("Send T_o...");
+
+ public const string FIND_MENU = _("_Find...");
+ public const string FIND_LABEL = _("Find");
+ public const string FIND_TOOLTIP = _("Find an image by typing text that appears in its name or tags");
+
+ public const string FLAG_MENU = _("_Flag");
+
+ public const string UNFLAG_MENU = _("Un_flag");
+
+ public string launch_editor_failed(Error err) {
+ return _("Unable to launch editor: %s").printf(err.message);
+ }
+
+ public string add_tags_label(string[] names) {
+ if (names.length == 1)
+ return _("Add Tag \"%s\"").printf(HierarchicalTagUtilities.get_basename(names[0]));
+ else if (names.length == 2)
+ return _("Add Tags \"%s\" and \"%s\"").printf(
+ HierarchicalTagUtilities.get_basename(names[0]),
+ HierarchicalTagUtilities.get_basename(names[1]));
+ else
+ return _("Add Tags");
+ }
+
+ public string delete_tag_menu(string name) {
+ return _("_Delete Tag \"%s\"").printf(name);
+ }
+
+ public string delete_tag_label(string name) {
+ return _("Delete Tag \"%s\"").printf(name);
+ }
+
+ public const string DELETE_TAG_TITLE = _("Delete Tag");
+ public const string DELETE_TAG_SIDEBAR_MENU = _("_Delete");
+
+ public const string NEW_CHILD_TAG_SIDEBAR_MENU = _("_New");
+
+ public string rename_tag_menu(string name) {
+ return _("Re_name Tag \"%s\"...").printf(name);
+ }
+
+ public string rename_tag_label(string old_name, string new_name) {
+ return _("Rename Tag \"%s\" to \"%s\"").printf(old_name, new_name);
+ }
+
+ public const string RENAME_TAG_SIDEBAR_MENU = _("_Rename...");
+
+ public const string MODIFY_TAGS_MENU = _("Modif_y Tags...");
+ public const string MODIFY_TAGS_LABEL = _("Modify Tags");
+
+ public string tag_photos_label(string name, int count) {
+ return ((count == 1) ? _("Tag Photo as \"%s\"") : _("Tag Photos as \"%s\"")).printf(name);
+ }
+
+ public string tag_photos_tooltip(string name, int count) {
+ return ((count == 1) ? _("Tag the selected photo as \"%s\"") :
+ _("Tag the selected photos as \"%s\"")).printf(name);
+ }
+
+ public string untag_photos_menu(string name, int count) {
+ return ((count == 1) ? _("Remove Tag \"%s\" From _Photo") :
+ _("Remove Tag \"%s\" From _Photos")).printf(name);
+ }
+
+ public string untag_photos_label(string name, int count) {
+ return ((count == 1) ? _("Remove Tag \"%s\" From Photo") :
+ _("Remove Tag \"%s\" From Photos")).printf(name);
+ }
+
+ public static string rename_tag_exists_message(string name) {
+ return _("Unable to rename tag to \"%s\" because the tag already exists.").printf(name);
+ }
+
+ public static string rename_search_exists_message(string name) {
+ return _("Unable to rename search to \"%s\" because the search already exists.").printf(name);
+ }
+
+ public const string DEFAULT_SAVED_SEARCH_NAME = _("Saved Search");
+
+ public const string DELETE_SAVED_SEARCH_DIALOG_TITLE = _("Delete Search");
+
+ public const string DELETE_SEARCH_MENU = _("_Delete");
+ public const string EDIT_SEARCH_MENU = _("_Edit...");
+ public const string RENAME_SEARCH_MENU = _("Re_name...");
+
+ public string rename_search_label(string old_name, string new_name) {
+ return _("Rename Search \"%s\" to \"%s\"").printf(old_name, new_name);
+ }
+
+ public string delete_search_label(string name) {
+ return _("Delete Search \"%s\"").printf(name);
+ }
+
+ private unowned string rating_menu(Rating rating) {
+ switch (rating) {
+ case Rating.REJECTED:
+ return RATE_REJECTED_MENU;
+ case Rating.UNRATED:
+ return RATE_UNRATED_MENU;
+ case Rating.ONE:
+ return RATE_ONE_MENU;
+ case Rating.TWO:
+ return RATE_TWO_MENU;
+ case Rating.THREE:
+ return RATE_THREE_MENU;
+ case Rating.FOUR:
+ return RATE_FOUR_MENU;
+ case Rating.FIVE:
+ return RATE_FIVE_MENU;
+ default:
+ return RATE_UNRATED_MENU;
+ }
+ }
+
+ private unowned string rating_label(Rating rating) {
+ switch (rating) {
+ case Rating.REJECTED:
+ return RATE_REJECTED_LABEL;
+ case Rating.UNRATED:
+ return RATE_UNRATED_LABEL;
+ case Rating.ONE:
+ return RATE_ONE_LABEL;
+ case Rating.TWO:
+ return RATE_TWO_LABEL;
+ case Rating.THREE:
+ return RATE_THREE_LABEL;
+ case Rating.FOUR:
+ return RATE_FOUR_LABEL;
+ case Rating.FIVE:
+ return RATE_FIVE_LABEL;
+ default:
+ return RATE_UNRATED_LABEL;
+ }
+ }
+
+ private unowned string rating_combo_box(Rating rating) {
+ switch (rating) {
+ case Rating.REJECTED:
+ return RATE_REJECTED_COMBO_BOX;
+ case Rating.UNRATED:
+ return RATE_UNRATED_COMBO_BOX;
+ case Rating.ONE:
+ return RATE_ONE_MENU;
+ case Rating.TWO:
+ return RATE_TWO_MENU;
+ case Rating.THREE:
+ return RATE_THREE_MENU;
+ case Rating.FOUR:
+ return RATE_FOUR_MENU;
+ case Rating.FIVE:
+ return RATE_FIVE_MENU;
+ default:
+ return RATE_UNRATED_MENU;
+ }
+ }
+
+ private string get_rating_filter_tooltip(RatingFilter filter) {
+ switch (filter) {
+ case RatingFilter.REJECTED_OR_HIGHER:
+ return Resources.DISPLAY_REJECTED_OR_HIGHER_TOOLTIP;
+
+ case RatingFilter.ONE_OR_HIGHER:
+ return Resources.DISPLAY_ONE_OR_HIGHER_TOOLTIP;
+
+ case RatingFilter.TWO_OR_HIGHER:
+ return Resources.DISPLAY_TWO_OR_HIGHER_TOOLTIP;
+
+ case RatingFilter.THREE_OR_HIGHER:
+ return Resources.DISPLAY_THREE_OR_HIGHER_TOOLTIP;
+
+ case RatingFilter.FOUR_OR_HIGHER:
+ return Resources.DISPLAY_FOUR_OR_HIGHER_TOOLTIP;
+
+ case RatingFilter.FIVE_ONLY:
+ case RatingFilter.FIVE_OR_HIGHER:
+ return Resources.DISPLAY_FIVE_OR_HIGHER_TOOLTIP;
+
+ case RatingFilter.REJECTED_ONLY:
+ return Resources.DISPLAY_REJECTED_ONLY_TOOLTIP;
+
+ case RatingFilter.UNRATED_OR_HIGHER:
+ default:
+ return Resources.DISPLAY_UNRATED_OR_HIGHER_TOOLTIP;
+ }
+ }
+
+ private string rating_progress(Rating rating) {
+ switch (rating) {
+ case Rating.REJECTED:
+ return RATE_REJECTED_PROGRESS;
+ case Rating.UNRATED:
+ return RATE_UNRATED_PROGRESS;
+ case Rating.ONE:
+ return RATE_ONE_PROGRESS;
+ case Rating.TWO:
+ return RATE_TWO_PROGRESS;
+ case Rating.THREE:
+ return RATE_THREE_PROGRESS;
+ case Rating.FOUR:
+ return RATE_FOUR_PROGRESS;
+ case Rating.FIVE:
+ return RATE_FIVE_PROGRESS;
+ default:
+ return RATE_UNRATED_PROGRESS;
+ }
+ }
+
+ private const int[] rating_thresholds = { 0, 1, 25, 50, 75, 99 };
+
+ private string get_stars(Rating rating) {
+ switch (rating) {
+ case Rating.ONE:
+ return "\xE2\x98\x85";
+ case Rating.TWO:
+ return "\xE2\x98\x85\xE2\x98\x85";
+ case Rating.THREE:
+ return "\xE2\x98\x85\xE2\x98\x85\xE2\x98\x85";
+ case Rating.FOUR:
+ return "\xE2\x98\x85\xE2\x98\x85\xE2\x98\x85\xE2\x98\x85";
+ case Rating.FIVE:
+ return "\xE2\x98\x85\xE2\x98\x85\xE2\x98\x85\xE2\x98\x85\xE2\x98\x85";
+ default:
+ return "";
+ }
+ }
+
+ private Gdk.Pixbuf? get_rating_trinket(Rating rating, int scale) {
+ switch (rating) {
+ case Rating.REJECTED:
+ return Resources.get_icon(Resources.ICON_RATING_REJECTED, scale);
+ // case Rating.UNRATED needs no icon
+ case Rating.ONE:
+ return Resources.get_icon(Resources.ICON_RATING_ONE, scale);
+ case Rating.TWO:
+ return Resources.get_icon(Resources.ICON_RATING_TWO, scale*2);
+ case Rating.THREE:
+ return Resources.get_icon(Resources.ICON_RATING_THREE, scale*3);
+ case Rating.FOUR:
+ return Resources.get_icon(Resources.ICON_RATING_FOUR, scale*4);
+ case Rating.FIVE:
+ return Resources.get_icon(Resources.ICON_RATING_FIVE, scale*5);
+ default:
+ return null;
+ }
+ }
+
+ private void generate_rating_strings() {
+ string menu_base = "%s";
+ string label_base = _("Rate %s");
+ string tooltip_base = _("Set rating to %s");
+ string progress_base = _("Setting rating to %s");
+ string display_rating_menu_base = "%s";
+ string display_rating_label_base = _("Display %s");
+ string display_rating_tooltip_base = _("Only show photos with a rating of %s");
+ string display_rating_or_higher_menu_base = _("%s or Better");
+ string display_rating_or_higher_label_base = _("Display %s or Better");
+ string display_rating_or_higher_tooltip_base = _("Only show photos with a rating of %s or better");
+
+ RATE_ONE_MENU = menu_base.printf(get_stars(Rating.ONE));
+ RATE_TWO_MENU = menu_base.printf(get_stars(Rating.TWO));
+ RATE_THREE_MENU = menu_base.printf(get_stars(Rating.THREE));
+ RATE_FOUR_MENU = menu_base.printf(get_stars(Rating.FOUR));
+ RATE_FIVE_MENU = menu_base.printf(get_stars(Rating.FIVE));
+
+ RATE_ONE_LABEL = label_base.printf(get_stars(Rating.ONE));
+ RATE_TWO_LABEL = label_base.printf(get_stars(Rating.TWO));
+ RATE_THREE_LABEL = label_base.printf(get_stars(Rating.THREE));
+ RATE_FOUR_LABEL = label_base.printf(get_stars(Rating.FOUR));
+ RATE_FIVE_LABEL = label_base.printf(get_stars(Rating.FIVE));
+
+ RATE_ONE_TOOLTIP = tooltip_base.printf(get_stars(Rating.ONE));
+ RATE_TWO_TOOLTIP = tooltip_base.printf(get_stars(Rating.TWO));
+ RATE_THREE_TOOLTIP = tooltip_base.printf(get_stars(Rating.THREE));
+ RATE_FOUR_TOOLTIP = tooltip_base.printf(get_stars(Rating.FOUR));
+ RATE_FIVE_TOOLTIP = tooltip_base.printf(get_stars(Rating.FIVE));
+
+ RATE_ONE_PROGRESS = progress_base.printf(get_stars(Rating.ONE));
+ RATE_TWO_PROGRESS = progress_base.printf(get_stars(Rating.TWO));
+ RATE_THREE_PROGRESS = progress_base.printf(get_stars(Rating.THREE));
+ RATE_FOUR_PROGRESS = progress_base.printf(get_stars(Rating.FOUR));
+ RATE_FIVE_PROGRESS = progress_base.printf(get_stars(Rating.FIVE));
+
+ DISPLAY_ONE_OR_HIGHER_MENU = display_rating_or_higher_menu_base.printf(get_stars(Rating.ONE));
+ DISPLAY_TWO_OR_HIGHER_MENU = display_rating_or_higher_menu_base.printf(get_stars(Rating.TWO));
+ DISPLAY_THREE_OR_HIGHER_MENU = display_rating_or_higher_menu_base.printf(get_stars(Rating.THREE));
+ DISPLAY_FOUR_OR_HIGHER_MENU = display_rating_or_higher_menu_base.printf(get_stars(Rating.FOUR));
+ DISPLAY_FIVE_OR_HIGHER_MENU = display_rating_menu_base.printf(get_stars(Rating.FIVE));
+
+ DISPLAY_ONE_OR_HIGHER_LABEL = display_rating_or_higher_label_base.printf(get_stars(Rating.ONE));
+ DISPLAY_TWO_OR_HIGHER_LABEL = display_rating_or_higher_label_base.printf(get_stars(Rating.TWO));
+ DISPLAY_THREE_OR_HIGHER_LABEL = display_rating_or_higher_label_base.printf(get_stars(Rating.THREE));
+ DISPLAY_FOUR_OR_HIGHER_LABEL = display_rating_or_higher_label_base.printf(get_stars(Rating.FOUR));
+ DISPLAY_FIVE_OR_HIGHER_LABEL = display_rating_label_base.printf(get_stars(Rating.FIVE));
+
+ DISPLAY_ONE_OR_HIGHER_TOOLTIP = display_rating_or_higher_tooltip_base.printf(get_stars(Rating.ONE));
+ DISPLAY_TWO_OR_HIGHER_TOOLTIP = display_rating_or_higher_tooltip_base.printf(get_stars(Rating.TWO));
+ DISPLAY_THREE_OR_HIGHER_TOOLTIP = display_rating_or_higher_tooltip_base.printf(get_stars(Rating.THREE));
+ DISPLAY_FOUR_OR_HIGHER_TOOLTIP = display_rating_or_higher_tooltip_base.printf(get_stars(Rating.FOUR));
+ DISPLAY_FIVE_OR_HIGHER_TOOLTIP = display_rating_tooltip_base.printf(get_stars(Rating.FIVE));
+ }
+
+ private string RATE_ONE_MENU;
+ private string RATE_ONE_LABEL;
+ private string RATE_ONE_TOOLTIP;
+ private string RATE_ONE_PROGRESS;
+
+ private string RATE_TWO_MENU;
+ private string RATE_TWO_LABEL;
+ private string RATE_TWO_TOOLTIP;
+ private string RATE_TWO_PROGRESS;
+
+ private string RATE_THREE_MENU;
+ private string RATE_THREE_LABEL;
+ private string RATE_THREE_TOOLTIP;
+ private string RATE_THREE_PROGRESS;
+
+ private string RATE_FOUR_MENU;
+ private string RATE_FOUR_LABEL;
+ private string RATE_FOUR_TOOLTIP;
+ private string RATE_FOUR_PROGRESS;
+
+ private string RATE_FIVE_MENU;
+ private string RATE_FIVE_LABEL;
+ private string RATE_FIVE_TOOLTIP;
+ private string RATE_FIVE_PROGRESS;
+
+ private string DISPLAY_ONE_OR_HIGHER_MENU;
+ private string DISPLAY_ONE_OR_HIGHER_LABEL;
+ private string DISPLAY_ONE_OR_HIGHER_TOOLTIP;
+
+ private string DISPLAY_TWO_OR_HIGHER_MENU;
+ private string DISPLAY_TWO_OR_HIGHER_LABEL;
+ private string DISPLAY_TWO_OR_HIGHER_TOOLTIP;
+
+ private string DISPLAY_THREE_OR_HIGHER_MENU;
+ private string DISPLAY_THREE_OR_HIGHER_LABEL;
+ private string DISPLAY_THREE_OR_HIGHER_TOOLTIP;
+
+ private string DISPLAY_FOUR_OR_HIGHER_MENU;
+ private string DISPLAY_FOUR_OR_HIGHER_LABEL;
+ private string DISPLAY_FOUR_OR_HIGHER_TOOLTIP;
+
+ private string DISPLAY_FIVE_OR_HIGHER_MENU;
+ private string DISPLAY_FIVE_OR_HIGHER_LABEL;
+ private string DISPLAY_FIVE_OR_HIGHER_TOOLTIP;
+
+ public const string DELETE_PHOTOS_MENU = _("_Delete");
+ public const string DELETE_FROM_TRASH_TOOLTIP = _("Remove the selected photos from the trash");
+ public const string DELETE_FROM_LIBRARY_TOOLTIP = _("Remove the selected photos from the library");
+
+ public const string RESTORE_PHOTOS_MENU = _("_Restore");
+ public const string RESTORE_PHOTOS_TOOLTIP = _("Move the selected photos back into the library");
+
+ public const string JUMP_TO_FILE_MENU = _("Show in File Mana_ger");
+ public const string JUMP_TO_FILE_TOOLTIP = _("Open the selected photo's directory in the file manager");
+
+ public string jump_to_file_failed(Error err) {
+ return _("Unable to open in file manager: %s").printf(err.message);
+ }
+
+ public const string REMOVE_FROM_LIBRARY_MENU = _("R_emove From Library");
+
+ public const string MOVE_TO_TRASH_MENU = _("_Move to Trash");
+
+ public const string SELECT_ALL_MENU = _("Select _All");
+ public const string SELECT_ALL_TOOLTIP = _("Select all items");
+
+ private Gtk.IconFactory factory = null;
+ private Gee.HashMap<string, Gdk.Pixbuf> icon_cache = null;
+ Gee.HashMap<string, Gdk.Pixbuf> scaled_icon_cache = null;
+
+ private string HH_MM_FORMAT_STRING = null;
+ private string HH_MM_SS_FORMAT_STRING = null;
+ private string LONG_DATE_FORMAT_STRING = null;
+ private string START_MULTIDAY_DATE_FORMAT_STRING = null;
+ private string END_MULTIDAY_DATE_FORMAT_STRING = null;
+ private string START_MULTIMONTH_DATE_FORMAT_STRING = null;
+
+ public void init () {
+ // load application-wide stock icons as IconSets
+ factory = new Gtk.IconFactory();
+
+ File icons_dir = AppDirs.get_resources_dir().get_child("icons");
+ add_stock_icon(icons_dir.get_child("crop.svg"), CROP);
+ add_stock_icon(icons_dir.get_child("redeye.png"), REDEYE);
+ add_stock_icon(icons_dir.get_child("image-adjust.svg"), ADJUST);
+ add_stock_icon(icons_dir.get_child("pin-toolbar.svg"), PIN_TOOLBAR);
+ add_stock_icon(icons_dir.get_child("make-primary.svg"), MAKE_PRIMARY);
+ add_stock_icon(icons_dir.get_child("import.svg"), IMPORT);
+ add_stock_icon(icons_dir.get_child("straighten.svg"), STRAIGHTEN);
+ add_stock_icon(icons_dir.get_child("import-all.png"), IMPORT_ALL);
+ add_stock_icon(icons_dir.get_child("enhance.png"), ENHANCE);
+ add_stock_icon(icons_dir.get_child("crop-pivot-reticle.png"), CROP_PIVOT_RETICLE);
+ add_stock_icon(icons_dir.get_child("merge.svg"), MERGE);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_FLAGGED_PAGE), ICON_FLAGGED_PAGE);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_VIDEOS_PAGE), ICON_VIDEOS_PAGE);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_SINGLE_PHOTO), ICON_SINGLE_PHOTO);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_CAMERAS), ICON_CAMERAS);
+
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_FILTER_FLAGGED),
+ ICON_FILTER_FLAGGED_DISABLED, dim_pixbuf);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_FILTER_PHOTOS),
+ ICON_FILTER_PHOTOS_DISABLED, dim_pixbuf);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_FILTER_VIDEOS),
+ ICON_FILTER_VIDEOS_DISABLED, dim_pixbuf);
+ add_stock_icon_from_themed_icon(new GLib.ThemedIcon(ICON_FILTER_RAW),
+ ICON_FILTER_RAW_DISABLED, dim_pixbuf);
+
+ factory.add_default();
+
+ generate_rating_strings();
+ }
+
+ public void terminate() {
+ }
+
+ /**
+ * @brief Helper for getting a format string that matches the
+ * user's LC_TIME settings from the system. This is intended
+ * to help support the use case where a user wants the text
+ * from one locale, but the timestamp format of another.
+ *
+ * Stolen wholesale from code written for Geary by Jim Nelson
+ * and from Marcel Stimberg's original patch to Shotwell to
+ * try to fix this; both are graciously thanked for their help.
+ */
+ private void fetch_lc_time_format() {
+ // temporarily unset LANGUAGE, as it interferes with LC_TIME
+ // and friends.
+ string? old_language = Environment.get_variable("LANGUAGE");
+ if (old_language != null) {
+ Environment.unset_variable("LANGUAGE");
+ }
+
+ // switch LC_MESSAGES to LC_TIME...
+ string? old_messages = Intl.setlocale(LocaleCategory.MESSAGES, null);
+ string? lc_time = Intl.setlocale(LocaleCategory.TIME, null);
+
+ if (lc_time != null) {
+ Intl.setlocale(LocaleCategory.MESSAGES, lc_time);
+ }
+
+ // ...precache the timestamp string...
+ /// Locale-specific time format for 12-hour time, i.e. 8:31 PM
+ /// Precede modifier with a dash ("-") to pad with spaces, otherwise will pad with zeroes
+ /// See http://developer.gnome.org/glib/2.32/glib-GDateTime.html#g-date-time-format
+ HH_MM_FORMAT_STRING = _("%-I:%M %p");
+
+ /// Locale-specific time format for 12-hour time with seconds, i.e. 8:31:42 PM
+ /// Precede modifier with a dash ("-") to pad with spaces, otherwise will pad with zeroes
+ /// See http://developer.gnome.org/glib/2.32/glib-GDateTime.html#g-date-time-format
+ HH_MM_SS_FORMAT_STRING = _("%-I:%M:%S %p");
+
+ /// Locale-specific calendar date format, i.e. "Tue Mar 08, 2006"
+ /// See http://developer.gnome.org/glib/2.32/glib-GDateTime.html#g-date-time-format
+ LONG_DATE_FORMAT_STRING = _("%a %b %d, %Y");
+
+ /// Locale-specific starting date format for multi-date strings,
+ /// i.e. the "Tue Mar 08" in "Tue Mar 08 - 10, 2006"
+ /// See http://developer.gnome.org/glib/2.32/glib-GDateTime.html#g-date-time-format
+ START_MULTIDAY_DATE_FORMAT_STRING = _("%a %b %d");
+
+ /// Locale-specific ending date format for multi-date strings,
+ /// i.e. the "10, 2006" in "Tue Mar 08 - 10, 2006"
+ /// See http://developer.gnome.org/glib/2.32/glib-GDateTime.html#g-date-time-format
+ END_MULTIDAY_DATE_FORMAT_STRING = _("%d, %Y");
+
+ /// Locale-specific calendar date format for multi-month strings,
+ /// i.e. the "Tue Mar 08" in "Tue Mar 08 to Mon Apr 06, 2006"
+ /// See http://developer.gnome.org/glib/2.32/glib-GDateTime.html#g-date-time-format
+ START_MULTIMONTH_DATE_FORMAT_STRING = _("%a %b %d");
+
+ // ...put everything back like we found it.
+ if (old_messages != null) {
+ Intl.setlocale(LocaleCategory.MESSAGES, old_messages);
+ }
+
+ if (old_language != null) {
+ Environment.set_variable("LANGUAGE", old_language, true);
+ }
+ }
+
+ /**
+ * @brief Returns a precached format string that matches the
+ * user's LC_TIME settings.
+ */
+ public string get_hh_mm_format_string() {
+ if (HH_MM_FORMAT_STRING == null) {
+ fetch_lc_time_format();
+ }
+
+ return HH_MM_FORMAT_STRING;
+ }
+
+ public string get_hh_mm_ss_format_string() {
+ if (HH_MM_SS_FORMAT_STRING == null) {
+ fetch_lc_time_format();
+ }
+
+ return HH_MM_SS_FORMAT_STRING;
+ }
+
+ public string get_long_date_format_string() {
+ if (LONG_DATE_FORMAT_STRING == null) {
+ fetch_lc_time_format();
+ }
+
+ return LONG_DATE_FORMAT_STRING;
+ }
+
+ public string get_start_multiday_span_format_string() {
+ if (START_MULTIDAY_DATE_FORMAT_STRING == null) {
+ fetch_lc_time_format();
+ }
+
+ return START_MULTIDAY_DATE_FORMAT_STRING;
+ }
+
+ public string get_end_multiday_span_format_string() {
+ if (END_MULTIDAY_DATE_FORMAT_STRING == null) {
+ fetch_lc_time_format();
+ }
+
+ return END_MULTIDAY_DATE_FORMAT_STRING;
+ }
+
+ public string get_start_multimonth_span_format_string() {
+ if (START_MULTIMONTH_DATE_FORMAT_STRING == null) {
+ fetch_lc_time_format();
+ }
+
+ return START_MULTIMONTH_DATE_FORMAT_STRING;
+ }
+
+ public string get_end_multimonth_span_format_string() {
+ return get_long_date_format_string();
+ }
+
+ public File get_ui(string filename) {
+ return AppDirs.get_resources_dir().get_child("ui").get_child(filename);
+ }
+
+ private const string NONINTERPRETABLE_BADGE_FILE = "noninterpretable-video.png";
+ private Gdk.Pixbuf? noninterpretable_badge_pixbuf = null;
+
+ public Gdk.Pixbuf? get_noninterpretable_badge_pixbuf() {
+ if (noninterpretable_badge_pixbuf == null) {
+ try {
+ noninterpretable_badge_pixbuf = new Gdk.Pixbuf.from_file(AppDirs.get_resources_dir().get_child(
+ "icons").get_child(NONINTERPRETABLE_BADGE_FILE).get_path());
+ } catch (Error err) {
+ error("VideoReader can't load noninterpretable badge image: %s", err.message);
+ }
+ }
+
+ return noninterpretable_badge_pixbuf;
+ }
+
+ public Gtk.IconTheme get_icon_theme_engine() {
+ Gtk.IconTheme icon_theme = Gtk.IconTheme.get_default();
+ icon_theme.append_search_path(AppDirs.get_resources_dir().get_child("icons").get_path());
+
+ return icon_theme;
+ }
+
+ // This method returns a reference to a cached pixbuf that may be shared throughout the system.
+ // If the pixbuf is to be modified, make a copy of it.
+ public Gdk.Pixbuf? get_icon(string name, int scale = DEFAULT_ICON_SCALE) {
+ if (scaled_icon_cache != null) {
+ string scaled_name = "%s-%d".printf(name, scale);
+ if (scaled_icon_cache.has_key(scaled_name))
+ return scaled_icon_cache.get(scaled_name);
+ }
+
+ // stash icons not available through the UI Manager (i.e. used directly as pixbufs)
+ // in the local cache
+ if (icon_cache == null)
+ icon_cache = new Gee.HashMap<string, Gdk.Pixbuf>();
+
+ // fetch from cache and if not present, from disk
+ Gdk.Pixbuf? pixbuf = icon_cache.get(name);
+ if (pixbuf == null) {
+ pixbuf = load_icon(name, 0);
+ if (pixbuf == null)
+ return null;
+
+ icon_cache.set(name, pixbuf);
+ }
+
+ if (scale <= 0)
+ return pixbuf;
+
+ Gdk.Pixbuf scaled_pixbuf = scale_pixbuf(pixbuf, scale, Gdk.InterpType.BILINEAR, false);
+
+ if (scaled_icon_cache == null)
+ scaled_icon_cache = new Gee.HashMap<string, Gdk.Pixbuf>();
+
+ scaled_icon_cache.set("%s-%d".printf(name, scale), scaled_pixbuf);
+
+ return scaled_pixbuf;
+ }
+
+ public Gdk.Pixbuf? load_icon(string name, int scale = DEFAULT_ICON_SCALE) {
+ File icons_dir = AppDirs.get_resources_dir().get_child("icons");
+
+ Gdk.Pixbuf pixbuf = null;
+ try {
+ pixbuf = new Gdk.Pixbuf.from_file(icons_dir.get_child(name).get_path());
+ } catch (Error err) {
+ critical("Unable to load icon %s: %s", name, err.message);
+ }
+
+ if (pixbuf == null)
+ return null;
+
+ return (scale > 0) ? scale_pixbuf(pixbuf, scale, Gdk.InterpType.BILINEAR, false) : pixbuf;
+ }
+
+ private void add_stock_icon(File file, string stock_id) {
+ Gdk.Pixbuf pixbuf = null;
+ try {
+ pixbuf = new Gdk.Pixbuf.from_file(file.get_path());
+ } catch (Error err) {
+ critical("Unable to load stock icon %s: %s", stock_id, err.message);
+ }
+
+ Gtk.IconSet icon_set = new Gtk.IconSet.from_pixbuf(pixbuf);
+ factory.add(stock_id, icon_set);
+ }
+
+ public delegate void AddStockIconModify(Gdk.Pixbuf pixbuf);
+
+ private void add_stock_icon_from_themed_icon(GLib.ThemedIcon gicon, string stock_id,
+ AddStockIconModify? modify = null) {
+ Gtk.IconTheme icon_theme = Gtk.IconTheme.get_default();
+ icon_theme.append_search_path(AppDirs.get_resources_dir().get_child("icons").get_path());
+
+ Gtk.IconInfo? info = icon_theme.lookup_by_gicon(gicon,
+ Resources.DEFAULT_ICON_SCALE, Gtk.IconLookupFlags.FORCE_SIZE);
+ if (info == null) {
+ debug("unable to load icon for: %s", stock_id);
+ return;
+ }
+
+ try {
+ Gdk.Pixbuf pix = info.load_icon();
+ if (modify != null) {
+ modify(pix);
+ }
+ Gtk.IconSet icon_set = new Gtk.IconSet.from_pixbuf(pix);
+ factory.add(stock_id, icon_set);
+ } catch (Error err) {
+ debug("%s", err.message);
+ }
+ }
+
+ // 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.
+ public static string? get_help_path() {
+ // Try looking for our 'index.page' in the build directory.
+ //
+ // TODO: Need to look for internationalized help before falling back on help/C
+
+ File help_dir = AppDirs.get_exec_dir().get_child("help").get_child("C");
+ File help_index = help_dir.get_child("index.page");
+
+ if (help_index.query_exists(null)) {
+ string help_path;
+
+ help_path = help_dir.get_path();
+
+ if (!help_path.has_suffix("/"))
+ help_path += "/";
+
+ // Found it.
+ return help_path;
+ }
+
+ // "./help/C/index.page" doesn't exist, so we're installed
+ // system-wide, and the caller should assume the default
+ // help location.
+ return null;
+ }
+
+ public static void launch_help(Gdk.Screen screen, string? anchor=null) throws Error {
+ string? help_path = get_help_path();
+
+ if(help_path != null) {
+ // We're running from the build directory; use local help.
+
+ // Allow the caller to request a specific page.
+ if (anchor != null) {
+ help_path +=anchor;
+ }
+
+ string[] argv = new string[3];
+ argv[0] = "gnome-help";
+ argv[1] = help_path;
+ argv[2] = null;
+
+ Pid pid;
+ if (Process.spawn_async(AppDirs.get_exec_dir().get_path(), argv, null,
+ SpawnFlags.SEARCH_PATH | SpawnFlags.STDERR_TO_DEV_NULL, null, out pid)) {
+ return;
+ }
+
+ warning("Unable to launch %s", argv[0]);
+ }
+
+ // launch from system-installed help
+ if (anchor != null) {
+ sys_show_uri(screen, "ghelp:shotwell" + anchor);
+ } else {
+ sys_show_uri(screen, "ghelp:shotwell");
+ }
+ }
+
+ public string to_css_color(Gdk.RGBA color) {
+ int r = (int) (color.red * 255);
+ int g = (int) (color.green * 255);
+ int b = (int) (color.blue * 255);
+
+ return "rgb(%d, %d, %d)".printf(r, g, b);
+ }
+
+ public const int ALL_DATA = -1;
+
+ private static Gee.Map<Gtk.Widget, Gtk.CssProvider> providers = null;
+
+ public static void style_widget(Gtk.Widget widget, string stylesheet) {
+ if (providers == null)
+ providers = new Gee.HashMap<Gtk.Widget, Gtk.CssProvider>();
+
+ if (providers.has_key(widget))
+ widget.get_style_context().remove_provider(providers.get(widget));
+
+ Gtk.CssProvider styler = new Gtk.CssProvider();
+
+ try {
+ styler.load_from_data(stylesheet, ALL_DATA);
+ } catch (Error e) {
+ warning("couldn't parse widget stylesheet '%s': %s", stylesheet,
+ e.message);
+ // short-circuit return -- if the stylesheet couldn't be interpreted
+ // then we can't do anything more
+ return;
+ }
+
+ widget.get_style_context().add_provider(styler,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
+
+ providers.set(widget, styler);
+ }
+
+ public const string INSET_FRAME_STYLESHEET =
+ """ .frame {
+ border-style: inset;
+ border-width: 1px;
+ }""";
+
+ public const string SCROLL_FRAME_STYLESHEET =
+ """ GtkScrolledWindow {
+ border-width: 0;
+ border-style: none;
+ border-radius: 0;
+ padding: 0;
+ }
+
+ .frame {
+ border-width: 1px;
+ border-style: inset;
+ }""";
+
+ public const string PAGE_STYLESHEET =
+ """ .frame {
+ border-width: 1px;
+ border-style: inset;
+ border-radius: 0;
+
+ padding: 0;
+ }""";
+
+ public const string VIEWPORT_STYLESHEET =
+ """ GtkViewport {
+ border-width: 1px;
+ border-style: inset;
+ border-radius: 0;
+ padding: 0;
+ }""";
+
+ public const string TOOLBAR_STYLESHEET_TEMPLATE =
+ """
+ @define-color primary-bg %s;
+
+ .toolbar {
+ background-color: @primary-bg;
+ border-width: 1px;
+ border-color: shade (@primary-bg, 0.75);
+ border-style: solid;
+ }""";
+
+ public const string SEARCH_BUTTON_STYLESHEET_TEMPLATE =
+ """
+ @define-color primary-bg %s;
+
+ .button {
+ background-image: none;
+ background-color: @primary-bg;
+ border-image: none;
+ border-color: shade (@primary-bg, 0.75) @primary-bg shade (@primary-bg, 0.75) @primary-bg;
+ border-style: solid;
+ margin: 5px;
+
+ -unico-border-gradient: none;
+ -unico-outer-stroke-width: 0;
+ -unico-outer-stroke-gradient: none;
+ -unico-glow-radius: 0;
+ -unico-inner-stroke-width: 0;
+ -unico-inner-stroke-color: shade (@primary-bg, 1.1);
+ }
+
+ .button:prelight {
+ border-style: solid;
+ border-width: 1px;
+ border-color: shade (@primary-bg, 1.1);
+
+ -unico-inner-stroke-color: shade (@primary-bg, 1.1);
+ -unico-inner-stroke-width: 0;
+
+ -unico-outer-stroke-width: 1px;
+ -unico-outer-stroke-color: shade (@primary-bg, 0.8);
+ }
+
+ .button:active {
+ background-image: none;
+ background-color: shade (@primary-bg, 0.75);
+ border-style: solid;
+ border-width: 1px;
+ border-color: shade (@primary-bg, 0.6);
+
+ -unico-outer-stroke-width: 1px;
+ -unico-outer-stroke-color: shade (@primary-bg, 1.1);
+ }""";
+
+ public const string ONIMAGE_FONT_COLOR = "#000000";
+ public const string ONIMAGE_FONT_BACKGROUND = "rgba(255,255,255,0.5)";
+}
+
diff --git a/src/Screensaver.vala b/src/Screensaver.vala
new file mode 100644
index 0000000..d00af21
--- /dev/null
+++ b/src/Screensaver.vala
@@ -0,0 +1,29 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class Screensaver {
+ private uint32 cookie = 0;
+
+ public Screensaver() {
+ }
+
+ public void inhibit(string reason) {
+ if (cookie != 0)
+ return;
+
+ cookie = Application.get_instance().inhibit(
+ Gtk.ApplicationInhibitFlags.IDLE | Gtk.ApplicationInhibitFlags.SUSPEND, _("Slideshow"));
+ }
+
+ public void uninhibit() {
+ if (cookie == 0)
+ return;
+
+ Application.get_instance().uninhibit(cookie);
+ cookie = 0;
+ }
+}
+
diff --git a/src/SearchFilter.vala b/src/SearchFilter.vala
new file mode 100644
index 0000000..9769195
--- /dev/null
+++ b/src/SearchFilter.vala
@@ -0,0 +1,1252 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// Bitfield values used to specify which search bar features we want.
+[Flags]
+public enum SearchFilterCriteria {
+ NONE = 0,
+ RECURSIVE,
+ TEXT,
+ FLAG,
+ MEDIA,
+ RATING,
+ ALL = 0xFFFFFFFF
+}
+
+public enum RatingFilter {
+ NO_FILTER = 0,
+ REJECTED_OR_HIGHER = 1,
+ UNRATED_OR_HIGHER = 2,
+ ONE_OR_HIGHER = 3,
+ TWO_OR_HIGHER = 4,
+ THREE_OR_HIGHER = 5,
+ FOUR_OR_HIGHER = 6,
+ FIVE_OR_HIGHER = 7,
+ REJECTED_ONLY = 8,
+ UNRATED_ONLY = 9,
+ ONE_ONLY = 10,
+ TWO_ONLY = 11,
+ THREE_ONLY = 12,
+ FOUR_ONLY = 13,
+ FIVE_ONLY = 14
+}
+
+ // Handles filtering via rating and text.
+public abstract class SearchViewFilter : ViewFilter {
+ // If this is true, allow the current rating or higher.
+ private bool rating_allow_higher = true;
+
+ // Rating to filter by.
+ private Rating rating = Rating.UNRATED;
+ private RatingFilter rating_filter = RatingFilter.UNRATED_OR_HIGHER;
+
+ // Show flagged only if set to true.
+ public bool flagged { get; set; default = false; }
+
+ // Media types.
+ public bool show_media_video { get; set; default = true; }
+ public bool show_media_photos { get; set; default = true; }
+ public bool show_media_raw { get; set; default = true; }
+
+ // Search text filter. Should only be set to lower-case.
+ private string? search_filter = null;
+ private string[]? search_filter_words = null;
+
+ // Returns a bitmask of SearchFilterCriteria.
+ // IMPORTANT: There is no signal on this, changing this value after the
+ // view filter is installed will NOT update the GUI.
+ public abstract uint get_criteria();
+
+ public void set_rating_filter(RatingFilter rf) {
+ rating_filter = rf;
+ switch (rating_filter) {
+ case RatingFilter.REJECTED_ONLY:
+ rating = Rating.REJECTED;
+ rating_allow_higher = false;
+ break;
+
+ case RatingFilter.REJECTED_OR_HIGHER:
+ rating = Rating.REJECTED;
+ rating_allow_higher = true;
+ break;
+
+ case RatingFilter.ONE_OR_HIGHER:
+ rating = Rating.ONE;
+ rating_allow_higher = true;
+ break;
+
+ case RatingFilter.ONE_ONLY:
+ rating = Rating.ONE;
+ rating_allow_higher = false;
+ break;
+
+ case RatingFilter.TWO_OR_HIGHER:
+ rating = Rating.TWO;
+ rating_allow_higher = true;
+ break;
+
+ case RatingFilter.TWO_ONLY:
+ rating = Rating.TWO;
+ rating_allow_higher = false;
+ break;
+
+ case RatingFilter.THREE_OR_HIGHER:
+ rating = Rating.THREE;
+ rating_allow_higher = true;
+ break;
+
+ case RatingFilter.THREE_ONLY:
+ rating = Rating.THREE;
+ rating_allow_higher = false;
+ break;
+
+ case RatingFilter.FOUR_OR_HIGHER:
+ rating = Rating.FOUR;
+ rating_allow_higher = true;
+ break;
+
+ case RatingFilter.FOUR_ONLY:
+ rating = Rating.FOUR;
+ rating_allow_higher = false;
+ break;
+
+ case RatingFilter.FIVE_OR_HIGHER:
+ rating = Rating.FIVE;
+ rating_allow_higher = true;
+ break;
+
+ case RatingFilter.FIVE_ONLY:
+ rating = Rating.FIVE;
+ rating_allow_higher = false;
+ break;
+
+ case RatingFilter.UNRATED_OR_HIGHER:
+ default:
+ rating = Rating.UNRATED;
+ rating_allow_higher = true;
+ break;
+ }
+ }
+
+ public bool has_search_filter() {
+ return !is_string_empty(search_filter);
+ }
+
+ public unowned string? get_search_filter() {
+ return search_filter;
+ }
+
+ public unowned string[]? get_search_filter_words() {
+ return search_filter_words;
+ }
+
+ public void set_search_filter(string? text) {
+ search_filter = !is_string_empty(text) ? text.down() : null;
+ search_filter_words = search_filter != null ? search_filter.split(" ") : null;
+ }
+
+ public void clear_search_filter() {
+ search_filter = null;
+ search_filter_words = null;
+ }
+
+ public bool get_rating_allow_higher() {
+ return rating_allow_higher;
+ }
+
+ public Rating get_rating() {
+ return rating;
+ }
+
+ public bool filter_by_media_type() {
+ return ((show_media_video || show_media_photos || show_media_raw) &&
+ !(show_media_video && show_media_photos && show_media_raw));
+ }
+}
+
+// This class provides a default predicate implementation used for CollectionPage
+// as well as Trash and Offline.
+public abstract class DefaultSearchViewFilter : SearchViewFilter {
+ public override bool predicate(DataView view) {
+ MediaSource source = ((Thumbnail) view).get_media_source();
+ uint criteria = get_criteria();
+
+ // Ratings filter
+ if ((SearchFilterCriteria.RATING & criteria) != 0) {
+ if (get_rating_allow_higher() && source.get_rating() < get_rating())
+ return false;
+ else if (!get_rating_allow_higher() && source.get_rating() != get_rating())
+ return false;
+ }
+
+ // Flag state.
+ if ((SearchFilterCriteria.FLAG & criteria) != 0) {
+ if (flagged && source is Flaggable && !((Flaggable) source).is_flagged())
+ return false;
+ }
+
+ // Media type.
+ if (((SearchFilterCriteria.MEDIA & criteria) != 0) && filter_by_media_type()) {
+ if (source is VideoSource) {
+ if (!show_media_video)
+ return false;
+ } else if (source is Photo) {
+ Photo photo = source as Photo;
+ if (photo.get_master_file_format() == PhotoFileFormat.RAW) {
+ if (photo.is_raw_developer_available(RawDeveloper.CAMERA)) {
+ if (!show_media_photos && !show_media_raw)
+ return false;
+ } else if (!show_media_raw) {
+ return false;
+ }
+ } else if (!show_media_photos)
+ return false;
+ }
+ }
+
+ if (((SearchFilterCriteria.TEXT & criteria) != 0) && has_search_filter()) {
+ unowned string? media_keywords = source.get_indexable_keywords();
+
+ unowned string? event_keywords = null;
+ Event? event = source.get_event();
+ if (event != null)
+ event_keywords = event.get_indexable_keywords();
+
+ Gee.List<Tag>? tags = Tag.global.fetch_for_source(source);
+ int tags_size = (tags != null) ? tags.size : 0;
+
+ foreach (unowned string word in get_search_filter_words()) {
+ if (media_keywords != null && media_keywords.contains(word))
+ continue;
+
+ if (event_keywords != null && event_keywords.contains(word))
+ continue;
+
+ if (tags_size > 0) {
+ bool found = false;
+ for (int ctr = 0; ctr < tags_size; ctr++) {
+ unowned string? tag_keywords = tags[ctr].get_indexable_keywords();
+ if (tag_keywords != null && tag_keywords.contains(word)) {
+ found = true;
+
+ break;
+ }
+ }
+
+ if (found)
+ continue;
+ }
+
+ // failed all tests (this even works if none of the Indexables have strings,
+ // as they fail the implicit AND test)
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
+
+public class DisabledViewFilter : SearchViewFilter {
+ public override bool predicate(DataView view) {
+ return true;
+ }
+
+ public override uint get_criteria() {
+ return SearchFilterCriteria.RATING;
+ }
+}
+
+public class TextAction {
+ public string? value {
+ get {
+ return text;
+ }
+ }
+
+ private string? text = null;
+ private bool sensitive = true;
+ private bool visible = true;
+
+ public signal void text_changed(string? text);
+
+ public signal void sensitivity_changed(bool sensitive);
+
+ public signal void visibility_changed(bool visible);
+
+ public TextAction(string? init = null) {
+ text = init;
+ }
+
+ public void set_text(string? text) {
+ if (this.text != text) {
+ this.text = text;
+ text_changed(text);
+ }
+ }
+
+ public void clear() {
+ set_text(null);
+ }
+
+ public bool is_sensitive() {
+ return sensitive;
+ }
+
+ public void set_sensitive(bool sensitive) {
+ if (this.sensitive != sensitive) {
+ this.sensitive = sensitive;
+ sensitivity_changed(sensitive);
+ }
+ }
+
+ public bool is_visible() {
+ return visible;
+ }
+
+ public void set_visible(bool visible) {
+ if (this.visible != visible) {
+ this.visible = visible;
+ visibility_changed(visible);
+ }
+ }
+}
+
+
+public class SearchFilterActions {
+ public unowned Gtk.ToggleAction? flagged {
+ get {
+ return get_action("CommonDisplayFlagged") as Gtk.ToggleAction;
+ }
+ }
+
+ public unowned Gtk.ToggleAction? photos {
+ get {
+ return get_action("CommonDisplayPhotos") as Gtk.ToggleAction;
+ }
+ }
+
+ public unowned Gtk.ToggleAction? videos {
+ get {
+ return get_action("CommonDisplayVideos") as Gtk.ToggleAction;
+ }
+ }
+
+ public unowned Gtk.ToggleAction? raw {
+ get {
+ return get_action("CommonDisplayRaw") as Gtk.ToggleAction;
+ }
+ }
+
+ public unowned Gtk.RadioAction? rating {
+ get {
+ return get_action("CommonDisplayUnratedOrHigher") as Gtk.RadioAction;
+ }
+ }
+
+ public unowned TextAction text {
+ get {
+ assert(_text != null);
+ return _text;
+ }
+ }
+
+ private Gtk.ActionGroup action_group = new Gtk.ActionGroup("SearchFilterActionGroup");
+ private SearchFilterCriteria criteria = SearchFilterCriteria.ALL;
+ private TextAction? _text = null;
+ private bool has_flagged = true;
+ private bool has_photos = true;
+ private bool has_videos = true;
+ private bool has_raw = true;
+ private bool can_filter_by_stars = true;
+
+ public signal void flagged_toggled(bool on);
+
+ public signal void photos_toggled(bool on);
+
+ public signal void videos_toggled(bool on);
+
+ public signal void raw_toggled(bool on);
+
+ public signal void rating_changed(RatingFilter filter);
+
+ public signal void text_changed(string? text);
+
+ /**
+ * fired when the kinds of media present in the current view change (e.g., a video becomes
+ * available in the view through a new import operation or no raw photos are available in
+ * the view anymore because the last one was moved to the trash)
+ */
+ public signal void media_context_changed(bool has_photos, bool has_videos, bool has_raw,
+ bool has_flagged);
+
+ // Ticket #3290 - Hide some search bar fields when they
+ // cannot be used.
+ // Part 1 - we use this to announce when the criteria have changed,
+ // and the toolbar can listen for it and hide or show widgets accordingly.
+ public signal void criteria_changed();
+
+ public SearchFilterActions() {
+ // the getters defined above should not be used until register() returns
+ register();
+
+ text.text_changed.connect(on_text_changed);
+ }
+
+ public Gtk.ActionGroup get_action_group() {
+ return action_group;
+ }
+
+ public SearchFilterCriteria get_criteria() {
+ return criteria;
+ }
+
+ public unowned Gtk.Action? get_action(string name) {
+ return action_group.get_action(name);
+ }
+
+ public void set_action_sensitive(string name, bool sensitive) {
+ Gtk.Action? action = get_action(name);
+ if (action != null)
+ action.sensitive = sensitive;
+ }
+
+ public void reset() {
+ flagged.active = false;
+ photos.active = false;
+ raw.active = false;
+ videos.active = false;
+ rating.current_value = RatingFilter.UNRATED_OR_HIGHER;
+ text.set_text(null);
+ }
+
+ public void set_sensitive_for_search_criteria(SearchFilterCriteria criteria) {
+ this.criteria = criteria;
+ update_sensitivities();
+
+ // Announce that we've gotten a new criteria...
+ criteria_changed();
+ }
+
+ public void monitor_page_contents(Page? old_page, Page? new_page) {
+ CheckerboardPage? old_tracked_page = old_page as CheckerboardPage;
+ if (old_tracked_page != null) {
+ Core.ViewTracker? tracker = old_tracked_page.get_view_tracker();
+ if (tracker is MediaViewTracker)
+ tracker.updated.disconnect(on_media_tracker_updated);
+ else if (tracker is CameraViewTracker)
+ tracker.updated.disconnect(on_camera_tracker_updated);
+ }
+
+ CheckerboardPage? new_tracked_page = new_page as CheckerboardPage;
+ if (new_tracked_page != null) {
+ can_filter_by_stars = true;
+
+ Core.ViewTracker? tracker = new_tracked_page.get_view_tracker();
+ if (tracker is MediaViewTracker) {
+ tracker.updated.connect(on_media_tracker_updated);
+ on_media_tracker_updated(tracker);
+
+ return;
+ } else if (tracker is CameraViewTracker) {
+ tracker.updated.connect(on_camera_tracker_updated);
+ on_camera_tracker_updated(tracker);
+
+ return;
+ }
+ }
+
+ // go with default behavior of making none of the filters available.
+ has_flagged = false;
+ has_photos = false;
+ has_videos = false;
+ has_raw = false;
+ can_filter_by_stars = false;
+
+ update_sensitivities();
+ }
+
+ private void on_media_tracker_updated(Core.Tracker t) {
+ MediaViewTracker tracker = (MediaViewTracker) t;
+
+ has_flagged = tracker.all.flagged > 0;
+ has_photos = tracker.all.photos > 0;
+ has_videos = tracker.all.videos > 0;
+ has_raw = tracker.all.raw > 0;
+
+ update_sensitivities();
+ }
+
+ private void on_camera_tracker_updated(Core.Tracker t) {
+ CameraViewTracker tracker = (CameraViewTracker) t;
+
+ has_flagged = false;
+ has_photos = tracker.all.photos > 0;
+ has_videos = tracker.all.videos > 0;
+ has_raw = tracker.all.raw > 0;
+
+ update_sensitivities();
+ }
+
+ private void update_sensitivities() {
+ flagged.set_stock_id(((SearchFilterCriteria.FLAG & criteria) != 0 && has_flagged) ?
+ Resources.ICON_FILTER_FLAGGED : Resources.ICON_FILTER_FLAGGED_DISABLED);
+
+ bool allow_media = (SearchFilterCriteria.MEDIA & criteria) != 0;
+ videos.set_stock_id((allow_media && has_videos) ?
+ Resources.ICON_FILTER_VIDEOS : Resources.ICON_FILTER_VIDEOS_DISABLED);
+ photos.set_stock_id((allow_media && has_photos) ?
+ Resources.ICON_FILTER_PHOTOS : Resources.ICON_FILTER_PHOTOS_DISABLED);
+ raw.set_stock_id((allow_media && has_raw) ?
+ Resources.ICON_FILTER_RAW : Resources.ICON_FILTER_RAW_DISABLED);
+
+ bool allow_ratings = (SearchFilterCriteria.RATING & criteria) != 0;
+ set_action_sensitive("CommonDisplayRejectedOnly", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayRejectedOrHigher", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayUnratedOrHigher", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayOneOrHigher", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayTwoOrHigher", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayThreeOrHigher", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayFourOrHigher", allow_ratings & can_filter_by_stars);
+ set_action_sensitive("CommonDisplayFiveOrHigher", allow_ratings & can_filter_by_stars);
+
+ // Ticket #3343 - Don't disable the text field, even
+ // when no searchable items are available.
+ text.set_sensitive(true);
+
+ media_context_changed(has_photos, has_videos, has_raw, has_flagged);
+ }
+
+ private void on_text_changed(TextAction action, string? text) {
+ text_changed(text);
+ }
+
+ private void register() {
+ _text = new TextAction();
+
+ Gtk.RadioActionEntry[] view_filter_actions = new Gtk.RadioActionEntry[0];
+
+ Gtk.RadioActionEntry rejected_only = { "CommonDisplayRejectedOnly", null, TRANSLATABLE,
+ "<Ctrl>8", TRANSLATABLE, RatingFilter.REJECTED_ONLY };
+ rejected_only.label = Resources.DISPLAY_REJECTED_ONLY_MENU;
+ rejected_only.tooltip = Resources.DISPLAY_REJECTED_ONLY_TOOLTIP;
+ view_filter_actions += rejected_only;
+
+ Gtk.RadioActionEntry rejected_or_higher = { "CommonDisplayRejectedOrHigher", null, TRANSLATABLE,
+ "<Ctrl>9", TRANSLATABLE, RatingFilter.REJECTED_OR_HIGHER };
+ rejected_or_higher.label = Resources.DISPLAY_REJECTED_OR_HIGHER_MENU;
+ rejected_or_higher.tooltip = Resources.DISPLAY_REJECTED_OR_HIGHER_TOOLTIP;
+ view_filter_actions += rejected_or_higher;
+
+ Gtk.RadioActionEntry unrated_or_higher = { "CommonDisplayUnratedOrHigher", null, TRANSLATABLE,
+ "<Ctrl>0", TRANSLATABLE, RatingFilter.UNRATED_OR_HIGHER };
+ unrated_or_higher.label = Resources.DISPLAY_UNRATED_OR_HIGHER_MENU;
+ unrated_or_higher.tooltip = Resources.DISPLAY_UNRATED_OR_HIGHER_TOOLTIP;
+ view_filter_actions += unrated_or_higher;
+
+ Gtk.RadioActionEntry one_or_higher = { "CommonDisplayOneOrHigher", null, TRANSLATABLE,
+ "<Ctrl>1", TRANSLATABLE, RatingFilter.ONE_OR_HIGHER };
+ one_or_higher.label = Resources.DISPLAY_ONE_OR_HIGHER_MENU;
+ one_or_higher.tooltip = Resources.DISPLAY_ONE_OR_HIGHER_TOOLTIP;
+ view_filter_actions += one_or_higher;
+
+ Gtk.RadioActionEntry two_or_higher = { "CommonDisplayTwoOrHigher", null, TRANSLATABLE,
+ "<Ctrl>2", TRANSLATABLE, RatingFilter.TWO_OR_HIGHER };
+ two_or_higher.label = Resources.DISPLAY_TWO_OR_HIGHER_MENU;
+ two_or_higher.tooltip = Resources.DISPLAY_TWO_OR_HIGHER_TOOLTIP;
+ view_filter_actions += two_or_higher;
+
+ Gtk.RadioActionEntry three_or_higher = { "CommonDisplayThreeOrHigher", null, TRANSLATABLE,
+ "<Ctrl>3", TRANSLATABLE, RatingFilter.THREE_OR_HIGHER };
+ three_or_higher.label = Resources.DISPLAY_THREE_OR_HIGHER_MENU;
+ three_or_higher.tooltip = Resources.DISPLAY_THREE_OR_HIGHER_TOOLTIP;
+ view_filter_actions += three_or_higher;
+
+ Gtk.RadioActionEntry four_or_higher = { "CommonDisplayFourOrHigher", null, TRANSLATABLE,
+ "<Ctrl>4", TRANSLATABLE, RatingFilter.FOUR_OR_HIGHER };
+ four_or_higher.label = Resources.DISPLAY_FOUR_OR_HIGHER_MENU;
+ four_or_higher.tooltip = Resources.DISPLAY_FOUR_OR_HIGHER_TOOLTIP;
+ view_filter_actions += four_or_higher;
+
+ Gtk.RadioActionEntry five_or_higher = { "CommonDisplayFiveOrHigher", null, TRANSLATABLE,
+ "<Ctrl>5", TRANSLATABLE, RatingFilter.FIVE_OR_HIGHER };
+ five_or_higher.label = Resources.DISPLAY_FIVE_OR_HIGHER_MENU;
+ five_or_higher.tooltip = Resources.DISPLAY_FIVE_OR_HIGHER_TOOLTIP;
+ view_filter_actions += five_or_higher;
+
+ action_group.add_radio_actions(view_filter_actions, RatingFilter.UNRATED_OR_HIGHER,
+ on_rating_changed);
+
+ Gtk.ToggleActionEntry[] toggle_actions = new Gtk.ToggleActionEntry[0];
+
+ Gtk.ToggleActionEntry flagged_action = { "CommonDisplayFlagged", Resources.ICON_FILTER_FLAGGED,
+ TRANSLATABLE, null, TRANSLATABLE, on_flagged_toggled, false };
+ flagged_action.label = _("Flagged");
+ flagged_action.tooltip = _("Flagged");
+ toggle_actions += flagged_action;
+
+ Gtk.ToggleActionEntry photos_action = { "CommonDisplayPhotos", Resources.ICON_FILTER_PHOTOS,
+ TRANSLATABLE, null, TRANSLATABLE, on_photos_toggled, false };
+ photos_action.label = _("Photos");
+ photos_action.tooltip = _("Photos");
+ toggle_actions += photos_action;
+
+ Gtk.ToggleActionEntry videos_action = { "CommonDisplayVideos", Resources.ICON_FILTER_VIDEOS,
+ TRANSLATABLE, null, TRANSLATABLE, on_videos_toggled, false };
+ videos_action.label = _("Videos");
+ videos_action.tooltip = _("Videos");
+ toggle_actions += videos_action;
+
+ Gtk.ToggleActionEntry raw_action = { "CommonDisplayRaw", Resources.ICON_FILTER_RAW, TRANSLATABLE,
+ null, TRANSLATABLE, on_raw_toggled, false };
+ raw_action.label = _("RAW Photos");
+ raw_action.tooltip = _("RAW photos");
+ toggle_actions += raw_action;
+
+ action_group.add_toggle_actions(toggle_actions, this);
+ }
+
+ private void on_rating_changed(Gtk.Action action, Gtk.Action current) {
+ rating_changed((RatingFilter) ((Gtk.RadioAction) current).get_current_value());
+ }
+
+ private void on_flagged_toggled(Gtk.Action action) {
+ flagged_toggled(((Gtk.ToggleAction) action).active);
+ }
+
+ private void on_photos_toggled(Gtk.Action action) {
+ photos_toggled(((Gtk.ToggleAction) action).active);
+ }
+
+ private void on_videos_toggled(Gtk.Action action) {
+ videos_toggled(((Gtk.ToggleAction) action).active);
+ }
+
+ private void on_raw_toggled(Gtk.Action action) {
+ raw_toggled(((Gtk.ToggleAction) action).active);
+ }
+
+ public bool get_has_photos() {
+ return has_photos;
+ }
+
+ public bool get_has_videos() {
+ return has_videos;
+ }
+
+ public bool get_has_raw() {
+ return has_raw;
+ }
+
+ public bool get_has_flagged() {
+ return has_flagged;
+ }
+}
+
+public class SearchFilterToolbar : Gtk.Toolbar {
+ private const int FILTER_BUTTON_MARGIN = 12; // the distance between icon and edge of button
+ private const float FILTER_ICON_STAR_SCALE = 0.65f; // changes the size of the filter icon
+ private const float FILTER_ICON_SCALE = 0.75f; // changes the size of the all photos icon
+
+ // filter_icon_base_width is the width (in px) of a single filter icon such as one star or an "X"
+ private const int FILTER_ICON_BASE_WIDTH = 30;
+ // filter_icon_plus_width is the width (in px) of the plus icon
+ private const int FILTER_ICON_PLUS_WIDTH = 20;
+
+ private class LabelToolItem : Gtk.ToolItem {
+ private Gtk.Label label;
+
+ public LabelToolItem(string s, int left_padding = 0, int right_padding = 0) {
+ label = new Gtk.Label(s);
+ if (left_padding != 0 || right_padding != 0) {
+ Gtk.Alignment alignment = new Gtk.Alignment(0, 0.5f, 0, 0);
+ alignment.add(label);
+ alignment.left_padding = left_padding;
+ alignment.right_padding = right_padding;
+ add(alignment);
+ } else {
+ add(label);
+ }
+ }
+
+ public void set_color(Gdk.RGBA color) {
+ label.override_color(Gtk.StateFlags.NORMAL, color);
+ }
+ }
+
+ private class ToggleActionToolButton : Gtk.ToolItem {
+ private Gtk.ToggleButton button;
+ private Gtk.ToggleAction action;
+
+ public ToggleActionToolButton(Gtk.ToggleAction action) {
+ this.action = action;
+ button = new Gtk.ToggleButton();
+ button.set_can_focus(false);
+ button.set_active(action.active);
+ button.clicked.connect(on_button_activate);
+ button.set_has_tooltip(true);
+
+ restyle();
+
+ this.add(button);
+ }
+
+ ~ToggleActionButton() {
+ button.clicked.disconnect(on_button_activate);
+ }
+
+ private void on_button_activate() {
+ action.activate();
+ }
+
+ public void set_icon_name(string icon_name) {
+ Gtk.Image? image = null;
+ if (icon_name.contains("disabled"))
+ image = new Gtk.Image.from_stock(icon_name, Gtk.IconSize.SMALL_TOOLBAR);
+ else
+ image = new Gtk.Image.from_icon_name(icon_name, Gtk.IconSize.SMALL_TOOLBAR);
+
+ button.set_image(image);
+ }
+
+ public void restyle() {
+ string bgcolorname =
+ Resources.to_css_color(Config.Facade.get_instance().get_bg_color());
+ string stylesheet = Resources.SEARCH_BUTTON_STYLESHEET_TEMPLATE.printf(bgcolorname);
+
+ Resources.style_widget(button, stylesheet);
+ }
+ }
+
+ // Ticket #3260 - Add a 'close' context menu to
+ // the searchbar.
+ // The close menu. Populated below in the constructor.
+ private Gtk.Menu close_menu = new Gtk.Menu();
+ private Gtk.ImageMenuItem close_item = new Gtk.ImageMenuItem.from_stock(Gtk.Stock.CLOSE, null);
+
+ // Text search box.
+ protected class SearchBox : Gtk.ToolItem {
+ private Gtk.SearchEntry search_entry;
+ private TextAction action;
+
+ public SearchBox(TextAction action) {
+ this.action = action;
+ search_entry = new Gtk.SearchEntry();
+
+ search_entry.width_chars = 23;
+ search_entry.key_press_event.connect(on_escape_key);
+ add(search_entry);
+
+ set_nullable_text(action.value);
+
+ action.text_changed.connect(on_action_text_changed);
+ action.sensitivity_changed.connect(on_sensitivity_changed);
+ action.visibility_changed.connect(on_visibility_changed);
+
+ search_entry.buffer.deleted_text.connect(on_entry_changed);
+ search_entry.buffer.inserted_text.connect(on_entry_changed);
+ }
+
+ ~SearchBox() {
+ action.text_changed.disconnect(on_action_text_changed);
+ action.sensitivity_changed.disconnect(on_sensitivity_changed);
+ action.visibility_changed.disconnect(on_visibility_changed);
+
+ search_entry.buffer.deleted_text.disconnect(on_entry_changed);
+ search_entry.buffer.inserted_text.disconnect(on_entry_changed);
+ }
+
+ public void get_focus() {
+ search_entry.has_focus = true;
+ }
+
+ // Ticket #3124 - user should be able to clear
+ // the search textbox by typing 'Esc'.
+ private bool on_escape_key(Gdk.EventKey e) {
+ if(Gdk.keyval_name(e.keyval) == "Escape")
+ action.clear();
+
+ // Continue processing this event, since the
+ // text entry functionality needs to see it too.
+ return false;
+ }
+
+ private void on_action_text_changed(string? text) {
+ search_entry.buffer.deleted_text.disconnect(on_entry_changed);
+ search_entry.buffer.inserted_text.disconnect(on_entry_changed);
+ set_nullable_text(text);
+ search_entry.buffer.deleted_text.connect(on_entry_changed);
+ search_entry.buffer.inserted_text.connect(on_entry_changed);
+ }
+
+ private void on_entry_changed() {
+ action.text_changed.disconnect(on_action_text_changed);
+ action.set_text(search_entry.get_text());
+ action.text_changed.connect(on_action_text_changed);
+ }
+
+ private void on_sensitivity_changed(bool sensitive) {
+ this.sensitive = sensitive;
+ }
+
+ private void on_visibility_changed(bool visible) {
+ ((Gtk.Widget) this).visible = visible;
+ }
+
+ private void set_nullable_text(string? text) {
+ search_entry.set_text(text != null ? text : "");
+ }
+ }
+
+ // Handles ratings filters.
+ protected class RatingFilterButton : Gtk.ToolItem {
+ public Gtk.Menu filter_popup = null;
+ public Gtk.Button button;
+
+ public signal void clicked();
+
+ public RatingFilterButton() {
+ button = new Gtk.Button();
+ button.set_image(get_filter_icon(RatingFilter.UNRATED_OR_HIGHER));
+ button.set_can_focus(false);
+
+ button.clicked.connect(on_clicked);
+
+ restyle();
+
+ set_homogeneous(false);
+
+ this.add(button);
+ }
+
+ ~RatingFilterButton() {
+ button.clicked.disconnect(on_clicked);
+ }
+
+ private void on_clicked() {
+ clicked();
+ }
+
+ private Gtk.Widget get_filter_icon(RatingFilter filter) {
+ string filename = null;
+
+ switch (filter) {
+ case RatingFilter.ONE_OR_HIGHER:
+ filename = Resources.ICON_FILTER_ONE_OR_BETTER;
+ break;
+
+ case RatingFilter.TWO_OR_HIGHER:
+ filename = Resources.ICON_FILTER_TWO_OR_BETTER;
+ break;
+
+ case RatingFilter.THREE_OR_HIGHER:
+ filename = Resources.ICON_FILTER_THREE_OR_BETTER;
+ break;
+
+ case RatingFilter.FOUR_OR_HIGHER:
+ filename = Resources.ICON_FILTER_FOUR_OR_BETTER;
+ break;
+
+ case RatingFilter.FIVE_OR_HIGHER:
+ filename = Resources.ICON_FILTER_FIVE;
+ break;
+
+ case RatingFilter.REJECTED_OR_HIGHER:
+ filename = Resources.ICON_FILTER_REJECTED_OR_BETTER;
+ break;
+
+ case RatingFilter.REJECTED_ONLY:
+ filename = Resources.ICON_RATING_REJECTED;
+ break;
+
+ case RatingFilter.UNRATED_OR_HIGHER:
+ default:
+ filename = Resources.ICON_FILTER_UNRATED_OR_BETTER;
+ break;
+ }
+
+ return new Gtk.Image.from_pixbuf(Resources.load_icon(filename,
+ get_filter_icon_size(filter)));
+ }
+
+ private int get_filter_icon_size(RatingFilter filter) {
+ int icon_base = (int) (FILTER_ICON_BASE_WIDTH * FILTER_ICON_SCALE);
+ int icon_star_base = (int) (FILTER_ICON_BASE_WIDTH * FILTER_ICON_STAR_SCALE);
+ int icon_plus = (int) (FILTER_ICON_PLUS_WIDTH * FILTER_ICON_STAR_SCALE);
+
+ switch (filter) {
+ case RatingFilter.ONE_OR_HIGHER:
+ return icon_star_base + icon_plus;
+ case RatingFilter.TWO_OR_HIGHER:
+ return icon_star_base * 2 + icon_plus;
+ case RatingFilter.THREE_OR_HIGHER:
+ return icon_star_base * 3 + icon_plus;
+ case RatingFilter.FOUR_OR_HIGHER:
+ return icon_star_base * 4 + icon_plus;
+ case RatingFilter.FIVE_OR_HIGHER:
+ case RatingFilter.FIVE_ONLY:
+ return icon_star_base * 5;
+ case RatingFilter.REJECTED_OR_HIGHER:
+ return Resources.ICON_FILTER_REJECTED_OR_BETTER_FIXED_SIZE;
+ case RatingFilter.UNRATED_OR_HIGHER:
+ return Resources.ICON_FILTER_UNRATED_OR_BETTER_FIXED_SIZE;
+ case RatingFilter.REJECTED_ONLY:
+ return icon_plus;
+ default:
+ return icon_base;
+ }
+ }
+
+ public void set_filter_icon(RatingFilter filter) {
+ button.set_image(get_filter_icon(filter));
+ set_size_request(get_filter_button_size(filter), -1);
+ set_tooltip_text(Resources.get_rating_filter_tooltip(filter));
+ set_has_tooltip(true);
+ show_all();
+ }
+
+ private int get_filter_button_size(RatingFilter filter) {
+ return get_filter_icon_size(filter) + 2 * FILTER_BUTTON_MARGIN;
+ }
+
+ public void restyle() {
+ string bgcolorname =
+ Resources.to_css_color(Config.Facade.get_instance().get_bg_color());
+ string stylesheet = Resources.SEARCH_BUTTON_STYLESHEET_TEMPLATE.printf(bgcolorname);
+
+ Resources.style_widget(button, stylesheet);
+ }
+ }
+
+ public Gtk.UIManager ui = new Gtk.UIManager();
+
+ private SearchFilterActions actions;
+ private SearchBox search_box;
+ private RatingFilterButton rating_button = new RatingFilterButton();
+ private SearchViewFilter? search_filter = null;
+ private LabelToolItem label_type;
+ private LabelToolItem label_flagged;
+ private LabelToolItem label_rating;
+ private ToggleActionToolButton toolbtn_photos;
+ private ToggleActionToolButton toolbtn_videos;
+ private ToggleActionToolButton toolbtn_raw;
+ private ToggleActionToolButton toolbtn_flag;
+ private Gtk.SeparatorToolItem sepr_mediatype_flagged;
+ private Gtk.SeparatorToolItem sepr_flagged_rating;
+
+ public SearchFilterToolbar(SearchFilterActions actions) {
+ this.actions = actions;
+ actions.media_context_changed.connect(on_media_context_changed);
+ search_box = new SearchBox(actions.text);
+
+ set_name("search-filter-toolbar");
+ set_icon_size(Gtk.IconSize.SMALL_TOOLBAR);
+
+ File ui_file = Resources.get_ui("search_bar.ui");
+ try {
+ ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ AppWindow.panic(_("Error loading UI file %s: %s").printf(
+ ui_file.get_path(), err.message));
+ }
+
+ ui.insert_action_group(actions.get_action_group(), 0);
+
+ // Ticket #3260 - Add a 'close' context menu to
+ // the searchbar.
+ // Prepare the close menu for use, but don't
+ // display it yet; we'll connect it to secondary
+ // click later on.
+ ((Gtk.MenuItem) close_item).show();
+ close_item.always_show_image = true;
+ close_item.activate.connect(on_context_menu_close_chosen);
+ close_menu.append(close_item);
+
+ // Type label and toggles
+ label_type = new LabelToolItem(_("Type"), 10, 5);
+ insert(label_type, -1);
+
+ toolbtn_photos = new ToggleActionToolButton(actions.photos);
+ toolbtn_photos.set_tooltip_text(actions.get_action_group().get_action("CommonDisplayPhotos").tooltip);
+
+ toolbtn_videos = new ToggleActionToolButton(actions.videos);
+ toolbtn_videos.set_tooltip_text(actions.get_action_group().get_action("CommonDisplayVideos").tooltip);
+
+ toolbtn_raw = new ToggleActionToolButton(actions.raw);
+ toolbtn_raw.set_tooltip_text(actions.get_action_group().get_action("CommonDisplayRaw").tooltip);
+
+ insert(toolbtn_photos, -1);
+ insert(toolbtn_videos, -1);
+ insert(toolbtn_raw, -1);
+
+ // separator
+ sepr_mediatype_flagged = new Gtk.SeparatorToolItem();
+ insert(sepr_mediatype_flagged, -1);
+
+ // Flagged label and toggle
+ label_flagged = new LabelToolItem(_("Flagged"));
+ insert(label_flagged, -1);
+
+ toolbtn_flag = new ToggleActionToolButton(actions.flagged);
+ toolbtn_flag.set_tooltip_text(actions.get_action_group().get_action("CommonDisplayFlagged").tooltip);
+
+ insert(toolbtn_flag, -1);
+
+ // separator
+ sepr_flagged_rating = new Gtk.SeparatorToolItem();
+ insert(sepr_flagged_rating, -1);
+
+ // Rating label and button
+ label_rating = new LabelToolItem(_("Rating"));
+ insert(label_rating, -1);
+ rating_button.filter_popup = (Gtk.Menu) ui.get_widget("/FilterPopupMenu");
+ rating_button.set_expand(false);
+ rating_button.clicked.connect(on_filter_button_clicked);
+ insert(rating_button, -1);
+
+ // Separator to right-align the text box
+ Gtk.SeparatorToolItem separator_align = new Gtk.SeparatorToolItem();
+ separator_align.set_expand(true);
+ separator_align.set_draw(false);
+ insert(separator_align, -1);
+
+ // Search box.
+ insert(search_box, -1);
+
+ // Set background color of toolbar and update them when the configuration is updated
+ Config.Facade.get_instance().bg_color_name_changed.connect(on_bg_color_name_changed);
+ on_bg_color_name_changed();
+
+ // hook up signals to actions to be notified when they change
+ actions.flagged_toggled.connect(on_flagged_toggled);
+ actions.photos_toggled.connect(on_photos_toggled);
+ actions.videos_toggled.connect(on_videos_toggled);
+ actions.raw_toggled.connect(on_raw_toggled);
+ actions.rating_changed.connect(on_rating_changed);
+ actions.text_changed.connect(on_search_text_changed);
+ actions.criteria_changed.connect(on_criteria_changed);
+
+ // #3260 part II Hook up close menu.
+ popup_context_menu.connect(on_context_menu_requested);
+
+ on_media_context_changed(actions.get_has_photos(), actions.get_has_videos(),
+ actions.get_has_raw(), actions.get_has_flagged());
+ }
+
+ ~SearchFilterToolbar() {
+ Config.Facade.get_instance().bg_color_name_changed.disconnect(on_bg_color_name_changed);
+
+ actions.media_context_changed.disconnect(on_media_context_changed);
+
+ actions.flagged_toggled.disconnect(on_flagged_toggled);
+ actions.photos_toggled.disconnect(on_photos_toggled);
+ actions.videos_toggled.disconnect(on_videos_toggled);
+ actions.raw_toggled.disconnect(on_raw_toggled);
+ actions.rating_changed.disconnect(on_rating_changed);
+ actions.text_changed.disconnect(on_search_text_changed);
+ actions.criteria_changed.disconnect(on_criteria_changed);
+
+ popup_context_menu.disconnect(on_context_menu_requested);
+ }
+
+ private void on_media_context_changed(bool has_photos, bool has_videos, bool has_raw,
+ bool has_flagged) {
+ if (has_photos)
+ toolbtn_photos.set_icon_name(Resources.ICON_FILTER_PHOTOS);
+ else
+ toolbtn_photos.set_icon_name(Resources.ICON_FILTER_PHOTOS_DISABLED);
+
+ if (has_videos)
+ toolbtn_videos.set_icon_name(Resources.ICON_FILTER_VIDEOS);
+ else
+ toolbtn_videos.set_icon_name(Resources.ICON_FILTER_VIDEOS_DISABLED);
+
+ if (has_raw)
+ toolbtn_raw.set_icon_name(Resources.ICON_FILTER_RAW);
+ else
+ toolbtn_raw.set_icon_name(Resources.ICON_FILTER_RAW_DISABLED);
+
+ if (has_flagged)
+ toolbtn_flag.set_icon_name(Resources.ICON_FILTER_FLAGGED);
+ else
+ toolbtn_flag.set_icon_name(Resources.ICON_FILTER_FLAGGED_DISABLED);
+ }
+
+ private void on_bg_color_name_changed() {
+ string bgcolorname =
+ Resources.to_css_color(Config.Facade.get_instance().get_bg_color());
+ string toolbar_stylesheet = Resources.TOOLBAR_STYLESHEET_TEMPLATE.printf(bgcolorname);
+ Resources.style_widget(this, toolbar_stylesheet);
+
+ label_type.set_color(Config.Facade.get_instance().get_unselected_color());
+ label_flagged.set_color(Config.Facade.get_instance().get_unselected_color());
+ label_rating.set_color(Config.Facade.get_instance().get_unselected_color());
+
+ toolbtn_photos.restyle();
+ toolbtn_videos.restyle();
+ toolbtn_raw.restyle();
+ toolbtn_flag.restyle();
+ rating_button.restyle();
+
+ }
+
+ // Ticket #3260 part IV - display the context menu on secondary click
+ private bool on_context_menu_requested(int x, int y, int button) {
+ close_menu.popup(null, null, null, button, Gtk.get_current_event_time());
+ return false;
+ }
+
+ // Ticket #3260 part III - this runs whenever 'close'
+ // is chosen in the context menu.
+ private void on_context_menu_close_chosen() {
+ AppWindow aw = LibraryWindow.get_app();
+
+ // Try to obtain the action for toggling the searchbar. If
+ // it's null, then we're probably in direct edit mode, and
+ // shouldn't do anything anyway.
+ Gtk.ToggleAction searchbar_toggle = aw.get_common_action("CommonDisplaySearchbar") as Gtk.ToggleAction;
+
+ // Could we find the appropriate action?
+ if(searchbar_toggle != null) {
+ // Yes, hide the search bar.
+ searchbar_toggle.set_active(false);
+ }
+ }
+
+ private void on_flagged_toggled() {
+ update();
+ }
+
+ private void on_videos_toggled() {
+ update();
+ }
+
+ private void on_photos_toggled() {
+ update();
+ }
+
+ private void on_raw_toggled() {
+ update();
+ }
+
+ private void on_search_text_changed() {
+ update();
+ }
+
+ private void on_rating_changed() {
+ AppWindow aw = LibraryWindow.get_app();
+
+ if (aw == null)
+ return;
+
+ Gtk.ToggleAction searchbar_toggle = aw.get_common_action("CommonDisplaySearchbar") as Gtk.ToggleAction;
+ if(searchbar_toggle != null)
+ searchbar_toggle.set_active(true);
+
+ update();
+ }
+
+ // Ticket #3290, part II - listen for criteria change signals,
+ // and show or hide widgets based on the criteria we just
+ // changed to.
+ private void on_criteria_changed() {
+ update();
+ }
+
+ public void set_view_filter(SearchViewFilter search_filter) {
+ if (search_filter == this.search_filter)
+ return;
+
+ this.search_filter = search_filter;
+
+ // Enable/disable toolbar features depending on what the filter offers
+ actions.set_sensitive_for_search_criteria((SearchFilterCriteria) search_filter.get_criteria());
+ rating_button.sensitive = (SearchFilterCriteria.RATING & search_filter.get_criteria()) != 0;
+
+ update();
+ }
+
+ public void unset_view_filter() {
+ set_view_filter(new DisabledViewFilter());
+ }
+
+ // Forces an update of the search filter.
+ public void update() {
+ if (null == search_filter) {
+ // Search bar isn't being shown, need to toggle it.
+ LibraryWindow.get_app().show_search_bar(true);
+ }
+
+ assert(null != search_filter);
+
+ search_filter.set_search_filter(actions.text.value);
+ search_filter.flagged = actions.flagged.active;
+ search_filter.show_media_video = actions.videos.active;
+ search_filter.show_media_photos = actions.photos.active;
+ search_filter.show_media_raw = actions.raw.active;
+
+ RatingFilter filter = (RatingFilter) actions.rating.current_value;
+ search_filter.set_rating_filter(filter);
+ rating_button.set_filter_icon(filter);
+
+ // Ticket #3290, part III - check the current criteria
+ // and show or hide widgets as needed.
+ SearchFilterCriteria criteria = actions.get_criteria();
+
+ search_box.visible = ((criteria & SearchFilterCriteria.TEXT) != 0);
+
+ rating_button.visible = ((criteria & SearchFilterCriteria.RATING) != 0);
+ label_rating.visible = ((criteria & SearchFilterCriteria.RATING) != 0);
+
+ label_flagged.visible = ((criteria & SearchFilterCriteria.FLAG) != 0);
+ toolbtn_flag.visible = ((criteria & SearchFilterCriteria.FLAG) != 0);
+
+ label_type.visible = ((criteria & SearchFilterCriteria.MEDIA) != 0);
+ toolbtn_photos.visible = ((criteria & SearchFilterCriteria.MEDIA) != 0);
+ toolbtn_videos.visible = ((criteria & SearchFilterCriteria.MEDIA) != 0);
+ toolbtn_raw.visible = ((criteria & SearchFilterCriteria.MEDIA) != 0);
+
+ // Ticket #3290, part IV - ensure that the separators
+ // are shown and/or hidden as needed.
+ sepr_mediatype_flagged.visible = (label_type.visible && label_flagged.visible);
+
+ sepr_flagged_rating.visible = ((label_type.visible && label_rating.visible) ||
+ (label_flagged.visible && label_rating.visible));
+
+ // Send update to view collection.
+ search_filter.refresh();
+ }
+
+ private void position_filter_popup(Gtk.Menu menu, out int x, out int y, out bool push_in) {
+ menu.realize();
+ int rx, ry;
+ rating_button.get_window().get_root_origin(out rx, out ry);
+
+ Gtk.Allocation rating_button_allocation;
+ rating_button.get_allocation(out rating_button_allocation);
+
+ Gtk.Allocation menubar_allocation;
+ AppWindow.get_instance().get_current_page().get_menubar().get_allocation(out menubar_allocation);
+
+ int sidebar_w = Config.Facade.get_instance().get_sidebar_position();
+
+ x = rx + rating_button_allocation.x + sidebar_w;
+ y = ry + rating_button_allocation.y + rating_button_allocation.height +
+ menubar_allocation.height;
+
+ push_in = false;
+ }
+
+ private void on_filter_button_clicked() {
+ rating_button.filter_popup.popup(null, null, position_filter_popup, 0,
+ Gtk.get_current_event_time());
+ }
+
+ public void take_focus() {
+ search_box.get_focus();
+ }
+}
+
diff --git a/src/SlideshowPage.vala b/src/SlideshowPage.vala
new file mode 100644
index 0000000..f22fd53
--- /dev/null
+++ b/src/SlideshowPage.vala
@@ -0,0 +1,466 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+class SlideshowPage : SinglePhotoPage {
+ private const int READAHEAD_COUNT = 5;
+ private const int CHECK_ADVANCE_MSEC = 250;
+
+ private SourceCollection sources;
+ private ViewCollection controller;
+ private Photo current;
+ private Gtk.ToolButton play_pause_button;
+ private Gtk.ToolButton settings_button;
+ private PixbufCache cache = null;
+ private Timer timer = new Timer();
+ private bool playing = true;
+ private bool exiting = false;
+ private string[] transitions;
+
+ private Screensaver screensaver;
+
+ public signal void hide_toolbar();
+
+ private class SettingsDialog : Gtk.Dialog {
+ private Gtk.Builder builder = null;
+ Gtk.SpinButton delay_entry;
+ Gtk.Scale delay_hscale;
+ Gtk.ComboBoxText transition_effect_selector;
+ Gtk.Scale transition_effect_hscale;
+ Gtk.SpinButton transition_effect_entry;
+ Gtk.Adjustment transition_effect_adjustment;
+ Gtk.CheckButton show_title_button;
+ Gtk.Box pane;
+
+ public SettingsDialog() {
+ builder = AppWindow.create_builder();
+ pane = builder.get_object("slideshow_settings_pane") as Gtk.Box;
+ get_content_area().add(pane);
+
+ double delay = Config.Facade.get_instance().get_slideshow_delay();
+
+ set_modal(true);
+ set_transient_for(AppWindow.get_fullscreen());
+
+ add_buttons(Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL,
+ Gtk.Stock.OK, Gtk.ResponseType.OK);
+ set_title(_("Settings"));
+
+ Gtk.Adjustment adjustment = new Gtk.Adjustment(delay, Config.Facade.SLIDESHOW_DELAY_MIN, Config.Facade.SLIDESHOW_DELAY_MAX, 0.1, 1, 0);
+ delay_hscale = builder.get_object("delay_hscale") as Gtk.Scale;
+ delay_hscale.adjustment = adjustment;
+
+ delay_entry = builder.get_object("delay_entry") as Gtk.SpinButton;
+ delay_entry.adjustment = adjustment;
+ delay_entry.set_value(delay);
+ delay_entry.set_numeric(true);
+ delay_entry.set_activates_default(true);
+
+ transition_effect_selector = builder.get_object("transition_effect_selector") as Gtk.ComboBoxText;
+
+ // get last effect id
+ string effect_id = Config.Facade.get_instance().get_slideshow_transition_effect_id();
+
+ // null effect first, always, and set active in case no other one is found
+ string null_display_name = TransitionEffectsManager.get_instance().get_effect_name(
+ TransitionEffectsManager.NULL_EFFECT_ID);
+ transition_effect_selector.append_text(null_display_name);
+ transition_effect_selector.set_active(0);
+
+ int i = 1;
+ foreach (string display_name in
+ TransitionEffectsManager.get_instance().get_effect_names(utf8_ci_compare)) {
+ if (display_name == null_display_name)
+ continue;
+
+ transition_effect_selector.append_text(display_name);
+ if (effect_id == TransitionEffectsManager.get_instance().get_id_for_effect_name(display_name))
+ transition_effect_selector.set_active(i);
+
+ ++i;
+ }
+ transition_effect_selector.changed.connect(on_transition_changed);
+
+ double transition_delay = Config.Facade.get_instance().get_slideshow_transition_delay();
+ transition_effect_adjustment = new Gtk.Adjustment(transition_delay,
+ Config.Facade.SLIDESHOW_TRANSITION_DELAY_MIN, Config.Facade.SLIDESHOW_TRANSITION_DELAY_MAX,
+ 0.1, 1, 0);
+ transition_effect_hscale = builder.get_object("transition_effect_hscale") as Gtk.Scale;
+ transition_effect_hscale.adjustment = transition_effect_adjustment;
+
+ transition_effect_entry = builder.get_object("transition_effect_entry") as Gtk.SpinButton;
+ transition_effect_entry.adjustment = transition_effect_adjustment;
+ transition_effect_entry.set_value(transition_delay);
+ transition_effect_entry.set_numeric(true);
+ transition_effect_entry.set_activates_default(true);
+
+ bool show_title = Config.Facade.get_instance().get_slideshow_show_title();
+ show_title_button = builder.get_object("show_title_button") as Gtk.CheckButton;
+ show_title_button.active = show_title;
+
+ set_default_response(Gtk.ResponseType.OK);
+
+ on_transition_changed();
+ }
+
+ private void on_transition_changed() {
+ string selected = transition_effect_selector.get_active_text();
+ bool sensitive = selected != null
+ && selected != TransitionEffectsManager.NULL_EFFECT_ID;
+
+ transition_effect_hscale.sensitive = sensitive;
+ transition_effect_entry.sensitive = sensitive;
+ }
+
+ public double get_delay() {
+ return delay_entry.get_value();
+ }
+
+ public double get_transition_delay() {
+ return transition_effect_entry.get_value();
+ }
+
+ public string get_transition_effect_id() {
+ string? active = transition_effect_selector.get_active_text();
+ if (active == null)
+ return TransitionEffectsManager.NULL_EFFECT_ID;
+
+ string? id = TransitionEffectsManager.get_instance().get_id_for_effect_name(active);
+
+ return (id != null) ? id : TransitionEffectsManager.NULL_EFFECT_ID;
+ }
+
+ public bool get_show_title() {
+ return show_title_button.active;
+ }
+ }
+
+ public SlideshowPage(SourceCollection sources, ViewCollection controller, Photo start) {
+ base(_("Slideshow"), true);
+
+ this.sources = sources;
+ this.controller = controller;
+
+ Gee.Collection<string> pluggables = TransitionEffectsManager.get_instance().get_effect_ids();
+ Gee.ArrayList<string> a = new Gee.ArrayList<string>();
+ a.add_all(pluggables);
+ a.remove(NullTransitionDescriptor.EFFECT_ID);
+ a.remove(RandomEffectDescriptor.EFFECT_ID);
+ transitions = a.to_array();
+ current = start;
+
+ update_transition_effect();
+
+ // Set up toolbar
+ Gtk.Toolbar toolbar = get_toolbar();
+
+ // add toolbar buttons
+ Gtk.ToolButton previous_button = new Gtk.ToolButton.from_stock(Gtk.Stock.GO_BACK);
+ previous_button.set_label(_("Back"));
+ previous_button.set_tooltip_text(_("Go to the previous photo"));
+ previous_button.clicked.connect(on_previous_photo);
+
+ toolbar.insert(previous_button, -1);
+
+ play_pause_button = new Gtk.ToolButton.from_stock(Gtk.Stock.MEDIA_PAUSE);
+ play_pause_button.set_label(_("Pause"));
+ play_pause_button.set_tooltip_text(_("Pause the slideshow"));
+ play_pause_button.clicked.connect(on_play_pause);
+
+ toolbar.insert(play_pause_button, -1);
+
+ Gtk.ToolButton next_button = new Gtk.ToolButton.from_stock(Gtk.Stock.GO_FORWARD);
+ next_button.set_label(_("Next"));
+ next_button.set_tooltip_text(_("Go to the next photo"));
+ next_button.clicked.connect(on_next_photo);
+
+ toolbar.insert(next_button, -1);
+
+ settings_button = new Gtk.ToolButton.from_stock(Gtk.Stock.PREFERENCES);
+ settings_button.set_label(_("Settings"));
+ settings_button.set_tooltip_text(_("Change slideshow settings"));
+ settings_button.clicked.connect(on_change_settings);
+ settings_button.is_important = true;
+
+ toolbar.insert(settings_button, -1);
+
+ screensaver = new Screensaver();
+ }
+
+ public override void switched_to() {
+ base.switched_to();
+
+ // create a cache for the size of this display
+ cache = new PixbufCache(sources, PixbufCache.PhotoType.BASELINE, get_canvas_scaling(),
+ READAHEAD_COUNT);
+
+ Gdk.Pixbuf pixbuf;
+ if (get_next_photo(current, Direction.FORWARD, out current, out pixbuf))
+ set_pixbuf(pixbuf, current.get_dimensions(), Direction.FORWARD);
+
+ // start the auto-advance timer
+ Timeout.add(CHECK_ADVANCE_MSEC, auto_advance);
+ timer.start();
+
+ screensaver.inhibit("Playing slideshow");
+ }
+
+ public override void switching_from() {
+ base.switching_from();
+
+ screensaver.uninhibit();
+ exiting = true;
+ }
+
+ private bool get_next_photo(Photo start, Direction direction, out Photo next,
+ out Gdk.Pixbuf next_pixbuf) {
+ next = start;
+
+ for (;;) {
+ try {
+ // Fails if a photo source file is missing.
+ next_pixbuf = cache.fetch(next);
+ } catch (Error err) {
+ warning("Unable to fetch pixbuf for %s: %s", next.to_string(), err.message);
+
+ // Look for the next good photo
+ DataView view = controller.get_view_for_source(next);
+ view = (direction == Direction.FORWARD)
+ ? controller.get_next(view)
+ : controller.get_previous(view);
+ next = (Photo) view.get_source();
+
+ // An entire slideshow set might be missing, so check for a loop.
+ if ((next == start && next != current) || next == current) {
+ AppWindow.error_message(_("All photo source files are missing."), get_container());
+ AppWindow.get_instance().end_fullscreen();
+
+ next = null;
+ next_pixbuf = null;
+
+ return false;
+ }
+
+ continue;
+ }
+
+ // prefetch this photo's extended neighbors: the next photo highest priority, the prior
+ // one normal, and the extended neighbors lowest, to recognize immediate needs
+ DataSource forward, back;
+ controller.get_immediate_neighbors(next, out forward, out back, Photo.TYPENAME);
+ cache.prefetch((Photo) forward, BackgroundJob.JobPriority.HIGHEST);
+ cache.prefetch((Photo) back, BackgroundJob.JobPriority.NORMAL);
+
+ Gee.Set<DataSource> neighbors = controller.get_extended_neighbors(next, Photo.TYPENAME);
+ neighbors.remove(forward);
+ neighbors.remove(back);
+
+ cache.prefetch_many((Gee.Collection<Photo>) neighbors, BackgroundJob.JobPriority.LOWEST);
+
+ return true;
+ }
+ }
+
+ private void on_play_pause() {
+ if (playing) {
+ play_pause_button.set_stock_id(Gtk.Stock.MEDIA_PLAY);
+ play_pause_button.set_label(_("Play"));
+ play_pause_button.set_tooltip_text(_("Continue the slideshow"));
+ } else {
+ play_pause_button.set_stock_id(Gtk.Stock.MEDIA_PAUSE);
+ play_pause_button.set_label(_("Pause"));
+ play_pause_button.set_tooltip_text(_("Pause the slideshow"));
+ }
+
+ playing = !playing;
+
+ // reset the timer
+ timer.start();
+ }
+
+ protected override void on_previous_photo() {
+ DataView view = controller.get_view_for_source(current);
+
+ Photo? prev_photo = null;
+ DataView? start_view = controller.get_previous(view);
+ DataView? prev_view = start_view;
+
+ while (prev_view != null) {
+ if (prev_view.get_source() is Photo) {
+ prev_photo = (Photo) prev_view.get_source();
+ break;
+ }
+
+ prev_view = controller.get_previous(prev_view);
+
+ if (prev_view == start_view) {
+ warning("on_previous( ): can't advance to previous photo: collection has only videos");
+ return;
+ }
+ }
+
+ advance(prev_photo, Direction.BACKWARD);
+ }
+
+ protected override void on_next_photo() {
+ DataView view = controller.get_view_for_source(current);
+
+ Photo? next_photo = null;
+ DataView? start_view = controller.get_next(view);
+ DataView? next_view = start_view;
+
+ while (next_view != null) {
+ if (next_view.get_source() is Photo) {
+ next_photo = (Photo) next_view.get_source();
+ break;
+ }
+
+ next_view = controller.get_next(next_view);
+
+ if (next_view == start_view) {
+ warning("on_next( ): can't advance to next photo: collection has only videos");
+ return;
+ }
+ }
+
+ if (Config.Facade.get_instance().get_slideshow_transition_effect_id() ==
+ RandomEffectDescriptor.EFFECT_ID) {
+ random_transition_effect();
+ }
+
+ advance(next_photo, Direction.FORWARD);
+ }
+
+ private void advance(Photo photo, Direction direction) {
+ current = photo;
+
+ // set pixbuf
+ Gdk.Pixbuf next_pixbuf;
+ if (get_next_photo(current, direction, out current, out next_pixbuf))
+ set_pixbuf(next_pixbuf, current.get_dimensions(), direction);
+
+ // reset the advance timer
+ timer.start();
+ }
+
+ private bool auto_advance() {
+ if (exiting)
+ return false;
+
+ if (!playing)
+ return true;
+
+ if (timer.elapsed() < Config.Facade.get_instance().get_slideshow_delay())
+ return true;
+
+ on_next_photo();
+
+ return true;
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ bool handled = true;
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "space":
+ on_play_pause();
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ if (handled)
+ return true;
+
+ return (base.key_press_event != null) ? base.key_press_event(event) : true;
+ }
+
+ private void on_change_settings() {
+ SettingsDialog settings_dialog = new SettingsDialog();
+ settings_dialog.show_all();
+
+ bool slideshow_playing = playing;
+ playing = false;
+ hide_toolbar();
+
+ if (settings_dialog.run() == Gtk.ResponseType.OK) {
+ // sync with the config setting so it will persist
+ Config.Facade.get_instance().set_slideshow_delay(settings_dialog.get_delay());
+
+ Config.Facade.get_instance().set_slideshow_transition_delay(settings_dialog.get_transition_delay());
+ Config.Facade.get_instance().set_slideshow_transition_effect_id(settings_dialog.get_transition_effect_id());
+ Config.Facade.get_instance().set_slideshow_show_title(settings_dialog.get_show_title());
+
+ update_transition_effect();
+ }
+
+ settings_dialog.destroy();
+ playing = slideshow_playing;
+ timer.start();
+ }
+
+ private void update_transition_effect() {
+ string effect_id = Config.Facade.get_instance().get_slideshow_transition_effect_id();
+ double effect_delay = Config.Facade.get_instance().get_slideshow_transition_delay();
+
+ set_transition(effect_id, (int) (effect_delay * 1000.0));
+ }
+
+ private void random_transition_effect() {
+ double effect_delay = Config.Facade.get_instance().get_slideshow_transition_delay();
+ string effect_id = TransitionEffectsManager.NULL_EFFECT_ID;
+ if (0 < transitions.length) {
+ int random = Random.int_range(0, transitions.length);
+ effect_id = transitions[random];
+ }
+ set_transition(effect_id, (int) (effect_delay * 1000.0));
+ }
+
+ // Paint the title of the photo
+ private void paint_title(Cairo.Context ctx, Dimensions ctx_dim) {
+ string? title = current.get_title();
+
+ // If the photo doesn't have a title, don't paint anything
+ if (title == null || title == "")
+ return;
+
+ Pango.Layout layout = create_pango_layout(title);
+ Pango.AttrList list = new Pango.AttrList();
+ Pango.Attribute size = Pango.attr_scale_new(3);
+ list.insert(size.copy());
+ layout.set_attributes(list);
+ layout.set_width((int) ((ctx_dim.width * 0.9) * Pango.SCALE));
+
+ // Find the right position
+ int title_width, title_height;
+ layout.get_pixel_size(out title_width, out title_height);
+ double x = ctx_dim.width * 0.2;
+ double y = ctx_dim.height * 0.90;
+
+ // Move the title up if it is too high
+ if (y + title_height >= ctx_dim.height * 0.95)
+ y = ctx_dim.height * 0.95 - title_height;
+ // Move to the left if the title is too long
+ if (x + title_width >= ctx_dim.width * 0.95)
+ x = ctx_dim.width / 2 - title_width / 2;
+
+ set_source_color_from_string(ctx, "#fff");
+ ctx.move_to(x, y);
+ Pango.cairo_show_layout(ctx, layout);
+ Pango.cairo_layout_path(ctx, layout);
+ ctx.set_line_width(1.5);
+ set_source_color_from_string(ctx, "#000");
+ ctx.stroke();
+ }
+
+ public override void paint(Cairo.Context ctx, Dimensions ctx_dim) {
+ base.paint(ctx, ctx_dim);
+
+ if (Config.Facade.get_instance().get_slideshow_show_title() && !is_transition_in_progress())
+ paint_title(ctx, ctx_dim);
+ }
+}
+
diff --git a/src/SortedList.vala b/src/SortedList.vala
new file mode 100644
index 0000000..791f9e0
--- /dev/null
+++ b/src/SortedList.vala
@@ -0,0 +1,429 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public delegate int64 Comparator(void *a, void *b);
+
+extern string g_utf8_collate_key_for_filename(string str, ssize_t len = -1);
+
+public int64 file_comparator(void *a, void *b) {
+ string? path_a = ((File *) a)->get_path();
+ string? path_b = ((File *) b)->get_path();
+
+ // if both are null, treat as equal; if one but not the other, prioritize the non-null
+ if (path_a == null)
+ return (path_b == null) ? 0 : 1;
+
+ if (path_b == null)
+ return -1;
+
+ return strcmp(g_utf8_collate_key_for_filename(path_a), g_utf8_collate_key_for_filename(path_b));
+}
+
+public class SortedList<G> : Object, Gee.Traversable<G>, Gee.Iterable<G>, Gee.Collection<G> {
+ private Gee.ArrayList<G> list;
+ private unowned Comparator? cmp;
+
+ public SortedList(Comparator? cmp = null) {
+ this.list = new Gee.ArrayList<G>();
+ this.cmp = cmp;
+ }
+
+ public Type element_type {
+ get { return typeof(G); }
+ }
+
+ public bool read_only {
+ get { return list.read_only; }
+ }
+
+ public Gee.Iterator<G?> iterator() {
+ return list.iterator();
+ }
+
+ public bool foreach(Gee.ForallFunc<G> f) {
+ return list.foreach(f);
+ }
+
+ public bool add(G? item) {
+ if (cmp == null)
+ list.add(item);
+ else
+ list.insert(get_sorted_insert_pos(item), item);
+
+#if VERIFY_SORTED_LIST
+ assert(is_sorted());
+#endif
+
+ return true;
+ }
+
+ public bool add_all(Gee.Collection<G> collection) {
+ if (collection.size == 0)
+ return false;
+
+ Gee.List<G> as_list = collection as Gee.List<G>;
+ if (as_list != null)
+ return add_list(as_list);
+
+ if (cmp == null)
+ return list.add_all(collection);
+
+ bool changed = false;
+ if (collection.size == 1) {
+ Gee.Iterator<G> iter = collection.iterator();
+ iter.next();
+ G item = iter.get();
+
+ list.insert(get_sorted_insert_pos(item), item);
+ changed = true;
+ } else {
+ Gee.List<G> items = new Gee.ArrayList<G>();
+ items.add_all(collection);
+
+ changed = merge_sort(items);
+ }
+
+#if VERIFY_SORTED_LIST
+ assert(is_sorted());
+#endif
+ return changed;
+ }
+
+ public bool add_list(Gee.List<G> items) {
+ bool added = false;
+ if (items.size == 0) {
+ // do nothing, return false
+ } else if (cmp != null) {
+ // don't use a full merge sort if the number of items is one ... a binary
+ // insertion sort with the insert is quicker
+ if (items.size == 1) {
+ list.insert(get_sorted_insert_pos(items.get(0)), items.get(0));
+ added = true;
+ } else {
+ added = merge_sort(items);
+ }
+ } else {
+ added = list.add_all(items);
+ }
+
+#if VERIFY_SORTED_LIST
+ assert(is_sorted());
+#endif
+
+ return added;
+ }
+
+ public void clear() {
+ list.clear();
+ }
+
+ public bool contains(G? item) {
+ return list.contains(item);
+ }
+
+ public bool contains_all(Gee.Collection<G> collection) {
+ return list.contains_all(collection);
+ }
+
+ public bool is_empty {
+ get {
+ return list.is_empty;
+ }
+ }
+
+ public bool remove(G? item) {
+ return list.remove(item);
+ }
+
+ public bool remove_all(Gee.Collection<G> collection) {
+ return list.remove_all(collection);
+ }
+
+ public bool retain_all(Gee.Collection<G> collection) {
+ return list.retain_all(collection);
+ }
+
+ public int size {
+ get { return list.size; }
+ }
+
+ public inline int get_count() {
+ return list.size;
+ }
+
+ public G? get_at(int index) {
+ return list.get(index);
+ }
+
+ private int binary_search(G search, EqualFunc? equal_func) {
+ assert(cmp != null);
+
+ int min = 0;
+ int max = list.size;
+ for (;;) {
+ int mid = min + ((max - min) / 2);
+ G item = list.get(mid);
+
+ if (equal_func != null && equal_func(item, search))
+ return mid;
+
+ int64 compare = cmp(item, search);
+ if (compare == 0)
+ return mid;
+ else if (compare > 0)
+ max = mid - 1;
+ else
+ min = mid + 1;
+
+ if (min > max)
+ break;
+ }
+
+ return -1;
+ }
+
+ // index_of uses the Comparator to find the item being searched for. Because SortedList allows
+ // for items identified as equal by the Comparator to co-exist in the list, this method will
+ // return the first item found where its compare() method returns zero. Use locate() if a
+ // specific EqualFunc is required for searching.
+ //
+ // Also, index_of() cannot be reliably used to find an item if it has been altered in such a
+ // way that it is no longer sorted properly. Use locate() for that.
+ public int index_of(G search) {
+ return cmp != null ? binary_search(search, null) : locate(search, false);
+ }
+
+ // 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) {
+ if (cmp == null || altered) {
+ int count = list.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ if (equal_func(list.get(ctr), search))
+ return ctr;
+ }
+
+ return -1;
+ }
+
+ return binary_search(search, equal_func);
+ }
+
+ public Gee.Collection<G> read_only_view {
+ owned get {
+ return list.read_only_view;
+ }
+ }
+
+ public Gee.List<G> read_only_view_as_list {
+ owned get {
+ return list.read_only_view;
+ }
+ }
+
+ public G? remove_at(int index) {
+ return list.remove_at(index);
+ }
+
+ public G[] to_array() {
+ return list.to_array();
+ }
+
+ public void resort(Comparator new_cmp) {
+ cmp = new_cmp;
+
+ merge_sort();
+
+#if VERIFY_SORTED_LIST
+ assert(is_sorted());
+#endif
+ }
+
+ // Returns true if item has moved.
+ public bool resort_item(G item) {
+ int index = locate(item, true);
+ assert(index >= 0);
+
+ int new_index = get_sorted_insert_pos(item);
+
+ if (index == new_index)
+ return false;
+
+ // insert in such a way to avoid index shift (performing the rightmost
+ // operation before the leftmost)
+ if (new_index > index) {
+ list.insert(new_index, item);
+ G removed_item = list.remove_at(index);
+ assert(item == removed_item);
+ } else {
+ G removed_item = list.remove_at(index);
+ assert(item == removed_item);
+ list.insert(new_index, item);
+ }
+
+#if VERIFY_SORTED_LIST
+ assert(is_sorted());
+#endif
+
+ return true;
+ }
+
+ private int get_sorted_insert_pos(G? item) {
+ int low = 0;
+ int high = list.size;
+ for (;;) {
+ if (low == high)
+ return low;
+
+ int mid = low + ((high - low) / 2);
+
+ // watch for the situation where the item is already in the list (can happen with
+ // resort_item())
+ G cmp_item = list.get(mid);
+ if (item == cmp_item) {
+ // if at the end of the list, add it there
+ if (mid >= list.size - 1)
+ return list.size;
+
+ cmp_item = list.get(mid + 1);
+ }
+
+ int64 result = cmp(item, cmp_item);
+ if (result < 0)
+ high = mid;
+ else if (result > 0)
+ low = mid + 1;
+ else
+ return mid;
+ }
+ }
+
+ public SortedList<G> copy() {
+ SortedList<G> copy = new SortedList<G>(cmp);
+
+ copy.list.add_all(list);
+
+ return copy;
+ }
+
+#if VERIFY_SORTED_LIST
+ private bool is_sorted() {
+ if (cmp == null)
+ return true;
+
+ int length = list.size;
+ for (int ctr = 1; ctr < length; ctr++) {
+ if (cmp(list.get(ctr - 1), list.get(ctr)) >= 0) {
+ critical("Out of order: %d and %d", ctr - 1, ctr);
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+#endif
+
+ private bool merge_sort(Gee.List<G>? add = null) {
+ assert(cmp != null);
+
+ int list_count = list.size;
+ int add_count = (add != null) ? add.size : 0;
+
+ int count = list_count + add_count;
+ if (count == 0)
+ return false;
+
+ // because list access is slow in large-scale sorts, flatten list (with additions) to
+ // an array, merge sort that, and then place them back in the internal ArrayList.
+ G[] array = new G[count];
+ int offset = 0;
+
+ while (offset < list_count) {
+ array[offset] = list.get(offset);
+ offset++;
+ }
+
+ if (add != null) {
+ int add_ctr = 0;
+ while (offset < count) {
+ array[offset] = add.get(add_ctr++);
+ offset++;
+ }
+ }
+
+ assert(offset == count);
+
+ _merge_sort(array, new G[count], 0, count - 1);
+
+ offset = 0;
+ while (offset < list_count) {
+ list.set(offset, array[offset]);
+ offset++;
+ }
+
+ while (offset < count) {
+ list.insert(offset, array[offset]);
+ offset++;
+ }
+
+ return true;
+ }
+
+ private void _merge_sort(G[] array, G[] scratch, int start_index, int end_index) {
+ assert(start_index <= end_index);
+
+ int count = end_index - start_index + 1;
+ if (count <= 1)
+ return;
+
+ int middle_index = start_index + (count / 2);
+
+ _merge_sort(array, scratch, start_index, middle_index - 1);
+ _merge_sort(array, scratch, middle_index, end_index);
+
+ if (cmp(array[middle_index - 1], array[middle_index]) > 0)
+ merge(array, scratch, start_index, middle_index, end_index);
+ }
+
+ private void merge(G[] array, G[] scratch, int start_index, int middle_index, int end_index) {
+ assert(start_index < end_index);
+
+ int count = end_index - start_index + 1;
+ int left_start = start_index;
+ int left_end = middle_index - 1;
+ int right_start = middle_index;
+ int right_end = end_index;
+
+ assert(scratch.length >= count);
+ int scratch_index = 0;
+
+ while (left_start <= left_end && right_start <= right_end) {
+ G left = array[left_start];
+ G right = array[right_start];
+
+ if (cmp(left, right) <= 0) {
+ scratch[scratch_index++] = left;
+ left_start++;
+ } else {
+ scratch[scratch_index++] = right;
+ right_start++;
+ }
+ }
+
+ while (left_start <= left_end)
+ scratch[scratch_index++] = array[left_start++];
+
+ while (right_start <= right_end)
+ scratch[scratch_index++] = array[right_start++];
+
+ assert(scratch_index == count);
+
+ scratch_index = 0;
+ for (int list_index = start_index; list_index <= end_index; list_index++)
+ array[list_index] = scratch[scratch_index++];
+ }
+}
+
diff --git a/src/Tag.vala b/src/Tag.vala
new file mode 100644
index 0000000..1ae76ba
--- /dev/null
+++ b/src/Tag.vala
@@ -0,0 +1,1189 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 TagSourceCollection : ContainerSourceCollection {
+ private Gee.HashMap<string, Tag> name_map = new Gee.HashMap<string, Tag>(Tag.hash_name_string,
+ Tag.equal_name_strings);
+ private Gee.HashMap<MediaSource, Gee.List<Tag>> source_map =
+ new Gee.HashMap<MediaSource, Gee.List<Tag>>();
+ private Gee.HashMap<MediaSource, Gee.SortedSet<Tag>> sorted_source_map =
+ new Gee.HashMap<MediaSource, Gee.SortedSet<Tag>>();
+
+ public TagSourceCollection() {
+ base (Tag.TYPENAME, "TagSourceCollection", get_tag_key);
+
+ attach_collection(LibraryPhoto.global);
+ attach_collection(Video.global);
+
+ // deal with LibraryPhotos being reimported (and possibly their on-disk keywords changing)
+ LibraryPhoto.global.source_reimported.connect(on_photo_source_reimported);
+ }
+
+ ~TagSourceCollection() {
+ LibraryPhoto.global.source_reimported.disconnect(on_photo_source_reimported);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is Tag;
+ }
+
+ private static int64 get_tag_key(DataSource source) {
+ return ((Tag) source).get_instance_id();
+ }
+
+ protected override Gee.Collection<ContainerSource>? get_containers_holding_source(DataSource source) {
+ return fetch_for_source((MediaSource) source);
+ }
+
+ public override ContainerSource? convert_backlink_to_container(SourceBacklink backlink) {
+ TagID tag_id = TagID(backlink.instance_id);
+ Tag? result = null;
+
+ // see if the backlinked tag is already rehydrated and available
+ Tag? tag = fetch(tag_id);
+ if (tag != null) {
+ result = tag;
+ } else {
+ // backlinked tag wasn't already available, so look for it in the holding tank
+ foreach (ContainerSource container in get_holding_tank()) {
+ tag = (Tag) container;
+ if (tag.get_tag_id().id == tag_id.id) {
+ result = tag;
+ break;
+ }
+ }
+ }
+
+ // if we have pulled a hierarchical tag out of the holding tank and the tag we've pulled out
+ // has a parent, its parent might need to be promoted (because it was flattened when put
+ // into the holding tank), so check for this case and promote if necessary
+ if (result != null) {
+ if ((result.get_path().has_prefix(Tag.PATH_SEPARATOR_STRING)) &&
+ (HierarchicalTagUtilities.enumerate_parent_paths(result.get_path()).size > 0)) {
+ string top_level_with_prefix_path =
+ HierarchicalTagUtilities.enumerate_parent_paths(result.get_path()).get(0);
+ string top_level_no_prefix_path =
+ HierarchicalTagUtilities.hierarchical_to_flat(top_level_with_prefix_path);
+
+ foreach (ContainerSource container in get_holding_tank()) {
+ Tag parent_candidate = (Tag) container;
+ if (parent_candidate.get_path() == top_level_no_prefix_path)
+ parent_candidate.promote();
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public Tag? fetch(TagID tag_id) {
+ return (Tag) fetch_by_key(tag_id.id);
+ }
+
+ public bool exists(string name, bool treat_htags_as_root = false) {
+ return fetch_by_name(name, treat_htags_as_root) != null;
+ }
+
+ public Gee.Collection<string> get_all_names() {
+ return name_map.keys;
+ }
+
+ // Returns a list of all Tags associated with the media source in no particular order.
+ //
+ // NOTE: As a search optimization, this returns the list that is maintained by Tags.global.
+ // Do NOT modify this list.
+ public Gee.List<Tag>? fetch_for_source(MediaSource source) {
+ return source_map.get(source);
+ }
+
+ // Returns a sorted set of all Tags associated with the media source (ascending by name).
+ //
+ // NOTE: As an optimization, this returns the list that is maintained by Tags.global.
+ // Do NOT modify this list.
+ public Gee.SortedSet<Tag>? fetch_sorted_for_source(MediaSource photo) {
+ return sorted_source_map.get(photo);
+ }
+
+ // Returns null if not Tag with name exists.
+ // treat_htags_as_root: set to true if you want this function to treat htags as root tags
+ public Tag? fetch_by_name(string name, bool treat_htags_as_root = false) {
+ if (treat_htags_as_root) {
+ if (name.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ if (HierarchicalTagUtilities.enumerate_path_components(name).size == 1) {
+ Tag? tag = name_map.get(HierarchicalTagUtilities.hierarchical_to_flat(name));
+ if (tag != null)
+ return tag;
+ }
+ } else {
+ Tag? tag = name_map.get(HierarchicalTagUtilities.flat_to_hierarchical(name));
+ if (tag != null)
+ return tag;
+ }
+ }
+
+ return name_map.get(name);
+ }
+
+ public Tag? restore_tag_from_holding_tank(string name) {
+ Tag? found = null;
+ foreach (ContainerSource container in get_holding_tank()) {
+ Tag tag = (Tag) container;
+ if (tag.get_name() == name) {
+ found = tag;
+
+ break;
+ }
+ }
+
+ if (found != null) {
+ bool relinked = relink_from_holding_tank(found);
+ assert(relinked);
+ }
+
+ return found;
+ }
+
+ protected override void notify_items_added(Gee.Iterable<DataObject> added) {
+ foreach (DataObject object in added) {
+ Tag tag = (Tag) object;
+
+ assert(!name_map.has_key(tag.get_name()));
+ name_map.set(tag.get_name(), tag);
+ }
+
+ base.notify_items_added(added);
+ }
+
+ protected override void notify_items_removed(Gee.Iterable<DataObject> removed) {
+ foreach (DataObject object in removed) {
+ Tag tag = (Tag) object;
+
+ bool unset = name_map.unset(tag.get_name());
+ assert(unset);
+
+ // if we just removed the last child tag of a top-level hierarchical tag, then convert
+ // the top-level tag back to a flat tag
+ Tag? parent = tag.get_hierarchical_parent();
+ if ((parent != null) && (parent.get_hierarchical_parent() == null)) {
+ if (parent.get_hierarchical_children().size == 0)
+ parent.flatten();
+ }
+ }
+
+ base.notify_items_removed(removed);
+ }
+
+ protected override void notify_items_altered(Gee.Map<DataObject, Alteration> map) {
+ foreach (DataObject object in map.keys) {
+ Tag tag = (Tag) object;
+
+ string? old_name = null;
+
+ // look for this tag being renamed
+ Gee.MapIterator<string, Tag> iter = name_map.map_iterator();
+ while (iter.next()) {
+ if (!iter.get_value().equals(tag))
+ continue;
+
+ old_name = iter.get_key();
+
+ break;
+ }
+
+ assert(old_name != null);
+
+ if (tag.get_name() != old_name) {
+ name_map.unset(old_name);
+ name_map.set(tag.get_name(), tag);
+ }
+ }
+
+ base.notify_items_altered(map);
+ }
+
+ protected override void notify_container_contents_added(ContainerSource container,
+ Gee.Collection<DataSource> added, bool relinking) {
+ Tag tag = (Tag) container;
+ Gee.Collection<MediaSource> sources = (Gee.Collection<MediaSource>) added;
+
+ foreach (MediaSource source in sources) {
+ Gee.List<Tag>? tags = source_map.get(source);
+ if (tags == null) {
+ tags = new Gee.ArrayList<Tag>();
+ source_map.set(source, tags);
+ }
+
+ bool is_added = tags.add(tag);
+ assert(is_added);
+
+ Gee.SortedSet<Tag>? sorted_tags = sorted_source_map.get(source);
+ if (sorted_tags == null) {
+ sorted_tags = new Gee.TreeSet<Tag>(Tag.compare_names);
+ sorted_source_map.set(source, sorted_tags);
+ }
+
+ is_added = sorted_tags.add(tag);
+ assert(is_added);
+ }
+
+ base.notify_container_contents_added(container, added, relinking);
+ }
+
+ protected override void notify_container_contents_removed(ContainerSource container,
+ Gee.Collection<DataSource> removed, bool unlinking) {
+ Tag tag = (Tag) container;
+ Gee.Collection<MediaSource> sources = (Gee.Collection<MediaSource>) removed;
+
+ foreach (MediaSource source in sources) {
+ Gee.List<Tag>? tags = source_map.get(source);
+ assert(tags != null);
+
+ bool is_removed = tags.remove(tag);
+ assert(is_removed);
+
+ if (tags.size == 0)
+ source_map.unset(source);
+
+ Gee.SortedSet<Tag>? sorted_tags = sorted_source_map.get(source);
+ assert(sorted_tags != null);
+
+ is_removed = sorted_tags.remove(tag);
+ assert(is_removed);
+
+ if (sorted_tags.size == 0)
+ sorted_source_map.unset(source);
+ }
+
+ base.notify_container_contents_removed(container, removed, unlinking);
+ }
+
+ private void on_photo_source_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ // with the introduction of HTags, all of this logic has been moved to
+ // Photo.apply_user_metadata_for_reimport( )
+ }
+}
+
+public class Tag : DataSource, ContainerSource, Proxyable, Indexable {
+ public const string TYPENAME = "tag";
+ public const string PATH_SEPARATOR_STRING = "/";
+
+ private class TagSnapshot : SourceSnapshot {
+ private TagRow row;
+ private Gee.HashSet<MediaSource> sources = new Gee.HashSet<MediaSource>();
+
+ public TagSnapshot(Tag tag) {
+ // stash current state of Tag
+ row = tag.row;
+
+ // stash photos and videos attached to this tag ... if any are destroyed, the tag
+ // cannot be reconstituted
+ foreach (MediaSource source in tag.get_sources())
+ sources.add(source);
+
+ LibraryPhoto.global.item_destroyed.connect(on_source_destroyed);
+ Video.global.item_destroyed.connect(on_source_destroyed);
+ }
+
+ ~TagSnapshot() {
+ LibraryPhoto.global.item_destroyed.disconnect(on_source_destroyed);
+ Video.global.item_destroyed.disconnect(on_source_destroyed);
+ }
+
+ public TagRow get_row() {
+ return row;
+ }
+
+ public override void notify_broken() {
+ row = new TagRow();
+ sources.clear();
+
+ base.notify_broken();
+ }
+
+ private void on_source_destroyed(DataSource source) {
+ if (sources.contains((MediaSource) source))
+ notify_broken();
+ }
+ }
+
+ private class TagProxy : SourceProxy {
+ public TagProxy(Tag tag) {
+ base (tag);
+ }
+
+ public override DataSource reconstitute(int64 object_id, SourceSnapshot snapshot) {
+ return Tag.reconstitute(object_id, ((TagSnapshot) snapshot).get_row());
+ }
+ }
+
+ public static TagSourceCollection global = null;
+
+ private TagRow row;
+ private ViewCollection media_views;
+ private string? name_collation_key = null;
+ private bool unlinking = false;
+ private bool relinking = false;
+ private string? indexable_keywords = null;
+
+ private Tag(TagRow row, int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+
+ this.row = row;
+
+ // normalize user text
+ this.row.name = prep_tag_name(this.row.name);
+
+ // convert source ids to MediaSources and ThumbnailViews for the internal ViewCollection
+ Gee.ArrayList<MediaSource> source_list = new Gee.ArrayList<MediaSource>();
+ Gee.ArrayList<ThumbnailView> thumbnail_views = new Gee.ArrayList<ThumbnailView>();
+ if (this.row.source_id_list != null) {
+ foreach (string source_id in this.row.source_id_list) {
+ MediaSource? current_source =
+ (MediaSource?) MediaCollectionRegistry.get_instance().fetch_media(source_id);
+ if (current_source == null)
+ continue;
+
+ source_list.add(current_source);
+ thumbnail_views.add(new ThumbnailView(current_source));
+ }
+ } else {
+ // allocate the source_id_list for use if/when media sources are added
+ this.row.source_id_list = new Gee.HashSet<string>();
+ }
+
+ // add to internal ViewCollection, which maintains media sources associated with this tag
+ media_views = new ViewCollection("ViewCollection for tag %s".printf(row.tag_id.id.to_string()));
+ media_views.add_many(thumbnail_views);
+
+ // need to do this manually here because only want to monitor photo_contents_altered
+ // after add_many() here; but need to keep the TagSourceCollection apprised
+ if (source_list.size > 0) {
+ global.notify_container_contents_added(this, source_list, false);
+ global.notify_container_contents_altered(this, source_list, false, null, false);
+ }
+
+ // monitor ViewCollection to (a) keep the in-memory list of source ids up-to-date, and
+ // (b) update the database whenever there's a change;
+ media_views.contents_altered.connect(on_media_views_contents_altered);
+
+ // monitor the global collections to trap when photos and videos are destroyed, then
+ // automatically remove from the tag
+ LibraryPhoto.global.items_destroyed.connect(on_sources_destroyed);
+ Video.global.items_destroyed.connect(on_sources_destroyed);
+
+ update_indexable_keywords();
+ }
+
+ ~Tag() {
+ media_views.contents_altered.disconnect(on_media_views_contents_altered);
+ LibraryPhoto.global.items_destroyed.disconnect(on_sources_destroyed);
+ Video.global.items_destroyed.disconnect(on_sources_destroyed);
+ }
+
+ public static void init(ProgressMonitor? monitor) {
+ global = new TagSourceCollection();
+
+ // scoop up all the rows at once
+ Gee.List<TagRow?> rows = null;
+ try {
+ rows = TagTable.get_instance().get_all_rows();
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ // turn the freshly-read TagRows into Tag objects.
+
+ // a lookup table of fully-qualified path ancestries and their
+ // attendant tag objects, used later for finding and deleting child
+ // tags with missing parents or incorrect source counts, then
+ // finally adding the remaining tags to the global media source list.
+ Gee.TreeMap<string, Tag> ancestry_dictionary = new Gee.TreeMap<string, Tag>();
+
+ Gee.ArrayList<Tag> unlinked = new Gee.ArrayList<Tag>();
+ int count = rows.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ TagRow row = rows.get(ctr);
+
+ // make sure the tag name is valid
+ string? name = prep_tag_name(row.name);
+ if (name == null) {
+ // TODO: More graceful handling of this situation would be to rename the tag or
+ // alert the user.
+ warning("Invalid tag name \"%s\": removing from database", row.name);
+ try {
+ TagTable.get_instance().remove(row.tag_id);
+ } catch (DatabaseError err) {
+ warning("Unable to delete tag \"%s\": %s", row.name, err.message);
+ }
+
+ continue;
+ }
+
+ row.name = name;
+
+ Tag tag = new Tag(row);
+ if (monitor != null)
+ monitor(ctr, count);
+
+ ancestry_dictionary.set(tag.get_path(), tag);
+
+ if (tag.has_links()) {
+ tag.rehydrate_backlinks(global, null);
+ unlinked.add(tag);
+ }
+ }
+
+ Gee.Set<Tag> victim_set = new Gee.HashSet<Tag>();
+
+ // look through the dictionary for pathological pairs of tags like so:
+ // 'Tag Name' and '/Tag Name'; if we see these, merge the media sources
+ // from '/Tag Name' into 'Tag Name' and delete the hierarchical version.
+ foreach (string fq_tag_path in ancestry_dictionary.keys) {
+ if (HierarchicalTagUtilities.enumerate_parent_paths(fq_tag_path).size < 1) {
+ if ((fq_tag_path.has_prefix(Tag.PATH_SEPARATOR_STRING)) &&
+ (ancestry_dictionary.has_key(HierarchicalTagUtilities.hierarchical_to_flat(fq_tag_path)))) {
+ victim_set.add(ancestry_dictionary.get(fq_tag_path));
+ }
+ }
+ }
+
+ foreach (Tag tag in victim_set) {
+ Gee.Collection<MediaSource> source_collection = tag.get_sources();
+ string flat_version = tag.get_user_visible_name();
+ global.fetch_by_name(flat_version).attach_many(source_collection);
+
+ ancestry_dictionary.unset(tag.get_path());
+
+ tag.detach_many(tag.get_sources());
+ tag.destroy_orphan(true);
+ }
+
+ // look through the dictionary for children with invalid source
+ // counts and/or missing parents and reap them. we'll also flatten
+ // any top-level parents who have 0 children remaining after the reap.
+ victim_set.clear();
+
+ foreach (string fq_tag_path in ancestry_dictionary.keys) {
+ Gee.List<string> parents_to_search =
+ HierarchicalTagUtilities.enumerate_parent_paths(fq_tag_path);
+
+ Tag curr_child = ancestry_dictionary.get(fq_tag_path);
+
+ foreach (string parent_path in parents_to_search) {
+ // if this tag has more sources than its parent, then we're
+ // in an inconsistent state and need to remove this tag.
+ int child_ref_count = curr_child.get_sources_count();
+ int parent_ref_count = -1;
+
+ // does this parent even exist?
+ if (ancestry_dictionary.has_key(parent_path)) {
+ // yes, get its source count.
+ parent_ref_count = ancestry_dictionary.get(parent_path).get_sources_count();
+ }
+
+ // do we have more sources than our parent?
+ if (child_ref_count > parent_ref_count) {
+ // yes, ask to be reaped later. we can't kill ourselves
+ // now because it would interfere with the dictionary's
+ // iterator.
+ victim_set.add(curr_child);
+
+ // if we already know we're going to be reaped,
+ // don't search anymore.
+ break;
+ }
+
+ // is our parent being reaped?
+ if (victim_set.contains(ancestry_dictionary.get(parent_path))) {
+ // yes, we have to be reaped too.
+ victim_set.add(curr_child);
+ break;
+ }
+ }
+ }
+
+ // actually reap invalid children.
+ foreach (Tag t in victim_set) {
+ ancestry_dictionary.unset(t.get_path());
+ t.destroy_orphan(true);
+ }
+
+ // add remaining tags all at once to the SourceCollection
+ global.add_many(ancestry_dictionary.values);
+ global.init_add_many_unlinked(unlinked);
+
+ // flatten root tags who have zero children; this will catch
+ // both parents whose children were reaped and corrupted parents.
+ foreach (Tag t in ancestry_dictionary.values) {
+ // do we have no parent and no children?
+ if ((t.get_hierarchical_children().size < 1) && (t.get_hierarchical_parent() == null)) {
+ //yes, flatten.
+ t.flatten();
+ }
+ }
+ }
+
+ public static void terminate() {
+ }
+
+ public static int compare_names(Tag a, Tag b) {
+ return String.precollated_compare(a.get_name(), a.get_name_collation_key(), b.get_name(),
+ b.get_name_collation_key());
+ }
+
+ public static uint hash_name_string(string a) {
+ return String.collated_hash(a);
+ }
+
+ public static bool equal_name_strings(string a, string b) {
+ return String.collated_equals(a, b);
+ }
+
+ // Returns a Tag for the path, creating a new empty one if it does not already exist.
+ // 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)
+ tag = global.restore_tag_from_holding_tank(name);
+
+ if (tag != null)
+ return tag;
+
+ // create a new Tag for this name
+ try {
+ tag = new Tag(TagTable.get_instance().add(name));
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ global.add(tag);
+
+ return tag;
+ }
+
+ public static Gee.Collection<Tag> get_terminal_tags(Gee.Collection<Tag> tags) {
+ Gee.Set<string> result_paths = new Gee.HashSet<string>();
+
+ foreach (Tag tag in tags) {
+ // if it's not hierarchical, it's terminal
+ if (!tag.get_path().has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ result_paths.add(tag.get_path());
+ continue;
+ }
+
+ // okay, it is hierarchical
+
+ // has it got a parent?
+ if (tag.get_hierarchical_parent() != null) {
+ // have we seen its parent? if so, remove its parent from the result set since
+ // its parent clearly isn't terminal
+ if (result_paths.contains(tag.get_hierarchical_parent().get_path()))
+ result_paths.remove(tag.get_hierarchical_parent().get_path());
+ }
+
+ result_paths.add(tag.get_path());
+ }
+
+ Gee.ArrayList<Tag> result = new Gee.ArrayList<Tag>();
+ foreach (string path in result_paths) {
+ if (Tag.global.exists(path)) {
+ result.add(Tag.for_path(path));
+ } else {
+ foreach (Tag probed_tag in tags) {
+ if (probed_tag.get_path() == path)
+ result.add(probed_tag);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ public static string make_tag_string(Gee.Collection<Tag> tags, string? start = null,
+ string separator = ", ", string? end = null, bool escape = false) {
+ StringBuilder builder = new StringBuilder(start ?? "");
+ Gee.HashSet<string> seen_tags = new Gee.HashSet<string>();
+ Gee.Collection<Tag> terminal_tags = get_terminal_tags(tags);
+ Gee.ArrayList<string> sorted_tags = new Gee.ArrayList<string>();
+ foreach (Tag tag in terminal_tags) {
+ string user_visible_name = escape ? guarded_markup_escape_text(
+ tag.get_user_visible_name()) : tag.get_user_visible_name();
+
+ if (!seen_tags.contains(user_visible_name))
+ sorted_tags.add(user_visible_name);
+ }
+
+ sorted_tags.sort();
+ Gee.Iterator<string> iter = sorted_tags.iterator();
+ while(iter.next()) {
+ builder.append(iter.get());
+ builder.append(separator);
+ }
+
+ string built = builder.str;
+
+ if (built.length >= separator.length)
+ if (built.substring(built.length - separator.length, separator.length) == separator);
+ built = built.substring(0, built.length - separator.length);
+
+ if (end != null)
+ built += end;
+
+ return built;
+ }
+
+ // Utility function to cleanup a tag 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_tag_name(string name) {
+ return prepare_input_text(name, PrepareInputTextOptions.DEFAULT, DEFAULT_USER_TEXT_INPUT_LENGTH);
+ }
+
+ // Akin to prep_tag_name. Returned array may be smaller than the in parameter (or empty!) if
+ // names are discovered that cannot be used.
+ public static string[] prep_tag_names(string[] names) {
+ string[] result = new string[0];
+
+ for (int ctr = 0; ctr < names.length; ctr++) {
+ string? new_name = prep_tag_name(names[ctr]);
+ if (new_name != null)
+ result += new_name;
+ }
+
+ return result;
+ }
+
+ private void set_raw_flat_name(string name) {
+ string? prepped_name = prep_tag_name(name);
+
+ assert(prepped_name != null);
+ assert(!prepped_name.has_prefix(Tag.PATH_SEPARATOR_STRING));
+
+ try {
+ TagTable.get_instance().rename(row.tag_id, prepped_name);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ return;
+ }
+
+ row.name = prepped_name;
+ name_collation_key = null;
+
+ update_indexable_keywords();
+
+ notify_altered(new Alteration.from_list("metadata:name, indexable:keywords"));
+ }
+
+ private void set_raw_path(string path, bool suppress_notify = false) {
+ string? prepped_path = prep_tag_name(path);
+
+ assert(prepped_path != null);
+ assert(prepped_path.has_prefix(Tag.PATH_SEPARATOR_STRING));
+ assert(!Tag.global.exists(prepped_path));
+
+ try {
+ TagTable.get_instance().rename(row.tag_id, prepped_path);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ return;
+ }
+
+ row.name = prepped_path;
+ name_collation_key = null;
+
+ if (!suppress_notify) {
+ update_indexable_keywords();
+ notify_altered(new Alteration.from_list("metadata:name, indexable:keywords"));
+ }
+ }
+
+ public override string get_typename() {
+ return TYPENAME;
+ }
+
+ public override int64 get_instance_id() {
+ return get_tag_id().id;
+ }
+
+ public override string get_name() {
+ return row.name;
+ }
+
+ public string get_path() {
+ return get_name();
+ }
+
+ public string get_user_visible_name() {
+ return HierarchicalTagUtilities.get_basename(get_path());
+ }
+
+ public string get_searchable_name() {
+ string istring = HierarchicalTagUtilities.get_basename(get_path()).down();
+ return String.remove_diacritics(istring);
+ }
+
+ public void flatten() {
+ assert (get_hierarchical_parent() == null);
+
+ set_raw_flat_name(get_user_visible_name());
+ }
+
+ public void promote() {
+ if (get_path().has_prefix(Tag.PATH_SEPARATOR_STRING))
+ return;
+
+ set_raw_path(Tag.PATH_SEPARATOR_STRING + get_path());
+ }
+
+ public Tag? get_hierarchical_parent() {
+ // if this is a flat tag, it has no parent
+ if (!get_path().has_prefix(Tag.PATH_SEPARATOR_STRING))
+ return null;
+
+ Gee.List<string> components =
+ HierarchicalTagUtilities.enumerate_path_components(get_path());
+
+ assert(components.size > 0);
+
+ if (components.size == 1) {
+ return null;
+ }
+
+ string parent_path = "";
+ for (int i = 0; i < (components.size - 1); i++)
+ parent_path += (Tag.PATH_SEPARATOR_STRING + components.get(i));
+
+ if (Tag.global.exists(parent_path))
+ return Tag.for_path(parent_path);
+ else
+ return null;
+ }
+
+ public int get_attachment_count(MediaSource source) {
+ // if we don't contain the source, the attachment count is zero
+ if (!contains(source))
+ return 0;
+
+ // we ourselves contain the source, so that's one attachment
+ int result = 1;
+
+ // check to see if our children contain the source
+ foreach (Tag child in get_hierarchical_children())
+ if (child.contains(source))
+ result++;
+
+ return result;
+ }
+
+ /**
+ * gets all hierarchical children of a tag recursively; tags are enumerated from most-derived
+ * to least-derived
+ */
+ public Gee.List<Tag> get_hierarchical_children() {
+ Gee.ArrayList<Tag> result = new Gee.ArrayList<Tag>();
+ Gee.ArrayList<Tag> result_reversed = new Gee.ArrayList<Tag>();
+
+ // if it's a flag tag, it doesn't have children
+ if (!get_path().has_prefix(Tag.PATH_SEPARATOR_STRING))
+ return result;
+
+ // default lexicographic comparison for strings ensures hierarchical tag paths will be
+ // sorted from least-derived to most-derived
+ Gee.TreeSet<string> forward_sorted_paths = new Gee.TreeSet<string>();
+
+ string target_path = get_path() + Tag.PATH_SEPARATOR_STRING;
+ foreach (string path in Tag.global.get_all_names()) {
+ if (path.has_prefix(target_path))
+ forward_sorted_paths.add(path);
+ }
+
+ foreach (string tmp in forward_sorted_paths) {
+ result_reversed.add(Tag.for_path(tmp));
+ }
+
+ for (int index = result_reversed.size - 1; index >= 0; index--) {
+ result.add(result_reversed[index]);
+ }
+
+ return result;
+ }
+
+ // Gets the next "untitled" tag name available.
+ // Note: Not thread-safe.
+ private static string get_next_untitled_tag_name(string? _prefix = null) {
+ string prefix = _prefix != null ? _prefix : "";
+ string candidate_name = _("untitled");
+ uint64 counter = 0;
+ do {
+ string path_candidate = prefix + candidate_name +
+ ((counter == 0) ? "" : (" " + counter.to_string()));
+
+ if (!Tag.global.exists(path_candidate))
+ return path_candidate;
+
+ counter++;
+ } while (counter < uint64.MAX);
+
+ // If we get here, it means all untitled tags up to uint64.MAX were used.
+ assert_not_reached();
+ }
+
+ public Tag create_new_child() {
+ string path_prefix = get_path();
+
+ if (!path_prefix.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ set_raw_path(HierarchicalTagUtilities.flat_to_hierarchical(get_path()));
+
+ path_prefix = get_path();
+ }
+
+ return Tag.for_path(get_next_untitled_tag_name(path_prefix + Tag.PATH_SEPARATOR_STRING));
+ }
+
+ public static Tag create_new_root() {
+ return Tag.for_path(get_next_untitled_tag_name());
+ }
+
+ public string get_name_collation_key() {
+ if (name_collation_key == null)
+ name_collation_key = row.name.collate_key();
+
+ return name_collation_key;
+ }
+
+ public override string to_string() {
+ return "Tag %s (%d sources)".printf(row.name, media_views.get_count());
+ }
+
+ public override bool equals(DataSource? source) {
+ // Validate uniqueness of primary key
+ Tag? tag = source as Tag;
+ if (tag != null) {
+ if (tag != this) {
+ assert(tag.row.tag_id.id != row.tag_id.id);
+ }
+ }
+
+ return base.equals(source);
+ }
+
+ public TagID get_tag_id() {
+ return row.tag_id;
+ }
+
+ public override SourceSnapshot? save_snapshot() {
+ return new TagSnapshot(this);
+ }
+
+ public SourceProxy get_proxy() {
+ return new TagProxy(this);
+ }
+
+ public static Tag reconstitute(int64 object_id, TagRow row) {
+ // fill in the row with the new TagID for this reconstituted tag
+ try {
+ row.tag_id = TagTable.get_instance().create_from_row(row);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ Tag tag = new Tag(row, object_id);
+ global.add(tag);
+
+ debug("Reconstituted %s", tag.to_string());
+
+ return tag;
+ }
+
+ public bool has_links() {
+ return LibraryPhoto.global.has_backlink(get_backlink());
+ }
+
+ public SourceBacklink get_backlink() {
+ return new SourceBacklink.from_source(this);
+ }
+
+ public void break_link(DataSource source) {
+ unlinking = true;
+
+ detach((LibraryPhoto) source);
+
+ unlinking = false;
+ }
+
+ public void break_link_many(Gee.Collection<DataSource> sources) {
+ unlinking = true;
+
+ detach_many((Gee.Collection<LibraryPhoto>) sources);
+
+ unlinking = false;
+ }
+
+ public void establish_link(DataSource source) {
+ relinking = true;
+
+ attach((LibraryPhoto) source);
+
+ relinking = false;
+ }
+
+ public void establish_link_many(Gee.Collection<DataSource> sources) {
+ relinking = true;
+
+ attach_many((Gee.Collection<LibraryPhoto>) sources);
+
+ relinking = false;
+ }
+
+ private void update_indexable_keywords() {
+ indexable_keywords = prepare_indexable_string(get_searchable_name());
+ }
+
+ public unowned string? get_indexable_keywords() {
+ return indexable_keywords;
+ }
+
+ public void attach(MediaSource source) {
+ Tag? attach_to = this;
+ while (attach_to != null) {
+ if (!attach_to.media_views.has_view_for_source(source)) {
+ attach_to.media_views.add(new ThumbnailView(source));
+ }
+
+ attach_to = attach_to.get_hierarchical_parent();
+ }
+ }
+
+ public void attach_many(Gee.Collection<MediaSource> sources) {
+ Tag? attach_to = this;
+ while (attach_to != null) {
+ Gee.ArrayList<ThumbnailView> view_list = new Gee.ArrayList<ThumbnailView>();
+ foreach (MediaSource source in sources) {
+ if (!attach_to.media_views.has_view_for_source(source))
+ view_list.add(new ThumbnailView(source));
+ }
+
+ if (view_list.size > 0)
+ attach_to.media_views.add_many(view_list);
+
+ attach_to = attach_to.get_hierarchical_parent();
+ }
+ }
+
+ // Returns a list of Tags the MediaSource was detached from as a result of detaching it from
+ // this Tag. (This Tag will always be in the list unless null is returned, indicating the
+ // MediaSource isn't present at all.)
+ public Gee.List<Tag>? detach(MediaSource source) {
+ DataView? this_view = media_views.get_view_for_source(source);
+ if (this_view == null)
+ return null;
+
+ Gee.List<Tag>? detached_from = new Gee.ArrayList<Tag>();
+
+ foreach (Tag child_tag in get_hierarchical_children()) {
+ DataView? child_view = child_tag.media_views.get_view_for_source(source);
+ if (child_view != null) {
+ child_tag.media_views.remove_marked(child_tag.media_views.mark(child_view));
+ detached_from.add(child_tag);
+ }
+ }
+
+ media_views.remove_marked(media_views.mark(this_view));
+ detached_from.add(this);
+
+ return detached_from;
+ }
+
+ // Returns a map of Tags the MediaSource was detached from as a result of detaching it from
+ // this Tag. (This Tag will always be in the list unless null is returned, indicating the
+ // MediaSource isn't present at all.)
+ public Gee.MultiMap<Tag, MediaSource>? detach_many(Gee.Collection<MediaSource> sources) {
+ Gee.MultiMap<Tag, MediaSource>? detached_from = new Gee.HashMultiMap<Tag, MediaSource>();
+
+ Marker marker = media_views.start_marking();
+ foreach (MediaSource source in sources) {
+ DataView? view = media_views.get_view_for_source(source);
+ if (view == null)
+ continue;
+
+ foreach (Tag child_tag in get_hierarchical_children()) {
+ DataView? child_view = child_tag.media_views.get_view_for_source(source);
+ if (child_view != null) {
+ child_tag.media_views.remove_marked(child_tag.media_views.mark(child_view));
+ detached_from.set(child_tag, source);
+ }
+ }
+
+ marker.mark(view);
+ detached_from.set(this, source);
+ }
+
+ media_views.remove_marked(marker);
+
+ return (detached_from.size > 0) ? detached_from : null;
+ }
+
+ // Returns false if the name already exists or a bad name.
+ public bool rename(string name) {
+ if (name == get_user_visible_name())
+ return true;
+
+ string? new_name = prep_tag_name(name);
+ if (new_name == null)
+ return false;
+
+ // if this is a hierarchical tag, then parents and children come into play
+ if (get_path().has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ string new_path = new_name;
+ string old_path = get_path();
+
+ Tag? parent = get_hierarchical_parent();
+ if (parent != null) {
+ new_path = parent.get_path() + PATH_SEPARATOR_STRING + new_path;
+ } else {
+ new_path = Tag.PATH_SEPARATOR_STRING + new_path;
+ }
+
+ if (Tag.global.exists(new_path, true))
+ return false;
+
+ Gee.Collection<Tag> children = get_hierarchical_children();
+
+ set_raw_path(new_path, true);
+
+ foreach (Tag child in children) {
+ // keep these loop-local temporaries around -- it's useful to be able to print them
+ // out when debugging
+ string old_child_path = child.get_path();
+
+ // find the first instance of old_path in the child path -- we want to replace
+ // the first and only the first instance
+ int old_path_index = old_child_path.index_of(old_path);
+ assert(old_path_index != -1);
+
+ string child_subpath = old_child_path.substring(old_path_index + old_path.length);
+
+ string new_child_path = new_path + child_subpath;
+
+ child.set_raw_path(new_child_path, true);
+ }
+
+ update_indexable_keywords();
+ notify_altered(new Alteration.from_list("metadata:name, indexable:keywords"));
+ foreach (Tag child in children) {
+ child.notify_altered(new Alteration.from_list("metadata:name, indexable:keywords"));
+ }
+ } else {
+ // if this is a flat tag, no problem -- just keep doing what we've always done
+ if (Tag.global.exists(new_name, true))
+ return false;
+
+ set_raw_flat_name(new_name);
+ }
+
+ return true;
+ }
+
+ public bool contains(MediaSource source) {
+ return media_views.has_view_for_source(source);
+ }
+
+ public int get_sources_count() {
+ return media_views.get_count();
+ }
+
+ public Gee.Collection<MediaSource> get_sources() {
+ return (Gee.Collection<MediaSource>) media_views.get_sources();
+ }
+
+ public void mirror_sources(ViewCollection view, CreateView mirroring_ctor) {
+ view.mirror(media_views, mirroring_ctor, null);
+ }
+
+ private void on_media_views_contents_altered(Gee.Iterable<DataView>? added,
+ Gee.Iterable<DataView>? removed) {
+ Gee.Collection<MediaSource> added_sources = null;
+ if (added != null) {
+ added_sources = new Gee.ArrayList<MediaSource>();
+ foreach (DataView view in added) {
+ MediaSource source = (MediaSource) view.get_source();
+
+ // possible a source is added twice if the same tag is in source ... add()
+ // returns true only if the set has altered
+ if (!row.source_id_list.contains(source.get_source_id())) {
+ bool is_added = row.source_id_list.add(source.get_source_id());
+ assert(is_added);
+ }
+
+ bool is_added = added_sources.add(source);
+ assert(is_added);
+ }
+ }
+
+ Gee.Collection<MediaSource> removed_sources = null;
+ if (removed != null) {
+ removed_sources = new Gee.ArrayList<MediaSource>();
+ foreach (DataView view in removed) {
+ MediaSource source = (MediaSource) view.get_source();
+
+ bool is_removed = row.source_id_list.remove(source.get_source_id());
+ assert(is_removed);
+
+ bool is_added = removed_sources.add(source);
+ assert(is_added);
+ }
+ }
+
+ try {
+ TagTable.get_instance().set_tagged_sources(row.tag_id, row.source_id_list);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ // notify of changes to this tag
+ if (added_sources != null)
+ global.notify_container_contents_added(this, added_sources, relinking);
+
+ if (removed_sources != null)
+ global.notify_container_contents_removed(this, removed_sources, unlinking);
+
+ if (added_sources != null || removed_sources != null) {
+ global.notify_container_contents_altered(this, added_sources, relinking, removed_sources,
+ unlinking);
+ }
+ }
+
+ private void on_sources_destroyed(Gee.Collection<DataSource> sources) {
+ detach_many((Gee.Collection<MediaSource>) sources);
+ }
+
+ public override void destroy() {
+ // detach all remaining sources from the tag, so observers are informed ... need to detach
+ // the contents_altered handler because it will destroy this object when sources is empty,
+ // which is bad reentrancy mojo (but hook it back up for the dtor's sake)
+ if (media_views.get_count() > 0) {
+ media_views.contents_altered.disconnect(on_media_views_contents_altered);
+
+ Gee.ArrayList<MediaSource> removed = new Gee.ArrayList<MediaSource>();
+ removed.add_all((Gee.Collection<MediaSource>) media_views.get_sources());
+
+ media_views.clear();
+
+ global.notify_container_contents_removed(this, removed, false);
+ global.notify_container_contents_altered(this, null, false, removed, false);
+
+ media_views.contents_altered.connect(on_media_views_contents_altered);
+ }
+
+ try {
+ TagTable.get_instance().remove(row.tag_id);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ base.destroy();
+ }
+}
+
diff --git a/src/Thumbnail.vala b/src/Thumbnail.vala
new file mode 100644
index 0000000..c33d43b
--- /dev/null
+++ b/src/Thumbnail.vala
@@ -0,0 +1,400 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class Thumbnail : MediaSourceItem {
+ // Collection properties Thumbnail responds to
+ // SHOW_TAGS (bool)
+ public const string PROP_SHOW_TAGS = CheckerboardItem.PROP_SHOW_SUBTITLES;
+ // SIZE (int, scale)
+ public const string PROP_SIZE = "thumbnail-size";
+ // SHOW_RATINGS (bool)
+ public const string PROP_SHOW_RATINGS = "show-ratings";
+
+ public static int MIN_SCALE {
+ get {
+ return 72;
+ }
+ }
+ public static int MAX_SCALE {
+ get {
+ return ThumbnailCache.Size.LARGEST.get_scale();
+ }
+ }
+ public static int DEFAULT_SCALE {
+ get {
+ return ThumbnailCache.Size.MEDIUM.get_scale();
+ }
+ }
+
+ public const Gdk.InterpType LOW_QUALITY_INTERP = Gdk.InterpType.NEAREST;
+ public const Gdk.InterpType HIGH_QUALITY_INTERP = Gdk.InterpType.BILINEAR;
+
+ private const int HQ_IMPROVEMENT_MSEC = 100;
+
+ private MediaSource media;
+ private int scale;
+ private Dimensions original_dim;
+ private Dimensions dim;
+ private Gdk.Pixbuf unscaled_pixbuf = null;
+ private Cancellable cancellable = null;
+ private bool hq_scheduled = false;
+ private bool hq_reschedule = false;
+ // this is cached locally because there are situations where the constant calls to is_exposed()
+ // was showing up in sysprof
+ private bool exposure = false;
+
+ public Thumbnail(MediaSource media, int scale = DEFAULT_SCALE) {
+ base (media, media.get_dimensions().get_scaled(scale, true), media.get_name(),
+ media.get_comment());
+
+ this.media = media;
+ this.scale = scale;
+
+ Tag.global.container_contents_altered.connect(on_tag_contents_altered);
+ Tag.global.items_altered.connect(on_tags_altered);
+
+ assert((media is LibraryPhoto) || (media is Video));
+ set_enable_sprockets(media is Video);
+
+ original_dim = media.get_dimensions();
+ dim = original_dim.get_scaled(scale, true);
+
+ // initialize title and tags text line so they're properly accounted for when the display
+ // size is calculated
+ update_title(true);
+ update_comment(true);
+ update_tags(true);
+ }
+
+ ~Thumbnail() {
+ if (cancellable != null)
+ cancellable.cancel();
+
+ Tag.global.container_contents_altered.disconnect(on_tag_contents_altered);
+ Tag.global.items_altered.disconnect(on_tags_altered);
+ }
+
+ private void update_tags(bool init = false) {
+ Gee.Collection<Tag>? tags = Tag.global.fetch_sorted_for_source(media);
+ if (tags == null || tags.size == 0)
+ clear_subtitle();
+ else if (!init)
+ set_subtitle(Tag.make_tag_string(tags, "<small>", ", ", "</small>", true), true);
+ else
+ set_subtitle("<small>.</small>", true);
+ }
+
+ private void on_tag_contents_altered(ContainerSource container, Gee.Collection<DataSource>? added,
+ bool relinking, Gee.Collection<DataSource>? removed, bool unlinking) {
+ if (!exposure)
+ return;
+
+ bool tag_added = (added != null) ? added.contains(media) : false;
+ bool tag_removed = (removed != null) ? removed.contains(media) : false;
+
+ // if media source we're monitoring is added or removed to any tag, update tag list
+ if (tag_added || tag_removed)
+ update_tags();
+ }
+
+ private void on_tags_altered(Gee.Map<DataObject, Alteration> altered) {
+ if (!exposure)
+ return;
+
+ foreach (DataObject object in altered.keys) {
+ Tag tag = (Tag) object;
+
+ if (tag.contains(media)) {
+ update_tags();
+
+ break;
+ }
+ }
+ }
+
+ private void update_title(bool init = false) {
+ string title = media.get_name();
+ if (is_string_empty(title))
+ clear_title();
+ else if (!init)
+ set_title(title);
+ else
+ set_title("");
+ }
+
+ private void update_comment(bool init = false) {
+ string comment = media.get_comment();
+ if (is_string_empty(comment))
+ clear_comment();
+ else if (!init)
+ set_comment(comment);
+ else
+ set_comment("");
+ }
+
+ protected override void notify_altered(Alteration alteration) {
+ if (exposure && alteration.has_detail("metadata", "name"))
+ update_title();
+ if (exposure && alteration.has_detail("metadata", "comment"))
+ update_comment();
+
+ base.notify_altered(alteration);
+ }
+
+ public MediaSource get_media_source() {
+ return media;
+ }
+
+ //
+ // Comparators
+ //
+
+ public static int64 photo_id_ascending_comparator(void *a, void *b) {
+ return ((Thumbnail *) a)->media.get_instance_id() - ((Thumbnail *) b)->media.get_instance_id();
+ }
+
+ public static int64 photo_id_descending_comparator(void *a, void *b) {
+ return photo_id_ascending_comparator(b, a);
+ }
+
+ public static int64 title_ascending_comparator(void *a, void *b) {
+ int64 result = strcmp(((Thumbnail *) a)->media.get_name(), ((Thumbnail *) b)->media.get_name());
+
+ return (result != 0) ? result : photo_id_ascending_comparator(a, b);
+ }
+
+ public static int64 title_descending_comparator(void *a, void *b) {
+ int64 result = title_ascending_comparator(b, a);
+
+ return (result != 0) ? result : photo_id_descending_comparator(a, b);
+ }
+
+ public static bool title_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "title");
+ }
+
+ 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);
+
+ return (result != 0) ? result : filename_ascending_comparator(a, b);
+ }
+
+ public static int64 exposure_time_desending_comparator(void *a, void *b) {
+ int64 result = exposure_time_ascending_comparator(b, a);
+
+ return (result != 0) ? result : filename_descending_comparator(a, b);
+ }
+
+ public static bool exposure_time_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "exposure-time");
+ }
+
+ public static int64 filename_ascending_comparator(void *a, void *b) {
+ string path_a = ((Thumbnail *) a)->media.get_file().get_basename().down();
+ string path_b = ((Thumbnail *) b)->media.get_file().get_basename().down();
+
+ int64 result = strcmp(g_utf8_collate_key_for_filename(path_a),
+ g_utf8_collate_key_for_filename(path_b));
+ return (result != 0) ? result : photo_id_ascending_comparator(a, b);
+ }
+
+ public static int64 filename_descending_comparator(void *a, void *b) {
+ int64 result = filename_ascending_comparator(b, a);
+
+ return (result != 0) ? result : photo_id_descending_comparator(a, b);
+ }
+
+ public static int64 rating_ascending_comparator(void *a, void *b) {
+ int64 result = ((Thumbnail *) a)->media.get_rating() - ((Thumbnail *) b)->media.get_rating();
+
+ return (result != 0) ? result : photo_id_ascending_comparator(a, b);
+ }
+
+ public static int64 rating_descending_comparator(void *a, void *b) {
+ int64 result = rating_ascending_comparator(b, a);
+
+ return (result != 0) ? result : photo_id_descending_comparator(a, b);
+ }
+
+ public static bool rating_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "rating");
+ }
+
+ protected override void thumbnail_altered() {
+ original_dim = media.get_dimensions();
+ dim = original_dim.get_scaled(scale, true);
+
+ if (exposure)
+ delayed_high_quality_fetch();
+ else
+ paint_empty();
+
+ base.thumbnail_altered();
+ }
+
+ protected override void notify_collection_property_set(string name, Value? old, Value val) {
+ switch (name) {
+ case PROP_SIZE:
+ resize((int) val);
+ break;
+
+ case PROP_SHOW_RATINGS:
+ notify_view_altered();
+ break;
+ }
+
+ base.notify_collection_property_set(name, old, val);
+ }
+
+ private void resize(int new_scale) {
+ assert(new_scale >= MIN_SCALE);
+ assert(new_scale <= MAX_SCALE);
+
+ if (scale == new_scale)
+ return;
+
+ scale = new_scale;
+ dim = original_dim.get_scaled(scale, true);
+
+ cancel_async_fetch();
+
+ if (exposure) {
+ // attempt to use an unscaled pixbuf (which is always larger or equal to the current
+ // size, and will most likely be larger than the new size -- and if not, a new one will
+ // be on its way), then use the current pixbuf if available (which may have to be
+ // scaled up, which is ugly)
+ Gdk.Pixbuf? resizable = null;
+ if (unscaled_pixbuf != null)
+ resizable = unscaled_pixbuf;
+ else if (has_image())
+ resizable = get_image();
+
+ if (resizable != null)
+ set_image(resize_pixbuf(resizable, dim, LOW_QUALITY_INTERP));
+
+ delayed_high_quality_fetch();
+ } else {
+ clear_image(dim);
+ }
+ }
+
+ private void paint_empty() {
+ cancel_async_fetch();
+ clear_image(dim);
+ unscaled_pixbuf = null;
+ }
+
+ private void schedule_low_quality_fetch() {
+ cancel_async_fetch();
+ cancellable = new Cancellable();
+
+ ThumbnailCache.fetch_async_scaled(media, ThumbnailCache.Size.SMALLEST,
+ dim, LOW_QUALITY_INTERP, on_low_quality_fetched, cancellable);
+ }
+
+ private void delayed_high_quality_fetch() {
+ if (hq_scheduled) {
+ hq_reschedule = true;
+
+ return;
+ }
+
+ Timeout.add_full(Priority.DEFAULT, HQ_IMPROVEMENT_MSEC, on_schedule_high_quality);
+ hq_scheduled = true;
+ }
+
+ private bool on_schedule_high_quality() {
+ if (hq_reschedule) {
+ hq_reschedule = false;
+
+ return true;
+ }
+
+ cancel_async_fetch();
+ cancellable = new Cancellable();
+
+ if (exposure) {
+ ThumbnailCache.fetch_async_scaled(media, scale, dim,
+ HIGH_QUALITY_INTERP, on_high_quality_fetched, cancellable);
+ }
+
+ hq_scheduled = false;
+
+ return false;
+ }
+
+ private void cancel_async_fetch() {
+ // cancel outstanding I/O
+ if (cancellable != null)
+ cancellable.cancel();
+ }
+
+ private void on_low_quality_fetched(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim,
+ Gdk.InterpType interp, Error? err) {
+ if (err != null)
+ critical("Unable to fetch low-quality thumbnail for %s (scale: %d): %s", to_string(), scale,
+ err.message);
+
+ if (pixbuf != null)
+ set_image(pixbuf);
+
+ if (unscaled != null)
+ unscaled_pixbuf = unscaled;
+
+ delayed_high_quality_fetch();
+ }
+
+ private void on_high_quality_fetched(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim,
+ Gdk.InterpType interp, Error? err) {
+ if (err != null)
+ critical("Unable to fetch high-quality thumbnail for %s (scale: %d): %s", to_string(), scale,
+ err.message);
+
+ if (pixbuf != null)
+ set_image(pixbuf);
+
+ if (unscaled != null)
+ unscaled_pixbuf = unscaled;
+ }
+
+ public override void exposed() {
+ exposure = true;
+
+ if (!has_image())
+ schedule_low_quality_fetch();
+
+ update_title();
+ update_comment();
+ update_tags();
+
+ base.exposed();
+ }
+
+ public override void unexposed() {
+ exposure = false;
+
+ paint_empty();
+
+ base.unexposed();
+ }
+
+ protected override Gdk.Pixbuf? get_top_right_trinket(int scale) {
+ Flaggable? flaggable = media as Flaggable;
+
+ return (flaggable != null && flaggable.is_flagged())
+ ? Resources.get_icon(Resources.ICON_FLAGGED_TRINKET) : null;
+ }
+
+ protected override Gdk.Pixbuf? get_bottom_left_trinket(int scale) {
+ Rating rating = media.get_rating();
+ bool show_ratings = (bool) get_collection_property(PROP_SHOW_RATINGS, false);
+
+ return (rating != Rating.UNRATED && show_ratings)
+ ? Resources.get_rating_trinket(rating, scale) : null;
+ }
+}
diff --git a/src/ThumbnailCache.vala b/src/ThumbnailCache.vala
new file mode 100644
index 0000000..c24087c
--- /dev/null
+++ b/src/ThumbnailCache.vala
@@ -0,0 +1,619 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class Thumbnails {
+ private Gee.HashMap<ThumbnailCache.Size, Gdk.Pixbuf> map = new Gee.HashMap<ThumbnailCache.Size,
+ Gdk.Pixbuf>();
+
+ public Thumbnails() {
+ }
+
+ public void set(ThumbnailCache.Size size, Gdk.Pixbuf pixbuf) {
+ map.set(size, pixbuf);
+ }
+
+ public void remove(ThumbnailCache.Size size) {
+ map.unset(size);
+ }
+
+ public Gdk.Pixbuf? get(ThumbnailCache.Size size) {
+ return map.get(size);
+ }
+}
+
+public class ThumbnailCache : Object {
+ public const Gdk.InterpType DEFAULT_INTERP = Gdk.InterpType.HYPER;
+ public const Jpeg.Quality DEFAULT_QUALITY = Jpeg.Quality.HIGH;
+ public const int MAX_INMEMORY_DATA_SIZE = 512 * 1024;
+
+ // Some code relies on Size's pixel values being manipulated and then using Size's methods,
+ // so be careful before changing any of these values (and especially careful before arbitrarily
+ // manipulating a Size enum)
+ public enum Size {
+ LARGEST = 360,
+ BIG = 360,
+ MEDIUM = 128,
+ SMALLEST = 128;
+
+ public int get_scale() {
+ return (int) this;
+ }
+
+ public Scaling get_scaling() {
+ return Scaling.for_best_fit(get_scale(), true);
+ }
+
+ public static Size get_best_size(int scale) {
+ return scale <= MEDIUM.get_scale() ? MEDIUM : BIG;
+ }
+ }
+
+ private static Size[] ALL_SIZES = { Size.BIG, Size.MEDIUM };
+
+ public delegate void AsyncFetchCallback(Gdk.Pixbuf? pixbuf, Gdk.Pixbuf? unscaled, Dimensions dim,
+ Gdk.InterpType interp, Error? err);
+
+ private class ImageData {
+ public Gdk.Pixbuf pixbuf;
+ public ulong bytes;
+
+ public ImageData(Gdk.Pixbuf pixbuf) {
+ this.pixbuf = pixbuf;
+
+ // This is not entirely accurate (see Gtk doc note on pixbuf Image Data), but close enough
+ // for government work
+ bytes = (ulong) pixbuf.get_rowstride() * (ulong) pixbuf.get_height();
+ }
+
+ ~ImageData() {
+ cycle_dropped_bytes += bytes;
+ schedule_debug();
+ }
+ }
+
+ private class AsyncFetchJob : BackgroundJob {
+ public ThumbnailCache cache;
+ public string thumbnail_name;
+ public ThumbnailSource source;
+ public PhotoFileFormat source_format;
+ public Dimensions dim;
+ public Gdk.InterpType interp;
+ public unowned AsyncFetchCallback callback;
+ public Gdk.Pixbuf unscaled;
+ public Gdk.Pixbuf scaled = null;
+ public Error err = null;
+ public bool fetched = false;
+
+ public AsyncFetchJob(ThumbnailCache cache, string thumbnail_name,
+ ThumbnailSource source, Gdk.Pixbuf? prefetched, Dimensions dim,
+ Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable) {
+ base(cache, async_fetch_completion_callback, cancellable);
+
+ this.cache = cache;
+ this.thumbnail_name = thumbnail_name;
+ this.source = source;
+ this.source_format = source.get_preferred_thumbnail_format();
+ this.unscaled = prefetched;
+ this.dim = dim;
+ this.interp = interp;
+ this.callback = callback;
+ }
+
+ public override BackgroundJob.JobPriority get_priority() {
+ // lower-quality interps are scheduled first; this is interpreted as a "quick" thumbnail
+ // fetch, versus higher-quality, which are to clean up the display
+ switch (interp) {
+ case Gdk.InterpType.NEAREST:
+ case Gdk.InterpType.TILES:
+ return JobPriority.HIGH;
+
+ case Gdk.InterpType.BILINEAR:
+ case Gdk.InterpType.HYPER:
+ default:
+ return JobPriority.NORMAL;
+ }
+ }
+
+ public override void execute() {
+ try {
+ // load-and-decode if not already prefetched
+ if (unscaled == null) {
+ unscaled = cache.read_pixbuf(thumbnail_name, source_format);
+ fetched = true;
+ }
+
+ if (is_cancelled())
+ return;
+
+ // scale if specified
+ scaled = dim.has_area() ? resize_pixbuf(unscaled, dim, interp) : unscaled;
+ } catch (Error err) {
+ // Is the problem that the thumbnail couldn't be read? If so, it's recoverable;
+ // we'll just create it and leave this.err as null if creation works.
+ if (err is FileError) {
+ try {
+ Photo photo = source as Photo;
+ Video video = source as Video;
+
+ if (photo != null) {
+ unscaled = photo.get_pixbuf(Scaling.for_best_fit(dim.width, true));
+ photo.notify_altered(new Alteration("image","thumbnail"));
+ return;
+ }
+
+ if (video != null) {
+ unscaled = video.create_thumbnail(dim.width);
+ scaled = resize_pixbuf(unscaled, dim, interp);
+ cache.save_thumbnail(cache.get_source_cached_file(source),
+ unscaled, source);
+ replace(source, cache.size, unscaled);
+ return;
+ }
+
+ } catch (Error e) {
+ // Creating the thumbnail failed; tell the rest of the app.
+ this.err = e;
+ return;
+ }
+ }
+
+ // ...the original error wasn't from reading the file, but something else;
+ // tell the rest of the app.
+ this.err = err;
+ }
+ }
+ }
+
+ private static Workers fetch_workers = null;
+
+ public const ulong MAX_BIG_CACHED_BYTES = 40 * 1024 * 1024;
+ public const ulong MAX_MEDIUM_CACHED_BYTES = 30 * 1024 * 1024;
+
+ private static ThumbnailCache big = null;
+ private static ThumbnailCache medium = null;
+
+ private static OneShotScheduler debug_scheduler = null;
+ private static int cycle_fetched_thumbnails = 0;
+ private static int cycle_async_fetched_thumbnails = 0;
+ private static int cycle_async_resized_thumbnails = 0;
+ private static int cycle_overflow_thumbnails = 0;
+ private static ulong cycle_dropped_bytes = 0;
+
+ private File cache_dir;
+ private Size size;
+ private ulong max_cached_bytes;
+ private Gdk.InterpType interp;
+ private Jpeg.Quality quality;
+ private Gee.HashMap<string, ImageData> cache_map = new Gee.HashMap<string, ImageData>();
+ private Gee.ArrayList<string> cache_lru = new Gee.ArrayList<string>();
+ private ulong cached_bytes = 0;
+
+ private ThumbnailCache(Size size, ulong max_cached_bytes, Gdk.InterpType interp = DEFAULT_INTERP,
+ Jpeg.Quality quality = DEFAULT_QUALITY) {
+ cache_dir = AppDirs.get_cache_subdir("thumbs", "thumbs%d".printf(size.get_scale()));
+ this.size = size;
+ this.max_cached_bytes = max_cached_bytes;
+ this.interp = interp;
+ this.quality = quality;
+ }
+
+ // Doing this because static construct {} not working nor new'ing in the above statement
+ public static void init() {
+ debug_scheduler = new OneShotScheduler("ThumbnailCache cycle reporter", report_cycle);
+ fetch_workers = new Workers(Workers.threads_per_cpu(1), true);
+
+ big = new ThumbnailCache(Size.BIG, MAX_BIG_CACHED_BYTES);
+ medium = new ThumbnailCache(Size.MEDIUM, MAX_MEDIUM_CACHED_BYTES);
+ }
+
+ public static void terminate() {
+ }
+
+ public static void import_from_source(ThumbnailSource source, bool force = false)
+ throws Error {
+ debug("import from source: %s", source.to_string());
+ big._import_from_source(source, force);
+ medium._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);
+ }
+
+ public static void duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) {
+ big._duplicate(src_source, dest_source);
+ medium._duplicate(src_source, dest_source);
+ }
+
+ public static void remove(ThumbnailSource source) {
+ big._remove(source);
+ medium._remove(source);
+ }
+
+ private static ThumbnailCache get_best_cache(int scale) {
+ Size size = Size.get_best_size(scale);
+ if (size == Size.BIG) {
+ return big;
+ } else {
+ assert(size == Size.MEDIUM);
+
+ return medium;
+ }
+ }
+
+ private static ThumbnailCache get_cache_for(Size size) {
+ switch (size) {
+ case Size.BIG:
+ return big;
+
+ case Size.MEDIUM:
+ return medium;
+
+ default:
+ error("Unknown thumbnail size %d", size.get_scale());
+ }
+ }
+
+ public static Gdk.Pixbuf fetch(ThumbnailSource source, int scale) throws Error {
+ return get_best_cache(scale)._fetch(source);
+ }
+
+ public static void fetch_async(ThumbnailSource source, int scale, AsyncFetchCallback callback,
+ Cancellable? cancellable = null) {
+ get_best_cache(scale)._fetch_async(source, source.get_preferred_thumbnail_format(),
+ Dimensions(), DEFAULT_INTERP, callback, cancellable);
+ }
+
+ public static void fetch_async_scaled(ThumbnailSource source, int scale, Dimensions dim,
+ Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable = null) {
+ get_best_cache(scale)._fetch_async(source,
+ source.get_preferred_thumbnail_format(), dim, interp, callback, cancellable);
+ }
+
+ public static void replace(ThumbnailSource source, Size size, Gdk.Pixbuf replacement)
+ throws Error {
+ get_cache_for(size)._replace(source, replacement);
+ }
+
+ public static bool exists(ThumbnailSource source) {
+ return big._exists(source) && medium._exists(source);
+ }
+
+ public static void rotate(ThumbnailSource source, Rotation rotation) throws Error {
+ foreach (Size size in ALL_SIZES) {
+ Gdk.Pixbuf thumbnail = fetch(source, size);
+ thumbnail = rotation.perform(thumbnail);
+ replace(source, size, thumbnail);
+ }
+ }
+
+ // This does not add the thumbnails to the ThumbnailCache, merely generates them for the
+ // supplied image file.
+ public static void generate_for_photo(Thumbnails thumbnails, PhotoFileReader reader,
+ Orientation orientation, Dimensions original_dim) throws Error {
+ // Taking advantage of Size's values matching their pixel size
+ Size max_size = Size.BIG * 2;
+ Dimensions dim = max_size.get_scaling().get_scaled_dimensions(original_dim);
+ Gdk.Pixbuf? largest_thumbnail = null;
+ try {
+ largest_thumbnail = reader.scaled_read(original_dim, dim);
+ } catch (Error err) {
+ // if the scaled read generated an error, catch it and try to do an unscaled read
+ // followed by a downsample. If the call to unscaled_read() below throws an error,
+ // just propagate it up to the caller
+ largest_thumbnail = reader.unscaled_read();
+ }
+ largest_thumbnail = orientation.rotate_pixbuf(largest_thumbnail);
+ Dimensions largest_thumb_dimensions = Dimensions.for_pixbuf(largest_thumbnail);
+
+ foreach (Size size in ALL_SIZES) {
+ dim = size.get_scaling().get_scaled_dimensions(largest_thumb_dimensions);
+ thumbnails.set(size, largest_thumbnail.scale_simple(dim.width, dim.height, Gdk.InterpType.HYPER));
+ }
+ }
+
+ public static void generate_for_video_frame(Thumbnails thumbnails, Gdk.Pixbuf preview_frame) {
+ foreach (Size size in ALL_SIZES) {
+ Scaling current_scaling = size.get_scaling();
+ Gdk.Pixbuf current_thumbnail = current_scaling.perform_on_pixbuf(preview_frame,
+ Gdk.InterpType.HYPER, true);
+ thumbnails.set(size, current_thumbnail);
+ }
+ }
+
+ // Displaying a debug message for each thumbnail loaded and dropped can cause a ton of messages
+ // and slow down scrolling operations ... this delays reporting them, and only then reporting
+ // them in one aggregate sum
+ private static void schedule_debug() {
+#if MONITOR_THUMBNAIL_CACHE
+ debug_scheduler.priority_after_timeout(Priority.LOW, 500, true);
+#endif
+ }
+
+ private static void report_cycle() {
+#if MONITOR_THUMBNAIL_CACHE
+ if (cycle_fetched_thumbnails > 0) {
+ debug("%d thumbnails fetched into memory", cycle_fetched_thumbnails);
+ cycle_fetched_thumbnails = 0;
+ }
+
+ if (cycle_async_fetched_thumbnails > 0) {
+ debug("%d thumbnails fetched async into memory", cycle_async_fetched_thumbnails);
+ cycle_async_fetched_thumbnails = 0;
+ }
+
+ if (cycle_async_resized_thumbnails > 0) {
+ debug("%d thumbnails resized async into memory", cycle_async_resized_thumbnails);
+ cycle_async_resized_thumbnails = 0;
+ }
+
+ if (cycle_overflow_thumbnails > 0) {
+ debug("%d thumbnails overflowed from memory cache", cycle_overflow_thumbnails);
+ cycle_overflow_thumbnails = 0;
+ }
+
+ if (cycle_dropped_bytes > 0) {
+ debug("%lu bytes freed", cycle_dropped_bytes);
+ cycle_dropped_bytes = 0;
+ }
+
+ foreach (Size size in ALL_SIZES) {
+ ThumbnailCache cache = get_cache_for(size);
+ ulong avg = (cache.cache_lru.size != 0) ? cache.cached_bytes / cache.cache_lru.size : 0;
+ debug("thumbnail cache %d: %d thumbnails, %lu/%lu bytes, %lu bytes/thumbnail",
+ cache.size.get_scale(), cache.cache_lru.size, cache.cached_bytes,
+ cache.max_cached_bytes, avg);
+ }
+#endif
+ }
+
+ private Gdk.Pixbuf _fetch(ThumbnailSource source) throws Error {
+ // use JPEG in memory cache if available
+ Gdk.Pixbuf pixbuf = fetch_from_memory(source.get_source_id());
+ if (pixbuf != null)
+ return pixbuf;
+
+ pixbuf = read_pixbuf(source.get_source_id(), source.get_preferred_thumbnail_format());
+
+ cycle_fetched_thumbnails++;
+ schedule_debug();
+
+ // stash in memory for next time
+ store_in_memory(source.get_source_id(), pixbuf);
+
+ return pixbuf;
+ }
+
+ private void _fetch_async(ThumbnailSource source, PhotoFileFormat format, Dimensions dim,
+ Gdk.InterpType interp, AsyncFetchCallback callback, Cancellable? cancellable) {
+ // check if the pixbuf is already in memory
+ string thumbnail_name = source.get_source_id();
+ Gdk.Pixbuf pixbuf = fetch_from_memory(thumbnail_name);
+ if (pixbuf != null && (!dim.has_area() || Dimensions.for_pixbuf(pixbuf).equals(dim))) {
+ // if no scaling operation required, callback in this context and done (otherwise,
+ // let the background threads perform the scaling operation, to spread out the work)
+ callback(pixbuf, pixbuf, dim, interp, null);
+
+ return;
+ }
+
+ // TODO: Note that there exists a cache condition in this current implementation. It's
+ // possible for two requests for the same thumbnail to come in back-to-back. Since there's
+ // no "reservation" system to indicate that an outstanding job is fetching that thumbnail
+ // (and the other should wait until it's done), two (or more) fetches could occur on the
+ // same thumbnail file.
+ //
+ // Due to the design of Shotwell, with one thumbnail per page, this is seen as an unlikely
+ // situation. This may change in the future, and the caching situation will need to be
+ // handled.
+
+ fetch_workers.enqueue(new AsyncFetchJob(this, thumbnail_name, source, pixbuf, dim,
+ interp, callback, cancellable));
+ }
+
+ // Called within Gtk.main's thread context
+ private static void async_fetch_completion_callback(BackgroundJob background_job) {
+ AsyncFetchJob job = (AsyncFetchJob) background_job;
+
+ if (job.unscaled != null) {
+ if (job.fetched) {
+ // only store in cache if fetched, not pre-fetched
+ job.cache.store_in_memory(job.thumbnail_name, job.unscaled);
+
+ cycle_async_fetched_thumbnails++;
+ schedule_debug();
+ } else {
+ cycle_async_resized_thumbnails++;
+ schedule_debug();
+ }
+ }
+
+ job.callback(job.scaled, job.unscaled, job.dim, job.interp, job.err);
+ }
+
+ private void _import_from_source(ThumbnailSource source, bool force = false)
+ throws Error {
+ File file = get_source_cached_file(source);
+
+ // if not forcing the cache operation, check if file exists and is represented in the
+ // database before continuing
+ if (!force) {
+ if (_exists(source))
+ return;
+ } else {
+ // wipe from system and continue
+ _remove(source);
+ }
+
+ LibraryPhoto photo = (LibraryPhoto) source;
+ save_thumbnail(file, photo.get_pixbuf(Scaling.for_best_fit(size.get_scale(), true)), source);
+
+ // See note in _import_with_pixbuf for reason why this is not maintained in in-memory
+ // cache
+ }
+
+ private void _import_thumbnail(ThumbnailSource source, Gdk.Pixbuf? scaled, bool force = false)
+ throws Error {
+ assert(scaled != null);
+ assert(Dimensions.for_pixbuf(scaled).approx_scaled(size.get_scale()));
+
+ // if not forcing the cache operation, check if file exists and is represented in the
+ // database before continuing
+ if (!force) {
+ if (_exists(source))
+ return;
+ } else {
+ // wipe previous from system and continue
+ _remove(source);
+ }
+
+ save_thumbnail(get_source_cached_file(source), scaled, source);
+
+ // do NOT store in the in-memory cache ... if a lot of photos are being imported at
+ // once, this will blow cache locality, especially when the user is viewing one portion
+ // of the collection while new photos are added far off the viewport
+ }
+
+ private void _duplicate(ThumbnailSource src_source, ThumbnailSource dest_source) {
+ File src_file = get_source_cached_file(src_source);
+ File dest_file = get_cached_file(dest_source.get_representative_id(),
+ src_source.get_preferred_thumbnail_format());
+
+ try {
+ src_file.copy(dest_file, FileCopyFlags.ALL_METADATA | FileCopyFlags.OVERWRITE, null, null);
+ } catch (Error err) {
+ AppWindow.panic("%s".printf(err.message));
+ }
+
+ // Do NOT store in memory cache, for similar reasons as stated in _import().
+ }
+
+ private void _replace(ThumbnailSource source, Gdk.Pixbuf original) throws Error {
+ File file = get_source_cached_file(source);
+
+ // Remove from in-memory cache, if present
+ remove_from_memory(source.get_source_id());
+
+ // scale to cache's parameters
+ Gdk.Pixbuf scaled = scale_pixbuf(original, size.get_scale(), interp, true);
+
+ // save scaled image to disk
+ save_thumbnail(file, scaled, source);
+
+ // Store in in-memory cache; a _replace() probably represents a user-initiated
+ // action (<cough>rotate</cough>) and the thumbnail will probably be fetched immediately.
+ // This means the thumbnail will be cached in scales that aren't immediately needed, but
+ // the benefit seems to outweigh the side-effects
+ store_in_memory(source.get_source_id(), scaled);
+ }
+
+ private void _remove(ThumbnailSource source) {
+ File file = get_source_cached_file(source);
+
+ // remove from in-memory cache
+ remove_from_memory(source.get_source_id());
+
+ // remove from disk
+ try {
+ file.delete(null);
+ } catch (Error err) {
+ // ignored
+ }
+ }
+
+ private bool _exists(ThumbnailSource source) {
+ return get_source_cached_file(source).query_exists(null);
+ }
+
+ // This method is thread-safe.
+ private Gdk.Pixbuf read_pixbuf(string thumbnail_name, PhotoFileFormat format) throws Error {
+ return format.create_reader(get_cached_file(thumbnail_name,
+ format).get_path()).unscaled_read();
+ }
+
+ private File get_source_cached_file(ThumbnailSource source) {
+ return get_cached_file(source.get_representative_id(),
+ source.get_preferred_thumbnail_format());
+ }
+
+ private File get_cached_file(string thumbnail_name, PhotoFileFormat thumbnail_format) {
+ return cache_dir.get_child(thumbnail_format.get_default_basename(thumbnail_name));
+ }
+
+ private Gdk.Pixbuf? fetch_from_memory(string thumbnail_name) {
+ ImageData data = cache_map.get(thumbnail_name);
+
+ return (data != null) ? data.pixbuf : null;
+ }
+
+ private void store_in_memory(string thumbnail_name, Gdk.Pixbuf thumbnail) {
+ if (max_cached_bytes <= 0)
+ return;
+
+ remove_from_memory(thumbnail_name);
+
+ ImageData data = new ImageData(thumbnail);
+
+ // see if this is too large to keep in memory
+ if(data.bytes > MAX_INMEMORY_DATA_SIZE) {
+ debug("Persistent thumbnail [%s] too large to cache in memory", thumbnail_name);
+
+ return;
+ }
+
+ cache_map.set(thumbnail_name, data);
+ cache_lru.insert(0, thumbnail_name);
+
+ cached_bytes += data.bytes;
+
+ // trim cache
+ while (cached_bytes > max_cached_bytes) {
+ assert(cache_lru.size > 0);
+ int index = cache_lru.size - 1;
+
+ string victim_name = cache_lru.get(index);
+ cache_lru.remove_at(index);
+
+ data = cache_map.get(victim_name);
+
+ cycle_overflow_thumbnails++;
+ schedule_debug();
+
+ bool removed = cache_map.unset(victim_name);
+ assert(removed);
+
+ assert(data.bytes <= cached_bytes);
+ cached_bytes -= data.bytes;
+ }
+ }
+
+ private bool remove_from_memory(string thumbnail_name) {
+ ImageData data = cache_map.get(thumbnail_name);
+ if (data == null)
+ return false;
+
+ assert(cached_bytes >= data.bytes);
+ cached_bytes -= data.bytes;
+
+ // remove data from in-memory cache
+ bool removed = cache_map.unset(thumbnail_name);
+ assert(removed);
+
+ // remove from LRU
+ removed = cache_lru.remove(thumbnail_name);
+ assert(removed);
+
+ return true;
+ }
+
+ private void save_thumbnail(File file, Gdk.Pixbuf pixbuf, ThumbnailSource source) throws Error {
+ source.get_preferred_thumbnail_format().create_writer(file.get_path()).write(pixbuf,
+ DEFAULT_QUALITY);
+ }
+}
+
diff --git a/src/TimedQueue.vala b/src/TimedQueue.vala
new file mode 100644
index 0000000..7001421
--- /dev/null
+++ b/src/TimedQueue.vala
@@ -0,0 +1,284 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// TimedQueue is a specialized collection class. It holds items in order, but rather than being
+// manually dequeued, they are dequeued automatically after a specified amount of time has elapsed
+// for that item. As of today, it's possible the item will be dequeued a bit later than asked
+// for, but it will never be early. Future implementations might tighten up the lateness.
+//
+// The original design was to use a signal to notify when an item has been dequeued, but Vala has
+// a bug with passing an unnamed type as a signal parameter:
+// https://bugzilla.gnome.org/show_bug.cgi?id=628639
+//
+// The rate the items come off the queue can be spaced out. Note that this can cause items to back
+// up. As of today, TimedQueue makes no effort to combat this.
+
+public delegate void DequeuedCallback<G>(G item);
+
+public class TimedQueue<G> {
+ private class Element<G> {
+ public G item;
+ public ulong ready;
+
+ public Element(G item, ulong ready) {
+ this.item = item;
+ this.ready = ready;
+ }
+
+ public static int64 comparator(void *a, void *b) {
+ return (int64) ((Element *) a)->ready - (int64) ((Element *) b)->ready;
+ }
+ }
+
+ private uint hold_msec;
+ private unowned DequeuedCallback<G> callback;
+ private Gee.EqualDataFunc<G> equal_func;
+ private int priority;
+ private uint timer_id = 0;
+ private SortedList<Element<G>> queue;
+ private uint dequeue_spacing_msec = 0;
+ private ulong last_dequeue = 0;
+ private bool paused_state = false;
+
+ public virtual signal void paused(bool is_paused) {
+ }
+
+ // Initial design was to have a signal that passed the dequeued G, but bug in valac meant
+ // 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) {
+ this.hold_msec = hold_msec;
+ this.callback = callback;
+
+ if (equal_func != null)
+ this.equal_func = (owned) equal_func;
+ else
+ this.equal_func = (Gee.EqualDataFunc<G>) (Gee.Functions.get_equal_func_for(typeof(G)));
+
+ this.priority = priority;
+
+ queue = new SortedList<Element<G>>(Element.comparator);
+
+ timer_id = Timeout.add(get_heartbeat_timeout(), on_heartbeat, priority);
+ }
+
+ ~TimedQueue() {
+ if (timer_id != 0)
+ Source.remove(timer_id);
+ }
+
+ public uint get_dequeue_spacing_msec() {
+ return dequeue_spacing_msec;
+ }
+
+ public void set_dequeue_spacing_msec(uint msec) {
+ if (msec == dequeue_spacing_msec)
+ return;
+
+ if (timer_id != 0)
+ Source.remove(timer_id);
+
+ dequeue_spacing_msec = msec;
+
+ timer_id = Timeout.add(get_heartbeat_timeout(), on_heartbeat, priority);
+ }
+
+ private uint get_heartbeat_timeout() {
+ return ((dequeue_spacing_msec == 0)
+ ? (hold_msec / 10)
+ : (dequeue_spacing_msec / 2)).clamp(10, uint.MAX);
+ }
+
+ protected virtual void notify_dequeued(G item) {
+ callback(item);
+ }
+
+ public bool is_paused() {
+ return paused_state;
+ }
+
+ public void pause() {
+ if (paused_state)
+ return;
+
+ paused_state = true;
+
+ paused(true);
+ }
+
+ public void unpause() {
+ if (!paused_state)
+ return;
+
+ paused_state = false;
+
+ paused(false);
+ }
+
+ public virtual void clear() {
+ queue.clear();
+ }
+
+ public virtual bool contains(G item) {
+ foreach (Element<G> e in queue) {
+ if (equal_func(item, e.item))
+ return true;
+ }
+
+ return false;
+ }
+
+ public virtual bool enqueue(G item) {
+ return queue.add(new Element<G>(item, calc_ready_time()));
+ }
+
+ public virtual bool enqueue_many(Gee.Collection<G> items) {
+ ulong ready_time = calc_ready_time();
+
+ Gee.ArrayList<Element<G>> elements = new Gee.ArrayList<Element<G>>();
+ foreach (G item in items)
+ elements.add(new Element<G>(item, ready_time));
+
+ return queue.add_list(elements);
+ }
+
+ public virtual bool remove_first(G item) {
+ Gee.Iterator<Element<G>> iter = queue.iterator();
+ while (iter.next()) {
+ Element<G> e = iter.get();
+ if (equal_func(item, e.item)) {
+ iter.remove();
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public virtual int size {
+ get {
+ return queue.size;
+ }
+ }
+
+ private ulong calc_ready_time() {
+ return now_ms() + (ulong) hold_msec;
+ }
+
+ private bool on_heartbeat() {
+ if (paused_state)
+ return true;
+
+ ulong now = 0;
+
+ for (;;) {
+ if (queue.size == 0)
+ break;
+
+ Element<G>? head = queue.get_at(0);
+ assert(head != null);
+
+ if (now == 0)
+ now = now_ms();
+
+ if (head.ready > now)
+ break;
+
+ // if a space of time is required between dequeues, check now
+ if ((dequeue_spacing_msec != 0) && ((now - last_dequeue) < dequeue_spacing_msec))
+ break;
+
+ Element<G>? h = queue.remove_at(0);
+ assert(head == h);
+
+ notify_dequeued(head.item);
+ last_dequeue = now;
+
+ // if a dequeue spacing is in place, it's a lock that only one item is dequeued per
+ // heartbeat
+ if (dequeue_spacing_msec != 0)
+ break;
+ }
+
+ return true;
+ }
+}
+
+// HashTimedQueue uses a HashMap for quick lookups of elements via contains().
+
+public class HashTimedQueue<G> : TimedQueue<G> {
+ private Gee.HashMap<G, int> item_count;
+
+ public HashTimedQueue(uint hold_msec, DequeuedCallback<G> callback,
+ owned Gee.HashDataFunc<G>? hash_func = null, owned Gee.EqualDataFunc<G>? equal_func = null,
+ int priority = Priority.DEFAULT) {
+ base (hold_msec, callback, (owned) equal_func, priority);
+
+ item_count = new Gee.HashMap<G, int>((owned) hash_func, (owned) equal_func);
+ }
+
+ protected override void notify_dequeued(G item) {
+ removed(item);
+
+ base.notify_dequeued(item);
+ }
+
+ public override void clear() {
+ item_count.clear();
+
+ base.clear();
+ }
+
+ public override bool contains(G item) {
+ return item_count.has_key(item);
+ }
+
+ public override bool enqueue(G item) {
+ if (!base.enqueue(item))
+ return false;
+
+ item_count.set(item, item_count.has_key(item) ? item_count.get(item) + 1 : 1);
+
+ return true;
+ }
+
+ public override bool enqueue_many(Gee.Collection<G> items) {
+ if (!base.enqueue_many(items))
+ return false;
+
+ foreach (G item in items)
+ item_count.set(item, item_count.has_key(item) ? item_count.get(item) + 1 : 1);
+
+ return true;
+ }
+
+ public override bool remove_first(G item) {
+ if (!base.remove_first(item))
+ return false;
+
+ removed(item);
+
+ return true;
+ }
+
+ private void removed(G item) {
+ // item in question is either already removed
+ // or was never added, safe to do nothing here
+ if (!item_count.has_key(item))
+ return;
+
+ int count = item_count.get(item);
+ assert(count > 0);
+
+ if (--count == 0)
+ item_count.unset(item);
+ else
+ item_count.set(item, count);
+ }
+}
+
diff --git a/src/Tombstone.vala b/src/Tombstone.vala
new file mode 100644
index 0000000..d25b979
--- /dev/null
+++ b/src/Tombstone.vala
@@ -0,0 +1,336 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 TombstoneSourceCollection : DatabaseSourceCollection {
+ private Gee.HashMap<File, Tombstone> file_map = new Gee.HashMap<File, Tombstone>(file_hash,
+ file_equal);
+
+ public TombstoneSourceCollection() {
+ base ("Tombstones", get_tombstone_id);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is Tombstone;
+ }
+
+ private static int64 get_tombstone_id(DataSource source) {
+ return ((Tombstone) source).get_tombstone_id().id;
+ }
+
+ protected override void notify_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ if (added != null) {
+ foreach (DataObject object in added) {
+ Tombstone tombstone = (Tombstone) object;
+
+ file_map.set(tombstone.get_file(), tombstone);
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed) {
+ Tombstone tombstone = (Tombstone) object;
+
+ // do we actually have this file?
+ if (file_map.has_key(tombstone.get_file())) {
+ // yes, try to remove it.
+ bool is_removed = file_map.unset(tombstone.get_file());
+ assert(is_removed);
+ }
+ // if the hashmap didn't have the file to begin with,
+ // we're already in the state we wanted to be in, so our
+ // work is done; no need to assert.
+ }
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ protected override void notify_items_altered(Gee.Map<DataObject, Alteration> items) {
+ foreach (DataObject object in items.keys) {
+ Alteration alteration = items.get(object);
+ if (!alteration.has_subject("file"))
+ continue;
+
+ Tombstone tombstone = (Tombstone) object;
+
+ foreach (string detail in alteration.get_details("file")) {
+ File old_file = File.new_for_path(detail);
+
+ bool removed = file_map.unset(old_file);
+ assert(removed);
+
+ file_map.set(tombstone.get_file(), tombstone);
+
+ break;
+ }
+ }
+ }
+
+ public Tombstone? locate(File file) {
+ return file_map.get(file);
+ }
+
+ public bool matches(File file) {
+ return file_map.has_key(file);
+ }
+
+ public void resurrect(Tombstone tombstone) {
+ destroy_marked(mark(tombstone), false);
+ }
+
+ public void resurrect_many(Gee.Collection<Tombstone> tombstones) {
+ Marker marker = mark_many(tombstones);
+
+ freeze_notifications();
+ DatabaseTable.begin_transaction();
+
+ destroy_marked(marker, false);
+
+ try {
+ DatabaseTable.commit_transaction();
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ thaw_notifications();
+ }
+
+ // This initiates a scan of the tombstoned files, resurrecting them if the file is no longer
+ // present on disk. If a DirectoryMonitor is supplied, the scan will use that object's FileInfo
+ // if available. If not available or not supplied, the scan will query for the file's
+ // existence.
+ //
+ // Note that this call is non-blocking.
+ public void launch_scan(DirectoryMonitor? monitor, Cancellable? cancellable) {
+ async_scan.begin(monitor, cancellable);
+ }
+
+ private async void async_scan(DirectoryMonitor? monitor, Cancellable? cancellable) {
+ // search through all tombstones for missing files, which indicate the tombstone can go away
+ Marker marker = start_marking();
+ foreach (DataObject object in get_all()) {
+ Tombstone tombstone = (Tombstone) object;
+ File file = tombstone.get_file();
+
+ FileInfo? info = null;
+ if (monitor != null)
+ info = monitor.get_file_info(file);
+
+ // Want to be conservative here; only resurrect a tombstone if file is actually detected
+ // as not present, and not some other problem (which may be intermittant)
+ if (info == null) {
+ try {
+ info = yield file.query_info_async(FileAttribute.STANDARD_NAME,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, Priority.LOW, cancellable);
+ } catch (Error err) {
+ // watch for cancellation, which signals it's time to go
+ if (err is IOError.CANCELLED)
+ break;
+
+ if (!(err is IOError.NOT_FOUND)) {
+ warning("Unable to check for existence of tombstoned file %s: %s",
+ file.get_path(), err.message);
+ }
+ }
+ }
+
+ // if not found, resurrect
+ if (info == null)
+ marker.mark(tombstone);
+
+ Idle.add(async_scan.callback);
+ yield;
+ }
+
+ if (marker.get_count() > 0) {
+ debug("Resurrecting %d tombstones with no backing file", marker.get_count());
+ DatabaseTable.begin_transaction();
+ destroy_marked(marker, false);
+ try {
+ DatabaseTable.commit_transaction();
+ } catch (DatabaseError err2) {
+ AppWindow.database_error(err2);
+ }
+ }
+ }
+}
+
+public class TombstonedFile {
+ public File file;
+ public int64 filesize;
+ public string? md5;
+
+ public TombstonedFile(File file, int64 filesize, string? md5) {
+ this.file = file;
+ this.filesize = filesize;
+ this.md5 = md5;
+ }
+}
+
+public class Tombstone : DataSource {
+ // These values are persisted. Do not change.
+ public enum Reason {
+ REMOVED_BY_USER = 0,
+ AUTO_DETECTED_DUPLICATE = 1;
+
+ public int serialize() {
+ return (int) this;
+ }
+
+ public static Reason unserialize(int value) {
+ switch ((Reason) value) {
+ case AUTO_DETECTED_DUPLICATE:
+ return AUTO_DETECTED_DUPLICATE;
+
+ // 0 is the default in the database, so it should remain so here
+ case REMOVED_BY_USER:
+ default:
+ return REMOVED_BY_USER;
+ }
+ }
+ }
+
+ public static TombstoneSourceCollection global = null;
+
+ private TombstoneRow row;
+ private File? file = null;
+
+ private Tombstone(TombstoneRow row) {
+ this.row = row;
+ }
+
+ public static void init() {
+ global = new TombstoneSourceCollection();
+
+ TombstoneRow[]? rows = null;
+ try {
+ rows = TombstoneTable.get_instance().fetch_all();
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ if (rows != null) {
+ Gee.ArrayList<Tombstone> tombstones = new Gee.ArrayList<Tombstone>();
+ foreach (TombstoneRow row in rows)
+ tombstones.add(new Tombstone(row));
+
+ global.add_many(tombstones);
+ }
+ }
+
+ public static void terminate() {
+ }
+
+ public static void entomb_many_sources(Gee.Collection<MediaSource> sources, Reason reason)
+ throws DatabaseError {
+ Gee.Collection<TombstonedFile> files = new Gee.ArrayList<TombstonedFile>();
+ foreach (MediaSource source in sources) {
+ foreach (BackingFileState state in source.get_backing_files_state())
+ files.add(new TombstonedFile(state.get_file(), state.filesize, state.md5));
+ }
+
+ entomb_many_files(files, reason);
+ }
+
+ public static void entomb_many_files(Gee.Collection<TombstonedFile> files, Reason reason)
+ throws DatabaseError {
+ // destroy any out-of-date tombstones so they may be updated
+ Marker to_destroy = global.start_marking();
+ foreach (TombstonedFile file in files) {
+ Tombstone? tombstone = global.locate(file.file);
+ if (tombstone != null)
+ to_destroy.mark(tombstone);
+ }
+
+ global.destroy_marked(to_destroy, false);
+
+ Gee.ArrayList<Tombstone> tombstones = new Gee.ArrayList<Tombstone>();
+ foreach (TombstonedFile file in files) {
+ tombstones.add(new Tombstone(TombstoneTable.get_instance().add(file.file.get_path(),
+ file.filesize, file.md5, reason)));
+ }
+
+ global.add_many(tombstones);
+ }
+
+ public override string get_typename() {
+ return "tombstone";
+ }
+
+ public override int64 get_instance_id() {
+ return get_tombstone_id().id;
+ }
+
+ public override string get_name() {
+ return row.filepath;
+ }
+
+ public override string to_string() {
+ return "Tombstone %s".printf(get_name());
+ }
+
+ public TombstoneID get_tombstone_id() {
+ return row.id;
+ }
+
+ public File get_file() {
+ if (file == null)
+ file = File.new_for_path(row.filepath);
+
+ return file;
+ }
+
+ public string? get_md5() {
+ return is_string_empty(row.md5) ? null : row.md5;
+ }
+
+ public Reason get_reason() {
+ return row.reason;
+ }
+
+ public void move(File file) {
+ try {
+ TombstoneTable.get_instance().update_file(row.id, file.get_path());
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ string old_filepath = row.filepath;
+ row.filepath = file.get_path();
+ this.file = file;
+
+ notify_altered(new Alteration("file", old_filepath));
+ }
+
+ public bool matches(File file, int64 filesize, string? md5) {
+ if (row.filesize != filesize)
+ return false;
+
+ // normalize to deal with empty strings
+ string? this_md5 = is_string_empty(row.md5) ? null : row.md5;
+ string? other_md5 = is_string_empty(md5) ? null : md5;
+
+ if (this_md5 != other_md5)
+ return false;
+
+ if (!get_file().equal(file))
+ return false;
+
+ return true;
+ }
+
+ public override void destroy() {
+ try {
+ TombstoneTable.get_instance().remove(row.id);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ base.destroy();
+ }
+}
+
diff --git a/src/UnityProgressBar.vala b/src/UnityProgressBar.vala
new file mode 100644
index 0000000..fcf36bc
--- /dev/null
+++ b/src/UnityProgressBar.vala
@@ -0,0 +1,83 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+#if UNITY_SUPPORT
+public class UnityProgressBar : Object {
+
+ private static Unity.LauncherEntry l = Unity.LauncherEntry.get_for_desktop_id("shotwell.desktop");
+ private static UnityProgressBar? visible_uniprobar;
+
+ private double progress;
+ private bool visible;
+
+ public static UnityProgressBar get_instance() {
+ if (visible_uniprobar == null) {
+ visible_uniprobar = new UnityProgressBar();
+ }
+
+ return visible_uniprobar;
+ }
+
+ private UnityProgressBar() {
+ progress = 0.0;
+ visible = false;
+ }
+
+ ~UnityProgressBar () {
+ reset_progress_bar();
+ }
+
+ public double get_progress () {
+ return progress;
+ }
+
+ public void set_progress (double percent) {
+ progress = percent;
+ update_visibility();
+ }
+
+ private void update_visibility () {
+ set_progress_bar(this, progress);
+ }
+
+ public bool get_visible () {
+ return visible;
+ }
+
+ public void set_visible (bool visible) {
+ this.visible = visible;
+
+ if (!visible) {
+ //if not visible and currently displayed, remove Unity progress bar
+ reset_progress_bar();
+ } else {
+ //update_visibility if this progress bar wants to be drawn
+ update_visibility();
+ }
+ }
+
+ public void reset () {
+ set_visible(false);
+ progress = 0.0;
+ }
+
+ private static void set_progress_bar (UnityProgressBar uniprobar, double percent) {
+ //set new visible ProgressBar
+ visible_uniprobar = uniprobar;
+ if (!l.progress_visible)
+ l.progress_visible = true;
+ l.progress = percent;
+ }
+
+ private static void reset_progress_bar () {
+ //reset to default values
+ visible_uniprobar = null;
+ l.progress = 0.0;
+ l.progress_visible = false;
+ }
+}
+
+#endif
diff --git a/src/Upgrades.vala b/src/Upgrades.vala
new file mode 100644
index 0000000..7fef59a
--- /dev/null
+++ b/src/Upgrades.vala
@@ -0,0 +1,115 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// Class for aggregating one-off "upgrade" tasks that occur at startup, such as
+// moving or deleting files. This occurs after the UI is shown, so it's not appropriate
+// for database updates and such.
+public class Upgrades {
+ private static Upgrades? instance = null;
+ private uint64 total_steps = 0;
+ private Gee.LinkedList<UpgradeTask> task_list = new Gee.LinkedList<UpgradeTask>();
+
+ private Upgrades() {
+ // Add all upgrade tasks here.
+ add(new MimicsRemovalTask());
+
+ if (Application.get_instance().get_raw_thumbs_fix_required())
+ add(new FixupRawThumbnailsTask());
+ }
+
+ // Call this to initialize the subsystem.
+ public static void init() {
+ assert(instance == null);
+ instance = new Upgrades();
+ }
+
+ public static Upgrades get_instance() {
+ return instance;
+ }
+
+ // Gets the total number of steps for the progress monitor.
+ public uint64 get_step_count() {
+ return total_steps;
+ }
+
+ // Performs all upgrade tasks.
+ public void execute(ProgressMonitor? monitor = null) {
+ foreach (UpgradeTask task in task_list)
+ task.execute(monitor);
+ }
+
+ private void add(UpgradeTask task) {
+ total_steps += task.get_step_count();
+ task_list.add(task);
+ }
+}
+
+// Interface for upgrades that happen on startup.
+// When creating a new upgrade task, you MUST add it to the constructor
+// supplied in Upgrades (see above.)
+private interface UpgradeTask : Object{
+ // Returns the number of steps involved in the ugprade.
+ public abstract uint64 get_step_count();
+
+ // Performs the upgrade. Note that when using the progress
+ // monitor, the total number of steps must be equal to the
+ // step count above.
+ public abstract void execute(ProgressMonitor? monitor = null);
+}
+
+// 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 durration 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");
+ private uint64 num_mimics = 0;
+
+ public uint64 get_step_count() {
+ try {
+ num_mimics = count_files_in_directory(mimic_dir);
+ } catch (Error e) {
+ debug("Error on deleting mimics: %s", e.message);
+ }
+ return num_mimics;
+ }
+
+ public void execute(ProgressMonitor? monitor = null) {
+ try {
+ delete_all_files(mimic_dir, null, monitor, num_mimics, null);
+ mimic_dir.delete();
+ } catch (Error e) {
+ debug("Could not delete mimics: %s", e.message);
+ }
+ }
+}
+
+// Deletes 'stale' thumbnails from camera raw files whose default developer was
+// CAMERA and who may have been incorrectly generated from the embedded preview by
+// previous versions of the application that had bug 4692.
+private class FixupRawThumbnailsTask : Object, UpgradeTask {
+ public uint64 get_step_count() {
+ int num_raw_files = 0;
+
+ foreach (PhotoRow phr in PhotoTable.get_instance().get_all()) {
+ if (phr.master.file_format == PhotoFileFormat.RAW)
+ num_raw_files++;
+ }
+ return num_raw_files;
+ }
+
+ public void execute(ProgressMonitor? monitor = null) {
+ debug("Executing thumbnail deletion and fixup");
+
+ foreach (PhotoRow phr in PhotoTable.get_instance().get_all()) {
+ if ((phr.master.file_format == PhotoFileFormat.RAW) &&
+ (phr.developer == RawDeveloper.CAMERA)) {
+ ThumbnailCache.remove(LibraryPhoto.global.fetch(phr.photo_id));
+ }
+ }
+ }
+}
+
diff --git a/src/VideoMetadata.vala b/src/VideoMetadata.vala
new file mode 100644
index 0000000..100a040
--- /dev/null
+++ b/src/VideoMetadata.vala
@@ -0,0 +1,655 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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/VideoMonitor.vala b/src/VideoMonitor.vala
new file mode 100644
index 0000000..f062999
--- /dev/null
+++ b/src/VideoMonitor.vala
@@ -0,0 +1,301 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+private class VideoUpdates : MonitorableUpdates {
+ public Video video;
+
+ private bool check_interpretable = false;
+
+ public VideoUpdates(Video video) {
+ base (video);
+
+ this.video = video;
+ }
+
+ public virtual void set_check_interpretable(bool check) {
+ check_interpretable = check;
+ }
+
+ public override void mark_online() {
+ base.mark_online();
+
+ set_check_interpretable(true);
+ }
+
+ public bool is_check_interpretable() {
+ return check_interpretable;
+ }
+
+ public override bool is_all_updated() {
+ return (check_interpretable == false) && base.is_all_updated();
+ }
+}
+
+private class VideoMonitor : MediaMonitor {
+ private const int MAX_INTERPRETABLE_CHECKS_PER_CYCLE = 5;
+
+ // Performs interpretable check on video. In a background job because
+ // this will create a new thumbnail for the video.
+ private class VideoInterpretableCheckJob : BackgroundJob {
+ // IN
+ public Video video;
+
+ // OUT
+ public Video.InterpretableResults? results = null;
+
+ public VideoInterpretableCheckJob(Video video, CompletionCallback? callback = null) {
+ base (video, callback);
+ this.video = video;
+ }
+
+ public override void execute() {
+ results = video.check_is_interpretable();
+ }
+ }
+
+ // Work queue for video thumbnailing.
+ // Note: only using 1 thread. If we want to change this to use multiple
+ // threads, we need to put a lock around background_jobs wherever it's modified.
+ private Workers workers = new Workers(1, false);
+ private uint64 background_jobs = 0;
+
+ public VideoMonitor(Cancellable cancellable) {
+ base (Video.global, cancellable);
+
+ foreach (DataObject obj in Video.global.get_all()) {
+ Video video = obj as Video;
+ assert (video != null);
+ if (!video.get_is_interpretable())
+ set_check_interpretable(video, true);
+ }
+ }
+
+ protected override MonitorableUpdates create_updates(Monitorable monitorable) {
+ assert(monitorable is Video);
+
+ return new VideoUpdates((Video) monitorable);
+ }
+
+ public override MediaSourceCollection get_media_source_collection() {
+ return Video.global;
+ }
+
+ public override bool is_file_represented(File file) {
+ VideoSourceCollection.State state;
+ return get_state(file, out state) != null;
+ }
+
+ public override MediaMonitor.DiscoveredFile notify_file_discovered(File file, FileInfo info,
+ out Monitorable monitorable) {
+ VideoSourceCollection.State state;
+ Video? video = get_state(file, out state);
+ if (video == null) {
+ monitorable = null;
+
+ return MediaMonitor.DiscoveredFile.UNKNOWN;
+ }
+
+ switch (state) {
+ case VideoSourceCollection.State.ONLINE:
+ case VideoSourceCollection.State.OFFLINE:
+ monitorable = video;
+
+ return MediaMonitor.DiscoveredFile.REPRESENTED;
+
+ case VideoSourceCollection.State.TRASH:
+ default:
+ // ignored ... trash always stays in trash
+ monitorable = null;
+
+ return MediaMonitor.DiscoveredFile.IGNORE;
+ }
+ }
+
+ public override Gee.Collection<Monitorable>? candidates_for_unknown_file(File file, FileInfo info,
+ out MediaMonitor.DiscoveredFile result) {
+ Gee.Collection<Video> matched = new Gee.ArrayList<Video>();
+ Video.global.fetch_by_matching_backing(info, matched);
+
+ result = MediaMonitor.DiscoveredFile.UNKNOWN;
+
+ return matched;
+ }
+
+ public override bool notify_file_created(File file, FileInfo info) {
+ VideoSourceCollection.State state;
+ Video? video = get_state(file, out state);
+ if (video == null)
+ return false;
+
+ update_online(video);
+
+ return true;
+ }
+
+ public override bool notify_file_moved(File old_file, File new_file, FileInfo new_file_info) {
+ VideoSourceCollection.State old_state;
+ Video? old_video = get_state(old_file, out old_state);
+
+ VideoSourceCollection.State new_state;
+ Video? new_video = get_state(new_file, out new_state);
+
+ // Four possibilities:
+ //
+ // 1. Moving an existing photo file to a location where no photo is represented
+ // Operation: have the Photo object move with the file.
+ // 2. Moving a file with no representative photo to a location where a photo is represented
+ // (i.e. is offline). Operation: Update the photo (backing has changed).
+ // 3. Moving a file with no representative photo to a location with no representative
+ // photo. Operation: Enqueue for import (if appropriate).
+ // 4. Move a file with a representative photo to a location where a photo is represented
+ // Operation: Mark the old photo as offline (or drop editable) and update new photo
+ // (the backing has changed).
+
+ if (old_video != null && new_video == null) {
+ // 1.
+ update_master_file(old_video, new_file);
+ } else if (old_video == null && new_video != null) {
+ // 2.
+ set_check_interpretable(new_video, true);
+ } else if (old_video == null && new_video == null) {
+ // 3.
+ return false;
+ } else {
+ assert(old_video != null && new_video != null);
+
+ // 4.
+ update_offline(old_video);
+ set_check_interpretable(new_video, true);
+ }
+
+ return true;
+ }
+
+ public override bool notify_file_altered(File file) {
+ VideoSourceCollection.State state;
+ return get_state(file, out state) != null;
+ }
+
+ public override bool notify_file_attributes_altered(File file) {
+ VideoSourceCollection.State state;
+ Video? video = get_state(file, out state);
+ if (video == null)
+ return false;
+
+ update_master_file_info_altered(video);
+ update_master_file_in_alteration(video, true);
+
+ return true;
+ }
+
+ public override bool notify_file_alteration_completed(File file, FileInfo info) {
+ VideoSourceCollection.State state;
+ Video? video = get_state(file, out state);
+ if (video == null)
+ return false;
+
+ update_master_file_alterations_completed(video, info);
+
+ return true;
+ }
+
+ public override bool notify_file_deleted(File file) {
+ VideoSourceCollection.State state;
+ Video? video = get_state(file, out state);
+ if (video == null)
+ return false;
+
+ update_master_file_in_alteration(video, false);
+ update_offline(video);
+
+ return true;
+ }
+
+ private Video? get_state(File file, out VideoSourceCollection.State state) {
+ File? real_file = null;
+ foreach (Monitorable monitorable in get_monitorables()) {
+ Video video = (Video) monitorable;
+
+ VideoUpdates? updates = get_existing_video_updates(video);
+ if (updates == null)
+ continue;
+
+ if (updates.get_master_file() != null && updates.get_master_file().equal(file)) {
+ real_file = video.get_master_file();
+
+ break;
+ }
+ }
+
+ return Video.global.get_state_by_file(real_file ?? file, out state);
+ }
+
+ public VideoUpdates fetch_video_updates(Video video) {
+ VideoUpdates? updates = fetch_updates(video) as VideoUpdates;
+ assert(updates != null);
+
+ return updates;
+ }
+
+ public VideoUpdates? get_existing_video_updates(Video video) {
+ return get_existing_updates(video) as VideoUpdates;
+ }
+
+ public void set_check_interpretable(Video video, bool check) {
+ fetch_video_updates(video).set_check_interpretable(check);
+ }
+
+ protected override void process_updates(Gee.Collection<MonitorableUpdates> all_updates,
+ TransactionController controller, ref int op_count) throws Error {
+ base.process_updates(all_updates, controller, ref op_count);
+
+ Gee.ArrayList<Video>? check = null;
+
+ foreach (MonitorableUpdates monitorable_updates in all_updates) {
+ if (op_count >= MAX_OPERATIONS_PER_CYCLE)
+ break;
+
+ // use a separate limit on interpretable checks because they're more expensive than
+ // simple database commands
+ if (check != null && check.size >= MAX_INTERPRETABLE_CHECKS_PER_CYCLE)
+ break;
+
+ VideoUpdates? updates = monitorable_updates as VideoUpdates;
+ if (updates == null)
+ continue;
+
+ if (updates.is_check_interpretable()) {
+ if (check == null)
+ check = new Gee.ArrayList<Video>();
+
+ check.add(updates.video);
+ updates.set_check_interpretable(false);
+ op_count++;
+ }
+ }
+
+ if (check != null) {
+ mdbg("Checking interpretable for %d videos".printf(check.size));
+
+ Video.notify_offline_thumbs_regenerated();
+
+ background_jobs += check.size;
+ foreach (Video video in check)
+ workers.enqueue(new VideoInterpretableCheckJob(video, on_interpretable_check_complete));
+ }
+ }
+
+ void on_interpretable_check_complete(BackgroundJob j) {
+ VideoInterpretableCheckJob job = (VideoInterpretableCheckJob) j;
+
+ job.results.foreground_finish();
+
+ --background_jobs;
+ if (background_jobs <= 0)
+ Video.notify_normal_thumbs_regenerated();
+ }
+}
+
diff --git a/src/VideoSupport.vala b/src/VideoSupport.vala
new file mode 100644
index 0000000..e23ba37
--- /dev/null
+++ b/src/VideoSupport.vala
@@ -0,0 +1,1188 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 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) {
+ 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);
+ if (mime_type.length >= 6 && mime_type[0:6] == "video/") {
+ 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 {
+ 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)) {
+ 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);
+ Posix.kill(thumbnailer_pid, Posix.SIGKILL);
+ }
+ 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 != posix_wexitstatus(child_status)) {
+ debug("Thumbnailer exited with error code: %d", posix_wexitstatus(child_status));
+ buf = null;
+ }
+
+ Posix.close(child_stdout);
+ 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(),
+ err.message);
+ }
+ }
+ }
+ }
+
+ private static bool interpreter_state_changed;
+ private static int current_state;
+ 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);
+ }
+
+ public static void init(ProgressMonitor? monitor = null) {
+ // Must initialize static variables here.
+ // TODO: set values at declaration time once the following Vala bug is fixed:
+ // https://bugzilla.gnome.org/show_bug.cgi?id=655594
+ interpreter_state_changed = false;
+ current_state = -1;
+ 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
+ string[] fake_args = new string[0];
+ unowned string[] fake_unowned_args = fake_args;
+ Gst.init(ref fake_unowned_args);
+
+ int saved_state = Config.Facade.get_instance().get_video_interpreter_state_cookie();
+ current_state = (int) Gst.Registry.get().get_feature_list_cookie();
+ if (saved_state == Config.Facade.NO_VIDEO_INTERPRETER_STATE) {
+ message("interpreter state cookie not found; assuming all video thumbnails are out of date");
+ interpreter_state_changed = true;
+ } else if (saved_state != current_state) {
+ message("interpreter state has changed; video thumbnails may be out of date");
+ interpreter_state_changed = true;
+ }
+
+ 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>();
+ Gee.ArrayList<Video> offline_videos = new Gee.ArrayList<Video>();
+ int count = all.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ Video video = new Video(all.get(ctr));
+
+ if (interpreter_state_changed)
+ video.set_is_interpretable(false);
+
+ 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);
+ }
+
+ global.add_many_to_trash(trashed_videos);
+ global.add_many_to_offline(offline_videos);
+ global.add_many(all_videos);
+ }
+
+ public static bool has_interpreter_state_changed() {
+ return interpreter_state_changed;
+ }
+
+ public static void notify_normal_thumbs_regenerated() {
+ if (normal_regen_complete)
+ return;
+
+ message("normal video thumbnail regeneration completed");
+
+ normal_regen_complete = true;
+ if (normal_regen_complete && offline_regen_complete)
+ save_interpreter_state();
+ }
+
+ public static void notify_offline_thumbs_regenerated() {
+ if (offline_regen_complete)
+ return;
+
+ message("offline video thumbnail regeneration completed");
+
+ offline_regen_complete = true;
+ if (normal_regen_complete && offline_regen_complete)
+ save_interpreter_state();
+ }
+
+ private static void save_interpreter_state() {
+ if (interpreter_state_changed) {
+ message("saving video interpreter state to configuration system");
+
+ Config.Facade.get_instance().set_video_interpreter_state_cookie(current_state);
+ interpreter_state_changed = false;
+ }
+ }
+
+ public static void terminate() {
+ }
+
+ public static ExporterUI? export_many(Gee.Collection<Video> videos, Exporter.CompletionCallback done,
+ 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,
+ Scaling.for_original(), ExportFormatParameters.unmodified()));
+ temp_exporter.export(done);
+ return temp_exporter;
+ }
+
+ // one video
+ if (videos.size == 1) {
+ Video video = null;
+ foreach (Video v in videos) {
+ 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);
+ AppWindow.get_instance().set_normal_cursor();
+ } catch (Error err) {
+ AppWindow.get_instance().set_normal_cursor();
+ export_error_dialog(save_as, false);
+ }
+
+ return null;
+ }
+
+ // multiple videos
+ 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);
+
+ return exporter;
+ }
+
+ protected override void commit_backlinks(SourceCollection? sources, string? backlinks) {
+ try {
+ VideoTable.get_instance().update_backlinks(get_video_id(), backlinks);
+ lock (backing_row) {
+ backing_row.backlinks = backlinks;
+ }
+ } catch (DatabaseError err) {
+ warning("Unable to update link state for %s: %s", to_string(), err.message);
+ }
+ }
+
+ protected override bool set_event_id(EventID event_id) {
+ lock (backing_row) {
+ bool committed = VideoTable.get_instance().set_event(backing_row.video_id, event_id);
+
+ if (committed)
+ backing_row.event_id = event_id;
+
+ return committed;
+ }
+ }
+
+ public static bool is_duplicate(File? file, string? full_md5) {
+ assert(file != null || full_md5 != null);
+#if !NO_DUPE_DETECTION
+ return VideoTable.get_instance().has_duplicate(file, full_md5);
+#else
+ 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())
+ return ImportResult.DATABASE_ERROR;
+ } 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());
+ } catch (DatabaseError err) {
+ 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_row.timestamp, backing_row.md5);
+ }
+
+ return backing;
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return ThumbnailCache.fetch(this, scale);
+ }
+
+ public override string get_master_md5() {
+ lock (backing_row) {
+ return backing_row.md5;
+ }
+ }
+
+ 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;
+ }
+
+ public override ImportID get_import_id() {
+ lock (backing_row) {
+ return backing_row.import_id;
+ }
+ }
+
+ 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;
+ }
+ }
+
+ public override void set_title(string? title) {
+ string? new_title = prep_title(title);
+
+ lock (backing_row) {
+ if (backing_row.title == new_title)
+ return;
+
+ try {
+ VideoTable.get_instance().set_title(backing_row.video_id, new_title);
+ } catch (DatabaseError e) {
+ AppWindow.database_error(e);
+ return;
+ }
+ // if we didn't short-circuit return in the catch clause above, then the change was
+ // successfully committed to the database, so update it in the in-memory row cache
+ backing_row.title = new_title;
+ }
+
+ notify_altered(new Alteration("metadata", "name"));
+ }
+
+ public override string? get_comment() {
+ lock (backing_row) {
+ return backing_row.comment;
+ }
+ }
+
+ public override bool set_comment(string? comment) {
+ string? new_comment = prep_title(comment);
+
+ lock (backing_row) {
+ if (backing_row.comment == new_comment)
+ return true;
+
+ try {
+ VideoTable.get_instance().set_comment(backing_row.video_id, new_comment);
+ } catch (DatabaseError e) {
+ AppWindow.database_error(e);
+ return false;
+ }
+ // if we didn't short-circuit return in the catch clause above, then the change was
+ // 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;
+ }
+
+
+ public override Rating get_rating() {
+ lock (backing_row) {
+ return backing_row.rating;
+ }
+ }
+
+ public override void set_rating(Rating rating) {
+ lock (backing_row) {
+ if ((!rating.is_valid()) || (rating == backing_row.rating))
+ return;
+
+ try {
+ VideoTable.get_instance().set_rating(get_video_id(), rating);
+ } catch (DatabaseError e) {
+ AppWindow.database_error(e);
+ return;
+ }
+ // if we didn't short-circuit return in the catch clause above, then the change was
+ // successfully committed to the database, so update it in the in-memory row cache
+ backing_row.rating = rating;
+ }
+
+ notify_altered(new Alteration("metadata", "rating"));
+ }
+
+ public override void increase_rating() {
+ lock (backing_row) {
+ set_rating(backing_row.rating.increase());
+ }
+ }
+
+ public override void decrease_rating() {
+ lock (backing_row) {
+ set_rating(backing_row.rating.decrease());
+ }
+ }
+
+ public override bool is_trashed() {
+ return is_flag_set(FLAG_TRASH);
+ }
+
+ public override bool is_offline() {
+ return is_flag_set(FLAG_OFFLINE);
+ }
+
+ public override void mark_offline() {
+ add_flags(FLAG_OFFLINE);
+ }
+
+ public override void mark_online() {
+ remove_flags(FLAG_OFFLINE);
+
+ if ((!get_is_interpretable()) && has_interpreter_state_changed())
+ check_is_interpretable().foreground_finish();
+ }
+
+ 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() {
+ lock (backing_row) {
+ return backing_row.exposure_time;
+ }
+ }
+
+ public void set_exposure_time(time_t time) {
+ lock (backing_row) {
+ try {
+ VideoTable.get_instance().set_exposure_time(backing_row.video_id, time);
+ } catch (Error e) {
+ debug("Warning - %s", e.message);
+ }
+ 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);
+ }
+ }
+
+ 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() {
+ lock (backing_row) {
+ return backing_row.timestamp;
+ }
+ }
+
+ public void set_master_timestamp(FileInfo info) {
+ TimeVal time_val = info.get_modification_time();
+
+ try {
+ lock (backing_row) {
+ if (backing_row.timestamp == time_val.tv_sec)
+ return;
+
+ VideoTable.get_instance().set_timestamp(backing_row.video_id, time_val.tv_sec);
+ backing_row.timestamp = time_val.tv_sec;
+ }
+ } catch (DatabaseError 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();
+ preview_frame = backing_file_reader.read_preview_frame();
+ } catch (VideoError e) {
+ // if we catch an error on an interpretable video here, then this video is
+ // 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;
+ try {
+ 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) {
+ 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/camera/Branch.vala b/src/camera/Branch.vala
new file mode 100644
index 0000000..63a5443
--- /dev/null
+++ b/src/camera/Branch.vala
@@ -0,0 +1,116 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Camera.Branch : Sidebar.Branch {
+ internal static Icon? cameras_icon = null;
+
+ private Gee.HashMap<DiscoveredCamera, Camera.SidebarEntry> camera_map = new Gee.HashMap<
+ DiscoveredCamera, Camera.SidebarEntry>();
+
+ public Branch() {
+ base (new Camera.Grouping(),
+ Sidebar.Branch.Options.HIDE_IF_EMPTY | Sidebar.Branch.Options.AUTO_OPEN_ON_NEW_CHILD,
+ camera_comparator);
+
+ foreach (DiscoveredCamera camera in CameraTable.get_instance().get_cameras())
+ add_camera(camera);
+
+ CameraTable.get_instance().camera_added.connect(on_camera_added);
+ CameraTable.get_instance().camera_removed.connect(on_camera_removed);
+ }
+
+ internal static void init() {
+ cameras_icon = new GLib.ThemedIcon(Resources.ICON_CAMERAS);
+ }
+
+ internal static void terminate() {
+ cameras_icon = null;
+ }
+
+ private static int camera_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ // Compare based on name.
+ int ret = a.get_sidebar_name().collate(b.get_sidebar_name());
+ if (ret == 0) {
+ // Cameras had same name! Fallback to URI comparison.
+ Camera.SidebarEntry? cam_a = a as Camera.SidebarEntry;
+ Camera.SidebarEntry? cam_b = b as Camera.SidebarEntry;
+ assert (cam_a != null && cam_b != null);
+ ret = cam_a.get_uri().collate(cam_b.get_uri());
+ }
+
+ return ret;
+ }
+
+ public Camera.SidebarEntry? get_entry_for_camera(DiscoveredCamera camera) {
+ return camera_map.get(camera);
+ }
+
+ private void on_camera_added(DiscoveredCamera camera) {
+ add_camera(camera);
+ }
+
+ private void on_camera_removed(DiscoveredCamera camera) {
+ remove_camera(camera);
+ }
+
+ private void add_camera(DiscoveredCamera camera) {
+ assert(!camera_map.has_key(camera));
+
+ Camera.SidebarEntry entry = new Camera.SidebarEntry(camera);
+ camera_map.set(camera, entry);
+
+ // want to show before adding page so the grouping is available to graft onto
+ graft(get_root(), entry);
+ }
+
+ private void remove_camera(DiscoveredCamera camera) {
+ assert(camera_map.has_key(camera));
+
+ Camera.SidebarEntry? entry = camera_map.get(camera);
+ assert(entry != null);
+
+ bool removed = camera_map.unset(camera);
+ assert(removed);
+
+ prune(entry);
+ }
+}
+
+public class Camera.Grouping : Sidebar.Grouping {
+ public Grouping() {
+ base (_("Cameras"), Camera.Branch.cameras_icon);
+ }
+}
+
+public class Camera.SidebarEntry : Sidebar.SimplePageEntry {
+ private DiscoveredCamera camera;
+ private string uri;
+
+ public SidebarEntry(DiscoveredCamera camera) {
+ this.camera = camera;
+ this.uri = camera.uri;
+ }
+
+ public override string get_sidebar_name() {
+ return camera.display_name ?? _("Camera");
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return camera.icon ?? Camera.Branch.cameras_icon;
+ }
+
+ protected override Page create_page() {
+ return new ImportPage(camera.gcamera, uri, get_sidebar_name(), get_sidebar_icon());
+ }
+
+ public string get_uri() {
+ return uri;
+ }
+}
+
diff --git a/src/camera/Camera.vala b/src/camera/Camera.vala
new file mode 100644
index 0000000..f6d1f4b
--- /dev/null
+++ b/src/camera/Camera.vala
@@ -0,0 +1,18 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Camera {
+
+public void init() throws Error {
+ Camera.Branch.init();
+}
+
+public void terminate() {
+ Camera.Branch.terminate();
+}
+
+}
+
diff --git a/src/camera/CameraTable.vala b/src/camera/CameraTable.vala
new file mode 100644
index 0000000..8466388
--- /dev/null
+++ b/src/camera/CameraTable.vala
@@ -0,0 +1,417 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * 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 GLib.Icon? icon;
+
+ public DiscoveredCamera(GPhoto.Camera gcamera, string uri, string display_name, GLib.Icon? 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;
+
+ // list of subsystems being monitored for events
+ private const string[] SUBSYSTEMS = { "usb", "block", null };
+
+ private static CameraTable instance = null;
+
+ private GUdev.Client client = new GUdev.Client(SUBSYSTEMS);
+ private OneShotScheduler camera_update_scheduler = null;
+ private GPhoto.Context null_context = new GPhoto.Context();
+ private GPhoto.CameraAbilitiesList abilities_list;
+ private VolumeMonitor volume_monitor;
+
+ private Gee.HashMap<string, DiscoveredCamera> camera_map = new Gee.HashMap<string, DiscoveredCamera>();
+
+ public signal void camera_added(DiscoveredCamera camera);
+
+ public signal void camera_removed(DiscoveredCamera camera);
+
+ private CameraTable() {
+ camera_update_scheduler = new OneShotScheduler("CameraTable update scheduler",
+ on_update_cameras);
+
+ // listen for interesting events on the specified subsystems
+ client.uevent.connect(on_udev_event);
+ volume_monitor = VolumeMonitor.get();
+ volume_monitor.volume_changed.connect(on_volume_changed);
+ volume_monitor.volume_added.connect(on_volume_changed);
+
+ // because loading the camera abilities list takes a bit of time and slows down app
+ // startup, delay loading it (and notifying any observers) for a small period of time,
+ // after the dust has settled
+ Timeout.add(500, delayed_init);
+ }
+
+ private bool delayed_init() {
+ // We disable this here so cameras that are already connected at the time
+ // the application is launched don't interfere with normal navigation...
+ ((LibraryWindow) AppWindow.get_instance()).set_page_switching_enabled(false);
+
+ try {
+ init_camera_table();
+ } catch (GPhotoError err) {
+ warning("Unable to initialize camera table: %s", err.message);
+
+ return false;
+ }
+
+ try {
+ update_camera_table();
+ } catch (GPhotoError err) {
+ warning("Unable to update camera table: %s", err.message);
+ }
+
+ // ...and re-enable it here, so that cameras connected -after- the initial
+ // populating of the table will trigger a switch to the import page, as before.
+ ((LibraryWindow) AppWindow.get_instance()).set_page_switching_enabled(true);
+ return false;
+ }
+
+ public static CameraTable get_instance() {
+ if (instance == null)
+ instance = new CameraTable();
+
+ return instance;
+ }
+
+ public Gee.Iterable<DiscoveredCamera> get_cameras() {
+ return camera_map.values;
+ }
+
+ public int get_count() {
+ return camera_map.size;
+ }
+
+ public DiscoveredCamera? get_for_uri(string uri) {
+ return camera_map.get(uri);
+ }
+
+ private void do_op(GPhoto.Result res, string op) throws GPhotoError {
+ if (res != GPhoto.Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Unable to %s: %s", (int) res, op, res.as_string());
+ }
+
+ private void init_camera_table() throws GPhotoError {
+ do_op(GPhoto.CameraAbilitiesList.create(out abilities_list), "create camera abilities list");
+ do_op(abilities_list.load(null_context), "load camera abilities list");
+ }
+
+ private string[] get_all_usb_cameras() {
+ string[] cameras = new string[0];
+
+ GLib.List<GUdev.Device> device_list = client.query_by_subsystem(null);
+ foreach (GUdev.Device device in device_list) {
+ string device_file = device.get_device_file();
+ if(
+ // only keep devices that have a non-null device file and that
+ // have both the ID_GPHOTO2 and GPHOTO2_DRIVER properties set
+ (device_file != null) &&
+ (device.has_property("ID_GPHOTO2")) &&
+ (device.has_property("GPHOTO2_DRIVER"))
+ ) {
+ int camera_bus, camera_device;
+ // extract the bus and device IDs from the device file string
+ // TODO: is it safe to parse the absolute path or should we be
+ // smarter and use a regex to only pick up the end of the path?
+ if (device_file.scanf("/dev/bus/usb/%d/%d", out camera_bus, out camera_device) < 2) {
+ critical("get_all_usb_cameras: Failed to scanf device file %s", device_file);
+
+ continue;
+ }
+ string camera = "usb:%.3d,%.3d".printf(camera_bus, camera_device);
+ debug("USB camera detected at %s", camera);
+ cameras += camera;
+ }
+ }
+
+ return cameras;
+ }
+
+ // USB (or libusb) is a funny beast; if only one USB device is present (i.e. the camera),
+ // then a single camera is detected at port usb:. However, if multiple USB devices are
+ // present (including non-cameras), then the first attached camera will be listed twice,
+ // first at usb:, then at usb:xxx,yyy. If the usb: device is removed, another usb:xxx,yyy
+ // device will lose its full-path name and be referred to as usb: only.
+ //
+ // This function gleans the full port name of a particular port, even if it's the unadorned
+ // "usb:", by using GUdev.
+ private bool usb_esp(int current_camera_count, string[] usb_cameras, string port,
+ out string full_port) {
+ // sanity
+ assert(current_camera_count > 0);
+
+ debug("USB ESP: current_camera_count=%d port=%s", current_camera_count, port);
+
+ full_port = null;
+
+ // if GPhoto detects one camera, and USB reports one camera, all is swell
+ if (current_camera_count == 1 && usb_cameras.length == 1) {
+ full_port = usb_cameras[0];
+
+ debug("USB ESP: port=%s full_port=%s", port, full_port);
+
+ return true;
+ }
+
+ // with more than one camera, skip the mirrored "usb:" port
+ if (port == "usb:") {
+ debug("USB ESP: Skipping %s", port);
+
+ return false;
+ }
+
+ // parse out the bus and device ID
+ int bus, device;
+ if (port.scanf("usb:%d,%d", out bus, out device) < 2) {
+ critical("USB ESP: Failed to scanf %s", port);
+
+ return false;
+ }
+
+ foreach (string usb_camera in usb_cameras) {
+ int camera_bus, camera_device;
+ if (usb_camera.scanf("usb:%d,%d", out camera_bus, out camera_device) < 2) {
+ critical("USB ESP: Failed to scanf %s", usb_camera);
+
+ continue;
+ }
+
+ if ((bus == camera_bus) && (device == camera_device)) {
+ full_port = port;
+
+ debug("USB ESP: port=%s full_port=%s", port, full_port);
+
+ return true;
+ }
+ }
+
+ debug("USB ESP: No matching bus/device found for port=%s", port);
+
+ return false;
+ }
+
+ public static string get_port_uri(string port) {
+ 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;
+ }
+
+ 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 GLib.Icon? get_icon_for_uuid(string uuid) {
+ foreach (Volume volume in volume_monitor.get_volumes()) {
+ if (volume.get_identifier(VolumeIdentifier.UUID) == uuid) {
+ return volume.get_icon();
+ }
+ }
+ return null;
+ }
+
+ 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;
+ do_op(GPhoto.PortInfoList.create(out port_info_list), "create port list");
+ do_op(port_info_list.load(), "load port list");
+
+ GPhoto.CameraList camera_list;
+ do_op(GPhoto.CameraList.create(out camera_list), "create camera list");
+ do_op(abilities_list.detect(port_info_list, camera_list, null_context), "detect cameras");
+
+ Gee.HashMap<string, string> detected_map = new Gee.HashMap<string, string>();
+
+ // walk the USB chain and find all PTP cameras; this is necessary for usb_esp
+ string[] usb_cameras = get_all_usb_cameras();
+
+ // go through the detected camera list and glean their ports
+ for (int ctr = 0; ctr < camera_list.count(); ctr++) {
+ string name;
+ do_op(camera_list.get_name(ctr, out name), "get detected camera name");
+
+ string port;
+ do_op(camera_list.get_value(ctr, out port), "get detected camera port");
+
+ debug("Detected %d/%d %s @ %s", ctr + 1, camera_list.count(), name, port);
+
+ // do some USB ESP, skipping ports that cannot be deduced
+ if (port.has_prefix("usb:")) {
+ string full_port;
+ if (!usb_esp(camera_list.count(), usb_cameras, port, out full_port))
+ continue;
+
+ port = full_port;
+ }
+
+ detected_map.set(port, name);
+ }
+
+ // find cameras that have disappeared
+ DiscoveredCamera[] missing = new DiscoveredCamera[0];
+ foreach (DiscoveredCamera camera in camera_map.values) {
+ GPhoto.PortInfo port_info;
+ string tmp_path;
+
+ do_op(camera.gcamera.get_port_info(out port_info),
+ "retrieve missing camera port information");
+
+#if WITH_GPHOTO_25
+ port_info.get_path(out tmp_path);
+#else
+ tmp_path = port_info.path;
+#endif
+
+ GPhoto.CameraAbilities abilities;
+ do_op(camera.gcamera.get_abilities(out abilities), "retrieve camera abilities");
+
+ if (detected_map.has_key(tmp_path)) {
+ debug("Found camera for %s @ %s in detected map", abilities.model, tmp_path);
+
+ continue;
+ }
+
+ debug("%s @ %s missing", abilities.model, tmp_path);
+
+ missing += camera;
+ }
+
+ // have to remove from hash map outside of iterator
+ foreach (DiscoveredCamera camera in missing) {
+ GPhoto.PortInfo port_info;
+ string tmp_path;
+
+ do_op(camera.gcamera.get_port_info(out port_info),
+ "retrieve missing camera port information");
+#if WITH_GPHOTO_25
+ port_info.get_path(out tmp_path);
+#else
+ tmp_path = port_info.path;
+#endif
+
+ GPhoto.CameraAbilities abilities;
+ do_op(camera.gcamera.get_abilities(out abilities), "retrieve missing camera abilities");
+
+ debug("Removing from camera table: %s @ %s", abilities.model, tmp_path);
+
+ camera_map.unset(get_port_uri(tmp_path));
+
+ camera_removed(camera);
+ }
+
+ // add cameras which were not present before
+ foreach (string port in detected_map.keys) {
+ string name = detected_map.get(port);
+ string display_name = null;
+ GLib.Icon? icon = null;
+ string uri = get_port_uri(port);
+
+ if (camera_map.has_key(uri)) {
+ // already known about
+ debug("%s @ %s already registered, skipping", name, port);
+
+ continue;
+ }
+
+ // 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");
+ }
+ }
+ 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));
+
+ GPhoto.PortInfo port_info;
+ string tmp_path;
+
+ do_op(port_info_list.get_info(index, out port_info), "get port info for %s".printf(port));
+#if WITH_GPHOTO_25
+ port_info.get_path(out tmp_path);
+#else
+ tmp_path = port_info.path;
+#endif
+
+ // this should match, every time
+ assert(port == tmp_path);
+
+ index = abilities_list.lookup_model(name);
+ if (index < 0)
+ do_op((GPhoto.Result) index, "lookup camera model %s".printf(name));
+
+ GPhoto.CameraAbilities camera_abilities;
+ 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);
+ camera_map.set(uri, camera);
+
+ camera_added(camera);
+ }
+ }
+
+ private void on_udev_event(string action, GUdev.Device device) {
+ debug("udev event: %s on %s", action, device.get_name());
+
+ // Device add/removes often arrive in pairs; this allows for a single
+ // update to occur when they come in all at once
+ camera_update_scheduler.after_timeout(UPDATE_DELAY_MSEC, true);
+ }
+
+ public void on_volume_changed(Volume volume) {
+ camera_update_scheduler.after_timeout(UPDATE_DELAY_MSEC, true);
+ }
+
+ private void on_update_cameras() {
+ try {
+ get_instance().update_camera_table();
+ } catch (GPhotoError err) {
+ warning("Error updating camera table: %s", err.message);
+ }
+ }
+}
+
diff --git a/src/camera/GPhoto.vala b/src/camera/GPhoto.vala
new file mode 100644
index 0000000..a1a46cb
--- /dev/null
+++ b/src/camera/GPhoto.vala
@@ -0,0 +1,367 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public errordomain GPhotoError {
+ LIBRARY
+}
+
+namespace GPhoto {
+ // ContextWrapper assigns signals to the various GPhoto.Context callbacks, as well as spins
+ // the event loop at opportune times.
+ public class ContextWrapper {
+ public Context context = new Context();
+
+ public ContextWrapper() {
+ context.set_idle_func(on_idle);
+ context.set_error_func(on_error);
+ context.set_status_func(on_status);
+ context.set_message_func(on_message);
+ context.set_progress_funcs(on_progress_start, on_progress_update, on_progress_stop);
+ }
+
+ public virtual void idle() {
+ }
+
+#if WITH_GPHOTO_25
+
+ public virtual void error(string text, void *data) {
+ }
+
+ public virtual void status(string text, void *data) {
+ }
+
+ public virtual void message(string text, void *data) {
+ }
+
+ public virtual void progress_start(float current, string text, void *data) {
+ }
+
+ public virtual void progress_update(float current, void *data) {
+ }
+
+ public virtual void progress_stop() {
+ }
+
+ private void on_idle(Context context) {
+ idle();
+ }
+
+ private void on_error(Context context, string text) {
+ error(text, null);
+ }
+
+ private void on_status(Context context, string text) {
+ status(text, null);
+ }
+
+ private void on_message(Context context, string text) {
+ message(text, null);
+ }
+
+ private uint on_progress_start(Context context, float target, string text) {
+ progress_start(target, text, null);
+
+ return 0;
+ }
+
+ private void on_progress_update(Context context, uint id, float current) {
+ progress_update(current, null);
+ }
+
+ private void on_progress_stop(Context context, uint id) {
+ progress_stop();
+ }
+
+#else
+
+ public virtual void error(string format, void *va_list) {
+ }
+
+ public virtual void status(string format, void *va_list) {
+ }
+
+ public virtual void message(string format, void *va_list) {
+ }
+
+ public virtual void progress_start(float target, string format, void *va_list) {
+ }
+
+ public virtual void progress_update(float current) {
+ }
+
+ public virtual void progress_stop() {
+ }
+
+ private void on_idle(Context context) {
+ idle();
+ }
+
+ private void on_error(Context context, string format, void *va_list) {
+ error(format, va_list);
+ }
+
+ private void on_status(Context context, string format, void *va_list) {
+ status(format, va_list);
+ }
+
+ private void on_message(Context context, string format, void *va_list) {
+ message(format, va_list);
+ }
+
+ private uint on_progress_start(Context context, float target, string format, void *va_list) {
+ progress_start(target, format, va_list);
+
+ return 0;
+ }
+
+ private void on_progress_update(Context context, uint id, float current) {
+ progress_update(current);
+ }
+
+ private void on_progress_stop(Context context, uint id) {
+ progress_stop();
+ }
+
+#endif
+ }
+
+ public class SpinIdleWrapper : ContextWrapper {
+ public SpinIdleWrapper() {
+ }
+
+ public override void idle() {
+ base.idle();
+
+ spin_event_loop();
+ }
+#if WITH_GPHOTO_25
+ public override void progress_update(float current, void *data) {
+ base.progress_update(current, data);
+
+ spin_event_loop();
+ }
+#else
+ public override void progress_update(float current) {
+ base.progress_update(current);
+
+ spin_event_loop();
+ }
+#endif
+ }
+
+ // For CameraFileInfoFile, CameraFileInfoPreview, and CameraStorageInformation. See:
+ // http://redmine.yorba.org/issues/1851
+ // 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;
+ public const int MAX_BASEDIR_LENGTH = 255;
+
+ public bool get_info(Context context, Camera camera, string folder, string filename,
+ out CameraFileInfo info) throws Error {
+ if (folder.length > MAX_BASEDIR_LENGTH || filename.length > MAX_FILENAME_LENGTH) {
+ info = {};
+
+ return false;
+ }
+
+ Result res = camera.get_file_info(folder, filename, out info, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file information for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ return true;
+ }
+
+ // Libgphoto will in some instances refuse to get metadata from a camera, but the camera is accessable as a
+ // filesystem. In these cases shotwell can access the file directly. See:
+ // http://redmine.yorba.org/issues/2959
+ public PhotoMetadata? get_fallback_metadata(Camera camera, Context context, string folder, string filename) {
+ GPhoto.CameraStorageInformation *sifs = null;
+ int count = 0;
+ camera.get_storageinfo(&sifs, out count, context);
+
+ GPhoto.PortInfo port_info;
+ camera.get_port_info(out port_info);
+
+ string path;
+#if WITH_GPHOTO_25
+ port_info.get_path(out path);
+#else
+ path = port_info.path;
+#endif
+
+ string prefix = "disk:";
+ if(path.has_prefix(prefix))
+ path = path[prefix.length:path.length];
+ else
+ return null;
+
+ PhotoMetadata? metadata = new PhotoMetadata();
+ try {
+ metadata.read_from_file(File.new_for_path(path + folder + "/" + filename));
+ } catch {
+ metadata = null;
+ }
+
+ return metadata;
+ }
+
+ public Gdk.Pixbuf? load_preview(Context context, Camera camera, string folder, string filename,
+ out uint8[] raw, out size_t raw_length) throws Error {
+ raw = null;
+ raw_length = 0;
+
+ try {
+ raw = load_file_into_buffer(context, camera, folder, filename, GPhoto.CameraFileType.PREVIEW);
+ } catch {
+ PhotoMetadata metadata = get_fallback_metadata(camera, context, folder, filename);
+ if(null == metadata)
+ return null;
+ if(0 == metadata.get_preview_count())
+ return null;
+ PhotoPreview? preview = metadata.get_preview(metadata.get_preview_count() - 1);
+ raw = preview.flatten();
+ }
+
+ if (raw == null) {
+ raw_length = 0;
+ return null;
+ }
+
+ raw_length = raw.length;
+
+ // Try to make sure last two bytes are JPEG footer.
+ // This is necessary because GPhoto sometimes includes a few extra bytes. See
+ // Yorba bug #2905 and the following GPhoto bug:
+ // http://sourceforge.net/tracker/?func=detail&aid=3141521&group_id=8874&atid=108874
+ if (raw_length > 32) {
+ for (size_t i = raw_length - 2; i > raw_length - 32; i--) {
+ if (raw[i] == Jpeg.MARKER_PREFIX && raw[i + 1] == Jpeg.Marker.EOI) {
+ debug("Adjusted length of thumbnail for: %s", filename);
+ raw_length = i + 2;
+ break;
+ }
+ }
+ }
+
+ MemoryInputStream mins = new MemoryInputStream.from_data(raw, null);
+ return new Gdk.Pixbuf.from_stream_at_scale(mins, ImportPreview.MAX_SCALE, ImportPreview.MAX_SCALE, true, null);
+ }
+
+ public Gdk.Pixbuf? load_image(Context context, Camera camera, string folder, string filename)
+ throws Error {
+ InputStream ins = load_file_into_stream(context, camera, folder, filename, GPhoto.CameraFileType.NORMAL);
+ if (ins == null)
+ return null;
+
+ return new Gdk.Pixbuf.from_stream(ins, null);
+ }
+
+ public void save_image(Context context, Camera camera, string folder, string filename,
+ File dest_file) throws Error {
+ GPhoto.CameraFile camera_file;
+ GPhoto.Result res = GPhoto.CameraFile.create(out camera_file);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error allocating camera file: %s", (int) res, res.as_string());
+
+ res = camera.get_file(folder, filename, GPhoto.CameraFileType.NORMAL, camera_file, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file object for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ res = camera_file.save(dest_file.get_path());
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error copying file %s/%s to %s: %s", (int) res,
+ folder, filename, dest_file.get_path(), res.as_string());
+ }
+
+ public PhotoMetadata? load_metadata(Context context, Camera camera, string folder, string filename)
+ throws Error {
+ uint8[] camera_raw = null;
+ try {
+ camera_raw = load_file_into_buffer(context, camera, folder, filename, GPhoto.CameraFileType.EXIF);
+ } catch {
+ return get_fallback_metadata(camera, context, folder, filename);
+ }
+
+ if (camera_raw == null || camera_raw.length == 0)
+ return null;
+
+ PhotoMetadata metadata = new PhotoMetadata();
+ metadata.read_from_app1_segment(camera_raw);
+
+ return metadata;
+ }
+
+ // Returns an InputStream for the requested camera file. The stream should be used
+ // immediately rather than stored, as the backing is temporary in nature.
+ public InputStream load_file_into_stream(Context context, Camera camera, string folder, string filename,
+ GPhoto.CameraFileType filetype) throws Error {
+ GPhoto.CameraFile camera_file;
+ GPhoto.Result res = GPhoto.CameraFile.create(out camera_file);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error allocating camera file: %s", (int) res, res.as_string());
+
+ res = camera.get_file(folder, filename, filetype, camera_file, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file object for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ // if entire file fits in memory, return a stream from that ... can't merely wrap
+ // MemoryInputStream around the camera_file buffer, as that will be destroyed when the
+ // function returns
+ unowned uint8 *data;
+ ulong data_len;
+ res = camera_file.get_data_and_size(out data, out data_len);
+ if (res == Result.OK) {
+ uint8[] buffer = new uint8[data_len];
+ Memory.copy(buffer, data, buffer.length);
+
+ return new MemoryInputStream.from_data(buffer, on_mins_destroyed);
+ }
+
+ // if not stored in memory, try copying it to a temp file and then reading out of that
+ File temp = AppDirs.get_temp_dir().get_child("import.tmp");
+ res = camera_file.save(temp.get_path());
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error copying file %s/%s to %s: %s", (int) res,
+ folder, filename, temp.get_path(), res.as_string());
+
+ return temp.read(null);
+ }
+
+ private static void on_mins_destroyed(void *data) {
+ free(data);
+ }
+
+ // Returns a buffer with the requested file, if within reason. Use load_file for larger files.
+ public uint8[]? load_file_into_buffer(Context context, Camera camera, string folder,
+ string filename, CameraFileType filetype) throws Error {
+ GPhoto.CameraFile camera_file;
+ GPhoto.Result res = GPhoto.CameraFile.create(out camera_file);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error allocating camera file: %s", (int) res, res.as_string());
+
+ res = camera.get_file(folder, filename, filetype, camera_file, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file object for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ // if buffer can be loaded into memory, return a copy of that (can't return buffer itself
+ // as it will be destroyed when the camera_file is unref'd)
+ unowned uint8 *data;
+ ulong data_len;
+ res = camera_file.get_data_and_size(out data, out data_len);
+ if (res != Result.OK)
+ return null;
+
+ uint8[] buffer = new uint8[data_len];
+ Memory.copy(buffer, data, buffer.length);
+
+ return buffer;
+ }
+}
+
diff --git a/src/camera/ImportPage.vala b/src/camera/ImportPage.vala
new file mode 100644
index 0000000..823a458
--- /dev/null
+++ b/src/camera/ImportPage.vala
@@ -0,0 +1,1799 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+private class ImportSourceCollection : SourceCollection {
+ public ImportSourceCollection(string name) {
+ base (name);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is ImportSource;
+ }
+}
+
+abstract class ImportSource : ThumbnailSource, Indexable {
+ private string camera_name;
+ private GPhoto.Camera camera;
+ private int fsid;
+ private string folder;
+ private string filename;
+ private ulong file_size;
+ private time_t modification_time;
+ private Gdk.Pixbuf? preview = null;
+ private string? indexable_keywords = null;
+
+ public 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;
+ this.camera = camera;
+ this.fsid = fsid;
+ this.folder = folder;
+ this.filename = filename;
+ this.file_size = file_size;
+ this.modification_time = modification_time;
+ indexable_keywords = prepare_indexable_string(filename);
+ }
+
+ protected void set_preview(Gdk.Pixbuf? preview) {
+ this.preview = preview;
+ }
+
+ public string get_camera_name() {
+ return camera_name;
+ }
+
+ public GPhoto.Camera get_camera() {
+ return camera;
+ }
+
+ public int get_fsid() {
+ return fsid;
+ }
+
+ public string get_folder() {
+ return folder;
+ }
+
+ public string get_filename() {
+ return filename;
+ }
+
+ public ulong get_filesize() {
+ return file_size;
+ }
+
+ public time_t get_modification_time() {
+ return modification_time;
+ }
+
+ public virtual Gdk.Pixbuf? get_preview() {
+ return preview;
+ }
+
+ public virtual time_t get_exposure_time() {
+ return get_modification_time();
+ }
+
+ public string? get_fulldir() {
+ return ImportPage.get_fulldir(get_camera(), get_camera_name(), get_fsid(), get_folder());
+ }
+
+ public override string to_string() {
+ return "%s %s/%s".printf(get_camera_name(), get_folder(), get_filename());
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ debug("Deleting %s from %s", to_string(), camera_name);
+
+ string? fulldir = get_fulldir();
+ if (fulldir == null) {
+ warning("Skipping deleting %s from %s: invalid folder name", to_string(), camera_name);
+
+ return base.internal_delete_backing();
+ }
+
+ GPhoto.Result result = get_camera().delete_file(fulldir, get_filename(),
+ ImportPage.spin_idle_context.context);
+ if (result != GPhoto.Result.OK)
+ warning("Error deleting %s from %s: %s", to_string(), camera_name, result.to_full_string());
+
+ return base.internal_delete_backing() && (result == GPhoto.Result.OK);
+ }
+
+ public unowned string? get_indexable_keywords() {
+ return indexable_keywords;
+ }
+}
+
+class VideoImportSource : ImportSource {
+ public VideoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder,
+ string filename, ulong file_size, time_t modification_time) {
+ base(camera_name, camera, fsid, folder, filename, file_size, modification_time);
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return create_thumbnail(scale);
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ if (get_preview() == null)
+ return null;
+
+ // this satifies the return-a-new-instance requirement of create_thumbnail( ) because
+ // scale_pixbuf( ) allocates a new pixbuf
+ return (scale > 0) ? scale_pixbuf(get_preview(), scale, Gdk.InterpType.BILINEAR, true) :
+ get_preview();
+ }
+
+ public override string get_typename() {
+ return "videoimport";
+ }
+
+ public override int64 get_instance_id() {
+ return get_object_id();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return PhotoFileFormat.get_system_default_format();
+ }
+
+ public override string get_name() {
+ return get_filename();
+ }
+
+ public void update(Gdk.Pixbuf? preview) {
+ set_preview((preview != null) ? preview : Resources.get_noninterpretable_badge_pixbuf());
+ }
+}
+
+class PhotoImportSource : ImportSource {
+ public const Gdk.InterpType INTERP = Gdk.InterpType.BILINEAR;
+
+ private PhotoFileFormat file_format;
+ private string? preview_md5 = null;
+ private PhotoMetadata? metadata = null;
+ private string? exif_md5 = null;
+ 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) {
+ base(camera_name, camera, fsid, folder, filename, file_size, modification_time);
+ this.file_format = file_format;
+ }
+
+ public override string get_name() {
+ string? title = get_title();
+
+ return !is_string_empty(title) ? title : get_filename();
+ }
+
+ public override string get_typename() {
+ return "photoimport";
+ }
+
+ public override int64 get_instance_id() {
+ return get_object_id();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return (file_format.can_write()) ? file_format :
+ PhotoFileFormat.get_system_default_format();
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ if (get_preview() == null)
+ return null;
+
+ // this satifies the return-a-new-instance requirement of create_thumbnail( ) because
+ // scale_pixbuf( ) allocates a new pixbuf
+ return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview();
+ }
+
+ // Needed because previews and exif are loaded after other information has been gathered.
+ public void update(Gdk.Pixbuf? preview, string? preview_md5, PhotoMetadata? metadata, string? exif_md5) {
+ set_preview(preview);
+ this.preview_md5 = preview_md5;
+ this.metadata = metadata;
+ this.exif_md5 = exif_md5;
+ }
+
+ public override time_t get_exposure_time() {
+ if (metadata == null)
+ return get_modification_time();
+
+ MetadataDateTime? date_time = metadata.get_exposure_date_time();
+
+ return (date_time != null) ? date_time.get_timestamp() : get_modification_time();
+ }
+
+ public string? get_title() {
+ return (metadata != null) ? metadata.get_title() : null;
+ }
+
+ public PhotoMetadata? get_metadata() {
+ if (associated != null)
+ return associated.get_metadata();
+
+ return metadata;
+ }
+
+ public override Gdk.Pixbuf? get_preview() {
+ if (associated != null)
+ return associated.get_preview();
+
+ if (base.get_preview() != null)
+ return base.get_preview();
+
+ return null;
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ if (get_preview() == null)
+ return null;
+
+ return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview();
+ }
+
+ public PhotoFileFormat get_file_format() {
+ return file_format;
+ }
+
+ public string? get_preview_md5() {
+ return preview_md5;
+ }
+
+ public void set_associated(PhotoImportSource? associated) {
+ this.associated = associated;
+ }
+
+ public PhotoImportSource? get_associated() {
+ return associated;
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ bool ret = base.internal_delete_backing();
+ if (associated != null)
+ ret &= associated.internal_delete_backing();
+ return ret;
+ }
+}
+
+class ImportPreview : MediaSourceItem {
+ public const int MAX_SCALE = 128;
+
+ private static Gdk.Pixbuf placeholder_preview = null;
+
+ private DuplicatedFile? duplicated_file;
+
+ public ImportPreview(ImportSource source) {
+ base(source, Dimensions(), source.get_name(), null);
+
+ this.duplicated_file = null;
+
+ // draw sprocket holes as visual indications on video previews
+ if (source is VideoImportSource)
+ set_enable_sprockets(true);
+
+ // scale down pixbuf if necessary
+ Gdk.Pixbuf pixbuf = null;
+ try {
+ pixbuf = source.get_thumbnail(0);
+ } catch (Error err) {
+ warning("Unable to fetch loaded import preview for %s: %s", to_string(), err.message);
+ }
+
+ // use placeholder if no preview available
+ bool using_placeholder = (pixbuf == null);
+ if (pixbuf == null) {
+ if (placeholder_preview == null) {
+ placeholder_preview = AppWindow.get_instance().render_icon(Gtk.Stock.MISSING_IMAGE,
+ Gtk.IconSize.DIALOG, null);
+ placeholder_preview = scale_pixbuf(placeholder_preview, MAX_SCALE,
+ Gdk.InterpType.BILINEAR, true);
+ }
+
+ pixbuf = placeholder_preview;
+ }
+
+ // scale down if too large
+ if (pixbuf.get_width() > MAX_SCALE || pixbuf.get_height() > MAX_SCALE)
+ pixbuf = scale_pixbuf(pixbuf, MAX_SCALE, PhotoImportSource.INTERP, false);
+
+ if (source is PhotoImportSource) {
+ // honor rotation for photos -- we don't care about videos since they can't be rotated
+ PhotoImportSource photo_import_source = source as PhotoImportSource;
+ if (!using_placeholder && photo_import_source.get_metadata() != null)
+ pixbuf = photo_import_source.get_metadata().get_orientation().rotate_pixbuf(pixbuf);
+
+ if (photo_import_source.get_associated() != null) {
+ set_subtitle("<small>%s</small>".printf(_("RAW+JPEG")), true);
+ }
+ }
+
+ set_image(pixbuf);
+ }
+
+ public bool is_already_imported() {
+ PhotoImportSource photo_import_source = get_import_source() as PhotoImportSource;
+ if (photo_import_source != null) {
+ string? preview_md5 = photo_import_source.get_preview_md5();
+ PhotoFileFormat file_format = photo_import_source.get_file_format();
+
+ // ignore trashed duplicates
+ if (!is_string_empty(preview_md5)
+ && LibraryPhoto.has_nontrash_duplicate(null, preview_md5, null, file_format)) {
+
+ duplicated_file = DuplicatedFile.create_from_photo_id(
+ LibraryPhoto.get_nontrash_duplicate(null, preview_md5, null, file_format));
+
+ return true;
+ }
+
+ // Because gPhoto doesn't reliably return thumbnails for RAW files, and because we want
+ // to avoid downloading huge RAW files during an "import all" only to determine they're
+ // duplicates, use the image's basename and filesize to do duplicate detection
+ if (file_format == PhotoFileFormat.RAW) {
+ uint64 filesize = get_import_source().get_filesize();
+ // unlikely to be a problem, but what the hay
+ if (filesize <= int64.MAX) {
+ if (LibraryPhoto.global.has_basename_filesize_duplicate(
+ get_import_source().get_filename(), (int64) filesize)) {
+
+ duplicated_file = DuplicatedFile.create_from_photo_id(
+ LibraryPhoto.global.get_basename_filesize_duplicate(
+ get_import_source().get_filename(), (int64) filesize));
+
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ VideoImportSource video_import_source = get_import_source() as VideoImportSource;
+ if (video_import_source != null) {
+ // Unlike photos, if a video does have a thumbnail (i.e. gphoto2 can retrieve one from
+ // a sidecar file), it will be unavailable to Shotwell during the import process, so
+ // no comparison is available. Instead, like RAW files, use name and filesize to
+ // do a less-reliable but better-than-nothing comparison
+ if (Video.global.has_basename_filesize_duplicate(video_import_source.get_filename(),
+ video_import_source.get_filesize())) {
+
+ duplicated_file = DuplicatedFile.create_from_video_id(
+ Video.global.get_basename_filesize_duplicate(
+ video_import_source.get_filename(),
+ video_import_source.get_filesize()));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ public DuplicatedFile? get_duplicated_file() {
+ if (!is_already_imported())
+ return null;
+
+ return duplicated_file;
+ }
+
+ public ImportSource get_import_source() {
+ return (ImportSource) get_source();
+ }
+}
+
+public class CameraViewTracker : Core.ViewTracker {
+ public CameraAccumulator all = new CameraAccumulator();
+ public CameraAccumulator visible = new CameraAccumulator();
+ public CameraAccumulator selected = new CameraAccumulator();
+
+ public CameraViewTracker(ViewCollection collection) {
+ base (collection);
+
+ start(all, visible, selected);
+ }
+}
+
+public class CameraAccumulator : Object, Core.TrackerAccumulator {
+ public int total { get; private set; default = 0; }
+ public int photos { get; private set; default = 0; }
+ public int videos { get; private set; default = 0; }
+ public int raw { get; private set; default = 0; }
+
+ public bool include(DataObject object) {
+ ImportSource source = (ImportSource) ((DataView) object).get_source();
+
+ total++;
+
+ PhotoImportSource? photo = source as PhotoImportSource;
+ if (photo != null && photo.get_file_format() != PhotoFileFormat.RAW)
+ photos++;
+ else if (photo != null && photo.get_file_format() == PhotoFileFormat.RAW)
+ raw++;
+ else if (source is VideoImportSource)
+ videos++;
+
+ // because of total, always fire "updated"
+ return true;
+ }
+
+ public bool uninclude(DataObject object) {
+ ImportSource source = (ImportSource) ((DataView) object).get_source();
+
+ total++;
+
+ PhotoImportSource? photo = source as PhotoImportSource;
+ if (photo != null && photo.get_file_format() != PhotoFileFormat.RAW) {
+ assert(photos > 0);
+ photos--;
+ } else if (photo != null && photo.get_file_format() == PhotoFileFormat.RAW) {
+ assert(raw > 0);
+ raw--;
+ } else if (source is VideoImportSource) {
+ assert(videos > 0);
+ videos--;
+ }
+
+ // because of total, always fire "updated"
+ return true;
+ }
+
+ public bool altered(DataObject object, Alteration alteration) {
+ // no alteration affects accumulated data
+ return false;
+ }
+
+ public string to_string() {
+ return "%d total/%d photos/%d videos/%d raw".printf(total, photos, videos, raw);
+ }
+}
+
+public class ImportPage : CheckerboardPage {
+ private const string UNMOUNT_FAILED_MSG = _("Unable to unmount camera. Try unmounting the camera from the file manager.");
+
+ private class ImportViewManager : ViewManager {
+ private ImportPage owner;
+
+ public ImportViewManager(ImportPage owner) {
+ this.owner = owner;
+ }
+
+ public override DataView create_view(DataSource source) {
+ return new ImportPreview((ImportSource) source);
+ }
+ }
+
+ private class CameraImportJob : BatchImportJob {
+ private GPhoto.ContextWrapper context;
+ private ImportSource import_file;
+ private GPhoto.Camera camera;
+ private string fulldir;
+ private string filename;
+ private uint64 filesize;
+ private PhotoMetadata metadata;
+ private time_t exposure_time;
+ private CameraImportJob? associated = null;
+ private BackingPhotoRow? associated_file = null;
+ private DuplicatedFile? duplicated_file;
+
+ public CameraImportJob(GPhoto.ContextWrapper context, ImportSource import_file,
+ DuplicatedFile? duplicated_file = null) {
+ this.context = context;
+ this.import_file = import_file;
+ this.duplicated_file = duplicated_file;
+
+ // stash everything called in prepare(), as it may/will be called from a separate thread
+ camera = import_file.get_camera();
+ fulldir = import_file.get_fulldir();
+ // this should've been caught long ago when the files were first enumerated
+ 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;
+ exposure_time = import_file.get_exposure_time();
+ }
+
+ public time_t get_exposure_time() {
+ return exposure_time;
+ }
+
+ public override DuplicatedFile? get_duplicated_file() {
+ return duplicated_file;
+ }
+
+ public override time_t get_exposure_time_override() {
+ return (import_file is VideoImportSource) ? get_exposure_time() : 0;
+ }
+
+ public override string get_dest_identifier() {
+ return filename;
+ }
+
+ public override string get_source_identifier() {
+ return import_file.get_filename();
+ }
+
+ public override string get_basename() {
+ return filename;
+ }
+
+ public override string get_path() {
+ return fulldir;
+ }
+
+ public override void set_associated(BatchImportJob associated) {
+ this.associated = associated as CameraImportJob;
+ }
+
+ public ImportSource get_source() {
+ return import_file;
+ }
+
+ public override bool is_directory() {
+ return false;
+ }
+
+ public override bool determine_file_size(out uint64 filesize, out File file) {
+ file = null;
+ filesize = this.filesize;
+
+ return true;
+ }
+
+ public override bool prepare(out File file_to_import, out bool copy_to_library) throws Error {
+ file_to_import = null;
+ copy_to_library = false;
+
+ File dest_file = null;
+ try {
+ bool collision;
+ dest_file = LibraryFiles.generate_unique_file(filename, metadata, exposure_time,
+ out collision);
+ } catch (Error err) {
+ warning("Unable to generate local file for %s: %s", import_file.get_filename(),
+ err.message);
+ }
+
+ if (dest_file == null) {
+ message("Unable to generate local file for %s", import_file.get_filename());
+
+ return false;
+ }
+
+ // always blacklist the copied images from the LibraryMonitor, otherwise it'll think
+ // they should be auto-imported
+ LibraryMonitor.blacklist_file(dest_file, "CameraImportJob.prepare");
+ try {
+ GPhoto.save_image(context.context, camera, fulldir, filename, dest_file);
+ } finally {
+ LibraryMonitor.unblacklist_file(dest_file);
+ }
+
+ // Copy over associated file, if it exists.
+ if (associated != null) {
+ try {
+ associated_file =
+ RawDeveloper.CAMERA.create_backing_row_for_development(dest_file.get_path(),
+ associated.get_basename());
+ } catch (Error err) {
+ warning("Unable to generate backing associated file for %s: %s", associated.filename,
+ err.message);
+ }
+
+ if (associated_file == null) {
+ message("Unable to generate backing associated file for %s", associated.filename);
+ return false;
+ }
+
+ File assoc_dest = File.new_for_path(associated_file.filepath);
+ LibraryMonitor.blacklist_file(assoc_dest, "CameraImportJob.prepare");
+ try {
+ GPhoto.save_image(context.context, camera, associated.fulldir, associated.filename,
+ assoc_dest);
+ } finally {
+ LibraryMonitor.unblacklist_file(assoc_dest);
+ }
+ }
+
+ file_to_import = dest_file;
+ copy_to_library = false;
+
+ return true;
+ }
+
+ public override bool complete(MediaSource source, BatchImportRoll import_roll) throws Error {
+ bool ret = false;
+ if (source is Photo) {
+ Photo photo = source as Photo;
+
+ // Associate paired JPEG with RAW photo.
+ if (associated_file != null) {
+ photo.add_backing_photo_for_development(RawDeveloper.CAMERA, associated_file);
+ ret = true;
+ photo.set_raw_developer(Config.Facade.get_instance().get_default_raw_developer());
+ }
+ }
+ return ret;
+ }
+ }
+
+ private class ImportPageSearchViewFilter : SearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT | SearchFilterCriteria.MEDIA;
+ }
+
+ public override bool predicate(DataView view) {
+ ImportSource source = ((ImportPreview) view).get_import_source();
+
+ // Media type.
+ if ((bool) (SearchFilterCriteria.MEDIA & get_criteria()) && filter_by_media_type()) {
+ if (source is VideoImportSource) {
+ if (!show_media_video)
+ return false;
+ } else if (source is PhotoImportSource) {
+ PhotoImportSource photo = source as PhotoImportSource;
+ if (photo.get_file_format() == PhotoFileFormat.RAW) {
+ if (photo.get_associated() != null) {
+ if (!show_media_photos && !show_media_raw)
+ return false;
+ } else if (!show_media_raw) {
+ return false;
+ }
+ } else if (!show_media_photos)
+ return false;
+ }
+ }
+
+ if ((bool) (SearchFilterCriteria.TEXT & get_criteria())) {
+ unowned string? keywords = source.get_indexable_keywords();
+ if (is_string_empty(keywords))
+ return false;
+
+ // Return false if the word isn't found, true otherwise.
+ foreach (unowned string word in get_search_filter_words()) {
+ if (!keywords.contains(word))
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ // View filter for already imported filter.
+ private class HideImportedViewFilter : ViewFilter {
+ public override bool predicate(DataView view) {
+ return !((ImportPreview) view).is_already_imported();
+ }
+ }
+
+ public static GPhoto.ContextWrapper null_context = null;
+ public static GPhoto.SpinIdleWrapper spin_idle_context = null;
+
+ private SourceCollection import_sources = null;
+ 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 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 GLib.Icon? icon;
+ private ImportPageSearchViewFilter search_filter = new ImportPageSearchViewFilter();
+ private HideImportedViewFilter hide_imported_filter = new HideImportedViewFilter();
+ private CameraViewTracker tracker;
+
+#if UNITY_SUPPORT
+ UnityProgressBar uniprobar = UnityProgressBar.get_instance();
+#endif
+
+ public enum RefreshResult {
+ OK,
+ BUSY,
+ LOCKED,
+ LIBRARY_ERROR
+ }
+
+ public ImportPage(GPhoto.Camera camera, string uri, string? display_name = null, GLib.Icon? icon = null) {
+ base(_("Camera"));
+ this.camera = camera;
+ this.uri = uri;
+ this.import_sources = new ImportSourceCollection("ImportSources for %s".printf(uri));
+ this.icon = icon;
+
+ 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);
+
+ // Mount.unmounted signal is *only* fired when a VolumeMonitor has been instantiated.
+ this.volume_monitor = VolumeMonitor.get();
+
+ // set up the global null context when needed
+ if (null_context == null)
+ null_context = new GPhoto.ContextWrapper();
+
+ // same with idle-loop wrapper
+ if (spin_idle_context == null)
+ spin_idle_context = new GPhoto.SpinIdleWrapper();
+
+ // monitor source collection to add/remove views
+ get_view().monitor_source_collection(import_sources, new ImportViewManager(this), null);
+
+ // sort by exposure time
+ get_view().set_comparator(preview_comparator, preview_comparator_predicate);
+
+ // monitor selection for UI
+ get_view().items_state_changed.connect(on_view_changed);
+ get_view().contents_altered.connect(on_view_changed);
+ get_view().items_visibility_changed.connect(on_view_changed);
+
+ // Show subtitles.
+ get_view().set_property(CheckerboardItem.PROP_SHOW_SUBTITLES, true);
+
+ // monitor Photos for removals, as that will change the result of the ViewFilter
+ LibraryPhoto.global.contents_altered.connect(on_media_added_removed);
+ Video.global.contents_altered.connect(on_media_added_removed);
+
+ init_item_context_menu("/ImportContextMenu");
+ init_page_context_menu("/ImportContextMenu");
+ }
+
+ ~ImportPage() {
+ LibraryPhoto.global.contents_altered.disconnect(on_media_added_removed);
+ Video.global.contents_altered.disconnect(on_media_added_removed);
+ }
+
+ public override Gtk.Toolbar get_toolbar() {
+ if (toolbar == null) {
+ base.get_toolbar();
+
+ // hide duplicates checkbox
+ hide_imported = new Gtk.CheckButton.with_label(_("Hide photos already imported"));
+ hide_imported.set_tooltip_text(_("Only display photos that have not been imported"));
+ hide_imported.clicked.connect(on_hide_imported);
+ hide_imported.sensitive = false;
+ hide_imported.active = Config.Facade.get_instance().get_hide_photos_already_imported();
+ Gtk.ToolItem hide_item = new Gtk.ToolItem();
+ hide_item.is_important = true;
+ hide_item.add(hide_imported);
+
+ toolbar.insert(hide_item, -1);
+
+ // separator to force buttons to right side of toolbar
+ Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
+ separator.set_draw(false);
+
+ toolbar.insert(separator, -1);
+
+ // progress bar in center of toolbar
+ progress_bar.set_orientation(Gtk.Orientation.HORIZONTAL);
+ progress_bar.visible = false;
+ Gtk.ToolItem progress_item = new Gtk.ToolItem();
+ progress_item.set_expand(true);
+ progress_item.add(progress_bar);
+ progress_bar.set_show_text(true);
+
+ toolbar.insert(progress_item, -1);
+
+ // Find button
+ Gtk.ToggleToolButton find_button = new Gtk.ToggleToolButton();
+ find_button.set_related_action(get_action("CommonDisplaySearchbar"));
+
+ toolbar.insert(find_button, -1);
+
+ // Separator
+ toolbar.insert(new Gtk.SeparatorToolItem(), -1);
+
+ // Import selected
+ Gtk.ToolButton import_selected_button = new Gtk.ToolButton.from_stock(Resources.IMPORT);
+ import_selected_button.set_related_action(get_action("ImportSelected"));
+
+ toolbar.insert(import_selected_button, -1);
+
+ // Import all
+ Gtk.ToolButton import_all_button = new Gtk.ToolButton.from_stock(Resources.IMPORT_ALL);
+ import_all_button.set_related_action(get_action("ImportAll"));
+
+ toolbar.insert(import_all_button, -1);
+
+ // restrain the recalcitrant rascal! prevents the progress bar from being added to the
+ // show_all queue so we have more control over its visibility
+ progress_bar.set_no_show_all(true);
+
+ update_toolbar_state();
+
+ show_all();
+ }
+
+ return toolbar;
+ }
+
+ public override Core.ViewTracker? get_view_tracker() {
+ return tracker;
+ }
+
+ // Ticket #3304 - Import page shouldn't display confusing message
+ // prior to import.
+ // TODO: replace this with approved text for "talking to camera,
+ // please wait" once new strings are being accepted.
+ protected override string get_view_empty_message() {
+ return _("Starting import, please wait...");
+ }
+
+ 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();
+ }
+
+ private static bool preview_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "exposure-time");
+ }
+
+ private int64 import_job_comparator(void *a, void *b) {
+ return ((CameraImportJob *) a)->get_exposure_time() - ((CameraImportJob *) b)->get_exposure_time();
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("import.ui");
+ }
+
+ protected override Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ Gtk.ToggleActionEntry[] toggle_actions = base.init_collect_toggle_action_entries();
+
+ Gtk.ToggleActionEntry titles = { "ViewTitle", null, TRANSLATABLE, "<Ctrl><Shift>T",
+ TRANSLATABLE, on_display_titles, Config.Facade.get_instance().get_display_photo_titles() };
+ titles.label = _("_Titles");
+ titles.tooltip = _("Display the title of each photo");
+ toggle_actions += titles;
+
+ return toggle_actions;
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry import_selected = { "ImportSelected", Resources.IMPORT,
+ TRANSLATABLE, null, null, on_import_selected };
+ import_selected.label = _("Import _Selected");
+ import_selected.tooltip = _("Import the selected photos into your library");
+ actions += import_selected;
+
+ Gtk.ActionEntry import_all = { "ImportAll", Resources.IMPORT_ALL, TRANSLATABLE,
+ null, null, on_import_all };
+ import_all.label = _("Import _All");
+ import_all.tooltip = _("Import all the photos into your library");
+ actions += import_all;
+
+ return actions;
+ }
+
+ public GPhoto.Camera get_camera() {
+ return camera;
+ }
+
+ public string get_uri() {
+ return uri;
+ }
+
+ public bool is_busy() {
+ return busy;
+ }
+
+ protected override void init_actions(int selected_count, int count) {
+ on_view_changed();
+
+ set_action_important("ImportSelected", true);
+ set_action_important("ImportAll", true);
+
+ base.init_actions(selected_count, count);
+ }
+
+ public bool is_refreshed() {
+ return refreshed && !busy;
+ }
+
+ public string? get_refresh_message() {
+ string msg = null;
+ if (refresh_error != null) {
+ msg = refresh_error;
+ } else if (refresh_result == GPhoto.Result.OK) {
+ // all went well
+ } else {
+ msg = refresh_result.to_full_string();
+ }
+
+ return msg;
+ }
+
+ private void update_status(bool busy, bool refreshed) {
+ this.busy = busy;
+ this.refreshed = refreshed;
+
+ on_view_changed();
+ }
+
+ private void update_toolbar_state() {
+ if (hide_imported != null)
+ hide_imported.sensitive = !busy && refreshed && (get_view().get_unfiltered_count() > 0);
+ }
+
+ private void on_view_changed() {
+ set_action_sensitive("ImportSelected", !busy && refreshed && get_view().get_selected_count() > 0);
+ set_action_sensitive("ImportAll", !busy && refreshed && get_view().get_count() > 0);
+ AppWindow.get_instance().set_common_action_sensitive("CommonSelectAll",
+ !busy && (get_view().get_count() > 0));
+
+ update_toolbar_state();
+ }
+
+ private void on_media_added_removed() {
+ search_filter.refresh();
+ }
+
+ private void on_display_titles(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_titles(display);
+ Config.Facade.get_instance().set_display_photo_titles(display);
+ }
+
+ public override void switched_to() {
+ set_display_titles(Config.Facade.get_instance().get_display_photo_titles());
+
+ base.switched_to();
+ }
+
+ public override void ready() {
+ try_refreshing_camera(false);
+ hide_imported_filter.refresh();
+ }
+
+ private void try_refreshing_camera(bool fail_on_locked) {
+ // if camera has been refreshed or is in the process of refreshing, go no further
+ if (refreshed || busy)
+ return;
+
+ RefreshResult res = refresh_camera();
+ switch (res) {
+ case ImportPage.RefreshResult.OK:
+ case ImportPage.RefreshResult.BUSY:
+ // nothing to report; if busy, let it continue doing its thing
+ // (although earlier check should've caught this)
+ break;
+
+ case ImportPage.RefreshResult.LOCKED:
+ if (fail_on_locked) {
+ AppWindow.error_message(UNMOUNT_FAILED_MSG);
+
+ break;
+ }
+
+ // if locked because it's mounted, offer to unmount
+ debug("Checking if %s is mounted ...", 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
+ }
+
+ if (mount != null) {
+ // it's mounted, offer to unmount for the user
+ string mounted_message = _("Shotwell needs to unmount the camera from the filesystem in order to access it. Continue?");
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION,
+ Gtk.ButtonsType.CANCEL, "%s", mounted_message);
+ dialog.title = Resources.APP_TITLE;
+ dialog.add_button(_("_Unmount"), Gtk.ResponseType.YES);
+ int dialog_res = dialog.run();
+ dialog.destroy();
+
+ if (dialog_res != Gtk.ResponseType.YES) {
+ set_page_message(_("Please unmount the camera."));
+ } else {
+ unmount_camera(mount);
+ }
+ } else {
+ string locked_message = _("The camera is locked by another application. Shotwell can only access the camera when it's unlocked. Please close any other application using the camera and try again.");
+
+ // it's not mounted, so another application must have it locked
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.WARNING,
+ Gtk.ButtonsType.OK, "%s", locked_message);
+ dialog.title = Resources.APP_TITLE;
+ dialog.run();
+ dialog.destroy();
+
+ set_page_message(_("Please close any other application using the camera."));
+ }
+ break;
+
+ case ImportPage.RefreshResult.LIBRARY_ERROR:
+ AppWindow.error_message(_("Unable to fetch previews from the camera:\n%s").printf(
+ get_refresh_message()));
+ break;
+
+ default:
+ error("Unknown result type %d", (int) res);
+ }
+ }
+
+ public bool unmount_camera(Mount mount) {
+ if (busy)
+ return false;
+
+ update_status(true, false);
+ progress_bar.visible = true;
+ progress_bar.set_fraction(0.0);
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text(_("Unmounting..."));
+
+ // unmount_with_operation() can/will complete with the volume still mounted (probably meaning
+ // it's been *scheduled* for unmounting). However, this signal is fired when the mount
+ // really is unmounted -- *if* a VolumeMonitor has been instantiated.
+ mount.unmounted.connect(on_unmounted);
+
+ debug("Unmounting camera ...");
+ mount.unmount_with_operation.begin(MountUnmountFlags.NONE,
+ new Gtk.MountOperation(AppWindow.get_instance()), null, on_unmount_finished);
+
+ return true;
+ }
+
+ private void on_unmount_finished(Object? source, AsyncResult aresult) {
+ debug("Async unmount finished");
+
+ Mount mount = (Mount) source;
+ try {
+ mount.unmount_with_operation.end(aresult);
+ } catch (Error err) {
+ AppWindow.error_message(UNMOUNT_FAILED_MSG);
+
+ // don't trap this signal, even if it does come in, we've backed off
+ mount.unmounted.disconnect(on_unmounted);
+
+ update_status(false, refreshed);
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.visible = false;
+ }
+ }
+
+ private void on_unmounted(Mount mount) {
+ debug("on_unmounted");
+
+ update_status(false, refreshed);
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.visible = false;
+
+ try_refreshing_camera(true);
+ }
+
+ private void clear_all_import_sources() {
+ Marker marker = import_sources.start_marking();
+ marker.mark_all();
+ import_sources.destroy_marked(marker, false);
+ }
+
+ /**
+ * @brief Returns whether the current device has a given directory or not.
+ *
+ * @param fsid The file system id of the camera or other device to search.
+ * @param dir The path to start searching from.
+ * @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);
+ GPhoto.Result result;
+ GPhoto.CameraList folders;
+
+ result = GPhoto.CameraList.create(out folders);
+ if (result != GPhoto.Result.OK) {
+ // couldn't create a list - can't determine whether specified dir is present
+ return false;
+ }
+
+ result = camera.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;
+ }
+
+ int list_len = folders.count();
+
+ for(int list_index = 0; list_index < list_len; list_index++) {
+ string tmp;
+
+ folders.get_name(list_index, out tmp);
+ if (tmp == search_target) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private RefreshResult refresh_camera() {
+ if (busy)
+ return RefreshResult.BUSY;
+
+ update_status(busy, false);
+
+ refresh_error = null;
+ refresh_result = camera.init(spin_idle_context.context);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to initialize camera: %s", refresh_result.to_full_string());
+
+ return (refresh_result == GPhoto.Result.IO_LOCK) ? RefreshResult.LOCKED : RefreshResult.LIBRARY_ERROR;
+ }
+
+ update_status(true, refreshed);
+
+ on_view_changed();
+
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text(_("Fetching photo information"));
+ progress_bar.set_fraction(0.0);
+ progress_bar.set_pulse_step(0.01);
+ progress_bar.visible = true;
+
+ Gee.ArrayList<ImportSource> import_list = new Gee.ArrayList<ImportSource>();
+
+ GPhoto.CameraStorageInformation *sifs = null;
+ int count = 0;
+ refresh_result = camera.get_storageinfo(&sifs, out count, spin_idle_context.context);
+ if (refresh_result == GPhoto.Result.OK) {
+ for (int fsid = 0; fsid < count; fsid++) {
+ // Check well-known video and image paths first to prevent accidental
+ // scanning of undesired directories (which can cause user annoyance with
+ // some smartphones or camera-equipped media players)
+ bool got_well_known_dir = false;
+
+ // Check common paths for most primarily-still cameras, many (most?) smartphones
+ if (check_directory_exists(fsid, "/", "DCIM")) {
+ enumerate_files(fsid, "/DCIM", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "dcim")) {
+ enumerate_files(fsid, "/dcim", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Check common paths for AVCHD camcorders, primarily-still
+ // cameras that shoot .mts video files
+ if (check_directory_exists(fsid, "/PRIVATE/", "AVCHD")) {
+ enumerate_files(fsid, "/PRIVATE/AVCHD", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/private/", "avchd")) {
+ enumerate_files(fsid, "/private/avchd", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "AVCHD")) {
+ enumerate_files(fsid, "/AVCHD", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "avchd")) {
+ enumerate_files(fsid, "/avchd", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Check common video paths for some Sony primarily-still
+ // cameras
+ if (check_directory_exists(fsid, "/PRIVATE/", "SONY")) {
+ enumerate_files(fsid, "/PRIVATE/SONY", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/private/", "sony")) {
+ enumerate_files(fsid, "/private/sony", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Check common video paths for Sony NEX3, PSP addon camera
+ if (check_directory_exists(fsid, "/", "MP_ROOT")) {
+ enumerate_files(fsid, "/MP_ROOT", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "mp_root")) {
+ enumerate_files(fsid, "/mp_root", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Didn't find any of the common directories we know about
+ // already - try scanning from device root.
+ if (!got_well_known_dir) {
+ if (!enumerate_files(fsid, "/", import_list))
+ break;
+ }
+ }
+ }
+
+ clear_all_import_sources();
+
+ // Associate files (for RAW+JPEG)
+ auto_match_raw_jpeg(import_list);
+
+#if UNITY_SUPPORT
+ //UnityProgressBar: try to draw progress bar
+ uniprobar.set_visible(true);
+#endif
+
+ load_previews_and_metadata(import_list);
+
+#if UNITY_SUPPORT
+ //UnityProgressBar: reset
+ uniprobar.reset();
+#endif
+
+ progress_bar.visible = false;
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.set_fraction(0.0);
+
+ GPhoto.Result res = camera.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());
+ }
+
+ if (refresh_result == GPhoto.Result.OK) {
+ update_status(false, true);
+ } else {
+ update_status(false, false);
+
+ // show 'em all or show none
+ clear_all_import_sources();
+ }
+
+ on_view_changed();
+
+ switch (refresh_result) {
+ case GPhoto.Result.OK:
+ return RefreshResult.OK;
+
+ case GPhoto.Result.IO_LOCK:
+ return RefreshResult.LOCKED;
+
+ default:
+ return RefreshResult.LIBRARY_ERROR;
+ }
+ }
+
+ private static string chomp_ch(string str, char ch) {
+ long offset = str.length;
+ while (--offset >= 0) {
+ if (str[offset] != ch)
+ return str.slice(0, offset);
+ }
+
+ return "";
+ }
+
+ public static string append_path(string basepath, string addition) {
+ if (!basepath.has_suffix("/") && !addition.has_prefix("/"))
+ return basepath + "/" + addition;
+ else if (basepath.has_suffix("/") && addition.has_prefix("/"))
+ return chomp_ch(basepath, '/') + addition;
+ else
+ return basepath + addition;
+ }
+
+ // Need to do this because some phones (iPhone, in particular) changes the name of their filesystem
+ // between each mount
+ public static string? get_fs_basedir(GPhoto.Camera camera, int fsid) {
+ GPhoto.CameraStorageInformation *sifs = null;
+ int count = 0;
+ GPhoto.Result res = camera.get_storageinfo(&sifs, out count, null_context.context);
+ if (res != GPhoto.Result.OK)
+ return null;
+
+ if (fsid >= count)
+ return null;
+
+ GPhoto.CameraStorageInformation *ifs = sifs + fsid;
+
+ return (ifs->fields & GPhoto.CameraStorageInfoFields.BASE) != 0 ? ifs->basedir : "/";
+ }
+
+ public static string? get_fulldir(GPhoto.Camera camera, string camera_name, int fsid, string folder) {
+ if (folder.length > GPhoto.MAX_BASEDIR_LENGTH)
+ return null;
+
+ string basedir = get_fs_basedir(camera, fsid);
+ if (basedir == null) {
+ debug("Unable to find base directory for %s fsid %d", camera_name, fsid);
+
+ return folder;
+ }
+
+ return append_path(basedir, folder);
+ }
+
+ private bool enumerate_files(int fsid, string dir, Gee.ArrayList<ImportSource> import_list) {
+ string? fulldir = get_fulldir(camera, camera_name, fsid, dir);
+ if (fulldir == null) {
+ warning("Skipping enumerating %s: invalid folder name", dir);
+
+ return true;
+ }
+
+ GPhoto.CameraList files;
+ refresh_result = GPhoto.CameraList.create(out files);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to create file list: %s", refresh_result.to_full_string());
+
+ return false;
+ }
+
+ refresh_result = camera.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());
+
+ // Although an error, don't abort the import because of this
+ refresh_result = GPhoto.Result.OK;
+
+ return true;
+ }
+
+ for (int ctr = 0; ctr < files.count(); ctr++) {
+ string filename;
+ refresh_result = files.get_name(ctr, out filename);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to get the name of file %d in %s: %s", ctr, fulldir,
+ refresh_result.to_full_string());
+
+ return false;
+ }
+
+ try {
+ GPhoto.CameraFileInfo info;
+ if (!GPhoto.get_info(spin_idle_context.context, camera, fulldir, filename, out info)) {
+ warning("Skipping import of %s/%s: name too long", fulldir, filename);
+
+ continue;
+ }
+
+ if ((info.file.fields & GPhoto.CameraFileInfoFields.TYPE) == 0) {
+ message("Skipping %s/%s: No file (file=%02Xh)", fulldir, filename,
+ info.file.fields);
+
+ continue;
+ }
+
+ if (VideoReader.is_supported_video_filename(filename)) {
+ VideoImportSource video_source = new VideoImportSource(camera_name, camera,
+ fsid, dir, filename, info.file.size, info.file.mtime);
+ import_list.add(video_source);
+ } else {
+ // determine file format from type, and then from file extension
+ PhotoFileFormat file_format = PhotoFileFormat.from_gphoto_type(info.file.type);
+ if (file_format == PhotoFileFormat.UNKNOWN) {
+ file_format = PhotoFileFormat.get_by_basename_extension(filename);
+ if (file_format == PhotoFileFormat.UNKNOWN) {
+ message("Skipping %s/%s: Not a supported file extension (%s)", fulldir,
+ filename, info.file.type);
+
+ continue;
+ }
+ }
+ import_list.add(new PhotoImportSource(camera_name, camera, fsid, dir, filename,
+ info.file.size, info.file.mtime, file_format));
+ }
+
+ progress_bar.pulse();
+
+ // spin the event loop so the UI doesn't freeze
+ spin_event_loop();
+ } catch (Error err) {
+ warning("Error while enumerating files in %s: %s", fulldir, err.message);
+
+ refresh_error = err.message;
+
+ return false;
+ }
+ }
+
+ GPhoto.CameraList folders;
+ refresh_result = GPhoto.CameraList.create(out folders);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to create folder list: %s", refresh_result.to_full_string());
+
+ return false;
+ }
+
+ refresh_result = camera.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());
+
+ // Although an error, don't abort the import because of this
+ refresh_result = GPhoto.Result.OK;
+
+ return true;
+ }
+
+ for (int ctr = 0; ctr < folders.count(); ctr++) {
+ string subdir;
+ refresh_result = folders.get_name(ctr, out subdir);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to get name of folder %d: %s", ctr, refresh_result.to_full_string());
+
+ return false;
+ }
+
+ if (!enumerate_files(fsid, append_path(dir, subdir), import_list))
+ return false;
+ }
+
+ return true;
+ }
+
+ // Try to match RAW+JPEG pairs.
+ private void auto_match_raw_jpeg(Gee.ArrayList<ImportSource> import_list) {
+ for (int i = 0; i < import_list.size; i++) {
+ PhotoImportSource? current = import_list.get(i) as PhotoImportSource;
+ PhotoImportSource? next = (i + 1 < import_list.size) ?
+ import_list.get(i + 1) as PhotoImportSource : null;
+ PhotoImportSource? prev = (i > 0) ?
+ import_list.get(i - 1) as PhotoImportSource : null;
+ if (current != null && current.get_file_format() == PhotoFileFormat.RAW) {
+ string current_name;
+ string ext;
+ disassemble_filename(current.get_filename(), out current_name, out ext);
+
+ // Try to find a matching pair.
+ PhotoImportSource? associated = null;
+ if (next != null && next.get_file_format() == PhotoFileFormat.JFIF) {
+ string next_name;
+ disassemble_filename(next.get_filename(), out next_name, out ext);
+ if (next_name == current_name)
+ associated = next;
+ }
+ if (prev != null && prev.get_file_format() == PhotoFileFormat.JFIF) {
+ string prev_name;
+ disassemble_filename(prev.get_filename(), out prev_name, out ext);
+ if (prev_name == current_name)
+ associated = prev;
+ }
+
+ // Associate!
+ if (associated != null) {
+ debug("Found RAW+JPEG pair: %s and %s", current.get_filename(), associated.get_filename());
+ current.set_associated(associated);
+ if (!import_list.remove(associated)) {
+ debug("Unable to associate files");
+ current.set_associated(null);
+ }
+ }
+ }
+ }
+ }
+
+ private void load_previews_and_metadata(Gee.List<ImportSource> import_list) {
+ int loaded_photos = 0;
+ foreach (ImportSource import_source in import_list) {
+ string filename = import_source.get_filename();
+ string? fulldir = import_source.get_fulldir();
+ if (fulldir == null) {
+ warning("Skipping loading preview of %s: invalid folder name", import_source.to_string());
+
+ continue;
+ }
+
+ // Get JPEG pair, if available.
+ PhotoImportSource? associated = null;
+ if (import_source is PhotoImportSource &&
+ ((PhotoImportSource) import_source).get_associated() != null) {
+ associated = ((PhotoImportSource) import_source).get_associated();
+ }
+
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.MIDDLE);
+ progress_bar.set_text(_("Fetching preview for %s").printf(import_source.get_name()));
+
+ // Ask GPhoto to read the current file's metadata, but only if the file is not a
+ // video. Across every memory card and camera type I've tested (lucas, as of 10/27/2010)
+ // GPhoto always loads null metadata for videos. So without the is-not-video guard,
+ // this code segment just needlessly and annoyingly prints a warning message to the
+ // console.
+ PhotoMetadata? metadata = null;
+ if (!VideoReader.is_supported_video_filename(filename)) {
+ try {
+ metadata = GPhoto.load_metadata(spin_idle_context.context, camera, fulldir,
+ filename);
+ } catch (Error err) {
+ warning("Unable to fetch metadata for %s/%s: %s", fulldir, filename,
+ err.message);
+ }
+ }
+
+ // calculate EXIF's fingerprint
+ string? exif_only_md5 = null;
+ if (metadata != null) {
+ uint8[]? flattened_sans_thumbnail = metadata.flatten_exif(false);
+ if (flattened_sans_thumbnail != null && flattened_sans_thumbnail.length > 0)
+ exif_only_md5 = md5_binary(flattened_sans_thumbnail, flattened_sans_thumbnail.length);
+ }
+
+ // XXX: Cannot use the metadata for the thumbnail preview because libgphoto2
+ // 2.4.6 has a bug where the returned EXIF data object is complete garbage. This
+ // is fixed in 2.4.7, but need to work around this as best we can. In particular,
+ // this means the preview orientation will be wrong and the MD5 is not generated
+ // if the EXIF did not parse properly (see above)
+
+ uint8[] preview_raw = null;
+ size_t preview_raw_length = 0;
+ Gdk.Pixbuf preview = null;
+ try {
+ string preview_fulldir = fulldir;
+ string preview_filename = filename;
+ if (associated != null) {
+ preview_fulldir = associated.get_fulldir();
+ preview_filename = associated.get_filename();
+ }
+ preview = GPhoto.load_preview(spin_idle_context.context, camera, preview_fulldir,
+ preview_filename, out preview_raw, out preview_raw_length);
+ } catch (Error err) {
+ // only issue the warning message if we're not reading a video. GPhoto is capable
+ // of reading video previews about 50% of the time, so we don't want to put a guard
+ // around this entire code segment like we did with the metadata-read segment above,
+ // however video previews being absent is so common that there's no reason
+ // we should generate a warning for one.
+ if (!VideoReader.is_supported_video_filename(filename)) {
+ warning("Unable to fetch preview for %s/%s: %s", fulldir, filename, err.message);
+ }
+ }
+
+ // calculate thumbnail fingerprint
+ string? preview_md5 = null;
+ if (preview != null && preview_raw != null && preview_raw_length > 0)
+ preview_md5 = md5_binary(preview_raw, preview_raw_length);
+
+#if TRACE_MD5
+ 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);
+
+ if (import_source is PhotoImportSource)
+ (import_source as PhotoImportSource).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());
+ associated.update(preview, preview_md5, associated_metadata, null);
+ } catch (Error err) {
+ warning("Unable to fetch metadata for %s/%s: %s", associated.get_fulldir(),
+ associated.get_filename(), err.message);
+ }
+ }
+
+ // *now* add to the SourceCollection, now that it is completed
+ import_sources.add(import_source);
+
+ progress_bar.set_fraction((double) (++loaded_photos) / (double) import_list.size);
+#if UNITY_SUPPORT
+ //UnityProgressBar: set progress
+ uniprobar.set_progress((double) (loaded_photos) / (double) import_list.size);
+#endif
+
+ // spin the event loop so the UI doesn't freeze
+ spin_event_loop();
+ }
+ }
+
+ private void on_hide_imported() {
+ if (hide_imported.get_active())
+ get_view().install_view_filter(hide_imported_filter);
+ else
+ get_view().remove_view_filter(hide_imported_filter);
+
+ Config.Facade.get_instance().set_hide_photos_already_imported(hide_imported.get_active());
+ }
+
+ private void on_import_selected() {
+ import(get_view().get_selected());
+ }
+
+ private void on_import_all() {
+ import(get_view().get_all());
+ }
+
+ private void import(Gee.Iterable<DataObject> items) {
+ GPhoto.Result res = camera.init(spin_idle_context.context);
+ if (res != GPhoto.Result.OK) {
+ AppWindow.error_message(_("Unable to lock camera: %s").printf(res.to_full_string()));
+
+ return;
+ }
+
+ update_status(true, refreshed);
+
+ on_view_changed();
+ progress_bar.visible = false;
+
+ SortedList<CameraImportJob> jobs = new SortedList<CameraImportJob>(import_job_comparator);
+ Gee.ArrayList<CameraImportJob> already_imported = new Gee.ArrayList<CameraImportJob>();
+
+ foreach (DataObject object in items) {
+ ImportPreview preview = (ImportPreview) object;
+ ImportSource import_file = (ImportSource) preview.get_source();
+
+ if (preview.is_already_imported()) {
+ message("Skipping import of %s: checksum detected in library",
+ import_file.get_filename());
+
+ already_imported.add(new CameraImportJob(null_context, import_file,
+ preview.get_duplicated_file()));
+
+ continue;
+ }
+
+ CameraImportJob import_job = new CameraImportJob(null_context, import_file);
+
+ // Maintain RAW+JPEG association.
+ if (import_file is PhotoImportSource &&
+ ((PhotoImportSource) import_file).get_associated() != null) {
+ import_job.set_associated(new CameraImportJob(null_context,
+ ((PhotoImportSource) import_file).get_associated()));
+ }
+
+ jobs.add(import_job);
+ }
+
+ debug("Importing %d files from %s", jobs.size, camera_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,
+ null, already_imported);
+ batch_import.import_job_failed.connect(on_import_job_failed);
+ batch_import.import_complete.connect(close_import);
+
+ LibraryWindow.get_app().enqueue_batch_import(batch_import, true);
+ LibraryWindow.get_app().switch_to_import_queue_page();
+ // camera.exit() and busy flag will be handled when the batch import completes
+ } else {
+ // since failed up-front, build a fake (faux?) ImportManifest and report it here
+ if (already_imported.size > 0)
+ import_reporter(new ImportManifest(null, already_imported));
+
+ close_import();
+ }
+ }
+
+ private void on_import_job_failed(BatchImportResult result) {
+ if (result.file == null || result.result == ImportResult.SUCCESS)
+ return;
+
+ // delete the copied file
+ try {
+ result.file.delete(null);
+ } catch (Error err) {
+ message("Unable to delete downloaded file %s: %s", result.file.get_path(), err.message);
+ }
+ }
+
+ private void import_reporter(ImportManifest manifest) {
+ // TODO: Need to keep the ImportPage around until the BatchImport is completed, but the
+ // page controller (i.e. LibraryWindow) needs to know (a) if ImportPage is busy before
+ // removing and (b) if it is, to be notified when it ain't. Until that's in place, need
+ // to hold the ref so the page isn't destroyed ... this switcheroo keeps the ref alive
+ // until this function returns (at any time)
+ ImportPage? local_ref = this.local_ref;
+ this.local_ref = null;
+
+ if (manifest.success.size > 0) {
+ string photos_string = (ngettext("Delete this photo from camera?",
+ "Delete these %d photos from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+ string videos_string = (ngettext("Delete this video from camera?",
+ "Delete these %d videos from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+ string both_string = (ngettext("Delete this photo/video from camera?",
+ "Delete these %d photos/videos from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+ string neither_string = (ngettext("Delete these files from camera?",
+ "Delete these %d files from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+
+ string question_string = ImportUI.get_media_specific_string(manifest.success,
+ photos_string, videos_string, both_string, neither_string);
+
+ ImportUI.QuestionParams question = new ImportUI.QuestionParams(
+ question_string, Gtk.Stock.DELETE, _("_Keep"));
+
+ if (!ImportUI.report_manifest(manifest, false, question))
+ return;
+ } else {
+ ImportUI.report_manifest(manifest, false, null);
+ return;
+ }
+
+ // delete the photos from the camera and the SourceCollection... for now, this is an
+ // all-or-nothing deal
+ Marker marker = import_sources.start_marking();
+ foreach (BatchImportResult batch_result in manifest.success) {
+ CameraImportJob job = batch_result.job as CameraImportJob;
+
+ marker.mark(job.get_source());
+ }
+
+ ProgressDialog progress = new ProgressDialog(AppWindow.get_instance(),
+ _("Removing photos/videos from camera"), new Cancellable());
+ int error_count = import_sources.destroy_marked(marker, true, progress.monitor);
+ if (error_count > 0) {
+ string error_string =
+ (ngettext("Unable to delete %d photo/video from the camera due to errors.",
+ "Unable to delete %d photos/videos from the camera due to errors.", error_count)).printf(
+ error_count);
+ AppWindow.error_message(error_string);
+ }
+
+ progress.close();
+
+ // to stop build warnings
+ local_ref = null;
+ }
+
+ private void close_import() {
+ GPhoto.Result res = camera.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());
+ }
+
+ update_status(false, refreshed);
+
+ on_view_changed();
+ }
+
+ public override void set_display_titles(bool display) {
+ base.set_display_titles(display);
+
+ Gtk.ToggleAction? action = get_action("ViewTitle") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ // Gets the search view filter for this page.
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
diff --git a/src/camera/mk/camera.mk b/src/camera/mk/camera.mk
new file mode 100644
index 0000000..1812a42
--- /dev/null
+++ b/src/camera/mk/camera.mk
@@ -0,0 +1,32 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Camera
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := camera
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ CameraTable.vala \
+ GPhoto.vala \
+ ImportPage.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Sidebar
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/config/Config.vala b/src/config/Config.vala
new file mode 100644
index 0000000..2095107
--- /dev/null
+++ b/src/config/Config.vala
@@ -0,0 +1,155 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Config 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 Config {
+
+public class Facade : ConfigurationFacade {
+ public const double SLIDESHOW_DELAY_MAX = 30.0;
+ public const double SLIDESHOW_DELAY_MIN = 1.0;
+ public const double SLIDESHOW_DELAY_DEFAULT = 3.0;
+ public const double SLIDESHOW_TRANSITION_DELAY_MAX = 1.0;
+ public const double SLIDESHOW_TRANSITION_DELAY_MIN = 0.1;
+ public const double SLIDESHOW_TRANSITION_DELAY_DEFAULT = 0.3;
+ public const int WIDTH_DEFAULT = 1024;
+ public const int HEIGHT_DEFAULT = 768;
+ public const int SIDEBAR_MIN_POSITION = 180;
+ public const int SIDEBAR_MAX_POSITION = 1000;
+ public const string DEFAULT_BG_COLOR = "#444";
+ public const int NO_VIDEO_INTERPRETER_STATE = -1;
+
+ private const double BLACK_THRESHOLD = 0.61;
+ private const string DARK_SELECTED_COLOR = "#0AD";
+ private const string LIGHT_SELECTED_COLOR = "#2DF";
+ private const string DARK_UNSELECTED_COLOR = "#000";
+ private const string LIGHT_UNSELECTED_COLOR = "#FFF";
+ private const string DARK_BORDER_COLOR = "#999";
+ private const string LIGHT_BORDER_COLOR = "#AAA";
+ private const string DARK_UNFOCUSED_SELECTED_COLOR = "#6fc4dd";
+ private const string LIGHT_UNFOCUSED_SELECTED_COLOR = "#99efff";
+
+ private string bg_color = null;
+ private string selected_color = null;
+ private string unselected_color = null;
+ private string unfocused_selected_color = null;
+ private string border_color = null;
+
+ private static Facade instance = null;
+
+ public signal void colors_changed();
+
+ private Facade() {
+ base(new GSettingsConfigurationEngine());
+
+ bg_color_name_changed.connect(on_color_name_changed);
+ }
+
+ public static Facade get_instance() {
+ if (instance == null)
+ instance = new Facade();
+
+ return instance;
+ }
+
+ private void on_color_name_changed() {
+ colors_changed();
+ }
+
+ private void set_text_colors(Gdk.RGBA bg_color) {
+ // since bg color is greyscale, we only need to compare the red value to the threshold,
+ // which determines whether the background is dark enough to need light text and selection
+ // colors or vice versa
+ if (bg_color.red > BLACK_THRESHOLD) {
+ selected_color = DARK_SELECTED_COLOR;
+ unselected_color = DARK_UNSELECTED_COLOR;
+ unfocused_selected_color = DARK_UNFOCUSED_SELECTED_COLOR;
+ border_color = DARK_BORDER_COLOR;
+ } else {
+ selected_color = LIGHT_SELECTED_COLOR;
+ unselected_color = LIGHT_UNSELECTED_COLOR;
+ unfocused_selected_color = LIGHT_UNFOCUSED_SELECTED_COLOR;
+ border_color = LIGHT_BORDER_COLOR;
+ }
+ }
+
+ private void get_colors() {
+ bg_color = base.get_bg_color_name();
+
+ if (!is_color_parsable(bg_color))
+ bg_color = DEFAULT_BG_COLOR;
+
+ set_text_colors(parse_color(bg_color));
+ }
+
+ public Gdk.RGBA get_bg_color() {
+ if (is_string_empty(bg_color))
+ get_colors();
+
+ return parse_color(bg_color);
+ }
+
+ public Gdk.RGBA get_selected_color(bool in_focus = true) {
+ if (in_focus) {
+ if (is_string_empty(selected_color))
+ get_colors();
+
+ return parse_color(selected_color);
+ } else {
+ if (is_string_empty(unfocused_selected_color))
+ get_colors();
+
+ return parse_color(unfocused_selected_color);
+ }
+ }
+
+ public Gdk.RGBA get_unselected_color() {
+ if (is_string_empty(unselected_color))
+ get_colors();
+
+ return parse_color(unselected_color);
+ }
+
+ public Gdk.RGBA get_border_color() {
+ if (is_string_empty(border_color))
+ get_colors();
+
+ return parse_color(border_color);
+ }
+
+ public void set_bg_color(Gdk.RGBA color) {
+ uint8 col_tmp = (uint8) (color.red * 255.0);
+
+ bg_color = "#%02X%02X%02X".printf(col_tmp, col_tmp, col_tmp);
+ set_bg_color_name(bg_color);
+
+ set_text_colors(color);
+ }
+
+ public void commit_bg_color() {
+ base.set_bg_color_name(bg_color);
+ }
+}
+
+// preconfigure may be deleted if not used.
+public void preconfigure() {
+}
+
+public void init() throws Error {
+}
+
+public void terminate() {
+}
+
+}
+
diff --git a/src/config/ConfigurationInterfaces.vala b/src/config/ConfigurationInterfaces.vala
new file mode 100644
index 0000000..97f41cc
--- /dev/null
+++ b/src/config/ConfigurationInterfaces.vala
@@ -0,0 +1,1609 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public errordomain ConfigurationError {
+ PROPERTY_HAS_NO_VALUE,
+ /**
+ * the underlying configuration engine reported an error; the error is
+ * specific to the configuration engine in use (e.g., GSettings)
+ * and is usually meaningless to client code */
+ ENGINE_ERROR,
+}
+
+public enum FuzzyPropertyState {
+ ENABLED,
+ DISABLED,
+ UNKNOWN
+}
+
+public enum ConfigurableProperty {
+ AUTO_IMPORT_FROM_LIBRARY = 0,
+ BG_COLOR_NAME,
+ COMMIT_METADATA_TO_MASTERS,
+ DESKTOP_BACKGROUND_FILE,
+ DESKTOP_BACKGROUND_MODE,
+ DIRECTORY_PATTERN,
+ DIRECTORY_PATTERN_CUSTOM,
+ DIRECT_WINDOW_HEIGHT,
+ DIRECT_WINDOW_MAXIMIZE,
+ DIRECT_WINDOW_WIDTH,
+ DISPLAY_BASIC_PROPERTIES,
+ DISPLAY_EVENT_COMMENTS,
+ DISPLAY_EXTENDED_PROPERTIES,
+ DISPLAY_SIDEBAR,
+ DISPLAY_SEARCH_BAR,
+ DISPLAY_PHOTO_RATINGS,
+ DISPLAY_PHOTO_TAGS,
+ DISPLAY_PHOTO_TITLES,
+ DISPLAY_PHOTO_COMMENTS,
+ EVENT_PHOTOS_SORT_ASCENDING,
+ EVENT_PHOTOS_SORT_BY,
+ EVENTS_SORT_ASCENDING,
+ EXTERNAL_PHOTO_APP,
+ EXTERNAL_RAW_APP,
+ HIDE_PHOTOS_ALREADY_IMPORTED,
+ IMPORT_DIR,
+ KEEP_RELATIVITY,
+ LAST_CROP_HEIGHT,
+ LAST_CROP_MENU_CHOICE,
+ LAST_CROP_WIDTH,
+ LAST_USED_SERVICE,
+ LAST_USED_DATAIMPORTS_SERVICE,
+ LIBRARY_PHOTOS_SORT_ASCENDING,
+ LIBRARY_PHOTOS_SORT_BY,
+ LIBRARY_WINDOW_HEIGHT,
+ LIBRARY_WINDOW_MAXIMIZE,
+ LIBRARY_WINDOW_WIDTH,
+ MODIFY_ORIGINALS,
+ PHOTO_THUMBNAIL_SCALE,
+ PIN_TOOLBAR_STATE,
+ PRINTING_CONTENT_HEIGHT,
+ PRINTING_CONTENT_LAYOUT,
+ PRINTING_CONTENT_PPI,
+ PRINTING_CONTENT_UNITS,
+ PRINTING_CONTENT_WIDTH,
+ PRINTING_IMAGES_PER_PAGE,
+ PRINTING_MATCH_ASPECT_RATIO,
+ PRINTING_PRINT_TITLES,
+ PRINTING_SIZE_SELECTION,
+ PRINTING_TITLES_FONT,
+ RAW_DEVELOPER_DEFAULT,
+ SHOW_WELCOME_DIALOG,
+ SIDEBAR_POSITION,
+ SLIDESHOW_DELAY,
+ SLIDESHOW_TRANSITION_DELAY,
+ SLIDESHOW_TRANSITION_EFFECT_ID,
+ SLIDESHOW_SHOW_TITLE,
+ USE_24_HOUR_TIME,
+ USE_LOWERCASE_FILENAMES,
+ VIDEO_INTERPRETER_STATE_COOKIE,
+
+
+ NUM_PROPERTIES;
+
+ public string to_string() {
+ switch (this) {
+ case AUTO_IMPORT_FROM_LIBRARY:
+ return "AUTO_IMPORT_FROM_LIBRARY";
+
+ case BG_COLOR_NAME:
+ return "BG_COLOR_NAME";
+
+ case COMMIT_METADATA_TO_MASTERS:
+ return "COMMIT_METADATA_TO_MASTERS";
+
+ case DESKTOP_BACKGROUND_FILE:
+ return "DESKTOP_BACKGROUND_FILE";
+
+ case DESKTOP_BACKGROUND_MODE:
+ return "DESKTOP_BACKGROUND_MODE";
+
+ case DIRECTORY_PATTERN:
+ return "DIRECTORY_PATTERN";
+
+ case DIRECTORY_PATTERN_CUSTOM:
+ return "DIRECTORY_PATTERN_CUSTOM";
+
+ case DIRECT_WINDOW_HEIGHT:
+ return "DIRECT_WINDOW_HEIGHT";
+
+ case DIRECT_WINDOW_MAXIMIZE:
+ return "DIRECT_WINDOW_MAXIMIZE";
+
+ case DIRECT_WINDOW_WIDTH:
+ return "DIRECT_WINDOW_WIDTH";
+
+ case DISPLAY_BASIC_PROPERTIES:
+ return "DISPLAY_BASIC_PROPERTIES";
+
+ case DISPLAY_EXTENDED_PROPERTIES:
+ return "DISPLAY_EXTENDED_PROPERTIES";
+
+ case DISPLAY_SIDEBAR:
+ return "DISPLAY_SIDEBAR";
+
+ case DISPLAY_SEARCH_BAR:
+ return "DISPLAY_SEARCH_BAR";
+
+ case DISPLAY_PHOTO_RATINGS:
+ return "DISPLAY_PHOTO_RATINGS";
+
+ case DISPLAY_PHOTO_TAGS:
+ return "DISPLAY_PHOTO_TAGS";
+
+ case DISPLAY_PHOTO_TITLES:
+ return "DISPLAY_PHOTO_TITLES";
+
+ case DISPLAY_PHOTO_COMMENTS:
+ return "DISPLAY_PHOTO_COMMENTS";
+
+ case DISPLAY_EVENT_COMMENTS:
+ return "DISPLAY_EVENT_COMMENTS";
+
+ case EVENT_PHOTOS_SORT_ASCENDING:
+ return "EVENT_PHOTOS_SORT_ASCENDING";
+
+ case EVENT_PHOTOS_SORT_BY:
+ return "EVENT_PHOTOS_SORT_BY";
+
+ case EVENTS_SORT_ASCENDING:
+ return "EVENTS_SORT_ASCENDING";
+
+ case EXTERNAL_PHOTO_APP:
+ return "EXTERNAL_PHOTO_APP";
+
+ case EXTERNAL_RAW_APP:
+ return "EXTERNAL_RAW_APP";
+
+ case HIDE_PHOTOS_ALREADY_IMPORTED:
+ return "HIDE_PHOTOS_ALREADY_IMPORTED";
+
+ case IMPORT_DIR:
+ return "IMPORT_DIR";
+
+ case KEEP_RELATIVITY:
+ return "KEEP_RELATIVITY";
+
+ case LAST_CROP_HEIGHT:
+ return "LAST_CROP_HEIGHT";
+
+ case LAST_CROP_MENU_CHOICE:
+ return "LAST_CROP_MENU_CHOICE";
+
+ case LAST_CROP_WIDTH:
+ return "LAST_CROP_WIDTH";
+
+ case LAST_USED_SERVICE:
+ return "LAST_USED_SERVICE";
+
+ case LAST_USED_DATAIMPORTS_SERVICE:
+ return "LAST_USED_DATAIMPORTS_SERVICE";
+
+ case LIBRARY_PHOTOS_SORT_ASCENDING:
+ return "LIBRARY_PHOTOS_SORT_ASCENDING";
+
+ case LIBRARY_PHOTOS_SORT_BY:
+ return "LIBRARY_PHOTOS_SORT_BY";
+
+ case LIBRARY_WINDOW_HEIGHT:
+ return "LIBRARY_WINDOW_HEIGHT";
+
+ case LIBRARY_WINDOW_MAXIMIZE:
+ return "LIBRARY_WINDOW_MAXIMIZE";
+
+ case LIBRARY_WINDOW_WIDTH:
+ return "LIBRARY_WINDOW_WIDTH";
+
+ case MODIFY_ORIGINALS:
+ return "MODIFY_ORIGINALS";
+
+ case PHOTO_THUMBNAIL_SCALE:
+ return "PHOTO_THUMBNAIL_SCALE";
+
+ case PIN_TOOLBAR_STATE:
+ return "PIN_TOOLBAR_STATE";
+
+ case PRINTING_CONTENT_HEIGHT:
+ return "PRINTING_CONTENT_HEIGHT";
+
+ case PRINTING_CONTENT_LAYOUT:
+ return "PRINTING_CONTENT_LAYOUT";
+
+ case PRINTING_CONTENT_PPI:
+ return "PRINTING_CONTENT_PPI";
+
+ case PRINTING_CONTENT_UNITS:
+ return "PRINTING_CONTENT_UNITS";
+
+ case PRINTING_CONTENT_WIDTH:
+ return "PRINTING_CONTENT_WIDTH";
+
+ case PRINTING_IMAGES_PER_PAGE:
+ return "PRINTING_IMAGES_PER_PAGE";
+
+ case PRINTING_MATCH_ASPECT_RATIO:
+ return "PRINTING_MATCH_ASPECT_RATIO";
+
+ case PRINTING_PRINT_TITLES:
+ return "PRINTING_PRINT_TITLES";
+
+ case PRINTING_SIZE_SELECTION:
+ return "PRINTING_SIZE_SELECTION";
+
+ case PRINTING_TITLES_FONT:
+ return "PRINTING_TITLES_FONT";
+
+ case RAW_DEVELOPER_DEFAULT:
+ return "RAW_DEVELOPER_DEFAULT";
+
+ case SHOW_WELCOME_DIALOG:
+ return "SHOW_WELCOME_DIALOG";
+
+ case SIDEBAR_POSITION:
+ return "SIDEBAR_POSITION";
+
+ case SLIDESHOW_DELAY:
+ return "SLIDESHOW_DELAY";
+
+ case SLIDESHOW_TRANSITION_DELAY:
+ return "SLIDESHOW_TRANSITION_DELAY";
+
+ case SLIDESHOW_TRANSITION_EFFECT_ID:
+ return "SLIDESHOW_TRANSITION_EFFECT_ID";
+
+ case SLIDESHOW_SHOW_TITLE:
+ return "SLIDESHOW_SHOW_TITLE";
+
+ case USE_24_HOUR_TIME:
+ return "USE_24_HOUR_TIME";
+
+ case USE_LOWERCASE_FILENAMES:
+ return "USE_LOWERCASE_FILENAMES";
+
+ case VIDEO_INTERPRETER_STATE_COOKIE:
+ return "VIDEO_INTERPRETER_STATE_COOKIE";
+
+ default:
+ error("unknown ConfigurableProperty enumeration value");
+ }
+ }
+}
+
+public interface ConfigurationEngine : GLib.Object {
+ public signal void property_changed(ConfigurableProperty p);
+
+ public abstract string get_name();
+
+ public abstract int get_int_property(ConfigurableProperty p) throws ConfigurationError;
+ public abstract void set_int_property(ConfigurableProperty p, int val) throws ConfigurationError;
+
+ public abstract string get_string_property(ConfigurableProperty p) throws ConfigurationError;
+ public abstract void set_string_property(ConfigurableProperty p, string val) throws ConfigurationError;
+
+ public abstract bool get_bool_property(ConfigurableProperty p) throws ConfigurationError;
+ public abstract void set_bool_property(ConfigurableProperty p, bool val) throws ConfigurationError;
+
+ public abstract double get_double_property(ConfigurableProperty p) throws ConfigurationError;
+ public abstract void set_double_property(ConfigurableProperty p, double val) throws ConfigurationError;
+
+ public abstract bool get_plugin_bool(string domain, string id, string key, bool def);
+ public abstract void set_plugin_bool(string domain, string id, string key, bool val);
+ public abstract double get_plugin_double(string domain, string id, string key, double def);
+ public abstract void set_plugin_double(string domain, string id, string key, double val);
+ public abstract int get_plugin_int(string domain, string id, string key, int def);
+ public abstract void set_plugin_int(string domain, string id, string key, int val);
+ public abstract string? get_plugin_string(string domain, string id, string key, string? def);
+ public abstract void set_plugin_string(string domain, string id, string key, string? val);
+ public abstract void unset_plugin_key(string domain, string id, string key);
+
+ public abstract FuzzyPropertyState is_plugin_enabled(string id);
+ public abstract void set_plugin_enabled(string id, bool enabled);
+}
+
+public abstract class ConfigurationFacade : Object {
+ private ConfigurationEngine engine;
+
+ public signal void auto_import_from_library_changed();
+ public signal void bg_color_name_changed();
+ public signal void commit_metadata_to_masters_changed();
+ public signal void events_sort_ascending_changed();
+ public signal void external_app_changed();
+ public signal void import_directory_changed();
+
+ protected ConfigurationFacade(ConfigurationEngine engine) {
+ this.engine = engine;
+
+ engine.property_changed.connect(on_property_changed);
+ }
+
+ private void on_property_changed(ConfigurableProperty p) {
+ debug ("ConfigurationFacade: engine reports property '%s' changed.", p.to_string());
+
+ switch (p) {
+ case ConfigurableProperty.AUTO_IMPORT_FROM_LIBRARY:
+ auto_import_from_library_changed();
+ break;
+
+ case ConfigurableProperty.BG_COLOR_NAME:
+ bg_color_name_changed();
+ break;
+
+ case ConfigurableProperty.COMMIT_METADATA_TO_MASTERS:
+ commit_metadata_to_masters_changed();
+ break;
+
+ case ConfigurableProperty.EVENTS_SORT_ASCENDING:
+ events_sort_ascending_changed();
+ break;
+
+ case ConfigurableProperty.EXTERNAL_PHOTO_APP:
+ case ConfigurableProperty.EXTERNAL_RAW_APP:
+ external_app_changed();
+ break;
+
+ case ConfigurableProperty.IMPORT_DIR:
+ import_directory_changed();
+ break;
+ }
+ }
+
+ protected ConfigurationEngine get_engine() {
+ return engine;
+ }
+
+ protected void on_configuration_error(ConfigurationError err) {
+ if (err is ConfigurationError.PROPERTY_HAS_NO_VALUE) {
+ message("configuration engine '%s' reports PROPERTY_HAS_NO_VALUE error: %s",
+ engine.get_name(), err.message);
+ }
+ else if (err is ConfigurationError.ENGINE_ERROR) {
+ critical("configuration engine '%s' reports ENGINE_ERROR: %s",
+ engine.get_name(), err.message);
+ } else {
+ critical("configuration engine '%s' reports unknown error: %s",
+ engine.get_name(), err.message);
+ }
+ }
+
+ //
+ // auto import from library
+ //
+ public virtual bool get_auto_import_from_library() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.AUTO_IMPORT_FROM_LIBRARY);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_auto_import_from_library(bool auto_import) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.AUTO_IMPORT_FROM_LIBRARY,
+ auto_import);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // bg color name
+ //
+ public virtual string get_bg_color_name() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.BG_COLOR_NAME);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_bg_color_name(string color_name) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.BG_COLOR_NAME, color_name);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // commit metadata to masters
+ //
+ public virtual bool get_commit_metadata_to_masters() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.COMMIT_METADATA_TO_MASTERS);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_commit_metadata_to_masters(bool commit_metadata) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.COMMIT_METADATA_TO_MASTERS,
+ commit_metadata);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // desktop background
+ //
+ public virtual string get_desktop_background() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.DESKTOP_BACKGROUND_FILE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_desktop_background(string filename) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.DESKTOP_BACKGROUND_FILE,
+ filename);
+ get_engine().set_string_property(ConfigurableProperty.DESKTOP_BACKGROUND_MODE,
+ "zoom");
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // directory pattern
+ //
+ public virtual string? get_directory_pattern() {
+ try {
+ string s = get_engine().get_string_property(ConfigurableProperty.DIRECTORY_PATTERN);
+ return (s == "") ? null : s;
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_directory_pattern(string? s) {
+ try {
+ if (s == null)
+ s = "";
+
+ get_engine().set_string_property(ConfigurableProperty.DIRECTORY_PATTERN, s);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // directory pattern custom
+ //
+ public virtual string get_directory_pattern_custom() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.DIRECTORY_PATTERN_CUSTOM);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_directory_pattern_custom(string s) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.DIRECTORY_PATTERN_CUSTOM, s);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // direct window state
+ //
+ public virtual void get_direct_window_state(out bool maximize, out Dimensions dimensions) {
+ maximize = false;
+ dimensions = Dimensions(1024, 768);
+ try {
+ maximize = get_engine().get_bool_property(ConfigurableProperty.DIRECT_WINDOW_MAXIMIZE);
+ int w = get_engine().get_int_property(ConfigurableProperty.DIRECT_WINDOW_WIDTH);
+ int h = get_engine().get_int_property(ConfigurableProperty.DIRECT_WINDOW_HEIGHT);
+ dimensions = Dimensions(w, h);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ public virtual void set_direct_window_state(bool maximize, Dimensions dimensions) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DIRECT_WINDOW_MAXIMIZE, maximize);
+ get_engine().set_int_property(ConfigurableProperty.DIRECT_WINDOW_WIDTH,
+ dimensions.width);
+ get_engine().set_int_property(ConfigurableProperty.DIRECT_WINDOW_HEIGHT,
+ dimensions.height);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display basic properties
+ //
+ public virtual bool get_display_basic_properties() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_BASIC_PROPERTIES);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_display_basic_properties(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_BASIC_PROPERTIES, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display extended properties
+ //
+ public virtual bool get_display_extended_properties() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_EXTENDED_PROPERTIES);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_display_extended_properties(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_EXTENDED_PROPERTIES,
+ display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display sidebar
+ //
+ public virtual bool get_display_sidebar() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_SIDEBAR);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_display_sidebar(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_SIDEBAR, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display search & filter toolbar
+ //
+ public virtual bool get_display_search_bar() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_SEARCH_BAR);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_display_search_bar(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_SEARCH_BAR, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display photo ratings
+ //
+ public virtual bool get_display_photo_ratings() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_PHOTO_RATINGS);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_display_photo_ratings(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_PHOTO_RATINGS, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display photo tags
+ //
+ public virtual bool get_display_photo_tags() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_PHOTO_TAGS);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_display_photo_tags(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_PHOTO_TAGS, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display photo titles
+ //
+ public virtual bool get_display_photo_titles() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_PHOTO_TITLES);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_display_photo_titles(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_PHOTO_TITLES, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display photo comments
+ //
+ public virtual bool get_display_photo_comments() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_PHOTO_COMMENTS);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_display_photo_comments(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_PHOTO_COMMENTS, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // display event comments
+ //
+ public virtual bool get_display_event_comments() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.DISPLAY_EVENT_COMMENTS);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_display_event_comments(bool display) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.DISPLAY_EVENT_COMMENTS, display);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // event photos sort
+ //
+ public virtual void get_event_photos_sort(out bool sort_order, out int sort_by) {
+ sort_order = false;
+ sort_by = 2;
+ try {
+ sort_order = get_engine().get_bool_property(
+ ConfigurableProperty.EVENT_PHOTOS_SORT_ASCENDING);
+ sort_by = get_engine().get_int_property(ConfigurableProperty.EVENT_PHOTOS_SORT_BY);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ public virtual void set_event_photos_sort(bool sort_order, int sort_by) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.EVENT_PHOTOS_SORT_ASCENDING,
+ sort_order);
+ get_engine().set_int_property(ConfigurableProperty.EVENT_PHOTOS_SORT_BY,
+ sort_by);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // events sort ascending
+ //
+ public virtual bool get_events_sort_ascending() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.EVENTS_SORT_ASCENDING);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_events_sort_ascending(bool sort) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.EVENTS_SORT_ASCENDING, sort);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // external photo app
+ //
+ public virtual string get_external_photo_app() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.EXTERNAL_PHOTO_APP);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_external_photo_app(string external_photo_app) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.EXTERNAL_PHOTO_APP,
+ external_photo_app);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // external raw app
+ //
+ public virtual string get_external_raw_app() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.EXTERNAL_RAW_APP);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_external_raw_app(string external_raw_app) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.EXTERNAL_RAW_APP,
+ external_raw_app);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // Default RAW developer.
+ //
+ public virtual RawDeveloper get_default_raw_developer() {
+ try {
+ return RawDeveloper.from_string(get_engine().get_string_property(
+ ConfigurableProperty.RAW_DEVELOPER_DEFAULT));
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return RawDeveloper.CAMERA;
+ }
+ }
+
+ public virtual void set_default_raw_developer(RawDeveloper d) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.RAW_DEVELOPER_DEFAULT,
+ d.to_string());
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return;
+ }
+ }
+
+ //
+ // hide photos already imported
+ //
+ public virtual bool get_hide_photos_already_imported() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.HIDE_PHOTOS_ALREADY_IMPORTED);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_hide_photos_already_imported(bool hide_imported) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.HIDE_PHOTOS_ALREADY_IMPORTED, hide_imported);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // import dir
+ //
+ public virtual string get_import_dir() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.IMPORT_DIR);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return "";
+ }
+ }
+
+ public virtual void set_import_dir(string import_dir) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.IMPORT_DIR, import_dir);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // keep relativity
+ //
+ public virtual bool get_keep_relativity() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.KEEP_RELATIVITY);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_keep_relativity(bool keep_relativity) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.KEEP_RELATIVITY, keep_relativity);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // pin toolbar state
+ //
+ public virtual bool get_pin_toolbar_state() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.PIN_TOOLBAR_STATE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return false;
+ }
+ }
+
+ public virtual void set_pin_toolbar_state(bool state) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.PIN_TOOLBAR_STATE, state);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // last crop height
+ //
+ public virtual int get_last_crop_height() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.LAST_CROP_HEIGHT);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return 1;
+ }
+ }
+
+ public virtual void set_last_crop_height(int choice) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.LAST_CROP_HEIGHT, choice);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // last crop menu choice
+ //
+ public virtual int get_last_crop_menu_choice() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.LAST_CROP_MENU_CHOICE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ // in the event we can't get a reasonable value from the configuration engine, we
+ // return the empty string since it won't match the name of any existing publishing
+ // service -- this will cause the publishing subsystem to select the first service
+ // loaded that supports the user's media type
+ return 0;
+ }
+ }
+
+ public virtual void set_last_crop_menu_choice(int choice) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.LAST_CROP_MENU_CHOICE, choice);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // last crop width
+ //
+ public virtual int get_last_crop_width() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.LAST_CROP_WIDTH);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return 1;
+ }
+ }
+
+ public virtual void set_last_crop_width(int choice) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.LAST_CROP_WIDTH, choice);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // last used service
+ //
+ public virtual string get_last_used_service() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.LAST_USED_SERVICE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ // in the event we can't get a reasonable value from the configuration engine, we
+ // return the empty string since it won't match the name of any existing publishing
+ // service -- this will cause the publishing subsystem to select the first service
+ // loaded that supports the user's media type
+ return "";
+ }
+ }
+
+ public virtual void set_last_used_service(string service_name) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.LAST_USED_SERVICE, service_name);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // last used import service
+ //
+ public virtual string get_last_used_dataimports_service() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.LAST_USED_DATAIMPORTS_SERVICE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ // in the event we can't get a reasonable value from the configuration engine, we
+ // return the empty string since it won't match the name of any existing import
+ // service -- this will cause the import subsystem to select the first service
+ // loaded
+ return "";
+ }
+ }
+
+ public virtual void set_last_used_dataimports_service(string service_name) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.LAST_USED_DATAIMPORTS_SERVICE, service_name);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // library photos sort
+ //
+ public virtual void get_library_photos_sort(out bool sort_order, out int sort_by) {
+ sort_order = false;
+ sort_by = 2;
+ try {
+ sort_order = get_engine().get_bool_property(
+ ConfigurableProperty.LIBRARY_PHOTOS_SORT_ASCENDING);
+ sort_by = get_engine().get_int_property(ConfigurableProperty.LIBRARY_PHOTOS_SORT_BY);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ public virtual void set_library_photos_sort(bool sort_order, int sort_by) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.LIBRARY_PHOTOS_SORT_ASCENDING,
+ sort_order);
+ get_engine().set_int_property(ConfigurableProperty.LIBRARY_PHOTOS_SORT_BY,
+ sort_by);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // library window state
+ //
+ public virtual void get_library_window_state(out bool maximize, out Dimensions dimensions) {
+ maximize = false;
+ dimensions = Dimensions(1024, 768);
+ try {
+ maximize = get_engine().get_bool_property(ConfigurableProperty.LIBRARY_WINDOW_MAXIMIZE);
+ int w = get_engine().get_int_property(ConfigurableProperty.LIBRARY_WINDOW_WIDTH);
+ int h = get_engine().get_int_property(ConfigurableProperty.LIBRARY_WINDOW_HEIGHT);
+ dimensions = Dimensions(w, h);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ public virtual void set_library_window_state(bool maximize, Dimensions dimensions) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.LIBRARY_WINDOW_MAXIMIZE, maximize);
+ get_engine().set_int_property(ConfigurableProperty.LIBRARY_WINDOW_WIDTH,
+ dimensions.width);
+ get_engine().set_int_property(ConfigurableProperty.LIBRARY_WINDOW_HEIGHT,
+ dimensions.height);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // modify originals
+ //
+ public virtual bool get_modify_originals() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.MODIFY_ORIGINALS);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ // if we can't get a reasonable value from the configuration engine, don't modify
+ // originals
+ return false;
+ }
+ }
+
+ public virtual void set_modify_originals(bool modify_originals) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.MODIFY_ORIGINALS, modify_originals);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // photo thumbnail scale
+ //
+ public virtual int get_photo_thumbnail_scale() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.PHOTO_THUMBNAIL_SCALE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ return Thumbnail.DEFAULT_SCALE;
+ }
+ }
+
+ public virtual void set_photo_thumbnail_scale(int scale) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.PHOTO_THUMBNAIL_SCALE, scale);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing content height
+ //
+ public virtual double get_printing_content_height() {
+ try {
+ return get_engine().get_double_property(ConfigurableProperty.PRINTING_CONTENT_HEIGHT);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 5.0;
+ }
+ }
+
+ public virtual void set_printing_content_height(double content_height) {
+ try {
+ get_engine().set_double_property(ConfigurableProperty.PRINTING_CONTENT_HEIGHT,
+ content_height);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing content layout
+ //
+ public virtual int get_printing_content_layout() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.PRINTING_CONTENT_LAYOUT) - 1;
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 0;
+ }
+ }
+
+ public virtual void set_printing_content_layout(int layout_code) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.PRINTING_CONTENT_LAYOUT,
+ layout_code + 1);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing content ppi
+ //
+ public virtual int get_printing_content_ppi() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.PRINTING_CONTENT_PPI);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 600;
+ }
+ }
+
+ public virtual void set_printing_content_ppi(int content_ppi) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.PRINTING_CONTENT_PPI, content_ppi);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing content units
+ //
+ public virtual int get_printing_content_units() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.PRINTING_CONTENT_UNITS) - 1;
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 0;
+ }
+ }
+
+ public virtual void set_printing_content_units(int units_code) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.PRINTING_CONTENT_UNITS,
+ units_code + 1);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing content width
+ //
+ public virtual double get_printing_content_width() {
+ try {
+ return get_engine().get_double_property(ConfigurableProperty.PRINTING_CONTENT_WIDTH);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 7.0;
+ }
+ }
+
+ public virtual void set_printing_content_width(double content_width) {
+ try {
+ get_engine().set_double_property(ConfigurableProperty.PRINTING_CONTENT_WIDTH,
+ content_width);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing images per page
+ //
+ public virtual int get_printing_images_per_page() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.PRINTING_IMAGES_PER_PAGE) - 1;
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 0;
+ }
+ }
+
+ public virtual void set_printing_images_per_page(int images_per_page_code) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.PRINTING_IMAGES_PER_PAGE,
+ images_per_page_code + 1);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing match aspect ratio
+ //
+ public virtual bool get_printing_match_aspect_ratio() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.PRINTING_MATCH_ASPECT_RATIO);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_printing_match_aspect_ratio(bool match_aspect_ratio) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.PRINTING_MATCH_ASPECT_RATIO,
+ match_aspect_ratio);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing print titles
+ //
+ public virtual bool get_printing_print_titles() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.PRINTING_PRINT_TITLES);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_printing_print_titles(bool print_titles) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.PRINTING_PRINT_TITLES,
+ print_titles);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing size selection
+ //
+ public virtual int get_printing_size_selection() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.PRINTING_SIZE_SELECTION) - 1;
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 0;
+ }
+ }
+
+ public virtual void set_printing_size_selection(int size_code) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.PRINTING_SIZE_SELECTION,
+ size_code + 1);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // printing titles font
+ //
+ public virtual string get_printing_titles_font() {
+ try {
+ return get_engine().get_string_property(ConfigurableProperty.PRINTING_TITLES_FONT);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ // in the event we can't get a reasonable value from the configuration engine, just
+ // use the system default Sans Serif font
+ return "Sans Bold 12";
+ }
+ }
+
+ public virtual void set_printing_titles_font(string font_name) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.PRINTING_TITLES_FONT, font_name);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // show welcome dialog
+ //
+ public virtual bool get_show_welcome_dialog() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.SHOW_WELCOME_DIALOG);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return true;
+ }
+ }
+
+ public virtual void set_show_welcome_dialog(bool show) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.SHOW_WELCOME_DIALOG,
+ show);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // sidebar position
+ //
+ public virtual int get_sidebar_position() {
+ try {
+ return get_engine().get_int_property(ConfigurableProperty.SIDEBAR_POSITION);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 180;
+ }
+ }
+
+ public virtual void set_sidebar_position(int position) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.SIDEBAR_POSITION, position);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // slideshow delay
+ //
+ public virtual double get_slideshow_delay() {
+ try {
+ return get_engine().get_double_property(ConfigurableProperty.SLIDESHOW_DELAY);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 3.0;
+ }
+ }
+
+ public virtual void set_slideshow_delay(double delay) {
+ try {
+ get_engine().set_double_property(ConfigurableProperty.SLIDESHOW_DELAY, delay);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // slideshow transition delay
+ //
+ public virtual double get_slideshow_transition_delay() {
+ try {
+ return get_engine().get_double_property(
+ ConfigurableProperty.SLIDESHOW_TRANSITION_DELAY);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return 0.3;
+ }
+ }
+
+ public virtual void set_slideshow_transition_delay(double delay) {
+ try {
+ get_engine().set_double_property(ConfigurableProperty.SLIDESHOW_TRANSITION_DELAY,
+ delay);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // slideshow transition effect id
+ //
+ public virtual string get_slideshow_transition_effect_id() {
+ try {
+ return get_engine().get_string_property(
+ ConfigurableProperty.SLIDESHOW_TRANSITION_EFFECT_ID);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ // in the event we can't get a reasonable value from the configuration engine, use
+ // the null transition effect
+ return TransitionEffectsManager.NULL_EFFECT_ID;
+ }
+ }
+
+ public virtual void set_slideshow_transition_effect_id(string id) {
+ try {
+ get_engine().set_string_property(ConfigurableProperty.SLIDESHOW_TRANSITION_EFFECT_ID,
+ id);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // Slideshow show title
+ //
+ public virtual bool get_slideshow_show_title() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.SLIDESHOW_SHOW_TITLE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_slideshow_show_title(bool show_title) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.SLIDESHOW_SHOW_TITLE, show_title);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // use 24 hour time
+ //
+ public virtual bool get_use_24_hour_time() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.USE_24_HOUR_TIME);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ // if we can't get a reasonable value from the configuration system, then use the
+ // operating system default for the user's country and region.
+ return is_string_empty(Time.local(0).format("%p"));
+ }
+ }
+
+ public virtual void set_use_24_hour_time(bool use_24_hour_time) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.USE_24_HOUR_TIME, use_24_hour_time);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // use lowercase filenames
+ //
+ public virtual bool get_use_lowercase_filenames() {
+ try {
+ return get_engine().get_bool_property(ConfigurableProperty.USE_LOWERCASE_FILENAMES);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return false;
+ }
+ }
+
+ public virtual void set_use_lowercase_filenames(bool b) {
+ try {
+ get_engine().set_bool_property(ConfigurableProperty.USE_LOWERCASE_FILENAMES, b);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // video interpreter state cookie
+ //
+ public virtual int get_video_interpreter_state_cookie() {
+ try {
+ return get_engine().get_int_property(
+ ConfigurableProperty.VIDEO_INTERPRETER_STATE_COOKIE);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+
+ return -1;
+ }
+ }
+
+ public virtual void set_video_interpreter_state_cookie(int state_cookie) {
+ try {
+ get_engine().set_int_property(ConfigurableProperty.VIDEO_INTERPRETER_STATE_COOKIE,
+ state_cookie);
+ } catch (ConfigurationError err) {
+ on_configuration_error(err);
+ }
+ }
+
+ //
+ // allow plugins to get & set arbitrary properties
+ //
+ public virtual bool get_plugin_bool(string domain, string id, string key, bool def) {
+ return get_engine().get_plugin_bool(domain, id, key, def);
+ }
+
+ public virtual void set_plugin_bool(string domain, string id, string key, bool val) {
+ get_engine().set_plugin_bool(domain, id, key, val);
+ }
+
+ public virtual double get_plugin_double(string domain, string id, string key, double def) {
+ return get_engine().get_plugin_double(domain, id, key, def);
+ }
+
+ public virtual void set_plugin_double(string domain, string id, string key, double val) {
+ get_engine().set_plugin_double(domain, id, key, val);
+ }
+
+ public virtual int get_plugin_int(string domain, string id, string key, int def) {
+ return get_engine().get_plugin_int(domain, id, key, def);
+ }
+
+ public virtual void set_plugin_int(string domain, string id, string key, int val) {
+ get_engine().set_plugin_int(domain, id, key, val);
+ }
+
+ public virtual string? get_plugin_string(string domain, string id, string key, string? def) {
+ string? result = get_engine().get_plugin_string(domain, id, key, def);
+ return (result == "") ? null : result;
+ }
+
+ public virtual void set_plugin_string(string domain, string id, string key, string? val) {
+ if (val == null)
+ val = "";
+
+ get_engine().set_plugin_string(domain, id, key, val);
+ }
+
+ public virtual void unset_plugin_key(string domain, string id, string key) {
+ get_engine().unset_plugin_key(domain, id, key);
+ }
+
+ //
+ // enable & disable plugins
+ //
+ public virtual FuzzyPropertyState is_plugin_enabled(string id) {
+ return get_engine().is_plugin_enabled(id);
+ }
+
+ public virtual void set_plugin_enabled(string id, bool enabled) {
+ get_engine().set_plugin_enabled(id, enabled);
+ }
+}
diff --git a/src/config/GSettingsEngine.vala b/src/config/GSettingsEngine.vala
new file mode 100644
index 0000000..3a55648
--- /dev/null
+++ b/src/config/GSettingsEngine.vala
@@ -0,0 +1,469 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class GSettingsConfigurationEngine : ConfigurationEngine, GLib.Object {
+ private const string ROOT_SCHEMA_NAME = "org.yorba.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";
+ private const string WINDOW_PREFS_SCHEMA_NAME = PREFS_SCHEMA_NAME + ".window";
+ private const string FILES_PREFS_SCHEMA_NAME = PREFS_SCHEMA_NAME + ".files";
+ private const string EDITING_PREFS_SCHEMA_NAME = PREFS_SCHEMA_NAME + ".editing";
+ private const string VIDEO_SCHEMA_NAME = ROOT_SCHEMA_NAME + ".video";
+ private const string PRINTING_SCHEMA_NAME = ROOT_SCHEMA_NAME + ".printing";
+ private const string SHARING_SCHEMA_NAME = ROOT_SCHEMA_NAME + ".sharing";
+ private const string IMPORTING_SCHEMA_NAME = ROOT_SCHEMA_NAME + ".dataimports";
+ private const string CROP_SCHEMA_NAME = ROOT_SCHEMA_NAME + ".crop-settings";
+ private const string SYSTEM_DESKTOP_SCHEMA_NAME = "org.gnome.desktop.background";
+ private const string PLUGINS_ENABLE_DISABLE_SCHEMA_NAME = ROOT_SCHEMA_NAME +
+ ".plugins.enable-state";
+
+ private Gee.Set<string> known_schemas;
+ private string[] schema_names;
+ private string[] key_names;
+
+ public GSettingsConfigurationEngine() {
+ known_schemas = new Gee.HashSet<string>();
+
+ foreach (string current_schema in Settings.list_schemas())
+ known_schemas.add(current_schema);
+
+ schema_names = new string[ConfigurableProperty.NUM_PROPERTIES];
+
+ schema_names[ConfigurableProperty.AUTO_IMPORT_FROM_LIBRARY] = FILES_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.BG_COLOR_NAME] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.COMMIT_METADATA_TO_MASTERS] = FILES_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DESKTOP_BACKGROUND_FILE] = SYSTEM_DESKTOP_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DESKTOP_BACKGROUND_MODE] = SYSTEM_DESKTOP_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DIRECTORY_PATTERN] = FILES_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DIRECTORY_PATTERN_CUSTOM] = FILES_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DIRECT_WINDOW_HEIGHT] = WINDOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DIRECT_WINDOW_MAXIMIZE] = WINDOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DIRECT_WINDOW_WIDTH] = WINDOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DISPLAY_BASIC_PROPERTIES] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DISPLAY_EXTENDED_PROPERTIES] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DISPLAY_SIDEBAR] = 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;
+ schema_names[ConfigurableProperty.DISPLAY_PHOTO_TITLES] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DISPLAY_PHOTO_COMMENTS] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.DISPLAY_EVENT_COMMENTS] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.EVENT_PHOTOS_SORT_ASCENDING] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.EVENT_PHOTOS_SORT_BY] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.EVENTS_SORT_ASCENDING] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.EXTERNAL_PHOTO_APP] = EDITING_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.EXTERNAL_RAW_APP] = EDITING_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.HIDE_PHOTOS_ALREADY_IMPORTED] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.IMPORT_DIR] = FILES_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.KEEP_RELATIVITY] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LAST_CROP_HEIGHT] = CROP_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LAST_CROP_MENU_CHOICE] = CROP_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LAST_CROP_WIDTH] = CROP_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LAST_USED_SERVICE] = SHARING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LAST_USED_DATAIMPORTS_SERVICE] = IMPORTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LIBRARY_PHOTOS_SORT_ASCENDING] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LIBRARY_PHOTOS_SORT_BY] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LIBRARY_WINDOW_HEIGHT] = WINDOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LIBRARY_WINDOW_MAXIMIZE] = WINDOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.LIBRARY_WINDOW_WIDTH] = WINDOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.MODIFY_ORIGINALS] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PHOTO_THUMBNAIL_SCALE] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PIN_TOOLBAR_STATE] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_CONTENT_HEIGHT] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_CONTENT_LAYOUT] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_CONTENT_PPI] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_CONTENT_UNITS] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_CONTENT_WIDTH] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_IMAGES_PER_PAGE] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_MATCH_ASPECT_RATIO] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_PRINT_TITLES] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_SIZE_SELECTION] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.PRINTING_TITLES_FONT] = PRINTING_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.RAW_DEVELOPER_DEFAULT] = FILES_PREFS_SCHEMA_NAME;;
+ schema_names[ConfigurableProperty.SHOW_WELCOME_DIALOG] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.SIDEBAR_POSITION] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.SLIDESHOW_DELAY] = SLIDESHOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.SLIDESHOW_TRANSITION_DELAY] = SLIDESHOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.SLIDESHOW_TRANSITION_EFFECT_ID] = SLIDESHOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.SLIDESHOW_SHOW_TITLE] = SLIDESHOW_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.USE_24_HOUR_TIME] = UI_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.USE_LOWERCASE_FILENAMES] = FILES_PREFS_SCHEMA_NAME;
+ schema_names[ConfigurableProperty.VIDEO_INTERPRETER_STATE_COOKIE] = VIDEO_SCHEMA_NAME;
+
+ key_names = new string[ConfigurableProperty.NUM_PROPERTIES];
+
+ key_names[ConfigurableProperty.AUTO_IMPORT_FROM_LIBRARY] = "auto-import";
+ key_names[ConfigurableProperty.BG_COLOR_NAME] = "background-color";
+ key_names[ConfigurableProperty.COMMIT_METADATA_TO_MASTERS] = "commit-metadata";
+ key_names[ConfigurableProperty.DESKTOP_BACKGROUND_FILE] = "picture-uri";
+ key_names[ConfigurableProperty.DESKTOP_BACKGROUND_MODE] = "picture-options";
+ key_names[ConfigurableProperty.DIRECTORY_PATTERN] = "directory-pattern";
+ key_names[ConfigurableProperty.DIRECTORY_PATTERN_CUSTOM] = "directory-pattern-custom";
+ key_names[ConfigurableProperty.DIRECT_WINDOW_HEIGHT] = "direct-height";
+ key_names[ConfigurableProperty.DIRECT_WINDOW_MAXIMIZE] = "direct-maximize";
+ key_names[ConfigurableProperty.DIRECT_WINDOW_WIDTH] = "direct-width";
+ key_names[ConfigurableProperty.DISPLAY_BASIC_PROPERTIES] = "display-basic-properties";
+ key_names[ConfigurableProperty.DISPLAY_EXTENDED_PROPERTIES] = "display-extended-properties";
+ key_names[ConfigurableProperty.DISPLAY_SIDEBAR] = "display-sidebar";
+ 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";
+ key_names[ConfigurableProperty.DISPLAY_PHOTO_TITLES] = "display-photo-titles";
+ key_names[ConfigurableProperty.DISPLAY_PHOTO_COMMENTS] = "display-photo-comments";
+ key_names[ConfigurableProperty.DISPLAY_EVENT_COMMENTS] = "display-event-comments";
+ key_names[ConfigurableProperty.EVENT_PHOTOS_SORT_ASCENDING] = "event-photos-sort-ascending";
+ key_names[ConfigurableProperty.EVENT_PHOTOS_SORT_BY] = "event-photos-sort-by";
+ key_names[ConfigurableProperty.EVENTS_SORT_ASCENDING] = "events-sort-ascending";
+ key_names[ConfigurableProperty.EXTERNAL_PHOTO_APP] = "external-photo-editor";
+ key_names[ConfigurableProperty.EXTERNAL_RAW_APP] = "external-raw-editor";
+ key_names[ConfigurableProperty.HIDE_PHOTOS_ALREADY_IMPORTED] = "hide-photos-already-imported";
+ key_names[ConfigurableProperty.IMPORT_DIR] = "import-dir";
+ key_names[ConfigurableProperty.KEEP_RELATIVITY] = "keep-relativity";
+ key_names[ConfigurableProperty.LAST_CROP_HEIGHT] = "last-crop-height";
+ key_names[ConfigurableProperty.LAST_CROP_MENU_CHOICE] = "last-crop-menu-choice";
+ key_names[ConfigurableProperty.LAST_CROP_WIDTH] = "last-crop-width";
+ key_names[ConfigurableProperty.LAST_USED_SERVICE] = "last-used-service";
+ key_names[ConfigurableProperty.LAST_USED_DATAIMPORTS_SERVICE] = "last-used-dataimports-service";
+ key_names[ConfigurableProperty.LIBRARY_PHOTOS_SORT_ASCENDING] = "library-photos-sort-ascending";
+ key_names[ConfigurableProperty.LIBRARY_PHOTOS_SORT_BY] = "library-photos-sort-by";
+ key_names[ConfigurableProperty.LIBRARY_WINDOW_HEIGHT] = "library-height";
+ key_names[ConfigurableProperty.LIBRARY_WINDOW_MAXIMIZE] = "library-maximize";
+ key_names[ConfigurableProperty.LIBRARY_WINDOW_WIDTH] = "library-width";
+ key_names[ConfigurableProperty.MODIFY_ORIGINALS] = "modify-originals";
+ key_names[ConfigurableProperty.PHOTO_THUMBNAIL_SCALE] = "photo-thumbnail-scale";
+ key_names[ConfigurableProperty.PIN_TOOLBAR_STATE] = "pin-toolbar-state";
+ key_names[ConfigurableProperty.PRINTING_CONTENT_HEIGHT] = "content-height";
+ key_names[ConfigurableProperty.PRINTING_CONTENT_LAYOUT] = "content-layout";
+ key_names[ConfigurableProperty.PRINTING_CONTENT_PPI] = "content-ppi";
+ key_names[ConfigurableProperty.PRINTING_CONTENT_UNITS] = "content-units";
+ key_names[ConfigurableProperty.PRINTING_CONTENT_WIDTH] = "content-width";
+ key_names[ConfigurableProperty.PRINTING_IMAGES_PER_PAGE] = "images-per-page";
+ key_names[ConfigurableProperty.PRINTING_MATCH_ASPECT_RATIO] = "match-aspect-ratio";
+ key_names[ConfigurableProperty.PRINTING_PRINT_TITLES] = "print-titles";
+ key_names[ConfigurableProperty.PRINTING_SIZE_SELECTION] = "size-selection";
+ key_names[ConfigurableProperty.PRINTING_TITLES_FONT] = "titles-font";
+ key_names[ConfigurableProperty.RAW_DEVELOPER_DEFAULT] = "raw-developer-default";
+ key_names[ConfigurableProperty.SHOW_WELCOME_DIALOG] = "show-welcome-dialog";
+ key_names[ConfigurableProperty.SIDEBAR_POSITION] = "sidebar-position";
+ key_names[ConfigurableProperty.SLIDESHOW_DELAY] = "delay";
+ key_names[ConfigurableProperty.SLIDESHOW_TRANSITION_DELAY] = "transition-delay";
+ key_names[ConfigurableProperty.SLIDESHOW_TRANSITION_EFFECT_ID] = "transition-effect-id";
+ key_names[ConfigurableProperty.SLIDESHOW_SHOW_TITLE] = "show-title";
+ key_names[ConfigurableProperty.USE_24_HOUR_TIME] = "use-24-hour-time";
+ key_names[ConfigurableProperty.USE_LOWERCASE_FILENAMES] = "use-lowercase-filenames";
+ key_names[ConfigurableProperty.VIDEO_INTERPRETER_STATE_COOKIE] = "interpreter-state-cookie";
+ }
+
+ private bool schema_has_key(Settings schema_object, string key) {
+ foreach (string current_key in schema_object.list_keys()) {
+ if (current_key == key)
+ return true;
+ }
+
+ return false;
+ }
+
+ private void check_key_valid(string schema, string key) throws ConfigurationError {
+ if (!known_schemas.contains(schema))
+ throw new ConfigurationError.ENGINE_ERROR("schema '%s' is not installed".printf(schema));
+
+ Settings schema_object = new Settings(schema);
+
+ if (!schema_has_key(schema_object, key))
+ throw new ConfigurationError.ENGINE_ERROR("schema '%s' does not define key '%s'".printf(
+ schema, key));
+ }
+
+ private bool get_gs_bool(string schema, string key) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ return schema_object.get_boolean(key);
+ }
+
+ private void set_gs_bool(string schema, string key, bool value) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ schema_object.set_boolean(key, value);
+ }
+
+ private int get_gs_int(string schema, string key) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ return schema_object.get_int(key);
+ }
+
+ private void set_gs_int(string schema, string key, int value) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ schema_object.set_int(key, value);
+ }
+
+ private double get_gs_double(string schema, string key) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ return schema_object.get_double(key);
+ }
+
+ private void set_gs_double(string schema, string key, double value) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ schema_object.set_double(key, value);
+ }
+
+ private string get_gs_string(string schema, string key) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ return schema_object.get_string(key);
+ }
+
+ private void set_gs_string(string schema, string key, string value) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ schema_object.set_string(key, value);
+ }
+
+ private void reset_gs_to_default(string schema, string key) throws ConfigurationError {
+ check_key_valid(schema, key);
+
+ Settings schema_object = new Settings(schema);
+
+ schema_object.reset(key);
+ }
+
+ private static string? clean_plugin_id(string id) {
+ string cleaned = id.replace("/", "-");
+ cleaned = cleaned.strip();
+
+ return !is_string_empty(cleaned) ? cleaned : null;
+ }
+
+ private static string get_plugin_enable_disable_name(string id) {
+ string? cleaned_id = clean_plugin_id(id);
+ if (cleaned_id == null)
+ cleaned_id = "default";
+
+ cleaned_id = cleaned_id.replace("org.yorba.shotwell.", "");
+ cleaned_id = cleaned_id.replace(".", "-");
+
+ return cleaned_id;
+ }
+
+ private static string make_plugin_schema_name(string domain, string id) {
+ string? cleaned_id = clean_plugin_id(id);
+ if (cleaned_id == null)
+ cleaned_id = "default";
+ cleaned_id = cleaned_id.replace(".", "-");
+
+ return "org.yorba.shotwell.%s.%s".printf(domain, cleaned_id);
+ }
+
+ private static string make_gsettings_key(string gconf_key) {
+ return gconf_key.replace("_", "-");
+ }
+
+ public string get_name() {
+ return "GSettings";
+ }
+
+ public int get_int_property(ConfigurableProperty p) throws ConfigurationError {
+ return get_gs_int(schema_names[p], key_names[p]);
+ }
+
+ public void set_int_property(ConfigurableProperty p, int val) throws ConfigurationError {
+ set_gs_int(schema_names[p], key_names[p], val);
+ property_changed(p);
+ }
+
+ public string get_string_property(ConfigurableProperty p) throws ConfigurationError {
+ string gs_result = get_gs_string(schema_names[p], key_names[p]);
+
+ // if we're getting the desktop background file, convert the file uri we get back from
+ // GSettings into a file path
+ string result = gs_result;
+ if (p == ConfigurableProperty.DESKTOP_BACKGROUND_FILE) {
+ result = gs_result.substring(7);
+ }
+
+ return result;
+ }
+
+ public void set_string_property(ConfigurableProperty p, string val) throws ConfigurationError {
+ // if we're setting the desktop background file, convert the filename into a file URI
+ string converted_val = val;
+ if (p == ConfigurableProperty.DESKTOP_BACKGROUND_FILE) {
+ converted_val = "file://" + val;
+ }
+
+ set_gs_string(schema_names[p], key_names[p], converted_val);
+ property_changed(p);
+ }
+
+ public bool get_bool_property(ConfigurableProperty p) throws ConfigurationError {
+ return get_gs_bool(schema_names[p], key_names[p]);
+ }
+
+ public void set_bool_property(ConfigurableProperty p, bool val) throws ConfigurationError {
+ set_gs_bool(schema_names[p], key_names[p], val);
+ property_changed(p);
+ }
+
+ public double get_double_property(ConfigurableProperty p) throws ConfigurationError {
+ return get_gs_double(schema_names[p], key_names[p]);
+ }
+
+ public void set_double_property(ConfigurableProperty p, double val) throws ConfigurationError {
+ set_gs_double(schema_names[p], key_names[p], val);
+ property_changed(p);
+ }
+
+ public bool get_plugin_bool(string domain, string id, string key, bool def) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ return get_gs_bool(schema_name, make_gsettings_key(key));
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ return def;
+ }
+ }
+
+ public void set_plugin_bool(string domain, string id, string key, bool val) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ set_gs_bool(schema_name, make_gsettings_key(key), val);
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ }
+ }
+
+ public double get_plugin_double(string domain, string id, string key, double def) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ return get_gs_double(schema_name, make_gsettings_key(key));
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ return def;
+ }
+ }
+
+ public void set_plugin_double(string domain, string id, string key, double val) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ set_gs_double(schema_name, make_gsettings_key(key), val);
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ }
+ }
+
+ public int get_plugin_int(string domain, string id, string key, int def) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ return get_gs_int(schema_name, make_gsettings_key(key));
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ return def;
+ }
+ }
+
+ public void set_plugin_int(string domain, string id, string key, int val) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ set_gs_int(schema_name, make_gsettings_key(key), val);
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ }
+ }
+
+ public string? get_plugin_string(string domain, string id, string key, string? def) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ return get_gs_string(schema_name, make_gsettings_key(key));
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ return def;
+ }
+ }
+
+ public void set_plugin_string(string domain, string id, string key, string? val) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ set_gs_string(schema_name, make_gsettings_key(key), val);
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ }
+ }
+
+ public void unset_plugin_key(string domain, string id, string key) {
+ string schema_name = make_plugin_schema_name(domain, id);
+
+ try {
+ reset_gs_to_default(schema_name, make_gsettings_key(key));
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ }
+ }
+
+ public FuzzyPropertyState is_plugin_enabled(string id) {
+ string enable_disable_name = get_plugin_enable_disable_name(id);
+
+ try {
+ return (get_gs_bool(PLUGINS_ENABLE_DISABLE_SCHEMA_NAME, enable_disable_name)) ?
+ FuzzyPropertyState.ENABLED : FuzzyPropertyState.DISABLED;
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ return FuzzyPropertyState.UNKNOWN;
+ }
+ }
+
+ public void set_plugin_enabled(string id, bool enabled) {
+ string enable_disable_name = get_plugin_enable_disable_name(id);
+
+ try {
+ set_gs_bool(PLUGINS_ENABLE_DISABLE_SCHEMA_NAME, enable_disable_name, enabled);
+ } catch (ConfigurationError err) {
+ critical("GSettingsConfigurationEngine: error: %s", err.message);
+ }
+ }
+
+ /*! @brief Migrates settings data over from old-style /apps/ paths to /org/yorba/ 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() {
+ string cmd_line = "sh " + AppDirs.get_settings_migrator_bin().get_path();
+
+ try {
+ Process.spawn_command_line_sync(cmd_line);
+ } catch (Error err) {
+ message("Error running shotwell-settings-migrator: %s", err.message);
+ }
+ }
+
+}
diff --git a/src/config/mk/config.mk b/src/config/mk/config.mk
new file mode 100644
index 0000000..e06dedf
--- /dev/null
+++ b/src/config/mk/config.mk
@@ -0,0 +1,29 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Config
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := config
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ ConfigurationInterfaces.vala \
+ GSettingsEngine.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/core/Alteration.vala b/src/core/Alteration.vala
new file mode 100644
index 0000000..865be84
--- /dev/null
+++ b/src/core/Alteration.vala
@@ -0,0 +1,316 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+//
+// Alteration represents a description of what has changed in the DataObject (reported via the
+// "altered" signal). Since the descriptions can vary wildly depending on the semantics of each
+// DataObject, no assumptions or requirements are placed on Alteration other than it must have
+// one or more "subjects", each with a "detail". Subscribers to the "altered" signal can query
+// the Alteration object to determine if the change is important to them.
+//
+// Alteration is an immutable type. This means it's possible to store const Alterations of oft-used
+// values for reuse.
+//
+// Alterations may be compressed, merging their subjects and details into a new aggregated
+// Alteration. Generally this is handled automatically by DataObject and DataCollection, when
+// necessary.
+//
+// NOTE: subjects and details should be ASCII labels (as in, plain-old ASCII, no code pages).
+// They are treated as case-sensitive strings.
+//
+// Recommended subjects include: image, thumbnail, metadata.
+//
+
+public class Alteration {
+ private string subject = null;
+ private string detail = null;
+ private Gee.MultiMap<string, string> map = null;
+
+ public Alteration(string subject, string detail) {
+ add_detail(subject, detail);
+ }
+
+ // Create an Alteration that has more than one subject/detail. list is a comma-delimited
+ // string of colon-separated subject:detail pairs.
+ public Alteration.from_list(string list) requires (list.length > 0) {
+ string[] pairs = list.split(",");
+ assert(pairs.length >= 1);
+
+ foreach (string pair in pairs) {
+ string[] subject_detail = pair.split(":", 2);
+ assert(subject_detail.length == 2);
+
+ add_detail(subject_detail[0], subject_detail[1]);
+ }
+ }
+
+ // Create an Alteration that has more than one subject/detail from an array of comma-delimited
+ // strings of colon-separate subject:detail pairs
+ public Alteration.from_array(string[] array) requires (array.length > 0) {
+ foreach (string pair in array) {
+ string[] subject_detail = pair.split(":", 2);
+ assert(subject_detail.length == 2);
+
+ add_detail(subject_detail[0], subject_detail[1]);
+ }
+ }
+
+ // Used for compression.
+ private Alteration.from_map(Gee.MultiMap<string, string> map) {
+ this.map = map;
+ }
+
+ private void add_detail(string sub, string det) {
+ // strip leading and trailing whitespace
+ string subject = sub.strip();
+ assert(subject.length > 0);
+
+ string detail = det.strip();
+ assert(detail.length > 0);
+
+ // if a simple Alteration, store in singleton refs
+ if (this.subject == null && map == null) {
+ assert(this.detail == null);
+
+ this.subject = subject;
+ this.detail = detail;
+
+ return;
+ }
+
+ // Now a complex Alteration, requiring a Map.
+ if (map == null)
+ map = create_map();
+
+ // Move singletons into Map
+ if (this.subject != null) {
+ assert(this.detail != null);
+
+ map.set(this.subject, this.detail);
+ this.subject = null;
+ this.detail = null;
+ }
+
+ // Store new subject:detail in Map as well
+ map.set(subject, detail);
+ }
+
+ private Gee.MultiMap<string, string> create_map() {
+ return new Gee.HashMultiMap<string, string>(case_hash, case_equal, case_hash, case_equal);
+ }
+
+ private static bool case_equal(string? a, string? b) {
+ return equal_values(a, b);
+ }
+
+ private static uint case_hash(string? a) {
+ return hash_value(a);
+ }
+
+ private static inline bool equal_values(string str1, string str2) {
+ return str1.ascii_casecmp(str2) == 0;
+ }
+
+ private static inline uint hash_value(string str) {
+ return str_hash(str);
+ }
+
+ public bool has_subject(string subject) {
+ if (this.subject != null)
+ return equal_values(this.subject, subject);
+
+ assert(map != null);
+ Gee.Set<string>? keys = map.get_keys();
+ if (keys != null) {
+ foreach (string key in keys) {
+ if (equal_values(key, subject))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public bool has_detail(string subject, string detail) {
+ if (this.subject != null && this.detail != null)
+ return equal_values(this.subject, subject) && equal_values(this.detail, detail);
+
+ assert(map != null);
+ Gee.Collection<string>? values = map.get(subject);
+ if (values != null) {
+ foreach (string value in values) {
+ if (equal_values(value, detail))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public Gee.Collection<string>? get_details(string subject) {
+ if (this.subject != null && detail != null && equal_values(this.subject, subject)) {
+ Gee.ArrayList<string> details = new Gee.ArrayList<string>();
+ details.add(detail);
+
+ return details;
+ }
+
+ return (map != null) ? map.get(subject) : null;
+ }
+
+ public string to_string() {
+ if (subject != null) {
+ assert(detail != null);
+
+ return "%s:%s".printf(subject, detail);
+ }
+
+ assert(map != null);
+
+ string str = "";
+ foreach (string key in map.get_keys()) {
+ foreach (string value in map.get(key)) {
+ if (str.length != 0)
+ str += ", ";
+
+ str += "%s:%s".printf(key, value);
+ }
+ }
+
+ return str;
+ }
+
+ // Returns true if this object has any subject:detail matches with the supplied Alteration.
+ public bool contains_any(Alteration other) {
+ // identity
+ if (this == other)
+ return true;
+
+ // if both singletons, check for singleton match
+ if (subject != null && other.subject != null && detail != null && other.detail != null)
+ return equal_values(subject, other.subject) && equal_values(detail, other.detail);
+
+ // if one is singleton and the other a multiple, search for singleton in multiple
+ if ((map != null && other.map == null) || (map == null && other.map != null)) {
+ string single_subject = subject != null ? subject : other.subject;
+ string single_detail = detail != null ? detail : other.detail;
+ Gee.MultiMap<string, string> multimap = map != null ? map : other.map;
+
+ return multimap.contains(single_subject) && map.get(single_subject).contains(single_detail);
+ }
+
+ // if both multiples, check for any match at all
+ if (map != null && other.map != null) {
+ Gee.Set<string>? keys = map.get_keys();
+ assert(keys != null);
+ Gee.Set<string>? other_keys = other.map.get_keys();
+ assert(other_keys != null);
+
+ foreach (string subject in other_keys) {
+ if (!keys.contains(subject))
+ continue;
+
+ Gee.Collection<string>? details = map.get(subject);
+ Gee.Collection<string>? other_details = other.map.get(subject);
+
+ if (details != null && other_details != null) {
+ foreach (string detail in other_details) {
+ if (details.contains(detail))
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public bool equals(Alteration other) {
+ // identity
+ if (this == other)
+ return true;
+
+ // if both singletons, check for singleton match
+ if (subject != null && other.subject != null && detail != null && other.detail != null)
+ return equal_values(subject, other.subject) && equal_values(detail, other.detail);
+
+ // if both multiples, check for across-the-board matches
+ if (map != null && other.map != null) {
+ // see if both maps contain the same set of keys
+ Gee.Set<string>? keys = map.get_keys();
+ assert(keys != null);
+ Gee.Set<string>? other_keys = other.map.get_keys();
+ assert(other_keys != null);
+
+ if (keys.size != other_keys.size)
+ return false;
+
+ if (!keys.contains_all(other_keys))
+ return false;
+
+ if (!other_keys.contains_all(keys))
+ return false;
+
+ foreach (string key in keys) {
+ Gee.Collection<string> values = map.get(key);
+ Gee.Collection<string> other_values = other.map.get(key);
+
+ if (values.size != other_values.size)
+ return false;
+
+ if (!values.contains_all(other_values))
+ return false;
+
+ if (!other_values.contains_all(values))
+ return false;
+ }
+
+ // maps are identical
+ return true;
+ }
+
+ // one singleton and one multiple, not equal
+ return false;
+ }
+
+ private static void multimap_add_all(Gee.MultiMap<string, string> dest,
+ Gee.MultiMap<string, string> src) {
+ Gee.Set<string> keys = src.get_keys();
+ foreach (string key in keys) {
+ Gee.Collection<string> values = src.get(key);
+ foreach (string value in values)
+ dest.set(key, value);
+ }
+ }
+
+ // This merges the Alterations, returning a new Alteration with both represented. If both
+ // Alterations are equal, this will return this object rather than create a new one.
+ public Alteration compress(Alteration other) {
+ if (equals(other))
+ return this;
+
+ // Build a new Alteration with both represented ... if they're unequal, then the new one
+ // is guaranteed not to be a singleton
+ Gee.MultiMap<string, string> compressed = create_map();
+
+ if (subject != null && detail != null) {
+ compressed.set(subject, detail);
+ } else {
+ assert(map != null);
+ multimap_add_all(compressed, map);
+ }
+
+ if (other.subject != null && other.detail != null) {
+ compressed.set(other.subject, other.detail);
+ } else {
+ assert(other.map != null);
+ multimap_add_all(compressed, other.map);
+ }
+
+ return new Alteration.from_map(compressed);
+ }
+}
+
diff --git a/src/core/ContainerSourceCollection.vala b/src/core/ContainerSourceCollection.vala
new file mode 100644
index 0000000..655cfa0
--- /dev/null
+++ b/src/core/ContainerSourceCollection.vala
@@ -0,0 +1,237 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// A ContainerSourceCollection is for DataSources which maintain links to one or more other
+// DataSources, assumed to be of a different type. ContainerSourceCollection automates the task
+// of handling unlinking and relinking and maintaining backlinks. Unlinked DataSources are
+// held in a holding tank, until they are either relinked or destroyed.
+//
+// If the ContainerSourceCollection's DataSources are types that "evaporate" (i.e. they disappear
+// when they hold no items), they should use the evaporate() method, which will either destroy
+// the DataSource or hold it in the tank (if backlinks are outstanding).
+public abstract class ContainerSourceCollection : DatabaseSourceCollection {
+ private Gee.HashSet<SourceCollection> attached_collections = new Gee.HashSet<SourceCollection>();
+ private string backlink_name;
+ private Gee.HashSet<ContainerSource> holding_tank = new Gee.HashSet<ContainerSource>();
+
+ public virtual signal void container_contents_added(ContainerSource container,
+ Gee.Collection<DataSource> added, bool relinked) {
+ }
+
+ public virtual signal void container_contents_removed(ContainerSource container,
+ Gee.Collection<DataSource> removed, bool unlinked) {
+ }
+
+ public virtual signal void container_contents_altered(ContainerSource container,
+ Gee.Collection<DataSource>? added, bool relinked, Gee.Collection<DataSource>? removed,
+ bool unlinked) {
+ }
+
+ public virtual signal void backlink_to_container_removed(ContainerSource container,
+ Gee.Collection<DataSource> sources) {
+ }
+
+ public ContainerSourceCollection(string backlink_name, string name,
+ GetSourceDatabaseKey source_key_func) {
+ base (name, source_key_func);
+
+ this.backlink_name = backlink_name;
+ }
+
+ ~ContainerSourceCollection() {
+ detach_all_collections();
+ }
+
+ protected override void notify_backlink_removed(SourceBacklink backlink,
+ Gee.Collection<DataSource> sources) {
+ base.notify_backlink_removed(backlink, sources);
+
+ ContainerSource? container = convert_backlink_to_container(backlink);
+ if (container != null)
+ notify_backlink_to_container_removed(container, sources);
+ }
+
+ public virtual void notify_container_contents_added(ContainerSource container,
+ Gee.Collection<DataSource> added, bool relinked) {
+ // if container is in holding tank, remove it now and relink to collection
+ if (holding_tank.contains(container)) {
+ bool removed = holding_tank.remove(container);
+ assert(removed);
+
+ relink(container);
+ }
+
+ container_contents_added(container, added, relinked);
+ }
+
+ public virtual void notify_container_contents_removed(ContainerSource container,
+ Gee.Collection<DataSource> removed, bool unlinked) {
+ container_contents_removed(container, removed, unlinked);
+ }
+
+ public virtual void notify_container_contents_altered(ContainerSource container,
+ Gee.Collection<DataSource>? added, bool relinked, Gee.Collection<DataSource>? removed,
+ bool unlinked) {
+ container_contents_altered(container, added, relinked, removed, unlinked);
+ }
+
+ public virtual void notify_backlink_to_container_removed(ContainerSource container,
+ Gee.Collection<DataSource> sources) {
+ backlink_to_container_removed(container, sources);
+ }
+
+ protected abstract Gee.Collection<ContainerSource>? get_containers_holding_source(DataSource source);
+
+ // Looks in holding_tank as well.
+ protected abstract ContainerSource? convert_backlink_to_container(SourceBacklink backlink);
+
+ protected void freeze_attached_notifications() {
+ foreach(SourceCollection collection in attached_collections)
+ collection.freeze_notifications();
+ }
+
+ protected void thaw_attached_notifications() {
+ foreach(SourceCollection collection in attached_collections)
+ collection.thaw_notifications();
+ }
+
+ public Gee.Collection<ContainerSource> get_holding_tank() {
+ return holding_tank.read_only_view;
+ }
+
+ public void init_add_unlinked(ContainerSource unlinked) {
+ holding_tank.add(unlinked);
+ }
+
+ public void init_add_many_unlinked(Gee.Collection<ContainerSource> unlinked) {
+ holding_tank.add_all(unlinked);
+ }
+
+ public bool relink_from_holding_tank(ContainerSource source) {
+ if (!holding_tank.remove(source))
+ return false;
+
+ relink(source);
+
+ return true;
+ }
+
+ private void on_contained_sources_unlinking(Gee.Collection<DataSource> unlinking) {
+ freeze_attached_notifications();
+
+ Gee.HashMultiMap<ContainerSource, DataSource> map =
+ new Gee.HashMultiMap<ContainerSource, DataSource>();
+
+ foreach (DataSource source in unlinking) {
+ Gee.Collection<ContainerSource>? containers = get_containers_holding_source(source);
+ if (containers == null || containers.size == 0)
+ continue;
+
+ foreach (ContainerSource container in containers) {
+ map.set(container, source);
+ source.set_backlink(container.get_backlink());
+ }
+ }
+
+ foreach (ContainerSource container in map.get_keys())
+ container.break_link_many(map.get(container));
+
+ thaw_attached_notifications();
+ }
+
+ private void on_contained_sources_relinked(Gee.Collection<DataSource> relinked) {
+ freeze_attached_notifications();
+
+ Gee.HashMultiMap<ContainerSource, DataSource> map =
+ new Gee.HashMultiMap<ContainerSource, DataSource>();
+
+ foreach (DataSource source in relinked) {
+ Gee.List<SourceBacklink>? backlinks = source.get_backlinks(backlink_name);
+ if (backlinks == null || backlinks.size == 0)
+ continue;
+
+ foreach (SourceBacklink backlink in backlinks) {
+ ContainerSource? container = convert_backlink_to_container(backlink);
+ if (container != null) {
+ map.set(container, source);
+ } else {
+ warning("Unable to relink %s to container backlink %s", source.to_string(),
+ backlink.to_string());
+ }
+ }
+ }
+
+ foreach (ContainerSource container in map.get_keys())
+ container.establish_link_many(map.get(container));
+
+ thaw_attached_notifications();
+ }
+
+ private void on_contained_source_destroyed(DataSource source) {
+ Gee.Iterator<ContainerSource> iter = holding_tank.iterator();
+ while (iter.next()) {
+ ContainerSource container = iter.get();
+
+ // By design, we no longer discard 'orphan' tags, that is, tags with zero media sources
+ // remaining, since empty tags are explicitly allowed to persist as of the 0.12 dev cycle.
+ if ((!container.has_links()) && !(container is Tag)) {
+ iter.remove();
+ container.destroy_orphan(true);
+ }
+ }
+ }
+
+ protected override void notify_item_destroyed(DataSource source) {
+ foreach (SourceCollection collection in attached_collections) {
+ collection.remove_backlink(((ContainerSource) source).get_backlink());
+ }
+
+ base.notify_item_destroyed(source);
+ }
+
+ // This method should be called by a ContainerSource when it needs to "evaporate" -- it no
+ // longer holds any source objects and should not be available to the user any longer. If link
+ // state persists for this ContainerSource, it will be held in the holding tank. Otherwise, it's
+ // destroyed.
+ public void evaporate(ContainerSource container) {
+ foreach (SourceCollection collection in attached_collections) {
+ if (collection.has_backlink(container.get_backlink())) {
+ unlink_marked(mark(container));
+ bool added = holding_tank.add(container);
+ assert(added);
+ return;
+ }
+ }
+
+ destroy_marked(mark(container), true);
+ }
+
+ public void attach_collection(SourceCollection collection) {
+ if (attached_collections.contains(collection)) {
+ warning("attempted to multiple-attach '%s' to '%s'", collection.to_string(), to_string());
+ return;
+ }
+
+ attached_collections.add(collection);
+
+ collection.items_unlinking.connect(on_contained_sources_unlinking);
+ collection.items_relinked.connect(on_contained_sources_relinked);
+ collection.item_destroyed.connect(on_contained_source_destroyed);
+ collection.unlinked_destroyed.connect(on_contained_source_destroyed);
+ }
+
+ public void detach_all_collections() {
+ foreach (SourceCollection collection in attached_collections) {
+ collection.items_unlinking.disconnect(on_contained_sources_unlinking);
+ collection.items_relinked.disconnect(on_contained_sources_relinked);
+ collection.item_destroyed.disconnect(on_contained_source_destroyed);
+ collection.unlinked_destroyed.disconnect(on_contained_source_destroyed);
+ }
+
+ attached_collections.clear();
+ }
+}
+
diff --git a/src/core/Core.vala b/src/core/Core.vala
new file mode 100644
index 0000000..1b9958e
--- /dev/null
+++ b/src/core/Core.vala
@@ -0,0 +1,29 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Core 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 Core {
+
+// preconfigure may be deleted if not used.
+public void preconfigure() {
+}
+
+public void init() throws Error {
+}
+
+public void terminate() {
+}
+
+}
+
diff --git a/src/core/DataCollection.vala b/src/core/DataCollection.vala
new file mode 100644
index 0000000..615c6ac
--- /dev/null
+++ b/src/core/DataCollection.vala
@@ -0,0 +1,623 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class DataCollection {
+ public const int64 INVALID_OBJECT_ORDINAL = -1;
+
+ private class MarkerImpl : Object, Marker {
+ public DataCollection owner;
+ public Gee.HashSet<DataObject> marked = new Gee.HashSet<DataObject>();
+ public int freeze_count = 0;
+
+ public MarkerImpl(DataCollection owner) {
+ this.owner = owner;
+
+ // if items are removed from main collection, they're removed from the marked list
+ // as well
+ owner.items_removed.connect(on_items_removed);
+ }
+
+ ~MarkerImpl() {
+ owner.items_removed.disconnect(on_items_removed);
+ }
+
+ public void mark(DataObject object) {
+ assert(owner.internal_contains(object));
+
+ marked.add(object);
+ }
+
+ public void unmark(DataObject object) {
+ assert(owner.internal_contains(object));
+
+ marked.remove(object);
+ }
+
+ public bool toggle(DataObject object) {
+ assert(owner.internal_contains(object));
+
+ if (marked.contains(object)) {
+ marked.remove(object);
+ } else {
+ marked.add(object);
+ }
+
+ return marked.contains(object);
+ }
+
+ public void mark_many(Gee.Collection<DataObject> list) {
+ foreach (DataObject object in list) {
+ assert(owner.internal_contains(object));
+
+ marked.add(object);
+ }
+ }
+
+ public void unmark_many(Gee.Collection<DataObject> list) {
+ foreach (DataObject object in list) {
+ assert(owner.internal_contains(object));
+
+ marked.remove(object);
+ }
+ }
+
+ public void mark_all() {
+ foreach (DataObject object in owner.get_all())
+ marked.add(object);
+ }
+
+ public int get_count() {
+ return (marked != null) ? marked.size : freeze_count;
+ }
+
+ public Gee.Collection<DataObject> get_all() {
+ Gee.ArrayList<DataObject> copy = new Gee.ArrayList<DataObject>();
+ copy.add_all(marked);
+
+ return copy;
+ }
+
+ private void on_items_removed(Gee.Iterable<DataObject> removed) {
+ foreach (DataObject object in removed)
+ marked.remove(object);
+ }
+
+ // This method is called by DataCollection when it starts iterating over the marked list ...
+ // the marker at this point stops monitoring the collection, preventing a possible
+ // removal during an iteration, which is bad.
+ public void freeze() {
+ owner.items_removed.disconnect(on_items_removed);
+ }
+
+ public void finished() {
+ if (marked != null)
+ freeze_count = marked.size;
+
+ marked = null;
+ }
+
+ public bool is_valid(DataCollection collection) {
+ return (collection == owner) && (marked != null);
+ }
+ }
+
+ private string name;
+ private DataSet dataset = new DataSet();
+ private Gee.HashMap<string, Value?> properties = new Gee.HashMap<string, Value?>();
+ private int64 object_ordinal_generator = 0;
+ private int notifies_frozen = 0;
+ private Gee.HashMap<DataObject, Alteration> frozen_items_altered = null;
+ private bool fire_ordering_changed = false;
+
+ // When this signal has been fired, the added items are part of the collection
+ public virtual signal void items_added(Gee.Iterable<DataObject> added) {
+ }
+
+ // When this signal is fired, the removed items are no longer part of the collection
+ public virtual signal void items_removed(Gee.Iterable<DataObject> removed) {
+ }
+
+ // When this signal is fired, the removed items are no longer part of the collection
+ public virtual signal void contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ }
+
+ // This signal fires whenever any (or multiple) items in the collection signal they've been
+ // altered.
+ public virtual signal void items_altered(Gee.Map<DataObject, Alteration> items) {
+ }
+
+ // Fired when a new sort comparator is registered or an item has moved in the ordering due to
+ // an alteration.
+ public virtual signal void ordering_changed() {
+ }
+
+ // Fired when a collection property is set. The old value is passed as well, null if not set
+ // previously.
+ public virtual signal void property_set(string name, Value? old, Value val) {
+ }
+
+ // Fired when a collection property is cleared.
+ public virtual signal void property_cleared(string name) {
+ }
+
+ // Fired when "altered" signal (and possibly other related signals, depending on the subclass)
+ // is frozen.
+ public virtual signal void frozen() {
+ }
+
+ // Fired when "altered" signal (and other related signals, depending on the subclass) is
+ // restored (thawed).
+ public virtual signal void thawed() {
+ }
+
+ public DataCollection(string name) {
+ this.name = name;
+ }
+
+ ~DataCollection() {
+#if TRACE_DTORS
+ debug("DTOR: DataCollection %s", name);
+#endif
+ }
+
+ public virtual string to_string() {
+ return "%s (%d)".printf(name, get_count());
+ }
+
+ // use notifies to ensure proper chronology of signal handling
+ protected virtual void notify_items_added(Gee.Iterable<DataObject> added) {
+ items_added(added);
+ }
+
+ protected virtual void notify_items_removed(Gee.Iterable<DataObject> removed) {
+ items_removed(removed);
+ }
+
+ protected virtual void notify_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ contents_altered(added, removed);
+ }
+
+ protected virtual void notify_items_altered(Gee.Map<DataObject, Alteration> items) {
+ items_altered(items);
+ }
+
+ protected virtual void notify_ordering_changed() {
+ ordering_changed();
+ }
+
+ protected virtual void notify_property_set(string name, Value? old, Value val) {
+ property_set(name, old, val);
+ }
+
+ protected virtual void notify_property_cleared(string name) {
+ property_cleared(name);
+ }
+
+ // A singleton list is used when a single item has been added/remove/selected/unselected
+ // and needs to be reported via a signal, which uses a list as a parameter ... although this
+ // seems wasteful, can't reuse a single singleton list because it's possible for a method
+ // that needs it to be called from within a signal handler for another method, corrupting the
+ // shared list's contents mid-signal
+ protected static Gee.Collection<DataObject> get_singleton(DataObject object) {
+ return new SingletonCollection<DataObject>(object);
+ }
+
+ protected static Gee.Map<DataObject, Alteration> get_alteration_singleton(DataObject object,
+ Alteration alteration) {
+ Gee.Map<DataObject, Alteration> map = new Gee.HashMap<DataObject, Alteration>();
+ map.set(object, alteration);
+
+ return map;
+ }
+
+ public virtual bool valid_type(DataObject object) {
+ return true;
+ }
+
+ public unowned Comparator get_comparator() {
+ return dataset.get_comparator();
+ }
+
+ public unowned ComparatorPredicate get_comparator_predicate() {
+ return dataset.get_comparator_predicate();
+ }
+
+ public virtual void set_comparator(Comparator comparator, ComparatorPredicate? predicate) {
+ dataset.set_comparator(comparator, predicate);
+ notify_ordering_changed();
+ }
+
+ // Return to natural ordering of DataObjects, which is order-added
+ public virtual void reset_comparator() {
+ dataset.reset_comparator();
+ notify_ordering_changed();
+ }
+
+ public virtual Gee.Collection<DataObject> get_all() {
+ return dataset.get_all();
+ }
+
+ protected DataSet get_dataset_copy() {
+ return dataset.copy();
+ }
+
+ public virtual int get_count() {
+ return dataset.get_count();
+ }
+
+ public virtual DataObject? get_at(int index) {
+ return dataset.get_at(index);
+ }
+
+ public virtual int index_of(DataObject object) {
+ return dataset.index_of(object);
+ }
+
+ public virtual bool contains(DataObject object) {
+ return internal_contains(object);
+ }
+
+ // Because subclasses may filter out objects (by overriding key methods here), need an
+ // internal_contains for consistency checking.
+ private bool internal_contains(DataObject object) {
+ if (!dataset.contains(object))
+ return false;
+
+ assert(object.get_membership() == this);
+
+ return true;
+ }
+
+ private void internal_add(DataObject object) {
+ assert(valid_type(object));
+
+ object.internal_set_membership(this, object_ordinal_generator++);
+
+ bool added = dataset.add(object);
+ assert(added);
+ }
+
+ private void internal_add_many(Gee.List<DataObject> objects, ProgressMonitor? monitor) {
+ int count = objects.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ DataObject object = objects.get(ctr);
+ assert(valid_type(object));
+
+ object.internal_set_membership(this, object_ordinal_generator++);
+
+ if (monitor != null)
+ monitor(ctr, count);
+ }
+
+ bool added = dataset.add_many(objects);
+ assert(added);
+ }
+
+ private void internal_remove(DataObject object) {
+ bool removed = dataset.remove(object);
+ assert(removed);
+
+ object.internal_clear_membership();
+ }
+
+ // Returns false if item is already part of the collection.
+ public virtual bool add(DataObject object) {
+ if (internal_contains(object)) {
+ debug("%s cannot add %s: already present", to_string(), object.to_string());
+
+ return false;
+ }
+
+ internal_add(object);
+
+ // fire signal after added using singleton list
+ Gee.Collection<DataObject> added = get_singleton(object);
+ notify_items_added(added);
+ notify_contents_altered(added, null);
+
+ // This must be called *after* the DataCollection has signalled.
+ object.notify_membership_changed(this);
+
+ return true;
+ }
+
+ // Returns the items added to the collection.
+ public virtual Gee.Collection<DataObject> add_many(Gee.Collection<DataObject> objects,
+ ProgressMonitor? monitor = null) {
+ Gee.ArrayList<DataObject> added = new Gee.ArrayList<DataObject>();
+ foreach (DataObject object in objects) {
+ if (internal_contains(object)) {
+ debug("%s cannot add %s: already present", to_string(), object.to_string());
+
+ continue;
+ }
+
+ added.add(object);
+ }
+
+ int count = added.size;
+ if (count == 0)
+ return added;
+
+ internal_add_many(added, monitor);
+
+ // signal once all have been added
+ notify_items_added(added);
+ notify_contents_altered(added, null);
+
+ // This must be called *after* the DataCollection signals have fired.
+ for (int ctr = 0; ctr < count; ctr++)
+ added.get(ctr).notify_membership_changed(this);
+
+ return added;
+ }
+
+ // Obtain a marker to build a list of objects to perform an action upon.
+ public Marker start_marking() {
+ return new MarkerImpl(this);
+ }
+
+ // Obtain a marker with a single item marked. More can be added.
+ public Marker mark(DataObject object) {
+ Marker marker = new MarkerImpl(this);
+ marker.mark(object);
+
+ return marker;
+ }
+
+ // Obtain a marker for all items in a collection. More can be added.
+ public Marker mark_many(Gee.Collection<DataObject> objects) {
+ Marker marker = new MarkerImpl(this);
+ marker.mark_many(objects);
+
+ return marker;
+ }
+
+ // Iterate over all the marked objects performing a user-supplied action on each one. The
+ // marker is invalid after calling this method.
+ public void act_on_marked(Marker m, MarkedAction action, ProgressMonitor? monitor = null,
+ Object? user = null) {
+ MarkerImpl marker = (MarkerImpl) m;
+
+ assert(marker.is_valid(this));
+
+ // freeze the marker to prepare it for iteration
+ marker.freeze();
+
+ uint64 count = 0;
+ uint64 total = marker.marked.size;
+
+ // iterate, breaking if the callback asks to stop
+ foreach (DataObject object in marker.marked) {
+ // although marker tracks when items are removed, catch it here as well
+ if (!internal_contains(object)) {
+ warning("act_on_marked: marker holding ref to unknown %s", object.to_string());
+
+ continue;
+ }
+
+ if (!action(object, user))
+ break;
+
+ if (monitor != null) {
+ if (!monitor(++count, total))
+ break;
+ }
+ }
+
+ // invalidate the marker
+ marker.finished();
+ }
+
+ // Remove marked items from collection. This two-step process allows for iterating in a foreach
+ // loop and removing without creating a separate list. The marker is invalid after this call.
+ public virtual void remove_marked(Marker m) {
+ MarkerImpl marker = (MarkerImpl) m;
+
+ assert(marker.is_valid(this));
+
+ // freeze the marker before signalling, so it doesn't remove all its items
+ marker.freeze();
+
+ // remove everything in the marked list
+ Gee.ArrayList<DataObject> skipped = null;
+ foreach (DataObject object in marker.marked) {
+ // although marker should track items already removed, catch it here as well
+ if (!internal_contains(object)) {
+ warning("remove_marked: marker holding ref to unknown %s", object.to_string());
+
+ if (skipped == null)
+ skipped = new Gee.ArrayList<DataObject>();
+
+ skipped.add(object);
+
+ continue;
+ }
+
+ internal_remove(object);
+ }
+
+ if (skipped != null)
+ marker.marked.remove_all(skipped);
+
+ // signal after removing
+ if (marker.marked.size > 0) {
+ notify_items_removed(marker.marked);
+ notify_contents_altered(null, marker.marked);
+
+ // this must be called after the DataCollection has signalled.
+ foreach (DataObject object in marker.marked)
+ object.notify_membership_changed(null);
+ }
+
+ // invalidate the marker
+ marker.finished();
+ }
+
+ public virtual void clear() {
+ if (dataset.get_count() == 0)
+ return;
+
+ // remove everything in the list, but have to maintain a new list for reporting the signal.
+ // Don't use an iterator, as list is modified in internal_remove().
+ Gee.ArrayList<DataObject> removed = new Gee.ArrayList<DataObject>();
+ do {
+ DataObject? object = dataset.get_at(0);
+ assert(object != null);
+
+ removed.add(object);
+ internal_remove(object);
+ } while (dataset.get_count() > 0);
+
+ // report after removal
+ notify_items_removed(removed);
+ notify_contents_altered(null, removed);
+
+ // This must be called after the DataCollection has signalled.
+ foreach (DataObject object in removed)
+ object.notify_membership_changed(null);
+ }
+
+ // close() must be called before disposing of the DataCollection, so all signals may be
+ // disconnected and all internal references to the collection can be dropped. In the bare
+ // minimum, all items will be removed from the collection (and the appropriate signals and
+ // notify calls will be made). Subclasses may fire other signals while disposing of their
+ // references. However, if they are entirely synchronized on DataCollection's signals, that
+ // may be enough for them to clean up.
+ public virtual void close() {
+ clear();
+ }
+
+ // This method is only called by DataObject to report when it has been altered, so observers of
+ // this collection may be notified as well.
+ public void internal_notify_altered(DataObject object, Alteration alteration) {
+ assert(internal_contains(object));
+
+ bool resort_occurred = dataset.resort_object(object, alteration);
+
+ if (are_notifications_frozen()) {
+ if (frozen_items_altered == null)
+ frozen_items_altered = new Gee.HashMap<DataObject, Alteration>();
+
+ // if an alteration for the object is already in place, compress the two and add the
+ // new one, otherwise set the supplied one
+ Alteration? current = frozen_items_altered.get(object);
+ if (current != null)
+ current = current.compress(alteration);
+ else
+ current = alteration;
+
+ frozen_items_altered.set(object, current);
+
+ fire_ordering_changed = fire_ordering_changed || resort_occurred;
+
+ return;
+ }
+
+ if (resort_occurred)
+ notify_ordering_changed();
+
+ notify_items_altered(get_alteration_singleton(object, alteration));
+ }
+
+ public Value? get_property(string name) {
+ return properties.get(name);
+ }
+
+ public void set_property(string name, Value val, ValueEqualFunc? value_equals = null) {
+ if (value_equals == null) {
+ if (val.holds(typeof(bool)))
+ value_equals = bool_value_equals;
+ else if (val.holds(typeof(int)))
+ value_equals = int_value_equals;
+ else
+ error("value_equals must be specified for this type");
+ }
+
+ Value? old = properties.get(name);
+ if (old != null) {
+ if (value_equals(old, val))
+ return;
+ }
+
+ properties.set(name, val);
+
+ notify_property_set(name, old, val);
+
+ // notify all items in the collection of the change
+ int count = dataset.get_count();
+ for (int ctr = 0; ctr < count; ctr++)
+ dataset.get_at(ctr).notify_collection_property_set(name, old, val);
+ }
+
+ public void clear_property(string name) {
+ if (!properties.unset(name))
+ return;
+
+ // only notify if the propery was unset (that is, was set to begin with)
+ notify_property_cleared(name);
+
+ // notify all items
+ int count = dataset.get_count();
+ for (int ctr = 0; ctr < count; ctr++)
+ dataset.get_at(ctr).notify_collection_property_cleared(name);
+ }
+
+ // This is only guaranteed to freeze notifications that come in from contained objects and
+ // need to be propagated with collection signals. Thus, the caller can freeze notifications,
+ // make modifications to many or all member objects, then unthaw and have the aggregated signals
+ // fired at once.
+ //
+ // DataObject/DataSource/DataView should also "eat" their signals as well, to prevent observers
+ // from being notified while their collection is frozen, and only fire them when
+ // internal_collection_thawed is called.
+ //
+ // For DataCollection, the signals affected are items_altered and ordering_changed.
+ public void freeze_notifications() {
+ if (notifies_frozen++ == 0)
+ notify_frozen();
+ }
+
+ public void thaw_notifications() {
+ if (notifies_frozen == 0)
+ return;
+
+ if (--notifies_frozen == 0)
+ notify_thawed();
+ }
+
+ public bool are_notifications_frozen() {
+ return notifies_frozen > 0;
+ }
+
+ // This is called when notifications have frozen. Child collections should halt notifications
+ // until thawed() is called.
+ protected virtual void notify_frozen() {
+ frozen();
+ }
+
+ // This is called when enough thaw_notifications() calls have been made. Child collections
+ // should issue caught notifications.
+ protected virtual void notify_thawed() {
+ if (frozen_items_altered != null) {
+ // refs are swapped around due to reentrancy
+ Gee.Map<DataObject, Alteration> copy = frozen_items_altered;
+ frozen_items_altered = null;
+
+ notify_items_altered(copy);
+ }
+
+ if (fire_ordering_changed) {
+ fire_ordering_changed = false;
+ notify_ordering_changed();
+ }
+
+ thawed();
+ }
+}
+
diff --git a/src/core/DataObject.vala b/src/core/DataObject.vala
new file mode 100644
index 0000000..1fe133d
--- /dev/null
+++ b/src/core/DataObject.vala
@@ -0,0 +1,137 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+//
+// DataObject
+//
+// Object IDs are incremented for each DataObject, and therefore may be used to compare
+// creation order. This behavior may be relied upon elsewhere. Object IDs may be recycled when
+// DataObjects are reconstituted by a proxy.
+//
+// Ordinal IDs are supplied by DataCollections to record the ordering of the object being added
+// to the collection. This value is primarily only used by DataCollection, but may be used
+// elsewhere to resolve ordering questions (including stabilizing a sort).
+//
+
+// Have to inherit from Object due to ContainerSource and this bug:
+// https://bugzilla.gnome.org/show_bug.cgi?id=615904
+public abstract class DataObject : Object {
+ public const int64 INVALID_OBJECT_ID = -1;
+
+ private static int64 object_id_generator = 0;
+
+#if TRACE_DTORS
+ // because calling to_string() in a destructor is dangerous, stash to_string()'s result in
+ // this variable for reporting
+ protected string dbg_to_string = null;
+#endif
+
+ private int64 object_id = INVALID_OBJECT_ID;
+ private DataCollection member_of = null;
+ private int64 ordinal = DataCollection.INVALID_OBJECT_ORDINAL;
+
+ // NOTE: Supplying an object ID should *only* be used when reconstituting the object (generally
+ // only done by DataSources).
+ public DataObject(int64 object_id = INVALID_OBJECT_ID) {
+ this.object_id = (object_id == INVALID_OBJECT_ID) ? object_id_generator++ : object_id;
+ }
+
+ public virtual void notify_altered(Alteration alteration) {
+ if (member_of != null)
+ member_of.internal_notify_altered(this, alteration);
+ }
+
+ // There is no membership_changed signal as it's expensive (esp. at startup) and not needed
+ // at this time. The notify_membership_changed mechanism is still in place for subclasses.
+ //
+ // This is called after the change has occurred (i.e., after the DataObject has been added
+ // to the DataCollection, or after it has been remove from the same). It is also called after
+ // the DataCollection has reported the change on its own signals, so it and its children can
+ // properly integrate the DataObject into its pools.
+ //
+ // This is only called by DataCollection.
+ public virtual void notify_membership_changed(DataCollection? collection) {
+ }
+
+ // Generally, this is only called by DataCollection. No signal is bound to this because
+ // it's not needed currently and affects performance.
+ public virtual void notify_collection_property_set(string name, Value? old, Value val) {
+ }
+
+ // Generally, this is only called by DataCollection. No signal is bound to this because
+ // it's not needed currently and affects performance.
+ public virtual void notify_collection_property_cleared(string name) {
+ }
+
+ public abstract string get_name();
+
+ public abstract string to_string();
+
+ public DataCollection? get_membership() {
+ return member_of;
+ }
+
+ public bool has_membership() {
+ return member_of != null;
+ }
+
+ // This method is only called by DataCollection. It's called after the DataObject has been
+ // assigned to a DataCollection.
+ public void internal_set_membership(DataCollection collection, int64 ordinal) {
+ assert(member_of == null);
+
+ member_of = collection;
+ this.ordinal = ordinal;
+
+#if TRACE_DTORS
+ dbg_to_string = to_string();
+#endif
+ }
+
+ // This method is only called by SourceHoldingTank (to give ordinality to its unassociated
+ // members). DataCollections should call internal_set_membership.
+ public void internal_set_ordinal(int64 ordinal) {
+ assert(member_of == null);
+
+ this.ordinal = ordinal;
+ }
+
+ // This method is only called by DataCollection. It's called after the DataObject has been
+ // assigned to a DataCollection.
+ public void internal_clear_membership() {
+ member_of = null;
+ ordinal = DataCollection.INVALID_OBJECT_ORDINAL;
+ }
+
+ // This method is only called by DataCollection, DataSet, and SourceHoldingTank.
+ public inline int64 internal_get_ordinal() {
+ return ordinal;
+ }
+
+ public inline int64 get_object_id() {
+ return object_id;
+ }
+
+ public Value? get_collection_property(string name, Value? def = null) {
+ if (member_of == null)
+ return def;
+
+ Value? result = member_of.get_property(name);
+
+ return (result != null) ? result : def;
+ }
+
+ public void set_collection_property(string name, Value val, ValueEqualFunc? value_equals = null) {
+ if (member_of != null)
+ member_of.set_property(name, val, value_equals);
+ }
+
+ public void clear_collection_property(string name) {
+ if (member_of != null)
+ member_of.clear_property(name);
+ }
+}
+
diff --git a/src/core/DataSet.vala b/src/core/DataSet.vala
new file mode 100644
index 0000000..ebb5500
--- /dev/null
+++ b/src/core/DataSet.vala
@@ -0,0 +1,183 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+//
+// DataSet
+//
+// A DataSet is a collection class used for internal implementations of DataCollection
+// and its children. It may be of use to other classes, however.
+//
+// The general purpose of DataSet is to provide low-cost implementations of various collection
+// operations at a cost of internally maintaining its objects in more than one simple collection.
+// contains(), for example, can return a result with hash-table performance while notions of
+// ordering are maintained by a SortedList. The cost is in adding and removing objects (in general,
+// there are others).
+//
+// Because this class has no signalling mechanisms and does not manipulate DataObjects in ways
+// they expect to be manipulated (these features are performed by DataCollection), it's probably
+// best not to use this class. Even in cases of building a list of DataObjects for some quick
+// operation is probably best done by a Gee.ArrayList.
+//
+
+// ComparatorPredicate is used to determine if a re-sort operation is necessary; it has no
+// effect on adding a DataObject to a DataSet in sorted order.
+public delegate bool ComparatorPredicate(DataObject object, Alteration alteration);
+
+public class DataSet {
+ private SortedList<DataObject> list = new SortedList<DataObject>();
+ private Gee.HashSet<DataObject> hash_set = new Gee.HashSet<DataObject>();
+ private unowned Comparator user_comparator = null;
+ private unowned ComparatorPredicate? comparator_predicate = null;
+
+ public DataSet() {
+ reset_comparator();
+ }
+
+ private int64 order_added_comparator(void *a, void *b) {
+ return ((DataObject *) a)->internal_get_ordinal() - ((DataObject *) b)->internal_get_ordinal();
+ }
+
+ private bool order_added_predicate(DataObject object, Alteration alteration) {
+ // ordinals don't change (shouldn't change!) while a part of the DataSet
+ return false;
+ }
+
+ private int64 comparator_wrapper(void *a, void *b) {
+ if (a == b)
+ return 0;
+
+ // use the order-added comparator if the user's compare returns equal, to stabilize the
+ // sort
+ int64 result = 0;
+
+ if (user_comparator != null)
+ result = user_comparator(a, b);
+
+ if (result == 0)
+ result = order_added_comparator(a, b);
+
+ assert(result != 0);
+
+ return result;
+ }
+
+ public bool contains(DataObject object) {
+ return hash_set.contains(object);
+ }
+
+ public inline int get_count() {
+ return list.get_count();
+ }
+
+ public void reset_comparator() {
+ user_comparator = null;
+ comparator_predicate = order_added_predicate;
+ list.resort(order_added_comparator);
+ }
+
+ public unowned Comparator get_comparator() {
+ return user_comparator;
+ }
+
+ public unowned ComparatorPredicate get_comparator_predicate() {
+ return comparator_predicate;
+ }
+
+ public void set_comparator(Comparator user_comparator, ComparatorPredicate? comparator_predicate) {
+ this.user_comparator = user_comparator;
+ this.comparator_predicate = comparator_predicate;
+ list.resort(comparator_wrapper);
+ }
+
+ public Gee.List<DataObject> get_all() {
+ return list.read_only_view_as_list;
+ }
+
+ public DataSet copy() {
+ DataSet clone = new DataSet();
+ clone.list = list.copy();
+ clone.hash_set.add_all(hash_set);
+
+ return clone;
+ }
+
+ public DataObject? get_at(int index) {
+ return list.get_at(index);
+ }
+
+ public int index_of(DataObject object) {
+ return list.locate(object, false);
+ }
+
+ // DataObject's ordinal should be set before adding.
+ public bool add(DataObject object) {
+ if (!list.add(object))
+ return false;
+
+ if (!hash_set.add(object)) {
+ // attempt to back out of previous operation
+ list.remove(object);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ // DataObjects' ordinals should be set before adding.
+ public bool add_many(Gee.Collection<DataObject> objects) {
+ int count = objects.size;
+ if (count == 0)
+ return true;
+
+ if (!list.add_all(objects))
+ return false;
+
+ if (!hash_set.add_all(objects)) {
+ // back out previous operation
+ list.remove_all(objects);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public bool remove(DataObject object) {
+ bool success = true;
+
+ if (!list.remove(object))
+ success = false;
+
+ if (!hash_set.remove(object))
+ success = false;
+
+ return success;
+ }
+
+ public bool remove_many(Gee.Collection<DataObject> objects) {
+ bool success = true;
+
+ if (!list.remove_all(objects))
+ success = false;
+
+ if (!hash_set.remove_all(objects))
+ success = false;
+
+ return success;
+ }
+
+ // Returns true if the item has moved.
+ public bool resort_object(DataObject object, Alteration? alteration) {
+ if (comparator_predicate != null && alteration != null
+ && !comparator_predicate(object, alteration)) {
+ return false;
+ }
+
+ return list.resort_item(object);
+ }
+}
+
diff --git a/src/core/DataSource.vala b/src/core/DataSource.vala
new file mode 100644
index 0000000..e4d2d34
--- /dev/null
+++ b/src/core/DataSource.vala
@@ -0,0 +1,679 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+//
+// DataSource
+//
+// A DataSource is an object that is unique throughout the system. DataSources
+// commonly have external and/or persistent representations, hence they have a notion of being
+// destroyed (versus removed or freed). Several DataViews may exist that reference a single
+// DataSource. Note that DataSources MUST be destroyed (rather than simply removed) from their
+// SourceCollection, and that they MUST be destroyed via their SourceCollection (rather than
+// calling DataSource.destroy() directly.)
+//
+// Destroying a DataSource indicates it should remove all secondary and tertiary structures (such
+// as thumbnails) and any records pointing to its backing store. SourceCollection.destroy_marked()
+// has a parameter indicating if the backing should be destroyed as well; that is when
+// internal_delete_backing() is called.
+//
+// There are no provisions (currently) for a DataSource to be removed from its SourceCollection
+// without destroying its backing and/or secondary and tertiary structures. DataSources are intended
+// to go to the grave with their SourceCollection otherwise. If a need arises for a DataSource to
+// be peaceably removed from its SourceCollection, code will need to be written. SourceSnapshots
+// may be one solution to this problem.
+//
+// Some DataSources cannot be reconstituted (for example, if its backing file is deleted). In
+// that case, dehydrate() should return null. When reconstituted, it is the responsibility of the
+// implementation to ensure an exact clone is produced, minus any details that are not relevant or
+// exposed (such as a database ID).
+//
+// If other DataSources refer to this DataSource, their state will *not* be
+// saved/restored. This must be achieved via other means. However, implementations *should*
+// track when changes to external state would break the proxy and call notify_broken();
+//
+
+public abstract class DataSource : DataObject {
+ protected delegate void ContactSubscriber(DataView view);
+ protected delegate void ContactSubscriberAlteration(DataView view, Alteration alteration);
+
+ private DataView[] subscribers = new DataView[4];
+ private SourceHoldingTank holding_tank = null;
+ private weak SourceCollection unlinked_from_collection = null;
+ private Gee.HashMap<string, Gee.List<string>> backlinks = null;
+ private bool in_contact = false;
+ private bool marked_for_destroy = false;
+ private bool is_destroyed = false;
+
+ // This signal is fired after the DataSource has been unlinked from its SourceCollection.
+ public virtual signal void unlinked(SourceCollection sources) {
+ }
+
+ // This signal is fired after the DataSource has been relinked to a SourceCollection.
+ public virtual signal void relinked(SourceCollection sources) {
+ }
+
+ // This signal is fired at the end of the destroy() chain. The object's state is either fragile
+ // or unusable. It is up to all observers to drop their references to the DataObject.
+ public virtual signal void destroyed() {
+ }
+
+ public DataSource(int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+ }
+
+ ~DataSource() {
+#if TRACE_DTORS
+ debug("DTOR: DataSource %s", dbg_to_string);
+#endif
+ }
+
+ public override void notify_membership_changed(DataCollection? collection) {
+ // DataSources can only be removed once they've been destroyed or unlinked.
+ if (collection == null) {
+ assert(is_destroyed || backlinks != null);
+ } else {
+ assert(!is_destroyed);
+ }
+
+ // If removed from a collection but have backlinks, then that's an unlink.
+ if (collection == null && backlinks != null)
+ notify_unlinked();
+
+ base.notify_membership_changed(collection);
+ }
+
+ public virtual void notify_held_in_tank(SourceHoldingTank? holding_tank) {
+ // this should never be called if part of a collection
+ assert(get_membership() == null);
+
+ // DataSources can only be held in a tank if not already in one, and must be removed from
+ // one before being put in another
+ if (holding_tank != null) {
+ assert(this.holding_tank == null);
+ } else {
+ assert(this.holding_tank != null);
+ }
+
+ this.holding_tank = holding_tank;
+ }
+
+ public override void notify_altered(Alteration alteration) {
+ // re-route this to the SourceHoldingTank if held in one
+ if (holding_tank != null) {
+ holding_tank.internal_notify_altered(this, alteration);
+ } else {
+ contact_subscribers_alteration(alteration);
+
+ base.notify_altered(alteration);
+ }
+ }
+
+ // This method is called by SourceCollection. It should not be called otherwise.
+ public virtual void notify_unlinking(SourceCollection collection) {
+ assert(backlinks == null && unlinked_from_collection == null);
+
+ unlinked_from_collection = collection;
+ backlinks = new Gee.HashMap<string, Gee.List<string>>();
+ }
+
+ // This method is called by DataSource. It should not be called otherwise.
+ protected virtual void notify_unlinked() {
+ assert(unlinked_from_collection != null && backlinks != null);
+
+ unlinked(unlinked_from_collection);
+
+ // give the DataSource a chance to persist the link state, if any
+ if (backlinks.size > 0)
+ commit_backlinks(unlinked_from_collection, dehydrate_backlinks());
+ }
+
+ // This method is called by SourceCollection. It should not be called otherwise.
+ public virtual void notify_relinking(SourceCollection collection) {
+ assert((backlinks != null) && (unlinked_from_collection == collection));
+ }
+
+ // This method is called by SourceCollection. It should not be called otherwise.
+ public virtual void notify_relinked() {
+ assert(backlinks != null && unlinked_from_collection != null);
+
+ SourceCollection relinked_to = unlinked_from_collection;
+ backlinks = null;
+ unlinked_from_collection = null;
+ relinked(relinked_to);
+
+ // have the DataSource delete any persisted link state
+ commit_backlinks(null, null);
+ }
+
+ // Each DataSource has a unique typename. All DataSources of the same type should have the
+ // same typename. This method should be thread-safe.
+ //
+ // NOTE: Because this value may be persisted in various ways, it should not be changed once
+ // defined.
+ public abstract string get_typename();
+
+ // Each DataSource of a particular typename has an instance ID. Many DataSources can have a
+ // typename of "tag" and many DataSources can have an ID of 42, but only one DataSource may
+ // have a typename of "tag" AND an ID of 42. If the DataSource is persisted, this number should
+ // be persisted as well. This method should be thread-safe.
+ public abstract int64 get_instance_id();
+
+ // This returns a string that can be used to uniquely identify the DataSource throughout the
+ // system. This method should be thread-safe.
+ public virtual string get_source_id() {
+ return ("%s-%016" + int64.FORMAT_MODIFIER + "x").printf(get_typename(), get_instance_id());
+ }
+
+ public bool has_backlink(SourceBacklink backlink) {
+ if (backlinks == null)
+ return false;
+
+ Gee.List<string>? values = backlinks.get(backlink.name);
+
+ return values != null ? values.contains(backlink.value) : false;
+ }
+
+ public Gee.List<SourceBacklink>? get_backlinks(string name) {
+ if (backlinks == null)
+ return null;
+
+ Gee.List<string>? values = backlinks.get(name);
+ if (values == null || values.size == 0)
+ return null;
+
+ Gee.List<SourceBacklink> backlinks = new Gee.ArrayList<SourceBacklink>();
+ foreach (string value in values)
+ backlinks.add(new SourceBacklink(name, value));
+
+ return backlinks;
+ }
+
+ public void set_backlink(SourceBacklink backlink) {
+ // can only be called during an unlink operation
+ assert(backlinks != null);
+
+ Gee.List<string> values = backlinks.get(backlink.name);
+ if (values == null) {
+ values = new Gee.ArrayList<string>();
+ backlinks.set(backlink.name, values);
+ }
+
+ values.add(backlink.value);
+
+ SourceCollection? sources = (SourceCollection?) get_membership();
+ if (sources != null)
+ sources.internal_backlink_set(this, backlink);
+ }
+
+ public bool remove_backlink(SourceBacklink backlink) {
+ if (backlinks == null)
+ return false;
+
+ Gee.List<string> values = backlinks.get(backlink.name);
+ if (values == null)
+ return false;
+
+ int original_size = values.size;
+ assert(original_size > 0);
+
+ Gee.Iterator<string> iter = values.iterator();
+ while (iter.next()) {
+ if (iter.get() == backlink.value)
+ iter.remove();
+ }
+
+ if (values.size == 0)
+ backlinks.unset(backlink.name);
+
+ // Commit here because this can come at any time; setting the backlinks should only
+ // happen during an unlink, which commits at the end of the cycle.
+ commit_backlinks(unlinked_from_collection, dehydrate_backlinks());
+
+ SourceCollection? sources = (SourceCollection?) get_membership();
+ if (sources != null)
+ sources.internal_backlink_removed(this, backlink);
+
+ return values.size != original_size;
+ }
+
+ // Base implementation is to do nothing; if DataSource wishes to persist link state across
+ // application sessions, it should do so when this is called. Do not call this base method
+ // when overriding; it will only issue a warning.
+ //
+ // If dehydrated is null, the persisted link state should be deleted. sources will be null
+ // as well.
+ protected virtual void commit_backlinks(SourceCollection? sources, string? dehydrated) {
+ if (sources != null || dehydrated != null)
+ warning("No implementation to commit link state for %s", to_string());
+ }
+
+ private string? dehydrate_backlinks() {
+ if (backlinks == null || backlinks.size == 0)
+ return null;
+
+ StringBuilder builder = new StringBuilder();
+ foreach (string name in backlinks.keys) {
+ Gee.List<string> values = backlinks.get(name);
+ if (values == null || values.size == 0)
+ continue;
+
+ string value_field = "";
+ foreach (string value in values) {
+ if (!is_string_empty(value))
+ value_field += value + "|";
+ }
+
+ if (value_field.length > 0)
+ builder.append("%s=%s\n".printf(name, value_field));
+ }
+
+ return builder.str.length > 0 ? builder.str : null;
+ }
+
+ // If dehydrated is null, this method will still put the DataSource into an unlinked state,
+ // simply without any backlinks to reestablish.
+ public void rehydrate_backlinks(SourceCollection unlinked_from, string? dehydrated) {
+ unlinked_from_collection = unlinked_from;
+ backlinks = new Gee.HashMap<string, Gee.List<string>>();
+
+ if (dehydrated == null)
+ return;
+
+ string[] lines = dehydrated.split("\n");
+ foreach (string line in lines) {
+ if (line.length == 0)
+ continue;
+
+ string[] tokens = line.split("=", 2);
+ if (tokens.length < 2) {
+ warning("Unable to rehydrate \"%s\" for %s: name and value not present", line,
+ to_string());
+
+ continue;
+ }
+
+ string[] decoded_values = tokens[1].split("|");
+ Gee.List<string> values = new Gee.ArrayList<string>();
+ foreach (string value in decoded_values) {
+ if (value != null && value.length > 0)
+ values.add(value);
+ }
+
+ if (values.size > 0)
+ backlinks.set(tokens[0], values);
+ }
+ }
+
+ // If a DataSource cannot produce snapshots, return null.
+ public virtual SourceSnapshot? save_snapshot() {
+ return null;
+ }
+
+ // This method is called by SourceCollection. It should not be called otherwise.
+ public void internal_mark_for_destroy() {
+ marked_for_destroy = true;
+ }
+
+ // This method is called by SourceCollection. It should not be called otherwise.
+ //
+ // This method deletes whatever backing this DataSource represents. It should either return
+ // false or throw an error if the delete fails.
+ public virtual bool internal_delete_backing() throws Error {
+ return true;
+ }
+
+ // Because of the rules of DataSources, a DataSource is only equal to itself; subclasses
+ // may override this to perform validations and/or assertions
+ public virtual bool equals(DataSource? source) {
+ return (this == source);
+ }
+
+ // This method is called by SourceCollection. It should not be called otherwise. To destroy
+ // a DataSource, destroy it from its SourceCollection.
+ //
+ // Child classes should call this base class to ensure that the collection this object is
+ // a member of is notified and the signal is properly called. The collection will remove this
+ // object automatically.
+ public virtual void destroy() {
+ assert(marked_for_destroy);
+
+ // mark as destroyed
+ is_destroyed = true;
+
+ // unsubscribe all subscribers
+ for (int ctr = 0; ctr < subscribers.length; ctr++) {
+ if (subscribers[ctr] != null) {
+ DataView view = subscribers[ctr];
+ subscribers[ctr] = null;
+
+ view.notify_unsubscribed(this);
+ }
+ }
+
+ // propagate the signal
+ destroyed();
+ }
+
+ // This method can be used to destroy a DataSource before it's added to a SourceCollection
+ // or has been unlinked from one. It should not be used otherwise. (In particular, don't
+ // automate destroys by removing and then calling this method -- that will happen automatically.)
+ // To destroy a DataSource already integrated into a SourceCollection, call
+ // SourceCollection.destroy_marked(). Returns true if the operation completed successfully,
+ // otherwise it will return false.
+ public bool destroy_orphan(bool delete_backing) {
+ bool ret = true;
+ if (delete_backing) {
+ try {
+ ret = internal_delete_backing();
+ if (!ret)
+ warning("Unable to delete backing for %s", to_string());
+
+ } catch (Error err) {
+ warning("Unable to delete backing for %s: %s", to_string(), err.message);
+ ret = false;
+ }
+ }
+
+ internal_mark_for_destroy();
+ destroy();
+
+ if (unlinked_from_collection != null)
+ unlinked_from_collection.notify_unlinked_destroyed(this);
+
+ return ret;
+ }
+
+ // DataViews subscribe to the DataSource to inform it of their existence. Not only does this
+ // allow for signal reflection (i.e. DataSource.altered -> DataView.altered) it also makes
+ // them first-in-line for notification of destruction, so they can remove themselves from
+ // their ViewCollections automatically.
+ //
+ // This method is only called by DataView.
+ public void internal_subscribe(DataView view) {
+ assert(!in_contact);
+
+ for (int ctr = 0; ctr < subscribers.length; ctr++) {
+ if (subscribers[ctr] == null) {
+ subscribers[ctr] = view;
+
+ return;
+ }
+ }
+
+ subscribers += view;
+ }
+
+ // This method is only called by DataView. NOTE: This method does NOT call
+ // DataView.notify_unsubscribed(), as it's assumed the DataView itself will do so if appropriate.
+ public void internal_unsubscribe(DataView view) {
+ assert(!in_contact);
+
+ for (int ctr = 0; ctr < subscribers.length; ctr++) {
+ if (subscribers[ctr] == view) {
+ subscribers[ctr] = null;
+
+ return;
+ }
+ }
+ }
+
+ protected void contact_subscribers(ContactSubscriber contact_subscriber) {
+ assert(!in_contact);
+
+ in_contact = true;
+ for (int ctr = 0; ctr < subscribers.length; ctr++) {
+ if (subscribers[ctr] != null)
+ contact_subscriber(subscribers[ctr]);
+ }
+ in_contact = false;
+ }
+
+ protected void contact_subscribers_alteration(Alteration alteration) {
+ assert(!in_contact);
+
+ in_contact = true;
+ for (int ctr = 0; ctr < subscribers.length; ctr++) {
+ if (subscribers[ctr] != null)
+ subscribers[ctr].notify_altered(alteration);
+ }
+ in_contact = false;
+ }
+}
+
+public abstract class SourceSnapshot {
+ private bool snapshot_broken = false;
+
+ // This is signalled when the DataSource, for whatever reason, can no longer be reconstituted
+ // from this Snapshot.
+ public virtual signal void broken() {
+ }
+
+ public virtual void notify_broken() {
+ snapshot_broken = true;
+
+ broken();
+ }
+
+ public bool is_broken() {
+ return snapshot_broken;
+ }
+}
+
+// Link state name may not contain the equal sign ("="). Link names and values may not contain the
+// pipe-character ("|"). Both will be stripped of leading and trailing whitespace. This may
+// affect retrieval.
+public class SourceBacklink {
+ private string _name;
+ private string _value;
+
+ public string name {
+ get {
+ return _name;
+ }
+ }
+
+ public string value {
+ get {
+ return _value;
+ }
+ }
+
+ // This only applies if the SourceBacklink comes from a DataSource.
+ public string typename {
+ get {
+ return _name;
+ }
+ }
+
+ // This only applies if the SourceBacklink comes from a DataSource.
+ public int64 instance_id {
+ get {
+ return int64.parse(_value);
+ }
+ }
+
+ public SourceBacklink(string name, string value) {
+ assert(validate_name_value(name, value));
+
+ _name = name.strip();
+ _value = value.strip();
+ }
+
+ public SourceBacklink.from_source(DataSource source) {
+ _name = source.get_typename().strip();
+ _value = source.get_instance_id().to_string().strip();
+
+ assert(validate_name_value(_name, _value));
+ }
+
+ private static bool validate_name_value(string name, string value) {
+ return !name.contains("=") && !name.contains("|") && !value.contains("|");
+ }
+
+ public string to_string() {
+ return "Backlink %s=%s".printf(name, value);
+ }
+
+ public static uint hash_func(SourceBacklink? backlink) {
+ return str_hash(backlink._name) ^ str_hash(backlink._value);
+ }
+
+ public static bool equal_func(SourceBacklink? alink, SourceBacklink? blink) {
+ return str_equal(alink._name, blink._name) && str_equal(alink._value, blink._value);
+ }
+}
+
+//
+// SourceProxy
+//
+// A SourceProxy allows for a DataSource's state to be maintained in memory regardless of
+// whether or not the DataSource has been destroyed. If a user of SourceProxy
+// requests the represented object and it is still in memory, it will be returned. If not, it
+// is reconstituted and the new DataSource is returned.
+//
+// Several SourceProxy can be wrapped around the same DataSource. If the DataSource is
+// destroyed, all Proxys drop their reference. When a Proxy reconstitutes the DataSource, all
+// will be aware of it and re-establish their reference.
+//
+// The snapshot that is maintained is the snapshot in regards to the time of the Proxy's creation.
+// Proxys do not update their snapshot thereafter. If a snapshot reports it is broken, the
+// Proxy will not reconstitute the DataSource and get_source() will return null thereafter.
+//
+// There is no preferential treatment in regards to snapshots of the DataSources. The first
+// Proxy to reconstitute the DataSource wins.
+//
+
+public abstract class SourceProxy {
+ private int64 object_id;
+ private string source_string;
+ private DataSource source;
+ private SourceSnapshot snapshot;
+ private SourceCollection membership;
+
+ // This is only signalled by the SourceProxy that reconstituted the DataSource. All
+ // Proxys will signal when this occurs.
+ public virtual signal void reconstituted(DataSource source) {
+ }
+
+ // This is signalled when the SourceProxy has dropped a destroyed DataSource. Calling
+ // get_source() will force it to be reconstituted.
+ public virtual signal void dehydrated() {
+ }
+
+ // This is signalled when the held DataSourceSnapshot reports it is broken. The DataSource
+ // will not be reconstituted and get_source() will return null thereafter.
+ public virtual signal void broken() {
+ }
+
+ public SourceProxy(DataSource source) {
+ object_id = source.get_object_id();
+ source_string = source.to_string();
+
+ snapshot = source.save_snapshot();
+ assert(snapshot != null);
+ snapshot.broken.connect(on_snapshot_broken);
+
+ set_source(source);
+
+ membership = (SourceCollection) source.get_membership();
+ assert(membership != null);
+ membership.items_added.connect(on_source_added);
+ }
+
+ ~SourceProxy() {
+ drop_source();
+ membership.items_added.disconnect(on_source_added);
+ }
+
+ public abstract DataSource reconstitute(int64 object_id, SourceSnapshot snapshot);
+
+ public virtual void notify_reconstituted(DataSource source) {
+ reconstituted(source);
+ }
+
+ public virtual void notify_dehydrated() {
+ dehydrated();
+ }
+
+ public virtual void notify_broken() {
+ broken();
+ }
+
+ private void on_snapshot_broken() {
+ drop_source();
+
+ notify_broken();
+ }
+
+ private void set_source(DataSource source) {
+ drop_source();
+
+ this.source = source;
+ source.destroyed.connect(on_destroyed);
+ }
+
+ private void drop_source() {
+ if (source == null)
+ return;
+
+ source.destroyed.disconnect(on_destroyed);
+ source = null;
+ }
+
+ public DataSource? get_source() {
+ if (snapshot.is_broken())
+ return null;
+
+ if (source != null)
+ return source;
+
+ // without the source, need to reconstitute it and re-add to its original SourceCollection
+ // it should also automatically add itself to its original collection (which is trapped
+ // in on_source_added)
+ DataSource new_source = reconstitute(object_id, snapshot);
+ if (source != new_source)
+ source = new_source;
+ if (object_id != source.get_object_id())
+ object_id = new_source.get_object_id();
+ assert(source.get_object_id() == object_id);
+ assert(membership.contains(source));
+
+ return source;
+ }
+
+ private void on_destroyed() {
+ assert(source != null);
+
+ // drop the reference ... will need to reconstitute later if requested
+ drop_source();
+
+ notify_dehydrated();
+ }
+
+ private void on_source_added(Gee.Iterable<DataObject> added) {
+ // only interested in new objects when the proxied object has gone away
+ if (source != null)
+ return;
+
+ foreach (DataObject object in added) {
+ // looking for new objects with original source object's id
+ if (object.get_object_id() != object_id)
+ continue;
+
+ // this is it; stash for future use
+ set_source((DataSource) object);
+
+ notify_reconstituted((DataSource) object);
+
+ break;
+ }
+ }
+}
+
+public interface Proxyable : Object {
+ public abstract SourceProxy get_proxy();
+}
+
diff --git a/src/core/DataSourceTypes.vala b/src/core/DataSourceTypes.vala
new file mode 100644
index 0000000..9f23ac6
--- /dev/null
+++ b/src/core/DataSourceTypes.vala
@@ -0,0 +1,108 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+//
+// Media sources
+//
+
+public abstract class ThumbnailSource : DataSource {
+ public virtual signal void thumbnail_altered() {
+ }
+
+ public ThumbnailSource(int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+ }
+
+ public virtual void notify_thumbnail_altered() {
+ // fire signal on self
+ thumbnail_altered();
+
+ // signal reflection to DataViews
+ contact_subscribers(subscriber_thumbnail_altered);
+ }
+
+ private void subscriber_thumbnail_altered(DataView view) {
+ ((ThumbnailView) view).notify_thumbnail_altered();
+ }
+
+ public abstract Gdk.Pixbuf? get_thumbnail(int scale) throws Error;
+
+ // get_thumbnail( ) may return a cached pixbuf; create_thumbnail( ) is guaranteed to create
+ // a new pixbuf (e.g., by the source loading, decoding, and scaling image data)
+ public abstract Gdk.Pixbuf? create_thumbnail(int scale) throws Error;
+
+ // A ThumbnailSource may use another ThumbnailSource as its representative. It's up to the
+ // subclass to forward on the appropriate methods to this ThumbnailSource. But, since multiple
+ // ThumbnailSources may be referring to a single ThumbnailSource, this allows for that to be
+ // detected and optimized (in caching).
+ //
+ // Note that it's the responsibility of this ThumbnailSource to fire "thumbnail-altered" if its
+ // representative does the same.
+ //
+ // Default behavior is to return the ID of this.
+ public virtual string get_representative_id() {
+ return get_source_id();
+ }
+
+ public abstract PhotoFileFormat get_preferred_thumbnail_format();
+}
+
+public abstract class PhotoSource : MediaSource {
+ public PhotoSource(int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+ }
+
+ public abstract PhotoMetadata? get_metadata();
+
+ public abstract Gdk.Pixbuf get_pixbuf(Scaling scaling) throws Error;
+}
+
+public abstract class VideoSource : MediaSource {
+}
+
+//
+// EventSource
+//
+
+public abstract class EventSource : ThumbnailSource {
+ public EventSource(int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+ }
+
+ public abstract time_t get_start_time();
+
+ public abstract time_t get_end_time();
+
+ public abstract uint64 get_total_filesize();
+
+ public abstract int get_media_count();
+
+ public abstract Gee.Collection<MediaSource> get_media();
+
+ public abstract string? get_comment();
+
+ public abstract bool set_comment(string? comment);
+}
+
+//
+// ContainerSource
+//
+
+public interface ContainerSource : DataSource {
+ public abstract bool has_links();
+
+ public abstract SourceBacklink get_backlink();
+
+ public abstract void break_link(DataSource source);
+
+ public abstract void break_link_many(Gee.Collection<DataSource> sources);
+
+ public abstract void establish_link(DataSource source);
+
+ public abstract void establish_link_many(Gee.Collection<DataSource> sources);
+}
+
+
diff --git a/src/core/DataView.vala b/src/core/DataView.vala
new file mode 100644
index 0000000..07cd4fc
--- /dev/null
+++ b/src/core/DataView.vala
@@ -0,0 +1,132 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 DataView : DataObject {
+ private DataSource source;
+ private bool selected = false;
+ private bool visible = true;
+
+ // Indicates that the selection state has changed.
+ public virtual signal void state_changed(bool selected) {
+ }
+
+ // Indicates the visible state has changed.
+ public virtual signal void visibility_changed(bool visible) {
+ }
+
+ // Indicates that the display (what is seen by the user) of the DataView has changed.
+ public virtual signal void view_altered() {
+ }
+
+ // Indicates that the geometry of the DataView has changed (which implies the view has altered,
+ // but only in that the same elements have changed size).
+ public virtual signal void geometry_altered() {
+ }
+
+ public virtual signal void unsubscribed(DataSource source) {
+ }
+
+ public DataView(DataSource source) {
+ this.source = source;
+
+ // subscribe to the DataSource, which sets up signal reflection and gives the DataView
+ // first notification of destruction.
+ source.internal_subscribe(this);
+ }
+
+ ~DataView() {
+#if TRACE_DTORS
+ debug("DTOR: DataView %s", dbg_to_string);
+#endif
+ source.internal_unsubscribe(this);
+ }
+
+ public override string get_name() {
+ return "View of %s".printf(source.get_name());
+ }
+
+ public override string to_string() {
+ return "DataView %s [DataSource %s]".printf(get_name(), source.to_string());
+ }
+
+ public DataSource get_source() {
+ return source;
+ }
+
+ public bool is_selected() {
+ return selected;
+ }
+
+ // This method is only called by ViewCollection.
+ public void internal_set_selected(bool selected) {
+ if (this.selected == selected)
+ return;
+
+ this.selected = selected;
+ state_changed(selected);
+ }
+
+ // This method is only called by ViewCollection. Returns the toggled state.
+ public bool internal_toggle() {
+ selected = !selected;
+ state_changed(selected);
+
+ return selected;
+ }
+
+ public bool is_visible() {
+ return visible;
+ }
+
+ // This method is only called by ViewCollection.
+ public void internal_set_visible(bool visible) {
+ if (this.visible == visible)
+ return;
+
+ this.visible = visible;
+ visibility_changed(visible);
+ }
+
+ protected virtual void notify_view_altered() {
+ // impossible when not visible
+ if (!visible)
+ return;
+
+ ViewCollection vc = get_membership() as ViewCollection;
+ if (vc != null) {
+ if (!vc.are_notifications_frozen())
+ view_altered();
+
+ // notify ViewCollection in any event
+ vc.internal_notify_view_altered(this);
+ } else {
+ view_altered();
+ }
+ }
+
+ protected virtual void notify_geometry_altered() {
+ // impossible when not visible
+ if (!visible)
+ return;
+
+ ViewCollection vc = get_membership() as ViewCollection;
+ if (vc != null) {
+ if (!vc.are_notifications_frozen())
+ geometry_altered();
+
+ // notify ViewCollection in any event
+ vc.internal_notify_geometry_altered(this);
+ } else {
+ geometry_altered();
+ }
+ }
+
+ // This is only called by DataSource
+ public virtual void notify_unsubscribed(DataSource source) {
+ unsubscribed(source);
+ }
+}
+
diff --git a/src/core/DataViewTypes.vala b/src/core/DataViewTypes.vala
new file mode 100644
index 0000000..fac7602
--- /dev/null
+++ b/src/core/DataViewTypes.vala
@@ -0,0 +1,50 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 ThumbnailView : DataView {
+ public virtual signal void thumbnail_altered() {
+ }
+
+ public ThumbnailView(ThumbnailSource source) {
+ base(source);
+ }
+
+ public virtual void notify_thumbnail_altered() {
+ // fire signal on self
+ thumbnail_altered();
+ }
+}
+
+public class PhotoView : ThumbnailView {
+ public PhotoView(PhotoSource source) {
+ base(source);
+ }
+
+ public PhotoSource get_photo_source() {
+ return (PhotoSource) get_source();
+ }
+}
+
+public class VideoView : ThumbnailView {
+ public VideoView(VideoSource source) {
+ base(source);
+ }
+
+ public VideoSource get_video_source() {
+ return (VideoSource) get_source();
+ }
+}
+
+public class EventView : ThumbnailView {
+ public EventView(EventSource source) {
+ base(source);
+ }
+
+ public EventSource get_event_source() {
+ return (EventSource) get_source();
+ }
+}
+
diff --git a/src/core/DatabaseSourceCollection.vala b/src/core/DatabaseSourceCollection.vala
new file mode 100644
index 0000000..0c704bb
--- /dev/null
+++ b/src/core/DatabaseSourceCollection.vala
@@ -0,0 +1,86 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public delegate int64 GetSourceDatabaseKey(DataSource source);
+
+// A DatabaseSourceCollection is a SourceCollection that understands database keys (IDs) and the
+// nature that a row in a database can only be instantiated once in the system, and so it tracks
+// their existence in a map so they can be fetched by their key.
+//
+// TODO: This would be better implemented as an observer class, possibly with an interface to
+// force subclasses to provide a fetch_by_key() method.
+public abstract class DatabaseSourceCollection : SourceCollection {
+ private unowned GetSourceDatabaseKey source_key_func;
+ private Gee.HashMap<int64?, DataSource> map = new Gee.HashMap<int64?, DataSource>(int64_hash,
+ int64_equal);
+
+ public DatabaseSourceCollection(string name, GetSourceDatabaseKey source_key_func) {
+ base (name);
+
+ this.source_key_func = source_key_func;
+ }
+
+ public override void notify_items_added(Gee.Iterable<DataObject> added) {
+ foreach (DataObject object in added) {
+ DataSource source = (DataSource) object;
+ int64 key = source_key_func(source);
+
+ assert(!map.has_key(key));
+
+ map.set(key, source);
+ }
+
+ base.notify_items_added(added);
+ }
+
+ public override void notify_items_removed(Gee.Iterable<DataObject> removed) {
+ foreach (DataObject object in removed) {
+ int64 key = source_key_func((DataSource) object);
+
+ bool is_removed = map.unset(key);
+ assert(is_removed);
+ }
+
+ base.notify_items_removed(removed);
+ }
+
+ protected DataSource fetch_by_key(int64 key) {
+ return map.get(key);
+ }
+}
+
+public class DatabaseSourceHoldingTank : SourceHoldingTank {
+ private unowned GetSourceDatabaseKey get_key;
+ private Gee.HashMap<int64?, DataSource> map = new Gee.HashMap<int64?, DataSource>(int64_hash,
+ int64_equal);
+
+ public DatabaseSourceHoldingTank(SourceCollection sources,
+ SourceHoldingTank.CheckToKeep check_to_keep, GetSourceDatabaseKey get_key) {
+ base (sources, check_to_keep);
+
+ this.get_key = get_key;
+ }
+
+ public DataSource? get_by_id(int64 id) {
+ return map.get(id);
+ }
+
+ protected override void notify_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ if (added != null) {
+ foreach (DataSource source in added)
+ map.set(get_key(source), source);
+ }
+
+ if (removed != null) {
+ foreach (DataSource source in removed)
+ map.unset(get_key(source));
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+}
+
diff --git a/src/core/SourceCollection.vala b/src/core/SourceCollection.vala
new file mode 100644
index 0000000..020df0e
--- /dev/null
+++ b/src/core/SourceCollection.vala
@@ -0,0 +1,221 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 SourceCollection : DataCollection {
+ private class DestroyCounter : Object {
+ public Marker remove_marker;
+ public Gee.ArrayList<DataSource> notify_list = new Gee.ArrayList<DataSource>();
+ public Gee.ArrayList<MediaSource> not_removed = new Gee.ArrayList<MediaSource>();
+
+ public DestroyCounter(Marker remove_marker) {
+ this.remove_marker = remove_marker;
+ }
+ }
+
+ // When this signal is fired, the items are about to be unlinked from the collection. The
+ // appropriate remove signals will follow.
+ public virtual signal void items_unlinking(Gee.Collection<DataSource> unlinking) {
+ }
+
+ // When this signal is fired, the items are being relinked to the collection. The appropriate
+ // add signals have already been fired.
+ public virtual signal void items_relinked(Gee.Collection<DataSource> relinked) {
+ }
+
+ // When this signal is fired, the item is still part of the collection but its own destroy()
+ // has already been called.
+ public virtual signal void item_destroyed(DataSource source) {
+ }
+
+ // When this signal is fired, the item is still part of the collection but its own destroy()
+ // has already been called.
+ public virtual signal void items_destroyed(Gee.Collection<DataSource> destroyed) {
+ }
+
+ // When this signal is fired, the unlinked item has been unlinked from the collection previously
+ // and its destroy() has been called.
+ public virtual signal void unlinked_destroyed(DataSource source) {
+ }
+
+ // When this signal is fired, the backlink to the ContainerSource has already been removed.
+ public virtual signal void backlink_removed(SourceBacklink backlink,
+ Gee.Collection<DataSource> sources) {
+ }
+
+ private Gee.MultiMap<SourceBacklink, DataSource>? backlinks = null;
+
+ public SourceCollection(string name) {
+ base (name);
+ }
+
+ public abstract bool holds_type_of_source(DataSource source);
+
+ protected virtual void notify_items_unlinking(Gee.Collection<DataSource> unlinking) {
+ items_unlinking(unlinking);
+ }
+
+ protected virtual void notify_items_relinked(Gee.Collection<DataSource> relinked) {
+ items_relinked(relinked);
+ }
+
+ protected virtual void notify_item_destroyed(DataSource source) {
+ item_destroyed(source);
+ }
+
+ protected virtual void notify_items_destroyed(Gee.Collection<DataSource> destroyed) {
+ items_destroyed(destroyed);
+ }
+
+ // This is only called by DataSource.
+ public virtual void notify_unlinked_destroyed(DataSource unlinked) {
+ unlinked_destroyed(unlinked);
+ }
+
+ protected virtual void notify_backlink_removed(SourceBacklink backlink,
+ Gee.Collection<DataSource> sources) {
+ backlink_removed(backlink, sources);
+ }
+
+ protected override bool valid_type(DataObject object) {
+ return object is DataSource;
+ }
+
+ // Destroy all marked items and optionally have them delete their backing. Returns the
+ // number of items which failed to delete their backing (if delete_backing is true) or zero.
+ public int destroy_marked(Marker marker, bool delete_backing, ProgressMonitor? monitor = null,
+ Gee.List<MediaSource>? not_removed = null) {
+ DestroyCounter counter = new DestroyCounter(start_marking());
+
+ if (delete_backing)
+ act_on_marked(marker, destroy_and_delete_source, monitor, counter);
+ else
+ act_on_marked(marker, destroy_source, monitor, counter);
+
+ // notify of destruction
+ foreach (DataSource source in counter.notify_list)
+ notify_item_destroyed(source);
+ notify_items_destroyed(counter.notify_list);
+
+ // remove once all destroyed
+ remove_marked(counter.remove_marker);
+
+ if (null != not_removed) {
+ not_removed.add_all(counter.not_removed);
+ }
+
+ return counter.not_removed.size;
+ }
+
+ private bool destroy_and_delete_source(DataObject object, Object? user) {
+ bool success = false;
+ try {
+ success = ((DataSource) object).internal_delete_backing();
+ } catch (Error err) {
+ success = false;
+ }
+
+ if (!success && object is MediaSource) {
+ ((DestroyCounter) user).not_removed.add((MediaSource) object);
+ }
+
+ return destroy_source(object, user) && success;
+ }
+
+ private bool destroy_source(DataObject object, Object? user) {
+ DataSource source = (DataSource) object;
+
+ source.internal_mark_for_destroy();
+ source.destroy();
+
+ ((DestroyCounter) user).remove_marker.mark(source);
+ ((DestroyCounter) user).notify_list.add(source);
+
+ return true;
+ }
+
+ // This is only called by DataSource.
+ public void internal_backlink_set(DataSource source, SourceBacklink backlink) {
+ if (backlinks == null) {
+ backlinks = new Gee.HashMultiMap<SourceBacklink, DataSource>(SourceBacklink.hash_func,
+ SourceBacklink.equal_func);
+ }
+
+ backlinks.set(backlink, source);
+ }
+
+ // This is only called by DataSource.
+ public void internal_backlink_removed(DataSource source, SourceBacklink backlink) {
+ assert(backlinks != null);
+
+ bool removed = backlinks.remove(backlink, source);
+ assert(removed);
+ }
+
+ public virtual bool has_backlink(SourceBacklink backlink) {
+ return backlinks != null ? backlinks.contains(backlink) : false;
+ }
+
+ public Gee.Collection<DataSource>? unlink_marked(Marker marker, ProgressMonitor? monitor = null) {
+ Gee.ArrayList<DataSource> list = new Gee.ArrayList<DataSource>();
+ act_on_marked(marker, prepare_for_unlink, monitor, list);
+
+ if (list.size == 0)
+ return null;
+
+ notify_items_unlinking(list);
+
+ remove_marked(mark_many(list));
+
+ return list;
+ }
+
+ private bool prepare_for_unlink(DataObject object, Object? user) {
+ DataSource source = (DataSource) object;
+
+ source.notify_unlinking(this);
+ ((Gee.List<DataSource>) user).add(source);
+
+ return true;
+ }
+
+ public void relink(DataSource source) {
+ source.notify_relinking(this);
+
+ add(source);
+ notify_items_relinked((Gee.Collection<DataSource>) get_singleton(source));
+
+ source.notify_relinked();
+ }
+
+ public void relink_many(Gee.Collection<DataSource> relink) {
+ if (relink.size == 0)
+ return;
+
+ foreach (DataSource source in relink)
+ source.notify_relinking(this);
+
+ add_many(relink);
+ notify_items_relinked(relink);
+
+ foreach (DataSource source in relink)
+ source.notify_relinked();
+ }
+
+ public virtual void remove_backlink(SourceBacklink backlink) {
+ if (backlinks == null)
+ return;
+
+ // create copy because the DataSources will be removing the backlinks
+ Gee.ArrayList<DataSource> sources = new Gee.ArrayList<DataSource>();
+ sources.add_all(backlinks.get(backlink));
+
+ foreach (DataSource source in sources)
+ source.remove_backlink(backlink);
+
+ notify_backlink_removed(backlink, sources);
+ }
+}
+
diff --git a/src/core/SourceHoldingTank.vala b/src/core/SourceHoldingTank.vala
new file mode 100644
index 0000000..adfec8b
--- /dev/null
+++ b/src/core/SourceHoldingTank.vala
@@ -0,0 +1,209 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// A SourceHoldingTank is similar to the holding tank used by ContainerSourceCollection, but for
+// non-ContainerSources to be held offline from their natural SourceCollection (i.e. PhotoSources
+// being held in a trashcan, for example). It is *not* a DataCollection (important!), but rather
+// a signalled collection that moves DataSources to and from their SourceCollection.
+//
+// DataSources can be shuttled from their SourceCollection to the SourceHoldingTank manually
+// (via unlink_and_hold) or can be automatically moved by installing a HoldingPredicate.
+// Only one HoldingConditional may be installed. Because of assertions in the methods, it's unwise
+// to use more than one method. add() and add_many() should ONLY be used for DataSources not
+// first installed in their SourceCollection (i.e. they're born in the SourceHoldingTank).
+//
+// NOTE: DataSources should never be in more than one SourceHoldingTank. No tests are performed
+// here to verify this. This is why a filter/predicate method (which could automatically move
+// them in as they're altered) is not offered; there's no easy way to keep DataSources from being
+// moved into more than one holding tank, or which should have preference. The CheckToRemove
+// predicate is offered only to know when to release them.
+
+public class SourceHoldingTank {
+ // Return true if the DataSource should remain in the SourceHoldingTank, false otherwise.
+ public delegate bool CheckToKeep(DataSource source, Alteration alteration);
+
+ private SourceCollection sources;
+ private unowned CheckToKeep check_to_keep;
+ private DataSet tank = new DataSet();
+ private Gee.HashSet<DataSource> relinks = new Gee.HashSet<DataSource>();
+ private Gee.HashSet<DataSource> unlinking = new Gee.HashSet<DataSource>();
+ private int64 ordinal = 0;
+
+ public virtual signal void contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ }
+
+ public SourceHoldingTank(SourceCollection sources, CheckToKeep check_to_keep) {
+ this.sources = sources;
+ this.check_to_keep = check_to_keep;
+
+ this.sources.item_destroyed.connect(on_source_destroyed);
+ this.sources.thawed.connect(on_source_collection_thawed);
+ }
+
+ ~SourceHoldingTank() {
+ sources.item_destroyed.disconnect(on_source_destroyed);
+ sources.thawed.disconnect(on_source_collection_thawed);
+ }
+
+ protected virtual void notify_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ if (added != null) {
+ foreach (DataSource source in added)
+ source.notify_held_in_tank(this);
+ }
+
+ if (removed != null) {
+ foreach (DataSource source in removed)
+ source.notify_held_in_tank(null);
+ }
+
+ contents_altered(added, removed);
+ }
+
+ public int get_count() {
+ return tank.get_count();
+ }
+
+ public Gee.Collection<DataSource> get_all() {
+ return (Gee.Collection<DataSource>) tank.get_all();
+ }
+
+ public bool contains(DataSource source) {
+ return tank.contains(source) || unlinking.contains(source);
+ }
+
+ // Only use for DataSources that have not been installed in their SourceCollection.
+ public void add_many(Gee.Collection<DataSource> many) {
+ if (many.size == 0)
+ return;
+
+ foreach (DataSource source in many)
+ source.internal_set_ordinal(ordinal++);
+
+ bool added = tank.add_many(many);
+ assert(added);
+
+ notify_contents_altered(many, null);
+ }
+
+ // Do not pass in DataSources which have already been unlinked, including into this holding
+ // tank.
+ public void unlink_and_hold(Gee.Collection<DataSource> unlink) {
+ if (unlink.size == 0)
+ return;
+
+ // store in the unlinking collection to guard against reentrancy
+ unlinking.add_all(unlink);
+
+ sources.unlink_marked(sources.mark_many(unlink));
+
+ foreach (DataSource source in unlink)
+ source.internal_set_ordinal(ordinal++);
+
+ bool added = tank.add_many(unlink);
+ assert(added);
+
+ // remove from the unlinking pool, as they're now unlinked
+ unlinking.remove_all(unlink);
+
+ notify_contents_altered(unlink, null);
+ }
+
+ public bool has_backlink(SourceBacklink backlink) {
+ int count = tank.get_count();
+ for (int ctr = 0; ctr < count; ctr++) {
+ if (((DataSource) tank.get_at(ctr)).has_backlink(backlink))
+ return true;
+ }
+
+ return false;
+ }
+
+ public void remove_backlink(SourceBacklink backlink) {
+ int count = tank.get_count();
+ for (int ctr = 0; ctr < count; ctr++)
+ ((DataSource) tank.get_at(ctr)).remove_backlink(backlink);
+ }
+
+ public void destroy_orphans(Gee.List<DataSource> destroy, bool delete_backing,
+ ProgressMonitor? monitor = null, Gee.List<DataSource>? not_removed = null) {
+ if (destroy.size == 0)
+ return;
+
+ bool removed = tank.remove_many(destroy);
+ assert(removed);
+
+ notify_contents_altered(null, destroy);
+
+ int count = destroy.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ DataSource source = destroy.get(ctr);
+ if (!source.destroy_orphan(delete_backing)) {
+ if (null != not_removed) {
+ not_removed.add(source);
+ }
+ }
+ if (monitor != null)
+ monitor(ctr + 1, count);
+ }
+ }
+
+ private void on_source_destroyed(DataSource source) {
+ if (!tank.contains(source))
+ return;
+
+ bool removed = tank.remove(source);
+ assert(removed);
+
+ notify_contents_altered(null, new SingletonCollection<DataSource>(source));
+ }
+
+ // This is only called by DataSource
+ public void internal_notify_altered(DataSource source, Alteration alteration) {
+ if (!tank.contains(source)) {
+ debug("SourceHoldingTank.internal_notify_altered called for %s not stored in %s",
+ source.to_string(), to_string());
+
+ return;
+ }
+
+ // see if it should stay put
+ if (check_to_keep(source, alteration))
+ return;
+
+ bool removed = tank.remove(source);
+ assert(removed);
+
+ if (sources.are_notifications_frozen()) {
+ relinks.add(source);
+
+ return;
+ }
+
+ notify_contents_altered(null, new SingletonCollection<DataSource>(source));
+
+ sources.relink(source);
+ }
+
+ private void on_source_collection_thawed() {
+ if (relinks.size == 0)
+ return;
+
+ // swap out to protect against reentrancy
+ Gee.HashSet<DataSource> copy = relinks;
+ relinks = new Gee.HashSet<DataSource>();
+
+ notify_contents_altered(null, copy);
+
+ sources.relink_many(copy);
+ }
+
+ public string to_string() {
+ return "SourceHoldingTank @ 0x%p".printf(this);
+ }
+}
+
diff --git a/src/core/SourceInterfaces.vala b/src/core/SourceInterfaces.vala
new file mode 100644
index 0000000..59956d3
--- /dev/null
+++ b/src/core/SourceInterfaces.vala
@@ -0,0 +1,44 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// See the note in MediaInterfaces.vala for some thoughts on the theory of expanding Shotwell's
+// features via interfaces rather than class heirarchies.
+
+// Indexable DataSources provide raw strings that may be searched against (and, in the future,
+// indexed) for free-text search queries. DataSources implementing Indexable must prepare and
+// store (i.e. cache) these strings using prepare_indexable_string(s), as preparing the strings
+// for each call is expensive.
+//
+// When the indexable string has changed, the object should fire an alteration of
+// "indexable:keywords". The prepare methods will not do this.
+
+public interface Indexable : DataSource {
+ public abstract unowned string? get_indexable_keywords();
+
+ public static string? prepare_indexable_string(string? str) {
+ if(is_string_empty(str))
+ return null;
+ return String.remove_diacritics(str.down());
+ }
+
+ public static string? prepare_indexable_strings(string[]? strs) {
+ if (strs == null || strs.length == 0)
+ return null;
+
+ StringBuilder builder = new StringBuilder();
+ int ctr = 0;
+ do {
+ if (!is_string_empty(strs[ctr])) {
+ builder.append(strs[ctr].down());
+ if (ctr < strs.length - 1)
+ builder.append_c(' ');
+ }
+ } while (++ctr < strs.length);
+
+ return !is_string_empty(builder.str) ? builder.str : null;
+ }
+}
+
diff --git a/src/core/Tracker.vala b/src/core/Tracker.vala
new file mode 100644
index 0000000..e72992b
--- /dev/null
+++ b/src/core/Tracker.vala
@@ -0,0 +1,216 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Core {
+
+// A TrackerAccumulator is called by Tracker indicating when a DataObject should be included or
+// unincluded in its accumulated data. All methods return true if their data has changed,
+// indicating that the Tracker's "updated" signal should be fired.
+public interface TrackerAccumulator : Object {
+ public abstract bool include(DataObject object);
+
+ public abstract bool uninclude(DataObject object);
+
+ public abstract bool altered(DataObject object, Alteration alteration);
+}
+
+// A Tracker monitors a DataCollection and reports to an installed TrackerAccumulator when objects
+// are available and unavailable. This simplifies connecting to the DataCollection manually to
+// monitoring availability (or subclassing for similar reasons, which may not always be available).
+public class Tracker {
+ protected delegate bool IncludeUnincludeObject(DataObject object);
+
+ private DataCollection collection;
+ private Gee.Collection<DataObject>? initial;
+ private TrackerAccumulator? acc = null;
+
+ public virtual signal void updated() {
+ }
+
+ public Tracker(DataCollection collection, Gee.Collection<DataObject>? initial = null) {
+ this.collection = collection;
+ this.initial = initial;
+ }
+
+ ~Tracker() {
+ if (acc != null) {
+ collection.items_added.disconnect(on_items_added);
+ collection.items_removed.disconnect(on_items_removed);
+ collection.items_altered.disconnect(on_items_altered);
+ }
+ }
+
+ public void start(TrackerAccumulator acc) {
+ // can only be started once
+ assert(this.acc == null);
+
+ this.acc = acc;
+
+ collection.items_added.connect(on_items_added);
+ collection.items_removed.connect(on_items_removed);
+ collection.items_altered.connect(on_items_altered);
+
+ if (initial != null && initial.size > 0)
+ on_items_added(initial);
+ else if (initial == null)
+ on_items_added(collection.get_all());
+
+ initial = null;
+ }
+
+ public DataCollection get_collection() {
+ return collection;
+ }
+
+ private void on_items_added(Gee.Iterable<DataObject> added) {
+ include_uninclude(added, acc.include);
+ }
+
+ private void on_items_removed(Gee.Iterable<DataObject> removed) {
+ include_uninclude(removed, acc.uninclude);
+ }
+
+ // Subclasses can use this as a utility method.
+ protected void include_uninclude(Gee.Iterable<DataObject> objects, IncludeUnincludeObject cb) {
+ bool fire_updated = false;
+ foreach (DataObject object in objects)
+ fire_updated = cb(object) || fire_updated;
+
+ if (fire_updated)
+ updated();
+ }
+
+ private void on_items_altered(Gee.Map<DataObject, Alteration> map) {
+ bool fire_updated = false;
+ foreach (DataObject object in map.keys)
+ fire_updated = acc.altered(object, map.get(object)) || fire_updated;
+
+ if (fire_updated)
+ updated();
+ }
+}
+
+// A ViewTracker is Tracker designed for ViewCollections. It uses an internal mux to route
+// Tracker's calls to three TrackerAccumulators: all (all objects in the ViewCollection), selected
+// (only for selected objects) and visible (only for items not hidden or filtered out).
+public class ViewTracker : Tracker {
+ private class Mux : Object, TrackerAccumulator {
+ public TrackerAccumulator? all;
+ public TrackerAccumulator? visible;
+ public TrackerAccumulator? selected;
+
+ public Mux(TrackerAccumulator? all, TrackerAccumulator? visible, TrackerAccumulator? selected) {
+ this.all = all;
+ this.visible = visible;
+ this.selected = selected;
+ }
+
+ public bool include(DataObject object) {
+ DataView view = (DataView) object;
+
+ bool fire_updated = false;
+
+ if (all != null)
+ fire_updated = all.include(view) || fire_updated;
+
+ if (visible != null && view.is_visible())
+ fire_updated = visible.include(view) || fire_updated;
+
+ if (selected != null && view.is_selected())
+ fire_updated = selected.include(view) || fire_updated;
+
+ return fire_updated;
+ }
+
+ public bool uninclude(DataObject object) {
+ DataView view = (DataView) object;
+
+ bool fire_updated = false;
+
+ if (all != null)
+ fire_updated = all.uninclude(view) || fire_updated;
+
+ if (visible != null && view.is_visible())
+ fire_updated = visible.uninclude(view) || fire_updated;
+
+ if (selected != null && view.is_selected())
+ fire_updated = selected.uninclude(view) || fire_updated;
+
+ return fire_updated;
+ }
+
+ public bool altered(DataObject object, Alteration alteration) {
+ DataView view = (DataView) object;
+
+ bool fire_updated = false;
+
+ if (all != null)
+ fire_updated = all.altered(view, alteration) || fire_updated;
+
+ if (visible != null && view.is_visible())
+ fire_updated = visible.altered(view, alteration) || fire_updated;
+
+ if (selected != null && view.is_selected())
+ fire_updated = selected.altered(view, alteration) || fire_updated;
+
+ return fire_updated;
+ }
+ }
+
+ private Mux? mux = null;
+
+ public ViewTracker(ViewCollection collection) {
+ base (collection, collection.get_all_unfiltered());
+ }
+
+ ~ViewTracker() {
+ if (mux != null) {
+ ViewCollection? collection = get_collection() as ViewCollection;
+ assert(collection != null);
+ collection.items_shown.disconnect(on_items_shown);
+ collection.items_hidden.disconnect(on_items_hidden);
+ collection.items_selected.disconnect(on_items_selected);
+ collection.items_unselected.disconnect(on_items_unselected);
+ }
+ }
+
+ public new void start(TrackerAccumulator? all, TrackerAccumulator? visible, TrackerAccumulator? selected) {
+ assert(mux == null);
+
+ mux = new Mux(all, visible, selected);
+
+ ViewCollection? collection = get_collection() as ViewCollection;
+ assert(collection != null);
+ collection.items_shown.connect(on_items_shown);
+ collection.items_hidden.connect(on_items_hidden);
+ collection.items_selected.connect(on_items_selected);
+ collection.items_unselected.connect(on_items_unselected);
+
+ base.start(mux);
+ }
+
+ private void on_items_shown(Gee.Collection<DataView> shown) {
+ if (mux.visible != null)
+ include_uninclude(shown, mux.visible.include);
+ }
+
+ private void on_items_hidden(Gee.Collection<DataView> hidden) {
+ if (mux.visible != null)
+ include_uninclude(hidden, mux.visible.uninclude);
+ }
+
+ private void on_items_selected(Gee.Iterable<DataView> selected) {
+ if (mux.selected != null)
+ include_uninclude(selected, mux.selected.include);
+ }
+
+ private void on_items_unselected(Gee.Iterable<DataView> unselected) {
+ if (mux.selected != null)
+ include_uninclude(unselected, mux.selected.uninclude);
+ }
+}
+
+}
diff --git a/src/core/ViewCollection.vala b/src/core/ViewCollection.vala
new file mode 100644
index 0000000..a34e23e
--- /dev/null
+++ b/src/core/ViewCollection.vala
@@ -0,0 +1,1287 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// A ViewCollection holds DataView objects, which are view instances wrapping DataSource objects.
+// Thus, multiple views can exist of a single SourceCollection, each view displaying all or some
+// of that SourceCollection. A view collection also has a notion of order
+// (first/last/next/previous) that can be overridden by child classes. It also understands hidden
+// objects, which are withheld entirely from the collection until they're made visible. Currently
+// the only way to hide objects is with a ViewFilter.
+//
+// A ViewCollection may also be locked. When locked, it will not (a) remove hidden items from the
+// collection and (b) remove DataViews representing unlinked DataSources. This allows for the
+// ViewCollection to be "frozen" while manipulating items within it. When the collection is
+// unlocked, all changes are applied at once.
+//
+// The default implementation provides a browser which orders the view in the order they're
+// stored in DataCollection, which is not specified.
+public class ViewCollection : DataCollection {
+ public class Monitor {
+ }
+
+ private class MonitorImpl : Monitor {
+ public ViewCollection owner;
+ public SourceCollection sources;
+ public ViewManager manager;
+ public Alteration? prereq;
+
+ public MonitorImpl(ViewCollection owner, SourceCollection sources, ViewManager manager,
+ Alteration? prereq) {
+ this.owner = owner;
+ this.sources = sources;
+ this.manager = manager;
+ this.prereq = prereq;
+
+ sources.items_added.connect(owner.on_sources_added);
+ sources.items_removed.connect(owner.on_sources_removed);
+ sources.items_altered.connect(owner.on_sources_altered);
+ }
+
+ ~MonitorImpl() {
+ sources.items_added.disconnect(owner.on_sources_added);
+ sources.items_removed.disconnect(owner.on_sources_removed);
+ sources.items_altered.disconnect(owner.on_sources_altered);
+ }
+ }
+
+ private class ToggleLists : Object {
+ public Gee.ArrayList<DataView> selected = new Gee.ArrayList<DataView>();
+ public Gee.ArrayList<DataView> unselected = new Gee.ArrayList<DataView>();
+ }
+
+#if MEASURE_VIEW_FILTERING
+ private static OpTimer filter_timer = new OpTimer("ViewCollection filter timer");
+#endif
+
+ private Gee.HashMultiMap<SourceCollection, MonitorImpl> monitors = new Gee.HashMultiMap<
+ SourceCollection, MonitorImpl>();
+ private ViewCollection mirroring = null;
+ private unowned CreateView mirroring_ctor = null;
+ private unowned CreateViewPredicate should_mirror = null;
+ private Gee.Set<ViewFilter> filters = new Gee.HashSet<ViewFilter>();
+ private DataSet selected = new DataSet();
+ private DataSet visible = null;
+ private Gee.HashSet<DataView> frozen_views_altered = null;
+ private Gee.HashSet<DataView> frozen_geometries_altered = null;
+
+ // TODO: source-to-view mapping ... for now, only one view is allowed for each source.
+ // This may need to change in the future.
+ private Gee.HashMap<DataSource, DataView> source_map = new Gee.HashMap<DataSource, DataView>();
+
+ // Signal aggregator.
+ public virtual signal void items_selected(Gee.Iterable<DataView> selected) {
+ }
+
+ // Signal aggregator.
+ public virtual signal void items_unselected(Gee.Iterable<DataView> unselected) {
+ }
+
+ // Signal aggregator.
+ public virtual signal void items_state_changed(Gee.Iterable<DataView> changed) {
+ }
+
+ // This signal is fired when the selection in the view has changed in any capacity. Items
+ // are not reported individually because they may have been removed (and are not reported as
+ // unselected). In other words, although individual DataViews' selection status may not have
+ // changed, what characterizes the total selection of the ViewCollection has changed.
+ public virtual signal void selection_group_altered() {
+ }
+
+ // Signal aggregator.
+ public virtual signal void items_shown(Gee.Collection<DataView> visible) {
+ }
+
+ // Signal aggregator.
+ public virtual signal void items_hidden(Gee.Collection<DataView> hidden) {
+ }
+
+ // Signal aggregator.
+ public virtual signal void items_visibility_changed(Gee.Collection<DataView> changed) {
+ }
+
+ // Signal aggregator.
+ public virtual signal void item_view_altered(DataView view) {
+ }
+
+ // Signal aggregator.
+ public virtual signal void item_geometry_altered(DataView view) {
+ }
+
+ public virtual signal void views_altered(Gee.Collection<DataView> views) {
+ }
+
+ public virtual signal void geometries_altered(Gee.Collection<DataView> views) {
+ }
+
+ public virtual signal void view_filter_installed(ViewFilter filer) {
+ }
+
+ public virtual signal void view_filter_removed(ViewFilter filer) {
+ }
+
+ public ViewCollection(string name) {
+ base (name);
+ }
+
+ protected virtual void notify_items_selected_unselected(Gee.Collection<DataView>? selected,
+ Gee.Collection<DataView>? unselected) {
+ bool has_selected = (selected != null) && (selected.size > 0);
+ bool has_unselected = (unselected != null) && (unselected.size > 0);
+
+ if (has_selected)
+ items_selected(selected);
+
+ if (has_unselected)
+ items_unselected(unselected);
+
+ Gee.Collection<DataView>? sum;
+ if (has_selected && !has_unselected) {
+ sum = selected;
+ } else if (!has_selected && has_unselected) {
+ sum = unselected;
+ } else if (!has_selected && !has_unselected) {
+ sum = null;
+ } else {
+ sum = new Gee.HashSet<DataView>();
+ sum.add_all(selected);
+ sum.add_all(unselected);
+ }
+
+ if (sum != null) {
+ items_state_changed(sum);
+ notify_selection_group_altered();
+ }
+ }
+
+ protected virtual void notify_selection_group_altered() {
+ selection_group_altered();
+ }
+
+ protected virtual void notify_item_view_altered(DataView view) {
+ item_view_altered(view);
+ }
+
+ protected virtual void notify_views_altered(Gee.Collection<DataView> views) {
+ views_altered(views);
+ }
+
+ protected virtual void notify_item_geometry_altered(DataView view) {
+ item_geometry_altered(view);
+ }
+
+ protected virtual void notify_geometries_altered(Gee.Collection<DataView> views) {
+ geometries_altered(views);
+ }
+
+ protected virtual void notify_items_shown(Gee.Collection<DataView> shown) {
+ items_shown(shown);
+ }
+
+ protected virtual void notify_items_hidden(Gee.Collection<DataView> hidden) {
+ items_hidden(hidden);
+ }
+
+ protected virtual void notify_items_visibility_changed(Gee.Collection<DataView> changed) {
+ items_visibility_changed(changed);
+ }
+
+ protected virtual void notify_view_filter_installed(ViewFilter filter) {
+ view_filter_installed(filter);
+ }
+
+ protected virtual void notify_view_filter_removed(ViewFilter filter) {
+ view_filter_removed(filter);
+ }
+
+ public override void clear() {
+ // cannot clear a ViewCollection if it is monitoring a SourceCollection or mirroring a
+ // ViewCollection
+ if (monitors.size > 0 || mirroring != null) {
+ warning("Cannot clear %s: monitoring or mirroring in effect", to_string());
+
+ return;
+ }
+
+ base.clear();
+ }
+
+ public override void close() {
+ halt_all_monitoring();
+ halt_mirroring();
+ foreach (ViewFilter f in filters)
+ f.refresh.disconnect(on_view_filter_refresh);
+ filters.clear();
+
+ base.close();
+ }
+
+ public Monitor monitor_source_collection(SourceCollection sources, ViewManager manager,
+ Alteration? prereq, Gee.Collection<DataSource>? initial = null,
+ ProgressMonitor? progress_monitor = null) {
+ // cannot use source monitoring and mirroring at the same time
+ halt_mirroring();
+
+ freeze_notifications();
+
+ // create a monitor, which will hook up all the signals and filter from there
+ MonitorImpl monitor = new MonitorImpl(this, sources, manager, prereq);
+ monitors.set(sources, monitor);
+
+ if (initial != null && initial.size > 0) {
+ // add from the initial list handed to us, using the ViewManager to add/remove later
+ Gee.ArrayList<DataView> created_views = new Gee.ArrayList<DataView>();
+ foreach (DataSource source in initial)
+ created_views.add(manager.create_view(source));
+
+ add_many(created_views, progress_monitor);
+ } else {
+ // load in all items from the SourceCollection, filtering with the manager
+ add_sources(sources, (Gee.Iterable<DataSource>) sources.get_all(), progress_monitor);
+ }
+
+ thaw_notifications();
+
+ return monitor;
+ }
+
+ public void halt_monitoring(Monitor m) {
+ MonitorImpl monitor = (MonitorImpl) m;
+
+ bool removed = monitors.remove(monitor.sources, monitor);
+ assert(removed);
+ }
+
+ public void halt_all_monitoring() {
+ monitors.clear();
+ }
+
+ public void mirror(ViewCollection to_mirror, CreateView mirroring_ctor,
+ CreateViewPredicate? should_mirror) {
+ halt_mirroring();
+ halt_all_monitoring();
+ clear();
+
+ mirroring = to_mirror;
+ this.mirroring_ctor = mirroring_ctor;
+ this.should_mirror = should_mirror;
+ set_comparator(to_mirror.get_comparator(), to_mirror.get_comparator_predicate());
+
+ // load up with current items
+ on_mirror_contents_added(mirroring.get_all());
+
+ mirroring.items_added.connect(on_mirror_contents_added);
+ mirroring.items_removed.connect(on_mirror_contents_removed);
+ }
+
+ public void halt_mirroring() {
+ if (mirroring != null) {
+ mirroring.items_added.disconnect(on_mirror_contents_added);
+ mirroring.items_removed.disconnect(on_mirror_contents_removed);
+ }
+
+ mirroring = null;
+ }
+
+ public void copy_into(ViewCollection to_copy, CreateView copying_ctor,
+ CreateViewPredicate should_copy) {
+ // Copy into self.
+ Gee.ArrayList<DataObject> copy_view = new Gee.ArrayList<DataObject>();
+ foreach (DataObject object in to_copy.get_all()) {
+ DataView view = (DataView) object;
+ if (should_copy(view.get_source())) {
+ copy_view.add(copying_ctor(view.get_source()));
+ }
+ }
+ add_many(copy_view);
+ }
+
+ public bool is_view_filter_installed(ViewFilter f) {
+ return filters.contains(f);
+ }
+
+ public void install_view_filter(ViewFilter f) {
+ if (is_view_filter_installed(f))
+ return;
+
+ filters.add(f);
+ f.refresh.connect(on_view_filter_refresh);
+
+ // filter existing items
+ on_view_filter_refresh();
+
+ // notify of change after activating filter
+ notify_view_filter_installed(f);
+ }
+
+ public void remove_view_filter(ViewFilter f) {
+ if (!is_view_filter_installed(f))
+ return;
+
+ filters.remove(f);
+ f.refresh.disconnect(on_view_filter_refresh);
+
+ // filter existing items
+ on_view_filter_refresh();
+
+ // notify of change after activating filter
+ notify_view_filter_removed(f);
+ }
+
+ private void on_view_filter_refresh() {
+ filter_altered_items((Gee.Collection<DataView>) base.get_all());
+ }
+
+ // Runs predicate on all filters, returns ANDed result.
+ private bool is_in_filter(DataView view) {
+ foreach (ViewFilter f in filters) {
+ if (!f.predicate(view))
+ return false;
+ }
+ return true;
+ }
+
+ public override bool valid_type(DataObject object) {
+ return object is DataView;
+ }
+
+ private void on_sources_added(DataCollection sources, Gee.Iterable<DataSource> added) {
+ add_sources((SourceCollection) sources, added);
+ }
+
+ private void add_sources(SourceCollection sources, Gee.Iterable<DataSource> added,
+ ProgressMonitor? progress_monitor = null) {
+ // add only source items which are to be included by the manager ... do this in batches
+ // to take advantage of add_many()
+ DataView created_view = null;
+ Gee.ArrayList<DataView> created_views = null;
+ foreach (DataSource source in added) {
+ CreateView factory = null;
+ foreach (MonitorImpl monitor in monitors.get(sources)) {
+ if (monitor.manager.include_in_view(source)) {
+ factory = monitor.manager.create_view;
+
+ break;
+ }
+ }
+
+ if (factory != null) {
+ DataView new_view = factory(source);
+
+ // this bit of code is designed to avoid creating the ArrayList if only one item
+ // is being added to the ViewCollection
+ if (created_views != null) {
+ created_views.add(new_view);
+ } else if (created_view == null) {
+ created_view = new_view;
+ } else {
+ created_views = new Gee.ArrayList<DataView>();
+ created_views.add(created_view);
+ created_view = null;
+ created_views.add(new_view);
+ }
+ }
+ }
+
+ if (created_view != null)
+ add(created_view);
+ else if (created_views != null && created_views.size > 0)
+ add_many(created_views, progress_monitor);
+ }
+
+ public override bool add(DataObject object) {
+ ((DataView) object).internal_set_visible(true);
+
+ if (!base.add(object))
+ return false;
+
+ filter_altered_items((Gee.Collection<DataView>) get_singleton(object));
+
+ return true;
+ }
+
+ public override Gee.Collection<DataObject> add_many(Gee.Collection<DataObject> objects,
+ ProgressMonitor? monitor = null) {
+ foreach (DataObject object in objects)
+ ((DataView) object).internal_set_visible(true);
+
+ Gee.Collection<DataObject> return_list = base.add_many(objects, monitor);
+
+ filter_altered_items((Gee.Collection<DataView>) return_list);
+
+ return return_list;
+ }
+
+ private void on_sources_removed(Gee.Iterable<DataSource> removed) {
+ // mark all view items associated with the source to be removed
+ Marker marker = null;
+ foreach (DataSource source in removed) {
+ DataView view = source_map.get(source);
+
+ // ignore if not represented in this view
+ if (view != null) {
+ if (marker == null)
+ marker = start_marking();
+
+ marker.mark(view);
+ }
+ }
+
+ if (marker != null && marker.get_count() != 0)
+ remove_marked(marker);
+ }
+
+ private void on_sources_altered(DataCollection collection, Gee.Map<DataObject, Alteration> items) {
+ // let ViewManager decide whether or not to keep, but only add if not already present
+ // and only remove if already present
+ Gee.ArrayList<DataView> to_add = null;
+ Gee.ArrayList<DataView> to_remove = null;
+ bool ordering_changed = false;
+ foreach (DataObject object in items.keys) {
+ Alteration alteration = items.get(object);
+ DataSource source = (DataSource) object;
+
+ MonitorImpl? monitor = null;
+ bool ignored = true;
+ foreach (MonitorImpl monitor_impl in monitors.get((SourceCollection) collection)) {
+ if (monitor_impl.prereq != null && !alteration.contains_any(monitor_impl.prereq))
+ continue;
+
+ ignored = false;
+
+ if (monitor_impl.manager.include_in_view(source)) {
+ monitor = monitor_impl;
+
+ break;
+ }
+ }
+
+ if (ignored) {
+ assert(monitor == null);
+
+ continue;
+ }
+
+ if (monitor != null && !has_view_for_source(source)) {
+ if (to_add == null)
+ to_add = new Gee.ArrayList<DataView>();
+
+ to_add.add(monitor.manager.create_view(source));
+ } else if (monitor == null && has_view_for_source(source)) {
+ if (to_remove == null)
+ to_remove = new Gee.ArrayList<DataView>();
+
+ to_remove.add(get_view_for_source(source));
+ } else if (monitor != null && has_view_for_source(source)) {
+ DataView view = get_view_for_source(source);
+
+ if (selected.contains(view))
+ selected.resort_object(view, alteration);
+
+ if (visible != null && is_visible(view)) {
+ if (visible.resort_object(view, alteration))
+ ordering_changed = true;
+ }
+ }
+ }
+
+ if (to_add != null)
+ add_many(to_add);
+
+ if (to_remove != null)
+ remove_marked(mark_many(to_remove));
+
+ if (ordering_changed)
+ notify_ordering_changed();
+ }
+
+ private void on_mirror_contents_added(Gee.Iterable<DataObject> added) {
+ Gee.ArrayList<DataView> to_add = new Gee.ArrayList<DataView>();
+ foreach (DataObject object in added) {
+ DataSource source = ((DataView) object).get_source();
+
+ if (should_mirror == null || should_mirror(source))
+ to_add.add(mirroring_ctor(source));
+ }
+
+ if (to_add.size > 0)
+ add_many(to_add);
+ }
+
+ private void on_mirror_contents_removed(Gee.Iterable<DataObject> removed) {
+ Marker marker = start_marking();
+ foreach (DataObject object in removed) {
+ DataView view = (DataView) object;
+
+ DataView? our_view = get_view_for_source(view.get_source());
+ assert(our_view != null);
+
+ marker.mark(our_view);
+ }
+
+ remove_marked(marker);
+ }
+
+ // Keep the source map and state tables synchronized
+ protected override void notify_items_added(Gee.Iterable<DataObject> added) {
+ Gee.ArrayList<DataView> added_visible = null;
+ Gee.ArrayList<DataView> added_selected = null;
+
+ foreach (DataObject object in added) {
+ DataView view = (DataView) object;
+ source_map.set(view.get_source(), view);
+
+ if (view.is_selected() && view.is_visible()) {
+ if (added_selected == null)
+ added_selected = new Gee.ArrayList<DataView>();
+
+ added_selected.add(view);
+ }
+
+ // add to visible list only if there is one
+ if (view.is_visible() && visible != null) {
+ if (added_visible == null)
+ added_visible = new Gee.ArrayList<DataView>();
+
+ added_visible.add(view);
+ }
+ }
+
+ if (added_visible != null) {
+ bool is_added = add_many_visible(added_visible);
+ assert(is_added);
+ }
+
+ if (added_selected != null) {
+ add_many_selected(added_selected);
+ notify_items_selected_unselected(added_selected, null);
+ }
+
+ base.notify_items_added(added);
+ }
+
+ // Keep the source map and state tables synchronized
+ protected override void notify_items_removed(Gee.Iterable<DataObject> removed) {
+ Gee.ArrayList<DataView>? selected_removed = null;
+ foreach (DataObject object in removed) {
+ DataView view = (DataView) object;
+
+ // It's possible for execution to get here in direct mode with the source
+ // in question already having been removed from the source map, but the
+ // double removal is unimportant to direct mode, so if this happens, the
+ // remove is skipped the second time (to prevent crashing).
+ if (source_map.has_key(view.get_source())) {
+ bool is_removed = source_map.unset(view.get_source());
+ assert(is_removed);
+
+ if (view.is_selected()) {
+ // hidden items may be selected, but they won't be in the selected pool
+ assert(selected.contains(view) == view.is_visible());
+
+ if (view.is_visible()) {
+ if (selected_removed == null)
+ selected_removed = new Gee.ArrayList<DataView>();
+
+ selected_removed.add(view);
+ }
+ }
+
+ if (view.is_visible() && visible != null) {
+ is_removed = visible.remove(view);
+ assert(is_removed);
+ }
+ }
+ }
+
+ if (selected_removed != null) {
+ remove_many_selected(selected_removed);
+
+ // If a selected item was removed, only fire the selected_removed signal, as the total
+ // selection character of the ViewCollection has changed, but not the individual items'
+ // state.
+ notify_selection_group_altered();
+ }
+
+ base.notify_items_removed(removed);
+ }
+
+ private void filter_altered_items(Gee.Collection<DataView> views) {
+ // Can't use the marker system because ViewCollection completely overrides DataCollection
+ // and hidden items cannot be marked.
+ Gee.ArrayList<DataView> to_show = null;
+ Gee.ArrayList<DataView> to_hide = null;
+
+#if MEASURE_VIEW_FILTERING
+ filter_timer.start();
+#endif
+ foreach (DataView view in views) {
+ if (is_in_filter(view)) {
+ if (!view.is_visible()) {
+ if (to_show == null)
+ to_show = new Gee.ArrayList<DataView>();
+
+ to_show.add(view);
+ }
+ } else {
+ if (view.is_visible()) {
+ if (to_hide == null)
+ to_hide = new Gee.ArrayList<DataView>();
+
+ to_hide.add(view);
+ }
+ }
+ }
+#if MEASURE_VIEW_FILTERING
+ filter_timer.stop();
+ debug("Filtering for %s: %s", to_string(), filter_timer.to_string());
+#endif
+
+ if (to_show != null)
+ show_items(to_show);
+
+ if (to_hide != null)
+ hide_items(to_hide);
+ }
+
+ public override void items_altered(Gee.Map<DataObject, Alteration> map) {
+ filter_altered_items(map.keys);
+
+ base.items_altered(map);
+ }
+
+ public override void set_comparator(Comparator comparator, ComparatorPredicate? predicate) {
+ selected.set_comparator(comparator, predicate);
+ if (visible != null)
+ visible.set_comparator(comparator, predicate);
+
+ base.set_comparator(comparator, predicate);
+ }
+
+ public override void reset_comparator() {
+ selected.reset_comparator();
+ if (visible != null)
+ visible.reset_comparator();
+
+ base.reset_comparator();
+ }
+
+ public override Gee.Collection<DataObject> get_all() {
+ return (visible != null) ? visible.get_all() : base.get_all();
+ }
+
+ public Gee.Collection<DataObject> get_all_unfiltered() {
+ return base.get_all();
+ }
+
+ public override int get_count() {
+ return (visible != null) ? visible.get_count() : base.get_count();
+ }
+
+ public int get_unfiltered_count() {
+ return base.get_count();
+ }
+
+ public override DataObject? get_at(int index) {
+ return (visible != null) ? visible.get_at(index) : base.get_at(index);
+ }
+
+ public override int index_of(DataObject object) {
+ return (visible != null) ? visible.index_of(object) : base.index_of(object);
+ }
+
+ public override bool contains(DataObject object) {
+ // use base method first, which can quickly ascertain if the object is *not* a member of
+ // this collection
+ if (!base.contains(object))
+ return false;
+
+ // even if a member, must be visible to be "contained"
+ return is_visible((DataView) object);
+ }
+
+ public virtual DataView? get_first() {
+ return (get_count() > 0) ? (DataView?) get_at(0) : null;
+ }
+
+ /**
+ * @brief A helper method for places in the app that need a
+ * non-rejected media source (namely Events, when looking to
+ * automatically choose a thumbnail).
+ *
+ * @note If every view in this collection is rejected, we
+ * return the first view; this is intentional. This prevents
+ * pathological events that have nothing but rejected images
+ * in them from breaking.
+ */
+ public virtual DataView? get_first_unrejected() {
+ // We have no media, unrejected or otherwise...
+ if (get_count() < 1)
+ return null;
+
+ // Loop through media we do have...
+ DataView dv = get_first();
+ int num_views = get_count();
+
+ while ((dv != null) && (index_of(dv) < (num_views - 1))) {
+ MediaSource tmp = dv.get_source() as MediaSource;
+
+ if ((tmp != null) && (tmp.get_rating() != Rating.REJECTED)) {
+ // ...found a good one; return it.
+ return dv;
+ } else {
+ dv = get_next(dv);
+ }
+ }
+
+ // Got to the end of the collection, none found, need to return
+ // _something_...
+ return get_first();
+ }
+
+ public virtual DataView? get_last() {
+ return (get_count() > 0) ? (DataView?) get_at(get_count() - 1) : null;
+ }
+
+ public virtual DataView? get_next(DataView view) {
+ if (get_count() == 0)
+ return null;
+
+ int index = index_of(view);
+ if (index < 0)
+ return null;
+
+ index++;
+ if (index >= get_count())
+ index = 0;
+
+ return (DataView?) get_at(index);
+ }
+
+ public virtual DataView? get_previous(DataView view) {
+ if (get_count() == 0)
+ return null;
+
+ int index = index_of(view);
+ if (index < 0)
+ return null;
+
+ index--;
+ if (index < 0)
+ index = get_count() - 1;
+
+ return (DataView?) get_at(index);
+ }
+
+ public bool get_immediate_neighbors(DataSource home, out DataSource? next,
+ out DataSource? prev, string? type_selector = null) {
+ next = null;
+ prev = null;
+
+ DataView home_view = get_view_for_source(home);
+ if (home_view == null)
+ return false;
+
+ DataView? next_view = get_next(home_view);
+ while (next_view != home_view) {
+ if ((type_selector == null) || (next_view.get_source().get_typename() == type_selector)) {
+ next = next_view.get_source();
+ break;
+ }
+ next_view = get_next(next_view);
+ }
+
+ DataView? prev_view = get_previous(home_view);
+ while (prev_view != home_view) {
+ if ((type_selector == null) || (prev_view.get_source().get_typename() == type_selector)) {
+ prev = prev_view.get_source();
+ break;
+ }
+ prev_view = get_previous(prev_view);
+ }
+
+ return true;
+ }
+
+ // "Extended" as in immediate neighbors and their neighbors
+ public Gee.Set<DataSource> get_extended_neighbors(DataSource home, string? typename = null) {
+ // build set of neighbors
+ Gee.Set<DataSource> neighbors = new Gee.HashSet<DataSource>();
+
+ // immediate neighbors
+ DataSource next, prev;
+ if (!get_immediate_neighbors(home, out next, out prev, typename))
+ return neighbors;
+
+ // add next and its distant neighbor
+ if (next != null) {
+ neighbors.add(next);
+
+ DataSource next_next, next_prev;
+ get_immediate_neighbors(next, out next_next, out next_prev, typename);
+
+ // only add next-next because next-prev is home
+ if (next_next != null)
+ neighbors.add(next_next);
+ }
+
+ // add previous and its distant neighbor
+ if (prev != null) {
+ neighbors.add(prev);
+
+ DataSource next_prev, prev_prev;
+ get_immediate_neighbors(prev, out next_prev, out prev_prev, typename);
+
+ // only add prev-prev because next-prev is home
+ if (prev_prev != null)
+ neighbors.add(prev_prev);
+ }
+
+ // finally, in a small collection a neighbor could be home itself, so exclude it
+ neighbors.remove(home);
+
+ return neighbors;
+ }
+
+ // Do NOT add hidden items to the selection collection, mark them as selected and they will be
+ // added when/if they are made visible.
+ private void add_many_selected(Gee.Collection<DataView> views) {
+ if (views.size == 0)
+ return;
+
+ foreach (DataView view in views)
+ assert(view.is_visible());
+
+ bool added = selected.add_many(views);
+ assert(added);
+ }
+
+ private void remove_many_selected(Gee.Collection<DataView> views) {
+ if (views.size == 0)
+ return;
+
+ bool removed = selected.remove_many(views);
+ assert(removed);
+ }
+
+ // Selects all the marked items. The marker will be invalid after this call.
+ public void select_marked(Marker marker) {
+ Gee.ArrayList<DataView> selected = new Gee.ArrayList<DataView>();
+ act_on_marked(marker, select_item, null, selected);
+
+ if (selected.size > 0) {
+ add_many_selected(selected);
+ notify_items_selected_unselected(selected, null);
+ }
+ }
+
+ // Selects all items.
+ public void select_all() {
+ Marker marker = start_marking();
+ marker.mark_all();
+ select_marked(marker);
+ }
+
+ private bool select_item(DataObject object, Object? user) {
+ DataView view = (DataView) object;
+ if (view.is_selected()) {
+ if (view.is_visible())
+ assert(selected.contains(view));
+
+ return true;
+ }
+
+ view.internal_set_selected(true);
+
+ // Do NOT add hidden items to the selection collection, merely mark them as selected
+ // and they will be re-added when/if they are made visible
+ if (view.is_visible())
+ ((Gee.ArrayList<DataView>) user).add(view);
+
+ return true;
+ }
+
+ // Unselects all the marked items. The marker will be invalid after this call.
+ public void unselect_marked(Marker marker) {
+ Gee.ArrayList<DataView> unselected = new Gee.ArrayList<DataView>();
+ act_on_marked(marker, unselect_item, null, unselected);
+
+ if (unselected.size > 0) {
+ remove_many_selected(unselected);
+ notify_items_selected_unselected(null, unselected);
+ }
+ }
+
+ // Unselects all items.
+ public void unselect_all() {
+ if (selected.get_count() == 0)
+ return;
+
+ Marker marker = start_marking();
+ marker.mark_many(get_selected());
+
+ unselect_marked(marker);
+ }
+
+ // Unselects all items but the one specified.
+ public void unselect_all_but(DataView exception) {
+ Marker marker = start_marking();
+ foreach (DataObject object in get_all()) {
+ DataView view = (DataView) object;
+ if (view != exception)
+ marker.mark(view);
+ }
+
+ unselect_marked(marker);
+ }
+
+ private bool unselect_item(DataObject object, Object? user) {
+ DataView view = (DataView) object;
+ if (!view.is_selected()) {
+ assert(!selected.contains(view));
+
+ return true;
+ }
+
+ view.internal_set_selected(false);
+ ((Gee.ArrayList<DataView>) user).add(view);
+
+ return true;
+ }
+
+ // Performs the operations in that order: unselects the marked then selects the marked
+ public void unselect_and_select_marked(Marker unselect, Marker select) {
+ Gee.ArrayList<DataView> unselected = new Gee.ArrayList<DataView>();
+ act_on_marked(unselect, unselect_item, null, unselected);
+
+ remove_many_selected(unselected);
+
+ Gee.ArrayList<DataView> selected = new Gee.ArrayList<DataView>();
+ act_on_marked(select, select_item, null, selected);
+
+ add_many_selected(selected);
+
+ notify_items_selected_unselected(selected, unselected);
+ }
+
+ // Toggle the selection state of all marked items. The marker will be invalid after this
+ // call.
+ public void toggle_marked(Marker marker) {
+ ToggleLists lists = new ToggleLists();
+ act_on_marked(marker, toggle_item, null, lists);
+
+ // add and remove selected before firing the signals
+ add_many_selected(lists.selected);
+ remove_many_selected(lists.unselected);
+
+ notify_items_selected_unselected(lists.selected, lists.unselected);
+ }
+
+ private bool toggle_item(DataObject object, Object? user) {
+ DataView view = (DataView) object;
+ ToggleLists lists = (ToggleLists) user;
+
+ // toggle the selection state of the view, adding or removing it from the selected list
+ // to maintain state and adding it to the ToggleLists for the caller to signal with
+ //
+ // See add_many_selected for rules on not adding hidden items to the selection pool
+ if (view.internal_toggle()) {
+ if (view.is_visible())
+ lists.selected.add(view);
+ } else {
+ lists.unselected.add(view);
+ }
+
+ return true;
+ }
+
+ public int get_selected_count() {
+ return selected.get_count();
+ }
+
+ public Gee.List<DataView> get_selected() {
+ return (Gee.List<DataView>) selected.get_all();
+ }
+
+ public DataView? get_selected_at(int index) {
+ return (DataView?) selected.get_at(index);
+ }
+
+ private bool is_visible(DataView view) {
+ return (visible != null) ? visible.contains(view) : true;
+ }
+
+ private bool add_many_visible(Gee.Collection<DataView> many) {
+ if (visible == null)
+ return true;
+
+ if (!visible.add_many(many))
+ return false;
+
+ // if all are visible, then revert to using base class's set
+ if (visible.get_count() == base.get_count())
+ visible = null;
+
+ return true;
+ }
+
+ // This method requires that all items in to_hide are not hidden already.
+ private void hide_items(Gee.List<DataView> to_hide) {
+ Gee.ArrayList<DataView> unselected = new Gee.ArrayList<DataView>();
+
+ int count = to_hide.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ DataView view = to_hide.get(ctr);
+ assert(view.is_visible());
+
+ if (view.is_selected()) {
+ view.internal_set_selected(false);
+ unselected.add(view);
+ } else {
+ assert(!selected.contains(view));
+ }
+
+ view.internal_set_visible(false);
+ }
+
+ if (visible == null) {
+ // make a copy of the full set before removing items
+ visible = get_dataset_copy();
+ }
+
+ bool removed = visible.remove_many(to_hide);
+ assert(removed);
+
+ remove_many_selected(unselected);
+
+ if (unselected.size > 0)
+ notify_items_selected_unselected(null, unselected);
+
+ if (to_hide.size > 0) {
+ notify_items_hidden(to_hide);
+ notify_items_visibility_changed(to_hide);
+ }
+ }
+
+ // This method requires that all items in to_show are hidden already.
+ private void show_items(Gee.List<DataView> to_show) {
+ Gee.ArrayList<DataView> added_selected = new Gee.ArrayList<DataView>();
+
+ int count = to_show.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ DataView view = to_show.get(ctr);
+ assert(!view.is_visible());
+
+ view.internal_set_visible(true);
+
+ // See note in add_selected for selection handling with hidden/visible items
+ if (view.is_selected()) {
+ assert(!selected.contains(view));
+ added_selected.add(view);
+ }
+ }
+
+ bool added = add_many_visible(to_show);
+ assert(added);
+
+ add_many_selected(added_selected);
+
+ if (to_show.size > 0) {
+ notify_items_shown(to_show);
+ notify_items_visibility_changed(to_show);
+ }
+ }
+
+ // This currently does not respect filtering.
+ public bool has_view_for_source(DataSource source) {
+ return get_view_for_source(source) != null;
+ }
+
+ // This currently does not respect filtering.
+ public DataView? get_view_for_source(DataSource source) {
+ return source_map.get(source);
+ }
+
+ // Respects filtering.
+ public bool has_view_for_source_with_filtered(DataSource source) {
+ return get_view_for_source_filtered(source) != null;
+ }
+
+ // Respects filtering.
+ public DataView? get_view_for_source_filtered(DataSource source) {
+ DataView? view = source_map.get(source);
+ // Consult with filter to make sure DataView is visible.
+ if (view != null && !is_in_filter(view))
+ return null;
+ return view;
+ }
+
+ // TODO: This currently does not respect filtering.
+ public Gee.Collection<DataSource> get_sources() {
+ return source_map.keys.read_only_view;
+ }
+
+ // TODO: This currently does not respect filtering.
+ public bool has_source_of_type(Type t) {
+ assert(t.is_a(typeof(DataSource)));
+
+ foreach (DataSource source in source_map.keys) {
+ if (source.get_type().is_a(t))
+ return true;
+ }
+
+ return false;
+ }
+
+ public int get_sources_of_type_count(Type t) {
+ assert(t.is_a(typeof(DataSource)));
+
+ int count = 0;
+ foreach (DataObject object in get_all()) {
+ if (((DataView) object).get_source().get_type().is_a(t))
+ count++;
+ }
+
+ return count;
+ }
+
+ public Gee.List<DataSource>? get_sources_of_type(Type t) {
+ assert(t.is_a(typeof(DataSource)));
+
+ Gee.List<DataSource>? sources = null;
+ foreach (DataObject object in get_all()) {
+ DataSource source = ((DataView) object).get_source();
+ if (source.get_type().is_a(t)) {
+ if (sources == null)
+ sources = new Gee.ArrayList<DataSource>();
+
+ sources.add(source);
+ }
+ }
+
+ return sources;
+ }
+
+ public Gee.List<DataSource> get_selected_sources() {
+ Gee.List<DataSource> sources = new Gee.ArrayList<DataSource>();
+
+ int count = selected.get_count();
+ for (int ctr = 0; ctr < count; ctr++)
+ sources.add(((DataView) selected.get_at(ctr)).get_source());
+
+ return sources;
+ }
+
+ public DataSource? get_selected_source_at(int index) {
+ DataObject? object = selected.get_at(index);
+
+ return (object != null) ? ((DataView) object).get_source() : null;
+ }
+
+ public Gee.List<DataSource>? get_selected_sources_of_type(Type t) {
+ Gee.List<DataSource>? sources = null;
+ foreach (DataView view in get_selected()) {
+ DataSource source = view.get_source();
+ if (source.get_type().is_a(t)) {
+ if (sources == null)
+ sources = new Gee.ArrayList<DataSource>();
+
+ sources.add(source);
+ }
+ }
+
+ return sources;
+ }
+
+ // Returns -1 if source is not in the ViewCollection.
+ public int index_of_source(DataSource source) {
+ DataView? view = get_view_for_source(source);
+
+ return (view != null) ? index_of(view) : -1;
+ }
+
+ // This is only used by DataView.
+ public void internal_notify_view_altered(DataView view) {
+ if (!are_notifications_frozen()) {
+ notify_item_view_altered(view);
+ notify_views_altered((Gee.Collection<DataView>) get_singleton(view));
+ } else {
+ if (frozen_views_altered == null)
+ frozen_views_altered = new Gee.HashSet<DataView>();
+ frozen_views_altered.add(view);
+ }
+ }
+
+ // This is only used by DataView.
+ public void internal_notify_geometry_altered(DataView view) {
+ if (!are_notifications_frozen()) {
+ notify_item_geometry_altered(view);
+ notify_geometries_altered((Gee.Collection<DataView>) get_singleton(view));
+ } else {
+ if (frozen_geometries_altered == null)
+ frozen_geometries_altered = new Gee.HashSet<DataView>();
+ frozen_geometries_altered.add(view);
+ }
+ }
+
+ protected override void notify_thawed() {
+ if (frozen_views_altered != null) {
+ foreach (DataView view in frozen_views_altered)
+ notify_item_view_altered(view);
+ notify_views_altered(frozen_views_altered);
+ frozen_views_altered = null;
+ }
+
+ if (frozen_geometries_altered != null) {
+ foreach (DataView view in frozen_geometries_altered)
+ notify_item_geometry_altered(view);
+ notify_geometries_altered(frozen_geometries_altered);
+ frozen_geometries_altered = null;
+ }
+
+ base.notify_thawed();
+ }
+
+ public bool are_items_filtered_out() {
+ return base.get_count() != get_count();
+ }
+}
+
+// A ViewManager allows an interface for ViewCollection to monitor a SourceCollection and
+// (selectively) add DataViews automatically.
+public abstract class ViewManager {
+ // This predicate function can be used to filter which DataView objects should be included
+ // in the collection as new source objects appear in the SourceCollection. May be called more
+ // than once for any DataSource object.
+ public virtual bool include_in_view(DataSource source) {
+ return true;
+ }
+
+ // If include_in_view returns true, this method will be called to instantiate a DataView object
+ // for the ViewCollection.
+ public abstract DataView create_view(DataSource source);
+}
+
+// CreateView is a construction delegate used when mirroring or copying a ViewCollection
+// in another ViewCollection.
+public delegate DataView CreateView(DataSource source);
+
+// CreateViewPredicate is a filter delegate used when copy a ViewCollection in another
+// ViewCollection.
+public delegate bool CreateViewPredicate(DataSource source);
+
+// A ViewFilter allows for items in a ViewCollection to be shown or hidden depending on the
+// supplied predicate method. For now, only one ViewFilter may be installed, although this may
+// change in the future. The ViewFilter is used whenever an object is added to the collection
+// and when its altered/metadata_altered signals fire.
+public abstract class ViewFilter {
+ // Fire this signal whenever a refresh is needed. The ViewCollection listens
+ // to this signal to know when to reapply the filter.
+ public virtual signal void refresh() {
+ }
+
+ // Return true if view should be visible, false if it should be hidden.
+ public abstract bool predicate(DataView view);
+}
+
diff --git a/src/core/mk/core.mk b/src/core/mk/core.mk
new file mode 100644
index 0000000..c35c93a
--- /dev/null
+++ b/src/core/mk/core.mk
@@ -0,0 +1,43 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Core
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := core
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ DataCollection.vala \
+ DataSet.vala \
+ util.vala \
+ SourceCollection.vala \
+ SourceHoldingTank.vala \
+ DatabaseSourceCollection.vala \
+ ContainerSourceCollection.vala \
+ ViewCollection.vala \
+ DataObject.vala \
+ Alteration.vala \
+ DataSource.vala \
+ DataSourceTypes.vala \
+ DataView.vala \
+ DataViewTypes.vala \
+ Tracker.vala \
+ SourceInterfaces.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/core/util.vala b/src/core/util.vala
new file mode 100644
index 0000000..1846380
--- /dev/null
+++ b/src/core/util.vala
@@ -0,0 +1,196 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// SingletonCollection is a read-only collection designed to hold exactly one item in it. This
+// is far more efficient than creating a dummy collection (such as ArrayList) merely to pass around
+// a single item, particularly for signals which require Iterables and Collections.
+//
+// This collection cannot be used to store null.
+
+public class SingletonCollection<G> : Gee.AbstractCollection<G> {
+ private class SingletonIterator<G> : Object, Gee.Traversable<G>, Gee.Iterator<G> {
+ private SingletonCollection<G> c;
+ private bool done = false;
+ private G? current = null;
+
+ public SingletonIterator(SingletonCollection<G> c) {
+ this.c = c;
+ }
+
+ public bool read_only {
+ get { return done; }
+ }
+
+ public bool valid {
+ get { return done; }
+ }
+
+ public bool foreach(Gee.ForallFunc<G> f) {
+ return f(c.object);
+ }
+
+ public new G? get() {
+ return current;
+ }
+
+ public bool has_next() {
+ return false;
+ }
+
+ public bool next() {
+ if (done)
+ return false;
+
+ done = true;
+ current = c.object;
+
+ return true;
+ }
+
+ public void remove() {
+ if (!done) {
+ c.object = null;
+ current = null;
+ }
+
+ done = true;
+ }
+ }
+
+ private G? object;
+
+ public SingletonCollection(G object) {
+ this.object = object;
+ }
+
+ public override bool read_only {
+ get { return false; }
+ }
+
+ public override bool add(G object) {
+ warning("Cannot add to SingletonCollection");
+
+ return false;
+ }
+
+ public override void clear() {
+ object = null;
+ }
+
+ public override bool contains(G object) {
+ return this.object == object;
+ }
+
+ public override Gee.Iterator<G> iterator() {
+ return new SingletonIterator<G>(this);
+ }
+
+ public override bool remove(G item) {
+ if (item == object) {
+ object = null;
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public override int size {
+ get {
+ return (object != null) ? 1 : 0;
+ }
+ }
+}
+
+// A Marker is an object for marking (selecting) DataObjects in a DataCollection to then perform
+// an action on all of them. This mechanism allows for performing mass operations in a generic
+// way, as well as dealing with the (perpetual) issue of removing items from a Collection within
+// an iterator.
+public interface Marker : Object {
+ public abstract void mark(DataObject object);
+
+ public abstract void unmark(DataObject object);
+
+ public abstract bool toggle(DataObject object);
+
+ public abstract void mark_many(Gee.Collection<DataObject> list);
+
+ public abstract void unmark_many(Gee.Collection<DataObject> list);
+
+ public abstract void mark_all();
+
+ // Returns the number of marked items, or the number of items when the marker was frozen
+ // and used.
+ public abstract int get_count();
+
+ // Returns a copy of the collection of marked items.
+ public abstract Gee.Collection<DataObject> get_all();
+}
+
+// MarkedAction is a callback to perform an action on the marked DataObject. Return false to
+// end iterating.
+public delegate bool MarkedAction(DataObject object, Object? user);
+
+// A ProgressMonitor allows for notifications of progress on operations on multiple items (via
+// the marked interfaces). Return false if the operation is cancelled and should end immediately.
+public delegate bool ProgressMonitor(uint64 current, uint64 total, bool do_event_loop = true);
+
+// UnknownTotalMonitor is useful when an interface cannot report the total count to a ProgressMonitor,
+// only a count, but the total is known by the caller.
+public class UnknownTotalMonitor {
+ private uint64 total;
+ private unowned ProgressMonitor wrapped_monitor;
+
+ public UnknownTotalMonitor(uint64 total, ProgressMonitor wrapped_monitor) {
+ this.total = total;
+ this.wrapped_monitor = wrapped_monitor;
+ }
+
+ public bool monitor(uint64 count, uint64 total) {
+ return wrapped_monitor(count, this.total);
+ }
+}
+
+// AggregateProgressMonitor is useful when several discrete operations are being performed against
+// a single ProgressMonitor.
+public class AggregateProgressMonitor {
+ private uint64 grand_total;
+ private unowned ProgressMonitor wrapped_monitor;
+ private uint64 aggregate_count = 0;
+ private uint64 last_count = uint64.MAX;
+
+ public AggregateProgressMonitor(uint64 grand_total, ProgressMonitor wrapped_monitor) {
+ this.grand_total = grand_total;
+ this.wrapped_monitor = wrapped_monitor;
+ }
+
+ public void next_step(string name) {
+ debug("next step: %s (%s/%s)", name, aggregate_count.to_string(), grand_total.to_string());
+ last_count = uint64.MAX;
+ }
+
+ public bool monitor(uint64 count, uint64 total) {
+ // add the difference from the last, unless a new step has started
+ aggregate_count += (last_count != uint64.MAX) ? (count - last_count) : count;
+ if (aggregate_count > grand_total)
+ aggregate_count = grand_total;
+
+ // save for next time
+ last_count = count;
+
+ return wrapped_monitor(aggregate_count, grand_total);
+ }
+}
+
+// Useful when debugging.
+public bool null_progress_monitor(uint64 count, uint64 total) {
+ return true;
+}
+
+
+double degrees_to_radians(double theta) {
+ return (theta * (GLib.Math.PI / 180.0));
+}
diff --git a/src/data_imports/DataImportJob.vala b/src/data_imports/DataImportJob.vala
new file mode 100644
index 0000000..b27997c
--- /dev/null
+++ b/src/data_imports/DataImportJob.vala
@@ -0,0 +1,177 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+namespace Spit.DataImports {
+
+/**
+ * A specialized import job implementation for alien databases.
+ */
+public class DataImportJob : BatchImportJob {
+ private DataImportSource import_source;
+ private File? src_file;
+ private uint64 filesize;
+ private time_t exposure_time;
+ private DataImportJob? associated = null;
+ private HierarchicalTagIndex? detected_htags = null;
+
+ public DataImportJob(DataImportSource import_source) {
+ this.import_source = import_source;
+
+ // stash everything called in prepare(), as it may/will be called from a separate thread
+ src_file = import_source.get_file();
+ filesize = import_source.get_filesize();
+ exposure_time = import_source.get_exposure_time();
+ }
+
+ private HierarchicalTagIndex? build_exclusion_index(ImportableTag[] src_tags) {
+ Gee.Set<string> detected_htags = new Gee.HashSet<string>();
+
+ foreach (ImportableTag src_tag in src_tags) {
+ string? prepped = HierarchicalTagUtilities.join_path_components(
+ Tag.prep_tag_names(
+ build_path_components(src_tag)
+ )
+ );
+
+ if (prepped != null && prepped.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ detected_htags.add(prepped);
+
+ Gee.List<string> parents = HierarchicalTagUtilities.enumerate_parent_paths(prepped);
+ foreach (string parent in parents)
+ detected_htags.add(parent);
+ }
+ }
+
+ return (detected_htags.size > 0) ? HierarchicalTagIndex.from_paths(detected_htags) : null;
+ }
+
+ public time_t get_exposure_time() {
+ return exposure_time;
+ }
+
+ public override string get_dest_identifier() {
+ return import_source.get_filename();
+ }
+
+ public override string get_source_identifier() {
+ return import_source.get_filename();
+ }
+
+ public override bool is_directory() {
+ return false;
+ }
+
+ public override string get_basename() {
+ return src_file.get_basename();
+ }
+
+ public override string get_path() {
+ return src_file.get_parent().get_path();
+ }
+
+ public override void set_associated(BatchImportJob associated) {
+ this.associated = associated as DataImportJob;
+ }
+
+ public override bool determine_file_size(out uint64 filesize, out File file) {
+ file = null;
+ filesize = this.filesize;
+
+ return true;
+ }
+
+ public override bool prepare(out File file_to_import, out bool copy_to_library) throws Error {
+ file_to_import = src_file;
+ copy_to_library = false;
+
+ detected_htags = build_exclusion_index(import_source.get_photo().get_tags());
+
+ return true;
+ }
+
+ public override bool complete(MediaSource source, BatchImportRoll import_roll) throws Error {
+ LibraryPhoto? photo = source as LibraryPhoto;
+ if (photo == null)
+ return false;
+
+ ImportableMediaItem src_photo = import_source.get_photo();
+
+ // tags
+ if (detected_htags != null) {
+ Gee.Collection<string> paths = detected_htags.get_all_paths();
+
+ foreach (string path in paths)
+ Tag.for_path(path);
+ }
+
+ ImportableTag[] src_tags = src_photo.get_tags();
+ foreach (ImportableTag src_tag in src_tags) {
+ string? prepped = HierarchicalTagUtilities.join_path_components(
+ Tag.prep_tag_names(
+ build_path_components(src_tag)
+ )
+ );
+ if (prepped != null) {
+ if (HierarchicalTagUtilities.enumerate_path_components(prepped).size == 1) {
+ if (prepped.has_prefix(Tag.PATH_SEPARATOR_STRING))
+ prepped = HierarchicalTagUtilities.hierarchical_to_flat(prepped);
+ } else {
+ Gee.List<string> parents =
+ HierarchicalTagUtilities.enumerate_parent_paths(prepped);
+
+ assert(parents.size > 0);
+
+ string top_level_parent = parents.get(0);
+ string flat_top_level_parent =
+ HierarchicalTagUtilities.hierarchical_to_flat(top_level_parent);
+
+ if (Tag.global.exists(flat_top_level_parent))
+ Tag.for_path(flat_top_level_parent).promote();
+ }
+
+ Tag.for_path(prepped).attach(photo);
+ }
+ }
+ // event
+ ImportableEvent? src_event = src_photo.get_event();
+ if (src_event != null) {
+ string? prepped = prepare_input_text(src_event.get_name(),
+ PrepareInputTextOptions.DEFAULT, -1);
+ if (prepped != null)
+ Event.generate_single_event(photo, import_roll.generated_events, prepped);
+ }
+ // rating
+ Rating dst_rating;
+ ImportableRating src_rating = src_photo.get_rating();
+ if (src_rating.is_rejected())
+ dst_rating = Rating.REJECTED;
+ else if (src_rating.is_unrated())
+ dst_rating = Rating.UNRATED;
+ else
+ dst_rating = Rating.unserialize(src_rating.get_value());
+ photo.set_rating(dst_rating);
+ // title
+ string? title = src_photo.get_title();
+ if (title != null)
+ photo.set_title(title);
+ // import ID
+ photo.set_import_id(import_roll.import_id);
+
+ return true;
+ }
+
+ private string[] build_path_components(ImportableTag tag) {
+ // use a linked list as we are always inserting in head position
+ Gee.List<string> components = new Gee.LinkedList<string>();
+ for (ImportableTag current_tag = tag; current_tag != null; current_tag = current_tag.get_parent()) {
+ components.insert(0, HierarchicalTagUtilities.make_flat_tag_safe(current_tag.get_name()));
+ }
+ return components.to_array();
+ }
+}
+
+}
+
diff --git a/src/data_imports/DataImportSource.vala b/src/data_imports/DataImportSource.vala
new file mode 100644
index 0000000..d7e8ec8
--- /dev/null
+++ b/src/data_imports/DataImportSource.vala
@@ -0,0 +1,135 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Spit.DataImports {
+
+/**
+ * Photo source implementation for alien databases. This class is responsible
+ * for extracting meta-data out of a source photo to support the import
+ * process.
+ *
+ * This class does not extend PhotoSource in order to minimise the API to the
+ * absolute minimum required to run the import job.
+ */
+public class DataImportSource {
+ private bool backing_file_found;
+ private ImportableMediaItem db_photo;
+ private string? title = null;
+ private string? preview_md5 = null;
+ private uint64 file_size;
+ private time_t modification_time;
+ private MetadataDateTime? exposure_time;
+
+ public DataImportSource(ImportableMediaItem db_photo) {
+ this.db_photo = db_photo;
+
+ // A well-behaved plugin will ensure that the path and file name are
+ // not null but we check just in case
+ string folder_path = db_photo.get_folder_path();
+ string filename = db_photo.get_filename();
+ File? photo = null;
+ if (folder_path != null && filename != null) {
+ photo = File.new_for_path(db_photo.get_folder_path()).
+ get_child(db_photo.get_filename());
+
+ backing_file_found = photo.query_exists();
+ } else {
+ backing_file_found = false;
+ }
+
+ if (photo != null && backing_file_found) {
+ PhotoMetadata? metadata = new PhotoMetadata();
+ try {
+ metadata.read_from_file(photo);
+ } catch(Error e) {
+ warning("Could not get file metadata for %s: %s", get_filename(), e.message);
+ metadata = null;
+ }
+
+ title = (metadata != null) ? metadata.get_title() : null;
+ exposure_time = (metadata != null) ? metadata.get_exposure_date_time() : null;
+ PhotoPreview? preview = metadata != null ? metadata.get_preview(0) : null;
+ if (preview != null) {
+ try {
+ uint8[] preview_raw = preview.flatten();
+ preview_md5 = md5_binary(preview_raw, preview_raw.length);
+ } catch(Error e) {
+ warning("Could not get raw preview for %s: %s", get_filename(), e.message);
+ }
+ }
+#if TRACE_MD5
+ debug("Photo MD5 %s: preview=%s", get_filename(), preview_md5);
+#endif
+
+ try {
+ file_size = query_total_file_size(photo);
+ } catch(Error e) {
+ warning("Could not get file size for %s: %s", get_filename(), e.message);
+ }
+ try {
+ modification_time = query_file_modified(photo);
+ } catch(Error e) {
+ warning("Could not get modification time for %s: %s", get_filename(), e.message);
+ }
+ } else {
+ debug ("Photo file %s not found".printf(photo.get_path()));
+ }
+ }
+
+ public string get_filename() {
+ return db_photo.get_filename();
+ }
+
+ public string get_fulldir() {
+ return db_photo.get_folder_path();
+ }
+
+ public File get_file() {
+ return File.new_for_path(get_fulldir()).get_child(get_filename());
+ }
+
+ public string get_name() {
+ return !is_string_empty(title) ? title : get_filename();
+ }
+
+ public string? get_title() {
+ return title;
+ }
+
+ public PhotoFileFormat get_file_format() {
+ return PhotoFileFormat.get_by_basename_extension(get_filename());
+ }
+
+ public string to_string() {
+ return get_name();
+ }
+
+ public time_t get_exposure_time() {
+ return (exposure_time != null) ? exposure_time.get_timestamp() : modification_time;
+ }
+
+ public uint64 get_filesize() {
+ return file_size;
+ }
+
+ public ImportableMediaItem get_photo() {
+ return db_photo;
+ }
+
+ public bool is_already_imported() {
+ // ignore trashed duplicates
+ return (preview_md5 != null)
+ ? LibraryPhoto.has_nontrash_duplicate(null, preview_md5, null, get_file_format())
+ : false;
+ }
+
+ public bool was_backing_file_found() {
+ return backing_file_found;
+ }
+}
+
+}
+
diff --git a/src/data_imports/DataImports.vala b/src/data_imports/DataImports.vala
new file mode 100644
index 0000000..72c8b4d
--- /dev/null
+++ b/src/data_imports/DataImports.vala
@@ -0,0 +1,30 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 DataImports 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 DataImports {
+
+public void init() throws Error {
+ string[] core_ids = new string[0];
+ core_ids += "org.yorba.shotwell.dataimports.fspot";
+
+ Plugins.register_extension_point(typeof(Spit.DataImports.Service), _("Data Imports"),
+ Resources.IMPORT, core_ids);
+}
+
+public void terminate() {
+}
+
+}
+
diff --git a/src/data_imports/DataImportsPluginHost.vala b/src/data_imports/DataImportsPluginHost.vala
new file mode 100644
index 0000000..f92bc53
--- /dev/null
+++ b/src/data_imports/DataImportsPluginHost.vala
@@ -0,0 +1,482 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Spit.DataImports {
+
+private class CoreImporter {
+ private weak Spit.DataImports.PluginHost host;
+ public int imported_items_count = 0;
+ public BatchImportRoll? current_import_roll = null;
+
+ public CoreImporter(Spit.DataImports.PluginHost host) {
+ this.host = host;
+ }
+
+ public void prepare_media_items_for_import(
+ ImportableMediaItem[] items,
+ double progress,
+ double host_progress_delta = 0.0,
+ string? progress_message = null
+ ) {
+ host.update_import_progress_pane(progress, progress_message);
+ //
+ SortedList<DataImportJob> jobs =
+ new SortedList<DataImportJob>(import_job_comparator);
+ Gee.ArrayList<DataImportJob> already_imported =
+ new Gee.ArrayList<DataImportJob>();
+ Gee.ArrayList<DataImportJob> failed =
+ new Gee.ArrayList<DataImportJob>();
+
+ int item_idx = 0;
+ double item_progress_delta = host_progress_delta / items.length;
+ foreach (ImportableMediaItem src_item in items) {
+ DataImportSource import_source = new DataImportSource(src_item);
+
+ if (!import_source.was_backing_file_found()) {
+ message("Skipping import of %s: backing file not found",
+ import_source.get_filename());
+ failed.add(new DataImportJob(import_source));
+
+ continue;
+ }
+
+ if (import_source.is_already_imported()) {
+ message("Skipping import of %s: checksum detected in library",
+ import_source.get_filename());
+ already_imported.add(new DataImportJob(import_source));
+
+ continue;
+ }
+
+ jobs.add(new DataImportJob(import_source));
+ item_idx++;
+ host.update_import_progress_pane(progress + item_idx * item_progress_delta);
+ }
+
+ if (jobs.size > 0) {
+ // If there it no current import roll, create one to ensure that all
+ // imported items end up in the same roll even if this method is called
+ // several times
+ if (current_import_roll == null)
+ current_import_roll = new BatchImportRoll();
+ string db_name = _("%s Database").printf(host.get_data_importer().get_service().get_pluggable_name());
+ BatchImport batch_import = new BatchImport(jobs, db_name, data_import_reporter,
+ failed, already_imported, null, current_import_roll);
+
+ LibraryWindow.get_app().enqueue_batch_import(batch_import, true);
+ imported_items_count += jobs.size;
+ }
+
+ host.update_import_progress_pane(progress + host_progress_delta);
+ }
+
+ public void finalize_import() {
+ // Send an empty job to the queue to mark the end of the import
+ string db_name = _("%s Database").printf(host.get_data_importer().get_service().get_pluggable_name());
+ BatchImport batch_import = new BatchImport(
+ new Gee.ArrayList<BatchImportJob>(), db_name, data_import_reporter, null, null, null, current_import_roll
+ );
+ LibraryWindow.get_app().enqueue_batch_import(batch_import, true);
+ current_import_roll = null;
+ }
+}
+
+public class ConcreteDataImportsHost : Plugins.StandardHostInterface,
+ Spit.DataImports.PluginHost {
+
+ private Spit.DataImports.DataImporter active_importer = null;
+ private weak DataImportsUI.DataImportsDialog dialog = null;
+ private DataImportsUI.ProgressPane? progress_pane = null;
+ private bool importing_halted = false;
+ private CoreImporter core_importer;
+
+ public ConcreteDataImportsHost(Service service, DataImportsUI.DataImportsDialog dialog) {
+ base(service, "data_imports");
+ this.dialog = dialog;
+
+ this.active_importer = service.create_data_importer(this);
+ this.core_importer = new CoreImporter(this);
+ }
+
+ public DataImporter get_data_importer() {
+ return active_importer;
+ }
+
+ public void start_importing() {
+ if (get_data_importer().is_running())
+ return;
+
+ debug("ConcreteDataImportsHost.start_importing( ): invoked.");
+
+ get_data_importer().start();
+ }
+
+ public void stop_importing() {
+ debug("ConcreteDataImportsHost.stop_importing( ): invoked.");
+
+ if (get_data_importer().is_running())
+ get_data_importer().stop();
+
+ clean_up();
+
+ importing_halted = true;
+ }
+
+ private void clean_up() {
+ progress_pane = null;
+ }
+
+ public void set_button_mode(Spit.DataImports.PluginHost.ButtonMode mode) {
+ if (mode == Spit.DataImports.PluginHost.ButtonMode.CLOSE)
+ dialog.set_close_button_mode();
+ else if (mode == Spit.DataImports.PluginHost.ButtonMode.CANCEL)
+ dialog.set_cancel_button_mode();
+ else
+ error("unrecognized button mode enumeration value");
+ }
+
+ // Pane handling methods
+
+ public void post_error(Error err) {
+ post_error_message(err.message);
+ }
+
+ public void post_error_message(string message) {
+ string msg = _("Importing from %s can't continue because an error occurred:").printf(
+ active_importer.get_service().get_pluggable_name());
+ msg += GLib.Markup.printf_escaped("\n\n<i>%s</i>\n\n", message);
+ msg += _("To try importing from another service, select one from the above menu.");
+
+ dialog.install_pane(new DataImportsUI.StaticMessagePane.with_pango(msg));
+ dialog.set_close_button_mode();
+ dialog.unlock_service();
+
+ get_data_importer().stop();
+
+ // post_error_message( ) tells the active_importer to stop importing and displays a
+ // non-removable error pane that effectively ends the publishing interaction,
+ // so no problem calling clean_up( ) here.
+ clean_up();
+ }
+
+ public void install_dialog_pane(Spit.DataImports.DialogPane pane,
+ Spit.DataImports.PluginHost.ButtonMode button_mode = Spit.DataImports.PluginHost.ButtonMode.CANCEL) {
+ debug("DataImports.PluginHost: install_dialog_pane( ): invoked.");
+
+ if (get_data_importer() == null || (!get_data_importer().is_running()))
+ return;
+
+ dialog.install_pane(pane);
+
+ set_button_mode(button_mode);
+ }
+
+ public void install_static_message_pane(string message,
+ Spit.DataImports.PluginHost.ButtonMode button_mode = Spit.DataImports.PluginHost.ButtonMode.CANCEL) {
+
+ set_button_mode(button_mode);
+
+ dialog.install_pane(new DataImportsUI.StaticMessagePane.with_pango(message));
+ }
+
+ public void install_library_selection_pane(
+ string welcome_message,
+ ImportableLibrary[] discovered_libraries,
+ string? file_select_label
+ ) {
+ if (discovered_libraries.length == 0 && file_select_label == null)
+ post_error_message("Libraries or file option needed");
+ else
+ dialog.install_pane(new DataImportsUI.LibrarySelectionPane(
+ this,
+ welcome_message,
+ discovered_libraries,
+ file_select_label
+ ));
+ set_button_mode(Spit.DataImports.PluginHost.ButtonMode.CLOSE);
+ }
+
+ public void install_import_progress_pane(
+ string message
+ ) {
+ progress_pane = new DataImportsUI.ProgressPane(message);
+ dialog.install_pane(progress_pane);
+ set_button_mode(Spit.DataImports.PluginHost.ButtonMode.CANCEL);
+ // initialize the import
+ core_importer.imported_items_count = 0;
+ core_importer.current_import_roll = null;
+ }
+
+ public void update_import_progress_pane(
+ double progress,
+ string? progress_message = null
+ ) {
+ if (progress_pane != null) {
+ progress_pane.update_progress(progress, progress_message);
+ }
+ }
+
+ public void prepare_media_items_for_import(
+ ImportableMediaItem[] items,
+ double progress,
+ double host_progress_delta = 0.0,
+ string? progress_message = null
+ ) {
+ core_importer.prepare_media_items_for_import(items, progress, host_progress_delta, progress_message);
+ }
+
+ public void finalize_import(
+ ImportedItemsCountCallback report_imported_items_count,
+ string? finalize_message = null
+ ) {
+ update_import_progress_pane(1.0, finalize_message);
+ set_button_mode(Spit.DataImports.PluginHost.ButtonMode.CLOSE);
+ core_importer.finalize_import();
+ report_imported_items_count(core_importer.imported_items_count);
+ if (core_importer.imported_items_count > 0)
+ LibraryWindow.get_app().switch_to_import_queue_page();
+ }
+}
+
+public class WelcomeDataImportsHost : Plugins.StandardHostInterface,
+ Spit.DataImports.PluginHost {
+
+ private weak WelcomeImportMetaHost meta_host;
+ private Spit.DataImports.DataImporter active_importer = null;
+ private bool importing_halted = false;
+ private CoreImporter core_importer;
+
+ public WelcomeDataImportsHost(Service service, WelcomeImportMetaHost meta_host) {
+ base(service, "data_imports");
+
+ this.active_importer = service.create_data_importer(this);
+ this.core_importer = new CoreImporter(this);
+ this.meta_host = meta_host;
+ }
+
+ public DataImporter get_data_importer() {
+ return active_importer;
+ }
+
+ public void start_importing() {
+ if (get_data_importer().is_running())
+ return;
+
+ debug("WelcomeDataImportsHost.start_importing( ): invoked.");
+
+ get_data_importer().start();
+ }
+
+ public void stop_importing() {
+ debug("WelcomeDataImportsHost.stop_importing( ): invoked.");
+
+ if (get_data_importer().is_running())
+ get_data_importer().stop();
+
+ clean_up();
+
+ importing_halted = true;
+ }
+
+ private void clean_up() {
+ }
+
+ // Pane handling methods
+
+ public void post_error(Error err) {
+ post_error_message(err.message);
+ }
+
+ public void post_error_message(string message) {
+ string msg = _("Importing from %s can't continue because an error occurred:").printf(
+ active_importer.get_service().get_pluggable_name());
+
+ debug(msg);
+
+ get_data_importer().stop();
+
+ // post_error_message( ) tells the active_importer to stop importing and displays a
+ // non-removable error pane that effectively ends the publishing interaction,
+ // so no problem calling clean_up( ) here.
+ clean_up();
+ }
+
+ public void install_dialog_pane(Spit.DataImports.DialogPane pane,
+ Spit.DataImports.PluginHost.ButtonMode button_mode = Spit.DataImports.PluginHost.ButtonMode.CANCEL) {
+ // do nothing
+ }
+
+ public void install_static_message_pane(string message,
+ Spit.DataImports.PluginHost.ButtonMode button_mode = Spit.DataImports.PluginHost.ButtonMode.CANCEL) {
+ // do nothing
+ }
+
+ public void install_library_selection_pane(
+ string welcome_message,
+ ImportableLibrary[] discovered_libraries,
+ string? file_select_label
+ ) {
+ debug("WelcomeDataImportsHost: Installing library selection pane for %s".printf(get_data_importer().get_service().get_pluggable_name()));
+ if (discovered_libraries.length > 0) {
+ meta_host.install_service_entry(new WelcomeImportServiceEntry(
+ this,
+ get_data_importer().get_service().get_pluggable_name(),
+ discovered_libraries
+ ));
+ }
+ }
+
+ public void install_import_progress_pane(
+ string message
+ ) {
+ // empty implementation
+ }
+
+ public void update_import_progress_pane(
+ double progress,
+ string? progress_message = null
+ ) {
+ // empty implementation
+ }
+
+ public void prepare_media_items_for_import(
+ ImportableMediaItem[] items,
+ double progress,
+ double host_progress_delta = 0.0,
+ string? progress_message = null
+ ) {
+ core_importer.prepare_media_items_for_import(items, progress, host_progress_delta, progress_message);
+ }
+
+ public void finalize_import(
+ ImportedItemsCountCallback report_imported_items_count,
+ string? finalize_message = null
+ ) {
+ core_importer.finalize_import();
+ report_imported_items_count(core_importer.imported_items_count);
+ meta_host.finalize_import(this);
+ }
+}
+
+
+//public delegate void WelcomeImporterCallback();
+
+public class WelcomeImportServiceEntry : GLib.Object, WelcomeServiceEntry {
+ private string pluggable_name;
+ private ImportableLibrary[] discovered_libraries;
+ private Spit.DataImports.PluginHost host;
+
+ public WelcomeImportServiceEntry(
+ Spit.DataImports.PluginHost host,
+ string pluggable_name, ImportableLibrary[] discovered_libraries) {
+
+ this.host = host;
+ this.pluggable_name = pluggable_name;
+ this.discovered_libraries = discovered_libraries;
+ }
+
+ public string get_service_name() {
+ return pluggable_name;
+ }
+
+ public void execute() {
+ foreach (ImportableLibrary library in discovered_libraries) {
+ host.get_data_importer().on_library_selected(library);
+ }
+ }
+}
+
+public class WelcomeImportMetaHost : GLib.Object {
+ private WelcomeDialog dialog;
+
+ public WelcomeImportMetaHost(WelcomeDialog dialog) {
+ this.dialog = dialog;
+ }
+
+ public void start() {
+ Service[] services = load_all_services();
+ foreach (Service service in services) {
+ WelcomeDataImportsHost host = new WelcomeDataImportsHost(service, this);
+ host.start_importing();
+ }
+ }
+
+ public void finalize_import(WelcomeDataImportsHost host) {
+ host.stop_importing();
+ }
+
+ public void install_service_entry(WelcomeServiceEntry entry) {
+ debug("WelcomeImportMetaHost: Installing service entry for %s".printf(entry.get_service_name()));
+ dialog.install_service_entry(entry);
+ }
+}
+
+public static Spit.DataImports.Service[] load_all_services() {
+ return load_services(true);
+}
+
+public static Spit.DataImports.Service[] load_services(bool load_all = false) {
+ Spit.DataImports.Service[] loaded_services = new Spit.DataImports.Service[0];
+
+ // load publishing services from plug-ins
+ Gee.Collection<Spit.Pluggable> pluggables = Plugins.get_pluggables_for_type(
+ typeof(Spit.DataImports.Service), null, load_all);
+ // TODO: include sorting function to ensure consistent order
+
+ debug("DataImportsDialog: discovered %d pluggable data import services.", pluggables.size);
+
+ foreach (Spit.Pluggable pluggable in pluggables) {
+ int pluggable_interface = pluggable.get_pluggable_interface(
+ Spit.DataImports.CURRENT_INTERFACE, Spit.DataImports.CURRENT_INTERFACE);
+ if (pluggable_interface != Spit.DataImports.CURRENT_INTERFACE) {
+ warning("Unable to load data import plugin %s: reported interface %d.",
+ Plugins.get_pluggable_module_id(pluggable), pluggable_interface);
+
+ continue;
+ }
+
+ Spit.DataImports.Service service =
+ (Spit.DataImports.Service) pluggable;
+
+ debug("DataImportsDialog: discovered pluggable data import service '%s'.",
+ service.get_pluggable_name());
+
+ loaded_services += service;
+ }
+
+ // Sort import services by name.
+ // TODO: extract to a function to sort it on initial request
+ Posix.qsort(loaded_services, loaded_services.length, sizeof(Spit.DataImports.Service),
+ (a, b) => {return utf8_cs_compare((*((Spit.DataImports.Service**) a))->get_pluggable_name(),
+ (*((Spit.DataImports.Service**) b))->get_pluggable_name());
+ });
+
+ return loaded_services;
+}
+
+private ImportManifest? meta_manifest = null;
+
+private void data_import_reporter(ImportManifest manifest, BatchImportRoll import_roll) {
+ if (manifest.all.size > 0) {
+ if (meta_manifest == null)
+ meta_manifest = new ImportManifest();
+ foreach (BatchImportResult result in manifest.all) {
+ meta_manifest.add_result(result);
+ }
+ } else {
+ DataImportsUI.DataImportsDialog.terminate_instance();
+ ImportUI.report_manifest(meta_manifest, true);
+ meta_manifest = null;
+ }
+}
+
+private int64 import_job_comparator(void *a, void *b) {
+ return ((DataImportJob *) a)->get_exposure_time()
+ - ((DataImportJob *) b)->get_exposure_time();
+}
+
+}
+
diff --git a/src/data_imports/DataImportsUI.vala b/src/data_imports/DataImportsUI.vala
new file mode 100644
index 0000000..9b171b1
--- /dev/null
+++ b/src/data_imports/DataImportsUI.vala
@@ -0,0 +1,445 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace DataImportsUI {
+
+internal const string NO_PLUGINS_ENABLED_MESSAGE =
+ _("You do not have any data imports plugins enabled.\n\nIn order to use the Import From Application functionality, you need to have at least one data imports plugin enabled. Plugins can be enabled in the Preferences dialog.");
+
+public class ConcreteDialogPane : Spit.DataImports.DialogPane, GLib.Object {
+ private Gtk.Box pane_widget;
+
+ public ConcreteDialogPane() {
+ pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 8);
+ }
+
+ public Gtk.Widget get_widget() {
+ return pane_widget;
+ }
+
+ public Spit.DataImports.DialogPane.GeometryOptions get_preferred_geometry() {
+ return Spit.DataImports.DialogPane.GeometryOptions.NONE;
+ }
+
+ public void on_pane_installed() {
+ }
+
+ public void on_pane_uninstalled() {
+ }
+}
+
+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);
+ }
+
+ public StaticMessagePane.with_pango(string msg) {
+ Gtk.Label label = new Gtk.Label(null);
+ label.set_markup(msg);
+ label.set_line_wrap(true);
+
+ (get_widget() as Gtk.Box).pack_start(label, true, true, 0);
+ }
+}
+
+public class LibrarySelectionPane : ConcreteDialogPane {
+ private weak Spit.DataImports.PluginHost host;
+ private Spit.DataImports.ImportableLibrary? selected_library = null;
+ private File? selected_file = null;
+ private Gtk.Button import_button;
+ private Gtk.RadioButton? file_radio = null;
+
+ public LibrarySelectionPane(
+ Spit.DataImports.PluginHost host,
+ string welcome_message,
+ Spit.DataImports.ImportableLibrary[] discovered_libraries,
+ string? file_select_label
+ ) {
+ assert(discovered_libraries.length > 0 || on_file_selected != null);
+
+ this.host = host;
+
+ Gtk.Box content_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 8);
+ content_box.set_margin_left(30);
+ content_box.set_margin_right(30);
+ Gtk.Label welcome_label = new Gtk.Label(null);
+ welcome_label.set_markup(welcome_message);
+ welcome_label.set_line_wrap(true);
+ welcome_label.set_halign(Gtk.Align.START);
+ content_box.pack_start(welcome_label, true, true, 6);
+
+ // margins for buttons
+ int radio_margin_left = 20;
+ int radio_margin_right = 20;
+ int chooser_margin_left = radio_margin_left;
+ int chooser_margin_right = radio_margin_right;
+
+ Gtk.RadioButton lib_radio = null;
+ if (discovered_libraries.length > 0) {
+ chooser_margin_left = radio_margin_left + 20;
+ foreach (Spit.DataImports.ImportableLibrary library in discovered_libraries) {
+ string lib_radio_label = library.get_display_name();
+ lib_radio = create_radio_button(
+ content_box, lib_radio, library, lib_radio_label,
+ radio_margin_left, radio_margin_right
+ );
+ }
+ if (file_select_label != null) {
+ lib_radio = create_radio_button(
+ content_box, lib_radio, null, file_select_label,
+ radio_margin_left, radio_margin_right
+ );
+ file_radio = lib_radio;
+ }
+ }
+ if (file_select_label != null) {
+ Gtk.FileChooserButton file_chooser = new Gtk.FileChooserButton(_("Database file:"), Gtk.FileChooserAction.OPEN);
+ file_chooser.selection_changed.connect(() => {
+ selected_file = file_chooser.get_file();
+ if (file_radio != null)
+ file_radio.active = true;
+ set_import_button_sensitivity();
+ });
+ file_chooser.set_margin_left(chooser_margin_left);
+ file_chooser.set_margin_right(chooser_margin_right);
+ content_box.pack_start(file_chooser, false, false, 6);
+ }
+
+ import_button = new Gtk.Button.with_mnemonic(_("_Import"));
+ import_button.clicked.connect(() => {
+ if (selected_library != null)
+ on_library_selected(selected_library);
+ else if (selected_file != null)
+ on_file_selected(selected_file);
+ else
+ debug("LibrarySelectionPane: Library or file should be selected.");
+ });
+ Gtk.ButtonBox button_box = new Gtk.ButtonBox(Gtk.Orientation.HORIZONTAL);
+ button_box.layout_style = Gtk.ButtonBoxStyle.CENTER;
+ 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);
+
+ set_import_button_sensitivity();
+ }
+
+ private Gtk.RadioButton create_radio_button(
+ Gtk.Box box, Gtk.RadioButton? group, Spit.DataImports.ImportableLibrary? library, string label,
+ int margin_left, int margin_right
+ ) {
+ var button = new Gtk.RadioButton.with_label_from_widget (group, label);
+ if (group == null) { // first radio button is active
+ button.active = true;
+ selected_library = library;
+ }
+ button.toggled.connect (() => {
+ if (button.active) {
+ this.selected_library = library;
+ set_import_button_sensitivity();
+ }
+
+ });
+ button.set_margin_left(margin_left);
+ button.set_margin_right(margin_right);
+ box.pack_start(button, false, false, 6);
+ return button;
+ }
+
+ private void set_import_button_sensitivity() {
+ import_button.set_sensitive(selected_library != null || selected_file != null);
+ }
+
+ private void on_library_selected(Spit.DataImports.ImportableLibrary library) {
+ host.get_data_importer().on_library_selected(library);
+ }
+
+ private void on_file_selected(File file) {
+ host.get_data_importer().on_file_selected(file);
+ }
+}
+
+public class ProgressPane : ConcreteDialogPane {
+ private Gtk.Label message_label;
+ private Gtk.Label progress_label;
+ private Gtk.ProgressBar progress_bar;
+
+ public ProgressPane(string message) {
+ Gtk.Box content_box = new Gtk.Box(Gtk.Orientation.VERTICAL, 8);
+ message_label = new Gtk.Label(message);
+ content_box.pack_start(message_label, true, true, 6);
+ progress_bar = new Gtk.ProgressBar();
+ content_box.pack_start(progress_bar, false, true, 6);
+ progress_label = new Gtk.Label("");
+ content_box.pack_start(progress_label, false, true, 6);
+
+ (get_widget() as Gtk.Container).add(content_box);
+ }
+
+ public void update_progress(double progress, string? progress_message) {
+ progress_bar.set_fraction(progress);
+ if (progress_message != null)
+ progress_label.set_label(progress_message);
+ spin_event_loop();
+ }
+}
+
+public class DataImportsDialog : Gtk.Dialog {
+ private const int LARGE_WINDOW_WIDTH = 860;
+ private const int LARGE_WINDOW_HEIGHT = 688;
+ private const int COLOSSAL_WINDOW_WIDTH = 1024;
+ private const int COLOSSAL_WINDOW_HEIGHT = 688;
+ private const int STANDARD_WINDOW_WIDTH = 600;
+ private const int STANDARD_WINDOW_HEIGHT = 510;
+ private const int BORDER_REGION_WIDTH = 16;
+ private const int BORDER_REGION_HEIGHT = 100;
+
+ public const int STANDARD_CONTENT_LABEL_WIDTH = 500;
+ public const int STANDARD_ACTION_BUTTON_WIDTH = 128;
+
+ private Gtk.ComboBoxText service_selector_box;
+ private Gtk.Label service_selector_box_label;
+ private Gtk.Box central_area_layouter;
+ private Gtk.Button close_cancel_button;
+ private Spit.DataImports.DialogPane active_pane;
+ private Spit.DataImports.ConcreteDataImportsHost host;
+
+ protected DataImportsDialog() {
+
+ resizable = false;
+ delete_event.connect(on_window_close);
+
+ string title = _("Import From Application");
+ string label = _("Import media _from:");
+
+ set_title(title);
+
+ Spit.DataImports.Service[] loaded_services = Spit.DataImports.load_services();
+
+ if (loaded_services.length > 0) {
+ // Install the service selector part only if there is at least one
+ // service to select from
+ service_selector_box = new Gtk.ComboBoxText();
+ service_selector_box.set_active(0);
+ service_selector_box_label = new Gtk.Label.with_mnemonic(label);
+ service_selector_box_label.set_mnemonic_widget(service_selector_box);
+ service_selector_box_label.set_alignment(0.0f, 0.5f);
+
+ // get the name of the service the user last used
+ string? last_used_service = Config.Facade.get_instance().get_last_used_dataimports_service();
+
+ int ticker = 0;
+ int last_used_index = -1;
+ foreach (Spit.DataImports.Service service in loaded_services) {
+ string curr_service_id = service.get_id();
+ if (last_used_service != null && last_used_service == curr_service_id)
+ last_used_index = ticker;
+
+ service_selector_box.append_text(service.get_pluggable_name());
+ ticker++;
+ }
+ if (last_used_index >= 0)
+ service_selector_box.set_active(last_used_index);
+ else
+ service_selector_box.set_active(0);
+
+ service_selector_box.changed.connect(on_service_changed);
+
+ /* the wrapper is not an extraneous widget -- it's necessary to prevent the service
+ selection box from growing and shrinking whenever its parent's size changes.
+ When wrapped inside a Gtk.Alignment, the Alignment grows and shrinks instead of
+ the service selection box. */
+ Gtk.Alignment service_selector_box_wrapper = new Gtk.Alignment(1.0f, 0.5f, 0.0f, 0.0f);
+ service_selector_box_wrapper.add(service_selector_box);
+
+ Gtk.Box service_selector_layouter = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ service_selector_layouter.set_border_width(12);
+ service_selector_layouter.add(service_selector_box_label);
+ service_selector_layouter.pack_start(service_selector_box_wrapper, true, true, 0);
+
+ /* 'service area' is the selector assembly plus the horizontal rule dividing it from the
+ rest of the dialog */
+ Gtk.Box service_area_layouter = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ service_area_layouter.pack_start(service_selector_layouter, true, true, 0);
+ Gtk.Separator service_central_separator = new Gtk.Separator(Gtk.Orientation.HORIZONTAL);
+ service_area_layouter.add(service_central_separator);
+
+ Gtk.Alignment service_area_wrapper = new Gtk.Alignment(0.0f, 0.0f, 1.0f, 0.0f);
+ service_area_wrapper.add(service_area_layouter);
+
+ ((Gtk.Box) get_content_area()).pack_start(service_area_wrapper, false, false, 0);
+ }
+
+ // Intall 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);
+
+ close_cancel_button = new Gtk.Button.with_mnemonic("_Cancel");
+ close_cancel_button.set_can_default(true);
+ close_cancel_button.clicked.connect(on_close_cancel_clicked);
+ ((Gtk.Box) get_action_area()).add(close_cancel_button);
+
+ set_standard_window_mode();
+
+ if (loaded_services.length > 0) {
+ // trigger the selected service if at least one service is available
+ on_service_changed();
+ } else {
+ // otherwise, install a message pane advising the user what to do
+ install_pane(new StaticMessagePane.with_pango(NO_PLUGINS_ENABLED_MESSAGE));
+ set_close_button_mode();
+ }
+
+ show_all();
+ }
+
+ public static DataImportsDialog get_or_create_instance() {
+ if (instance == null) {
+ instance = new DataImportsDialog();
+ }
+ return instance;
+ }
+
+ public static void terminate_instance() {
+ if (instance != null) {
+ instance.terminate();
+ }
+ instance = null;
+ }
+
+ private bool on_window_close(Gdk.EventAny evt) {
+ debug("DataImportsDialog: on_window_close( ): invoked.");
+ terminate();
+
+ return true;
+ }
+
+ private void on_service_changed() {
+ debug("DataImportsDialog: on_service_changed invoked.");
+ string service_name = service_selector_box.get_active_text();
+
+ Spit.DataImports.Service? selected_service = null;
+ Spit.DataImports.Service[] services = Spit.DataImports.load_all_services();
+ foreach (Spit.DataImports.Service service in services) {
+ if (service.get_pluggable_name() == service_name) {
+ selected_service = service;
+ break;
+ }
+ }
+ assert(selected_service != null);
+
+ Config.Facade.get_instance().set_last_used_dataimports_service(selected_service.get_id());
+
+ host = new Spit.DataImports.ConcreteDataImportsHost(selected_service, this);
+ host.start_importing();
+ }
+
+ private void on_close_cancel_clicked() {
+ debug("DataImportsDialog: on_close_cancel_clicked( ): invoked.");
+
+ terminate();
+ }
+
+ private void terminate() {
+ debug("DataImportsDialog: terminate( ): invoked.");
+
+ if (host != null) {
+ host.stop_importing();
+ host = null;
+ }
+
+ hide();
+ destroy();
+ instance = null;
+ }
+
+ private void set_large_window_mode() {
+ set_size_request(LARGE_WINDOW_WIDTH, LARGE_WINDOW_HEIGHT);
+ central_area_layouter.set_size_request(LARGE_WINDOW_WIDTH - BORDER_REGION_WIDTH,
+ LARGE_WINDOW_HEIGHT - BORDER_REGION_HEIGHT);
+ resizable = false;
+ }
+
+ private void set_colossal_window_mode() {
+ set_size_request(COLOSSAL_WINDOW_WIDTH, COLOSSAL_WINDOW_HEIGHT);
+ central_area_layouter.set_size_request(COLOSSAL_WINDOW_WIDTH - BORDER_REGION_WIDTH,
+ COLOSSAL_WINDOW_HEIGHT - BORDER_REGION_HEIGHT);
+ resizable = false;
+ }
+
+ private void set_standard_window_mode() {
+ set_size_request(STANDARD_WINDOW_WIDTH, STANDARD_WINDOW_HEIGHT);
+ central_area_layouter.set_size_request(STANDARD_WINDOW_WIDTH - BORDER_REGION_WIDTH,
+ STANDARD_WINDOW_HEIGHT - BORDER_REGION_HEIGHT);
+ resizable = false;
+ }
+
+ private void set_free_sizable_window_mode() {
+ resizable = true;
+ }
+
+ private void clear_free_sizable_window_mode() {
+ resizable = false;
+ }
+
+ public Spit.DataImports.DialogPane get_active_pane() {
+ return active_pane;
+ }
+
+ public void set_close_button_mode() {
+ close_cancel_button.set_label(_("_Close"));
+ set_default(close_cancel_button);
+ }
+
+ public void set_cancel_button_mode() {
+ close_cancel_button.set_label(_("_Cancel"));
+ }
+
+ public void lock_service() {
+ service_selector_box.set_sensitive(false);
+ }
+
+ public void unlock_service() {
+ service_selector_box.set_sensitive(true);
+ }
+
+ public void install_pane(Spit.DataImports.DialogPane pane) {
+ debug("DataImportsDialog: install_pane( ): invoked.");
+
+ if (active_pane != null) {
+ debug("DataImportsDialog: install_pane( ): a pane is already installed; removing it.");
+
+ active_pane.on_pane_uninstalled();
+ central_area_layouter.remove(active_pane.get_widget());
+ }
+
+ central_area_layouter.pack_start(pane.get_widget(), true, true, 0);
+ show_all();
+
+ Spit.DataImports.DialogPane.GeometryOptions geometry_options =
+ pane.get_preferred_geometry();
+ if ((geometry_options & Spit.Publishing.DialogPane.GeometryOptions.EXTENDED_SIZE) != 0)
+ set_large_window_mode();
+ else if ((geometry_options & Spit.Publishing.DialogPane.GeometryOptions.COLOSSAL_SIZE) != 0)
+ set_colossal_window_mode();
+ else
+ set_standard_window_mode();
+
+ if ((geometry_options & Spit.Publishing.DialogPane.GeometryOptions.RESIZABLE) != 0)
+ set_free_sizable_window_mode();
+ else
+ clear_free_sizable_window_mode();
+
+ active_pane = pane;
+ pane.on_pane_installed();
+ }
+
+ private static DataImportsDialog? instance;
+}
+
+}
+
diff --git a/src/data_imports/mk/data_imports.mk b/src/data_imports/mk/data_imports.mk
new file mode 100644
index 0000000..771ba74
--- /dev/null
+++ b/src/data_imports/mk/data_imports.mk
@@ -0,0 +1,31 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := DataImports
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := data_imports
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ DataImportsPluginHost.vala \
+ DataImportsUI.vala \
+ DataImportJob.vala \
+ DataImportSource.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/db/DatabaseTable.vala b/src/db/DatabaseTable.vala
new file mode 100644
index 0000000..55d440d
--- /dev/null
+++ b/src/db/DatabaseTable.vala
@@ -0,0 +1,384 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public errordomain DatabaseError {
+ ERROR,
+ BACKING,
+ MEMORY,
+ ABORT,
+ LIMITS,
+ TYPESPEC
+}
+
+public abstract class DatabaseTable {
+ /***
+ * This number should be incremented every time any database schema is altered.
+ *
+ * NOTE: Adding or removing tables or removing columns do not need a new schema version, because
+ * 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;
+
+ protected static Sqlite.Database db;
+
+ private static int in_transaction = 0;
+
+ public string table_name = null;
+
+ private static void prepare_db(string filename) {
+ // Open DB.
+ int res = Sqlite.Database.open_v2(filename, out db, Sqlite.OPEN_READWRITE | Sqlite.OPEN_CREATE,
+ null);
+ if (res != Sqlite.OK)
+ AppWindow.panic(_("Unable to open/create photo database %s: error code %d").printf(filename,
+ res));
+
+ // Check if we have write access to database.
+ if (filename != Db.IN_MEMORY_NAME) {
+ try {
+ File file_db = File.new_for_path(filename);
+ FileInfo info = file_db.query_info(FileAttribute.ACCESS_CAN_WRITE, FileQueryInfoFlags.NONE);
+ if (!info.get_attribute_boolean(FileAttribute.ACCESS_CAN_WRITE))
+ AppWindow.panic(_("Unable to write to photo database file:\n %s").printf(filename));
+ } catch (Error e) {
+ AppWindow.panic(_("Error accessing database file:\n %s\n\nError was: \n%s").printf(filename,
+ e.message));
+ }
+ }
+ }
+
+ public static void init(string filename) {
+ // Open DB.
+ prepare_db(filename);
+
+ // Try a query to make sure DB is intact; if not, try to use the backup
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS VersionTable ("
+ + "id INTEGER PRIMARY KEY, "
+ + "schema_version INTEGER, "
+ + "app_version TEXT, "
+ + "user_data TEXT NULL"
+ + ")", -1, out stmt);
+
+ // Query on db failed, copy over backup and open it
+ if(res != Sqlite.OK) {
+ db = null;
+
+ string backup_path = filename + ".bak";
+ string cmdline = "cp " + backup_path + " " + filename;
+ Posix.system(cmdline);
+
+ prepare_db(filename);
+ }
+
+ // disable synchronized commits for performance reasons ... this is not vital, hence we
+ // don't error out if this fails
+ res = db.exec("PRAGMA synchronous=OFF");
+ if (res != Sqlite.OK)
+ warning("Unable to disable synchronous mode", res);
+ }
+
+ public static void terminate() {
+ // freeing the database closes it
+ db = null;
+ }
+
+ // XXX: errmsg() is global, and so this will not be accurate in a threaded situation
+ protected static void fatal(string op, int res) {
+ error("%s: [%d] %s", op, res, db.errmsg());
+ }
+
+ // XXX: errmsg() is global, and so this will not be accurate in a threaded situation
+ protected static void warning(string op, int res) {
+ GLib.warning("%s: [%d] %s", op, res, db.errmsg());
+ }
+
+ protected void set_table_name(string table_name) {
+ this.table_name = table_name;
+ }
+
+ // This method will throw an error on an SQLite return code unless it's OK, DONE, or ROW, which
+ // are considered normal results.
+ protected static void throw_error(string method, int res) throws DatabaseError {
+ string msg = "(%s) [%d] - %s".printf(method, res, db.errmsg());
+
+ switch (res) {
+ case Sqlite.OK:
+ case Sqlite.DONE:
+ case Sqlite.ROW:
+ return;
+
+ case Sqlite.PERM:
+ case Sqlite.BUSY:
+ case Sqlite.READONLY:
+ case Sqlite.IOERR:
+ case Sqlite.CORRUPT:
+ case Sqlite.CANTOPEN:
+ case Sqlite.NOLFS:
+ case Sqlite.AUTH:
+ case Sqlite.FORMAT:
+ case Sqlite.NOTADB:
+ throw new DatabaseError.BACKING(msg);
+
+ case Sqlite.NOMEM:
+ throw new DatabaseError.MEMORY(msg);
+
+ case Sqlite.ABORT:
+ case Sqlite.LOCKED:
+ case Sqlite.INTERRUPT:
+ throw new DatabaseError.ABORT(msg);
+
+ case Sqlite.FULL:
+ case Sqlite.EMPTY:
+ case Sqlite.TOOBIG:
+ case Sqlite.CONSTRAINT:
+ case Sqlite.RANGE:
+ throw new DatabaseError.LIMITS(msg);
+
+ case Sqlite.SCHEMA:
+ case Sqlite.MISMATCH:
+ throw new DatabaseError.TYPESPEC(msg);
+
+ case Sqlite.ERROR:
+ case Sqlite.INTERNAL:
+ case Sqlite.MISUSE:
+ default:
+ throw new DatabaseError.ERROR(msg);
+ }
+ }
+
+ protected bool exists_by_id(int64 id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM %s WHERE id=?".printf(table_name), -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.ROW && res != Sqlite.DONE)
+ fatal("exists_by_id [%s] %s".printf(id.to_string(), table_name), res);
+
+ return (res == Sqlite.ROW);
+ }
+
+ protected bool select_by_id(int64 id, string columns, out Sqlite.Statement stmt) {
+ string sql = "SELECT %s FROM %s WHERE id=?".printf(columns, table_name);
+
+ int res = db.prepare_v2(sql, -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.ROW && res != Sqlite.DONE)
+ fatal("select_by_id [%s] %s %s".printf(id.to_string(), table_name, columns), res);
+
+ return (res == Sqlite.ROW);
+ }
+
+ // Caller needs to bind value #1 before calling execute_update_by_id()
+ private void prepare_update_by_id(int64 id, string column, out Sqlite.Statement stmt) {
+ string sql = "UPDATE %s SET %s=? WHERE id=?".printf(table_name, column);
+
+ int res = db.prepare_v2(sql, -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(2, id);
+ assert(res == Sqlite.OK);
+ }
+
+ private bool execute_update_by_id(Sqlite.Statement stmt) {
+ int res = stmt.step();
+ if (res != Sqlite.DONE) {
+ fatal("execute_update_by_id", res);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ protected bool update_text_by_id(int64 id, string column, string text) {
+ Sqlite.Statement stmt;
+ prepare_update_by_id(id, column, out stmt);
+
+ int res = stmt.bind_text(1, text);
+ assert(res == Sqlite.OK);
+
+ return execute_update_by_id(stmt);
+ }
+
+ protected void update_text_by_id_2(int64 id, string column, string text) throws DatabaseError {
+ Sqlite.Statement stmt;
+ prepare_update_by_id(id, column, out stmt);
+
+ int res = stmt.bind_text(1, text);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("DatabaseTable.update_text_by_id_2 %s.%s".printf(table_name, column), res);
+ }
+
+ protected bool update_int_by_id(int64 id, string column, int value) {
+ Sqlite.Statement stmt;
+ prepare_update_by_id(id, column, out stmt);
+
+ int res = stmt.bind_int(1, value);
+ assert(res == Sqlite.OK);
+
+ return execute_update_by_id(stmt);
+ }
+
+ protected void update_int_by_id_2(int64 id, string column, int value) throws DatabaseError {
+ Sqlite.Statement stmt;
+ prepare_update_by_id(id, column, out stmt);
+
+ int res = stmt.bind_int(1, value);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("DatabaseTable.update_int_by_id_2 %s.%s".printf(table_name, column), res);
+ }
+
+ protected bool update_int64_by_id(int64 id, string column, int64 value) {
+ Sqlite.Statement stmt;
+ prepare_update_by_id(id, column, out stmt);
+
+ int res = stmt.bind_int64(1, value);
+ assert(res == Sqlite.OK);
+
+ return execute_update_by_id(stmt);
+ }
+
+ protected void update_int64_by_id_2(int64 id, string column, int64 value) throws DatabaseError {
+ Sqlite.Statement stmt;
+ prepare_update_by_id(id, column, out stmt);
+
+ int res = stmt.bind_int64(1, value);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("DatabaseTable.update_int64_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);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("%s.remove".printf(table_name), res);
+ }
+
+ public static bool has_column(string table_name, string column_name) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("PRAGMA table_info(%s)".printf(table_name), -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ fatal("has_column %s".printf(table_name), res);
+
+ break;
+ } else {
+ string column = stmt.column_text(1);
+ if (column != null && column == column_name)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static bool has_table(string table_name) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("PRAGMA table_info(%s)".printf(table_name), -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+
+ return (res != Sqlite.DONE);
+ }
+
+ public static bool add_column(string table_name, string column_name, string column_constraints) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("ALTER TABLE %s ADD COLUMN %s %s".printf(table_name, column_name,
+ column_constraints), -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ critical("Unable to add column %s %s %s: (%d) %s", table_name, column_name, column_constraints,
+ res, db.errmsg());
+
+ return false;
+ }
+
+ return true;
+ }
+
+ // This method will only add the column if a table exists (relying on the table object
+ // to build a new one when first referenced) and only if the column does not exist. In essence,
+ // it's a cleaner way to run has_table(), has_column(), and add_column().
+ public static bool ensure_column(string table_name, string column_name, string column_constraints,
+ string upgrade_msg) {
+ if (!has_table(table_name) || has_column(table_name, column_name))
+ return true;
+
+ message("%s", upgrade_msg);
+
+ return add_column(table_name, column_name, column_constraints);
+ }
+
+ public int get_row_count() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT COUNT(id) AS RowCount FROM %s".printf(table_name), -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.ROW) {
+ critical("Unable to retrieve row count on %s: (%d) %s", table_name, res, db.errmsg());
+
+ return 0;
+ }
+
+ return stmt.column_int(0);
+ }
+
+ // This is not thread-safe.
+ public static void begin_transaction() {
+ if (in_transaction++ != 0)
+ return;
+
+ int res = db.exec("BEGIN TRANSACTION");
+ assert(res == Sqlite.OK);
+ }
+
+ // This is not thread-safe.
+ public static void commit_transaction() throws DatabaseError {
+ assert(in_transaction > 0);
+ if (--in_transaction != 0)
+ return;
+
+ int res = db.exec("COMMIT TRANSACTION");
+ if (res != Sqlite.DONE)
+ throw_error("commit_transaction", res);
+ }
+}
+
diff --git a/src/db/Db.vala b/src/db/Db.vala
new file mode 100644
index 0000000..ced530a
--- /dev/null
+++ b/src/db/Db.vala
@@ -0,0 +1,366 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Db {
+
+public static const string IN_MEMORY_NAME = ":memory:";
+
+private string? filename = null;
+
+// Passing null as the db_file will create an in-memory, non-persistent database.
+public void preconfigure(File? db_file) {
+ filename = (db_file != null) ? db_file.get_path() : IN_MEMORY_NAME;
+}
+
+public void init() throws Error {
+ assert(filename != null);
+
+ DatabaseTable.init(filename);
+}
+
+public void terminate() {
+ DatabaseTable.terminate();
+}
+
+public enum VerifyResult {
+ OK,
+ FUTURE_VERSION,
+ UPGRADE_ERROR,
+ NO_UPGRADE_AVAILABLE
+}
+
+public VerifyResult verify_database(out string app_version, out int schema_version) {
+ VersionTable version_table = VersionTable.get_instance();
+ schema_version = version_table.get_version(out app_version);
+
+ if (schema_version >= 0)
+ debug("Database schema version %d created by app version %s", schema_version, app_version);
+
+ if (schema_version == -1) {
+ // no version set, do it now (tables will be created on demand)
+ debug("Creating database schema version %d for app version %s", DatabaseTable.SCHEMA_VERSION,
+ Resources.APP_VERSION);
+ version_table.set_version(DatabaseTable.SCHEMA_VERSION, Resources.APP_VERSION);
+ app_version = Resources.APP_VERSION;
+ schema_version = DatabaseTable.SCHEMA_VERSION;
+ } else if (schema_version > DatabaseTable.SCHEMA_VERSION) {
+ // Back to the future
+ return Db.VerifyResult.FUTURE_VERSION;
+ } else if (schema_version < DatabaseTable.SCHEMA_VERSION) {
+ // Past is present
+ VerifyResult result = upgrade_database(schema_version);
+ if (result != VerifyResult.OK)
+ return result;
+ }
+
+ return VerifyResult.OK;
+}
+
+private VerifyResult upgrade_database(int input_version) {
+ assert(input_version < DatabaseTable.SCHEMA_VERSION);
+
+ int version = input_version;
+
+ // No upgrade available from version 1.
+ if (version == 1)
+ return VerifyResult.NO_UPGRADE_AVAILABLE;
+
+ message("Upgrading database from schema version %d to %d", version, DatabaseTable.SCHEMA_VERSION);
+
+ //
+ // Version 2: For all intents and purposes, the baseline schema version.
+ // * Removed start_time and end_time from EventsTable
+ //
+
+ //
+ // Version 3:
+ // * Added flags column to PhotoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "flags")) {
+ message("upgrade_database: adding flags column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "flags", "INTEGER DEFAULT 0"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 3;
+
+ //
+ // ThumbnailTable(s) removed.
+ //
+
+ //
+ // Version 4:
+ // * Added file_format column to PhotoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "file_format")) {
+ message("upgrade_database: adding file_format column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "file_format", "INTEGER DEFAULT 0"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 4;
+
+ //
+ // Version 5:
+ // * Added title column to PhotoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "title")) {
+ message("upgrade_database: adding title column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "title", "TEXT"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 5;
+
+ //
+ // Version 6:
+ // * Added backlinks column to PhotoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "backlinks")) {
+ message("upgrade_database: adding backlinks column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "backlinks", "TEXT"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 6;
+
+ //
+ // * Ignore the exif_md5 column from PhotoTable. Because removing columns with SQLite is
+ // painful, simply ignoring the column for now. Keeping it up-to-date when possible in
+ // case a future requirement is discovered.
+ //
+
+ //
+ // Version 7:
+ // * Added BackingPhotoTable (which creates itself if needed)
+ // * Added time_reimported and editable_id columns to PhotoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "time_reimported")) {
+ message("upgrade_database: adding time_reimported column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "time_reimported", "INTEGER"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ if (!DatabaseTable.has_column("PhotoTable", "editable_id")) {
+ message("upgrade_database: adding editable_id column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "editable_id", "INTEGER DEFAULT -1"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 7;
+
+ //
+ // * Ignore the orientation column in BackingPhotoTable. (See note above about removing
+ // columns from tables.)
+ //
+
+ //
+ // Version 8:
+ // * Added rating column to PhotoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "rating")) {
+ message("upgrade_database: adding rating column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "rating", "INTEGER DEFAULT 0"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ //
+ // Version 9:
+ // * Added metadata_dirty flag to PhotoTable. Default to 1 rather than 0 on upgrades so
+ // changes to metadata prior to upgrade will be caught by MetadataWriter.
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "metadata_dirty")) {
+ message("upgrade_database: adding metadata_dirty column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "metadata_dirty", "INTEGER DEFAULT 1"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 9;
+
+ //
+ // Version 10:
+ // * Added flags column to VideoTable
+ //
+
+ if (DatabaseTable.has_table("VideoTable") && !DatabaseTable.has_column("VideoTable", "flags")) {
+ message("upgrade_database: adding flags column to VideoTable");
+ if (!DatabaseTable.add_column("VideoTable", "flags", "INTEGER DEFAULT 0"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 10;
+
+ //
+ // Version 11:
+ // * Added primary_source_id column to EventTable
+ //
+
+ if (!DatabaseTable.has_column("EventTable", "primary_source_id")) {
+ message("upgrade_database: adding primary_source_id column to EventTable");
+ if (!DatabaseTable.add_column("EventTable", "primary_source_id", "INTEGER DEFAULT 0"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 11;
+
+ //
+ // Version 12:
+ // * Added reason column to TombstoneTable
+ //
+
+ if (!DatabaseTable.ensure_column("TombstoneTable", "reason", "INTEGER DEFAULT 0",
+ "upgrade_database: adding reason column to TombstoneTable")) {
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 12;
+
+ //
+ // Version 13:
+ // * Added RAW development columns to Photo table.
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "developer")) {
+ message("upgrade_database: adding developer column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "developer", "TEXT"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ if (!DatabaseTable.has_column("PhotoTable", "develop_shotwell_id")) {
+ message("upgrade_database: adding develop_shotwell_id column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "develop_shotwell_id", "INTEGER DEFAULT -1"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ if (!DatabaseTable.has_column("PhotoTable", "develop_camera_id")) {
+ message("upgrade_database: adding develop_camera_id column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "develop_camera_id", "INTEGER DEFAULT -1"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ if (!DatabaseTable.has_column("PhotoTable", "develop_embedded_id")) {
+ message("upgrade_database: adding develop_embedded_id column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "develop_embedded_id", "INTEGER DEFAULT -1"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 13;
+
+ //
+ // Version 14:
+ // * Upgrades tag names in the TagTable for hierarchical tag support
+ //
+
+ if (input_version < 14)
+ TagTable.upgrade_for_htags();
+
+ version = 14;
+
+ //
+ // Version 15:
+ // * Upgrades the version number to prevent Shotwell 0.11 users from opening
+ // Shotwell 0.12 databases. While the database schema hasn't changed,
+ // straighten was only partially implemented in 0.11 but is fully
+ // implemented in 0.12, so when 0.11 users open an 0.12 database with
+ // straightening information, they see partially and/or incorrectly
+ // rotated photos.
+ //
+
+ version = 15;
+
+ //
+ // Version 16:
+ // * Migration of dconf settings data from /apps/shotwell to /org/yorba/shotwell.
+ //
+ // The database itself doesn't change; this is to force the path migration to
+ // occur.
+ //
+
+ if (input_version < 16) {
+ // Run the settings migrator to copy settings data from /apps/shotwell to /org/yorba/shotwell.
+ // Please see https://mail.gnome.org/archives/desktop-devel-list/2011-February/msg00064.html
+ GSettingsConfigurationEngine.run_gsettings_migrator();
+ }
+
+ version = 16;
+
+ //
+ // Version 17:
+ // * Added comment column to PhotoTable and VideoTable
+ //
+
+ if (!DatabaseTable.has_column("PhotoTable", "comment")) {
+ message("upgrade_database: adding comment column to PhotoTable");
+ if (!DatabaseTable.add_column("PhotoTable", "comment", "TEXT"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+ if (!DatabaseTable.has_column("VideoTable", "comment")) {
+ message("upgrade_database: adding comment column to VideoTable");
+ if (!DatabaseTable.add_column("VideoTable", "comment", "TEXT"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 17;
+
+ //
+ // Version 18:
+ // * Added comment column to EventTable
+ //
+
+ if (!DatabaseTable.has_column("EventTable", "comment")) {
+ message("upgrade_database: adding comment column to EventTable");
+ if (!DatabaseTable.add_column("EventTable", "comment", "TEXT"))
+ return VerifyResult.UPGRADE_ERROR;
+ }
+
+ version = 18;
+
+ //
+ // Version 19:
+ // * Deletion and regeneration of camera-raw thumbnails from previous versions,
+ // since they're likely to be incorrect.
+ //
+ // The database itself doesn't change; this is to force the thumbnail fixup to
+ // occur.
+ //
+
+ if (input_version < 19) {
+ Application.get_instance().set_raw_thumbs_fix_required(true);
+ }
+
+ version = 19;
+
+ //
+ // Version 20:
+ // * No change to database schema but fixing issue #6541 ("Saved searches should be aware of
+ // comments") added a new enumeration value that is stored in the SavedSearchTable. The
+ // presence of this heretofore unseen enumeration value will cause prior versions of
+ // Shotwell to yarf, so we bump the version here to ensure this doesn't happen
+ //
+
+ version = 20;
+
+ //
+ // Finalize the upgrade process
+ //
+
+ assert(version == DatabaseTable.SCHEMA_VERSION);
+ VersionTable.get_instance().update_version(version, Resources.APP_VERSION);
+
+ message("Database upgrade to schema version %d successful", version);
+
+ return VerifyResult.OK;
+}
+
+}
+
diff --git a/src/db/EventTable.vala b/src/db/EventTable.vala
new file mode 100644
index 0000000..016fa00
--- /dev/null
+++ b/src/db/EventTable.vala
@@ -0,0 +1,235 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public struct EventID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public EventID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+}
+
+public class EventRow {
+ public EventID event_id;
+ public string? name;
+ public time_t time_created;
+ public string? primary_source_id;
+ public string? comment;
+}
+
+public class EventTable : DatabaseTable {
+ private static EventTable instance = null;
+
+ private EventTable() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS EventTable ("
+ + "id INTEGER PRIMARY KEY, "
+ + "name TEXT, "
+ + "primary_photo_id INTEGER, "
+ + "time_created INTEGER,"
+ + "primary_source_id TEXT,"
+ + "comment TEXT"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create photo table", res);
+
+ set_table_name("EventTable");
+ }
+
+ public static EventTable get_instance() {
+ if (instance == null)
+ instance = new EventTable();
+
+ return instance;
+ }
+
+ // Returns a valid source ID, creating one from a legacy primary photo ID when needed.
+ private string? source_id_upgrade(int64 primary_photo_id, string? primary_source_id) {
+ if (MediaCollectionRegistry.get_instance().is_valid_source_id(primary_source_id)) {
+ return primary_source_id;
+ }
+ if (primary_photo_id != PhotoID.INVALID) {
+ // Upgrade to source_id from photo_id.
+ return PhotoID.upgrade_photo_id_to_source_id(PhotoID(primary_photo_id));
+ }
+ return null;
+ }
+
+ public EventRow create(string? primary_source_id, string? comment) throws DatabaseError {
+ assert(primary_source_id != null && primary_source_id != "");
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "INSERT INTO EventTable (primary_source_id, time_created, comment) VALUES (?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ time_t time_created = (time_t) now_sec();
+
+ res = stmt.bind_text(1, primary_source_id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, time_created);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(3, comment);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("EventTable.create", res);
+
+ EventRow row = new EventRow();
+ row.event_id = EventID(db.last_insert_rowid());
+ row.name = null;
+ row.primary_source_id = primary_source_id;
+ row.time_created = time_created;
+ row.comment = comment;
+
+ return row;
+ }
+
+ // NOTE: The event_id in EventRow is ignored here. No checking is done to prevent
+ // against creating duplicate events or for the validity of other fields in the row (i.e.
+ // the primary photo ID).
+ public EventID create_from_row(EventRow row) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO EventTable (name, primary_photo_id, primary_source_id, time_created, comment) VALUES (?, ?, ?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, row.name);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, PhotoID.INVALID);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(3, row.primary_source_id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(4, row.time_created);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(5, row.comment);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ fatal("Event create_from_row", res);
+
+ return EventID();
+ }
+
+ return EventID(db.last_insert_rowid());
+ }
+
+ public EventRow? get_row(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "SELECT name, primary_photo_id, primary_source_id, time_created, comment FROM EventTable WHERE id=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, event_id.id);
+ assert(res == Sqlite.OK);
+
+ if (stmt.step() != Sqlite.ROW)
+ return null;
+
+ EventRow row = new EventRow();
+ row.event_id = event_id;
+ row.name = stmt.column_text(0);
+ 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.comment = stmt.column_text(4);
+
+ return row;
+ }
+
+ public void remove(EventID event_id) throws DatabaseError {
+ delete_by_id(event_id.id);
+ }
+
+ public Gee.ArrayList<EventRow?> get_events() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id, name, primary_photo_id, primary_source_id, time_created, comment FROM EventTable",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<EventRow?> event_rows = new Gee.ArrayList<EventRow?>();
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ fatal("get_events", res);
+
+ break;
+ }
+
+ EventRow row = new EventRow();
+
+ 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.comment = stmt.column_text(5);
+
+ event_rows.add(row);
+ }
+
+ return event_rows;
+ }
+
+ public bool rename(EventID event_id, string? name) {
+ return update_text_by_id(event_id.id, "name", name != null ? name : "");
+ }
+
+ public string? get_name(EventID event_id) {
+ Sqlite.Statement stmt;
+ if (!select_by_id(event_id.id, "name", out stmt))
+ return null;
+
+ string name = stmt.column_text(0);
+
+ return (name != null && name.length > 0) ? name : null;
+ }
+
+ public string? get_primary_source_id(EventID event_id) {
+ Sqlite.Statement stmt;
+ if (!select_by_id(event_id.id, "primary_source_id", out stmt))
+ return null;
+
+ return stmt.column_text(0);
+ }
+
+ public bool set_primary_source_id(EventID event_id, string primary_source_id) {
+ return update_text_by_id(event_id.id, "primary_source_id", primary_source_id);
+ }
+
+ public time_t get_time_created(EventID event_id) {
+ Sqlite.Statement stmt;
+ if (!select_by_id(event_id.id, "time_created", out stmt))
+ return 0;
+
+ return (time_t) stmt.column_int64(0);
+ }
+
+ public bool set_comment(EventID event_id, string new_comment) {
+ return update_text_by_id(event_id.id, "comment", new_comment != null ? new_comment : "");
+ }
+
+}
+
+
diff --git a/src/db/PhotoTable.vala b/src/db/PhotoTable.vala
new file mode 100644
index 0000000..9891fe6
--- /dev/null
+++ b/src/db/PhotoTable.vala
@@ -0,0 +1,1245 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public struct PhotoID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public PhotoID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+
+ public uint hash() {
+ return int64_hash(id);
+ }
+
+ public static bool equal(void *a, void *b) {
+ return ((PhotoID *) a)->id == ((PhotoID *) b)->id;
+ }
+
+ public static string upgrade_photo_id_to_source_id(PhotoID photo_id) {
+ return ("%s%016" + int64.FORMAT_MODIFIER + "x").printf(Photo.TYPENAME, photo_id.id);
+ }
+}
+
+public struct ImportID {
+ public const int64 INVALID = 0;
+
+ public int64 id;
+
+ public ImportID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public static ImportID generate() {
+ TimeVal timestamp = TimeVal();
+ timestamp.get_current_time();
+ int64 id = timestamp.tv_sec;
+
+ return ImportID(id);
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+
+ public static int compare_func(ImportID? a, ImportID? b) {
+ assert (a != null && b != null);
+ return (int) (a.id - b.id);
+ }
+
+ public static int64 comparator(void *a, void *b) {
+ return ((ImportID *) a)->id - ((ImportID *) b)->id;
+ }
+}
+
+public class PhotoRow {
+ public PhotoID photo_id;
+ public BackingPhotoRow master;
+ public time_t exposure_time;
+ public ImportID import_id;
+ public EventID event_id;
+ public Orientation orientation;
+ public Gee.HashMap<string, KeyValueMap>? transformations;
+ public string md5;
+ public string thumbnail_md5;
+ public string exif_md5;
+ public time_t time_created;
+ public uint64 flags;
+ public Rating rating;
+ public string title;
+ public string comment;
+ public string? backlinks;
+ public time_t time_reimported;
+ public BackingPhotoID editable_id;
+ public bool metadata_dirty;
+
+ // Currently selected developer (RAW only)
+ public RawDeveloper developer;
+
+ // Currently selected developer (RAW only)
+ public BackingPhotoID[] development_ids;
+
+
+ public PhotoRow() {
+ master = new BackingPhotoRow();
+ editable_id = BackingPhotoID();
+ development_ids = new BackingPhotoID[RawDeveloper.as_array().length];
+ foreach (RawDeveloper d in RawDeveloper.as_array())
+ development_ids[d] = BackingPhotoID();
+ }
+}
+
+public class PhotoTable : DatabaseTable {
+ private static PhotoTable instance = null;
+
+ private PhotoTable() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS PhotoTable ("
+ + "id INTEGER PRIMARY KEY, "
+ + "filename TEXT UNIQUE NOT NULL, "
+ + "width INTEGER, "
+ + "height INTEGER, "
+ + "filesize INTEGER, "
+ + "timestamp INTEGER, "
+ + "exposure_time INTEGER, "
+ + "orientation INTEGER, "
+ + "original_orientation INTEGER, "
+ + "import_id INTEGER, "
+ + "event_id INTEGER, "
+ + "transformations TEXT, "
+ + "md5 TEXT, "
+ + "thumbnail_md5 TEXT, "
+ + "exif_md5 TEXT, "
+ + "time_created INTEGER, "
+ + "flags INTEGER DEFAULT 0, "
+ + "rating INTEGER DEFAULT 0, "
+ + "file_format INTEGER DEFAULT 0, "
+ + "title TEXT, "
+ + "backlinks TEXT, "
+ + "time_reimported INTEGER, "
+ + "editable_id INTEGER DEFAULT -1, "
+ + "metadata_dirty INTEGER DEFAULT 0, "
+ + "developer TEXT, "
+ + "develop_shotwell_id INTEGER DEFAULT -1, "
+ + "develop_camera_id INTEGER DEFAULT -1, "
+ + "develop_embedded_id INTEGER DEFAULT -1, "
+ + "comment TEXT"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create photo table", res);
+
+ // index on event_id
+ Sqlite.Statement stmt2;
+ int res2 = db.prepare_v2("CREATE INDEX IF NOT EXISTS PhotoEventIDIndex ON PhotoTable (event_id)",
+ -1, out stmt2);
+ assert(res2 == Sqlite.OK);
+
+ res2 = stmt2.step();
+ if (res2 != Sqlite.DONE)
+ fatal("create photo table", res2);
+
+ set_table_name("PhotoTable");
+ }
+
+ public static PhotoTable get_instance() {
+ if (instance == null)
+ instance = new PhotoTable();
+
+ return instance;
+ }
+
+ // PhotoRow.photo_id, event_id, master.orientation, flags, and time_created are ignored on input.
+ // All fields are set on exit with values stored in the database. editable_id field is ignored.
+ public PhotoID add(PhotoRow photo_row) {
+ Sqlite.Statement stmt;
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ ulong time_created = now_sec();
+
+ res = stmt.bind_text(1, photo_row.master.filepath);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(2, photo_row.master.dim.width);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(3, photo_row.master.dim.height);
+ 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);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(6, photo_row.exposure_time);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(7, photo_row.master.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(8, photo_row.master.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(9, photo_row.import_id.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(10, EventID.INVALID);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(11, photo_row.md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(12, photo_row.thumbnail_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(13, photo_row.exif_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(14, time_created);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(15, photo_row.master.file_format.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(16, photo_row.title);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(17, photo_row.rating.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(18, BackingPhotoID.INVALID);
+ 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);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ if (res != Sqlite.CONSTRAINT)
+ fatal("add_photo", res);
+
+ return PhotoID();
+ }
+
+ // fill in ignored fields with database values
+ 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.flags = 0;
+
+ return photo_row.photo_id;
+ }
+
+ // The only fields recognized in the PhotoRow are photo_id, dimensions,
+ // filesize, timestamp, exposure_time, original_orientation, file_format,
+ // and the md5 fields. When the method returns, time_reimported and master.orientation has been
+ // updated. editable_id is ignored. transformations are untouched; use
+ // remove_all_transformations() if necessary.
+ public void reimport(PhotoRow row) throws DatabaseError {
+ Sqlite.Statement stmt;
+ 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 = ? "
+ + "WHERE id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ time_t time_reimported = (time_t) now_sec();
+
+ res = stmt.bind_int(1, row.master.dim.width);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(2, row.master.dim.height);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(3, row.master.filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(4, row.master.timestamp);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(5, row.exposure_time);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(6, row.master.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(7, row.master.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(8, row.md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(9, row.exif_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(10, row.thumbnail_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(11, row.master.file_format.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(12, row.title);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(13, time_reimported);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(14, row.photo_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("PhotoTable.reimport_master", res);
+
+ row.time_reimported = time_reimported;
+ row.orientation = row.master.original_orientation;
+ }
+
+ public bool master_exif_updated(PhotoID photoID, int64 filesize, long timestamp,
+ string md5, string? exif_md5, string? thumbnail_md5, PhotoRow row) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "UPDATE PhotoTable SET filesize = ?, timestamp = ?, md5 = ?, exif_md5 = ?,"
+ + "thumbnail_md5 =? WHERE id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, timestamp);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(3, md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(4, exif_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(5, thumbnail_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(6, photoID.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ if (res != Sqlite.CONSTRAINT)
+ fatal("write_update_photo", res);
+
+ return false;
+ }
+
+ row.master.filesize = filesize;
+ row.master.timestamp = timestamp;
+ row.md5 = md5;
+ row.exif_md5 = exif_md5;
+ row.thumbnail_md5 = thumbnail_md5;
+
+ return true;
+ }
+
+ // Force corrupted orientations to a safe value.
+ //
+ // In previous versions of Shotwell, this field could be written to
+ // 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
+ // https://bugzilla.gnome.org/show_bug.cgi?id=663818 .
+ private void validate_orientation(PhotoRow row) {
+ if ((row.orientation < Orientation.MIN) ||
+ (row.orientation > Orientation.MAX)) {
+ // orientation was corrupted; set it to top left.
+ set_orientation(row.photo_id, Orientation.MIN);
+ row.orientation = Orientation.MIN;
+ }
+ }
+
+ public PhotoRow? get_row(PhotoID photo_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "SELECT 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, backlinks, "
+ + "time_reimported, editable_id, metadata_dirty, developer, develop_shotwell_id, "
+ + "develop_camera_id, develop_embedded_id, comment "
+ + "FROM PhotoTable WHERE id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, photo_id.id);
+ assert(res == Sqlite.OK);
+
+ if (stmt.step() != Sqlite.ROW)
+ return null;
+
+ PhotoRow row = new PhotoRow();
+ row.photo_id = photo_id;
+ 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.orientation = (Orientation) stmt.column_int(6);
+ row.master.original_orientation = (Orientation) stmt.column_int(7);
+ row.import_id.id = stmt.column_int64(8);
+ row.event_id.id = stmt.column_int64(9);
+ row.transformations = marshall_all_transformations(stmt.column_text(10));
+ 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.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.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)) :
+ RawDeveloper.CAMERA;
+ 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);
+
+ return row;
+ }
+
+ public Gee.ArrayList<PhotoRow?> get_all() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "SELECT id, 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, backlinks, time_reimported, "
+ + "editable_id, metadata_dirty, developer, develop_shotwell_id, develop_camera_id, "
+ + "develop_embedded_id, comment FROM PhotoTable",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<PhotoRow?> all = new Gee.ArrayList<PhotoRow?>();
+
+ while ((res = stmt.step()) == Sqlite.ROW) {
+ PhotoRow row = new PhotoRow();
+ row.photo_id.id = stmt.column_int64(0);
+ 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.orientation = (Orientation) stmt.column_int(7);
+ row.master.original_orientation = (Orientation) stmt.column_int(8);
+ row.import_id.id = stmt.column_int64(9);
+ row.event_id.id = stmt.column_int64(10);
+ row.transformations = marshall_all_transformations(stmt.column_text(11));
+ 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.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.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)) :
+ RawDeveloper.CAMERA;
+ 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);
+
+ validate_orientation(row);
+
+ all.add(row);
+ }
+
+ return all;
+ }
+
+ // Create a duplicate of the specified row. A new byte-for-byte duplicate (including filesystem
+ // metadata) of PhotoID's file needs to back this duplicate and its editable (if exists).
+ public PhotoID duplicate(PhotoID photo_id, string new_filename, BackingPhotoID editable_id,
+ BackingPhotoID develop_shotwell, BackingPhotoID develop_camera_id,
+ BackingPhotoID develop_embedded_id) {
+ // get a copy of the original row, duplicating most (but not all) of it
+ PhotoRow original = get_row(photo_id);
+
+ Sqlite.Statement stmt;
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, new_filename);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(2, original.master.dim.width);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(3, original.master.dim.height);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(4, original.master.filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(5, original.master.timestamp);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(6, original.exposure_time);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(7, original.orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(8, original.master.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(9, original.import_id.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(10, original.event_id.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(11, unmarshall_all_transformations(original.transformations));
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(12, original.md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(13, original.thumbnail_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(14, original.exif_md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(15, now_sec());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(16, (int64) original.flags);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(17, original.rating.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(18, original.master.file_format.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(19, original.title);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(20, editable_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(21, original.developer.to_string());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(22, develop_shotwell.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(23, develop_camera_id.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(24, develop_embedded_id.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(25, original.comment);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ if (res != Sqlite.CONSTRAINT)
+ fatal("duplicate", res);
+
+ return PhotoID();
+ }
+
+ return PhotoID(db.last_insert_rowid());
+ }
+
+ 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 bool set_comment(PhotoID photo_id, string? new_comment) {
+ return update_text_by_id(photo_id.id, "comment", new_comment != null ? new_comment : "");
+ }
+
+ public void set_filepath(PhotoID photo_id, string filepath) throws DatabaseError {
+ 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 bool set_exposure_time(PhotoID photo_id, time_t time) {
+ return update_int64_by_id(photo_id.id, "exposure_time", (int64) time);
+ }
+
+ public void set_import_id(PhotoID photo_id, ImportID import_id) throws DatabaseError {
+ update_int64_by_id_2(photo_id.id, "import_id", import_id.id);
+ }
+
+ public bool remove_by_file(File file) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("DELETE FROM PhotoTable WHERE filename=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, file.get_path());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ warning("remove", res);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public void remove(PhotoID photo_id) throws DatabaseError {
+ delete_by_id(photo_id.id);
+ }
+
+ public Gee.ArrayList<PhotoID?> get_photos() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM PhotoTable", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<PhotoID?> photo_ids = new Gee.ArrayList<PhotoID?>();
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ fatal("get_photos", res);
+
+ break;
+ }
+
+ photo_ids.add(PhotoID(stmt.column_int64(0)));
+ }
+
+ return photo_ids;
+ }
+
+ public bool set_orientation(PhotoID photo_id, Orientation orientation) {
+ return update_int_by_id(photo_id.id, "orientation", (int) orientation);
+ }
+
+ public bool replace_flags(PhotoID photo_id, uint64 flags) {
+ return update_int64_by_id(photo_id.id, "flags", (int64) flags);
+ }
+
+ public bool set_rating(PhotoID photo_id, Rating rating) {
+ return update_int_by_id(photo_id.id, "rating", rating.serialize());
+ }
+
+ public int get_event_photo_count(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM PhotoTable WHERE event_id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, event_id.id);
+ assert(res == Sqlite.OK);
+
+ int count = 0;
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ fatal("get_event_photo_count", res);
+
+ break;
+ }
+
+ count++;
+ }
+
+ return count;
+ }
+
+ public Gee.ArrayList<string> get_event_source_ids(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM PhotoTable WHERE event_id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, event_id.id);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<string> result = new Gee.ArrayList<string>();
+ for(;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ fatal("get_event_source_ids", res);
+
+ break;
+ }
+
+ result.add(PhotoID.upgrade_photo_id_to_source_id(PhotoID(stmt.column_int64(0))));
+ }
+
+ return result;
+ }
+
+ public bool event_has_photos(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM PhotoTable WHERE event_id = ? LIMIT 1", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, event_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ return false;
+ } else if (res != Sqlite.ROW) {
+ fatal("event_has_photos", res);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public bool drop_event(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("UPDATE PhotoTable SET event_id = ? WHERE event_id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, EventID.INVALID);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, event_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ fatal("drop_event", res);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public bool set_event(PhotoID photo_id, EventID event_id) {
+ return update_int64_by_id(photo_id.id, "event_id", event_id.id);
+ }
+
+ private string? get_raw_transformations(PhotoID photo_id) {
+ Sqlite.Statement stmt;
+ if (!select_by_id(photo_id.id, "transformations", out stmt))
+ return null;
+
+ string trans = stmt.column_text(0);
+ if (trans == null || trans.length == 0)
+ return null;
+
+ return trans;
+ }
+
+ private bool set_raw_transformations(PhotoID photo_id, string trans) {
+ return update_text_by_id(photo_id.id, "transformations", trans);
+ }
+
+ public bool set_transformation_state(PhotoID photo_id, Orientation orientation,
+ Gee.HashMap<string, KeyValueMap>? transformations) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("UPDATE PhotoTable SET orientation = ?, transformations = ? WHERE id = ?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int(1, orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(2, unmarshall_all_transformations(transformations));
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(3, photo_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ fatal("set_transformation_state", res);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public static Gee.HashMap<string, KeyValueMap>? marshall_all_transformations(string? trans) {
+ if (trans == null || trans.length == 0)
+ return null;
+
+ try {
+ KeyFile keyfile = new KeyFile();
+ if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
+ return null;
+
+ Gee.HashMap<string, KeyValueMap> map = new Gee.HashMap<string, KeyValueMap>();
+
+ string[] objects = keyfile.get_groups();
+ foreach (string object in objects) {
+ string[] keys = keyfile.get_keys(object);
+ if (keys == null || keys.length == 0)
+ continue;
+
+ KeyValueMap key_map = new KeyValueMap(object);
+ for (int ctr = 0; ctr < keys.length; ctr++)
+ key_map.set_string(keys[ctr], keyfile.get_string(object, keys[ctr]));
+
+ map.set(object, key_map);
+ }
+
+ return map;
+ } catch (Error err) {
+ error("%s", err.message);
+ }
+ }
+
+ public static string? unmarshall_all_transformations(Gee.HashMap<string, KeyValueMap>? transformations) {
+ if (transformations == null || transformations.keys.size == 0)
+ return null;
+
+ KeyFile keyfile = new KeyFile();
+
+ foreach (string object in transformations.keys) {
+ KeyValueMap map = transformations.get(object);
+
+ foreach (string key in map.get_keys()) {
+ string? value = map.get_string(key, null);
+ assert(value != null);
+
+ keyfile.set_string(object, key, value);
+ }
+ }
+
+ size_t length;
+ string unmarshalled = keyfile.to_data(out length);
+ assert(unmarshalled != null);
+ assert(unmarshalled.length > 0);
+
+ return unmarshalled;
+ }
+
+ public bool set_transformation(PhotoID photo_id, KeyValueMap map) {
+ string trans = get_raw_transformations(photo_id);
+
+ try {
+ KeyFile keyfile = new KeyFile();
+ if (trans != null) {
+ if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
+ return false;
+ }
+
+ Gee.Set<string> keys = map.get_keys();
+ foreach (string key in keys) {
+ string value = map.get_string(key, null);
+ assert(value != null);
+
+ keyfile.set_string(map.get_group(), key, value);
+ }
+
+ size_t length;
+ trans = keyfile.to_data(out length);
+ assert(trans != null);
+ assert(trans.length > 0);
+ } catch (Error err) {
+ error("%s", err.message);
+ }
+
+ return set_raw_transformations(photo_id, trans);
+ }
+
+ public bool remove_transformation(PhotoID photo_id, string object) {
+ string trans = get_raw_transformations(photo_id);
+ if (trans == null)
+ return true;
+
+ try {
+ KeyFile keyfile = new KeyFile();
+ if (!keyfile.load_from_data(trans, trans.length, KeyFileFlags.NONE))
+ return false;
+
+ if (!keyfile.has_group(object))
+ return true;
+
+ keyfile.remove_group(object);
+
+ size_t length;
+ trans = keyfile.to_data(out length);
+ assert(trans != null);
+ } catch (Error err) {
+ error("%s", err.message);
+ }
+
+ return set_raw_transformations(photo_id, trans);
+ }
+
+ public bool remove_all_transformations(PhotoID photo_id) {
+ if (get_raw_transformations(photo_id) == null)
+ return false;
+
+ return update_text_by_id(photo_id.id, "transformations", "");
+ }
+
+ // Use PhotoFileFormat.UNKNOWN if not to search for matching file format; it's only used if
+ // searching for MD5 duplicates.
+ private Sqlite.Statement get_duplicate_stmt(File? file, string? thumbnail_md5, string? md5,
+ PhotoFileFormat file_format) {
+ assert(file != null || thumbnail_md5 != null || md5 != null);
+
+ string sql = "SELECT id FROM PhotoTable WHERE";
+ bool first = true;
+
+ if (file != null) {
+ sql += " filename=?";
+ first = false;
+ }
+
+ if (thumbnail_md5 != null || md5 != null) {
+ if (first)
+ sql += " ((";
+ else
+ sql += " OR ((";
+ first = false;
+
+ if (thumbnail_md5 != null)
+ sql += " thumbnail_md5=?";
+
+ if (md5 != null) {
+ if (thumbnail_md5 == null)
+ sql += " md5=?";
+ else
+ sql += " OR md5=?";
+ }
+
+ sql += ")";
+
+ if (file_format != PhotoFileFormat.UNKNOWN)
+ sql += " AND file_format=?";
+
+ sql += ")";
+ }
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(sql, -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ int col = 1;
+
+ if (file != null) {
+ res = stmt.bind_text(col++, file.get_path());
+ assert(res == Sqlite.OK);
+ }
+
+ if (thumbnail_md5 != null) {
+ res = stmt.bind_text(col++, thumbnail_md5);
+ assert(res == Sqlite.OK);
+ }
+
+ if (md5 != null) {
+ res = stmt.bind_text(col++, md5);
+ assert(res == Sqlite.OK);
+ }
+
+ if ((thumbnail_md5 != null || md5 != null) && file_format != PhotoFileFormat.UNKNOWN) {
+ res = stmt.bind_int(col++, file_format.serialize());
+ assert(res == Sqlite.OK);
+ }
+
+ return stmt;
+ }
+
+ public bool has_duplicate(File? file, string? thumbnail_md5, string? md5, PhotoFileFormat file_format) {
+ Sqlite.Statement stmt = get_duplicate_stmt(file, thumbnail_md5, md5, file_format);
+ int res = stmt.step();
+
+ if (res == Sqlite.DONE) {
+ // not found
+ return false;
+ } else if (res == Sqlite.ROW) {
+ // at least one found
+ return true;
+ } else {
+ fatal("has_duplicate", res);
+
+ return false;
+ }
+ }
+
+ public PhotoID[] get_duplicate_ids(File? file, string? thumbnail_md5, string? md5,
+ PhotoFileFormat file_format) {
+ Sqlite.Statement stmt = get_duplicate_stmt(file, thumbnail_md5, md5, file_format);
+
+ PhotoID[] ids = new PhotoID[0];
+
+ int res = stmt.step();
+ while (res == Sqlite.ROW) {
+ ids += PhotoID(stmt.column_int64(0));
+ res = stmt.step();
+ }
+
+ return ids;
+ }
+
+ public void update_backlinks(PhotoID photo_id, string? backlinks) throws DatabaseError {
+ update_text_by_id_2(photo_id.id, "backlinks", backlinks != null ? backlinks : "");
+ }
+
+ public void attach_editable(PhotoRow row, BackingPhotoID editable_id) throws DatabaseError {
+ update_int64_by_id_2(row.photo_id.id, "editable_id", editable_id.id);
+
+ row.editable_id = editable_id;
+ }
+
+ public void detach_editable(PhotoRow row) throws DatabaseError {
+ update_int64_by_id_2(row.photo_id.id, "editable_id", BackingPhotoID.INVALID);
+
+ row.editable_id = BackingPhotoID();
+ }
+
+ public void set_metadata_dirty(PhotoID photo_id, bool dirty) throws DatabaseError {
+ update_int_by_id_2(photo_id.id, "metadata_dirty", dirty ? 1 : 0);
+ }
+
+ public void update_raw_development(PhotoRow row, RawDeveloper rd, BackingPhotoID backing_photo_id)
+ throws DatabaseError {
+
+ string col;
+ switch (rd) {
+ case RawDeveloper.SHOTWELL:
+ col = "develop_shotwell_id";
+ break;
+
+ case RawDeveloper.CAMERA:
+ col = "develop_camera_id";
+ break;
+
+ case RawDeveloper.EMBEDDED:
+ col = "develop_embedded_id";
+ break;
+
+ default:
+ assert_not_reached();
+ }
+
+ row.development_ids[rd] = backing_photo_id;
+ update_int64_by_id_2(row.photo_id.id, col, backing_photo_id.id);
+
+ if (backing_photo_id.id != BackingPhotoID.INVALID)
+ update_text_by_id_2(row.photo_id.id, "developer", rd.to_string());
+ }
+
+ public void remove_development(PhotoRow row, RawDeveloper rd) throws DatabaseError {
+ update_raw_development(row, rd, BackingPhotoID());
+ }
+
+}
+
+//
+// BackingPhotoTable
+//
+// BackingPhotoTable is designed to hold any number of alternative backing photos
+// for a Photo. In the first implementation it was designed for editable photos (Edit with
+// External Editor), but if other such alternates are needed, this is where to store them.
+//
+// Note that no transformations are held here.
+//
+
+public struct BackingPhotoID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public BackingPhotoID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+}
+
+public class BackingPhotoRow {
+ public BackingPhotoID id;
+ public time_t time_created;
+ public string? filepath = null;
+ public int64 filesize;
+ public time_t timestamp;
+ public PhotoFileFormat file_format;
+ public Dimensions dim;
+ public Orientation original_orientation;
+
+ public bool matches_file_info(FileInfo info) {
+ if (filesize != info.get_size())
+ return false;
+
+ return timestamp == info.get_modification_time().tv_sec;
+ }
+
+ public bool is_touched(FileInfo info) {
+ if (filesize != info.get_size())
+ return false;
+
+ return timestamp != info.get_modification_time().tv_sec;
+ }
+
+ // Copies another backing photo row into this one.
+ public void copy_from(BackingPhotoRow from) {
+ id = from.id;
+ time_created = from.time_created;
+ filepath = from.filepath;
+ filesize = from.filesize;
+ timestamp = from.timestamp;
+ file_format = from.file_format;
+ dim = from.dim;
+ original_orientation = from.original_orientation;
+ }
+}
+
+public class BackingPhotoTable : DatabaseTable {
+ private static BackingPhotoTable instance = null;
+
+ private BackingPhotoTable() {
+ set_table_name("BackingPhotoTable");
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "BackingPhotoTable "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "filepath TEXT UNIQUE NOT NULL, "
+ + "timestamp INTEGER, "
+ + "filesize INTEGER, "
+ + "width INTEGER, "
+ + "height INTEGER, "
+ + "original_orientation INTEGER, "
+ + "file_format INTEGER, "
+ + "time_created INTEGER "
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create PhotoBackingTable", res);
+ }
+
+ public static BackingPhotoTable get_instance() {
+ if (instance == null)
+ instance = new BackingPhotoTable();
+
+ return instance;
+ }
+
+ public void add(BackingPhotoRow state) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO BackingPhotoTable "
+ + "(filepath, timestamp, filesize, width, height, original_orientation, "
+ + "file_format, time_created) "
+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ time_t time_created = (time_t) now_sec();
+
+ res = stmt.bind_text(1, state.filepath);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, state.timestamp);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(3, state.filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(4, state.dim.width);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(5, state.dim.height);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(6, state.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(7, state.file_format.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(8, (int64) time_created);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("PhotoBackingTable.add", res);
+
+ state.id = BackingPhotoID(db.last_insert_rowid());
+ state.time_created = time_created;
+ }
+
+ public BackingPhotoRow? fetch(BackingPhotoID id) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT filepath, timestamp, filesize, width, height, "
+ + "original_orientation, file_format, time_created FROM BackingPhotoTable WHERE id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ return null;
+ else if (res != Sqlite.ROW)
+ throw_error("BackingPhotoTable.fetch_for_photo", res);
+
+ BackingPhotoRow row = new BackingPhotoRow();
+ row.id = id;
+ row.filepath = stmt.column_text(0);
+ row.timestamp = (time_t) 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);
+
+ return row;
+ }
+
+ // Everything but filepath is updated.
+ public void update(BackingPhotoRow row) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("UPDATE BackingPhotoTable SET timestamp=?, filesize=?, "
+ + "width=?, height=?, original_orientation=?, file_format=? "
+ + "WHERE id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, row.timestamp);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, row.filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(3, row.dim.width);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(4, row.dim.height);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(5, row.original_orientation);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(6, row.file_format.serialize());
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(7, row.id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("BackingPhotoTable.update", res);
+ }
+
+ public void update_attributes(BackingPhotoID id, time_t 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);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(3, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("BackingPhotoTable.update_attributes", res);
+ }
+
+ public void remove(BackingPhotoID backing_id) throws DatabaseError {
+ delete_by_id(backing_id.id);
+ }
+
+ public void set_filepath(BackingPhotoID id, string filepath) throws DatabaseError {
+ 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);
+ }
+}
+
diff --git a/src/db/SavedSearchDBTable.vala b/src/db/SavedSearchDBTable.vala
new file mode 100644
index 0000000..d986038
--- /dev/null
+++ b/src/db/SavedSearchDBTable.vala
@@ -0,0 +1,641 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public struct SavedSearchID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public SavedSearchID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+}
+
+public class SavedSearchRow {
+ public SavedSearchID search_id;
+
+ public string name;
+ public SearchOperator operator;
+ public Gee.List<SearchCondition> conditions;
+}
+
+public class SavedSearchDBTable : DatabaseTable {
+ private static SavedSearchDBTable instance = null;
+
+ private SavedSearchDBTable() {
+ set_table_name("SavedSearchDBTable");
+
+ // Create main search table.
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "name TEXT UNIQUE NOT NULL, "
+ + "operator TEXT NOT NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable", res);
+
+ // Create search text table.
+ res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable_Text "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "search_id INTEGER NOT NULL, "
+ + "search_type TEXT NOT NULL, "
+ + "context TEXT NOT NULL, "
+ + "text TEXT"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Text", res);
+
+ // Create search media type table.
+ res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable_MediaType "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "search_id INTEGER NOT NULL, "
+ + "search_type TEXT NOT NULL, "
+ + "context TEXT NOT NULL, "
+ + "type TEXT NOT_NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_MediaType", res);
+
+ // Create flagged search table.
+ res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable_Flagged "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "search_id INTEGER NOT NULL, "
+ + "search_type TEXT NOT NULL, "
+ + "flag_state TEXT NOT NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Flagged", res);
+
+ // Create modified search table.
+ res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable_Modified "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "search_id INTEGER NOT NULL, "
+ + "search_type TEXT NOT NULL, "
+ + "context TEXT NOT NULL, "
+ + "modified_state TEXT NOT NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Modified", res);
+
+ // Create rating search table.
+ res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable_Rating "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "search_id INTEGER NOT NULL, "
+ + "search_type TEXT NOT NULL, "
+ + "rating INTEGER NOT_NULL, "
+ + "context TEXT NOT NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Rating", res);
+
+ // Create date search table.
+ res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "SavedSearchDBTable_Date "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "search_id INTEGER NOT NULL, "
+ + "search_type TEXT NOT NULL, "
+ + "context TEXT NOT NULL, "
+ + "date_one INTEGER NOT_NULL, "
+ + "date_two INTEGER NOT_NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Rating", res);
+
+ // Create indexes.
+ res = db.prepare_v2("CREATE INDEX IF NOT EXISTS "
+ + "SavedSearchDBTable_Text_Index "
+ + "ON SavedSearchDBTable_Text(search_id)", -1, out stmt);
+ assert(res == Sqlite.OK);
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Text_Index", res);
+
+ res = db.prepare_v2("CREATE INDEX IF NOT EXISTS "
+ + "SavedSearchDBTable_MediaType_Index "
+ + "ON SavedSearchDBTable_MediaType(search_id)", -1, out stmt);
+ assert(res == Sqlite.OK);
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_MediaType_Index", res);
+
+ res = db.prepare_v2("CREATE INDEX IF NOT EXISTS "
+ + "SavedSearchDBTable_Flagged_Index "
+ + "ON SavedSearchDBTable_Flagged(search_id)", -1, out stmt);
+ assert(res == Sqlite.OK);
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Flagged_Index", res);
+
+ res = db.prepare_v2("CREATE INDEX IF NOT EXISTS "
+ + "SavedSearchDBTable_Modified_Index "
+ + "ON SavedSearchDBTable_Modified(search_id)", -1, out stmt);
+ assert(res == Sqlite.OK);
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Modified_Index", res);
+
+ res = db.prepare_v2("CREATE INDEX IF NOT EXISTS "
+ + "SavedSearchDBTable_Rating_Index "
+ + "ON SavedSearchDBTable_Rating(search_id)", -1, out stmt);
+ assert(res == Sqlite.OK);
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Rating_Index", res);
+
+ res = db.prepare_v2("CREATE INDEX IF NOT EXISTS "
+ + "SavedSearchDBTable_Date_Index "
+ + "ON SavedSearchDBTable_Date(search_id)", -1, out stmt);
+ assert(res == Sqlite.OK);
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create SavedSearchDBTable_Date_Index", res);
+ }
+
+ public static SavedSearchDBTable get_instance() {
+ if (instance == null)
+ instance = new SavedSearchDBTable();
+
+ return instance;
+ }
+
+ public SavedSearchRow add(string name, SearchOperator operator,
+ Gee.ArrayList<SearchCondition> conditions) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable (name, operator) VALUES (?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, name);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(2, operator.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable.add", res);
+
+ SavedSearchRow row = new SavedSearchRow();
+ row.search_id = SavedSearchID(db.last_insert_rowid());
+ row.name = name;
+ row.operator = operator;
+ row.conditions = conditions;
+
+ foreach (SearchCondition sc in conditions) {
+ add_condition(row.search_id, sc);
+ }
+
+ return row;
+ }
+
+ private void add_condition(SavedSearchID id, SearchCondition condition) throws DatabaseError {
+ if (condition is SearchConditionText) {
+ SearchConditionText text = condition as SearchConditionText;
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable_Text (search_id, search_type, context, "
+ + "text) VALUES (?, ?, ?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(2, text.search_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(3, text.context.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(4, text.text);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable_Text.add", res);
+ } else if (condition is SearchConditionMediaType) {
+ SearchConditionMediaType media_type = condition as SearchConditionMediaType;
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable_MediaType (search_id, search_type, context, "
+ + "type) VALUES (?, ?, ?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(2, media_type.search_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(3, media_type.context.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(4, media_type.media_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable_MediaType.add", res);
+ } else if (condition is SearchConditionFlagged) {
+ SearchConditionFlagged flag_state = condition as SearchConditionFlagged;
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable_Flagged (search_id, search_type, "
+ + "flag_state) VALUES (?, ?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(2, flag_state.search_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(3, flag_state.state.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable_Flagged.add", res);
+ } else if (condition is SearchConditionModified) {
+ SearchConditionModified modified_state = condition as SearchConditionModified;
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable_Modified (search_id, search_type, context, "
+ + "modified_state) VALUES (?, ?, ?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(2, modified_state.search_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(3, modified_state.context.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(4, modified_state.state.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable_Modified.add", res);
+ } else if (condition is SearchConditionRating) {
+ SearchConditionRating rating = condition as SearchConditionRating;
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable_Rating (search_id, search_type, rating, "
+ + "context) VALUES (?, ?, ?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(2, rating.search_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int(3, rating.rating.serialize());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(4, rating.context.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable_Rating.add", res);
+ } else if (condition is SearchConditionDate) {
+ SearchConditionDate date = condition as SearchConditionDate;
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable_Date (search_id, search_type, "
+ + "context, date_one, date_two) VALUES (?, ?, ?, ?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(2, date.search_type.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(3, date.context.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(4, date.date_one.to_unix());
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(5, date.date_two.to_unix());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable_Date.add", res);
+ } else {
+ assert_not_reached();
+ }
+ }
+
+ // Removes the conditions of a search. Used on delete.
+ private void remove_conditions_for_search_id(SavedSearchID search_id) throws DatabaseError {
+ remove_conditions_for_table("SavedSearchDBTable_Text", search_id);
+ remove_conditions_for_table("SavedSearchDBTable_MediaType", search_id);
+ remove_conditions_for_table("SavedSearchDBTable_Flagged", search_id);
+ remove_conditions_for_table("SavedSearchDBTable_Modified", search_id);
+ remove_conditions_for_table("SavedSearchDBTable_Rating", search_id);
+ remove_conditions_for_table("SavedSearchDBTable_Date", search_id);
+ }
+
+ private void remove_conditions_for_table(string table_name, SavedSearchID search_id)
+ throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("DELETE FROM %s WHERE search_id=?".printf(table_name), -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("%s.remove".printf(table_name), res);
+ }
+
+ // Returns all conditions for a given search. Used on loading a search.
+ private Gee.List<SearchCondition> get_conditions_for_id(SavedSearchID search_id)
+ throws DatabaseError {
+ Gee.List<SearchCondition> list = new Gee.ArrayList<SearchCondition>();
+ Sqlite.Statement stmt;
+ int res;
+
+ // Get all text conditions.
+ res = db.prepare_v2("SELECT search_type, context, text FROM SavedSearchDBTable_Text "
+ + "WHERE search_id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable_Text.get_all_rows", res);
+
+ SearchConditionText condition = new SearchConditionText(
+ SearchCondition.SearchType.from_string(stmt.column_text(0)),
+ stmt.column_text(2),
+ SearchConditionText.Context.from_string(stmt.column_text(1)));
+
+ list.add(condition);
+ }
+
+ // Get all media type conditions.
+ res = db.prepare_v2("SELECT search_type, context, type FROM SavedSearchDBTable_MediaType "
+ + "WHERE search_id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable_MediaType.get_all_rows", res);
+
+ SearchConditionMediaType condition = new SearchConditionMediaType(
+ SearchCondition.SearchType.from_string(stmt.column_text(0)),
+ SearchConditionMediaType.Context.from_string(stmt.column_text(1)),
+ SearchConditionMediaType.MediaType.from_string(stmt.column_text(2)));
+
+ list.add(condition);
+ }
+
+ // Get all flagged state conditions.
+ res = db.prepare_v2("SELECT search_type, flag_state FROM SavedSearchDBTable_Flagged "
+ + "WHERE search_id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable_Flagged.get_all_rows", res);
+
+ SearchConditionFlagged condition = new SearchConditionFlagged(
+ SearchCondition.SearchType.from_string(stmt.column_text(0)),
+ SearchConditionFlagged.State.from_string(stmt.column_text(1)));
+
+ list.add(condition);
+ }
+
+ // Get all modified state conditions.
+ res = db.prepare_v2("SELECT search_type, context, modified_state FROM SavedSearchDBTable_Modified "
+ + "WHERE search_id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable_Modified.get_all_rows", res);
+
+ SearchConditionModified condition = new SearchConditionModified(
+ SearchCondition.SearchType.from_string(stmt.column_text(0)),
+ SearchConditionModified.Context.from_string(stmt.column_text(1)),
+ SearchConditionModified.State.from_string(stmt.column_text(2)));
+
+ list.add(condition);
+ }
+
+ // Get all rating conditions.
+ res = db.prepare_v2("SELECT search_type, rating, context FROM SavedSearchDBTable_Rating "
+ + "WHERE search_id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable_Rating.get_all_rows", res);
+
+ SearchConditionRating condition = new SearchConditionRating(
+ SearchCondition.SearchType.from_string(stmt.column_text(0)),
+ Rating.unserialize(stmt.column_int(1)),
+ SearchConditionRating.Context.from_string(stmt.column_text(2)));
+
+ list.add(condition);
+ }
+
+ // Get all date conditions.
+ res = db.prepare_v2("SELECT search_type, context, date_one, date_two FROM SavedSearchDBTable_Date "
+ + "WHERE search_id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable_Date.get_all_rows", res);
+
+ SearchConditionDate condition = new SearchConditionDate(
+ SearchCondition.SearchType.from_string(stmt.column_text(0)),
+ SearchConditionDate.Context.from_string(stmt.column_text(1)),
+ new DateTime.from_unix_local(stmt.column_int64(2)),
+ new DateTime.from_unix_local(stmt.column_int64(3)));
+ list.add(condition);
+ }
+
+ return list;
+ }
+
+ // All fields but search_id are respected in SavedSearchRow.
+ public SavedSearchID create_from_row(SavedSearchRow row) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO SavedSearchDBTable (name, operator) VALUES (?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, row.name);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(2, row.operator.to_string());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("SavedSearchDBTable.create_from_row", res);
+
+ SavedSearchID search_id = SavedSearchID(db.last_insert_rowid());
+
+ foreach (SearchCondition sc in row.conditions) {
+ add_condition(search_id, sc);
+ }
+
+ return search_id;
+ }
+
+ public void remove(SavedSearchID search_id) throws DatabaseError {
+ remove_conditions_for_search_id(search_id);
+ delete_by_id(search_id.id);
+ }
+
+ public SavedSearchRow? get_row(SavedSearchID search_id) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT name, operator FROM SavedSearchDBTable WHERE id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, search_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ return null;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable.get_row", res);
+
+ SavedSearchRow row = new SavedSearchRow();
+ row.search_id = search_id;
+ row.name = stmt.column_text(0);
+ row.operator = SearchOperator.from_string(stmt.column_text(1));
+
+ return row;
+ }
+
+ public Gee.List<SavedSearchRow?> get_all_rows() throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id, name, operator FROM SavedSearchDBTable", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.List<SavedSearchRow?> rows = new Gee.ArrayList<SavedSearchRow?>();
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("SavedSearchDBTable.get_all_rows", res);
+
+ SavedSearchRow row = new SavedSearchRow();
+ row.search_id = SavedSearchID(stmt.column_int64(0));
+ row.name = stmt.column_text(1);
+ row.operator = SearchOperator.from_string(stmt.column_text(2));
+ row.conditions = get_conditions_for_id(row.search_id);
+
+ rows.add(row);
+ }
+
+ return rows;
+ }
+
+ public void rename(SavedSearchID search_id, string new_name) throws DatabaseError {
+ update_text_by_id_2(search_id.id, "name", new_name);
+ }
+}
+
diff --git a/src/db/TagTable.vala b/src/db/TagTable.vala
new file mode 100644
index 0000000..a0fade8
--- /dev/null
+++ b/src/db/TagTable.vala
@@ -0,0 +1,248 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public struct TagID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public TagID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+}
+
+public class TagRow {
+ public TagID tag_id;
+ public string name;
+ public Gee.Set<string>? source_id_list;
+ public time_t time_created;
+}
+
+public class TagTable : DatabaseTable {
+ private static TagTable instance = null;
+
+ private TagTable() {
+ set_table_name("TagTable");
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "TagTable "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "name TEXT UNIQUE NOT NULL, "
+ + "photo_id_list TEXT, "
+ + "time_created INTEGER"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create TagTable", res);
+ }
+
+ public static TagTable get_instance() {
+ if (instance == null)
+ instance = new TagTable();
+
+ return instance;
+ }
+
+ public static void upgrade_for_htags() {
+ TagTable table = get_instance();
+
+ try {
+ Gee.List<TagRow?> rows = table.get_all_rows();
+
+ foreach (TagRow row in rows) {
+ row.name = row.name.replace(Tag.PATH_SEPARATOR_STRING, "-");
+ table.rename(row.tag_id, row.name);
+ }
+ } catch (DatabaseError e) {
+ error ("TagTable: can't upgrade tag names for hierarchical tag support: %s", e.message);
+ }
+ }
+
+ public TagRow add(string name) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO TagTable (name, time_created) VALUES (?, ?)", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ time_t time_created = (time_t) now_sec();
+
+ res = stmt.bind_text(1, name);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, time_created);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("TagTable.add", res);
+
+ TagRow row = new TagRow();
+ row.tag_id = TagID(db.last_insert_rowid());
+ row.name = name;
+ row.source_id_list = null;
+ row.time_created = time_created;
+
+ return row;
+ }
+
+ // All fields but tag_id are respected in TagRow.
+ public TagID create_from_row(TagRow row) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO TagTable (name, photo_id_list, time_created) VALUES (?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, row.name);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(2, serialize_source_ids(row.source_id_list));
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(3, row.time_created);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("TagTable.create_from_row", res);
+
+ return TagID(db.last_insert_rowid());
+ }
+
+ public void remove(TagID tag_id) throws DatabaseError {
+ delete_by_id(tag_id.id);
+ }
+
+ public string? get_name(TagID tag_id) throws DatabaseError {
+ Sqlite.Statement stmt;
+ if (!select_by_id(tag_id.id, "name", out stmt))
+ return null;
+
+ return stmt.column_text(0);
+ }
+
+ public TagRow? get_row(TagID tag_id) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT name, photo_id_list, time_created FROM TagTable WHERE id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, tag_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ return null;
+ else if (res != Sqlite.ROW)
+ throw_error("TagTable.get_row", res);
+
+ TagRow row = new TagRow();
+ 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);
+
+ return row;
+ }
+
+ public Gee.List<TagRow?> get_all_rows() throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id, name, photo_id_list, time_created FROM TagTable", -1,
+ out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.List<TagRow?> rows = new Gee.ArrayList<TagRow?>();
+
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("TagTable.get_all_rows", res);
+
+ // res == Sqlite.ROW
+ TagRow row = new TagRow();
+ 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);
+
+ rows.add(row);
+ }
+
+ return rows;
+ }
+
+ public void rename(TagID tag_id, string new_name) throws DatabaseError {
+ update_text_by_id_2(tag_id.id, "name", new_name);
+ }
+
+ public void set_tagged_sources(TagID tag_id, Gee.Collection<string> source_ids) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("UPDATE TagTable SET photo_id_list=? WHERE id=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, serialize_source_ids(source_ids));
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, tag_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("TagTable.set_tagged_photos", res);
+ }
+
+ private string? serialize_source_ids(Gee.Collection<string>? source_ids) {
+ if (source_ids == null)
+ return null;
+
+ StringBuilder result = new StringBuilder();
+
+ foreach (string source_id in source_ids) {
+ result.append(source_id);
+ result.append(",");
+ }
+
+ return (result.len != 0) ? result.str : null;
+ }
+
+ private Gee.Set<string> unserialize_source_ids(string? text_list) {
+ Gee.Set<string> result = new Gee.HashSet<string>();
+
+ if (text_list == null)
+ return result;
+
+ string[] split = text_list.split(",");
+ foreach (string token in split) {
+ if (is_string_empty(token))
+ continue;
+
+ // handle current and legacy encoding of source ids -- in the past, we only stored
+ // LibraryPhotos in tags so we only needed to store the numeric database key of the
+ // photo to uniquely identify it. Now, however, tags can store arbitrary MediaSources,
+ // so instead of simply storing a number we store the source id, a string that contains
+ // a typename followed by an identifying number (e.g., "video-022354").
+ if (token[0].isdigit()) {
+ // this is a legacy entry
+ result.add(PhotoID.upgrade_photo_id_to_source_id(PhotoID(parse_int64(token, 10))));
+ } else if (token[0].isalpha()) {
+ // this is a modern entry
+ result.add(token);
+ }
+ }
+
+ return result;
+ }
+}
+
diff --git a/src/db/TombstoneTable.vala b/src/db/TombstoneTable.vala
new file mode 100644
index 0000000..9e108bd
--- /dev/null
+++ b/src/db/TombstoneTable.vala
@@ -0,0 +1,146 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public struct TombstoneID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public TombstoneID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+}
+
+public class TombstoneRow {
+ public TombstoneID id;
+ public string filepath;
+ public int64 filesize;
+ public string? md5;
+ public time_t time_created;
+ public Tombstone.Reason reason;
+}
+
+public class TombstoneTable : DatabaseTable {
+ private static TombstoneTable instance = null;
+
+ private TombstoneTable() {
+ set_table_name("TombstoneTable");
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS "
+ + "TombstoneTable "
+ + "("
+ + "id INTEGER PRIMARY KEY, "
+ + "filepath TEXT NOT NULL, "
+ + "filesize INTEGER, "
+ + "md5 TEXT, "
+ + "time_created INTEGER, "
+ + "reason INTEGER DEFAULT 0 "
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create TombstoneTable", res);
+ }
+
+ public static TombstoneTable get_instance() {
+ if (instance == null)
+ instance = new TombstoneTable();
+
+ return instance;
+ }
+
+ public TombstoneRow add(string filepath, int64 filesize, string? md5, Tombstone.Reason reason)
+ throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("INSERT INTO TombstoneTable "
+ + "(filepath, filesize, md5, time_created, reason) "
+ + "VALUES (?, ?, ?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ time_t time_created = (time_t) now_sec();
+
+ res = stmt.bind_text(1, filepath);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(3, md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(4, (int64) time_created);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(5, reason.serialize());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("TombstoneTable.add", res);
+
+ TombstoneRow row = new TombstoneRow();
+ row.id = TombstoneID(db.last_insert_rowid());
+ row.filepath = filepath;
+ row.filesize = filesize;
+ row.md5 = md5;
+ row.time_created = time_created;
+ row.reason = reason;
+
+ return row;
+ }
+
+ public TombstoneRow[]? fetch_all() throws DatabaseError {
+ int row_count = get_row_count();
+ if (row_count == 0)
+ return null;
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id, filepath, filesize, md5, time_created, reason "
+ + "FROM TombstoneTable", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ TombstoneRow[] rows = new TombstoneRow[row_count];
+
+ int index = 0;
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE)
+ break;
+ else if (res != Sqlite.ROW)
+ throw_error("TombstoneTable.fetch_all", res);
+
+ TombstoneRow row = new TombstoneRow();
+ row.id = TombstoneID(stmt.column_int64(0));
+ 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.reason = Tombstone.Reason.unserialize(stmt.column_int(5));
+
+ rows[index++] = row;
+ }
+
+ assert(index == row_count);
+
+ return rows;
+ }
+
+ public void update_file(TombstoneID tombstone_id, string filepath) throws DatabaseError {
+ update_text_by_id_2(tombstone_id.id, "filepath", filepath);
+ }
+
+ public void remove(TombstoneID tombstone_id) throws DatabaseError {
+ delete_by_id(tombstone_id.id);
+ }
+}
+
diff --git a/src/db/VersionTable.vala b/src/db/VersionTable.vala
new file mode 100644
index 0000000..9003b1b
--- /dev/null
+++ b/src/db/VersionTable.vala
@@ -0,0 +1,98 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 VersionTable : DatabaseTable {
+ private static VersionTable instance = null;
+
+ private VersionTable() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS VersionTable ("
+ + "id INTEGER PRIMARY KEY, "
+ + "schema_version INTEGER, "
+ + "app_version TEXT, "
+ + "user_data TEXT NULL"
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("create version table", res);
+
+ set_table_name("VersionTable");
+ }
+
+ public static VersionTable get_instance() {
+ if (instance == null)
+ instance = new VersionTable();
+
+ return instance;
+ }
+
+ public int get_version(out string app_version) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT schema_version, app_version FROM VersionTable ORDER BY schema_version DESC LIMIT 1",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.ROW) {
+ if (res != Sqlite.DONE)
+ fatal("get_version", res);
+
+ app_version = null;
+
+ return -1;
+ }
+
+ app_version = stmt.column_text(1);
+
+ return stmt.column_int(0);
+ }
+
+ public void set_version(int version, string app_version, string? user_data = null) {
+ Sqlite.Statement stmt;
+
+ string bitbucket;
+ if (get_version(out bitbucket) != -1) {
+ // overwrite existing row
+ int res = db.prepare_v2("UPDATE VersionTable SET schema_version=?, app_version=?, user_data=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+ } else {
+ // insert new row
+ int res = db.prepare_v2("INSERT INTO VersionTable (schema_version, app_version, user_data) VALUES (?,?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+ }
+
+ int res = stmt.bind_int(1, version);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(2, app_version);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(3, user_data);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("set_version %d %s %s".printf(version, app_version, user_data), res);
+ }
+
+ public void update_version(int version, string app_version) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("UPDATE VersionTable SET schema_version=?, app_version=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int(1, version);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(2, app_version);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("update_version %d".printf(version), res);
+ }
+}
+
diff --git a/src/db/VideoTable.vala b/src/db/VideoTable.vala
new file mode 100644
index 0000000..c681dc2
--- /dev/null
+++ b/src/db/VideoTable.vala
@@ -0,0 +1,462 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public struct VideoID {
+ public const int64 INVALID = -1;
+
+ public int64 id;
+
+ public VideoID(int64 id = INVALID) {
+ this.id = id;
+ }
+
+ public bool is_invalid() {
+ return (id == INVALID);
+ }
+
+ public bool is_valid() {
+ return (id != INVALID);
+ }
+
+ public static uint hash(VideoID? a) {
+ return int64_hash(a.id);
+ }
+
+ public static bool equal(void *a, void *b) {
+ return ((VideoID *) a)->id == ((VideoID *) b)->id;
+ }
+
+ public static string upgrade_video_id_to_source_id(VideoID video_id) {
+ return ("%s-%016" + int64.FORMAT_MODIFIER + "x").printf(Video.TYPENAME, video_id.id);
+ }
+}
+
+public class VideoRow {
+ public VideoID video_id;
+ public string filepath;
+ public int64 filesize;
+ public time_t timestamp;
+ public int width;
+ public int height;
+ public double clip_duration;
+ public bool is_interpretable;
+ public time_t exposure_time;
+ public ImportID import_id;
+ public EventID event_id;
+ public string md5;
+ public time_t time_created;
+ public Rating rating;
+ public string title;
+ public string? backlinks;
+ public time_t time_reimported;
+ public uint64 flags;
+ public string comment;
+}
+
+public class VideoTable : DatabaseTable {
+ private static VideoTable instance = null;
+
+ private VideoTable() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("CREATE TABLE IF NOT EXISTS VideoTable ("
+ + "id INTEGER PRIMARY KEY, "
+ + "filename TEXT UNIQUE NOT NULL, "
+ + "width INTEGER, "
+ + "height INTEGER, "
+ + "clip_duration REAL, "
+ + "is_interpretable INTEGER, "
+ + "filesize INTEGER, "
+ + "timestamp INTEGER, "
+ + "exposure_time INTEGER, "
+ + "import_id INTEGER, "
+ + "event_id INTEGER, "
+ + "md5 TEXT, "
+ + "time_created INTEGER, "
+ + "rating INTEGER DEFAULT 0, "
+ + "title TEXT, "
+ + "backlinks TEXT, "
+ + "time_reimported INTEGER, "
+ + "flags INTEGER DEFAULT 0, "
+ + "comment TEXT "
+ + ")", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ fatal("VideoTable constructor", res);
+
+ // index on event_id
+ Sqlite.Statement stmt2;
+ int res2 = db.prepare_v2("CREATE INDEX IF NOT EXISTS VideoEventIDIndex ON VideoTable (event_id)",
+ -1, out stmt2);
+ assert(res2 == Sqlite.OK);
+
+ res2 = stmt2.step();
+ if (res2 != Sqlite.DONE)
+ fatal("VideoTable constructor", res2);
+
+ set_table_name("VideoTable");
+ }
+
+ public static VideoTable get_instance() {
+ if (instance == null)
+ instance = new VideoTable();
+
+ return instance;
+ }
+
+ // VideoRow.video_id, event_id, time_created are ignored on input. All fields are set on exit
+ // with values stored in the database.
+ public VideoID add(VideoRow video_row) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "INSERT INTO VideoTable (filename, width, height, clip_duration, is_interpretable, "
+ + "filesize, timestamp, exposure_time, import_id, event_id, md5, time_created, title, comment) "
+ + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ ulong time_created = now_sec();
+
+ res = stmt.bind_text(1, video_row.filepath);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(2, video_row.width);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(3, video_row.height);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_double(4, video_row.clip_duration);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int(5, (video_row.is_interpretable) ? 1 : 0);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(6, video_row.filesize);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(7, video_row.timestamp);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(8, video_row.exposure_time);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(9, video_row.import_id.id);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(10, EventID.INVALID);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(11, video_row.md5);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(12, time_created);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(13, video_row.title);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_text(14, video_row.comment);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ if (res != Sqlite.CONSTRAINT)
+ throw_error("VideoTable.add", res);
+ }
+
+ // 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.flags = 0;
+
+ return video_row.video_id;
+ }
+
+ public bool drop_event(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("UPDATE VideoTable SET event_id = ? WHERE event_id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, EventID.INVALID);
+ assert(res == Sqlite.OK);
+ res = stmt.bind_int64(2, event_id.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE) {
+ fatal("VideoTable.drop_event", res);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ public VideoRow? get_row(VideoID video_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "SELECT filename, width, height, clip_duration, is_interpretable, filesize, timestamp, "
+ + "exposure_time, import_id, event_id, md5, time_created, rating, title, backlinks, "
+ + "time_reimported, flags, comment FROM VideoTable WHERE id=?",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, video_id.id);
+ assert(res == Sqlite.OK);
+
+ if (stmt.step() != Sqlite.ROW)
+ return null;
+
+ VideoRow row = new VideoRow();
+ row.video_id = video_id;
+ row.filepath = stmt.column_text(0);
+ row.width = stmt.column_int(1);
+ row.height = stmt.column_int(2);
+ 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);
+ 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.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.flags = stmt.column_int64(16);
+ row.comment = stmt.column_text(17);
+
+ return row;
+ }
+
+ public Gee.ArrayList<VideoRow?> get_all() {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(
+ "SELECT id, filename, width, height, clip_duration, is_interpretable, filesize, "
+ + "timestamp, exposure_time, import_id, event_id, md5, time_created, rating, title, "
+ + "backlinks, time_reimported, flags, comment FROM VideoTable",
+ -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<VideoRow?> all = new Gee.ArrayList<VideoRow?>();
+
+ while ((res = stmt.step()) == Sqlite.ROW) {
+ VideoRow row = new VideoRow();
+ row.video_id.id = stmt.column_int64(0);
+ row.filepath = stmt.column_text(1);
+ row.width = stmt.column_int(2);
+ row.height = stmt.column_int(3);
+ 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.event_id.id = stmt.column_int64(10);
+ row.md5 = stmt.column_text(11);
+ row.time_created = (time_t) 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.flags = stmt.column_int64(17);
+ row.comment = stmt.column_text(18);
+
+ all.add(row);
+ }
+
+ return all;
+ }
+
+ public void set_filepath(VideoID video_id, string filepath) throws DatabaseError {
+ update_text_by_id_2(video_id.id, "filename", filepath);
+ }
+
+ public void set_title(VideoID video_id, string? new_title) throws DatabaseError {
+ update_text_by_id_2(video_id.id, "title", new_title != null ? new_title : "");
+ }
+
+ public void set_comment(VideoID video_id, string? new_comment) throws DatabaseError {
+ 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_rating(VideoID video_id, Rating rating) throws DatabaseError {
+ update_int64_by_id_2(video_id.id, "rating", rating.serialize());
+ }
+
+ public void set_flags(VideoID video_id, uint64 flags) throws DatabaseError {
+ update_int64_by_id_2(video_id.id, "flags", (int64) flags);
+ }
+
+ public void update_backlinks(VideoID video_id, string? backlinks) throws DatabaseError {
+ update_text_by_id_2(video_id.id, "backlinks", backlinks != null ? backlinks : "");
+ }
+
+ public void update_is_interpretable(VideoID video_id, bool is_interpretable) throws DatabaseError {
+ update_int_by_id_2(video_id.id, "is_interpretable", (is_interpretable) ? 1 : 0);
+ }
+
+ public bool set_event(VideoID video_id, EventID event_id) {
+ return update_int64_by_id(video_id.id, "event_id", event_id.id);
+ }
+
+ public void remove_by_file(File file) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("DELETE FROM VideoTable WHERE filename=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, file.get_path());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("VideoTable.remove_by_file", res);
+ }
+
+ public void remove(VideoID videoID) throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("DELETE FROM VideoTable WHERE id=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, videoID.id);
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+ if (res != Sqlite.DONE)
+ throw_error("VideoTable.remove", res);
+ }
+
+ public bool is_video_stored(File file) {
+ return get_id(file).is_valid();
+ }
+
+ public VideoID get_id(File file) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT ID FROM VideoTable WHERE filename=?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_text(1, file.get_path());
+ assert(res == Sqlite.OK);
+
+ res = stmt.step();
+
+ return (res == Sqlite.ROW) ? VideoID(stmt.column_int64(0)) : VideoID();
+ }
+
+ public Gee.ArrayList<VideoID?> get_videos() throws DatabaseError {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM VideoTable", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<VideoID?> video_ids = new Gee.ArrayList<VideoID?>();
+ for (;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ throw_error("VideoTable.get_videos", res);
+ }
+
+ video_ids.add(VideoID(stmt.column_int64(0)));
+ }
+
+ return video_ids;
+ }
+
+ private Sqlite.Statement get_duplicate_stmt(File? file, string? md5) {
+ assert(file != null || md5 != null);
+
+ string sql = "SELECT id FROM VideoTable WHERE";
+ bool first = true;
+
+ if (file != null) {
+ sql += " filename=?";
+ first = false;
+ }
+
+ if (md5 != null) {
+ if (!first)
+ sql += " OR ";
+
+ sql += " md5=?";
+ }
+
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2(sql, -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ int col = 1;
+
+ if (file != null) {
+ res = stmt.bind_text(col++, file.get_path());
+ assert(res == Sqlite.OK);
+ }
+
+ if (md5 != null) {
+ res = stmt.bind_text(col++, md5);
+ assert(res == Sqlite.OK);
+ }
+
+ return stmt;
+ }
+
+ public bool has_duplicate(File? file, string? md5) {
+ Sqlite.Statement stmt = get_duplicate_stmt(file, md5);
+ int res = stmt.step();
+
+ if (res == Sqlite.DONE) {
+ // not found
+ return false;
+ } else if (res == Sqlite.ROW) {
+ // at least one found
+ return true;
+ } else {
+ fatal("VideoTable.has_duplicate", res);
+ }
+
+ return false;
+ }
+
+ public VideoID[] get_duplicate_ids(File? file, string? md5) {
+ Sqlite.Statement stmt = get_duplicate_stmt(file, md5);
+
+ VideoID[] ids = new VideoID[0];
+
+ int res = stmt.step();
+ while (res == Sqlite.ROW) {
+ ids += VideoID(stmt.column_int64(0));
+ res = stmt.step();
+ }
+
+ return ids;
+ }
+
+ public Gee.ArrayList<string> get_event_source_ids(EventID event_id) {
+ Sqlite.Statement stmt;
+ int res = db.prepare_v2("SELECT id FROM VideoTable WHERE event_id = ?", -1, out stmt);
+ assert(res == Sqlite.OK);
+
+ res = stmt.bind_int64(1, event_id.id);
+ assert(res == Sqlite.OK);
+
+ Gee.ArrayList<string> result = new Gee.ArrayList<string>();
+ for(;;) {
+ res = stmt.step();
+ if (res == Sqlite.DONE) {
+ break;
+ } else if (res != Sqlite.ROW) {
+ fatal("get_event_source_ids", res);
+
+ break;
+ }
+
+ result.add(VideoID.upgrade_video_id_to_source_id(VideoID(stmt.column_int64(0))));
+ }
+
+ 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);
+ }
+}
+
diff --git a/src/db/mk/db.mk b/src/db/mk/db.mk
new file mode 100644
index 0000000..421961a
--- /dev/null
+++ b/src/db/mk/db.mk
@@ -0,0 +1,35 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Db
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := db
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ DatabaseTable.vala \
+ PhotoTable.vala \
+ EventTable.vala \
+ TagTable.vala \
+ TombstoneTable.vala \
+ VideoTable.vala \
+ VersionTable.vala \
+ SavedSearchDBTable.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/direct/Direct.vala b/src/direct/Direct.vala
new file mode 100644
index 0000000..dd2a847
--- /dev/null
+++ b/src/direct/Direct.vala
@@ -0,0 +1,35 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Direct 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 Direct {
+
+private File? initial_file = null;
+
+public void preconfigure(File initial_file) {
+ Direct.initial_file = initial_file;
+}
+
+public void init() throws Error {
+ assert(initial_file != null);
+
+ DirectPhoto.init(initial_file);
+}
+
+public void terminate() {
+ DirectPhoto.terminate();
+}
+
+}
+
diff --git a/src/direct/DirectPhoto.vala b/src/direct/DirectPhoto.vala
new file mode 100644
index 0000000..98886ba
--- /dev/null
+++ b/src/direct/DirectPhoto.vala
@@ -0,0 +1,316 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * 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 DirectPhoto : Photo {
+ private const int PREVIEW_BEST_FIT = 360;
+
+ public static DirectPhotoSourceCollection global = null;
+
+ public signal void can_rotate_changed(bool b);
+
+ private Gdk.Pixbuf preview = null;
+ private bool loaded = false;
+
+ private DirectPhoto(PhotoRow row) {
+ base (row);
+ }
+
+ /**
+ * @brief Because all transformations are discarded on reimport by design, including
+ * Orientation, a JFIF file that is only rotated or flipped, then saved, has the orientation
+ * change the user made before saving removed (recall that fetch() remembers which images it
+ * has seen before and will only add a file to the file map once; every time it sees it
+ * again after this is considered a reimport). This will set the orientation to the
+ * specified value, fixing up both the row and the backing row.
+ *
+ * @warning Only reimported JFIF files should need this; non-lossy image types have their
+ * actual pixels physically rotated in the file when they're exported.
+ *
+ * @param dest The orientation to set the photo to; usually, this should be a value
+ * obtained by calling get_orientation() prior to export()ing a DirectPhoto.
+ */
+ public void fixup_orientation_after_reimport(Orientation dest) {
+ row.orientation = dest;
+ backing_photo_row.original_orientation = dest;
+ }
+
+ public static void init(File initial_file) {
+ global = new DirectPhotoSourceCollection(initial_file);
+ DirectPhoto photo;
+ string? reason = global.fetch(initial_file, out photo, false);
+ if (reason != null)
+ warning("fetch error: %s", reason);
+ global.add(photo);
+ }
+
+ public static void terminate() {
+ }
+
+ // Gets the dimensions of this photo's pixbuf when scaled to original
+ // size and saves them where get_raw_dimensions can find them.
+ private void save_dims() {
+ try {
+ backing_photo_row.dim = Dimensions.for_pixbuf(get_pixbuf_with_options(Scaling.for_original(),
+ Exception.CROP | Exception.STRAIGHTEN | Exception.ORIENTATION));
+ } catch (Error e) {
+ warning("Dimensions for image %s could not be gotten.", to_string());
+ }
+ }
+
+ // Loads a photo on demand.
+ public ImportResult demand_load() {
+ if (loaded) {
+ save_dims();
+ return ImportResult.SUCCESS;
+ }
+
+ Photo.ReimportMasterState reimport_state;
+ try {
+ prepare_for_reimport_master(out reimport_state);
+ finish_reimport_master(reimport_state);
+ } catch (Error err) {
+ warning("Database error on re-importing image: %s", err.message);
+ return ImportResult.DATABASE_ERROR;
+ }
+
+ loaded = true;
+ save_dims();
+ return ImportResult.SUCCESS;
+ }
+
+ // This method should only be called by DirectPhotoSourceCollection. Use
+ // DirectPhoto.global.fetch to import files into the system.
+ public static ImportResult internal_import(File file, out DirectPhoto photo) {
+ PhotoImportParams params = new PhotoImportParams.create_placeholder(file, ImportID.generate());
+ Photo.create_pre_import(params);
+ PhotoTable.get_instance().add(params.row);
+
+ photo = new DirectPhoto(params.row);
+
+ return ImportResult.SUCCESS;
+ }
+
+ public override Gdk.Pixbuf get_preview_pixbuf(Scaling scaling) throws Error {
+ if (preview == null) {
+ preview = get_thumbnail(PREVIEW_BEST_FIT);
+
+ if (preview == null)
+ preview = get_pixbuf(scaling);
+ }
+
+ return scaling.perform_on_pixbuf(preview, Gdk.InterpType.BILINEAR, true);
+ }
+
+ public override void rotate(Rotation rotation) {
+ can_rotate_now = false;
+ can_rotate_changed(false);
+ base.rotate(rotation);
+ }
+
+ public override Gdk.Pixbuf get_pixbuf(Scaling scaling) throws Error {
+ Gdk.Pixbuf ret = base.get_pixbuf(scaling);
+ can_rotate_changed(true);
+ can_rotate_now = true;
+ return ret;
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return (get_metadata().get_preview_count() == 0) ? null :
+ get_orientation().rotate_pixbuf(get_metadata().get_preview(0).get_pixbuf());
+ }
+
+ protected override void notify_altered(Alteration alteration) {
+ preview = null;
+
+ base.notify_altered(alteration);
+ }
+
+ protected override bool has_user_generated_metadata() {
+ // TODO: implement this method
+ return false;
+ }
+
+ protected override void set_user_metadata_for_export(PhotoMetadata metadata) {
+ // TODO: implement this method, see ticket
+ }
+
+ protected override void apply_user_metadata_for_reimport(PhotoMetadata metadata) {
+ }
+
+ public override bool is_trashed() {
+ // always returns false -- direct-edit mode has no concept of the trash can
+ return false;
+ }
+
+ public override bool is_offline() {
+ // always returns false -- direct-edit mode has no concept of offline photos
+ return false;
+ }
+
+ public override void trash() {
+ // a no-op -- direct-edit mode has no concept of the trash can
+ }
+
+ public override void untrash() {
+ // a no-op -- direct-edit mode has no concept of the trash can
+ }
+
+ public override void mark_offline() {
+ // a no-op -- direct-edit mode has no concept of offline photos
+ }
+
+ public override void mark_online() {
+ // a no-op -- direct-edit mode has no concept of offline photos
+ }
+}
+
+public class DirectPhotoSourceCollection : DatabaseSourceCollection {
+ private const int DISCOVERED_FILES_BATCH_ADD = 500;
+ private Gee.Collection<DirectPhoto> prepared_photos = new Gee.ArrayList<DirectPhoto>();
+ private Gee.HashMap<File, DirectPhoto> file_map = new Gee.HashMap<File, DirectPhoto>(file_hash,
+ file_equal);
+ private DirectoryMonitor monitor;
+
+ public DirectPhotoSourceCollection(File initial_file) {
+ base("DirectPhotoSourceCollection", get_direct_key);
+
+ // only use the monitor for discovery in the specified directory, not its children
+ monitor = new DirectoryMonitor(initial_file.get_parent(), false, false);
+ monitor.file_discovered.connect(on_file_discovered);
+ monitor.discovery_completed.connect(on_discovery_completed);
+
+ monitor.start_discovery();
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is DirectPhoto;
+ }
+
+ private static int64 get_direct_key(DataSource source) {
+ DirectPhoto photo = (DirectPhoto) source;
+ PhotoID photo_id = photo.get_photo_id();
+
+ return photo_id.id;
+ }
+
+ public override void notify_items_added(Gee.Iterable<DataObject> added) {
+ foreach (DataObject object in added) {
+ DirectPhoto photo = (DirectPhoto) object;
+ File file = photo.get_file();
+
+ assert(!file_map.has_key(file));
+
+ file_map.set(file, photo);
+ }
+
+ base.notify_items_added(added);
+ }
+
+ public override void notify_items_removed(Gee.Iterable<DataObject> removed) {
+ foreach (DataObject object in removed) {
+ DirectPhoto photo = (DirectPhoto) object;
+ File file = photo.get_file();
+
+ bool is_removed = file_map.unset(file);
+ assert(is_removed);
+ }
+
+ base.notify_items_removed(removed);
+ }
+
+ public bool has_source_for_file(File file) {
+ return file_map.has_key(file);
+ }
+
+ private void on_file_discovered(File file, FileInfo info) {
+ // skip already-seen files
+ if (has_source_for_file(file))
+ return;
+
+ // only add files that look like photo files we support
+ if (!PhotoFileFormat.is_file_supported(file))
+ return;
+
+ DirectPhoto photo;
+ string? reason = fetch(file, out photo, false);
+ if (reason != null)
+ warning("Error fetching file: %s", reason);
+ prepared_photos.add(photo);
+ if (prepared_photos.size >= DISCOVERED_FILES_BATCH_ADD)
+ flush_prepared_photos();
+ }
+
+ private void on_discovery_completed() {
+ flush_prepared_photos();
+ }
+
+ private void flush_prepared_photos() {
+ add_many(prepared_photos);
+ prepared_photos.clear();
+ }
+
+ public bool has_file(File file) {
+ return file_map.has_key(file);
+ }
+
+ public void reimport_photo(DirectPhoto photo) {
+ photo.discard_prefetched(true);
+ DirectPhoto reimported_photo;
+ fetch(photo.get_file(), out reimported_photo, true);
+ }
+
+ // Returns an error string if unable to fetch, null otherwise
+ public string? fetch(File file, out DirectPhoto photo, bool reimport) {
+ // fetch from the map first, which ensures that only one DirectPhoto exists for each file
+ photo = file_map.get(file);
+ if (photo != null) {
+ string? reason = null;
+
+ if (reimport) {
+ try {
+ Orientation ori_tmp = Orientation.TOP_LEFT;
+ bool should_restore_ori = false;
+
+ if ((photo.only_metadata_changed()) ||
+ (photo.get_file_format() == PhotoFileFormat.JFIF)) {
+ ori_tmp = photo.get_orientation();
+ should_restore_ori = true;
+ }
+
+ Photo.ReimportMasterState reimport_state;
+ if (photo.prepare_for_reimport_master(out reimport_state)) {
+ photo.finish_reimport_master(reimport_state);
+ if (should_restore_ori) {
+ photo.fixup_orientation_after_reimport(ori_tmp);
+ }
+ }
+ else {
+ reason = ImportResult.FILE_ERROR.to_string();
+ }
+ } catch (Error err) {
+ reason = err.message;
+ }
+ }
+
+ return reason;
+ }
+
+ // for DirectPhoto, a fetch on an unknown file is an implicit import into the in-memory
+ // database (which automatically adds the new DirectPhoto object to DirectPhoto.global)
+ ImportResult result = DirectPhoto.internal_import(file, out photo);
+
+ return (result == ImportResult.SUCCESS) ? null : result.to_string();
+ }
+
+ public bool has_file_source(File file) {
+ return file_map.has_key(file);
+ }
+
+ public DirectPhoto? get_file_source(File file) {
+ return file_map.get(file);
+ }
+}
+
diff --git a/src/direct/DirectPhotoPage.vala b/src/direct/DirectPhotoPage.vala
new file mode 100644
index 0000000..b2e130d
--- /dev/null
+++ b/src/direct/DirectPhotoPage.vala
@@ -0,0 +1,580 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * 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 DirectPhotoPage : EditingHostPage {
+ private File initial_file;
+ private DirectViewCollection? view_controller = null;
+ private File current_save_dir;
+ private bool drop_if_dirty = false;
+
+ public DirectPhotoPage(File file) {
+ base (DirectPhoto.global, file.get_basename());
+
+ if (!check_editable_file(file)) {
+ Application.get_instance().panic();
+
+ return;
+ }
+
+ initial_file = file;
+ view_controller = new DirectViewCollection();
+ current_save_dir = file.get_parent();
+
+ DirectPhoto.global.items_altered.connect(on_photos_altered);
+
+ get_view().selection_group_altered.connect(on_selection_group_altered);
+ }
+
+ ~DirectPhotoPage() {
+ DirectPhoto.global.items_altered.disconnect(on_photos_altered);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("direct_context.ui");
+ ui_filenames.add("direct.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry file = { "FileMenu", null, TRANSLATABLE, null, null, null };
+ file.label = _("_File");
+ actions += file;
+
+ Gtk.ActionEntry save = { "Save", Gtk.Stock.SAVE, TRANSLATABLE, "<Ctrl>S", TRANSLATABLE,
+ on_save };
+ save.label = _("_Save");
+ save.tooltip = _("Save photo");
+ actions += save;
+
+ Gtk.ActionEntry save_as = { "SaveAs", Gtk.Stock.SAVE_AS, TRANSLATABLE,
+ "<Ctrl><Shift>S", TRANSLATABLE, on_save_as };
+ save_as.label = _("Save _As...");
+ save_as.tooltip = _("Save photo with a different name");
+ actions += save_as;
+
+ Gtk.ActionEntry send_to = { "SendTo", "document-send", TRANSLATABLE, null,
+ TRANSLATABLE, on_send_to };
+ send_to.label = Resources.SEND_TO_MENU;
+ actions += send_to;
+
+ Gtk.ActionEntry print = { "Print", Gtk.Stock.PRINT, TRANSLATABLE, "<Ctrl>P",
+ TRANSLATABLE, on_print };
+ print.label = Resources.PRINT_MENU;
+ print.tooltip = _("Print the photo to a printer connected to your computer");
+ actions += print;
+
+ Gtk.ActionEntry edit = { "EditMenu", null, TRANSLATABLE, null, null, null };
+ edit.label = _("_Edit");
+ actions += edit;
+
+ Gtk.ActionEntry photo = { "PhotoMenu", null, "", null, null, null };
+ photo.label = _("_Photo");
+ actions += photo;
+
+ Gtk.ActionEntry tools = { "Tools", null, TRANSLATABLE, null, null, null };
+ tools.label = _("T_ools");
+ actions += tools;
+
+ Gtk.ActionEntry prev = { "PrevPhoto", Gtk.Stock.GO_BACK, TRANSLATABLE, null,
+ TRANSLATABLE, on_previous_photo };
+ prev.label = _("_Previous Photo");
+ prev.tooltip = _("Previous Photo");
+ actions += prev;
+
+ Gtk.ActionEntry next = { "NextPhoto", Gtk.Stock.GO_FORWARD, TRANSLATABLE, null,
+ TRANSLATABLE, on_next_photo };
+ next.label = _("_Next Photo");
+ next.tooltip = _("Next Photo");
+ actions += next;
+
+ Gtk.ActionEntry rotate_right = { "RotateClockwise", Resources.CLOCKWISE,
+ TRANSLATABLE, "<Ctrl>R", TRANSLATABLE, on_rotate_clockwise };
+ rotate_right.label = Resources.ROTATE_CW_MENU;
+ rotate_right.tooltip = Resources.ROTATE_CCW_TOOLTIP;
+ actions += rotate_right;
+
+ Gtk.ActionEntry rotate_left = { "RotateCounterclockwise", Resources.COUNTERCLOCKWISE,
+ TRANSLATABLE, "<Ctrl><Shift>R", TRANSLATABLE, on_rotate_counterclockwise };
+ rotate_left.label = Resources.ROTATE_CCW_MENU;
+ rotate_left.tooltip = Resources.ROTATE_CCW_TOOLTIP;
+ actions += rotate_left;
+
+ Gtk.ActionEntry hflip = { "FlipHorizontally", Resources.HFLIP, TRANSLATABLE, null,
+ TRANSLATABLE, on_flip_horizontally };
+ hflip.label = Resources.HFLIP_MENU;
+ actions += hflip;
+
+ Gtk.ActionEntry vflip = { "FlipVertically", Resources.VFLIP, TRANSLATABLE, null,
+ TRANSLATABLE, on_flip_vertically };
+ vflip.label = Resources.VFLIP_MENU;
+ actions += vflip;
+
+ Gtk.ActionEntry enhance = { "Enhance", Resources.ENHANCE, TRANSLATABLE, "<Ctrl>E",
+ TRANSLATABLE, on_enhance };
+ enhance.label = Resources.ENHANCE_MENU;
+ enhance.tooltip = Resources.ENHANCE_TOOLTIP;
+ actions += enhance;
+
+ Gtk.ActionEntry crop = { "Crop", Resources.CROP, TRANSLATABLE, "<Ctrl>O",
+ TRANSLATABLE, toggle_crop };
+ crop.label = Resources.CROP_MENU;
+ crop.tooltip = Resources.CROP_TOOLTIP;
+ actions += crop;
+
+ Gtk.ActionEntry straighten = { "Straighten", Gtk.Stock.REFRESH, TRANSLATABLE, "<Ctrl>A",
+ TRANSLATABLE, toggle_straighten };
+ straighten.label = Resources.STRAIGHTEN_MENU;
+ straighten.tooltip = Resources.STRAIGHTEN_TOOLTIP;
+ actions += straighten;
+
+ Gtk.ActionEntry red_eye = { "RedEye", Resources.REDEYE, TRANSLATABLE, "<Ctrl>Y",
+ TRANSLATABLE, toggle_redeye };
+ red_eye.label = Resources.RED_EYE_MENU;
+ red_eye.tooltip = Resources.RED_EYE_TOOLTIP;
+ actions += red_eye;
+
+ Gtk.ActionEntry adjust = { "Adjust", Resources.ADJUST, TRANSLATABLE, "<Ctrl>D",
+ TRANSLATABLE, toggle_adjust };
+ adjust.label = Resources.ADJUST_MENU;
+ adjust.tooltip = Resources.ADJUST_TOOLTIP;
+ actions += adjust;
+
+ Gtk.ActionEntry revert = { "Revert", Gtk.Stock.REVERT_TO_SAVED, TRANSLATABLE,
+ null, TRANSLATABLE, on_revert };
+ revert.label = Resources.REVERT_MENU;
+ actions += revert;
+
+ Gtk.ActionEntry adjust_date_time = { "AdjustDateTime", null, TRANSLATABLE, null,
+ TRANSLATABLE, on_adjust_date_time };
+ adjust_date_time.label = Resources.ADJUST_DATE_TIME_MENU;
+ actions += adjust_date_time;
+
+ Gtk.ActionEntry set_background = { "SetBackground", null, TRANSLATABLE, "<Ctrl>B",
+ TRANSLATABLE, on_set_background };
+ set_background.label = Resources.SET_BACKGROUND_MENU;
+ set_background.tooltip = Resources.SET_BACKGROUND_TOOLTIP;
+ actions += set_background;
+
+ Gtk.ActionEntry view = { "ViewMenu", null, TRANSLATABLE, null, null, null };
+ view.label = _("_View");
+ actions += view;
+
+ Gtk.ActionEntry help = { "HelpMenu", null, TRANSLATABLE, null, null, null };
+ help.label = _("_Help");
+ actions += help;
+
+ Gtk.ActionEntry increase_size = { "IncreaseSize", Gtk.Stock.ZOOM_IN, TRANSLATABLE,
+ "<Ctrl>plus", TRANSLATABLE, on_increase_size };
+ increase_size.label = _("Zoom _In");
+ increase_size.tooltip = _("Increase the magnification of the photo");
+ actions += increase_size;
+
+ Gtk.ActionEntry decrease_size = { "DecreaseSize", Gtk.Stock.ZOOM_OUT, TRANSLATABLE,
+ "<Ctrl>minus", TRANSLATABLE, on_decrease_size };
+ decrease_size.label = _("Zoom _Out");
+ decrease_size.tooltip = _("Decrease the magnification of the photo");
+ actions += decrease_size;
+
+ Gtk.ActionEntry best_fit = { "ZoomFit", Gtk.Stock.ZOOM_FIT, TRANSLATABLE,
+ "<Ctrl>0", TRANSLATABLE, snap_zoom_to_min };
+ best_fit.label = _("Fit to _Page");
+ best_fit.tooltip = _("Zoom the photo to fit on the screen");
+ actions += best_fit;
+
+ Gtk.ActionEntry actual_size = { "Zoom100", Gtk.Stock.ZOOM_100, TRANSLATABLE,
+ "<Ctrl>1", TRANSLATABLE, snap_zoom_to_isomorphic };
+ /// xgettext:no-c-format
+ actual_size.label = _("Zoom _100%");
+ /// xgettext:no-c-format
+ actual_size.tooltip = _("Zoom the photo to 100% magnification");
+ actions += actual_size;
+
+ Gtk.ActionEntry max_size = { "Zoom200", null, TRANSLATABLE,
+ "<Ctrl>2", TRANSLATABLE, snap_zoom_to_max };
+ /// xgettext:no-c-format
+ max_size.label = _("Zoom _200%");
+ /// xgettext:no-c-format
+ max_size.tooltip = _("Zoom the photo to 200% magnification");
+ actions += max_size;
+
+ return actions;
+ }
+
+ protected override InjectionGroup[] init_collect_injection_groups() {
+ InjectionGroup[] groups = base.init_collect_injection_groups();
+
+ InjectionGroup print_group = new InjectionGroup("/MenuBar/FileMenu/PrintPlaceholder");
+ print_group.add_menu_item("Print");
+
+ groups += print_group;
+
+ InjectionGroup bg_group = new InjectionGroup("/MenuBar/FileMenu/SetBackgroundPlaceholder");
+ bg_group.add_menu_item("SetBackground");
+
+ groups += bg_group;
+
+ return groups;
+ }
+
+ private static bool check_editable_file(File file) {
+ if (!FileUtils.test(file.get_path(), FileTest.EXISTS))
+ AppWindow.error_message(_("%s does not exist.").printf(file.get_path()));
+ else if (!FileUtils.test(file.get_path(), FileTest.IS_REGULAR))
+ AppWindow.error_message(_("%s is not a file.").printf(file.get_path()));
+ else if (!PhotoFileFormat.is_file_supported(file))
+ AppWindow.error_message(_("%s does not support the file format of\n%s.").printf(
+ Resources.APP_TITLE, file.get_path()));
+ else
+ return true;
+
+ return false;
+ }
+
+ public override void realize() {
+ if (base.realize != null)
+ base.realize();
+
+ DirectPhoto? photo = DirectPhoto.global.get_file_source(initial_file);
+
+ display_mirror_of(view_controller, photo);
+ initial_file = null;
+ }
+
+ protected override void photo_changing(Photo new_photo) {
+ if (get_photo() != null) {
+ DirectPhoto tmp = get_photo() as DirectPhoto;
+
+ if (tmp != null) {
+ tmp.can_rotate_changed.disconnect(on_dphoto_can_rotate_changed);
+ }
+ }
+
+ ((DirectPhoto) new_photo).demand_load();
+
+ DirectPhoto tmp = new_photo as DirectPhoto;
+
+ if (tmp != null) {
+ tmp.can_rotate_changed.connect(on_dphoto_can_rotate_changed);
+ }
+ }
+
+ public File get_current_file() {
+ return get_photo().get_file();
+ }
+
+ protected override bool on_context_buttonpress(Gdk.EventButton event) {
+ Gtk.Menu context_menu = (Gtk.Menu) ui.get_widget("/DirectContextMenu");
+ popup_context_menu(context_menu, event);
+
+ return true;
+ }
+
+ private void update_zoom_menu_item_sensitivity() {
+ set_action_sensitive("IncreaseSize", !get_zoom_state().is_max() && !get_photo_missing());
+ set_action_sensitive("DecreaseSize", !get_zoom_state().is_default() && !get_photo_missing());
+ }
+
+ protected override void on_increase_size() {
+ base.on_increase_size();
+
+ update_zoom_menu_item_sensitivity();
+ }
+
+ protected override void on_decrease_size() {
+ base.on_decrease_size();
+
+ update_zoom_menu_item_sensitivity();
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> map) {
+ bool contains = false;
+ if (has_photo()) {
+ Photo photo = get_photo();
+ foreach (DataObject object in map.keys) {
+ if (((Photo) object) == photo) {
+ contains = true;
+
+ break;
+ }
+ }
+ }
+
+ bool sensitive = has_photo() && !get_photo_missing();
+ if (sensitive)
+ sensitive = contains;
+
+ set_action_sensitive("Save", sensitive && get_photo().get_file_format().can_write());
+ set_action_sensitive("Revert", sensitive);
+ }
+
+ private void on_selection_group_altered() {
+ // On EditingHostPage, the displayed photo is always selected, so this signal is fired
+ // whenever a new photo is displayed (which even happens on an in-place save; the changes
+ // are written and a new DirectPhoto is loaded into its place).
+ //
+ // In every case, reset the CommandManager, as the command stack is not valid against this
+ // new file.
+ get_command_manager().reset();
+ }
+
+ protected override bool on_double_click(Gdk.EventButton event) {
+ AppWindow.get_instance().end_fullscreen();
+
+ return base.on_double_click(event);
+ }
+
+ protected override void update_ui(bool missing) {
+ bool sensitivity = !missing;
+
+ set_action_sensitive("Save", sensitivity);
+ set_action_sensitive("SaveAs", sensitivity);
+ set_action_sensitive("SendTo", sensitivity);
+ set_action_sensitive("Publish", sensitivity);
+ set_action_sensitive("Print", sensitivity);
+ set_action_sensitive("CommonJumpToFile", sensitivity);
+
+ set_action_sensitive("CommonUndo", sensitivity);
+ set_action_sensitive("CommonRedo", sensitivity);
+
+ set_action_sensitive("IncreaseSize", sensitivity);
+ set_action_sensitive("DecreaseSize", sensitivity);
+ set_action_sensitive("ZoomFit", sensitivity);
+ set_action_sensitive("Zoom100", sensitivity);
+ set_action_sensitive("Zoom200", sensitivity);
+
+ set_action_sensitive("RotateClockwise", sensitivity);
+ set_action_sensitive("RotateCounterclockwise", sensitivity);
+ set_action_sensitive("FlipHorizontally", sensitivity);
+ set_action_sensitive("FlipVertically", sensitivity);
+ set_action_sensitive("Enhance", sensitivity);
+ set_action_sensitive("Crop", sensitivity);
+ set_action_sensitive("Straighten", sensitivity);
+ set_action_sensitive("RedEye", sensitivity);
+ set_action_sensitive("Adjust", sensitivity);
+ set_action_sensitive("Revert", sensitivity);
+ set_action_sensitive("AdjustDateTime", sensitivity);
+ set_action_sensitive("Fullscreen", sensitivity);
+
+ set_action_sensitive("SetBackground", has_photo() && !get_photo_missing());
+
+ base.update_ui(missing);
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ bool multiple = get_view().get_count() > 1;
+ bool revert_possible = has_photo() ? get_photo().has_transformations()
+ && !get_photo_missing() : false;
+ bool rotate_possible = has_photo() ? is_rotate_available(get_photo()) : false;
+ bool enhance_possible = has_photo() ? is_enhance_available(get_photo()) : false;
+
+ set_action_sensitive("PrevPhoto", multiple);
+ set_action_sensitive("NextPhoto", multiple);
+ set_action_sensitive("RotateClockwise", rotate_possible);
+ set_action_sensitive("RotateCounterclockwise", rotate_possible);
+ set_action_sensitive("FlipHorizontally", rotate_possible);
+ set_action_sensitive("FlipVertically", rotate_possible);
+ set_action_sensitive("Revert", revert_possible);
+ set_action_sensitive("Enhance", enhance_possible);
+
+ set_action_sensitive("SetBackground", has_photo());
+
+ if (has_photo()) {
+ set_action_sensitive("Crop", EditingTools.CropTool.is_available(get_photo(), Scaling.for_original()));
+ set_action_sensitive("RedEye", EditingTools.RedeyeTool.is_available(get_photo(),
+ Scaling.for_original()));
+ }
+
+ // can't write to raws, and trapping the output JPEG here is tricky,
+ // so don't allow date/time changes here.
+ if (get_photo() != null) {
+ set_action_sensitive("AdjustDateTime", (get_photo().get_file_format() != PhotoFileFormat.RAW));
+ } else {
+ set_action_sensitive("AdjustDateTime", false);
+ }
+
+ base.update_actions(selected_count, count);
+ }
+
+ private bool check_ok_to_close_photo(Photo photo) {
+ if (!photo.has_alterations())
+ return true;
+
+ if (drop_if_dirty) {
+ // need to remove transformations, or else they stick around in memory (reappearing
+ // if the user opens the file again)
+ photo.remove_all_transformations();
+
+ return true;
+ }
+
+ bool is_writeable = get_photo().get_file_format().can_write();
+ string save_option = is_writeable ? _("_Save") : _("_Save a Copy");
+
+ Gtk.ResponseType response = AppWindow.negate_affirm_cancel_question(
+ _("Lose changes to %s?").printf(photo.get_basename()), save_option,
+ _("Close _without Saving"));
+
+ if (response == Gtk.ResponseType.YES)
+ photo.remove_all_transformations();
+ else if (response == Gtk.ResponseType.NO) {
+ if (is_writeable)
+ save(photo.get_file(), 0, ScaleConstraint.ORIGINAL, Jpeg.Quality.HIGH,
+ get_photo().get_file_format());
+ else
+ on_save_as();
+ } else if ((response == Gtk.ResponseType.CANCEL) || (response == Gtk.ResponseType.DELETE_EVENT) ||
+ (response == Gtk.ResponseType.CLOSE)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public bool check_quit() {
+ return check_ok_to_close_photo(get_photo());
+ }
+
+ protected override bool confirm_replace_photo(Photo? old_photo, Photo new_photo) {
+ return (old_photo != null) ? check_ok_to_close_photo(old_photo) : true;
+ }
+
+ private void save(File dest, int scale, ScaleConstraint constraint, Jpeg.Quality quality,
+ PhotoFileFormat format, bool copy_unmodified = false, bool save_metadata = true) {
+ Scaling scaling = Scaling.for_constraint(constraint, scale, false);
+
+ try {
+ get_photo().export(dest, scaling, quality, format, copy_unmodified, save_metadata);
+ } catch (Error err) {
+ AppWindow.error_message(_("Error while saving to %s: %s").printf(dest.get_path(),
+ err.message));
+
+ return;
+ }
+
+ // Fetch the DirectPhoto and reimport.
+ DirectPhoto photo;
+ DirectPhoto.global.fetch(dest, out photo, true);
+
+ DirectView tmp_view = new DirectView(photo);
+ view_controller.add(tmp_view);
+
+ DirectPhoto.global.reimport_photo(photo);
+ display_mirror_of(view_controller, photo);
+ }
+
+ private void on_save() {
+ if (!get_photo().has_alterations() || !get_photo().get_file_format().can_write() ||
+ get_photo_missing())
+ return;
+
+ // save full-sized version right on top of the current file
+ save(get_photo().get_file(), 0, ScaleConstraint.ORIGINAL, Jpeg.Quality.HIGH,
+ get_photo().get_file_format());
+ }
+
+ private void on_save_as() {
+ ExportDialog export_dialog = new ExportDialog(_("Save As"));
+
+ int scale;
+ ScaleConstraint constraint;
+ ExportFormatParameters export_params = ExportFormatParameters.last();
+ if (!export_dialog.execute(out scale, out constraint, ref export_params))
+ return;
+
+ string filename = get_photo().get_export_basename_for_parameters(export_params);
+ PhotoFileFormat effective_export_format =
+ get_photo().get_export_format_for_parameters(export_params);
+
+ string[] output_format_extensions =
+ effective_export_format.get_properties().get_known_extensions();
+ Gtk.FileFilter output_format_filter = new Gtk.FileFilter();
+ foreach(string extension in output_format_extensions) {
+ string uppercase_extension = extension.up();
+ output_format_filter.add_pattern("*." + extension);
+ output_format_filter.add_pattern("*." + uppercase_extension);
+ }
+
+ Gtk.FileChooserDialog save_as_dialog = new Gtk.FileChooserDialog(_("Save As"),
+ AppWindow.get_instance(), Gtk.FileChooserAction.SAVE, Gtk.Stock.CANCEL,
+ Gtk.ResponseType.CANCEL, Gtk.Stock.OK, Gtk.ResponseType.OK);
+ save_as_dialog.set_select_multiple(false);
+ save_as_dialog.set_current_name(filename);
+ save_as_dialog.set_current_folder(current_save_dir.get_path());
+ save_as_dialog.add_filter(output_format_filter);
+ save_as_dialog.set_do_overwrite_confirmation(true);
+ save_as_dialog.set_local_only(false);
+
+ int response = save_as_dialog.run();
+ if (response == Gtk.ResponseType.OK) {
+ // flag to prevent asking user about losing changes to the old file (since they'll be
+ // loaded right into the new one)
+ drop_if_dirty = true;
+ save(File.new_for_uri(save_as_dialog.get_uri()), scale, constraint, export_params.quality,
+ effective_export_format, export_params.mode == ExportFormatMode.UNMODIFIED,
+ export_params.export_metadata);
+ drop_if_dirty = false;
+
+ current_save_dir = File.new_for_path(save_as_dialog.get_current_folder());
+ }
+
+ save_as_dialog.destroy();
+ }
+
+ private void on_send_to() {
+ if (has_photo())
+ DesktopIntegration.send_to((Gee.Collection<Photo>) get_view().get_selected_sources());
+ }
+
+ protected override bool on_app_key_pressed(Gdk.EventKey event) {
+ bool handled = true;
+
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "bracketright":
+ activate_action("RotateClockwise");
+ break;
+
+ case "bracketleft":
+ activate_action("RotateClockwise");
+ break;
+
+ default:
+ handled = false;
+ break;
+ }
+
+ return handled ? true : base.on_app_key_pressed(event);
+ }
+
+ private void on_print() {
+ if (get_view().get_selected_count() > 0) {
+ PrintManager.get_instance().spool_photo(
+ (Gee.Collection<Photo>) get_view().get_selected_sources_of_type(typeof(Photo)));
+ }
+ }
+
+ private void on_dphoto_can_rotate_changed(bool should_allow_rotation) {
+ enable_rotate(should_allow_rotation);
+ }
+
+ protected override DataView create_photo_view(DataSource source) {
+ return new DirectView((DirectPhoto) source);
+ }
+}
+
+public class DirectFullscreenPhotoPage : DirectPhotoPage {
+ public DirectFullscreenPhotoPage(File file) {
+ base(file);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ // We intentionally avoid calling the base class implementation since we don't want
+ // direct.ui.
+ ui_filenames.add("direct_context.ui");
+ }
+}
diff --git a/src/direct/DirectView.vala b/src/direct/DirectView.vala
new file mode 100644
index 0000000..a36ec68
--- /dev/null
+++ b/src/direct/DirectView.vala
@@ -0,0 +1,50 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 DirectView : DataView {
+ private File file;
+ private string? collate_key = null;
+
+ public DirectView(DirectPhoto source) {
+ base ((DataSource) source);
+
+ this.file = ((Photo) source).get_file();
+ }
+
+ public File get_file() {
+ return file;
+ }
+
+ public string get_collate_key() {
+ if (collate_key == null)
+ collate_key = file.get_basename().collate_key_for_filename();
+
+ return collate_key;
+ }
+}
+
+private class DirectViewCollection : ViewCollection {
+ private class DirectViewManager : ViewManager {
+ public override DataView create_view(DataSource source) {
+ return new DirectView((DirectPhoto) source);
+ }
+ }
+
+ public DirectViewCollection() {
+ base ("DirectViewCollection");
+
+ set_comparator(filename_comparator, null);
+ monitor_source_collection(DirectPhoto.global, new DirectViewManager(), null);
+ }
+
+ private static int64 filename_comparator(void *a, void *b) {
+ DirectView *aview = (DirectView *) a;
+ DirectView *bview = (DirectView *) b;
+
+ return strcmp(aview->get_collate_key(), bview->get_collate_key());
+ }
+}
+
diff --git a/src/direct/DirectWindow.vala b/src/direct/DirectWindow.vala
new file mode 100644
index 0000000..b339ac4
--- /dev/null
+++ b/src/direct/DirectWindow.vala
@@ -0,0 +1,98 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class DirectWindow : AppWindow {
+ private DirectPhotoPage direct_photo_page;
+
+ public DirectWindow(File file) {
+ direct_photo_page = new DirectPhotoPage(file);
+ direct_photo_page.get_view().items_altered.connect(on_photo_changed);
+ direct_photo_page.get_view().items_state_changed.connect(on_photo_changed);
+
+ set_current_page(direct_photo_page);
+
+ update_title(file, false);
+
+ direct_photo_page.switched_to();
+
+ // simple layout: menu on top, photo in center, toolbar along bottom (mimicking the
+ // PhotoPage in the library, but without the sidebar)
+ Gtk.Box layout = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ layout.pack_start(direct_photo_page.get_menubar(), false, false, 0);
+ layout.pack_start(direct_photo_page, true, true, 0);
+ layout.pack_end(direct_photo_page.get_toolbar(), false, false, 0);
+
+ add(layout);
+ }
+
+ public static DirectWindow get_app() {
+ return (DirectWindow) instance;
+ }
+
+ public DirectPhotoPage get_direct_page() {
+ return (DirectPhotoPage) get_current_page();
+ }
+
+ public void update_title(File file, bool modified) {
+ title = "%s%s (%s) - %s".printf((modified) ? "*" : "", file.get_basename(),
+ get_display_pathname(file.get_parent()), Resources.APP_TITLE);
+ }
+
+ protected override void on_fullscreen() {
+ File file = get_direct_page().get_current_file();
+
+ go_fullscreen(new DirectFullscreenPhotoPage(file));
+ }
+
+ public override string get_app_role() {
+ return Resources.APP_DIRECT_ROLE;
+ }
+
+ private void on_photo_changed() {
+ Photo? photo = direct_photo_page.get_photo();
+ if (photo != null)
+ update_title(photo.get_file(), photo.has_alterations());
+ }
+
+ protected override void on_quit() {
+ if (!get_direct_page().check_quit())
+ return;
+
+ Config.Facade.get_instance().set_direct_window_state(maximized, dimensions);
+
+ base.on_quit();
+ }
+
+ public override bool delete_event(Gdk.EventAny event) {
+ if (!get_direct_page().check_quit())
+ return true;
+
+ return (base.delete_event != null) ? base.delete_event(event) : false;
+ }
+
+ public override bool button_press_event(Gdk.EventButton event) {
+ if (event.type == Gdk.EventType.2BUTTON_PRESS) {
+ on_fullscreen();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ // check for an escape
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ on_quit();
+
+ return true;
+ }
+
+ // ...then let the base class take over
+ return (base.key_press_event != null) ? base.key_press_event(event) : false;
+ }
+}
+
diff --git a/src/direct/mk/direct.mk b/src/direct/mk/direct.mk
new file mode 100644
index 0000000..4c0f226
--- /dev/null
+++ b/src/direct/mk/direct.mk
@@ -0,0 +1,36 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Direct
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := direct
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ DirectWindow.vala \
+ DirectPhoto.vala \
+ DirectPhotoPage.vala \
+ DirectView.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Db \
+ Util \
+ Photos \
+ Slideshow \
+ Core
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/editing_tools/EditingTools.vala b/src/editing_tools/EditingTools.vala
new file mode 100644
index 0000000..b06dbf4
--- /dev/null
+++ b/src/editing_tools/EditingTools.vala
@@ -0,0 +1,2934 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 EditingTools 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 EditingTools {
+
+// preconfigure may be deleted if not used.
+public void preconfigure() {
+}
+
+public void init() throws Error {
+}
+
+public void terminate() {
+}
+
+public abstract class EditingToolWindow : Gtk.Window {
+ private const int FRAME_BORDER = 6;
+
+ private Gtk.Frame layout_frame = new Gtk.Frame(null);
+ private bool user_moved = false;
+
+ public EditingToolWindow(Gtk.Window container) {
+ // needed so that windows will appear properly in fullscreen mode
+ type_hint = Gdk.WindowTypeHint.UTILITY;
+
+ set_decorated(false);
+ set_transient_for(container);
+
+ Gtk.Frame outer_frame = new Gtk.Frame(null);
+ outer_frame.set_border_width(0);
+ outer_frame.set_shadow_type(Gtk.ShadowType.OUT);
+
+ layout_frame.set_border_width(FRAME_BORDER);
+ layout_frame.set_shadow_type(Gtk.ShadowType.NONE);
+
+ outer_frame.add(layout_frame);
+ base.add(outer_frame);
+
+ add_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.KEY_PRESS_MASK);
+ focus_on_map = true;
+ set_accept_focus(true);
+ set_can_focus(true);
+ set_has_resize_grip(false);
+
+ // Needed to prevent the (spurious) 'This event was synthesised outside of GDK'
+ // warnings after a keypress.
+ Log.set_handler("Gdk", LogLevelFlags.LEVEL_WARNING, suppress_warnings);
+ }
+
+ ~EditingToolWindow() {
+ Log.set_handler("Gdk", LogLevelFlags.LEVEL_WARNING, Log.default_handler);
+ }
+
+ public override void add(Gtk.Widget widget) {
+ layout_frame.add(widget);
+ }
+
+ public bool has_user_moved() {
+ return user_moved;
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ if (base.key_press_event(event)) {
+ return true;
+ }
+ return AppWindow.get_instance().key_press_event(event);
+ }
+
+ public override bool button_press_event(Gdk.EventButton event) {
+ // LMB only
+ if (event.button != 1)
+ return (base.button_press_event != null) ? base.button_press_event(event) : true;
+
+ begin_move_drag((int) event.button, (int) event.x_root, (int) event.y_root, event.time);
+ user_moved = true;
+
+ return true;
+ }
+
+ public override void realize() {
+ set_opacity(Resources.TRANSIENT_WINDOW_OPACITY);
+
+ base.realize();
+ }
+}
+
+// The PhotoCanvas is an interface object between an EditingTool and its host. It provides objects
+// and primitives for an EditingTool to obtain information about the image, to draw on the host's
+// canvas, and to be signalled when the canvas and its pixbuf changes (is resized).
+public abstract class PhotoCanvas {
+ private Gtk.Window container;
+ private Gdk.Window drawing_window;
+ private Photo photo;
+ private Cairo.Context default_ctx;
+ private Dimensions surface_dim;
+ private Cairo.Surface scaled;
+ private Gdk.Pixbuf scaled_pixbuf;
+ private Gdk.Rectangle scaled_position;
+
+ public PhotoCanvas(Gtk.Window container, Gdk.Window drawing_window, Photo photo,
+ Cairo.Context default_ctx, Dimensions surface_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) {
+ this.container = container;
+ this.drawing_window = drawing_window;
+ this.photo = photo;
+ this.default_ctx = default_ctx;
+ this.surface_dim = surface_dim;
+ this.scaled_position = scaled_position;
+ this.scaled_pixbuf = scaled;
+ this.scaled = pixbuf_to_surface(default_ctx, scaled, scaled_position);
+ }
+
+ public signal void new_surface(Cairo.Context ctx, Dimensions dim);
+
+ public signal void resized_scaled_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled,
+ Gdk.Rectangle scaled_position);
+
+ public Gdk.Rectangle unscaled_to_raw_rect(Gdk.Rectangle rectangle) {
+ return photo.unscaled_to_raw_rect(rectangle);
+ }
+
+ public Gdk.Point active_to_unscaled_point(Gdk.Point active_point) {
+ Gdk.Rectangle scaled_position = get_scaled_pixbuf_position();
+ Dimensions unscaled_dims = photo.get_dimensions();
+
+ double scale_factor_x = ((double) unscaled_dims.width) /
+ ((double) scaled_position.width);
+ double scale_factor_y = ((double) unscaled_dims.height) /
+ ((double) scaled_position.height);
+
+ Gdk.Point result = {0};
+ result.x = (int)(((double) active_point.x) * scale_factor_x + 0.5);
+ result.y = (int)(((double) active_point.y) * scale_factor_y + 0.5);
+
+ return result;
+ }
+
+ public Gdk.Rectangle active_to_unscaled_rect(Gdk.Rectangle active_rect) {
+ Gdk.Point upper_left = {0};
+ Gdk.Point lower_right = {0};
+ upper_left.x = active_rect.x;
+ upper_left.y = active_rect.y;
+ lower_right.x = upper_left.x + active_rect.width;
+ lower_right.y = upper_left.y + active_rect.height;
+
+ upper_left = active_to_unscaled_point(upper_left);
+ lower_right = active_to_unscaled_point(lower_right);
+
+ Gdk.Rectangle unscaled_rect = Gdk.Rectangle();
+ unscaled_rect.x = upper_left.x;
+ unscaled_rect.y = upper_left.y;
+ unscaled_rect.width = lower_right.x - upper_left.x;
+ unscaled_rect.height = lower_right.y - upper_left.y;
+
+ return unscaled_rect;
+ }
+
+ public Gdk.Point user_to_active_point(Gdk.Point user_point) {
+ Gdk.Rectangle active_offsets = get_scaled_pixbuf_position();
+
+ Gdk.Point result = {0};
+ result.x = user_point.x - active_offsets.x;
+ result.y = user_point.y - active_offsets.y;
+
+ return result;
+ }
+
+ public Gdk.Rectangle user_to_active_rect(Gdk.Rectangle user_rect) {
+ Gdk.Point upper_left = {0};
+ Gdk.Point lower_right = {0};
+ upper_left.x = user_rect.x;
+ upper_left.y = user_rect.y;
+ lower_right.x = upper_left.x + user_rect.width;
+ lower_right.y = upper_left.y + user_rect.height;
+
+ upper_left = user_to_active_point(upper_left);
+ lower_right = user_to_active_point(lower_right);
+
+ Gdk.Rectangle active_rect = Gdk.Rectangle();
+ active_rect.x = upper_left.x;
+ active_rect.y = upper_left.y;
+ active_rect.width = lower_right.x - upper_left.x;
+ active_rect.height = lower_right.y - upper_left.y;
+
+ return active_rect;
+ }
+
+ public Photo get_photo() {
+ return photo;
+ }
+
+ public Gtk.Window get_container() {
+ return container;
+ }
+
+ public Gdk.Window get_drawing_window() {
+ return drawing_window;
+ }
+
+ public Cairo.Context get_default_ctx() {
+ return default_ctx;
+ }
+
+ public Dimensions get_surface_dim() {
+ return surface_dim;
+ }
+
+ public Scaling get_scaling() {
+ return Scaling.for_viewport(surface_dim, false);
+ }
+
+ public void set_surface(Cairo.Context default_ctx, Dimensions surface_dim) {
+ this.default_ctx = default_ctx;
+ this.surface_dim = surface_dim;
+
+ new_surface(default_ctx, surface_dim);
+ }
+
+ public Cairo.Surface get_scaled_surface() {
+ return scaled;
+ }
+
+ public Gdk.Pixbuf get_scaled_pixbuf() {
+ return scaled_pixbuf;
+ }
+
+ public Gdk.Rectangle get_scaled_pixbuf_position() {
+ return scaled_position;
+ }
+
+ public void resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) {
+ this.scaled = pixbuf_to_surface(default_ctx, scaled, scaled_position);
+ this.scaled_pixbuf = scaled;
+ this.scaled_position = scaled_position;
+
+ resized_scaled_pixbuf(old_dim, scaled, scaled_position);
+ }
+
+ public abstract void repaint();
+
+ // Because the editing tool should not have any need to draw on the gutters outside the photo,
+ // and it's a pain to constantly calculate where it's laid out on the drawable, these convenience
+ // methods automatically adjust for its position.
+ //
+ // If these methods are not used, all painting to the drawable should be offet by
+ // get_scaled_pixbuf_position().x and get_scaled_pixbuf_position().y
+ public void paint_pixbuf(Gdk.Pixbuf pixbuf) {
+ default_ctx.save();
+
+ // paint black background
+ set_source_color_from_string(default_ctx, "#000");
+ default_ctx.rectangle(0, 0, surface_dim.width, surface_dim.height);
+ default_ctx.fill();
+
+ // paint the actual image
+ Gdk.cairo_set_source_pixbuf(default_ctx, pixbuf, scaled_position.x, scaled_position.y);
+ default_ctx.rectangle(scaled_position.x, scaled_position.y,
+ pixbuf.get_width(), pixbuf.get_height());
+ default_ctx.fill();
+ default_ctx.restore();
+ }
+
+ public void paint_pixbuf_area(Gdk.Pixbuf pixbuf, Box source_area) {
+ default_ctx.save();
+ if (pixbuf.get_has_alpha()) {
+ set_source_color_from_string(default_ctx, "#000");
+ default_ctx.rectangle(scaled_position.x + source_area.left,
+ scaled_position.y + source_area.top,
+ source_area.get_width(), source_area.get_height());
+ default_ctx.fill();
+
+ }
+ Gdk.cairo_set_source_pixbuf(default_ctx, pixbuf, scaled_position.x,
+ scaled_position.y);
+ default_ctx.rectangle(scaled_position.x + source_area.left,
+ scaled_position.y + source_area.top,
+ source_area.get_width(), source_area.get_height());
+ default_ctx.fill();
+ default_ctx.restore();
+ }
+
+ // Paint a surface on top of the photo
+ public void paint_surface(Cairo.Surface surface, bool over) {
+ default_ctx.save();
+ if (over == false)
+ default_ctx.set_operator(Cairo.Operator.SOURCE);
+ else
+ default_ctx.set_operator(Cairo.Operator.OVER);
+
+ default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y);
+ default_ctx.paint();
+ default_ctx.set_source_surface(surface, scaled_position.x, scaled_position.y);
+ default_ctx.paint();
+ default_ctx.restore();
+ }
+
+ public void paint_surface_area(Cairo.Surface surface, Box source_area, bool over) {
+ default_ctx.save();
+ if (over == false)
+ default_ctx.set_operator(Cairo.Operator.SOURCE);
+ else
+ default_ctx.set_operator(Cairo.Operator.OVER);
+
+ default_ctx.set_source_surface(scaled, scaled_position.x, scaled_position.y);
+ default_ctx.rectangle(scaled_position.x + source_area.left,
+ scaled_position.y + source_area.top,
+ source_area.get_width(), source_area.get_height());
+ default_ctx.fill();
+
+ default_ctx.set_source_surface(surface, scaled_position.x, scaled_position.y);
+ default_ctx.rectangle(scaled_position.x + source_area.left,
+ scaled_position.y + source_area.top,
+ source_area.get_width(), source_area.get_height());
+ default_ctx.fill();
+ default_ctx.restore();
+ }
+
+ public void draw_box(Cairo.Context ctx, Box box) {
+ Gdk.Rectangle rect = box.get_rectangle();
+ rect.x += scaled_position.x;
+ rect.y += scaled_position.y;
+
+ ctx.rectangle(rect.x + 0.5, rect.y + 0.5, rect.width - 1, rect.height - 1);
+ ctx.stroke();
+ }
+
+ public void draw_text(Cairo.Context ctx, string text, int x, int y, bool use_scaled_pos = true) {
+ if (use_scaled_pos) {
+ x += scaled_position.x;
+ y += scaled_position.y;
+ }
+ Cairo.TextExtents extents;
+ ctx.text_extents(text, out extents);
+ x -= (int) extents.width / 2;
+
+ set_source_color_from_string(ctx, Resources.ONIMAGE_FONT_BACKGROUND);
+
+ int pane_border = 5; // border around edge of pane in pixels
+ ctx.rectangle(x - pane_border, y - pane_border - extents.height,
+ extents.width + 2 * pane_border,
+ extents.height + 2 * pane_border);
+ ctx.fill();
+
+ ctx.move_to(x, y);
+ set_source_color_from_string(ctx, Resources.ONIMAGE_FONT_COLOR);
+ ctx.show_text(text);
+ }
+
+ /**
+ * Draw a horizontal line into the specified Cairo context at the specified position, taking
+ * into account the scaled position of the image unless directed otherwise.
+ *
+ * @param ctx The drawing context of the surface we're drawing to.
+ * @param x The horizontal position to place the line at.
+ * @param y The vertical position to place the line at.
+ * @param width The length of the line.
+ * @param use_scaled_pos Whether to use absolute window positioning or take into account the
+ * position of the scaled image.
+ */
+ public void draw_horizontal_line(Cairo.Context ctx, int x, int y, int width, bool use_scaled_pos = true) {
+ if (use_scaled_pos) {
+ x += scaled_position.x;
+ y += scaled_position.y;
+ }
+
+ ctx.move_to(x + 0.5, y + 0.5);
+ ctx.line_to(x + width - 1, y + 0.5);
+ ctx.stroke();
+ }
+
+ /**
+ * Draw a vertical line into the specified Cairo context at the specified position, taking
+ * into account the scaled position of the image unless directed otherwise.
+ *
+ * @param ctx The drawing context of the surface we're drawing to.
+ * @param x The horizontal position to place the line at.
+ * @param y The vertical position to place the line at.
+ * @param width The length of the line.
+ * @param use_scaled_pos Whether to use absolute window positioning or take into account the
+ * position of the scaled image.
+ */
+ public void draw_vertical_line(Cairo.Context ctx, int x, int y, int height, bool use_scaled_pos = true) {
+ if (use_scaled_pos) {
+ x += scaled_position.x;
+ y += scaled_position.y;
+ }
+
+ ctx.move_to(x + 0.5, y + 0.5);
+ ctx.line_to(x + 0.5, y + height - 1);
+ ctx.stroke();
+ }
+
+ public void erase_horizontal_line(int x, int y, int width) {
+ 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);
+ default_ctx.fill();
+
+ default_ctx.restore();
+ }
+
+ public void draw_circle(Cairo.Context ctx, int active_center_x, int active_center_y,
+ int radius) {
+ int center_x = active_center_x + scaled_position.x;
+ int center_y = active_center_y + scaled_position.y;
+
+ ctx.arc(center_x, center_y, radius, 0, 2 * GLib.Math.PI);
+ ctx.stroke();
+ }
+
+ public void erase_vertical_line(int x, int y, int height) {
+ default_ctx.save();
+
+ // 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
+ // a region to erase.
+ 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);
+ default_ctx.fill();
+
+ default_ctx.restore();
+ }
+
+ public void erase_box(Box box) {
+ erase_horizontal_line(box.left, box.top, box.get_width());
+ erase_horizontal_line(box.left, box.bottom, box.get_width());
+
+ erase_vertical_line(box.left, box.top, box.get_height());
+ erase_vertical_line(box.right, box.top, box.get_height());
+ }
+
+ 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);
+ }
+
+ 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(),
+ Cairo.Content.COLOR_ALPHA, pos.width, pos.height);
+ Cairo.Context ctx = new Cairo.Context(surface);
+ Gdk.cairo_set_source_pixbuf(ctx, pixbuf, 0, 0);
+ ctx.paint();
+ return surface;
+ }
+}
+
+public abstract class EditingTool {
+ public PhotoCanvas canvas = null;
+
+ private EditingToolWindow tool_window = null;
+ protected Cairo.Surface surface;
+ public string name;
+
+ [CCode (has_target=false)]
+ public delegate EditingTool Factory();
+
+ public signal void activated();
+
+ public signal void deactivated();
+
+ public signal void applied(Command? command, Gdk.Pixbuf? new_pixbuf, Dimensions new_max_dim,
+ bool needs_improvement);
+
+ public signal void cancelled();
+
+ public signal void aborted();
+
+ public EditingTool(string name) {
+ this.name = name;
+ }
+
+ // base.activate() should always be called by an overriding member to ensure the base class
+ // gets to set up and store the PhotoCanvas in the canvas member field. More importantly,
+ // the activated signal is called here, and should only be called once the tool is completely
+ // initialized.
+ public virtual void activate(PhotoCanvas canvas) {
+ // multiple activates are not tolerated
+ assert(this.canvas == null);
+ assert(tool_window == null);
+
+ this.canvas = canvas;
+
+ tool_window = get_tool_window();
+ if (tool_window != null)
+ tool_window.key_press_event.connect(on_keypress);
+
+ activated();
+ }
+
+ // Like activate(), this should always be called from an overriding subclass.
+ public virtual void deactivate() {
+ // multiple deactivates are tolerated
+ if (canvas == null && tool_window == null)
+ return;
+
+ canvas = null;
+
+ if (tool_window != null) {
+ tool_window.key_press_event.disconnect(on_keypress);
+ tool_window = null;
+ }
+
+ deactivated();
+ }
+
+ public bool is_activated() {
+ return canvas != null;
+ }
+
+ public virtual EditingToolWindow? get_tool_window() {
+ return null;
+ }
+
+ // This allows the EditingTool to specify which pixbuf to display during the tool's
+ // operation. Returning null means the host should use the pixbuf associated with the current
+ // Photo. Note: This will be called before activate(), primarily to display the pixbuf before
+ // the tool is on the screen, and before paint_full() is hooked in. It also means the PhotoCanvas
+ // will have this pixbuf rather than one from the Photo class.
+ //
+ // If returns non-null, should also fill max_dim with the maximum dimensions of the original
+ // image, as the editing host may not always scale images up to fit the viewport.
+ //
+ // Note this this method doesn't need to be returning the "proper" pixbuf on-the-fly (i.e.
+ // a pixbuf with unsaved tool edits in it). That can be handled in the paint() virtual method.
+ public virtual Gdk.Pixbuf? get_display_pixbuf(Scaling scaling, Photo photo,
+ out Dimensions max_dim) throws Error {
+ max_dim = Dimensions();
+
+ return null;
+ }
+
+ public virtual void on_left_click(int x, int y) {
+ }
+
+ public virtual void on_left_released(int x, int y) {
+ }
+
+ public virtual void on_motion(int x, int y, Gdk.ModifierType mask) {
+ }
+
+ public virtual bool on_leave_notify_event(){
+ return false;
+ }
+
+ public virtual bool on_keypress(Gdk.EventKey event) {
+ // check for an escape/abort first
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ notify_cancel();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public virtual void paint(Cairo.Context ctx) {
+ }
+
+ // Helper function that fires the cancelled signal. (Can be connected to other signals.)
+ protected void notify_cancel() {
+ cancelled();
+ }
+}
+
+public class CropTool : EditingTool {
+ private const double CROP_INIT_X_PCT = 0.15;
+ private const double CROP_INIT_Y_PCT = 0.15;
+
+ private const int CROP_MIN_SIZE = 8;
+
+ private const float CROP_EXTERIOR_SATURATION = 0.00f;
+ private const int CROP_EXTERIOR_RED_SHIFT = -32;
+ private const int CROP_EXTERIOR_GREEN_SHIFT = -32;
+ private const int CROP_EXTERIOR_BLUE_SHIFT = -32;
+ private const int CROP_EXTERIOR_ALPHA_SHIFT = 0;
+
+ private const float ANY_ASPECT_RATIO = -1.0f;
+ private const float SCREEN_ASPECT_RATIO = -2.0f;
+ private const float ORIGINAL_ASPECT_RATIO = -3.0f;
+ private const float CUSTOM_ASPECT_RATIO = -4.0f;
+ private const float COMPUTE_FROM_BASIS = -5.0f;
+ private const float SEPARATOR = -6.0f;
+ private const float MIN_ASPECT_RATIO = 1.0f / 64.0f;
+ private const float MAX_ASPECT_RATIO = 64.0f;
+
+ private class ConstraintDescription {
+ public string name;
+ public int basis_width;
+ public int basis_height;
+ public bool is_pivotable;
+ public float aspect_ratio;
+
+ public ConstraintDescription(string new_name, int new_basis_width, int new_basis_height,
+ bool new_pivotable, float new_aspect_ratio = COMPUTE_FROM_BASIS) {
+ name = new_name;
+ basis_width = new_basis_width;
+ basis_height = new_basis_height;
+ if (new_aspect_ratio == COMPUTE_FROM_BASIS)
+ aspect_ratio = ((float) basis_width) / ((float) basis_height);
+ else
+ aspect_ratio = new_aspect_ratio;
+ is_pivotable = new_pivotable;
+ }
+
+ public bool is_separator() {
+ return !is_pivotable && aspect_ratio == SEPARATOR;
+ }
+ }
+
+ private enum ReticleOrientation {
+ LANDSCAPE,
+ PORTRAIT;
+
+ public ReticleOrientation toggle() {
+ return (this == ReticleOrientation.LANDSCAPE) ? ReticleOrientation.PORTRAIT :
+ ReticleOrientation.LANDSCAPE;
+ }
+ }
+
+ private enum ConstraintMode {
+ NORMAL,
+ CUSTOM
+ }
+
+ private class CropToolWindow : EditingToolWindow {
+ private const int CONTROL_SPACING = 8;
+
+ public Gtk.Button ok_button = new Gtk.Button.with_label(Resources.CROP_LABEL);
+ public Gtk.Button cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL);
+ public Gtk.ComboBox constraint_combo;
+ public Gtk.Button pivot_reticle_button = new Gtk.Button();
+ public Gtk.Entry custom_width_entry = new Gtk.Entry();
+ public Gtk.Entry custom_height_entry = new Gtk.Entry();
+ public Gtk.Label custom_mulsign_label = new Gtk.Label.with_mnemonic("x");
+ public Gtk.Entry most_recently_edited = null;
+ public Gtk.Box response_layout = null;
+ public Gtk.Box layout = null;
+ public int normal_width = -1;
+ public int normal_height = -1;
+
+ public CropToolWindow(Gtk.Window container) {
+ base(container);
+
+ cancel_button.set_tooltip_text(_("Return to current photo dimensions"));
+ cancel_button.set_image_position(Gtk.PositionType.LEFT);
+
+ ok_button.set_tooltip_text(_("Set the crop for this photo"));
+ ok_button.set_image_position(Gtk.PositionType.LEFT);
+
+ constraint_combo = new Gtk.ComboBox();
+ Gtk.CellRendererText combo_text_renderer = new Gtk.CellRendererText();
+ constraint_combo.pack_start(combo_text_renderer, true);
+ constraint_combo.add_attribute(combo_text_renderer, "text", 0);
+ constraint_combo.set_row_separator_func(constraint_combo_separator_func);
+ constraint_combo.set_active(0);
+
+ pivot_reticle_button.set_image(new Gtk.Image.from_stock(Resources.CROP_PIVOT_RETICLE,
+ Gtk.IconSize.SMALL_TOOLBAR));
+ pivot_reticle_button.set_tooltip_text(_("Pivot the crop rectangle between portrait and landscape orientations"));
+
+ custom_width_entry.set_width_chars(4);
+ custom_width_entry.editable = true;
+ custom_height_entry.set_width_chars(4);
+ custom_height_entry.editable = true;
+
+ response_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING);
+ response_layout.homogeneous = true;
+ response_layout.add(cancel_button);
+ response_layout.add(ok_button);
+
+ layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING);
+ layout.add(constraint_combo);
+ layout.add(pivot_reticle_button);
+ layout.add(response_layout);
+
+ add(layout);
+ }
+
+ private static bool constraint_combo_separator_func(Gtk.TreeModel model, Gtk.TreeIter iter) {
+ Value val;
+ model.get_value(iter, 0, out val);
+
+ return (val.dup_string() == "-");
+ }
+ }
+
+ private CropToolWindow crop_tool_window = null;
+ private Gdk.CursorType current_cursor_type = Gdk.CursorType.LEFT_PTR;
+ 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 Cairo.Context text_ctx = null;
+
+ // This is where we draw our crop tool
+ private Cairo.Surface crop_surface = null;
+
+ // these are kept in absolute coordinates, not relative to photo's position on canvas
+ private Box scaled_crop;
+ private int last_grab_x = -1;
+ private int last_grab_y = -1;
+
+ private ConstraintDescription[] constraints = create_constraints();
+ private Gtk.ListStore constraint_list = create_constraint_list(create_constraints());
+ private ReticleOrientation reticle_orientation = ReticleOrientation.LANDSCAPE;
+ private ConstraintMode constraint_mode = ConstraintMode.NORMAL;
+ private bool entry_insert_in_progress = false;
+ private float custom_aspect_ratio = 1.0f;
+ private int custom_width = -1;
+ private int custom_height = -1;
+ private int custom_init_width = -1;
+ private int custom_init_height = -1;
+ private float pre_aspect_ratio = ANY_ASPECT_RATIO;
+
+ private CropTool() {
+ base("CropTool");
+ }
+
+ public static CropTool factory() {
+ return new CropTool();
+ }
+
+ public static bool is_available(Photo photo, Scaling scaling) {
+ Dimensions dim = scaling.get_scaled_dimensions(photo.get_original_dimensions());
+
+ return dim.width > CROP_MIN_SIZE && dim.height > CROP_MIN_SIZE;
+ }
+
+ private static ConstraintDescription[] create_constraints() {
+ ConstraintDescription[] result = new ConstraintDescription[0];
+
+ result += new ConstraintDescription(_("Unconstrained"), 0, 0, false, ANY_ASPECT_RATIO);
+ result += new ConstraintDescription(_("Square"), 1, 1, false);
+ result += new ConstraintDescription(_("Screen"), 0, 0, true, SCREEN_ASPECT_RATIO);
+ result += new ConstraintDescription(_("Original Size"), 0, 0, true, ORIGINAL_ASPECT_RATIO);
+ result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR);
+ result += new ConstraintDescription(_("SD Video (4 : 3)"), 4, 3, true);
+ result += new ConstraintDescription(_("HD Video (16 : 9)"), 16, 9, true);
+ result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR);
+ result += new ConstraintDescription(_("Wallet (2 x 3 in.)"), 3, 2, true);
+ result += new ConstraintDescription(_("Notecard (3 x 5 in.)"), 5, 3, true);
+ result += new ConstraintDescription(_("4 x 6 in."), 6, 4, true);
+ result += new ConstraintDescription(_("5 x 7 in."), 7, 5, true);
+ result += new ConstraintDescription(_("8 x 10 in."), 10, 8, true);
+ result += new ConstraintDescription(_("Letter (8.5 x 11 in.)"), 85, 110, true);
+ result += new ConstraintDescription(_("11 x 14 in."), 14, 11, true);
+ result += new ConstraintDescription(_("Tabloid (11 x 17 in.)"), 17, 11, true);
+ result += new ConstraintDescription(_("16 x 20 in."), 20, 16, true);
+ result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR);
+ result += new ConstraintDescription(_("Metric Wallet (9 x 13 cm)"), 13, 9, true);
+ result += new ConstraintDescription(_("Postcard (10 x 15 cm)"), 15, 10, true);
+ result += new ConstraintDescription(_("13 x 18 cm"), 18, 13, true);
+ result += new ConstraintDescription(_("18 x 24 cm"), 24, 18, true);
+ result += new ConstraintDescription(_("A4 (210 x 297 mm)"), 210, 297, true);
+ result += new ConstraintDescription(_("20 x 30 cm"), 30, 20, true);
+ result += new ConstraintDescription(_("24 x 40 cm"), 40, 24, true);
+ result += new ConstraintDescription(_("30 x 40 cm"), 40, 30, true);
+ result += new ConstraintDescription(_("A3 (297 x 420 mm)"), 420, 297, true);
+ result += new ConstraintDescription(_("-"), 0, 0, false, SEPARATOR);
+ result += new ConstraintDescription(_("Custom"), 0, 0, true, CUSTOM_ASPECT_RATIO);
+
+ return result;
+ }
+
+ private static Gtk.ListStore create_constraint_list(ConstraintDescription[] constraint_data) {
+ Gtk.ListStore result = new Gtk.ListStore(1, typeof(string), typeof(string));
+
+ Gtk.TreeIter iter;
+ foreach (ConstraintDescription constraint in constraint_data) {
+ result.append(out iter);
+ result.set_value(iter, 0, constraint.name);
+ }
+
+ return result;
+ }
+
+ private void update_pivot_button_state() {
+ crop_tool_window.pivot_reticle_button.set_sensitive(
+ get_selected_constraint().is_pivotable);
+ }
+
+ private ConstraintDescription get_selected_constraint() {
+ ConstraintDescription result = constraints[crop_tool_window.constraint_combo.get_active()];
+
+ if (result.aspect_ratio == ORIGINAL_ASPECT_RATIO) {
+ result.basis_width = canvas.get_scaled_pixbuf_position().width;
+ result.basis_height = canvas.get_scaled_pixbuf_position().height;
+ } else if (result.aspect_ratio == SCREEN_ASPECT_RATIO) {
+ Gdk.Screen screen = Gdk.Screen.get_default();
+ result.basis_width = screen.get_width();
+ result.basis_height = screen.get_height();
+ }
+
+ return result;
+ }
+
+ private bool on_width_entry_focus_out(Gdk.EventFocus event) {
+ crop_tool_window.most_recently_edited = crop_tool_window.custom_width_entry;
+ return on_custom_entry_focus_out(event);
+ }
+
+ private bool on_height_entry_focus_out(Gdk.EventFocus event) {
+ crop_tool_window.most_recently_edited = crop_tool_window.custom_height_entry;
+ return on_custom_entry_focus_out(event);
+ }
+
+ private bool on_custom_entry_focus_out(Gdk.EventFocus event) {
+ int width = int.parse(crop_tool_window.custom_width_entry.text);
+ int height = int.parse(crop_tool_window.custom_height_entry.text);
+
+ if(width < 1) {
+ width = 1;
+ crop_tool_window.custom_width_entry.set_text("%d".printf(width));
+ }
+
+ if(height < 1) {
+ height = 1;
+ crop_tool_window.custom_height_entry.set_text("%d".printf(height));
+ }
+
+ if ((width == custom_width) && (height == custom_height))
+ return false;
+
+ custom_aspect_ratio = ((float) width) / ((float) height);
+
+ if (custom_aspect_ratio < MIN_ASPECT_RATIO) {
+ if (crop_tool_window.most_recently_edited == crop_tool_window.custom_height_entry) {
+ height = (int) (width / MIN_ASPECT_RATIO);
+ crop_tool_window.custom_height_entry.set_text("%d".printf(height));
+ } else {
+ width = (int) (height * MIN_ASPECT_RATIO);
+ crop_tool_window.custom_width_entry.set_text("%d".printf(width));
+ }
+ } else if (custom_aspect_ratio > MAX_ASPECT_RATIO) {
+ if (crop_tool_window.most_recently_edited == crop_tool_window.custom_height_entry) {
+ height = (int) (width / MAX_ASPECT_RATIO);
+ crop_tool_window.custom_height_entry.set_text("%d".printf(height));
+ } else {
+ width = (int) (height * MAX_ASPECT_RATIO);
+ crop_tool_window.custom_width_entry.set_text("%d".printf(width));
+ }
+ }
+
+ custom_aspect_ratio = ((float) width) / ((float) height);
+
+ Box new_crop = constrain_crop(scaled_crop);
+
+ crop_resized(new_crop);
+ scaled_crop = new_crop;
+ canvas.invalidate_area(new_crop);
+ canvas.repaint();
+
+ custom_width = width;
+ custom_height = height;
+
+ return false;
+ }
+
+ private void on_width_insert_text(string text, int length, ref int position) {
+ on_entry_insert_text(crop_tool_window.custom_width_entry, text, length, ref position);
+ }
+
+ private void on_height_insert_text(string text, int length, ref int position) {
+ on_entry_insert_text(crop_tool_window.custom_height_entry, text, length, ref position);
+ }
+
+ private void on_entry_insert_text(Gtk.Entry sender, string text, int length, ref int position) {
+ if (entry_insert_in_progress)
+ return;
+
+ entry_insert_in_progress = true;
+
+ if (length == -1)
+ length = (int) text.length;
+
+ // only permit numeric text
+ string new_text = "";
+ for (int ctr = 0; ctr < length; ctr++) {
+ if (text[ctr].isdigit()) {
+ new_text += ((char) text[ctr]).to_string();
+ }
+ }
+
+ if (new_text.length > 0)
+ sender.insert_text(new_text, (int) new_text.length, ref position);
+
+ Signal.stop_emission_by_name(sender, "insert-text");
+
+ entry_insert_in_progress = false;
+ }
+
+ private float get_constraint_aspect_ratio() {
+ float result = get_selected_constraint().aspect_ratio;
+
+ if (result == ORIGINAL_ASPECT_RATIO) {
+ result = ((float) canvas.get_scaled_pixbuf_position().width) /
+ ((float) canvas.get_scaled_pixbuf_position().height);
+ } else if (result == SCREEN_ASPECT_RATIO) {
+ Gdk.Screen screen = Gdk.Screen.get_default();
+ result = ((float) screen.get_width()) / ((float) screen.get_height());
+ } else if (result == CUSTOM_ASPECT_RATIO) {
+ result = custom_aspect_ratio;
+ }
+ if (reticle_orientation == ReticleOrientation.PORTRAIT)
+ result = 1.0f / result;
+
+ return result;
+ }
+
+ private void constraint_changed() {
+ ConstraintDescription selected_constraint = get_selected_constraint();
+ if (selected_constraint.aspect_ratio == CUSTOM_ASPECT_RATIO) {
+ set_custom_constraint_mode();
+ } else {
+ set_normal_constraint_mode();
+
+ if (selected_constraint.aspect_ratio != ANY_ASPECT_RATIO) {
+ // user may have switched away from 'Custom' without
+ // accepting, so set these to default back to saved
+ // values.
+ custom_init_width = Config.Facade.get_instance().get_last_crop_width();
+ custom_init_height = Config.Facade.get_instance().get_last_crop_height();
+ custom_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height);
+ }
+ }
+
+ update_pivot_button_state();
+
+ if (!get_selected_constraint().is_pivotable)
+ reticle_orientation = ReticleOrientation.LANDSCAPE;
+
+ if (get_constraint_aspect_ratio() != pre_aspect_ratio) {
+ Box new_crop = constrain_crop(scaled_crop);
+
+ crop_resized(new_crop);
+ scaled_crop = new_crop;
+ canvas.invalidate_area(new_crop);
+ canvas.repaint();
+
+ pre_aspect_ratio = get_constraint_aspect_ratio();
+ }
+ }
+
+ private void set_custom_constraint_mode() {
+ if (constraint_mode == ConstraintMode.CUSTOM)
+ return;
+
+ if ((crop_tool_window.normal_width == -1) || (crop_tool_window.normal_height == -1))
+ crop_tool_window.get_size(out crop_tool_window.normal_width,
+ out crop_tool_window.normal_height);
+
+ int window_x_pos = 0;
+ int window_y_pos = 0;
+ crop_tool_window.get_position(out window_x_pos, out window_y_pos);
+
+ crop_tool_window.hide();
+
+ crop_tool_window.layout.remove(crop_tool_window.constraint_combo);
+ crop_tool_window.layout.remove(crop_tool_window.pivot_reticle_button);
+ crop_tool_window.layout.remove(crop_tool_window.response_layout);
+
+ crop_tool_window.layout.add(crop_tool_window.constraint_combo);
+ crop_tool_window.layout.add(crop_tool_window.custom_width_entry);
+ crop_tool_window.layout.add(crop_tool_window.custom_mulsign_label);
+ crop_tool_window.layout.add(crop_tool_window.custom_height_entry);
+ crop_tool_window.layout.add(crop_tool_window.pivot_reticle_button);
+ crop_tool_window.layout.add(crop_tool_window.response_layout);
+
+ if (reticle_orientation == ReticleOrientation.LANDSCAPE) {
+ crop_tool_window.custom_width_entry.set_text("%d".printf(custom_init_width));
+ crop_tool_window.custom_height_entry.set_text("%d".printf(custom_init_height));
+ } else {
+ crop_tool_window.custom_width_entry.set_text("%d".printf(custom_init_height));
+ crop_tool_window.custom_height_entry.set_text("%d".printf(custom_init_width));
+ }
+ custom_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height);
+
+ crop_tool_window.move(window_x_pos, window_y_pos);
+ crop_tool_window.show_all();
+
+ constraint_mode = ConstraintMode.CUSTOM;
+ }
+
+ private void set_normal_constraint_mode() {
+ if (constraint_mode == ConstraintMode.NORMAL)
+ return;
+
+ int window_x_pos = 0;
+ int window_y_pos = 0;
+ crop_tool_window.get_position(out window_x_pos, out window_y_pos);
+
+ crop_tool_window.hide();
+
+ crop_tool_window.layout.remove(crop_tool_window.constraint_combo);
+ crop_tool_window.layout.remove(crop_tool_window.custom_width_entry);
+ crop_tool_window.layout.remove(crop_tool_window.custom_mulsign_label);
+ crop_tool_window.layout.remove(crop_tool_window.custom_height_entry);
+ crop_tool_window.layout.remove(crop_tool_window.pivot_reticle_button);
+ crop_tool_window.layout.remove(crop_tool_window.response_layout);
+
+ crop_tool_window.layout.add(crop_tool_window.constraint_combo);
+ crop_tool_window.layout.add(crop_tool_window.pivot_reticle_button);
+ crop_tool_window.layout.add(crop_tool_window.response_layout);
+
+ crop_tool_window.resize(crop_tool_window.normal_width,
+ crop_tool_window.normal_height);
+
+ crop_tool_window.move(window_x_pos, window_y_pos);
+ crop_tool_window.show_all();
+
+ constraint_mode = ConstraintMode.NORMAL;
+ }
+
+ private Box constrain_crop(Box crop) {
+ float user_aspect_ratio = get_constraint_aspect_ratio();
+ if (user_aspect_ratio == ANY_ASPECT_RATIO)
+ return crop;
+
+ // PHASE 1: Scale to the desired aspect ratio, preserving area and center.
+ float old_area = (float) (crop.get_width() * crop.get_height());
+ crop.adjust_height((int) Math.sqrt(old_area / user_aspect_ratio));
+ crop.adjust_width((int) Math.sqrt(old_area * user_aspect_ratio));
+
+ // PHASE 2: Crop to the image boundary.
+ Dimensions image_size = get_photo_dimensions();
+ double angle;
+ canvas.get_photo().get_straighten(out angle);
+ crop = clamp_inside_rotated_image(crop, image_size.width, image_size.height, angle, false);
+
+ // PHASE 3: Crop down to the aspect ratio if necessary.
+ if (crop.get_width() >= crop.get_height() * user_aspect_ratio) // possibly too wide
+ crop.adjust_width((int) (crop.get_height() * user_aspect_ratio));
+ else // possibly too tall
+ crop.adjust_height((int) (crop.get_width() / user_aspect_ratio));
+
+ return crop;
+ }
+
+ private ConstraintDescription? get_last_constraint(out int index) {
+ index = Config.Facade.get_instance().get_last_crop_menu_choice();
+
+ return (index < constraints.length) ? constraints[index] : null;
+ }
+
+ public override void activate(PhotoCanvas canvas) {
+ bind_canvas_handlers(canvas);
+
+ prepare_ctx(canvas.get_default_ctx(), canvas.get_surface_dim());
+
+ if (crop_surface != null)
+ crop_surface = null;
+
+ crop_surface = new Cairo.ImageSurface(Cairo.Format.ARGB32,
+ canvas.get_scaled_pixbuf_position().width,
+ canvas.get_scaled_pixbuf_position().height);
+
+ Cairo.Context ctx = new Cairo.Context(crop_surface);
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0);
+ ctx.paint();
+
+ // create the crop tool window, where the user can apply or cancel the crop
+ crop_tool_window = new CropToolWindow(canvas.get_container());
+
+ // set up the constraint combo box
+ crop_tool_window.constraint_combo.set_model(constraint_list);
+ if(!canvas.get_photo().has_crop()) {
+ int index;
+ ConstraintDescription? desc = get_last_constraint(out index);
+ if (desc != null && !desc.is_separator())
+ crop_tool_window.constraint_combo.set_active(index);
+ }
+
+ // set up the pivot reticle button
+ update_pivot_button_state();
+ reticle_orientation = ReticleOrientation.LANDSCAPE;
+
+ bind_window_handlers();
+
+ // obtain crop dimensions and paint against the uncropped photo
+ Dimensions uncropped_dim = canvas.get_photo().get_dimensions(Photo.Exception.CROP);
+
+ Box crop;
+ if (!canvas.get_photo().get_crop(out crop)) {
+ int xofs = (int) (uncropped_dim.width * CROP_INIT_X_PCT);
+ int yofs = (int) (uncropped_dim.height * CROP_INIT_Y_PCT);
+
+ // initialize the actual crop in absolute coordinates, not relative
+ // to the photo's position on the canvas
+ crop = Box(xofs, yofs, uncropped_dim.width - xofs, uncropped_dim.height - yofs);
+ }
+
+ // scale the crop to the scaled photo's size ... the scaled crop is maintained in
+ // coordinates not relative to photo's position on canvas
+ scaled_crop = crop.get_scaled_similar(uncropped_dim,
+ Dimensions.for_rectangle(canvas.get_scaled_pixbuf_position()));
+
+ // get the custom width and height from the saved config and
+ // set up the initial custom values with it.
+ custom_width = Config.Facade.get_instance().get_last_crop_width();
+ custom_height = Config.Facade.get_instance().get_last_crop_height();
+ custom_init_width = custom_width;
+ custom_init_height = custom_height;
+ pre_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height);
+
+ constraint_mode = ConstraintMode.NORMAL;
+
+ base.activate(canvas);
+
+ // make sure the window has its regular size before going into
+ // custom mode, which will resize it and needs to save the old
+ // size first.
+ crop_tool_window.show_all();
+ crop_tool_window.hide();
+
+ // was 'custom' the most-recently-chosen menu item?
+ if(!canvas.get_photo().has_crop()) {
+ ConstraintDescription? desc = get_last_constraint(null);
+ if (desc != null && !desc.is_separator() && desc.aspect_ratio == CUSTOM_ASPECT_RATIO)
+ set_custom_constraint_mode();
+ }
+
+ // since we no longer just run with the default, but rather
+ // a saved value, we'll behave as if the saved constraint has
+ // just been changed to so that everything gets updated and
+ // the canvas stays in sync.
+ Box new_crop = constrain_crop(scaled_crop);
+
+ crop_resized(new_crop);
+ scaled_crop = new_crop;
+ canvas.invalidate_area(new_crop);
+ canvas.repaint();
+
+ pre_aspect_ratio = get_constraint_aspect_ratio();
+ }
+
+ private void bind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.new_surface.connect(prepare_ctx);
+ canvas.resized_scaled_pixbuf.connect(on_resized_pixbuf);
+ }
+
+ private void unbind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.new_surface.disconnect(prepare_ctx);
+ canvas.resized_scaled_pixbuf.disconnect(on_resized_pixbuf);
+ }
+
+ private void bind_window_handlers() {
+ crop_tool_window.key_press_event.connect(on_keypress);
+ crop_tool_window.ok_button.clicked.connect(on_crop_ok);
+ crop_tool_window.cancel_button.clicked.connect(notify_cancel);
+ crop_tool_window.constraint_combo.changed.connect(constraint_changed);
+ crop_tool_window.pivot_reticle_button.clicked.connect(on_pivot_button_clicked);
+
+ // set up the custom width and height entry boxes
+ crop_tool_window.custom_width_entry.focus_out_event.connect(on_width_entry_focus_out);
+ crop_tool_window.custom_height_entry.focus_out_event.connect(on_height_entry_focus_out);
+ crop_tool_window.custom_width_entry.insert_text.connect(on_width_insert_text);
+ crop_tool_window.custom_height_entry.insert_text.connect(on_height_insert_text);
+ }
+
+ private void unbind_window_handlers() {
+ crop_tool_window.key_press_event.disconnect(on_keypress);
+ crop_tool_window.ok_button.clicked.disconnect(on_crop_ok);
+ crop_tool_window.cancel_button.clicked.disconnect(notify_cancel);
+ crop_tool_window.constraint_combo.changed.disconnect(constraint_changed);
+ crop_tool_window.pivot_reticle_button.clicked.disconnect(on_pivot_button_clicked);
+
+ // set up the custom width and height entry boxes
+ crop_tool_window.custom_width_entry.focus_out_event.disconnect(on_width_entry_focus_out);
+ crop_tool_window.custom_height_entry.focus_out_event.disconnect(on_height_entry_focus_out);
+ crop_tool_window.custom_width_entry.insert_text.disconnect(on_width_insert_text);
+ }
+
+ public override bool on_keypress(Gdk.EventKey event) {
+ if ((Gdk.keyval_name(event.keyval) == "KP_Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Return")) {
+ on_crop_ok();
+ return true;
+ }
+
+ return base.on_keypress(event);
+ }
+
+ private void on_pivot_button_clicked() {
+ if (get_selected_constraint().aspect_ratio == CUSTOM_ASPECT_RATIO) {
+ string width_text = crop_tool_window.custom_width_entry.get_text();
+ string height_text = crop_tool_window.custom_height_entry.get_text();
+ crop_tool_window.custom_width_entry.set_text(height_text);
+ crop_tool_window.custom_height_entry.set_text(width_text);
+
+ int temp = custom_width;
+ custom_width = custom_height;
+ custom_height = temp;
+ }
+ reticle_orientation = reticle_orientation.toggle();
+ constraint_changed();
+ }
+
+ public override void deactivate() {
+ if (canvas != null)
+ unbind_canvas_handlers(canvas);
+
+ if (crop_tool_window != null) {
+ unbind_window_handlers();
+ crop_tool_window.hide();
+ crop_tool_window.destroy();
+ crop_tool_window = null;
+ }
+
+ // make sure the cursor isn't set to a modify indicator
+ if (canvas != null)
+ canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR));
+
+ crop_surface = null;
+
+ base.deactivate();
+ }
+
+ public override EditingToolWindow? get_tool_window() {
+ return crop_tool_window;
+ }
+
+ public override Gdk.Pixbuf? get_display_pixbuf(Scaling scaling, Photo photo,
+ out Dimensions max_dim) throws Error {
+ max_dim = photo.get_dimensions(Photo.Exception.CROP);
+
+ return photo.get_pixbuf_with_options(scaling, Photo.Exception.CROP);
+ }
+
+ private 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_white_ctx = new Cairo.Context(ctx.get_target());
+ set_source_color_from_string(wide_white_ctx, "#FFF");
+ wide_white_ctx.set_line_width(1);
+
+ 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);
+
+ text_ctx = new Cairo.Context(ctx.get_target());
+ text_ctx.select_font_face("Sans", Cairo.FontSlant.NORMAL, Cairo.FontWeight.NORMAL);
+ }
+
+ private void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) {
+ Dimensions new_dim = Dimensions.for_pixbuf(scaled);
+ Dimensions uncropped_dim = canvas.get_photo().get_dimensions(Photo.Exception.CROP);
+
+ // rescale to full crop
+ Box crop = scaled_crop.get_scaled_similar(old_dim, uncropped_dim);
+
+ // rescale back to new size
+ scaled_crop = crop.get_scaled_similar(uncropped_dim, new_dim);
+ if (crop_surface != null)
+ crop_surface = null;
+
+ crop_surface = new Cairo.ImageSurface(Cairo.Format.ARGB32, scaled.width, scaled.height);
+ Cairo.Context ctx = new Cairo.Context(crop_surface);
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0);
+ ctx.paint();
+
+ }
+
+ public override void on_left_click(int x, int y) {
+ Gdk.Rectangle scaled_pixbuf_pos = canvas.get_scaled_pixbuf_position();
+
+ // scaled_crop is not maintained relative to photo's position on canvas
+ 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);
+ last_grab_x = x -= scaled_pixbuf_pos.x;
+ last_grab_y = y -= scaled_pixbuf_pos.y;
+
+ // repaint because the crop changes on a mouse down
+ canvas.repaint();
+ }
+
+ public override void on_left_released(int x, int y) {
+ // nothing to do if released outside of the crop box
+ if (in_manipulation == BoxLocation.OUTSIDE)
+ return;
+
+ // end manipulation
+ in_manipulation = BoxLocation.OUTSIDE;
+ last_grab_x = -1;
+ last_grab_y = -1;
+
+ update_cursor(x, y);
+
+ // repaint because crop changes when released
+ canvas.repaint();
+ }
+
+ public override void on_motion(int x, int y, Gdk.ModifierType mask) {
+ // 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);
+
+ update_cursor(x, y);
+ canvas.repaint();
+ }
+
+ public override void paint(Cairo.Context default_ctx) {
+ // 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.fill();
+ default_ctx.paint();
+
+ Cairo.Context ctx = new Cairo.Context(crop_surface);
+ ctx.set_operator(Cairo.Operator.SOURCE);
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 0.5);
+ ctx.paint();
+
+ // paint exposed (cropped) part of pixbuf minus crop border
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 0.0);
+ ctx.rectangle(scaled_crop.left, scaled_crop.top, scaled_crop.get_width(),
+ scaled_crop.get_height());
+ ctx.fill();
+ canvas.paint_surface(crop_surface, true);
+
+ // paint crop tool last
+ paint_crop_tool(scaled_crop);
+ }
+
+ private void on_crop_ok() {
+ // user's clicked OK, save the combobox choice and width/height.
+ // safe to do, even if not in 'custom' mode - the previous values
+ // will just get saved again.
+ Config.Facade.get_instance().set_last_crop_menu_choice(
+ crop_tool_window.constraint_combo.get_active());
+ Config.Facade.get_instance().set_last_crop_width(custom_width);
+ Config.Facade.get_instance().set_last_crop_height(custom_height);
+
+ // scale screen-coordinate crop to photo's coordinate system
+ Box crop = scaled_crop.get_scaled_similar(
+ Dimensions.for_rectangle(canvas.get_scaled_pixbuf_position()),
+ canvas.get_photo().get_dimensions(Photo.Exception.CROP));
+
+ // crop the current pixbuf and offer it to the editing host
+ Gdk.Pixbuf cropped = new Gdk.Pixbuf.subpixbuf(canvas.get_scaled_pixbuf(), scaled_crop.left,
+ scaled_crop.top, scaled_crop.get_width(), scaled_crop.get_height());
+
+ // signal host; we have a cropped image, but it will be scaled upward, and so a better one
+ // should be fetched
+ applied(new CropCommand(canvas.get_photo(), crop, Resources.CROP_LABEL,
+ Resources.CROP_TOOLTIP), cropped, crop.get_dimensions(), true);
+ }
+
+ private void update_cursor(int x, int y) {
+ // scaled_crop is not maintained relative to photo's position on canvas
+ Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position();
+ 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)) {
+ case BoxLocation.LEFT_SIDE:
+ cursor_type = Gdk.CursorType.LEFT_SIDE;
+ break;
+
+ case BoxLocation.TOP_SIDE:
+ cursor_type = Gdk.CursorType.TOP_SIDE;
+ break;
+
+ case BoxLocation.RIGHT_SIDE:
+ cursor_type = Gdk.CursorType.RIGHT_SIDE;
+ break;
+
+ case BoxLocation.BOTTOM_SIDE:
+ cursor_type = Gdk.CursorType.BOTTOM_SIDE;
+ break;
+
+ case BoxLocation.TOP_LEFT:
+ cursor_type = Gdk.CursorType.TOP_LEFT_CORNER;
+ break;
+
+ case BoxLocation.BOTTOM_LEFT:
+ cursor_type = Gdk.CursorType.BOTTOM_LEFT_CORNER;
+ break;
+
+ case BoxLocation.TOP_RIGHT:
+ cursor_type = Gdk.CursorType.TOP_RIGHT_CORNER;
+ break;
+
+ case BoxLocation.BOTTOM_RIGHT:
+ cursor_type = Gdk.CursorType.BOTTOM_RIGHT_CORNER;
+ break;
+
+ case BoxLocation.INSIDE:
+ cursor_type = Gdk.CursorType.FLEUR;
+ break;
+
+ default:
+ // use Gdk.CursorType.LEFT_PTR
+ break;
+ }
+
+ if (cursor_type != current_cursor_type) {
+ Gdk.Cursor cursor = new Gdk.Cursor(cursor_type);
+ canvas.get_drawing_window().set_cursor(cursor);
+ current_cursor_type = cursor_type;
+ }
+ }
+
+ private int eval_radial_line(double center_x, double center_y, double bounds_x,
+ double bounds_y, double user_x) {
+ double decision_slope = (bounds_y - center_y) / (bounds_x - center_x);
+ double decision_intercept = bounds_y - (decision_slope * bounds_x);
+
+ return (int) (decision_slope * user_x + decision_intercept);
+ }
+
+ // Return the dimensions of the uncropped source photo scaled to canvas coordinates.
+ private Dimensions get_photo_dimensions() {
+ Dimensions photo_dims = canvas.get_photo().get_dimensions(Photo.Exception.CROP);
+ Dimensions surface_dims = canvas.get_surface_dim();
+ double scale_factor = double.min((double) surface_dims.width / photo_dims.width,
+ (double) surface_dims.height / photo_dims.height);
+ scale_factor = double.min(scale_factor, 1.0);
+
+ photo_dims = canvas.get_photo().get_dimensions(
+ Photo.Exception.CROP | Photo.Exception.STRAIGHTEN);
+
+ return { (int) (photo_dims.width * scale_factor),
+ (int) (photo_dims.height * scale_factor) };
+ }
+
+ private bool on_canvas_manipulation(int x, int y) {
+ Gdk.Rectangle scaled_pos = canvas.get_scaled_pixbuf_position();
+
+ // scaled_crop is maintained in coordinates non-relative to photo's position on canvas ...
+ // but bound tool to photo itself
+ x -= scaled_pos.x;
+ if (x < 0)
+ x = 0;
+ else if (x >= scaled_pos.width)
+ x = scaled_pos.width - 1;
+
+ y -= scaled_pos.y;
+ if (y < 0)
+ y = 0;
+ else if (y >= scaled_pos.height)
+ y = scaled_pos.height - 1;
+
+ // need to make manipulations outside of box structure, because its methods do sanity
+ // checking
+ int left = scaled_crop.left;
+ int top = scaled_crop.top;
+ int right = scaled_crop.right;
+ int bottom = scaled_crop.bottom;
+
+ // get extra geometric information needed to enforce constraints
+ int center_x = (left + right) / 2;
+ int center_y = (top + bottom) / 2;
+
+ switch (in_manipulation) {
+ case BoxLocation.LEFT_SIDE:
+ left = x;
+ if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) {
+ float new_height = ((float) (right - left)) / get_constraint_aspect_ratio();
+ bottom = top + ((int) new_height);
+ }
+ break;
+
+ case BoxLocation.TOP_SIDE:
+ top = y;
+ if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) {
+ float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio();
+ right = left + ((int) new_width);
+ }
+ break;
+
+ case BoxLocation.RIGHT_SIDE:
+ right = x;
+ if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) {
+ float new_height = ((float) (right - left)) / get_constraint_aspect_ratio();
+ bottom = top + ((int) new_height);
+ }
+ break;
+
+ case BoxLocation.BOTTOM_SIDE:
+ bottom = y;
+ if (get_constraint_aspect_ratio() != ANY_ASPECT_RATIO) {
+ float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio();
+ right = left + ((int) new_width);
+ }
+ break;
+
+ case BoxLocation.TOP_LEFT:
+ if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) {
+ top = y;
+ left = x;
+ } else {
+ if (y < eval_radial_line(center_x, center_y, left, top, x)) {
+ top = y;
+ float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio();
+ left = right - ((int) new_width);
+ } else {
+ left = x;
+ float new_height = ((float) (right - left)) / get_constraint_aspect_ratio();
+ top = bottom - ((int) new_height);
+ }
+ }
+ break;
+
+ case BoxLocation.BOTTOM_LEFT:
+ if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) {
+ bottom = y;
+ left = x;
+ } else {
+ if (y < eval_radial_line(center_x, center_y, left, bottom, x)) {
+ left = x;
+ float new_height = ((float) (right - left)) / get_constraint_aspect_ratio();
+ bottom = top + ((int) new_height);
+ } else {
+ bottom = y;
+ float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio();
+ left = right - ((int) new_width);
+ }
+ }
+ break;
+
+ case BoxLocation.TOP_RIGHT:
+ if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) {
+ top = y;
+ right = x;
+ } else {
+ if (y < eval_radial_line(center_x, center_y, right, top, x)) {
+ top = y;
+ float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio();
+ right = left + ((int) new_width);
+ } else {
+ right = x;
+ float new_height = ((float) (right - left)) / get_constraint_aspect_ratio();
+ top = bottom - ((int) new_height);
+ }
+ }
+ break;
+
+ case BoxLocation.BOTTOM_RIGHT:
+ if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) {
+ bottom = y;
+ right = x;
+ } else {
+ if (y < eval_radial_line(center_x, center_y, right, bottom, x)) {
+ right = x;
+ float new_height = ((float) (right - left)) / get_constraint_aspect_ratio();
+ bottom = top + ((int) new_height);
+ } else {
+ bottom = y;
+ float new_width = ((float) (bottom - top)) * get_constraint_aspect_ratio();
+ right = left + ((int) new_width);
+ }
+ }
+ break;
+
+ case BoxLocation.INSIDE:
+ assert(last_grab_x >= 0);
+ assert(last_grab_y >= 0);
+
+ int delta_x = (x - last_grab_x);
+ int delta_y = (y - last_grab_y);
+
+ last_grab_x = x;
+ last_grab_y = y;
+
+ int width = right - left + 1;
+ int height = bottom - top + 1;
+
+ left += delta_x;
+ top += delta_y;
+ right += delta_x;
+ bottom += delta_y;
+
+ // bound crop inside of photo
+ if (left < 0)
+ left = 0;
+
+ if (top < 0)
+ top = 0;
+
+ if (right >= scaled_pos.width)
+ right = scaled_pos.width - 1;
+
+ if (bottom >= scaled_pos.height)
+ bottom = scaled_pos.height - 1;
+
+ int adj_width = right - left + 1;
+ int adj_height = bottom - top + 1;
+
+ // don't let adjustments affect the size of the crop
+ if (adj_width != width) {
+ if (delta_x < 0)
+ right = left + width - 1;
+ else
+ left = right - width + 1;
+ }
+
+ if (adj_height != height) {
+ if (delta_y < 0)
+ bottom = top + height - 1;
+ else
+ top = bottom - height + 1;
+ }
+ break;
+
+ default:
+ // do nothing, not even a repaint
+ return false;
+ }
+
+ // Check if the mouse has gone out of bounds, and if it has, make sure that the
+ // crop reticle's edges stay within the photo bounds. This bounds check works
+ // differently in constrained versus unconstrained mode. In unconstrained mode,
+ // we need only to bounds clamp the one or two edge(s) that are actually out-of-bounds.
+ // In constrained mode however, we need to bounds clamp the entire box, because the
+ // positions of edges are all interdependent (so as to enforce the aspect ratio
+ // constraint).
+ int width = right - left + 1;
+ int height = bottom - top + 1;
+
+ Dimensions photo_dims = get_photo_dimensions();
+ double angle;
+ canvas.get_photo().get_straighten(out angle);
+
+ Box new_crop;
+ if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) {
+ width = right - left + 1;
+ height = bottom - top + 1;
+
+ switch (in_manipulation) {
+ case BoxLocation.LEFT_SIDE:
+ case BoxLocation.TOP_LEFT:
+ case BoxLocation.BOTTOM_LEFT:
+ if (width < CROP_MIN_SIZE)
+ left = right - CROP_MIN_SIZE;
+ break;
+
+ case BoxLocation.RIGHT_SIDE:
+ case BoxLocation.TOP_RIGHT:
+ case BoxLocation.BOTTOM_RIGHT:
+ if (width < CROP_MIN_SIZE)
+ right = left + CROP_MIN_SIZE;
+ break;
+
+ default:
+ break;
+ }
+
+ switch (in_manipulation) {
+ case BoxLocation.TOP_SIDE:
+ case BoxLocation.TOP_LEFT:
+ case BoxLocation.TOP_RIGHT:
+ if (height < CROP_MIN_SIZE)
+ top = bottom - CROP_MIN_SIZE;
+ break;
+
+ case BoxLocation.BOTTOM_SIDE:
+ case BoxLocation.BOTTOM_LEFT:
+ case BoxLocation.BOTTOM_RIGHT:
+ if (height < CROP_MIN_SIZE)
+ bottom = top + CROP_MIN_SIZE;
+ break;
+
+ default:
+ break;
+ }
+
+ // preliminary crop region has been chosen, now clamp it inside the
+ // image as needed.
+
+ new_crop = clamp_inside_rotated_image(
+ Box(left, top, right, bottom),
+ photo_dims.width, photo_dims.height, angle,
+ in_manipulation == BoxLocation.INSIDE);
+
+ } else {
+ // one of the constrained modes is active; revert instead of clamping so
+ // that aspect ratio stays intact
+
+ new_crop = Box(left, top, right, bottom);
+ Box adjusted = clamp_inside_rotated_image(new_crop,
+ photo_dims.width, photo_dims.height, angle,
+ in_manipulation == BoxLocation.INSIDE);
+
+ if (adjusted != new_crop || width < CROP_MIN_SIZE || height < CROP_MIN_SIZE) {
+ new_crop = scaled_crop; // revert crop move
+ }
+ }
+
+ if (in_manipulation != BoxLocation.INSIDE)
+ crop_resized(new_crop);
+ else
+ crop_moved(new_crop);
+
+ // load new values
+ scaled_crop = new_crop;
+
+ if (get_constraint_aspect_ratio() == ANY_ASPECT_RATIO) {
+ custom_init_width = scaled_crop.get_width();
+ custom_init_height = scaled_crop.get_height();
+ custom_aspect_ratio = ((float) custom_init_width) / ((float) custom_init_height);
+ }
+
+ return false;
+ }
+
+ private void crop_resized(Box new_crop) {
+ if(scaled_crop.equals(new_crop)) {
+ // no change
+ return;
+ }
+
+ canvas.invalidate_area(scaled_crop);
+
+ Box horizontal;
+ bool horizontal_enlarged;
+ Box vertical;
+ bool vertical_enlarged;
+ BoxComplements complements = scaled_crop.resized_complements(new_crop, out horizontal,
+ out horizontal_enlarged, out vertical, out vertical_enlarged);
+
+ // this should never happen ... this means that the operation wasn't a resize
+ assert(complements != BoxComplements.NONE);
+
+ if (complements == BoxComplements.HORIZONTAL || complements == BoxComplements.BOTH)
+ set_area_alpha(horizontal, horizontal_enlarged ? 0.0 : 0.5);
+
+ if (complements == BoxComplements.VERTICAL || complements == BoxComplements.BOTH)
+ set_area_alpha(vertical, vertical_enlarged ? 0.0 : 0.5);
+
+ paint_crop_tool(new_crop);
+ canvas.invalidate_area(new_crop);
+ }
+
+ private void crop_moved(Box new_crop) {
+ if (scaled_crop.equals(new_crop)) {
+ // no change
+ return;
+ }
+
+ canvas.invalidate_area(scaled_crop);
+
+ set_area_alpha(scaled_crop, 0.5);
+ set_area_alpha(new_crop, 0.0);
+
+
+ // paint crop in new location
+ paint_crop_tool(new_crop);
+ canvas.invalidate_area(new_crop);
+ }
+
+ private void set_area_alpha(Box area, double alpha) {
+ Cairo.Context ctx = new Cairo.Context(crop_surface);
+ ctx.set_operator(Cairo.Operator.SOURCE);
+ ctx.set_source_rgba(0.0, 0.0, 0.0, alpha);
+ ctx.rectangle(area.left, area.top, area.get_width(), area.get_height());
+ ctx.fill();
+ canvas.paint_surface_area(crop_surface, area, true);
+ }
+
+ private void paint_crop_tool(Box crop) {
+ // paint rule-of-thirds lines and current dimensions if user is manipulating the crop
+ if (in_manipulation != BoxLocation.OUTSIDE) {
+ int one_third_x = crop.get_width() / 3;
+ int one_third_y = crop.get_height() / 3;
+
+ canvas.draw_horizontal_line(thin_white_ctx, crop.left, crop.top + one_third_y, crop.get_width());
+ canvas.draw_horizontal_line(thin_white_ctx, crop.left, crop.top + (one_third_y * 2), crop.get_width());
+
+ canvas.draw_vertical_line(thin_white_ctx, crop.left + one_third_x, crop.top, crop.get_height());
+ canvas.draw_vertical_line(thin_white_ctx, crop.left + (one_third_x * 2), crop.top, crop.get_height());
+
+ // current dimensions
+ // scale screen-coordinate crop to photo's coordinate system
+ Box adj_crop = scaled_crop.get_scaled_similar(
+ Dimensions.for_rectangle(canvas.get_scaled_pixbuf_position()),
+ canvas.get_photo().get_dimensions(Photo.Exception.CROP));
+ string text = adj_crop.get_width().to_string() + "x" + adj_crop.get_height().to_string();
+ int x = crop.left + crop.get_width() / 2;
+ int y = crop.top + crop.get_height() / 2;
+ canvas.draw_text(text_ctx, text, x, y);
+ }
+
+ // outer rectangle ... outer line in black, inner in white, corners fully black
+ canvas.draw_box(wide_black_ctx, crop);
+ canvas.draw_box(wide_white_ctx, crop.get_reduced(1));
+ canvas.draw_box(wide_white_ctx, crop.get_reduced(2));
+ }
+
+}
+
+public struct RedeyeInstance {
+ public const int MIN_RADIUS = 4;
+ public const int MAX_RADIUS = 32;
+ public const int DEFAULT_RADIUS = 10;
+
+ public Gdk.Point center;
+ public int radius;
+
+ RedeyeInstance() {
+ Gdk.Point default_center = Gdk.Point();
+ center = default_center;
+ radius = DEFAULT_RADIUS;
+ }
+
+ public static Gdk.Rectangle to_bounds_rect(EditingTools.RedeyeInstance inst) {
+ Gdk.Rectangle result = Gdk.Rectangle();
+ result.x = inst.center.x - inst.radius;
+ result.y = inst.center.y - inst.radius;
+ result.width = 2 * inst.radius;
+ result.height = result.width;
+
+ return result;
+ }
+
+ public static RedeyeInstance from_bounds_rect(Gdk.Rectangle rect) {
+ Gdk.Rectangle in_rect = rect;
+
+ RedeyeInstance result = RedeyeInstance();
+ result.radius = (in_rect.width + in_rect.height) / 4;
+ result.center.x = in_rect.x + result.radius;
+ result.center.y = in_rect.y + result.radius;
+
+ return result;
+ }
+}
+
+public class RedeyeTool : EditingTool {
+ private class RedeyeToolWindow : EditingToolWindow {
+ private const int CONTROL_SPACING = 8;
+
+ private Gtk.Label slider_label = new Gtk.Label.with_mnemonic(_("Size:"));
+
+ public Gtk.Button apply_button =
+ new Gtk.Button.from_stock(Gtk.Stock.APPLY);
+ public Gtk.Button close_button =
+ new Gtk.Button.from_stock(Gtk.Stock.CLOSE);
+ public Gtk.Scale slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ RedeyeInstance.MIN_RADIUS, RedeyeInstance.MAX_RADIUS, 1.0);
+
+ public RedeyeToolWindow(Gtk.Window container) {
+ base(container);
+
+ slider.set_size_request(80, -1);
+ slider.set_draw_value(false);
+
+ close_button.set_tooltip_text(_("Close the red-eye tool"));
+ close_button.set_image_position(Gtk.PositionType.LEFT);
+
+ apply_button.set_tooltip_text(_("Remove any red-eye effects in the selected region"));
+ apply_button.set_image_position(Gtk.PositionType.LEFT);
+
+ Gtk.Box layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING);
+ layout.add(slider_label);
+ layout.add(slider);
+ layout.add(close_button);
+ layout.add(apply_button);
+
+ add(layout);
+ }
+ }
+
+ private Cairo.Context thin_white_ctx = null;
+ private Cairo.Context wider_gray_ctx = null;
+ private RedeyeToolWindow redeye_tool_window = null;
+ private RedeyeInstance user_interaction_instance;
+ 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;
+
+ private RedeyeTool() {
+ base("RedeyeTool");
+ }
+
+ public static RedeyeTool factory() {
+ return new RedeyeTool();
+ }
+
+ public static bool is_available(Photo photo, Scaling scaling) {
+ Dimensions dim = scaling.get_scaled_dimensions(photo.get_dimensions());
+
+ return dim.width >= (RedeyeInstance.MAX_RADIUS * 2)
+ && dim.height >= (RedeyeInstance.MAX_RADIUS * 2);
+ }
+
+ private RedeyeInstance new_interaction_instance(PhotoCanvas canvas) {
+ Gdk.Rectangle photo_bounds = canvas.get_scaled_pixbuf_position();
+ Gdk.Point photo_center = {0};
+ photo_center.x = photo_bounds.x + (photo_bounds.width / 2);
+ photo_center.y = photo_bounds.y + (photo_bounds.height / 2);
+
+ RedeyeInstance result = RedeyeInstance();
+ result.center.x = photo_center.x;
+ result.center.y = photo_center.y;
+ result.radius = RedeyeInstance.DEFAULT_RADIUS;
+
+ return result;
+ }
+
+ private void prepare_ctx(Cairo.Context ctx, Dimensions dim) {
+ 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);
+
+ 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);
+ }
+
+ private void draw_redeye_instance(RedeyeInstance inst) {
+ canvas.draw_circle(wider_gray_ctx, inst.center.x, inst.center.y,
+ inst.radius);
+ canvas.draw_circle(thin_white_ctx, inst.center.x, inst.center.y,
+ inst.radius);
+ }
+
+ private bool on_size_slider_adjust(Gtk.ScrollType type) {
+ user_interaction_instance.radius =
+ (int) redeye_tool_window.slider.get_value();
+
+ canvas.repaint();
+
+ return false;
+ }
+
+ private void on_apply() {
+ Gdk.Rectangle bounds_rect_user =
+ RedeyeInstance.to_bounds_rect(user_interaction_instance);
+
+ Gdk.Rectangle bounds_rect_active =
+ canvas.user_to_active_rect(bounds_rect_user);
+ Gdk.Rectangle bounds_rect_unscaled =
+ canvas.active_to_unscaled_rect(bounds_rect_active);
+ Gdk.Rectangle bounds_rect_raw =
+ canvas.unscaled_to_raw_rect(bounds_rect_unscaled);
+
+ RedeyeInstance instance_raw =
+ RedeyeInstance.from_bounds_rect(bounds_rect_raw);
+
+ // transform screen coords back to image coords,
+ // taking into account straightening angle.
+ Dimensions dimensions = canvas.get_photo().get_dimensions(
+ Photo.Exception.STRAIGHTEN | Photo.Exception.CROP);
+
+ double theta = 0.0;
+
+ canvas.get_photo().get_straighten(out theta);
+
+ instance_raw.center = derotate_point_arb(instance_raw.center,
+ dimensions.width, dimensions.height, theta);
+
+ RedeyeCommand command = new RedeyeCommand(canvas.get_photo(), instance_raw,
+ Resources.RED_EYE_LABEL, Resources.RED_EYE_TOOLTIP);
+ AppWindow.get_command_manager().execute(command);
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> map) {
+ if (!map.has_key(canvas.get_photo()))
+ return;
+
+ try {
+ current_pixbuf = canvas.get_photo().get_pixbuf(canvas.get_scaling());
+ } catch (Error err) {
+ warning("%s", err.message);
+ aborted();
+
+ return;
+ }
+
+ canvas.repaint();
+ }
+
+ private void on_close() {
+ applied(null, current_pixbuf, canvas.get_photo().get_dimensions(), false);
+ }
+
+ private void on_canvas_resize() {
+ Gdk.Rectangle scaled_pixbuf_position =
+ canvas.get_scaled_pixbuf_position();
+
+ user_interaction_instance.center.x -= old_scaled_pixbuf_position.x;
+ user_interaction_instance.center.y -= old_scaled_pixbuf_position.y;
+
+ double scale_factor = ((double) scaled_pixbuf_position.width) /
+ ((double) old_scaled_pixbuf_position.width);
+
+ user_interaction_instance.center.x =
+ (int)(((double) user_interaction_instance.center.x) *
+ scale_factor + 0.5);
+ user_interaction_instance.center.y =
+ (int)(((double) user_interaction_instance.center.y) *
+ scale_factor + 0.5);
+
+ user_interaction_instance.center.x += scaled_pixbuf_position.x;
+ user_interaction_instance.center.y += scaled_pixbuf_position.y;
+
+ old_scaled_pixbuf_position = scaled_pixbuf_position;
+
+ current_pixbuf = null;
+ }
+
+ public override void activate(PhotoCanvas canvas) {
+ user_interaction_instance = new_interaction_instance(canvas);
+
+ prepare_ctx(canvas.get_default_ctx(), canvas.get_surface_dim());
+
+ bind_canvas_handlers(canvas);
+
+ old_scaled_pixbuf_position = canvas.get_scaled_pixbuf_position();
+ current_pixbuf = canvas.get_scaled_pixbuf();
+
+ redeye_tool_window = new RedeyeToolWindow(canvas.get_container());
+ redeye_tool_window.slider.set_value(user_interaction_instance.radius);
+
+ bind_window_handlers();
+
+ cached_arrow_cursor = new Gdk.Cursor(Gdk.CursorType.LEFT_PTR);
+ cached_grab_cursor = new Gdk.Cursor(Gdk.CursorType.FLEUR);
+
+ DataCollection? owner = canvas.get_photo().get_membership();
+ if (owner != null)
+ owner.items_altered.connect(on_photos_altered);
+
+ base.activate(canvas);
+ }
+
+ public override void deactivate() {
+ if (canvas != null) {
+ DataCollection? owner = canvas.get_photo().get_membership();
+ if (owner != null)
+ owner.items_altered.disconnect(on_photos_altered);
+
+ unbind_canvas_handlers(canvas);
+ }
+
+ if (redeye_tool_window != null) {
+ unbind_window_handlers();
+ redeye_tool_window.hide();
+ redeye_tool_window.destroy();
+ redeye_tool_window = null;
+ }
+
+ base.deactivate();
+ }
+
+ private void bind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.new_surface.connect(prepare_ctx);
+ canvas.resized_scaled_pixbuf.connect(on_canvas_resize);
+ }
+
+ private void unbind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.new_surface.disconnect(prepare_ctx);
+ canvas.resized_scaled_pixbuf.disconnect(on_canvas_resize);
+ }
+
+ private void bind_window_handlers() {
+ redeye_tool_window.apply_button.clicked.connect(on_apply);
+ redeye_tool_window.close_button.clicked.connect(on_close);
+ redeye_tool_window.slider.change_value.connect(on_size_slider_adjust);
+ }
+
+ private void unbind_window_handlers() {
+ redeye_tool_window.apply_button.clicked.disconnect(on_apply);
+ redeye_tool_window.close_button.clicked.disconnect(on_close);
+ redeye_tool_window.slider.change_value.disconnect(on_size_slider_adjust);
+ }
+
+ public override EditingToolWindow? get_tool_window() {
+ return redeye_tool_window;
+ }
+
+ public override void paint(Cairo.Context ctx) {
+ canvas.paint_pixbuf((current_pixbuf != null) ? current_pixbuf : canvas.get_scaled_pixbuf());
+
+ /* user_interaction_instance has its radius in user coords, and
+ draw_redeye_instance expects active region coords */
+ RedeyeInstance active_inst = user_interaction_instance;
+ active_inst.center =
+ canvas.user_to_active_point(user_interaction_instance.center);
+ draw_redeye_instance(active_inst);
+ }
+
+ public override void on_left_click(int x, int y) {
+ Gdk.Rectangle bounds_rect =
+ RedeyeInstance.to_bounds_rect(user_interaction_instance);
+
+ if (coord_in_rectangle(x, y, bounds_rect)) {
+ is_reticle_move_in_progress = true;
+ reticle_move_mouse_start_point.x = x;
+ reticle_move_mouse_start_point.y = y;
+ reticle_move_anchor = user_interaction_instance.center;
+ }
+ }
+
+ public override void on_left_released(int x, int y) {
+ is_reticle_move_in_progress = false;
+ }
+
+ public override void on_motion(int x, int y, Gdk.ModifierType mask) {
+ if (is_reticle_move_in_progress) {
+
+ Gdk.Rectangle active_region_rect =
+ canvas.get_scaled_pixbuf_position();
+
+ int x_clamp_low =
+ active_region_rect.x + user_interaction_instance.radius + 1;
+ int y_clamp_low =
+ active_region_rect.y + user_interaction_instance.radius + 1;
+ int x_clamp_high =
+ active_region_rect.x + active_region_rect.width -
+ user_interaction_instance.radius - 1;
+ int y_clamp_high =
+ 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;
+
+ user_interaction_instance.center.x = reticle_move_anchor.x +
+ delta_x;
+ user_interaction_instance.center.y = reticle_move_anchor.y +
+ delta_y;
+
+ user_interaction_instance.center.x =
+ (reticle_move_anchor.x + delta_x).clamp(x_clamp_low,
+ x_clamp_high);
+ user_interaction_instance.center.y =
+ (reticle_move_anchor.y + delta_y).clamp(y_clamp_low,
+ y_clamp_high);
+
+ canvas.repaint();
+ } else {
+ 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);
+ } else {
+ canvas.get_drawing_window().set_cursor(cached_arrow_cursor);
+ }
+ }
+ }
+
+ public override bool on_keypress(Gdk.EventKey event) {
+ if ((Gdk.keyval_name(event.keyval) == "KP_Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Return")) {
+ on_close();
+ return true;
+ }
+
+ return base.on_keypress(event);
+ }
+}
+
+public class AdjustTool : EditingTool {
+ private const int SLIDER_WIDTH = 160;
+ private const uint SLIDER_DELAY_MSEC = 100;
+
+ private class AdjustToolWindow : EditingToolWindow {
+ public Gtk.Scale exposure_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ ExposureTransformation.MIN_PARAMETER, ExposureTransformation.MAX_PARAMETER,
+ 1.0);
+ public Gtk.Scale saturation_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ SaturationTransformation.MIN_PARAMETER, SaturationTransformation.MAX_PARAMETER,
+ 1.0);
+ public Gtk.Scale tint_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ TintTransformation.MIN_PARAMETER, TintTransformation.MAX_PARAMETER, 1.0);
+ public Gtk.Scale temperature_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ TemperatureTransformation.MIN_PARAMETER, TemperatureTransformation.MAX_PARAMETER,
+ 1.0);
+
+ public Gtk.Scale shadows_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ ShadowDetailTransformation.MIN_PARAMETER, ShadowDetailTransformation.MAX_PARAMETER,
+ 1.0);
+
+ public Gtk.Scale highlights_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL,
+ HighlightDetailTransformation.MIN_PARAMETER, HighlightDetailTransformation.MAX_PARAMETER,
+ 1.0);
+
+ public Gtk.Button ok_button = new Gtk.Button.from_stock(Gtk.Stock.OK);
+ public Gtk.Button reset_button = new Gtk.Button.with_mnemonic(_("_Reset"));
+ public Gtk.Button cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL);
+ public RGBHistogramManipulator histogram_manipulator = new RGBHistogramManipulator();
+
+ public AdjustToolWindow(Gtk.Window container) {
+ base(container);
+
+ Gtk.Grid slider_organizer = new Gtk.Grid();
+ slider_organizer.set_column_homogeneous(false);
+ slider_organizer.set_row_spacing(12);
+ slider_organizer.set_column_spacing(12);
+ slider_organizer.set_margin_left(12);
+ slider_organizer.set_margin_bottom(12);
+
+ Gtk.Label exposure_label = new Gtk.Label.with_mnemonic(_("Exposure:"));
+ exposure_label.set_alignment(0.0f, 0.5f);
+ slider_organizer.attach(exposure_label, 0, 0, 1, 1);
+ slider_organizer.attach(exposure_slider, 1, 0, 1, 1);
+ exposure_slider.set_size_request(SLIDER_WIDTH, -1);
+ exposure_slider.set_draw_value(false);
+ exposure_slider.set_margin_right(0);
+
+ Gtk.Label saturation_label = new Gtk.Label.with_mnemonic(_("Saturation:"));
+ saturation_label.set_alignment(0.0f, 0.5f);
+ slider_organizer.attach(saturation_label, 0, 1, 1, 1);
+ slider_organizer.attach(saturation_slider, 1, 1, 1, 1);
+ saturation_slider.set_size_request(SLIDER_WIDTH, -1);
+ saturation_slider.set_draw_value(false);
+ saturation_slider.set_margin_right(0);
+
+ Gtk.Label tint_label = new Gtk.Label.with_mnemonic(_("Tint:"));
+ tint_label.set_alignment(0.0f, 0.5f);
+ slider_organizer.attach(tint_label, 0, 2, 1, 1);
+ slider_organizer.attach(tint_slider, 1, 2, 1, 1);
+ tint_slider.set_size_request(SLIDER_WIDTH, -1);
+ tint_slider.set_draw_value(false);
+ tint_slider.set_margin_right(0);
+
+ Gtk.Label temperature_label =
+ new Gtk.Label.with_mnemonic(_("Temperature:"));
+ temperature_label.set_alignment(0.0f, 0.5f);
+ slider_organizer.attach(temperature_label, 0, 3, 1, 1);
+ slider_organizer.attach(temperature_slider, 1, 3, 1, 1);
+ temperature_slider.set_size_request(SLIDER_WIDTH, -1);
+ temperature_slider.set_draw_value(false);
+ temperature_slider.set_margin_right(0);
+
+ Gtk.Label shadows_label = new Gtk.Label.with_mnemonic(_("Shadows:"));
+ shadows_label.set_alignment(0.0f, 0.5f);
+ slider_organizer.attach(shadows_label, 0, 4, 1, 1);
+ slider_organizer.attach(shadows_slider, 1, 4, 1, 1);
+ shadows_slider.set_size_request(SLIDER_WIDTH, -1);
+ shadows_slider.set_draw_value(false);
+ shadows_slider.set_margin_right(0);
+
+ Gtk.Label highlights_label = new Gtk.Label.with_mnemonic(_("Highlights:"));
+ highlights_label.set_alignment(0.0f, 0.5f);
+ slider_organizer.attach(highlights_label, 0, 5, 1, 1);
+ slider_organizer.attach(highlights_slider, 1, 5, 1, 1);
+ highlights_slider.set_size_request(SLIDER_WIDTH, -1);
+ highlights_slider.set_draw_value(false);
+
+ Gtk.Box button_layouter = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ button_layouter.set_homogeneous(true);
+ button_layouter.pack_start(cancel_button, true, true, 1);
+ button_layouter.pack_start(reset_button, true, true, 1);
+ button_layouter.pack_start(ok_button, true, true, 1);
+
+ Gtk.Alignment histogram_aligner = new Gtk.Alignment(0.0f, 0.0f, 0.0f, 0.0f);
+ histogram_aligner.add(histogram_manipulator);
+ histogram_aligner.set_padding(12, 8, 12, 12);
+
+ Gtk.Box pane_layouter = new Gtk.Box(Gtk.Orientation.VERTICAL, 8);
+ pane_layouter.add(histogram_aligner);
+ pane_layouter.add(slider_organizer);
+ pane_layouter.add(button_layouter);
+ pane_layouter.set_child_packing(histogram_aligner, true, true, 0, Gtk.PackType.START);
+
+ add(pane_layouter);
+ }
+ }
+
+ private abstract class AdjustToolCommand : Command {
+ protected weak AdjustTool owner;
+
+ public AdjustToolCommand(AdjustTool owner, string name, string explanation) {
+ base (name, explanation);
+
+ this.owner = owner;
+ owner.deactivated.connect(on_owner_deactivated);
+ }
+
+ ~AdjustToolCommand() {
+ if (owner != null)
+ owner.deactivated.disconnect(on_owner_deactivated);
+ }
+
+ private void on_owner_deactivated() {
+ // This reset call is by design. See notes on ticket #1946 if this is undesirable or if
+ // you are planning to change it.
+ AppWindow.get_command_manager().reset();
+ }
+ }
+
+ private class AdjustResetCommand : AdjustToolCommand {
+ private PixelTransformationBundle original;
+ private PixelTransformationBundle reset;
+
+ public AdjustResetCommand(AdjustTool owner, PixelTransformationBundle current) {
+ base (owner, _("Reset Colors"), _("Reset all color adjustments to original"));
+
+ original = current.copy();
+ reset = new PixelTransformationBundle();
+ reset.set_to_identity();
+ }
+
+ public override void execute() {
+ owner.set_adjustments(reset);
+ }
+
+ public override void undo() {
+ owner.set_adjustments(original);
+ }
+
+ public override bool compress(Command command) {
+ AdjustResetCommand reset_command = command as AdjustResetCommand;
+ if (reset_command == null)
+ return false;
+
+ if (reset_command.owner != owner)
+ return false;
+
+ // multiple successive resets on the same photo as good as a single
+ return true;
+ }
+ }
+
+ private class SliderAdjustmentCommand : AdjustToolCommand {
+ private PixelTransformationType transformation_type;
+ private PixelTransformation new_transformation;
+ private PixelTransformation old_transformation;
+
+ public SliderAdjustmentCommand(AdjustTool owner, PixelTransformation old_transformation,
+ PixelTransformation new_transformation, string name) {
+ base(owner, name, name);
+
+ this.old_transformation = old_transformation;
+ this.new_transformation = new_transformation;
+ transformation_type = old_transformation.get_transformation_type();
+ assert(new_transformation.get_transformation_type() == transformation_type);
+ }
+
+ public override void execute() {
+ // don't update slider; it's been moved by the user
+ owner.update_transformation(new_transformation);
+ owner.canvas.repaint();
+ }
+
+ public override void undo() {
+ owner.update_transformation(old_transformation);
+
+ owner.unbind_window_handlers();
+ owner.update_slider(old_transformation);
+ owner.bind_window_handlers();
+
+ owner.canvas.repaint();
+ }
+
+ public override void redo() {
+ owner.update_transformation(new_transformation);
+
+ owner.unbind_window_handlers();
+ owner.update_slider(new_transformation);
+ owner.bind_window_handlers();
+
+ owner.canvas.repaint();
+ }
+
+ public override bool compress(Command command) {
+ SliderAdjustmentCommand slider_adjustment = command as SliderAdjustmentCommand;
+ if (slider_adjustment == null)
+ return false;
+
+ // same photo
+ if (slider_adjustment.owner != owner)
+ return false;
+
+ // same adjustment
+ if (slider_adjustment.transformation_type != transformation_type)
+ return false;
+
+ // execute the command
+ slider_adjustment.execute();
+
+ // save it's transformation as ours
+ new_transformation = slider_adjustment.new_transformation;
+
+ return true;
+ }
+ }
+
+ private class AdjustEnhanceCommand : AdjustToolCommand {
+ private Photo photo;
+ private PixelTransformationBundle original;
+ private PixelTransformationBundle enhanced = null;
+
+ public AdjustEnhanceCommand(AdjustTool owner, Photo photo) {
+ base(owner, Resources.ENHANCE_LABEL, Resources.ENHANCE_TOOLTIP);
+
+ this.photo = photo;
+ original = photo.get_color_adjustments();
+ }
+
+ public override void execute() {
+ if (enhanced == null)
+ enhanced = photo.get_enhance_transformations();
+
+ owner.set_adjustments(enhanced);
+ }
+
+ public override void undo() {
+ owner.set_adjustments(original);
+ }
+
+ public override bool compress(Command command) {
+ // can compress both normal enhance and one with the adjust tool running
+ EnhanceSingleCommand enhance_single = command as EnhanceSingleCommand;
+ if (enhance_single != null) {
+ Photo photo = (Photo) enhance_single.get_source();
+
+ // multiple successive enhances are as good as a single, as long as it's on the
+ // same photo
+ return photo.equals(owner.canvas.get_photo());
+ }
+
+ AdjustEnhanceCommand enhance_command = command as AdjustEnhanceCommand;
+ if (enhance_command == null)
+ return false;
+
+ if (enhance_command.owner != owner)
+ return false;
+
+ // multiple successive as good as a single
+ return true;
+ }
+ }
+
+ private AdjustToolWindow adjust_tool_window = null;
+ private bool suppress_effect_redraw = false;
+ private Gdk.Pixbuf draw_to_pixbuf = null;
+ private Gdk.Pixbuf histogram_pixbuf = null;
+ private Gdk.Pixbuf virgin_histogram_pixbuf = null;
+ private PixelTransformer transformer = null;
+ private PixelTransformer histogram_transformer = null;
+ private PixelTransformationBundle transformations = null;
+ private float[] fp_pixel_cache = null;
+ private bool disable_histogram_refresh = false;
+ private OneShotScheduler? temperature_scheduler = null;
+ private OneShotScheduler? tint_scheduler = null;
+ private OneShotScheduler? saturation_scheduler = null;
+ private OneShotScheduler? exposure_scheduler = null;
+ private OneShotScheduler? shadows_scheduler = null;
+ private OneShotScheduler? highlights_scheduler = null;
+
+ private AdjustTool() {
+ base("AdjustTool");
+ }
+
+ public static AdjustTool factory() {
+ return new AdjustTool();
+ }
+
+ public static bool is_available(Photo photo, Scaling scaling) {
+ return true;
+ }
+
+ public override void activate(PhotoCanvas canvas) {
+ adjust_tool_window = new AdjustToolWindow(canvas.get_container());
+
+ Photo photo = canvas.get_photo();
+ transformations = photo.get_color_adjustments();
+ transformer = transformations.generate_transformer();
+
+ // the histogram transformer uses all transformations but contrast expansion
+ histogram_transformer = new PixelTransformer();
+
+ /* set up expansion */
+ ExpansionTransformation expansion_trans = (ExpansionTransformation)
+ transformations.get_transformation(PixelTransformationType.TONE_EXPANSION);
+ adjust_tool_window.histogram_manipulator.set_left_nub_position(
+ expansion_trans.get_black_point());
+ adjust_tool_window.histogram_manipulator.set_right_nub_position(
+ expansion_trans.get_white_point());
+
+ /* set up shadows */
+ ShadowDetailTransformation shadows_trans = (ShadowDetailTransformation)
+ transformations.get_transformation(PixelTransformationType.SHADOWS);
+ histogram_transformer.attach_transformation(shadows_trans);
+ adjust_tool_window.shadows_slider.set_value(shadows_trans.get_parameter());
+
+ /* set up highlights */
+ HighlightDetailTransformation highlights_trans = (HighlightDetailTransformation)
+ transformations.get_transformation(PixelTransformationType.HIGHLIGHTS);
+ histogram_transformer.attach_transformation(highlights_trans);
+ adjust_tool_window.highlights_slider.set_value(highlights_trans.get_parameter());
+
+ /* set up temperature & tint */
+ TemperatureTransformation temp_trans = (TemperatureTransformation)
+ transformations.get_transformation(PixelTransformationType.TEMPERATURE);
+ histogram_transformer.attach_transformation(temp_trans);
+ adjust_tool_window.temperature_slider.set_value(temp_trans.get_parameter());
+
+ TintTransformation tint_trans = (TintTransformation)
+ transformations.get_transformation(PixelTransformationType.TINT);
+ histogram_transformer.attach_transformation(tint_trans);
+ adjust_tool_window.tint_slider.set_value(tint_trans.get_parameter());
+
+ /* set up saturation */
+ SaturationTransformation sat_trans = (SaturationTransformation)
+ transformations.get_transformation(PixelTransformationType.SATURATION);
+ histogram_transformer.attach_transformation(sat_trans);
+ adjust_tool_window.saturation_slider.set_value(sat_trans.get_parameter());
+
+ /* set up exposure */
+ ExposureTransformation exposure_trans = (ExposureTransformation)
+ transformations.get_transformation(PixelTransformationType.EXPOSURE);
+ histogram_transformer.attach_transformation(exposure_trans);
+ adjust_tool_window.exposure_slider.set_value(exposure_trans.get_parameter());
+
+ bind_canvas_handlers(canvas);
+ bind_window_handlers();
+
+ draw_to_pixbuf = canvas.get_scaled_pixbuf().copy();
+ init_fp_pixel_cache(canvas.get_scaled_pixbuf());
+
+ /* if we have an 1x1 pixel image, then there's no need to deal with recomputing the
+ histogram, because a histogram for a 1x1 image is meaningless. The histogram shows the
+ distribution of color over all the many pixels in an image, but if an image only has
+ one pixel, the notion of a "distribution over pixels" makes no sense. */
+ if (draw_to_pixbuf.width == 1 && draw_to_pixbuf.height == 1)
+ disable_histogram_refresh = true;
+
+ /* don't sample the original image to create the histogram if the original image is
+ sufficiently large -- if it's over 8k pixels, then we'll get pretty much the same
+ histogram if we sample from a half-size image */
+ if (((draw_to_pixbuf.width * draw_to_pixbuf.height) > 8192) && (draw_to_pixbuf.width > 1) &&
+ (draw_to_pixbuf.height > 1)) {
+ histogram_pixbuf = draw_to_pixbuf.scale_simple(draw_to_pixbuf.width / 2,
+ draw_to_pixbuf.height / 2, Gdk.InterpType.HYPER);
+ } else {
+ histogram_pixbuf = draw_to_pixbuf.copy();
+ }
+ virgin_histogram_pixbuf = histogram_pixbuf.copy();
+
+ DataCollection? owner = canvas.get_photo().get_membership();
+ if (owner != null)
+ owner.items_altered.connect(on_photos_altered);
+
+ base.activate(canvas);
+ }
+
+ public override EditingToolWindow? get_tool_window() {
+ return adjust_tool_window;
+ }
+
+ public override void deactivate() {
+ if (canvas != null) {
+ DataCollection? owner = canvas.get_photo().get_membership();
+ if (owner != null)
+ owner.items_altered.disconnect(on_photos_altered);
+
+ unbind_canvas_handlers(canvas);
+ }
+
+ if (adjust_tool_window != null) {
+ unbind_window_handlers();
+ adjust_tool_window.hide();
+ adjust_tool_window.destroy();
+ adjust_tool_window = null;
+ }
+
+ draw_to_pixbuf = null;
+ fp_pixel_cache = null;
+
+ base.deactivate();
+ }
+
+ public override void paint(Cairo.Context ctx) {
+ if (!suppress_effect_redraw) {
+ transformer.transform_from_fp(ref fp_pixel_cache, draw_to_pixbuf);
+ histogram_transformer.transform_to_other_pixbuf(virgin_histogram_pixbuf,
+ histogram_pixbuf);
+ if (!disable_histogram_refresh)
+ adjust_tool_window.histogram_manipulator.update_histogram(histogram_pixbuf);
+ }
+
+ canvas.paint_pixbuf(draw_to_pixbuf);
+ }
+
+ public override Gdk.Pixbuf? get_display_pixbuf(Scaling scaling, Photo photo,
+ out Dimensions max_dim) throws Error {
+ if (!photo.has_color_adjustments()) {
+ max_dim = Dimensions();
+
+ return null;
+ }
+
+ max_dim = photo.get_dimensions();
+
+ return photo.get_pixbuf_with_options(scaling, Photo.Exception.ADJUST);
+ }
+
+ private void on_reset() {
+ AdjustResetCommand command = new AdjustResetCommand(this, transformations);
+ AppWindow.get_command_manager().execute(command);
+ }
+
+ private void on_ok() {
+ suppress_effect_redraw = true;
+
+ get_tool_window().hide();
+
+ applied(new AdjustColorsSingleCommand(canvas.get_photo(), transformations,
+ Resources.ADJUST_LABEL, Resources.ADJUST_TOOLTIP), draw_to_pixbuf,
+ canvas.get_photo().get_dimensions(), false);
+ }
+
+ private void update_transformations(PixelTransformationBundle new_transformations) {
+ foreach (PixelTransformation transformation in new_transformations.get_transformations())
+ update_transformation(transformation);
+ }
+
+ private void update_transformation(PixelTransformation new_transformation) {
+ PixelTransformation old_transformation = transformations.get_transformation(
+ new_transformation.get_transformation_type());
+
+ transformer.replace_transformation(old_transformation, new_transformation);
+ if (new_transformation.get_transformation_type() != PixelTransformationType.TONE_EXPANSION)
+ histogram_transformer.replace_transformation(old_transformation, new_transformation);
+
+ transformations.set(new_transformation);
+ }
+
+ private void slider_updated(PixelTransformation new_transformation, string name) {
+ PixelTransformation old_transformation = transformations.get_transformation(
+ new_transformation.get_transformation_type());
+ SliderAdjustmentCommand command = new SliderAdjustmentCommand(this, old_transformation,
+ new_transformation, name);
+ AppWindow.get_command_manager().execute(command);
+ }
+
+ private void on_temperature_adjustment() {
+ if (temperature_scheduler == null)
+ temperature_scheduler = new OneShotScheduler("temperature", on_delayed_temperature_adjustment);
+
+ temperature_scheduler.after_timeout(SLIDER_DELAY_MSEC, true);
+ }
+
+ private void on_delayed_temperature_adjustment() {
+ TemperatureTransformation new_temp_trans = new TemperatureTransformation(
+ (float) adjust_tool_window.temperature_slider.get_value());
+ slider_updated(new_temp_trans, _("Temperature"));
+ }
+
+ private void on_tint_adjustment() {
+ if (tint_scheduler == null)
+ tint_scheduler = new OneShotScheduler("tint", on_delayed_tint_adjustment);
+
+ tint_scheduler.after_timeout(SLIDER_DELAY_MSEC, true);
+ }
+
+ private void on_delayed_tint_adjustment() {
+ TintTransformation new_tint_trans = new TintTransformation(
+ (float) adjust_tool_window.tint_slider.get_value());
+ slider_updated(new_tint_trans, _("Tint"));
+ }
+
+ private void on_saturation_adjustment() {
+ if (saturation_scheduler == null)
+ saturation_scheduler = new OneShotScheduler("saturation", on_delayed_saturation_adjustment);
+
+ saturation_scheduler.after_timeout(SLIDER_DELAY_MSEC, true);
+ }
+
+ private void on_delayed_saturation_adjustment() {
+ SaturationTransformation new_sat_trans = new SaturationTransformation(
+ (float) adjust_tool_window.saturation_slider.get_value());
+ slider_updated(new_sat_trans, _("Saturation"));
+ }
+
+ private void on_exposure_adjustment() {
+ if (exposure_scheduler == null)
+ exposure_scheduler = new OneShotScheduler("exposure", on_delayed_exposure_adjustment);
+
+ exposure_scheduler.after_timeout(SLIDER_DELAY_MSEC, true);
+ }
+
+ private void on_delayed_exposure_adjustment() {
+ ExposureTransformation new_exp_trans = new ExposureTransformation(
+ (float) adjust_tool_window.exposure_slider.get_value());
+ slider_updated(new_exp_trans, _("Exposure"));
+ }
+
+ private void on_shadows_adjustment() {
+ if (shadows_scheduler == null)
+ shadows_scheduler = new OneShotScheduler("shadows", on_delayed_shadows_adjustment);
+
+ shadows_scheduler.after_timeout(SLIDER_DELAY_MSEC, true);
+ }
+
+ private void on_delayed_shadows_adjustment() {
+ ShadowDetailTransformation new_shadows_trans = new ShadowDetailTransformation(
+ (float) adjust_tool_window.shadows_slider.get_value());
+ slider_updated(new_shadows_trans, _("Shadows"));
+ }
+
+ private void on_highlights_adjustment() {
+ if (highlights_scheduler == null)
+ highlights_scheduler = new OneShotScheduler("highlights", on_delayed_highlights_adjustment);
+
+ highlights_scheduler.after_timeout(SLIDER_DELAY_MSEC, true);
+ }
+
+ private void on_delayed_highlights_adjustment() {
+ HighlightDetailTransformation new_highlights_trans = new HighlightDetailTransformation(
+ (float) adjust_tool_window.highlights_slider.get_value());
+ slider_updated(new_highlights_trans, _("Highlights"));
+ }
+
+ private void on_histogram_constraint() {
+ int expansion_black_point =
+ adjust_tool_window.histogram_manipulator.get_left_nub_position();
+ int expansion_white_point =
+ adjust_tool_window.histogram_manipulator.get_right_nub_position();
+ ExpansionTransformation new_exp_trans =
+ new ExpansionTransformation.from_extrema(expansion_black_point, expansion_white_point);
+ slider_updated(new_exp_trans, _("Contrast Expansion"));
+ }
+
+ private void on_canvas_resize() {
+ draw_to_pixbuf = canvas.get_scaled_pixbuf().copy();
+ init_fp_pixel_cache(canvas.get_scaled_pixbuf());
+ }
+
+ private bool on_hscale_reset(Gtk.Widget widget, Gdk.EventButton event) {
+ Gtk.Scale source = (Gtk.Scale) widget;
+
+ if (event.button == 1 && event.type == Gdk.EventType.BUTTON_PRESS
+ && has_only_key_modifier(event.state, Gdk.ModifierType.CONTROL_MASK)) {
+ // Left Mouse Button and CTRL pressed
+ source.set_value(0);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private void bind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.resized_scaled_pixbuf.connect(on_canvas_resize);
+ }
+
+ private void unbind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.resized_scaled_pixbuf.disconnect(on_canvas_resize);
+ }
+
+ private void bind_window_handlers() {
+ adjust_tool_window.ok_button.clicked.connect(on_ok);
+ adjust_tool_window.reset_button.clicked.connect(on_reset);
+ adjust_tool_window.cancel_button.clicked.connect(notify_cancel);
+ adjust_tool_window.exposure_slider.value_changed.connect(on_exposure_adjustment);
+ adjust_tool_window.saturation_slider.value_changed.connect(on_saturation_adjustment);
+ adjust_tool_window.tint_slider.value_changed.connect(on_tint_adjustment);
+ adjust_tool_window.temperature_slider.value_changed.connect(on_temperature_adjustment);
+ adjust_tool_window.shadows_slider.value_changed.connect(on_shadows_adjustment);
+ adjust_tool_window.highlights_slider.value_changed.connect(on_highlights_adjustment);
+ adjust_tool_window.histogram_manipulator.nub_position_changed.connect(on_histogram_constraint);
+
+ adjust_tool_window.saturation_slider.button_press_event.connect(on_hscale_reset);
+ adjust_tool_window.exposure_slider.button_press_event.connect(on_hscale_reset);
+ adjust_tool_window.tint_slider.button_press_event.connect(on_hscale_reset);
+ adjust_tool_window.temperature_slider.button_press_event.connect(on_hscale_reset);
+ adjust_tool_window.shadows_slider.button_press_event.connect(on_hscale_reset);
+ adjust_tool_window.highlights_slider.button_press_event.connect(on_hscale_reset);
+ }
+
+ private void unbind_window_handlers() {
+ adjust_tool_window.ok_button.clicked.disconnect(on_ok);
+ adjust_tool_window.reset_button.clicked.disconnect(on_reset);
+ adjust_tool_window.cancel_button.clicked.disconnect(notify_cancel);
+ adjust_tool_window.exposure_slider.value_changed.disconnect(on_exposure_adjustment);
+ adjust_tool_window.saturation_slider.value_changed.disconnect(on_saturation_adjustment);
+ adjust_tool_window.tint_slider.value_changed.disconnect(on_tint_adjustment);
+ adjust_tool_window.temperature_slider.value_changed.disconnect(on_temperature_adjustment);
+ adjust_tool_window.shadows_slider.value_changed.disconnect(on_shadows_adjustment);
+ adjust_tool_window.highlights_slider.value_changed.disconnect(on_highlights_adjustment);
+ adjust_tool_window.histogram_manipulator.nub_position_changed.disconnect(on_histogram_constraint);
+
+ adjust_tool_window.saturation_slider.button_press_event.disconnect(on_hscale_reset);
+ adjust_tool_window.exposure_slider.button_press_event.disconnect(on_hscale_reset);
+ adjust_tool_window.tint_slider.button_press_event.disconnect(on_hscale_reset);
+ adjust_tool_window.temperature_slider.button_press_event.disconnect(on_hscale_reset);
+ adjust_tool_window.shadows_slider.button_press_event.disconnect(on_hscale_reset);
+ adjust_tool_window.highlights_slider.button_press_event.disconnect(on_hscale_reset);
+ }
+
+ public bool enhance() {
+ AdjustEnhanceCommand command = new AdjustEnhanceCommand(this, canvas.get_photo());
+ AppWindow.get_command_manager().execute(command);
+
+ return true;
+ }
+
+ private void on_photos_altered(Gee.Map<DataObject, Alteration> map) {
+ if (!map.has_key(canvas.get_photo()))
+ return;
+
+ PixelTransformationBundle adjustments = canvas.get_photo().get_color_adjustments();
+ set_adjustments(adjustments);
+ }
+
+ private void set_adjustments(PixelTransformationBundle new_adjustments) {
+ unbind_window_handlers();
+
+ update_transformations(new_adjustments);
+
+ foreach (PixelTransformation adjustment in new_adjustments.get_transformations())
+ update_slider(adjustment);
+
+ bind_window_handlers();
+ canvas.repaint();
+ }
+
+ // Note that window handlers should be unbound (unbind_window_handlers) prior to calling this
+ // if the caller doesn't want the widget's signals to fire with the change.
+ private void update_slider(PixelTransformation transformation) {
+ switch (transformation.get_transformation_type()) {
+ case PixelTransformationType.TONE_EXPANSION:
+ ExpansionTransformation expansion = (ExpansionTransformation) transformation;
+
+ if (!disable_histogram_refresh) {
+ adjust_tool_window.histogram_manipulator.set_left_nub_position(
+ expansion.get_black_point());
+ adjust_tool_window.histogram_manipulator.set_right_nub_position(
+ expansion.get_white_point());
+ }
+ break;
+
+ case PixelTransformationType.SHADOWS:
+ adjust_tool_window.shadows_slider.set_value(
+ ((ShadowDetailTransformation) transformation).get_parameter());
+ break;
+
+ case PixelTransformationType.HIGHLIGHTS:
+ adjust_tool_window.highlights_slider.set_value(
+ ((HighlightDetailTransformation) transformation).get_parameter());
+ break;
+
+ case PixelTransformationType.EXPOSURE:
+ adjust_tool_window.exposure_slider.set_value(
+ ((ExposureTransformation) transformation).get_parameter());
+ break;
+
+ case PixelTransformationType.SATURATION:
+ adjust_tool_window.saturation_slider.set_value(
+ ((SaturationTransformation) transformation).get_parameter());
+ break;
+
+ case PixelTransformationType.TINT:
+ adjust_tool_window.tint_slider.set_value(
+ ((TintTransformation) transformation).get_parameter());
+ break;
+
+ case PixelTransformationType.TEMPERATURE:
+ adjust_tool_window.temperature_slider.set_value(
+ ((TemperatureTransformation) transformation).get_parameter());
+ break;
+
+ default:
+ error("Unknown adjustment: %d", (int) transformation.get_transformation_type());
+ }
+ }
+
+ private void init_fp_pixel_cache(Gdk.Pixbuf source) {
+ int source_width = source.get_width();
+ int source_height = source.get_height();
+ int source_num_channels = source.get_n_channels();
+ int source_rowstride = source.get_rowstride();
+ unowned uchar[] source_pixels = source.get_pixels();
+
+ fp_pixel_cache = new float[3 * source_width * source_height];
+ int cache_pixel_index = 0;
+ float INV_255 = 1.0f / 255.0f;
+
+ for (int j = 0; j < source_height; j++) {
+ int row_start_index = j * source_rowstride;
+ int row_end_index = row_start_index + (source_width * source_num_channels);
+ for (int i = row_start_index; i < row_end_index; i += source_num_channels) {
+ fp_pixel_cache[cache_pixel_index++] = ((float) source_pixels[i]) * INV_255;
+ fp_pixel_cache[cache_pixel_index++] = ((float) source_pixels[i + 1]) * INV_255;
+ fp_pixel_cache[cache_pixel_index++] = ((float) source_pixels[i + 2]) * INV_255;
+ }
+ }
+ }
+
+ public override bool on_keypress(Gdk.EventKey event) {
+ if ((Gdk.keyval_name(event.keyval) == "KP_Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Return")) {
+ on_ok();
+ return true;
+ }
+
+ return base.on_keypress(event);
+ }
+}
+
+
+}
+
diff --git a/src/editing_tools/StraightenTool.vala b/src/editing_tools/StraightenTool.vala
new file mode 100644
index 0000000..8a778ec
--- /dev/null
+++ b/src/editing_tools/StraightenTool.vala
@@ -0,0 +1,559 @@
+
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace EditingTools {
+
+/**
+ * An editing tool that allows one to introduce or remove a Dutch angle from
+ * a photograph.
+ */
+public class StraightenTool : EditingTool {
+ private const double MIN_ANGLE = -15.0;
+ private const double MAX_ANGLE = 15.0;
+ private const double INCREMENT = 0.1;
+ private const int MIN_SLIDER_SIZE = 160;
+ private const int MIN_LABEL_SIZE = 100;
+ private const int MIN_BUTTON_SIZE = 84;
+ private const int TEMP_PIXBUF_SIZE = 768;
+ private const double GUIDE_DASH[2] = {10, 10};
+ private const int REPAINT_ON_STOP_DELAY_MSEC = 100;
+
+ private class StraightenGuide {
+ private bool is_active = false;
+ private int x[2]; // start & end drag coords
+ private int y[2];
+ private double angle0; // current angle
+
+ public void reset(int x, int y, double angle) {
+ this.x = {x, x};
+ this.y = {y, y};
+ this.is_active = true;
+ this.angle0 = angle;
+ }
+
+ public bool update(int x, int y) {
+ if (this.is_active) {
+ this.x[1] = x;
+ this.y[1] = y;
+ return true;
+ }
+
+ return false;
+ }
+
+ public void clear() {
+ this.is_active = false;
+ }
+
+ public double? get_angle() {
+ double dx = x[1] - x[0];
+ double dy = y[1] - y[0];
+
+ // minimum radius to consider: discard clicks
+ if (dy*dy + dx*dx < 40)
+ return null;
+
+ // distinguish guides closer to horizontal or vertical
+ if (Math.fabs(dy) > Math.fabs(dx))
+ return angle0 + Math.atan(dx / dy) / Math.PI * 180;
+ else
+ return angle0 - Math.atan(dy / dx) / Math.PI * 180;
+ }
+
+ public void draw(Cairo.Context ctx) {
+ if (!is_active)
+ return;
+
+ double angle = get_angle() ?? 0.0;
+ if (angle == 0.0)
+ return;
+
+ double alpha = 1.0;
+ if (angle < MIN_ANGLE || angle > MAX_ANGLE)
+ alpha = 0.35;
+
+ // b&w dashing so it will be more visible on
+ // 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.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.stroke();
+ }
+ }
+
+ private class StraightenToolWindow : EditingToolWindow {
+ public const int CONTROL_SPACING = 8;
+
+ public Gtk.Scale angle_slider = new Gtk.Scale.with_range(Gtk.Orientation.HORIZONTAL, MIN_ANGLE, MAX_ANGLE, INCREMENT);
+ public Gtk.Label angle_label = new Gtk.Label("");
+ public Gtk.Label description_label = new Gtk.Label(_("Angle:"));
+ public Gtk.Button ok_button = new Gtk.Button.with_mnemonic(_("_Straighten"));
+ public Gtk.Button cancel_button = new Gtk.Button.from_stock(Gtk.Stock.CANCEL);
+ public Gtk.Button reset_button = new Gtk.Button.with_mnemonic(_("_Reset"));
+
+ /**
+ * Prepare straighten tool's window for use and initialize all its controls.
+ *
+ * @param container The application's main window.
+ */
+ public StraightenToolWindow(Gtk.Window container) {
+ base(container);
+
+ angle_slider.set_min_slider_size(MIN_SLIDER_SIZE);
+ angle_slider.set_size_request(MIN_SLIDER_SIZE, -1);
+ angle_slider.set_value(0.0);
+ angle_slider.set_draw_value(false);
+
+ description_label.set_padding(CONTROL_SPACING, 0);
+ angle_label.set_padding(0, 0);
+ angle_label.set_size_request(MIN_LABEL_SIZE,-1);
+
+ Gtk.Box slider_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING);
+ slider_layout.pack_start(angle_slider, true, true, 0);
+
+ Gtk.Box button_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, CONTROL_SPACING);
+ cancel_button.set_size_request(MIN_BUTTON_SIZE, -1);
+ reset_button.set_size_request(MIN_BUTTON_SIZE, -1);
+ ok_button.set_size_request(MIN_BUTTON_SIZE, -1);
+ button_layout.pack_start(cancel_button, true, true, 0);
+ button_layout.pack_start(reset_button, true, true, 0);
+ button_layout.pack_start(ok_button, true, true, 0);
+
+ Gtk.Box main_layout = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 0);
+ main_layout.pack_start(description_label, true, true, 0);
+ main_layout.pack_start(slider_layout, true, true, 0);
+ main_layout.pack_start(angle_label, true, true, 0);
+ main_layout.pack_start(button_layout, true, true, 0);
+
+ add(main_layout);
+
+ reset_button.clicked.connect(on_reset_clicked);
+
+ set_position(Gtk.WindowPosition.CENTER_ON_PARENT);
+ }
+
+ private void on_reset_clicked() {
+ angle_slider.set_value(0.0);
+ }
+ }
+
+ private StraightenToolWindow window;
+
+ // the incoming image itself.
+ private Cairo.Surface photo_surf;
+ Dimensions image_dims;
+
+ // temporary surface we'll draw the rotated image into.
+ private Cairo.Surface rotate_surf;
+ private Cairo.Context rotate_ctx;
+
+ private Dimensions last_viewport;
+ private int view_width;
+ private int view_height;
+ private double photo_angle = 0.0;
+
+ // should we use a nicer-but-more-expensive filter
+ // when repainting the rotated image?
+ private bool use_high_qual = true;
+ private OneShotScheduler? slider_sched = null;
+
+ private Gdk.Point crop_center; // original center in image coordinates
+ private int crop_width;
+ private int crop_height;
+
+ private StraightenGuide guide = new StraightenGuide();
+
+ // As the crop box rotates, we adjust its center and/or scale it so that it fits in the image.
+ private Gdk.Point rotated_center; // in image coordinates
+ private double rotate_scale; // always <= 1.0: rotation may shrink but not grow box
+
+ private double preview_scale;
+
+ private StraightenTool() {
+ base("StraightenTool");
+ }
+
+ public static StraightenTool factory() {
+ return new StraightenTool();
+ }
+
+ public static bool is_available(Photo photo, Scaling scaling) {
+ return true;
+ }
+
+ /**
+ * @brief Signal handler for when the 'OK' button has been clicked. Computes where a previously-
+ * set crop region should have rotated to (to match the Photo's straightening angle).
+ *
+ * @note After this has been called against a Photo, it will always have a crop region; in the
+ * case of a previously-uncropped Photo, the crop region will be set to the original dimensions
+ * of the photo and centered at the Photo's center.
+ */
+ private void on_ok_clicked() {
+ assert(canvas.get_photo() != null);
+
+ // compute where the crop box should be now and set the image's
+ // current crop to it
+ double slider_val = window.angle_slider.get_value();
+
+ Gdk.Point new_crop_center = rotate_point_arb(rotated_center,
+ image_dims.width, image_dims.height, slider_val);
+
+ StraightenCommand command = new StraightenCommand(
+ canvas.get_photo(), slider_val,
+ Box.from_center(new_crop_center,
+ (int) (rotate_scale * crop_width), (int) (rotate_scale * crop_height)),
+ Resources.STRAIGHTEN_LABEL, Resources.STRAIGHTEN_TOOLTIP);
+ applied(command, null, image_dims, true);
+ }
+
+ private void high_qual_repaint(){
+ use_high_qual = true;
+ update_rotated_surface();
+ this.canvas.repaint();
+ }
+
+ private void on_slider_stopped_delayed() {
+ high_qual_repaint();
+ }
+
+ public override void on_left_click(int x, int y) {
+ guide.reset(x, y, photo_angle);
+ }
+
+ public override void on_left_released(int x, int y) {
+ guide.update(x, y);
+ double? a = guide.get_angle();
+ guide.clear();
+ if (a != null) {
+ window.angle_slider.set_value(a);
+ high_qual_repaint();
+ }
+ }
+
+ public override void on_motion(int x, int y, Gdk.ModifierType mask) {
+ if (guide.update(x, y))
+ canvas.repaint();
+ }
+
+ public override bool on_keypress(Gdk.EventKey event) {
+ if ((Gdk.keyval_name(event.keyval) == "KP_Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Enter") ||
+ (Gdk.keyval_name(event.keyval) == "Return")) {
+ on_ok_clicked();
+ return true;
+ }
+
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ notify_cancel();
+ return true;
+ }
+
+ return base.on_keypress(event);
+ }
+
+ private void prepare_image() {
+ Dimensions canvas_dims = canvas.get_surface_dim();
+ Dimensions viewport = canvas_dims.with_max(TEMP_PIXBUF_SIZE, TEMP_PIXBUF_SIZE);
+ if (viewport == last_viewport)
+ return; // no change
+
+ last_viewport = viewport;
+
+ Gdk.Pixbuf low_res_tmp = null;
+ try {
+ low_res_tmp =
+ canvas.get_photo().get_pixbuf_with_options(Scaling.for_viewport(viewport, false),
+ Photo.Exception.STRAIGHTEN | Photo.Exception.CROP);
+ } catch (Error e) {
+ warning("A pixbuf for %s couldn't be fetched.", canvas.get_photo().to_string());
+ low_res_tmp = new Gdk.Pixbuf(Gdk.Colorspace.RGB, false, 8, 1, 1);
+ }
+
+ preview_scale = low_res_tmp.width / (double) image_dims.width;
+
+ // copy image data from photo into a cairo surface.
+ photo_surf = new Cairo.ImageSurface(Cairo.Format.ARGB32, low_res_tmp.width, low_res_tmp.height);
+ Cairo.Context ctx = new Cairo.Context(photo_surf);
+ Gdk.cairo_set_source_pixbuf(ctx, low_res_tmp, 0, 0);
+ ctx.rectangle(0, 0, low_res_tmp.width, low_res_tmp.height);
+ ctx.fill();
+ ctx.paint();
+
+ // prepare rotation surface and context. we paint a rotated,
+ // low-res copy of the image into it, followed by a faint grid.
+ view_width = (int) (crop_width * preview_scale);
+ view_height = (int) (crop_height * preview_scale);
+ rotate_surf = new Cairo.ImageSurface(Cairo.Format.ARGB32, view_width, view_height);
+ rotate_ctx = new Cairo.Context(rotate_surf);
+ }
+
+ // Adjust the rotated crop box so that it fits in the source image.
+ void adjust_for_rotation() {
+ double width, height;
+ compute_arb_rotated_size(crop_width, crop_height, photo_angle, out width, out height);
+
+ // First compute a scaling factor that will let the rotated box fit in the image.
+ rotate_scale = double.min(image_dims.width / width, image_dims.height / height);
+ rotate_scale = double.min(rotate_scale, 1.0);
+
+ // Now nudge the box into the image if necessary.
+ rotated_center = crop_center;
+ int radius_x = (int) (rotate_scale * width / 2);
+ int radius_y = (int) (rotate_scale * height / 2);
+ rotated_center.x = rotated_center.x.clamp(radius_x, image_dims.width - radius_x);
+ rotated_center.y = rotated_center.y.clamp(radius_y, image_dims.height - radius_y);
+ }
+
+ /**
+ * @brief Spawn the tool window, set up the scratch surfaces and prepare the straightening
+ * tool for use. If a valid pixbuf of the incoming Photo can't be loaded for any
+ * reason, the tool will use a 1x1 temporary image instead to avoid crashing.
+ *
+ * @param canvas The PhotoCanvas the tool's output should be painted to.
+ */
+ public override void activate(PhotoCanvas canvas) {
+ base.activate(canvas);
+ this.canvas = canvas;
+ bind_canvas_handlers(this.canvas);
+
+ image_dims = canvas.get_photo().get_dimensions(
+ Photo.Exception.STRAIGHTEN | Photo.Exception.CROP);
+
+ Box crop_region;
+ if (!canvas.get_photo().get_crop(out crop_region)) {
+ crop_region.left = 0;
+ crop_region.right = image_dims.width;
+
+ crop_region.top = 0;
+ crop_region.bottom = image_dims.height;
+ }
+
+ // read the photo's current angle and start the tool with the slider set to that value. we
+ // also use this to de-rotate the crop region
+ double incoming_angle = 0.0;
+ canvas.get_photo().get_straighten(out incoming_angle);
+
+ // Translate the crop center to image coordinates.
+ crop_center = derotate_point_arb(crop_region.get_center(),
+ image_dims.width, image_dims.height, incoming_angle);
+ crop_width = crop_region.get_width();
+ crop_height = crop_region.get_height();
+
+ adjust_for_rotation();
+
+ prepare_image();
+
+ // set crosshair cursor
+ canvas.get_drawing_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.CROSSHAIR));
+
+ window = new StraightenToolWindow(canvas.get_container());
+ bind_window_handlers();
+
+ // prepare ths slider for display
+ window.angle_slider.set_value(incoming_angle);
+ photo_angle = incoming_angle;
+
+ string tmp = "%2.1f°".printf(incoming_angle);
+ window.angle_label.set_text(tmp);
+
+ high_qual_repaint();
+ window.show_all();
+ }
+
+ /**
+ * Tears down the tool window and frees resources.
+ */
+ public override void deactivate() {
+ if(window != null) {
+
+ unbind_window_handlers();
+
+ window.hide();
+ window = null;
+ }
+
+ if (canvas != null) {
+ unbind_canvas_handlers(canvas);
+ canvas.get_drawing_window().set_cursor(null);
+ }
+
+ base.deactivate();
+ }
+
+ private void bind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.resized_scaled_pixbuf.connect(on_resized_pixbuf);
+ }
+
+ private void unbind_canvas_handlers(PhotoCanvas canvas) {
+ canvas.resized_scaled_pixbuf.disconnect(on_resized_pixbuf);
+ }
+
+ private void bind_window_handlers() {
+ window.key_press_event.connect(on_keypress);
+ window.ok_button.clicked.connect(on_ok_clicked);
+ window.cancel_button.clicked.connect(notify_cancel);
+ window.angle_slider.value_changed.connect(on_angle_changed);
+ }
+
+ private void unbind_window_handlers() {
+ window.key_press_event.disconnect(on_keypress);
+ window.ok_button.clicked.disconnect(on_ok_clicked);
+ window.cancel_button.clicked.disconnect(notify_cancel);
+ window.angle_slider.value_changed.disconnect(on_angle_changed);
+ }
+
+ private void on_angle_changed() {
+ photo_angle = window.angle_slider.get_value();
+ string tmp = "%2.1f°".printf(window.angle_slider.get_value());
+ window.angle_label.set_text(tmp);
+
+ if (slider_sched == null)
+ slider_sched = new OneShotScheduler("straighten", on_slider_stopped_delayed);
+ slider_sched.after_timeout(REPAINT_ON_STOP_DELAY_MSEC, true);
+
+ use_high_qual = false;
+
+ adjust_for_rotation();
+ update_rotated_surface();
+ this.canvas.repaint();
+ }
+
+ /**
+ * @brief Called by the EditingHostPage when a resize event occurs.
+ */
+ private void on_resized_pixbuf(Dimensions old_dim, Gdk.Pixbuf scaled, Gdk.Rectangle scaled_position) {
+ prepare_image();
+ }
+
+ /**
+ * Returns a reference to the current StraightenTool instance's tool window;
+ * the PhotoPage uses this to control the tool window's positioning, etc.
+ */
+ public override EditingToolWindow? get_tool_window() {
+ return window;
+ }
+
+ /**
+ * Draw the rotated photo and grid.
+ */
+ 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);
+ draw_superimposed_grid(rotate_ctx, view_width, view_height);
+ }
+
+ /**
+ * Render a smaller, rotated version of the image, with a grid superimposed over it.
+ *
+ * @param ctx The rendering context of a 'scratch' Cairo surface. The tool makes its own
+ * surfaces and contexts so it can have things set up exactly like it wants them, so
+ * 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();
+
+ // fill region behind the rotation surface with neutral color.
+ canvas.get_default_ctx().identity_matrix();
+ canvas.get_default_ctx().set_source_rgba(0.0, 0.0, 0.0, 1.0);
+ canvas.get_default_ctx().rectangle(0, 0, w, h);
+ canvas.get_default_ctx().fill();
+
+ // copy the composited result to the main window.
+ canvas.get_default_ctx().translate((w - view_width) / 2.0, (h - view_height) / 2.0);
+ canvas.get_default_ctx().set_source_surface(rotate_surf, 0, 0);
+ canvas.get_default_ctx().rectangle(0, 0, view_width, view_height);
+ canvas.get_default_ctx().fill();
+ canvas.get_default_ctx().paint();
+
+ // reset the 'modelview' matrix, since when the canvas is not in
+ // 'tool' mode, it 'expects' things to be set up a certain way.
+ canvas.get_default_ctx().identity_matrix();
+
+ guide.draw(canvas.get_default_ctx());
+ }
+
+ /**
+ * Copy a rotated version of the source image onto the destination
+ * context.
+ *
+ * @param src_surf A Cairo surface containing the source image.
+ * @param dest_ctx The rendering context of the destination image.
+ * @param src_width The width of the image data in src_surf in pixels.
+ * @param src_height The height of the image data in src_surf in pixels.
+ * @param angle The angle the source image should be rotated by, in degrees.
+ */
+ private void draw_rotated_source(Cairo.Surface src_surf, Cairo.Context dest_ctx,
+ int src_width, int src_height, double angle) {
+ double angle_internal = degrees_to_radians(angle);
+
+ // fill area behind rotated image with neutral color to avoid 'ghosting'.
+ // this should be removed after #4612 has been addressed.
+ dest_ctx.identity_matrix();
+ dest_ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0);
+ dest_ctx.rectangle(0, 0, view_width, view_height);
+ dest_ctx.fill();
+
+ // rotate the image, taking into account that the position of the
+ // upper left corner must change depending on rotation amount and direction
+ // and translate so center of preview crop region is now center of rotation
+ dest_ctx.identity_matrix();
+
+ dest_ctx.translate(view_width / 2, view_height / 2);
+ dest_ctx.scale(1.0 / rotate_scale, 1.0 / rotate_scale);
+ dest_ctx.rotate(angle_internal);
+ dest_ctx.translate(- rotated_center.x * preview_scale, - rotated_center.y * preview_scale);
+
+ dest_ctx.set_source_surface(src_surf, 0, 0);
+ dest_ctx.get_source().set_filter(use_high_qual ? Cairo.Filter.BEST : Cairo.Filter.NEAREST);
+ dest_ctx.rectangle(0, 0, src_width, src_height);
+ dest_ctx.fill();
+ dest_ctx.paint();
+ }
+
+ /**
+ * Superimpose a faint grid over the supplied image.
+ *
+ * @param width The total width the grid should be drawn to.
+ * @param height The total height the grid should be drawn to.
+ * @param dest_ctx The rendering context of the destination image.
+ */
+ private void draw_superimposed_grid(Cairo.Context dest_ctx, int width, int height) {
+ int half_width = width / 2;
+ int quarter_width = width / 4;
+
+ int half_height = height / 2;
+ int quarter_height = height / 4;
+
+ dest_ctx.identity_matrix();
+ dest_ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0);
+
+ canvas.draw_horizontal_line(dest_ctx, 0, 0, width, false);
+ canvas.draw_horizontal_line(dest_ctx, 0, half_height, width, false);
+ canvas.draw_horizontal_line(dest_ctx, 0, view_height - 1, width, false);
+
+ canvas.draw_vertical_line(dest_ctx, 0, 0, height + 1, false);
+ canvas.draw_vertical_line(dest_ctx, half_width, 0, height + 1, false);
+ canvas.draw_vertical_line(dest_ctx, width - 1, 0, height + 1, false);
+
+ dest_ctx.set_source_rgba(1.0, 1.0, 1.0, 0.33);
+
+ canvas.draw_horizontal_line(dest_ctx, 0, quarter_height, width, false);
+ canvas.draw_horizontal_line(dest_ctx, 0, half_height + quarter_height, width, false);
+ canvas.draw_vertical_line(dest_ctx, quarter_width, 0, height, false);
+ canvas.draw_vertical_line(dest_ctx, half_width + quarter_width, 0, height, false);
+ }
+}
+
+} // end namespace
diff --git a/src/editing_tools/mk/editing_tools.mk b/src/editing_tools/mk/editing_tools.mk
new file mode 100644
index 0000000..424c525
--- /dev/null
+++ b/src/editing_tools/mk/editing_tools.mk
@@ -0,0 +1,28 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := EditingTools
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := editing_tools
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ StraightenTool.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/events/Branch.vala b/src/events/Branch.vala
new file mode 100644
index 0000000..e1b5221
--- /dev/null
+++ b/src/events/Branch.vala
@@ -0,0 +1,542 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Events.Branch : Sidebar.Branch {
+ internal static Icon open_icon;
+ internal static Icon closed_icon;
+ internal static Icon events_icon;
+ internal static Icon single_event_icon;
+ internal static Icon no_event_icon;
+
+ // NOTE: Because the comparators must be static methods (due to CompareFunc's stupid impl.)
+ // and there's an assumption that only one Events.Branch is ever created, this is a static
+ // member but it's modified by instance methods.
+ private static bool sort_ascending = false;
+
+ private Gee.HashMap<Event, Events.EventEntry> entry_map = new Gee.HashMap<
+ Event, Events.EventEntry>();
+ private Events.UndatedDirectoryEntry undated_entry = new Events.UndatedDirectoryEntry();
+ private Events.NoEventEntry no_event_entry = new Events.NoEventEntry();
+
+ public Branch() {
+ base (new Events.MasterDirectoryEntry(), Sidebar.Branch.Options.STARTUP_EXPAND_TO_FIRST_CHILD,
+ event_year_comparator);
+
+ // seed the branch
+ foreach (DataObject object in Event.global.get_all())
+ add_event((Event) object);
+
+ show_no_events(Event.global.get_no_event_objects().size > 0);
+
+ // monitor Events for future changes
+ Event.global.contents_altered.connect(on_events_added_removed);
+ Event.global.items_altered.connect(on_events_altered);
+ Event.global.no_event_collection_altered.connect(on_no_event_collection_altered);
+
+ // monitor sorting criteria (see note at sort_ascending about this)
+ Config.Facade.get_instance().events_sort_ascending_changed.connect(on_config_changed);
+ }
+
+ ~Branch() {
+ Event.global.contents_altered.disconnect(on_events_added_removed);
+ Event.global.items_altered.disconnect(on_events_altered);
+ Event.global.no_event_collection_altered.disconnect(on_no_event_collection_altered);
+
+ Config.Facade.get_instance().events_sort_ascending_changed.disconnect(on_config_changed);
+ }
+
+ internal static void init() {
+ open_icon = new ThemedIcon(Resources.ICON_FOLDER_OPEN);
+ closed_icon = new ThemedIcon(Resources.ICON_FOLDER_CLOSED);
+ events_icon = new ThemedIcon(Resources.ICON_EVENTS);
+ single_event_icon = new ThemedIcon(Resources.ICON_ONE_EVENT);
+ no_event_icon = new ThemedIcon(Resources.ICON_NO_EVENT);
+
+ sort_ascending = Config.Facade.get_instance().get_events_sort_ascending();
+ }
+
+ internal static void terminate() {
+ open_icon = null;
+ closed_icon = null;
+ events_icon = null;
+ single_event_icon = null;
+ no_event_icon = null;
+ }
+
+ public Events.MasterDirectoryEntry get_master_entry() {
+ return (Events.MasterDirectoryEntry) get_root();
+ }
+
+ private static int event_year_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ // The Undated and No Event entries should always appear last in the
+ // list, respectively.
+ if (a is Events.UndatedDirectoryEntry) {
+ if (b is Events.NoEventEntry)
+ return -1;
+ return 1;
+ } else if (b is Events.UndatedDirectoryEntry) {
+ if (a is Events.NoEventEntry)
+ return 1;
+ return -1;
+ }
+
+ if (a is Events.NoEventEntry)
+ return 1;
+ else if (b is Events.NoEventEntry)
+ return -1;
+
+ if (!sort_ascending) {
+ Sidebar.Entry swap = a;
+ a = b;
+ b = swap;
+ }
+
+ int result =
+ ((Events.YearDirectoryEntry) a).get_year() - ((Events.YearDirectoryEntry) b).get_year();
+ assert(result != 0);
+
+ return result;
+ }
+
+ private static int event_month_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ if (!sort_ascending) {
+ Sidebar.Entry swap = a;
+ a = b;
+ b = swap;
+ }
+
+ int result =
+ ((Events.MonthDirectoryEntry) a).get_month() - ((Events.MonthDirectoryEntry) b).get_month();
+ assert(result != 0);
+
+ return result;
+ }
+
+ private static int event_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ if (!sort_ascending) {
+ Sidebar.Entry swap = a;
+ a = b;
+ b = swap;
+ }
+
+ int64 result = ((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) {
+ result = ((Events.EventEntry) a).get_event().get_event_id().id
+ - ((Events.EventEntry) b).get_event().get_event_id().id;
+ }
+
+ assert(result != 0);
+
+ return (result < 0) ? -1 : 1;
+ }
+
+ private static int undated_event_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ if (!sort_ascending) {
+ Sidebar.Entry swap = a;
+ a = b;
+ b = swap;
+ }
+
+ int ret = ((Events.EventEntry) a).get_event().get_name().collate(
+ ((Events.EventEntry) b).get_event().get_name());
+
+ if (ret == 0)
+ ret = (int) (((Events.EventEntry) b).get_event().get_instance_id() -
+ ((Events.EventEntry) a).get_event().get_instance_id());
+
+ return ret;
+ }
+
+ public Events.EventEntry? get_entry_for_event(Event event) {
+ return entry_map.get(event);
+ }
+
+ private void on_config_changed() {
+ bool value = Config.Facade.get_instance().get_events_sort_ascending();
+
+ sort_ascending = value;
+ reorder_all();
+ }
+
+ private void on_events_added_removed(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ if (added != null) {
+ foreach (DataObject object in added)
+ add_event((Event) object);
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed)
+ remove_event((Event) object);
+ }
+ }
+
+ private void on_events_altered(Gee.Map<DataObject, Alteration> altered) {
+ foreach (DataObject object in altered.keys) {
+ Event event = (Event) object;
+ Alteration alteration = altered.get(object);
+
+ if (alteration.has_detail("metadata", "time")) {
+ // can't merely re-sort the event because it might have moved to a new month or
+ // even a new year
+ move_event(event);
+ } else if (alteration.has_detail("metadata", "name")) {
+ Events.EventEntry? entry = entry_map.get(event);
+ assert(entry != null);
+
+ entry.sidebar_name_changed(event.get_name());
+ entry.sidebar_tooltip_changed(event.get_name());
+ }
+ }
+ }
+
+ private void on_no_event_collection_altered() {
+ show_no_events(Event.global.get_no_event_objects().size > 0);
+ }
+
+ private void add_event(Event event) {
+ time_t event_time = event.get_start_time();
+ if (event_time == 0) {
+ add_undated_event(event);
+
+ return;
+ }
+
+ Time event_tm = Time.local(event_time);
+
+ Sidebar.Entry? year;
+ Sidebar.Entry? month = find_event_month(event, event_tm, out year);
+ if (month != null) {
+ graft_event(month, event, event_comparator);
+
+ return;
+ }
+
+ if (year == null) {
+ year = new Events.YearDirectoryEntry(event_tm.format(SubEventsDirectoryPage.YEAR_FORMAT),
+ event_tm);
+ graft(get_root(), year, event_month_comparator);
+ }
+
+ month = new Events.MonthDirectoryEntry(event_tm.format(SubEventsDirectoryPage.MONTH_FORMAT),
+ event_tm);
+ graft(year, month, event_comparator);
+
+ graft_event(month, event, event_comparator);
+ }
+
+ private void move_event(Event event) {
+ time_t event_time = event.get_start_time();
+ if (event_time == 0) {
+ move_to_undated_event(event);
+
+ return;
+ }
+
+ Time event_tm = Time.local(event_time);
+
+ Sidebar.Entry? year;
+ Sidebar.Entry? month = find_event_month(event, event_tm, out year);
+
+ if (year == null) {
+ year = new Events.YearDirectoryEntry(event_tm.format(SubEventsDirectoryPage.YEAR_FORMAT),
+ event_tm);
+ graft(get_root(), year, event_month_comparator);
+ }
+
+ if (month == null) {
+ month = new Events.MonthDirectoryEntry(event_tm.format(SubEventsDirectoryPage.MONTH_FORMAT),
+ event_tm);
+ graft(year, month, event_comparator);
+ }
+
+ reparent_event(event, month);
+ }
+
+ private void remove_event(Event event) {
+ // the following code works for undated events as well as dated (no need for special
+ // case, as in add_event())
+ Sidebar.Entry? entry;
+ bool removed = entry_map.unset(event, out entry);
+ assert(removed);
+
+ Sidebar.Entry? parent = get_parent(entry);
+ assert(parent != null);
+
+ prune(entry);
+
+ // prune up the tree to the root
+ while (get_child_count(parent) == 0 && parent != get_root()) {
+ Sidebar.Entry? grandparent = get_parent(parent);
+ assert(grandparent != null);
+
+ prune(parent);
+
+ parent = grandparent;
+ }
+ }
+
+ private Sidebar.Entry? find_event_month(Event event, Time 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;
+
+ // found the year, traverse the months
+ return find_first_child(found_year, (entry) => {
+ return ((Events.MonthDirectoryEntry) entry).get_month() == event_month;
+ });
+ }
+
+ private Sidebar.Entry? find_event_year(Event event, Time event_tm) {
+ int event_year = event_tm.year + 1900;
+
+ return find_first_child(get_root(), (entry) => {
+ if ((entry is Events.UndatedDirectoryEntry) || (entry is Events.NoEventEntry))
+ return false;
+ else
+ return ((Events.YearDirectoryEntry) entry).get_year() == event_year;
+ });
+ }
+
+ private void add_undated_event(Event event) {
+ if (!has_entry(undated_entry))
+ graft(get_root(), undated_entry, undated_event_comparator);
+
+ graft_event(undated_entry, event);
+ }
+
+ private void move_to_undated_event(Event event) {
+ if (!has_entry(undated_entry))
+ graft(get_root(), undated_entry);
+
+ reparent_event(event, undated_entry);
+ }
+
+ private void graft_event(Sidebar.Entry parent, Event event,
+ owned CompareDataFunc<Sidebar.Entry>? comparator = null) {
+ Events.EventEntry entry = new Events.EventEntry(event);
+ entry_map.set(event, entry);
+
+ graft(parent, entry, (owned) comparator);
+ }
+
+ private void reparent_event(Event event, Sidebar.Entry new_parent) {
+ Events.EventEntry? entry = entry_map.get(event);
+ assert(entry != null);
+
+ Sidebar.Entry? old_parent = get_parent(entry);
+ assert(old_parent != null);
+
+ reparent(new_parent, entry);
+
+ while (get_child_count(old_parent) == 0 && old_parent != get_root()) {
+ Sidebar.Entry? grandparent = get_parent(old_parent);
+ assert(grandparent != null);
+
+ prune(old_parent);
+
+ old_parent = grandparent;
+ }
+ }
+
+ private void show_no_events(bool show) {
+ if (show && !has_entry(no_event_entry))
+ graft(get_root(), no_event_entry);
+ else if (!show && has_entry(no_event_entry))
+ prune(no_event_entry);
+ }
+}
+
+public abstract class Events.DirectoryEntry : Sidebar.SimplePageEntry, Sidebar.ExpandableEntry {
+ public DirectoryEntry() {
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return null;
+ }
+
+ public virtual Icon? get_sidebar_open_icon() {
+ return Events.Branch.open_icon;
+ }
+
+ public virtual Icon? get_sidebar_closed_icon() {
+ return Events.Branch.closed_icon;
+ }
+
+ public bool expand_on_select() {
+ return true;
+ }
+}
+
+public class Events.MasterDirectoryEntry : Events.DirectoryEntry {
+ public MasterDirectoryEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return MasterEventsDirectoryPage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return Events.Branch.events_icon;
+ }
+
+ public override Icon? get_sidebar_open_icon() {
+ return Events.Branch.events_icon;
+ }
+
+ public override Icon? get_sidebar_closed_icon() {
+ return Events.Branch.events_icon;
+ }
+
+ protected override Page create_page() {
+ return new MasterEventsDirectoryPage();
+ }
+}
+
+public class Events.YearDirectoryEntry : Events.DirectoryEntry {
+ private string name;
+ private Time tm;
+
+ public YearDirectoryEntry(string name, Time tm) {
+ this.name = name;
+ this.tm = tm;
+ }
+
+ public override string get_sidebar_name() {
+ return name;
+ }
+
+ public int get_year() {
+ return tm.year + 1900;
+ }
+
+ protected override Page create_page() {
+ return new SubEventsDirectoryPage(SubEventsDirectoryPage.DirectoryType.YEAR, tm);
+ }
+}
+
+public class Events.MonthDirectoryEntry : Events.DirectoryEntry {
+ private string name;
+ private Time tm;
+
+ public MonthDirectoryEntry(string name, Time tm) {
+ this.name = name;
+ this.tm = tm;
+ }
+
+ public override string get_sidebar_name() {
+ return name;
+ }
+
+ public int get_year() {
+ return tm.year + 1900;
+ }
+
+ public int get_month() {
+ return tm.month + 1;
+ }
+
+ protected override Page create_page() {
+ return new SubEventsDirectoryPage(SubEventsDirectoryPage.DirectoryType.MONTH, tm);
+ }
+}
+
+public class Events.UndatedDirectoryEntry : Events.DirectoryEntry {
+ public UndatedDirectoryEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return SubEventsDirectoryPage.UNDATED_PAGE_NAME;
+ }
+
+ protected override Page create_page() {
+ return new SubEventsDirectoryPage(SubEventsDirectoryPage.DirectoryType.UNDATED,
+ Time.local(0));
+ }
+}
+
+public class Events.EventEntry : Sidebar.SimplePageEntry, Sidebar.RenameableEntry,
+ Sidebar.InternalDropTargetEntry {
+ private Event event;
+
+ public EventEntry(Event event) {
+ this.event = event;
+ }
+
+ public Event get_event() {
+ return event;
+ }
+
+ public override string get_sidebar_name() {
+ return event.get_name();
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return Events.Branch.single_event_icon;
+ }
+
+ protected override Page create_page() {
+ return new EventPage(event);
+ }
+
+ public void rename(string new_name) {
+ string? prepped = Event.prep_event_name(new_name);
+ if (prepped != null)
+ AppWindow.get_command_manager().execute(new RenameEventCommand(event, prepped));
+ }
+
+ public bool internal_drop_received(Gee.List<MediaSource> media) {
+ // ugh ... some early Commands expected DataViews instead of DataSources (to make life
+ // easier for Pages) and this is one of the prices paid for that
+ Gee.ArrayList<DataView> views = new Gee.ArrayList<DataView>();
+ foreach (MediaSource media_source in media)
+ views.add(new DataView(media_source));
+
+ AppWindow.get_command_manager().execute(new SetEventCommand(views, event));
+
+ return true;
+ }
+
+ public bool internal_drop_received_arbitrary(Gtk.SelectionData data) {
+ return false;
+ }
+}
+
+public class Events.NoEventEntry : Sidebar.SimplePageEntry {
+ public NoEventEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return NoEventPage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return Events.Branch.no_event_icon;
+ }
+
+ protected override Page create_page() {
+ return new NoEventPage();
+ }
+}
+
diff --git a/src/events/EventDirectoryItem.vala b/src/events/EventDirectoryItem.vala
new file mode 100644
index 0000000..5b2026b
--- /dev/null
+++ b/src/events/EventDirectoryItem.vala
@@ -0,0 +1,189 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+class EventDirectoryItem : CheckerboardItem {
+ private static int CROPPED_SCALE {
+ get {
+ return ThumbnailCache.Size.MEDIUM.get_scale()
+ + ((ThumbnailCache.Size.BIG.get_scale() - ThumbnailCache.Size.MEDIUM.get_scale()) / 2);
+ }
+ }
+
+ public static Scaling squared_scaling = Scaling.to_fill_viewport(Dimensions(CROPPED_SCALE,
+ CROPPED_SCALE));
+
+ public Event event;
+
+ private Gdk.Rectangle paul_lynde = Gdk.Rectangle();
+
+ public EventDirectoryItem(Event event) {
+ base (event, Dimensions(CROPPED_SCALE, CROPPED_SCALE), get_formatted_title(event), event.get_comment(), true,
+ Pango.Alignment.CENTER);
+
+ this.event = event;
+
+ // find the center square
+ paul_lynde = get_paul_lynde_rect(event.get_primary_source());
+
+ // don't display yet, but claim its dimensions
+ clear_image(Dimensions.for_rectangle(paul_lynde));
+
+ // monitor the event for changes
+ Event.global.items_altered.connect(on_events_altered);
+ }
+
+ ~EventDirectoryItem() {
+ Event.global.items_altered.disconnect(on_events_altered);
+ }
+
+ // square the photo's dimensions and locate the pixbuf's center square
+ private static Gdk.Rectangle get_paul_lynde_rect(MediaSource source) {
+ Dimensions scaled = squared_scaling.get_scaled_dimensions(source.get_dimensions());
+
+ Gdk.Rectangle paul_lynde = Gdk.Rectangle();
+ paul_lynde.x = (scaled.width - CROPPED_SCALE).clamp(0, scaled.width) / 2;
+ paul_lynde.y = (scaled.height - CROPPED_SCALE).clamp(0, scaled.height) / 2;
+ paul_lynde.width = CROPPED_SCALE;
+ paul_lynde.height = CROPPED_SCALE;
+
+ return paul_lynde;
+ }
+
+ // scale and crop the center square of the media
+ private static Gdk.Pixbuf get_paul_lynde(MediaSource media, Gdk.Rectangle paul_lynde) throws Error {
+ Gdk.Pixbuf pixbuf = media.get_preview_pixbuf(squared_scaling);
+
+ Dimensions thumbnail_dimensions = Dimensions.for_pixbuf(pixbuf);
+
+ if (thumbnail_dimensions.width > 2 * paul_lynde.width ||
+ thumbnail_dimensions.height > paul_lynde.height * 2 ) {
+ LibraryPhoto photo = (LibraryPhoto) media;
+ pixbuf = photo.get_pixbuf(squared_scaling);
+ thumbnail_dimensions = Dimensions.for_pixbuf(pixbuf);
+ }
+
+ // to catch rounding errors in the two algorithms
+ paul_lynde = clamp_rectangle(paul_lynde, thumbnail_dimensions);
+
+ // crop the center square
+ return new Gdk.Pixbuf.subpixbuf(pixbuf, paul_lynde.x, paul_lynde.y, paul_lynde.width,
+ paul_lynde.height);
+ }
+
+ private static string get_formatted_title(Event event) {
+ bool has_photos = MediaSourceCollection.has_photo(event.get_media());
+ bool has_videos = MediaSourceCollection.has_video(event.get_media());
+
+ int count = event.get_media_count();
+ string count_text = "";
+ if (has_photos && has_videos)
+ count_text = ngettext("%d Photo/Video", "%d Photos/Videos", count).printf(count);
+ else if (has_videos)
+ count_text = ngettext("%d Video", "%d Videos", count).printf(count);
+ else
+ count_text = ngettext("%d Photo", "%d Photos", count).printf(count);
+
+ string? daterange = event.get_formatted_daterange();
+ string name = event.get_name();
+
+ // if we don't have a daterange or if it's the same as name, then don't print it; otherwise
+ // print it beneath the preview photo
+ if (daterange == null || daterange == name)
+ return "<b>%s</b>\n%s".printf(guarded_markup_escape_text(name),
+ guarded_markup_escape_text(count_text));
+ else
+ return "<b>%s</b>\n%s\n%s".printf(guarded_markup_escape_text(name),
+ guarded_markup_escape_text(count_text), guarded_markup_escape_text(daterange));
+ }
+
+ public override void exposed() {
+ if (is_exposed())
+ return;
+
+ try {
+ set_image(get_paul_lynde(event.get_primary_source(), paul_lynde));
+ } catch (Error err) {
+ critical("Unable to fetch preview for %s: %s", event.to_string(), err.message);
+ }
+
+ update_comment();
+
+ base.exposed();
+ }
+
+ public override void unexposed() {
+ if (!is_exposed())
+ return;
+
+ clear_image(Dimensions.for_rectangle(paul_lynde));
+
+ base.unexposed();
+ }
+
+ private void on_events_altered(Gee.Map<DataObject, Alteration> map) {
+ update_comment();
+ if (map.has_key(event))
+ set_title(get_formatted_title(event), true, Pango.Alignment.CENTER);
+ }
+
+ protected override void thumbnail_altered() {
+ MediaSource media = event.get_primary_source();
+
+ // get new center square
+ paul_lynde = get_paul_lynde_rect(media);
+
+ if (is_exposed()) {
+ try {
+ set_image(get_paul_lynde(media, paul_lynde));
+ } catch (Error err) {
+ critical("Unable to fetch preview for %s: %s", event.to_string(), err.message);
+ }
+ } else {
+ clear_image(Dimensions.for_rectangle(paul_lynde));
+ }
+
+ base.thumbnail_altered();
+ }
+
+ protected override void paint_shadow(Cairo.Context ctx, Dimensions dimensions, Gdk.Point origin,
+ int radius, float initial_alpha) {
+ Dimensions altered = Dimensions(dimensions.width - 25, dimensions.height - 25);
+ base.paint_shadow(ctx, altered, origin, 36, initial_alpha);
+ }
+
+ protected override void paint_border(Cairo.Context ctx, Dimensions object_dimensions,
+ Gdk.Point object_origin, int border_width) {
+ Dimensions dimensions = get_border_dimensions(object_dimensions, border_width);
+ Gdk.Point origin = get_border_origin(object_origin, border_width);
+
+ draw_rounded_corners_filled(ctx, dimensions, origin, 6.0);
+ }
+
+ protected override void paint_image(Cairo.Context ctx, Gdk.Pixbuf pixbuf,
+ Gdk.Point origin) {
+ Dimensions dimensions = Dimensions.for_pixbuf(pixbuf);
+
+ if (pixbuf.get_has_alpha())
+ draw_rounded_corners_filled(ctx, dimensions, origin, 6.0);
+
+ // use rounded corners on events
+ context_rounded_corners(ctx, dimensions, origin, 6.0);
+ Gdk.cairo_set_source_pixbuf(ctx, pixbuf, origin.x, origin.y);
+ ctx.paint();
+ }
+
+ private void update_comment(bool init = false) {
+ string comment = event.get_comment();
+ if (is_string_empty(comment))
+ clear_comment();
+ else if (!init)
+ set_comment(comment);
+ else
+ set_comment("");
+ }
+}
+
+
diff --git a/src/events/EventPage.vala b/src/events/EventPage.vala
new file mode 100644
index 0000000..3d23f25
--- /dev/null
+++ b/src/events/EventPage.vala
@@ -0,0 +1,162 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * 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 EventPage : CollectionPage {
+ private Event page_event;
+
+ public EventPage(Event page_event) {
+ base (page_event.get_name());
+
+ this.page_event = page_event;
+ page_event.mirror_photos(get_view(), create_thumbnail);
+
+ init_page_context_menu("/EventContextMenu");
+
+ Event.global.items_altered.connect(on_events_altered);
+ }
+
+ public Event get_event() {
+ return page_event;
+ }
+
+ protected override bool on_app_key_pressed(Gdk.EventKey event) {
+ // If and only if one image is selected, propagate F2 to the rest of
+ // the window, otherwise, consume it here - if we don't do this, it'll
+ // either let us re-title multiple images at the same time or
+ // spuriously highlight the event name in the sidebar for editing...
+ if (Gdk.keyval_name(event.keyval) == "F2") {
+ if (get_view().get_selected_count() != 1) {
+ return true;
+ }
+ }
+
+ return base.on_app_key_pressed(event);
+ }
+
+ ~EventPage() {
+ Event.global.items_altered.disconnect(on_events_altered);
+ get_view().halt_mirroring();
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("event.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] new_actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry make_primary = { "MakePrimary", Resources.MAKE_PRIMARY,
+ TRANSLATABLE, null, TRANSLATABLE, on_make_primary };
+ make_primary.label = Resources.MAKE_KEY_PHOTO_MENU;
+ new_actions += make_primary;
+
+ Gtk.ActionEntry rename = { "Rename", null, TRANSLATABLE, null, TRANSLATABLE, on_rename };
+ rename.label = Resources.RENAME_EVENT_MENU;
+ new_actions += rename;
+
+ Gtk.ActionEntry comment = { "EditEventComment", null, TRANSLATABLE, null,
+ Resources.EDIT_EVENT_COMMENT_MENU, on_edit_comment};
+ comment.label = Resources.EDIT_EVENT_COMMENT_MENU;
+ new_actions += comment;
+
+ return new_actions;
+ }
+
+ protected override void init_actions(int selected_count, int count) {
+ base.init_actions(selected_count, count);
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ set_action_sensitive("MakePrimary", selected_count == 1);
+
+ // hide this command in CollectionPage, as it does not apply here
+ set_action_visible("CommonJumpToEvent", false);
+
+ base.update_actions(selected_count, count);
+
+ // this is always valid; if the user has right-clicked in an empty area,
+ // change the comment on the event itself.
+ set_action_sensitive("EditEventComment", true);
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_event_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_event_photos_sort(sort_order, sort_by);
+ }
+
+ private void on_events_altered(Gee.Map<DataObject, Alteration> map) {
+ if (map.has_key(page_event))
+ set_page_name(page_event.get_name());
+ }
+
+ protected override void on_edit_comment() {
+ if (get_view().get_selected_count() == 0) {
+ EditCommentDialog edit_comment_dialog = new EditCommentDialog(page_event.get_comment(),
+ true);
+ string? new_comment = edit_comment_dialog.execute();
+ if (new_comment == null)
+ return;
+
+ EditEventCommentCommand command = new EditEventCommentCommand(page_event, new_comment);
+ get_command_manager().execute(command);
+ return;
+ }
+
+ base.on_edit_comment();
+ }
+
+ private void on_make_primary() {
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ page_event.set_primary_source((MediaSource) get_view().get_selected_at(0).get_source());
+ }
+
+ private void on_rename() {
+ LibraryWindow.get_app().rename_event_in_sidebar(page_event);
+ }
+}
+
+public class NoEventPage : CollectionPage {
+ public const string NAME = _("No Event");
+
+ // This seems very similar to EventSourceCollection -> ViewManager
+ private class NoEventViewManager : CollectionViewManager {
+ public NoEventViewManager(NoEventPage page) {
+ base (page);
+ }
+
+ // this is not threadsafe
+ public override bool include_in_view(DataSource source) {
+ return (((MediaSource) source).get_event_id().id != EventID.INVALID) ? false :
+ base.include_in_view(source);
+ }
+ }
+
+ private static Alteration no_event_page_alteration = new Alteration("metadata", "event");
+
+ public NoEventPage() {
+ base (NAME);
+
+ ViewManager filter = new NoEventViewManager(this);
+ get_view().monitor_source_collection(LibraryPhoto.global, filter, no_event_page_alteration);
+ get_view().monitor_source_collection(Video.global, filter, no_event_page_alteration);
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_event_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_event_photos_sort(sort_order, sort_by);
+ }
+}
+
diff --git a/src/events/Events.vala b/src/events/Events.vala
new file mode 100644
index 0000000..dc2b662
--- /dev/null
+++ b/src/events/Events.vala
@@ -0,0 +1,18 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Events {
+
+public void init() throws Error {
+ Events.Branch.init();
+}
+
+public void terminate() {
+ Events.Branch.terminate();
+}
+
+}
+
diff --git a/src/events/EventsDirectoryPage.vala b/src/events/EventsDirectoryPage.vala
new file mode 100644
index 0000000..41a1ac6
--- /dev/null
+++ b/src/events/EventsDirectoryPage.vala
@@ -0,0 +1,313 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public abstract class EventsDirectoryPage : CheckerboardPage {
+ public class EventDirectoryManager : ViewManager {
+ public override DataView create_view(DataSource source) {
+ return new EventDirectoryItem((Event) source);
+ }
+ }
+
+ private class EventsDirectorySearchViewFilter : SearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT;
+ }
+
+ public override bool predicate(DataView view) {
+ assert(view.get_source() is Event);
+ if (is_string_empty(get_search_filter()))
+ return true;
+
+ Event source = (Event) view.get_source();
+ unowned string? event_keywords = source.get_indexable_keywords();
+ if (is_string_empty(event_keywords))
+ return false;
+
+ // Return false if the word isn't found, true otherwise.
+ foreach (unowned string word in get_search_filter_words()) {
+ if (!event_keywords.contains(word))
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ private const int MIN_PHOTOS_FOR_PROGRESS_WINDOW = 50;
+
+ protected ViewManager view_manager;
+
+ private EventsDirectorySearchViewFilter search_filter = new EventsDirectorySearchViewFilter();
+
+ public EventsDirectoryPage(string page_name, ViewManager view_manager,
+ Gee.Collection<Event>? initial_events) {
+ base (page_name);
+
+ // set comparator before monitoring source collection, to prevent a re-sort
+ get_view().set_comparator(get_event_comparator(Config.Facade.get_instance().get_events_sort_ascending()),
+ event_comparator_predicate);
+ get_view().monitor_source_collection(Event.global, view_manager, null, initial_events);
+
+ get_view().set_property(Event.PROP_SHOW_COMMENTS,
+ Config.Facade.get_instance().get_display_event_comments());
+
+ init_item_context_menu("/EventsDirectoryContextMenu");
+
+ this.view_manager = view_manager;
+
+ // set up page's toolbar (used by AppWindow for layout and FullscreenWindow as a popup)
+ Gtk.Toolbar toolbar = get_toolbar();
+
+ // merge tool
+ Gtk.ToolButton merge_button = new Gtk.ToolButton.from_stock(Resources.MERGE);
+ merge_button.set_related_action(get_action("Merge"));
+
+ toolbar.insert(merge_button, -1);
+ }
+
+ ~EventsDirectoryPage() {
+ Gtk.RadioAction? action = get_action("CommonSortEventsAscending") as Gtk.RadioAction;
+ assert(action != null);
+ action.changed.disconnect(on_sort_changed);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ ui_filenames.add("events_directory.ui");
+
+ base.init_collect_ui_filenames(ui_filenames);
+ }
+
+ protected static bool event_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "time");
+ }
+
+ 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();
+
+ return start_a - start_b;
+ }
+
+ private static int64 event_descending_comparator(void *a, void *b) {
+ return event_ascending_comparator(b, a);
+ }
+
+ private static Comparator get_event_comparator(bool ascending) {
+ if (ascending)
+ return event_ascending_comparator;
+ else
+ return event_descending_comparator;
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry rename = { "Rename", null, TRANSLATABLE, "F2", TRANSLATABLE, on_rename };
+ rename.label = Resources.RENAME_EVENT_MENU;
+ actions += rename;
+
+ Gtk.ActionEntry merge = { "Merge", Resources.MERGE, TRANSLATABLE, null, Resources.MERGE_TOOLTIP,
+ on_merge };
+ merge.label = Resources.MERGE_MENU;
+ actions += merge;
+
+ Gtk.ActionEntry comment = { "EditComment", null, TRANSLATABLE, null, Resources.EDIT_COMMENT_MENU,
+ on_edit_comment };
+ comment.label = Resources.EDIT_COMMENT_MENU;
+ actions += comment;
+
+ return actions;
+ }
+
+ protected override Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ Gtk.ToggleActionEntry[] toggle_actions = base.init_collect_toggle_action_entries();
+
+ Gtk.ToggleActionEntry comments = { "ViewComment", null, TRANSLATABLE, "<Ctrl><Shift>C",
+ TRANSLATABLE, on_display_comments, Config.Facade.get_instance().get_display_event_comments() };
+ comments.label = _("_Comments");
+ comments.tooltip = _("Display the comment of each event");
+ toggle_actions += comments;
+
+ return toggle_actions;
+ }
+
+ protected override void init_actions(int selected_count, int count) {
+ base.init_actions(selected_count, count);
+
+ Gtk.RadioAction? action = get_action("CommonSortEventsAscending") as Gtk.RadioAction;
+ assert(action != null);
+ action.changed.connect(on_sort_changed);
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ set_action_sensitive("Merge", selected_count > 1);
+ set_action_important("Merge", true);
+ set_action_sensitive("Rename", selected_count == 1);
+ set_action_sensitive("EditComment", selected_count == 1);
+
+ base.update_actions(selected_count, count);
+ }
+
+ protected override string get_view_empty_message() {
+ return _("No events");
+ }
+
+ protected override string get_filter_no_match_message() {
+ return _("No events found");
+ }
+
+ public override void on_item_activated(CheckerboardItem item, CheckerboardPage.Activator
+ activator, CheckerboardPage.KeyboardModifiers modifiers) {
+ EventDirectoryItem event = (EventDirectoryItem) item;
+ LibraryWindow.get_app().switch_to_event(event.event);
+ }
+
+ private void on_sort_changed(Gtk.Action action, Gtk.Action c) {
+ Gtk.RadioAction current = (Gtk.RadioAction) c;
+
+ get_view().set_comparator(
+ get_event_comparator(current.current_value == LibraryWindow.SORT_EVENTS_ORDER_ASCENDING),
+ event_comparator_predicate);
+ }
+
+ private void on_rename() {
+ // only rename one at a time
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ EventDirectoryItem item = (EventDirectoryItem) get_view().get_selected_at(0);
+
+ EventRenameDialog rename_dialog = new EventRenameDialog(item.event.get_raw_name());
+ string? new_name = rename_dialog.execute();
+ if (new_name == null)
+ return;
+
+ RenameEventCommand command = new RenameEventCommand(item.event, new_name);
+ get_command_manager().execute(command);
+ }
+
+ protected void on_edit_comment() {
+ // only edit one at a time
+ if (get_view().get_selected_count() != 1)
+ return;
+
+ EventDirectoryItem item = (EventDirectoryItem) get_view().get_selected_at(0);
+
+ EditCommentDialog edit_comment_dialog = new EditCommentDialog(item.event.get_comment());
+ string? new_comment = edit_comment_dialog.execute();
+ if (new_comment == null)
+ return;
+
+ EditEventCommentCommand command = new EditEventCommentCommand(item.event, new_comment);
+ get_command_manager().execute(command);
+ }
+
+ private void on_merge() {
+ if (get_view().get_selected_count() <= 1)
+ return;
+
+ MergeEventsCommand command = new MergeEventsCommand(get_view().get_selected());
+ get_command_manager().execute(command);
+ }
+
+ private void on_display_comments(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_comments(display);
+
+ Config.Facade.get_instance().set_display_event_comments(display);
+ }
+
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
+public class MasterEventsDirectoryPage : EventsDirectoryPage {
+ public const string NAME = _("Events");
+
+ public MasterEventsDirectoryPage() {
+ base (NAME, new EventDirectoryManager(), (Gee.Collection<Event>) Event.global.get_all());
+ }
+}
+
+public class SubEventsDirectoryPage : EventsDirectoryPage {
+ public enum DirectoryType {
+ YEAR,
+ MONTH,
+ UNDATED;
+ }
+
+ public const string UNDATED_PAGE_NAME = _("Undated");
+ public const string YEAR_FORMAT = _("%Y");
+ public const string MONTH_FORMAT = _("%B");
+
+ private class SubEventDirectoryManager : EventsDirectoryPage.EventDirectoryManager {
+ private int month = 0;
+ private int year = 0;
+ DirectoryType type;
+
+ public SubEventDirectoryManager(DirectoryType type, Time time) {
+ base();
+
+ if (type == DirectoryType.MONTH)
+ month = time.month;
+ this.type = type;
+ year = time.year;
+ }
+
+ public override bool include_in_view(DataSource source) {
+ if (!base.include_in_view(source))
+ return false;
+
+ EventSource event = (EventSource) source;
+ Time event_time = Time.local(event.get_start_time());
+ if (event_time.year == year) {
+ if (type == DirectoryType.MONTH) {
+ return (event_time.month == month);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public int get_month() {
+ return month;
+ }
+
+ public int get_year() {
+ return year;
+ }
+
+ public DirectoryType get_event_directory_type() {
+ return type;
+ }
+ }
+
+ public SubEventsDirectoryPage(DirectoryType type, Time 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);
+ }
+
+ base(page_name, new SubEventDirectoryManager(type, time), null);
+ }
+
+ public int get_month() {
+ return ((SubEventDirectoryManager) view_manager).get_month();
+ }
+
+ public int get_year() {
+ return ((SubEventDirectoryManager) view_manager).get_year();
+ }
+
+ public DirectoryType get_event_directory_type() {
+ return ((SubEventDirectoryManager) view_manager).get_event_directory_type();
+ }
+}
+
diff --git a/src/events/mk/events.mk b/src/events/mk/events.mk
new file mode 100644
index 0000000..d09fc0f
--- /dev/null
+++ b/src/events/mk/events.mk
@@ -0,0 +1,32 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Events
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := events
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ EventsDirectoryPage.vala \
+ EventPage.vala \
+ EventDirectoryItem.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Sidebar
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/folders/Branch.vala b/src/folders/Branch.vala
new file mode 100644
index 0000000..bc5b578
--- /dev/null
+++ b/src/folders/Branch.vala
@@ -0,0 +1,198 @@
+/* Copyright 2012-2014 Yorba Foundation
+ *
+ * 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 Folders.Branch : Sidebar.Branch {
+ private Gee.HashMap<File, Folders.SidebarEntry> entries =
+ new Gee.HashMap<File, Folders.SidebarEntry>(file_hash, file_equal);
+ private File home_dir;
+
+ public class Branch() {
+ base (new Folders.Root(), Sidebar.Branch.Options.STARTUP_OPEN_GROUPING, comparator);
+
+ home_dir = File.new_for_path(Environment.get_home_dir());
+
+ foreach (MediaSourceCollection sources in MediaCollectionRegistry.get_instance().get_all()) {
+ // seed
+ on_media_contents_altered(sources.get_all(), null);
+
+ // monitor
+ sources.contents_altered.connect(on_media_contents_altered);
+ }
+ }
+
+ ~Branch() {
+ foreach (MediaSourceCollection sources in MediaCollectionRegistry.get_instance().get_all())
+ sources.contents_altered.disconnect(on_media_contents_altered);
+ }
+
+ private static int comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ int coll_key_equality = strcmp(((Folders.SidebarEntry) a).collation,
+ ((Folders.SidebarEntry) b).collation);
+
+ if (coll_key_equality == 0) {
+ // Collation keys were the same, double-check that
+ // these really are the same string...
+ return strcmp(((Folders.SidebarEntry) a).get_sidebar_name(),
+ ((Folders.SidebarEntry) b).get_sidebar_name());
+ }
+
+ return coll_key_equality;
+ }
+
+ private void on_master_source_replaced(MediaSource media_source, File old_file, File new_file) {
+ remove_entry(old_file);
+ add_entry(media_source);
+ }
+
+ private void on_media_contents_altered(Gee.Iterable<DataObject>? added, Gee.Iterable<DataObject>? removed) {
+ if (added != null) {
+ foreach (DataObject object in added) {
+ add_entry((MediaSource) object);
+ ((MediaSource) object).master_replaced.connect(on_master_source_replaced);
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed) {
+ remove_entry(((MediaSource) object).get_file());
+ ((MediaSource) object).master_replaced.disconnect(on_master_source_replaced);
+ }
+ }
+ }
+
+ void add_entry(MediaSource media) {
+ File file = media.get_file();
+
+ Gee.ArrayList<File> elements = new Gee.ArrayList<File>();
+
+ // add the path elements in reverse order up to home directory
+ File? parent = file.get_parent();
+ while (parent != null) {
+ // don't process paths above the user's home directory
+ if (parent.equal(home_dir.get_parent()))
+ break;
+
+ elements.add(parent);
+
+ parent = parent.get_parent();
+ }
+
+ // walk path elements in order from home directory down, building needed sidebar entries
+ // along the way
+ Folders.SidebarEntry? parent_entry = null;
+ for (int ctr = elements.size - 1; ctr >= 0; ctr--) {
+ File parent_dir = elements[ctr];
+
+ // save current parent, needed if this entry needs to be grafted
+ Folders.SidebarEntry? old_parent_entry = parent_entry;
+
+ parent_entry = entries.get(parent_dir);
+ if (parent_entry == null) {
+ parent_entry = new Folders.SidebarEntry(parent_dir);
+ entries.set(parent_dir, parent_entry);
+
+ graft((old_parent_entry == null) ? get_root() : old_parent_entry, parent_entry);
+ }
+
+ // only increment entry's file count if File is going in this folder
+ if (ctr == 0)
+ parent_entry.count++;
+ }
+ }
+
+ private void remove_entry(File file) {
+ Folders.SidebarEntry? folder_entry = entries.get(file.get_parent());
+ if (folder_entry == null)
+ return;
+
+ assert(folder_entry.count > 0);
+
+ // decrement file count for folder of photo
+ if (--folder_entry.count > 0 || get_child_count(folder_entry) > 0)
+ return;
+
+ // empty folder so prune tree
+ Folders.SidebarEntry? prune_point = folder_entry;
+ assert(prune_point != null);
+
+ for (;;) {
+ bool removed = entries.unset(prune_point.dir);
+ assert(removed);
+
+ Folders.SidebarEntry? parent = get_parent(prune_point) as Folders.SidebarEntry;
+ if (parent == null || parent.count != 0 || get_child_count(parent) > 1)
+ break;
+
+ prune_point = parent;
+ }
+
+ prune(prune_point);
+ }
+}
+
+private class Folders.Root : Sidebar.Grouping {
+ public Root() {
+ base (_("Folders"), Folders.opened_icon, Folders.closed_icon);
+ }
+}
+
+public class Folders.SidebarEntry : Sidebar.SimplePageEntry, Sidebar.ExpandableEntry {
+ public File dir { get; private set; }
+ public string collation { get; private set; }
+
+ private int _count = 0;
+ public int count {
+ get {
+ return _count;
+ }
+
+ set {
+ int prev_count = _count;
+ _count = value;
+
+ // when count change 0->1 and 1->0 may need refresh icon
+ if ((prev_count == 0 && _count == 1) || (prev_count == 1 && _count == 0))
+ sidebar_icon_changed(get_sidebar_icon());
+
+ }
+ }
+
+ public SidebarEntry(File dir) {
+ this.dir = dir;
+ collation = g_utf8_collate_key_for_filename(dir.get_path());
+ }
+
+ public override string get_sidebar_name() {
+ return dir.get_basename();
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return count == 0 ? closed_icon : have_photos_icon;
+ }
+
+ public override string to_string() {
+ return dir.get_path();
+ }
+
+ public Icon? get_sidebar_open_icon() {
+ return count == 0 ? opened_icon : have_photos_icon;
+ }
+
+ public Icon? get_sidebar_closed_icon() {
+ return count == 0 ? closed_icon : have_photos_icon;
+ }
+
+ public bool expand_on_select() {
+ return true;
+ }
+
+ protected override global::Page create_page() {
+ return new Folders.Page(dir);
+ }
+}
diff --git a/src/folders/Folders.vala b/src/folders/Folders.vala
new file mode 100644
index 0000000..1cc14b1
--- /dev/null
+++ b/src/folders/Folders.vala
@@ -0,0 +1,35 @@
+/* Copyright 2012-2014 Yorba Foundation
+ *
+ * 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 Folders 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 Folders {
+
+static Icon? opened_icon = null;
+static Icon? closed_icon = null;
+static Icon? have_photos_icon = null;
+
+public void init() throws Error {
+ opened_icon = new ThemedIcon(Resources.ICON_FOLDER_OPEN);
+ closed_icon = new ThemedIcon(Resources.ICON_FOLDER_CLOSED);
+ have_photos_icon = new ThemedIcon(Resources.ICON_FOLDER_DOCUMENTS);
+}
+
+public void terminate() {
+ opened_icon = null;
+ closed_icon = null;
+ have_photos_icon = null;
+}
+
+}
+
diff --git a/src/folders/Page.vala b/src/folders/Page.vala
new file mode 100644
index 0000000..d101e88
--- /dev/null
+++ b/src/folders/Page.vala
@@ -0,0 +1,41 @@
+/* Copyright 2012-2014 Yorba Foundation
+ *
+ * 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 Folders.Page : CollectionPage {
+ private class FolderViewManager : CollectionViewManager {
+ public File dir;
+
+ public FolderViewManager(Folders.Page owner, File dir) {
+ base (owner);
+
+ this.dir = dir;
+ }
+
+ public override bool include_in_view(DataSource source) {
+ return ((MediaSource) source).get_file().has_prefix(dir);
+ }
+ }
+
+ private FolderViewManager view_manager;
+
+ public Page(File dir) {
+ base (dir.get_path());
+
+ view_manager = new FolderViewManager(this, dir);
+
+ foreach (MediaSourceCollection sources in MediaCollectionRegistry.get_instance().get_all())
+ get_view().monitor_source_collection(sources, view_manager, null);
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_library_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_library_photos_sort(sort_order, sort_by);
+ }
+}
+
diff --git a/src/folders/mk/folders.mk b/src/folders/mk/folders.mk
new file mode 100644
index 0000000..d0023d7
--- /dev/null
+++ b/src/folders/mk/folders.mk
@@ -0,0 +1,31 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Folders
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := folders
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ Page.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Sidebar \
+ Photos
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/library/Branch.vala b/src/library/Branch.vala
new file mode 100644
index 0000000..dc05d60
--- /dev/null
+++ b/src/library/Branch.vala
@@ -0,0 +1,54 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Library.Branch : Sidebar.RootOnlyBranch {
+ public Branch() {
+ base (new Library.SidebarEntry());
+ }
+
+ public Library.MainPage get_main_page() {
+ return (Library.MainPage) ((Library.SidebarEntry) get_root()).get_page();
+ }
+}
+
+public class Library.SidebarEntry : Sidebar.SimplePageEntry {
+ private Icon icon = new ThemedIcon(Resources.ICON_PHOTOS);
+
+ public SidebarEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return Library.MainPage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return icon;
+ }
+
+ protected override Page create_page() {
+ return new Library.MainPage();
+ }
+}
+
+public class Library.MainPage : CollectionPage {
+ public const string NAME = _("Library");
+
+ public MainPage(ProgressMonitor? monitor = null) {
+ base (NAME);
+
+ foreach (MediaSourceCollection sources in MediaCollectionRegistry.get_instance().get_all())
+ get_view().monitor_source_collection(sources, new CollectionViewManager(this), null, null, monitor);
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_library_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_library_photos_sort(sort_order, sort_by);
+ }
+}
+
diff --git a/src/library/FlaggedBranch.vala b/src/library/FlaggedBranch.vala
new file mode 100644
index 0000000..472d999
--- /dev/null
+++ b/src/library/FlaggedBranch.vala
@@ -0,0 +1,61 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Library.FlaggedBranch : Sidebar.RootOnlyBranch {
+ public FlaggedBranch() {
+ base (new Library.FlaggedSidebarEntry());
+
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.flagged_contents_altered.connect(on_flagged_contents_altered);
+
+ set_show_branch(get_total_flagged() != 0);
+ }
+
+ ~FlaggedBranch() {
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.flagged_contents_altered.disconnect(on_flagged_contents_altered);
+ }
+
+ private void on_flagged_contents_altered() {
+ set_show_branch(get_total_flagged() != 0);
+ }
+
+ private int get_total_flagged() {
+ int total = 0;
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ total += media_sources.get_flagged().size;
+
+ return total;
+ }
+}
+
+public class Library.FlaggedSidebarEntry : Sidebar.SimplePageEntry, Sidebar.InternalDropTargetEntry {
+ public FlaggedSidebarEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return FlaggedPage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return new ThemedIcon(Resources.ICON_FLAGGED_PAGE);
+ }
+
+ protected override Page create_page() {
+ return new FlaggedPage();
+ }
+
+ public bool internal_drop_received(Gee.List<MediaSource> media) {
+ AppWindow.get_command_manager().execute(new FlagUnflagCommand(media, true));
+
+ return true;
+ }
+
+ public bool internal_drop_received_arbitrary(Gtk.SelectionData data) {
+ return false;
+ }
+}
+
diff --git a/src/library/FlaggedPage.vala b/src/library/FlaggedPage.vala
new file mode 100644
index 0000000..28bc57b
--- /dev/null
+++ b/src/library/FlaggedPage.vala
@@ -0,0 +1,54 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class FlaggedPage : CollectionPage {
+ public const string NAME = _("Flagged");
+
+ private class FlaggedViewManager : CollectionViewManager {
+ public FlaggedViewManager(FlaggedPage owner) {
+ base (owner);
+ }
+
+ public override bool include_in_view(DataSource source) {
+ Flaggable? flaggable = source as Flaggable;
+
+ return (flaggable != null) && flaggable.is_flagged();
+ }
+ }
+
+ private class FlaggedSearchViewFilter : CollectionPage.CollectionSearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT | SearchFilterCriteria.MEDIA |
+ SearchFilterCriteria.RATING;
+ }
+ }
+
+ private ViewManager view_manager;
+ private Alteration prereq = new Alteration("metadata", "flagged");
+ private FlaggedSearchViewFilter search_filter = new FlaggedSearchViewFilter();
+
+ public FlaggedPage() {
+ base (NAME);
+
+ view_manager = new FlaggedViewManager(this);
+
+ foreach (MediaSourceCollection sources in MediaCollectionRegistry.get_instance().get_all())
+ get_view().monitor_source_collection(sources, view_manager, prereq);
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_library_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_library_photos_sort(sort_order, sort_by);
+ }
+
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
diff --git a/src/library/ImportQueueBranch.vala b/src/library/ImportQueueBranch.vala
new file mode 100644
index 0000000..32a3e0d
--- /dev/null
+++ b/src/library/ImportQueueBranch.vala
@@ -0,0 +1,74 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Library.ImportQueueBranch : Sidebar.RootOnlyBranch {
+ private Library.ImportQueueSidebarEntry entry;
+
+ public ImportQueueBranch() {
+ // can't pass to base() an object that was allocated in declaration; see
+ // https://bugzilla.gnome.org/show_bug.cgi?id=646286
+ base (new Library.ImportQueueSidebarEntry());
+
+ entry = (Library.ImportQueueSidebarEntry) get_root();
+
+ // only attach signals to the page when it's created
+ entry.page_created.connect(on_page_created);
+ entry.destroying_page.connect(on_destroying_page);
+
+ // don't use entry.get_page() or get_queue_page() because (a) we don't want to
+ // create the page during initialization, and (b) we know there's no import activity
+ // at this moment
+ set_show_branch(false);
+ }
+
+ ~ImportQueueBranch() {
+ entry.page_created.disconnect(on_page_created);
+ entry.destroying_page.disconnect(on_destroying_page);
+ }
+
+ public ImportQueuePage get_queue_page() {
+ return (ImportQueuePage) entry.get_page();
+ }
+
+ private void on_page_created() {
+ get_queue_page().batch_added.connect(on_batch_added_or_removed);
+ get_queue_page().batch_removed.connect(on_batch_added_or_removed);
+ }
+
+ private void on_destroying_page() {
+ get_queue_page().batch_added.disconnect(on_batch_added_or_removed);
+ get_queue_page().batch_removed.disconnect(on_batch_added_or_removed);
+ }
+
+ private void on_batch_added_or_removed() {
+ set_show_branch(get_queue_page().get_batch_count() > 0);
+ }
+
+ public void enqueue_and_schedule(BatchImport batch_import, bool allow_user_cancel) {
+ // want to display the branch before passing to the page because this might result in the
+ // page being created, and want it all hooked up in the tree prior to creating the page
+ set_show_branch(true);
+ get_queue_page().enqueue_and_schedule(batch_import, allow_user_cancel);
+ }
+}
+
+public class Library.ImportQueueSidebarEntry : Sidebar.SimplePageEntry {
+ public ImportQueueSidebarEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return ImportQueuePage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return new ThemedIcon(Resources.ICON_IMPORTING);
+ }
+
+ protected override Page create_page() {
+ return new ImportQueuePage();
+ }
+}
+
diff --git a/src/library/ImportQueuePage.vala b/src/library/ImportQueuePage.vala
new file mode 100644
index 0000000..5ace1d8
--- /dev/null
+++ b/src/library/ImportQueuePage.vala
@@ -0,0 +1,208 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * 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 ImportQueuePage : SinglePhotoPage {
+ public const string NAME = _("Importing...");
+
+ private Gee.ArrayList<BatchImport> queue = new Gee.ArrayList<BatchImport>();
+ private Gee.HashSet<BatchImport> cancel_unallowed = new Gee.HashSet<BatchImport>();
+ private BatchImport current_batch = null;
+ private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar();
+ private bool stopped = false;
+
+#if UNITY_SUPPORT
+ UnityProgressBar uniprobar = UnityProgressBar.get_instance();
+#endif
+
+ public signal void batch_added(BatchImport batch_import);
+
+ public signal void batch_removed(BatchImport batch_import);
+
+ public ImportQueuePage() {
+ base (NAME, false);
+
+ // Set up toolbar
+ Gtk.Toolbar toolbar = get_toolbar();
+
+ // Stop button
+ Gtk.ToolButton stop_button = new Gtk.ToolButton.from_stock(Gtk.Stock.STOP);
+ stop_button.set_related_action(get_action("Stop"));
+
+ toolbar.insert(stop_button, -1);
+
+ // separator to force progress bar to right side of toolbar
+ Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
+ separator.set_draw(false);
+
+ toolbar.insert(separator, -1);
+
+ // Progress bar
+ Gtk.ToolItem progress_item = new Gtk.ToolItem();
+ progress_item.set_expand(true);
+ progress_item.add(progress_bar);
+ progress_bar.set_show_text(true);
+
+ toolbar.insert(progress_item, -1);
+#if UNITY_SUPPORT
+ //UnityProgressBar: try to draw progress bar
+ uniprobar.set_visible(true);
+#endif
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ ui_filenames.add("import_queue.ui");
+
+ base.init_collect_ui_filenames(ui_filenames);
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry stop = { "Stop", Gtk.Stock.STOP, TRANSLATABLE, null, TRANSLATABLE,
+ on_stop };
+ stop.label = _("_Stop Import");
+ stop.tooltip = _("Stop importing photos");
+ actions += stop;
+
+ return actions;
+ }
+
+ public void enqueue_and_schedule(BatchImport batch_import, bool allow_user_cancel) {
+ assert(!queue.contains(batch_import));
+
+ batch_import.starting.connect(on_starting);
+ batch_import.preparing.connect(on_preparing);
+ batch_import.progress.connect(on_progress);
+ batch_import.imported.connect(on_imported);
+ batch_import.import_complete.connect(on_import_complete);
+ batch_import.fatal_error.connect(on_fatal_error);
+
+ if (!allow_user_cancel)
+ cancel_unallowed.add(batch_import);
+
+ queue.add(batch_import);
+ batch_added(batch_import);
+
+ if (queue.size == 1)
+ batch_import.schedule();
+
+ update_stop_action();
+ }
+
+ public int get_batch_count() {
+ return queue.size;
+ }
+
+ private void update_stop_action() {
+ set_action_sensitive("Stop", !cancel_unallowed.contains(current_batch) && queue.size > 0);
+ }
+
+ private void on_stop() {
+ update_stop_action();
+
+ if (queue.size == 0)
+ return;
+
+ AppWindow.get_instance().set_busy_cursor();
+ stopped = true;
+
+ // mark all as halted and let each signal failure
+ foreach (BatchImport batch_import in queue)
+ batch_import.user_halt();
+ }
+
+ private void on_starting(BatchImport batch_import) {
+ update_stop_action();
+ current_batch = batch_import;
+ }
+
+ private void on_preparing() {
+ progress_bar.set_text(_("Preparing to import..."));
+ progress_bar.pulse();
+ }
+
+ private void on_progress(uint64 completed_bytes, uint64 total_bytes) {
+ double pct = (completed_bytes <= total_bytes) ? (double) completed_bytes / (double) total_bytes
+ : 0.0;
+ progress_bar.set_fraction(pct);
+#if UNITY_SUPPORT
+ //UnityProgressBar: set progress
+ uniprobar.set_progress(pct);
+#endif
+ }
+
+ private void on_imported(ThumbnailSource source, Gdk.Pixbuf pixbuf, int to_follow) {
+ // only interested in updating the display for the last of the bunch
+ if (to_follow > 0 || !is_in_view())
+ return;
+
+ set_pixbuf(pixbuf, Dimensions.for_pixbuf(pixbuf));
+
+ // set the singleton collection to this item
+ get_view().clear();
+ (source is LibraryPhoto) ? get_view().add(new PhotoView(source as LibraryPhoto)) :
+ get_view().add(new VideoView(source as Video));
+
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.MIDDLE);
+ progress_bar.set_text(_("Imported %s").printf(source.get_name()));
+ }
+
+ private void on_import_complete(BatchImport batch_import, ImportManifest manifest,
+ BatchImportRoll import_roll) {
+ assert(batch_import == current_batch);
+ current_batch = null;
+
+ assert(queue.size > 0);
+ assert(queue.get(0) == batch_import);
+
+ bool removed = queue.remove(batch_import);
+ assert(removed);
+
+ // fail quietly if cancel was allowed
+ cancel_unallowed.remove(batch_import);
+
+ // strip signal handlers
+ batch_import.starting.disconnect(on_starting);
+ batch_import.preparing.disconnect(on_preparing);
+ batch_import.progress.disconnect(on_progress);
+ batch_import.imported.disconnect(on_imported);
+ batch_import.import_complete.disconnect(on_import_complete);
+ batch_import.fatal_error.disconnect(on_fatal_error);
+
+ // schedule next if available
+ if (queue.size > 0) {
+ queue.get(0).schedule();
+ } else {
+ // reset UI
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.set_fraction(0.0);
+#if UNITY_SUPPORT
+ //UnityProgressBar: reset
+ uniprobar.reset();
+#endif
+
+ // blank the display
+ blank_display();
+
+ // reset cursor if cancelled
+ if (stopped)
+ AppWindow.get_instance().set_normal_cursor();
+
+ stopped = false;
+ }
+
+ update_stop_action();
+
+ // report the batch has been removed from the queue after everything else is set
+ batch_removed(batch_import);
+ }
+
+ private void on_fatal_error(ImportResult result, string message) {
+ AppWindow.error_message(message);
+ }
+}
+
diff --git a/src/library/LastImportBranch.vala b/src/library/LastImportBranch.vala
new file mode 100644
index 0000000..bc03ee5
--- /dev/null
+++ b/src/library/LastImportBranch.vala
@@ -0,0 +1,47 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Library.LastImportBranch : Sidebar.RootOnlyBranch {
+ public LastImportBranch() {
+ base (new Library.LastImportSidebarEntry());
+
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.import_roll_altered.connect(on_import_rolls_altered);
+
+ set_show_branch(MediaCollectionRegistry.get_instance().get_last_import_id() != null);
+ }
+
+ ~LastImportBranch() {
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.import_roll_altered.disconnect(on_import_rolls_altered);
+ }
+
+ public Library.LastImportSidebarEntry get_main_entry() {
+ return (Library.LastImportSidebarEntry) get_root();
+ }
+
+ private void on_import_rolls_altered() {
+ set_show_branch(MediaCollectionRegistry.get_instance().get_last_import_id() != null);
+ }
+}
+
+public class Library.LastImportSidebarEntry : Sidebar.SimplePageEntry {
+ public LastImportSidebarEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return LastImportPage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return new ThemedIcon(Resources.ICON_LAST_IMPORT);
+ }
+
+ protected override Page create_page() {
+ return new LastImportPage();
+ }
+}
+
diff --git a/src/library/LastImportPage.vala b/src/library/LastImportPage.vala
new file mode 100644
index 0000000..877faa5
--- /dev/null
+++ b/src/library/LastImportPage.vala
@@ -0,0 +1,79 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 LastImportPage : CollectionPage {
+ public const string NAME = _("Last Import");
+
+ private class LastImportViewManager : CollectionViewManager {
+ private ImportID import_id;
+
+ public LastImportViewManager(LastImportPage owner, ImportID import_id) {
+ base (owner);
+
+ this.import_id = import_id;
+ }
+
+ public override bool include_in_view(DataSource source) {
+ return ((MediaSource) source).get_import_id().id == import_id.id;
+ }
+ }
+
+ private ImportID last_import_id = ImportID();
+ private Alteration last_import_alteration = new Alteration("metadata", "import-id");
+
+ public LastImportPage() {
+ base (NAME);
+
+ // be notified when the import rolls change
+ foreach (MediaSourceCollection col in MediaCollectionRegistry.get_instance().get_all()) {
+ col.import_roll_altered.connect(on_import_rolls_altered);
+ }
+
+ // set up view manager for the last import roll
+ on_import_rolls_altered();
+ }
+
+ ~LastImportPage() {
+ foreach (MediaSourceCollection col in MediaCollectionRegistry.get_instance().get_all()) {
+ col.import_roll_altered.disconnect(on_import_rolls_altered);
+ }
+ }
+
+ private void on_import_rolls_altered() {
+ // see if there's a new last ImportID, or no last import at all
+ ImportID? current_last_import_id =
+ MediaCollectionRegistry.get_instance().get_last_import_id();
+
+ if (current_last_import_id == null) {
+ get_view().halt_all_monitoring();
+ get_view().clear();
+
+ return;
+ }
+
+ if (current_last_import_id.id == last_import_id.id)
+ return;
+
+ last_import_id = current_last_import_id;
+
+ get_view().halt_all_monitoring();
+ get_view().clear();
+
+ foreach (MediaSourceCollection col in MediaCollectionRegistry.get_instance().get_all()) {
+ get_view().monitor_source_collection(col, new LastImportViewManager(this,
+ last_import_id), last_import_alteration);
+ }
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_library_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_library_photos_sort(sort_order, sort_by);
+ }
+}
+
diff --git a/src/library/Library.vala b/src/library/Library.vala
new file mode 100644
index 0000000..79a4880
--- /dev/null
+++ b/src/library/Library.vala
@@ -0,0 +1,19 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Library {
+
+public void init() throws Error {
+ Library.TrashSidebarEntry.init();
+ Photo.develop_raw_photos_to_files = true;
+}
+
+public void terminate() {
+ Library.TrashSidebarEntry.terminate();
+}
+
+}
+
diff --git a/src/library/LibraryWindow.vala b/src/library/LibraryWindow.vala
new file mode 100644
index 0000000..dab1f6f
--- /dev/null
+++ b/src/library/LibraryWindow.vala
@@ -0,0 +1,1587 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * 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 LibraryWindow : AppWindow {
+ public const int SIDEBAR_MIN_WIDTH = 224;
+ public const int SIDEBAR_MAX_WIDTH = 320;
+
+ public static int PAGE_MIN_WIDTH {
+ get {
+ return Thumbnail.MAX_SCALE + (CheckerboardLayout.COLUMN_GUTTER_PADDING * 2);
+ }
+ }
+
+ public const int SORT_EVENTS_ORDER_ASCENDING = 0;
+ public const int SORT_EVENTS_ORDER_DESCENDING = 1;
+
+ private const string[] SUPPORTED_MOUNT_SCHEMES = {
+ "gphoto2:",
+ "disk:",
+ "file:"
+ };
+
+ 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;
+
+ // 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
+ // the item in this list to a new position. These numbers should *not* persist anywhere
+ // outside the app.
+ private enum SidebarRootPosition {
+ LIBRARY,
+ FLAGGED,
+ LAST_IMPORTED,
+ CAMERAS,
+ IMPORT_QUEUE,
+ SAVED_SEARCH,
+ EVENTS,
+ FOLDERS,
+ TAGS,
+ TRASH,
+ OFFLINE
+ }
+
+ public enum TargetType {
+ URI_LIST,
+ MEDIA_LIST,
+ TAG_PATH
+ }
+
+ public const string TAG_PATH_MIME_TYPE = "shotwell/tag-path";
+ public const string MEDIA_LIST_MIME_TYPE = "shotwell/media-id-atom";
+
+ public const Gtk.TargetEntry[] DND_TARGET_ENTRIES = {
+ { "text/uri-list", Gtk.TargetFlags.OTHER_APP, TargetType.URI_LIST },
+ { MEDIA_LIST_MIME_TYPE, Gtk.TargetFlags.SAME_APP, TargetType.MEDIA_LIST },
+ { TAG_PATH_MIME_TYPE, Gtk.TargetFlags.SAME_WIDGET, TargetType.TAG_PATH }
+ };
+
+ // In fullscreen mode, want to use LibraryPhotoPage, but fullscreen has different requirements,
+ // esp. regarding when the widget is realized and when it should first try and throw them image
+ // on the page. This handles this without introducing lots of special cases in
+ // LibraryPhotoPage.
+ private class FullscreenPhotoPage : LibraryPhotoPage {
+ private CollectionPage collection;
+ private Photo start;
+ private ViewCollection? view;
+
+ public FullscreenPhotoPage(CollectionPage collection, Photo start, ViewCollection? view) {
+ this.collection = collection;
+ this.start = start;
+ this.view = view;
+ }
+
+ public override void switched_to() {
+ display_for_collection(collection, start, view);
+
+ base.switched_to();
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ // We intentionally don't call the base class here since we don't want the
+ // top-level menu in photo.ui.
+ ui_filenames.add("photo_context.ui");
+ }
+
+ }
+
+ private string import_dir = Environment.get_home_dir();
+
+ private Gtk.Paned sidebar_paned = new Gtk.Paned(Gtk.Orientation.VERTICAL);
+ private Gtk.Paned client_paned = new Gtk.Paned(Gtk.Orientation.HORIZONTAL);
+ private Gtk.Frame bottom_frame = new Gtk.Frame(null);
+
+ private Gtk.ActionGroup common_action_group = new Gtk.ActionGroup("LibraryWindowGlobalActionGroup");
+
+ private OneShotScheduler properties_scheduler = null;
+ private bool notify_library_is_home_dir = true;
+
+ // Sidebar tree and roots (ordered by SidebarRootPosition)
+ private Sidebar.Tree sidebar_tree;
+ private Library.Branch library_branch = new Library.Branch();
+ private Tags.Branch tags_branch = new Tags.Branch();
+ private Folders.Branch folders_branch = new Folders.Branch();
+ private Library.TrashBranch trash_branch = new Library.TrashBranch();
+ private Events.Branch events_branch = new Events.Branch();
+ private Library.OfflineBranch offline_branch = new Library.OfflineBranch();
+ private Library.FlaggedBranch flagged_branch = new Library.FlaggedBranch();
+ private Library.LastImportBranch last_import_branch = new Library.LastImportBranch();
+ private Library.ImportQueueBranch import_queue_branch = new Library.ImportQueueBranch();
+ private Camera.Branch camera_branch = new Camera.Branch();
+ private Searches.Branch saved_search_branch = new Searches.Branch();
+ private bool page_switching_enabled = true;
+
+ private Gee.HashMap<Page, Sidebar.Entry> page_map = new Gee.HashMap<Page, Sidebar.Entry>();
+
+ private LibraryPhotoPage photo_page = null;
+
+ // this is to keep track of cameras which initiate the app
+ private static Gee.HashSet<string> initial_camera_uris = new Gee.HashSet<string>();
+
+ private bool is_search_toolbar_visible = false;
+
+ // Want to instantiate this in the constructor rather than here because the search bar has its
+ // own UIManager which will suck up the accelerators, and we want them to be associated with
+ // AppWindows instead.
+ private SearchFilterActions search_actions = new SearchFilterActions();
+ 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 BasicProperties basic_properties = new BasicProperties();
+ private ExtendedPropertiesWindow extended_properties;
+
+ private Gtk.Notebook notebook = new Gtk.Notebook();
+ private Gtk.Box layout = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ private Gtk.Box right_vbox;
+
+ 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) {
+ // prep sidebar and add roots
+ sidebar_tree = new Sidebar.Tree(DND_TARGET_ENTRIES, Gdk.DragAction.ASK,
+ external_drop_handler);
+
+ sidebar_tree.page_created.connect(on_page_created);
+ sidebar_tree.destroying_page.connect(on_destroying_page);
+ sidebar_tree.entry_selected.connect(on_sidebar_entry_selected);
+ sidebar_tree.selected_entry_removed.connect(on_sidebar_selected_entry_removed);
+
+ sidebar_tree.graft(library_branch, SidebarRootPosition.LIBRARY);
+ sidebar_tree.graft(tags_branch, SidebarRootPosition.TAGS);
+ sidebar_tree.graft(folders_branch, SidebarRootPosition.FOLDERS);
+ sidebar_tree.graft(trash_branch, SidebarRootPosition.TRASH);
+ sidebar_tree.graft(events_branch, SidebarRootPosition.EVENTS);
+ sidebar_tree.graft(offline_branch, SidebarRootPosition.OFFLINE);
+ sidebar_tree.graft(flagged_branch, SidebarRootPosition.FLAGGED);
+ sidebar_tree.graft(last_import_branch, SidebarRootPosition.LAST_IMPORTED);
+ sidebar_tree.graft(import_queue_branch, SidebarRootPosition.IMPORT_QUEUE);
+ sidebar_tree.graft(camera_branch, SidebarRootPosition.CAMERAS);
+ sidebar_tree.graft(saved_search_branch, SidebarRootPosition.SAVED_SEARCH);
+
+ // create and connect extended properties window
+ extended_properties = new ExtendedPropertiesWindow(this);
+ extended_properties.hide.connect(hide_extended_properties);
+ extended_properties.show.connect(show_extended_properties);
+
+ 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);
+
+ try {
+ File ui_file = Resources.get_ui("top.ui");
+ ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error e) {
+ error(e.message);
+ }
+
+ Gtk.MenuBar? menubar = ui.get_widget("/MenuBar") as Gtk.MenuBar;
+ layout.add(menubar);
+
+ // We never want to invoke show_all() on the menubar since that will show empty menus,
+ // which should be hidden.
+ menubar.no_show_all = true;
+
+ // create the main layout & start at the Library page
+ create_layout(library_branch.get_main_page());
+
+ // settings that should persist between sessions
+ load_configuration();
+
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all()) {
+ media_sources.trashcan_contents_altered.connect(on_trashcan_contents_altered);
+ media_sources.items_altered.connect(on_media_altered);
+ }
+
+ // set up main window as a drag-and-drop destination (rather than each page; assume
+ // a drag and drop is for general library import, which means it goes to library_page)
+ Gtk.TargetEntry[] main_window_dnd_targets = {
+ DND_TARGET_ENTRIES[TargetType.URI_LIST],
+ DND_TARGET_ENTRIES[TargetType.MEDIA_LIST]
+ /* the main window accepts URI lists and media lists but not tag paths -- yet; we
+ might wish to support dropping tags onto photos at some future point */
+ };
+ Gtk.drag_dest_set(this, Gtk.DestDefaults.ALL, main_window_dnd_targets,
+ Gdk.DragAction.COPY | Gdk.DragAction.LINK | Gdk.DragAction.ASK);
+
+ MetadataWriter.get_instance().progress.connect(on_metadata_writer_progress);
+
+ LibraryMonitor? monitor = LibraryMonitorPool.get_instance().get_monitor();
+ if (monitor != null)
+ on_library_monitor_installed(monitor);
+
+ LibraryMonitorPool.get_instance().monitor_installed.connect(on_library_monitor_installed);
+ LibraryMonitorPool.get_instance().monitor_destroyed.connect(on_library_monitor_destroyed);
+
+ CameraTable.get_instance().camera_added.connect(on_camera_added);
+
+ background_progress_bar.set_show_text(true);
+
+ }
+
+ ~LibraryWindow() {
+ sidebar_tree.page_created.disconnect(on_page_created);
+ sidebar_tree.destroying_page.disconnect(on_destroying_page);
+ sidebar_tree.entry_selected.disconnect(on_sidebar_entry_selected);
+ sidebar_tree.selected_entry_removed.disconnect(on_sidebar_selected_entry_removed);
+
+ unsubscribe_from_basic_information(get_current_page());
+
+ extended_properties.hide.disconnect(hide_extended_properties);
+ extended_properties.show.disconnect(show_extended_properties);
+
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all()) {
+ media_sources.trashcan_contents_altered.disconnect(on_trashcan_contents_altered);
+ media_sources.items_altered.disconnect(on_media_altered);
+ }
+
+ MetadataWriter.get_instance().progress.disconnect(on_metadata_writer_progress);
+
+ LibraryMonitor? monitor = LibraryMonitorPool.get_instance().get_monitor();
+ if (monitor != null)
+ on_library_monitor_destroyed(monitor);
+
+ LibraryMonitorPool.get_instance().monitor_installed.disconnect(on_library_monitor_installed);
+ LibraryMonitorPool.get_instance().monitor_destroyed.disconnect(on_library_monitor_destroyed);
+
+ CameraTable.get_instance().camera_added.disconnect(on_camera_added);
+ }
+
+ private void on_library_monitor_installed(LibraryMonitor monitor) {
+ debug("on_library_monitor_installed: %s", monitor.get_root().get_path());
+
+ monitor.discovery_started.connect(on_library_monitor_discovery_started);
+ monitor.discovery_completed.connect(on_library_monitor_discovery_completed);
+ monitor.closed.connect(on_library_monitor_discovery_completed);
+ monitor.auto_update_progress.connect(on_library_monitor_auto_update_progress);
+ monitor.auto_import_preparing.connect(on_library_monitor_auto_import_preparing);
+ monitor.auto_import_progress.connect(on_library_monitor_auto_import_progress);
+ }
+
+ private void on_library_monitor_destroyed(LibraryMonitor monitor) {
+ debug("on_library_monitor_destroyed: %s", monitor.get_root().get_path());
+
+ monitor.discovery_started.disconnect(on_library_monitor_discovery_started);
+ monitor.discovery_completed.disconnect(on_library_monitor_discovery_completed);
+ monitor.closed.disconnect(on_library_monitor_discovery_completed);
+ monitor.auto_update_progress.disconnect(on_library_monitor_auto_update_progress);
+ monitor.auto_import_preparing.disconnect(on_library_monitor_auto_import_preparing);
+ monitor.auto_import_progress.disconnect(on_library_monitor_auto_import_progress);
+ }
+
+ private Gtk.ActionEntry[] create_common_actions() {
+ Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
+
+ Gtk.ActionEntry import = { "CommonFileImport", Resources.IMPORT,
+ TRANSLATABLE, "<Ctrl>I", TRANSLATABLE, on_file_import };
+ import.label = _("_Import From Folder...");
+ import.tooltip = _("Import photos from disk to library");
+ actions += import;
+
+ Gtk.ActionEntry import_from_external = {
+ "ExternalLibraryImport", Resources.IMPORT, TRANSLATABLE,
+ null, TRANSLATABLE, on_external_library_import
+ };
+ import_from_external.label = _("Import From _Application...");
+ actions += import_from_external;
+
+ Gtk.ActionEntry sort = { "CommonSortEvents", null, TRANSLATABLE, null, null, null };
+ sort.label = _("Sort _Events");
+ actions += sort;
+
+ Gtk.ActionEntry preferences = { "CommonPreferences", Gtk.Stock.PREFERENCES, TRANSLATABLE,
+ null, TRANSLATABLE, on_preferences };
+ preferences.label = Resources.PREFERENCES_MENU;
+ actions += preferences;
+
+ Gtk.ActionEntry empty = { "CommonEmptyTrash", Gtk.Stock.CLEAR, TRANSLATABLE, null, null,
+ on_empty_trash };
+ empty.label = _("Empty T_rash");
+ empty.tooltip = _("Delete all photos in the trash");
+ actions += empty;
+
+ Gtk.ActionEntry jump_to_event = { "CommonJumpToEvent", null, TRANSLATABLE, null,
+ TRANSLATABLE, on_jump_to_event };
+ jump_to_event.label = _("View Eve_nt for Photo");
+ actions += jump_to_event;
+
+ Gtk.ActionEntry find = { "CommonFind", Gtk.Stock.FIND, TRANSLATABLE, null, null,
+ on_find };
+ find.label = _("_Find");
+ find.tooltip = _("Find photos and videos by search criteria");
+ actions += find;
+
+ // add the common action for the FilterPhotos submenu (the submenu contains items from
+ // SearchFilterActions)
+ Gtk.ActionEntry filter_photos = { "CommonFilterPhotos", null, TRANSLATABLE, null, null, null };
+ filter_photos.label = Resources.FILTER_PHOTOS_MENU;
+ actions += filter_photos;
+
+ Gtk.ActionEntry new_search = { "CommonNewSearch", null, TRANSLATABLE, "<Ctrl>S", null,
+ on_new_search };
+ new_search.label = _("Ne_w Saved Search...");
+ actions += new_search;
+
+ // top-level menus
+
+ Gtk.ActionEntry file = { "FileMenu", null, TRANSLATABLE, null, null, null };
+ file.label = _("_File");
+ actions += file;
+
+ Gtk.ActionEntry edit = { "EditMenu", null, TRANSLATABLE, null, null, null };
+ edit.label = _("_Edit");
+ actions += edit;
+
+ Gtk.ActionEntry view = { "ViewMenu", null, TRANSLATABLE, null, null, null };
+ view.label = _("_View");
+ actions += view;
+
+ Gtk.ActionEntry photo = { "PhotoMenu", null, TRANSLATABLE, null, null, null };
+ photo.label = _("_Photo");
+ actions += photo;
+
+ Gtk.ActionEntry photos = { "PhotosMenu", null, TRANSLATABLE, null, null, null };
+ photos.label = _("_Photos");
+ actions += photos;
+
+ Gtk.ActionEntry event = { "EventsMenu", null, TRANSLATABLE, null, null, null };
+ event.label = _("Even_ts");
+ actions += event;
+
+ Gtk.ActionEntry tags = { "TagsMenu", null, TRANSLATABLE, null, null, null };
+ tags.label = _("Ta_gs");
+ actions += tags;
+
+ Gtk.ActionEntry help = { "HelpMenu", null, TRANSLATABLE, null, null, null };
+ help.label = _("_Help");
+ actions += help;
+
+ return actions;
+ }
+
+ private Gtk.ToggleActionEntry[] create_common_toggle_actions() {
+ Gtk.ToggleActionEntry[] actions = new Gtk.ToggleActionEntry[0];
+
+ Gtk.ToggleActionEntry basic_props = { "CommonDisplayBasicProperties", null,
+ TRANSLATABLE, "<Ctrl><Shift>I", TRANSLATABLE, on_display_basic_properties, false };
+ basic_props.label = _("_Basic Information");
+ basic_props.tooltip = _("Display basic information for the selection");
+ actions += basic_props;
+
+ Gtk.ToggleActionEntry extended_props = { "CommonDisplayExtendedProperties", null,
+ TRANSLATABLE, "<Ctrl><Shift>X", TRANSLATABLE, on_display_extended_properties, false };
+ extended_props.label = _("E_xtended Information");
+ extended_props.tooltip = _("Display extended information for the selection");
+ actions += extended_props;
+
+ Gtk.ToggleActionEntry searchbar = { "CommonDisplaySearchbar", Gtk.Stock.FIND, TRANSLATABLE,
+ "F8", TRANSLATABLE, on_display_searchbar, is_search_toolbar_visible };
+ searchbar.label = _("_Search Bar");
+ searchbar.tooltip = _("Display the search bar");
+ actions += searchbar;
+
+ Gtk.ToggleActionEntry sidebar = { "CommonDisplaySidebar", null, TRANSLATABLE,
+ "F9", TRANSLATABLE, on_display_sidebar, is_sidebar_visible() };
+ sidebar.label = _("S_idebar");
+ sidebar.tooltip = _("Display the sidebar");
+ actions += sidebar;
+
+ return actions;
+ }
+
+ private void add_common_radio_actions(Gtk.ActionGroup group) {
+ Gtk.RadioActionEntry[] actions = new Gtk.RadioActionEntry[0];
+
+ Gtk.RadioActionEntry ascending = { "CommonSortEventsAscending",
+ Gtk.Stock.SORT_ASCENDING, TRANSLATABLE, null, TRANSLATABLE,
+ SORT_EVENTS_ORDER_ASCENDING };
+ ascending.label = _("_Ascending");
+ ascending.tooltip = _("Sort photos in an ascending order");
+ actions += ascending;
+
+ Gtk.RadioActionEntry descending = { "CommonSortEventsDescending",
+ Gtk.Stock.SORT_DESCENDING, TRANSLATABLE, null, TRANSLATABLE,
+ SORT_EVENTS_ORDER_DESCENDING };
+ descending.label = _("D_escending");
+ descending.tooltip = _("Sort photos in a descending order");
+ actions += descending;
+
+ group.add_radio_actions(actions, SORT_EVENTS_ORDER_ASCENDING, on_events_sort_changed);
+ }
+
+ protected override Gtk.ActionGroup[] create_common_action_groups() {
+ Gtk.ActionGroup[] groups = base.create_common_action_groups();
+
+ common_action_group.add_actions(create_common_actions(), this);
+ common_action_group.add_toggle_actions(create_common_toggle_actions(), this);
+ add_common_radio_actions(common_action_group);
+
+ Gtk.Action? action = common_action_group.get_action("CommonDisplaySearchbar");
+ if (action != null) {
+ action.short_label = Resources.FIND_LABEL;
+ action.is_important = true;
+ }
+
+ groups += common_action_group;
+ groups += search_actions.get_action_group();
+
+ return groups;
+ }
+
+ public override void replace_common_placeholders(Gtk.UIManager ui) {
+ base.replace_common_placeholders(ui);
+ }
+
+ protected override void switched_pages(Page? old_page, Page? new_page) {
+ base.switched_pages(old_page, new_page);
+
+ // monitor when the ViewFilter is changed in any page
+ if (old_page != null) {
+ old_page.get_view().view_filter_installed.disconnect(on_view_filter_installed);
+ old_page.get_view().view_filter_removed.disconnect(on_view_filter_removed);
+ }
+
+ if (new_page != null) {
+ new_page.get_view().view_filter_installed.connect(on_view_filter_installed);
+ new_page.get_view().view_filter_removed.connect(on_view_filter_removed);
+ }
+
+ search_actions.monitor_page_contents(old_page, new_page);
+ }
+
+ private void on_view_filter_installed(ViewFilter filter) {
+ filter.refresh.connect(on_view_filter_refreshed);
+ }
+
+ private void on_view_filter_removed(ViewFilter filter) {
+ filter.refresh.disconnect(on_view_filter_refreshed);
+ }
+
+ private void on_view_filter_refreshed() {
+ // if view filter is reset to show all items, do nothing (leave searchbar in current
+ // state)
+ if (!get_current_page().get_view().are_items_filtered_out())
+ return;
+
+ // always show the searchbar when items are filtered
+ Gtk.ToggleAction? display_searchbar = get_common_action("CommonDisplaySearchbar")
+ as Gtk.ToggleAction;
+ if (display_searchbar != null)
+ display_searchbar.active = true;
+ }
+
+ // show_all() may make visible certain items we wish to keep programmatically hidden
+ public override void show_all() {
+ base.show_all();
+
+ Gtk.ToggleAction? basic_properties_action = get_current_page().get_common_action(
+ "CommonDisplayBasicProperties") as Gtk.ToggleAction;
+ assert(basic_properties_action != null);
+
+ if (!basic_properties_action.get_active())
+ bottom_frame.hide();
+
+ Gtk.ToggleAction? searchbar_action = get_current_page().get_common_action(
+ "CommonDisplaySearchbar") as Gtk.ToggleAction;
+ assert(searchbar_action != null);
+
+ // Make sure rejected pictures are not being displayed on startup
+ CheckerboardPage? current_page = get_current_page() as CheckerboardPage;
+ if (current_page != null)
+ init_view_filter(current_page);
+
+ toggle_search_bar(should_show_search_bar(), current_page);
+
+ // Sidebar
+ set_sidebar_visible(is_sidebar_visible());
+ }
+
+ public static LibraryWindow get_app() {
+ assert(instance is LibraryWindow);
+
+ return (LibraryWindow) instance;
+ }
+
+ // This may be called before Debug.init(), so no error logging may be made
+ public static bool is_mount_uri_supported(string uri) {
+ foreach (string scheme in SUPPORTED_MOUNT_SCHEMES) {
+ if (uri.has_prefix(scheme))
+ return true;
+ }
+
+ return false;
+ }
+
+ public override string get_app_role() {
+ return Resources.APP_LIBRARY_ROLE;
+ }
+
+ public void rename_tag_in_sidebar(Tag tag) {
+ Tags.SidebarEntry? entry = tags_branch.get_entry_for_tag(tag);
+ if (entry != null)
+ sidebar_tree.rename_entry_in_place(entry);
+ else
+ debug("No tag entry found for rename");
+ }
+
+ public void rename_event_in_sidebar(Event event) {
+ Events.EventEntry? entry = events_branch.get_entry_for_event(event);
+ if (entry != null)
+ sidebar_tree.rename_entry_in_place(entry);
+ else
+ debug("No event entry found for rename");
+ }
+
+ public void rename_search_in_sidebar(SavedSearch search) {
+ Searches.SidebarEntry? entry = saved_search_branch.get_entry_for_saved_search(search);
+ if (entry != null)
+ sidebar_tree.rename_entry_in_place(entry);
+ else
+ debug("No search entry found for rename");
+ }
+
+ protected override void on_quit() {
+ Config.Facade.get_instance().set_library_window_state(maximized, dimensions);
+
+ Config.Facade.get_instance().set_sidebar_position(client_paned.position);
+
+ base.on_quit();
+ }
+
+ private Photo? get_start_fullscreen_photo(CollectionPage page) {
+ ViewCollection view = page.get_view();
+
+ // if a selection is present, use the first selected LibraryPhoto, otherwise do
+ // nothing; if no selection present, use the first LibraryPhoto
+ Gee.List<DataSource>? sources = (view.get_selected_count() > 0)
+ ? view.get_selected_sources_of_type(typeof(LibraryPhoto))
+ : view.get_sources_of_type(typeof(LibraryPhoto));
+
+ return (sources != null && sources.size != 0)
+ ? (Photo) sources[0] : null;
+ }
+
+ private bool get_fullscreen_photo(Page page, out CollectionPage collection, out Photo start,
+ out ViewCollection? view_collection = null) {
+ collection = null;
+ start = null;
+ view_collection = null;
+
+ // fullscreen behavior depends on the type of page being looked at
+ if (page is CollectionPage) {
+ collection = (CollectionPage) page;
+ Photo? photo = get_start_fullscreen_photo(collection);
+ if (photo == null)
+ return false;
+
+ start = photo;
+ view_collection = null;
+
+ return true;
+ }
+
+ if (page is EventsDirectoryPage) {
+ ViewCollection view = page.get_view();
+ if (view.get_count() == 0)
+ return false;
+
+ Event? event = (Event?) ((DataView) view.get_at(0)).get_source();
+ if (event == null)
+ return false;
+
+ Events.EventEntry? entry = events_branch.get_entry_for_event(event);
+ if (entry == null)
+ return false;
+
+ collection = (EventPage) entry.get_page();
+ Photo? photo = get_start_fullscreen_photo(collection);
+ if (photo == null)
+ return false;
+
+ start = photo;
+ view_collection = null;
+
+ return true;
+ }
+
+ if (page is LibraryPhotoPage) {
+ LibraryPhotoPage photo_page = (LibraryPhotoPage) page;
+
+ CollectionPage? controller = photo_page.get_controller_page();
+ if (controller == null)
+ return false;
+
+ if (!photo_page.has_photo())
+ return false;
+
+ collection = controller;
+ start = photo_page.get_photo();
+ view_collection = photo_page.get_view();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ protected override void on_fullscreen() {
+ Page? current_page = get_current_page();
+ if (current_page == null)
+ return;
+
+ CollectionPage collection;
+ Photo start;
+ ViewCollection? view = null;
+ if (!get_fullscreen_photo(current_page, out collection, out start, out view))
+ return;
+
+ FullscreenPhotoPage fs_photo = new FullscreenPhotoPage(collection, start, view);
+
+ go_fullscreen(fs_photo);
+ }
+
+ private void on_file_import() {
+ Gtk.FileChooserDialog import_dialog = new Gtk.FileChooserDialog(_("Import From Folder"), null,
+ Gtk.FileChooserAction.SELECT_FOLDER, Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL,
+ Gtk.Stock.OK, Gtk.ResponseType.OK);
+ import_dialog.set_local_only(false);
+ import_dialog.set_select_multiple(true);
+ import_dialog.set_current_folder(import_dir);
+
+ int response = import_dialog.run();
+
+ if (response == Gtk.ResponseType.OK) {
+ // force file linking if directory is inside current library directory
+ Gtk.ResponseType copy_files_response =
+ AppDirs.is_in_import_dir(File.new_for_uri(import_dialog.get_uri()))
+ ? Gtk.ResponseType.REJECT : copy_files_dialog();
+
+ if (copy_files_response != Gtk.ResponseType.CANCEL) {
+ dispatch_import_jobs(import_dialog.get_uris(), "folders",
+ copy_files_response == Gtk.ResponseType.ACCEPT);
+ }
+ }
+
+ import_dir = import_dialog.get_current_folder();
+ import_dialog.destroy();
+ }
+
+ private void on_external_library_import() {
+ Gtk.Dialog import_dialog = DataImportsUI.DataImportsDialog.get_or_create_instance();
+
+ import_dialog.run();
+ }
+
+ protected override void update_common_action_availability(Page? old_page, Page? new_page) {
+ base.update_common_action_availability(old_page, new_page);
+
+ bool is_checkerboard = new_page is CheckerboardPage;
+
+ set_common_action_sensitive("CommonDisplaySearchbar", is_checkerboard);
+ set_common_action_sensitive("CommonFind", is_checkerboard);
+ }
+
+ protected override void update_common_actions(Page page, int selected_count, int count) {
+ // see on_fullscreen for the logic here ... both CollectionPage and EventsDirectoryPage
+ // are CheckerboardPages (but in on_fullscreen have to be handled differently to locate
+ // the view controller)
+ CollectionPage collection;
+ Photo start;
+ bool can_fullscreen = get_fullscreen_photo(page, out collection, out start);
+
+ set_common_action_sensitive("CommonEmptyTrash", can_empty_trash());
+ set_common_action_visible("CommonJumpToEvent", true);
+ set_common_action_sensitive("CommonJumpToEvent", can_jump_to_event());
+ set_common_action_sensitive("CommonFullscreen", can_fullscreen);
+
+ base.update_common_actions(page, selected_count, count);
+ }
+
+ private void on_trashcan_contents_altered() {
+ set_common_action_sensitive("CommonEmptyTrash", can_empty_trash());
+ }
+
+ private bool can_empty_trash() {
+ return (LibraryPhoto.global.get_trashcan_count() > 0) || (Video.global.get_trashcan_count() > 0);
+ }
+
+ private void on_empty_trash() {
+ Gee.ArrayList<MediaSource> to_remove = new Gee.ArrayList<MediaSource>();
+ to_remove.add_all(LibraryPhoto.global.get_trashcan_contents());
+ to_remove.add_all(Video.global.get_trashcan_contents());
+
+ remove_from_app(to_remove, _("Empty Trash"), _("Emptying Trash..."));
+
+ AppWindow.get_command_manager().reset();
+ }
+
+ private void on_new_search() {
+ (new SavedSearchDialog()).show();
+ }
+
+ private bool can_jump_to_event() {
+ ViewCollection view = get_current_page().get_view();
+ if (view.get_selected_count() == 1) {
+ DataSource selected_source = view.get_selected_source_at(0);
+ if (selected_source is Event)
+ return true;
+ else if (selected_source is MediaSource)
+ return ((MediaSource) view.get_selected_source_at(0)).get_event() != null;
+ else
+ return false;
+ } else {
+ return false;
+ }
+ }
+
+ private void on_jump_to_event() {
+ ViewCollection view = get_current_page().get_view();
+
+ if (view.get_selected_count() != 1)
+ return;
+
+ MediaSource? media = view.get_selected_source_at(0) as MediaSource;
+ if (media == null)
+ return;
+
+ if (media.get_event() != null)
+ switch_to_event(media.get_event());
+ }
+
+ private void on_find() {
+ Gtk.ToggleAction action = (Gtk.ToggleAction) get_current_page().get_common_action(
+ "CommonDisplaySearchbar");
+ action.active = true;
+
+ // give it focus (which should move cursor to the text entry control)
+ search_toolbar.take_focus();
+ }
+
+ private void on_media_altered() {
+ set_common_action_sensitive("CommonJumpToEvent", can_jump_to_event());
+ }
+
+ private void on_clear_search() {
+ if (is_search_toolbar_visible)
+ search_actions.reset();
+ }
+
+ public int get_events_sort() {
+ Gtk.RadioAction? action = get_common_action("CommonSortEventsAscending") as Gtk.RadioAction;
+
+ return (action != null) ? action.current_value : SORT_EVENTS_ORDER_DESCENDING;
+ }
+
+ private void on_events_sort_changed(Gtk.Action action, Gtk.Action c) {
+ Gtk.RadioAction current = (Gtk.RadioAction) c;
+
+ Config.Facade.get_instance().set_events_sort_ascending(
+ current.current_value == SORT_EVENTS_ORDER_ASCENDING);
+ }
+
+ private void on_preferences() {
+ PreferencesDialog.show();
+ }
+
+ private void on_display_basic_properties(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ if (display) {
+ basic_properties.update_properties(get_current_page());
+ bottom_frame.show();
+ } else {
+ if (sidebar_paned.get_child2() != null) {
+ bottom_frame.hide();
+ }
+ }
+
+ // sync the setting so it will persist
+ Config.Facade.get_instance().set_display_basic_properties(display);
+ }
+
+ private void on_display_extended_properties(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ if (display) {
+ extended_properties.update_properties(get_current_page());
+ extended_properties.show_all();
+ } else {
+ extended_properties.hide();
+ }
+ }
+
+ private void on_display_searchbar(Gtk.Action action) {
+ bool is_shown = ((Gtk.ToggleAction) action).get_active();
+ Config.Facade.get_instance().set_display_search_bar(is_shown);
+ show_search_bar(is_shown);
+ }
+
+ public void show_search_bar(bool display) {
+ if (!(get_current_page() is CheckerboardPage))
+ return;
+
+ is_search_toolbar_visible = display;
+ toggle_search_bar(should_show_search_bar(), get_current_page() as CheckerboardPage);
+ if (!display)
+ search_actions.reset();
+ }
+
+ private void on_display_sidebar(Gtk.Action action) {
+ set_sidebar_visible(((Gtk.ToggleAction) action).get_active());
+
+ }
+
+ private void set_sidebar_visible(bool visible) {
+ sidebar_paned.set_visible(visible);
+ Config.Facade.get_instance().set_display_sidebar(visible);
+ }
+
+ private bool is_sidebar_visible() {
+ return Config.Facade.get_instance().get_display_sidebar();
+ }
+
+ private void show_extended_properties() {
+ sync_extended_properties(true);
+ }
+
+ private void hide_extended_properties() {
+ sync_extended_properties(false);
+ }
+
+ private void sync_extended_properties(bool show) {
+ Gtk.ToggleAction? extended_display_action = get_common_action("CommonDisplayExtendedProperties")
+ as Gtk.ToggleAction;
+ assert(extended_display_action != null);
+ extended_display_action.set_active(show);
+
+ // sync the setting so it will persist
+ Config.Facade.get_instance().set_display_extended_properties(show);
+ }
+
+ public void enqueue_batch_import(BatchImport batch_import, bool allow_user_cancel) {
+ import_queue_branch.enqueue_and_schedule(batch_import, allow_user_cancel);
+ }
+
+ private void import_reporter(ImportManifest manifest) {
+ ImportUI.report_manifest(manifest, true);
+ }
+
+ private void dispatch_import_jobs(GLib.SList<string> uris, string job_name, bool copy_to_library) {
+ 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" +
+ "Do you want to continue importing photos?").printf("▸"),
+ _("_Import"), _("Library Location"), AppWindow.get_instance());
+
+ if (response == Gtk.ResponseType.CANCEL)
+ return;
+
+ notify_library_is_home_dir = false;
+ }
+
+ Gee.ArrayList<FileImportJob> jobs = new Gee.ArrayList<FileImportJob>();
+ foreach (string uri in uris) {
+ File file_or_dir = File.new_for_uri(uri);
+ if (file_or_dir.get_path() == null) {
+ // TODO: Specify which directory/file.
+ AppWindow.error_message(_("Photos cannot be imported from this directory."));
+
+ continue;
+ }
+
+ jobs.add(new FileImportJob(file_or_dir, copy_to_library));
+ }
+
+ if (jobs.size > 0) {
+ BatchImport batch_import = new BatchImport(jobs, job_name, import_reporter);
+ enqueue_batch_import(batch_import, true);
+ switch_to_import_queue_page();
+ }
+ }
+
+ private Gdk.DragAction get_drag_action() {
+ Gdk.ModifierType mask;
+
+ get_window().get_device_position(Gdk.Display.get_default().get_device_manager()
+ .get_client_pointer(), null, null, out mask);
+
+ bool ctrl = (mask & Gdk.ModifierType.CONTROL_MASK) != 0;
+ bool alt = (mask & Gdk.ModifierType.MOD1_MASK) != 0;
+ bool shift = (mask & Gdk.ModifierType.SHIFT_MASK) != 0;
+
+ if (ctrl && !alt && !shift)
+ return Gdk.DragAction.COPY;
+ else if (!ctrl && alt && !shift)
+ return Gdk.DragAction.ASK;
+ else if (ctrl && !alt && shift)
+ return Gdk.DragAction.LINK;
+ else
+ return Gdk.DragAction.DEFAULT;
+ }
+
+ public override bool drag_motion(Gdk.DragContext context, int x, int y, uint time) {
+ Gdk.Atom target = Gtk.drag_dest_find_target(this, context, Gtk.drag_dest_get_target_list(this));
+ // Want to use GDK_NONE (or, properly bound, Gdk.Atom.NONE) but GTK3 doesn't have it bound
+ // See: https://bugzilla.gnome.org/show_bug.cgi?id=655094
+ if (((int) target) == 0) {
+ debug("drag target is GDK_NONE");
+ Gdk.drag_status(context, 0, time);
+
+ return true;
+ }
+
+ // internal drag
+ if (Gtk.drag_get_source_widget(context) != null) {
+ Gdk.drag_status(context, Gdk.DragAction.PRIVATE, time);
+
+ return true;
+ }
+
+ // since we cannot set a default action, we must set it when we spy a drag motion
+ Gdk.DragAction drag_action = get_drag_action();
+
+ if (drag_action == Gdk.DragAction.DEFAULT)
+ drag_action = Gdk.DragAction.ASK;
+
+ Gdk.drag_status(context, drag_action, time);
+
+ return true;
+ }
+
+ public override void drag_data_received(Gdk.DragContext context, int x, int y,
+ Gtk.SelectionData selection_data, uint info, uint time) {
+ if (selection_data.get_data().length < 0)
+ debug("failed to retrieve SelectionData");
+
+ // If an external drop, piggyback on the sidebar ExternalDropHandler, otherwise it's an
+ // internal drop, which isn't handled by the main window
+ if (Gtk.drag_get_source_widget(context) == null)
+ external_drop_handler(context, null, selection_data, info, time);
+ else
+ Gtk.drag_finish(context, false, false, time);
+ }
+
+ private void external_drop_handler(Gdk.DragContext context, Sidebar.Entry? entry,
+ Gtk.SelectionData data, uint info, uint time) {
+ string[] uris_array = data.get_uris();
+
+ GLib.SList<string> uris = new GLib.SList<string>();
+ foreach (string uri in uris_array)
+ uris.append(uri);
+
+ Gdk.DragAction selected_action = context.get_selected_action();
+ if (selected_action == Gdk.DragAction.ASK) {
+ // Default action is to link, unless one or more URIs are external to the library
+ Gtk.ResponseType result = Gtk.ResponseType.REJECT;
+ foreach (string uri in uris) {
+ if (!AppDirs.is_in_import_dir(File.new_for_uri(uri))) {
+ result = copy_files_dialog();
+
+ break;
+ }
+ }
+
+ switch (result) {
+ case Gtk.ResponseType.ACCEPT:
+ selected_action = Gdk.DragAction.COPY;
+ break;
+
+ case Gtk.ResponseType.REJECT:
+ selected_action = Gdk.DragAction.LINK;
+ break;
+
+ default:
+ // cancelled
+ Gtk.drag_finish(context, false, false, time);
+
+ return;
+ }
+ }
+
+ dispatch_import_jobs(uris, "drag-and-drop", selected_action == Gdk.DragAction.COPY);
+
+ Gtk.drag_finish(context, true, false, time);
+ }
+
+ public void switch_to_library_page() {
+ switch_to_page(library_branch.get_main_page());
+ }
+
+ public void switch_to_event(Event event) {
+ Events.EventEntry? entry = events_branch.get_entry_for_event(event);
+ if (entry != null)
+ switch_to_page(entry.get_page());
+ }
+
+ public void switch_to_tag(Tag tag) {
+ Tags.SidebarEntry? entry = tags_branch.get_entry_for_tag(tag);
+ if (entry != null)
+ switch_to_page(entry.get_page());
+ }
+
+ public void switch_to_saved_search(SavedSearch search) {
+ Searches.SidebarEntry? entry = saved_search_branch.get_entry_for_saved_search(search);
+ if (entry != null)
+ switch_to_page(entry.get_page());
+ }
+
+ public void switch_to_photo_page(CollectionPage controller, Photo current) {
+ assert(controller.get_view().get_view_for_source(current) != null);
+ if (photo_page == null) {
+ photo_page = new LibraryPhotoPage();
+ add_to_notebook(photo_page);
+
+ // need to do this to allow the event loop a chance to map and realize the page
+ // before switching to it
+ spin_event_loop();
+ }
+
+ photo_page.display_for_collection(controller, current);
+ switch_to_page(photo_page);
+ }
+
+ public void switch_to_import_queue_page() {
+ switch_to_page(import_queue_branch.get_queue_page());
+ }
+
+ private void on_camera_added(DiscoveredCamera camera) {
+ Camera.SidebarEntry? entry = camera_branch.get_entry_for_camera(camera);
+ if (entry == null)
+ 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
+ }
+
+ // don't unmount mass storage cameras, as they are then unavailable to gPhoto
+ if (mount != null && !camera.uri.has_prefix("file://")) {
+ if (page.unmount_camera(mount))
+ switch_to_page(page);
+ else
+ error_message("Unable to unmount the camera at this time.");
+ } else {
+ switch_to_page(page);
+ }
+ }
+
+ // This should only be called by LibraryWindow and PageStub.
+ public void add_to_notebook(Page page) {
+ // need to show all before handing over to notebook
+ page.show_all();
+
+ int pos = notebook.append_page(page, null);
+ assert(pos >= 0);
+
+ // need to show_all() after pages are added and removed
+ notebook.show_all();
+ }
+
+ private void remove_from_notebook(Page page) {
+ notebook.remove(page);
+
+ // need to show_all() after pages are added and removed
+ notebook.show_all();
+ }
+
+ // check for settings that should persist between instances
+ private void load_configuration() {
+ Gtk.ToggleAction? basic_display_action = get_common_action("CommonDisplayBasicProperties")
+ as Gtk.ToggleAction;
+ assert(basic_display_action != null);
+ basic_display_action.set_active(Config.Facade.get_instance().get_display_basic_properties());
+
+ Gtk.ToggleAction? extended_display_action = get_common_action("CommonDisplayExtendedProperties")
+ as Gtk.ToggleAction;
+ assert(extended_display_action != null);
+ extended_display_action.set_active(Config.Facade.get_instance().get_display_extended_properties());
+
+ Gtk.ToggleAction? search_bar_display_action = get_common_action("CommonDisplaySearchbar")
+ as Gtk.ToggleAction;
+ assert(search_bar_display_action != null);
+ search_bar_display_action.set_active(Config.Facade.get_instance().get_display_search_bar());
+
+ Gtk.RadioAction? sort_events_action = get_common_action("CommonSortEventsAscending")
+ as Gtk.RadioAction;
+ assert(sort_events_action != null);
+
+ // Ticket #3321 - Event sorting order wasn't saving on exit.
+ // Instead of calling set_active against one of the toggles, call
+ // set_current_value against the entire radio group...
+ int event_sort_val = Config.Facade.get_instance().get_events_sort_ascending() ? SORT_EVENTS_ORDER_ASCENDING :
+ SORT_EVENTS_ORDER_DESCENDING;
+
+ sort_events_action.set_current_value(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);
+ }
+
+ private void on_library_monitor_discovery_completed() {
+ stop_pulse_background_progress_bar(STARTUP_SCAN_PROGRESS_PRIORITY, 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);
+ else {
+ update_background_progress_bar(_("Updating library..."), REALTIME_UPDATE_PROGRESS_PRIORITY,
+ 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);
+ }
+
+ 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);
+ }
+
+ private void on_metadata_writer_progress(uint completed, uint total) {
+ if (total < MIN_PROGRESS_BAR_FILES)
+ clear_background_progress_bar(METADATA_WRITER_PROGRESS_PRIORITY);
+ else {
+ update_background_progress_bar(_("Writing metadata to files..."),
+ METADATA_WRITER_PROGRESS_PRIORITY, completed, total);
+ }
+ }
+
+ private void create_layout(Page start_page) {
+ // use a Notebook to hold all the pages, which are switched when a sidebar child is selected
+ notebook.set_show_tabs(false);
+ notebook.set_show_border(false);
+
+ // put the sidebar in a scrolling window
+ Gtk.ScrolledWindow scrolled_sidebar = new Gtk.ScrolledWindow(null, null);
+ scrolled_sidebar.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
+ scrolled_sidebar.add(sidebar_tree);
+ scrolled_sidebar.get_style_context().add_class(Gtk.STYLE_CLASS_SIDEBAR);
+ scrolled_sidebar.set_shadow_type(Gtk.ShadowType.IN);
+ get_style_context().add_class("sidebar-pane-separator");
+
+ // divy the sidebar up into selection tree list, background progress bar, and properties
+ Gtk.Frame top_frame = new Gtk.Frame(null);
+ top_frame.add(scrolled_sidebar);
+ top_frame.set_shadow_type(Gtk.ShadowType.IN);
+
+ background_progress_frame.add(background_progress_bar);
+ background_progress_frame.set_shadow_type(Gtk.ShadowType.IN);
+
+ // pad the bottom frame (properties)
+ Gtk.Alignment bottom_alignment = new Gtk.Alignment(0, 0.5f, 1, 0);
+
+ Resources.style_widget(scrolled_sidebar, Resources.SCROLL_FRAME_STYLESHEET);
+ Resources.style_widget(bottom_frame, Resources.INSET_FRAME_STYLESHEET);
+
+ bottom_alignment.set_padding(10, 10, 6, 0);
+ bottom_alignment.add(basic_properties);
+
+ bottom_frame.add(bottom_alignment);
+ bottom_frame.set_shadow_type(Gtk.ShadowType.IN);
+
+ // "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(top_frame, true, true, 0);
+
+ sidebar_paned.pack1(top_section, true, false);
+ sidebar_paned.pack2(bottom_frame, false, false);
+ sidebar_paned.set_position(1000);
+
+ // layout the selection tree to the left of the collection/toolbar box with an adjustable
+ // gutter between them, framed for presentation
+ Gtk.Frame right_frame = new Gtk.Frame(null);
+ right_frame.set_shadow_type(Gtk.ShadowType.IN);
+
+ right_vbox = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ right_frame.add(right_vbox);
+ right_vbox.pack_start(search_toolbar, false, false, 0);
+ right_vbox.pack_start(notebook, true, true, 0);
+
+ client_paned = new Gtk.Paned(Gtk.Orientation.HORIZONTAL);
+ client_paned.pack1(sidebar_paned, false, false);
+ sidebar_tree.set_size_request(SIDEBAR_MIN_WIDTH, -1);
+ client_paned.pack2(right_frame, true, false);
+ client_paned.set_position(Config.Facade.get_instance().get_sidebar_position());
+ // TODO: Calc according to layout's size, to give sidebar a maximum width
+ notebook.set_size_request(PAGE_MIN_WIDTH, -1);
+
+ layout.pack_end(client_paned, true, true, 0);
+
+ add(layout);
+
+ switch_to_page(start_page);
+ start_page.grab_focus();
+ }
+
+ public override void set_current_page(Page page) {
+ // switch_to_page() will call base.set_current_page(), maintain the semantics of this call
+ switch_to_page(page);
+ }
+
+ public void set_page_switching_enabled(bool should_enable) {
+ page_switching_enabled = should_enable;
+ }
+
+ public void switch_to_page(Page page) {
+ if (!page_switching_enabled)
+ return;
+
+ if (page == get_current_page())
+ return;
+
+ Page current_page = get_current_page();
+ if (current_page != null) {
+ Gtk.Toolbar toolbar = current_page.get_toolbar();
+ if (toolbar != null)
+ right_vbox.remove(toolbar);
+
+ current_page.switching_from();
+
+ // see note below about why the sidebar is uneditable while the LibraryPhotoPage is
+ // visible
+ if (current_page is LibraryPhotoPage)
+ sidebar_tree.enable_editing();
+
+ // old page unsubscribes to these signals (new page subscribes below)
+ unsubscribe_from_basic_information(current_page);
+ }
+
+ notebook.set_current_page(notebook.page_num(page));
+
+ // do this prior to changing selection, as the change will fire a cursor-changed event,
+ // which will then call this function again
+ base.set_current_page(page);
+
+ // if the visible page is the LibraryPhotoPage, we need to prevent single-click inline
+ // renaming in the sidebar because a single click while in the LibraryPhotoPage indicates
+ // the user wants to return to the controlling page ... that is, in this special case, the
+ // sidebar cursor is set not to the 'current' page, but the page the user came from
+ if (page is LibraryPhotoPage)
+ sidebar_tree.disable_editing();
+
+ // Update search filter to new page.
+ toggle_search_bar(should_show_search_bar(), page as CheckerboardPage);
+
+ // Not all pages have sidebar entries
+ Sidebar.Entry? entry = page_map.get(page);
+ if (entry != null) {
+ // if the corresponding sidebar entry is an expandable entry and wants to be
+ // expanded when it's selected, then expand it
+ Sidebar.ExpandableEntry expandable_entry = entry as Sidebar.ExpandableEntry;
+ if (expandable_entry != null && expandable_entry.expand_on_select())
+ sidebar_tree.expand_to_entry(entry);
+
+ sidebar_tree.place_cursor(entry, true);
+ }
+
+ on_update_properties();
+
+ if (page is CheckerboardPage)
+ init_view_filter((CheckerboardPage)page);
+
+ page.show_all();
+
+ // subscribe to these signals for each event page so basic properties display will update
+ subscribe_for_basic_information(get_current_page());
+
+ page.switched_to();
+
+ Gtk.Toolbar toolbar = page.get_toolbar();
+ if (toolbar != null) {
+ right_vbox.add(toolbar);
+ toolbar.show_all();
+ }
+
+ page.ready();
+ }
+
+ private void init_view_filter(CheckerboardPage page) {
+ search_toolbar.set_view_filter(page.get_search_view_filter());
+ page.get_view().install_view_filter(page.get_search_view_filter());
+ }
+
+ private bool should_show_search_bar() {
+ return (get_current_page() is CheckerboardPage) ? is_search_toolbar_visible : false;
+ }
+
+ // Turns the search bar on or off. Note that if show is true, page must not be null.
+ private void toggle_search_bar(bool show, CheckerboardPage? page = null) {
+ search_toolbar.visible = show;
+ if (show) {
+ assert(null != page);
+ search_toolbar.set_view_filter(page.get_search_view_filter());
+ page.get_view().install_view_filter(page.get_search_view_filter());
+ } else {
+ if (page != null)
+ page.get_view().install_view_filter(new DisabledViewFilter());
+ }
+ }
+
+ private void on_page_created(Sidebar.PageRepresentative entry, Page page) {
+ assert(!page_map.has_key(page));
+ page_map.set(page, entry);
+
+ add_to_notebook(page);
+ }
+
+ private void on_destroying_page(Sidebar.PageRepresentative entry, Page page) {
+ // if page is the current page, switch to fallback before destroying
+ if (page == get_current_page())
+ switch_to_page(library_branch.get_main_page());
+
+ remove_from_notebook(page);
+
+ bool removed = page_map.unset(page);
+ assert(removed);
+ }
+
+ private void on_sidebar_entry_selected(Sidebar.SelectableEntry selectable) {
+ Sidebar.PageRepresentative? page_rep = selectable as Sidebar.PageRepresentative;
+ if (page_rep != null)
+ switch_to_page(page_rep.get_page());
+ }
+
+ private void on_sidebar_selected_entry_removed(Sidebar.SelectableEntry selectable) {
+ // if the currently selected item is removed, want to jump to fallback page (which
+ // depends on the item that was selected)
+
+ // Importing... -> Last Import (if available)
+ if (selectable is Library.ImportQueueSidebarEntry && last_import_branch.get_show_branch()) {
+ switch_to_page(last_import_branch.get_main_entry().get_page());
+
+ return;
+ }
+
+ // Event page -> Events (master event directory)
+ if (selectable is Events.EventEntry && events_branch.get_show_branch()) {
+ switch_to_page(events_branch.get_master_entry().get_page());
+
+ return;
+ }
+
+ // Any event directory -> Events (master event directory)
+ if (selectable is Events.DirectoryEntry && events_branch.get_show_branch()) {
+ switch_to_page(events_branch.get_master_entry().get_page());
+
+ return;
+ }
+
+ // basic all-around default: jump to the Library page
+ switch_to_page(library_branch.get_main_page());
+ }
+
+ private void subscribe_for_basic_information(Page page) {
+ ViewCollection view = page.get_view();
+
+ view.items_state_changed.connect(on_update_properties);
+ view.items_altered.connect(on_update_properties);
+ view.contents_altered.connect(on_update_properties);
+ view.items_visibility_changed.connect(on_update_properties);
+ }
+
+ private void unsubscribe_from_basic_information(Page page) {
+ ViewCollection view = page.get_view();
+
+ view.items_state_changed.disconnect(on_update_properties);
+ view.items_altered.disconnect(on_update_properties);
+ view.contents_altered.disconnect(on_update_properties);
+ view.items_visibility_changed.disconnect(on_update_properties);
+ }
+
+ private void on_update_properties() {
+ properties_scheduler.at_idle();
+ }
+
+ private void on_update_properties_now() {
+ if (bottom_frame.visible)
+ basic_properties.update_properties(get_current_page());
+
+ if (extended_properties.visible)
+ extended_properties.update_properties(get_current_page());
+ }
+
+ public void mounted_camera_shell_notification(string uri, bool at_startup) {
+ debug("mount point reported: %s", uri);
+
+ // ignore unsupport mount URIs
+ if (!is_mount_uri_supported(uri)) {
+ debug("Unsupported mount scheme: %s", uri);
+
+ return;
+ }
+
+ File uri_file = File.new_for_uri(uri);
+
+ // find the VFS mount point
+ Mount mount = null;
+ try {
+ mount = uri_file.find_enclosing_mount(null);
+ } catch (Error err) {
+ debug("%s", err.message);
+
+ return;
+ }
+
+ // convert file: URIs into gphoto disk: URIs
+ string alt_uri = null;
+ if (uri.has_prefix("file://"))
+ alt_uri = CameraTable.get_port_uri(uri.replace("file://", "disk:"));
+
+ // we only add uris when the notification is called on startup
+ if (at_startup) {
+ if (!is_string_empty(uri))
+ initial_camera_uris.add(uri);
+
+ if (!is_string_empty(alt_uri))
+ initial_camera_uris.add(alt_uri);
+ }
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ if (sidebar_tree.has_focus && sidebar_tree.is_keypress_interpreted(event)
+ && sidebar_tree.key_press_event(event)) {
+ return true;
+ }
+
+ if (base.key_press_event(event))
+ return true;
+
+ if (Gdk.keyval_name(event.keyval) == "Escape") {
+ on_clear_search();
+ return true;
+ }
+
+ return false;
+ }
+}
+
diff --git a/src/library/OfflineBranch.vala b/src/library/OfflineBranch.vala
new file mode 100644
index 0000000..4ed2e49
--- /dev/null
+++ b/src/library/OfflineBranch.vala
@@ -0,0 +1,51 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Library.OfflineBranch : Sidebar.RootOnlyBranch {
+ public OfflineBranch() {
+ base (new Library.OfflineSidebarEntry());
+
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.offline_contents_altered.connect(on_offline_contents_altered);
+
+ set_show_branch(get_total_offline() != 0);
+ }
+
+ ~OfflineBranch() {
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.trashcan_contents_altered.disconnect(on_offline_contents_altered);
+ }
+
+ private void on_offline_contents_altered() {
+ set_show_branch(get_total_offline() != 0);
+ }
+
+ private int get_total_offline() {
+ int total = 0;
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ total += media_sources.get_offline_bin_contents().size;
+
+ return total;
+ }
+}
+
+public class Library.OfflineSidebarEntry : Sidebar.SimplePageEntry {
+ public OfflineSidebarEntry() {
+ }
+
+ public override string get_sidebar_name() {
+ return OfflinePage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return new ThemedIcon(Resources.ICON_MISSING_FILES);
+ }
+
+ protected override Page create_page() {
+ return new OfflinePage();
+ }
+}
+
diff --git a/src/library/OfflinePage.vala b/src/library/OfflinePage.vala
new file mode 100644
index 0000000..cb6af2d
--- /dev/null
+++ b/src/library/OfflinePage.vala
@@ -0,0 +1,130 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 OfflinePage : CheckerboardPage {
+ public const string NAME = _("Missing Files");
+
+ private class OfflineView : Thumbnail {
+ public OfflineView(MediaSource source) {
+ base (source);
+
+ assert(source.is_offline());
+ }
+ }
+
+ private class OfflineSearchViewFilter : DefaultSearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT | SearchFilterCriteria.FLAG |
+ SearchFilterCriteria.MEDIA | SearchFilterCriteria.RATING;
+ }
+ }
+
+ private OfflineSearchViewFilter search_filter = new OfflineSearchViewFilter();
+ private MediaViewTracker tracker;
+
+ public OfflinePage() {
+ base (NAME);
+
+ init_item_context_menu("/OfflineContextMenu");
+ init_toolbar("/OfflineToolbar");
+
+ tracker = new MediaViewTracker(get_view());
+
+ // monitor offline and initialize view with all items in it
+ LibraryPhoto.global.offline_contents_altered.connect(on_offline_contents_altered);
+ Video.global.offline_contents_altered.connect(on_offline_contents_altered);
+
+ on_offline_contents_altered(LibraryPhoto.global.get_offline_bin_contents(), null);
+ on_offline_contents_altered(Video.global.get_offline_bin_contents(), null);
+ }
+
+ ~OfflinePage() {
+ LibraryPhoto.global.offline_contents_altered.disconnect(on_offline_contents_altered);
+ Video.global.offline_contents_altered.disconnect(on_offline_contents_altered);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("offline.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry remove = { "RemoveFromLibrary", Gtk.Stock.REMOVE, TRANSLATABLE, "Delete",
+ TRANSLATABLE, on_remove_from_library };
+ remove.label = Resources.REMOVE_FROM_LIBRARY_MENU;
+ remove.tooltip = Resources.DELETE_FROM_LIBRARY_TOOLTIP;
+ actions += remove;
+
+ return actions;
+ }
+
+ public override Core.ViewTracker? get_view_tracker() {
+ return tracker;
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ set_action_sensitive("RemoveFromLibrary", selected_count > 0);
+ set_action_important("RemoveFromLibrary", true);
+
+ base.update_actions(selected_count, count);
+ }
+
+ private void on_offline_contents_altered(Gee.Collection<MediaSource>? added,
+ Gee.Collection<MediaSource>? removed) {
+ if (added != null) {
+ foreach (MediaSource source in added)
+ get_view().add(new OfflineView(source));
+ }
+
+ if (removed != null) {
+ Marker marker = get_view().start_marking();
+ foreach (MediaSource source in removed)
+ marker.mark(get_view().get_view_for_source(source));
+ get_view().remove_marked(marker);
+ }
+ }
+
+ private void on_remove_from_library() {
+ Gee.Collection<MediaSource> sources =
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources();
+ if (sources.size == 0)
+ return;
+
+ if (!remove_offline_dialog(AppWindow.get_instance(), sources.size))
+ return;
+
+ AppWindow.get_instance().set_busy_cursor();
+
+ ProgressDialog progress = null;
+ if (sources.size >= 20)
+ progress = new ProgressDialog(AppWindow.get_instance(), _("Deleting..."));
+
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
+ MediaSourceCollection.filter_media(sources, photos, videos);
+
+ if (progress != null) {
+ LibraryPhoto.global.remove_from_app(photos, false, progress.monitor);
+ Video.global.remove_from_app(videos, false, progress.monitor);
+ } else {
+ LibraryPhoto.global.remove_from_app(photos, false);
+ Video.global.remove_from_app(videos, false);
+ }
+
+ if (progress != null)
+ progress.close();
+
+ AppWindow.get_instance().set_normal_cursor();
+ }
+
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
diff --git a/src/library/TrashBranch.vala b/src/library/TrashBranch.vala
new file mode 100644
index 0000000..5ef8b3c
--- /dev/null
+++ b/src/library/TrashBranch.vala
@@ -0,0 +1,73 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Library.TrashBranch : Sidebar.RootOnlyBranch {
+ public TrashBranch() {
+ base (new Library.TrashSidebarEntry());
+ }
+}
+
+public class Library.TrashSidebarEntry : Sidebar.SimplePageEntry, Sidebar.InternalDropTargetEntry {
+ private static Icon? full_icon = null;
+ private static Icon? empty_icon = null;
+
+ public TrashSidebarEntry() {
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.trashcan_contents_altered.connect(on_trashcan_contents_altered);
+ }
+
+ ~TrashSidebarEntry() {
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
+ media_sources.trashcan_contents_altered.disconnect(on_trashcan_contents_altered);
+ }
+
+ internal static void init() {
+ full_icon = new ThemedIcon(Resources.ICON_TRASH_FULL);
+ empty_icon = new ThemedIcon(Resources.ICON_TRASH_EMPTY);
+ }
+
+ internal static void terminate() {
+ full_icon = null;
+ empty_icon = null;
+ }
+
+ public override string get_sidebar_name() {
+ return TrashPage.NAME;
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return get_current_icon();
+ }
+
+ private static Icon get_current_icon() {
+ foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all()) {
+ if (media_sources.get_trashcan_count() > 0)
+ return full_icon;
+ }
+
+ return empty_icon;
+ }
+
+ public bool internal_drop_received(Gee.List<MediaSource> media) {
+ AppWindow.get_command_manager().execute(new TrashUntrashPhotosCommand(media, true));
+
+ return true;
+ }
+
+ public bool internal_drop_received_arbitrary(Gtk.SelectionData data) {
+ return false;
+ }
+
+ protected override Page create_page() {
+ return new TrashPage();
+ }
+
+ private void on_trashcan_contents_altered() {
+ sidebar_icon_changed(get_current_icon());
+ }
+}
+
+
diff --git a/src/library/TrashPage.vala b/src/library/TrashPage.vala
new file mode 100644
index 0000000..2991727
--- /dev/null
+++ b/src/library/TrashPage.vala
@@ -0,0 +1,120 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 TrashPage : CheckerboardPage {
+ public const string NAME = _("Trash");
+
+ private class TrashView : Thumbnail {
+ public TrashView(MediaSource source) {
+ base (source);
+
+ assert(source.is_trashed());
+ }
+ }
+
+ private class TrashSearchViewFilter : DefaultSearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT | SearchFilterCriteria.FLAG |
+ SearchFilterCriteria.MEDIA | SearchFilterCriteria.RATING;
+ }
+ }
+
+ private TrashSearchViewFilter search_filter = new TrashSearchViewFilter();
+ private MediaViewTracker tracker;
+
+ public TrashPage() {
+ base (NAME);
+
+ init_item_context_menu("/TrashContextMenu");
+ init_page_context_menu("/TrashPageMenu");
+ init_toolbar("/TrashToolbar");
+
+ tracker = new MediaViewTracker(get_view());
+
+ // monitor trashcans and initialize view with all items in them
+ LibraryPhoto.global.trashcan_contents_altered.connect(on_trashcan_contents_altered);
+ Video.global.trashcan_contents_altered.connect(on_trashcan_contents_altered);
+ on_trashcan_contents_altered(LibraryPhoto.global.get_trashcan_contents(), null);
+ on_trashcan_contents_altered(Video.global.get_trashcan_contents(), null);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("trash.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry delete_action = { "Delete", Gtk.Stock.DELETE, TRANSLATABLE, "Delete",
+ TRANSLATABLE, on_delete };
+ delete_action.label = Resources.DELETE_PHOTOS_MENU;
+ delete_action.tooltip = Resources.DELETE_FROM_TRASH_TOOLTIP;
+ actions += delete_action;
+
+ Gtk.ActionEntry restore = { "Restore", Gtk.Stock.UNDELETE, TRANSLATABLE, null, TRANSLATABLE,
+ on_restore };
+ restore.label = Resources.RESTORE_PHOTOS_MENU;
+ restore.tooltip = Resources.RESTORE_PHOTOS_TOOLTIP;
+ actions += restore;
+
+ return actions;
+ }
+
+ public override Core.ViewTracker? get_view_tracker() {
+ return tracker;
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ bool has_selected = selected_count > 0;
+
+ set_action_sensitive("Delete", has_selected);
+ set_action_important("Delete", true);
+ set_action_sensitive("Restore", has_selected);
+ set_action_important("Restore", true);
+ set_common_action_important("CommonEmptyTrash", true);
+
+ base.update_actions(selected_count, count);
+ }
+
+ private void on_trashcan_contents_altered(Gee.Collection<MediaSource>? added,
+ Gee.Collection<MediaSource>? removed) {
+ if (added != null) {
+ foreach (MediaSource source in added)
+ get_view().add(new TrashView(source));
+ }
+
+ if (removed != null) {
+ Marker marker = get_view().start_marking();
+ foreach (MediaSource source in removed)
+ marker.mark(get_view().get_view_for_source(source));
+ get_view().remove_marked(marker);
+ }
+ }
+
+ private void on_restore() {
+ if (get_view().get_selected_count() == 0)
+ return;
+
+ get_command_manager().execute(new TrashUntrashPhotosCommand(
+ (Gee.Collection<LibraryPhoto>) get_view().get_selected_sources(), false));
+ }
+
+ protected override string get_view_empty_message() {
+ return _("Trash is empty");
+ }
+
+ private void on_delete() {
+ remove_from_app((Gee.Collection<MediaSource>) get_view().get_selected_sources(), _("Delete"),
+ (get_view().get_selected_count() == 1) ? ("Deleting a Photo") : _("Deleting Photos"));
+ }
+
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
diff --git a/src/library/mk/library.mk b/src/library/mk/library.mk
new file mode 100644
index 0000000..b4ab790
--- /dev/null
+++ b/src/library/mk/library.mk
@@ -0,0 +1,54 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Library
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := library
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ LibraryWindow.vala \
+ Branch.vala \
+ TrashBranch.vala \
+ OfflineBranch.vala \
+ FlaggedBranch.vala \
+ LastImportBranch.vala \
+ ImportQueueBranch.vala \
+ FlaggedPage.vala \
+ ImportQueuePage.vala \
+ LastImportPage.vala \
+ OfflinePage.vala \
+ TrashPage.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Util \
+ Threads \
+ Db \
+ Plugins \
+ Slideshow \
+ Photos \
+ Publishing \
+ Core \
+ Sidebar \
+ Events \
+ Tags \
+ Camera \
+ Searches \
+ DataImports \
+ Folders
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/main.vala b/src/main.vala
new file mode 100644
index 0000000..8c045fd
--- /dev/null
+++ b/src/main.vala
@@ -0,0 +1,441 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+enum ShotwellCommand {
+ // user-defined commands must be positive ints
+ MOUNTED_CAMERA = 1
+}
+
+private Timer startup_timer = null;
+private bool was_already_running = false;
+
+void library_exec(string[] mounts) {
+ was_already_running = Application.get_is_remote();
+
+ if (was_already_running) {
+ // Send attached cameras out to the primary instance.
+ // The primary instance will get a 'command-line' signal with mounts[]
+ // as an argument, and an 'activate', which will present the window.
+ //
+ // This will also take care of killing us when it sees that another
+ // instance was already registered.
+ Application.present_primary_instance();
+ Application.send_to_primary_instance(mounts);
+ return;
+ }
+
+ // preconfigure units
+ Db.preconfigure(AppDirs.get_data_subdir("data").get_child("photo.db"));
+
+ // initialize units
+ try {
+ Library.app_init();
+ } catch (Error err) {
+ AppWindow.panic(err.message);
+
+ return;
+ }
+
+ // validate the databases prior to using them
+ message("Verifying database ...");
+ string errormsg = null;
+ string app_version;
+ int schema_version;
+ Db.VerifyResult result = Db.verify_database(out app_version, out schema_version);
+ switch (result) {
+ case Db.VerifyResult.OK:
+ // do nothing; no problems
+ break;
+
+ case Db.VerifyResult.FUTURE_VERSION:
+ errormsg = _("Your photo library is not compatible with this version of Shotwell. It appears it was created by Shotwell %s (schema %d). This version is %s (schema %d). Please use the latest version of Shotwell.").printf(
+ app_version, schema_version, Resources.APP_VERSION, DatabaseTable.SCHEMA_VERSION);
+ break;
+
+ case Db.VerifyResult.UPGRADE_ERROR:
+ errormsg = _("Shotwell was unable to upgrade your photo library from version %s (schema %d) to %s (schema %d). For more information please check the Shotwell Wiki at %s").printf(
+ app_version, schema_version, Resources.APP_VERSION, DatabaseTable.SCHEMA_VERSION,
+ Resources.HOME_URL);
+ break;
+
+ case Db.VerifyResult.NO_UPGRADE_AVAILABLE:
+ errormsg = _("Your photo library is not compatible with this version of Shotwell. It appears it was created by Shotwell %s (schema %d). This version is %s (schema %d). Please clear your library by deleting %s and re-import your photos.").printf(
+ app_version, schema_version, Resources.APP_VERSION, DatabaseTable.SCHEMA_VERSION,
+ AppDirs.get_data_dir().get_path());
+ break;
+
+ default:
+ errormsg = _("Unknown error attempting to verify Shotwell's database: %s").printf(
+ result.to_string());
+ break;
+ }
+
+ if (errormsg != null) {
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(null, Gtk.DialogFlags.MODAL,
+ Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s", errormsg);
+ dialog.title = Resources.APP_TITLE;
+ dialog.run();
+ dialog.destroy();
+
+ DatabaseTable.terminate();
+
+ return;
+ }
+
+ Upgrades.init();
+
+ ProgressDialog progress_dialog = null;
+ AggregateProgressMonitor aggregate_monitor = null;
+ ProgressMonitor monitor = null;
+
+ if (!CommandlineOptions.no_startup_progress) {
+ // only throw up a startup progress dialog if over a reasonable amount of objects ... multiplying
+ // photos by two because there's two heavy-duty operations on them: creating the LibraryPhoto
+ // objects and then populating the initial page with them.
+ uint64 grand_total = PhotoTable.get_instance().get_row_count()
+ + EventTable.get_instance().get_row_count()
+ + TagTable.get_instance().get_row_count()
+ + VideoTable.get_instance().get_row_count()
+ + Upgrades.get_instance().get_step_count();
+ if (grand_total > 5000) {
+ progress_dialog = new ProgressDialog(null, _("Loading Shotwell"));
+ progress_dialog.update_display_every(100);
+ progress_dialog.set_minimum_on_screen_time_msec(250);
+ try {
+ string icon_path = AppDirs.get_resources_dir().get_child("icons").get_child("shotwell.svg").get_path();
+ progress_dialog.icon = new Gdk.Pixbuf.from_file(icon_path);
+ } catch (Error err) {
+ debug("Warning - could not load application icon for loading window: %s", err.message);
+ }
+
+ aggregate_monitor = new AggregateProgressMonitor(grand_total, progress_dialog.monitor);
+ monitor = aggregate_monitor.monitor;
+ }
+ }
+
+ ThumbnailCache.init();
+ Tombstone.init();
+
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("LibraryPhoto.init");
+ LibraryPhoto.init(monitor);
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("Video.init");
+ Video.init(monitor);
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("Upgrades.execute");
+ Upgrades.get_instance().execute();
+
+ LibraryMonitorPool.init();
+ MediaCollectionRegistry.init();
+ MediaCollectionRegistry registry = MediaCollectionRegistry.get_instance();
+ registry.register_collection(LibraryPhoto.global);
+ registry.register_collection(Video.global);
+
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("Event.init");
+ Event.init(monitor);
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("Tag.init");
+ Tag.init(monitor);
+
+ MetadataWriter.init();
+ DesktopIntegration.init();
+
+ Application.get_instance().init_done();
+
+ // create main library application window
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("LibraryWindow");
+ LibraryWindow library_window = new LibraryWindow(monitor);
+
+ if (aggregate_monitor != null)
+ aggregate_monitor.next_step("done");
+
+ // destroy and tear down everything ... no need for them to stick around the lifetime of the
+ // application
+
+ monitor = null;
+ aggregate_monitor = null;
+ if (progress_dialog != null)
+ progress_dialog.destroy();
+ progress_dialog = null;
+
+ // report mount points
+ foreach (string mount in mounts)
+ library_window.mounted_camera_shell_notification(mount, true);
+
+ library_window.show_all();
+
+ WelcomeServiceEntry[] selected_import_entries = new WelcomeServiceEntry[0];
+ if (Config.Facade.get_instance().get_show_welcome_dialog() &&
+ LibraryPhoto.global.get_count() == 0) {
+ WelcomeDialog welcome = new WelcomeDialog(library_window);
+ Config.Facade.get_instance().set_show_welcome_dialog(welcome.execute(out selected_import_entries,
+ out do_system_pictures_import));
+ } else {
+ Config.Facade.get_instance().set_show_welcome_dialog(false);
+ }
+
+ if (selected_import_entries.length > 0) {
+ do_external_import = true;
+ foreach (WelcomeServiceEntry entry in selected_import_entries)
+ entry.execute();
+ }
+ if (do_system_pictures_import) {
+ /* Do the system import even if other plugins have run as some plugins may not
+ as some plugins may not import pictures from the system folder.
+ */
+ run_system_pictures_import();
+ }
+
+ debug("%lf seconds to Gtk.main()", startup_timer.elapsed());
+
+ Application.get_instance().start();
+
+ DesktopIntegration.terminate();
+ MetadataWriter.terminate();
+ Tag.terminate();
+ Event.terminate();
+ LibraryPhoto.terminate();
+ MediaCollectionRegistry.terminate();
+ LibraryMonitorPool.terminate();
+ Tombstone.terminate();
+ ThumbnailCache.terminate();
+ Video.terminate();
+ Library.app_terminate();
+}
+
+private bool do_system_pictures_import = false;
+private bool do_external_import = false;
+
+public void run_system_pictures_import(ImportManifest? external_exclusion_manifest = null) {
+ if (!do_system_pictures_import)
+ return;
+
+ Gee.ArrayList<FileImportJob> jobs = new Gee.ArrayList<FileImportJob>();
+ jobs.add(new FileImportJob(AppDirs.get_import_dir(), false));
+
+ LibraryWindow library_window = (LibraryWindow) AppWindow.get_instance();
+
+ BatchImport batch_import = new BatchImport(jobs, "startup_import",
+ report_system_pictures_import, null, null, null, null, external_exclusion_manifest);
+ library_window.enqueue_batch_import(batch_import, true);
+
+ library_window.switch_to_import_queue_page();
+}
+
+private void report_system_pictures_import(ImportManifest manifest, BatchImportRoll import_roll) {
+ /* Don't report the manifest to the user if exteral import was done and the entire manifest
+ is empty. An empty manifest in this case results from files that were already imported
+ in the external import phase being skipped. Note that we are testing against manifest.all,
+ not manifest.success; manifest.all is zero when no files were enqueued for import in the
+ first place and the only way this happens is if all files were skipped -- even failed
+ files are counted in manifest.all */
+ if (do_external_import && (manifest.all.size == 0))
+ return;
+
+ ImportUI.report_manifest(manifest, true);
+}
+
+void editing_exec(string filename) {
+ File initial_file = File.new_for_commandline_arg(filename);
+
+ // preconfigure units
+ Direct.preconfigure(initial_file);
+ Db.preconfigure(null);
+
+ // initialize units for direct-edit mode
+ try {
+ Direct.app_init();
+ } catch (Error err) {
+ AppWindow.panic(err.message);
+
+ return;
+ }
+
+ // init modules direct-editing relies on
+ DesktopIntegration.init();
+
+ // TODO: At some point in the future, to support mixed-media in direct-edit mode, we will
+ // refactor DirectPhotoSourceCollection to be a MediaSourceCollection. At that point,
+ // we'll need to register DirectPhoto.global with the MediaCollectionRegistry
+
+ DirectWindow direct_window = new DirectWindow(initial_file);
+ direct_window.show_all();
+
+ debug("%lf seconds to Gtk.main()", startup_timer.elapsed());
+
+ Application.get_instance().start();
+
+ DesktopIntegration.terminate();
+
+ // terminate units for direct-edit mode
+ Direct.app_terminate();
+}
+
+namespace CommandlineOptions {
+
+bool no_startup_progress = false;
+string data_dir = null;
+bool show_version = false;
+bool no_runtime_monitoring = 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 terminator = { null, 0, 0, 0, null, null, null };
+ entries += terminator;
+
+ return entries;
+}
+
+}
+
+void main(string[] args) {
+ // Call AppDirs init *before* calling Gtk.init_with_args, as it will strip the
+ // exec file from the array
+ AppDirs.init(args[0]);
+
+ // 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.
+ GExiv2.initialize();
+
+ // 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_exec_dir().get_path() +
+ "/misc", true);
+ }
+
+ // init GTK (valac has already called g_threads_init())
+ try {
+ Gtk.init_with_args(ref args, _("[FILE]"), CommandlineOptions.get_options(),
+ Resources.APP_GETTEXT_PACKAGE);
+ } catch (Error e) {
+ print(e.message + "\n");
+ print(_("Run '%s --help' to see a full list of available command line options.\n"), args[0]);
+ AppDirs.terminate();
+ return;
+ }
+
+ if (CommandlineOptions.show_version) {
+ if (Resources.GIT_VERSION != null)
+ print("%s %s (%s)\n", Resources.APP_TITLE, Resources.APP_VERSION, Resources.GIT_VERSION);
+ else
+ print("%s %s\n", Resources.APP_TITLE, Resources.APP_VERSION);
+
+ 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
+
+ // walk command-line arguments for camera mounts or filename for direct editing ... only one
+ // filename supported for now, so take the first one and drop the rest ... note that URIs for
+ // filenames are currently not permitted, to differentiate between mount points
+ string[] mounts = new string[0];
+ string filename = null;
+
+ for (int ctr = 1; ctr < args.length; ctr++) {
+ string arg = args[ctr];
+
+ if (LibraryWindow.is_mount_uri_supported(arg)) {
+ mounts += arg;
+ } else if (is_string_empty(filename) && !arg.contains("://")) {
+ filename = arg;
+ }
+ }
+
+ Debug.init(is_string_empty(filename) ? Debug.LIBRARY_PREFIX : Debug.VIEWER_PREFIX);
+
+ if (Resources.GIT_VERSION != null)
+ message("Shotwell %s %s (%s)",
+ is_string_empty(filename) ? Resources.APP_LIBRARY_ROLE : Resources.APP_DIRECT_ROLE,
+ Resources.APP_VERSION, Resources.GIT_VERSION);
+ else
+ message("Shotwell %s %s",
+ is_string_empty(filename) ? Resources.APP_LIBRARY_ROLE : Resources.APP_DIRECT_ROLE,
+ Resources.APP_VERSION);
+
+ // Have a filename here? If so, configure ourselves for direct
+ // mode, otherwise, default to library mode.
+ Application.init(!is_string_empty(filename));
+
+ // set custom data directory if it's been supplied
+ if (CommandlineOptions.data_dir != null)
+ AppDirs.set_data_dir(CommandlineOptions.data_dir);
+ else
+ AppDirs.try_migrate_data();
+
+ // Verify the private data directory before continuing
+ AppDirs.verify_data_dir();
+ AppDirs.verify_cache_dir();
+
+ // init internationalization with the default system locale
+ InternationalSupport.init(Resources.APP_GETTEXT_PACKAGE, args);
+
+ startup_timer = new Timer();
+ startup_timer.start();
+
+ // set up GLib environment
+ GLib.Environment.set_application_name(Resources.APP_TITLE);
+
+ // in both the case of running as the library or an editor, Resources is always
+ // initialized
+ Resources.init();
+
+ // since it's possible for a mount name to be passed that's not supported (and hence an empty
+ // mount list), or for nothing to be on the command-line at all, only go to direct editing if a
+ // filename is spec'd
+ if (is_string_empty(filename))
+ library_exec(mounts);
+ else
+ editing_exec(filename);
+
+ // terminate mode-inspecific modules
+ Resources.terminate();
+ Application.terminate();
+ Debug.terminate();
+ AppDirs.terminate();
+
+ // Back up db on successful run so we have something to roll back to if
+ // it gets corrupted in the next session. Don't do this if another shotwell
+ // is open or if we're in direct mode.
+ if (is_string_empty(filename) && !was_already_running) {
+ string orig_path = AppDirs.get_data_subdir("data").get_child("photo.db").get_path();
+ string backup_path = orig_path + ".bak";
+ string cmdline = "cp " + orig_path + " " + backup_path;
+ Posix.system(cmdline);
+ Posix.system("sync");
+ }
+}
+
diff --git a/src/photos/BmpSupport.vala b/src/photos/BmpSupport.vala
new file mode 100644
index 0000000..546bed2
--- /dev/null
+++ b/src/photos/BmpSupport.vala
@@ -0,0 +1,184 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+namespace Photos {
+
+class BmpFileFormatProperties : PhotoFileFormatProperties {
+ private static string[] KNOWN_EXTENSIONS = { "bmp", "dib" };
+ private static string[] KNOWN_MIME_TYPES = { GPhoto.MIME.BMP };
+
+ private static BmpFileFormatProperties instance = null;
+
+ public static void init() {
+ instance = new BmpFileFormatProperties();
+ }
+
+ public static BmpFileFormatProperties get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormat get_file_format() {
+ return PhotoFileFormat.BMP;
+ }
+
+ public override PhotoFileFormatFlags get_flags() {
+ return PhotoFileFormatFlags.NONE;
+ }
+
+ public override string get_user_visible_name() {
+ return _("BMP");
+ }
+
+ 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 BmpSniffer : GdkSniffer {
+ private const uint8[] MAGIC_SEQUENCE = { 0x42, 0x4D };
+
+ public BmpSniffer(File file, PhotoFileSniffer.Options options) {
+ base (file, options);
+ }
+
+ private static bool is_bmp_file(File file) throws Error {
+ FileInputStream instream = file.read(null);
+
+ uint8[] file_lead_sequence = new uint8[MAGIC_SEQUENCE.length];
+
+ instream.read(file_lead_sequence, null);
+
+ for (int i = 0; i < MAGIC_SEQUENCE.length; i++) {
+ if (file_lead_sequence[i] != MAGIC_SEQUENCE[i])
+ return false;
+ }
+
+ return true;
+ }
+
+ public override DetectedPhotoInformation? sniff() throws Error {
+ if (!is_bmp_file(file))
+ return null;
+
+ DetectedPhotoInformation? detected = base.sniff();
+ if (detected == null)
+ return null;
+
+ return (detected.file_format == PhotoFileFormat.BMP) ? detected : null;
+ }
+}
+
+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 {
+ public BmpWriter(string filepath) {
+ base (filepath, PhotoFileFormat.BMP);
+ }
+
+ public override void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error {
+ pixbuf.save(get_filepath(), "bmp", null);
+ }
+}
+
+public class BmpMetadataWriter : PhotoFileMetadataWriter {
+ public BmpMetadataWriter(string filepath) {
+ base (filepath, PhotoFileFormat.BMP);
+ }
+
+ public override void write_metadata(PhotoMetadata metadata) throws Error {
+ // Metadata writing isn't supported for .BMPs, so this is a no-op.
+ }
+}
+
+public class BmpFileFormatDriver : PhotoFileFormatDriver {
+ private static BmpFileFormatDriver instance = null;
+
+ public static void init() {
+ instance = new BmpFileFormatDriver();
+ BmpFileFormatProperties.init();
+ }
+
+ public static BmpFileFormatDriver get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormatProperties get_properties() {
+ return BmpFileFormatProperties.get_instance();
+ }
+
+ public override PhotoFileReader create_reader(string filepath) {
+ return new BmpReader(filepath);
+ }
+
+ public override bool can_write_image() {
+ return true;
+ }
+
+ public override bool can_write_metadata() {
+ return false;
+ }
+
+ public override PhotoFileWriter? create_writer(string filepath) {
+ return new BmpWriter(filepath);
+ }
+
+ public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) {
+ return new BmpMetadataWriter(filepath);
+ }
+
+ public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
+ return new BmpSniffer(file, options);
+ }
+
+ public override PhotoMetadata create_metadata() {
+ return new PhotoMetadata();
+ }
+}
+
+}
diff --git a/src/photos/GRaw.vala b/src/photos/GRaw.vala
new file mode 100644
index 0000000..915a861
--- /dev/null
+++ b/src/photos/GRaw.vala
@@ -0,0 +1,307 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace GRaw {
+
+public const double HD_POWER = 2.222;
+public const double HD_SLOPE = 4.5;
+
+public const double SRGB_POWER = 2.4;
+public const double SRGB_SLOPE = 12.92;
+
+public enum Colorspace {
+ RAW = 0,
+ SRGB = 1,
+ ADOBE = 2,
+ WIDE = 3,
+ PROPHOTO = 4,
+ XYZ = 5
+}
+
+public errordomain Exception {
+ UNSPECIFIED,
+ UNSUPPORTED_FILE,
+ NONEXISTANT_IMAGE,
+ OUT_OF_ORDER_CALL,
+ NO_THUMBNAIL,
+ UNSUPPORTED_THUMBNAIL,
+ OUT_OF_MEMORY,
+ DATA_ERROR,
+ IO_ERROR,
+ CANCELLED_BY_CALLBACK,
+ BAD_CROP,
+ SYSTEM_ERROR
+}
+
+public enum Flip {
+ FROM_SOURCE = -1,
+ NONE = 0,
+ UPSIDE_DOWN = 3,
+ COUNTERCLOCKWISE = 5,
+ CLOCKWISE = 6
+}
+
+public enum FujiRotate {
+ USE = -1,
+ DONT_USE = 0
+}
+
+public enum HighlightMode {
+ CLIP = 0,
+ UNCLIP = 1,
+ BLEND = 2,
+ REBUILD = 3
+}
+
+public enum InterpolationQuality {
+ LINEAR = 0,
+ VNG = 1,
+ PPG = 2,
+ AHD = 3
+}
+
+public class ProcessedImage {
+ private LibRaw.ProcessedImage image;
+ private Gdk.Pixbuf pixbuf = null;
+
+ public ushort width {
+ get {
+ return image.width;
+ }
+ }
+
+ public ushort height {
+ get {
+ return image.height;
+ }
+ }
+
+ public ushort colors {
+ get {
+ return image.colors;
+ }
+ }
+
+ public ushort bits {
+ get {
+ return image.bits;
+ }
+ }
+
+ public uint8* data {
+ get {
+ return image.data;
+ }
+ }
+
+ public uint data_size {
+ get {
+ return image.data_size;
+ }
+ }
+
+ public ProcessedImage(LibRaw.Processor proc) throws Exception {
+ LibRaw.Result result = LibRaw.Result.SUCCESS;
+ image = proc.make_mem_image(ref result);
+ throw_exception("ProcessedImage", result);
+ assert(image != null);
+
+ // A regular mem image comes back with raw RGB data ready for pixbuf (data buffer is shared
+ // between the ProcessedImage and the Gdk.Pixbuf)
+ pixbuf = new Gdk.Pixbuf.with_unowned_data(image.data, Gdk.Colorspace.RGB, false, image.bits,
+ image.width, image.height, image.width * image.colors, null);
+ }
+
+ public ProcessedImage.from_thumb(LibRaw.Processor proc) throws Exception {
+ LibRaw.Result result = LibRaw.Result.SUCCESS;
+ image = proc.make_mem_thumb(ref result);
+ throw_exception("ProcessedImage.from_thumb", result);
+ assert(image != null);
+
+ // A mem thumb comes back as the raw bytes from the data segment in the file -- this needs
+ // to be decoded before being useful. This will throw an error if the format is not
+ // supported
+ try {
+ pixbuf = new Gdk.Pixbuf.from_stream(new MemoryInputStream.from_data(image.data, null),
+ null);
+ } catch (Error err) {
+ throw new Exception.UNSUPPORTED_THUMBNAIL(err.message);
+ }
+
+ // fix up the ProcessedImage fields (which are unset when decoding the thumb)
+ image.width = (ushort) pixbuf.width;
+ image.height = (ushort) pixbuf.height;
+ image.colors = (ushort) pixbuf.n_channels;
+ image.bits = (ushort) pixbuf.bits_per_sample;
+ }
+
+ // This method returns a copy of a pixbuf representing the ProcessedImage.
+ public Gdk.Pixbuf get_pixbuf_copy() {
+ return pixbuf.copy();
+ }
+}
+
+public class Processor {
+ public LibRaw.OutputParams* output_params {
+ get {
+ return &proc.params;
+ }
+ }
+
+ private LibRaw.Processor proc;
+
+ public Processor(LibRaw.Options options = LibRaw.Options.NONE) {
+ proc = new LibRaw.Processor(options);
+ }
+
+ public void adjust_sizes_info_only() throws Exception {
+ throw_exception("adjust_sizes_info_only", proc.adjust_sizes_info_only());
+ }
+
+ public unowned LibRaw.ImageOther get_image_other() {
+ return proc.get_image_other();
+ }
+
+ public unowned LibRaw.ImageParams get_image_params() {
+ return proc.get_image_params();
+ }
+
+ public unowned LibRaw.ImageSizes get_sizes() {
+ return proc.get_sizes();
+ }
+
+ public unowned LibRaw.Thumbnail get_thumbnail() {
+ return proc.get_thumbnail();
+ }
+
+ public ProcessedImage make_mem_image() throws Exception {
+ return new ProcessedImage(proc);
+ }
+
+ public ProcessedImage make_thumb_image() throws Exception {
+ return new ProcessedImage.from_thumb(proc);
+ }
+
+ public void open_buffer(uint8[] buffer) throws Exception {
+ throw_exception("open_buffer", proc.open_buffer(buffer));
+ }
+
+ public void open_file(string filename) throws Exception {
+ throw_exception("open_file", proc.open_file(filename));
+ }
+
+ public void process() throws Exception {
+ throw_exception("process", proc.process());
+ }
+
+ public void ppm_tiff_writer(string filename) throws Exception {
+ throw_exception("ppm_tiff_writer", proc.ppm_tiff_writer(filename));
+ }
+
+ public void thumb_writer(string filename) throws Exception {
+ throw_exception("thumb_writer", proc.thumb_writer(filename));
+ }
+
+ public void recycle() {
+ proc.recycle();
+ }
+
+ public void unpack() throws Exception {
+ throw_exception("unpack", proc.unpack());
+ }
+
+ public void unpack_thumb() throws Exception {
+ throw_exception("unpack_thumb", proc.unpack_thumb());
+ }
+
+ // This configures output_params for reasonable settings for turning a RAW image into an
+ // RGB ProcessedImage suitable for display. Tweaks can occur after this call and before
+ // process().
+ public void configure_for_rgb_display(bool half_size) {
+ // Fields in comments are left to their defaults and/or should be modified by the caller.
+ // These fields are set to reasonable defaults by libraw.
+
+ // greybox
+ output_params->set_chromatic_aberrations(1.0, 1.0);
+ output_params->set_gamma_curve(GRaw.SRGB_POWER, GRaw.SRGB_SLOPE);
+ // user_mul
+ // shot_select
+ // multi_out
+ output_params->bright = 1.0f;
+ // threshold
+ output_params->half_size = half_size;
+ // four_color_rgb
+ output_params->highlight = GRaw.HighlightMode.CLIP;
+ output_params->use_auto_wb = true;
+ output_params->use_camera_wb = true;
+ output_params->use_camera_matrix = true;
+ output_params->output_color = GRaw.Colorspace.SRGB;
+ // output_profile
+ // camera_profile
+ // bad_pixels
+ // dark_frame
+ output_params->output_bps = 8;
+ // output_tiff
+ output_params->user_flip = GRaw.Flip.FROM_SOURCE;
+ output_params->user_qual = GRaw.InterpolationQuality.PPG;
+ // user_black
+ // user_sat
+ // med_passes
+ output_params->no_auto_bright = true;
+ output_params->auto_bright_thr = 0.01f;
+ output_params->use_fuji_rotate = GRaw.FujiRotate.USE;
+ }
+}
+
+private void throw_exception(string caller, LibRaw.Result result) throws Exception {
+ if (result == LibRaw.Result.SUCCESS)
+ return;
+ else if (result > 0)
+ throw new Exception.SYSTEM_ERROR("%s: System error %d: %s", caller, (int) result, strerror(result));
+
+ string msg = "%s: %s".printf(caller, result.to_string());
+
+ switch (result) {
+ case LibRaw.Result.UNSPECIFIED_ERROR:
+ throw new Exception.UNSPECIFIED(msg);
+
+ case LibRaw.Result.FILE_UNSUPPORTED:
+ throw new Exception.UNSUPPORTED_FILE(msg);
+
+ case LibRaw.Result.REQUEST_FOR_NONEXISTENT_IMAGE:
+ throw new Exception.NONEXISTANT_IMAGE(msg);
+
+ case LibRaw.Result.OUT_OF_ORDER_CALL:
+ throw new Exception.OUT_OF_ORDER_CALL(msg);
+
+ case LibRaw.Result.NO_THUMBNAIL:
+ throw new Exception.NO_THUMBNAIL(msg);
+
+ case LibRaw.Result.UNSUPPORTED_THUMBNAIL:
+ throw new Exception.UNSUPPORTED_THUMBNAIL(msg);
+
+ case LibRaw.Result.UNSUFFICIENT_MEMORY:
+ throw new Exception.OUT_OF_MEMORY(msg);
+
+ case LibRaw.Result.DATA_ERROR:
+ throw new Exception.DATA_ERROR(msg);
+
+ case LibRaw.Result.IO_ERROR:
+ throw new Exception.IO_ERROR(msg);
+
+ case LibRaw.Result.CANCELLED_BY_CALLBACK:
+ throw new Exception.CANCELLED_BY_CALLBACK(msg);
+
+ case LibRaw.Result.BAD_CROP:
+ throw new Exception.BAD_CROP(msg);
+
+ default:
+ return;
+ }
+}
+
+}
+
diff --git a/src/photos/GdkSupport.vala b/src/photos/GdkSupport.vala
new file mode 100644
index 0000000..4ca0893
--- /dev/null
+++ b/src/photos/GdkSupport.vala
@@ -0,0 +1,129 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 GdkReader : PhotoFileReader {
+ public GdkReader(string filepath, PhotoFileFormat file_format) {
+ base (filepath, file_format);
+ }
+
+ 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 {
+ return new Gdk.Pixbuf.from_file(get_filepath());
+ }
+
+ 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);
+ }
+}
+
+public abstract class GdkSniffer : PhotoFileSniffer {
+ private DetectedPhotoInformation detected = null;
+ private bool size_ready = false;
+ private bool area_prepared = false;
+
+ public GdkSniffer(File file, PhotoFileSniffer.Options options) {
+ base (file, options);
+ }
+
+ public override DetectedPhotoInformation? sniff() throws Error {
+ detected = new DetectedPhotoInformation();
+
+ Gdk.PixbufLoader pixbuf_loader = new Gdk.PixbufLoader();
+ pixbuf_loader.size_prepared.connect(on_size_prepared);
+ pixbuf_loader.area_prepared.connect(on_area_prepared);
+
+ // 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) {
+ // no metadata detected
+ detected.metadata = null;
+ }
+
+ if (calc_md5 && detected.metadata != null) {
+ uint8[]? flattened_sans_thumbnail = detected.metadata.flatten_exif(false);
+ if (flattened_sans_thumbnail != null && flattened_sans_thumbnail.length > 0)
+ detected.exif_md5 = md5_binary(flattened_sans_thumbnail, flattened_sans_thumbnail.length);
+
+ uint8[]? flattened_thumbnail = detected.metadata.flatten_exif_preview();
+ if (flattened_thumbnail != null && flattened_thumbnail.length > 0)
+ detected.thumbnail_md5 = md5_binary(flattened_thumbnail, flattened_thumbnail.length);
+ }
+
+ // 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);
+ for (;;) {
+ size_t bytes_read = fins.read(buffer, null);
+ if (bytes_read <= 0)
+ break;
+
+ count += bytes_read;
+
+ if (calc_md5)
+ md5_checksum.update(buffer, bytes_read);
+
+ // keep parsing the image until the size is discovered
+ if (!size_ready || !area_prepared)
+ pixbuf_loader.write(buffer[0:bytes_read]);
+
+ // if not searching for anything else, exit
+ if (!calc_md5 && size_ready && area_prepared)
+ break;
+ }
+
+ // PixbufLoader throws an error if you close it with an incomplete image, so trap this
+ try {
+ pixbuf_loader.close();
+ } catch (Error err) {
+ }
+
+ if (fins != null)
+ fins.close(null);
+
+ if (calc_md5)
+ detected.md5 = md5_checksum.get_string();
+
+ return detected;
+ }
+
+ private void on_size_prepared(Gdk.PixbufLoader loader, int width, int height) {
+ detected.image_dim = Dimensions(width, height);
+ size_ready = true;
+ }
+
+ private void on_area_prepared(Gdk.PixbufLoader pixbuf_loader) {
+ 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();
+ detected.file_format = PhotoFileFormat.from_pixbuf_name(detected.format_name);
+
+ area_prepared = true;
+ }
+}
+
diff --git a/src/photos/JfifSupport.vala b/src/photos/JfifSupport.vala
new file mode 100644
index 0000000..12ac80a
--- /dev/null
+++ b/src/photos/JfifSupport.vala
@@ -0,0 +1,236 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class JfifFileFormatDriver : PhotoFileFormatDriver {
+ private static JfifFileFormatDriver instance = null;
+
+ public static void init() {
+ instance = new JfifFileFormatDriver();
+ JfifFileFormatProperties.init();
+ }
+
+ public static JfifFileFormatDriver get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormatProperties get_properties() {
+ return JfifFileFormatProperties.get_instance();
+ }
+
+ public override PhotoFileReader create_reader(string filepath) {
+ return new JfifReader(filepath);
+ }
+
+ public override PhotoMetadata create_metadata() {
+ return new PhotoMetadata();
+ }
+
+ 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 JfifWriter(filepath);
+ }
+
+ public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) {
+ return new JfifMetadataWriter(filepath);
+ }
+
+ public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
+ return new JfifSniffer(file, options);
+ }
+}
+
+public class JfifFileFormatProperties : PhotoFileFormatProperties {
+ private static string[] KNOWN_EXTENSIONS = {
+ "jpg", "jpeg", "jpe"
+ };
+
+ private static string[] KNOWN_MIME_TYPES = {
+ "image/jpeg"
+ };
+
+ private static JfifFileFormatProperties instance = null;
+
+ public static void init() {
+ instance = new JfifFileFormatProperties();
+ }
+
+ public static JfifFileFormatProperties get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormat get_file_format() {
+ return PhotoFileFormat.JFIF;
+ }
+
+ public override PhotoFileFormatFlags get_flags() {
+ return PhotoFileFormatFlags.NONE;
+ }
+
+ public override string get_default_extension() {
+ return "jpg";
+ }
+
+ public override string get_user_visible_name() {
+ return _("JPEG");
+ }
+
+ 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 JfifSniffer : GdkSniffer {
+ public JfifSniffer(File file, PhotoFileSniffer.Options options) {
+ base (file, options);
+ }
+
+ public override DetectedPhotoInformation? sniff() throws Error {
+ if (!Jpeg.is_jpeg(file))
+ return null;
+
+ DetectedPhotoInformation? detected = base.sniff();
+ if (detected == null)
+ return null;
+
+ return (detected.file_format == PhotoFileFormat.JFIF) ? detected : null;
+ }
+}
+
+public class JfifReader : GdkReader {
+ public JfifReader(string filepath) {
+ base (filepath, PhotoFileFormat.JFIF);
+ }
+}
+
+public class JfifWriter : PhotoFileWriter {
+ public JfifWriter(string filepath) {
+ base (filepath, PhotoFileFormat.JFIF);
+ }
+
+ public override void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error {
+ pixbuf.save(get_filepath(), "jpeg", "quality", quality.get_pct_text());
+ }
+}
+
+public class JfifMetadataWriter : PhotoFileMetadataWriter {
+ public JfifMetadataWriter(string filepath) {
+ base (filepath, PhotoFileFormat.JFIF);
+ }
+
+ public override void write_metadata(PhotoMetadata metadata) throws Error {
+ metadata.write_to_file(get_file());
+ }
+}
+
+namespace Jpeg {
+ public const uint8 MARKER_PREFIX = 0xFF;
+
+ public enum Marker {
+ // Could also be 0xFF according to spec
+ INVALID = 0x00,
+
+ SOI = 0xD8,
+ EOI = 0xD9,
+
+ APP0 = 0xE0,
+ APP1 = 0xE1;
+
+ public uint8 get_byte() {
+ return (uint8) this;
+ }
+ }
+
+ public enum Quality {
+ LOW = 50,
+ MEDIUM = 75,
+ HIGH = 90,
+ MAXIMUM = 100;
+
+ public int get_pct() {
+ return (int) this;
+ }
+
+ public string get_pct_text() {
+ return "%d".printf((int) this);
+ }
+
+ public static Quality[] get_all() {
+ return { LOW, MEDIUM, HIGH, MAXIMUM };
+ }
+
+ public string? to_string() {
+ switch (this) {
+ case LOW:
+ return _("Low (%d%%)").printf((int) this);
+
+ case MEDIUM:
+ return _("Medium (%d%%)").printf((int) this);
+
+ case HIGH:
+ return _("High (%d%%)").printf((int) this);
+
+ case MAXIMUM:
+ return _("Maximum (%d%%)").printf((int) this);
+ }
+
+ warn_if_reached();
+
+ return null;
+ }
+ }
+
+ public bool is_jpeg(File file) throws Error {
+ FileInputStream fins = file.read(null);
+
+ Marker marker;
+ int segment_length = read_marker(fins, out marker);
+
+ // for now, merely checking for SOI
+ return (marker == Marker.SOI) && (segment_length == 0);
+ }
+
+ private int read_marker(FileInputStream fins, 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;
+
+ marker = (Jpeg.Marker) dins.read_byte();
+ if ((marker == Jpeg.Marker.SOI) || (marker == Jpeg.Marker.EOI)) {
+ // no length
+ return 0;
+ }
+
+ uint16 length = dins.read_uint16();
+ if (length < 2) {
+ debug("Invalid length %Xh at ofs %" + int64.FORMAT + "Xh", length, fins.tell() - 2);
+
+ return -1;
+ }
+
+ // account for two length bytes already read
+ return length - 2;
+ }
+}
+
diff --git a/src/photos/PhotoFileAdapter.vala b/src/photos/PhotoFileAdapter.vala
new file mode 100644
index 0000000..38b00ea
--- /dev/null
+++ b/src/photos/PhotoFileAdapter.vala
@@ -0,0 +1,112 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+//
+// PhotoFileAdapter
+//
+// PhotoFileAdapter (and its immediate children, PhotoFileReader and PhotoFileWriter) are drivers
+// hiding details of reading and writing image files and their metadata. They should keep
+// minimal state beyond the filename, if any stat at all. In particular, they should avoid caching
+// values, especially the readers, as writers may be created at any time and invalidate that
+// information, unless the readers monitor the file for these changes.
+//
+// PhotoFileAdapters should be entirely thread-safe. They are not, however, responsible for
+// atomicity on the filesystem.
+//
+
+public abstract class PhotoFileAdapter {
+ private string filepath;
+ private PhotoFileFormat file_format;
+ private File file = null;
+
+ public PhotoFileAdapter(string filepath, PhotoFileFormat file_format) {
+ this.filepath = filepath;
+ this.file_format = file_format;
+ }
+
+ public bool file_exists() {
+ return FileUtils.test(filepath, FileTest.IS_REGULAR);
+ }
+
+ public string get_filepath() {
+ return filepath;
+ }
+
+ public File get_file() {
+ File result;
+ lock (file) {
+ if (file == null)
+ file = File.new_for_path(filepath);
+
+ result = file;
+ }
+
+ return result;
+ }
+
+ public PhotoFileFormat get_file_format() {
+ return file_format;
+ }
+}
+
+//
+// PhotoFileReader
+//
+
+public abstract class PhotoFileReader : PhotoFileAdapter {
+ protected PhotoFileReader(string filepath, PhotoFileFormat file_format) {
+ base (filepath, file_format);
+ }
+
+ public PhotoFileWriter create_writer() throws PhotoFormatError {
+ return get_file_format().create_writer(get_filepath());
+ }
+
+ public PhotoFileMetadataWriter create_metadata_writer() throws PhotoFormatError {
+ return get_file_format().create_metadata_writer(get_filepath());
+ }
+
+ public abstract PhotoMetadata read_metadata() throws Error;
+
+ public abstract Gdk.Pixbuf unscaled_read() throws Error;
+
+ public virtual Gdk.Pixbuf scaled_read(Dimensions full, Dimensions scaled) throws Error {
+ return resize_pixbuf(unscaled_read(), scaled, Gdk.InterpType.BILINEAR);
+ }
+}
+
+//
+// PhotoFileWriter
+//
+
+public abstract class PhotoFileWriter : PhotoFileAdapter {
+ protected PhotoFileWriter(string filepath, PhotoFileFormat file_format) {
+ base (filepath, file_format);
+ }
+
+ public PhotoFileReader create_reader() {
+ return get_file_format().create_reader(get_filepath());
+ }
+
+ public abstract void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error;
+}
+
+//
+// PhotoFileMetadataWriter
+//
+
+public abstract class PhotoFileMetadataWriter : PhotoFileAdapter {
+ protected PhotoFileMetadataWriter(string filepath, PhotoFileFormat file_format) {
+ base (filepath, file_format);
+ }
+
+ public PhotoFileReader create_reader() {
+ return get_file_format().create_reader(get_filepath());
+ }
+
+ public abstract void write_metadata(PhotoMetadata metadata) throws Error;
+}
+
diff --git a/src/photos/PhotoFileFormat.vala b/src/photos/PhotoFileFormat.vala
new file mode 100644
index 0000000..926254d
--- /dev/null
+++ b/src/photos/PhotoFileFormat.vala
@@ -0,0 +1,410 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public errordomain PhotoFormatError {
+ READ_ONLY
+}
+
+//
+// PhotoFileFormat
+//
+
+namespace PhotoFileFormatData {
+ private static PhotoFileFormat[] writeable = null;
+ private static PhotoFileFormat[] image_writeable = null;
+ private static PhotoFileFormat[] metadata_writeable = null;
+
+ private delegate bool ApplicableTest(PhotoFileFormat format);
+
+ private PhotoFileFormat[] find_applicable(ApplicableTest test) {
+ PhotoFileFormat[] applicable = new PhotoFileFormat[0];
+ foreach (PhotoFileFormat format in PhotoFileFormat.get_supported()) {
+ if (test(format))
+ applicable += format;
+ }
+
+ return applicable;
+ }
+
+ public PhotoFileFormat[] get_writeable() {
+ if (writeable == null)
+ writeable = find_applicable((format) => { return format.can_write(); });
+
+ return writeable;
+ }
+
+ public static PhotoFileFormat[] get_image_writeable() {
+ if (image_writeable == null)
+ image_writeable = find_applicable((format) => { return format.can_write_image(); });
+
+ return image_writeable;
+ }
+
+ public static PhotoFileFormat[] get_metadata_writeable() {
+ if (metadata_writeable == null)
+ metadata_writeable = find_applicable((format) => { return format.can_write_metadata(); });
+
+ return metadata_writeable;
+ }
+}
+
+public enum PhotoFileFormat {
+ JFIF,
+ RAW,
+ PNG,
+ TIFF,
+ BMP,
+ 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 };
+ }
+
+ public static PhotoFileFormat[] get_writeable() {
+ return PhotoFileFormatData.get_writeable();
+ }
+
+ public static PhotoFileFormat[] get_image_writeable() {
+ return PhotoFileFormatData.get_image_writeable();
+ }
+
+ public static PhotoFileFormat[] get_metadata_writeable() {
+ return PhotoFileFormatData.get_metadata_writeable();
+ }
+
+ public static PhotoFileFormat get_by_basename_extension(string basename) {
+ string name, ext;
+ disassemble_filename(basename, out name, out ext);
+
+ if (is_string_empty(ext))
+ return UNKNOWN;
+
+ foreach (PhotoFileFormat file_format in get_supported()) {
+ if (file_format.get_driver().get_properties().is_recognized_extension(ext))
+ return file_format;
+ }
+
+ return UNKNOWN;
+ }
+
+ public static bool is_file_supported(File file) {
+ return is_basename_supported(file.get_basename());
+ }
+
+ public static bool is_basename_supported(string basename) {
+ string name, ext;
+ disassemble_filename(basename, out name, out ext);
+
+ if (is_string_empty(ext))
+ return false;
+
+ foreach (PhotoFileFormat format in get_supported()) {
+ if (format.get_driver().get_properties().is_recognized_extension(ext))
+ return true;
+ }
+
+ return false;
+ }
+
+ // Guaranteed to be writeable.
+ public static PhotoFileFormat get_system_default_format() {
+ return JFIF;
+ }
+
+ public static PhotoFileFormat get_by_file_extension(File file) {
+ return get_by_basename_extension(file.get_basename());
+ }
+
+ // These values are persisted in the database. DO NOT CHANGE THE INTEGER EQUIVALENTS.
+ public int serialize() {
+ switch (this) {
+ case JFIF:
+ return 0;
+
+ case RAW:
+ return 1;
+
+ case PNG:
+ return 2;
+
+ case TIFF:
+ return 3;
+
+ case BMP:
+ return 4;
+
+ case UNKNOWN:
+ default:
+ return -1;
+ }
+ }
+
+ // These values are persisted in the database. DO NOT CHANGE THE INTEGER EQUIVALENTS.
+ public static PhotoFileFormat unserialize(int value) {
+ switch (value) {
+ case 0:
+ return JFIF;
+
+ case 1:
+ return RAW;
+
+ case 2:
+ return PNG;
+
+ case 3:
+ return TIFF;
+
+ case 4:
+ return BMP;
+
+ default:
+ return UNKNOWN;
+ }
+ }
+
+ public static PhotoFileFormat from_gphoto_type(string type) {
+ switch (type) {
+ case GPhoto.MIME.JPEG:
+ return PhotoFileFormat.JFIF;
+
+ case GPhoto.MIME.RAW:
+ case GPhoto.MIME.CRW:
+ return PhotoFileFormat.RAW;
+
+ case GPhoto.MIME.PNG:
+ return PhotoFileFormat.PNG;
+
+ case GPhoto.MIME.TIFF:
+ return PhotoFileFormat.TIFF;
+
+ case GPhoto.MIME.BMP:
+ return PhotoFileFormat.BMP;
+
+ default:
+ // check file extension against those we support
+ return PhotoFileFormat.UNKNOWN;
+ }
+ }
+
+ // Converts GDK's pixbuf library's name to a PhotoFileFormat
+ public static PhotoFileFormat from_pixbuf_name(string name) {
+ switch (name) {
+ case "jpeg":
+ return PhotoFileFormat.JFIF;
+
+ case "png":
+ return PhotoFileFormat.PNG;
+
+ case "tiff":
+ return PhotoFileFormat.TIFF;
+
+ case "bmp":
+ return PhotoFileFormat.BMP;
+
+ default:
+ return PhotoFileFormat.UNKNOWN;
+ }
+ }
+
+ public void init() {
+ switch (this) {
+ case JFIF:
+ JfifFileFormatDriver.init();
+ break;
+
+ case RAW:
+ RawFileFormatDriver.init();
+ break;
+
+ case PNG:
+ PngFileFormatDriver.init();
+ break;
+
+ case TIFF:
+ Photos.TiffFileFormatDriver.init();
+ break;
+
+ case BMP:
+ Photos.BmpFileFormatDriver.init();
+ break;
+
+ default:
+ error("Unsupported file format %s", this.to_string());
+ }
+ }
+
+ private PhotoFileFormatDriver get_driver() {
+ switch (this) {
+ case JFIF:
+ return JfifFileFormatDriver.get_instance();
+
+ case RAW:
+ return RawFileFormatDriver.get_instance();
+
+ case PNG:
+ return PngFileFormatDriver.get_instance();
+
+ case TIFF:
+ return Photos.TiffFileFormatDriver.get_instance();
+
+ case BMP:
+ return Photos.BmpFileFormatDriver.get_instance();
+
+ default:
+ error("Unsupported file format %s", this.to_string());
+ }
+ }
+
+ public PhotoFileFormatProperties get_properties() {
+ return get_driver().get_properties();
+ }
+
+ // Supplied with a name, returns the name with the file format's default extension.
+ public string get_default_basename(string name) {
+ return "%s.%s".printf(name, get_properties().get_default_extension());
+ }
+
+ public PhotoFileReader create_reader(string filepath) {
+ return get_driver().create_reader(filepath);
+ }
+
+ // This means the image and its metadata are writeable.
+ public bool can_write() {
+ return can_write_image() && can_write_metadata();
+ }
+
+ public bool can_write_image() {
+ return get_driver().can_write_image();
+ }
+
+ public bool can_write_metadata() {
+ return get_driver().can_write_metadata();
+ }
+
+ public PhotoFileWriter create_writer(string filepath) throws PhotoFormatError {
+ PhotoFileWriter writer = get_driver().create_writer(filepath);
+ if (writer == null)
+ throw new PhotoFormatError.READ_ONLY("File format %s is read-only", this.to_string());
+
+ return writer;
+ }
+
+ public PhotoFileMetadataWriter create_metadata_writer(string filepath) throws PhotoFormatError {
+ PhotoFileMetadataWriter writer = get_driver().create_metadata_writer(filepath);
+ if (writer == null)
+ throw new PhotoFormatError.READ_ONLY("File format %s metadata is read-only", this.to_string());
+
+ return writer;
+ }
+
+ public PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
+ return get_driver().create_sniffer(file, options);
+ }
+
+ public PhotoMetadata create_metadata() {
+ return get_driver().create_metadata();
+ }
+
+ public string get_default_mime_type() {
+ return get_driver().get_properties().get_default_mime_type();
+ }
+
+ public string[] get_mime_types() {
+ return get_driver().get_properties().get_mime_types();
+ }
+
+ public static string[] get_editable_mime_types() {
+ string[] mime_types = {};
+
+ foreach (PhotoFileFormat file_format in PhotoFileFormat.get_supported()) {
+ foreach (string mime_type in file_format.get_mime_types())
+ mime_types += mime_type;
+ }
+
+ return mime_types;
+ }
+}
+
+//
+// PhotoFileFormatDriver
+//
+// Each supported file format is expected to have a PhotoFileFormatDriver that returns all possible
+// resources that are needed to operate on file of its particular type. It's expected that each
+// format subsystem will only create and cache a single instance of this driver, although it's
+// not required.
+//
+// Like the other elements in the PhotoFileFormat family, this class should be thread-safe.
+//
+
+public abstract class PhotoFileFormatDriver {
+ public abstract PhotoFileFormatProperties get_properties();
+
+ public abstract PhotoFileReader create_reader(string filepath);
+
+ public abstract PhotoMetadata create_metadata();
+
+ public abstract bool can_write_image();
+
+ public abstract bool can_write_metadata();
+
+ public abstract PhotoFileWriter? create_writer(string filepath);
+
+ public abstract PhotoFileMetadataWriter? create_metadata_writer(string filepath);
+
+ public abstract PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options);
+}
+
+//
+// PhotoFileFormatProperties
+//
+// Although each PhotoFileFormatProperties is expected to be largely static and immutable, these
+// classes should be thread-safe.
+//
+
+public enum PhotoFileFormatFlags {
+ NONE = 0x00000000,
+}
+
+public abstract class PhotoFileFormatProperties {
+ public abstract PhotoFileFormat get_file_format();
+
+ public abstract PhotoFileFormatFlags get_flags();
+
+ // Default implementation will search for ext in get_known_extensions(), assuming they are
+ // all stored in lowercase.
+ public virtual bool is_recognized_extension(string ext) {
+ return is_in_ci_array(ext, get_known_extensions());
+ }
+
+ public abstract string get_default_extension();
+
+ public abstract string[] get_known_extensions();
+
+ public abstract string get_default_mime_type();
+
+ public abstract string[] get_mime_types();
+
+ // returns the user-visible name of the file format -- this name is used in user interface
+ // strings whenever the file format needs to named. This name is not the same as the format
+ // enum value converted to a string. The format enum value is meaningful to developers and is
+ // constant across languages (e.g. "JFIF", "TGA") whereas the user-visible name is translatable
+ // and is meaningful to users (e.g. "JPEG", "Truevision TARGA")
+ public abstract string get_user_visible_name();
+
+ // Takes a given file and returns one with the file format's default extension, unless it
+ // already has one of the format's known extensions
+ public File convert_file_extension(File file) {
+ string name, ext;
+ disassemble_filename(file.get_basename(), out name, out ext);
+ if (ext != null && is_recognized_extension(ext))
+ return file;
+
+ return file.get_parent().get_child("%s.%s".printf(name, get_default_extension()));
+ }
+}
+
diff --git a/src/photos/PhotoFileSniffer.vala b/src/photos/PhotoFileSniffer.vala
new file mode 100644
index 0000000..8bd6711
--- /dev/null
+++ b/src/photos/PhotoFileSniffer.vala
@@ -0,0 +1,90 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class DetectedPhotoInformation {
+ public PhotoFileFormat file_format = PhotoFileFormat.UNKNOWN;
+ public PhotoMetadata? metadata = null;
+ public string? md5 = null;
+ public string? exif_md5 = null;
+ public string? thumbnail_md5 = null;
+ public string? format_name = null;
+ public Dimensions image_dim = Dimensions();
+ public Gdk.Colorspace colorspace = Gdk.Colorspace.RGB;
+ public int channels = 0;
+ public int bits_per_channel = 0;
+}
+
+//
+// A PhotoFileSniffer is expected to examine the supplied file as efficiently as humanly possible
+// to detect (a) if it is of a file format supported by the particular sniffer, and (b) fill out
+// a DetectedPhotoInformation record and return it to the caller.
+//
+// The PhotoFileSniffer is not expected to cache information. It should return a fresh
+// DetectedPhotoInformation record each time.
+//
+// PhotoFileSniffer must be thread-safe. Like PhotoFileAdapters, it is not expected to guarantee
+// atomicity with respect to the filesystem.
+//
+
+public abstract class PhotoFileSniffer {
+ public enum Options {
+ GET_ALL = 0x00000000,
+ NO_MD5 = 0x00000001
+ }
+
+ protected File file;
+ protected Options options;
+ protected bool calc_md5;
+
+ public PhotoFileSniffer(File file, Options options) {
+ this.file = file;
+ this.options = options;
+
+ calc_md5 = (options & Options.NO_MD5) == 0;
+ }
+
+ public abstract DetectedPhotoInformation? sniff() throws Error;
+}
+
+//
+// PhotoFileInterrogator
+//
+// A PhotoFileInterrogator is merely an aggregator of PhotoFileSniffers. It will create sniffers
+// for each supported PhotoFileFormat and see if they recognize the file.
+//
+// The PhotoFileInterrogator is not thread-safe.
+//
+
+public class PhotoFileInterrogator {
+ private File file;
+ private PhotoFileSniffer.Options options;
+ private DetectedPhotoInformation? detected = null;
+
+ public PhotoFileInterrogator(File file,
+ PhotoFileSniffer.Options options = PhotoFileSniffer.Options.GET_ALL) {
+ this.file = file;
+ this.options = options;
+ }
+
+ // This should only be called after interrogate(). Will return null every time, otherwise.
+ // If called after interrogate and returns null, that indicates the file is not an image file.
+ public DetectedPhotoInformation? get_detected_photo_information() {
+ return detected;
+ }
+
+ public void interrogate() throws Error {
+ foreach (PhotoFileFormat file_format in PhotoFileFormat.get_supported()) {
+ PhotoFileSniffer sniffer = file_format.create_sniffer(file, options);
+ detected = sniffer.sniff();
+ if (detected != null) {
+ assert(detected.file_format == file_format);
+
+ break;
+ }
+ }
+ }
+}
+
diff --git a/src/photos/PhotoMetadata.vala b/src/photos/PhotoMetadata.vala
new file mode 100644
index 0000000..37804bf
--- /dev/null
+++ b/src/photos/PhotoMetadata.vala
@@ -0,0 +1,1169 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+//
+// PhotoMetadata
+//
+// PhotoMetadata is a wrapper class around gexiv2. The reasoning for this is (a) to facilitiate
+// interface changes to meet Shotwell's requirements without needing modifications of the library
+// itself, and (b) some requirements for this class (i.e. obtaining raw metadata) is not available
+// in gexiv2, and so must be done by hand.
+//
+// Although it's perceived that Exiv2 will remain Shotwell's metadata library of choice, this
+// may change in the future, and so this wrapper helps with that as well.
+//
+// There is no expectation of thread-safety in this class (yet).
+//
+// Tags come from Exiv2's naming scheme:
+// http://www.exiv2.org/metadata.html
+//
+
+public enum MetadataDomain {
+ UNKNOWN,
+ EXIF,
+ XMP,
+ IPTC
+}
+
+public class HierarchicalKeywordField {
+ public string field_name;
+ public string path_separator;
+ public bool wants_leading_separator;
+ public bool is_writeable;
+
+ public HierarchicalKeywordField(string field_name, string path_separator,
+ bool wants_leading_separator, bool is_writeable) {
+ this.field_name = field_name;
+ this.path_separator = path_separator;
+ this.wants_leading_separator = wants_leading_separator;
+ this.is_writeable = is_writeable;
+ }
+}
+
+public abstract class PhotoPreview {
+ private string name;
+ private Dimensions dimensions;
+ private uint32 size;
+ private string mime_type;
+ private string extension;
+
+ public PhotoPreview(string name, Dimensions dimensions, uint32 size, string mime_type, string extension) {
+ this.name = name;
+ this.dimensions = dimensions;
+ this.size = size;
+ this.mime_type = mime_type;
+ this.extension = extension;
+ }
+
+ public string get_name() {
+ return name;
+ }
+
+ public Dimensions get_pixel_dimensions() {
+ return dimensions;
+ }
+
+ public uint32 get_size() {
+ return size;
+ }
+
+ public string get_mime_type() {
+ return mime_type;
+ }
+
+ public string get_extension() {
+ return extension;
+ }
+
+ public abstract uint8[] flatten() throws Error;
+
+ public virtual Gdk.Pixbuf? get_pixbuf() throws Error {
+ uint8[] flattened = flatten();
+
+ // Need to create from stream or file for decode ... catch decode error and return null,
+ // different from an I/O error causing the problem
+ try {
+ return new Gdk.Pixbuf.from_stream(new MemoryInputStream.from_data(flattened, null),
+ null);
+ } catch (Error err) {
+ warning("Unable to decode thumbnail for %s: %s", name, err.message);
+
+ return null;
+ }
+ }
+}
+
+public class PhotoMetadata : MediaMetadata {
+ public enum SetOption {
+ ALL_DOMAINS,
+ ONLY_IF_DOMAIN_PRESENT,
+ AT_LEAST_DEFAULT_DOMAIN
+ }
+
+ private const PrepareInputTextOptions PREPARE_STRING_OPTIONS =
+ PrepareInputTextOptions.INVALID_IS_NULL
+ | PrepareInputTextOptions.EMPTY_IS_NULL
+ | PrepareInputTextOptions.STRIP
+ | PrepareInputTextOptions.STRIP_CRLF
+ | PrepareInputTextOptions.NORMALIZE
+ | PrepareInputTextOptions.VALIDATE;
+
+ private class InternalPhotoPreview : PhotoPreview {
+ public PhotoMetadata owner;
+ public uint number;
+
+ public InternalPhotoPreview(PhotoMetadata owner, string name, uint number,
+ GExiv2.PreviewProperties props) {
+ base (name, Dimensions((int) props.get_width(), (int) props.get_height()),
+ props.get_size(), props.get_mime_type(), props.get_extension());
+
+ this.owner = owner;
+ this.number = number;
+ }
+
+ public override uint8[] flatten() throws Error {
+ unowned GExiv2.PreviewProperties?[] props = owner.exiv2.get_preview_properties();
+ assert(props != null && props.length > number);
+
+ return owner.exiv2.get_preview_image(props[number]).get_data();
+ }
+ }
+
+ private GExiv2.Metadata exiv2 = new GExiv2.Metadata();
+ private Exif.Data? exif = null;
+ string source_name = "<uninitialized>";
+
+ public PhotoMetadata() {
+ }
+
+ public override void read_from_file(File file) throws Error {
+ exiv2 = new GExiv2.Metadata();
+ exif = null;
+
+ exiv2.open_path(file.get_path());
+ exif = Exif.Data.new_from_file(file.get_path());
+ source_name = file.get_basename();
+ }
+
+ public void write_to_file(File file) throws Error {
+ exiv2.save_file(file.get_path());
+ }
+
+ public void read_from_buffer(uint8[] buffer, int length = 0) throws Error {
+ if (length <= 0)
+ length = buffer.length;
+
+ assert(buffer.length >= length);
+
+ exiv2 = new GExiv2.Metadata();
+ exif = null;
+
+ exiv2.open_buf(buffer, length);
+ exif = Exif.Data.new_from_data(buffer, length);
+ source_name = "<memory buffer %d bytes>".printf(length);
+ }
+
+ public void read_from_app1_segment(uint8[] buffer, int length = 0) throws Error {
+ if (length <= 0)
+ length = buffer.length;
+
+ assert(buffer.length >= length);
+
+ exiv2 = new GExiv2.Metadata();
+ exif = null;
+
+ exiv2.from_app1_segment(buffer, length);
+ exif = Exif.Data.new_from_data(buffer, length);
+ source_name = "<app1 segment %d bytes>".printf(length);
+ }
+
+ public static MetadataDomain get_tag_domain(string tag) {
+ if (GExiv2.Metadata.is_exif_tag(tag))
+ return MetadataDomain.EXIF;
+
+ if (GExiv2.Metadata.is_xmp_tag(tag))
+ return MetadataDomain.XMP;
+
+ if (GExiv2.Metadata.is_iptc_tag(tag))
+ return MetadataDomain.IPTC;
+
+ return MetadataDomain.UNKNOWN;
+ }
+
+ public bool has_domain(MetadataDomain domain) {
+ switch (domain) {
+ case MetadataDomain.EXIF:
+ return exiv2.has_exif();
+
+ case MetadataDomain.XMP:
+ return exiv2.has_xmp();
+
+ case MetadataDomain.IPTC:
+ return exiv2.has_iptc();
+
+ case MetadataDomain.UNKNOWN:
+ default:
+ return false;
+ }
+ }
+
+ public bool has_exif() {
+ return has_domain(MetadataDomain.EXIF);
+ }
+
+ public bool has_xmp() {
+ return has_domain(MetadataDomain.XMP);
+ }
+
+ public bool has_iptc() {
+ return has_domain(MetadataDomain.IPTC);
+ }
+
+ public bool can_write_to_domain(MetadataDomain domain) {
+ switch (domain) {
+ case MetadataDomain.EXIF:
+ return exiv2.get_supports_exif();
+
+ case MetadataDomain.XMP:
+ return exiv2.get_supports_xmp();
+
+ case MetadataDomain.IPTC:
+ return exiv2.get_supports_iptc();
+
+ case MetadataDomain.UNKNOWN:
+ default:
+ return false;
+ }
+ }
+
+ public bool can_write_exif() {
+ return can_write_to_domain(MetadataDomain.EXIF);
+ }
+
+ public bool can_write_xmp() {
+ return can_write_to_domain(MetadataDomain.XMP);
+ }
+
+ public bool can_write_iptc() {
+ return can_write_to_domain(MetadataDomain.IPTC);
+ }
+
+ public bool has_tag(string tag) {
+ return exiv2.has_tag(tag);
+ }
+
+ private Gee.Set<string> create_string_set(owned CompareDataFunc<string>? compare_func) {
+ // ternary doesn't work here
+ if (compare_func == null)
+ return new Gee.HashSet<string>();
+ else
+ return new Gee.TreeSet<string>((owned) compare_func);
+ }
+
+ public Gee.Collection<string>? get_tags(MetadataDomain domain,
+ owned CompareDataFunc<string>? compare_func = null) {
+ string[] tags = null;
+ switch (domain) {
+ case MetadataDomain.EXIF:
+ tags = exiv2.get_exif_tags();
+ break;
+
+ case MetadataDomain.XMP:
+ tags = exiv2.get_xmp_tags();
+ break;
+
+ case MetadataDomain.IPTC:
+ tags = exiv2.get_iptc_tags();
+ break;
+ }
+
+ if (tags == null || tags.length == 0)
+ return null;
+
+ Gee.Collection<string> collection = create_string_set((owned) compare_func);
+ foreach (string tag in tags)
+ collection.add(tag);
+
+ return collection;
+ }
+
+ public Gee.Collection<string> get_all_tags(
+ owned CompareDataFunc<string>? compare_func = null) {
+ Gee.Collection<string> all_tags = create_string_set((owned) compare_func);
+
+ Gee.Collection<string>? exif_tags = get_tags(MetadataDomain.EXIF);
+ if (exif_tags != null && exif_tags.size > 0)
+ all_tags.add_all(exif_tags);
+
+ Gee.Collection<string>? xmp_tags = get_tags(MetadataDomain.XMP);
+ if (xmp_tags != null && xmp_tags.size > 0)
+ all_tags.add_all(xmp_tags);
+
+ Gee.Collection<string>? iptc_tags = get_tags(MetadataDomain.IPTC);
+ if (iptc_tags != null && iptc_tags.size > 0)
+ all_tags.add_all(iptc_tags);
+
+ return all_tags.size > 0 ? all_tags : null;
+ }
+
+ public string? get_tag_label(string tag) {
+ return GExiv2.Metadata.get_tag_label(tag);
+ }
+
+ public string? get_tag_description(string tag) {
+ return GExiv2.Metadata.get_tag_description(tag);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ public string? get_first_string(string[] tags) {
+ foreach (string tag in tags) {
+ string? value = get_string(tag);
+ if (value != null)
+ return value;
+ }
+
+ return null;
+ }
+
+ public string? get_first_string_interpreted(string[] tags) {
+ foreach (string tag in tags) {
+ string? value = get_string_interpreted(tag);
+ if (value != null)
+ return value;
+ }
+
+ return null;
+ }
+
+ // Returns a List that has been filtered through a Set, so no duplicates will be returned.
+ //
+ // 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
+ 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);
+ }
+ }
+
+ return list.size > 0 ? list : null;
+ }
+
+ // Returns a List that has been filtered through a Set, so no duplicates will be found.
+ //
+ // 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
+ public Gee.List<string>? get_first_string_multiple(string[] tags) {
+ foreach (string tag in tags) {
+ Gee.List<string>? values = get_string_multiple(tag);
+ if (values != null && values.size > 0)
+ return values;
+ }
+
+ 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);
+ 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);
+ }
+
+ private delegate void SetGenericValue(string tag);
+
+ private void set_all_generic(string[] tags, SetOption option, SetGenericValue setter) {
+ bool written = false;
+ foreach (string tag in tags) {
+ if (option == SetOption.ALL_DOMAINS || has_domain(get_tag_domain(tag))) {
+ setter(tag);
+ written = true;
+ }
+ }
+
+ if (option == SetOption.AT_LEAST_DEFAULT_DOMAIN && !written && tags.length > 0) {
+ MetadataDomain default_domain = get_tag_domain(tags[0]);
+
+ // write at least the first one, as it's the default
+ setter(tags[0]);
+
+ // write the remainder, if they are of the same domain
+ for (int ctr = 1; ctr < tags.length; ctr++) {
+ if (get_tag_domain(tags[ctr]) == default_domain)
+ setter(tags[ctr]);
+ }
+ }
+ }
+
+ public void set_all_string(string[] tags, string value, SetOption option) {
+ set_all_generic(tags, option, (tag) => { set_string(tag, value); });
+ }
+
+ public void set_string_multiple(string tag, Gee.Collection<string> collection) {
+ string[] values = new string[0];
+ foreach (string value in collection) {
+ string? prepped = prepare_input_text(value, PREPARE_STRING_OPTIONS,-1);
+ if (prepped != null)
+ values += prepped;
+ else
+ warning("Unable to set string %s to %s: invalid UTF-8", value, tag);
+ }
+
+ if (values.length == 0)
+ 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.
+ 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);
+ }
+
+ public void set_all_string_multiple(string[] tags, Gee.Collection<string> values, SetOption option) {
+ set_all_generic(tags, option, (tag) => { set_string_multiple(tag, values); });
+ }
+
+ public bool get_long(string tag, out long value) {
+ if (!has_tag(tag)) {
+ value = 0;
+
+ return false;
+ }
+
+ value = exiv2.get_tag_long(tag);
+
+ return true;
+ }
+
+ public bool get_first_long(string[] tags, out long value) {
+ foreach (string tag in tags) {
+ if (get_long(tag, out value))
+ return true;
+ }
+
+ value = 0;
+
+ return false;
+ }
+
+ 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);
+ }
+
+ public void set_all_long(string[] tags, long value, SetOption option) {
+ set_all_generic(tags, option, (tag) => { set_long(tag, value); });
+ }
+
+ 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;
+ }
+
+ public bool get_first_rational(string[] tags, out MetadataRational rational) {
+ foreach (string tag in tags) {
+ if (get_rational(tag, out rational))
+ return true;
+ }
+
+ rational = MetadataRational(0, 0);
+
+ return false;
+ }
+
+ 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);
+ }
+ }
+
+ public void set_all_rational(string[] tags, MetadataRational rational, SetOption option) {
+ set_all_generic(tags, option, (tag) => { set_rational(tag, rational); });
+ }
+
+ public MetadataDateTime? get_date_time(string tag) {
+ string? value = get_string(tag);
+ if (value == null)
+ return null;
+
+ try {
+ switch (get_tag_domain(tag)) {
+ case MetadataDomain.XMP:
+ return new MetadataDateTime.from_xmp(value);
+
+ // TODO: IPTC date/time support (which is tricky here, because date/time values
+ // are stored in separate tags)
+ case MetadataDomain.IPTC:
+ return null;
+
+ case MetadataDomain.EXIF:
+ default:
+ return new MetadataDateTime.from_exif(value);
+ }
+ } catch (Error err) {
+ warning("Unable to read date/time %s from source %s: %s", tag, source_name, err.message);
+
+ return null;
+ }
+ }
+
+ public MetadataDateTime? get_first_date_time(string[] tags) {
+ foreach (string tag in tags) {
+ MetadataDateTime? date_time = get_date_time(tag);
+ if (date_time != null)
+ return date_time;
+ }
+
+ return null;
+ }
+
+ public void set_date_time(string tag, MetadataDateTime date_time) {
+ switch (get_tag_domain(tag)) {
+ case MetadataDomain.EXIF:
+ set_string(tag, date_time.get_exif_label());
+ break;
+
+ case MetadataDomain.XMP:
+ set_string(tag, date_time.get_xmp_label());
+ break;
+
+ // TODO: Support IPTC date/time (which are stored in separate tags)
+ case MetadataDomain.IPTC:
+ default:
+ warning("Cannot set date/time for %s from source %s: unsupported metadata domain %s", tag,
+ source_name, get_tag_domain(tag).to_string());
+ break;
+ }
+ }
+
+ public void set_all_date_time(string[] tags, MetadataDateTime date_time, SetOption option) {
+ set_all_generic(tags, option, (tag) => { set_date_time(tag, date_time); });
+ }
+
+ // Returns raw bytes of EXIF metadata, including signature and optionally the preview (if present).
+ public uint8[]? flatten_exif(bool include_preview) {
+ if (exif == null)
+ return null;
+
+ // save thumbnail to strip if no attachments requested (so it can be added back and
+ // deallocated automatically)
+ uchar *thumbnail = exif.data;
+ uint thumbnail_size = exif.size;
+ if (!include_preview) {
+ exif.data = null;
+ exif.size = 0;
+ }
+
+ uint8[]? flattened = null;
+
+ // save the struct to a buffer and copy into a Vala-friendly one
+ uchar *saved_data = null;
+ uint saved_size = 0;
+ exif.save_data(&saved_data, &saved_size);
+ if (saved_size > 0 && saved_data != null) {
+ flattened = new uint8[saved_size];
+ Memory.copy(flattened, saved_data, saved_size);
+
+ Exif.Mem.new_default().free(saved_data);
+ }
+
+ // restore thumbnail (this works in either case)
+ exif.data = thumbnail;
+ exif.size = thumbnail_size;
+
+ return flattened;
+ }
+
+ // Returns raw bytes of EXIF preview, if present
+ public uint8[]? flatten_exif_preview() {
+ uchar[] buffer;
+ return exiv2.get_exif_thumbnail(out buffer) ? buffer : null;
+ }
+
+ public uint get_preview_count() {
+ unowned GExiv2.PreviewProperties?[] props = exiv2.get_preview_properties();
+
+ return (props != null) ? props.length : 0;
+ }
+
+ // Previews are sorted from smallest to largest (width x height)
+ public PhotoPreview? get_preview(uint number) {
+ unowned GExiv2.PreviewProperties?[] props = exiv2.get_preview_properties();
+ if (props == null || props.length <= number)
+ return null;
+
+ return new InternalPhotoPreview(this, source_name, number, props[number]);
+ }
+
+ public void remove_exif_thumbnail() {
+ exiv2.erase_exif_thumbnail();
+ if (exif != null) {
+ Exif.Mem.new_default().free(exif.data);
+ exif.data = null;
+ exif.size = 0;
+ }
+ }
+
+ public void remove_tag(string tag) {
+ exiv2.clear_tag(tag);
+ }
+
+ public void remove_tags(string[] tags) {
+ foreach (string tag in tags)
+ remove_tag(tag);
+ }
+
+ public void clear_domain(MetadataDomain domain) {
+ switch (domain) {
+ case MetadataDomain.EXIF:
+ exiv2.clear_exif();
+ break;
+
+ case MetadataDomain.XMP:
+ exiv2.clear_xmp();
+ break;
+
+ case MetadataDomain.IPTC:
+ exiv2.clear_iptc();
+ break;
+ }
+ }
+
+ public void clear() {
+ exiv2.clear();
+ }
+
+ private static string[] DATE_TIME_TAGS = {
+ "Exif.Image.DateTime",
+ "Xmp.tiff.DateTime",
+ "Xmp.xmp.ModifyDate"
+ };
+
+ public MetadataDateTime? get_modification_date_time() {
+ return get_first_date_time(DATE_TIME_TAGS);
+ }
+
+ public void set_modification_date_time(MetadataDateTime? date_time,
+ SetOption option = SetOption.ALL_DOMAINS) {
+ if (date_time != null)
+ set_all_date_time(DATE_TIME_TAGS, date_time, option);
+ else
+ remove_tags(DATE_TIME_TAGS);
+ }
+
+ private static string[] EXPOSURE_DATE_TIME_TAGS = {
+ "Exif.Photo.DateTimeOriginal",
+ "Xmp.exif.DateTimeOriginal",
+ "Xmp.xmp.CreateDate",
+ "Exif.Photo.DateTimeDigitized",
+ "Xmp.exif.DateTimeDigitized",
+ "Exif.Image.DateTime"
+ };
+
+ public MetadataDateTime? get_exposure_date_time() {
+ return get_first_date_time(EXPOSURE_DATE_TIME_TAGS);
+ }
+
+ public void set_exposure_date_time(MetadataDateTime? date_time,
+ SetOption option = SetOption.ALL_DOMAINS) {
+ if (date_time != null)
+ set_all_date_time(EXPOSURE_DATE_TIME_TAGS, date_time, option);
+ else
+ remove_tags(EXPOSURE_DATE_TIME_TAGS);
+ }
+
+ private static string[] DIGITIZED_DATE_TIME_TAGS = {
+ "Exif.Photo.DateTimeDigitized",
+ "Xmp.exif.DateTimeDigitized"
+ };
+
+ public MetadataDateTime? get_digitized_date_time() {
+ return get_first_date_time(DIGITIZED_DATE_TIME_TAGS);
+ }
+
+ public void set_digitized_date_time(MetadataDateTime? date_time,
+ SetOption option = SetOption.ALL_DOMAINS) {
+ if (date_time != null)
+ set_all_date_time(DIGITIZED_DATE_TIME_TAGS, date_time, option);
+ else
+ remove_tags(DIGITIZED_DATE_TIME_TAGS);
+ }
+
+ public override MetadataDateTime? get_creation_date_time() {
+ MetadataDateTime? creation = get_exposure_date_time();
+ if (creation == null)
+ creation = get_digitized_date_time();
+
+ return creation;
+ }
+
+ private static string[] WIDTH_TAGS = {
+ "Exif.Photo.PixelXDimension",
+ "Xmp.exif.PixelXDimension",
+ "Xmp.tiff.ImageWidth",
+ "Xmp.exif.PixelXDimension"
+ };
+
+ public static string[] HEIGHT_TAGS = {
+ "Exif.Photo.PixelYDimension",
+ "Xmp.exif.PixelYDimension",
+ "Xmp.tiff.ImageHeight",
+ "Xmp.exif.PixelYDimension"
+ };
+
+ public Dimensions? get_pixel_dimensions() {
+ // walk the tag arrays concurrently, returning the dimensions of the first found pair
+ assert(WIDTH_TAGS.length == HEIGHT_TAGS.length);
+ for (int ctr = 0; ctr < WIDTH_TAGS.length; ctr++) {
+ // Can't turn this into a single if statement with an || bailing out due to this bug:
+ // https://bugzilla.gnome.org/show_bug.cgi?id=565385
+ long width;
+ if (!get_long(WIDTH_TAGS[ctr], out width))
+ continue;
+
+ long height;
+ if (!get_long(HEIGHT_TAGS[ctr], out height))
+ continue;
+
+ return Dimensions((int) width, (int) height);
+ }
+
+ return null;
+ }
+
+ public void set_pixel_dimensions(Dimensions? dim, SetOption option = SetOption.ALL_DOMAINS) {
+ if (dim != null) {
+ set_all_long(WIDTH_TAGS, dim.width, option);
+ set_all_long(HEIGHT_TAGS, dim.height, option);
+ } else {
+ remove_tags(WIDTH_TAGS);
+ remove_tags(HEIGHT_TAGS);
+ }
+ }
+
+ //
+ // A note regarding titles and descriptions:
+ //
+ // iPhoto stores its title in Iptc.Application2.ObjectName and its description in
+ // Iptc.Application2.Caption. Most others use .Caption for the title and another
+ // (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
+ //
+ // 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,
+ // the title/description are searched out from a list of standard tags.
+ //
+ // Exif.Image.ImageDescription seems to be abused, both in that iPhoto uses it as a multiline
+ // description and that some cameras insert their make & model information there (IN ALL CAPS,
+ // to really rub it in). We are ignoring the field until a compelling reason to support it
+ // is found.
+ //
+
+ private const string IPHOTO_TITLE_TAG = "Iptc.Application2.ObjectName";
+
+ private static string[] STANDARD_TITLE_TAGS = {
+ "Iptc.Application2.Caption",
+ "Xmp.dc.title",
+ "Iptc.Application2.Headline",
+ "Xmp.photoshop.Headline"
+ };
+
+ public override string? get_title() {
+ // using get_string_multiple()/get_first_string_multiple() because it's possible for
+ // multiple strings to be specified in XMP for different language codes, and want to
+ // retrieve only the first one (other get_string variants will return ugly strings like
+ //
+ // lang="x-default" Xyzzy
+ //
+ // but get_string_multiple will return a list of titles w/o language information
+ Gee.List<string>? titles = has_tag(IPHOTO_TITLE_TAG)
+ ? get_string_multiple(IPHOTO_TITLE_TAG)
+ : get_first_string_multiple(STANDARD_TITLE_TAGS);
+
+ // use the first string every time (assume it's default)
+ // TODO: We could get a list of all titles by their lang="<iso code>" and attempt to find
+ // the right one for the user's locale, but this does not seem to be a normal use case
+ string? title = (titles != null && titles.size > 0) ? titles[0] : null;
+
+ // strip out leading and trailing whitespace
+ if (title != null)
+ title = title.strip();
+
+ // check for \n and \r to prevent multiline titles, which have been spotted in the wild
+ return (!is_string_empty(title) && !title.contains("\n") && !title.contains("\r")) ?
+ title : null;
+ }
+
+ public void set_title(string? title, SetOption option = SetOption.ALL_DOMAINS) {
+ if (!is_string_empty(title)) {
+ if (has_tag(IPHOTO_TITLE_TAG))
+ set_string(IPHOTO_TITLE_TAG, title);
+ else
+ set_all_string(STANDARD_TITLE_TAGS, title, option);
+ } else {
+ remove_tags(STANDARD_TITLE_TAGS);
+ }
+ }
+
+ public override string? get_comment() {
+ return get_string_interpreted("Exif.Photo.UserComment", PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.STRIP_CRLF);
+ }
+
+ public void set_comment(string? comment) {
+ if (!is_string_empty(comment))
+ set_string("Exif.Photo.UserComment", comment, PrepareInputTextOptions.DEFAULT & ~PrepareInputTextOptions.STRIP_CRLF);
+ else
+ remove_tag("Exif.Photo.UserComment");
+ }
+
+ private static string[] KEYWORD_TAGS = {
+ "Xmp.dc.subject",
+ "Iptc.Application2.Keywords"
+ };
+
+ private static HierarchicalKeywordField[] HIERARCHICAL_KEYWORD_TAGS = {
+ // Xmp.lr.hierarchicalSubject should be writeable but isn't due to this bug
+ // in libexiv2: http://dev.exiv2.org/issues/784
+ new HierarchicalKeywordField("Xmp.lr.hierarchicalSubject", "|", false, false),
+ new HierarchicalKeywordField("Xmp.digiKam.TagsList", "/", false, true),
+ new HierarchicalKeywordField("Xmp.MicrosoftPhoto.LastKeywordXMP", "/", false, true)
+ };
+
+ public Gee.Set<string>? get_keywords(owned CompareDataFunc<string>? compare_func = null) {
+ Gee.Set<string> keywords = null;
+ foreach (string tag in KEYWORD_TAGS) {
+ Gee.Collection<string>? values = get_string_multiple(tag);
+ if (values != null && values.size > 0) {
+ if (keywords == null)
+ keywords = create_string_set((owned) compare_func);
+
+ foreach (string current_value in values)
+ keywords.add(HierarchicalTagUtilities.make_flat_tag_safe(current_value));
+ }
+ }
+
+ return (keywords != null && keywords.size > 0) ? keywords : null;
+ }
+
+ private void internal_set_hierarchical_keywords(HierarchicalTagIndex? index) {
+ foreach (HierarchicalKeywordField current_field in HIERARCHICAL_KEYWORD_TAGS)
+ remove_tag(current_field.field_name);
+
+ if (index == null)
+ return;
+
+ foreach (HierarchicalKeywordField current_field in HIERARCHICAL_KEYWORD_TAGS) {
+ if (!current_field.is_writeable)
+ continue;
+
+ Gee.Set<string> writeable_set = new Gee.TreeSet<string>();
+
+ foreach (string current_path in index.get_all_paths()) {
+ string writeable_path = current_path.replace(Tag.PATH_SEPARATOR_STRING,
+ current_field.path_separator);
+ if (!current_field.wants_leading_separator)
+ writeable_path = writeable_path.substring(1);
+
+ writeable_set.add(writeable_path);
+ }
+
+ set_string_multiple(current_field.field_name, writeable_set);
+ }
+ }
+
+ public void set_keywords(Gee.Collection<string>? keywords, SetOption option = SetOption.ALL_DOMAINS) {
+ HierarchicalTagIndex htag_index = new HierarchicalTagIndex();
+ Gee.Set<string> flat_keywords = new Gee.TreeSet<string>();
+
+ if (keywords != null) {
+ foreach (string keyword in keywords) {
+ if (keyword.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ Gee.Collection<string> path_components =
+ HierarchicalTagUtilities.enumerate_path_components(keyword);
+ foreach (string component in path_components)
+ htag_index.add_path(component, keyword);
+ } else {
+ flat_keywords.add(keyword);
+ }
+ }
+
+ flat_keywords.add_all(htag_index.get_all_tags());
+ }
+
+ if (keywords != null) {
+ set_all_string_multiple(KEYWORD_TAGS, flat_keywords, option);
+ internal_set_hierarchical_keywords(htag_index);
+ } else {
+ remove_tags(KEYWORD_TAGS);
+ internal_set_hierarchical_keywords(null);
+ }
+ }
+
+ public bool has_hierarchical_keywords() {
+ foreach (HierarchicalKeywordField field in HIERARCHICAL_KEYWORD_TAGS) {
+ Gee.Collection<string>? values = get_string_multiple(field.field_name);
+
+ if (values != null && values.size > 0)
+ return true;
+ }
+
+ return false;
+ }
+
+ public Gee.Set<string> get_hierarchical_keywords() {
+ assert(has_hierarchical_keywords());
+
+ Gee.Set<string> h_keywords = create_string_set(null);
+
+ foreach (HierarchicalKeywordField field in HIERARCHICAL_KEYWORD_TAGS) {
+ Gee.Collection<string>? values = get_string_multiple(field.field_name);
+
+ if (values == null || values.size < 1)
+ continue;
+
+ foreach (string current_value in values) {
+ string? canonicalized = HierarchicalTagUtilities.canonicalize(current_value,
+ field.path_separator);
+
+ if (canonicalized != null)
+ h_keywords.add(canonicalized);
+ }
+ }
+
+ return h_keywords;
+ }
+
+ public bool has_orientation() {
+ return exiv2.get_orientation() == GExiv2.Orientation.UNSPECIFIED;
+ }
+
+ // 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)
+ 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);
+ }
+
+ public bool get_gps(out double longitude, out string long_ref, out double latitude, out string lat_ref,
+ out double altitude) {
+ if (!exiv2.get_gps_info(out longitude, out latitude, out altitude)) {
+ long_ref = null;
+ lat_ref = null;
+
+ return false;
+ }
+
+ long_ref = get_string("Exif.GPSInfo.GPSLongitudeRef");
+ lat_ref = get_string("Exif.GPSInfo.GPSLatitudeRef");
+
+ return true;
+ }
+
+ public bool get_exposure(out MetadataRational exposure) {
+ return get_rational("Exif.Photo.ExposureTime", out exposure);
+ }
+
+ public string? get_exposure_string() {
+ MetadataRational exposure_time;
+ if (!get_rational("Exif.Photo.ExposureTime", out exposure_time))
+ return null;
+
+ if (!exposure_time.is_valid())
+ return null;
+
+ return get_string_interpreted("Exif.Photo.ExposureTime");
+ }
+
+ public bool get_iso(out long iso) {
+ bool fetched_ok = get_long("Exif.Photo.ISOSpeedRatings", out iso);
+
+ if (fetched_ok == false)
+ return false;
+
+ // lower boundary is original (ca. 1935) Kodachrome speed, the lowest ISO rated film ever
+ // manufactured; upper boundary is 4 x fastest high-speed digital camera speeds
+ if ((iso < 6) || (iso > 409600))
+ return false;
+
+ return true;
+ }
+
+ public string? get_iso_string() {
+ long iso;
+ if (!get_iso(out iso))
+ return null;
+
+ return get_string_interpreted("Exif.Photo.ISOSpeedRatings");
+ }
+
+ public bool get_aperture(out MetadataRational aperture) {
+ return get_rational("Exif.Photo.FNumber", out aperture);
+ }
+
+ public string? get_aperture_string(bool pango_formatted = false) {
+ MetadataRational aperture;
+ if (!get_aperture(out aperture))
+ return null;
+
+ double aperture_value = ((double) aperture.numerator) / ((double) aperture.denominator);
+ aperture_value = ((int) (aperture_value * 10.0)) / 10.0;
+
+ return (pango_formatted ? "<i>f</i>/" : "f/") +
+ ((aperture_value % 1 == 0) ? "%.0f" : "%.1f").printf(aperture_value);
+ }
+
+ public string? get_camera_make() {
+ return get_string_interpreted("Exif.Image.Make");
+ }
+
+ public string? get_camera_model() {
+ return get_string_interpreted("Exif.Image.Model");
+ }
+
+ public bool get_flash(out long flash) {
+ // Exif.Image.Flash does not work for some reason
+ return get_long("Exif.Photo.Flash", out flash);
+ }
+
+ public string? get_flash_string() {
+ // Exif.Image.Flash does not work for some reason
+ return get_string_interpreted("Exif.Photo.Flash");
+ }
+
+ public bool get_focal_length(out MetadataRational focal_length) {
+ return get_rational("Exif.Photo.FocalLength", out focal_length);
+ }
+
+ public string? get_focal_length_string() {
+ return get_string_interpreted("Exif.Photo.FocalLength");
+ }
+
+ private static string[] ARTIST_TAGS = {
+ "Exif.Image.Artist",
+ "Exif.Canon.OwnerName" // Custom tag used by Canon DSLR cameras
+ };
+
+ public string? get_artist() {
+ return get_first_string_interpreted(ARTIST_TAGS);
+ }
+
+ public string? get_copyright() {
+ return get_string_interpreted("Exif.Image.Copyright");
+ }
+
+ public string? get_software() {
+ return get_string_interpreted("Exif.Image.Software");
+ }
+
+ public void set_software(string software, string version) {
+ // always set this one, even if EXIF not present
+ set_string("Exif.Image.Software", "%s %s".printf(software, version));
+
+ if (has_iptc()) {
+ set_string("Iptc.Application2.Program", software);
+ set_string("Iptc.Application2.ProgramVersion", version);
+ }
+ }
+
+ public void remove_software() {
+ remove_tag("Exif.Image.Software");
+ remove_tag("Iptc.Application2.Program");
+ remove_tag("Iptc.Application2.ProgramVersion");
+ }
+
+ public string? get_exposure_bias() {
+ return get_string_interpreted("Exif.Photo.ExposureBiasValue");
+ }
+
+ private static string[] RATING_TAGS = {
+ "Xmp.xmp.Rating",
+ "Iptc.Application2.Urgency",
+ "Xmp.photoshop.Urgency",
+ "Exif.Image.Rating"
+ };
+
+ public Rating get_rating() {
+ string? rating_string = get_first_string(RATING_TAGS);
+ if(rating_string != null)
+ return Rating.unserialize(int.parse(rating_string));
+
+ rating_string = get_string("Exif.Image.RatingPercent");
+ if(rating_string == null) {
+ return Rating.UNRATED;
+ }
+
+ int int_percent_rating = int.parse(rating_string);
+ for(int i = 5; i >= 0; --i) {
+ if(int_percent_rating >= Resources.rating_thresholds[i])
+ return Rating.unserialize(i);
+ }
+ return Rating.unserialize(-1);
+ }
+
+ // Among photo managers, Xmp.xmp.Rating tends to be the standard way to represent ratings.
+ // 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.
+ public void set_rating(Rating rating) {
+ int int_rating = rating.serialize();
+ set_string("Xmp.xmp.Rating", int_rating.to_string());
+ set_string("Exif.Image.Rating", int_rating.to_string());
+
+ if( 0 <= int_rating )
+ set_string("Exif.Image.RatingPercent", Resources.rating_thresholds[int_rating].to_string());
+ else // in this case we _know_ int_rating is -1
+ set_string("Exif.Image.RatingPercent", int_rating.to_string());
+ }
+}
+
diff --git a/src/photos/Photos.vala b/src/photos/Photos.vala
new file mode 100644
index 0000000..3033b92
--- /dev/null
+++ b/src/photos/Photos.vala
@@ -0,0 +1,31 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+/* This file is the master unit file for the Photo 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 Photos {
+
+// preconfigure may be deleted if not used.
+public void preconfigure() {
+}
+
+public void init() throws Error {
+ foreach (PhotoFileFormat format in PhotoFileFormat.get_supported())
+ format.init();
+}
+
+public void terminate() {
+}
+
+}
+
diff --git a/src/photos/PngSupport.vala b/src/photos/PngSupport.vala
new file mode 100644
index 0000000..ffc7faa
--- /dev/null
+++ b/src/photos/PngSupport.vala
@@ -0,0 +1,181 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+class PngFileFormatProperties : PhotoFileFormatProperties {
+ private static string[] KNOWN_EXTENSIONS = { "png" };
+ private static string[] KNOWN_MIME_TYPES = { "image/png" };
+
+ private static PngFileFormatProperties instance = null;
+
+ public static void init() {
+ instance = new PngFileFormatProperties();
+ }
+
+ public static PngFileFormatProperties get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormat get_file_format() {
+ return PhotoFileFormat.PNG;
+ }
+
+ public override PhotoFileFormatFlags get_flags() {
+ return PhotoFileFormatFlags.NONE;
+ }
+
+ public override string get_user_visible_name() {
+ return _("PNG");
+ }
+
+ 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 PngSniffer : GdkSniffer {
+ private const uint8[] MAGIC_SEQUENCE = { 137, 80, 78, 71, 13, 10, 26, 10 };
+
+ public PngSniffer(File file, PhotoFileSniffer.Options options) {
+ base (file, options);
+ }
+
+ private static bool is_png_file(File file) throws Error {
+ FileInputStream instream = file.read(null);
+
+ uint8[] file_lead_sequence = new uint8[MAGIC_SEQUENCE.length];
+
+ instream.read(file_lead_sequence, null);
+
+ for (int i = 0; i < MAGIC_SEQUENCE.length; i++) {
+ if (file_lead_sequence[i] != MAGIC_SEQUENCE[i])
+ return false;
+ }
+
+ return true;
+ }
+
+ public override DetectedPhotoInformation? sniff() throws Error {
+ if (!is_png_file(file))
+ return null;
+
+ DetectedPhotoInformation? detected = base.sniff();
+ if (detected == null)
+ return null;
+
+ return (detected.file_format == PhotoFileFormat.PNG) ? detected : null;
+ }
+}
+
+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 {
+ public PngWriter(string filepath) {
+ base (filepath, PhotoFileFormat.PNG);
+ }
+
+ public override void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error {
+ pixbuf.save(get_filepath(), "png", "compression", "9", null);
+ }
+}
+
+public class PngMetadataWriter : PhotoFileMetadataWriter {
+ public PngMetadataWriter(string filepath) {
+ base (filepath, PhotoFileFormat.PNG);
+ }
+
+ public override void write_metadata(PhotoMetadata metadata) throws Error {
+ metadata.write_to_file(get_file());
+ }
+}
+
+public class PngFileFormatDriver : PhotoFileFormatDriver {
+ private static PngFileFormatDriver instance = null;
+
+ public static void init() {
+ instance = new PngFileFormatDriver();
+ PngFileFormatProperties.init();
+ }
+
+ public static PngFileFormatDriver get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormatProperties get_properties() {
+ return PngFileFormatProperties.get_instance();
+ }
+
+ public override PhotoFileReader create_reader(string filepath) {
+ return new PngReader(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 PngWriter(filepath);
+ }
+
+ public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) {
+ return new PngMetadataWriter(filepath);
+ }
+
+ public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
+ return new PngSniffer(file, options);
+ }
+
+ public override PhotoMetadata create_metadata() {
+ return new PhotoMetadata();
+ }
+}
+
diff --git a/src/photos/RawSupport.vala b/src/photos/RawSupport.vala
new file mode 100644
index 0000000..bad9572
--- /dev/null
+++ b/src/photos/RawSupport.vala
@@ -0,0 +1,347 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 RawFileFormatDriver : PhotoFileFormatDriver {
+ private static RawFileFormatDriver instance = null;
+
+ public static void init() {
+ instance = new RawFileFormatDriver();
+ RawFileFormatProperties.init();
+ }
+
+ public static RawFileFormatDriver get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormatProperties get_properties() {
+ return RawFileFormatProperties.get_instance();
+ }
+
+ public override PhotoFileReader create_reader(string filepath) {
+ return new RawReader(filepath);
+ }
+
+ public override PhotoMetadata create_metadata() {
+ return new PhotoMetadata();
+ }
+
+ public override bool can_write_image() {
+ return false;
+ }
+
+ public override bool can_write_metadata() {
+ return false;
+ }
+
+ public override PhotoFileWriter? create_writer(string filepath) {
+ return null;
+ }
+
+ public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) {
+ return null;
+ }
+
+ public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
+ return new RawSniffer(file, options);
+ }
+}
+
+public class RawFileFormatProperties : PhotoFileFormatProperties {
+ private static string[] KNOWN_EXTENSIONS = {
+ "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "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"
+ };
+
+ private static string[] KNOWN_MIME_TYPES = {
+ /* a catch-all MIME type for all formats supported by the dcraw command-line
+ tool (and hence libraw) */
+ "image/x-dcraw",
+
+ /* manufacturer blessed MIME types */
+ "image/x-canon-cr2",
+ "image/x-canon-crw",
+ "image/x-fuji-raf",
+ "image/x-adobe-dng",
+ "image/x-panasonic-raw",
+ "image/x-raw",
+ "image/x-minolta-mrw",
+ "image/x-nikon-nef",
+ "image/x-olympus-orf",
+ "image/x-pentax-pef",
+ "image/x-sony-arw",
+ "image/x-sony-srf",
+ "image/x-sony-sr2",
+ "image/x-samsung-raw",
+
+ /* generic MIME types for file extensions*/
+ "image/x-3fr",
+ "image/x-arw",
+ "image/x-srf",
+ "image/x-sr2",
+ "image/x-bay",
+ "image/x-crw",
+ "image/x-cr2",
+ "image/x-cap",
+ "image/x-iiq",
+ "image/x-eip",
+ "image/x-dcs",
+ "image/x-dcr",
+ "image/x-drf",
+ "image/x-k25",
+ "image/x-kdc",
+ "image/x-dng",
+ "image/x-erf",
+ "image/x-fff",
+ "image/x-mef",
+ "image/x-mos",
+ "image/x-mrw",
+ "image/x-nef",
+ "image/x-nrw",
+ "image/x-orf",
+ "image/x-ptx",
+ "image/x-pef",
+ "image/x-pxn",
+ "image/x-r3d",
+ "image/x-raf",
+ "image/x-raw",
+ "image/x-rw2",
+ "image/x-raw",
+ "image/x-rwl",
+ "image/x-rwz",
+ "image/x-x3f",
+ "image/x-srw"
+ };
+
+ private static RawFileFormatProperties instance = null;
+
+ public static void init() {
+ instance = new RawFileFormatProperties();
+ }
+
+ public static RawFileFormatProperties get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormat get_file_format() {
+ return PhotoFileFormat.RAW;
+ }
+
+ public override string get_user_visible_name() {
+ return _("RAW");
+ }
+
+ public override PhotoFileFormatFlags get_flags() {
+ return PhotoFileFormatFlags.NONE;
+ }
+
+ public override string get_default_extension() {
+ // Because RAW is a smorgasbord of file formats and exporting to a RAW file is
+ // not expected, this function should probably never be called. However, need to pick
+ // one, so here it is.
+ return "raw";
+ }
+
+ 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 RawSniffer : PhotoFileSniffer {
+ public RawSniffer(File file, PhotoFileSniffer.Options options) {
+ base (file, options);
+ }
+
+ public override DetectedPhotoInformation? sniff() throws Error {
+ DetectedPhotoInformation detected = new DetectedPhotoInformation();
+
+ GRaw.Processor processor = new GRaw.Processor();
+ processor.output_params->user_flip = GRaw.Flip.NONE;
+
+ 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)
+ return null;
+
+ throw exception;
+ }
+
+ detected.image_dim = Dimensions(processor.get_sizes().iwidth, processor.get_sizes().iheight);
+ detected.colorspace = Gdk.Colorspace.RGB;
+ detected.channels = 3;
+ detected.bits_per_channel = 8;
+
+ RawReader reader = new RawReader(file.get_path());
+ try {
+ detected.metadata = reader.read_metadata();
+ } catch (Error err) {
+ // ignored
+ }
+
+ if (detected.metadata != null) {
+ uint8[]? flattened_sans_thumbnail = detected.metadata.flatten_exif(false);
+ if (flattened_sans_thumbnail != null && flattened_sans_thumbnail.length > 0)
+ detected.exif_md5 = md5_binary(flattened_sans_thumbnail, flattened_sans_thumbnail.length);
+
+ uint8[]? flattened_thumbnail = detected.metadata.flatten_exif_preview();
+ if (flattened_thumbnail != null && flattened_thumbnail.length > 0)
+ detected.thumbnail_md5 = md5_binary(flattened_thumbnail, flattened_thumbnail.length);
+ }
+
+ if (calc_md5)
+ detected.md5 = md5_file(file);
+
+ detected.format_name = "raw";
+ detected.file_format = PhotoFileFormat.RAW;
+
+ return detected;
+ }
+}
+
+public class RawReader : PhotoFileReader {
+ 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;
+ }
+
+ public override Gdk.Pixbuf unscaled_read() throws Error {
+ GRaw.Processor processor = new GRaw.Processor();
+ processor.configure_for_rgb_display(false);
+ processor.output_params->user_flip = GRaw.Flip.NONE;
+
+ processor.open_file(get_filepath());
+ processor.unpack();
+ processor.process();
+
+ return processor.make_mem_image().get_pixbuf_copy();
+ }
+
+ public override Gdk.Pixbuf scaled_read(Dimensions full, Dimensions scaled) throws Error {
+ double width_proportion = (double) scaled.width / (double) full.width;
+ double height_proportion = (double) scaled.height / (double) full.height;
+ bool half_size = width_proportion < 0.5 && height_proportion < 0.5;
+
+ GRaw.Processor processor = new GRaw.Processor();
+ processor.configure_for_rgb_display(half_size);
+ processor.output_params->user_flip = GRaw.Flip.NONE;
+
+ processor.open_file(get_filepath());
+ processor.unpack();
+ processor.process();
+
+ GRaw.ProcessedImage image = processor.make_mem_image();
+
+ return resize_pixbuf(image.get_pixbuf_copy(), scaled, Gdk.InterpType.BILINEAR);
+ }
+}
+
+// Development mode of a RAW photo.
+public enum RawDeveloper {
+ SHOTWELL = 0, // Developed internally by Shotwell
+ CAMERA, // JPEG from RAW+JPEG pair (if available)
+ EMBEDDED; // Largest-size
+
+ public static RawDeveloper[] as_array() {
+ return { SHOTWELL, CAMERA, EMBEDDED };
+ }
+
+ public string to_string() {
+ switch (this) {
+ case SHOTWELL:
+ return "SHOTWELL";
+ case CAMERA:
+ return "CAMERA";
+ case EMBEDDED:
+ return "EMBEDDED";
+ default:
+ assert_not_reached();
+ }
+ }
+
+ public static RawDeveloper from_string(string value) {
+ switch (value) {
+ case "SHOTWELL":
+ return SHOTWELL;
+ case "CAMERA":
+ return CAMERA;
+ case "EMBEDDED":
+ return EMBEDDED;
+ default:
+ assert_not_reached();
+ }
+ }
+
+ public string get_label() {
+ switch (this) {
+ case SHOTWELL:
+ return _("Shotwell");
+ case CAMERA:
+ case EMBEDDED:
+ return _("Camera");
+ default:
+ assert_not_reached();
+ }
+ }
+
+ // Determines if two RAW developers are equivalent, treating camera and embedded
+ // as the same.
+ public bool is_equivalent(RawDeveloper d) {
+ if (this == d)
+ return true;
+
+ if ((this == RawDeveloper.CAMERA && d == RawDeveloper.EMBEDDED) ||
+ (this == RawDeveloper.EMBEDDED && d == RawDeveloper.CAMERA))
+ return true;
+
+ return false;
+ }
+
+ // Creates a backing JPEG.
+ // raw_filepath is the full path of the imported RAW file.
+ public BackingPhotoRow create_backing_row_for_development(string raw_filepath,
+ string? camera_development_filename = null) throws Error {
+ BackingPhotoRow ns = new BackingPhotoRow();
+ File master = File.new_for_path(raw_filepath);
+ string name, ext;
+ disassemble_filename(master.get_basename(), out name, out ext);
+
+ string basename;
+
+ // If this image is coming in with an existing development, use its existing
+ // filename instead.
+ if (camera_development_filename == null) {
+ basename = name + "_" + ext +
+ (this != CAMERA ? ("_" + this.to_string().down()) : "") + ".jpg";
+ } else {
+ basename = camera_development_filename;
+ }
+
+ bool c;
+ File? new_back = generate_unique_file(master.get_parent(), basename, out c);
+ claim_file(new_back);
+ ns.file_format = PhotoFileFormat.JFIF;
+ ns.filepath = new_back.get_path();
+
+ return ns;
+ }
+}
diff --git a/src/photos/TiffSupport.vala b/src/photos/TiffSupport.vala
new file mode 100644
index 0000000..decc052
--- /dev/null
+++ b/src/photos/TiffSupport.vala
@@ -0,0 +1,180 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+namespace Photos {
+
+public class TiffFileFormatDriver : PhotoFileFormatDriver {
+ private static TiffFileFormatDriver instance = null;
+
+ public static void init() {
+ instance = new TiffFileFormatDriver();
+ TiffFileFormatProperties.init();
+ }
+
+ public static TiffFileFormatDriver get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormatProperties get_properties() {
+ return TiffFileFormatProperties.get_instance();
+ }
+
+ public override PhotoFileReader create_reader(string filepath) {
+ return new TiffReader(filepath);
+ }
+
+ public override PhotoMetadata create_metadata() {
+ return new PhotoMetadata();
+ }
+
+ 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 TiffWriter(filepath);
+ }
+
+ public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) {
+ return new TiffMetadataWriter(filepath);
+ }
+
+ public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
+ return new TiffSniffer(file, options);
+ }
+}
+
+private class TiffFileFormatProperties : PhotoFileFormatProperties {
+ private static string[] KNOWN_EXTENSIONS = {
+ "tif", "tiff"
+ };
+
+ private static string[] KNOWN_MIME_TYPES = {
+ "image/tiff"
+ };
+
+ private static TiffFileFormatProperties instance = null;
+
+ public static void init() {
+ instance = new TiffFileFormatProperties();
+ }
+
+ public static TiffFileFormatProperties get_instance() {
+ return instance;
+ }
+
+ public override PhotoFileFormat get_file_format() {
+ return PhotoFileFormat.TIFF;
+ }
+
+ public override PhotoFileFormatFlags get_flags() {
+ return PhotoFileFormatFlags.NONE;
+ }
+
+ public override string get_default_extension() {
+ return "tif";
+ }
+
+ public override string get_user_visible_name() {
+ return _("TIFF");
+ }
+
+ 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 TiffSniffer : GdkSniffer {
+ public TiffSniffer(File file, PhotoFileSniffer.Options options) {
+ base (file, options);
+ }
+
+ public override DetectedPhotoInformation? sniff() throws Error {
+ if (!is_tiff(file))
+ return null;
+
+ DetectedPhotoInformation? detected = base.sniff();
+ if (detected == null)
+ return null;
+
+ return (detected.file_format == PhotoFileFormat.TIFF) ? detected : null;
+ }
+}
+
+private class TiffReader : GdkReader {
+ public TiffReader(string filepath) {
+ base (filepath, PhotoFileFormat.TIFF);
+ }
+}
+
+private class TiffWriter : PhotoFileWriter {
+ private const string COMPRESSION_NONE = "1";
+ private const string COMPRESSION_HUFFMAN = "2";
+ private const string COMPRESSION_LZW = "5";
+ private const string COMPRESSION_JPEG = "7";
+ private const string COMPRESSION_DEFLATE = "8";
+
+ public TiffWriter(string filepath) {
+ base (filepath, PhotoFileFormat.TIFF);
+ }
+
+ public override void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error {
+ pixbuf.save(get_filepath(), "tiff", "compression", COMPRESSION_LZW);
+ }
+}
+
+private class TiffMetadataWriter : PhotoFileMetadataWriter {
+ public TiffMetadataWriter(string filepath) {
+ base (filepath, PhotoFileFormat.TIFF);
+ }
+
+ public override void write_metadata(PhotoMetadata metadata) throws Error {
+ metadata.write_to_file(get_file());
+ }
+}
+
+public bool is_tiff(File file, Cancellable? cancellable = null) throws Error {
+ DataInputStream dins = new DataInputStream(file.read());
+
+ // first two bytes: "II" (0x4949, for Intel) or "MM" (0x4D4D, for Motorola)
+ DataStreamByteOrder order;
+ switch (dins.read_uint16(cancellable)) {
+ case 0x4949:
+ order = DataStreamByteOrder.LITTLE_ENDIAN;
+ break;
+
+ case 0x4D4D:
+ order = DataStreamByteOrder.BIG_ENDIAN;
+ break;
+
+ default:
+ return false;
+ }
+
+ dins.set_byte_order(order);
+
+ // second two bytes: some random number
+ uint16 lue = dins.read_uint16(cancellable);
+ if (lue != 42)
+ return false;
+
+ // remaining bytes are offset of first IFD, which doesn't matter for our purposes
+ return true;
+}
+
+}
diff --git a/src/photos/mk/photos.mk b/src/photos/mk/photos.mk
new file mode 100644
index 0000000..6be33a4
--- /dev/null
+++ b/src/photos/mk/photos.mk
@@ -0,0 +1,38 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Photos
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := photos
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ PhotoFileAdapter.vala \
+ PhotoFileFormat.vala \
+ PhotoFileSniffer.vala \
+ PhotoMetadata.vala \
+ GRaw.vala \
+ GdkSupport.vala \
+ JfifSupport.vala \
+ BmpSupport.vala \
+ RawSupport.vala \
+ PngSupport.vala \
+ TiffSupport.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/plugins/DataImportsInterfaces.vala b/src/plugins/DataImportsInterfaces.vala
new file mode 100644
index 0000000..154503b
--- /dev/null
+++ b/src/plugins/DataImportsInterfaces.vala
@@ -0,0 +1,489 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Shotwell Pluggable Data Imports API
+ *
+ * The Shotwell Pluggable Data Imports API allows you to write plugins that import
+ * information from other media library databases to help migration to Shotwell.
+ * The Shotwell distribution includes import support for F-Spot.
+ * To enable Shotwell to import from additional libaries, developers like you write
+ * data import plugins, dynamically-loadable shared objects that are linked into the
+ * Shotwell process at runtime. Data import plugins are just one of several kinds of
+ * plugins supported by {@link Spit}, the Shotwell Pluggable Interfaces Technology.
+ */
+namespace Spit.DataImports {
+
+/**
+ * The current version of the Pluggable Data Import API
+ */
+public const int CURRENT_INTERFACE = 0;
+
+/**
+ * The error domain for alien databases
+ */
+public errordomain DataImportError {
+ /**
+ * Indicates that the version of the external database being imported is
+ * not supported by this version of the plugin.
+ *
+ * This occurs for example when trying to import an F-Spot database that
+ * has a version that is more recent than what the current plugin supports.
+ */
+ UNSUPPORTED_VERSION
+}
+
+/**
+ * Represents a module that is able to import data from a specific database format.
+ *
+ * Developers of data import plugins provide a class that implements this interface. At
+ * any given time, only one DataImporter can be running. When a data importer is running, it
+ * has exclusive use of the shared user-interface and
+ * configuration services provided by the {@link PluginHost}. Data importers are created in
+ * a non-running state and do not begin running until start( ) is invoked. Data importers
+ * run until stop( ) is invoked.
+ */
+public interface DataImporter : GLib.Object {
+ /**
+ * Returns a {@link Service} object describing the service to which this connects.
+ */
+ public abstract Service get_service();
+
+ /**
+ * Makes this data importer enter the running state and endows it with exclusive access
+ * to the shared services provided by the {@link PluginHost}. Through the host’s interface,
+ * this data importer can install user interface panes and query configuration information.
+ */
+ public abstract void start();
+
+ /**
+ * Returns true if this data importer is in the running state; false otherwise.
+ */
+ public abstract bool is_running();
+
+ /**
+ * Causes this data importer to enter a non-running state. This data importer should stop all
+ * data access operations and cease use of the shared services provided by the {@link PluginHost}.
+ */
+ public abstract void stop();
+
+ /**
+ * Causes this data importer to enter start the import of a library.
+ */
+ public abstract void on_library_selected(ImportableLibrary library);
+
+ /**
+ * Causes this data importer to enter start the import of a library file.
+ */
+ public abstract void on_file_selected(File file);
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Represents a library of importable media items.
+ *
+ * Developers of data import plugins provide a class that implements this interface.
+ */
+public interface ImportableLibrary : GLib.Object {
+ public abstract string get_display_name();
+}
+
+/**
+ * Represents an importable media item such as a photo or a video file.
+ *
+ * Developers of data import plugins provide a class that implements this interface.
+ */
+public interface ImportableMediaItem : GLib.Object {
+ public abstract ImportableTag[] get_tags();
+
+ public abstract ImportableEvent? get_event();
+
+ public abstract ImportableRating get_rating();
+
+ public abstract string? get_title();
+
+ public abstract string get_folder_path();
+
+ public abstract string get_filename();
+}
+
+/**
+ * Represents an importable tag.
+ *
+ * Developers of data import plugins provide a class that implements this interface.
+ */
+public interface ImportableTag : GLib.Object {
+ public abstract string get_name();
+
+ public abstract ImportableTag? get_parent();
+}
+
+/**
+ * Represents an importable event.
+ *
+ * Developers of data import plugins provide a class that implements this interface.
+ */
+public interface ImportableEvent : GLib.Object {
+ public abstract string get_name();
+}
+
+/**
+ * Represents an importable rating value.
+ *
+ * Developers of data import plugins provide a class that implements this interface.
+ * Note that the value returned by the get_value method should be a value between
+ * 1 and 5, unless the rating object is unrated or rejected, in which case the
+ * value is unspecified.
+ */
+public interface ImportableRating : GLib.Object {
+ public abstract bool is_unrated();
+
+ public abstract bool is_rejected();
+
+ public abstract int get_value();
+}
+
+/**
+ * Encapsulates a pane that can be installed in the on-screen import dialog box to
+ * communicate status to and to get information from the user.
+ *
+ */
+public interface DialogPane : GLib.Object {
+
+ /**
+ * Describes how the on-screen publishing dialog box should look and behave when an associated
+ * pane is installed in the on-screen publishing dialog box.
+ */
+ public enum GeometryOptions {
+
+ /**
+ * When the associated pane is installed, the on-screen publishing dialog box will be
+ * sized normally and will not allow the user to change its size.
+ */
+ NONE = 0,
+
+ /**
+ * If this bit is set, when the associated pane is installed, the on-screen publishing
+ * dialog box will grow to a larger size.
+ */
+ EXTENDED_SIZE = 1 << 0,
+
+ /**
+ * If this bit is set, when the associated pane is installed, the on-screen publishing
+ * dialog box will allow the user to change its size.
+ */
+ RESIZABLE = 1 << 1,
+
+ /**
+ * If this bit is set, when the associated pane is installed, the on-screen publishing
+ * dialog box will grow to accommodate a full-width 1024 pixel web page. If both
+ * EXTENDED_SIZE and COLOSSAL_SIZE are set, EXTENDED_SIZE takes precedence.
+ */
+ COLOSSAL_SIZE = 1 << 2;
+ }
+
+ /**
+ * Returns the Gtk.Widget that is this pane's on-screen representation.
+ */
+ public abstract Gtk.Widget get_widget();
+
+ /**
+ * Returns a {@link GeometryOptions} bitfield describing how the on-screen publishing dialog
+ * box should look and behave when this pane is installed.
+ */
+ public abstract GeometryOptions get_preferred_geometry();
+
+ /**
+ * Invoked automatically by Shotwell when this pane has been installed into the on-screen
+ * publishing dialog box and become visible to the user.
+ */
+ public abstract void on_pane_installed();
+
+ /**
+ * Invoked automatically by Shotwell when this pane has been removed from the on-screen
+ * publishing dialog box and is no longer visible to the user.
+ */
+ public abstract void on_pane_uninstalled();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Called by the data imports system at the end of an import batch to report
+ * to the plugin the number of items that were really imported. This enables
+ * the plugin to display a final message to the user. However, the plugin
+ * should not rely on this callback being called in order to clean up.
+ */
+public delegate void ImportedItemsCountCallback(int imported_items_count);
+
+/**
+ * Manages and provides services for data import plugins.
+ *
+ * Implemented inside Shotwell, the PluginHost provides an interface through which the
+ * developers of data import plugins can query and make changes to the import
+ * environment. Plugins can use the services of the PluginHost only when their
+ * {@link DataImporter} is in the running state. This ensures that non-running data importers
+ * don’t destructively interfere with the actively running importer.
+ */
+public interface PluginHost : GLib.Object, Spit.HostInterface {
+
+ /**
+ * Specifies the label text on the push button control that appears in the
+ * lower-right-hand corner of the on-screen publishing dialog box.
+ */
+ public enum ButtonMode {
+ CLOSE = 0,
+ CANCEL = 1
+ }
+
+ /**
+ * Notifies the user that an unrecoverable import error has occurred and halts
+ * the import process.
+ *
+ * @param err An error object that describes the kind of error that occurred.
+ */
+ public abstract void post_error(Error err);
+
+ /**
+ * Notifies the user that an unrecoverable import error has occurred and halts
+ * the import process.
+ *
+ * @param msg A message that describes the kind of error that occurred.
+ */
+ public abstract void post_error_message(string msg);
+
+ /**
+ * Starts the import process.
+ *
+ * Calling this method starts the import activity for this host.
+ */
+ public abstract void start_importing();
+
+ /**
+ * Halts the import process.
+ *
+ * Calling this method stops all import activity and hides the on-screen import
+ * dialog box.
+ */
+ public abstract void stop_importing();
+
+ /**
+ * Returns a reference to the {@link DataImporter} object that this is currently hosting.
+ */
+ public abstract DataImporter get_data_importer();
+
+ /**
+ * Attempts to install a pane in the on-screen data import dialog box, making the pane visible
+ * and allowing it to interact with the user.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param pane the pane to install
+ *
+ * @param mode allows you to set the text displayed on the close/cancel button in the
+ * lower-right-hand corner of the on-screen data import dialog box when pane is installed.
+ * If mode is ButtonMode.CLOSE, the button will have the title "Close." If mode is
+ * ButtonMode.CANCEL, the button will be titled "Cancel." You should set mode depending on
+ * whether a cancellable action is in progress. For example, if your importer is in the
+ * middle of processing 3 of 8 videos, then mode should be ButtonMode.CANCEL. However, if
+ * the processing operation has completed and the success pane is displayed, then mode
+ * should be ButtonMode.CLOSE, because all cancellable actions have already
+ * occurred.
+ */
+ public abstract void install_dialog_pane(Spit.DataImports.DialogPane pane,
+ ButtonMode mode = ButtonMode.CANCEL);
+
+ /**
+ * Attempts to install a pane in the on-screen data import dialog box that contains
+ * static text.
+ *
+ * The text appears centered in the data import dialog box and is drawn in
+ * the system font. This is a convenience method only; similar results could be
+ * achieved by manually constructing a Gtk.Label widget, wrapping it inside a
+ * {@link DialogPane}, and installing it manually with a call to
+ * install_dialog_pane( ). To provide visual consistency across data import services,
+ * however, always use this convenience method instead of constructing label panes when
+ * you need to display static text to the user.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param message the text to show in the pane
+ *
+ * @param mode allows you to set the text displayed on the close/cancel button in the
+ * lower-right-hand corner of the on-screen data import dialog box when pane is installed.
+ * If mode is ButtonMode.CLOSE, the button will have the title "Close." If mode is
+ * ButtonMode.CANCEL, the button will be titled "Cancel." You should set mode depending on
+ * whether a cancellable action is in progress. For example, if your importer is in the
+ * middle of processing 3 of 8 videos, then mode should be ButtonMode.CANCEL. However, if
+ * the processing operation has completed and the success pane is displayed, then mode
+ * should be ButtonMode.CLOSE, because all cancellable actions have already
+ * occurred.
+ */
+ public abstract void install_static_message_pane(string message,
+ ButtonMode mode = ButtonMode.CANCEL);
+
+ /**
+ * Attempts to install a library selection pane that presents a list of
+ * discovered libraries to the user.
+ *
+ * When the user clicks the “OK” button, you’ll be notified of the user’s action through
+ * the 'on_library_selected' callback if a discovered library was selected or through
+ * the 'on_file_selected' callback if a file was selected.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param welcome_message the text to be displayed above the list of discovered
+ * libraries.
+ *
+ * @param discovered_libraries the list of importable libraries that the plugin
+ * has discovered in well known locations.
+ *
+ * @param file_select_label the label to display for the file selection
+ * option. If this label is null, the
+ * user will not be presented with a file selection option.
+ */
+ public abstract void install_library_selection_pane(
+ string welcome_message,
+ ImportableLibrary[] discovered_libraries,
+ string? file_select_label
+ );
+
+ /**
+ * Attempts to install a progress pane that provides the user with feedback
+ * on import preparation.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param message the text to be displayed above the progress bar.
+ */
+ public abstract void install_import_progress_pane(
+ string message
+ );
+
+ /**
+ * Update the progress bar installed by install_import_progress_pane.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param progress a value between 0.0 and 1.0 identifying progress for the
+ * plugin.
+ *
+ * @param progress_label the text to be displayed below the progress bar. If that
+ * parameter is null, the message will be left unchanged.
+ */
+ public abstract void update_import_progress_pane(
+ double progress,
+ string? progress_message = null
+ );
+
+ /**
+ * Sends an importable media item to the host in order to prepare it for import
+ * and update the progress bar installed by install_import_progress_pane.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param item the importable media item to prepare for import.
+ *
+ * @param progress a value between 0.0 and 1.0 identifying progress for the
+ * plugin.
+ *
+ * @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.
+ *
+ * @param progress_message the text to be displayed below the progress bar. If that
+ * parameter is null, the message will be left unchanged.
+ */
+ public abstract void prepare_media_items_for_import(
+ ImportableMediaItem[] items,
+ double progress,
+ double host_progress_delta = 0.0,
+ string? progress_message = null
+ );
+
+ /**
+ * Finalize the import sequence for the plugin. This tells the host that
+ * all media items have been processed and that the plugin has finished all
+ * import work. Once this method has been called, all resources used by the
+ * plugin for import should be released and the plugin should be back to the
+ * state it had just after running the start method. The host will then display
+ * the final message and show progress as fully complete. In a standard import
+ * scenario, the user is expected to click the Close button to dismiss the
+ * dialog. On first run, the host may call the LibrarySelectedCallback again
+ * to import another library handled by the same plugin.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param finalize_message the text to be displayed below the progress bar. If that
+ * parameter is null, the message will be left unchanged.
+ */
+ public abstract void finalize_import(
+ ImportedItemsCountCallback report_imported_items_count,
+ string? finalize_message = null
+ );
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Describes the features and capabilities of a data import service.
+ *
+ * Developers of data import plugins provide a class that implements this interface.
+ */
+public interface Service : Object, Spit.Pluggable {
+ /**
+ * A factory method that instantiates and returns a new {@link DataImporter} object
+ * that this Service describes.
+ */
+ public abstract Spit.DataImports.DataImporter create_data_importer(Spit.DataImports.PluginHost host);
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+}
+
diff --git a/src/plugins/ManifestWidget.vala b/src/plugins/ManifestWidget.vala
new file mode 100644
index 0000000..54f2e56
--- /dev/null
+++ b/src/plugins/ManifestWidget.vala
@@ -0,0 +1,282 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Plugins {
+
+public class ManifestWidgetMediator {
+ public Gtk.Widget widget {
+ get {
+ return builder.get_object("plugin-manifest") as Gtk.Widget;
+ }
+ }
+
+ private Gtk.Button about_button {
+ get {
+ return builder.get_object("about-plugin-button") as Gtk.Button;
+ }
+ }
+
+ private Gtk.ScrolledWindow list_bin {
+ get {
+ return builder.get_object("plugin-list-scrolled-window") as Gtk.ScrolledWindow;
+ }
+ }
+
+ private Gtk.Builder builder = AppWindow.create_builder();
+ private ManifestListView list = new ManifestListView();
+
+ public ManifestWidgetMediator() {
+ list_bin.add_with_viewport(list);
+
+ about_button.clicked.connect(on_about);
+ list.get_selection().changed.connect(on_selection_changed);
+
+ set_about_button_sensitivity();
+ }
+
+ ~ManifestWidgetMediator() {
+ about_button.clicked.disconnect(on_about);
+ list.get_selection().changed.disconnect(on_selection_changed);
+ }
+
+ 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;
+ }
+
+ // 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;
+ }
+ }
+ }
+
+ 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();
+ }
+
+ private void on_selection_changed() {
+ set_about_button_sensitivity();
+ }
+
+ 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);
+ }
+}
+
+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 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);
+
+ set_headers_visible(false);
+ set_enable_search(false);
+ set_rules_hint(true);
+ 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);
+
+ Gtk.IconTheme icon_theme = Resources.get_icon_theme_engine();
+
+ // 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) {
+ 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);
+ }
+ }
+
+ 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;
+ }
+}
+
+}
+
diff --git a/src/plugins/Plugins.vala b/src/plugins/Plugins.vala
new file mode 100644
index 0000000..d0f9185
--- /dev/null
+++ b/src/plugins/Plugins.vala
@@ -0,0 +1,436 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+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;
+private const int MAX_SPIT_INTERFACE = 0;
+
+public class ExtensionPoint {
+ public GLib.Type pluggable_type { get; private set; }
+ // name is user-visible
+ public string name { get; private set; }
+ public string? icon_name { get; private set; }
+ public string[]? core_ids { get; private set; }
+
+ public ExtensionPoint(Type pluggable_type, string name, string? icon_name, string[]? core_ids) {
+ this.pluggable_type = pluggable_type;
+ this.name = name;
+ this.icon_name = icon_name;
+ this.core_ids = core_ids;
+ }
+}
+
+private class ModuleRep {
+ public File file;
+ public Module? module;
+ public Spit.Module? spit_module = null;
+ public int spit_interface = Spit.UNSUPPORTED_INTERFACE;
+ public string? id = null;
+
+ private ModuleRep(File file) {
+ this.file = file;
+
+ module = Module.open(file.get_path(), ModuleFlags.BIND_LAZY);
+ }
+
+ ~ModuleRep() {
+ // ensure that the Spit.Module is destroyed before the GLib.Module
+ spit_module = null;
+ }
+
+ // Have to use this funky static factory because GModule is a compact class and has no copy
+ // constructor. The handle must be kept open for the lifetime of the application (or until
+ // the module is ready to be discarded), as dropping the reference will unload the binary.
+ public static ModuleRep? open(File file) {
+ ModuleRep module_rep = new ModuleRep(file);
+
+ return (module_rep.module != null) ? module_rep : null;
+ }
+}
+
+private class PluggableRep {
+ public Spit.Pluggable pluggable { get; private set; }
+ public string id { get; private set; }
+ public bool is_core { get; private set; default = false; }
+ public bool activated { get; private set; default = false; }
+
+ private bool enabled = false;
+
+ // Note that creating a PluggableRep does not activate it.
+ public PluggableRep(Spit.Pluggable pluggable) {
+ this.pluggable = pluggable;
+ id = pluggable.get_id();
+ }
+
+ public void activate() {
+ // determine if a core pluggable (which is only known after all the extension points
+ // register themselves)
+ is_core = is_core_pluggable(pluggable);
+
+ FuzzyPropertyState saved_state = Config.Facade.get_instance().is_plugin_enabled(id);
+ enabled = ((is_core && (saved_state != FuzzyPropertyState.DISABLED)) ||
+ (!is_core && (saved_state == FuzzyPropertyState.ENABLED)));
+
+ // inform the plugin of its activation state
+ pluggable.activation(enabled);
+
+ activated = true;
+ }
+
+ public bool is_enabled() {
+ return enabled;
+ }
+
+ // Returns true if value changed, false otherwise
+ public bool set_enabled(bool enabled) {
+ if (enabled == this.enabled)
+ return false;
+
+ this.enabled = enabled;
+ Config.Facade.get_instance().set_plugin_enabled(id, enabled);
+ pluggable.activation(enabled);
+
+ return true;
+ }
+}
+
+private File[] search_dirs;
+private Gee.HashMap<string, ModuleRep> module_table;
+private Gee.HashMap<string, PluggableRep> pluggable_table;
+private Gee.HashMap<Type, ExtensionPoint> extension_points;
+private Gee.HashSet<string> core_ids;
+
+public void init() throws Error {
+ search_dirs = new File[0];
+ search_dirs += AppDirs.get_user_plugins_dir();
+ search_dirs += AppDirs.get_system_plugins_dir();
+
+ module_table = new Gee.HashMap<string, ModuleRep>();
+ pluggable_table = new Gee.HashMap<string, PluggableRep>();
+ extension_points = new Gee.HashMap<Type, ExtensionPoint>();
+ core_ids = new Gee.HashSet<string>();
+
+ // do this after constructing member variables so accessors don't blow up if GModule isn't
+ // supported
+ if (!Module.supported()) {
+ warning("Plugins not support: GModule not supported on this platform.");
+
+ return;
+ }
+
+ foreach (File dir in search_dirs) {
+ try {
+ search_for_plugins(dir);
+ } catch (Error err) {
+ debug("Unable to search directory %s for plugins: %s", dir.get_path(), err.message);
+ }
+ }
+}
+
+public void terminate() {
+ search_dirs = null;
+ pluggable_table = null;
+ module_table = null;
+ extension_points = null;
+ core_ids = null;
+}
+
+public class Notifier {
+ private static Notifier? instance = null;
+
+ public signal void pluggable_activation(Spit.Pluggable pluggable, bool enabled);
+
+ private Notifier() {
+ }
+
+ public static Notifier get_instance() {
+ if (instance == null)
+ instance = new Notifier();
+
+ return instance;
+ }
+}
+
+public void register_extension_point(Type type, string name, string? icon_name, string[]? core_ids) {
+ // if this assertion triggers, it means this extension point has already registered
+ assert(!extension_points.has_key(type));
+
+ extension_points.set(type, new ExtensionPoint(type, name, icon_name, core_ids));
+
+ // add core IDs to master list
+ if (core_ids != null) {
+ foreach (string core_id in core_ids)
+ Plugins.core_ids.add(core_id);
+ }
+
+ // activate all the pluggables for this extension point
+ foreach (PluggableRep pluggable_rep in pluggable_table.values) {
+ if (!pluggable_rep.pluggable.get_type().is_a(type))
+ continue;
+
+ pluggable_rep.activate();
+ Notifier.get_instance().pluggable_activation(pluggable_rep.pluggable, pluggable_rep.is_enabled());
+ }
+}
+
+public Gee.Collection<Spit.Pluggable> get_pluggables(bool include_disabled = false) {
+ Gee.Collection<Spit.Pluggable> all = new Gee.HashSet<Spit.Pluggable>();
+ foreach (PluggableRep pluggable_rep in pluggable_table.values) {
+ if (pluggable_rep.activated && (include_disabled || pluggable_rep.is_enabled()))
+ all.add(pluggable_rep.pluggable);
+ }
+
+ return all;
+}
+
+public bool is_core_pluggable(Spit.Pluggable pluggable) {
+ return core_ids.contains(pluggable.get_id());
+}
+
+private ModuleRep? get_module_for_pluggable(Spit.Pluggable needle) {
+ foreach (ModuleRep module_rep in module_table.values) {
+ Spit.Pluggable[]? pluggables = module_rep.spit_module.get_pluggables();
+ if (pluggables != null) {
+ foreach (Spit.Pluggable pluggable in pluggables) {
+ if (pluggable == needle)
+ return module_rep;
+ }
+ }
+ }
+
+ return null;
+}
+
+public string? get_pluggable_module_id(Spit.Pluggable needle) {
+ ModuleRep? module_rep = get_module_for_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) {
+ Gee.Collection<ExtensionPoint> sorted = new Gee.TreeSet<ExtensionPoint>((owned) compare_func);
+ sorted.add_all(extension_points.values);
+
+ return sorted;
+}
+
+public Gee.Collection<Spit.Pluggable> get_pluggables_for_type(Type type,
+ owned CompareDataFunc? 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));
+
+ Gee.Collection<Spit.Pluggable> for_type = new Gee.TreeSet<Spit.Pluggable>((owned) compare_func);
+ foreach (PluggableRep pluggable_rep in pluggable_table.values) {
+ if (pluggable_rep.activated
+ && pluggable_rep.pluggable.get_type().is_a(type)
+ && (include_disabled || pluggable_rep.is_enabled())) {
+ for_type.add(pluggable_rep.pluggable);
+ }
+ }
+
+ return for_type;
+}
+
+public string? get_pluggable_name(string id) {
+ PluggableRep? pluggable_rep = pluggable_table.get(id);
+
+ return (pluggable_rep != null && pluggable_rep.activated)
+ ? pluggable_rep.pluggable.get_pluggable_name() : null;
+}
+
+public bool get_pluggable_info(string id, ref Spit.PluggableInfo info) {
+ PluggableRep? pluggable_rep = pluggable_table.get(id);
+ if (pluggable_rep == null || !pluggable_rep.activated)
+ return false;
+
+ pluggable_rep.pluggable.get_info(ref info);
+
+ return true;
+}
+
+public bool get_pluggable_enabled(string id, out bool enabled) {
+ PluggableRep? pluggable_rep = pluggable_table.get(id);
+ if (pluggable_rep == null || !pluggable_rep.activated) {
+ enabled = false;
+
+ return false;
+ }
+
+ enabled = pluggable_rep.is_enabled();
+
+ return true;
+}
+
+public void set_pluggable_enabled(string id, bool enabled) {
+ PluggableRep? pluggable_rep = pluggable_table.get(id);
+ if (pluggable_rep == null || !pluggable_rep.activated)
+ return;
+
+ if (pluggable_rep.set_enabled(enabled))
+ Notifier.get_instance().pluggable_activation(pluggable_rep.pluggable, enabled);
+}
+
+public File get_pluggable_module_file(Spit.Pluggable pluggable) {
+ ModuleRep? module_rep = get_module_for_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_extension_point_names(void *a, void *b) {
+ ExtensionPoint *apoint = (ExtensionPoint *) a;
+ ExtensionPoint *bpoint = (ExtensionPoint *) b;
+
+ return apoint->name.collate(bpoint->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;
+}
+
+private void search_for_plugins(File dir) throws Error {
+ debug("Searching %s for plugins ...", dir.get_path());
+
+ // build a set of module names sans file extension ... this is to deal with the question of
+ // .so vs. .la existing in the same directory (and letting GModule deal with the problem)
+ FileEnumerator enumerator = dir.enumerate_children(Util.FILE_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ for (;;) {
+ FileInfo? info = enumerator.next_file(null);
+ if (info == null)
+ break;
+
+ if (info.get_is_hidden())
+ continue;
+
+ File file = dir.get_child(info.get_name());
+
+ switch (info.get_file_type()) {
+ case FileType.DIRECTORY:
+ try {
+ search_for_plugins(file);
+ } catch (Error err) {
+ warning("Unable to search directory %s for plugins: %s", file.get_path(), err.message);
+ }
+ break;
+
+ case FileType.REGULAR:
+ if (is_shared_library(file))
+ load_module(file);
+ break;
+
+ default:
+ // ignored
+ break;
+ }
+ }
+}
+
+private void load_module(File file) {
+ ModuleRep? module_rep = ModuleRep.open(file);
+ if (module_rep == null) {
+ critical("Unable to load module %s: %s", file.get_path(), Module.error());
+
+ return;
+ }
+
+ // look for the well-known entry point
+ void *entry;
+ if (!module_rep.module.symbol(Spit.ENTRY_POINT_NAME, out entry)) {
+ critical("Unable to load module %s: well-known entry point %s not found", file.get_path(),
+ Spit.ENTRY_POINT_NAME);
+
+ return;
+ }
+
+ Spit.EntryPoint spit_entry_point = (Spit.EntryPoint) entry;
+
+ assert(MIN_SPIT_INTERFACE <= Spit.CURRENT_INTERFACE && Spit.CURRENT_INTERFACE <= MAX_SPIT_INTERFACE);
+ Spit.EntryPointParams params = Spit.EntryPointParams();
+ params.host_min_spit_interface = MIN_SPIT_INTERFACE;
+ params.host_max_spit_interface = MAX_SPIT_INTERFACE;
+ params.module_spit_interface = Spit.UNSUPPORTED_INTERFACE;
+ params.module_file = file;
+
+ module_rep.spit_module = spit_entry_point(&params);
+ if (params.module_spit_interface == Spit.UNSUPPORTED_INTERFACE) {
+ critical("Unable to load module %s: module reports no support for SPIT interfaces %d to %d",
+ file.get_path(), MIN_SPIT_INTERFACE, MAX_SPIT_INTERFACE);
+
+ return;
+ }
+
+ if (params.module_spit_interface < MIN_SPIT_INTERFACE || params.module_spit_interface > MAX_SPIT_INTERFACE) {
+ critical("Unable to load module %s: module reports unsupported SPIT version %d (out of range %d to %d)",
+ file.get_path(), module_rep.spit_interface, MIN_SPIT_INTERFACE, MAX_SPIT_INTERFACE);
+
+ return;
+ }
+
+ module_rep.spit_interface = params.module_spit_interface;
+
+ // verify type (as best as possible; still potential to segfault inside GType here)
+ if (!(module_rep.spit_module is Spit.Module))
+ module_rep.spit_module = null;
+
+ if (module_rep.spit_module == null) {
+ critical("Unable to load module %s (SPIT %d): no spit module returned", file.get_path(),
+ module_rep.spit_interface);
+
+ return;
+ }
+
+ // if module has already been loaded, drop this one (search path is set up to load user-installed
+ // binaries prior to system binaries)
+ module_rep.id = prepare_input_text(module_rep.spit_module.get_id(), PrepareInputTextOptions.DEFAULT, -1);
+ if (module_rep.id == null) {
+ critical("Unable to load module %s (SPIT %d): invalid or empty module name",
+ file.get_path(), module_rep.spit_interface);
+
+ return;
+ }
+
+ if (module_table.has_key(module_rep.id)) {
+ critical("Not loading module %s (SPIT %d): module with name \"%s\" already loaded",
+ file.get_path(), module_rep.spit_interface, module_rep.id);
+
+ return;
+ }
+
+ debug("Loaded SPIT module \"%s %s\" (%s) [%s]", module_rep.spit_module.get_module_name(),
+ module_rep.spit_module.get_version(), module_rep.id, file.get_path());
+
+ // stash in module table by their ID
+ module_table.set(module_rep.id, module_rep);
+
+ // stash pluggables in pluggable table by their ID
+ foreach (Spit.Pluggable pluggable in module_rep.spit_module.get_pluggables())
+ pluggable_table.set(pluggable.get_id(), new PluggableRep(pluggable));
+}
+
+}
+
diff --git a/src/plugins/PublishingInterfaces.vala b/src/plugins/PublishingInterfaces.vala
new file mode 100644
index 0000000..ca74597
--- /dev/null
+++ b/src/plugins/PublishingInterfaces.vala
@@ -0,0 +1,605 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Shotwell Pluggable Publishing API
+ *
+ * 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.
+ * 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
+ * plugins supported by {@link Spit}, the Shotwell Pluggable Interfaces Technology.
+ */
+namespace Spit.Publishing {
+
+/**
+ * The current version of the Pluggable Publishing API
+ */
+public const int CURRENT_INTERFACE = 0;
+
+/**
+ * Defines different kinds of errors that can occur during publishing.
+ */
+public errordomain PublishingError {
+ /**
+ * Indicates that no communications channel could be opened to the remote host.
+ *
+ * This error occurs, for example, when no network connection is available or
+ * when a DNS lookup fails.
+ */
+ NO_ANSWER,
+
+ /**
+ * Indicates that a communications channel to the remote host was previously opened, but
+ * the remote host can no longer be reached.
+ *
+ * This error occurs, for example, when the network is disconnected during a publishing
+ * interaction.
+ */
+ COMMUNICATION_FAILED,
+
+ /**
+ * Indicates that a communications channel to the remote host was opened and
+ * is active, but that messages sent to or from the remote host can't be understood.
+ *
+ * This error occurs, for example, when attempting to interact with a RESTful host
+ * via XML-RPC.
+ */
+ PROTOCOL_ERROR,
+
+ /**
+ * Indicates that the remote host has received a well-formed message that has caused
+ * a server-side error.
+ *
+ * This error occurs, for example, when the remote host receives a message that should
+ * be signed but isn't.
+ */
+ SERVICE_ERROR,
+
+ /**
+ * Indicates that the remote host has sent the local client back a well-formed response,
+ * but the response can't be understood.
+ *
+ * This error occurs, for example, when the remote host sends a response in an XML grammar
+ * different from that expected by the local client.
+ */
+ MALFORMED_RESPONSE,
+
+ /**
+ * Indicates that the local client can't access a file or files in local storage.
+ *
+ * This error occurs, for example, when the local client attempts to read binary data
+ * out of a photo or video file that doesn't exist.
+ */
+ LOCAL_FILE_ERROR,
+
+ /**
+ * Indicates that the remote host has rejected the session identifier used by the local
+ * client as out-of-date. The local client should acquire a new session identifier.
+ */
+ EXPIRED_SESSION
+}
+
+/**
+ * Represents a connection to a publishing service.
+ *
+ * Developers of publishing plugins provide a class that implements this interface. At
+ * any given time, only one Publisher can be running. When a publisher is running, it is
+ * allowed to access the network and has exclusive use of the shared user-interface and
+ * configuration services provided by the {@link PluginHost}. Publishers are created in
+ * a non-running state and do not begin running until start( ) is invoked. Publishers
+ * run until stop( ) is invoked.
+ */
+public interface Publisher : GLib.Object {
+ /**
+ * Describes the kinds of media a publishing service supports.
+ *
+ * Values can be masked together, for example: {{{(MediaType.PHOTO | MediaType.VIDEO)}}}
+ * indicates that a publishing service supports the upload of both photos and videos.
+ */
+ public enum MediaType {
+ NONE = 0,
+ PHOTO = 1 << 0,
+ VIDEO = 1 << 1
+ }
+
+ /**
+ * Returns a {@link Service} object describing the service to which this connects.
+ */
+ public abstract Service get_service();
+
+ /**
+ * Makes this publisher enter the running state and endows it with exclusive access
+ * to the shared services provided by the {@link PluginHost}. Through the host’s interface,
+ * this publisher can install user interface panes and query configuration information.
+ * Only running services should perform network operations.
+ */
+ public abstract void start();
+
+ /**
+ * Returns true if this publisher is in the running state; false otherwise.
+ */
+ public abstract bool is_running();
+
+ /**
+ * Causes this publisher to enter a non-running state. This publisher should stop all
+ * network operations and cease use of the shared services provided by the {@link PluginHost}.
+ */
+ public abstract void stop();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Encapsulates a pane that can be installed in the on-screen publishing dialog box to
+ * communicate status to and to get information from the user.
+ *
+ */
+public interface DialogPane : GLib.Object {
+
+ /**
+ * Describes how the on-screen publishing dialog box should look and behave when an associated
+ * pane is installed in the on-screen publishing dialog box.
+ */
+ public enum GeometryOptions {
+
+ /**
+ * When the associated pane is installed, the on-screen publishing dialog box will be
+ * sized normally and will not allow the user to change its size.
+ */
+ NONE = 0,
+
+ /**
+ * If this bit is set, when the associated pane is installed, the on-screen publishing
+ * dialog box will grow to a larger size.
+ */
+ EXTENDED_SIZE = 1 << 0,
+
+ /**
+ * If this bit is set, when the associated pane is installed, the on-screen publishing
+ * dialog box will allow the user to change its size.
+ */
+ RESIZABLE = 1 << 1,
+
+ /**
+ * If this bit is set, when the associated pane is installed, the on-screen publishing
+ * dialog box will grow to accommodate a full-width 1024 pixel web page. If both
+ * EXTENDED_SIZE and COLOSSAL_SIZE are set, EXTENDED_SIZE takes precedence.
+ */
+ COLOSSAL_SIZE = 1 << 2;
+ }
+
+ /**
+ * Returns the Gtk.Widget that is this pane's on-screen representation.
+ */
+ public abstract Gtk.Widget get_widget();
+
+ /**
+ * Returns a {@link GeometryOptions} bitfield describing how the on-screen publishing dialog
+ * box should look and behave when this pane is installed.
+ */
+ public abstract GeometryOptions get_preferred_geometry();
+
+ /**
+ * Invoked automatically by Shotwell when this pane has been installed into the on-screen
+ * publishing dialog box and become visible to the user.
+ */
+ public abstract void on_pane_installed();
+
+ /**
+ * Invoked automatically by Shotwell when this pane has been removed from the on-screen
+ * publishing dialog box and is no longer visible to the user.
+ */
+ public abstract void on_pane_uninstalled();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Enables its caller to report to the user on the progress of a publishing operation.
+ *
+ * @param file_number the sequence number of media item that the publishing system is currently
+ * working with, starting at 1. For example, if the user chooses to publish
+ * 4 photos, these photos would have sequence numbers 1, 2, 3, and 4.
+ *
+ * @param fraction_complete the fraction of the current publishing operation that has been
+ * completed, from 0.0 to 1.0, inclusive.
+ */
+public delegate void ProgressCallback(int file_number, double fraction_complete);
+
+/**
+ * Called by the publishing system when the user clicks the 'Login' button in a service welcome
+ * pane.
+ */
+public delegate void LoginCallback();
+
+/**
+ * Manages and provides services for publishing plugins.
+ *
+ * Implemented inside Shotwell, the PluginHost provides an interface through which the
+ * developers of publishing plugins can query and make changes to the publishing
+ * environment. For example, through the PluginHost, plugins can get a list of the photos
+ * and videos to be published, install and remove user-interface panes in the publishing
+ * dialog box, and request that the items to be uploaded be serialized to a temporary
+ * directory on disk. Plugins can use the services of the PluginHost only when their
+ * {@link Publisher} is in the running state. This ensures that non-running publishers
+ * don’t destructively interfere with the actively running publisher.
+ */
+public interface PluginHost : GLib.Object, Spit.HostInterface {
+
+ /**
+ * Specifies the label text on the push button control that appears in the
+ * lower-right-hand corner of the on-screen publishing dialog box.
+ */
+ public enum ButtonMode {
+ CLOSE = 0,
+ CANCEL = 1
+ }
+
+ /**
+ * Notifies the user that an unrecoverable publishing error has occurred and halts
+ * the publishing process.
+ *
+ * @param err An error object that describes the kind of error that occurred.
+ */
+ public abstract void post_error(Error err);
+
+ /**
+ * Halts the publishing process.
+ *
+ * Calling this method stops all network activity and hides the on-screen publishing
+ * dialog box.
+ */
+ public abstract void stop_publishing();
+
+ /**
+ * Returns a reference to the {@link Publisher} object that this is currently hosting.
+ */
+ public abstract Publisher get_publisher();
+
+ /**
+ * Attempts to install a pane in the on-screen publishing dialog box, making the pane visible
+ * and allowing it to interact with the user.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param pane the pane to install
+ *
+ * @param mode allows you to set the text displayed on the close/cancel button in the
+ * lower-right-hand corner of the on-screen publishing dialog box when pane is installed.
+ * If mode is ButtonMode.CLOSE, the button will have the title "Close." If mode is
+ * ButtonMode.CANCEL, the button will be titled "Cancel." You should set mode depending on
+ * whether a cancellable action is in progress. For example, if your publisher is in the
+ * middle of uploading 3 of 8 videos, then mode should be ButtonMode.CANCEL. However, if
+ * the publishing operation has completed and the success pane is displayed, then mode
+ * should be ButtonMode.CLOSE, because all cancellable publishing actions have already
+ * occurred.
+ */
+ public abstract void install_dialog_pane(Spit.Publishing.DialogPane pane,
+ ButtonMode mode = ButtonMode.CANCEL);
+
+ /**
+ * Attempts to install a pane in the on-screen publishing dialog box that contains
+ * static text.
+ *
+ * The text appears centered in the publishing dialog box and is drawn in
+ * the system font. This is a convenience method only; similar results could be
+ * achieved by manually constructing a Gtk.Label widget, wrapping it inside a
+ * {@link DialogPane}, and installing it manually with a call to
+ * install_dialog_pane( ). To provide visual consistency across publishing services,
+ * however, always use this convenience method instead of constructing label panes when
+ * you need to display static text to the user.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param message the text to show in the pane
+ *
+ * @param mode allows you to set the text displayed on the close/cancel button in the
+ * lower-right-hand corner of the on-screen publishing dialog box when pane is installed.
+ * If mode is ButtonMode.CLOSE, the button will have the title "Close." If mode is
+ * ButtonMode.CANCEL, the button will be titled "Cancel." You should set mode depending on
+ * whether a cancellable action is in progress. For example, if your publisher is in the
+ * middle of uploading 3 of 8 videos, then mode should be ButtonMode.CANCEL. However, if
+ * the publishing operation has completed and the success pane is displayed, then mode
+ * should be ButtonMode.CLOSE, because all cancellable publishing actions have already
+ * occurred.
+ */
+ public abstract void install_static_message_pane(string message,
+ ButtonMode mode = ButtonMode.CANCEL);
+
+ /**
+ * Works just like {@link install_static_message_pane} but allows markup to contain
+ * Pango text formatting tags as well as unstyled text.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param markup the text to show in the pane, marked up with Pango formatting tags.
+ *
+ * @param mode allows you to set the text displayed on the close/cancel button in the
+ * lower-right-hand corner of the on-screen publishing dialog box when pane is installed.
+ * If mode is ButtonMode.CLOSE, the button will have the title "Close." If mode is
+ * ButtonMode.CANCEL, the button will be titled "Cancel." You should set mode depending on
+ * whether a cancellable action is in progress. For example, if your publisher is in the
+ * middle of uploading 3 of 8 videos, then mode should be ButtonMode.CANCEL. However, if
+ * the publishing operation has completed and the success pane is displayed, then mode
+ * should be ButtonMode.CLOSE, because all cancellable publishing actions have already
+ * occurred.
+ */
+ public abstract void install_pango_message_pane(string markup,
+ ButtonMode mode = ButtonMode.CANCEL);
+
+ /**
+ * Attempts to install a pane in the on-screen publishing dialog box notifying the user
+ * that his or her publishing operation completed successfully.
+ *
+ * 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.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor
+ * this request.
+ */
+ public abstract void install_success_pane();
+
+ /**
+ * Attempts to install a pane displaying the static text “Fetching account information...”
+ * in the on-screen publishing dialog box, making it visible to the user.
+ *
+ * This is a convenience method only; similar results could be achieved by calling
+ * {@link install_static_message_pane} with an appropriate text argument. To provide
+ * visual consistency across publishing services and to allow Shotwell to handle
+ * internationalization, however, you should always use this convenience method whenever
+ * you need to tell the user that you’re querying account information over the network.
+ * Queries such as this are almost always performed immediately after the user has logged
+ * in to the remote service.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ */
+ public abstract void install_account_fetch_wait_pane();
+
+
+ /**
+ * Works just like {@link install_account_fetch_wait_pane} but displays the static text
+ * “Logging in...“
+ *
+ * As with {@link install_account_fetch_wait_pane}, this is a convenience method, but
+ * you should you use it provide to visual consistency and to let Shotwell handle
+ * internationalization. See the description of {@link install_account_fetch_wait_pane}
+ * for more information.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ */
+ public abstract void install_login_wait_pane();
+
+ /**
+ * Attempts to install a pane displaying the text 'welcome_message' above a push
+ * button labeled “Login” in the on-screen publishing dialog box, making it visible to the
+ * user.
+ *
+ * When the user clicks the “Login” button, you’ll be notified of the user’s action through
+ * 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
+ * welcome panes manually.
+ *
+ * If an error has posted, the {@link PluginHost} will not honor this request.
+ *
+ * @param welcome_message the text to be displayed above a push button labeled “Login”
+ * in the on-screen publishing dialog box.
+ *
+ * @param on_login_clicked specifies the callback that is invoked when the user clicks
+ * the “Login” button.
+ */
+ public abstract void install_welcome_pane(string welcome_message,
+ LoginCallback on_login_clicked);
+
+ /**
+ * Toggles whether the service selector combo box in the upper-right-hand corner of the
+ * on-screen publishing dialog box is sensitive to input.
+ *
+ * Publishers should make the service selector box insensitive to input when they are performing
+ * non-interruptible file or network operations, since switching to another publishing
+ * service will halt whatever service is currently running. Under certain circumstances,
+ * the {@link PluginHost} may not honor this request.
+ *
+ * @param is_locked when is_locked is true, the service selector combo box is made insensitive.
+ * It appears greyed out and the user is prevented from switching to another publishing service.
+ * When is_locked is false, the combo box is sensitive, allowing the user to freely switch
+ * from the current service to another service.
+ */
+ public abstract void set_service_locked(bool is_locked);
+
+ /**
+ * Makes the designated widget the default widget for the publishing dialog.
+ *
+ * After a call to this method, the designated widget will be activated whenever the user
+ * presses the [ENTER] key anywhere in the on-screen publishing dialog box. Under certain
+ * circumstances, the {@link PluginHost} may not honor this request.
+ *
+ * @param widget a reference to the widget to designate as the default widget for the
+ * publishing dialog.
+ */
+ public abstract void set_dialog_default_widget(Gtk.Widget widget);
+
+ /**
+ * Returns an array of the publishable media items that the user has selected for upload to the
+ * remote service.
+ */
+ public abstract Publishable[] get_publishables();
+
+ /**
+ * Writes all of the publishable media items that the user has selected for upload to the
+ * remote service to a temporary directory on a local disk.
+ *
+ * You should call this method immediately before sending the publishable media items to the
+ * remote service over the network. Because serializing several megabytes of data is a
+ * potentially lengthy operation, calling this method installs an activity status pane in
+ * the on-screen publishing dialog box. The activity status pane displays a progress bar along
+ * with a string of informational text.
+ *
+ * Because sending items over the network to the remote service is also a potentially lengthy
+ * operation, you should leave the activity status pane installed in the on-screen publishing
+ * dialog box until this task is finished. Periodically during the sending process, you should
+ * report to the user on the progress of his or her upload. You can do this by invoking the
+ * returned {@link ProgressCallback} delegate.
+ *
+ * After calling this method, the activity status pane that this method installs remains
+ * displayed in the on-screen publishing dialog box until you install a new pane.
+ *
+ * @param content_major_axis when serializing publishable media items that are photos,
+ * ensure that neither the width nor the height of the serialized
+ * photo is greater than content_major_axis pixels. The value of
+ * this parameter has no effect on video publishables.
+ *
+ * @param strip_metadata when serializing publishable media items that are photos, if
+ * strip_metadata is true, all EXIF, IPTC, and XMP metadata will be
+ * removed from the serialized file. If strip_metadata is false, all
+ * metadata will be left intact. The value of this parameter has no
+ * effect on video publishables.
+ */
+ public abstract ProgressCallback? serialize_publishables(int content_major_axis,
+ bool strip_metadata = false);
+
+ /**
+ * Returns a {@link Publisher.MediaType} bitfield describing which kinds of media are present
+ * in the set of publishable media items that the user has selected for upload to the remote
+ * service.
+ */
+ public abstract Spit.Publishing.Publisher.MediaType get_publishable_media_type();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Describes an underlying media item (such as a photo or a video) that your plugin
+ * uploads to a remote publishing service.
+ */
+public interface Publishable : GLib.Object {
+
+ public static const string PARAM_STRING_BASENAME = "basename";
+ public static const string PARAM_STRING_TITLE = "title";
+ public static const string PARAM_STRING_COMMENT = "comment";
+ public static const string PARAM_STRING_EVENTCOMMENT= "eventcomment";
+
+ /**
+ * Returns a handle to the file on disk to which this publishable's data has been
+ * serialized.
+ *
+ * You should use this file handle to read into memory the binary data you will send over
+ * the network to the remote publishing service when this publishable is uploaded.
+ */
+ public abstract GLib.File? get_serialized_file();
+
+ /**
+ * Returns a name that can be used to identify this publishable to the remote service.
+ * If the publishing host cannot derive a sensible name, this method will
+ * return an empty string. Plugins should be able to handle that situation
+ * and provide a fallback value. One possible option for a fallback is:
+ * get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME)
+ */
+ public abstract string get_publishing_name();
+
+ /**
+ * Returns a string value from the publishable corresponding with the parameter name
+ * provided, or null if there is no value for this name.
+ */
+ public abstract string? get_param_string(string name);
+
+ /**
+ * Returns an array of strings that should be used to tag or mark this publishable on the
+ * remote service, or null if this publishable has no tags or markings.
+ */
+ public abstract string[] get_publishing_keywords();
+
+ /**
+ * Returns the kind of media item this publishable encapsulates.
+ */
+ public abstract Spit.Publishing.Publisher.MediaType get_media_type();
+
+ /**
+ * Returns the creation timestamp on the file.
+ */
+ public abstract GLib.DateTime get_exposure_date_time();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Describes the features and capabilities of a remote publishing service.
+ *
+ * Developers of publishing plugins provide a class that implements this interface.
+ */
+public interface Service : Object, Spit.Pluggable {
+ /**
+ * A factory method that instantiates and returns a new {@link Publisher} object that
+ * encapsulates a connection to the remote publishing service that this Service describes.
+ */
+ public abstract Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host);
+
+ /**
+ * Returns the kinds of media that this service can work with.
+ */
+ public abstract Spit.Publishing.Publisher.MediaType get_supported_media();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+}
+
diff --git a/src/plugins/SpitInterfaces.vala b/src/plugins/SpitInterfaces.vala
new file mode 100644
index 0000000..f2fce6f
--- /dev/null
+++ b/src/plugins/SpitInterfaces.vala
@@ -0,0 +1,367 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Shotwell Pluggable Interface Technology (SPIT)
+ *
+ * This is the front-end interface for all modules (i.e. .so/.la files) that allows for Shotwell
+ * to query them for information and to get a list of all plug-ins stored in the module. This
+ * is named Shotwell Pluggable Interface Technology (SPIT). This is intended only to last long
+ * enough for another generic plug-in library (most likely Peas) to be used later.
+ *
+ * The Spit namespace is used for all interfaces and code that are made available to plugins or
+ * are exposed by plugins.
+ *
+ * More information can be found at [[https://wiki.gnome.org/Apps/Shotwell/Architecture/WritingPlugins]]
+ */
+namespace Spit {
+
+/**
+ * Reserved interface value denoting an unsupported interface version.
+ *
+ * All interface versions should be zero-based and incrementing.
+ */
+public const int UNSUPPORTED_INTERFACE = -1;
+
+/**
+ * Current version of the SPIT interface.
+ */
+public const int CURRENT_INTERFACE = 0;
+
+/**
+ * A utility function for checking host interfaces against one's own and returning the right value.
+ *
+ * Note that this only works if the caller operates on only one interface version (and cannot mutate
+ * between multiple ones).
+ *
+ * @param min_host_interface The minimum supported host interface version.
+ * @param max_host_interface The maximum supported host interface version.
+ * @param plugin_interface The interface version supported by the Pluggable.
+ *
+ * @return The plugin's interface version if supported, {@link UNSUPPORTED_INTERFACE} otherwise.
+ */
+public int negotiate_interfaces(int min_host_interface, int max_host_interface, int plugin_interface) {
+ return (min_host_interface > plugin_interface || max_host_interface < plugin_interface)
+ ? UNSUPPORTED_INTERFACE : plugin_interface;
+}
+
+/**
+ * SPIT entry point parameters.
+ *
+ * The host application passes a pointer to this structure for the module's information.
+ * The pointer should //not// be held, as it may be freed or reused by the host application
+ * after calling the entry point. The module should copy any information it may need (or hold
+ * a GObject reference) in its own memory space.
+ *
+ * Note that the module //must// fill in the module_spit_interface field with the SPIT interface
+ * version it understands prior to returning control.
+ */
+public struct EntryPointParams {
+ /**
+ * The host's minimum supported interface version.
+ */
+ public int host_min_spit_interface;
+ /**
+ * The host's maximum supported interface version.
+ */
+ public int host_max_spit_interface;
+ /**
+ * The module returns here the interface version of SPIT it supports,
+ * {@link UNSUPPORTED_INTERFACE} otherwise.
+ */
+ public int module_spit_interface;
+ /**
+ * A File object representing the library file (.so/la.) that the plugin was loaded from.
+ */
+ public File module_file;
+}
+
+/**
+ * SPIT API entry point.
+ *
+ * Host application passes in the minimum and maximum version of the SPIT
+ * interface it supports (values are inclusive) in the {@link EntryPointParams} struct.
+ * The module returns the version it wishes to use and a pointer to a {@link Spit.Module} (which
+ * will remain ref'ed by the host as long as the module is loaded in memory). The module should
+ * return {@link UNSUPPORTED_INTERFACE} if the min/max are out of its range and null for its
+ * Spit.Module. ({@link negotiate_interfaces} is good for dealing with this.)
+ *
+ * @return A {@link Spit.Module} if the interface negotiation is acceptable, null otherwise.
+ */
+[CCode (has_target = false)]
+public delegate Module? EntryPoint(EntryPointParams *params);
+
+/**
+ * SPIT entry point name, which matches {@link EntryPoint}'s interface
+ */
+public const string ENTRY_POINT_NAME = "spit_entry_point";
+
+/**
+ * A Module represents the resources of an entire dynamically-linked module (i.e. a .so/.la).
+ *
+ * A module holds zero or more Shotwell plugins ({@link Pluggable}). Once the module has been
+ * loaded into process space this object is retrieved by Shotwell. All calls to the module and
+ * its plugins are resolved through this interface.
+ *
+ * Note: The module is responsible for holding the reference to the Module object, of which there
+ * should be only one in the library file. The module should implement a g_module_unload method
+ * and drop the reference there.
+ */
+public interface Module : Object {
+ /**
+ * Returns a user-visible string describing the module.
+ */
+ public abstract unowned string get_module_name();
+
+ /**
+ * Returns a user-visible string describing the module version.
+ *
+ * Note that this may be programmatically interpreted at some point, so use a widespread
+ * versioning scheme.
+ */
+ public abstract unowned string get_version();
+
+ /**
+ * Returns a unique identifier for this module.
+ *
+ * This is used to differentiate between multiple
+ * installed versions and to determine which one should be used (i.e. if a module is available
+ * in a system directory and a user directory). This name is case-sensitive.
+ *
+ * Best practice: use a reverse-DNS-order scheme, a la Java's packages
+ * (i.e. "org.yorba.shotwell.frotz").
+ */
+ public abstract unowned string get_id();
+
+ /**
+ * Returns an array of {@link Pluggable} that represent each plugin available in the module.
+ *
+ * May return NULL or an empty array.
+ */
+ public abstract unowned Pluggable[]? get_pluggables();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ 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;
+ /**
+ * 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;
+}
+
+/**
+ * A generic interface to all Shotwell plugins.
+ *
+ * Each plugin in a module needs to implement this interface at a minimum. Extension
+ * points may have (and probably will have) specific interface requirements as well.
+ */
+public interface Pluggable : Object {
+ /**
+ * Pluggable interface version negotiation.
+ *
+ * Like the {@link EntryPoint}, this mechanism allows for the host to negotiate with the Pluggable
+ * for its interface version. If the pluggable does not support an interface between the
+ * two ranges (inclusive), it should return {@link UNSUPPORTED_INTERFACE}.
+ *
+ * Note that this is ''not'' a negotiation of the SPIT interface versions (which is the
+ * responsibility of {@link EntryPoint}. Rather, each extension point is expected to version
+ * its own cluster of interfaces. It is that interface version that is being negotiated here.
+ *
+ * {@link negotiate_interfaces} can be used to implement this method.
+ *
+ * @param min_host_interface The host's minimum supported interface version number
+ * //for this Pluggable's intended extension point//.
+ * @param max_host_interface The host's maximum supported interface version number
+ * //for this Pluggable's intended extension point//.
+ *
+ * @return The version number supported by the host and the Pluggable or
+ * {@link UNSUPPORTED_INTERFACE}.
+ */
+ public abstract int get_pluggable_interface(int min_host_interface, int max_host_interface);
+
+ /**
+ * Returns a unique identifier for this Pluggable.
+ *
+ * Like {@link Module.get_id}, best practice is to use a reverse-DNS-order scheme to avoid
+ * conflicts.
+ */
+ public abstract unowned string get_id();
+
+ /**
+ * Returns a user-visible name for the Pluggable.
+ */
+ public abstract unowned string get_pluggable_name();
+
+ /**
+ * Returns extra information about the Pluggable that is used to identify it to the user.
+ */
+ public abstract void get_info(ref PluggableInfo info);
+
+ /**
+ * Called when the Pluggable is enabled (activated) or disabled (deactivated).
+ *
+ * activation will be called at the start of the program if the user previously
+ * enabled/disabled it as well as during program execution if the user changes its state. Note
+ * that disabling a Pluggable does not require destroying existing resources or objects
+ * the Pluggable has previously handed off to the host.
+ *
+ * This is purely informational. The Pluggable should acquire any long-term resources
+ * it may be holding onto here, or wait until an extension-specific call is made to it.
+ *
+ * @param enabled ``true`` if the Pluggable has been enabled, ``false`` otherwise.
+ */
+ public abstract void activation(bool enabled);
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * An interface to common services supplied by the host (Shotwell).
+ *
+ * Each {@link Pluggable} is offered a HostInterface for needs common to most plugins.
+ *
+ * Note that
+ * a HostInterface is not explicitly handed to the Pluggable through the SPIT interface, but is expected
+ * to be offered to the Pluggable through an interface applicable to the extension point. This
+ * also allows the extension point to extend HostInterface to offer other services applicable to the
+ * type of plugin.
+ */
+public interface HostInterface : Object {
+ /**
+ * Returns a File object representing the library file (.so/la.) that the plugin was loaded
+ * from.
+ */
+ public abstract File get_module_file();
+
+ /**
+ * Get a boolean from a persistent configuration store.
+ *
+ * @param key The name of the value to be retrieved.
+ * @param def The default value (returned if the key has not been previously set).
+ *
+ * @return The value associated with key, def if not set.
+ */
+ public abstract bool get_config_bool(string key, bool def);
+
+ /**
+ * Store a boolean in a persistent configuration store.
+ *
+ * @param key The name of the value to be stored.
+ * @param val The value to be stored.
+ */
+ public abstract void set_config_bool(string key, bool val);
+
+ /**
+ * Get an integer from a persistent configuration store.
+ *
+ * @param key The name of the value to be retrieved.
+ * @param def The default value (returned if the key has not been previously set).
+ *
+ * @return The value associated with key, def if not set.
+ */
+ public abstract int get_config_int(string key, int def);
+
+ /**
+ * Store an integer in a persistent configuration store.
+ *
+ * @param key The name of the value to be stored.
+ * @param val The value to be stored.
+ */
+ public abstract void set_config_int(string key, int val);
+
+ /**
+ * Get a string from a persistent configuration store.
+ *
+ * @param key The name of the value to be retrieved.
+ * @param def The default value (returned if the key has not been previously set).
+ *
+ * @return The value associated with key, def if not set.
+ */
+ public abstract string? get_config_string(string key, string? def);
+
+ /**
+ * Store a string in a persistent configuration store.
+ *
+ * @param key The name of the value to be stored.
+ * @param val The value to be stored.
+ */
+ public abstract void set_config_string(string key, string? val);
+
+ /**
+ * Get a double from a persistent configuration store.
+ *
+ * @param key The name of the value to be retrieved.
+ * @param def The default value (returned if the key has not been previously set).
+ *
+ * @return The value associated with key, def if not set.
+ */
+ public abstract double get_config_double(string key, double def);
+
+ /**
+ * Store a double in a persistent configuration store.
+ *
+ * @param key The name of the value to be stored.
+ * @param val The value to be stored.
+ */
+ public abstract void set_config_double(string key, double val);
+
+ /**
+ * Delete the value from the persistent configuration store.
+ */
+ public abstract void unset_config_key(string key);
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+}
+
diff --git a/src/plugins/StandardHostInterface.vala b/src/plugins/StandardHostInterface.vala
new file mode 100644
index 0000000..9bfc0aa
--- /dev/null
+++ b/src/plugins/StandardHostInterface.vala
@@ -0,0 +1,84 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Plugins {
+
+public class StandardHostInterface : Object, Spit.HostInterface {
+ private string config_domain;
+ private string config_id;
+ private File module_file;
+ private Spit.PluggableInfo info;
+
+ public StandardHostInterface(Spit.Pluggable pluggable, string config_domain) {
+ this.config_domain = config_domain;
+ config_id = parse_key(pluggable.get_id());
+ module_file = get_pluggable_module_file(pluggable);
+ pluggable.get_info(ref 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.picasa":
+ return "picasa";
+
+ case "org.yorba.shotwell.publishing.flickr":
+ return "flickr";
+
+ case "org.yorba.shotwell.publishing.youtube":
+ return "youtube";
+
+ default:
+ return id;
+ }
+ }
+
+ public File get_module_file() {
+ return module_file;
+ }
+
+ public bool get_config_bool(string key, bool def) {
+ return Config.Facade.get_instance().get_plugin_bool(config_domain, config_id, key, def);
+ }
+
+ public void set_config_bool(string key, bool val) {
+ Config.Facade.get_instance().set_plugin_bool(config_domain, config_id, key, val);
+ }
+
+ public int get_config_int(string key, int def) {
+ return Config.Facade.get_instance().get_plugin_int(config_domain, config_id, key, def);
+ }
+
+ public void set_config_int(string key, int val) {
+ Config.Facade.get_instance().set_plugin_int(config_domain, config_id, key, val);
+ }
+
+ public string? get_config_string(string key, string? def) {
+ return Config.Facade.get_instance().get_plugin_string(config_domain, config_id, key, def);
+ }
+
+ public void set_config_string(string key, string? val) {
+ Config.Facade.get_instance().set_plugin_string(config_domain, config_id, key, val);
+ }
+
+ public double get_config_double(string key, double def) {
+ return Config.Facade.get_instance().get_plugin_double(config_domain, config_id, key, def);
+ }
+
+ public void set_config_double(string key, double val) {
+ Config.Facade.get_instance().set_plugin_double(config_domain, config_id, key, val);
+ }
+
+ public void unset_config_key(string key) {
+ Config.Facade.get_instance().unset_plugin_key(config_domain, config_id, key);
+ }
+}
+
+}
diff --git a/src/plugins/TransitionsInterfaces.vala b/src/plugins/TransitionsInterfaces.vala
new file mode 100644
index 0000000..eae76cf
--- /dev/null
+++ b/src/plugins/TransitionsInterfaces.vala
@@ -0,0 +1,300 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Transitions are used in Shotwell for interstitial effects in slideshow mode. They may
+ * also be used elsewhere in future releases.
+ *
+ * Plugin writers should start by implementing a {@link Descriptor} which in turn Shotwell uses
+ * to instantiate an {@link Effect}.
+ */
+namespace Spit.Transitions {
+
+/**
+ * The current version of the Transitions plugin interface.
+ */
+public const int CURRENT_INTERFACE = 0;
+
+/**
+ * Direction indicates what direction (animated motion) the {@link Effect} should simulate the
+ * images are moving, if appropriate.
+ *
+ * The direction indicates from what side or corner of the screen the new image should come in from.
+ * Thus, a LEFT slide means the current image exits via the left-hand edge of the screen and the
+ * new image moves into place from the right-hand edge.
+ *
+ * UP, DOWN, and diagonals may be added at some point.
+ */
+public enum Direction {
+ LEFT = 0,
+ RIGHT = 1,
+
+ /**
+ * Convenience definition (for LTR readers).
+ */
+ FORWARD = LEFT,
+
+ /**
+ * Convenience definition (for LTR readers).
+ */
+ BACKWARD = RIGHT
+}
+
+/**
+ * Visuals contains the pertinent drawing information for the transition that must occur.
+ *
+ * A Visuals object is supplied to {@link Effect} at the start of the transition and during each
+ * call to paint to the screen.
+ *
+ * Note that if starting with a blank screen, from_pixbuf will be null and from_pos will be
+ * zeroed. The transition should be considered to start from a blank screen of the supplied
+ * background color.
+ *
+ * Also note that if transitioning to a blank screen, to_pixbuf will be null and to_pos will be
+ * zeroed. Like the prior case, the transition should move toward a blank screen of the background
+ * color.
+ */
+public class Visuals : Object {
+ /**
+ * Returns the starting pixbuf (the pixbuf currently on the display).
+ *
+ * If transitioning from a blank screen, this will return null.
+ */
+ public Gdk.Pixbuf? from_pixbuf { get; private set; }
+
+ /**
+ * Returns the position of the starting pixbuf on the display.
+ *
+ * If transitioning from a blank screen, this will be zeroed.
+ */
+ public Gdk.Rectangle from_pos { get; private set; }
+
+ /**
+ * Returns the ending pixbuf (the pixbuf that the transition should result in).
+ *
+ * If transitioning to a blank screen, this will return null.
+ */
+ public Gdk.Pixbuf? to_pixbuf { get; private set; }
+
+ /**
+ * Returns the position of the ending pixbuf on the display.
+ *
+ * If transitioning to a blank screen, this will be zeroed.
+ */
+ public Gdk.Rectangle to_pos { get; private set; }
+
+ /**
+ * Returns the background color of the viewport.
+ */
+ public Gdk.RGBA bg_color { get; private set; }
+
+ public Visuals(Gdk.Pixbuf? from_pixbuf, Gdk.Rectangle from_pos, Gdk.Pixbuf? to_pixbuf,
+ Gdk.Rectangle to_pos, Gdk.RGBA bg_color) {
+ this.from_pixbuf = from_pixbuf;
+ this.from_pos = from_pos;
+ this.to_pixbuf = to_pixbuf;
+ this.to_pos = to_pos;
+ this.bg_color = bg_color;
+ }
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * Motion contains all the pertinent information regarding the animation of the transition.
+ *
+ * Some of Motion's information may not apply to a transition effect (such as Direction for a
+ * fade effect).
+ */
+public class Motion : Object {
+ /**
+ * Returns the direction the transition should occur in (if pertinent to the {@link Effect}.
+ */
+ public Direction direction { get; private set; }
+
+ /**
+ * Returns the frames per second of the {@link Effect}.
+ */
+ public int fps { get; private set; }
+
+ /**
+ * Returns the amount of time the transition should take (in milliseconds).
+ */
+ public int duration_msec { get; private set; }
+
+ /**
+ * Returns the number of frames that should be required to perform the transition in the
+ * expected {@link duration_msec}.
+ */
+ public int total_frames {
+ get {
+ return (int) ((double) fps * ((double) duration_msec / 1000.0));
+ }
+ }
+
+ /**
+ * Returns the approximate time between each frame draw (in milliseconds).
+ */
+ public int tick_msec {
+ get {
+ return (int) (1000.0 / (double) fps);
+ }
+ }
+
+ public Motion(Direction direction, int fps, int duration_msec) {
+ this.direction = direction;
+ this.fps = fps;
+ this.duration_msec = duration_msec;
+ }
+
+ /**
+ * Returns a value from 0.0 to 1.0 that represents the percentage of the transition's completion
+ * for the specified frame.
+ */
+ public double get_alpha(int frame_number) {
+ return (double) frame_number / (double) total_frames;
+ }
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * A Descriptor offers a factory method for creating {@link Effect} instances.
+ */
+public interface Descriptor : Object, Spit.Pluggable {
+ /**
+ * Returns an instance of the {@link Effect} this descriptor represents.
+ */
+ public abstract Effect create(Spit.HostInterface host);
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+/**
+ * An Effect represents an interstitial effect that is used to transition the display from one
+ * image to another.
+ *
+ * An Effect must hold state so that it knows what it should be drawn at any call to {@link paint}
+ * (which is called regularly during a transition). That is, it should be able to draw any frame of
+ * the transition at any time. The same frame may need to be drawn multiple times, or the host
+ * may skip ahead and ask for a frame well ahead of the last requested one.
+ *
+ * ''Frame numbers are one-based throughout this interface''. This is because the initial state (the
+ * blank viewport or the starting pixbuf) is frame zero. The Effect is never called to paint this
+ * frame. The Effect is also not called to paint the final frame (a blank viewport or the ending
+ * pixbuf).
+ *
+ * If the Effect uses background threads for its work, it should use the appropriate primitives
+ * for critical sections. All calls to this interface will be from the context of the main UI
+ * thread. ''None of these calls should block.''
+ *
+ * If the Details object needs to be held by the Effect, its reference to it should be dropped at
+ * the end of the cycle (or shortly thereafter).
+ *
+ * An instance may be reused and should be prepared for restarts.
+ */
+public interface Effect : Object {
+ /**
+ * Returns frames per second (FPS) information for this effect.
+ *
+ * If the min_fps is not met, the Effect may be cancelled or the host will skip ahead.
+ *
+ * @param desired_fps The desired FPS of the transition. Return zero if no
+ * transition is to occur (instantaneous or null transition).
+ * @param min_fps The minimum FPS before the effect is consider "ruined".
+ * Return zero if any FPS is acceptable.
+ */
+ public abstract void get_fps(out int desired_fps, out int min_fps);
+
+ /**
+ * Called when the effect is starting.
+ *
+ * All state should be reset. The frame number, which is not supplied, is one.
+ */
+ public abstract void start(Visuals visuals, Motion motion);
+
+ /**
+ * Return true if the Effect needs the background cleared prior to calling {@link paint}.
+ */
+ public abstract bool needs_clear_background();
+
+ /**
+ * Called when the effect needs to paint (i.e. an expose or draw event has occurred).
+ *
+ * This call should ''not'' advance the state of the effect (i.e. it may be called more than
+ * once for the same frame).
+ *
+ * @param ctx The Cairo context the Effect should use to paint the transition.
+ * @param width The width (in pixels) of the Cairo surface.
+ * @param height The height (in pixels) of the Cairo surface.
+ * @param frame_number The ''one-based'' frame being drawn.
+ */
+ public abstract void paint(Visuals visuals, Motion motion, Cairo.Context ctx, int width,
+ int height, int frame_number);
+
+ /**
+ * Called to notify the effect that the state of the transition should advance to the specified
+ * frame number.
+ *
+ * Note: There is no guarantee frame numbers will be consecutive between calls
+ * to next, especially if the transition clock is attempting to catch up.
+ *
+ * @param frame_number The ''one-based'' frame being advanced to.
+ */
+ public abstract void advance(Visuals visuals, Motion motion, int frame_number);
+
+ /**
+ * Called if the Effect should halt the transition.
+ *
+ * It only needs to reset state if {@link start} is called again.
+ */
+ public abstract void cancel();
+
+ //
+ // For future expansion.
+ //
+ protected virtual void reserved0() {}
+ protected virtual void reserved1() {}
+ protected virtual void reserved2() {}
+ protected virtual void reserved3() {}
+ protected virtual void reserved4() {}
+ protected virtual void reserved5() {}
+ protected virtual void reserved6() {}
+ protected virtual void reserved7() {}
+}
+
+}
+
diff --git a/src/plugins/mk/interfaces.mk b/src/plugins/mk/interfaces.mk
new file mode 100644
index 0000000..34be1eb
--- /dev/null
+++ b/src/plugins/mk/interfaces.mk
@@ -0,0 +1,29 @@
+
+PLUGIN_INTERFACES := \
+ src/plugins/SpitInterfaces.vala \
+ src/plugins/TransitionsInterfaces.vala \
+ src/plugins/PublishingInterfaces.vala \
+ src/plugins/DataImportsInterfaces.vala
+
+PLUGIN_PKG_REQS := \
+ gobject-2.0 \
+ glib-2.0 \
+ gdk-3.0 \
+ gtk+-3.0 \
+ gee-0.8
+
+PLUGIN_VAPI := plugins/shotwell-plugin-dev-1.0.vapi
+PLUGIN_HEADER := $(PLUGIN_VAPI:.vapi=.h)
+PLUGIN_DEPS := $(PLUGIN_VAPI:.vapi=.deps)
+
+$(PLUGIN_DEPS): src/plugins/mk/interfaces.mk
+ rm -f $@
+ $(foreach pkg,$(PLUGIN_PKG_REQS),`echo $(pkg) >> $@`)
+
+$(PLUGIN_HEADER): $(PLUGIN_VAPI)
+
+$(PLUGIN_VAPI): $(PLUGIN_INTERFACES) src/plugins/mk/interfaces.mk
+ $(call check_valac_version)
+ $(VALAC) -c $(VALAFLAGS) -X -DGETTEXT_PACKAGE='"shotwell"' -X -I. $(foreach pkg,$(PLUGIN_PKG_REQS),--pkg=$(pkg)) --includedir=plugins --vapi=$@ --header=$(basename $@).h $(PLUGIN_INTERFACES)
+ $(foreach src,$(PLUGIN_INTERFACES),`rm $(notdir $(src)).o`)
+
diff --git a/src/plugins/mk/plugins.mk b/src/plugins/mk/plugins.mk
new file mode 100644
index 0000000..903dd8e
--- /dev/null
+++ b/src/plugins/mk/plugins.mk
@@ -0,0 +1,35 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Plugins
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := plugins
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ PublishingInterfaces.vala \
+ SpitInterfaces.vala \
+ TransitionsInterfaces.vala \
+ StandardHostInterface.vala \
+ ManifestWidget.vala \
+ DataImportsInterfaces.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Util
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC := \
+ mk/interfaces.mk
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/publishing/APIGlue.vala b/src/publishing/APIGlue.vala
new file mode 100644
index 0000000..f282c1f
--- /dev/null
+++ b/src/publishing/APIGlue.vala
@@ -0,0 +1,133 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+namespace Publishing.Glue {
+
+public class MediaSourcePublishableWrapper : Spit.Publishing.Publishable, GLib.Object {
+ private static int name_ticker = 0;
+
+ private MediaSource wrapped;
+ private GLib.File? serialized_file = null;
+ private Gee.Map<string, string> param_string = new Gee.HashMap<string, string>();
+
+ public MediaSourcePublishableWrapper(MediaSource to_wrap) {
+ wrapped = to_wrap;
+ setup_parameters();
+ }
+
+ public void clean_up() {
+ if (serialized_file == null)
+ return;
+
+ debug("cleaning up temporary publishing file '%s'.", serialized_file.get_path());
+
+ try {
+ serialized_file.delete(null);
+ } catch (Error err) {
+ warning("couldn't delete temporary publishing file '%s'.", serialized_file.get_path());
+ }
+
+ serialized_file = null;
+ }
+
+ private void setup_parameters() {
+ param_string.set(PARAM_STRING_BASENAME, wrapped.get_basename());
+ param_string.set(PARAM_STRING_TITLE, wrapped.get_title());
+ param_string.set(PARAM_STRING_COMMENT, wrapped.get_comment());
+
+ if (wrapped.get_event() != null)
+ param_string.set(PARAM_STRING_EVENTCOMMENT, wrapped.get_event().get_comment());
+ else
+ param_string.set(PARAM_STRING_EVENTCOMMENT, "");
+ }
+
+ public GLib.File serialize_for_publishing(int content_major_axis,
+ bool strip_metadata = false) throws Spit.Publishing.PublishingError {
+
+ if (wrapped is LibraryPhoto) {
+ LibraryPhoto photo = (LibraryPhoto) wrapped;
+
+ GLib.File to_file =
+ AppDirs.get_temp_dir().get_child("publishing-%d.jpg".printf(name_ticker++));
+
+ debug("writing photo '%s' to temporary file '%s' for publishing.",
+ photo.get_source_id(), to_file.get_path());
+ try {
+ Scaling scaling = (content_major_axis > 0) ?
+ Scaling.for_best_fit(content_major_axis, false) : Scaling.for_original();
+ photo.export(to_file, scaling, Jpeg.Quality.HIGH, PhotoFileFormat.JFIF, false, !strip_metadata);
+ } catch (Error err) {
+ throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(
+ "unable to serialize photo '%s' for publishing.", photo.get_name());
+ }
+
+ serialized_file = to_file;
+ } else if (wrapped is Video) {
+ Video video = (Video) wrapped;
+
+ string basename;
+ string extension;
+ disassemble_filename(video.get_file().get_basename(), out basename, out extension);
+
+ GLib.File to_file =
+ GLib.File.new_for_path("publishing-%d.%s".printf(name_ticker++, extension));
+
+ debug("writing video '%s' to temporary file '%s' for publishing.",
+ video.get_source_id(), to_file.get_path());
+ try {
+ video.export(to_file);
+ } catch (Error err) {
+ throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(
+ "unable to serialize video '%s' for publishing.", video.get_name());
+ }
+
+ serialized_file = to_file;
+ } else {
+ error("MediaSourcePublishableWrapper.serialize_for_publishing( ): unknown media type.");
+ }
+
+ return serialized_file;
+ }
+
+ public string get_publishing_name() {
+ return wrapped.get_title() != null ? wrapped.get_title() : "";
+ }
+
+ public string? get_param_string(string name) {
+ return param_string.get(name);
+ }
+
+ public string[] get_publishing_keywords() {
+ string[] result = new string[0];
+
+ Gee.Collection<Tag>? tagset = Tag.global.fetch_sorted_for_source(wrapped);
+ if (tagset != null) {
+ foreach (Tag tag in tagset) {
+ result += tag.get_name();
+ }
+ }
+
+ return (result.length > 0) ? result : null;
+ }
+
+ public Spit.Publishing.Publisher.MediaType get_media_type() {
+ if (wrapped is LibraryPhoto)
+ return Spit.Publishing.Publisher.MediaType.PHOTO;
+ else if (wrapped is Video)
+ return Spit.Publishing.Publisher.MediaType.VIDEO;
+ else
+ return Spit.Publishing.Publisher.MediaType.NONE;
+ }
+
+ public GLib.File? get_serialized_file() {
+ return serialized_file;
+ }
+
+ public GLib.DateTime get_exposure_date_time() {
+ return new GLib.DateTime.from_unix_local(wrapped.get_exposure_time());
+ }
+}
+
+}
diff --git a/src/publishing/Publishing.vala b/src/publishing/Publishing.vala
new file mode 100644
index 0000000..fb04eab
--- /dev/null
+++ b/src/publishing/Publishing.vala
@@ -0,0 +1,24 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+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.picasa";
+ core_ids += "org.yorba.shotwell.publishing.flickr";
+ core_ids += "org.yorba.shotwell.publishing.youtube";
+
+ Plugins.register_extension_point(typeof(Spit.Publishing.Service), _("Publishing"),
+ Resources.PUBLISH, core_ids);
+}
+
+public void terminate() {
+}
+
+}
+
diff --git a/src/publishing/PublishingPluginHost.vala b/src/publishing/PublishingPluginHost.vala
new file mode 100644
index 0000000..1a5ed86
--- /dev/null
+++ b/src/publishing/PublishingPluginHost.vala
@@ -0,0 +1,238 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Spit.Publishing {
+
+public class ConcretePublishingHost : Plugins.StandardHostInterface,
+ Spit.Publishing.PluginHost {
+ private const string PREPARE_STATUS_DESCRIPTION = _("Preparing for upload");
+ private const string UPLOAD_STATUS_DESCRIPTION = _("Uploading %d of %d");
+ private const double STATUS_PREPARATION_FRACTION = 0.3;
+ private const double STATUS_UPLOAD_FRACTION = 0.7;
+
+ private weak PublishingUI.PublishingDialog dialog = null;
+ private Spit.Publishing.Publisher active_publisher = null;
+ private Publishable[] publishables = null;
+ private unowned LoginCallback current_login_callback = null;
+ private bool publishing_halted = false;
+ private Spit.Publishing.Publisher.MediaType media_type =
+ Spit.Publishing.Publisher.MediaType.NONE;
+
+ public ConcretePublishingHost(Service service, PublishingUI.PublishingDialog dialog,
+ Publishable[] publishables) {
+ base(service, "sharing");
+ this.dialog = dialog;
+ this.publishables = publishables;
+
+ foreach (Publishable curr_publishable in publishables)
+ this.media_type |= curr_publishable.get_media_type();
+
+ this.active_publisher = service.create_publisher(this);
+ }
+
+ private void on_login_clicked() {
+ if (current_login_callback != null)
+ current_login_callback();
+ }
+
+ private void clean_up() {
+ foreach (Publishable publishable in publishables)
+ ((global::Publishing.Glue.MediaSourcePublishableWrapper) publishable).clean_up();
+ }
+
+ private void report_plugin_upload_progress(int file_number, double fraction_complete) {
+ // if the currently installed pane isn't the progress pane, do nothing
+ if (!(dialog.get_active_pane() is PublishingUI.ProgressPane))
+ return;
+
+ PublishingUI.ProgressPane pane = (PublishingUI.ProgressPane) dialog.get_active_pane();
+
+ string status_string = UPLOAD_STATUS_DESCRIPTION.printf(file_number,
+ publishables.length);
+ double status_fraction = STATUS_PREPARATION_FRACTION + (STATUS_UPLOAD_FRACTION *
+ fraction_complete);
+
+ pane.set_status(status_string, status_fraction);
+ }
+
+ private void install_progress_pane() {
+ PublishingUI.ProgressPane progress_pane = new PublishingUI.ProgressPane();
+
+ dialog.install_pane(progress_pane);
+ set_button_mode(Spit.Publishing.PluginHost.ButtonMode.CANCEL);
+ }
+
+ public void install_dialog_pane(Spit.Publishing.DialogPane pane,
+ Spit.Publishing.PluginHost.ButtonMode button_mode) {
+ debug("Publishing.PluginHost: install_dialog_pane( ): invoked.");
+
+ if (active_publisher == null || (!active_publisher.is_running()))
+ return;
+
+ dialog.install_pane(pane);
+
+ set_button_mode(button_mode);
+ }
+
+ public void post_error(Error err) {
+ string msg = _("Publishing to %s can't continue because an error occurred:").printf(
+ active_publisher.get_service().get_pluggable_name());
+ msg += GLib.Markup.printf_escaped("\n\n<i>%s</i>\n\n", err.message);
+ msg += _("To try publishing to another service, select one from the above menu.");
+
+ dialog.install_pane(new PublishingUI.StaticMessagePane(msg, true));
+ dialog.set_close_button_mode();
+ dialog.unlock_service();
+
+ active_publisher.stop();
+
+ // post_error( ) tells the active_publisher to stop publishing and displays a
+ // non-removable error pane that effectively ends the publishing interaction,
+ // so no problem calling clean_up( ) here.
+ clean_up();
+ }
+
+ public void stop_publishing() {
+ debug("ConcretePublishingHost.stop_publishing( ): invoked.");
+
+ if (active_publisher.is_running())
+ active_publisher.stop();
+
+ clean_up();
+
+ publishing_halted = true;
+ }
+
+ public void start_publishing() {
+ if (active_publisher.is_running())
+ return;
+
+ debug("ConcretePublishingHost.start_publishing( ): invoked.");
+
+ active_publisher.start();
+ }
+
+ public Publisher get_publisher() {
+ return active_publisher;
+ }
+
+ public void install_static_message_pane(string message,
+ Spit.Publishing.PluginHost.ButtonMode button_mode) {
+
+ set_button_mode(button_mode);
+
+ dialog.install_pane(new PublishingUI.StaticMessagePane(message));
+ }
+
+ public void install_pango_message_pane(string markup,
+ Spit.Publishing.PluginHost.ButtonMode button_mode) {
+ set_button_mode(button_mode);
+
+ dialog.install_pane(new PublishingUI.StaticMessagePane(markup, true));
+ }
+
+ public void install_success_pane() {
+ dialog.install_pane(new PublishingUI.SuccessPane(get_publishable_media_type(),
+ publishables.length));
+ dialog.set_close_button_mode();
+
+ // the success pane is a terminal pane; once it's installed, the publishing
+ // interaction is considered over, so clean up
+ clean_up();
+ }
+
+ public void install_account_fetch_wait_pane() {
+ dialog.install_pane(new PublishingUI.AccountFetchWaitPane());
+ set_button_mode(Spit.Publishing.PluginHost.ButtonMode.CANCEL);
+ }
+
+ public void install_login_wait_pane() {
+ dialog.install_pane(new PublishingUI.LoginWaitPane());
+ }
+
+ public void install_welcome_pane(string welcome_message, LoginCallback login_clicked_callback) {
+ PublishingUI.LoginWelcomePane login_pane =
+ new PublishingUI.LoginWelcomePane(welcome_message);
+ current_login_callback = login_clicked_callback;
+ login_pane.login_requested.connect(on_login_clicked);
+
+ set_button_mode(Spit.Publishing.PluginHost.ButtonMode.CLOSE);
+
+ dialog.install_pane(login_pane);
+ }
+
+ public void set_service_locked(bool locked) {
+ if (locked)
+ dialog.lock_service();
+ else
+ dialog.unlock_service();
+ }
+
+ public void set_button_mode(Spit.Publishing.PluginHost.ButtonMode mode) {
+ if (mode == Spit.Publishing.PluginHost.ButtonMode.CLOSE)
+ dialog.set_close_button_mode();
+ else if (mode == Spit.Publishing.PluginHost.ButtonMode.CANCEL)
+ dialog.set_cancel_button_mode();
+ else
+ error("unrecognized button mode enumeration value");
+ }
+
+ public void set_dialog_default_widget(Gtk.Widget widget) {
+ widget.can_default = true;
+ dialog.set_default(widget);
+ }
+
+ public Spit.Publishing.Publisher.MediaType get_publishable_media_type() {
+ return media_type;
+ }
+
+ public Publishable[] get_publishables() {
+ return publishables;
+ }
+
+ public Spit.Publishing.ProgressCallback? serialize_publishables(int content_major_axis,
+ bool strip_metadata = false) {
+ install_progress_pane();
+ PublishingUI.ProgressPane progress_pane =
+ (PublishingUI.ProgressPane) dialog.get_active_pane();
+
+ // spin the event loop right after installing the progress_pane so that the progress_pane
+ // will appear and let the user know that something is going on while file serialization
+ // takes place
+ spin_event_loop();
+
+ int i = 0;
+ foreach (Spit.Publishing.Publishable publishable in publishables) {
+ if (publishing_halted || !active_publisher.is_running())
+ return null;
+
+ try {
+ global::Publishing.Glue.MediaSourcePublishableWrapper wrapper =
+ (global::Publishing.Glue.MediaSourcePublishableWrapper) publishable;
+ wrapper.serialize_for_publishing(content_major_axis, strip_metadata);
+ } catch (Spit.Publishing.PublishingError err) {
+ post_error(err);
+ return null;
+ }
+
+ double phase_fraction_complete = ((double) (i + 1)) / ((double) publishables.length);
+ double fraction_complete = phase_fraction_complete * STATUS_PREPARATION_FRACTION;
+
+ debug("serialize_publishables( ): fraction_complete = %f.", fraction_complete);
+
+ progress_pane.set_status(PREPARE_STATUS_DESCRIPTION, fraction_complete);
+
+ spin_event_loop();
+
+ i++;
+ }
+
+ return report_plugin_upload_progress;
+ }
+}
+
+}
+
diff --git a/src/publishing/PublishingUI.vala b/src/publishing/PublishingUI.vala
new file mode 100644
index 0000000..4f63f55
--- /dev/null
+++ b/src/publishing/PublishingUI.vala
@@ -0,0 +1,552 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 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(num_uploaded > 1) {
+ if (published_media == (Spit.Publishing.Publisher.MediaType.PHOTO | Spit.Publishing.Publisher.MediaType.VIDEO))
+ message_string = _("The selected photos/videos were successfully published.");
+ else if (published_media == Spit.Publishing.Publisher.MediaType.VIDEO)
+ message_string = _("The selected videos were successfully published.");
+ else
+ message_string = _("The selected photos were successfully published.");
+ } else {
+ if (published_media == Spit.Publishing.Publisher.MediaType.VIDEO)
+ message_string = _("The selected video was successfully published.");
+ else
+ message_string = _("The selected photo was successfully published.");
+ }
+ 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;
+ private const int COLOSSAL_WINDOW_WIDTH = 1024;
+ private const int COLOSSAL_WINDOW_HEIGHT = 688;
+ private const int STANDARD_WINDOW_WIDTH = 632;
+ private const int STANDARD_WINDOW_HEIGHT = 540;
+ private const int BORDER_REGION_WIDTH = 16;
+ private const int BORDER_REGION_HEIGHT = 100;
+
+ public const int STANDARD_CONTENT_LABEL_WIDTH = 500;
+ public const int STANDARD_ACTION_BUTTON_WIDTH = 128;
+
+ private static PublishingDialog active_instance = null;
+
+ private Gtk.ListStore service_selector_box_model;
+ private Gtk.ComboBox service_selector_box;
+ private Gtk.Label service_selector_box_label;
+ private Gtk.Box central_area_layouter;
+ private Gtk.Button close_cancel_button;
+ private Spit.Publishing.DialogPane active_pane;
+ private Spit.Publishing.Publishable[] publishables;
+ private Spit.Publishing.ConcretePublishingHost host;
+ private Spit.PluggableInfo info;
+
+ protected PublishingDialog(Gee.Collection<MediaSource> to_publish) {
+ assert(to_publish.size > 0);
+
+ resizable = false;
+ delete_event.connect(on_window_close);
+
+ publishables = new Spit.Publishing.Publishable[0];
+ bool has_photos = false;
+ bool has_videos = false;
+ foreach (MediaSource media in to_publish) {
+ Spit.Publishing.Publishable publishable =
+ new Publishing.Glue.MediaSourcePublishableWrapper(media);
+ if (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.PHOTO)
+ has_photos = true;
+ else if (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO)
+ has_videos = true;
+ else
+ assert_not_reached();
+
+ publishables += publishable;
+ }
+
+ string title = null;
+ string label = null;
+
+ if (has_photos && !has_videos) {
+ title = null;_("Publish Photos");
+ label = _("Publish photos _to:");
+ } else if (!has_photos && has_videos) {
+ title = _("Publish Videos");
+ label = _("Publish videos _to");
+ } else {
+ title = _("Publish Photos and Videos");
+ label = _("Publish photos and videos _to");
+ }
+ set_title(title);
+
+ service_selector_box_model = new Gtk.ListStore(2, typeof(Gdk.Pixbuf), typeof(string));
+ 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);
+
+ Gtk.CellRendererText renderer_text = new Gtk.CellRendererText();
+ service_selector_box.pack_start(renderer_text,true);
+ service_selector_box.add_attribute(renderer_text, "text", 1);
+
+ service_selector_box.set_active(0);
+
+ service_selector_box_label = new Gtk.Label.with_mnemonic(label);
+ service_selector_box_label.set_mnemonic_widget(service_selector_box);
+ service_selector_box_label.set_alignment(0.0f, 0.5f);
+
+ // get the name of the service the user last used
+ string? last_used_service = Config.Facade.get_instance().get_last_used_service();
+
+ Spit.Publishing.Service[] loaded_services = load_services(has_photos, has_videos);
+
+ 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);
+
+ 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);
+ }
+ }
+
+ service_selector_box.changed.connect(on_service_changed);
+
+ /* the wrapper is not an extraneous widget -- it's necessary to prevent the service
+ selection box from growing and shrinking whenever its parent's size changes.
+ When wrapped inside a Gtk.Alignment, the Alignment grows and shrinks instead of
+ the service selection box. */
+ Gtk.Alignment service_selector_box_wrapper = new Gtk.Alignment(1.0f, 0.5f, 0.0f, 0.0f);
+ service_selector_box_wrapper.add(service_selector_box);
+
+ Gtk.Box service_selector_layouter = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ service_selector_layouter.set_border_width(12);
+ service_selector_layouter.add(service_selector_box_label);
+ service_selector_layouter.pack_start(service_selector_box_wrapper, true, true, 0);
+
+ /* 'service area' is the selector assembly plus the horizontal rule dividing it from the
+ rest of the dialog */
+ Gtk.Box service_area_layouter = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ service_area_layouter.add(service_selector_layouter);
+ service_area_layouter.add(new Gtk.Separator(Gtk.Orientation.HORIZONTAL));
+
+ Gtk.Alignment service_area_wrapper = new Gtk.Alignment(0.0f, 0.0f, 1.0f, 0.0f);
+ service_area_wrapper.add(service_area_layouter);
+
+ central_area_layouter = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+
+ get_content_area().pack_start(service_area_wrapper, false, false, 0);
+ get_content_area().pack_start(central_area_layouter, true, true, 0);
+
+ close_cancel_button = new Gtk.Button.with_mnemonic("_Cancel");
+ close_cancel_button.set_can_default(true);
+ close_cancel_button.clicked.connect(on_close_cancel_clicked);
+ ((Gtk.Container) get_action_area()).add(close_cancel_button);
+
+ set_standard_window_mode();
+
+ show_all();
+ }
+
+ private static Spit.Publishing.Service[] load_all_services() {
+ Spit.Publishing.Service[] loaded_services = new Spit.Publishing.Service[0];
+
+ // load publishing services from plug-ins
+ Gee.Collection<Spit.Pluggable> pluggables = Plugins.get_pluggables_for_type(
+ typeof(Spit.Publishing.Service));
+
+ debug("PublisingDialog: discovered %d pluggable publishing services.", pluggables.size);
+
+ foreach (Spit.Pluggable pluggable in pluggables) {
+ int pluggable_interface = pluggable.get_pluggable_interface(
+ Spit.Publishing.CURRENT_INTERFACE, Spit.Publishing.CURRENT_INTERFACE);
+ if (pluggable_interface != Spit.Publishing.CURRENT_INTERFACE) {
+ warning("Unable to load publisher %s: reported interface %d.",
+ Plugins.get_pluggable_module_id(pluggable), pluggable_interface);
+
+ continue;
+ }
+
+ Spit.Publishing.Service service =
+ (Spit.Publishing.Service) pluggable;
+
+ debug("PublishingDialog: discovered pluggable publishing service '%s'.",
+ service.get_pluggable_name());
+
+ loaded_services += service;
+ }
+
+ // Sort publishing services by name.
+ Posix.qsort(loaded_services, loaded_services.length, sizeof(Spit.Publishing.Service),
+ (a, b) => {return utf8_cs_compare((*((Spit.Publishing.Service**) a))->get_pluggable_name(),
+ (*((Spit.Publishing.Service**) b))->get_pluggable_name());
+ });
+
+ return loaded_services;
+ }
+
+ private static Spit.Publishing.Service[] load_services(bool has_photos, bool has_videos) {
+ assert (has_photos || has_videos);
+
+ Spit.Publishing.Service[] filtered_services = new Spit.Publishing.Service[0];
+ Spit.Publishing.Service[] all_services = load_all_services();
+
+ foreach (Spit.Publishing.Service service in all_services) {
+
+ if (has_photos && !has_videos) {
+ if ((service.get_supported_media() & Spit.Publishing.Publisher.MediaType.PHOTO) != 0)
+ filtered_services += service;
+ } else if (!has_photos && has_videos) {
+ if ((service.get_supported_media() & Spit.Publishing.Publisher.MediaType.VIDEO) != 0)
+ filtered_services += service;
+ } else {
+ if (((service.get_supported_media() & Spit.Publishing.Publisher.MediaType.PHOTO) != 0) &&
+ ((service.get_supported_media() & Spit.Publishing.Publisher.MediaType.VIDEO) != 0))
+ filtered_services += service;
+ }
+ }
+
+ 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( ).
+ private static Timer since_last_start = null;
+ private static bool elapsed_is_valid = false;
+ public static void go(Gee.Collection<MediaSource> to_publish) {
+ if (active_instance != null)
+ return;
+
+ if (since_last_start == null) {
+ // GLib.Timers start themselves automatically when they're created, so stop our
+ // new timer and reset it to zero 'til were ready to start timing.
+ since_last_start = new Timer();
+ since_last_start.stop();
+ since_last_start.reset();
+ elapsed_is_valid = false;
+ } else {
+ double elapsed = since_last_start.elapsed();
+ if ((elapsed < 0.05) && (elapsed_is_valid))
+ return;
+ }
+
+ Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
+ MediaSourceCollection.filter_media(to_publish, photos, videos);
+
+ Spit.Publishing.Service[] avail_services =
+ load_services((photos.size > 0), (videos.size > 0));
+
+ if (avail_services.length == 0) {
+ // 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("▸"),
+ null, false);
+
+ return;
+ }
+
+ // If we get down here, it means that at least one publishing service
+ // was found that could accept this type of media, so continue normally.
+
+ debug("PublishingDialog.go( )");
+
+ active_instance = new PublishingDialog(to_publish);
+
+ active_instance.run();
+
+ active_instance = null;
+
+ // start timing just before we return
+ since_last_start.start();
+ elapsed_is_valid = true;
+ }
+
+ private bool on_window_close(Gdk.EventAny evt) {
+ host.stop_publishing();
+ host = null;
+ hide();
+ destroy();
+
+ return true;
+ }
+
+ private void on_service_changed() {
+ Gtk.TreeIter iter;
+ bool have_active_iter = false;
+ have_active_iter = service_selector_box.get_active_iter(out iter);
+
+ // this occurs when the user removes the last active publisher
+ if (!have_active_iter) {
+ // default to the first in the list (as good as any)
+ service_selector_box.set_active(0);
+
+ // and get active again
+ service_selector_box.get_active_iter(out iter);
+ }
+
+ Value service_name_val;
+ service_selector_box_model.get_value(iter, 1, out service_name_val);
+
+ string service_name = (string) service_name_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) {
+ selected_service = service;
+ break;
+ }
+ }
+ assert(selected_service != null);
+
+ Config.Facade.get_instance().set_last_used_service(selected_service.get_id());
+
+ host = new Spit.Publishing.ConcretePublishingHost(selected_service, this, publishables);
+ host.start_publishing();
+ }
+
+ private void on_close_cancel_clicked() {
+ debug("PublishingDialog: on_close_cancel_clicked( ): invoked.");
+
+ host.stop_publishing();
+ host = null;
+ hide();
+ destroy();
+ }
+
+ private void set_large_window_mode() {
+ set_size_request(LARGE_WINDOW_WIDTH, LARGE_WINDOW_HEIGHT);
+ central_area_layouter.set_size_request(LARGE_WINDOW_WIDTH - BORDER_REGION_WIDTH,
+ LARGE_WINDOW_HEIGHT - BORDER_REGION_HEIGHT);
+ resizable = false;
+ }
+
+ private void set_colossal_window_mode() {
+ set_size_request(COLOSSAL_WINDOW_WIDTH, COLOSSAL_WINDOW_HEIGHT);
+ central_area_layouter.set_size_request(COLOSSAL_WINDOW_WIDTH - BORDER_REGION_WIDTH,
+ COLOSSAL_WINDOW_HEIGHT - BORDER_REGION_HEIGHT);
+ resizable = false;
+ }
+
+ private void set_standard_window_mode() {
+ set_size_request(STANDARD_WINDOW_WIDTH, STANDARD_WINDOW_HEIGHT);
+ central_area_layouter.set_size_request(STANDARD_WINDOW_WIDTH - BORDER_REGION_WIDTH,
+ STANDARD_WINDOW_HEIGHT - BORDER_REGION_HEIGHT);
+ resizable = false;
+ }
+
+ private void set_free_sizable_window_mode() {
+ resizable = true;
+ }
+
+ private void clear_free_sizable_window_mode() {
+ resizable = false;
+ }
+
+ public Spit.Publishing.DialogPane get_active_pane() {
+ return active_pane;
+ }
+
+ public void set_close_button_mode() {
+ close_cancel_button.set_label(_("_Close"));
+ set_default(close_cancel_button);
+ }
+
+ public void set_cancel_button_mode() {
+ close_cancel_button.set_label(_("_Cancel"));
+ }
+
+ public void lock_service() {
+ service_selector_box.set_sensitive(false);
+ }
+
+ public void unlock_service() {
+ service_selector_box.set_sensitive(true);
+ }
+
+ public void install_pane(Spit.Publishing.DialogPane pane) {
+ debug("PublishingDialog: install_pane( ): invoked.");
+
+ if (active_pane != null) {
+ debug("PublishingDialog: install_pane( ): a pane is already installed; removing it.");
+
+ active_pane.on_pane_uninstalled();
+ central_area_layouter.remove(active_pane.get_widget());
+ }
+
+ central_area_layouter.pack_start(pane.get_widget(), true, true, 0);
+ show_all();
+
+ Spit.Publishing.DialogPane.GeometryOptions geometry_options =
+ pane.get_preferred_geometry();
+ if ((geometry_options & Spit.Publishing.DialogPane.GeometryOptions.EXTENDED_SIZE) != 0)
+ set_large_window_mode();
+ else if ((geometry_options & Spit.Publishing.DialogPane.GeometryOptions.COLOSSAL_SIZE) != 0)
+ set_colossal_window_mode();
+ else
+ set_standard_window_mode();
+
+ if ((geometry_options & Spit.Publishing.DialogPane.GeometryOptions.RESIZABLE) != 0)
+ set_free_sizable_window_mode();
+ else
+ clear_free_sizable_window_mode();
+
+ active_pane = pane;
+ pane.on_pane_installed();
+ }
+
+ public new int run() {
+ on_service_changed();
+
+ int result = base.run();
+
+ host = null;
+
+ return result;
+ }
+}
+
+}
+
diff --git a/src/publishing/mk/publishing.mk b/src/publishing/mk/publishing.mk
new file mode 100644
index 0000000..fd31b81
--- /dev/null
+++ b/src/publishing/mk/publishing.mk
@@ -0,0 +1,31 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Publishing
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := publishing
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ PublishingUI.vala \
+ PublishingPluginHost.vala \
+ APIGlue.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Plugins
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/searches/Branch.vala b/src/searches/Branch.vala
new file mode 100644
index 0000000..229c710
--- /dev/null
+++ b/src/searches/Branch.vala
@@ -0,0 +1,150 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class Searches.Branch : Sidebar.Branch {
+ private Gee.HashMap<SavedSearch, Searches.SidebarEntry> entry_map =
+ new Gee.HashMap<SavedSearch, Searches.SidebarEntry>();
+
+ public Branch() {
+ base (new Searches.Grouping(),
+ Sidebar.Branch.Options.HIDE_IF_EMPTY
+ | Sidebar.Branch.Options.AUTO_OPEN_ON_NEW_CHILD
+ | Sidebar.Branch.Options.STARTUP_EXPAND_TO_FIRST_CHILD,
+ comparator);
+
+ // seed the branch with existing searches
+ foreach (SavedSearch search in SavedSearchTable.get_instance().get_all())
+ on_saved_search_added(search);
+
+ // monitor collection for future events
+ SavedSearchTable.get_instance().search_added.connect(on_saved_search_added);
+ SavedSearchTable.get_instance().search_removed.connect(on_saved_search_removed);
+ }
+
+ ~Branch() {
+ SavedSearchTable.get_instance().search_added.disconnect(on_saved_search_added);
+ SavedSearchTable.get_instance().search_removed.disconnect(on_saved_search_removed);
+ }
+
+ public Searches.SidebarEntry? get_entry_for_saved_search(SavedSearch search) {
+ return entry_map.get(search);
+ }
+
+ private static int comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ return SavedSearch.compare_names(((Searches.SidebarEntry) a).for_saved_search(),
+ ((Searches.SidebarEntry) b).for_saved_search());
+ }
+
+ private void on_saved_search_added(SavedSearch search) {
+ debug("search added");
+ Searches.SidebarEntry entry = new Searches.SidebarEntry(search);
+ entry_map.set(search, entry);
+ graft(get_root(), entry);
+ }
+
+ private void on_saved_search_removed(SavedSearch search) {
+ debug("search removed");
+ Searches.SidebarEntry? entry = entry_map.get(search);
+ assert(entry != null);
+
+ bool is_removed = entry_map.unset(search);
+ assert(is_removed);
+
+ prune(entry);
+ }
+}
+
+public class Searches.Grouping : Sidebar.Grouping, Sidebar.Contextable {
+ private Gtk.UIManager ui = new Gtk.UIManager();
+ private Gtk.Menu? context_menu = null;
+
+ public Grouping() {
+ base (_("Saved Searches"), new ThemedIcon(Gtk.Stock.FIND));
+ setup_context_menu();
+ }
+
+ private void setup_context_menu() {
+ Gtk.ActionGroup group = new Gtk.ActionGroup("SidebarDefault");
+ Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
+
+ Gtk.ActionEntry new_search = { "CommonNewSearch", null, TRANSLATABLE, null, null, on_new_search };
+ new_search.label = _("Ne_w Saved Search...");
+ actions += new_search;
+
+ group.add_actions(actions, this);
+ ui.insert_action_group(group, 0);
+
+ File ui_file = Resources.get_ui("search_sidebar_context.ui");
+ try {
+ ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ AppWindow.error_message("Error loading UI file %s: %s".printf(
+ ui_file.get_path(), err.message));
+ Application.get_instance().panic();
+ }
+ context_menu = (Gtk.Menu) ui.get_widget("/SidebarSearchContextMenu");
+
+ ui.ensure_update();
+ }
+
+ public Gtk.Menu? get_sidebar_context_menu(Gdk.EventButton? event) {
+ return context_menu;
+ }
+
+ private void on_new_search() {
+ (new SavedSearchDialog()).show();
+ }
+}
+
+public class Searches.SidebarEntry : Sidebar.SimplePageEntry, Sidebar.RenameableEntry,
+ Sidebar.DestroyableEntry {
+ private static Icon single_search_icon;
+
+ private SavedSearch search;
+
+ public SidebarEntry(SavedSearch search) {
+ this.search = search;
+ }
+
+ internal static void init() {
+ single_search_icon = new ThemedIcon(Gtk.Stock.FIND);
+ }
+
+ internal static void terminate() {
+ single_search_icon = null;
+ }
+
+ public SavedSearch for_saved_search() {
+ return search;
+ }
+
+ public override string get_sidebar_name() {
+ return search.get_name();
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return single_search_icon;
+ }
+
+ protected override Page create_page() {
+ return new SavedSearchPage(search);
+ }
+
+ public void rename(string new_name) {
+ if (!SavedSearchTable.get_instance().exists(new_name))
+ AppWindow.get_command_manager().execute(new RenameSavedSearchCommand(search, new_name));
+ else if (new_name != search.get_name())
+ AppWindow.error_message(Resources.rename_search_exists_message(new_name));
+ }
+
+ public void destroy_source() {
+ if (Dialogs.confirm_delete_saved_search(search))
+ AppWindow.get_command_manager().execute(new DeleteSavedSearchCommand(search));
+ }
+}
diff --git a/src/searches/SavedSearchDialog.vala b/src/searches/SavedSearchDialog.vala
new file mode 100644
index 0000000..b425cfb
--- /dev/null
+++ b/src/searches/SavedSearchDialog.vala
@@ -0,0 +1,829 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// This dialog displays a boolean search configuration.
+public class SavedSearchDialog {
+
+ // Conatins a search row, with a type selector and remove button.
+ private class SearchRowContainer {
+ public signal void remove(SearchRowContainer this_row);
+ public signal void changed(SearchRowContainer this_row);
+
+ private Gtk.ComboBoxText type_combo;
+ private Gtk.Box box;
+ private Gtk.Alignment align;
+ private Gtk.Button remove_button;
+ private SearchCondition.SearchType[] search_types;
+ private Gee.HashMap<SearchCondition.SearchType, int> search_types_index;
+
+ private SearchRow? my_row = null;
+
+ public SearchRowContainer() {
+ setup_gui();
+ set_type(SearchCondition.SearchType.ANY_TEXT);
+ }
+
+ public SearchRowContainer.edit_existing(SearchCondition sc) {
+ setup_gui();
+ set_type(sc.search_type);
+ set_type_combo_box(sc.search_type);
+ my_row.populate(sc);
+ }
+
+ // Creates the GUI for this row.
+ private void setup_gui() {
+ search_types = SearchCondition.SearchType.as_array();
+ search_types_index = new Gee.HashMap<SearchCondition.SearchType, int>();
+ SearchCondition.SearchType.sort_array(ref search_types);
+
+ type_combo = new Gtk.ComboBoxText();
+ for (int i = 0; i < search_types.length; i++) {
+ SearchCondition.SearchType st = search_types[i];
+ search_types_index.set(st, i);
+ type_combo.append_text(st.display_text());
+ }
+ set_type_combo_box(SearchCondition.SearchType.ANY_TEXT); // Sets default.
+ type_combo.changed.connect(on_type_changed);
+
+ remove_button = new Gtk.Button();
+ remove_button.set_label(" – ");
+ remove_button.button_press_event.connect(on_removed);
+
+ align = new Gtk.Alignment(0,0,0,0);
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(type_combo, false, false, 0);
+ box.pack_start(align, false, false, 0);
+ box.pack_start(new Gtk.Alignment(0,0,0,0), true, true, 0); // Fill space.
+ box.pack_start(remove_button, false, false, 0);
+ box.show_all();
+ }
+
+ private void on_type_changed() {
+ set_type(get_search_type());
+ changed(this);
+ }
+
+ private void set_type_combo_box(SearchCondition.SearchType st) {
+ type_combo.set_active(search_types_index.get(st));
+ }
+
+ private void set_type(SearchCondition.SearchType type) {
+ if (my_row != null)
+ align.remove(my_row.get_widget());
+
+ switch (type) {
+ case SearchCondition.SearchType.ANY_TEXT:
+ case SearchCondition.SearchType.EVENT_NAME:
+ case SearchCondition.SearchType.FILE_NAME:
+ case SearchCondition.SearchType.TAG:
+ case SearchCondition.SearchType.COMMENT:
+ case SearchCondition.SearchType.TITLE:
+ my_row = new SearchRowText(this);
+ break;
+
+ case SearchCondition.SearchType.MEDIA_TYPE:
+ my_row = new SearchRowMediaType(this);
+ break;
+
+ case SearchCondition.SearchType.FLAG_STATE:
+ my_row = new SearchRowFlagged(this);
+ break;
+
+ case SearchCondition.SearchType.MODIFIED_STATE:
+ my_row = new SearchRowModified(this);
+ break;
+
+ case SearchCondition.SearchType.RATING:
+ my_row = new SearchRowRating(this);
+ break;
+
+ case SearchCondition.SearchType.DATE:
+ my_row = new SearchRowDate(this);
+ break;
+
+ default:
+ assert(false);
+ break;
+ }
+
+ align.add(my_row.get_widget());
+ }
+
+ public SearchCondition.SearchType get_search_type() {
+ return search_types[type_combo.get_active()];
+ }
+
+ private bool on_removed(Gdk.EventButton event) {
+ remove(this);
+ return false;
+ }
+
+ public void allow_removal(bool allow) {
+ remove_button.sensitive = allow;
+ }
+
+ public Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public SearchCondition get_search_condition() {
+ return my_row.get_search_condition();
+ }
+
+ public bool is_complete() {
+ return my_row.is_complete();
+ }
+ }
+
+ // Represents a row-type.
+ private abstract class SearchRow {
+ // Returns the GUI widget for this row.
+ public abstract Gtk.Widget get_widget();
+
+ // Returns the search condition for this row.
+ public abstract SearchCondition get_search_condition();
+
+ // Fills out the fields in this row based on an existing search condition (for edit mode.)
+ public abstract void populate(SearchCondition sc);
+
+ // Returns true if the row is valid and complete.
+ public abstract bool is_complete();
+ }
+
+ private class SearchRowText : SearchRow {
+ private Gtk.Box box;
+ private Gtk.ComboBoxText text_context;
+ private Gtk.Entry entry;
+
+ private SearchRowContainer parent;
+
+ public SearchRowText(SearchRowContainer parent) {
+ this.parent = parent;
+
+ // Ordering must correspond with SearchConditionText.Context
+ text_context = new Gtk.ComboBoxText();
+ text_context.append_text(_("contains"));
+ text_context.append_text(_("is exactly"));
+ text_context.append_text(_("starts with"));
+ text_context.append_text(_("ends with"));
+ text_context.append_text(_("does not contain"));
+ text_context.append_text(_("is not set"));
+ text_context.set_active(0);
+ text_context.changed.connect(on_changed);
+
+ entry = new Gtk.Entry();
+ entry.set_width_chars(25);
+ entry.set_activates_default(true);
+ entry.changed.connect(on_changed);
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(text_context, false, false, 0);
+ box.pack_start(entry, false, false, 0);
+ box.show_all();
+ }
+
+ ~SearchRowText() {
+ text_context.changed.disconnect(on_changed);
+ entry.changed.disconnect(on_changed);
+ }
+
+ public override Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public override SearchCondition get_search_condition() {
+ SearchCondition.SearchType type = parent.get_search_type();
+ string text = entry.get_text();
+ SearchConditionText.Context context = get_text_context();
+ SearchConditionText c = new SearchConditionText(type, text, context);
+ return c;
+ }
+
+ public override void populate(SearchCondition sc) {
+ SearchConditionText? text = sc as SearchConditionText;
+ assert(text != null);
+ text_context.set_active(text.context);
+ entry.set_text(text.text);
+ on_changed();
+ }
+
+ public override bool is_complete() {
+ return entry.text.chomp() != "" || get_text_context() == SearchConditionText.Context.IS_NOT_SET;
+ }
+
+ private SearchConditionText.Context get_text_context() {
+ return (SearchConditionText.Context) text_context.get_active();
+ }
+
+ private void on_changed() {
+ if (get_text_context() == SearchConditionText.Context.IS_NOT_SET) {
+ entry.hide();
+ } else {
+ entry.show();
+ }
+
+ parent.changed(parent);
+ }
+ }
+
+ private class SearchRowMediaType : SearchRow {
+ private Gtk.Box box;
+ private Gtk.ComboBoxText media_context;
+ private Gtk.ComboBoxText media_type;
+
+ private SearchRowContainer parent;
+
+ public SearchRowMediaType(SearchRowContainer parent) {
+ this.parent = parent;
+
+ // Ordering must correspond with SearchConditionMediaType.Context
+ media_context = new Gtk.ComboBoxText();
+ media_context.append_text(_("is"));
+ media_context.append_text(_("is not"));
+ media_context.set_active(0);
+ media_context.changed.connect(on_changed);
+
+ // Ordering must correspond with SearchConditionMediaType.MediaType
+ media_type = new Gtk.ComboBoxText();
+ media_type.append_text(_("any photo"));
+ media_type.append_text(_("a raw photo"));
+ media_type.append_text(_("a video"));
+ media_type.set_active(0);
+ media_type.changed.connect(on_changed);
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(media_context, false, false, 0);
+ box.pack_start(media_type, false, false, 0);
+ box.show_all();
+ }
+
+ ~SearchRowMediaType() {
+ media_context.changed.disconnect(on_changed);
+ media_type.changed.disconnect(on_changed);
+ }
+
+ public override Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public override SearchCondition get_search_condition() {
+ SearchCondition.SearchType search_type = parent.get_search_type();
+ SearchConditionMediaType.Context context = (SearchConditionMediaType.Context) media_context.get_active();
+ SearchConditionMediaType.MediaType type = (SearchConditionMediaType.MediaType) media_type.get_active();
+ SearchConditionMediaType c = new SearchConditionMediaType(search_type, context, type);
+ return c;
+ }
+
+ public override void populate(SearchCondition sc) {
+ SearchConditionMediaType? media = sc as SearchConditionMediaType;
+ assert(media != null);
+ media_context.set_active(media.context);
+ media_type.set_active(media.media_type);
+ }
+
+ public override bool is_complete() {
+ return true;
+ }
+
+ private void on_changed() {
+ parent.changed(parent);
+ }
+ }
+
+ private class SearchRowModified : SearchRow {
+ private Gtk.Box box;
+ private Gtk.ComboBoxText modified_context;
+ private Gtk.ComboBoxText modified_state;
+
+ private SearchRowContainer parent;
+
+ public SearchRowModified(SearchRowContainer parent) {
+ this.parent = parent;
+
+ modified_context = new Gtk.ComboBoxText();
+ modified_context.append_text(_("has"));
+ modified_context.append_text(_("has no"));
+ modified_context.set_active(0);
+ modified_context.changed.connect(on_changed);
+
+ modified_state = new Gtk.ComboBoxText();
+ modified_state.append_text(_("modifications"));
+ modified_state.append_text(_("internal modifications"));
+ modified_state.append_text(_("external modifications"));
+ modified_state.set_active(0);
+ modified_state.changed.connect(on_changed);
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(modified_context, false, false, 0);
+ box.pack_start(modified_state, false, false, 0);
+ box.show_all();
+ }
+
+ ~SearchRowModified() {
+ modified_state.changed.disconnect(on_changed);
+ modified_context.changed.disconnect(on_changed);
+ }
+
+ public override Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public override SearchCondition get_search_condition() {
+ SearchCondition.SearchType search_type = parent.get_search_type();
+ SearchConditionModified.Context context = (SearchConditionModified.Context) modified_context.get_active();
+ SearchConditionModified.State state = (SearchConditionModified.State) modified_state.get_active();
+ SearchConditionModified c = new SearchConditionModified(search_type, context, state);
+ return c;
+ }
+
+ public override void populate(SearchCondition sc) {
+ SearchConditionModified? scm = sc as SearchConditionModified;
+ assert(scm != null);
+ modified_state.set_active(scm.state);
+ modified_context.set_active(scm.context);
+ }
+
+ public override bool is_complete() {
+ return true;
+ }
+
+ private void on_changed() {
+ parent.changed(parent);
+ }
+ }
+
+ private class SearchRowFlagged : SearchRow {
+ private Gtk.Box box;
+ private Gtk.ComboBoxText flagged_state;
+
+ private SearchRowContainer parent;
+
+ public SearchRowFlagged(SearchRowContainer parent) {
+ this.parent = parent;
+
+ // Ordering must correspond with SearchConditionFlagged.State
+ flagged_state = new Gtk.ComboBoxText();
+ flagged_state.append_text(_("flagged"));
+ flagged_state.append_text(_("not flagged"));
+ flagged_state.set_active(0);
+ flagged_state.changed.connect(on_changed);
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(new Gtk.Label(_("is")), false, false, 0);
+ box.pack_start(flagged_state, false, false, 0);
+ box.show_all();
+ }
+
+ ~SearchRowFlagged() {
+ flagged_state.changed.disconnect(on_changed);
+ }
+
+ public override Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public override SearchCondition get_search_condition() {
+ SearchCondition.SearchType search_type = parent.get_search_type();
+ SearchConditionFlagged.State state = (SearchConditionFlagged.State) flagged_state.get_active();
+ SearchConditionFlagged c = new SearchConditionFlagged(search_type, state);
+ return c;
+ }
+
+ public override void populate(SearchCondition sc) {
+ SearchConditionFlagged? f = sc as SearchConditionFlagged;
+ assert(f != null);
+ flagged_state.set_active(f.state);
+ }
+
+ public override bool is_complete() {
+ return true;
+ }
+
+ private void on_changed() {
+ parent.changed(parent);
+ }
+ }
+
+ private class SearchRowRating : SearchRow {
+ private Gtk.Box box;
+ private Gtk.ComboBoxText rating;
+ private Gtk.ComboBoxText context;
+
+ private SearchRowContainer parent;
+
+ public SearchRowRating(SearchRowContainer parent) {
+ this.parent = parent;
+
+ // Ordering must correspond with Rating
+ rating = new Gtk.ComboBoxText();
+ rating.append_text(Resources.rating_combo_box(Rating.REJECTED));
+ rating.append_text(Resources.rating_combo_box(Rating.UNRATED));
+ rating.append_text(Resources.rating_combo_box(Rating.ONE));
+ rating.append_text(Resources.rating_combo_box(Rating.TWO));
+ rating.append_text(Resources.rating_combo_box(Rating.THREE));
+ rating.append_text(Resources.rating_combo_box(Rating.FOUR));
+ rating.append_text(Resources.rating_combo_box(Rating.FIVE));
+ rating.set_active(0);
+ rating.changed.connect(on_changed);
+
+ context = new Gtk.ComboBoxText();
+ context.append_text(_("and higher"));
+ context.append_text(_("only"));
+ context.append_text(_("and lower"));
+ context.set_active(0);
+ context.changed.connect(on_changed);
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(new Gtk.Label(_("is")), false, false, 0);
+ box.pack_start(rating, false, false, 0);
+ box.pack_start(context, false, false, 0);
+ box.show_all();
+ }
+
+ ~SearchRowRating() {
+ rating.changed.disconnect(on_changed);
+ context.changed.disconnect(on_changed);
+ }
+
+ public override Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public override SearchCondition get_search_condition() {
+ SearchCondition.SearchType search_type = parent.get_search_type();
+ Rating search_rating = (Rating) rating.get_active() + Rating.REJECTED;
+ SearchConditionRating.Context search_context = (SearchConditionRating.Context) context.get_active();
+ SearchConditionRating c = new SearchConditionRating(search_type, search_rating, search_context);
+ return c;
+ }
+
+ public override void populate(SearchCondition sc) {
+ SearchConditionRating? r = sc as SearchConditionRating;
+ assert(r != null);
+ context.set_active(r.context);
+ rating.set_active(r.rating - Rating.REJECTED);
+ }
+
+ public override bool is_complete() {
+ return true;
+ }
+
+ private void on_changed() {
+ parent.changed(parent);
+ }
+ }
+
+ private class SearchRowDate : SearchRow {
+ private const string DATE_FORMAT = "%x";
+ private Gtk.Box box;
+ private Gtk.ComboBoxText context;
+ private Gtk.Button label_one;
+ private Gtk.Button label_two;
+ private Gtk.Calendar cal_one;
+ private Gtk.Calendar cal_two;
+ private Gtk.Label and;
+
+ private SearchRowContainer parent;
+
+ public SearchRowDate(SearchRowContainer parent) {
+ this.parent = parent;
+
+ // Ordering must correspond with Context
+ context = new Gtk.ComboBoxText();
+ context.append_text(_("is exactly"));
+ context.append_text(_("is after"));
+ context.append_text(_("is before"));
+ context.append_text(_("is between"));
+ context.append_text(_("is not set"));
+ context.set_active(0);
+ context.changed.connect(on_changed);
+
+ cal_one = new Gtk.Calendar();
+ cal_two = new Gtk.Calendar();
+
+ label_one = new Gtk.Button();
+ label_one.clicked.connect(on_one_clicked);
+ label_two = new Gtk.Button();
+ label_two.clicked.connect(on_two_clicked);
+
+ and = new Gtk.Label(_("and"));
+
+ box = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
+ box.pack_start(context, false, false, 0);
+ box.pack_start(label_one, false, false, 0);
+ box.pack_start(and, false, false, 0);
+ box.pack_start(label_two, false, false, 0);
+
+ box.show_all();
+ update_date_labels();
+ }
+
+ ~SearchRowRating() {
+ context.changed.disconnect(on_changed);
+ }
+
+ private void update_date_labels() {
+ SearchConditionDate.Context c = (SearchConditionDate.Context) context.get_active();
+
+ // Only show "and" and 2nd date label for between mode.
+ if (c == SearchConditionDate.Context.BETWEEN) {
+ label_one.show();
+ and.show();
+ label_two.show();
+ } else if (c == SearchConditionDate.Context.IS_NOT_SET) {
+ label_one.hide();
+ and.hide();
+ label_two.hide();
+ } else {
+ label_one.show();
+ and.hide();
+ label_two.hide();
+ }
+
+ // Set label text to date.
+ label_one.label = get_date_one().format(DATE_FORMAT);
+ label_two.label = get_date_two().format(DATE_FORMAT);;
+ }
+
+ public override Gtk.Widget get_widget() {
+ return box;
+ }
+
+ private DateTime get_date_one() {
+ return new DateTime.local(cal_one.year, cal_one.month + 1, cal_one.day, 0, 0, 0.0);
+ }
+
+ private DateTime get_date_two() {
+ return new DateTime.local(cal_two.year, cal_two.month + 1, cal_two.day, 0, 0, 0.0);
+ }
+
+ private void set_date_one(DateTime date) {
+ cal_one.day = date.get_day_of_month();
+ cal_one.month = date.get_month() - 1;
+ cal_one.year = date.get_year();
+ }
+
+ private void set_date_two(DateTime date) {
+ cal_two.day = date.get_day_of_month();
+ cal_two.month = date.get_month() - 1;
+ cal_two.year = date.get_year();
+ }
+
+ public override SearchCondition get_search_condition() {
+ SearchCondition.SearchType search_type = parent.get_search_type();
+ SearchConditionDate.Context search_context = (SearchConditionDate.Context) context.get_active();
+ SearchConditionDate c = new SearchConditionDate(search_type, search_context, get_date_one(),
+ get_date_two());
+ return c;
+ }
+
+ public override void populate(SearchCondition sc) {
+ SearchConditionDate? cond = sc as SearchConditionDate;
+ assert(cond != null);
+ context.set_active(cond.context);
+ set_date_one(cond.date_one);
+ set_date_two(cond.date_two);
+ update_date_labels();
+ }
+
+ public override bool is_complete() {
+ return true;
+ }
+
+ private void on_changed() {
+ parent.changed(parent);
+ update_date_labels();
+ }
+
+ private void popup_calendar(Gtk.Calendar cal) {
+ int orig_day = cal.day;
+ int orig_month = cal.month;
+ int orig_year = cal.year;
+ Gtk.Dialog d = new Gtk.Dialog.with_buttons(null, null,
+ Gtk.DialogFlags.MODAL, Gtk.Stock.CANCEL, Gtk.ResponseType.REJECT,
+ Gtk.Stock.OK, Gtk.ResponseType.ACCEPT);
+ d.set_modal(true);
+ d.set_resizable(false);
+ d.set_decorated(false);
+ ((Gtk.Box) d.get_content_area()).add(cal);
+ ulong id_1 = cal.day_selected.connect(()=>{update_date_labels();});
+ ulong id_2 = cal.day_selected_double_click.connect(()=>{d.close();});
+ d.show_all();
+ int res = d.run();
+ if (res != Gtk.ResponseType.ACCEPT) {
+ // User hit cancel, restore original date.
+ cal.day = orig_day;
+ cal.month = orig_month;
+ cal.year = orig_year;
+ }
+ cal.disconnect(id_1);
+ cal.disconnect(id_2);
+ d.destroy();
+ update_date_labels();
+ }
+
+ private void on_one_clicked() {
+ popup_calendar(cal_one);
+ }
+
+ private void on_two_clicked() {
+ popup_calendar(cal_two);
+ }
+ }
+
+ private Gtk.Builder builder;
+ private Gtk.Dialog dialog;
+ private Gtk.Button add_criteria;
+ private Gtk.ComboBoxText operator;
+ private Gtk.Box row_box;
+ private Gtk.Entry search_title;
+ private Gee.ArrayList<SearchRowContainer> row_list = new Gee.ArrayList<SearchRowContainer>();
+ private bool edit_mode = false;
+ private SavedSearch? previous_search = null;
+ private bool valid = false;
+
+ public SavedSearchDialog() {
+ setup_dialog();
+
+ // Default name.
+ search_title.set_text(SavedSearchTable.get_instance().generate_unique_name());
+ search_title.select_region(0, -1); // select all
+
+ // Default is text search.
+ add_text_search();
+ row_list.get(0).allow_removal(false);
+
+ // Add buttons for new search.
+ dialog.add_action_widget(new Gtk.Button.from_stock(Gtk.Stock.CANCEL), Gtk.ResponseType.CANCEL);
+ Gtk.Button ok_button = new Gtk.Button.from_stock(Gtk.Stock.OK);
+ ok_button.can_default = true;
+ dialog.add_action_widget(ok_button, Gtk.ResponseType.OK);
+ dialog.set_default_response(Gtk.ResponseType.OK);
+
+ dialog.show_all();
+ set_valid(false);
+ }
+
+ public SavedSearchDialog.edit_existing(SavedSearch saved_search) {
+ previous_search = saved_search;
+ edit_mode = true;
+ setup_dialog();
+
+ // Add close button.
+ Gtk.Button close_button = new Gtk.Button.from_stock(Gtk.Stock.CLOSE);
+ close_button.can_default = true;
+ dialog.add_action_widget(close_button, Gtk.ResponseType.OK);
+ dialog.set_default_response(Gtk.ResponseType.OK);
+
+ dialog.show_all();
+
+ // Load existing search into dialog.
+ operator.set_active((SearchOperator) saved_search.get_operator());
+ search_title.set_text(saved_search.get_name());
+ foreach (SearchCondition sc in saved_search.get_conditions()) {
+ add_row(new SearchRowContainer.edit_existing(sc));
+ }
+
+ if (row_list.size == 1)
+ row_list.get(0).allow_removal(false);
+
+ set_valid(true);
+ }
+
+ ~SavedSearchDialog() {
+ search_title.changed.disconnect(on_title_changed);
+ }
+
+ // Builds the dialog UI. Doesn't add buttons to the dialog or call dialog.show().
+ private void setup_dialog() {
+ builder = AppWindow.create_builder();
+
+ dialog = builder.get_object("Search criteria") as Gtk.Dialog;
+ dialog.set_parent_window(AppWindow.get_instance().get_parent_window());
+ dialog.set_transient_for(AppWindow.get_instance());
+ dialog.response.connect(on_response);
+
+ add_criteria = builder.get_object("Add search button") as Gtk.Button;
+ add_criteria.button_press_event.connect(on_add_criteria);
+
+ search_title = builder.get_object("Search title") as Gtk.Entry;
+ search_title.set_activates_default(true);
+ search_title.changed.connect(on_title_changed);
+
+ row_box = builder.get_object("row_box") as Gtk.Box;
+
+ operator = builder.get_object("Type of search criteria") as Gtk.ComboBoxText;
+ operator.append_text(_("any"));
+ operator.append_text(_("all"));
+ operator.append_text(_("none"));
+ operator.set_active(0);
+ }
+
+ // Displays the dialog.
+ public void show() {
+ dialog.run();
+ dialog.destroy();
+ }
+
+ // Adds a row of search criteria.
+ private bool on_add_criteria(Gdk.EventButton event) {
+ add_text_search();
+ return false;
+ }
+
+ private void add_text_search() {
+ SearchRowContainer text = new SearchRowContainer();
+ add_row(text);
+ }
+
+ // Appends a row of search criteria to the list and table.
+ private void add_row(SearchRowContainer row) {
+ if (row_list.size == 1)
+ row_list.get(0).allow_removal(true);
+ row_box.add(row.get_widget());
+ row_list.add(row);
+ row.remove.connect(on_remove_row);
+ row.changed.connect(on_row_changed);
+ set_valid(row.is_complete());
+ }
+
+ // Removes a row of search criteria.
+ private void on_remove_row(SearchRowContainer row) {
+ row.remove.disconnect(on_remove_row);
+ row.changed.disconnect(on_row_changed);
+ row_box.remove(row.get_widget());
+ row_list.remove(row);
+ if (row_list.size == 1)
+ row_list.get(0).allow_removal(false);
+ set_valid(true); // try setting to "true" since we removed a row
+ }
+
+ private void on_response(int response_id) {
+ if (response_id == Gtk.ResponseType.OK) {
+ if (SavedSearchTable.get_instance().exists(search_title.get_text()) &&
+ !(edit_mode && previous_search.get_name() == search_title.get_text())) {
+ AppWindow.error_message(Resources.rename_search_exists_message(search_title.get_text()));
+ return;
+ }
+
+ if (edit_mode) {
+ // Remove previous search.
+ SavedSearchTable.get_instance().remove(previous_search);
+ }
+
+ // Build the condition list from the search rows, and add our new saved search to the table.
+ Gee.ArrayList<SearchCondition> conditions = new Gee.ArrayList<SearchCondition>();
+ foreach (SearchRowContainer c in row_list) {
+ conditions.add(c.get_search_condition());
+ }
+
+ // Create the object. It will be added to the DB and SearchTable automatically.
+ SearchOperator search_operator = (SearchOperator)operator.get_active();
+ SavedSearchTable.get_instance().create(search_title.get_text(), search_operator, conditions);
+ }
+ }
+
+ private void on_row_changed(SearchRowContainer row) {
+ set_valid(row.is_complete());
+ }
+
+ private void on_title_changed() {
+ set_valid(is_title_valid());
+ }
+
+ private bool is_title_valid() {
+ if (edit_mode && previous_search != null &&
+ previous_search.get_name() == search_title.get_text())
+ return true; // Title hasn't changed.
+ if (search_title.get_text().chomp() == "")
+ return false;
+ if (SavedSearchTable.get_instance().exists(search_title.get_text()))
+ return false;
+ return true;
+ }
+
+ // Call this with your new value for validity whenever a row or the title changes.
+ private void set_valid(bool v) {
+ if (!v) {
+ valid = false;
+ } else if (v != valid) {
+ if (is_title_valid()) {
+ // Go through rows to check validity.
+ int valid_rows = 0;
+ foreach (SearchRowContainer c in row_list) {
+ if (c.is_complete())
+ valid_rows++;
+ }
+ valid = (valid_rows == row_list.size);
+ } else {
+ valid = false; // title was invalid
+ }
+ }
+
+ dialog.set_response_sensitive(Gtk.ResponseType.OK, valid);
+ }
+}
diff --git a/src/searches/SavedSearchPage.vala b/src/searches/SavedSearchPage.vala
new file mode 100644
index 0000000..8e6672e
--- /dev/null
+++ b/src/searches/SavedSearchPage.vala
@@ -0,0 +1,92 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// Source monitoring for saved searches.
+private class SavedSearchManager : CollectionViewManager {
+ SavedSearch search;
+ public SavedSearchManager(SavedSearchPage owner, SavedSearch search) {
+ base (owner);
+ this.search = search;
+ }
+
+ public override bool include_in_view(DataSource source) {
+ return search.predicate((MediaSource) source);
+ }
+}
+
+// Page for displaying saved searches.
+public class SavedSearchPage : CollectionPage {
+
+ // The search logic and parameters are contained in the SavedSearch.
+ private SavedSearch search;
+
+ public SavedSearchPage(SavedSearch search) {
+ base (search.get_name());
+ this.search = search;
+
+
+ foreach (MediaSourceCollection sources in MediaCollectionRegistry.get_instance().get_all())
+ get_view().monitor_source_collection(sources, new SavedSearchManager(this, search), null);
+
+ init_page_context_menu("/SearchContextMenu");
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_library_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_library_photos_sort(sort_order, sort_by);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+ ui_filenames.add("savedsearch.ui");
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry rename_search = { "RenameSearch", null, TRANSLATABLE, null, null, on_rename_search };
+ actions += rename_search;
+
+ Gtk.ActionEntry edit_search = { "EditSearch", null, TRANSLATABLE, null, null, on_edit_search };
+ actions += edit_search;
+
+ Gtk.ActionEntry delete_search = { "DeleteSearch", null, TRANSLATABLE, null, null, on_delete_search };
+ actions += delete_search;
+
+ return actions;
+ }
+
+ private void on_delete_search() {
+ if (Dialogs.confirm_delete_saved_search(search))
+ AppWindow.get_command_manager().execute(new DeleteSavedSearchCommand(search));
+ }
+
+ private void on_rename_search() {
+ LibraryWindow.get_app().rename_search_in_sidebar(search);
+ }
+
+ private void on_edit_search() {
+ SavedSearchDialog ssd = new SavedSearchDialog.edit_existing(search);
+ ssd.show();
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ set_action_details("RenameSearch",
+ Resources.RENAME_SEARCH_MENU,
+ null, true);
+ set_action_details("EditSearch",
+ Resources.EDIT_SEARCH_MENU,
+ null, true);
+ set_action_details("DeleteSearch",
+ Resources.DELETE_SEARCH_MENU,
+ null, true);
+ base.update_actions(selected_count, count);
+ }
+}
+
diff --git a/src/searches/SearchBoolean.vala b/src/searches/SearchBoolean.vala
new file mode 100644
index 0000000..431e398
--- /dev/null
+++ b/src/searches/SearchBoolean.vala
@@ -0,0 +1,971 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// For specifying whether a search should be ORed (any) or ANDed (all).
+public enum SearchOperator {
+ ANY = 0,
+ ALL,
+ NONE;
+
+ public string to_string() {
+ switch (this) {
+ case SearchOperator.ANY:
+ return "ANY";
+
+ case SearchOperator.ALL:
+ return "ALL";
+
+ case SearchOperator.NONE:
+ return "NONE";
+
+ default:
+ error("unrecognized search operator enumeration value");
+ }
+ }
+
+ public static SearchOperator from_string(string str) {
+ if (str == "ANY")
+ return SearchOperator.ANY;
+
+ else if (str == "ALL")
+ return SearchOperator.ALL;
+
+ else if (str == "NONE")
+ return SearchOperator.NONE;
+
+ else
+ error("unrecognized search operator name: %s", str);
+ }
+}
+
+// Important note: if you are adding, removing, or otherwise changing
+// this table, you're going to have to modify SavedSearchDBTable.vala
+// as well.
+public abstract class SearchCondition {
+ // Type of search condition.
+ public enum SearchType {
+ ANY_TEXT = 0,
+ TITLE,
+ TAG,
+ EVENT_NAME,
+ FILE_NAME,
+ MEDIA_TYPE,
+ FLAG_STATE,
+ MODIFIED_STATE,
+ RATING,
+ COMMENT,
+ DATE;
+ // Note: when adding new types, be sure to update all functions below.
+
+ public static SearchType[] as_array() {
+ return { ANY_TEXT, TITLE, TAG, COMMENT, EVENT_NAME, FILE_NAME,
+ MEDIA_TYPE, FLAG_STATE, MODIFIED_STATE, RATING, DATE };
+ }
+
+ // Sorts an array alphabetically by display name.
+ public static void sort_array(ref SearchType[] array) {
+ Posix.qsort(array, array.length, sizeof(SearchType), (a, b) => {
+ return utf8_cs_compare(((*(SearchType*) a)).display_text(),
+ ((*(SearchType*) b)).display_text());
+ });
+ }
+
+ public string to_string() {
+ switch (this) {
+ case SearchType.ANY_TEXT:
+ return "ANY_TEXT";
+
+ case SearchType.TITLE:
+ return "TITLE";
+
+ case SearchType.TAG:
+ return "TAG";
+
+ case SearchType.COMMENT:
+ return "COMMENT";
+
+ case SearchType.EVENT_NAME:
+ return "EVENT_NAME";
+
+ case SearchType.FILE_NAME:
+ return "FILE_NAME";
+
+ case SearchType.MEDIA_TYPE:
+ return "MEDIA_TYPE";
+
+ case SearchType.FLAG_STATE:
+ return "FLAG_STATE";
+
+ case SearchType.MODIFIED_STATE:
+ return "MODIFIED_STATE";
+
+ case SearchType.RATING:
+ return "RATING";
+
+ case SearchType.DATE:
+ return "DATE";
+
+ default:
+ error("unrecognized search type enumeration value");
+ }
+ }
+
+ public static SearchType from_string(string str) {
+ if (str == "ANY_TEXT")
+ return SearchType.ANY_TEXT;
+
+ else if (str == "TITLE")
+ return SearchType.TITLE;
+
+ else if (str == "TAG")
+ return SearchType.TAG;
+
+ else if (str == "COMMENT")
+ return SearchType.COMMENT;
+
+ else if (str == "EVENT_NAME")
+ return SearchType.EVENT_NAME;
+
+ else if (str == "FILE_NAME")
+ return SearchType.FILE_NAME;
+
+ else if (str == "MEDIA_TYPE")
+ return SearchType.MEDIA_TYPE;
+
+ else if (str == "FLAG_STATE")
+ return SearchType.FLAG_STATE;
+
+ else if (str == "MODIFIED_STATE")
+ return SearchType.MODIFIED_STATE;
+
+ else if (str == "RATING")
+ return SearchType.RATING;
+
+ else if (str == "DATE")
+ return SearchType.DATE;
+
+ else
+ error("unrecognized search type name: %s", str);
+ }
+
+ public string display_text() {
+ switch (this) {
+ case SearchType.ANY_TEXT:
+ return _("Any text");
+
+ case SearchType.TITLE:
+ return _("Title");
+
+ case SearchType.TAG:
+ return _("Tag");
+
+ case SearchType.COMMENT:
+ return _("Comment");
+
+ case SearchType.EVENT_NAME:
+ return _("Event name");
+
+ case SearchType.FILE_NAME:
+ return _("File name");
+
+ case SearchType.MEDIA_TYPE:
+ return _("Media type");
+
+ case SearchType.FLAG_STATE:
+ return _("Flag state");
+
+ case SearchType.MODIFIED_STATE:
+ return _("Photo state");
+
+ case SearchType.RATING:
+ return _("Rating");
+
+ case SearchType.DATE:
+ return _("Date");
+
+ default:
+ error("unrecognized search type enumeration value");
+ }
+ }
+ }
+
+ public SearchType search_type { get; protected set; }
+
+ // Determines whether the source is included.
+ public abstract bool predicate(MediaSource source);
+}
+
+// Condition for text matching.
+public class SearchConditionText : SearchCondition {
+ public enum Context {
+ CONTAINS = 0,
+ IS_EXACTLY,
+ STARTS_WITH,
+ ENDS_WITH,
+ DOES_NOT_CONTAIN,
+ IS_NOT_SET;
+
+ public string to_string() {
+ switch (this) {
+ case Context.CONTAINS:
+ return "CONTAINS";
+
+ case Context.IS_EXACTLY:
+ return "IS_EXACTLY";
+
+ case Context.STARTS_WITH:
+ return "STARTS_WITH";
+
+ case Context.ENDS_WITH:
+ return "ENDS_WITH";
+
+ case Context.DOES_NOT_CONTAIN:
+ return "DOES_NOT_CONTAIN";
+
+ case Context.IS_NOT_SET:
+ return "IS_NOT_SET";
+
+ default:
+ error("unrecognized text search context enumeration value");
+ }
+ }
+
+ public static Context from_string(string str) {
+ if (str == "CONTAINS")
+ return Context.CONTAINS;
+
+ else if (str == "IS_EXACTLY")
+ return Context.IS_EXACTLY;
+
+ else if (str == "STARTS_WITH")
+ return Context.STARTS_WITH;
+
+ else if (str == "ENDS_WITH")
+ return Context.ENDS_WITH;
+
+ else if (str == "DOES_NOT_CONTAIN")
+ return Context.DOES_NOT_CONTAIN;
+
+ else if (str == "IS_NOT_SET")
+ return Context.IS_NOT_SET;
+
+ else
+ error("unrecognized text search context name: %s", str);
+ }
+ }
+
+ // What to search for.
+ public string text { get; private set; }
+
+ // How to match.
+ public Context context { get; private set; }
+
+ public SearchConditionText(SearchCondition.SearchType search_type, string? text, Context context) {
+ this.search_type = search_type;
+ this.text = (text != null) ? String.remove_diacritics(text.down()) : "";
+ this.context = context;
+ }
+
+ // Match string by context.
+ private bool string_match(string needle, string? haystack) {
+ switch (context) {
+ case Context.CONTAINS:
+ case Context.DOES_NOT_CONTAIN:
+ return !is_string_empty(haystack) && haystack.contains(needle);
+
+ case Context.IS_EXACTLY:
+ return !is_string_empty(haystack) && haystack == needle;
+
+ case Context.STARTS_WITH:
+ return !is_string_empty(haystack) && haystack.has_prefix(needle);
+
+ case Context.ENDS_WITH:
+ return !is_string_empty(haystack) && haystack.has_suffix(needle);
+
+ case Context.IS_NOT_SET:
+ return (is_string_empty(haystack));
+ }
+
+ return false;
+ }
+
+ // Determines whether the source is included.
+ public override bool predicate(MediaSource source) {
+ bool ret = false;
+
+ // title
+ if (SearchType.ANY_TEXT == search_type || SearchType.TITLE == search_type) {
+ string title = source.get_title();
+ if(title != null){
+ ret |= string_match(text, String.remove_diacritics(title.down()));
+ }
+ }
+
+ // tags
+ if (SearchType.ANY_TEXT == search_type || SearchType.TAG == search_type) {
+ Gee.List<Tag>? tag_list = Tag.global.fetch_for_source(source);
+ if (null != tag_list) {
+ string itag;
+ foreach (Tag tag in tag_list) {
+ itag = tag.get_searchable_name().down(); // get_searchable already remove diacritics
+ ret |= string_match(text, itag);
+ }
+ } else {
+ ret |= string_match(text, null); // for IS_NOT_SET
+ }
+ }
+
+ // event name
+ if (SearchType.ANY_TEXT == search_type || SearchType.EVENT_NAME == search_type) {
+ string? event_name = (null != source.get_event()) ?
+ String.remove_diacritics(source.get_event().get_name().down()) : null;
+ ret |= string_match(text, event_name);
+ }
+
+ // comment
+ if (SearchType.ANY_TEXT == search_type || SearchType.COMMENT == search_type) {
+ string? comment = source.get_comment();
+ if(null != comment)
+ ret |= string_match(text, String.remove_diacritics(comment.down()));
+ }
+
+ // file name
+ if (SearchType.ANY_TEXT == search_type || SearchType.FILE_NAME == search_type) {
+ ret |= string_match(text, String.remove_diacritics(source.get_basename().down()));
+ }
+
+ return (context == Context.DOES_NOT_CONTAIN) ? !ret : ret;
+ }
+}
+
+// Condition for media type matching.
+public class SearchConditionMediaType : SearchCondition {
+ public enum Context {
+ IS = 0,
+ IS_NOT;
+
+ public string to_string() {
+ switch (this) {
+ case Context.IS:
+ return "IS";
+
+ case Context.IS_NOT:
+ return "IS_NOT";
+
+ default:
+ error("unrecognized media search context enumeration value");
+ }
+ }
+
+ public static Context from_string(string str) {
+ if (str == "IS")
+ return Context.IS;
+
+ else if (str == "IS_NOT")
+ return Context.IS_NOT;
+
+ else
+ error("unrecognized media search context name: %s", str);
+ }
+ }
+
+ public enum MediaType {
+ PHOTO_ALL = 0,
+ PHOTO_RAW,
+ VIDEO;
+
+ public string to_string() {
+ switch (this) {
+ case MediaType.PHOTO_ALL:
+ return "PHOTO_ALL";
+
+ case MediaType.PHOTO_RAW:
+ return "PHOTO_RAW";
+
+ case MediaType.VIDEO:
+ return "VIDEO";
+
+ default:
+ error("unrecognized media search type enumeration value");
+ }
+ }
+
+ public static MediaType from_string(string str) {
+ if (str == "PHOTO_ALL")
+ return MediaType.PHOTO_ALL;
+
+ else if (str == "PHOTO_RAW")
+ return MediaType.PHOTO_RAW;
+
+ else if (str == "VIDEO")
+ return MediaType.VIDEO;
+
+ else
+ error("unrecognized media search type name: %s", str);
+ }
+ }
+
+ // What to search for.
+ public MediaType media_type { get; private set; }
+
+ // How to match.
+ public Context context { get; private set; }
+
+ public SearchConditionMediaType(SearchCondition.SearchType search_type, Context context, MediaType media_type) {
+ this.search_type = search_type;
+ this.context = context;
+ this.media_type = media_type;
+ }
+
+ // Determines whether the source is included.
+ public override bool predicate(MediaSource source) {
+ // For the given type, check it against the MediaSource type
+ // and the given search context.
+ switch (media_type) {
+ case MediaType.PHOTO_ALL:
+ if (source is Photo)
+ return context == Context.IS;
+ else
+ return context == Context.IS_NOT;
+
+ case MediaType.PHOTO_RAW:
+ if (source is Photo && ((Photo) source).get_master_file_format() == PhotoFileFormat.RAW)
+ return context == Context.IS;
+ else
+ return context == Context.IS_NOT;
+
+ case MediaType.VIDEO:
+ if (source is VideoSource)
+ return context == Context.IS;
+ else
+ return context == Context.IS_NOT;
+
+ default:
+ error("unrecognized media search type enumeration value");
+ }
+ }
+}
+
+// Condition for flag state matching.
+public class SearchConditionFlagged : SearchCondition {
+ public enum State {
+ FLAGGED = 0,
+ UNFLAGGED;
+
+ public string to_string() {
+ switch (this) {
+ case State.FLAGGED:
+ return "FLAGGED";
+
+ case State.UNFLAGGED:
+ return "UNFLAGGED";
+
+ default:
+ error("unrecognized flagged search state enumeration value");
+ }
+ }
+
+ public static State from_string(string str) {
+ if (str == "FLAGGED")
+ return State.FLAGGED;
+
+ else if (str == "UNFLAGGED")
+ return State.UNFLAGGED;
+
+ else
+ error("unrecognized flagged search state name: %s", str);
+ }
+ }
+
+ // What to match.
+ public State state { get; private set; }
+
+ public SearchConditionFlagged(SearchCondition.SearchType search_type, State state) {
+ this.search_type = search_type;
+ this.state = state;
+ }
+
+ // Determines whether the source is included.
+ public override bool predicate(MediaSource source) {
+ if (state == State.FLAGGED) {
+ return ((Flaggable) source).is_flagged();
+ } else if (state == State.UNFLAGGED) {
+ return !((Flaggable) source).is_flagged();
+ } else {
+ error("unrecognized flagged search state");
+ }
+ }
+}
+
+// Condition for modified state matching.
+public class SearchConditionModified : SearchCondition {
+
+ public enum Context {
+ HAS = 0,
+ HAS_NO;
+
+ public string to_string() {
+ switch (this) {
+ case Context.HAS:
+ return "HAS";
+
+ case Context.HAS_NO:
+ return "HAS_NO";
+
+ default:
+ error("unrecognized modified search context enumeration value");
+ }
+ }
+
+ public static Context from_string(string str) {
+ if (str == "HAS")
+ return Context.HAS;
+
+ else if (str == "HAS_NO")
+ return Context.HAS_NO;
+
+ else
+ error("unrecognized modified search context name: %s", str);
+ }
+ }
+
+ public enum State {
+ MODIFIED = 0,
+ INTERNAL_CHANGES,
+ EXTERNAL_CHANGES;
+
+ public string to_string() {
+ switch (this) {
+ case State.MODIFIED:
+ return "MODIFIED";
+
+ case State.INTERNAL_CHANGES:
+ return "INTERNAL_CHANGES";
+
+ case State.EXTERNAL_CHANGES:
+ return "EXTERNAL_CHANGES";
+
+ default:
+ error("unrecognized modified search state enumeration value");
+ }
+ }
+
+ public static State from_string(string str) {
+ if (str == "MODIFIED")
+ return State.MODIFIED;
+
+ else if (str == "INTERNAL_CHANGES")
+ return State.INTERNAL_CHANGES;
+
+ else if (str == "EXTERNAL_CHANGES")
+ return State.EXTERNAL_CHANGES;
+
+ else
+ error("unrecognized modified search state name: %s", str);
+ }
+ }
+
+ // What to match.
+ public State state { get; private set; }
+
+ // How to match.
+ public Context context { get; private set; }
+
+ public SearchConditionModified(SearchCondition.SearchType search_type, Context context, State state) {
+ this.search_type = search_type;
+ this.context = context;
+ this.state = state;
+ }
+
+ // Determines whether the source is included.
+ public override bool predicate(MediaSource source) {
+ // check against state and the given search context.
+ Photo? photo = source as Photo;
+ if (photo == null)
+ return false;
+
+ bool match;
+ if (state == State.MODIFIED)
+ match = photo.has_transformations() || photo.has_editable();
+ else if (state == State.INTERNAL_CHANGES)
+ match = photo.has_transformations();
+ else if (state == State.EXTERNAL_CHANGES)
+ match = photo.has_editable();
+ else
+ error("unrecognized modified search state");
+
+ if (match)
+ return context == Context.HAS;
+ else
+ return context == Context.HAS_NO;
+ }
+}
+
+
+// Condition for rating matching.
+public class SearchConditionRating : SearchCondition {
+ public enum Context {
+ AND_HIGHER = 0,
+ ONLY,
+ AND_LOWER;
+
+ public string to_string() {
+ switch (this) {
+ case Context.AND_HIGHER:
+ return "AND_HIGHER";
+
+ case Context.ONLY:
+ return "ONLY";
+
+ case Context.AND_LOWER:
+ return "AND_LOWER";
+
+ default:
+ error("unrecognized rating search context enumeration value");
+ }
+ }
+
+ public static Context from_string(string str) {
+ if (str == "AND_HIGHER")
+ return Context.AND_HIGHER;
+
+ else if (str == "ONLY")
+ return Context.ONLY;
+
+ else if (str == "AND_LOWER")
+ return Context.AND_LOWER;
+
+ else
+ error("unrecognized rating search context name: %s", str);
+ }
+ }
+
+ // Rating to check against.
+ public Rating rating { get; private set; }
+
+ // How to match.
+ public Context context { get; private set; }
+
+ public SearchConditionRating(SearchCondition.SearchType search_type, Rating rating, Context context) {
+ this.search_type = search_type;
+ this.rating = rating;
+ this.context = context;
+ }
+
+ // Determines whether the source is included.
+ public override bool predicate(MediaSource source) {
+ Rating source_rating = source.get_rating();
+ if (context == Context.AND_HIGHER)
+ return source_rating >= rating;
+ else if (context == Context.ONLY)
+ return source_rating == rating;
+ else if (context == Context.AND_LOWER)
+ return source_rating <= rating;
+ else
+ error("unknown rating search context");
+ }
+}
+
+
+// Condition for date range.
+public class SearchConditionDate : SearchCondition {
+ public enum Context {
+ EXACT = 0,
+ AFTER,
+ BEFORE,
+ BETWEEN,
+ IS_NOT_SET;
+
+ public string to_string() {
+ switch (this) {
+ case Context.EXACT:
+ return "EXACT";
+
+ case Context.AFTER:
+ return "AFTER";
+
+ case Context.BEFORE:
+ return "BEFORE";
+
+ case Context.BETWEEN:
+ return "BETWEEN";
+
+ case Context.IS_NOT_SET:
+ return "IS_NOT_SET";
+
+ default:
+ error("unrecognized date search context enumeration value");
+ }
+ }
+
+ public static Context from_string(string str) {
+ if (str == "EXACT")
+ return Context.EXACT;
+
+ if (str == "AFTER")
+ return Context.AFTER;
+
+ else if (str == "BEFORE")
+ return Context.BEFORE;
+
+ else if (str == "BETWEEN")
+ return Context.BETWEEN;
+
+ else if (str == "IS_NOT_SET")
+ return Context.IS_NOT_SET;
+
+ else
+ error("unrecognized date search context name: %s", str);
+ }
+ }
+
+ // Date to check against. Second date only used for between searches.
+ public DateTime date_one { get; private set; }
+ public DateTime date_two { get; private set; }
+
+ // How to match.
+ public Context context { get; private set; }
+
+ public SearchConditionDate(SearchCondition.SearchType search_type, Context context,
+ DateTime date_one, DateTime date_two) {
+ this.search_type = search_type;
+ this.context = context;
+ if (context != Context.BETWEEN || date_two.compare(date_one) >= 1) {
+ this.date_one = date_one;
+ this.date_two = date_two;
+ } else {
+ this.date_one = date_two;
+ this.date_two = date_one;
+ }
+
+ }
+
+ // Determines whether the source is included.
+ public override bool predicate(MediaSource source) {
+ time_t exposure_time = source.get_exposure_time();
+ if (exposure_time == 0)
+ return context == Context.IS_NOT_SET;
+
+ DateTime dt = new DateTime.from_unix_local(exposure_time);
+ switch (context) {
+ case Context.EXACT:
+ DateTime second = date_one.add_days(1);
+ return (dt.compare(date_one) >= 0 && dt.compare(second) < 0);
+
+ case Context.AFTER:
+ return (dt.compare(date_one) >= 0);
+
+ case Context.BEFORE:
+ return (dt.compare(date_one) <= 0);
+
+ case Context.BETWEEN:
+ DateTime second = date_two.add_days(1);
+ return (dt.compare(date_one) >= 0 && dt.compare(second) < 0);
+
+ case Context.IS_NOT_SET:
+ return false; // Already checked above.
+
+ default:
+ error("unrecognized date search context enumeration value");
+ }
+ }
+}
+
+// Contains the logic of a search.
+// A saved search requires a name, an AND/OR (all/any) operator, as well as a list of one or more conditions.
+public class SavedSearch : DataSource {
+ public const string TYPENAME = "saved_search";
+
+ // Row from the database.
+ private SavedSearchRow row;
+
+ public SavedSearch(SavedSearchRow row, int64 object_id = INVALID_OBJECT_ID) {
+ base (object_id);
+
+ this.row = row;
+ }
+
+ public override string get_name() {
+ return row.name;
+ }
+
+ public override string to_string() {
+ return "SavedSearch " + get_name();
+ }
+
+ public override string get_typename() {
+ return TYPENAME;
+ }
+
+ public SavedSearchID get_saved_search_id() {
+ return row.search_id;
+ }
+
+ public override int64 get_instance_id() {
+ return get_saved_search_id().id;
+ }
+
+ public static int compare_names(void *a, void *b) {
+ SavedSearch *asearch = (SavedSearch *) a;
+ SavedSearch *bsearch = (SavedSearch *) b;
+
+ return String.collated_compare(asearch->get_name(), bsearch->get_name());
+ }
+
+ public bool predicate(MediaSource source) {
+ bool ret;
+ if (SearchOperator.ALL == row.operator || SearchOperator.NONE == row.operator)
+ ret = true;
+ else
+ ret = false; // assumes conditions.size() > 0
+
+ foreach (SearchCondition c in row.conditions) {
+ if (SearchOperator.ALL == row.operator)
+ ret &= c.predicate(source);
+ else if (SearchOperator.ANY == row.operator)
+ ret |= c.predicate(source);
+ else if (SearchOperator.NONE == row.operator)
+ ret &= !c.predicate(source);
+ }
+ return ret;
+ }
+
+ public void reconstitute() {
+ try {
+ row.search_id = SavedSearchDBTable.get_instance().create_from_row(row);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ SavedSearchTable.get_instance().add_to_map(this);
+ debug("Reconstituted %s", to_string());
+ }
+
+ // Returns false if the name already exists or a bad name.
+ public bool rename(string new_name) {
+ if (is_string_empty(new_name))
+ return false;
+
+ if (SavedSearchTable.get_instance().exists(new_name))
+ return false;
+
+ try {
+ SavedSearchDBTable.get_instance().rename(row.search_id, new_name);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ return false;
+ }
+
+ SavedSearchTable.get_instance().remove_from_map(this);
+ row.name = new_name;
+ SavedSearchTable.get_instance().add_to_map(this);
+
+ LibraryWindow.get_app().switch_to_saved_search(this);
+ return true;
+ }
+
+ public Gee.List<SearchCondition> get_conditions() {
+ return row.conditions.read_only_view;
+ }
+
+ public SearchOperator get_operator() {
+ return row.operator;
+ }
+}
+
+// This table contains every saved search. It's the preferred way to add and destroy a saved
+// search as well, since this table's create/destroy methods are tied to the database.
+public class SavedSearchTable {
+ private static SavedSearchTable? instance = null;
+ private Gee.HashMap<string, SavedSearch> search_map = new Gee.HashMap<string, SavedSearch>();
+
+ public signal void search_added(SavedSearch search);
+ public signal void search_removed(SavedSearch search);
+
+ private SavedSearchTable() {
+ // Load existing searches from DB.
+ try {
+ foreach(SavedSearchRow row in SavedSearchDBTable.get_instance().get_all_rows())
+ add_to_map(new SavedSearch(row));
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ }
+
+ public static SavedSearchTable get_instance() {
+ if (instance == null)
+ instance = new SavedSearchTable();
+
+ return instance;
+ }
+
+ public Gee.Collection<SavedSearch> get_all() {
+ return search_map.values;
+ }
+
+ // Creates a saved search with the given name, operator, and conditions. The saved search is
+ // added to the database and to this table.
+ public SavedSearch create(string name, SearchOperator operator,
+ Gee.ArrayList<SearchCondition> conditions) {
+ SavedSearch? search = null;
+ // Create a new SavedSearch in the database.
+ try {
+ search = new SavedSearch(SavedSearchDBTable.get_instance().add(name, operator, conditions));
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ // Add search to table.
+ add_to_map(search);
+ LibraryWindow.get_app().switch_to_saved_search(search);
+ return search;
+ }
+
+ // Removes a saved search, both from here and from the table.
+ public void remove(SavedSearch search) {
+ try {
+ SavedSearchDBTable.get_instance().remove(search.get_saved_search_id());
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ remove_from_map(search);
+ }
+
+ public void add_to_map(SavedSearch search) {
+ search_map.set(search.get_name(), search);
+ search_added(search);
+ }
+
+ public void remove_from_map(SavedSearch search) {
+ search_map.unset(search.get_name());
+ search_removed(search);
+ }
+
+ public Gee.Iterable<SavedSearch> get_saved_searches() {
+ return search_map.values;
+ }
+
+ public int get_count() {
+ return search_map.size;
+ }
+
+ public bool exists(string search_name) {
+ return search_map.has_key(search_name);
+ }
+
+ // Generate a unique search name (not thread safe)
+ public string generate_unique_name() {
+ for (int ctr = 1; ctr < int.MAX; ctr++) {
+ string name = "%s %d".printf(Resources.DEFAULT_SAVED_SEARCH_NAME, ctr);
+
+ if (!exists(name))
+ return name;
+ }
+ return ""; // If all names are used (unlikely!)
+ }
+}
diff --git a/src/searches/Searches.vala b/src/searches/Searches.vala
new file mode 100644
index 0000000..478de86
--- /dev/null
+++ b/src/searches/Searches.vala
@@ -0,0 +1,31 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Searches 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 Searches {
+
+// preconfigure may be deleted if not used.
+public void preconfigure() {
+}
+
+public void init() throws Error {
+ Searches.SidebarEntry.init();
+}
+
+public void terminate() {
+ Searches.SidebarEntry.terminate();
+}
+
+}
+
diff --git a/src/searches/mk/searches.mk b/src/searches/mk/searches.mk
new file mode 100644
index 0000000..6df4b5d
--- /dev/null
+++ b/src/searches/mk/searches.mk
@@ -0,0 +1,31 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Searches
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := searches
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ SearchBoolean.vala \
+ SavedSearchPage.vala \
+ SavedSearchDialog.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/sidebar/Branch.vala b/src/sidebar/Branch.vala
new file mode 100644
index 0000000..23badda
--- /dev/null
+++ b/src/sidebar/Branch.vala
@@ -0,0 +1,450 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public delegate bool Locator<G>(G item);
+
+public class Sidebar.Branch : Object {
+ [Flags]
+ public enum Options {
+ NONE = 0,
+ HIDE_IF_EMPTY,
+ AUTO_OPEN_ON_NEW_CHILD,
+ STARTUP_EXPAND_TO_FIRST_CHILD,
+ STARTUP_OPEN_GROUPING;
+
+ public bool is_hide_if_empty() {
+ return (this & HIDE_IF_EMPTY) != 0;
+ }
+
+ public bool is_auto_open_on_new_child() {
+ return (this & AUTO_OPEN_ON_NEW_CHILD) != 0;
+ }
+
+ public bool is_startup_expand_to_first_child() {
+ return (this & STARTUP_EXPAND_TO_FIRST_CHILD) != 0;
+ }
+
+ public bool is_startup_open_grouping() {
+ return (this & STARTUP_OPEN_GROUPING) != 0;
+ }
+ }
+
+ private class Node {
+ public delegate void PruneCallback(Node node);
+
+ public delegate void ChildrenReorderedCallback(Node node);
+
+ public Sidebar.Entry entry;
+ public weak Node? parent;
+ public CompareDataFunc<Sidebar.Entry> comparator;
+ public Gee.SortedSet<Node>? children = null;
+
+ public Node(Sidebar.Entry entry, Node? parent,
+ owned CompareDataFunc<Sidebar.Entry> comparator) {
+ this.entry = entry;
+ this.parent = parent;
+ this.comparator = (owned) comparator;
+ }
+
+ private static int comparator_wrapper(Node? a, Node? b) {
+ if (a == b)
+ return 0;
+
+ assert(a.parent == b.parent);
+
+ return a.parent.comparator(a.entry, b.entry);
+ }
+
+ public bool has_children() {
+ return (children != null && children.size > 0);
+ }
+
+ public void add_child(Node child) {
+ child.parent = this;
+
+ if (children == null)
+ children = new Gee.TreeSet<Node>(comparator_wrapper);
+
+ bool added = children.add(child);
+ assert(added);
+ }
+
+ public void remove_child(Node child) {
+ assert(children != null);
+
+ Gee.SortedSet<Node> new_children = new Gee.TreeSet<Node>(comparator_wrapper);
+
+ // For similar reasons as in reorder_child(), can't rely on Gee.TreeSet to locate this
+ // node because we need reference equality.
+ bool found = false;
+ foreach (Node c in children) {
+ if (c != child)
+ new_children.add(c);
+ else
+ found = true;
+ }
+
+ assert(found);
+
+ if (new_children.size != 0)
+ children = new_children;
+ else
+ children = null;
+
+ child.parent = null;
+ }
+
+ public void prune_children(PruneCallback cb) {
+ if (children == null)
+ return;
+
+ foreach (Node child in children)
+ child.prune_children(cb);
+
+ Gee.SortedSet<Node> old_children = children;
+ children = null;
+
+ // Although this could've been done in the prior loop, it means notifying that
+ // a child has been removed prior to it being removed; this can cause problem
+ // if a signal handler calls back into the Tree to examine/add/remove nodes.
+ foreach (Node child in old_children)
+ cb(child);
+ }
+
+ // This returns the index of the Node purely by reference equality, making it useful if
+ // the criteria the Node is sorted upon has changed.
+ public int index_of_by_reference(Node child) {
+ if (children == null)
+ return -1;
+
+ int index = 0;
+ foreach (Node c in children) {
+ if (child == c)
+ return index;
+
+ index++;
+ }
+
+ return -1;
+ }
+
+ // Returns true if child moved when reordered.
+ public bool reorder_child(Node child) {
+ assert(children != null);
+
+ int old_index = index_of_by_reference(child);
+ assert(old_index >= 0);
+
+ // Because Gee.SortedSet uses the comparator for equality, if the Node's entry state
+ // has changed in such a way that the item is no longer sorted properly, the SortedSet's
+ // search and remove methods are useless. Makes no difference if children.remove() is
+ // called or the set is manually iterated over and removed via the Iterator -- a
+ // tree search is performed and the child will not be found. Only easy solution is
+ // to rebuild a new SortedSet and see if the child has moved.
+ Gee.SortedSet<Node> new_children = new Gee.TreeSet<Node>(comparator_wrapper);
+ bool added = new_children.add_all(children);
+ assert(added);
+
+ children = new_children;
+
+ int new_index = index_of_by_reference(child);
+ assert(new_index >= 0);
+
+ return (old_index != new_index);
+ }
+
+ public void reorder_children(bool recursive, ChildrenReorderedCallback cb) {
+ if (children == null)
+ return;
+
+ Gee.SortedSet<Node> reordered = new Gee.TreeSet<Node>(comparator_wrapper);
+ reordered.add_all(children);
+ children = reordered;
+
+ if (recursive) {
+ foreach (Node child in children)
+ child.reorder_children(true, cb);
+ }
+
+ cb(this);
+ }
+
+ public void change_comparator(owned CompareDataFunc<Sidebar.Entry> comparator, bool recursive,
+ ChildrenReorderedCallback cb) {
+ this.comparator = (owned) comparator;
+
+ // reorder children, but need to do manual recursion to set comparator
+ reorder_children(false, cb);
+
+ if (recursive) {
+ foreach (Node child in children)
+ child.change_comparator((owned) comparator, true, cb);
+ }
+ }
+ }
+
+ private Node root;
+ private Options options;
+ private bool shown = true;
+ private CompareDataFunc<Sidebar.Entry> default_comparator;
+ private Gee.HashMap<Sidebar.Entry, Node> map = new Gee.HashMap<Sidebar.Entry, Node>();
+
+ public signal void entry_added(Sidebar.Entry entry);
+
+ public signal void entry_removed(Sidebar.Entry entry);
+
+ public signal void entry_moved(Sidebar.Entry entry);
+
+ public signal void entry_reparented(Sidebar.Entry entry, Sidebar.Entry old_parent);
+
+ public signal void children_reordered(Sidebar.Entry entry);
+
+ public signal void show_branch(bool show);
+
+ public Branch(Sidebar.Entry root, Options options,
+ owned CompareDataFunc<Sidebar.Entry> default_comparator,
+ owned CompareDataFunc<Sidebar.Entry>? root_comparator = null) {
+ this.default_comparator = (owned) default_comparator;
+
+ CompareDataFunc<Sidebar.Entry>? broken_ternary_workaround;
+
+ if (root_comparator != null)
+ broken_ternary_workaround = (owned) root_comparator;
+ else
+ broken_ternary_workaround = (owned) default_comparator;
+
+ this.root = new Node(root, null, (owned) broken_ternary_workaround);
+ this.options = options;
+
+ map.set(root, this.root);
+
+ if (options.is_hide_if_empty())
+ set_show_branch(false);
+ }
+
+ public Sidebar.Entry get_root() {
+ return root.entry;
+ }
+
+ public void set_show_branch(bool shown) {
+ if (this.shown == shown)
+ return;
+
+ this.shown = shown;
+ show_branch(shown);
+ }
+
+ public bool get_show_branch() {
+ return shown;
+ }
+
+ public bool is_auto_open_on_new_child() {
+ return options.is_auto_open_on_new_child();
+ }
+
+ public bool is_startup_expand_to_first_child() {
+ return options.is_startup_expand_to_first_child();
+ }
+
+ public bool is_startup_open_grouping() {
+ return options.is_startup_open_grouping();
+ }
+
+ public void graft(Sidebar.Entry parent, Sidebar.Entry entry,
+ owned CompareDataFunc<Sidebar.Entry>? comparator = null) {
+ assert(map.has_key(parent));
+ assert(!map.has_key(entry));
+
+ if (options.is_hide_if_empty())
+ set_show_branch(true);
+
+ Node parent_node = map.get(parent);
+
+ CompareDataFunc<Sidebar.Entry>? broken_ternary_workaround;
+
+ if (comparator != null)
+ broken_ternary_workaround = (owned) comparator;
+ else
+ broken_ternary_workaround = (owned) default_comparator;
+
+ Node entry_node = new Node(entry, parent_node, (owned) broken_ternary_workaround);
+
+ parent_node.add_child(entry_node);
+ map.set(entry, entry_node);
+
+ entry_added(entry);
+ }
+
+ // Cannot prune the root. The Branch should simply be removed from the Tree.
+ public void prune(Sidebar.Entry entry) {
+ assert(entry != root.entry);
+ assert(map.has_key(entry));
+
+ Node entry_node = map.get(entry);
+
+ entry_node.prune_children(prune_callback);
+
+ assert(entry_node.parent != null);
+ entry_node.parent.remove_child(entry_node);
+
+ bool removed = map.unset(entry);
+ assert(removed);
+
+ entry_removed(entry);
+
+ if (options.is_hide_if_empty() && !root.has_children())
+ set_show_branch(false);
+ }
+
+ // Cannot reparent the root.
+ public void reparent(Sidebar.Entry new_parent, Sidebar.Entry entry) {
+ assert(entry != root.entry);
+ assert(map.has_key(entry));
+ assert(map.has_key(new_parent));
+
+ Node entry_node = map.get(entry);
+ Node new_parent_node = map.get(new_parent);
+
+ assert(entry_node.parent != null);
+ Sidebar.Entry old_parent = entry_node.parent.entry;
+
+ entry_node.parent.remove_child(entry_node);
+ new_parent_node.add_child(entry_node);
+
+ entry_reparented(entry, old_parent);
+ }
+
+ public bool has_entry(Sidebar.Entry entry) {
+ return (root.entry == entry || map.has_key(entry));
+ }
+
+ // Call when a value related to the comparison of this entry has changed. The root cannot be
+ // reordered.
+ public void reorder(Sidebar.Entry entry) {
+ assert(entry != root.entry);
+
+ Node? entry_node = map.get(entry);
+ assert(entry_node != null);
+
+ assert(entry_node.parent != null);
+ if (entry_node.parent.reorder_child(entry_node))
+ entry_moved(entry);
+ }
+
+ // Call when the entire tree needs to be reordered.
+ public void reorder_all() {
+ root.reorder_children(true, children_reordered_callback);
+ }
+
+ // Call when the children of the entry need to be reordered.
+ public void reorder_children(Sidebar.Entry entry, bool recursive) {
+ Node? entry_node = map.get(entry);
+ assert(entry_node != null);
+
+ entry_node.reorder_children(recursive, children_reordered_callback);
+ }
+
+ public void change_all_comparators(owned CompareDataFunc<Sidebar.Entry>? comparator) {
+ root.change_comparator((owned) comparator, true, children_reordered_callback);
+ }
+
+ public void change_comparator(Sidebar.Entry entry, bool recursive,
+ owned CompareDataFunc<Sidebar.Entry>? comparator) {
+ Node? entry_node = map.get(entry);
+ assert(entry_node != null);
+
+ entry_node.change_comparator((owned) comparator, recursive, children_reordered_callback);
+ }
+
+ public int get_child_count(Sidebar.Entry parent) {
+ Node? parent_node = map.get(parent);
+ assert(parent_node != null);
+
+ return (parent_node.children != null) ? parent_node.children.size : 0;
+ }
+
+ // Gets a snapshot of the children of the entry; this list will not be changed as the
+ // branch is updated.
+ public Gee.List<Sidebar.Entry>? get_children(Sidebar.Entry parent) {
+ assert(map.has_key(parent));
+
+ Node parent_node = map.get(parent);
+ if (parent_node.children == null)
+ return null;
+
+ Gee.List<Sidebar.Entry> child_entries = new Gee.ArrayList<Sidebar.Entry>();
+ foreach (Node child in parent_node.children)
+ child_entries.add(child.entry);
+
+ return child_entries;
+ }
+
+ public Sidebar.Entry? find_first_child(Sidebar.Entry parent, Locator<Sidebar.Entry> locator) {
+ Node? parent_node = map.get(parent);
+ assert(parent_node != null);
+
+ if (parent_node.children == null)
+ return null;
+
+ foreach (Node child in parent_node.children) {
+ if (locator(child.entry))
+ return child.entry;
+ }
+
+ return null;
+ }
+
+ // Returns null if entry is root;
+ public Sidebar.Entry? get_parent(Sidebar.Entry entry) {
+ if (entry == root.entry)
+ return null;
+
+ Node? entry_node = map.get(entry);
+ assert(entry_node != null);
+ assert(entry_node.parent != null);
+
+ return entry_node.parent.entry;
+ }
+
+ // Returns null if entry is root;
+ public Sidebar.Entry? get_previous_sibling(Sidebar.Entry entry) {
+ if (entry == root.entry)
+ return null;
+
+ Node? entry_node = map.get(entry);
+ assert(entry_node != null);
+ assert(entry_node.parent != null);
+ assert(entry_node.parent.children != null);
+
+ Node? sibling = entry_node.parent.children.lower(entry_node);
+
+ return (sibling != null) ? sibling.entry : null;
+ }
+
+ // Returns null if entry is root;
+ public Sidebar.Entry? get_next_sibling(Sidebar.Entry entry) {
+ if (entry == root.entry)
+ return null;
+
+ Node? entry_node = map.get(entry);
+ assert(entry_node != null);
+ assert(entry_node.parent != null);
+ assert(entry_node.parent.children != null);
+
+ Node? sibling = entry_node.parent.children.higher(entry_node);
+
+ return (sibling != null) ? sibling.entry : null;
+ }
+
+ private void prune_callback(Node node) {
+ entry_removed(node.entry);
+ }
+
+ private void children_reordered_callback(Node node) {
+ children_reordered(node.entry);
+ }
+}
+
diff --git a/src/sidebar/Entry.vala b/src/sidebar/Entry.vala
new file mode 100644
index 0000000..4162f21
--- /dev/null
+++ b/src/sidebar/Entry.vala
@@ -0,0 +1,70 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public interface Sidebar.Entry : Object {
+ public signal void sidebar_tooltip_changed(string? tooltip);
+
+ public signal void sidebar_icon_changed(Icon? icon);
+
+ public abstract string get_sidebar_name();
+
+ public abstract string? get_sidebar_tooltip();
+
+ public abstract Icon? get_sidebar_icon();
+
+ public abstract string to_string();
+
+ internal virtual void grafted(Sidebar.Tree tree) {
+ }
+
+ internal virtual void pruned(Sidebar.Tree tree) {
+ }
+}
+
+public interface Sidebar.ExpandableEntry : Sidebar.Entry {
+ public signal void sidebar_open_closed_icons_changed(Icon? open, Icon? closed);
+
+ public abstract Icon? get_sidebar_open_icon();
+
+ public abstract Icon? get_sidebar_closed_icon();
+
+ public abstract bool expand_on_select();
+}
+
+public interface Sidebar.SelectableEntry : Sidebar.Entry {
+}
+
+public interface Sidebar.PageRepresentative : Sidebar.Entry, Sidebar.SelectableEntry {
+ // Fired after the page has been created
+ public signal void page_created(Page page);
+
+ // Fired before the page is destroyed.
+ public signal void destroying_page(Page page);
+
+ public abstract bool has_page();
+
+ public abstract Page get_page();
+}
+
+public interface Sidebar.RenameableEntry : Sidebar.Entry {
+ public signal void sidebar_name_changed(string name);
+
+ public abstract void rename(string new_name);
+}
+
+public interface Sidebar.DestroyableEntry : Sidebar.Entry {
+ public abstract void destroy_source();
+}
+
+public interface Sidebar.InternalDropTargetEntry : Sidebar.Entry {
+ // Returns true if drop was successful
+ public abstract bool internal_drop_received(Gee.List<MediaSource> sources);
+ public abstract bool internal_drop_received_arbitrary(Gtk.SelectionData data);
+}
+
+public interface Sidebar.InternalDragSourceEntry : Sidebar.Entry {
+ public abstract void prepare_selection_data(Gtk.SelectionData data);
+}
diff --git a/src/sidebar/Sidebar.vala b/src/sidebar/Sidebar.vala
new file mode 100644
index 0000000..8f6904b
--- /dev/null
+++ b/src/sidebar/Sidebar.vala
@@ -0,0 +1,16 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Sidebar {
+
+public void init() throws Error {
+}
+
+public void terminate() {
+}
+
+}
+
diff --git a/src/sidebar/Tree.vala b/src/sidebar/Tree.vala
new file mode 100644
index 0000000..37da7e0
--- /dev/null
+++ b/src/sidebar/Tree.vala
@@ -0,0 +1,1175 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Sidebar.Tree : Gtk.TreeView {
+ public const int ICON_SIZE = 16;
+
+ // Only one ExternalDropHandler can be registered with the Tree; it's responsible for completing
+ // the "drag-data-received" signal properly.
+ public delegate void ExternalDropHandler(Gdk.DragContext context, Sidebar.Entry? entry,
+ Gtk.SelectionData data, uint info, uint time);
+
+ private class EntryWrapper : Object {
+ public Sidebar.Entry entry;
+ public Gtk.TreeRowReference row;
+
+ public EntryWrapper(Gtk.TreeModel model, Sidebar.Entry entry, Gtk.TreePath path) {
+ this.entry = entry;
+ this.row = new Gtk.TreeRowReference(model, path);
+ }
+
+ public Gtk.TreePath get_path() {
+ return row.get_path();
+ }
+
+ public Gtk.TreeIter get_iter() {
+ Gtk.TreeIter iter;
+ bool valid = row.get_model().get_iter(out iter, get_path());
+ assert(valid);
+
+ return iter;
+ }
+ }
+
+ private class RootWrapper : EntryWrapper {
+ public int root_position;
+
+ public RootWrapper(Gtk.TreeModel model, Sidebar.Entry entry, Gtk.TreePath path, int root_position)
+ requires (root_position >= 0) {
+ base (model, entry, path);
+
+ this.root_position = root_position;
+ }
+ }
+
+ private enum Columns {
+ NAME,
+ TOOLTIP,
+ WRAPPER,
+ PIXBUF,
+ CLOSED_PIXBUF,
+ OPEN_PIXBUF,
+ N_COLUMNS
+ }
+
+ private Gtk.TreeStore store = new Gtk.TreeStore(Columns.N_COLUMNS,
+ typeof (string), // NAME
+ typeof (string?), // TOOLTIP
+ typeof (EntryWrapper), // WRAPPER
+ typeof (Gdk.Pixbuf?), // PIXBUF
+ typeof (Gdk.Pixbuf?), // CLOSED_PIXBUF
+ typeof (Gdk.Pixbuf?) // OPEN_PIXBUF
+ );
+
+ private Gtk.UIManager ui = new Gtk.UIManager();
+ private Gtk.IconTheme icon_theme;
+ private Gtk.CellRendererText text_renderer;
+ private unowned ExternalDropHandler drop_handler;
+ private Gtk.Entry? text_entry = null;
+ private Gee.HashMap<string, Gdk.Pixbuf> icon_cache = new Gee.HashMap<string, Gdk.Pixbuf>();
+ private Gee.HashMap<Sidebar.Entry, EntryWrapper> entry_map =
+ new Gee.HashMap<Sidebar.Entry, EntryWrapper>();
+ private Gee.HashMap<Sidebar.Branch, int> branches = new Gee.HashMap<Sidebar.Branch, int>();
+ private int editing_disabled = 0;
+ private bool mask_entry_selected_signal = false;
+ private weak EntryWrapper? selected_wrapper = null;
+ private Gtk.Menu? default_context_menu = null;
+ private bool is_internal_drag_in_progress = false;
+ private Sidebar.Entry? internal_drag_source_entry = null;
+
+ public signal void entry_selected(Sidebar.SelectableEntry selectable);
+
+ public signal void selected_entry_removed(Sidebar.SelectableEntry removed);
+
+ public signal void branch_added(Sidebar.Branch branch);
+
+ public signal void branch_removed(Sidebar.Branch branch);
+
+ public signal void branch_shown(Sidebar.Branch branch, bool shown);
+
+ public signal void page_created(Sidebar.PageRepresentative entry, Page page);
+
+ public signal void destroying_page(Sidebar.PageRepresentative entry, Page page);
+
+ public Tree(Gtk.TargetEntry[] target_entries, Gdk.DragAction actions,
+ ExternalDropHandler drop_handler) {
+ set_model(store);
+
+ Gtk.TreeViewColumn text_column = new Gtk.TreeViewColumn();
+ text_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);
+ Gtk.CellRendererPixbuf icon_renderer = new Gtk.CellRendererPixbuf();
+ text_column.pack_start(icon_renderer, false);
+ text_column.add_attribute(icon_renderer, "pixbuf", Columns.PIXBUF);
+ text_column.add_attribute(icon_renderer, "pixbuf_expander_closed", Columns.CLOSED_PIXBUF);
+ text_column.add_attribute(icon_renderer, "pixbuf_expander_open", Columns.OPEN_PIXBUF);
+ text_renderer = new Gtk.CellRendererText();
+ text_renderer.editing_canceled.connect(on_editing_canceled);
+ text_renderer.editing_started.connect(on_editing_started);
+ text_column.pack_start(text_renderer, true);
+ text_column.add_attribute(text_renderer, "markup", Columns.NAME);
+ append_column(text_column);
+
+ Gtk.CellRendererText invisitext = new Gtk.CellRendererText();
+ Gtk.TreeViewColumn page_holder = new Gtk.TreeViewColumn();
+ page_holder.pack_start(invisitext, true);
+ page_holder.visible = false;
+ append_column(page_holder);
+
+ set_headers_visible(false);
+ set_enable_search(false);
+ set_rules_hint(false);
+ set_show_expanders(true);
+ set_reorderable(false);
+ set_enable_tree_lines(false);
+ set_grid_lines(Gtk.TreeViewGridLines.NONE);
+ set_tooltip_column(Columns.TOOLTIP);
+
+ Gtk.TreeSelection selection = get_selection();
+ selection.set_mode(Gtk.SelectionMode.BROWSE);
+ selection.set_select_function(on_selection);
+
+ // It Would Be Nice if the target entries and actions were gleaned by querying each
+ // Sidebar.Entry as it was added, but that's a tad too complicated for our needs
+ // currently
+ enable_model_drag_dest(target_entries, actions);
+
+ Gtk.TargetEntry[] source_entries = new Gtk.TargetEntry[0];
+ source_entries += target_entries[LibraryWindow.TargetType.TAG_PATH];
+ enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, source_entries,
+ Gdk.DragAction.COPY);
+
+ this.drop_handler = drop_handler;
+
+ popup_menu.connect(on_context_menu_keypress);
+
+ icon_theme = Resources.get_icon_theme_engine();
+ icon_theme.changed.connect(on_theme_change);
+
+ setup_default_context_menu();
+
+ drag_begin.connect(on_drag_begin);
+ drag_end.connect(on_drag_end);
+ drag_motion.connect(on_drag_motion);
+ }
+
+ ~Tree() {
+ text_renderer.editing_canceled.disconnect(on_editing_canceled);
+ text_renderer.editing_started.disconnect(on_editing_started);
+ icon_theme.changed.disconnect(on_theme_change);
+ }
+
+ private void on_drag_begin(Gdk.DragContext ctx) {
+ is_internal_drag_in_progress = true;
+ }
+
+ private void on_drag_end(Gdk.DragContext ctx) {
+ is_internal_drag_in_progress = false;
+ internal_drag_source_entry = null;
+ }
+
+ private bool on_drag_motion (Gdk.DragContext context, int x, int y, uint time_) {
+ if (is_internal_drag_in_progress && internal_drag_source_entry == null) {
+ Gtk.TreePath? path;
+ Gtk.TreeViewDropPosition position;
+ get_dest_row_at_pos(x, y, out path, out position);
+
+ if (path != null) {
+ EntryWrapper wrapper = get_wrapper_at_path(path);
+ if (wrapper != null)
+ internal_drag_source_entry = wrapper.entry;
+ }
+ }
+
+ return false;
+ }
+
+ private void setup_default_context_menu() {
+ Gtk.ActionGroup group = new Gtk.ActionGroup("SidebarDefault");
+ Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
+
+ Gtk.ActionEntry new_search = { "CommonNewSearch", null, TRANSLATABLE, null, null, on_new_search };
+ new_search.label = _("Ne_w Saved Search...");
+ actions += new_search;
+
+ Gtk.ActionEntry new_tag = { "CommonNewTag", null, TRANSLATABLE, null, null, on_new_tag };
+ new_tag.label = _("New _Tag...");
+ actions += new_tag;
+
+ group.add_actions(actions, this);
+ ui.insert_action_group(group, 0);
+
+ File ui_file = Resources.get_ui("sidebar_default_context.ui");
+ try {
+ ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ AppWindow.error_message("Error loading UI file %s: %s".printf(
+ ui_file.get_path(), err.message));
+ Application.get_instance().panic();
+ }
+ default_context_menu = (Gtk.Menu) ui.get_widget("/SidebarDefaultContextMenu");
+
+ ui.ensure_update();
+ }
+
+ private bool has_wrapper(Sidebar.Entry entry) {
+ return entry_map.has_key(entry);
+ }
+
+ private EntryWrapper? get_wrapper(Sidebar.Entry entry) {
+ EntryWrapper? wrapper = entry_map.get(entry);
+ if (wrapper == null)
+ warning("Entry %s not found in sidebar", entry.to_string());
+
+ return wrapper;
+ }
+
+ private EntryWrapper? get_wrapper_at_iter(Gtk.TreeIter iter) {
+ Value val;
+ store.get_value(iter, Columns.WRAPPER, out val);
+
+ EntryWrapper? wrapper = (EntryWrapper?) val;
+ if (wrapper == null)
+ message("No entry found in sidebar at %s", store.get_path(iter).to_string());
+
+ return wrapper;
+ }
+
+ private EntryWrapper? get_wrapper_at_path(Gtk.TreePath path) {
+ Gtk.TreeIter iter;
+ if (!store.get_iter(out iter, path)) {
+ message("No entry found in sidebar at %s", path.to_string());
+
+ return null;
+ }
+
+ return get_wrapper_at_iter(iter);
+ }
+
+ // Note that this method will result in the "entry-selected" signal to fire if mask_signal
+ // is set to false.
+ public bool place_cursor(Sidebar.Entry entry, bool mask_signal) {
+ if (!expand_to_entry(entry))
+ return false;
+
+ EntryWrapper? wrapper = get_wrapper(entry);
+ if (wrapper == null)
+ return false;
+
+ get_selection().select_path(wrapper.get_path());
+
+ mask_entry_selected_signal = mask_signal;
+ set_cursor(wrapper.get_path(), null, false);
+ mask_entry_selected_signal = false;
+
+ return scroll_to_entry(entry);
+ }
+
+ public bool is_selected(Sidebar.Entry entry) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+
+ return (wrapper != null) ? get_selection().path_is_selected(wrapper.get_path()) : false;
+ }
+
+ public bool is_any_selected() {
+ return get_selection().count_selected_rows() != 0;
+ }
+
+ private Gtk.TreePath? get_selected_path() {
+ Gtk.TreeModel model;
+ GLib.List<Gtk.TreePath> rows = get_selection().get_selected_rows(out model);
+ assert(rows.length() == 0 || rows.length() == 1);
+
+ return rows.length() != 0 ? rows.nth_data(0) : null;
+ }
+
+ public override void cursor_changed() {
+ Gtk.TreePath? path = get_selected_path();
+ if (path == null) {
+ if (base.cursor_changed != null)
+ base.cursor_changed();
+
+ return;
+ }
+
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+
+ selected_wrapper = wrapper;
+
+ if (editing_disabled == 0 && wrapper != null)
+ text_renderer.editable = wrapper.entry is Sidebar.RenameableEntry;
+
+ if (wrapper != null && !mask_entry_selected_signal) {
+ Sidebar.SelectableEntry? selectable = wrapper.entry as Sidebar.SelectableEntry;
+ if (selectable != null)
+ entry_selected(selectable);
+ }
+
+ if (base.cursor_changed != null)
+ base.cursor_changed();
+ }
+
+ public void disable_editing() {
+ if (editing_disabled++ == 0)
+ text_renderer.editable = false;
+ }
+
+ public void enable_editing() {
+ Gtk.TreePath? path = get_selected_path();
+ if (path != null && editing_disabled > 0 && --editing_disabled == 0) {
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+ text_renderer.editable = (wrapper != null && (wrapper.entry is Sidebar.RenameableEntry));
+ }
+ }
+
+ public void toggle_branch_expansion(Gtk.TreePath path, bool expand_all) {
+ if (is_row_expanded(path))
+ collapse_row(path);
+ else
+ expand_row(path, expand_all);
+ }
+
+ public bool expand_to_entry(Sidebar.Entry entry) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ if (wrapper == null)
+ return false;
+
+ expand_to_path(wrapper.get_path());
+
+ return true;
+ }
+
+ public void expand_to_first_child(Sidebar.Entry entry) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ if (wrapper == null)
+ return;
+
+ Gtk.TreePath path = wrapper.get_path();
+
+ Gtk.TreeIter iter;
+ while (store.get_iter(out iter, path)) {
+ if (!store.iter_has_child(iter))
+ break;
+
+ path.down();
+ }
+
+ expand_to_path(path);
+ }
+
+ public void graft(Sidebar.Branch branch, int position) requires (position >= 0) {
+ assert(!branches.has_key(branch));
+
+ branches.set(branch, position);
+
+ if (branch.get_show_branch()) {
+ associate_branch(branch);
+
+ if (branch.is_startup_expand_to_first_child())
+ expand_to_first_child(branch.get_root());
+
+ if (branch.is_startup_open_grouping())
+ expand_to_entry(branch.get_root());
+ }
+
+ branch.entry_added.connect(on_branch_entry_added);
+ branch.entry_removed.connect(on_branch_entry_removed);
+ branch.entry_moved.connect(on_branch_entry_moved);
+ branch.entry_reparented.connect(on_branch_entry_reparented);
+ branch.children_reordered.connect(on_branch_children_reordered);
+ branch.show_branch.connect(on_show_branch);
+
+ branch_added(branch);
+ }
+
+ // This is used to associate a known branch with the TreeView.
+ private void associate_branch(Sidebar.Branch branch) {
+ assert(branches.has_key(branch));
+
+ int position = branches.get(branch);
+
+ Gtk.TreeIter? insertion_iter = null;
+
+ // search current roots for insertion point
+ Gtk.TreeIter iter;
+ bool found = store.get_iter_first(out iter);
+ while (found) {
+ RootWrapper? root_wrapper = get_wrapper_at_iter(iter) as RootWrapper;
+ assert(root_wrapper != null);
+
+ if (position < root_wrapper.root_position) {
+ store.insert_before(out insertion_iter, null, iter);
+
+ break;
+ }
+
+ found = store.iter_next(ref iter);
+ }
+
+ // if not found, append
+ if (insertion_iter == null)
+ store.append(out insertion_iter, null);
+
+ associate_wrapper(insertion_iter,
+ new RootWrapper(store, branch.get_root(), store.get_path(insertion_iter), position));
+
+ // mirror the branch's initial contents from below the root down, let the signals handle
+ // future work
+ associate_children(branch, branch.get_root(), insertion_iter);
+ }
+
+ private void associate_children(Sidebar.Branch branch, Sidebar.Entry parent,
+ Gtk.TreeIter parent_iter) {
+ Gee.List<Sidebar.Entry>? children = branch.get_children(parent);
+ if (children == null)
+ return;
+
+ foreach (Sidebar.Entry child in children) {
+ Gtk.TreeIter append_iter;
+ store.append(out append_iter, parent_iter);
+
+ associate_entry(append_iter, child);
+ associate_children(branch, child, append_iter);
+ }
+ }
+
+ private void associate_entry(Gtk.TreeIter assoc_iter, Sidebar.Entry entry) {
+ associate_wrapper(assoc_iter, new EntryWrapper(store, entry, store.get_path(assoc_iter)));
+ }
+
+ private void associate_wrapper(Gtk.TreeIter assoc_iter, EntryWrapper wrapper) {
+ Sidebar.Entry entry = wrapper.entry;
+
+ assert(!entry_map.has_key(entry));
+ entry_map.set(entry, wrapper);
+
+ store.set(assoc_iter, Columns.NAME, guarded_markup_escape_text(entry.get_sidebar_name()));
+ store.set(assoc_iter, Columns.TOOLTIP, guarded_markup_escape_text(entry.get_sidebar_tooltip()));
+ store.set(assoc_iter, Columns.WRAPPER, wrapper);
+ load_entry_icons(assoc_iter);
+
+ entry.sidebar_tooltip_changed.connect(on_sidebar_tooltip_changed);
+ entry.sidebar_icon_changed.connect(on_sidebar_icon_changed);
+
+ Sidebar.PageRepresentative? pageable = entry as Sidebar.PageRepresentative;
+ if (pageable != null) {
+ pageable.page_created.connect(on_sidebar_page_created);
+ pageable.destroying_page.connect(on_sidebar_destroying_page);
+ }
+
+ Sidebar.RenameableEntry? renameable = entry as Sidebar.RenameableEntry;
+ if (renameable != null)
+ renameable.sidebar_name_changed.connect(on_sidebar_name_changed);
+
+ Sidebar.ExpandableEntry? expandable = entry as Sidebar.ExpandableEntry;
+ if (expandable != null)
+ expandable.sidebar_open_closed_icons_changed.connect(on_sidebar_open_closed_icons_changed);
+
+ entry.grafted(this);
+ }
+
+ private EntryWrapper reparent_wrapper(Gtk.TreeIter new_iter, EntryWrapper current_wrapper) {
+ Sidebar.Entry entry = current_wrapper.entry;
+
+ bool removed = entry_map.unset(entry);
+ assert(removed);
+
+ EntryWrapper new_wrapper = new EntryWrapper(store, entry, store.get_path(new_iter));
+ entry_map.set(entry, new_wrapper);
+
+ store.set(new_iter, Columns.NAME, guarded_markup_escape_text(entry.get_sidebar_name()));
+ store.set(new_iter, Columns.TOOLTIP, guarded_markup_escape_text(entry.get_sidebar_tooltip()));
+ store.set(new_iter, Columns.WRAPPER, new_wrapper);
+ load_entry_icons(new_iter);
+
+ return new_wrapper;
+ }
+
+ public void prune(Sidebar.Branch branch) {
+ assert(branches.has_key(branch));
+
+ if (has_wrapper(branch.get_root()))
+ disassociate_branch(branch);
+
+ branch.entry_added.disconnect(on_branch_entry_added);
+ branch.entry_removed.disconnect(on_branch_entry_removed);
+ branch.entry_moved.disconnect(on_branch_entry_moved);
+ branch.entry_reparented.disconnect(on_branch_entry_reparented);
+ branch.children_reordered.disconnect(on_branch_children_reordered);
+ branch.show_branch.disconnect(on_show_branch);
+
+ bool removed = branches.unset(branch);
+ assert(removed);
+
+ branch_removed(branch);
+ }
+
+ private void disassociate_branch(Sidebar.Branch branch) {
+ RootWrapper? root_wrapper = get_wrapper(branch.get_root()) as RootWrapper;
+ assert(root_wrapper != null);
+
+ disassociate_wrapper_and_signal(root_wrapper, false);
+ }
+
+ // A wrapper for disassociate_wrapper() (?!?) that fires the "selected-entry-removed" signal if
+ // condition exists
+ private void disassociate_wrapper_and_signal(EntryWrapper wrapper, bool only_children) {
+ bool selected = is_selected(wrapper.entry);
+
+ disassociate_wrapper(wrapper, only_children);
+
+ if (selected) {
+ Sidebar.SelectableEntry? selectable = wrapper.entry as Sidebar.SelectableEntry;
+ assert(selectable != null);
+
+ selected_entry_removed(selectable);
+ }
+ }
+
+ private void disassociate_wrapper(EntryWrapper wrapper, bool only_children) {
+ Gee.ArrayList<EntryWrapper> children = new Gee.ArrayList<EntryWrapper>();
+
+ Gtk.TreeIter child_iter;
+ bool found = store.iter_children(out child_iter, wrapper.get_iter());
+ while (found) {
+ EntryWrapper? child_wrapper = get_wrapper_at_iter(child_iter);
+ assert(child_wrapper != null);
+
+ children.add(child_wrapper);
+
+ found = store.iter_next(ref child_iter);
+ }
+
+ foreach (EntryWrapper child_wrapper in children)
+ disassociate_wrapper(child_wrapper, false);
+
+ if (only_children)
+ return;
+
+ Gtk.TreeIter iter = wrapper.get_iter();
+ store.remove(ref iter);
+
+ if (selected_wrapper == wrapper)
+ selected_wrapper = null;
+
+ Sidebar.Entry entry = wrapper.entry;
+
+ entry.pruned(this);
+
+ entry.sidebar_tooltip_changed.disconnect(on_sidebar_tooltip_changed);
+ entry.sidebar_icon_changed.disconnect(on_sidebar_icon_changed);
+
+ Sidebar.PageRepresentative? pageable = entry as Sidebar.PageRepresentative;
+ if (pageable != null) {
+ pageable.page_created.disconnect(on_sidebar_page_created);
+ pageable.destroying_page.disconnect(on_sidebar_destroying_page);
+ }
+
+ Sidebar.RenameableEntry? renameable = entry as Sidebar.RenameableEntry;
+ if (renameable != null)
+ renameable.sidebar_name_changed.disconnect(on_sidebar_name_changed);
+
+ Sidebar.ExpandableEntry? expandable = entry as Sidebar.ExpandableEntry;
+ if (expandable != null)
+ expandable.sidebar_open_closed_icons_changed.disconnect(on_sidebar_open_closed_icons_changed);
+
+ bool removed = entry_map.unset(entry);
+ assert(removed);
+ }
+
+ private void on_branch_entry_added(Sidebar.Branch branch, Sidebar.Entry entry) {
+ Sidebar.Entry? parent = branch.get_parent(entry);
+ assert(parent != null);
+
+ EntryWrapper? parent_wrapper = get_wrapper(parent);
+ assert(parent_wrapper != null);
+
+ Gtk.TreeIter insertion_iter;
+ Sidebar.Entry? next = branch.get_next_sibling(entry);
+ if (next != null) {
+ EntryWrapper next_wrapper = get_wrapper(next);
+
+ // insert before the next sibling in this branch level
+ store.insert_before(out insertion_iter, parent_wrapper.get_iter(), next_wrapper.get_iter());
+ } else {
+ // append to the bottom of this branch level
+ store.append(out insertion_iter, parent_wrapper.get_iter());
+ }
+
+ associate_entry(insertion_iter, entry);
+ associate_children(branch, entry, insertion_iter);
+
+ if (branch.is_auto_open_on_new_child())
+ expand_to_entry(entry);
+ }
+
+ private void on_branch_entry_removed(Sidebar.Branch branch, Sidebar.Entry entry) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+ assert(!(wrapper is RootWrapper));
+
+ disassociate_wrapper_and_signal(wrapper, false);
+ }
+
+ private void on_branch_entry_moved(Sidebar.Branch branch, Sidebar.Entry entry) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+ assert(!(wrapper is RootWrapper));
+
+ // null means entry is now at the top of the sibling list
+ Gtk.TreeIter? prev_iter = null;
+ Sidebar.Entry? prev = branch.get_previous_sibling(entry);
+ if (prev != null) {
+ EntryWrapper? prev_wrapper = get_wrapper(prev);
+ assert(prev_wrapper != null);
+
+ prev_iter = prev_wrapper.get_iter();
+ }
+
+ Gtk.TreeIter entry_iter = wrapper.get_iter();
+ store.move_after(ref entry_iter, prev_iter);
+ }
+
+ private void on_branch_entry_reparented(Sidebar.Branch branch, Sidebar.Entry entry,
+ Sidebar.Entry old_parent) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+ assert(!(wrapper is RootWrapper));
+
+ bool selected = (get_current_path().compare(wrapper.get_path()) == 0);
+
+ // remove from current position in tree
+ Gtk.TreeIter iter = wrapper.get_iter();
+ store.remove(ref iter);
+
+ Sidebar.Entry? parent = branch.get_parent(entry);
+ assert(parent != null);
+
+ EntryWrapper? parent_wrapper = get_wrapper(parent);
+ assert(parent_wrapper != null);
+
+ // null means entry is now at the top of the sibling list
+ Gtk.TreeIter? prev_iter = null;
+ Sidebar.Entry? prev = branch.get_previous_sibling(entry);
+ if (prev != null) {
+ EntryWrapper? prev_wrapper = get_wrapper(prev);
+ assert(prev_wrapper != null);
+
+ prev_iter = prev_wrapper.get_iter();
+ }
+
+ Gtk.TreeIter new_iter;
+ store.insert_after(out new_iter, parent_wrapper.get_iter(), prev_iter);
+
+ EntryWrapper new_wrapper = reparent_wrapper(new_iter, wrapper);
+
+ if (selected) {
+ expand_to_entry(new_wrapper.entry);
+ place_cursor(new_wrapper.entry, false);
+ }
+ }
+
+ private void on_branch_children_reordered(Sidebar.Branch branch, Sidebar.Entry entry) {
+ Gee.List<Sidebar.Entry>? children = branch.get_children(entry);
+ if (children == null)
+ return;
+
+ // This works by moving the entries to the bottom of the tree's list in the order they
+ // are presented in the Sidebar.Branch list.
+ foreach (Sidebar.Entry child in children) {
+ EntryWrapper? child_wrapper = get_wrapper(child);
+ assert(child_wrapper != null);
+
+ Gtk.TreeIter child_iter = child_wrapper.get_iter();
+ store.move_before(ref child_iter, null);
+ }
+ }
+
+ private void on_show_branch(Sidebar.Branch branch, bool shown) {
+ if (shown)
+ associate_branch(branch);
+ else
+ disassociate_branch(branch);
+
+ branch_shown(branch, shown);
+ }
+
+ private void on_sidebar_tooltip_changed(Sidebar.Entry entry, string? tooltip) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+
+ store.set(wrapper.get_iter(), Columns.TOOLTIP, guarded_markup_escape_text(tooltip));
+ }
+
+ private void on_sidebar_icon_changed(Sidebar.Entry entry, Icon? icon) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+
+ store.set(wrapper.get_iter(), Columns.PIXBUF, fetch_icon_pixbuf(icon));
+ }
+
+ private void on_sidebar_page_created(Sidebar.PageRepresentative entry, Page page) {
+ page_created(entry, page);
+ }
+
+ private void on_sidebar_destroying_page(Sidebar.PageRepresentative entry, Page page) {
+ destroying_page(entry, page);
+ }
+
+ private void on_sidebar_open_closed_icons_changed(Sidebar.ExpandableEntry entry, Icon? open,
+ Icon? closed) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+
+ store.set(wrapper.get_iter(), Columns.OPEN_PIXBUF, fetch_icon_pixbuf(open));
+ store.set(wrapper.get_iter(), Columns.CLOSED_PIXBUF, fetch_icon_pixbuf(closed));
+ }
+
+ private void on_sidebar_name_changed(Sidebar.RenameableEntry entry, string name) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ assert(wrapper != null);
+
+ store.set(wrapper.get_iter(), Columns.NAME, guarded_markup_escape_text(name));
+ }
+
+ private Gdk.Pixbuf? fetch_icon_pixbuf(GLib.Icon? gicon) {
+ if (gicon == null)
+ return null;
+
+ try {
+ Gdk.Pixbuf? icon = icon_cache.get(gicon.to_string());
+ if (icon != null)
+ return icon;
+
+ Gtk.IconInfo? info = icon_theme.lookup_by_gicon(gicon, ICON_SIZE, 0);
+ if (info == null)
+ return null;
+
+ icon = info.load_icon();
+ if (icon == null)
+ return null;
+
+ icon_cache.set(gicon.to_string(), icon);
+
+ return icon;
+ } catch (Error err) {
+ warning("Unable to load icon %s: %s", gicon.to_string(), err.message);
+
+ return null;
+ }
+ }
+
+ private void load_entry_icons(Gtk.TreeIter iter) {
+ EntryWrapper? wrapper = get_wrapper_at_iter(iter);
+ if (wrapper == null)
+ return;
+
+ Icon? icon = wrapper.entry.get_sidebar_icon();
+ Icon? open = null;
+ Icon? closed = null;
+
+ Sidebar.ExpandableEntry? expandable = wrapper.entry as Sidebar.ExpandableEntry;
+ if (expandable != null) {
+ open = expandable.get_sidebar_open_icon();
+ closed = expandable.get_sidebar_closed_icon();
+ }
+
+ if (open == null)
+ open = icon;
+
+ if (closed == null)
+ closed = icon;
+
+ store.set(iter, Columns.PIXBUF, fetch_icon_pixbuf(icon));
+ store.set(iter, Columns.OPEN_PIXBUF, fetch_icon_pixbuf(open));
+ store.set(iter, Columns.CLOSED_PIXBUF, fetch_icon_pixbuf(closed));
+ }
+
+ 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 void on_theme_change() {
+ Gtk.TreeIter iter;
+ if (store.get_iter_first(out iter)) {
+ do {
+ load_branch_icons(iter);
+ } while (store.iter_next(ref 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
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+
+ return (wrapper != null) ? (wrapper.entry is Sidebar.SelectableEntry) : false;
+ }
+
+ private Gtk.TreePath? get_path_from_event(Gdk.EventButton event) {
+ int x, y;
+ Gdk.ModifierType mask;
+ event.window.get_device_position(Gdk.Display.get_default().get_device_manager().
+ get_client_pointer(), out x, out y, out mask);
+
+ int cell_x, cell_y;
+ Gtk.TreePath path;
+ return get_path_at_pos(x, y, out path, null, out cell_x, out cell_y) ? path : null;
+ }
+
+ private Gtk.TreePath? get_current_path() {
+ Gtk.TreeModel model;
+ GLib.List<Gtk.TreePath> rows = get_selection().get_selected_rows(out model);
+ assert(rows.length() == 0 || rows.length() == 1);
+
+ return rows.length() != 0 ? rows.nth_data(0) : null;
+ }
+
+ private bool on_context_menu_keypress() {
+ GLib.List<Gtk.TreePath> rows = get_selection().get_selected_rows(null);
+ if (rows == null)
+ return false;
+
+ Gtk.TreePath? path = rows.data;
+ if (path == null)
+ return false;
+
+ scroll_to_cell(path, null, false, 0, 0);
+
+ return popup_context_menu(path);
+ }
+
+ private bool popup_context_menu(Gtk.TreePath path, Gdk.EventButton? event = null) {
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+ if (wrapper == null)
+ return false;
+
+ Sidebar.Contextable? contextable = wrapper.entry as Sidebar.Contextable;
+ if (contextable == null)
+ return false;
+
+ // First select the sidebar item so that its context menu will be available.
+ Sidebar.SelectableEntry? selectable = wrapper.entry as Sidebar.SelectableEntry;
+ if (selectable != null)
+ entry_selected(selectable);
+
+ Gtk.Menu? context_menu = contextable.get_sidebar_context_menu(event);
+ if (context_menu == null)
+ return false;
+
+ if (event != null)
+ context_menu.popup(null, null, null, event.button, event.time);
+ else
+ context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
+
+ return true;
+ }
+
+ private bool popup_default_context_menu(Gdk.EventButton event) {
+ default_context_menu.popup(null, null, null, event.button, event.time);
+ return true;
+ }
+
+ public override bool button_press_event(Gdk.EventButton event) {
+ Gtk.TreePath? path = get_path_from_event(event);
+
+ // user clicked on empty area, but isn't trying to spawn a context menu?
+ if ((path == null) && (event.button != 3)) {
+ return true;
+ }
+
+ if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) {
+ // single right click
+ if (path != null)
+ popup_context_menu(path, event);
+ else
+ popup_default_context_menu(event);
+ } else if (event.button == 1 && event.type == Gdk.EventType.2BUTTON_PRESS) {
+ // double left click
+ if (path != null) {
+ toggle_branch_expansion(path, false);
+
+ if (can_rename_path(path))
+ return false;
+ }
+ } else if (event.button == 1 && event.type == Gdk.EventType.BUTTON_PRESS) {
+ // Is this a click on an already-highlighted tree item?
+ Gtk.TreePath? cursor_path = null;
+ get_cursor(out cursor_path, null);
+ if ((cursor_path != null) && (cursor_path.compare(path) == 0)) {
+ // yes, don't allow single-click editing, but
+ // pass the event on for dragging.
+ text_renderer.editable = false;
+ return base.button_press_event(event);
+ }
+
+ // Got click on different tree item, make sure it is editable
+ // if it needs to be.
+ if (path != null && get_wrapper_at_path(path).entry is Sidebar.RenameableEntry) {
+ text_renderer.editable = true;
+ }
+ }
+
+ return base.button_press_event(event);
+ }
+
+ public bool is_keypress_interpreted(Gdk.EventKey event) {
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "F2":
+ case "Delete":
+ case "Return":
+ case "KP_Enter":
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ public override bool key_press_event(Gdk.EventKey event) {
+ switch (Gdk.keyval_name(event.keyval)) {
+ case "Return":
+ case "KP_Enter":
+ Gtk.TreePath? path = get_current_path();
+ if (path != null)
+ toggle_branch_expansion(path, false);
+
+ return true;
+
+ case "F2":
+ return rename_in_place();
+
+ case "Delete":
+ Gtk.TreePath? path = get_current_path();
+
+ return (path != null) ? destroy_path(path) : false;
+ }
+
+ return base.key_press_event(event);
+ }
+
+ public bool rename_entry_in_place(Sidebar.Entry entry) {
+ if (!expand_to_entry(entry))
+ return false;
+
+ if (!place_cursor(entry, false))
+ return false;
+
+ return rename_in_place();
+ }
+
+ private bool rename_in_place() {
+ Gtk.TreePath? cursor_path;
+ Gtk.TreeViewColumn? cursor_column;
+ get_cursor(out cursor_path, out cursor_column);
+
+ if (can_rename_path(cursor_path)) {
+ set_cursor(cursor_path, cursor_column, true);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool scroll_to_entry(Sidebar.Entry entry) {
+ EntryWrapper? wrapper = get_wrapper(entry);
+ if (wrapper == null)
+ return false;
+
+ scroll_to_cell(wrapper.get_path(), null, false, 0, 0);
+
+ return true;
+ }
+
+ public override void drag_data_get(Gdk.DragContext context, Gtk.SelectionData selection_data,
+ uint info, uint time) {
+ InternalDragSourceEntry? drag_source = null;
+
+ if (internal_drag_source_entry != null) {
+ Sidebar.SelectableEntry selectable =
+ internal_drag_source_entry as Sidebar.SelectableEntry;
+ if (selectable == null) {
+ drag_source = internal_drag_source_entry as InternalDragSourceEntry;
+ }
+ }
+
+ if (drag_source == null) {
+ Gtk.TreePath? selected_path = get_selected_path();
+ if (selected_path == null)
+ return;
+
+ EntryWrapper? wrapper = get_wrapper_at_path(selected_path);
+ if (wrapper == null)
+ return;
+
+ drag_source = wrapper.entry as InternalDragSourceEntry;
+ if (drag_source == null)
+ return;
+ }
+
+ drag_source.prepare_selection_data(selection_data);
+ }
+
+ public override void drag_data_received(Gdk.DragContext context, int x, int y,
+ Gtk.SelectionData selection_data, uint info, uint time) {
+
+ Gtk.TreePath path;
+ Gtk.TreeViewDropPosition pos;
+ if (!get_dest_row_at_pos(x, y, out path, out pos)) {
+ // If an external drop, hand it off to the handler
+ if (Gtk.drag_get_source_widget(context) == null)
+ drop_handler(context, null, selection_data, info, time);
+ else
+ Gtk.drag_finish(context, false, false, time);
+
+ return;
+ }
+
+ // Note that a drop outside a sidebar entry is legal if an external drop.
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+
+ // If an external drop, hand it off to the handler
+ if (Gtk.drag_get_source_widget(context) == null) {
+ drop_handler(context, (wrapper != null) ? wrapper.entry : null, selection_data,
+ info, time);
+
+ return;
+ }
+
+ // An internal drop only applies to DropTargetEntry's
+ if (wrapper == null) {
+ Gtk.drag_finish(context, false, false, time);
+
+ return;
+ }
+
+ Sidebar.InternalDropTargetEntry? targetable = wrapper.entry as Sidebar.InternalDropTargetEntry;
+ if (targetable == null) {
+ Gtk.drag_finish(context, false, false, time);
+
+ return;
+ }
+
+ bool success = false;
+
+ if (selection_data.get_data_type().name() == LibraryWindow.TAG_PATH_MIME_TYPE) {
+ success = targetable.internal_drop_received_arbitrary(selection_data);
+ } else {
+ Gee.List<MediaSource>? media = unserialize_media_sources(selection_data.get_data(),
+ selection_data.get_length());
+ if (media != null && media.size > 0)
+ success = targetable.internal_drop_received(media);
+ }
+
+ Gtk.drag_finish(context, success, false, time);
+ }
+
+ public override bool drag_motion(Gdk.DragContext context, int x, int y, uint time) {
+ // call the base signal to get rows with children to spring open
+ base.drag_motion(context, x, y, time);
+
+ Gtk.TreePath path;
+ Gtk.TreeViewDropPosition pos;
+ bool has_dest = get_dest_row_at_pos(x, y, out path, out pos);
+
+ // we don't want to insert between rows, only select the rows themselves
+ if (!has_dest || pos == Gtk.TreeViewDropPosition.BEFORE)
+ set_drag_dest_row(path, Gtk.TreeViewDropPosition.INTO_OR_BEFORE);
+ else if (pos == Gtk.TreeViewDropPosition.AFTER)
+ set_drag_dest_row(path, Gtk.TreeViewDropPosition.INTO_OR_AFTER);
+
+ Gdk.drag_status(context, context.get_suggested_action(), time);
+
+ return has_dest;
+ }
+
+ // Returns true if path is renameable, and selects the path as well.
+ private bool can_rename_path(Gtk.TreePath path) {
+ if (editing_disabled > 0)
+ return false;
+
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+ if (wrapper == null)
+ return false;
+
+ Sidebar.RenameableEntry? renameable = wrapper.entry as Sidebar.RenameableEntry;
+ if (renameable == null)
+ return false;
+
+ get_selection().select_path(path);
+
+ return true;
+ }
+
+ private bool destroy_path(Gtk.TreePath path) {
+ EntryWrapper? wrapper = get_wrapper_at_path(path);
+ if (wrapper == null)
+ return false;
+
+ Sidebar.DestroyableEntry? destroyable = wrapper.entry as Sidebar.DestroyableEntry;
+ if (destroyable == null)
+ return false;
+
+ destroyable.destroy_source();
+
+ return true;
+ }
+
+ private void on_editing_started(Gtk.CellEditable editable, string path) {
+ if (editable is Gtk.Entry) {
+ text_entry = (Gtk.Entry) editable;
+ text_entry.editing_done.connect(on_editing_done);
+ text_entry.focus_out_event.connect(on_editing_focus_out);
+ text_entry.editable = true;
+ }
+ }
+
+ private void on_editing_canceled() {
+ text_entry.editable = false;
+
+ text_entry.editing_done.disconnect(on_editing_done);
+ text_entry.focus_out_event.disconnect(on_editing_focus_out);
+ }
+
+ private void on_editing_done() {
+ text_entry.editable = false;
+
+ EntryWrapper? wrapper = get_wrapper_at_path(get_current_path());
+ if (wrapper != null) {
+ Sidebar.RenameableEntry? renameable = wrapper.entry as Sidebar.RenameableEntry;
+ if (renameable != null)
+ renameable.rename(text_entry.get_text());
+ }
+
+ text_entry.editing_done.disconnect(on_editing_done);
+ text_entry.focus_out_event.disconnect(on_editing_focus_out);
+ }
+
+ private bool on_editing_focus_out(Gdk.EventFocus event) {
+ // We'll return false here, in case other parts of the app
+ // want to know if the button press event that caused
+ // us to lose focus have been fully handled.
+ return false;
+ }
+
+ private void on_new_search() {
+ (new SavedSearchDialog()).show();
+ }
+
+ private void on_new_tag() {
+ NewRootTagCommand creation_command = new NewRootTagCommand();
+ AppWindow.get_command_manager().execute(creation_command);
+ LibraryWindow.get_app().rename_tag_in_sidebar(creation_command.get_created_tag());
+ }
+}
+
diff --git a/src/sidebar/common.vala b/src/sidebar/common.vala
new file mode 100644
index 0000000..36adfff
--- /dev/null
+++ b/src/sidebar/common.vala
@@ -0,0 +1,114 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// A simple grouping Entry that is only expandable
+public class Sidebar.Grouping : Object, Sidebar.Entry, Sidebar.ExpandableEntry {
+ private string name;
+ private Icon? open_icon;
+ private Icon? closed_icon;
+
+ public Grouping(string name, Icon? open_icon, Icon? closed_icon = null) {
+ this.name = name;
+ this.open_icon = open_icon;
+ this.closed_icon = closed_icon ?? open_icon;
+ }
+
+ public string get_sidebar_name() {
+ return name;
+ }
+
+ public string? get_sidebar_tooltip() {
+ return name;
+ }
+
+ public Icon? get_sidebar_icon() {
+ return null;
+ }
+
+ public Icon? get_sidebar_open_icon() {
+ return open_icon;
+ }
+
+ public Icon? get_sidebar_closed_icon() {
+ return closed_icon;
+ }
+
+ public string to_string() {
+ return name;
+ }
+
+ public bool expand_on_select() {
+ return true;
+ }
+}
+
+// An end-node on the sidebar that represents a Page with its page context menu. Additional
+// interfaces can be added if additional functionality is required (such as a drop target).
+// This class also handles the bookwork of creating the Page on-demand and maintaining it in memory.
+public abstract class Sidebar.SimplePageEntry : Object, Sidebar.Entry, Sidebar.SelectableEntry,
+ Sidebar.PageRepresentative, Sidebar.Contextable {
+ private Page? page = null;
+
+ public SimplePageEntry() {
+ }
+
+ public abstract string get_sidebar_name();
+
+ public virtual string? get_sidebar_tooltip() {
+ return get_sidebar_name();
+ }
+
+ public abstract Icon? get_sidebar_icon();
+
+ public virtual string to_string() {
+ return get_sidebar_name();
+ }
+
+ protected abstract Page create_page();
+
+ public bool has_page() {
+ return page != null;
+ }
+
+ protected Page get_page() {
+ if (page == null) {
+ page = create_page();
+ page_created(page);
+ }
+
+ return page;
+ }
+
+ internal void pruned(Sidebar.Tree tree) {
+ if (page == null)
+ return;
+
+ destroying_page(page);
+ page.destroy();
+ page = null;
+ }
+
+ public Gtk.Menu? get_sidebar_context_menu(Gdk.EventButton? event) {
+ return get_page().get_page_context_menu();
+ }
+}
+
+// A simple Sidebar.Branch where the root node is the branch in entirety.
+public class Sidebar.RootOnlyBranch : Sidebar.Branch {
+ public RootOnlyBranch(Sidebar.Entry root) {
+ base (root, Sidebar.Branch.Options.NONE, null_comparator);
+ }
+
+ private static int null_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ return (a != b) ? -1 : 0;
+ }
+}
+
+public interface Sidebar.Contextable : Object {
+ // Return null if the context menu should not be invoked for this event
+ public abstract Gtk.Menu? get_sidebar_context_menu(Gdk.EventButton? event);
+}
+
diff --git a/src/sidebar/mk/sidebar.mk b/src/sidebar/mk/sidebar.mk
new file mode 100644
index 0000000..2a6c67d
--- /dev/null
+++ b/src/sidebar/mk/sidebar.mk
@@ -0,0 +1,31 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Sidebar
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := sidebar
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ Entry.vala \
+ Tree.vala \
+ common.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/slideshow/Slideshow.vala b/src/slideshow/Slideshow.vala
new file mode 100644
index 0000000..d55e3d6
--- /dev/null
+++ b/src/slideshow/Slideshow.vala
@@ -0,0 +1,32 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+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";
+
+ Plugins.register_extension_point(typeof(Spit.Transitions.Descriptor), _("Slideshow Transitions"),
+ Resources.ICON_SLIDESHOW_EXTENSION_POINT, core_ids);
+ TransitionEffectsManager.init();
+}
+
+public void terminate() {
+ TransitionEffectsManager.terminate();
+}
+
+}
+
diff --git a/src/slideshow/TransitionEffects.vala b/src/slideshow/TransitionEffects.vala
new file mode 100644
index 0000000..7b10543
--- /dev/null
+++ b/src/slideshow/TransitionEffects.vala
@@ -0,0 +1,351 @@
+/* Copyright 2010 Maxim Kartashev
+ * Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class TransitionEffectsManager {
+ public const string NULL_EFFECT_ID = NullTransitionDescriptor.EFFECT_ID;
+ public const string RANDOM_EFFECT_ID = RandomEffectDescriptor.EFFECT_ID;
+ private static TransitionEffectsManager? instance = null;
+
+ // effects are stored by effect ID
+ private Gee.Map<string, Spit.Transitions.Descriptor> effects = new Gee.HashMap<
+ string, Spit.Transitions.Descriptor>();
+ private Spit.Transitions.Descriptor null_descriptor = new NullTransitionDescriptor();
+ private Spit.Transitions.Descriptor random_descriptor = new RandomEffectDescriptor();
+
+ private TransitionEffectsManager() {
+ load_transitions();
+ Plugins.Notifier.get_instance().pluggable_activation.connect(load_transitions);
+ }
+
+ ~TransitionEffectsManager() {
+ Plugins.Notifier.get_instance().pluggable_activation.disconnect(load_transitions);
+ }
+
+ private void load_transitions() {
+ effects.clear();
+
+ // add null and random effect first
+ effects.set(null_descriptor.get_id(), null_descriptor);
+ effects.set(random_descriptor.get_id(),random_descriptor);
+
+ // load effects from plug-ins
+ Gee.Collection<Spit.Pluggable> pluggables = Plugins.get_pluggables_for_type(
+ typeof(Spit.Transitions.Descriptor));
+ foreach (Spit.Pluggable pluggable in pluggables) {
+ int pluggable_interface = pluggable.get_pluggable_interface(Spit.Transitions.CURRENT_INTERFACE,
+ Spit.Transitions.CURRENT_INTERFACE);
+ if (pluggable_interface != Spit.Transitions.CURRENT_INTERFACE) {
+ warning("Unable to load transitions plug-in %s: reported interface %d",
+ Plugins.get_pluggable_module_id(pluggable), pluggable_interface);
+
+ continue;
+ }
+
+ Spit.Transitions.Descriptor desc = (Spit.Transitions.Descriptor) pluggable;
+ if (effects.has_key(desc.get_id()))
+ warning("Multiple transitions loaded with same effect ID %s", desc.get_id());
+ else
+ effects.set(desc.get_id(), desc);
+ }
+ }
+
+ public static void init() {
+ instance = new TransitionEffectsManager();
+ }
+
+ public static void terminate() {
+ instance = null;
+ }
+
+ public static TransitionEffectsManager get_instance() {
+ assert(instance != null);
+
+ return instance;
+ }
+
+ public Gee.Collection<string> get_effect_ids() {
+ return effects.keys;
+ }
+
+ public Gee.Collection<string> get_effect_names(owned CompareDataFunc? 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());
+
+ return effect_names;
+ }
+
+ public string? get_id_for_effect_name(string effect_name) {
+ foreach (Spit.Transitions.Descriptor desc in effects.values) {
+ if (desc.get_pluggable_name() == effect_name)
+ return desc.get_id();
+ }
+
+ return null;
+ }
+
+ public Spit.Transitions.Descriptor? get_effect_descriptor(string effect_id) {
+ return effects.get(effect_id);
+ }
+
+ public string get_effect_name(string effect_id) {
+ Spit.Transitions.Descriptor? desc = get_effect_descriptor(effect_id);
+
+ return (desc != null) ? desc.get_pluggable_name() : _("(None)");
+ }
+
+ public Spit.Transitions.Descriptor get_null_descriptor() {
+ return null_descriptor;
+ }
+
+ public TransitionClock? create_transition_clock(string effect_id) {
+ Spit.Transitions.Descriptor? desc = get_effect_descriptor(effect_id);
+
+ return (desc != null) ? new TransitionClock(desc) : null;
+ }
+
+ public TransitionClock create_null_transition_clock() {
+ return new TransitionClock(null_descriptor);
+ }
+}
+
+public class TransitionClock {
+ // This method is called by TransitionClock to indicate that it's time for the transition to be
+ // repainted. The callback should call TransitionClock.paint() with the appropriate Drawable
+ // either immediately or quite soon (in an expose event).
+ public delegate void RepaintCallback();
+
+ private Spit.Transitions.Descriptor desc;
+ private Spit.Transitions.Effect effect;
+ private int desired_fps;
+ private int min_fps;
+ private int current_fps = 0;
+ private OpTimer paint_timer;
+ private Spit.Transitions.Visuals? visuals = null;
+ private Spit.Transitions.Motion? motion = null;
+ private unowned RepaintCallback? repaint = null;
+ private uint timer_id = 0;
+ private ulong time_started = 0;
+ private int frame_number = 0;
+ private bool cancelled = false;
+
+ public TransitionClock(Spit.Transitions.Descriptor desc) {
+ this.desc = desc;
+
+ effect = desc.create(new Plugins.StandardHostInterface(desc, "transitions"));
+ effect.get_fps(out desired_fps, out min_fps);
+
+ paint_timer = new OpTimer(desc.get_pluggable_name());
+ }
+
+ ~TransitionClock() {
+ cancel_timer();
+ debug("%s tick_msec=%d min/desired/current fps=%d/%d/%d", paint_timer.to_string(),
+ (motion != null) ? motion.tick_msec : 0, min_fps, desired_fps, current_fps);
+ }
+
+ public bool is_in_progress() {
+ return (!cancelled && motion != null) ? frame_number < motion.total_frames : false;
+ }
+
+ public void start(Spit.Transitions.Visuals visuals, Spit.Transitions.Direction direction,
+ int duration_msec, RepaintCallback repaint) {
+ reset();
+
+ // if no desired FPS, this is a no-op transition
+ if (desired_fps == 0)
+ return;
+
+ this.visuals = visuals;
+ this.repaint = repaint;
+ motion = new Spit.Transitions.Motion(direction, desired_fps, duration_msec);
+
+ effect.start(visuals, motion);
+
+ // start the timer
+ // TODO: It may be smarter to not use Timeout naively, as it does not attempt to catch up
+ // when tick() is called late.
+ time_started = now_ms();
+ timer_id = Timeout.add_full(Priority.HIGH, motion.tick_msec, tick);
+ }
+
+ // This resets all state for the clock. No check is done if the clock is running.
+ private void reset() {
+ visuals = null;
+ motion = null;
+ repaint = null;
+ cancel_timer();
+ time_started = 0;
+ frame_number = 1;
+ current_fps = 0;
+ cancelled = false;
+ }
+
+ private void cancel_timer() {
+ if (timer_id != 0) {
+ Source.remove(timer_id);
+ timer_id = 0;
+ }
+ }
+
+ // Calculate current FPS rate and returns true if it's above minimum
+ private bool is_fps_ok() {
+ assert(time_started > 0);
+
+ if (frame_number <= 3)
+ return true; // don't bother measuring if statistical data are too small
+
+ double elapsed_msec = (double) (now_ms() - time_started);
+ if (elapsed_msec <= 0.0)
+ return true;
+
+ current_fps = (int) ((frame_number * 1000.0) / elapsed_msec);
+ if (current_fps < min_fps) {
+ debug("Transition rate of %dfps below minimum of %dfps (elapsed=%lf frames=%d)",
+ current_fps, min_fps, elapsed_msec, frame_number);
+ }
+
+ return (current_fps >= min_fps);
+ }
+
+ // Cancels current transition.
+ public void cancel() {
+ cancelled = true;
+ cancel_timer();
+ effect.cancel();
+
+ // repaint to complete the transition
+ repaint();
+ }
+
+ // Call this whenever using a TransitionClock in the expose event. Returns false if the
+ // transition has completed, in which case the caller should paint the final result.
+ public bool paint(Cairo.Context ctx, int width, int height) {
+ if (!is_in_progress())
+ return false;
+
+ paint_timer.start();
+
+ ctx.save();
+
+ if (effect.needs_clear_background()) {
+ ctx.set_source_rgba(visuals.bg_color.red, visuals.bg_color.green, visuals.bg_color.blue,
+ visuals.bg_color.alpha);
+ ctx.rectangle(0, 0, width, height);
+ ctx.fill();
+ }
+
+ effect.paint(visuals, motion, ctx, width, height, frame_number);
+
+ ctx.restore();
+
+ paint_timer.stop();
+
+ return true;
+ }
+
+ private bool tick() {
+ if (!is_fps_ok()) {
+ debug("Cancelling transition: below minimum fps");
+ cancel();
+ }
+
+ // repaint always; this timer tick will go away when the frames have exhausted (and
+ // guarantees the first frame is painted before advancing the counter)
+ repaint();
+
+ if (!is_in_progress()) {
+ cancel_timer();
+
+ return false;
+ }
+
+ // advance to the next frame
+ if (frame_number < motion.total_frames)
+ effect.advance(visuals, motion, ++frame_number);
+
+ return true;
+ }
+}
+
+public class NullTransitionDescriptor : Object, Spit.Pluggable, Spit.Transitions.Descriptor {
+ public const string EFFECT_ID = "org.yorba.shotwell.transitions.null";
+
+ public int get_pluggable_interface(int min_host_version, int max_host_version) {
+ return Spit.Transitions.CURRENT_INTERFACE;
+ }
+
+ public unowned string get_id() {
+ return EFFECT_ID;
+ }
+
+ public unowned string get_pluggable_name() {
+ return _("None");
+ }
+
+ public void get_info(ref Spit.PluggableInfo info) {
+ }
+
+ public void activation(bool enabled) {
+ }
+
+ public Spit.Transitions.Effect create(Spit.HostInterface host) {
+ return new NullEffect();
+ }
+}
+
+public class NullEffect : Object, Spit.Transitions.Effect {
+ public NullEffect() {
+ }
+
+ public void get_fps(out int desired_fps, out int min_fps) {
+ desired_fps = 0;
+ min_fps = 0;
+ }
+
+ public void start(Spit.Transitions.Visuals visuals, Spit.Transitions.Motion motion) {
+ }
+
+ public bool needs_clear_background() {
+ return false;
+ }
+
+ public void paint(Spit.Transitions.Visuals visuals, Spit.Transitions.Motion motion, Cairo.Context ctx,
+ int width, int height, int frame_number) {
+ }
+
+ public void advance(Spit.Transitions.Visuals visuals, Spit.Transitions.Motion motion, int frame_number) {
+ }
+
+ public void cancel() {
+ }
+}
+public class RandomEffectDescriptor : Object, Spit.Pluggable, Spit.Transitions.Descriptor {
+ public const string EFFECT_ID = "org.yorba.shotwell.transitions.random";
+
+ public int get_pluggable_interface(int min_host_version, int max_host_version) {
+ return Spit.Transitions.CURRENT_INTERFACE;
+ }
+
+ public unowned string get_id() {
+ return EFFECT_ID;
+ }
+
+ public unowned string get_pluggable_name() {
+ return _("Random");
+ }
+
+ public void get_info(ref Spit.PluggableInfo info) {
+ }
+
+ public void activation(bool enabled) {
+ }
+
+ public Spit.Transitions.Effect create(Spit.HostInterface host) {
+ return new NullEffect();
+ }
+}
diff --git a/src/slideshow/mk/slideshow.mk b/src/slideshow/mk/slideshow.mk
new file mode 100644
index 0000000..0a62e8d
--- /dev/null
+++ b/src/slideshow/mk/slideshow.mk
@@ -0,0 +1,29 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Slideshow
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := slideshow
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. Slideshow.vala.
+UNIT_FILES := \
+ TransitionEffects.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Plugins
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/tags/Branch.vala b/src/tags/Branch.vala
new file mode 100644
index 0000000..71bf424
--- /dev/null
+++ b/src/tags/Branch.vala
@@ -0,0 +1,310 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 Tags.Branch : Sidebar.Branch {
+ private Gee.HashMap<Tag, Tags.SidebarEntry> entry_map = new Gee.HashMap<Tag, Tags.SidebarEntry>();
+
+ public Branch() {
+ base (new Tags.Grouping(),
+ Sidebar.Branch.Options.HIDE_IF_EMPTY
+ | Sidebar.Branch.Options.AUTO_OPEN_ON_NEW_CHILD
+ | Sidebar.Branch.Options.STARTUP_OPEN_GROUPING,
+ comparator);
+
+ // seed the branch with existing tags
+ on_tags_added_removed(Tag.global.get_all(), null);
+
+ // monitor collection for future events
+ Tag.global.contents_altered.connect(on_tags_added_removed);
+ Tag.global.items_altered.connect(on_tags_altered);
+ }
+
+ ~Branch() {
+ Tag.global.contents_altered.disconnect(on_tags_added_removed);
+ Tag.global.items_altered.disconnect(on_tags_altered);
+ }
+
+ public Tags.SidebarEntry? get_entry_for_tag(Tag tag) {
+ return entry_map.get(tag);
+ }
+
+ private static int comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ return Tag.compare_names(((Tags.SidebarEntry) a).for_tag(),
+ ((Tags.SidebarEntry) b).for_tag());
+ }
+
+ private void on_tags_added_removed(Gee.Iterable<DataObject>? added_raw, Gee.Iterable<DataObject>? removed) {
+ // Store the tag whose page we'll eventually want to go to,
+ // since this is lost when a tag is reparented (pruning a currently-
+ // highlighted entry from the tree causes the highlight to go to the library,
+ // and reparenting requires pruning the old location (along with adding the new one)).
+ Tag? restore_point = null;
+
+ if (added_raw != null) {
+ // prepare a collection of tags guaranteed to be sorted; this is critical for
+ // hierarchical tags since it ensures that parent tags must be encountered
+ // before their children
+ Gee.SortedSet<Tag> added = new Gee.TreeSet<Tag>(Tag.compare_names);
+ foreach (DataObject object in added_raw) {
+ Tag tag = (Tag) object;
+ added.add(tag);
+ }
+
+ foreach (Tag tag in added) {
+ // ensure that all parent tags of this tag (if any) already have sidebar
+ // entries
+ Tag? parent_tag = tag.get_hierarchical_parent();
+ while (parent_tag != null) {
+ if (!entry_map.has_key(parent_tag)) {
+ Tags.SidebarEntry parent_entry = new Tags.SidebarEntry(parent_tag);
+ entry_map.set(parent_tag, parent_entry);
+ }
+
+ parent_tag = parent_tag.get_hierarchical_parent();
+
+ }
+
+ Tags.SidebarEntry entry = new Tags.SidebarEntry(tag);
+ entry_map.set(tag, entry);
+
+ parent_tag = tag.get_hierarchical_parent();
+ if (parent_tag != null) {
+ Tags.SidebarEntry parent_entry = entry_map.get(parent_tag);
+ graft(parent_entry, entry);
+ } else {
+ graft(get_root(), entry);
+ }
+
+ // Save the most-recently-processed on tag. During a reparenting,
+ // this will be the only tag processed.
+ restore_point = tag;
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed) {
+ Tag tag = (Tag) object;
+
+ Tags.SidebarEntry? entry = entry_map.get(tag);
+ assert(entry != null);
+
+ bool is_removed = entry_map.unset(tag);
+ assert(is_removed);
+
+ prune(entry);
+ }
+ }
+ }
+
+ private void on_tags_altered(Gee.Map<DataObject, Alteration> altered) {
+ foreach (DataObject object in altered.keys) {
+ if (!altered.get(object).has_detail("metadata", "name"))
+ continue;
+
+ Tag tag = (Tag) object;
+ Tags.SidebarEntry? entry = entry_map.get(tag);
+ assert(entry != null);
+
+ entry.sidebar_name_changed(tag.get_user_visible_name());
+ entry.sidebar_tooltip_changed(tag.get_user_visible_name());
+ reorder(entry);
+ }
+ }
+}
+
+public class Tags.Grouping : Sidebar.Grouping, Sidebar.InternalDropTargetEntry,
+ Sidebar.InternalDragSourceEntry, Sidebar.Contextable {
+ private Gtk.UIManager ui = new Gtk.UIManager();
+ private Gtk.Menu? context_menu = null;
+
+ public Grouping() {
+ base (_("Tags"), new ThemedIcon(Resources.ICON_TAGS));
+ setup_context_menu();
+ }
+
+ private void setup_context_menu() {
+ Gtk.ActionGroup group = new Gtk.ActionGroup("SidebarDefault");
+ Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
+
+ Gtk.ActionEntry new_tag = { "CommonNewTag", null, TRANSLATABLE, null, null, on_new_tag };
+ new_tag.label = Resources.NEW_CHILD_TAG_SIDEBAR_MENU;
+ actions += new_tag;
+
+ group.add_actions(actions, this);
+ ui.insert_action_group(group, 0);
+
+ File ui_file = Resources.get_ui("tag_sidebar_context.ui");
+ try {
+ ui.add_ui_from_file(ui_file.get_path());
+ } catch (Error err) {
+ AppWindow.error_message("Error loading UI file %s: %s".printf(
+ ui_file.get_path(), err.message));
+ Application.get_instance().panic();
+ }
+ context_menu = (Gtk.Menu) ui.get_widget("/SidebarTagContextMenu");
+
+ ui.ensure_update();
+ }
+
+ public bool internal_drop_received(Gee.List<MediaSource> media) {
+ AddTagsDialog dialog = new AddTagsDialog();
+ string[]? names = dialog.execute();
+ if (names == null || names.length == 0)
+ return false;
+
+ AppWindow.get_command_manager().execute(new AddTagsCommand(names, media));
+
+ return true;
+ }
+
+ public bool internal_drop_received_arbitrary(Gtk.SelectionData data) {
+ if (data.get_data_type().name() == LibraryWindow.TAG_PATH_MIME_TYPE) {
+ string old_tag_path = (string) data.get_data();
+ assert (Tag.global.exists(old_tag_path));
+
+ // if this is already a top-level tag, do a short-circuit return
+ if (HierarchicalTagUtilities.enumerate_path_components(old_tag_path).size < 2)
+ return true;
+
+ AppWindow.get_command_manager().execute(
+ new ReparentTagCommand(Tag.for_path(old_tag_path), "/"));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public void prepare_selection_data(Gtk.SelectionData data) {
+ ;
+ }
+
+ public Gtk.Menu? get_sidebar_context_menu(Gdk.EventButton? event) {
+ return context_menu;
+ }
+
+ private void on_new_tag() {
+ NewRootTagCommand creation_command = new NewRootTagCommand();
+ AppWindow.get_command_manager().execute(creation_command);
+ LibraryWindow.get_app().rename_tag_in_sidebar(creation_command.get_created_tag());
+ }
+}
+
+public class Tags.SidebarEntry : Sidebar.SimplePageEntry, Sidebar.RenameableEntry,
+ Sidebar.DestroyableEntry, Sidebar.InternalDropTargetEntry, Sidebar.ExpandableEntry,
+ Sidebar.InternalDragSourceEntry {
+ private static Icon single_tag_icon;
+
+ private Tag tag;
+
+ public SidebarEntry(Tag tag) {
+ this.tag = tag;
+ }
+
+ internal static void init() {
+ single_tag_icon = new ThemedIcon(Resources.ICON_ONE_TAG);
+ }
+
+ internal static void terminate() {
+ single_tag_icon = null;
+ }
+
+ public Tag for_tag() {
+ return tag;
+ }
+
+ public override string get_sidebar_name() {
+ return tag.get_user_visible_name();
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return single_tag_icon;
+ }
+
+ protected override Page create_page() {
+ return new TagPage(tag);
+ }
+
+ public void rename(string new_name) {
+ string? prepped = Tag.prep_tag_name(new_name);
+ if (prepped == null)
+ return;
+
+ prepped = prepped.replace("/", "");
+
+ if (prepped == tag.get_user_visible_name())
+ return;
+
+ if (prepped == "")
+ return;
+
+ AppWindow.get_command_manager().execute(new RenameTagCommand(tag, prepped));
+ }
+
+ public void destroy_source() {
+ if (Dialogs.confirm_delete_tag(tag))
+ AppWindow.get_command_manager().execute(new DeleteTagCommand(tag));
+ }
+
+ public bool internal_drop_received(Gee.List<MediaSource> media) {
+ AppWindow.get_command_manager().execute(new TagUntagPhotosCommand(tag, media, media.size,
+ true));
+
+ return true;
+ }
+
+ public bool internal_drop_received_arbitrary(Gtk.SelectionData data) {
+ if (data.get_data_type().name() == LibraryWindow.TAG_PATH_MIME_TYPE) {
+ string old_tag_path = (string) data.get_data();
+
+ // if we're dragging onto ourself, it's a no-op
+ if (old_tag_path == tag.get_path())
+ return true;
+
+ // if we're dragging onto one of our children, it's a no-op
+ foreach (string parent_path in HierarchicalTagUtilities.enumerate_parent_paths(tag.get_path())) {
+ if (parent_path == old_tag_path)
+ return true;
+ }
+
+ assert (Tag.global.exists(old_tag_path));
+
+ // if we're dragging onto our parent, it's a no-op
+ Tag old_tag = Tag.for_path(old_tag_path);
+ Tag old_tag_parent = old_tag.get_hierarchical_parent();
+ if (old_tag_parent != null && old_tag_parent.get_path() == tag.get_path())
+ return true;
+
+ AppWindow.get_command_manager().execute(
+ new ReparentTagCommand(old_tag, tag.get_path()));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public Icon? get_sidebar_open_icon() {
+ return single_tag_icon;
+ }
+
+ public Icon? get_sidebar_closed_icon() {
+ return single_tag_icon;
+ }
+
+ public bool expand_on_select() {
+ return false;
+ }
+
+ public void prepare_selection_data(Gtk.SelectionData data) {
+ data.set(Gdk.Atom.intern_static_string(LibraryWindow.TAG_PATH_MIME_TYPE), 0,
+ tag.get_path().data);
+ }
+}
+
diff --git a/src/tags/HierarchicalTagIndex.vala b/src/tags/HierarchicalTagIndex.vala
new file mode 100644
index 0000000..58b5f89
--- /dev/null
+++ b/src/tags/HierarchicalTagIndex.vala
@@ -0,0 +1,90 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class HierarchicalTagIndex {
+ private Gee.Map<string, Gee.Collection<string>> tag_table;
+ private Gee.SortedSet<string> known_paths;
+
+ public HierarchicalTagIndex( ) {
+ this.tag_table = new Gee.HashMap<string, Gee.ArrayList<string>>();
+ this.known_paths = new Gee.TreeSet<string>();
+ }
+
+ public static HierarchicalTagIndex from_paths(Gee.Collection<string> client_paths) {
+ Gee.Collection<string> paths = client_paths.read_only_view;
+
+ HierarchicalTagIndex result = new HierarchicalTagIndex();
+
+ foreach (string path in paths) {
+ if (path.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ Gee.Collection<string> components =
+ HierarchicalTagUtilities.enumerate_path_components(path);
+
+ foreach (string component in components)
+ result.add_path(component, path);
+ } else {
+ result.add_path(path, path);
+ }
+ }
+
+ return result;
+ }
+
+ public static HierarchicalTagIndex get_global_index() {
+ return HierarchicalTagIndex.from_paths(Tag.global.get_all_names());
+ }
+
+ public void add_path(string tag, string path) {
+ if (!tag_table.has_key(tag)) {
+ tag_table.set(tag, new Gee.ArrayList<string>());
+ }
+
+ tag_table.get(tag).add(path);
+ known_paths.add(path);
+ }
+
+ public Gee.Collection<string> get_all_paths() {
+ return known_paths.read_only_view;
+ }
+
+ public bool is_tag_in_index(string tag) {
+ return tag_table.has_key(tag);
+ }
+
+ public Gee.Collection<string> get_all_tags() {
+ return tag_table.keys;
+ }
+
+ public bool is_path_known(string path) {
+ return known_paths.contains(path);
+ }
+
+ public string get_path_for_name(string name) {
+ if (!is_tag_in_index(name))
+ return name;
+
+ Gee.Collection<string> paths = tag_table.get(name);
+ foreach (string path in paths) {
+ Gee.List<string> components = HierarchicalTagUtilities.enumerate_path_components(path);
+ if (components.get(components.size - 1) == name) {
+ return path;
+ }
+ }
+
+ assert_not_reached();
+ }
+
+ public string[] get_paths_for_names_array(string[] names) {
+ string[] result = new string[0];
+
+ foreach (string name in names)
+ result += get_path_for_name(name);
+
+ return result;
+ }
+
+}
+
diff --git a/src/tags/HierarchicalTagUtilities.vala b/src/tags/HierarchicalTagUtilities.vala
new file mode 100644
index 0000000..985491d
--- /dev/null
+++ b/src/tags/HierarchicalTagUtilities.vala
@@ -0,0 +1,184 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+class HierarchicalTagUtilities {
+
+ /**
+ * converts a flat tag name 'name' (e.g., "Animals") to a tag path compatible with the
+ * hierarchical tag data model (e.g., "/Animals"). if 'name' is already a path compatible with
+ * the hierarchical data model, 'name' is returned untouched
+ */
+ public static string flat_to_hierarchical(string name) {
+ if (!name.has_prefix(Tag.PATH_SEPARATOR_STRING))
+ return Tag.PATH_SEPARATOR_STRING + name;
+ else
+ return name;
+ }
+
+ /**
+ * converts a hierarchical tag path 'path' (e.g., "/Animals") to a flat tag name
+ * (e.g., "Animals"); if 'path' is already a flat tag name, 'path' is returned untouched; note
+ * that 'path' must be a top-level path (i.e., "/Animals" not "/Animals/Mammals/...") with
+ * only one path component; invoking this method with a 'path' argument other than a top-level
+ * path will cause an assertion failure.
+ */
+ public static string hierarchical_to_flat(string path) {
+ if (path.has_prefix(Tag.PATH_SEPARATOR_STRING)) {
+ assert(enumerate_path_components(path).size == 1);
+
+ return path.substring(1);
+ } else {
+ return path;
+ }
+ }
+
+ /**
+ * given a path 'path', generate all parent paths of 'path' and return them in sorted order,
+ * from most basic to most derived. For example, if 'path' == "/Animals/Mammals/Elephant",
+ * the list { "/Animals", "/Animals/Mammals" } is returned
+ */
+ public static Gee.List<string> enumerate_parent_paths(string in_path) {
+ string path = flat_to_hierarchical(in_path);
+
+ Gee.List<string> result = new Gee.ArrayList<string>();
+
+ string accumulator = "";
+ foreach (string component in enumerate_path_components(path)) {
+ accumulator += (Tag.PATH_SEPARATOR_STRING + component);
+ if (accumulator != path)
+ result.add(accumulator);
+ }
+
+ return result;
+ }
+
+ /**
+ * given a path 'path', enumerate all of the components of 'path' and return them in
+ * order, excluding the path component separator. For example if
+ * 'path' == "/Animals/Mammals/Elephant" the list { "Animals", "Mammals", "Elephant" } will
+ * be returned
+ */
+ public static Gee.List<string> enumerate_path_components(string in_path) {
+ string path = flat_to_hierarchical(in_path);
+
+ Gee.ArrayList<string> components = new Gee.ArrayList<string>();
+
+ string[] raw_components = path.split(Tag.PATH_SEPARATOR_STRING);
+
+ foreach (string component in raw_components) {
+ if (component != "")
+ components.add(component);
+ }
+
+ assert(components.size > 0);
+
+ return components;
+ }
+
+ /**
+ * given a list of path elements, create a fully qualified path string.
+ * For example if 'path_elements' is the list { "Animals", "Mammals", "Elephant" }
+ * the path "/Animals/Mammals/Elephant" will be returned
+ */
+ public static string? join_path_components(string[] path_components) {
+ if (path_components.length <= 0)
+ return null;
+ string tmp = string.joinv(Tag.PATH_SEPARATOR_STRING, path_components);
+ return string.joinv(Tag.PATH_SEPARATOR_STRING, { "", tmp });
+ }
+
+ public static string get_basename(string in_path) {
+ string path = flat_to_hierarchical(in_path);
+
+ Gee.List<string> components = enumerate_path_components(path);
+
+ string basename = components.get(components.size - 1);
+
+ return basename;
+ }
+
+ public static string? canonicalize(string in_tag, string foreign_separator) {
+ string result = in_tag.replace(foreign_separator, Tag.PATH_SEPARATOR_STRING);
+
+ if (!result.has_prefix(Tag.PATH_SEPARATOR_STRING))
+ result = Tag.PATH_SEPARATOR_STRING + result;
+
+ // ensure the result has text other than separators in it
+ bool is_valid = false;
+ for (int i = 0; i < result.length; i++) {
+ if (result[i] != Tag.PATH_SEPARATOR_STRING[0]) {
+ is_valid = true;
+ break;
+ }
+ }
+
+ return (is_valid) ? result : null;
+ }
+
+ public static string make_flat_tag_safe(string in_tag) {
+ return in_tag.replace(Tag.PATH_SEPARATOR_STRING, "-");
+ }
+
+ public static HierarchicalTagIndex process_hierarchical_import_keywords(Gee.Collection<string> h_keywords) {
+ HierarchicalTagIndex index = new HierarchicalTagIndex();
+
+ foreach (string keyword in h_keywords) {
+ Gee.List<string> parent_paths =
+ HierarchicalTagUtilities.enumerate_parent_paths(keyword);
+ Gee.List<string> path_components =
+ HierarchicalTagUtilities.enumerate_path_components(keyword);
+
+ assert(parent_paths.size <= path_components.size);
+
+ for (int i = 0; i < parent_paths.size; i++) {
+ if (!index.is_path_known(path_components[i]))
+ index.add_path(path_components[i], parent_paths[i]);
+ }
+
+ index.add_path(HierarchicalTagUtilities.get_basename(keyword), keyword);
+ }
+
+ return index;
+ }
+
+ public static string? get_root_path_form(string? client_path) {
+ if (client_path == null)
+ return null;
+
+ if (HierarchicalTagUtilities.enumerate_parent_paths(client_path).size != 0)
+ return client_path;
+
+ string path = client_path;
+
+ if (!Tag.global.exists(path)) {
+ if (path.has_prefix(Tag.PATH_SEPARATOR_STRING))
+ path = HierarchicalTagUtilities.hierarchical_to_flat(path);
+ else
+ path = HierarchicalTagUtilities.flat_to_hierarchical(path);
+ }
+
+ return (Tag.global.exists(path)) ? path : null;
+ }
+
+ public static void cleanup_root_path(string path) {
+ Gee.List<string> paths = HierarchicalTagUtilities.enumerate_parent_paths(path);
+
+ if (paths.size == 0) {
+ string? actual_path = HierarchicalTagUtilities.get_root_path_form(path);
+
+ if (actual_path == null)
+ return;
+
+ Tag? t = null;
+ if (Tag.global.exists(actual_path));
+ t = Tag.for_path(actual_path);
+
+ if (t != null && t.get_hierarchical_children().size == 0)
+ t.flatten();
+ }
+ }
+}
+
diff --git a/src/tags/TagPage.vala b/src/tags/TagPage.vala
new file mode 100644
index 0000000..f3ef237
--- /dev/null
+++ b/src/tags/TagPage.vala
@@ -0,0 +1,125 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * 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 TagPage : CollectionPage {
+ private Tag tag;
+
+ public TagPage(Tag tag) {
+ base (tag.get_name());
+
+ this.tag = tag;
+
+ Tag.global.items_altered.connect(on_tags_altered);
+ tag.mirror_sources(get_view(), create_thumbnail);
+
+ init_page_context_menu("/TagsContextMenu");
+ }
+
+ ~TagPage() {
+ get_view().halt_mirroring();
+ Tag.global.items_altered.disconnect(on_tags_altered);
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+ ui_filenames.add("tags.ui");
+ }
+
+ public Tag get_tag() {
+ return tag;
+ }
+
+ protected override void get_config_photos_sort(out bool sort_order, out int sort_by) {
+ Config.Facade.get_instance().get_event_photos_sort(out sort_order, out sort_by);
+ }
+
+ protected override void set_config_photos_sort(bool sort_order, int sort_by) {
+ Config.Facade.get_instance().set_event_photos_sort(sort_order, sort_by);
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry delete_tag = { "DeleteTag", null, TRANSLATABLE, null, null, on_delete_tag };
+ // label and tooltip are assigned when the menu is displayed
+ actions += delete_tag;
+
+ Gtk.ActionEntry rename_tag = { "RenameTag", null, TRANSLATABLE, null, null, on_rename_tag };
+ // label and tooltip are assigned when the menu is displayed
+ actions += rename_tag;
+
+ Gtk.ActionEntry remove_tag = { "RemoveTagFromPhotos", null, TRANSLATABLE, null, null,
+ on_remove_tag_from_photos };
+ // label and tooltip are assigned when the menu is displayed
+ actions += remove_tag;
+
+ Gtk.ActionEntry delete_tag_sidebar = { "DeleteTagSidebar", null, Resources.DELETE_TAG_SIDEBAR_MENU,
+ null, null, on_delete_tag };
+ actions += delete_tag_sidebar;
+
+ Gtk.ActionEntry rename_tag_sidebar = { "RenameTagSidebar", null, Resources.RENAME_TAG_SIDEBAR_MENU,
+ null, null, on_rename_tag };
+ actions += rename_tag_sidebar;
+
+ Gtk.ActionEntry new_child_tag_sidebar = { "NewChildTagSidebar", null, Resources.NEW_CHILD_TAG_SIDEBAR_MENU,
+ null, null, on_new_child_tag_sidebar };
+ actions += new_child_tag_sidebar;
+
+ return actions;
+ }
+
+ private void on_tags_altered(Gee.Map<DataObject, Alteration> map) {
+ if (map.has_key(tag)) {
+ set_page_name(tag.get_name());
+ update_actions(get_view().get_selected_count(), get_view().get_count());
+ }
+ }
+
+ protected override void update_actions(int selected_count, int count) {
+ set_action_details("DeleteTag",
+ Resources.delete_tag_menu(tag.get_user_visible_name()),
+ null,
+ true);
+
+ set_action_details("RenameTag",
+ Resources.rename_tag_menu(tag.get_user_visible_name()),
+ null,
+ true);
+
+ set_action_details("RemoveTagFromPhotos",
+ Resources.untag_photos_menu(tag.get_user_visible_name(), selected_count),
+ null,
+ selected_count > 0);
+
+ base.update_actions(selected_count, count);
+ }
+
+ private void on_new_child_tag_sidebar() {
+ NewChildTagCommand creation_command = new NewChildTagCommand(tag);
+
+ AppWindow.get_command_manager().execute(creation_command);
+
+ LibraryWindow.get_app().rename_tag_in_sidebar(creation_command.get_created_child());
+ }
+
+ private void on_rename_tag() {
+ LibraryWindow.get_app().rename_tag_in_sidebar(tag);
+ }
+
+ private void on_delete_tag() {
+ if (Dialogs.confirm_delete_tag(tag))
+ AppWindow.get_command_manager().execute(new DeleteTagCommand(tag));
+ }
+
+ private void on_remove_tag_from_photos() {
+ if (get_view().get_selected_count() > 0) {
+ get_command_manager().execute(new TagUntagPhotosCommand(tag,
+ (Gee.Collection<MediaSource>) get_view().get_selected_sources(),
+ get_view().get_selected_count(), false));
+ }
+ }
+}
+
diff --git a/src/tags/Tags.vala b/src/tags/Tags.vala
new file mode 100644
index 0000000..7d02a2f
--- /dev/null
+++ b/src/tags/Tags.vala
@@ -0,0 +1,18 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Tags {
+
+public void init() throws Error {
+ Tags.SidebarEntry.init();
+}
+
+public void terminate() {
+ Tags.SidebarEntry.terminate();
+}
+
+}
+
diff --git a/src/tags/mk/tags.mk b/src/tags/mk/tags.mk
new file mode 100644
index 0000000..6b2e193
--- /dev/null
+++ b/src/tags/mk/tags.mk
@@ -0,0 +1,32 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Tags
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := tags
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ TagPage.vala \
+ HierarchicalTagIndex.vala \
+ HierarchicalTagUtilities.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Sidebar
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/threads/BackgroundJob.vala b/src/threads/BackgroundJob.vala
new file mode 100644
index 0000000..178211e
--- /dev/null
+++ b/src/threads/BackgroundJob.vala
@@ -0,0 +1,243 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// This callback is executed when an associated BackgroundJob completes. It is called from within
+// the Gtk event loop, *not* the background thread's context.
+public delegate void CompletionCallback(BackgroundJob job);
+
+// This callback is executed when an associated BackgroundJob has been cancelled (via its
+// Cancellable). Note that it's *possible* the BackgroundJob performed some or all of its work
+// prior to executing this delegate.
+public delegate void CancellationCallback(BackgroundJob job);
+
+// This callback is executed by the BackgroundJob when a unit of work is completed, but not the
+// entire job. It is called from within the Gtk event loop, *not* the background thread's
+// context.
+//
+// Note that there does not seem to be any guarantees of order in the Idle queue documentation,
+// and this it's possible (and, depending on assigned priorities, likely) that notifications could
+// arrive in different orders, and even after the CompletionCallback. Thus, no guarantee of
+// ordering is made here.
+//
+// NOTE: Would like Value to be nullable, but can't due to this bug:
+// https://bugzilla.gnome.org/show_bug.cgi?id=607098
+//
+// NOTE: There will be a memory leak using NotificationCallbacks due to this bug:
+// https://bugzilla.gnome.org/show_bug.cgi?id=571264
+//
+// NOTE: Because of these two bugs, using an abstract base class rather than Value. When both are
+// fixed (at least the second), may consider going back to Value.
+
+public abstract class NotificationObject {
+}
+
+public abstract class InterlockedNotificationObject : NotificationObject {
+ private Semaphore semaphore = new Semaphore();
+
+ // Only called by BackgroundJob; no need for users or subclasses to use
+ public void internal_wait_for_completion() {
+ semaphore.wait();
+ }
+
+ // Only called by BackgroundJob; no need for users or subclasses to use
+ public void internal_completed() {
+ semaphore.notify();
+ }
+}
+
+public delegate void NotificationCallback(BackgroundJob job, NotificationObject? user);
+
+// This abstract class represents a unit of work that can be executed within a background thread's
+// context. If specified, the job may be cancellable (which can be checked by execute() and the
+// worker thread prior to calling execute()). The BackgroundJob may also specify a
+// CompletionCallback and/or a CancellationCallback to be executed within Gtk's event loop.
+// A BackgroundJob may also emit NotificationCallbacks, all of which are also executed within
+// Gtk's event loop.
+//
+// The BackgroundJob may be constructed with a reference to its "owner". This is not used directly
+// by BackgroundJob or Worker, but merely exists to hold a reference to the Object that is receiving
+// the various callbacks from BackgroundJob. Without this, it's possible for the object creating
+// BackgroundJobs to be freed before all the callbacks have been received, or even during a callback,
+// which is an unstable situation.
+public abstract class BackgroundJob {
+ public enum JobPriority {
+ HIGHEST = 100,
+ HIGH = 75,
+ NORMAL = 50,
+ LOW = 25,
+ LOWEST = 0;
+
+ // Returns negative if this is higher, zero if equal, positive if this is lower
+ public int compare(JobPriority other) {
+ return (int) other - (int) this;
+ }
+
+ public static int compare_func(void *a, void *b) {
+ return (int) b - (int) a;
+ }
+ }
+
+ private class NotificationJob {
+ public unowned NotificationCallback callback;
+ public BackgroundJob background_job;
+ public NotificationObject? user;
+
+ public NotificationJob(NotificationCallback callback, BackgroundJob background_job,
+ NotificationObject? user) {
+ this.callback = callback;
+ this.background_job = background_job;
+ this.user = user;
+ }
+ }
+
+ private static Gee.ArrayList<NotificationJob> notify_queue = new Gee.ArrayList<NotificationJob>();
+
+ private Object owner;
+ private unowned CompletionCallback callback;
+ private Cancellable cancellable;
+ private unowned CancellationCallback cancellation;
+ private BackgroundJob self = null;
+ private AbstractSemaphore semaphore = null;
+
+ // The thinking here is that there is exactly one CompletionCallback per job, and the caller
+ // probably wants to know that to set off UI and other events in response. There are several
+ // (possibly hundreds or thousands) or notifications, and thus should arrive in a more
+ // controlled way (to avoid locking up the UI, for example). This has ramifications about
+ // the order in which completion and notifications arrive (see above note).
+ private int completion_priority = Priority.HIGH;
+ private int notification_priority = Priority.DEFAULT_IDLE;
+
+ public BackgroundJob(Object? owner = null, CompletionCallback? callback = null,
+ Cancellable? cancellable = null, CancellationCallback? cancellation = null,
+ AbstractSemaphore? completion_semaphore = null) {
+ this.owner = owner;
+ this.callback = callback;
+ this.cancellable = cancellable;
+ this.cancellation = cancellation;
+ this.semaphore = completion_semaphore;
+ }
+
+ public abstract void execute();
+
+ public virtual JobPriority get_priority() {
+ return JobPriority.NORMAL;
+ }
+
+ // For the CompareFunc delegate, according to JobPriority.
+ public static int priority_compare_func(BackgroundJob a, BackgroundJob b) {
+ return a.get_priority().compare(b.get_priority());
+ }
+
+ // For the Comparator delegate, according to JobPriority.
+ public static int64 priority_comparator(void *a, void *b) {
+ return priority_compare_func((BackgroundJob) a, (BackgroundJob) b);
+ }
+
+ // This method is not thread-safe. Best to set priority before the job is enqueued.
+ public void set_completion_priority(int priority) {
+ completion_priority = priority;
+ }
+
+ // This method is not thread-safe. Best to set priority before the job is enqueued.
+ public void set_notification_priority(int priority) {
+ notification_priority = priority;
+ }
+
+ // This method is thread-safe, but only waits if a completion semaphore has been set, otherwise
+ // exits immediately. Note that blocking for a semaphore does NOT spin the event loop, so a
+ // thread relying on it to continue should not use this.
+ public void wait_for_completion() {
+ if (semaphore != null)
+ semaphore.wait();
+ }
+
+ public Cancellable? get_cancellable() {
+ return cancellable;
+ }
+
+ public bool is_cancelled() {
+ return (cancellable != null) ? cancellable.is_cancelled() : false;
+ }
+
+ public void cancel() {
+ if (cancellable != null)
+ cancellable.cancel();
+ }
+
+ // This should only be called by Workers. Beware to all who fail to heed.
+ public void internal_notify_completion() {
+ if (semaphore != null)
+ semaphore.notify();
+
+ if (callback == null && cancellation == null)
+ return;
+
+ if (is_cancelled() && cancellation == null)
+ return;
+
+ // Because Idle doesn't maintain a ref count of the job, and it's going to be dropped by
+ // the worker thread soon, need to maintain a ref until the completion callback is made
+ self = this;
+
+ Idle.add_full(completion_priority, on_notify_completion);
+ }
+
+ private bool on_notify_completion() {
+ // it's still possible the caller cancelled this operation during or after the execute()
+ // method was called ... since the completion work can be costly for a job that was
+ // already cancelled, and the caller might've dropped all references to the job by now,
+ // only notify completion in this context if not cancelled
+ if (is_cancelled()) {
+ if (cancellation != null)
+ cancellation(this);
+ } else {
+ if (callback != null)
+ callback(this);
+ }
+
+ // drop the ref so this object can be freed ... must not touch "this" after this point
+ self = null;
+
+ return false;
+ }
+
+ // This call may be executed by the child class during execute() to inform of a unit of
+ // work being completed
+ protected void notify(NotificationCallback callback, NotificationObject? user) {
+ lock (notify_queue) {
+ notify_queue.add(new NotificationJob(callback, this, user));
+ }
+
+ Idle.add_full(notification_priority, on_notification_ready);
+
+ // If an interlocked notification, block until the main thread completes the notification
+ // callback
+ InterlockedNotificationObject? interlocked = user as InterlockedNotificationObject;
+ if (interlocked != null)
+ interlocked.internal_wait_for_completion();
+ }
+
+ private bool on_notification_ready() {
+ // this is called once for every notification added, so there should always be something
+ // waiting for us
+ NotificationJob? notification_job = null;
+ lock (notify_queue) {
+ if (notify_queue.size > 0)
+ notification_job = notify_queue.remove_at(0);
+ }
+ assert(notification_job != null);
+
+ notification_job.callback(notification_job.background_job, notification_job.user);
+
+ // Release the blocked thread waiting for this notification to complete
+ InterlockedNotificationObject? interlocked = notification_job.user as InterlockedNotificationObject;
+ if (interlocked != null)
+ interlocked.internal_completed();
+
+ return false;
+ }
+}
+
diff --git a/src/threads/Semaphore.vala b/src/threads/Semaphore.vala
new file mode 100644
index 0000000..dfb0a2f
--- /dev/null
+++ b/src/threads/Semaphore.vala
@@ -0,0 +1,160 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// Semaphores may be used to be notified when a job is completed. This provides an alternate
+// mechanism (essentially, a blocking mechanism) to the system of callbacks that BackgroundJob
+// offers. They can also be used for other job-dependent notification mechanisms.
+public abstract class AbstractSemaphore {
+ public enum Type {
+ SERIAL,
+ BROADCAST
+ }
+
+ protected enum NotifyAction {
+ NONE,
+ SIGNAL
+ }
+
+ protected enum WaitAction {
+ SLEEP,
+ READY
+ }
+
+ private Type type;
+ private Mutex mutex = Mutex();
+ private Cond monitor = Cond();
+
+ public AbstractSemaphore(Type type) {
+ assert(type == Type.SERIAL || type == Type.BROADCAST);
+
+ this.type = type;
+ }
+
+ private void trigger() {
+ if (type == Type.SERIAL)
+ monitor.signal();
+ else
+ monitor.broadcast();
+ }
+
+ public void notify() {
+ mutex.lock();
+
+ NotifyAction action = do_notify();
+ switch (action) {
+ case NotifyAction.NONE:
+ // do nothing
+ break;
+
+ case NotifyAction.SIGNAL:
+ trigger();
+ break;
+
+ default:
+ error("Unknown semaphore action: %s", action.to_string());
+ }
+
+ mutex.unlock();
+ }
+
+ // This method is called by notify() with the semaphore's mutex locked.
+ protected abstract NotifyAction do_notify();
+
+ public void wait() {
+ mutex.lock();
+
+ while (do_wait() == WaitAction.SLEEP)
+ monitor.wait(mutex);
+
+ mutex.unlock();
+ }
+
+ // This method is called by wait() with the semaphore's mutex locked.
+ protected abstract WaitAction do_wait();
+
+ // Returns true if the semaphore is reset, false otherwise.
+ public bool reset() {
+ mutex.lock();
+ bool is_reset = do_reset();
+ mutex.unlock();
+
+ return is_reset;
+ }
+
+ // This method is called by reset() with the semaphore's mutex locked. Returns true if reset,
+ // false if not supported.
+ protected virtual bool do_reset() {
+ return false;
+ }
+}
+
+public class Semaphore : AbstractSemaphore {
+ bool passed = false;
+
+ public Semaphore() {
+ base (AbstractSemaphore.Type.BROADCAST);
+ }
+
+ protected override AbstractSemaphore.NotifyAction do_notify() {
+ if (passed)
+ return NotifyAction.NONE;
+
+ passed = true;
+
+ return NotifyAction.SIGNAL;
+ }
+
+ protected override AbstractSemaphore.WaitAction do_wait() {
+ return passed ? WaitAction.READY : WaitAction.SLEEP;
+ }
+}
+
+public class CountdownSemaphore : AbstractSemaphore {
+ private int total;
+ private int passed = 0;
+
+ public CountdownSemaphore(int total) {
+ base (AbstractSemaphore.Type.BROADCAST);
+
+ this.total = total;
+ }
+
+ protected override AbstractSemaphore.NotifyAction do_notify() {
+ if (passed >= total)
+ critical("CountdownSemaphore overrun: %d/%d", passed + 1, total);
+
+ return (++passed >= total) ? NotifyAction.SIGNAL : NotifyAction.NONE;
+ }
+
+ protected override AbstractSemaphore.WaitAction do_wait() {
+ return (passed < total) ? WaitAction.SLEEP : WaitAction.READY;
+ }
+}
+
+public class EventSemaphore : AbstractSemaphore {
+ bool fired = false;
+
+ public EventSemaphore() {
+ base (AbstractSemaphore.Type.BROADCAST);
+ }
+
+ protected override AbstractSemaphore.NotifyAction do_notify() {
+ fired = true;
+
+ return NotifyAction.SIGNAL;
+ }
+
+ protected override AbstractSemaphore.WaitAction do_wait() {
+ return fired ? WaitAction.READY : WaitAction.SLEEP;
+ }
+
+ protected override bool do_reset() {
+ fired = false;
+
+ return true;
+ }
+}
+
diff --git a/src/threads/Threads.vala b/src/threads/Threads.vala
new file mode 100644
index 0000000..0e1b1ec
--- /dev/null
+++ b/src/threads/Threads.vala
@@ -0,0 +1,14 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Threads {
+ public void init() throws Error {
+ }
+
+ public void terminate() {
+ }
+}
+
diff --git a/src/threads/Workers.vala b/src/threads/Workers.vala
new file mode 100644
index 0000000..756eb01
--- /dev/null
+++ b/src/threads/Workers.vala
@@ -0,0 +1,104 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+
+public class BackgroundJobBatch : SortedList<BackgroundJob> {
+ public BackgroundJobBatch() {
+ base (BackgroundJob.priority_comparator);
+ }
+}
+
+// Workers wraps some of ThreadPool's oddities up into an interface that emphasizes BackgroundJobs.
+public class Workers {
+ public const int UNLIMITED_THREADS = -1;
+
+ private ThreadPool<void *> thread_pool;
+ private AsyncQueue<BackgroundJob> queue = new AsyncQueue<BackgroundJob>();
+ private EventSemaphore empty_event = new EventSemaphore();
+ private int enqueued = 0;
+
+ public Workers(int max_threads, bool exclusive) {
+ if (max_threads <= 0 && max_threads != UNLIMITED_THREADS)
+ max_threads = 1;
+
+ // event starts as set because queue is empty
+ empty_event.notify();
+
+ try {
+ thread_pool = new ThreadPool<void *>.with_owned_data(thread_start, max_threads, exclusive);
+ } catch (ThreadError err) {
+ error("Unable to create thread pool: %s", err.message);
+ }
+ }
+
+ public static int threads_per_cpu(int per = 1, int max = -1) requires (per > 0) ensures (result > 0) {
+ int count = number_of_processors() * per;
+
+ return (max < 0) ? count : count.clamp(0, max);
+ }
+
+ // This is useful when the intent is for the worker threads to use all the CPUs minus one for
+ // the main/UI thread. (No guarantees, of course.)
+ public static int thread_per_cpu_minus_one() ensures (result > 0) {
+ return (number_of_processors() - 1).clamp(1, int.MAX);
+ }
+
+ // Enqueues a BackgroundJob for work in a thread context. BackgroundJob.execute() is called
+ // within the thread's context, while its CompletionCallback is called within the Gtk event loop.
+ public void enqueue(BackgroundJob job) {
+ empty_event.reset();
+
+ lock (queue) {
+ queue.push_sorted(job, BackgroundJob.priority_compare_func);
+ enqueued++;
+ }
+
+ try {
+ thread_pool.add(job);
+ } catch (ThreadError err) {
+ // error should only occur when a thread could not be created, in which case, the
+ // BackgroundJob is queued up
+ warning("Unable to create worker thread: %s", err.message);
+ }
+ }
+
+ public void enqueue_many(BackgroundJobBatch batch) {
+ foreach (BackgroundJob job in batch)
+ enqueue(job);
+ }
+
+ public void wait_for_empty_queue() {
+ empty_event.wait();
+ }
+
+ // Returns the number of BackgroundJobs on the queue, not including active jobs.
+ public int get_pending_job_count() {
+ lock (queue) {
+ return enqueued;
+ }
+ }
+
+ private void thread_start(void *ignored) {
+ BackgroundJob? job;
+ bool empty;
+ lock (queue) {
+ job = queue.try_pop();
+ assert(job != null);
+
+ assert(enqueued > 0);
+ empty = (--enqueued == 0);
+ }
+
+ if (!job.is_cancelled())
+ job.execute();
+
+ job.internal_notify_completion();
+
+ if (empty)
+ empty_event.notify();
+ }
+}
+
diff --git a/src/threads/mk/threads.mk b/src/threads/mk/threads.mk
new file mode 100644
index 0000000..83afc47
--- /dev/null
+++ b/src/threads/mk/threads.mk
@@ -0,0 +1,30 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Threads
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := threads
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Workers.vala \
+ BackgroundJob.vala \
+ Semaphore.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/unit/Unit.vala b/src/unit/Unit.vala
new file mode 100644
index 0000000..f297974
--- /dev/null
+++ b/src/unit/Unit.vala
@@ -0,0 +1,14 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Unit {
+ public void init() throws Error {
+ }
+
+ public void terminate() {
+ }
+}
+
diff --git a/src/unit/mk/unit.mk b/src/unit/mk/unit.mk
new file mode 100644
index 0000000..618ebca
--- /dev/null
+++ b/src/unit/mk/unit.mk
@@ -0,0 +1,32 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Unit
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := unit
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES :=
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC := \
+ rc/UnitInternals.m4 \
+ rc/Unit.m4 \
+ rc/template.mk \
+ rc/unitize_entry.m4 \
+ rc/template.vala
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/unit/rc/Unit.m4 b/src/unit/rc/Unit.m4
new file mode 100644
index 0000000..13ef6a7
--- /dev/null
+++ b/src/unit/rc/Unit.m4
@@ -0,0 +1,29 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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
new file mode 100644
index 0000000..4fe3153
--- /dev/null
+++ b/src/unit/rc/UnitInternals.m4
@@ -0,0 +1,32 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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.mk b/src/unit/rc/template.mk
new file mode 100644
index 0000000..34873d3
--- /dev/null
+++ b/src/unit/rc/template.mk
@@ -0,0 +1,27 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := _UNIT_NAME_
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := _UNIT_DIR_
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES :=
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/unit/rc/template.vala b/src/unit/rc/template.vala
new file mode 100644
index 0000000..4869700
--- /dev/null
+++ b/src/unit/rc/template.vala
@@ -0,0 +1,7 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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
new file mode 100644
index 0000000..34407e4
--- /dev/null
+++ b/src/unit/rc/unitize_entry.m4
@@ -0,0 +1,19 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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/KeyValueMap.vala b/src/util/KeyValueMap.vala
new file mode 100644
index 0000000..c1f5a55
--- /dev/null
+++ b/src/util/KeyValueMap.vala
@@ -0,0 +1,118 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * 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 KeyValueMap {
+ private string group;
+ private Gee.HashMap<string, string> map = new Gee.HashMap<string, string>();
+
+ public KeyValueMap(string group) {
+ this.group = group;
+ }
+
+ public KeyValueMap copy() {
+ KeyValueMap clone = new KeyValueMap(group);
+ foreach (string key in map.keys)
+ clone.map.set(key, map.get(key));
+
+ return clone;
+ }
+
+ public string get_group() {
+ return group;
+ }
+
+ public Gee.Set<string> get_keys() {
+ return map.keys;
+ }
+
+ public bool has_key(string key) {
+ return map.has_key(key);
+ }
+
+ public void set_string(string key, string value) {
+ assert(key != null);
+
+ map.set(key, value);
+ }
+
+ public void set_int(string key, int value) {
+ assert(key != null);
+
+ map.set(key, value.to_string());
+ }
+
+ public void set_double(string key, double value) {
+ assert(key != null);
+
+ map.set(key, value.to_string());
+ }
+
+ public void set_float(string key, float value) {
+ assert(key != null);
+
+ map.set(key, value.to_string());
+ }
+
+ public void set_bool(string key, bool value) {
+ assert(key != null);
+
+ map.set(key, value.to_string());
+ }
+
+ public string get_string(string key, string? def) {
+ string value = map.get(key);
+
+ return (value != null) ? value : def;
+ }
+
+ public int get_int(string key, int def) {
+ string value = map.get(key);
+
+ return (value != null) ? int.parse(value) : def;
+ }
+
+ public double get_double(string key, double def) {
+ string value = map.get(key);
+
+ return (value != null) ? double.parse(value) : def;
+ }
+
+ public float get_float(string key, float def) {
+ string value = map.get(key);
+
+ return (value != null) ? (float) double.parse(value) : def;
+ }
+
+ public bool get_bool(string key, bool def) {
+ string value = map.get(key);
+
+ return (value != null) ? bool.parse(value) : def;
+ }
+
+ // REDEYE: redeye reduction operates on circular regions defined by
+ // (Gdk.Point, int) pairs, where the Gdk.Point specifies the
+ // bounding circle's center and the the int specifies the circle's
+ // radius so, get_point( ) and set_point( ) functions have been
+ // added here to easily encode/decode Gdk.Points as strings.
+ public Gdk.Point get_point(string key, Gdk.Point def) {
+ string value = map.get(key);
+
+ if (value == null) {
+ return def;
+ } else {
+ Gdk.Point result = {0};
+ if (value.scanf("(%d, %d)", &result.x, &result.y) == 2)
+ return result;
+ else
+ return def;
+ }
+ }
+
+ public void set_point(string key, Gdk.Point point) {
+ map.set(key, "(%d, %d)".printf(point.x, point.y));
+ }
+}
+
diff --git a/src/util/Util.vala b/src/util/Util.vala
new file mode 100644
index 0000000..c754ff8
--- /dev/null
+++ b/src/util/Util.vala
@@ -0,0 +1,17 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+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 void init() throws Error {
+ }
+
+ public void terminate() {
+ }
+}
+
diff --git a/src/util/file.vala b/src/util/file.vala
new file mode 100644
index 0000000..1b6bb6c
--- /dev/null
+++ b/src/util/file.vala
@@ -0,0 +1,241 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+// Returns true if the file is claimed, false if it exists, and throws an Error otherwise. The file
+// will be created when the function exits and should be overwritten. Note that the file is not
+// held open; claiming a file is merely based on its existence.
+//
+// This function is thread-safe.
+public bool claim_file(File file) throws Error {
+ try {
+ file.create(FileCreateFlags.NONE, null);
+
+ // created; success
+ return true;
+ } catch (Error err) {
+ // check for file-exists error
+ if (!(err is IOError.EXISTS)) {
+ warning("claim_file %s: %s", file.get_path(), err.message);
+
+ throw err;
+ }
+
+ return false;
+ }
+}
+
+// This function "claims" a file on the filesystem in the directory specified with a basename the
+// 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.
+//
+// This function is thread-safe.
+public File? generate_unique_file(File dir, string basename, out bool collision) throws Error {
+ // create the file to atomically "claim" it
+ File file = dir.get_child(basename);
+ if (claim_file(file)) {
+ collision = false;
+
+ return file;
+ }
+
+ // file exists, note collision and keep searching
+ collision = true;
+
+ string name, ext;
+ disassemble_filename(basename, out name, out ext);
+
+ // 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);
+
+ file = dir.get_child(new_name);
+ if (claim_file(file))
+ return file;
+ }
+
+ warning("generate_unique_filename %s for %s: unable to claim file", dir.get_path(), basename);
+
+ return null;
+}
+
+public void disassemble_filename(string basename, out string name, out string ext) {
+ long offset = find_last_offset(basename, '.');
+ if (offset <= 0) {
+ name = basename;
+ ext = null;
+ } else {
+ name = basename.substring(0, offset);
+ ext = basename.substring(offset + 1, -1);
+ }
+}
+
+// This function is thread-safe.
+public uint64 query_total_file_size(File file_or_dir, Cancellable? cancellable = null) throws Error {
+ FileType type = file_or_dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ if (type == FileType.REGULAR) {
+ FileInfo info = null;
+ try {
+ info = file_or_dir.query_info(FileAttribute.STANDARD_SIZE,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, cancellable);
+ } catch (Error err) {
+ if (err is IOError.CANCELLED)
+ throw err;
+
+ debug("Unable to query filesize for %s: %s", file_or_dir.get_path(), err.message);
+
+ return 0;
+ }
+
+ return info.get_size();
+ } else if (type != FileType.DIRECTORY) {
+ return 0;
+ }
+
+ FileEnumerator enumerator;
+ try {
+ enumerator = file_or_dir.enumerate_children(FileAttribute.STANDARD_NAME,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, cancellable);
+ if (enumerator == null)
+ return 0;
+ } catch (Error err) {
+ // Don't treat a permissions failure as a hard failure, just skip the directory
+ if (err is FileError.PERM || err is IOError.PERMISSION_DENIED)
+ return 0;
+
+ throw err;
+ }
+
+ uint64 total_bytes = 0;
+
+ FileInfo info = null;
+ while ((info = enumerator.next_file(cancellable)) != null)
+ total_bytes += query_total_file_size(file_or_dir.get_child(info.get_name()), cancellable);
+
+ return total_bytes;
+}
+
+// Does not currently recurse. Could be modified to do so. Does not error out on first file that
+// does not delete, but logs a warning and continues.
+// Note: if supplying a progress monitor, a file count is also required. The count_files_in_directory()
+// function below should do the trick.
+public void delete_all_files(File dir, Gee.Set<string>? exceptions = null, ProgressMonitor? monitor = null,
+ uint64 file_count = 0, Cancellable? cancellable = null) throws Error {
+ FileType type = dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ if (type != FileType.DIRECTORY)
+ throw new IOError.NOT_DIRECTORY("%s is not a directory".printf(dir.get_path()));
+
+ FileEnumerator enumerator = dir.enumerate_children("standard::name,standard::type",
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, cancellable);
+ FileInfo info = null;
+ uint64 i = 0;
+ while ((info = enumerator.next_file(cancellable)) != null) {
+ if (info.get_file_type() != FileType.REGULAR)
+ continue;
+
+ if (exceptions != null && exceptions.contains(info.get_name()))
+ continue;
+
+ File file = dir.get_child(info.get_name());
+ try {
+ file.delete(cancellable);
+ } catch (Error err) {
+ warning("Unable to delete file %s: %s", file.get_path(), err.message);
+ }
+
+ if (monitor != null && file_count > 0)
+ monitor(file_count, ++i);
+ }
+}
+
+public time_t 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;
+}
+
+public bool query_is_directory(File file) {
+ return file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) == FileType.DIRECTORY;
+}
+
+public bool query_is_directory_empty(File dir) throws Error {
+ if (dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) != FileType.DIRECTORY)
+ return false;
+
+ FileEnumerator enumerator = dir.enumerate_children("standard::name",
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ if (enumerator == null)
+ return false;
+
+ return enumerator.next_file(null) == null;
+}
+
+public string get_display_pathname(File file) {
+ // attempt to replace home path with tilde in a user-pleasable way
+ string path = file.get_parse_name();
+ string home = Environment.get_home_dir();
+
+ if (path == home)
+ return "~";
+
+ if (path.has_prefix(home))
+ return "~%s".printf(path.substring(home.length));
+
+ return path;
+}
+
+public string strip_pretty_path(string path) {
+ if (!path.has_prefix("~"))
+ return path;
+
+ return Environment.get_home_dir() + path.substring(1);
+}
+
+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))
+ return 0;
+
+ uint64 count = 0;
+ FileEnumerator enumerator = dir.enumerate_children("standard::*",
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+
+ FileInfo info = null;
+ while ((info = enumerator.next_file()) != null)
+ count++;
+
+ return count;
+}
+
+// Replacement for deprecated Gio.file_equal
+public bool file_equal(File? a, File? b) {
+ return (a != null && b != null) ? a.equal(b) : false;
+}
+
+// Replacement for deprecated Gio.file_hash
+public uint file_hash(File? file) {
+ return file != null ? file.hash() : 0;
+}
+
diff --git a/src/util/image.vala b/src/util/image.vala
new file mode 100644
index 0000000..e8f93ba
--- /dev/null
+++ b/src/util/image.vala
@@ -0,0 +1,364 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+bool is_color_parsable(string spec) {
+ Gdk.Color color;
+ return Gdk.Color.parse(spec, out color);
+}
+
+Gdk.RGBA parse_color(string spec) {
+ return fetch_color(spec);
+}
+
+Gdk.RGBA fetch_color(string spec) {
+ Gdk.RGBA rgba = Gdk.RGBA();
+ if (!rgba.parse(spec))
+ error("Can't parse color %s", spec);
+
+ return rgba;
+}
+
+void set_source_color_from_string(Cairo.Context ctx, string spec) {
+ Gdk.RGBA rgba = fetch_color(spec);
+ ctx.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha);
+}
+
+private const int MIN_SCALED_WIDTH = 10;
+private const int MIN_SCALED_HEIGHT = 10;
+
+Gdk.Pixbuf scale_pixbuf(Gdk.Pixbuf pixbuf, int scale, Gdk.InterpType interp, bool scale_up) {
+ Dimensions original = Dimensions.for_pixbuf(pixbuf);
+ Dimensions scaled = original.get_scaled(scale, scale_up);
+ if ((original.width == scaled.width) && (original.height == scaled.height))
+ return pixbuf;
+
+ // use sane minimums ... scale_simple will hang if this is too low
+ scaled = scaled.with_min(MIN_SCALED_WIDTH, MIN_SCALED_HEIGHT);
+
+ return pixbuf.scale_simple(scaled.width, scaled.height, interp);
+}
+
+Gdk.Pixbuf resize_pixbuf(Gdk.Pixbuf pixbuf, Dimensions resized, Gdk.InterpType interp) {
+ Dimensions original = Dimensions.for_pixbuf(pixbuf);
+ if (original.width == resized.width && original.height == resized.height)
+ return pixbuf;
+
+ // use sane minimums ... scale_simple will hang if this is too low
+ resized = resized.with_min(MIN_SCALED_WIDTH, MIN_SCALED_HEIGHT);
+
+ return pixbuf.scale_simple(resized.width, resized.height, interp);
+}
+
+private const double DEGREE = Math.PI / 180.0;
+
+void draw_rounded_corners_filled(Cairo.Context ctx, Dimensions dim, Gdk.Point origin,
+ double radius_proportion) {
+ context_rounded_corners(ctx, dim, origin, radius_proportion);
+ ctx.paint();
+}
+
+void context_rounded_corners(Cairo.Context cx, Dimensions dim, Gdk.Point origin,
+ double radius_proportion) {
+ // establish a reasonable range
+ radius_proportion = radius_proportion.clamp(2.0, 100.0);
+
+ double left = origin.x;
+ double top = origin.y;
+ double right = origin.x + dim.width;
+ double bottom = origin.y + dim.height;
+
+ // the radius of the corners is proportional to the distance of the minor axis
+ double radius = ((double) dim.minor_axis()) / radius_proportion;
+
+ // create context and clipping region, starting from the top right arc and working around
+ // clockwise
+ cx.move_to(left, top);
+ cx.arc(right - radius, top + radius, radius, -90 * DEGREE, 0 * DEGREE);
+ cx.arc(right - radius, bottom - radius, radius, 0 * DEGREE, 90 * DEGREE);
+ cx.arc(left + radius, bottom - radius, radius, 90 * DEGREE, 180 * DEGREE);
+ cx.arc(left + radius, top + radius, radius, 180 * DEGREE, 270 * DEGREE);
+ cx.clip();
+}
+
+inline uchar shift_color_byte(int b, int shift) {
+ return (uchar) (b + shift).clamp(0, 255);
+}
+
+public void shift_colors(Gdk.Pixbuf pixbuf, int red, int green, int blue, int alpha) {
+ assert(red >= -255 && red <= 255);
+ assert(green >= -255 && green <= 255);
+ assert(blue >= -255 && blue <= 255);
+ assert(alpha >= -255 && alpha <= 255);
+
+ int width = pixbuf.get_width();
+ int height = pixbuf.get_height();
+ int rowstride = pixbuf.get_rowstride();
+ int channels = pixbuf.get_n_channels();
+ uchar *pixels = pixbuf.get_pixels();
+
+ assert(channels >= 3);
+ assert(pixbuf.get_colorspace() == Gdk.Colorspace.RGB);
+ assert(pixbuf.get_bits_per_sample() == 8);
+
+ for (int y = 0; y < height; y++) {
+ int y_offset = y * rowstride;
+
+ for (int x = 0; x < width; x++) {
+ int offset = y_offset + (x * channels);
+
+ if (red != 0)
+ pixels[offset] = shift_color_byte(pixels[offset], red);
+
+ if (green != 0)
+ pixels[offset + 1] = shift_color_byte(pixels[offset + 1], green);
+
+ if (blue != 0)
+ pixels[offset + 2] = shift_color_byte(pixels[offset + 2], blue);
+
+ if (alpha != 0 && channels >= 4)
+ pixels[offset + 3] = shift_color_byte(pixels[offset + 3], alpha);
+ }
+ }
+}
+
+public void dim_pixbuf(Gdk.Pixbuf pixbuf) {
+ PixelTransformer transformer = new PixelTransformer();
+ SaturationTransformation sat = new SaturationTransformation(SaturationTransformation.MIN_PARAMETER);
+ transformer.attach_transformation(sat);
+ transformer.transform_pixbuf(pixbuf);
+ shift_colors(pixbuf, 0, 0, 0, -100);
+}
+
+bool coord_in_rectangle(int x, int y, Gdk.Rectangle rect) {
+ return (x >= rect.x && x < (rect.x + rect.width) && y >= rect.y && y <= (rect.y + rect.height));
+}
+
+public bool rectangles_equal(Gdk.Rectangle a, Gdk.Rectangle b) {
+ return (a.x == b.x) && (a.y == b.y) && (a.width == b.width) && (a.height == b.height);
+}
+
+public string rectangle_to_string(Gdk.Rectangle rect) {
+ return "%d,%d %dx%d".printf(rect.x, rect.y, rect.width, rect.height);
+}
+
+public Gdk.Rectangle clamp_rectangle(Gdk.Rectangle original, Dimensions max) {
+ Gdk.Rectangle rect = Gdk.Rectangle();
+ rect.x = original.x.clamp(0, max.width);
+ rect.y = original.y.clamp(0, max.height);
+ rect.width = original.width.clamp(0, max.width);
+ rect.height = original.height.clamp(0, max.height);
+
+ return rect;
+}
+
+public Gdk.Point scale_point(Gdk.Point p, double factor) {
+ Gdk.Point result = {0};
+ result.x = (int) (factor * p.x + 0.5);
+ result.y = (int) (factor * p.y + 0.5);
+
+ return result;
+}
+
+public Gdk.Point add_points(Gdk.Point p1, Gdk.Point p2) {
+ Gdk.Point result = {0};
+ result.x = p1.x + p2.x;
+ result.y = p1.y + p2.y;
+
+ return result;
+}
+
+public Gdk.Point subtract_points(Gdk.Point p1, Gdk.Point p2) {
+ Gdk.Point result = {0};
+ result.x = p1.x - p2.x;
+ result.y = p1.y - p2.y;
+
+ return result;
+}
+
+// Converts XRGB/ARGB (Cairo)-formatted pixels to RGBA (GDK).
+void fix_cairo_pixbuf(Gdk.Pixbuf pixbuf) {
+ uchar *gdk_pixels = pixbuf.pixels;
+ for (int j = 0 ; j < pixbuf.height; ++j) {
+ uchar *p = gdk_pixels;
+ uchar *end = p + 4 * pixbuf.width;
+
+ while (p < end) {
+ uchar tmp = p[0];
+#if G_BYTE_ORDER == G_LITTLE_ENDIAN
+ p[0] = p[2];
+ p[2] = tmp;
+#else
+ p[0] = p[1];
+ p[1] = p[2];
+ p[2] = p[3];
+ p[3] = tmp;
+#endif
+ p += 4;
+ }
+
+ gdk_pixels += pixbuf.rowstride;
+ }
+}
+
+/**
+ * Finds the size of the smallest axially-aligned rectangle that could contain
+ * a rectangle src_width by src_height, rotated by angle.
+ *
+ * @param src_width The width of the incoming rectangle.
+ * @param src_height The height of the incoming rectangle.
+ * @param angle The amount to rotate by, given in degrees.
+ * @param dest_width The width of the computed rectangle.
+ * @param dest_height The height of the computed rectangle.
+ */
+void compute_arb_rotated_size(double src_width, double src_height, double angle,
+ out double dest_width, out double dest_height) {
+
+ angle = Math.fabs(degrees_to_radians(angle));
+ assert(angle <= Math.PI_2);
+ dest_width = src_width * Math.cos(angle) + src_height * Math.sin(angle);
+ dest_height = src_height * Math.cos(angle) + src_width * Math.sin(angle);
+}
+
+/**
+ * @brief Rotates a pixbuf to an arbitrary angle, given in degrees, and returns the rotated pixbuf.
+ *
+ * @param source_pixbuf The source image that needs to be angled.
+ * @param angle The angle the source image should be rotated by.
+ */
+Gdk.Pixbuf rotate_arb(Gdk.Pixbuf source_pixbuf, double angle) {
+ // if the straightening angle has been reset
+ // or was never set in the first place, nothing
+ // needs to be done to the source image.
+ if (angle == 0.0) {
+ return source_pixbuf;
+ }
+
+ // Compute how much the corners of the source image will
+ // move by to determine how big the dest pixbuf should be.
+
+ double x_tmp, y_tmp;
+ compute_arb_rotated_size(source_pixbuf.width, source_pixbuf.height, angle,
+ out x_tmp, out y_tmp);
+
+ Gdk.Pixbuf dest_pixbuf = new Gdk.Pixbuf(
+ Gdk.Colorspace.RGB, true, 8, (int) Math.round(x_tmp), (int) Math.round(y_tmp));
+
+ Cairo.ImageSurface surface = new Cairo.ImageSurface.for_data(
+ (uchar []) dest_pixbuf.pixels,
+ source_pixbuf.has_alpha ? Cairo.Format.ARGB32 : Cairo.Format.RGB24,
+ dest_pixbuf.width, dest_pixbuf.height, dest_pixbuf.rowstride);
+
+ Cairo.Context context = new Cairo.Context(surface);
+
+ context.set_source_rgb(0, 0, 0);
+ context.rectangle(0, 0, dest_pixbuf.width, dest_pixbuf.height);
+ context.fill();
+
+ context.translate(dest_pixbuf.width / 2, dest_pixbuf.height / 2);
+ context.rotate(degrees_to_radians(angle));
+ context.translate(- source_pixbuf.width / 2, - source_pixbuf.height / 2);
+
+ Gdk.cairo_set_source_pixbuf(context, source_pixbuf, 0, 0);
+ context.get_source().set_filter(Cairo.Filter.BEST);
+ context.paint();
+
+ // prepare the newly-drawn image for use by
+ // the rest of the pipeline.
+ fix_cairo_pixbuf(dest_pixbuf);
+
+ return dest_pixbuf;
+}
+
+/**
+ * @brief Rotates a point around the upper left corner of an image to an arbitrary angle,
+ * given in degrees, and returns the rotated point, translated such that it, along with its attendant
+ * image, are in positive x, positive y.
+ *
+ * @note May be subject to slight inaccuracy as Gdk points' coordinates may only be in whole pixels,
+ * so the fractional component is lost.
+ *
+ * @param source_point The point to be rotated and scaled.
+ * @param img_w The width of the source image (unrotated).
+ * @param img_h The height of the source image (unrotated).
+ * @param angle The angle the source image is to be rotated by to straighten it.
+ */
+Gdk.Point rotate_point_arb(Gdk.Point source_point, int img_w, int img_h, double angle,
+ bool invert = false) {
+ // angle of 0 degrees or angle was never set?
+ if (angle == 0.0) {
+ // nothing needs to be done.
+ return source_point;
+ }
+
+ double dest_width;
+ double dest_height;
+ compute_arb_rotated_size(img_w, img_h, angle, out dest_width, out dest_height);
+
+ Cairo.Matrix matrix = Cairo.Matrix.identity();
+ matrix.translate(dest_width / 2, dest_height / 2);
+ matrix.rotate(degrees_to_radians(angle));
+ matrix.translate(- img_w / 2, - img_h / 2);
+ if (invert)
+ assert(matrix.invert() == Cairo.Status.SUCCESS);
+
+ double dest_x = source_point.x;
+ double dest_y = source_point.y;
+ matrix.transform_point(ref dest_x, ref dest_y);
+
+ return { (int) dest_x, (int) dest_y };
+}
+
+/**
+ * @brief <u>De</u>rotates a point around the upper left corner of an image from an arbitrary angle,
+ * given in degrees, and returns the de-rotated point, taking into account any translation necessary
+ * to make sure all of the rotated image stays in positive x, positive y.
+ *
+ * @note May be subject to slight inaccuracy as Gdk points' coordinates may only be in whole pixels,
+ * so the fractional component is lost.
+ *
+ * @param source_point The point to be de-rotated.
+ * @param img_w The width of the source image (unrotated).
+ * @param img_h The height of the source image (unrotated).
+ * @param angle The angle the source image is to be rotated by to straighten it.
+ */
+Gdk.Point derotate_point_arb(Gdk.Point source_point, int img_w, int img_h, double angle) {
+ return rotate_point_arb(source_point, img_w, img_h, angle, true);
+}
+
+
+// Force an axially-aligned box to be inside a rotated rectangle.
+Box clamp_inside_rotated_image(Box src, int img_w, int img_h, double angle_deg,
+ bool preserve_geom) {
+
+ Gdk.Point top_left = derotate_point_arb({src.left, src.top}, img_w, img_h, angle_deg);
+ Gdk.Point top_right = derotate_point_arb({src.right, src.top}, img_w, img_h, angle_deg);
+ Gdk.Point bottom_left = derotate_point_arb({src.left, src.bottom}, img_w, img_h, angle_deg);
+ Gdk.Point bottom_right = derotate_point_arb({src.right, src.bottom}, img_w, img_h, angle_deg);
+
+ double angle = degrees_to_radians(angle_deg);
+ int top_offset = 0, bottom_offset = 0, left_offset = 0, right_offset = 0;
+
+ int top = int.min(top_left.y, top_right.y);
+ if (top < 0)
+ top_offset = (int) ((0 - top) * Math.cos(angle));
+
+ int bottom = int.max(bottom_left.y, bottom_right.y);
+ if (bottom > img_h)
+ bottom_offset = (int) ((img_h - bottom) * Math.cos(angle));
+
+ int left = int.min(top_left.x, bottom_left.x);
+ if (left < 0)
+ left_offset = (int) ((0 - left) * Math.cos(angle));
+
+ int right = int.max(top_right.x, bottom_right.x);
+ if (right > img_w)
+ right_offset = (int) ((img_w - right) * Math.cos(angle));
+
+ return preserve_geom ? src.get_offset(left_offset + right_offset, top_offset + bottom_offset)
+ : Box(src.left + left_offset, src.top + top_offset,
+ src.right + right_offset, src.bottom + bottom_offset);
+}
+
diff --git a/src/util/misc.vala b/src/util/misc.vala
new file mode 100644
index 0000000..73ce428
--- /dev/null
+++ b/src/util/misc.vala
@@ -0,0 +1,377 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public uint int64_hash(int64? n) {
+ // Rotating XOR hash
+ uint8 *u8 = (uint8 *) n;
+ uint hash = 0;
+ for (int ctr = 0; ctr < (sizeof(int64) / sizeof(uint8)); ctr++) {
+ hash = (hash << 4) ^ (hash >> 28) ^ (*u8++);
+ }
+
+ return hash;
+}
+
+public bool int64_equal(int64? a, int64? b) {
+ int64 *bia = (int64 *) a;
+ int64 *bib = (int64 *) b;
+
+ return (*bia) == (*bib);
+}
+
+public int int64_compare(int64? a, int64? b) {
+ int64 diff = *((int64 *) a) - *((int64 *) b);
+ if (diff < 0)
+ return -1;
+ else if (diff > 0)
+ return 1;
+ else
+ return 0;
+}
+
+public int uint64_compare(uint64? a, uint64? b) {
+ uint64 a64 = *((uint64 *) a);
+ uint64 b64 = *((uint64 *) b);
+
+ if (a64 < b64)
+ return -1;
+ else if (a64 > b64)
+ return 1;
+ else
+ return 0;
+}
+
+public delegate bool ValueEqualFunc(Value a, Value b);
+
+public bool bool_value_equals(Value a, Value b) {
+ return (bool) a == (bool) b;
+}
+
+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;
+}
+
+public inline time_t now_time_t() {
+ return (time_t) now_sec();
+}
+
+public string md5_binary(uint8 *buffer, size_t length) {
+ assert(length != 0);
+
+ Checksum md5 = new Checksum(ChecksumType.MD5);
+ md5.update((uchar []) buffer, length);
+
+ return md5.get_string();
+}
+
+public string md5_file(File file) throws Error {
+ Checksum md5 = new Checksum(ChecksumType.MD5);
+ uint8[] buffer = new uint8[64 * 1024];
+
+ FileInputStream fins = file.read(null);
+ for (;;) {
+ size_t bytes_read = fins.read(buffer, null);
+ if (bytes_read <= 0)
+ break;
+
+ md5.update((uchar[]) buffer, bytes_read);
+ }
+
+ try {
+ fins.close(null);
+ } catch (Error err) {
+ warning("Unable to close MD5 input stream for %s: %s", file.get_path(), err.message);
+ }
+
+ return md5.get_string();
+}
+
+// Once generic functions are available in Vala, this could be genericized.
+public bool equal_sets(Gee.Set<string>? a, Gee.Set<string>? b) {
+ if ((a != null && a.size == 0) && (b == null))
+ return true;
+
+ if ((a == null) && (b != null && b.size == 0))
+ return true;
+
+ if ((a == null && b != null) || (a != null && b == null))
+ return false;
+
+ if (a == null && b == null)
+ return true;
+
+ if (a.size != b.size)
+ return false;
+
+ // because they're sets and the same size, only need to iterate over one set to know
+ // it is equal to the other
+ foreach (string element in a) {
+ if (!b.contains(element))
+ return false;
+ }
+
+ return true;
+}
+
+// Once generic functions are available in Vala, this could be genericized.
+public Gee.Set<string>? intersection_of_sets(Gee.Set<string>? a, Gee.Set<string>? b,
+ Gee.Set<string>? excluded) {
+ if (a != null && b == null) {
+ if (excluded != null)
+ excluded.add_all(a);
+
+ return null;
+ }
+
+ if (a == null && b != null) {
+ if (excluded != null)
+ excluded.add_all(b);
+
+ return null;
+ }
+
+ Gee.Set<string> intersection = new Gee.HashSet<string>();
+
+ foreach (string element in a) {
+ if (b.contains(element))
+ intersection.add(element);
+ else if (excluded != null)
+ excluded.add(element);
+ }
+
+ foreach (string element in b) {
+ if (a.contains(element))
+ intersection.add(element);
+ else if (excluded != null)
+ excluded.add(element);
+ }
+
+ return intersection.size > 0 ? intersection : null;
+}
+
+public uchar[] serialize_photo_ids(Gee.Collection<Photo> photos) {
+ int64[] ids = new int64[photos.size];
+ int ctr = 0;
+ foreach (Photo photo in photos)
+ ids[ctr++] = photo.get_photo_id().id;
+
+ size_t bytes = photos.size * sizeof(int64);
+ uchar[] serialized = new uchar[bytes];
+ Memory.copy(serialized, ids, bytes);
+
+ return serialized;
+}
+
+public Gee.List<PhotoID?>? unserialize_photo_ids(uchar* serialized, int size) {
+ size_t count = (size / sizeof(int64));
+ if (count <= 0 || serialized == null)
+ return null;
+
+ int64[] ids = new int64[count];
+ Memory.copy(ids, serialized, size);
+
+ Gee.ArrayList<PhotoID?> list = new Gee.ArrayList<PhotoID?>();
+ foreach (int64 id in ids)
+ list.add(PhotoID(id));
+
+ return list;
+}
+
+public uchar[] serialize_media_sources(Gee.Collection<MediaSource> media) {
+ Gdk.Atom[] atoms = new Gdk.Atom[media.size];
+ int ctr = 0;
+ foreach (MediaSource current_media in media)
+ atoms[ctr++] = Gdk.Atom.intern(current_media.get_source_id(), false);
+
+ size_t bytes = media.size * sizeof(Gdk.Atom);
+ uchar[] serialized = new uchar[bytes];
+ Memory.copy(serialized, atoms, bytes);
+
+ return serialized;
+}
+
+public Gee.List<MediaSource>? unserialize_media_sources(uchar* serialized, int size) {
+ size_t count = (size / sizeof(Gdk.Atom));
+ if (count <= 0 || serialized == null)
+ return null;
+
+ Gdk.Atom[] atoms = new Gdk.Atom[count];
+ Memory.copy(atoms, serialized, size);
+
+ Gee.ArrayList<MediaSource> list = new Gee.ArrayList<MediaSource>();
+ foreach (Gdk.Atom current_atom in atoms) {
+ MediaSource media = MediaCollectionRegistry.get_instance().fetch_media(current_atom.name());
+ assert(media != null);
+ list.add(media);
+ }
+
+ return list;
+}
+
+public string format_local_datespan(Time from_date, Time 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) {
+ // are these consecutive dates?
+ if ((from_date.month == to_date.month) && (from_date.day == (to_date.day - 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();
+ } else {
+ // No, but they're in the same year; display in shortened
+ // form: Sat, July 4 - Mon, July 6, 20X6
+ from_format = Resources.get_start_multimonth_span_format_string();
+ to_format = Resources.get_end_multimonth_span_format_string();
+ }
+ } else {
+ // Span crosses a year boundary, use long form dates
+ // for both start and end date.
+ from_format = Resources.get_long_date_format_string();
+ to_format = Resources.get_long_date_format_string();
+ }
+
+ return String.strip_leading_zeroes("%s - %s".printf(from_date.format(from_format),
+ to_date.format(to_format)));
+}
+
+public string format_local_date(Time date) {
+ return String.strip_leading_zeroes(date.format(Resources.get_long_date_format_string()));
+}
+
+public delegate void OneShotCallback();
+
+public class OneShotScheduler {
+ private string name;
+ private unowned OneShotCallback callback;
+ private uint scheduled = 0;
+
+ public OneShotScheduler(string name, OneShotCallback callback) {
+ this.name = name;
+ this.callback = callback;
+ }
+
+ ~OneShotScheduler() {
+#if TRACE_DTORS
+ debug("DTOR: OneShotScheduler for %s", name);
+#endif
+
+ cancel();
+ }
+
+ public bool is_scheduled() {
+ return scheduled != 0;
+ }
+
+ public void at_idle() {
+ at_priority_idle(Priority.DEFAULT_IDLE);
+ }
+
+ public void at_priority_idle(int priority) {
+ if (scheduled == 0)
+ scheduled = Idle.add_full(priority, callback_wrapper);
+ }
+
+ public void after_timeout(uint msec, bool reschedule) {
+ priority_after_timeout(Priority.DEFAULT, msec, reschedule);
+ }
+
+ public void priority_after_timeout(int priority, uint msec, bool reschedule) {
+ if (scheduled != 0 && !reschedule)
+ return;
+
+ if (scheduled != 0)
+ Source.remove(scheduled);
+
+ scheduled = Timeout.add_full(priority, msec, callback_wrapper);
+ }
+
+ public void cancel() {
+ if (scheduled == 0)
+ return;
+
+ Source.remove(scheduled);
+ scheduled = 0;
+ }
+
+ private bool callback_wrapper() {
+ scheduled = 0;
+ callback();
+
+ return false;
+ }
+}
+
+public class OpTimer {
+ private string name;
+ private Timer timer = new Timer();
+ private long count = 0;
+ private double elapsed = 0;
+ private double shortest = double.MAX;
+ private double longest = double.MIN;
+
+ public OpTimer(string name) {
+ this.name = name;
+ }
+
+ public void start() {
+ timer.start();
+ }
+
+ public void stop() {
+ double time = timer.elapsed();
+
+ elapsed += time;
+
+ if (time < shortest)
+ shortest = time;
+
+ if (time > longest)
+ longest = time;
+
+ count++;
+ }
+
+ public string to_string() {
+ if (count > 0) {
+ return "%s: count=%ld elapsed=%.03lfs min/avg/max=%.03lf/%.03lf/%.03lf".printf(name,
+ count, elapsed, shortest, elapsed / (double) count, longest);
+ } else {
+ return "%s: no operations".printf(name);
+ }
+ }
+}
+
+// Dummy function for suppressing 'could not stat file' errors
+// generated when saving into a previously non-existent file -
+// please see https://bugzilla.gnome.org/show_bug.cgi?id=662814
+// and to work around a spurious warning given by GDK when a
+// key press event is passed from a child class' event handler
+// to a parent's; (gnome bug pending, but see https://bugzilla.redhat.com/show_bug.cgi?id=665568).
+public void suppress_warnings(string? log_domain, LogLevelFlags log_levels, string message) {
+ // do nothing.
+}
+
+public bool is_twentyfour_hr_time_system() {
+ // if no AM/PM designation is found, the location is set to use a 24 hr time system
+ return is_string_empty(Time.local(0).format("%p"));
+}
+
diff --git a/src/util/mk/util.mk b/src/util/mk/util.mk
new file mode 100644
index 0000000..86a8bd5
--- /dev/null
+++ b/src/util/mk/util.mk
@@ -0,0 +1,34 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Util
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := util
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ file.vala \
+ image.vala \
+ misc.vala \
+ string.vala \
+ system.vala \
+ KeyValueMap.vala \
+ ui.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES :=
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+
diff --git a/src/util/string.vala b/src/util/string.vala
new file mode 100644
index 0000000..9fda007
--- /dev/null
+++ b/src/util/string.vala
@@ -0,0 +1,268 @@
+/* Copyright 2010-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+extern int64 g_ascii_strtoll(string str, out char *endptr, uint num_base);
+
+public const int DEFAULT_USER_TEXT_INPUT_LENGTH = 1024;
+
+public inline bool is_string_empty(string? s) {
+ return (s == null || s[0] == '\0');
+}
+
+// utf8 case sensitive compare
+public int utf8_cs_compare(void *a, void *b) {
+ return ((string) a).collate((string) b);
+}
+
+// utf8 case insensitive compare
+public int utf8_ci_compare(void *a, void *b) {
+ return ((string) a).down().collate(((string) b).down());
+}
+
+// utf8 array to string
+public string uchar_array_to_string(uchar[] data, int length = -1) {
+ if (length < 0)
+ length = data.length;
+
+ StringBuilder builder = new StringBuilder();
+ for (int ctr = 0; ctr < length; ctr++) {
+ if (data[ctr] != '\0')
+ builder.append_c((char) data[ctr]);
+ else
+ break;
+ }
+
+ return builder.str;
+}
+
+// string to uchar array
+public uchar[] string_to_uchar_array(string str) {
+ uchar[] data = new uchar[0];
+ for (int ctr = 0; ctr < str.length; ctr++)
+ data += (uchar) str[ctr];
+
+ return data;
+}
+
+// Markup.escape_text() will crash if the UTF-8 text is not valid; it relies on a call to
+// g_utf8_next_char(), which demands that the string be validated before use, which escape_text()
+// does not do. This handles this problem by kicking back an empty string if the text is not
+// valid. Text should be validated upon entry to the system as well to guard against this
+// problem.
+//
+// Null strings are accepted; they will result in an empty string returned.
+public inline string guarded_markup_escape_text(string? plain) {
+ return (!is_string_empty(plain) && plain.validate()) ? Markup.escape_text(plain) : "";
+}
+
+public long find_last_offset(string str, char c) {
+ long offset = str.length;
+ while (--offset >= 0) {
+ if (str[offset] == c)
+ return offset;
+ }
+
+ return -1;
+}
+
+// Helper function for searching an array of case-insensitive strings. The array should be
+// all lowercase.
+public bool is_in_ci_array(string str, string[] strings) {
+ string strdown = str.down();
+ foreach (string str_element in strings) {
+ if (strdown == str_element)
+ return true;
+ }
+
+ return false;
+}
+
+[Flags]
+public enum PrepareInputTextOptions {
+ EMPTY_IS_NULL,
+ VALIDATE,
+ INVALID_IS_NULL,
+ STRIP,
+ STRIP_CRLF,
+ NORMALIZE,
+ DEFAULT = EMPTY_IS_NULL | VALIDATE | INVALID_IS_NULL | STRIP_CRLF | STRIP | NORMALIZE;
+}
+
+public string? prepare_input_text(string? text, PrepareInputTextOptions options, int dest_length) {
+ if (text == null)
+ return null;
+
+ if ((options & PrepareInputTextOptions.VALIDATE) != 0 && !text.validate())
+ return (options & PrepareInputTextOptions.INVALID_IS_NULL) != 0 ? null : "";
+
+ string prepped = text;
+
+ // 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
+ if ((options & PrepareInputTextOptions.NORMALIZE) != 0)
+ prepped = prepped.normalize(-1, NormalizeMode.NFC);
+
+ if ((options & PrepareInputTextOptions.STRIP) != 0)
+ prepped = prepped.strip();
+
+ // Ticket #3245 - Prevent carriage return mayhem
+ // in image titles, tag names, etc.
+ if ((options & PrepareInputTextOptions.STRIP_CRLF) != 0)
+ prepped = prepped.delimit("\n\r", ' ');
+
+ if ((options & PrepareInputTextOptions.EMPTY_IS_NULL) != 0 && is_string_empty(prepped))
+ return null;
+
+ // Ticket #3196 - Allow calling functions to limit the length of the
+ // string we return to them. Passing any negative value is interpreted
+ // as 'do not truncate'.
+ if (dest_length >= 0) {
+ StringBuilder sb = new StringBuilder(prepped);
+ sb.truncate(dest_length);
+ return sb.str;
+ }
+
+ // otherwise, return normally.
+ return prepped;
+}
+
+public int64 parse_int64(string str, int num_base) {
+ return g_ascii_strtoll(str, null, num_base);
+}
+
+namespace String {
+
+public inline bool contains_char(string haystack, unichar needle) {
+ return haystack.index_of_char(needle) >= 0;
+}
+
+public inline bool contains_str(string haystack, string needle) {
+ return haystack.index_of(needle) >= 0;
+}
+
+public inline string? sliced_at(string str, int index) {
+ return (index >= 0) ? str[index:str.length] : null;
+}
+
+public inline string? sliced_at_first_str(string haystack, string needle, int start_index = 0) {
+ return sliced_at(haystack, haystack.index_of(needle, start_index));
+}
+
+public inline string? sliced_at_last_str(string haystack, string needle, int start_index = 0) {
+ return sliced_at(haystack, haystack.last_index_of(needle, start_index));
+}
+
+public inline string? sliced_at_first_char(string haystack, unichar ch, int start_index = 0) {
+ return sliced_at(haystack, haystack.index_of_char(ch, start_index));
+}
+
+public inline string? sliced_at_last_char(string haystack, unichar ch, int start_index = 0) {
+ return sliced_at(haystack, haystack.last_index_of_char(ch, start_index));
+}
+
+// Note that this method currently turns a word of all zeros into empty space ("000" -> "")
+public string strip_leading_zeroes(string str) {
+ StringBuilder stripped = new StringBuilder();
+ bool prev_is_space = true;
+ for (unowned string iter = str; iter.get_char() != 0; iter = iter.next_char()) {
+ unichar ch = iter.get_char();
+
+ if (!prev_is_space || ch != '0') {
+ stripped.append_unichar(ch);
+ prev_is_space = ch.isspace();
+ }
+ }
+
+ return stripped.str;
+}
+
+public string remove_diacritics(string istring) {
+ var builder = new StringBuilder ();
+ unichar ch;
+ int i = 0;
+ while(istring.normalize().get_next_char(ref i, out ch)) {
+ switch(ch.type()) {
+ case UnicodeType.CONTROL:
+ case UnicodeType.FORMAT:
+ case UnicodeType.UNASSIGNED:
+ case UnicodeType.NON_SPACING_MARK:
+ case UnicodeType.COMBINING_MARK:
+ case UnicodeType.ENCLOSING_MARK:
+ // Ignore those
+ continue;
+ }
+ builder.append_unichar(ch);
+ }
+ return builder.str;
+}
+
+public string to_hex_string(string str) {
+ StringBuilder builder = new StringBuilder();
+
+ uint8 *data = (uint8 *) str;
+ while (*data != 0)
+ builder.append_printf("%02Xh%s", *data++, (*data != 0) ? " " : "");
+
+ return builder.str;
+}
+
+// A note on the collated_* and precollated_* methods:
+//
+// A bug report (http://trac.yorba.org/ticket/3152) 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
+// collation, meaning that in one table the strings were seen as the same and in another as
+// different.
+//
+// The solution we arrived at is to use collation whenever possible, but if two strings have the
+// same collation, then fall back on strcmp(), which looks for byte-for-byte comparisons. Note
+// that this technique requires that both strings have been properly composed (use
+// prepare_input_text() for that task) so that equal UTF-8 strings are byte-for-byte equal as
+// well.
+
+// See note above.
+public uint collated_hash(void *ptr) {
+ string str = (string) ptr;
+
+ return str_hash(str.collate_key());
+}
+
+// See note above.
+public uint precollated_hash(void *ptr) {
+ return str_hash((string) ptr);
+}
+
+// See note above.
+public int collated_compare(void *a, void *b) {
+ string astr = (string) a;
+ string bstr = (string) b;
+
+ int result = astr.collate(bstr);
+
+ return (result != 0) ? result : strcmp(astr, bstr);
+}
+
+// See note above.
+public int precollated_compare(string astr, string akey, string bstr, string bkey) {
+ int result = strcmp(akey, bkey);
+
+ return (result != 0) ? result : strcmp(astr, bstr);
+}
+
+// See note above.
+public bool collated_equals(void *a, void *b) {
+ return collated_compare(a, b) == 0;
+}
+
+// See note above.
+public bool precollated_equals(string astr, string akey, string bstr, string bkey) {
+ return precollated_compare(astr, akey, bstr, bkey) == 0;
+}
+
+}
diff --git a/src/util/system.vala b/src/util/system.vala
new file mode 100644
index 0000000..9407405
--- /dev/null
+++ b/src/util/system.vala
@@ -0,0 +1,40 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+int number_of_processors() {
+ int n = (int) ExtendedPosix.sysconf(ExtendedPosix.ConfName._SC_NPROCESSORS_ONLN);
+ return n <= 0 ? 1 : n;
+}
+
+// Return the directory in which Shotwell is installed, or null if uninstalled.
+File? get_sys_install_dir(File exec_dir) {
+ // guard against exec_dir being a symlink
+ File exec_dir1 = exec_dir;
+ try {
+ exec_dir1 = File.new_for_path(
+ FileUtils.read_link("/" + FileUtils.read_link(exec_dir.get_path())));
+ } catch (FileError e) {
+ // exec_dir is not a symlink
+ }
+ File prefix_dir = File.new_for_path(Resources.PREFIX);
+ return exec_dir1.has_prefix(prefix_dir) ? prefix_dir : null;
+}
+
+string get_nautilus_install_location() {
+ return Environment.find_program_in_path("nautilus");
+}
+
+void sys_show_uri(Gdk.Screen screen, string uri) throws Error {
+ Gtk.show_uri(screen, uri, Gdk.CURRENT_TIME);
+}
+
+void show_file_in_nautilus(string filename) throws Error {
+ GLib.Process.spawn_command_line_async(get_nautilus_install_location() + " " + filename);
+}
+
+int posix_wexitstatus(int status) {
+ return (((status) & 0xff00) >> 8);
+}
diff --git a/src/util/ui.vala b/src/util/ui.vala
new file mode 100644
index 0000000..9f66de8
--- /dev/null
+++ b/src/util/ui.vala
@@ -0,0 +1,98 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public enum AdjustmentRelation {
+ BELOW,
+ IN_RANGE,
+ ABOVE
+}
+
+public enum CompassPoint {
+ NORTH,
+ SOUTH,
+ EAST,
+ WEST
+}
+
+public enum Direction {
+ FORWARD,
+ BACKWARD;
+
+ public Spit.Transitions.Direction to_transition_direction() {
+ switch (this) {
+ case FORWARD:
+ return Spit.Transitions.Direction.FORWARD;
+
+ case BACKWARD:
+ return Spit.Transitions.Direction.BACKWARD;
+
+ default:
+ error("Unknown Direction %s", this.to_string());
+ }
+ }
+}
+
+public void spin_event_loop() {
+ while (Gtk.events_pending())
+ Gtk.main_iteration();
+}
+
+public AdjustmentRelation get_adjustment_relation(Gtk.Adjustment adjustment, int value) {
+ if (value < (int) adjustment.get_value())
+ return AdjustmentRelation.BELOW;
+ else if (value > (int) (adjustment.get_value() + adjustment.get_page_size()))
+ return AdjustmentRelation.ABOVE;
+ else
+ return AdjustmentRelation.IN_RANGE;
+}
+
+public Gdk.Rectangle get_adjustment_page(Gtk.Adjustment hadj, Gtk.Adjustment vadj) {
+ Gdk.Rectangle rect = Gdk.Rectangle();
+ rect.x = (int) hadj.get_value();
+ rect.y = (int) vadj.get_value();
+ rect.width = (int) hadj.get_page_size();
+ rect.height = (int) vadj.get_page_size();
+
+ return rect;
+}
+
+// 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
+// one or more bits set, but should only consist of these values:
+// * Gdk.ModifierType.SHIFT_MASK
+// * Gdk.ModifierType.CONTROL_MASK
+// * Gdk.ModifierType.MOD1_MASK (Alt)
+// * Gdk.ModifierType.MOD3_MASK
+// * Gdk.ModifierType.MOD4_MASK
+// * Gdk.ModifierType.MOD5_MASK
+// * Gdk.ModifierType.SUPER_MASK
+// * Gdk.ModifierType.HYPER_MASK
+// * Gdk.ModifierType.META_MASK
+//
+// (Note: MOD2 seems to be Num Lock in GDK.)
+public bool has_only_key_modifier(Gdk.ModifierType field, Gdk.ModifierType mask) {
+ return (field
+ & (Gdk.ModifierType.SHIFT_MASK
+ | Gdk.ModifierType.CONTROL_MASK
+ | Gdk.ModifierType.MOD1_MASK
+ | Gdk.ModifierType.MOD3_MASK
+ | Gdk.ModifierType.MOD4_MASK
+ | Gdk.ModifierType.MOD5_MASK
+ | Gdk.ModifierType.SUPER_MASK
+ | Gdk.ModifierType.HYPER_MASK
+ | Gdk.ModifierType.META_MASK)) == mask;
+}
+
+public string build_dummy_ui_string(Gtk.ActionGroup[] groups) {
+ string ui_string = "<ui>";
+ foreach (Gtk.ActionGroup group in groups) {
+ foreach (Gtk.Action action in group.list_actions())
+ ui_string += "<accelerator name=\"%s\" action=\"%s\" />".printf(action.name, action.name);
+ }
+ ui_string += "</ui>";
+
+ return ui_string;
+}