From 4ea2cc3bd4a7d9b1c54a9d33e6a1cf82e7c8c21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Frings-F=C3=BCrst?= Date: Wed, 23 Jul 2014 09:06:59 +0200 Subject: Imported Upstream version 0.18.1 --- src/AppDirs.vala | 285 ++ src/AppWindow.vala | 961 +++++ src/Application.vala | 228 ++ src/BatchImport.vala | 2051 ++++++++++ src/Box.vala | 403 ++ src/CheckerboardLayout.vala | 1872 ++++++++++ src/CollectionPage.vala | 765 ++++ src/ColorTransformation.vala | 1519 ++++++++ src/CommandManager.vala | 201 + src/Commands.vala | 2497 +++++++++++++ src/CustomComponents.vala | 513 +++ src/Debug.vala | 146 + src/DesktopIntegration.vala | 308 ++ src/Dialogs.vala | 2714 ++++++++++++++ src/Dimensions.vala | 732 ++++ src/DirectoryMonitor.vala | 1454 ++++++++ src/Event.vala | 924 +++++ src/Exporter.vala | 343 ++ src/International.vala | 32 + src/LibraryFiles.vala | 104 + src/LibraryMonitor.vala | 1013 +++++ src/MediaDataRepresentation.vala | 899 +++++ src/MediaInterfaces.vala | 215 ++ src/MediaMetadata.vala | 128 + src/MediaMonitor.vala | 418 +++ src/MediaPage.vala | 1308 +++++++ src/MediaViewTracker.vala | 114 + src/MetadataWriter.vala | 675 ++++ src/Orientation.vala | 493 +++ src/Page.vala | 2589 +++++++++++++ src/Photo.vala | 5381 +++++++++++++++++++++++++++ src/PhotoMonitor.vala | 1156 ++++++ src/PhotoPage.vala | 3361 +++++++++++++++++ src/PixbufCache.vala | 360 ++ src/Printing.vala | 1156 ++++++ src/Properties.vala | 700 ++++ src/Resources.vala | 1143 ++++++ src/Screensaver.vala | 29 + src/SearchFilter.vala | 1252 +++++++ src/SlideshowPage.vala | 466 +++ src/SortedList.vala | 429 +++ src/Tag.vala | 1189 ++++++ src/Thumbnail.vala | 400 ++ src/ThumbnailCache.vala | 619 +++ src/TimedQueue.vala | 284 ++ src/Tombstone.vala | 336 ++ src/UnityProgressBar.vala | 83 + src/Upgrades.vala | 115 + src/VideoMetadata.vala | 655 ++++ src/VideoMonitor.vala | 301 ++ src/VideoSupport.vala | 1188 ++++++ src/camera/Branch.vala | 116 + src/camera/Camera.vala | 18 + src/camera/CameraTable.vala | 417 +++ src/camera/GPhoto.vala | 367 ++ src/camera/ImportPage.vala | 1799 +++++++++ src/camera/mk/camera.mk | 32 + src/config/Config.vala | 155 + src/config/ConfigurationInterfaces.vala | 1609 ++++++++ src/config/GSettingsEngine.vala | 469 +++ src/config/mk/config.mk | 29 + src/core/Alteration.vala | 316 ++ src/core/ContainerSourceCollection.vala | 237 ++ src/core/Core.vala | 29 + src/core/DataCollection.vala | 623 ++++ src/core/DataObject.vala | 137 + src/core/DataSet.vala | 183 + src/core/DataSource.vala | 679 ++++ src/core/DataSourceTypes.vala | 108 + src/core/DataView.vala | 132 + src/core/DataViewTypes.vala | 50 + src/core/DatabaseSourceCollection.vala | 86 + src/core/SourceCollection.vala | 221 ++ src/core/SourceHoldingTank.vala | 209 ++ src/core/SourceInterfaces.vala | 44 + src/core/Tracker.vala | 216 ++ src/core/ViewCollection.vala | 1287 +++++++ src/core/mk/core.mk | 43 + src/core/util.vala | 196 + src/data_imports/DataImportJob.vala | 177 + src/data_imports/DataImportSource.vala | 135 + src/data_imports/DataImports.vala | 30 + src/data_imports/DataImportsPluginHost.vala | 482 +++ src/data_imports/DataImportsUI.vala | 445 +++ src/data_imports/mk/data_imports.mk | 31 + src/db/DatabaseTable.vala | 384 ++ src/db/Db.vala | 366 ++ src/db/EventTable.vala | 235 ++ src/db/PhotoTable.vala | 1245 +++++++ src/db/SavedSearchDBTable.vala | 641 ++++ src/db/TagTable.vala | 248 ++ src/db/TombstoneTable.vala | 146 + src/db/VersionTable.vala | 98 + src/db/VideoTable.vala | 462 +++ src/db/mk/db.mk | 35 + src/direct/Direct.vala | 35 + src/direct/DirectPhoto.vala | 316 ++ src/direct/DirectPhotoPage.vala | 580 +++ src/direct/DirectView.vala | 50 + src/direct/DirectWindow.vala | 98 + src/direct/mk/direct.mk | 36 + src/editing_tools/EditingTools.vala | 2934 +++++++++++++++ src/editing_tools/StraightenTool.vala | 559 +++ src/editing_tools/mk/editing_tools.mk | 28 + src/events/Branch.vala | 542 +++ src/events/EventDirectoryItem.vala | 189 + src/events/EventPage.vala | 162 + src/events/Events.vala | 18 + src/events/EventsDirectoryPage.vala | 313 ++ src/events/mk/events.mk | 32 + src/folders/Branch.vala | 198 + src/folders/Folders.vala | 35 + src/folders/Page.vala | 41 + src/folders/mk/folders.mk | 31 + src/library/Branch.vala | 54 + src/library/FlaggedBranch.vala | 61 + src/library/FlaggedPage.vala | 54 + src/library/ImportQueueBranch.vala | 74 + src/library/ImportQueuePage.vala | 208 ++ src/library/LastImportBranch.vala | 47 + src/library/LastImportPage.vala | 79 + src/library/Library.vala | 19 + src/library/LibraryWindow.vala | 1587 ++++++++ src/library/OfflineBranch.vala | 51 + src/library/OfflinePage.vala | 130 + src/library/TrashBranch.vala | 73 + src/library/TrashPage.vala | 120 + src/library/mk/library.mk | 54 + src/main.vala | 441 +++ src/photos/BmpSupport.vala | 184 + src/photos/GRaw.vala | 307 ++ src/photos/GdkSupport.vala | 129 + src/photos/JfifSupport.vala | 236 ++ src/photos/PhotoFileAdapter.vala | 112 + src/photos/PhotoFileFormat.vala | 410 ++ src/photos/PhotoFileSniffer.vala | 90 + src/photos/PhotoMetadata.vala | 1169 ++++++ src/photos/Photos.vala | 31 + src/photos/PngSupport.vala | 181 + src/photos/RawSupport.vala | 347 ++ src/photos/TiffSupport.vala | 180 + src/photos/mk/photos.mk | 38 + src/plugins/DataImportsInterfaces.vala | 489 +++ src/plugins/ManifestWidget.vala | 282 ++ src/plugins/Plugins.vala | 436 +++ src/plugins/PublishingInterfaces.vala | 605 +++ src/plugins/SpitInterfaces.vala | 367 ++ src/plugins/StandardHostInterface.vala | 84 + src/plugins/TransitionsInterfaces.vala | 300 ++ src/plugins/mk/interfaces.mk | 29 + src/plugins/mk/plugins.mk | 35 + src/publishing/APIGlue.vala | 133 + src/publishing/Publishing.vala | 24 + src/publishing/PublishingPluginHost.vala | 238 ++ src/publishing/PublishingUI.vala | 552 +++ src/publishing/mk/publishing.mk | 31 + src/searches/Branch.vala | 150 + src/searches/SavedSearchDialog.vala | 829 +++++ src/searches/SavedSearchPage.vala | 92 + src/searches/SearchBoolean.vala | 971 +++++ src/searches/Searches.vala | 31 + src/searches/mk/searches.mk | 31 + src/sidebar/Branch.vala | 450 +++ src/sidebar/Entry.vala | 70 + src/sidebar/Sidebar.vala | 16 + src/sidebar/Tree.vala | 1175 ++++++ src/sidebar/common.vala | 114 + src/sidebar/mk/sidebar.mk | 31 + src/slideshow/Slideshow.vala | 32 + src/slideshow/TransitionEffects.vala | 351 ++ src/slideshow/mk/slideshow.mk | 29 + src/tags/Branch.vala | 310 ++ src/tags/HierarchicalTagIndex.vala | 90 + src/tags/HierarchicalTagUtilities.vala | 184 + src/tags/TagPage.vala | 125 + src/tags/Tags.vala | 18 + src/tags/mk/tags.mk | 32 + src/threads/BackgroundJob.vala | 243 ++ src/threads/Semaphore.vala | 160 + src/threads/Threads.vala | 14 + src/threads/Workers.vala | 104 + src/threads/mk/threads.mk | 30 + src/unit/Unit.vala | 14 + src/unit/mk/unit.mk | 32 + src/unit/rc/Unit.m4 | 29 + src/unit/rc/UnitInternals.m4 | 32 + src/unit/rc/template.mk | 27 + src/unit/rc/template.vala | 7 + src/unit/rc/unitize_entry.m4 | 19 + src/util/KeyValueMap.vala | 118 + src/util/Util.vala | 17 + src/util/file.vala | 241 ++ src/util/image.vala | 364 ++ src/util/misc.vala | 377 ++ src/util/mk/util.mk | 34 + src/util/string.vala | 268 ++ src/util/system.vala | 40 + src/util/ui.vala | 98 + 198 files changed, 85141 insertions(+) create mode 100644 src/AppDirs.vala create mode 100644 src/AppWindow.vala create mode 100644 src/Application.vala create mode 100644 src/BatchImport.vala create mode 100644 src/Box.vala create mode 100644 src/CheckerboardLayout.vala create mode 100644 src/CollectionPage.vala create mode 100644 src/ColorTransformation.vala create mode 100644 src/CommandManager.vala create mode 100644 src/Commands.vala create mode 100644 src/CustomComponents.vala create mode 100644 src/Debug.vala create mode 100644 src/DesktopIntegration.vala create mode 100644 src/Dialogs.vala create mode 100644 src/Dimensions.vala create mode 100644 src/DirectoryMonitor.vala create mode 100644 src/Event.vala create mode 100644 src/Exporter.vala create mode 100644 src/International.vala create mode 100644 src/LibraryFiles.vala create mode 100644 src/LibraryMonitor.vala create mode 100644 src/MediaDataRepresentation.vala create mode 100644 src/MediaInterfaces.vala create mode 100644 src/MediaMetadata.vala create mode 100644 src/MediaMonitor.vala create mode 100644 src/MediaPage.vala create mode 100644 src/MediaViewTracker.vala create mode 100644 src/MetadataWriter.vala create mode 100644 src/Orientation.vala create mode 100644 src/Page.vala create mode 100644 src/Photo.vala create mode 100644 src/PhotoMonitor.vala create mode 100644 src/PhotoPage.vala create mode 100644 src/PixbufCache.vala create mode 100644 src/Printing.vala create mode 100644 src/Properties.vala create mode 100644 src/Resources.vala create mode 100644 src/Screensaver.vala create mode 100644 src/SearchFilter.vala create mode 100644 src/SlideshowPage.vala create mode 100644 src/SortedList.vala create mode 100644 src/Tag.vala create mode 100644 src/Thumbnail.vala create mode 100644 src/ThumbnailCache.vala create mode 100644 src/TimedQueue.vala create mode 100644 src/Tombstone.vala create mode 100644 src/UnityProgressBar.vala create mode 100644 src/Upgrades.vala create mode 100644 src/VideoMetadata.vala create mode 100644 src/VideoMonitor.vala create mode 100644 src/VideoSupport.vala create mode 100644 src/camera/Branch.vala create mode 100644 src/camera/Camera.vala create mode 100644 src/camera/CameraTable.vala create mode 100644 src/camera/GPhoto.vala create mode 100644 src/camera/ImportPage.vala create mode 100644 src/camera/mk/camera.mk create mode 100644 src/config/Config.vala create mode 100644 src/config/ConfigurationInterfaces.vala create mode 100644 src/config/GSettingsEngine.vala create mode 100644 src/config/mk/config.mk create mode 100644 src/core/Alteration.vala create mode 100644 src/core/ContainerSourceCollection.vala create mode 100644 src/core/Core.vala create mode 100644 src/core/DataCollection.vala create mode 100644 src/core/DataObject.vala create mode 100644 src/core/DataSet.vala create mode 100644 src/core/DataSource.vala create mode 100644 src/core/DataSourceTypes.vala create mode 100644 src/core/DataView.vala create mode 100644 src/core/DataViewTypes.vala create mode 100644 src/core/DatabaseSourceCollection.vala create mode 100644 src/core/SourceCollection.vala create mode 100644 src/core/SourceHoldingTank.vala create mode 100644 src/core/SourceInterfaces.vala create mode 100644 src/core/Tracker.vala create mode 100644 src/core/ViewCollection.vala create mode 100644 src/core/mk/core.mk create mode 100644 src/core/util.vala create mode 100644 src/data_imports/DataImportJob.vala create mode 100644 src/data_imports/DataImportSource.vala create mode 100644 src/data_imports/DataImports.vala create mode 100644 src/data_imports/DataImportsPluginHost.vala create mode 100644 src/data_imports/DataImportsUI.vala create mode 100644 src/data_imports/mk/data_imports.mk create mode 100644 src/db/DatabaseTable.vala create mode 100644 src/db/Db.vala create mode 100644 src/db/EventTable.vala create mode 100644 src/db/PhotoTable.vala create mode 100644 src/db/SavedSearchDBTable.vala create mode 100644 src/db/TagTable.vala create mode 100644 src/db/TombstoneTable.vala create mode 100644 src/db/VersionTable.vala create mode 100644 src/db/VideoTable.vala create mode 100644 src/db/mk/db.mk create mode 100644 src/direct/Direct.vala create mode 100644 src/direct/DirectPhoto.vala create mode 100644 src/direct/DirectPhotoPage.vala create mode 100644 src/direct/DirectView.vala create mode 100644 src/direct/DirectWindow.vala create mode 100644 src/direct/mk/direct.mk create mode 100644 src/editing_tools/EditingTools.vala create mode 100644 src/editing_tools/StraightenTool.vala create mode 100644 src/editing_tools/mk/editing_tools.mk create mode 100644 src/events/Branch.vala create mode 100644 src/events/EventDirectoryItem.vala create mode 100644 src/events/EventPage.vala create mode 100644 src/events/Events.vala create mode 100644 src/events/EventsDirectoryPage.vala create mode 100644 src/events/mk/events.mk create mode 100644 src/folders/Branch.vala create mode 100644 src/folders/Folders.vala create mode 100644 src/folders/Page.vala create mode 100644 src/folders/mk/folders.mk create mode 100644 src/library/Branch.vala create mode 100644 src/library/FlaggedBranch.vala create mode 100644 src/library/FlaggedPage.vala create mode 100644 src/library/ImportQueueBranch.vala create mode 100644 src/library/ImportQueuePage.vala create mode 100644 src/library/LastImportBranch.vala create mode 100644 src/library/LastImportPage.vala create mode 100644 src/library/Library.vala create mode 100644 src/library/LibraryWindow.vala create mode 100644 src/library/OfflineBranch.vala create mode 100644 src/library/OfflinePage.vala create mode 100644 src/library/TrashBranch.vala create mode 100644 src/library/TrashPage.vala create mode 100644 src/library/mk/library.mk create mode 100644 src/main.vala create mode 100644 src/photos/BmpSupport.vala create mode 100644 src/photos/GRaw.vala create mode 100644 src/photos/GdkSupport.vala create mode 100644 src/photos/JfifSupport.vala create mode 100644 src/photos/PhotoFileAdapter.vala create mode 100644 src/photos/PhotoFileFormat.vala create mode 100644 src/photos/PhotoFileSniffer.vala create mode 100644 src/photos/PhotoMetadata.vala create mode 100644 src/photos/Photos.vala create mode 100644 src/photos/PngSupport.vala create mode 100644 src/photos/RawSupport.vala create mode 100644 src/photos/TiffSupport.vala create mode 100644 src/photos/mk/photos.mk create mode 100644 src/plugins/DataImportsInterfaces.vala create mode 100644 src/plugins/ManifestWidget.vala create mode 100644 src/plugins/Plugins.vala create mode 100644 src/plugins/PublishingInterfaces.vala create mode 100644 src/plugins/SpitInterfaces.vala create mode 100644 src/plugins/StandardHostInterface.vala create mode 100644 src/plugins/TransitionsInterfaces.vala create mode 100644 src/plugins/mk/interfaces.mk create mode 100644 src/plugins/mk/plugins.mk create mode 100644 src/publishing/APIGlue.vala create mode 100644 src/publishing/Publishing.vala create mode 100644 src/publishing/PublishingPluginHost.vala create mode 100644 src/publishing/PublishingUI.vala create mode 100644 src/publishing/mk/publishing.mk create mode 100644 src/searches/Branch.vala create mode 100644 src/searches/SavedSearchDialog.vala create mode 100644 src/searches/SavedSearchPage.vala create mode 100644 src/searches/SearchBoolean.vala create mode 100644 src/searches/Searches.vala create mode 100644 src/searches/mk/searches.mk create mode 100644 src/sidebar/Branch.vala create mode 100644 src/sidebar/Entry.vala create mode 100644 src/sidebar/Sidebar.vala create mode 100644 src/sidebar/Tree.vala create mode 100644 src/sidebar/common.vala create mode 100644 src/sidebar/mk/sidebar.mk create mode 100644 src/slideshow/Slideshow.vala create mode 100644 src/slideshow/TransitionEffects.vala create mode 100644 src/slideshow/mk/slideshow.mk create mode 100644 src/tags/Branch.vala create mode 100644 src/tags/HierarchicalTagIndex.vala create mode 100644 src/tags/HierarchicalTagUtilities.vala create mode 100644 src/tags/TagPage.vala create mode 100644 src/tags/Tags.vala create mode 100644 src/tags/mk/tags.mk create mode 100644 src/threads/BackgroundJob.vala create mode 100644 src/threads/Semaphore.vala create mode 100644 src/threads/Threads.vala create mode 100644 src/threads/Workers.vala create mode 100644 src/threads/mk/threads.mk create mode 100644 src/unit/Unit.vala create mode 100644 src/unit/mk/unit.mk create mode 100644 src/unit/rc/Unit.m4 create mode 100644 src/unit/rc/UnitInternals.m4 create mode 100644 src/unit/rc/template.mk create mode 100644 src/unit/rc/template.vala create mode 100644 src/unit/rc/unitize_entry.m4 create mode 100644 src/util/KeyValueMap.vala create mode 100644 src/util/Util.vala create mode 100644 src/util/file.vala create mode 100644 src/util/image.vala create mode 100644 src/util/misc.vala create mode 100644 src/util/mk/util.mk create mode 100644 src/util/string.vala create mode 100644 src/util/system.vala create mode 100644 src/util/ui.vala (limited to 'src') 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 pixbuf_list = new GLib.List(); + 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, "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, "Z", + TRANSLATABLE, on_undo }; + undo.label = Resources.UNDO_MENU; + actions += undo; + + Gtk.ActionEntry redo = { "CommonRedo", Gtk.Stock.REDO, TRANSLATABLE, "Z", + TRANSLATABLE, on_redo }; + redo.label = Resources.REDO_MENU; + actions += redo; + + Gtk.ActionEntry jump_to_file = { "CommonJumpToFile", Gtk.Stock.JUMP_TO, TRANSLATABLE, + "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, + "A", TRANSLATABLE, on_select_all }; + select_all.label = Resources.SELECT_ALL_MENU; + actions += select_all; + + Gtk.ActionEntry select_none = { "CommonSelectNone", null, null, + "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 imported = new Gee.ArrayList(); + public Gee.List success = new Gee.ArrayList(); + public Gee.List camera_failed = new Gee.ArrayList(); + public Gee.List failed = new Gee.ArrayList(); + public Gee.List write_failed = new Gee.ArrayList(); + public Gee.List skipped_photos = new Gee.ArrayList(); + public Gee.List skipped_files = new Gee.ArrayList(); + public Gee.List aborted = new Gee.ArrayList(); + public Gee.List already_imported = new Gee.ArrayList(); + public Gee.List corrupt_files = new Gee.ArrayList(); + public Gee.List all = new Gee.ArrayList(); + + public ImportManifest(Gee.List? prefailed = null, + Gee.List? 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 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 skipset; +#if !NO_DUPE_DETECTION + private Gee.HashMap imported_full_md5_table = new Gee.HashMap(); +#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 ready_files = new Gee.LinkedList(); + private Gee.List ready_thumbnails = + new Gee.LinkedList(); + private Gee.List display_imported_queue = + new Gee.LinkedList(); + private Gee.List ready_sources = new Gee.LinkedList(); + + // 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 jobs, string name, ImportReporter? reporter, + Gee.ArrayList? prefailed = null, + Gee.ArrayList? 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_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 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 all = new Gee.ArrayList(); + Gee.ArrayList photos = new Gee.ArrayList(); + Gee.ArrayList