summaryrefslogtreecommitdiff
path: root/src/Photo.vala
diff options
context:
space:
mode:
Diffstat (limited to 'src/Photo.vala')
-rw-r--r--src/Photo.vala5481
1 files changed, 5481 insertions, 0 deletions
diff --git a/src/Photo.vala b/src/Photo.vala
new file mode 100644
index 0000000..98a3175
--- /dev/null
+++ b/src/Photo.vala
@@ -0,0 +1,5481 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+// Specifies how pixel data is fetched from the backing file on disk. MASTER is the original
+// backing photo of any supported photo file format; SOURCE is either the master or the editable
+// file, that is, the appropriate reference file for user display; BASELINE is an appropriate
+// file with the proviso that it may be a suitable substitute for the master and/or the editable.
+// UNMODIFIED represents the photo with no edits, i.e. the head of the pipeline.
+//
+// In general, callers want to use the BASELINE unless requirements are specific.
+public enum BackingFetchMode {
+ SOURCE,
+ BASELINE,
+ MASTER,
+ UNMODIFIED
+}
+
+public class PhotoImportParams {
+ // IN:
+ public File file;
+ public File final_associated_file = null;
+ public ImportID import_id;
+ public PhotoFileSniffer.Options sniffer_options;
+ public string? exif_md5;
+ public string? thumbnail_md5;
+ public string? full_md5;
+
+ // IN/OUT:
+ public Thumbnails? thumbnails;
+
+ // OUT:
+ public PhotoRow row = new PhotoRow();
+ public Gee.Collection<string>? keywords = null;
+
+ public PhotoImportParams(File file, File? final_associated_file, ImportID import_id,
+ PhotoFileSniffer.Options sniffer_options, string? exif_md5, string? thumbnail_md5, string? full_md5,
+ Thumbnails? thumbnails = null) {
+ this.file = file;
+ this.final_associated_file = final_associated_file;
+ this.import_id = import_id;
+ this.sniffer_options = sniffer_options;
+ this.exif_md5 = exif_md5;
+ this.thumbnail_md5 = thumbnail_md5;
+ this.full_md5 = full_md5;
+ this.thumbnails = thumbnails;
+ }
+
+ // Creates a placeholder import.
+ public PhotoImportParams.create_placeholder(File file, ImportID import_id) {
+ this.file = file;
+ this.import_id = import_id;
+ this.sniffer_options = PhotoFileSniffer.Options.NO_MD5;
+ this.exif_md5 = null;
+ this.thumbnail_md5 = null;
+ this.full_md5 = null;
+ this.thumbnails = null;
+ }
+}
+
+public abstract class PhotoTransformationState : Object {
+ private bool is_broke = false;
+
+ // This signal is fired when the Photo object can no longer accept it and reliably return to
+ // this state.
+ public virtual signal void broken() {
+ is_broke = true;
+ }
+
+ protected PhotoTransformationState() {
+ }
+
+ public bool is_broken() {
+ return is_broke;
+ }
+}
+
+public enum Rating {
+ REJECTED = -1,
+ UNRATED = 0,
+ ONE = 1,
+ TWO = 2,
+ THREE = 3,
+ FOUR = 4,
+ FIVE = 5;
+
+ public bool can_increase() {
+ return this < FIVE;
+ }
+
+ public bool can_decrease() {
+ return this > REJECTED;
+ }
+
+ public bool is_valid() {
+ return this >= REJECTED && this <= FIVE;
+ }
+
+ public Rating increase() {
+ return can_increase() ? this + 1 : this;
+ }
+
+ public Rating decrease() {
+ return can_decrease() ? this - 1 : this;
+ }
+
+ public int serialize() {
+ switch (this) {
+ case REJECTED:
+ return -1;
+ case UNRATED:
+ return 0;
+ case ONE:
+ return 1;
+ case TWO:
+ return 2;
+ case THREE:
+ return 3;
+ case FOUR:
+ return 4;
+ case FIVE:
+ return 5;
+ default:
+ return 0;
+ }
+ }
+
+ public static Rating unserialize(int value) {
+ if (value > FIVE)
+ return FIVE;
+ else if (value < REJECTED)
+ return REJECTED;
+
+ switch (value) {
+ case -1:
+ return REJECTED;
+ case 0:
+ return UNRATED;
+ case 1:
+ return ONE;
+ case 2:
+ return TWO;
+ case 3:
+ return THREE;
+ case 4:
+ return FOUR;
+ case 5:
+ return FIVE;
+ default:
+ return UNRATED;
+ }
+ }
+}
+
+// Photo is an abstract class that allows for applying transformations on-the-fly to a
+// particular photo without modifying the backing image file. The interface allows for
+// transformations to be stored persistently elsewhere or in memory until they're committed en
+// masse to an image file.
+public abstract class Photo : PhotoSource, Dateable {
+ // Need to use "thumb" rather than "photo" for historical reasons -- this name is used
+ // directly to load thumbnails from disk by already-existing filenames
+ public const string TYPENAME = "thumb";
+
+ private const string[] IMAGE_EXTENSIONS = {
+ // raster formats
+ "jpg", "jpeg", "jpe",
+ "tiff", "tif",
+ "png",
+ "gif",
+ "bmp",
+ "ppm", "pgm", "pbm", "pnm",
+
+ // THM are JPEG thumbnails produced by some RAW cameras ... want to support the RAW
+ // image but not import their thumbnails
+ "thm",
+
+ // less common
+ "tga", "ilbm", "pcx", "ecw", "img", "sid", "cd5", "fits", "pgf",
+
+ // vector
+ "cgm", "svg", "odg", "eps", "pdf", "swf", "wmf", "emf", "xps",
+
+ // 3D
+ "pns", "jps", "mpo",
+
+ // RAW extensions
+ "3fr", "arw", "srf", "sr2", "bay", "crw", "cr2", "cap", "iiq", "eip", "dcs", "dcr", "drf",
+ "k25", "kdc", "dng", "erf", "fff", "mef", "mos", "mrw", "nef", "nrw", "orf", "ptx", "pef",
+ "pxn", "r3d", "raf", "raw", "rw2", "rwl", "rwz", "x3f", "srw"
+ };
+
+ // There are assertions in the photo pipeline to verify that the generated (or loaded) pixbuf
+ // is scaled properly. We have to allow for some wobble here because of rounding errors and
+ // precision limitations of various subsystems. Pixel-accuracy would be best, but barring that,
+ // need to just make sure the pixbuf is in the ballpark.
+ private const int SCALING_FUDGE = 64;
+
+ // The number of seconds we should hold onto a precached copy of the original image; if
+ // it hasn't been accessed in this many seconds, discard it to conserve memory.
+ private const int SOURCE_PIXBUF_TIME_TO_LIVE_SEC = 10;
+
+ // min and max size of source pixbuf cache LRU
+ private const int SOURCE_PIXBUF_MIN_LRU_COUNT = 1;
+ private const int SOURCE_PIXBUF_MAX_LRU_COUNT = 3;
+
+ // Minimum raw embedded preview size we're willing to accept; any smaller than this, and
+ // it's probably intended primarily for use only as a thumbnail and won't look good on the
+ // PhotoPage.
+ private const int MIN_EMBEDDED_SIZE = 1024;
+
+ // Here, we cache the exposure time to avoid paying to access the row every time we
+ // need to know it. This is initially set in the constructor, and updated whenever
+ // the exposure time is set (please see set_exposure_time() for details).
+ private time_t cached_exposure_time;
+
+ public enum Exception {
+ NONE = 0,
+ ORIENTATION = 1 << 0,
+ CROP = 1 << 1,
+ REDEYE = 1 << 2,
+ ADJUST = 1 << 3,
+ STRAIGHTEN = 1 << 4,
+ ALL = 0xFFFFFFFF;
+
+ public bool prohibits(Exception exception) {
+ return ((this & exception) != 0);
+ }
+
+ public bool allows(Exception exception) {
+ return ((this & exception) == 0);
+ }
+ }
+
+ // NOTE: This class should only be instantiated when row is locked.
+ private class PhotoTransformationStateImpl : PhotoTransformationState {
+ private Photo photo;
+ private Orientation orientation;
+ private Gee.HashMap<string, KeyValueMap>? transformations;
+ private PixelTransformer? transformer;
+ private PixelTransformationBundle? adjustments;
+
+ public PhotoTransformationStateImpl(Photo photo, Orientation orientation,
+ Gee.HashMap<string, KeyValueMap>? transformations, PixelTransformer? transformer,
+ PixelTransformationBundle? adjustments) {
+ this.photo = photo;
+ this.orientation = orientation;
+ this.transformations = copy_transformations(transformations);
+ this.transformer = transformer;
+ this.adjustments = adjustments;
+
+ photo.baseline_replaced.connect(on_photo_baseline_replaced);
+ }
+
+ ~PhotoTransformationStateImpl() {
+ photo.baseline_replaced.disconnect(on_photo_baseline_replaced);
+ }
+
+ public Orientation get_orientation() {
+ return orientation;
+ }
+
+ public Gee.HashMap<string, KeyValueMap>? get_transformations() {
+ return copy_transformations(transformations);
+ }
+
+ public PixelTransformer? get_transformer() {
+ return (transformer != null) ? transformer.copy() : null;
+ }
+
+ public PixelTransformationBundle? get_color_adjustments() {
+ return (adjustments != null) ? adjustments.copy() : null;
+ }
+
+ private static Gee.HashMap<string, KeyValueMap>? copy_transformations(
+ Gee.HashMap<string, KeyValueMap>? original) {
+ if (original == null)
+ return null;
+
+ Gee.HashMap<string, KeyValueMap>? clone = new Gee.HashMap<string, KeyValueMap>();
+ foreach (string object in original.keys)
+ clone.set(object, original.get(object).copy());
+
+ return clone;
+ }
+
+ private void on_photo_baseline_replaced() {
+ if (!is_broken())
+ broken();
+ }
+ }
+
+ private class BackingReaders {
+ public PhotoFileReader master;
+ public PhotoFileReader developer;
+ public PhotoFileReader editable;
+ }
+
+ private class CachedPixbuf {
+ public Photo photo;
+ public Gdk.Pixbuf pixbuf;
+ public Timer last_touched = new Timer();
+
+ public CachedPixbuf(Photo photo, Gdk.Pixbuf pixbuf) {
+ this.photo = photo;
+ this.pixbuf = pixbuf;
+ }
+ }
+
+ // The first time we have to run the pipeline on an image, we'll precache
+ // a copy of the unscaled, unmodified version; this allows us to operate
+ // directly on the image data quickly without re-fetching it at the top
+ // of the pipeline, which can cause significant lag with larger images.
+ //
+ // This adds a small amount of (automatically garbage-collected) memory
+ // overhead, but greatly simplifies the pipeline, since scaling can now
+ // be blithely ignored, and most of the pixel operations are fast enough
+ // that the app remains responsive, even with 10MP images.
+ //
+ // In order to make sure we discard unneeded precaches in a timely fashion,
+ // we spawn a timer when the unmodified pixbuf is first precached; if the
+ // timer elapses and the pixbuf hasn't been needed again since then, we'll
+ // discard it and free up the memory. The cache also has an LRU to prevent
+ // runaway amounts of memory from being stored (see SOURCE_PIXBUF_LRU_COUNT)
+ private static Gee.LinkedList<CachedPixbuf>? source_pixbuf_cache = null;
+ private static uint discard_source_id = 0;
+
+ // because fetching individual items from the database is high-overhead, store all of
+ // the photo row in memory
+ protected PhotoRow row;
+ private BackingPhotoRow editable = new BackingPhotoRow();
+ private BackingReaders readers = new BackingReaders();
+ private PixelTransformer transformer = null;
+ private PixelTransformationBundle adjustments = null;
+ // because file_title is determined by data in row, it should only be accessed when row is locked
+ private string file_title = null;
+ private FileMonitor editable_monitor = null;
+ private OneShotScheduler reimport_editable_scheduler = null;
+ private OneShotScheduler update_editable_attributes_scheduler = null;
+ private OneShotScheduler remove_editable_scheduler = null;
+
+ protected bool can_rotate_now = true;
+
+ // RAW only: developed backing photos.
+ private Gee.HashMap<RawDeveloper, BackingPhotoRow?>? developments = null;
+
+ // Set to true if we want to develop RAW photos into new files.
+ public static bool develop_raw_photos_to_files { get; set; default = false; }
+
+ // This pointer is used to determine which BackingPhotoRow in the PhotoRow to be using at
+ // any time. It should only be accessed -- read or write -- when row is locked.
+ protected BackingPhotoRow? backing_photo_row = null;
+
+ // This is fired when the photo's editable file is replaced. The image it generates may or
+ // may not be the same; the altered signal is best for that. null is passed if the editable
+ // is being added, replaced, or removed (in the appropriate places)
+ public virtual signal void editable_replaced(File? old_file, File? new_file) {
+ }
+
+ // Fired when one or more of the photo's RAW developments has been changed. This will only
+ // be fired on RAW photos, and only when a development has been added or removed.
+ public virtual signal void raw_development_modified() {
+ }
+
+ // This is fired when the photo's baseline file (the file that generates images at the head
+ // of the pipeline) is replaced. Photo will make every sane effort to only fire this signal
+ // if the new baseline is the same image-wise (i.e. the pixbufs it generates are essentially
+ // the same).
+ public virtual signal void baseline_replaced() {
+ }
+
+ // This is fired when the photo's master is reimported in place. It's fired after all changes
+ // to the Photo's state have been incorporated into the object and the "altered" signal has
+ // been fired notifying of the various details that have changed.
+ public virtual signal void master_reimported(PhotoMetadata? metadata) {
+ }
+
+ // Like "master-reimported", but when a photo's editable has been reimported.
+ public virtual signal void editable_reimported(PhotoMetadata? metadata) {
+ }
+
+ // Like "master-reimported" but when the baseline file has been reimported. Note that this
+ // could be the master file OR the editable file.
+ //
+ // See BackingFetchMode for more details.
+ public virtual signal void baseline_reimported(PhotoMetadata? metadata) {
+ }
+
+ // Like "master-reimported" but when the source file has been reimported. Note that this could
+ // be the master file OR the editable file.
+ //
+ // See BackingFetchMode for more details.
+ public virtual signal void source_reimported(PhotoMetadata? metadata) {
+ }
+
+ // The key to this implementation is that multiple instances of Photo with the
+ // same PhotoID cannot exist; it is up to the subclasses to ensure this.
+ protected Photo(PhotoRow row) {
+ this.row = row;
+
+ // normalize user text
+ this.row.title = prep_title(this.row.title);
+ this.row.comment = prep_comment(this.row.comment);
+
+ // don't need to lock the struct in the constructor (and to do so would hurt startup
+ // time)
+ readers.master = row.master.file_format.create_reader(row.master.filepath);
+
+ // get the file title of the Photo without using a File object, skipping the separator itself
+ string? basename = String.sliced_at_last_char(row.master.filepath, Path.DIR_SEPARATOR);
+ if (basename != null)
+ file_title = String.sliced_at(basename, 1);
+
+ if (is_string_empty(file_title))
+ file_title = row.master.filepath;
+
+ if (row.editable_id.id != BackingPhotoID.INVALID) {
+ BackingPhotoRow? e = get_backing_row(row.editable_id);
+ if (e != null) {
+ editable = e;
+ readers.editable = editable.file_format.create_reader(editable.filepath);
+ } else {
+ try {
+ PhotoTable.get_instance().detach_editable(this.row);
+ } catch (DatabaseError err) {
+ // ignored
+ }
+
+ // need to remove all transformations as they're keyed to the editable's
+ // coordinate system
+ internal_remove_all_transformations(false);
+ }
+ }
+
+ if (row.master.file_format == PhotoFileFormat.RAW) {
+ // Fetch development backing photos for RAW.
+ developments = new Gee.HashMap<RawDeveloper, BackingPhotoRow?>();
+ foreach (RawDeveloper d in RawDeveloper.as_array()) {
+ BackingPhotoID id = row.development_ids[d];
+ if (id.id != BackingPhotoID.INVALID) {
+ BackingPhotoRow? bpr = get_backing_row(id);
+ if (bpr != null)
+ developments.set(d, bpr);
+ }
+ }
+ }
+
+ // Set up reader for developer.
+ if (row.master.file_format == PhotoFileFormat.RAW && developments.has_key(row.developer)) {
+ BackingPhotoRow r = developments.get(row.developer);
+ readers.developer = r.file_format.create_reader(r.filepath);
+ }
+
+ // Set the backing photo state appropriately.
+ if (readers.editable != null) {
+ backing_photo_row = this.editable;
+ } else if (row.master.file_format != PhotoFileFormat.RAW) {
+ backing_photo_row = this.row.master;
+ } else {
+ // For RAW photos, the backing photo is either the editable (above) or
+ // the selected raw development.
+ if (developments.has_key(row.developer)) {
+ backing_photo_row = developments.get(row.developer);
+ } else {
+ // Use row's backing photo.
+ backing_photo_row = this.row.master;
+ }
+ }
+
+ cached_exposure_time = this.row.exposure_time;
+ }
+
+ protected static void init_photo() {
+ source_pixbuf_cache = new Gee.LinkedList<CachedPixbuf>();
+ }
+
+ protected static void terminate_photo() {
+ source_pixbuf_cache = null;
+
+ if (discard_source_id != 0) {
+ Source.remove(discard_source_id);
+ discard_source_id = 0;
+ }
+ }
+
+ protected virtual void notify_editable_replaced(File? old_file, File? new_file) {
+ editable_replaced(old_file, new_file);
+ }
+
+ protected virtual void notify_raw_development_modified() {
+ raw_development_modified();
+ }
+
+ protected virtual void notify_baseline_replaced() {
+ baseline_replaced();
+ }
+
+ protected virtual void notify_master_reimported(PhotoMetadata? metadata) {
+ master_reimported(metadata);
+ }
+
+ protected virtual void notify_editable_reimported(PhotoMetadata? metadata) {
+ editable_reimported(metadata);
+ }
+
+ protected virtual void notify_source_reimported(PhotoMetadata? metadata) {
+ source_reimported(metadata);
+ }
+
+ protected virtual void notify_baseline_reimported(PhotoMetadata? metadata) {
+ baseline_reimported(metadata);
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ bool ret = true;
+ File file = null;
+ lock (readers) {
+ if (readers.editable != null)
+ file = readers.editable.get_file();
+ }
+
+ detach_editable(true, false);
+
+ if (get_master_file_format() == PhotoFileFormat.RAW) {
+ foreach (RawDeveloper d in RawDeveloper.as_array()) {
+ delete_raw_development(d);
+ }
+ }
+
+ if (file != null) {
+ try {
+ ret = file.trash(null);
+ } catch (Error err) {
+ ret = false;
+ message("Unable to move editable %s for %s to trash: %s", file.get_path(),
+ to_string(), err.message);
+ }
+ }
+
+ // Return false if parent method failed.
+ return base.internal_delete_backing() && ret;
+ }
+
+ // Fetches the backing state. If it can't be read, the ID is flushed from the database
+ // for safety. If the ID is invalid or any error occurs, null is returned.
+ private BackingPhotoRow? get_backing_row(BackingPhotoID id) {
+ if (id.id == BackingPhotoID.INVALID)
+ return null;
+
+ BackingPhotoRow? backing_row = null;
+ try {
+ backing_row = BackingPhotoTable.get_instance().fetch(id);
+ } catch (DatabaseError err) {
+ warning("Unable to fetch backing state for %s: %s", to_string(), err.message);
+ }
+
+ if (backing_row == null) {
+ try {
+ BackingPhotoTable.get_instance().remove(id);
+ } catch (DatabaseError err) {
+ // ignored
+ }
+ return null;
+ }
+
+ return backing_row;
+ }
+
+ // Returns true if the given raw development was already made and the developed image
+ // exists on disk.
+ public bool is_raw_developer_complete(RawDeveloper d) {
+ lock (developments) {
+ return developments.has_key(d) &&
+ FileUtils.test(developments.get(d).filepath, FileTest.EXISTS);
+ }
+ }
+
+ // Determines whether a given RAW developer is available for this photo.
+ public bool is_raw_developer_available(RawDeveloper d) {
+ lock (developments) {
+ if (developments.has_key(d))
+ return true;
+ }
+
+ switch (d) {
+ case RawDeveloper.SHOTWELL:
+ return true;
+
+ case RawDeveloper.CAMERA:
+ return false;
+
+ case RawDeveloper.EMBEDDED:
+ try {
+ PhotoMetadata meta = get_master_metadata();
+ uint num_previews = meta.get_preview_count();
+
+ if (num_previews > 0) {
+ PhotoPreview? prev = meta.get_preview(num_previews - 1);
+
+ // Embedded preview could not be fetched?
+ if (prev == null)
+ return false;
+
+ Dimensions dims = prev.get_pixel_dimensions();
+
+ // Largest embedded preview was an unacceptable size?
+ int preview_major_axis = (dims.width > dims.height) ? dims.width : dims.height;
+ if (preview_major_axis < MIN_EMBEDDED_SIZE)
+ return false;
+
+ // Preview was a supported size, use it.
+ return true;
+ }
+
+ // Image has no embedded preview at all.
+ return false;
+ } catch (Error e) {
+ debug("Error accessing embedded preview. Message: %s", e.message);
+ }
+ return false;
+
+ default:
+ assert_not_reached();
+ }
+ }
+
+ // Reads info on a backing photo and adds it.
+ // Note: this function was created for importing new photos. It will not
+ // notify of changes to the developments.
+ public void add_backing_photo_for_development(RawDeveloper d, BackingPhotoRow bpr) throws Error {
+ import_developed_backing_photo(row, d, bpr);
+ lock (developments) {
+ developments.set(d, bpr);
+ }
+ notify_altered(new Alteration("image", "developer"));
+ }
+
+ public static void import_developed_backing_photo(PhotoRow row, RawDeveloper d,
+ BackingPhotoRow bpr) throws Error {
+ File file = File.new_for_path(bpr.filepath);
+ FileInfo info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ TimeVal timestamp = info.get_modification_time();
+
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(
+ file, PhotoFileSniffer.Options.GET_ALL);
+ interrogator.interrogate();
+
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected == null || interrogator.get_is_photo_corrupted()) {
+ // TODO: Probably should remove from database, but simply exiting for now (prior code
+ // didn't even do this check)
+ return;
+ }
+
+ bpr.dim = detected.image_dim;
+ bpr.filesize = info.get_size();
+ bpr.timestamp = timestamp.tv_sec;
+ bpr.original_orientation = detected.metadata != null ? detected.metadata.get_orientation() :
+ Orientation.TOP_LEFT;
+
+ // Add to DB.
+ BackingPhotoTable.get_instance().add(bpr);
+ PhotoTable.get_instance().update_raw_development(row, d, bpr.id);
+ }
+
+ // "Develops" a raw photo
+ // Not thread-safe.
+ private void develop_photo(RawDeveloper d) {
+ bool wrote_img_to_disk = false;
+ BackingPhotoRow bps = null;
+
+ switch (d) {
+ case RawDeveloper.SHOTWELL:
+ try {
+ // Create file and prep.
+ bps = d.create_backing_row_for_development(row.master.filepath);
+ Gdk.Pixbuf? pix = null;
+ lock (readers) {
+ // Don't rotate this pixbuf before writing it out. We don't
+ // need to because we'll display it using the orientation
+ // from the parent raw file, so rotating it here would cause
+ // portrait images to rotate _twice_...
+ pix = get_master_pixbuf(Scaling.for_original(), false);
+ }
+
+ if (pix == null) {
+ debug("Could not get preview pixbuf");
+ return;
+ }
+
+ // Write out the JPEG.
+ PhotoFileWriter writer = PhotoFileFormat.JFIF.create_writer(bps.filepath);
+ writer.write(pix, Jpeg.Quality.HIGH);
+
+ // Remember that we wrote it (we'll only get here if writing
+ // the jpeg doesn't throw an exception). We do this because
+ // some cameras' output has non-spec-compliant exif segments
+ // larger than 64k (exiv2 can't cope with this), so saving
+ // metadata to the development could fail, but we want to use
+ // it anyway since the image portion is still valid...
+ wrote_img_to_disk = true;
+
+ // Write out metadata. An exception could get thrown here as
+ // well, hence the separate check for being able to save the
+ // image above...
+ PhotoMetadata meta = get_master_metadata();
+ PhotoFileMetadataWriter mwriter = PhotoFileFormat.JFIF.create_metadata_writer(bps.filepath);
+ mwriter.write_metadata(meta);
+ } catch (Error err) {
+ debug("Error developing photo: %s", err.message);
+ } finally {
+ if (wrote_img_to_disk) {
+ try {
+ // Read in backing photo info, add to DB.
+ add_backing_photo_for_development(d, bps);
+
+ notify_raw_development_modified();
+ } catch (Error e) {
+ debug("Error adding backing photo as development. Message: %s",
+ e.message);
+ }
+ }
+ }
+
+ break;
+
+ case RawDeveloper.CAMERA:
+ // No development needed.
+ break;
+
+ case RawDeveloper.EMBEDDED:
+ try {
+ // Read in embedded JPEG.
+ PhotoMetadata meta = get_master_metadata();
+ uint c = meta.get_preview_count();
+ if (c <= 0)
+ return;
+ PhotoPreview? prev = meta.get_preview(c - 1);
+ if (prev == null) {
+ debug("Could not get preview from metadata");
+ return;
+ }
+
+ Gdk.Pixbuf? pix = prev.get_pixbuf();
+ if (pix == null) {
+ debug("Could not get preview pixbuf");
+ return;
+ }
+
+ // Write out file.
+ bps = d.create_backing_row_for_development(row.master.filepath);
+ PhotoFileWriter writer = PhotoFileFormat.JFIF.create_writer(bps.filepath);
+ writer.write(pix, Jpeg.Quality.HIGH);
+
+ // Remember that we wrote it (see above
+ // case for why this is necessary).
+ wrote_img_to_disk = true;
+
+ // Write out metadata
+ PhotoFileMetadataWriter mwriter = PhotoFileFormat.JFIF.create_metadata_writer(bps.filepath);
+ mwriter.write_metadata(meta);
+ } catch (Error e) {
+ debug("Error accessing embedded preview. Message: %s", e.message);
+ return;
+ } finally {
+ if (wrote_img_to_disk) {
+ try {
+ // Read in backing photo info, add to DB.
+ add_backing_photo_for_development(d, bps);
+
+ notify_raw_development_modified();
+ } catch (Error e) {
+ debug("Error adding backing photo as development. Message: %s",
+ e.message);
+ }
+ }
+ }
+ break;
+
+ default:
+ assert_not_reached();
+ }
+ }
+
+ // Sets the developer internally, but does not actually develop the backing file.
+ public void set_default_raw_developer(RawDeveloper d) {
+ lock (row) {
+ row.developer = d;
+ }
+ }
+
+ // Sets the developer and develops the photo.
+ public void set_raw_developer(RawDeveloper d) {
+ if (get_master_file_format() != PhotoFileFormat.RAW)
+ return;
+
+ // If the caller has asked for 'embedded', but there's a camera development
+ // available, always prefer that instead, as it's likely to be of higher
+ // quality and resolution.
+ if (is_raw_developer_available(RawDeveloper.CAMERA) && (d == RawDeveloper.EMBEDDED))
+ d = RawDeveloper.CAMERA;
+
+ // If the embedded preview is too small to be used in the PhotoPage, don't
+ // allow EMBEDDED to be chosen.
+ if (!is_raw_developer_available(RawDeveloper.EMBEDDED))
+ d = RawDeveloper.SHOTWELL;
+
+ lock (developments) {
+ RawDeveloper stale_raw_developer = row.developer;
+
+ // Perform development, bail out if it doesn't work.
+ if (!is_raw_developer_complete(d)) {
+ develop_photo(d);
+ try {
+ get_prefetched_copy();
+ } catch (Error e) {
+ // couldn't reload the freshly-developed image, nothing to display
+ return;
+ }
+ }
+ if (!developments.has_key(d))
+ return; // we tried!
+
+ // Disgard changes.
+ revert_to_master(false);
+
+ // Switch master to the new photo.
+ row.developer = d;
+ backing_photo_row = developments.get(d);
+ readers.developer = backing_photo_row.file_format.create_reader(backing_photo_row.filepath);
+
+ set_orientation(backing_photo_row.original_orientation);
+
+ try {
+ PhotoTable.get_instance().update_raw_development(row, d, backing_photo_row.id);
+ } catch (Error e) {
+ warning("Error updating database: %s", e.message);
+ }
+
+ // Is the 'stale' development _NOT_ a camera-supplied one?
+ //
+ // NOTE: When a raw is first developed, both 'stale' and 'incoming' developers
+ // will be the same, so the second test is required for correct operation.
+ if ((stale_raw_developer != RawDeveloper.CAMERA) &&
+ (stale_raw_developer != row.developer)) {
+ // The 'stale' non-Shotwell development we're using was
+ // created by us, not the camera, so discard it...
+ delete_raw_development(stale_raw_developer);
+ }
+
+ // Otherwise, don't delete the paired JPEG, since it is user/camera-created
+ // and is to be preserved.
+ }
+
+ notify_altered(new Alteration("image", "developer"));
+ discard_prefetched();
+ }
+
+ public RawDeveloper get_raw_developer() {
+ return row.developer;
+ }
+
+ // Removes a development from the database, filesystem, etc.
+ // Returns true if a development was removed, otherwise false.
+ private bool delete_raw_development(RawDeveloper d) {
+ bool ret = false;
+
+ lock (developments) {
+ if (!developments.has_key(d))
+ return false;
+
+ // Remove file. If this is a camera-generated JPEG, we trash it;
+ // otherwise, it was generated by us and should be deleted outright.
+ debug("Delete raw development: %s %s", this.to_string(), d.to_string());
+ BackingPhotoRow bpr = developments.get(d);
+ if (bpr.filepath != null) {
+ File f = File.new_for_path(bpr.filepath);
+ try {
+ if (d == RawDeveloper.CAMERA)
+ f.trash();
+ else
+ f.delete();
+ } catch (Error e) {
+ warning("Unable to delete RAW development: %s error: %s", bpr.filepath, e.message);
+ }
+ }
+
+ // Delete references in DB.
+ try {
+ PhotoTable.get_instance().remove_development(row, d);
+ BackingPhotoTable.get_instance().remove(bpr.id);
+ } catch (Error e) {
+ warning("Database error while deleting RAW development: %s", e.message);
+ }
+
+ ret = developments.unset(d);
+ }
+
+ notify_raw_development_modified();
+ return ret;
+ }
+
+ // Re-do development for photo.
+ public void redevelop_raw(RawDeveloper d) {
+ lock (developments) {
+ delete_raw_development(d);
+ RawDeveloper dev = d;
+ if (dev == RawDeveloper.CAMERA)
+ dev = RawDeveloper.EMBEDDED;
+
+ set_raw_developer(dev);
+ }
+ }
+
+ public override BackingFileState[] get_backing_files_state() {
+ BackingFileState[] backing = new BackingFileState[0];
+ lock (row) {
+ backing += new BackingFileState.from_photo_row(row.master, row.md5);
+ if (has_editable())
+ backing += new BackingFileState.from_photo_row(editable, null);
+
+ if (is_developed()) {
+ Gee.Collection<BackingPhotoRow>? dev_rows = get_raw_development_photo_rows();
+ if (dev_rows != null) {
+ foreach (BackingPhotoRow r in dev_rows) {
+ debug("adding: %s", r.filepath);
+ backing += new BackingFileState.from_photo_row(r, null);
+ }
+ }
+ }
+ }
+
+ return backing;
+ }
+
+ private PhotoFileReader get_backing_reader(BackingFetchMode mode) {
+ switch (mode) {
+ case BackingFetchMode.MASTER:
+ return get_master_reader();
+
+ case BackingFetchMode.BASELINE:
+ return get_baseline_reader();
+
+ case BackingFetchMode.SOURCE:
+ return get_source_reader();
+
+ case BackingFetchMode.UNMODIFIED:
+ if (this.get_master_file_format() == PhotoFileFormat.RAW)
+ return get_raw_developer_reader();
+ else
+ return get_master_reader();
+
+ default:
+ error("Unknown backing fetch mode %s", mode.to_string());
+ }
+ }
+
+ private PhotoFileReader get_master_reader() {
+ lock (readers) {
+ return readers.master;
+ }
+ }
+
+ protected PhotoFileReader? get_editable_reader() {
+ lock (readers) {
+ return readers.editable;
+ }
+ }
+
+ // Returns a reader for the head of the pipeline.
+ private PhotoFileReader get_baseline_reader() {
+ lock (readers) {
+ if (readers.editable != null)
+ return readers.editable;
+
+ if (readers.developer != null)
+ return readers.developer;
+
+ return readers.master;
+ }
+ }
+
+ // Returns a reader for the photo file that is the source of the image.
+ private PhotoFileReader get_source_reader() {
+ lock (readers) {
+ if (readers.editable != null)
+ return readers.editable;
+
+ if (readers.developer != null)
+ return readers.developer;
+
+ return readers.master;
+ }
+ }
+
+ // Returns the reader used for reading the RAW development.
+ private PhotoFileReader get_raw_developer_reader() {
+ lock (readers) {
+ return readers.developer;
+ }
+ }
+
+ public bool is_developed() {
+ lock (readers) {
+ return readers.developer != null;
+ }
+ }
+
+ public bool has_editable() {
+ lock (readers) {
+ return readers.editable != null;
+ }
+ }
+
+ public bool does_master_exist() {
+ lock (readers) {
+ return readers.master.file_exists();
+ }
+ }
+
+ // Returns false if the backing editable does not exist OR the photo does not have an editable
+ public bool does_editable_exist() {
+ lock (readers) {
+ return readers.editable != null ? readers.editable.file_exists() : false;
+ }
+ }
+
+ public bool is_master_baseline() {
+ lock (readers) {
+ return readers.editable == null;
+ }
+ }
+
+ public bool is_master_source() {
+ return !has_editable();
+ }
+
+ public bool is_editable_baseline() {
+ lock (readers) {
+ return readers.editable != null;
+ }
+ }
+
+ public bool is_editable_source() {
+ return has_editable();
+ }
+
+ public BackingPhotoRow get_master_photo_row() {
+ lock (row) {
+ return row.master;
+ }
+ }
+
+ public BackingPhotoRow? get_editable_photo_row() {
+ lock (row) {
+ // ternary doesn't work here
+ if (row.editable_id.is_valid())
+ return editable;
+ else
+ return null;
+ }
+ }
+
+ public Gee.Collection<BackingPhotoRow>? get_raw_development_photo_rows() {
+ lock (row) {
+ return developments != null ? developments.values : null;
+ }
+ }
+
+ public BackingPhotoRow? get_raw_development_photo_row(RawDeveloper d) {
+ lock (row) {
+ return developments != null ? developments.get(d) : null;
+ }
+ }
+
+ public PhotoFileFormat? get_editable_file_format() {
+ PhotoFileReader? reader = get_editable_reader();
+ if (reader == null)
+ return null;
+
+ // ternary operator doesn't work here
+ return reader.get_file_format();
+ }
+
+ public PhotoFileFormat get_export_format_for_parameters(ExportFormatParameters params) {
+ PhotoFileFormat result = PhotoFileFormat.get_system_default_format();
+
+ switch (params.mode) {
+ case ExportFormatMode.UNMODIFIED:
+ result = get_master_file_format();
+ break;
+
+ case ExportFormatMode.CURRENT:
+ result = get_best_export_file_format();
+ break;
+
+ case ExportFormatMode.SPECIFIED:
+ result = params.specified_format;
+ break;
+
+ default:
+ error("get_export_format_for_parameters: unsupported export format mode");
+ }
+
+ return result;
+ }
+
+ public string get_export_basename_for_parameters(ExportFormatParameters params) {
+ string? result = null;
+
+ switch (params.mode) {
+ case ExportFormatMode.UNMODIFIED:
+ result = get_master_file().get_basename();
+ break;
+
+ case ExportFormatMode.CURRENT:
+ case ExportFormatMode.SPECIFIED:
+ return get_export_basename(get_export_format_for_parameters(params));
+
+ default:
+ error("get_export_basename_for_parameters: unsupported export format mode");
+ }
+
+ assert (result != null);
+ return result;
+ }
+
+ // This method interrogates the specified file and returns a PhotoRow with all relevant
+ // information about it. It uses the PhotoFileInterrogator to do so. The caller should create
+ // a PhotoFileInterrogator with the proper options prior to calling. prepare_for_import()
+ // will determine what's been discovered and fill out in the PhotoRow or return the relevant
+ // objects and information. If Thumbnails is not null, thumbnails suitable for caching or
+ // framing will be returned as well. Note that this method will call interrogate() and
+ // perform all error-handling; the caller simply needs to construct the object.
+ //
+ // This is the acid-test; if unable to generate a pixbuf or thumbnails, that indicates the
+ // photo itself is bogus and should be discarded.
+ //
+ // NOTE: This method is thread-safe.
+ public static ImportResult prepare_for_import(PhotoImportParams params) {
+#if MEASURE_IMPORT
+ Timer total_time = new Timer();
+#endif
+ File file = params.file;
+
+ FileInfo info = null;
+ try {
+ info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ } catch (Error err) {
+ return ImportResult.FILE_ERROR;
+ }
+
+ if (info.get_file_type() != FileType.REGULAR)
+ return ImportResult.NOT_A_FILE;
+
+ if (!is_file_image(file)) {
+ message("Not importing %s: Not an image file", file.get_path());
+
+ return ImportResult.NOT_AN_IMAGE;
+ }
+
+ if (!PhotoFileFormat.is_file_supported(file)) {
+ message("Not importing %s: Unsupported extension", file.get_path());
+
+ return ImportResult.UNSUPPORTED_FORMAT;
+ }
+
+ TimeVal timestamp = info.get_modification_time();
+
+ // if all MD5s supplied, don't sniff for them
+ if (params.exif_md5 != null && params.thumbnail_md5 != null && params.full_md5 != null)
+ params.sniffer_options |= PhotoFileSniffer.Options.NO_MD5;
+
+ // interrogate file for photo information
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file, params.sniffer_options);
+ try {
+ interrogator.interrogate();
+ } catch (Error err) {
+ warning("Unable to interrogate photo file %s: %s", file.get_path(), err.message);
+
+ return ImportResult.DECODE_ERROR;
+ }
+
+ if (interrogator.get_is_photo_corrupted())
+ return ImportResult.NOT_AN_IMAGE;
+
+ // if not detected photo information, unsupported
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected == null || detected.file_format == PhotoFileFormat.UNKNOWN)
+ return ImportResult.UNSUPPORTED_FORMAT;
+
+ // copy over supplied MD5s if provided
+ if ((params.sniffer_options & PhotoFileSniffer.Options.NO_MD5) != 0) {
+ detected.exif_md5 = params.exif_md5;
+ detected.thumbnail_md5 = params.thumbnail_md5;
+ detected.md5 = params.full_md5;
+ }
+
+ Orientation orientation = Orientation.TOP_LEFT;
+ time_t exposure_time = 0;
+ string title = "";
+ string comment = "";
+ Rating rating = Rating.UNRATED;
+
+#if TRACE_MD5
+ debug("importing MD5 %s: exif=%s preview=%s full=%s", file.get_path(), detected.exif_md5,
+ detected.thumbnail_md5, detected.md5);
+#endif
+
+ if (detected.metadata != null) {
+ MetadataDateTime? date_time = detected.metadata.get_exposure_date_time();
+ if (date_time != null)
+ exposure_time = date_time.get_timestamp();
+
+ orientation = detected.metadata.get_orientation();
+ title = detected.metadata.get_title();
+ comment = detected.metadata.get_comment();
+ params.keywords = detected.metadata.get_keywords();
+ rating = detected.metadata.get_rating();
+ }
+
+ // verify basic mechanics of photo: RGB 8-bit encoding
+ if (detected.colorspace != Gdk.Colorspace.RGB
+ || detected.channels < 3
+ || detected.bits_per_channel != 8) {
+ message("Not importing %s: Unsupported color format", file.get_path());
+
+ return ImportResult.UNSUPPORTED_FORMAT;
+ }
+
+ // photo information is initially stored in database in raw, non-modified format ... this is
+ // especially important dealing with dimensions and orientation ... Don't trust EXIF
+ // dimensions, they can lie or not be present
+ params.row.photo_id = PhotoID();
+ params.row.master.filepath = file.get_path();
+ params.row.master.dim = detected.image_dim;
+ params.row.master.filesize = info.get_size();
+ params.row.master.timestamp = timestamp.tv_sec;
+ params.row.exposure_time = exposure_time;
+ params.row.orientation = orientation;
+ params.row.master.original_orientation = orientation;
+ params.row.import_id = params.import_id;
+ params.row.event_id = EventID();
+ params.row.transformations = null;
+ params.row.md5 = detected.md5;
+ params.row.thumbnail_md5 = detected.thumbnail_md5;
+ params.row.exif_md5 = detected.exif_md5;
+ params.row.time_created = 0;
+ params.row.flags = 0;
+ params.row.master.file_format = detected.file_format;
+ params.row.title = title;
+ params.row.comment = comment;
+ params.row.rating = rating;
+
+ if (params.thumbnails != null) {
+ PhotoFileReader reader = params.row.master.file_format.create_reader(
+ params.row.master.filepath);
+ try {
+ ThumbnailCache.generate_for_photo(params.thumbnails, reader, params.row.orientation,
+ params.row.master.dim);
+ } catch (Error err) {
+ return ImportResult.convert_error(err, ImportResult.FILE_ERROR);
+ }
+ }
+
+#if MEASURE_IMPORT
+ debug("IMPORT: total=%lf", total_time.elapsed());
+#endif
+ return ImportResult.SUCCESS;
+ }
+
+ public static void create_pre_import(PhotoImportParams params) {
+ File file = params.file;
+ params.row.photo_id = PhotoID();
+ params.row.master.filepath = file.get_path();
+ params.row.master.dim = Dimensions(0,0);
+ params.row.master.filesize = 0;
+ params.row.master.timestamp = 0;
+ params.row.exposure_time = 0;
+ params.row.orientation = Orientation.TOP_LEFT;
+ params.row.master.original_orientation = Orientation.TOP_LEFT;
+ params.row.import_id = params.import_id;
+ params.row.event_id = EventID();
+ params.row.transformations = null;
+ params.row.md5 = null;
+ params.row.thumbnail_md5 = null;
+ params.row.exif_md5 = null;
+ params.row.time_created = 0;
+ params.row.flags = 0;
+ params.row.master.file_format = PhotoFileFormat.JFIF;
+ params.row.title = null;
+ params.row.comment = null;
+ params.row.rating = Rating.UNRATED;
+
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(params.file, params.sniffer_options);
+ try {
+ interrogator.interrogate();
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected != null && !interrogator.get_is_photo_corrupted() && detected.file_format != PhotoFileFormat.UNKNOWN)
+ params.row.master.file_format = detected.file_format;
+ } catch (Error err) {
+ debug("Unable to interrogate photo file %s: %s", file.get_path(), err.message);
+ }
+ }
+
+ protected BackingPhotoRow? query_backing_photo_row(File file, PhotoFileSniffer.Options options,
+ out DetectedPhotoInformation detected) throws Error {
+ detected = null;
+
+ BackingPhotoRow backing = new BackingPhotoRow();
+ // get basic file information
+ FileInfo info = null;
+ try {
+ info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ } catch (Error err) {
+ critical("Unable to read file information for %s: %s", file.get_path(), err.message);
+
+ return null;
+ }
+
+ // sniff photo information
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file, options);
+ interrogator.interrogate();
+ detected = interrogator.get_detected_photo_information();
+ if (detected == null || interrogator.get_is_photo_corrupted()) {
+ critical("Photo update: %s no longer a recognized image", to_string());
+
+ return null;
+ }
+
+ TimeVal modification_time = info.get_modification_time();
+
+ backing.filepath = file.get_path();
+ backing.timestamp = modification_time.tv_sec;
+ backing.filesize = info.get_size();
+ backing.file_format = detected.file_format;
+ backing.dim = detected.image_dim;
+ backing.original_orientation = detected.metadata != null
+ ? detected.metadata.get_orientation() : Orientation.TOP_LEFT;
+
+ return backing;
+ }
+
+ public abstract class ReimportMasterState {
+ }
+
+ private class ReimportMasterStateImpl : ReimportMasterState {
+ public PhotoRow row = new PhotoRow();
+ public PhotoMetadata? metadata;
+ public string[] alterations;
+ public bool metadata_only = false;
+
+ public ReimportMasterStateImpl(PhotoRow row, PhotoMetadata? metadata, string[] alterations) {
+ this.row = row;
+ this.metadata = metadata;
+ this.alterations = alterations;
+ }
+ }
+
+ public abstract class ReimportEditableState {
+ }
+
+ private class ReimportEditableStateImpl : ReimportEditableState {
+ public BackingPhotoRow backing_state = new BackingPhotoRow();
+ public PhotoMetadata? metadata;
+ public bool metadata_only = false;
+
+ public ReimportEditableStateImpl(BackingPhotoRow backing_state, PhotoMetadata? metadata) {
+ this.backing_state = backing_state;
+ this.metadata = metadata;
+ }
+ }
+
+ public abstract class ReimportRawDevelopmentState {
+ }
+
+ private class ReimportRawDevelopmentStateImpl : ReimportRawDevelopmentState {
+ public class DevToReimport {
+ public BackingPhotoRow backing = new BackingPhotoRow();
+ public PhotoMetadata? metadata;
+
+ public DevToReimport(BackingPhotoRow backing, PhotoMetadata? metadata) {
+ this.backing = backing;
+ this.metadata = metadata;
+ }
+ }
+
+ public Gee.Collection<DevToReimport> list = new Gee.ArrayList<DevToReimport>();
+ public bool metadata_only = false;
+
+ public ReimportRawDevelopmentStateImpl() {
+ }
+
+ public void add(BackingPhotoRow backing, PhotoMetadata? metadata) {
+ list.add(new DevToReimport(backing, metadata));
+ }
+
+ public int get_size() {
+ return list.size;
+ }
+ }
+
+ // This method is thread-safe. If returns false the photo should be marked offline (in the
+ // main UI thread).
+ public bool prepare_for_reimport_master(out ReimportMasterState reimport_state) throws Error {
+ reimport_state = null;
+
+ File file = get_master_reader().get_file();
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow? backing = query_backing_photo_row(file, PhotoFileSniffer.Options.GET_ALL,
+ out detected);
+ if (backing == null) {
+ warning("Unable to retrieve photo state from %s for reimport", file.get_path());
+ return false;
+ }
+
+ // verify basic mechanics of photo: RGB 8-bit encoding
+ if (detected.colorspace != Gdk.Colorspace.RGB
+ || detected.channels < 3
+ || detected.bits_per_channel != 8) {
+ warning("Not re-importing %s: Unsupported color format", file.get_path());
+
+ return false;
+ }
+
+ // start with existing row and update appropriate fields
+ PhotoRow updated_row = new PhotoRow();
+ lock (row) {
+ updated_row = row;
+ }
+
+ // build an Alteration list for the relevant changes
+ string[] list = new string[0];
+
+ if (updated_row.md5 != detected.md5)
+ list += "metadata:md5";
+
+ if (updated_row.master.original_orientation != backing.original_orientation) {
+ list += "image:orientation";
+ updated_row.master.original_orientation = backing.original_orientation;
+ }
+
+ if (detected.metadata != null) {
+ MetadataDateTime? date_time = detected.metadata.get_exposure_date_time();
+ if (date_time != null && updated_row.exposure_time != date_time.get_timestamp())
+ list += "metadata:exposure-time";
+
+ if (updated_row.title != detected.metadata.get_title())
+ list += "metadata:name";
+
+ if (updated_row.comment != detected.metadata.get_comment())
+ list += "metadata:comment";
+
+ if (updated_row.rating != detected.metadata.get_rating())
+ list += "metadata:rating";
+ }
+
+ updated_row.master = backing;
+ updated_row.md5 = detected.md5;
+ updated_row.exif_md5 = detected.exif_md5;
+ updated_row.thumbnail_md5 = detected.thumbnail_md5;
+
+ PhotoMetadata? metadata = null;
+ if (detected.metadata != null) {
+ metadata = detected.metadata;
+
+ MetadataDateTime? date_time = detected.metadata.get_exposure_date_time();
+ if (date_time != null)
+ updated_row.exposure_time = date_time.get_timestamp();
+
+ updated_row.title = detected.metadata.get_title();
+ updated_row.comment = detected.metadata.get_comment();
+ updated_row.rating = detected.metadata.get_rating();
+ }
+
+ reimport_state = new ReimportMasterStateImpl(updated_row, metadata, list);
+
+ return true;
+ }
+
+ protected abstract void apply_user_metadata_for_reimport(PhotoMetadata metadata);
+
+ // This method is not thread-safe and should be called in the main thread.
+ public void finish_reimport_master(ReimportMasterState state) throws DatabaseError {
+ ReimportMasterStateImpl reimport_state = (ReimportMasterStateImpl) state;
+
+ PhotoTable.get_instance().reimport(reimport_state.row);
+
+ lock (row) {
+ // Copy row while preserving reference to master.
+ BackingPhotoRow original_master = row.master;
+ row = reimport_state.row;
+ row.master = original_master;
+ row.master.copy_from(reimport_state.row.master);
+ if (!reimport_state.metadata_only)
+ internal_remove_all_transformations(false);
+ }
+
+ if (reimport_state.metadata != null)
+ apply_user_metadata_for_reimport(reimport_state.metadata);
+
+ if (!reimport_state.metadata_only) {
+ reimport_state.alterations += "image:master";
+ if (is_master_baseline())
+ reimport_state.alterations += "image:baseline";
+ }
+
+ if (reimport_state.alterations.length > 0)
+ notify_altered(new Alteration.from_array(reimport_state.alterations));
+
+ notify_master_reimported(reimport_state.metadata);
+
+ if (is_master_baseline())
+ notify_baseline_reimported(reimport_state.metadata);
+
+ if (is_master_source())
+ notify_source_reimported(reimport_state.metadata);
+ }
+
+ // Verifies a file for reimport. Returns the file's detected photo info.
+ private bool verify_file_for_reimport(File file, out BackingPhotoRow backing,
+ out DetectedPhotoInformation detected) throws Error {
+ backing = query_backing_photo_row(file, PhotoFileSniffer.Options.NO_MD5,
+ out detected);
+ if (backing == null) {
+ return false;
+ }
+
+ // verify basic mechanics of photo: RGB 8-bit encoding
+ if (detected.colorspace != Gdk.Colorspace.RGB
+ || detected.channels < 3
+ || detected.bits_per_channel != 8) {
+ warning("Not re-importing %s: Unsupported color format", file.get_path());
+
+ return false;
+ }
+
+ return true;
+ }
+
+ // This method is thread-safe. Returns false if the photo has no associated editable.
+ public bool prepare_for_reimport_editable(out ReimportEditableState state) throws Error {
+ state = null;
+
+ File? file = get_editable_file();
+ if (file == null)
+ return false;
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow backing;
+ if (!verify_file_for_reimport(file, out backing, out detected))
+ return false;
+
+ state = new ReimportEditableStateImpl(backing, detected.metadata);
+
+ return true;
+ }
+
+ // This method is not thread-safe. It should be called by the main thread.
+ public void finish_reimport_editable(ReimportEditableState state) throws DatabaseError {
+ BackingPhotoID editable_id = get_editable_id();
+ if (editable_id.is_invalid())
+ return;
+
+ ReimportEditableStateImpl reimport_state = (ReimportEditableStateImpl) state;
+
+ if (!reimport_state.metadata_only) {
+ BackingPhotoTable.get_instance().update(reimport_state.backing_state);
+
+ lock (row) {
+ editable = reimport_state.backing_state;
+ set_orientation(reimport_state.backing_state.original_orientation);
+ internal_remove_all_transformations(false);
+ }
+ } else {
+ set_orientation(reimport_state.backing_state.original_orientation);
+ }
+
+ if (reimport_state.metadata != null) {
+ set_title(reimport_state.metadata.get_title());
+ set_comment(reimport_state.metadata.get_comment());
+ set_rating(reimport_state.metadata.get_rating());
+ apply_user_metadata_for_reimport(reimport_state.metadata);
+ }
+
+ string list = "metadata:name,image:orientation,metadata:rating,metadata:exposure-time";
+ if (!reimport_state.metadata_only)
+ list += "image:editable,image:baseline";
+
+ notify_altered(new Alteration.from_list(list));
+
+ notify_editable_reimported(reimport_state.metadata);
+
+ if (is_editable_baseline())
+ notify_baseline_reimported(reimport_state.metadata);
+
+ if (is_editable_source())
+ notify_source_reimported(reimport_state.metadata);
+ }
+
+ // This method is thread-safe. Returns false if the photo has no associated RAW developments.
+ public bool prepare_for_reimport_raw_development(out ReimportRawDevelopmentState state) throws Error {
+ state = null;
+
+ Gee.Collection<File>? files = get_raw_developer_files();
+ if (files == null)
+ return false;
+
+ ReimportRawDevelopmentStateImpl reimport_state = new ReimportRawDevelopmentStateImpl();
+
+ foreach (File file in files) {
+ DetectedPhotoInformation detected;
+ BackingPhotoRow backing;
+ if (!verify_file_for_reimport(file, out backing, out detected))
+ continue;
+
+ reimport_state.add(backing, detected.metadata);
+ }
+
+ state = reimport_state;
+ return reimport_state.get_size() > 0;
+ }
+
+ // This method is not thread-safe. It should be called by the main thread.
+ public void finish_reimport_raw_development(ReimportRawDevelopmentState state) throws DatabaseError {
+ if (this.get_master_file_format() != PhotoFileFormat.RAW)
+ return;
+
+ ReimportRawDevelopmentStateImpl reimport_state = (ReimportRawDevelopmentStateImpl) state;
+
+ foreach (ReimportRawDevelopmentStateImpl.DevToReimport dev in reimport_state.list) {
+ if (!reimport_state.metadata_only) {
+ BackingPhotoTable.get_instance().update(dev.backing);
+
+ lock (row) {
+ // Refresh raw developments.
+ foreach (RawDeveloper d in RawDeveloper.as_array()) {
+ BackingPhotoID id = row.development_ids[d];
+ if (id.id != BackingPhotoID.INVALID) {
+ BackingPhotoRow? bpr = get_backing_row(id);
+ if (bpr != null)
+ developments.set(d, bpr);
+ }
+ }
+ }
+ }
+ }
+
+ string list = "metadata:name,image:orientation,metadata:rating,metadata:exposure-time";
+ if (!reimport_state.metadata_only)
+ list += "image:editable,image:baseline";
+
+ notify_altered(new Alteration.from_list(list));
+
+ notify_raw_development_modified();
+ }
+
+ public override string get_typename() {
+ return TYPENAME;
+ }
+
+ public override int64 get_instance_id() {
+ return get_photo_id().id;
+ }
+
+ public override string get_source_id() {
+ // Because of historical reasons, need to format Photo's source ID without a dash for
+ // ThumbnailCache. Note that any future routine designed to tear a source ID apart and
+ // locate by typename will need to account for this exception.
+ return ("%s%016" + int64.FORMAT_MODIFIER + "x").printf(get_typename(), get_instance_id());
+ }
+
+ // Use this only if the master file's modification time has been changed (i.e. touched)
+ public void set_master_timestamp(FileInfo info) {
+ TimeVal modification = info.get_modification_time();
+
+ try {
+ lock (row) {
+ if (row.master.timestamp == modification.tv_sec)
+ return;
+
+ PhotoTable.get_instance().update_timestamp(row.photo_id, modification.tv_sec);
+ row.master.timestamp = modification.tv_sec;
+ }
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+
+ return;
+ }
+
+ if (is_master_baseline())
+ notify_altered(new Alteration.from_list("metadata:master-timestamp,metadata:baseline-timestamp"));
+ else
+ notify_altered(new Alteration("metadata", "master-timestamp"));
+ }
+
+ // Use this only if the editable file's modification time has been changed (i.e. touched)
+ public void update_editable_modification_time(FileInfo info) throws DatabaseError {
+ TimeVal modification = info.get_modification_time();
+
+ bool altered = false;
+ lock (row) {
+ if (row.editable_id.is_valid() && editable.timestamp != modification.tv_sec) {
+ BackingPhotoTable.get_instance().update_timestamp(row.editable_id,
+ modification.tv_sec);
+ editable.timestamp = modification.tv_sec;
+ altered = true;
+ }
+ }
+
+ if (altered)
+ notify_altered(new Alteration.from_list("metadata:editable-timestamp,metadata:baseline-timestamp"));
+ }
+
+ // Most useful if the appropriate SourceCollection is frozen while calling this.
+ public static void update_many_editable_timestamps(Gee.Map<Photo, FileInfo> map)
+ throws DatabaseError {
+ DatabaseTable.begin_transaction();
+ foreach (Photo photo in map.keys)
+ photo.update_editable_modification_time(map.get(photo));
+ DatabaseTable.commit_transaction();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return (get_file_format().can_write_image()) ? get_file_format() :
+ PhotoFileFormat.get_system_default_format();
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ return get_pixbuf(Scaling.for_best_fit(scale, true));
+ }
+
+ public static bool is_file_image(File file) {
+ // if it's a supported image file, by definition it's an image file, otherwise check the
+ // master list of common image extensions (checking this way allows for extensions to be
+ // added to various PhotoFileFormats without having to also add them to IMAGE_EXTENSIONS)
+ return PhotoFileFormat.is_file_supported(file)
+ ? true : is_extension_found(file.get_basename(), IMAGE_EXTENSIONS);
+ }
+
+ private static bool is_extension_found(string basename, string[] extensions) {
+ string name, ext;
+ disassemble_filename(basename, out name, out ext);
+ if (ext == null)
+ return false;
+
+ // treat extensions as case-insensitive
+ ext = ext.down();
+
+ // search supported list
+ foreach (string extension in extensions) {
+ if (ext == extension)
+ return true;
+ }
+
+ return false;
+ }
+
+ // This is not thread-safe. Obviously, at least one field must be non-null for this to be
+ // effective, although there is no guarantee that any one will be sufficient. file_format
+ // should be UNKNOWN if not to require matching file formats.
+ public static bool is_duplicate(File? file, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format) {
+#if !NO_DUPE_DETECTION
+ return PhotoTable.get_instance().has_duplicate(file, thumbnail_md5, full_md5, file_format);
+#else
+ return false;
+#endif
+ }
+
+ protected static PhotoID[]? get_duplicate_ids(File? file, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format) {
+#if !NO_DUPE_DETECTION
+ return PhotoTable.get_instance().get_duplicate_ids(file, thumbnail_md5, full_md5, file_format);
+#else
+ return null;
+#endif
+ }
+
+ // Conforms to GetDatabaseSourceKey
+ public static int64 get_photo_key(DataSource source) {
+ return ((LibraryPhoto) source).get_photo_id().id;
+ }
+
+ // Data element accessors ... by making these thread-safe, and by the remainder of this class
+ // (and subclasses) accessing row *only* through these, helps ensure this object is suitable
+ // for threads. This implementation is specifically for PixbufCache to work properly.
+ //
+ // Much of the setter's thread-safety (especially in regard to writing to the database) is
+ // that there is a single Photo object per row of the database. The PhotoTable is accessed
+ // elsewhere in the system (usually for aggregate and search functions). Those would need to
+ // be factored and locked in order to guarantee full thread safety.
+ //
+ // Also note there is a certain amount of paranoia here. Many of PhotoRow's elements are
+ // currently static, with no setters to change them. However, since some of these may become
+ // mutable in the future, the entire structure is locked. If performance becomes an issue,
+ // more fine-tuned locking may be implemented -- another reason to *only* use these getters
+ // and setters inside this class.
+
+ public override File get_file() {
+ return get_source_reader().get_file();
+ }
+
+ // This should only be used when the photo's master backing file has been renamed; if it's been
+ // altered, use update().
+ public void set_master_file(File file) {
+ string filepath = file.get_path();
+
+ bool altered = false;
+ bool is_baseline = false;
+ bool is_source = false;
+ bool name_changed = false;
+ File? old_file = null;
+ try {
+ lock (row) {
+ lock (readers) {
+ old_file = readers.master.get_file();
+ if (!file.equal(old_file)) {
+ PhotoTable.get_instance().set_filepath(get_photo_id(), filepath);
+
+ row.master.filepath = filepath;
+ file_title = file.get_basename();
+ readers.master = row.master.file_format.create_reader(filepath);
+
+ altered = true;
+ is_baseline = is_master_baseline();
+ is_source = is_master_source();
+ name_changed = is_string_empty(row.title)
+ && old_file.get_basename() != file.get_basename();
+ }
+ }
+ }
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ if (altered) {
+ notify_master_replaced(old_file, file);
+
+ if (is_baseline)
+ notify_baseline_replaced();
+
+ string[] alteration_list = new string[0];
+ alteration_list += "backing:master";
+
+ // because the name of the photo is determined by its file title if no user title is present,
+ // signal metadata has altered
+ if (name_changed)
+ alteration_list += "metadata:name";
+
+ if (is_source)
+ alteration_list += "backing:source";
+
+ if (is_baseline)
+ alteration_list += "backing:baseline";
+
+ notify_altered(new Alteration.from_array(alteration_list));
+ }
+ }
+
+ // This should only be used when the photo's editable file has been renamed. If it's been
+ // altered, use update(). DO NOT USE THIS TO ATTACH A NEW EDITABLE FILE TO THE PHOTO.
+ public void set_editable_file(File file) {
+ string filepath = file.get_path();
+
+ bool altered = false;
+ bool is_baseline = false;
+ bool is_source = false;
+ File? old_file = null;
+ try {
+ lock (row) {
+ lock (readers) {
+ old_file = (readers.editable != null) ? readers.editable.get_file() : null;
+ if (old_file != null && !old_file.equal(file)) {
+ BackingPhotoTable.get_instance().set_filepath(row.editable_id, filepath);
+
+ editable.filepath = filepath;
+ readers.editable = editable.file_format.create_reader(filepath);
+
+ altered = true;
+ is_baseline = is_editable_baseline();
+ is_source = is_editable_source();
+ }
+ }
+ }
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ if (altered) {
+ notify_editable_replaced(old_file, file);
+
+ if (is_baseline)
+ notify_baseline_replaced();
+
+ string[] alteration_list = new string[0];
+ alteration_list += "backing:editable";
+
+ if (is_baseline)
+ alteration_list += "backing:baseline";
+
+ if (is_source)
+ alteration_list += "backing:source";
+
+ notify_altered(new Alteration.from_array(alteration_list));
+ }
+ }
+
+ // Also makes sense to freeze the SourceCollection during this operation.
+ public static void set_many_editable_file(Gee.Map<Photo, File> map) throws DatabaseError {
+ DatabaseTable.begin_transaction();
+
+ Gee.MapIterator<Photo, File> map_iter = map.map_iterator();
+ while (map_iter.next())
+ map_iter.get_key().set_editable_file(map_iter.get_value());
+
+ DatabaseTable.commit_transaction();
+ }
+
+ // Returns the file generating pixbufs, that is, the baseline if present, the backing
+ // file if not.
+ public File get_actual_file() {
+ return get_baseline_reader().get_file();
+ }
+
+ public override File get_master_file() {
+ return get_master_reader().get_file();
+ }
+
+ public File? get_editable_file() {
+ PhotoFileReader? reader = get_editable_reader();
+
+ return reader != null ? reader.get_file() : null;
+ }
+
+ public Gee.Collection<File>? get_raw_developer_files() {
+ if (get_master_file_format() != PhotoFileFormat.RAW)
+ return null;
+
+ Gee.ArrayList<File> ret = new Gee.ArrayList<File>();
+ lock (row) {
+ foreach (BackingPhotoRow row in developments.values)
+ ret.add(File.new_for_path(row.filepath));
+ }
+
+ return ret;
+ }
+
+ public File get_source_file() {
+ return get_source_reader().get_file();
+ }
+
+ public PhotoFileFormat get_file_format() {
+ lock (row) {
+ return backing_photo_row.file_format;
+ }
+ }
+
+ public PhotoFileFormat get_best_export_file_format() {
+ PhotoFileFormat file_format = get_file_format();
+ if (!file_format.can_write())
+ file_format = PhotoFileFormat.get_system_default_format();
+
+ return file_format;
+ }
+
+ public PhotoFileFormat get_master_file_format() {
+ lock (row) {
+ return readers.master.get_file_format();
+ }
+ }
+
+ public override time_t get_timestamp() {
+ lock (row) {
+ return backing_photo_row.timestamp;
+ }
+ }
+
+ public PhotoID get_photo_id() {
+ lock (row) {
+ return row.photo_id;
+ }
+ }
+
+ // This is NOT thread-safe.
+ public override inline EventID get_event_id() {
+ return row.event_id;
+ }
+
+ // This is NOT thread-safe.
+ public inline int64 get_raw_event_id() {
+ return row.event_id.id;
+ }
+
+ public override ImportID get_import_id() {
+ lock (row) {
+ return row.import_id;
+ }
+ }
+
+ protected BackingPhotoID get_editable_id() {
+ lock (row) {
+ return row.editable_id;
+ }
+ }
+
+ public override string get_master_md5() {
+ lock (row) {
+ return row.md5;
+ }
+ }
+
+ // Flags' meanings are determined by subclasses. Top 16 flags (0xFFFF000000000000) reserved
+ // for Photo.
+ public uint64 get_flags() {
+ lock (row) {
+ return row.flags;
+ }
+ }
+
+ private void notify_flags_altered(Alteration? additional_alteration) {
+ Alteration alteration = new Alteration("metadata", "flags");
+ if (additional_alteration != null)
+ alteration = alteration.compress(additional_alteration);
+
+ notify_altered(alteration);
+ }
+
+ public uint64 replace_flags(uint64 flags, Alteration? additional_alteration = null) {
+ bool committed;
+ lock (row) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public bool is_flag_set(uint64 mask) {
+ lock (row) {
+ return internal_is_flag_set(row.flags, mask);
+ }
+ }
+
+ public uint64 add_flags(uint64 mask, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = internal_add_flags(row.flags, mask);
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public uint64 remove_flags(uint64 mask, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = internal_remove_flags(row.flags, mask);
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public uint64 add_remove_flags(uint64 add, uint64 remove, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = (row.flags | add) & ~remove;
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public static void add_remove_many_flags(Gee.Collection<Photo>? add, uint64 add_mask,
+ Alteration? additional_add_alteration, Gee.Collection<Photo>? remove, uint64 remove_mask,
+ Alteration? additional_remove_alteration) throws DatabaseError {
+ DatabaseTable.begin_transaction();
+
+ if (add != null) {
+ foreach (Photo photo in add)
+ photo.add_flags(add_mask, additional_add_alteration);
+ }
+
+ if (remove != null) {
+ foreach (Photo photo in remove)
+ photo.remove_flags(remove_mask, additional_remove_alteration);
+ }
+
+ DatabaseTable.commit_transaction();
+ }
+
+ public uint64 toggle_flags(uint64 mask, Alteration? additional_alteration = null) {
+ uint64 flags = 0;
+
+ bool committed = false;
+ lock (row) {
+ flags = row.flags ^ mask;
+ if (row.flags != flags) {
+ committed = PhotoTable.get_instance().replace_flags(get_photo_id(), flags);
+ if (committed)
+ row.flags = flags;
+ }
+ }
+
+ if (committed)
+ notify_flags_altered(additional_alteration);
+
+ return flags;
+ }
+
+ public bool is_master_metadata_dirty() {
+ lock (row) {
+ return row.metadata_dirty;
+ }
+ }
+
+ public void set_master_metadata_dirty(bool dirty) throws DatabaseError {
+ bool committed = false;
+ lock (row) {
+ if (row.metadata_dirty != dirty) {
+ PhotoTable.get_instance().set_metadata_dirty(get_photo_id(), dirty);
+ row.metadata_dirty = dirty;
+ committed = true;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "master-dirty"));
+ }
+
+ public override Rating get_rating() {
+ lock (row) {
+ return row.rating;
+ }
+ }
+
+ public override void set_rating(Rating rating) {
+ bool committed = false;
+
+ lock (row) {
+ if (rating != row.rating && rating.is_valid()) {
+ committed = PhotoTable.get_instance().set_rating(get_photo_id(), rating);
+ if (committed)
+ row.rating = rating;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "rating"));
+ }
+
+ public override void increase_rating() {
+ lock (row) {
+ set_rating(row.rating.increase());
+ }
+ }
+
+ public override void decrease_rating() {
+ lock (row) {
+ set_rating(row.rating.decrease());
+ }
+ }
+
+ protected override void commit_backlinks(SourceCollection? sources, string? backlinks) {
+ // For now, only one link state may be stored in the database ... if this turns into a
+ // problem, will use SourceCollection to determine where to store it.
+
+ try {
+ PhotoTable.get_instance().update_backlinks(get_photo_id(), backlinks);
+ lock (row) {
+ row.backlinks = backlinks;
+ }
+ } catch (DatabaseError err) {
+ warning("Unable to update link state for %s: %s", to_string(), err.message);
+ }
+
+ // Note: *Not* firing altered or metadata_altered signal because link_state is not a
+ // property that's available to users of Photo. Persisting it as a mechanism for deaing
+ // with unlink/relink properly.
+ }
+
+ protected override bool set_event_id(EventID event_id) {
+ lock (row) {
+ bool committed = PhotoTable.get_instance().set_event(row.photo_id, event_id);
+
+ if (committed)
+ row.event_id = event_id;
+
+ return committed;
+ }
+ }
+
+ public override string to_string() {
+ return "[%s] %s%s".printf(get_photo_id().id.to_string(), get_master_reader().get_filepath(),
+ !is_master_baseline() ? " (" + get_actual_file().get_path() + ")" : "");
+ }
+
+ public override bool equals(DataSource? source) {
+ // Primary key is where the rubber hits the road
+ Photo? photo = source as Photo;
+ if (photo != null) {
+ PhotoID photo_id = get_photo_id();
+ PhotoID other_photo_id = photo.get_photo_id();
+
+ if (this != photo && photo_id.id != PhotoID.INVALID) {
+ assert(photo_id.id != other_photo_id.id);
+ }
+ }
+
+ return base.equals(source);
+ }
+
+ // used to update the database after an internal metadata exif write
+ private void file_exif_updated() {
+ File file = get_file();
+
+ FileInfo info = null;
+ try {
+ info = file.query_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES,
+ FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
+ } catch (Error err) {
+ error("Unable to read file information for %s: %s", to_string(), err.message);
+ }
+
+ TimeVal timestamp = info.get_modification_time();
+
+ // interrogate file for photo information
+ PhotoFileInterrogator interrogator = new PhotoFileInterrogator(file);
+ try {
+ interrogator.interrogate();
+ } catch (Error err) {
+ warning("Unable to interrogate photo file %s: %s", file.get_path(), err.message);
+ }
+
+ DetectedPhotoInformation? detected = interrogator.get_detected_photo_information();
+ if (detected == null || interrogator.get_is_photo_corrupted()) {
+ critical("file_exif_updated: %s no longer an image", to_string());
+
+ return;
+ }
+
+ bool success;
+ lock (row) {
+ success = PhotoTable.get_instance().master_exif_updated(get_photo_id(), info.get_size(),
+ timestamp.tv_sec, detected.md5, detected.exif_md5, detected.thumbnail_md5, row);
+ }
+
+ if (success)
+ notify_altered(new Alteration.from_list("metadata:exif,metadata:md5"));
+ }
+
+ // PhotoSource
+
+ public override uint64 get_filesize() {
+ lock (row) {
+ return backing_photo_row.filesize;
+ }
+ }
+
+ public override uint64 get_master_filesize() {
+ lock (row) {
+ return row.master.filesize;
+ }
+ }
+
+ public uint64 get_editable_filesize() {
+ lock (row) {
+ return editable.filesize;
+ }
+ }
+
+ public override time_t get_exposure_time() {
+ return cached_exposure_time;
+ }
+
+ public override string get_basename() {
+ lock (row) {
+ return file_title;
+ }
+ }
+
+ public override string? get_title() {
+ lock (row) {
+ return row.title;
+ }
+ }
+
+ public override string? get_comment() {
+ lock (row) {
+ return row.comment;
+ }
+ }
+
+ public override void set_title(string? title) {
+ string? new_title = prep_title(title);
+
+ bool committed = false;
+ lock (row) {
+ if (new_title == row.title)
+ return;
+
+ committed = PhotoTable.get_instance().set_title(row.photo_id, new_title);
+ if (committed)
+ row.title = new_title;
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "name"));
+ }
+
+ public override bool set_comment(string? comment) {
+ string? new_comment = prep_comment(comment);
+
+ bool committed = false;
+ lock (row) {
+ if (new_comment == row.comment)
+ return true;
+
+ committed = PhotoTable.get_instance().set_comment(row.photo_id, new_comment);
+ if (committed)
+ row.comment = new_comment;
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "comment"));
+
+ return committed;
+ }
+
+ public void set_import_id(ImportID import_id) {
+ DatabaseError dberr = null;
+ lock (row) {
+ try {
+ PhotoTable.get_instance().set_import_id(row.photo_id, import_id);
+ row.import_id = import_id;
+ } catch (DatabaseError err) {
+ dberr = err;
+ }
+ }
+
+ if (dberr == null)
+ notify_altered(new Alteration("metadata", "import-id"));
+ else
+ warning("Unable to write import ID for %s: %s", to_string(), dberr.message);
+ }
+
+ public void set_title_persistent(string? title) throws Error {
+ PhotoFileReader source = get_source_reader();
+
+ // Try to write to backing file
+ if (!source.get_file_format().can_write_metadata()) {
+ warning("No photo file writer available for %s", source.get_filepath());
+
+ set_title(title);
+
+ return;
+ }
+
+ PhotoMetadata metadata = source.read_metadata();
+ metadata.set_title(title);
+
+ PhotoFileMetadataWriter writer = source.create_metadata_writer();
+ LibraryMonitor.blacklist_file(source.get_file(), "Photo.set_persistent_title");
+ try {
+ writer.write_metadata(metadata);
+ } finally {
+ LibraryMonitor.unblacklist_file(source.get_file());
+ }
+
+ set_title(title);
+
+ file_exif_updated();
+ }
+
+ public void set_comment_persistent(string? comment) throws Error {
+ PhotoFileReader source = get_source_reader();
+
+ // Try to write to backing file
+ if (!source.get_file_format().can_write_metadata()) {
+ warning("No photo file writer available for %s", source.get_filepath());
+
+ set_comment(comment);
+
+ return;
+ }
+
+ PhotoMetadata metadata = source.read_metadata();
+ metadata.set_comment(comment);
+
+ PhotoFileMetadataWriter writer = source.create_metadata_writer();
+ LibraryMonitor.blacklist_file(source.get_file(), "Photo.set_persistent_comment");
+ try {
+ writer.write_metadata(metadata);
+ } finally {
+ LibraryMonitor.unblacklist_file(source.get_file());
+ }
+
+ set_comment(comment);
+
+ file_exif_updated();
+ }
+
+ public void set_exposure_time(time_t time) {
+ bool committed;
+ lock (row) {
+ committed = PhotoTable.get_instance().set_exposure_time(row.photo_id, time);
+ if (committed) {
+ row.exposure_time = time;
+ cached_exposure_time = time;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("metadata", "exposure-time"));
+ }
+
+ public void set_exposure_time_persistent(time_t time) throws Error {
+ PhotoFileReader source = get_source_reader();
+
+ // Try to write to backing file
+ if (!source.get_file_format().can_write_metadata()) {
+ warning("No photo file writer available for %s", source.get_filepath());
+
+ set_exposure_time(time);
+
+ return;
+ }
+
+ PhotoMetadata metadata = source.read_metadata();
+ metadata.set_exposure_date_time(new MetadataDateTime(time));
+
+ PhotoFileMetadataWriter writer = source.create_metadata_writer();
+ LibraryMonitor.blacklist_file(source.get_file(), "Photo.set_exposure_time_persistent");
+ try {
+ writer.write_metadata(metadata);
+ } finally {
+ LibraryMonitor.unblacklist_file(source.get_file());
+ }
+
+ set_exposure_time(time);
+
+ file_exif_updated();
+ }
+
+ /**
+ * @brief Returns the width and height of the Photo after various
+ * arbitrary stages of the pipeline have been applied in
+ * the same order they're applied in get_pixbuf_with_options.
+ * With no argument passed, it works exactly like the
+ * previous incarnation did.
+ *
+ * @param disallowed_steps Which pipeline steps should NOT
+ * be taken into account when computing image dimensions
+ * (matching the convention set by get_pixbuf_with_options()).
+ * Pipeline steps that do not affect the image geometry are
+ * ignored.
+ */
+ public override Dimensions get_dimensions(Exception disallowed_steps = Exception.NONE) {
+ // The raw dimensions of the incoming image prior to the pipeline.
+ Dimensions returned_dims = get_raw_dimensions();
+
+ // Compute how much the image would be resized by after rotating and/or mirroring.
+ if (disallowed_steps.allows(Exception.ORIENTATION)) {
+ Orientation ori_tmp = get_orientation();
+
+ // Is this image rotated 90 or 270 degrees?
+ switch (ori_tmp) {
+ case Orientation.LEFT_TOP:
+ case Orientation.RIGHT_TOP:
+ case Orientation.LEFT_BOTTOM:
+ case Orientation.RIGHT_BOTTOM:
+ // Yes, swap width and height of raw dimensions.
+ int width_tmp = returned_dims.width;
+
+ returned_dims.width = returned_dims.height;
+ returned_dims.height = width_tmp;
+ break;
+
+ default:
+ // No, only mirrored or rotated 180; do nothing.
+ break;
+ }
+ }
+
+ // Compute how much the image would be resized by after straightening.
+ if (disallowed_steps.allows(Exception.STRAIGHTEN)) {
+ double x_size, y_size;
+ double angle = 0.0;
+
+ get_straighten(out angle);
+
+ compute_arb_rotated_size(returned_dims.width, returned_dims.height, angle, out x_size, out y_size);
+
+ returned_dims.width = (int) (x_size);
+ returned_dims.height = (int) (y_size);
+ }
+
+ // Compute how much the image would be resized by after cropping.
+ if (disallowed_steps.allows(Exception.CROP)) {
+ Box crop;
+ if (get_crop(out crop, disallowed_steps)) {
+ returned_dims = crop.get_dimensions();
+ }
+ }
+ return returned_dims;
+ }
+
+ // This method *must* be called with row locked.
+ private void locked_create_adjustments_from_data() {
+ adjustments = new PixelTransformationBundle();
+
+ KeyValueMap map = get_transformation("adjustments");
+ if (map == null)
+ adjustments.set_to_identity();
+ else
+ adjustments.load(map);
+
+ transformer = adjustments.generate_transformer();
+ }
+
+ // Returns a copy of the color adjustments array. Use set_color_adjustments to persist.
+ public PixelTransformationBundle get_color_adjustments() {
+ lock (row) {
+ if (adjustments == null)
+ locked_create_adjustments_from_data();
+
+ return adjustments.copy();
+ }
+ }
+
+ public PixelTransformer get_pixel_transformer() {
+ lock (row) {
+ if (transformer == null)
+ locked_create_adjustments_from_data();
+
+ return transformer.copy();
+ }
+ }
+
+ public bool has_color_adjustments() {
+ return has_transformation("adjustments");
+ }
+
+ public PixelTransformation? get_color_adjustment(PixelTransformationType type) {
+ return get_color_adjustments().get_transformation(type);
+ }
+
+ public void set_color_adjustments(PixelTransformationBundle new_adjustments) {
+ /* if every transformation in 'new_adjustments' is the identity, then just remove all
+ adjustments from the database */
+ if (new_adjustments.is_identity()) {
+ bool result;
+ lock (row) {
+ result = remove_transformation("adjustments");
+ adjustments = null;
+ transformer = null;
+ }
+
+ if (result)
+ notify_altered(new Alteration("image", "color-adjustments"));
+
+ return;
+ }
+
+ // convert bundle to KeyValueMap, which can be saved in the database
+ KeyValueMap map = new_adjustments.save("adjustments");
+
+ bool committed;
+ lock (row) {
+ if (transformer == null || adjustments == null) {
+ // create new
+ adjustments = new_adjustments.copy();
+ transformer = new_adjustments.generate_transformer();
+ } else {
+ // replace existing
+ foreach (PixelTransformation transformation in new_adjustments.get_transformations()) {
+ transformer.replace_transformation(
+ adjustments.get_transformation(transformation.get_transformation_type()),
+ transformation);
+ }
+
+ adjustments = new_adjustments.copy();
+ }
+
+ committed = set_transformation(map);
+ }
+
+ if (committed)
+ notify_altered(new Alteration("image", "color-adjustments"));
+ }
+
+ // This is thread-safe. Returns the source file's metadata.
+ public override PhotoMetadata? get_metadata() {
+ try {
+ return get_source_reader().read_metadata();
+ } catch (Error err) {
+ warning("Unable to load metadata: %s", err.message);
+
+ return null;
+ }
+ }
+
+ public PhotoMetadata get_master_metadata() throws Error {
+ return get_master_reader().read_metadata();
+ }
+
+ public PhotoMetadata? get_editable_metadata() throws Error {
+ PhotoFileReader? reader = get_editable_reader();
+
+ return (reader != null) ? reader.read_metadata() : null;
+ }
+
+ // This is thread-safe. This must be followed by a call to finish_update_master_metadata() in
+ // the main thread. Returns false if unable to write metadata (because operation is
+ // unsupported) or the file is unavailable.
+ public bool persist_master_metadata(PhotoMetadata metadata, out ReimportMasterState state)
+ throws Error {
+ state = null;
+
+ PhotoFileReader master_reader = get_master_reader();
+
+ if (!master_reader.get_file_format().can_write_metadata())
+ return false;
+
+ master_reader.create_metadata_writer().write_metadata(metadata);
+
+ if (!prepare_for_reimport_master(out state))
+ return false;
+
+ ((ReimportMasterStateImpl) state).metadata_only = true;
+
+ return true;
+ }
+
+ public void finish_update_master_metadata(ReimportMasterState state) throws DatabaseError {
+ finish_reimport_master(state);
+ }
+
+ public bool persist_editable_metadata(PhotoMetadata metadata, out ReimportEditableState state)
+ throws Error {
+ state = null;
+
+ PhotoFileReader? editable_reader = get_editable_reader();
+ if (editable_reader == null)
+ return false;
+
+ if (!editable_reader.get_file_format().can_write_metadata())
+ return false;
+
+ editable_reader.create_metadata_writer().write_metadata(metadata);
+
+ if (!prepare_for_reimport_editable(out state))
+ return false;
+
+ ((ReimportEditableStateImpl) state).metadata_only = true;
+
+ return true;
+ }
+
+ public void finish_update_editable_metadata(ReimportEditableState state) throws DatabaseError {
+ finish_reimport_editable(state);
+ }
+
+ // Transformation storage and exporting
+
+ public Dimensions get_raw_dimensions() {
+ lock (row) {
+ return backing_photo_row.dim;
+ }
+ }
+
+ public bool has_transformations() {
+ lock (row) {
+ return (row.orientation != backing_photo_row.original_orientation)
+ ? true
+ : (row.transformations != null);
+ }
+ }
+
+ public bool only_metadata_changed() {
+ MetadataDateTime? date_time = null;
+
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata != null)
+ date_time = metadata.get_exposure_date_time();
+
+ lock (row) {
+ return row.transformations == null
+ && (row.orientation != backing_photo_row.original_orientation
+ || (date_time != null && row.exposure_time != date_time.get_timestamp()));
+ }
+ }
+
+ public bool has_alterations() {
+ MetadataDateTime? date_time = null;
+ string? title = null;
+ string? comment = null;
+
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata != null) {
+ date_time = metadata.get_exposure_date_time();
+ title = metadata.get_title();
+ comment = metadata.get_comment();
+ }
+
+ // Does this photo contain any date/time info?
+ if (date_time == null) {
+ // No, use file timestamp as date/time.
+ lock (row) {
+ // Did we manually set an exposure date?
+ if(backing_photo_row.timestamp != row.exposure_time) {
+ // Yes, we need to save this.
+ return true;
+ }
+ }
+ }
+
+ lock (row) {
+ return row.transformations != null
+ || row.orientation != backing_photo_row.original_orientation
+ || (date_time != null && row.exposure_time != date_time.get_timestamp())
+ || (get_comment() != comment)
+ || (get_title() != title);
+ }
+
+ }
+
+ public PhotoTransformationState save_transformation_state() {
+ lock (row) {
+ return new PhotoTransformationStateImpl(this, row.orientation,
+ row.transformations,
+ transformer != null ? transformer.copy() : null,
+ adjustments != null ? adjustments.copy() : null);
+ }
+ }
+
+ public bool load_transformation_state(PhotoTransformationState state) {
+ PhotoTransformationStateImpl state_impl = state as PhotoTransformationStateImpl;
+ if (state_impl == null)
+ return false;
+
+ Orientation saved_orientation = state_impl.get_orientation();
+ Gee.HashMap<string, KeyValueMap>? saved_transformations = state_impl.get_transformations();
+ PixelTransformer? saved_transformer = state_impl.get_transformer();
+ PixelTransformationBundle? saved_adjustments = state_impl.get_color_adjustments();
+
+ bool committed;
+ lock (row) {
+ committed = PhotoTable.get_instance().set_transformation_state(row.photo_id,
+ saved_orientation, saved_transformations);
+ if (committed) {
+ row.orientation = saved_orientation;
+ row.transformations = saved_transformations;
+ transformer = saved_transformer;
+ adjustments = saved_adjustments;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("image", "transformation-state"));
+
+ return committed;
+ }
+
+ public void remove_all_transformations() {
+ internal_remove_all_transformations(true);
+ }
+
+ private void internal_remove_all_transformations(bool notify) {
+ bool is_altered = false;
+ lock (row) {
+ is_altered = PhotoTable.get_instance().remove_all_transformations(row.photo_id);
+ row.transformations = null;
+
+ transformer = null;
+ adjustments = null;
+
+ if (row.orientation != backing_photo_row.original_orientation) {
+ PhotoTable.get_instance().set_orientation(row.photo_id,
+ backing_photo_row.original_orientation);
+ row.orientation = backing_photo_row.original_orientation;
+ is_altered = true;
+ }
+ }
+
+ if (is_altered && notify)
+ notify_altered(new Alteration("image", "revert"));
+ }
+
+ public Orientation get_original_orientation() {
+ lock (row) {
+ return backing_photo_row.original_orientation;
+ }
+ }
+
+ public Orientation get_orientation() {
+ lock (row) {
+ return row.orientation;
+ }
+ }
+
+ public bool set_orientation(Orientation orientation) {
+ bool committed = false;
+ lock (row) {
+ if (row.orientation != orientation) {
+ committed = PhotoTable.get_instance().set_orientation(row.photo_id, orientation);
+ if (committed)
+ row.orientation = orientation;
+ }
+ }
+
+ if (committed)
+ notify_altered(new Alteration("image", "orientation"));
+
+ return committed;
+ }
+
+ public bool check_can_rotate() {
+ return can_rotate_now;
+ }
+
+ public virtual void rotate(Rotation rotation) {
+ lock (row) {
+ set_orientation(get_orientation().perform(rotation));
+ }
+ }
+
+ private bool has_transformation(string name) {
+ lock (row) {
+ return (row.transformations != null) ? row.transformations.has_key(name) : false;
+ }
+ }
+
+ // Note that obtaining the proper map is thread-safe here. The returned map is a copy of
+ // the original, so it is thread-safe as well. However: modifying the returned map
+ // does not modify the original; set_transformation() must be used.
+ private KeyValueMap? get_transformation(string name) {
+ KeyValueMap map = null;
+ lock (row) {
+ if (row.transformations != null) {
+ map = row.transformations.get(name);
+ if (map != null)
+ map = map.copy();
+ }
+ }
+
+ return map;
+ }
+
+ private bool set_transformation(KeyValueMap trans) {
+ lock (row) {
+ if (row.transformations == null)
+ row.transformations = new Gee.HashMap<string, KeyValueMap>();
+
+ row.transformations.set(trans.get_group(), trans);
+
+ return PhotoTable.get_instance().set_transformation(row.photo_id, trans);
+ }
+ }
+
+ private bool remove_transformation(string name) {
+ bool altered_cache, altered_persistent;
+ lock (row) {
+ if (row.transformations != null) {
+ altered_cache = row.transformations.unset(name);
+ if (row.transformations.size == 0)
+ row.transformations = null;
+ } else {
+ altered_cache = false;
+ }
+
+ altered_persistent = PhotoTable.get_instance().remove_transformation(row.photo_id,
+ name);
+ }
+
+ return (altered_cache || altered_persistent);
+ }
+
+ public bool has_crop() {
+ return has_transformation("crop");
+ }
+
+ // Returns the crop in the raw photo's coordinate system
+ public bool get_raw_crop(out Box crop) {
+ crop = Box();
+
+ KeyValueMap map = get_transformation("crop");
+ if (map == null)
+ return false;
+
+ int left = map.get_int("left", -1);
+ int top = map.get_int("top", -1);
+ int right = map.get_int("right", -1);
+ int bottom = map.get_int("bottom", -1);
+
+ if (left == -1 || top == -1 || right == -1 || bottom == -1)
+ return false;
+
+ crop = Box(left, top, right, bottom);
+
+ return true;
+ }
+
+ // Sets the crop using the raw photo's unrotated coordinate system
+ private void set_raw_crop(Box crop) {
+ KeyValueMap map = new KeyValueMap("crop");
+ map.set_int("left", crop.left);
+ map.set_int("top", crop.top);
+ map.set_int("right", crop.right);
+ map.set_int("bottom", crop.bottom);
+
+ if (set_transformation(map))
+ notify_altered(new Alteration("image", "crop"));
+ }
+
+ private bool get_raw_straighten(out double angle) {
+ KeyValueMap map = get_transformation("straighten");
+ if (map == null) {
+ angle = 0.0;
+
+ return false;
+ }
+
+ angle = map.get_double("angle", 0.0);
+
+ return true;
+ }
+
+ private void set_raw_straighten(double theta) {
+ KeyValueMap map = new KeyValueMap("straighten");
+ map.set_double("angle", theta);
+
+ if (set_transformation(map)) {
+ notify_altered(new Alteration("image", "straighten"));
+ }
+ }
+
+ // All instances are against the coordinate system of the unscaled, unrotated photo.
+ private EditingTools.RedeyeInstance[] get_raw_redeye_instances() {
+ KeyValueMap map = get_transformation("redeye");
+ if (map == null)
+ return new EditingTools.RedeyeInstance[0];
+
+ int num_points = map.get_int("num_points", -1);
+ assert(num_points > 0);
+
+ EditingTools.RedeyeInstance[] res = new EditingTools.RedeyeInstance[num_points];
+
+ Gdk.Point default_point = {0};
+ default_point.x = -1;
+ default_point.y = -1;
+
+ for (int i = 0; i < num_points; i++) {
+ string center_key = "center%d".printf(i);
+ string radius_key = "radius%d".printf(i);
+
+ res[i].center = map.get_point(center_key, default_point);
+ assert(res[i].center.x != default_point.x);
+ assert(res[i].center.y != default_point.y);
+
+ res[i].radius = map.get_int(radius_key, -1);
+ assert(res[i].radius != -1);
+ }
+
+ return res;
+ }
+
+ public bool has_redeye_transformations() {
+ return has_transformation("redeye");
+ }
+
+ // All instances are against the coordinate system of the unrotated photo.
+ public void add_redeye_instance(EditingTools.RedeyeInstance redeye) {
+ KeyValueMap map = get_transformation("redeye");
+ if (map == null) {
+ map = new KeyValueMap("redeye");
+ map.set_int("num_points", 0);
+ }
+
+ int num_points = map.get_int("num_points", -1);
+ assert(num_points >= 0);
+
+ num_points++;
+
+ string radius_key = "radius%d".printf(num_points - 1);
+ string center_key = "center%d".printf(num_points - 1);
+
+ map.set_int(radius_key, redeye.radius);
+ map.set_point(center_key, redeye.center);
+
+ map.set_int("num_points", num_points);
+
+ if (set_transformation(map))
+ notify_altered(new Alteration("image", "redeye"));
+ }
+
+ // Pixbuf generation
+
+ // Returns dimensions for the pixbuf at various stages of the pipeline.
+ //
+ // scaled_image is the dimensions of the image after a scaled load-and-decode.
+ // scaled_to_viewport is the dimensions of the image sized according to the scaling parameter.
+ // scaled_image and scaled_to_viewport may be different if the photo is cropped.
+ //
+ // Returns true if scaling is to occur, false otherwise. If false, scaled_image will be set to
+ // the raw image dimensions and scaled_to_viewport will be the dimensions of the image scaled
+ // to the Scaling viewport.
+ private bool calculate_pixbuf_dimensions(Scaling scaling, Exception exceptions,
+ out Dimensions scaled_image, out Dimensions scaled_to_viewport) {
+ lock (row) {
+ // this function needs to access various elements of the Photo atomically
+ return locked_calculate_pixbuf_dimensions(scaling, exceptions,
+ out scaled_image, out scaled_to_viewport);
+ }
+ }
+
+ // Must be called with row locked.
+ private bool locked_calculate_pixbuf_dimensions(Scaling scaling, Exception exceptions,
+ out Dimensions scaled_image, out Dimensions scaled_to_viewport) {
+ Dimensions raw = get_raw_dimensions();
+
+ if (scaling.is_unscaled()) {
+ scaled_image = raw;
+ scaled_to_viewport = raw;
+
+ return false;
+ }
+
+ Orientation orientation = get_orientation();
+
+ // If no crop, the scaled_image is simply raw scaled to fit into the viewport. Otherwise,
+ // the image is scaled enough so the cropped region fits the viewport.
+
+ scaled_image = Dimensions();
+ scaled_to_viewport = Dimensions();
+
+ if (exceptions.allows(Exception.CROP)) {
+ Box crop;
+ if (get_raw_crop(out crop)) {
+ // rotate the crop and raw space accordingly ... order is important here, rotate_box
+ // works with the unrotated dimensions in space
+ Dimensions rotated_raw = raw;
+ if (exceptions.allows(Exception.ORIENTATION)) {
+ crop = orientation.rotate_box(raw, crop);
+ rotated_raw = orientation.rotate_dimensions(raw);
+ }
+
+ // scale the rotated crop to fit in the viewport
+ Box scaled_crop = crop.get_scaled(scaling.get_scaled_dimensions(crop.get_dimensions()));
+
+ // the viewport size is the size of the scaled crop
+ scaled_to_viewport = scaled_crop.get_dimensions();
+
+ // only scale the image if the crop is larger than the viewport
+ if (crop.get_width() <= scaled_crop.get_width()
+ && crop.get_height() <= scaled_crop.get_height()) {
+ scaled_image = raw;
+ scaled_to_viewport = crop.get_dimensions();
+
+ return false;
+ }
+ // resize the total pixbuf so the crop slices directly from the scaled pixbuf,
+ // with no need for resizing thereafter. The decoded size is determined by the
+ // proportion of the actual size to the crop size
+ scaled_image = rotated_raw.get_scaled_similar(crop.get_dimensions(),
+ scaled_crop.get_dimensions());
+
+ // derotate, as the loader knows nothing about orientation
+ if (exceptions.allows(Exception.ORIENTATION))
+ scaled_image = orientation.derotate_dimensions(scaled_image);
+ }
+ }
+
+ // if scaled_image not set, merely scale the raw pixbuf
+ if (!scaled_image.has_area()) {
+ // rotate for the scaler
+ Dimensions rotated_raw = raw;
+ if (exceptions.allows(Exception.ORIENTATION))
+ rotated_raw = orientation.rotate_dimensions(raw);
+
+ scaled_image = scaling.get_scaled_dimensions(rotated_raw);
+ scaled_to_viewport = scaled_image;
+
+ // derotate the scaled dimensions, as the loader knows nothing about orientation
+ if (exceptions.allows(Exception.ORIENTATION))
+ scaled_image = orientation.derotate_dimensions(scaled_image);
+ }
+
+ // do not scale up
+ if (scaled_image.width >= raw.width && scaled_image.height >= raw.height) {
+ scaled_image = raw;
+
+ return false;
+ }
+
+ assert(scaled_image.has_area());
+ assert(scaled_to_viewport.has_area());
+
+ return true;
+ }
+
+ // Returns a raw, untransformed, unrotated pixbuf directly from the source. Scaling provides
+ // asked for a scaled-down image, which has certain performance benefits if the resized
+ // JPEG is scaled down by a factor of a power of two (one-half, one-fourth, etc.).
+ private Gdk.Pixbuf load_raw_pixbuf(Scaling scaling, Exception exceptions,
+ BackingFetchMode fetch_mode = BackingFetchMode.BASELINE) throws Error {
+
+ PhotoFileReader loader = get_backing_reader(fetch_mode);
+
+ // no scaling, load and get out
+ if (scaling.is_unscaled()) {
+#if MEASURE_PIPELINE
+ debug("LOAD_RAW_PIXBUF UNSCALED %s: requested", loader.get_filepath());
+#endif
+
+ return loader.unscaled_read();
+ }
+
+ // Need the dimensions of the image to load
+ Dimensions scaled_image, scaled_to_viewport;
+ bool is_scaled = calculate_pixbuf_dimensions(scaling, exceptions, out scaled_image,
+ out scaled_to_viewport);
+ if (!is_scaled) {
+#if MEASURE_PIPELINE
+ debug("LOAD_RAW_PIXBUF UNSCALED %s: scaling unavailable", loader.get_filepath());
+#endif
+
+ return loader.unscaled_read();
+ }
+
+ Gdk.Pixbuf pixbuf = loader.scaled_read(get_raw_dimensions(), scaled_image);
+
+#if MEASURE_PIPELINE
+ debug("LOAD_RAW_PIXBUF %s %s: %s -> %s (actual: %s)", scaling.to_string(), loader.get_filepath(),
+ get_raw_dimensions().to_string(), scaled_image.to_string(),
+ Dimensions.for_pixbuf(pixbuf).to_string());
+#endif
+
+ assert(scaled_image.approx_equals(Dimensions.for_pixbuf(pixbuf), SCALING_FUDGE));
+
+ return pixbuf;
+ }
+
+ // Returns a raw, untransformed, scaled pixbuf from the master that has been optionally rotated
+ // according to its original EXIF settings.
+ public Gdk.Pixbuf get_master_pixbuf(Scaling scaling, bool rotate = true) throws Error {
+ return get_untransformed_pixbuf(scaling, rotate, BackingFetchMode.MASTER);
+ }
+
+ // Returns a pixbuf that hasn't been modified (head of the pipeline.)
+ public Gdk.Pixbuf get_unmodified_pixbuf(Scaling scaling, bool rotate = true) throws Error {
+ return get_untransformed_pixbuf(scaling, rotate, BackingFetchMode.UNMODIFIED);
+ }
+
+ // Returns an untransformed pixbuf with optional scaling, rotation, and fetch mode.
+ private Gdk.Pixbuf get_untransformed_pixbuf(Scaling scaling, bool rotate, BackingFetchMode fetch_mode)
+ throws Error {
+#if MEASURE_PIPELINE
+ Timer timer = new Timer();
+ Timer total_timer = new Timer();
+ double orientation_time = 0.0;
+
+ total_timer.start();
+#endif
+
+ // get required fields all at once, to avoid holding the row lock
+ Dimensions scaled_image, scaled_to_viewport;
+ Orientation original_orientation;
+
+ lock (row) {
+ calculate_pixbuf_dimensions(scaling, Exception.NONE, out scaled_image,
+ out scaled_to_viewport);
+ original_orientation = get_original_orientation();
+ }
+
+ // load-and-decode and scale
+ Gdk.Pixbuf pixbuf = load_raw_pixbuf(scaling, Exception.NONE, fetch_mode);
+
+ // orientation
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (rotate)
+ pixbuf = original_orientation.rotate_pixbuf(pixbuf);
+
+#if MEASURE_PIPELINE
+ orientation_time = timer.elapsed();
+
+ debug("MASTER PIPELINE %s (%s): orientation=%lf total=%lf", to_string(), scaling.to_string(),
+ orientation_time, total_timer.elapsed());
+#endif
+
+ return pixbuf;
+ }
+
+ public override Gdk.Pixbuf get_pixbuf(Scaling scaling) throws Error {
+ return get_pixbuf_with_options(scaling);
+ }
+
+ /**
+ * One-stop shopping for the source pixbuf cache.
+ *
+ * The source pixbuf cache holds untransformed, unscaled (full-sized) pixbufs of Photo objects.
+ * These can be rather large and shouldn't be held in memory for too long, nor should many be
+ * allowed to stack up.
+ *
+ * If locate is non-null, a source pixbuf is returned for the Photo. If keep is true, the
+ * pixbuf is stored in the cache. (Thus, passing a Photo w/ keep == false will drop the cached
+ * pixbuf.) If Photo is non-null but keep is false, null is returned.
+ *
+ * Whether locate is null or not, the cache is walked in its entirety, dropping expired pixbufs
+ * and dropping excessive pixbufs from the LRU. Locating a Photo "touches" the pixbuf, i.e.
+ * it moves to the head of the LRU.
+ */
+ private static Gdk.Pixbuf? run_source_pixbuf_cache(Photo? locate, bool keep) throws Error {
+ lock (source_pixbuf_cache) {
+ CachedPixbuf? found = null;
+
+ // walk list looking for photo to locate (if specified), dropping expired and LRU'd
+ // pixbufs along the way
+ double min_elapsed = double.MAX;
+ int count = 0;
+ Gee.Iterator<CachedPixbuf> iter = source_pixbuf_cache.iterator();
+ while (iter.next()) {
+ CachedPixbuf cached_pixbuf = iter.get();
+
+ double elapsed = Math.trunc(cached_pixbuf.last_touched.elapsed()) + 1;
+
+ if (locate != null && cached_pixbuf.photo.equals(locate)) {
+ // found it, remove and reinsert at head of LRU (below)...
+ iter.remove();
+ found = cached_pixbuf;
+
+ // ...that's why the counter is incremented
+ count++;
+ } else if (elapsed >= SOURCE_PIXBUF_TIME_TO_LIVE_SEC) {
+ iter.remove();
+ } else if (count >= SOURCE_PIXBUF_MAX_LRU_COUNT) {
+ iter.remove();
+ } else {
+ // find the item with the least elapsed time to reschedule a cache trim (to
+ // prevent onesy-twosy reschedules)
+ min_elapsed = double.min(elapsed, min_elapsed);
+ count++;
+ }
+ }
+
+ // if not found and trying to locate one and keep it, generate now
+ if (found == null && locate != null && keep) {
+ found = new CachedPixbuf(locate,
+ locate.load_raw_pixbuf(Scaling.for_original(), Exception.ALL, BackingFetchMode.SOURCE));
+ } else if (found != null) {
+ // since it was located, touch it so it doesn't expire
+ found.last_touched.start();
+ }
+
+ // if keeping it, insert at head of LRU
+ if (found != null && keep) {
+ source_pixbuf_cache.insert(0, found);
+
+ // since this is (re-)inserted, count its elapsed time too ... w/ min_elapsed, this
+ // is almost guaranteed to be the min, since it was was touched mere clock cycles
+ // ago...
+ min_elapsed = double.min(found.last_touched.elapsed(), min_elapsed);
+
+ // ...which means don't need to readjust the min_elapsed when trimming off excess
+ // due to adding back an element
+ while(source_pixbuf_cache.size > SOURCE_PIXBUF_MAX_LRU_COUNT)
+ source_pixbuf_cache.poll_tail();
+ }
+
+ // drop expiration timer...
+ if (discard_source_id != 0) {
+ Source.remove(discard_source_id);
+ discard_source_id = 0;
+ }
+
+ // ...only reschedule if there's something to expire
+ if (source_pixbuf_cache.size > SOURCE_PIXBUF_MIN_LRU_COUNT) {
+ assert(min_elapsed >= 0.0);
+
+ // round-up to avoid a bunch of zero-second timeouts
+ uint retry_sec = SOURCE_PIXBUF_TIME_TO_LIVE_SEC - ((uint) Math.trunc(min_elapsed));
+ discard_source_id = Timeout.add_seconds(retry_sec, trim_source_pixbuf_cache, Priority.LOW);
+ }
+
+ return found != null ? found.pixbuf : null;
+ }
+ }
+
+ private static bool trim_source_pixbuf_cache() {
+ try {
+ run_source_pixbuf_cache(null, false);
+ } catch (Error err) {
+ }
+
+ return false;
+ }
+
+ /**
+ * @brief Get a copy of what's in the cache.
+ *
+ * @return A copy of the Pixbuf with the image data from unmodified_precached.
+ */
+ public Gdk.Pixbuf get_prefetched_copy() throws Error {
+ return run_source_pixbuf_cache(this, true).copy();
+ }
+
+ /**
+ * @brief Discards the cached version of the unmodified image.
+ */
+ public void discard_prefetched() {
+ try {
+ run_source_pixbuf_cache(this, false);
+ } catch (Error err) {
+ }
+ }
+
+ /**
+ * @brief Returns a fully transformed and scaled pixbuf. Transformations may be excluded via
+ * the mask. If the image is smaller than the scaling, it will be returned in its actual size.
+ * The caller is responsible for scaling thereafter.
+ *
+ * @param scaling A scaling object that describes the size the output pixbuf should be.
+ * @param exceptions The parts of the pipeline that should be skipped; defaults to NONE if
+ * left unset.
+ * @param fetch_mode The fetch mode; if left unset, defaults to BASELINE so that
+ * we get the image exactly as it is in the file.
+ */
+ public Gdk.Pixbuf get_pixbuf_with_options(Scaling scaling, Exception exceptions =
+ Exception.NONE, BackingFetchMode fetch_mode = BackingFetchMode.BASELINE) throws Error {
+
+#if MEASURE_PIPELINE
+ Timer timer = new Timer();
+ Timer total_timer = new Timer();
+ double redeye_time = 0.0, crop_time = 0.0, adjustment_time = 0.0, orientation_time = 0.0,
+ straighten_time = 0.0, scale_time = 0.0;
+
+ total_timer.start();
+#endif
+
+ // If this is a RAW photo, ensure the development is ready.
+ if (Photo.develop_raw_photos_to_files &&
+ get_master_file_format() == PhotoFileFormat.RAW &&
+ (fetch_mode == BackingFetchMode.BASELINE || fetch_mode == BackingFetchMode.UNMODIFIED
+ || fetch_mode == BackingFetchMode.SOURCE) &&
+ !is_raw_developer_complete(get_raw_developer()))
+ set_raw_developer(get_raw_developer());
+
+ // to minimize holding the row lock, fetch everything needed for the pipeline up-front
+ bool is_scaled, is_cropped, is_straightened;
+ Dimensions scaled_to_viewport;
+ Dimensions original = Dimensions();
+ Dimensions scaled = Dimensions();
+ EditingTools.RedeyeInstance[] redeye_instances = null;
+ Box crop;
+ double straightening_angle;
+ PixelTransformer transformer = null;
+ Orientation orientation;
+
+ lock (row) {
+ original = get_dimensions(Exception.ALL);
+ scaled = scaling.get_scaled_dimensions(get_dimensions(exceptions));
+ scaled_to_viewport = scaled;
+
+ is_scaled = !(get_dimensions().equals(scaled));
+
+ redeye_instances = get_raw_redeye_instances();
+
+ is_cropped = get_raw_crop(out crop);
+
+ is_straightened = get_raw_straighten(out straightening_angle);
+
+ if (has_color_adjustments())
+ transformer = get_pixel_transformer();
+
+ orientation = get_orientation();
+ }
+
+ //
+ // Image load-and-decode
+ //
+
+ Gdk.Pixbuf pixbuf = get_prefetched_copy();
+
+ //
+ // Image transformation pipeline
+ //
+
+ // redeye reduction
+ if (exceptions.allows(Exception.REDEYE)) {
+
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ foreach (EditingTools.RedeyeInstance instance in redeye_instances) {
+ pixbuf = do_redeye(pixbuf, instance);
+ }
+#if MEASURE_PIPELINE
+ redeye_time = timer.elapsed();
+#endif
+ }
+
+ // angle photograph so in-image horizon is aligned with horizontal
+ if (exceptions.allows(Exception.STRAIGHTEN)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (is_straightened) {
+ pixbuf = rotate_arb(pixbuf, straightening_angle);
+ }
+
+#if MEASURE_PIPELINE
+ straighten_time = timer.elapsed();
+#endif
+ }
+
+ // crop
+ if (exceptions.allows(Exception.CROP)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (is_cropped) {
+
+ // ensure the crop region stays inside the scaled image boundaries and is
+ // at least 1 px by 1 px; this is needed as a work-around for inaccuracies
+ // which can occur when zooming.
+ crop.left = crop.left.clamp(0, pixbuf.width - 2);
+ crop.top = crop.top.clamp(0, pixbuf.height - 2);
+
+ crop.right = crop.right.clamp(crop.left + 1, pixbuf.width - 1);
+ crop.bottom = crop.bottom.clamp(crop.top + 1, pixbuf.height - 1);
+
+ pixbuf = new Gdk.Pixbuf.subpixbuf(pixbuf, crop.left, crop.top, crop.get_width(),
+ crop.get_height());
+ }
+
+#if MEASURE_PIPELINE
+ crop_time = timer.elapsed();
+#endif
+ }
+
+ // orientation (all modifications are stored in unrotated coordinate system)
+ if (exceptions.allows(Exception.ORIENTATION)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ pixbuf = orientation.rotate_pixbuf(pixbuf);
+#if MEASURE_PIPELINE
+ orientation_time = timer.elapsed();
+#endif
+ }
+
+ // scale the scratch image, as needed.
+ if (is_scaled) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ pixbuf = pixbuf.scale_simple(scaled_to_viewport.width, scaled_to_viewport.height, Gdk.InterpType.BILINEAR);
+#if MEASURE_PIPELINE
+ scale_time = timer.elapsed();
+#endif
+ }
+
+ // color adjustment; we do this dead last, since, if an image has been scaled down,
+ // it may allow us to reduce the amount of pixel arithmetic, increasing responsiveness.
+ if (exceptions.allows(Exception.ADJUST)) {
+#if MEASURE_PIPELINE
+ timer.start();
+#endif
+ if (transformer != null)
+ transformer.transform_pixbuf(pixbuf);
+#if MEASURE_PIPELINE
+ adjustment_time = timer.elapsed();
+#endif
+ }
+
+ // This is to verify the generated pixbuf matches the scale requirements; crop, straighten
+ // and orientation are all transformations that change the dimensions or aspect ratio of
+ // the pixbuf, and must be accounted for the test to be valid.
+ if ((is_scaled) && (!is_straightened))
+ assert(scaled_to_viewport.approx_equals(Dimensions.for_pixbuf(pixbuf), SCALING_FUDGE));
+
+#if MEASURE_PIPELINE
+ debug("PIPELINE %s (%s): redeye=%lf crop=%lf adjustment=%lf orientation=%lf straighten=%lf scale=%lf total=%lf",
+ to_string(), scaling.to_string(), redeye_time, crop_time, adjustment_time,
+ orientation_time, straighten_time, scale_time, total_timer.elapsed());
+#endif
+
+ return pixbuf;
+ }
+
+
+ //
+ // File export
+ //
+
+ protected abstract bool has_user_generated_metadata();
+
+ // Sets the metadata values for any user generated metadata, only called if
+ // has_user_generated_metadata returns true
+ protected abstract void set_user_metadata_for_export(PhotoMetadata metadata);
+
+ // Returns the basename of the file if it were to be exported in format 'file_format'; if
+ // 'file_format' is null, then return the basename of the file if it were to be exported in the
+ // native backing format of the photo (i.e. no conversion is performed). If 'file_format' is
+ // null and the native backing format is not writeable (i.e. RAW), then use the system
+ // default file format, as defined in PhotoFileFormat
+ public string get_export_basename(PhotoFileFormat? file_format = null) {
+ if (file_format != null) {
+ return file_format.get_properties().convert_file_extension(get_master_file()).get_basename();
+ } else {
+ if (get_file_format().can_write()) {
+ return get_file_format().get_properties().convert_file_extension(
+ get_master_file()).get_basename();
+ } else {
+ return PhotoFileFormat.get_system_default_format().get_properties().convert_file_extension(
+ get_master_file()).get_basename();
+ }
+ }
+ }
+
+ private bool export_fullsized_backing(File file, bool export_metadata = true) throws Error {
+ // See if the native reader supports writing ... if no matches, need to fall back
+ // on a "regular" export, which requires decoding then encoding
+ PhotoFileReader export_reader = null;
+ bool is_master = true;
+ lock (readers) {
+ if (readers.editable != null && readers.editable.get_file_format().can_write_metadata()) {
+ export_reader = readers.editable;
+ is_master = false;
+ } else if (readers.developer != null && readers.developer.get_file_format().can_write_metadata()) {
+ export_reader = readers.developer;
+ is_master = false;
+ } else if (readers.master.get_file_format().can_write_metadata()) {
+ export_reader = readers.master;
+ }
+ }
+
+ if (export_reader == null)
+ return false;
+
+ PhotoFileFormatProperties format_properties = export_reader.get_file_format().get_properties();
+
+ // Build a destination file with the caller's name but the appropriate extension
+ File dest_file = format_properties.convert_file_extension(file);
+
+ // Create a PhotoFileMetadataWriter that matches the PhotoFileReader's file format
+ PhotoFileMetadataWriter writer = export_reader.get_file_format().create_metadata_writer(
+ dest_file.get_path());
+
+ debug("Exporting full-sized copy of %s to %s", to_string(), writer.get_filepath());
+
+ export_reader.get_file().copy(dest_file,
+ FileCopyFlags.OVERWRITE | FileCopyFlags.TARGET_DEFAULT_PERMS, null, null);
+
+ // If asking for an full-sized file and there are no alterations (transformations or EXIF)
+ // *and* this is a copy of the original backing *and* there's no user metadata or title *and* metadata should be exported, then done
+ if (!has_alterations() && is_master && !has_user_generated_metadata() &&
+ (get_title() == null) && (get_comment() == null) && export_metadata)
+ return true;
+
+ // copy over relevant metadata if possible, otherwise generate new metadata
+ PhotoMetadata? metadata = export_reader.read_metadata();
+ if (metadata == null)
+ metadata = export_reader.get_file_format().create_metadata();
+
+ debug("Updating metadata of %s", writer.get_filepath());
+
+ if (get_exposure_time() != 0)
+ metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time()));
+ else
+ metadata.set_exposure_date_time(null);
+
+ if(export_metadata) {
+ //set metadata
+ metadata.set_title(get_title());
+ metadata.set_comment(get_comment());
+ metadata.set_pixel_dimensions(get_dimensions()); // created by sniffing pixbuf not metadata
+ metadata.set_orientation(get_orientation());
+ metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION);
+
+ if (get_orientation() != get_original_orientation())
+ metadata.remove_exif_thumbnail();
+
+ set_user_metadata_for_export(metadata);
+ }
+ else
+ //delete metadata
+ metadata.clear();
+
+ writer.write_metadata(metadata);
+
+ return true;
+ }
+
+ // Returns true if there's any reason that an export is required to fully represent the photo
+ // on disk. False essentially means that the source file (NOT NECESSARILY the master file)
+ // *is* the full representation of the photo and its metadata.
+ public bool is_export_required(Scaling scaling, PhotoFileFormat export_format) {
+ return (!scaling.is_unscaled() || has_alterations() || has_user_generated_metadata()
+ || export_format != get_file_format());
+ }
+
+ // TODO: Lossless transformations, especially for mere rotations of JFIF files.
+ //
+ // This method is thread-safe.
+ public void export(File dest_file, Scaling scaling, Jpeg.Quality quality,
+ PhotoFileFormat export_format, bool direct_copy_unmodified = false, bool export_metadata = true) throws Error {
+ if (direct_copy_unmodified) {
+ get_master_file().copy(dest_file, FileCopyFlags.OVERWRITE |
+ FileCopyFlags.TARGET_DEFAULT_PERMS, null, null);
+ return;
+ }
+
+ // Attempt to avoid decode/encoding cycle when exporting original-sized photos for lossy
+ // formats, as that degrades image quality. If alterations exist, but only EXIF has
+ // changed and the user hasn't requested conversion between image formats, then just copy
+ // the original file and update relevant EXIF.
+ if (scaling.is_unscaled() && (!has_alterations() || only_metadata_changed()) &&
+ (export_format == get_file_format()) && (get_file_format() == PhotoFileFormat.JFIF)) {
+ if (export_fullsized_backing(dest_file, export_metadata))
+ return;
+ }
+
+ // Copy over existing metadata from source if available, or create new metadata and
+ // save it for later export below. This has to happen before the format writer writes
+ // out the modified image, as that write will strip the existing exif data.
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata == null)
+ metadata = export_format.create_metadata();
+
+ if (!export_format.can_write())
+ export_format = PhotoFileFormat.get_system_default_format();
+
+ PhotoFileWriter writer = export_format.create_writer(dest_file.get_path());
+
+ debug("Saving transformed version of %s to %s in file format %s", to_string(),
+ writer.get_filepath(), export_format.to_string());
+
+ Gdk.Pixbuf pixbuf;
+
+ // Since JPEGs can store their own orientation, we save the pixels
+ // directly and let the orientation field do the rotation...
+ if ((get_file_format() == PhotoFileFormat.JFIF) ||
+ (get_file_format() == PhotoFileFormat.RAW)) {
+ pixbuf = get_pixbuf_with_options(scaling, Exception.ORIENTATION,
+ BackingFetchMode.SOURCE);
+ } else {
+ // Non-JPEG image - we'll need to save the rotated pixels.
+ pixbuf = get_pixbuf_with_options(scaling, Exception.NONE,
+ BackingFetchMode.SOURCE);
+ }
+
+ writer.write(pixbuf, quality);
+
+ debug("Setting EXIF for %s", writer.get_filepath());
+
+ // Do we need to save metadata to this file?
+ if (export_metadata) {
+ //Yes, set metadata obtained above.
+ metadata.set_title(get_title());
+ metadata.set_comment(get_comment());
+ metadata.set_software(Resources.APP_TITLE, Resources.APP_VERSION);
+
+ if (get_exposure_time() != 0)
+ metadata.set_exposure_date_time(new MetadataDateTime(get_exposure_time()));
+ else
+ metadata.set_exposure_date_time(null);
+
+ metadata.remove_tag("Exif.Iop.RelatedImageWidth");
+ metadata.remove_tag("Exif.Iop.RelatedImageHeight");
+ metadata.remove_exif_thumbnail();
+
+ if (has_user_generated_metadata())
+ set_user_metadata_for_export(metadata);
+ }
+ else {
+ //No, delete metadata.
+ metadata.clear();
+ }
+
+ // Even if we were told to trash camera-identifying data, we need
+ // to make sure the orientation propagates. Also, because JPEGs
+ // can store their own orientation, we'll save the original dimensions
+ // directly and let the orientation field do the rotation there.
+ if ((get_file_format() == PhotoFileFormat.JFIF) ||
+ (get_file_format() == PhotoFileFormat.RAW)) {
+ metadata.set_pixel_dimensions(get_dimensions(Exception.ORIENTATION));
+ metadata.set_orientation(get_orientation());
+ } else {
+ // Non-JPEG image - we'll need to save the rotated dimensions.
+ metadata.set_pixel_dimensions(Dimensions.for_pixbuf(pixbuf));
+ metadata.set_orientation(Orientation.TOP_LEFT);
+ }
+
+ export_format.create_metadata_writer(dest_file.get_path()).write_metadata(metadata);
+ }
+
+ private File generate_new_editable_file(out PhotoFileFormat file_format) throws Error {
+ File backing;
+ lock (row) {
+ file_format = get_file_format();
+ backing = get_file();
+ }
+
+ if (!file_format.can_write())
+ file_format = PhotoFileFormat.get_system_default_format();
+
+ string name, ext;
+ disassemble_filename(backing.get_basename(), out name, out ext);
+
+ if (ext == null || !file_format.get_properties().is_recognized_extension(ext))
+ ext = file_format.get_properties().get_default_extension();
+
+ string editable_basename = "%s_%s.%s".printf(name, _("modified"), ext);
+
+ bool collision;
+ return generate_unique_file(backing.get_parent(), editable_basename, out collision);
+ }
+
+ private static bool launch_editor(File file, PhotoFileFormat file_format) throws Error {
+ string commandline = file_format == PhotoFileFormat.RAW ? Config.Facade.get_instance().get_external_raw_app() :
+ Config.Facade.get_instance().get_external_photo_app();
+
+ if (is_string_empty(commandline))
+ return false;
+
+ AppInfo? app;
+ try {
+ app = AppInfo.create_from_commandline(commandline, "",
+ AppInfoCreateFlags.NONE);
+ } catch (Error er) {
+ app = null;
+ }
+
+ List<File> files = new List<File>();
+ files.insert(file, -1);
+
+ if (app != null)
+ return app.launch(files, null);
+
+ string[] argv = new string[2];
+ argv[0] = commandline;
+ argv[1] = file.get_path();
+
+ Pid child_pid;
+
+ return Process.spawn_async(
+ "/",
+ argv,
+ null, // environment
+ SpawnFlags.SEARCH_PATH,
+ null, // child setup
+ out child_pid);
+ }
+
+ // Opens with Ufraw, etc.
+ public void open_with_raw_external_editor() throws Error {
+ launch_editor(get_master_file(), get_master_file_format());
+ }
+
+ // Opens with GIMP, etc.
+ public void open_with_external_editor() throws Error {
+ File current_editable_file = null;
+ File create_editable_file = null;
+ PhotoFileFormat editable_file_format;
+ lock (readers) {
+ if (readers.editable != null)
+ current_editable_file = readers.editable.get_file();
+
+ if (current_editable_file == null)
+ create_editable_file = generate_new_editable_file(out editable_file_format);
+ else
+ editable_file_format = readers.editable.get_file_format();
+ }
+
+ // if this isn't the first time but the file does not exist OR there are transformations
+ // that need to be represented there, create a new one
+ if (create_editable_file == null && current_editable_file != null &&
+ (!current_editable_file.query_exists(null) || has_transformations()))
+ create_editable_file = current_editable_file;
+
+ // if creating a new edited file and can write to it, stop watching the old one
+ if (create_editable_file != null && editable_file_format.can_write()) {
+ halt_monitoring_editable();
+
+ try {
+ export(create_editable_file, Scaling.for_original(), Jpeg.Quality.MAXIMUM,
+ editable_file_format);
+ } catch (Error err) {
+ // if an error is thrown creating the file, clean it up
+ try {
+ create_editable_file.delete(null);
+ } catch (Error delete_err) {
+ // ignored
+ warning("Unable to delete editable file %s after export error: %s",
+ create_editable_file.get_path(), delete_err.message);
+ }
+
+ throw err;
+ }
+
+ // attach the editable file to the photo
+ attach_editable(editable_file_format, create_editable_file);
+
+ current_editable_file = create_editable_file;
+ }
+
+ assert(current_editable_file != null);
+
+ // if not already monitoring, monitor now
+ if (editable_monitor == null)
+ start_monitoring_editable(current_editable_file);
+
+ launch_editor(current_editable_file, get_file_format());
+ }
+
+ public void revert_to_master(bool notify = true) {
+ detach_editable(true, true, notify);
+ }
+
+ private void start_monitoring_editable(File file) throws Error {
+ halt_monitoring_editable();
+
+ // tell the LibraryMonitor not to monitor this file
+ LibraryMonitor.blacklist_file(file, "Photo.start_monitoring_editable");
+
+ editable_monitor = file.monitor(FileMonitorFlags.NONE, null);
+ editable_monitor.changed.connect(on_editable_file_changed);
+ }
+
+ private void halt_monitoring_editable() {
+ if (editable_monitor == null)
+ return;
+
+ // tell the LibraryMonitor a-ok to watch this file again
+ File? file = get_editable_file();
+ if (file != null)
+ LibraryMonitor.unblacklist_file(file);
+
+ editable_monitor.changed.disconnect(on_editable_file_changed);
+ editable_monitor.cancel();
+ editable_monitor = null;
+ }
+
+ private void attach_editable(PhotoFileFormat file_format, File file) throws Error {
+ // remove the transformations ... this must be done before attaching the editable, as these
+ // transformations are in the master's coordinate system, not the editable's ... don't
+ // notify photo is altered *yet* because update_editable will notify, and want to avoid
+ // stacking them up
+ internal_remove_all_transformations(false);
+ update_editable(false, file_format.create_reader(file.get_path()));
+ }
+
+ private void update_editable_attributes() throws Error {
+ update_editable(true, null);
+ }
+
+ public void reimport_editable() throws Error {
+ update_editable(false, null);
+ }
+
+ // In general, because of the fragility of the order of operations and what's required where,
+ // use one of the above wrapper functions to call this rather than call this directly.
+ private void update_editable(bool only_attributes, PhotoFileReader? new_reader = null) throws Error {
+ // only_attributes only available for updating existing editable
+ assert((only_attributes && new_reader == null) || (!only_attributes));
+
+ PhotoFileReader? old_reader = get_editable_reader();
+
+ PhotoFileReader reader = new_reader ?? old_reader;
+ if (reader == null) {
+ detach_editable(false, true);
+
+ return;
+ }
+
+ bool timestamp_changed = false;
+ bool filesize_changed = false;
+ bool is_new_editable = false;
+
+ BackingPhotoID editable_id = get_editable_id();
+ File file = reader.get_file();
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow? backing = query_backing_photo_row(file, PhotoFileSniffer.Options.NO_MD5,
+ out detected);
+
+ // Have we _not_ got an editable attached yet?
+ if (editable_id.is_invalid()) {
+ // Yes, try to create and attach one.
+ if (backing != null) {
+ BackingPhotoTable.get_instance().add(backing);
+ lock (row) {
+ timestamp_changed = true;
+ filesize_changed = true;
+
+ PhotoTable.get_instance().attach_editable(row, backing.id);
+ editable = backing;
+ backing_photo_row = editable;
+ set_orientation(backing_photo_row.original_orientation);
+ }
+ }
+ is_new_editable = true;
+ }
+
+ if (only_attributes) {
+ // This should only be possible if the editable exists already.
+ assert(editable_id.is_valid());
+
+ FileInfo info;
+ try {
+ info = file.query_filesystem_info(DirectoryMonitor.SUPPLIED_ATTRIBUTES, null);
+ } catch (Error err) {
+ warning("Unable to read editable filesystem info for %s: %s", to_string(), err.message);
+ detach_editable(false, true);
+
+ return;
+ }
+
+ TimeVal timestamp = info.get_modification_time();
+
+ BackingPhotoTable.get_instance().update_attributes(editable_id, timestamp.tv_sec,
+ info.get_size());
+ lock (row) {
+ timestamp_changed = editable.timestamp != timestamp.tv_sec;
+ filesize_changed = editable.filesize != info.get_size();
+
+ editable.timestamp = timestamp.tv_sec;
+ editable.filesize = info.get_size();
+ }
+ } else {
+ // Not just a file-attribute-only change.
+ if (editable_id.is_valid() && !is_new_editable) {
+ // Only check these if we didn't just have to create
+ // this editable, since, with a newly-created editable,
+ // the file size and modification time are by definition
+ // freshly-changed.
+ backing.id = editable_id;
+ BackingPhotoTable.get_instance().update(backing);
+ lock (row) {
+ timestamp_changed = editable.timestamp != backing.timestamp;
+ filesize_changed = editable.filesize != backing.filesize;
+
+ editable = backing;
+ backing_photo_row = editable;
+ set_orientation(backing_photo_row.original_orientation);
+ }
+ }
+ }
+
+ // if a new reader was specified, install that and begin using it
+ if (new_reader != null) {
+ lock (readers) {
+ readers.editable = new_reader;
+ }
+ }
+
+ if (!only_attributes && reader != old_reader) {
+ notify_baseline_replaced();
+ notify_editable_replaced(old_reader != null ? old_reader.get_file() : null,
+ new_reader != null ? new_reader.get_file() : null);
+ }
+
+ string[] alteration_list = new string[0];
+ if (timestamp_changed) {
+ alteration_list += "metadata:editable-timestamp";
+ alteration_list += "metadata:baseline-timestamp";
+
+ if (is_editable_source())
+ alteration_list += "metadata:source-timestamp";
+ }
+
+ if (filesize_changed || new_reader != null) {
+ alteration_list += "image:editable";
+ alteration_list += "image:baseline";
+
+ if (is_editable_source())
+ alteration_list += "image:source";
+ }
+
+ if (alteration_list.length > 0)
+ notify_altered(new Alteration.from_array(alteration_list));
+ }
+
+ private void detach_editable(bool delete_editable, bool remove_transformations, bool notify = true) {
+ halt_monitoring_editable();
+
+ bool has_editable = false;
+ File? editable_file = null;
+ lock (readers) {
+ if (readers.editable != null) {
+ editable_file = readers.editable.get_file();
+ readers.editable = null;
+ has_editable = true;
+ }
+ }
+
+ if (has_editable) {
+ BackingPhotoID editable_id = BackingPhotoID();
+ try {
+ lock (row) {
+ editable_id = row.editable_id;
+ if (editable_id.is_valid())
+ PhotoTable.get_instance().detach_editable(row);
+ backing_photo_row = row.master;
+ }
+ } catch (DatabaseError err) {
+ warning("Unable to remove editable from PhotoTable: %s", err.message);
+ }
+
+ try {
+ if (editable_id.is_valid())
+ BackingPhotoTable.get_instance().remove(editable_id);
+ } catch (DatabaseError err) {
+ warning("Unable to remove editable from BackingPhotoTable: %s", err.message);
+ }
+ }
+
+ if (remove_transformations)
+ internal_remove_all_transformations(false);
+
+ if (has_editable) {
+ notify_baseline_replaced();
+ notify_editable_replaced(editable_file, null);
+ }
+
+ if (delete_editable && editable_file != null) {
+ try {
+ editable_file.trash(null);
+ } catch (Error err) {
+ warning("Unable to trash editable %s for %s: %s", editable_file.get_path(), to_string(),
+ err.message);
+ }
+ }
+
+ if ((has_editable || remove_transformations) && notify)
+ notify_altered(new Alteration("image", "revert"));
+ }
+
+ private void on_editable_file_changed(File file, File? other_file, FileMonitorEvent event) {
+ // This has some expense, but this assertion is important for a lot of sanity reasons.
+ lock (readers) {
+ assert(readers.editable != null && file.equal(readers.editable.get_file()));
+ }
+
+ debug("EDITABLE %s: %s", event.to_string(), file.get_path());
+
+ switch (event) {
+ case FileMonitorEvent.CHANGED:
+ case FileMonitorEvent.CREATED:
+ if (reimport_editable_scheduler == null) {
+ reimport_editable_scheduler = new OneShotScheduler("Photo.reimport_editable",
+ on_reimport_editable);
+ }
+
+ reimport_editable_scheduler.after_timeout(1000, true);
+ break;
+
+ case FileMonitorEvent.ATTRIBUTE_CHANGED:
+ if (update_editable_attributes_scheduler == null) {
+ update_editable_attributes_scheduler = new OneShotScheduler(
+ "Photo.update_editable_attributes", on_update_editable_attributes);
+ }
+
+ update_editable_attributes_scheduler.after_timeout(1000, true);
+ break;
+
+ case FileMonitorEvent.DELETED:
+ if (remove_editable_scheduler == null) {
+ remove_editable_scheduler = new OneShotScheduler("Photo.remove_editable",
+ on_remove_editable);
+ }
+
+ remove_editable_scheduler.after_timeout(3000, true);
+ break;
+
+ case FileMonitorEvent.CHANGES_DONE_HINT:
+ default:
+ // ignored
+ break;
+ }
+
+ // at this point, any image date we have cached is stale,
+ // so delete it and force the pipeline to re-fetch it
+ discard_prefetched();
+ }
+
+ private void on_reimport_editable() {
+ // delete old image data and force the pipeline to load new from file.
+ discard_prefetched();
+
+ debug("Reimporting editable for %s", to_string());
+ try {
+ reimport_editable();
+ } catch (Error err) {
+ warning("Unable to reimport photo %s changed by external editor: %s",
+ to_string(), err.message);
+ }
+ }
+
+ private void on_update_editable_attributes() {
+ debug("Updating editable attributes for %s", to_string());
+ try {
+ update_editable_attributes();
+ } catch (Error err) {
+ warning("Unable to update editable attributes: %s", err.message);
+ }
+ }
+
+ private void on_remove_editable() {
+ PhotoFileReader? reader = get_editable_reader();
+ if (reader == null)
+ return;
+
+ File file = reader.get_file();
+ if (file.query_exists(null)) {
+ debug("Not removing editable for %s: file exists", to_string());
+
+ return;
+ }
+
+ debug("Removing editable for %s: file no longer exists", to_string());
+ detach_editable(false, true);
+ }
+
+ //
+ // Aggregate/helper/translation functions
+ //
+
+ // Returns uncropped (but rotated) dimensions
+ public Dimensions get_original_dimensions() {
+ Dimensions dim = get_raw_dimensions();
+ Orientation orientation = get_orientation();
+
+ return orientation.rotate_dimensions(dim);
+ }
+
+ // Returns uncropped dimensions rotated only to reflect the original orientation
+ public Dimensions get_master_dimensions() {
+ return get_original_orientation().rotate_dimensions(get_raw_dimensions());
+ }
+
+ // Returns the crop against the coordinate system of the rotated photo
+ public bool get_crop(out Box crop, Exception exceptions = Exception.NONE) {
+ Box raw;
+ if (!get_raw_crop(out raw)) {
+ crop = Box();
+
+ return false;
+ }
+
+ Dimensions dim = get_dimensions(Exception.CROP | Exception.ORIENTATION);
+ Orientation orientation = get_orientation();
+
+ if(exceptions.allows(Exception.ORIENTATION))
+ crop = orientation.rotate_box(dim, raw);
+ else
+ crop = raw;
+
+ return true;
+ }
+
+ // Sets the crop against the coordinate system of the rotated photo
+ public void set_crop(Box crop) {
+ Dimensions dim = get_dimensions(Exception.CROP | Exception.ORIENTATION);
+ Orientation orientation = get_orientation();
+
+ Box derotated = orientation.derotate_box(dim, crop);
+
+ derotated.left = derotated.left.clamp(0, dim.width - 2);
+ derotated.right = derotated.right.clamp(derotated.left, dim.width - 1);
+
+ derotated.top = derotated.top.clamp(0, dim.height - 2);
+ derotated.bottom = derotated.bottom.clamp(derotated.top, dim.height - 1);
+
+ set_raw_crop(derotated);
+ }
+
+ public bool get_straighten(out double theta) {
+ if (!get_raw_straighten(out theta))
+ return false;
+
+ return true;
+ }
+
+ public void set_straighten(double theta) {
+ set_raw_straighten(theta);
+ }
+
+ private Gdk.Pixbuf do_redeye(Gdk.Pixbuf pixbuf, EditingTools.RedeyeInstance inst) {
+ /* we remove redeye within a circular region called the "effect
+ extent." the effect extent is inscribed within its "bounding
+ rectangle." */
+
+ /* for each scanline in the top half-circle of the effect extent,
+ compute the number of pixels by which the effect extent is inset
+ from the edges of its bounding rectangle. note that we only have
+ to do this for the first quadrant because the second quadrant's
+ insets can be derived by symmetry */
+ double r = (double) inst.radius;
+ int[] x_insets_first_quadrant = new int[inst.radius + 1];
+
+ int i = 0;
+ for (double y = r; y >= 0.0; y -= 1.0) {
+ double theta = Math.asin(y / r);
+ int x = (int)((r * Math.cos(theta)) + 0.5);
+ x_insets_first_quadrant[i] = inst.radius - x;
+
+ i++;
+ }
+
+ int x_bounds_min = inst.center.x - inst.radius;
+ int x_bounds_max = inst.center.x + inst.radius;
+ int ymin = inst.center.y - inst.radius;
+ ymin = (ymin < 0) ? 0 : ymin;
+ int ymax = inst.center.y;
+ ymax = (ymax > (pixbuf.height - 1)) ? (pixbuf.height - 1) : ymax;
+
+ /* iterate over all the pixels in the top half-circle of the effect
+ extent from top to bottom */
+ int inset_index = 0;
+ for (int y_it = ymin; y_it <= ymax; y_it++) {
+ int xmin = x_bounds_min + x_insets_first_quadrant[inset_index];
+ xmin = (xmin < 0) ? 0 : xmin;
+ int xmax = x_bounds_max - x_insets_first_quadrant[inset_index];
+ xmax = (xmax > (pixbuf.width - 1)) ? (pixbuf.width - 1) : xmax;
+
+ for (int x_it = xmin; x_it <= xmax; x_it++) {
+ red_reduce_pixel(pixbuf, x_it, y_it);
+ }
+ inset_index++;
+ }
+
+ /* iterate over all the pixels in the top half-circle of the effect
+ extent from top to bottom */
+ ymin = inst.center.y;
+ ymax = inst.center.y + inst.radius;
+ inset_index = x_insets_first_quadrant.length - 1;
+ for (int y_it = ymin; y_it <= ymax; y_it++) {
+ int xmin = x_bounds_min + x_insets_first_quadrant[inset_index];
+ xmin = (xmin < 0) ? 0 : xmin;
+ int xmax = x_bounds_max - x_insets_first_quadrant[inset_index];
+ xmax = (xmax > (pixbuf.width - 1)) ? (pixbuf.width - 1) : xmax;
+
+ for (int x_it = xmin; x_it <= xmax; x_it++) {
+ red_reduce_pixel(pixbuf, x_it, y_it);
+ }
+ inset_index--;
+ }
+
+ return pixbuf;
+ }
+
+ private Gdk.Pixbuf red_reduce_pixel(Gdk.Pixbuf pixbuf, int x, int y) {
+ int px_start_byte_offset = (y * pixbuf.get_rowstride()) +
+ (x * pixbuf.get_n_channels());
+
+ /* Due to inaccuracies in the scaler, we can occasionally
+ * get passed a coordinate pair outside the image, causing
+ * us to walk off the array and into segfault territory.
+ * Check coords prior to drawing to prevent this... */
+ if ((x >= 0) && (y >= 0) && (x < pixbuf.width) && (y < pixbuf.height)) {
+ unowned uchar[] pixel_data = pixbuf.get_pixels();
+
+ /* The pupil of the human eye has no pigment, so we expect all
+ color channels to be of about equal intensity. This means that at
+ any point within the effects region, the value of the red channel
+ should be about the same as the values of the green and blue
+ channels. So set the value of the red channel to be the mean of the
+ values of the red and blue channels. This preserves achromatic
+ intensity across all channels while eliminating any extraneous flare
+ affecting the red channel only (i.e. the red-eye effect). */
+ uchar g = pixel_data[px_start_byte_offset + 1];
+ uchar b = pixel_data[px_start_byte_offset + 2];
+
+ uchar r = (g + b) / 2;
+
+ pixel_data[px_start_byte_offset] = r;
+ }
+
+ return pixbuf;
+ }
+
+ public Gdk.Point unscaled_to_raw_point(Gdk.Point unscaled_point) {
+ Orientation unscaled_orientation = get_orientation();
+
+ Dimensions unscaled_dims =
+ unscaled_orientation.rotate_dimensions(get_dimensions());
+
+ int unscaled_x_offset_raw = 0;
+ int unscaled_y_offset_raw = 0;
+
+ Box crop_box;
+ if (get_raw_crop(out crop_box)) {
+ unscaled_x_offset_raw = crop_box.left;
+ unscaled_y_offset_raw = crop_box.top;
+ }
+
+ Gdk.Point derotated_point =
+ unscaled_orientation.derotate_point(unscaled_dims,
+ unscaled_point);
+
+ derotated_point.x += unscaled_x_offset_raw;
+ derotated_point.y += unscaled_y_offset_raw;
+
+ return derotated_point;
+ }
+
+ public Gdk.Rectangle unscaled_to_raw_rect(Gdk.Rectangle unscaled_rect) {
+ Gdk.Point upper_left = {0};
+ Gdk.Point lower_right = {0};
+ upper_left.x = unscaled_rect.x;
+ upper_left.y = unscaled_rect.y;
+ lower_right.x = upper_left.x + unscaled_rect.width;
+ lower_right.y = upper_left.y + unscaled_rect.height;
+
+ upper_left = unscaled_to_raw_point(upper_left);
+ lower_right = unscaled_to_raw_point(lower_right);
+
+ if (upper_left.x > lower_right.x) {
+ int temp = upper_left.x;
+ upper_left.x = lower_right.x;
+ lower_right.x = temp;
+ }
+ if (upper_left.y > lower_right.y) {
+ int temp = upper_left.y;
+ upper_left.y = lower_right.y;
+ lower_right.y = temp;
+ }
+
+ Gdk.Rectangle raw_rect = Gdk.Rectangle();
+ raw_rect.x = upper_left.x;
+ raw_rect.y = upper_left.y;
+ raw_rect.width = lower_right.x - upper_left.x;
+ raw_rect.height = lower_right.y - upper_left.y;
+
+ return raw_rect;
+ }
+
+ public PixelTransformationBundle? get_enhance_transformations() {
+ Gdk.Pixbuf pixbuf = null;
+
+#if MEASURE_ENHANCE
+ Timer fetch_timer = new Timer();
+#endif
+
+ try {
+ pixbuf = get_pixbuf_with_options(Scaling.for_best_fit(360, false),
+ Photo.Exception.ALL);
+
+#if MEASURE_ENHANCE
+ fetch_timer.stop();
+#endif
+ } catch (Error e) {
+ warning("Photo: get_enhance_transformations: couldn't obtain pixbuf to build " +
+ "transform histogram");
+ return null;
+ }
+
+#if MEASURE_ENHANCE
+ Timer analyze_timer = new Timer();
+#endif
+
+ PixelTransformationBundle transformations = AutoEnhance.create_auto_enhance_adjustments(pixbuf);
+
+#if MEASURE_ENHANCE
+ analyze_timer.stop();
+ debug("Auto-Enhance fetch time: %f sec; analyze time: %f sec", fetch_timer.elapsed(),
+ analyze_timer.elapsed());
+#endif
+
+ return transformations;
+ }
+
+ public bool enhance() {
+ PixelTransformationBundle transformations = get_enhance_transformations();
+
+ if (transformations == null)
+ return false;
+
+#if MEASURE_ENHANCE
+ Timer apply_timer = new Timer();
+#endif
+ lock (row) {
+ set_color_adjustments(transformations);
+ }
+
+#if MEASURE_ENHANCE
+ apply_timer.stop();
+ debug("Auto-Enhance apply time: %f sec", apply_timer.elapsed());
+#endif
+ return true;
+ }
+}
+
+public class LibraryPhotoSourceCollection : MediaSourceCollection {
+ public enum State {
+ UNKNOWN,
+ ONLINE,
+ OFFLINE,
+ TRASH,
+ EDITABLE,
+ DEVELOPER
+ }
+
+ public override TransactionController transaction_controller {
+ get {
+ if (_transaction_controller == null)
+ _transaction_controller = new MediaSourceTransactionController(this);
+
+ return _transaction_controller;
+ }
+ }
+
+ private TransactionController? _transaction_controller = null;
+ private Gee.HashMap<File, LibraryPhoto> by_editable_file = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.HashMap<File, LibraryPhoto> by_raw_development_file = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.MultiMap<int64?, LibraryPhoto> filesize_to_photo =
+ new Gee.TreeMultiMap<int64?, LibraryPhoto>(int64_compare);
+ private Gee.HashMap<LibraryPhoto, int64?> photo_to_master_filesize =
+ new Gee.HashMap<LibraryPhoto, int64?>(null, null, int64_equal);
+ private Gee.HashMap<LibraryPhoto, int64?> photo_to_editable_filesize =
+ new Gee.HashMap<LibraryPhoto, int64?>(null, null, int64_equal);
+ private Gee.MultiMap<LibraryPhoto, int64?> photo_to_raw_development_filesize =
+ new Gee.TreeMultiMap<LibraryPhoto, int64?>();
+
+ public virtual signal void master_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public virtual signal void editable_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public virtual signal void baseline_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public virtual signal void source_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ }
+
+ public LibraryPhotoSourceCollection() {
+ base ("LibraryPhotoSourceCollection", Photo.get_photo_key);
+
+ get_trashcan().contents_altered.connect(on_trashcan_contents_altered);
+ get_offline_bin().contents_altered.connect(on_offline_contents_altered);
+ }
+
+ protected override MediaSourceHoldingTank create_trashcan() {
+ return new LibraryPhotoSourceHoldingTank(this, check_if_trashed_photo, Photo.get_photo_key);
+ }
+
+ protected override MediaSourceHoldingTank create_offline_bin() {
+ return new LibraryPhotoSourceHoldingTank(this, check_if_offline_photo, Photo.get_photo_key);
+ }
+
+ public override MediaMonitor create_media_monitor(Workers workers, Cancellable cancellable) {
+ return new PhotoMonitor(workers, cancellable);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is LibraryPhoto;
+ }
+
+ public override string get_typename() {
+ return Photo.TYPENAME;
+ }
+
+ public override bool is_file_recognized(File file) {
+ return PhotoFileFormat.is_file_supported(file);
+ }
+
+ protected override void notify_contents_altered(Gee.Iterable<DataObject>? added,
+ Gee.Iterable<DataObject>? removed) {
+ if (added != null) {
+ foreach (DataObject object in added) {
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ File? editable = photo.get_editable_file();
+ if (editable != null)
+ by_editable_file.set(editable, photo);
+ photo.editable_replaced.connect(on_editable_replaced);
+
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null)
+ foreach (File f in raw_list)
+ by_raw_development_file.set(f, photo);
+ photo.raw_development_modified.connect(on_raw_development_modified);
+
+ int64 master_filesize = photo.get_master_photo_row().filesize;
+ int64 editable_filesize = photo.get_editable_photo_row() != null
+ ? photo.get_editable_photo_row().filesize
+ : -1;
+ filesize_to_photo.set(master_filesize, photo);
+ photo_to_master_filesize.set(photo, master_filesize);
+ if (editable_filesize >= 0) {
+ filesize_to_photo.set(editable_filesize, photo);
+ photo_to_editable_filesize.set(photo, editable_filesize);
+ }
+
+ Gee.Collection<BackingPhotoRow>? raw_rows = photo.get_raw_development_photo_rows();
+ if (raw_rows != null) {
+ foreach (BackingPhotoRow row in raw_rows) {
+ if (row.filesize >= 0) {
+ filesize_to_photo.set(row.filesize, photo);
+ photo_to_raw_development_filesize.set(photo, row.filesize);
+ }
+ }
+ }
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataObject object in removed) {
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ File? editable = photo.get_editable_file();
+ if (editable != null) {
+ bool is_removed = by_editable_file.unset(photo.get_editable_file());
+ assert(is_removed);
+ }
+ photo.editable_replaced.disconnect(on_editable_replaced);
+
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null)
+ foreach (File f in raw_list)
+ by_raw_development_file.unset(f);
+ photo.raw_development_modified.disconnect(on_raw_development_modified);
+
+ int64 master_filesize = photo.get_master_photo_row().filesize;
+ int64 editable_filesize = photo.get_editable_photo_row() != null
+ ? photo.get_editable_photo_row().filesize
+ : -1;
+ filesize_to_photo.remove(master_filesize, photo);
+ photo_to_master_filesize.unset(photo);
+ if (editable_filesize >= 0) {
+ filesize_to_photo.remove(editable_filesize, photo);
+ photo_to_editable_filesize.unset(photo);
+ }
+
+ Gee.Collection<BackingPhotoRow>? raw_rows = photo.get_raw_development_photo_rows();
+ if (raw_rows != null) {
+ foreach (BackingPhotoRow row in raw_rows) {
+ if (row.filesize >= 0) {
+ filesize_to_photo.remove(row.filesize, photo);
+ photo_to_raw_development_filesize.remove(photo, row.filesize);
+ }
+ }
+ }
+ }
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ private void on_editable_replaced(Photo photo, File? old_file, File? new_file) {
+ if (old_file != null) {
+ bool is_removed = by_editable_file.unset(old_file);
+ assert(is_removed);
+ }
+
+ if (new_file != null)
+ by_editable_file.set(new_file, (LibraryPhoto) photo);
+ }
+
+ private void on_raw_development_modified(Photo _photo) {
+ LibraryPhoto? photo = _photo as LibraryPhoto;
+ if (photo == null)
+ return;
+
+ // Unset existing files.
+ if (photo_to_raw_development_filesize.contains(photo)) {
+ foreach (int64 s in photo_to_raw_development_filesize.get(photo))
+ filesize_to_photo.remove(s, photo);
+ photo_to_raw_development_filesize.remove_all(photo);
+ }
+
+ // Add new ones.
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null)
+ foreach (File f in raw_list)
+ by_raw_development_file.set(f, photo);
+
+ Gee.Collection<BackingPhotoRow>? raw_rows = photo.get_raw_development_photo_rows();
+ if (raw_rows != null) {
+ foreach (BackingPhotoRow row in raw_rows) {
+ if (row.filesize > 0) {
+ filesize_to_photo.set(row.filesize, photo);
+ photo_to_raw_development_filesize.set(photo, row.filesize);
+ }
+ }
+ }
+ }
+
+ protected override void items_altered(Gee.Map<DataObject, Alteration> items) {
+ foreach (DataObject object in items.keys) {
+ Alteration alteration = items.get(object);
+
+ LibraryPhoto photo = (LibraryPhoto) object;
+
+ if (alteration.has_detail("image", "master") || alteration.has_detail("image", "editable")) {
+ int64 old_master_filesize = photo_to_master_filesize.get(photo);
+ int64 old_editable_filesize = photo_to_editable_filesize.has_key(photo)
+ ? photo_to_editable_filesize.get(photo)
+ : -1;
+
+ photo_to_master_filesize.unset(photo);
+ filesize_to_photo.remove(old_master_filesize, photo);
+ if (old_editable_filesize >= 0) {
+ photo_to_editable_filesize.unset(photo);
+ filesize_to_photo.remove(old_editable_filesize, photo);
+ }
+
+ int64 master_filesize = photo.get_master_photo_row().filesize;
+ int64 editable_filesize = photo.get_editable_photo_row() != null
+ ? photo.get_editable_photo_row().filesize
+ : -1;
+ photo_to_master_filesize.set(photo, master_filesize);
+ filesize_to_photo.set(master_filesize, photo);
+ if (editable_filesize >= 0) {
+ photo_to_editable_filesize.set(photo, editable_filesize);
+ filesize_to_photo.set(editable_filesize, photo);
+ }
+ }
+ }
+
+ base.items_altered(items);
+ }
+
+ // This method adds the photos to the Tags (keywords) that were discovered during import.
+ public override void postprocess_imported_media(Gee.Collection<MediaSource> media_sources) {
+ Gee.HashMultiMap<Tag, LibraryPhoto> map = new Gee.HashMultiMap<Tag, LibraryPhoto>();
+ foreach (MediaSource media in media_sources) {
+ LibraryPhoto photo = (LibraryPhoto) media;
+ PhotoMetadata metadata = photo.get_metadata();
+
+ // get an index of all the htags in the application
+ HierarchicalTagIndex global_index = HierarchicalTagIndex.get_global_index();
+
+ // if any hierarchical tag information is available, process it first. hierarchical tag
+ // information must be processed first to avoid tag duplication, since most photo
+ // management applications that support hierarchical tags also "flatten" the
+ // hierarchical tag information as plain old tags. If a tag name appears as part of
+ // a hierarchical path, it needs to be excluded from being processed as a flat tag
+ HierarchicalTagIndex? htag_index = null;
+ if (metadata.has_hierarchical_keywords()) {
+ htag_index = HierarchicalTagUtilities.process_hierarchical_import_keywords(
+ metadata.get_hierarchical_keywords());
+ }
+
+ if (photo.get_import_keywords() != null) {
+ foreach (string keyword in photo.get_import_keywords()) {
+ if (htag_index != null && htag_index.is_tag_in_index(keyword))
+ continue;
+
+ string? name = Tag.prep_tag_name(keyword);
+
+ if (global_index.is_tag_in_index(name)) {
+ string most_derived_path = global_index.get_path_for_name(name);
+ map.set(Tag.for_path(most_derived_path), photo);
+ continue;
+ }
+
+ if (name != null)
+ map.set(Tag.for_path(name), photo);
+ }
+ }
+
+ if (metadata.has_hierarchical_keywords()) {
+ foreach (string path in htag_index.get_all_paths()) {
+ string? name = Tag.prep_tag_name(path);
+ if (name != null)
+ map.set(Tag.for_path(name), photo);
+ }
+ }
+ }
+
+ foreach (MediaSource media in media_sources) {
+ LibraryPhoto photo = (LibraryPhoto) media;
+ photo.clear_import_keywords();
+ }
+
+ foreach (Tag tag in map.get_keys())
+ tag.attach_many(map.get(tag));
+
+ base.postprocess_imported_media(media_sources);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_master_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ master_reimported(photo, metadata);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_editable_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ editable_reimported(photo, metadata);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_source_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ source_reimported(photo, metadata);
+ }
+
+ // This is only called by LibraryPhoto.
+ public virtual void notify_baseline_reimported(LibraryPhoto photo, PhotoMetadata? metadata) {
+ baseline_reimported(photo, metadata);
+ }
+
+ protected override MediaSource? fetch_by_numeric_id(int64 numeric_id) {
+ return fetch(PhotoID(numeric_id));
+ }
+
+ private void on_trashcan_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ trashcan_contents_altered((Gee.Collection<LibraryPhoto>?) added,
+ (Gee.Collection<LibraryPhoto>?) removed);
+ }
+
+ private bool check_if_trashed_photo(DataSource source, Alteration alteration) {
+ return ((LibraryPhoto) source).is_trashed();
+ }
+
+ private void on_offline_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ offline_contents_altered((Gee.Collection<LibraryPhoto>?) added,
+ (Gee.Collection<LibraryPhoto>?) removed);
+ }
+
+ private bool check_if_offline_photo(DataSource source, Alteration alteration) {
+ return ((LibraryPhoto) source).is_offline();
+ }
+
+ public override MediaSource? fetch_by_source_id(string source_id) {
+ assert(source_id.has_prefix(Photo.TYPENAME));
+ string numeric_only = source_id.substring(Photo.TYPENAME.length, -1);
+
+ return fetch_by_numeric_id(parse_int64(numeric_only, 16));
+ }
+
+ public override Gee.Collection<string> get_event_source_ids(EventID event_id){
+ return PhotoTable.get_instance().get_event_source_ids(event_id);
+ }
+
+ public LibraryPhoto fetch(PhotoID photo_id) {
+ return (LibraryPhoto) fetch_by_key(photo_id.id);
+ }
+
+ public LibraryPhoto? fetch_by_editable_file(File file) {
+ return by_editable_file.get(file);
+ }
+
+ public LibraryPhoto? fetch_by_raw_development_file(File file) {
+ return by_raw_development_file.get(file);
+ }
+
+ private void compare_backing(LibraryPhoto photo, FileInfo info,
+ Gee.Collection<LibraryPhoto> matches_master, Gee.Collection<LibraryPhoto> matches_editable,
+ Gee.Collection<LibraryPhoto> matches_development) {
+ if (photo.get_master_photo_row().matches_file_info(info))
+ matches_master.add(photo);
+
+ BackingPhotoRow? editable = photo.get_editable_photo_row();
+ if (editable != null && editable.matches_file_info(info))
+ matches_editable.add(photo);
+
+ Gee.Collection<BackingPhotoRow>? development = photo.get_raw_development_photo_rows();
+ if (development != null) {
+ foreach (BackingPhotoRow row in development) {
+ if (row.matches_file_info(info)) {
+ matches_development.add(photo);
+
+ break;
+ }
+ }
+ }
+ }
+
+ // Adds photos to both collections if their filesize and timestamp match. Note that it's possible
+ // for a single photo to be added to both collections.
+ public void fetch_by_matching_backing(FileInfo info, Gee.Collection<LibraryPhoto> matches_master,
+ Gee.Collection<LibraryPhoto> matches_editable, Gee.Collection<LibraryPhoto> matched_development) {
+ foreach (LibraryPhoto photo in filesize_to_photo.get(info.get_size()))
+ compare_backing(photo, info, matches_master, matches_editable, matched_development);
+
+ foreach (MediaSource media in get_offline_bin_contents())
+ compare_backing((LibraryPhoto) media, info, matches_master, matches_editable, matched_development);
+ }
+
+ public PhotoID get_basename_filesize_duplicate(string basename, int64 filesize) {
+ foreach (LibraryPhoto photo in filesize_to_photo.get(filesize)) {
+ if (utf8_ci_compare(photo.get_master_file().get_basename(), basename) == 0)
+ return photo.get_photo_id();
+ }
+
+ return PhotoID(); // default constructor for PhotoIDs will create an invalid ID --
+ // this is just the behavior that we want
+ }
+
+ public bool has_basename_filesize_duplicate(string basename, int64 filesize) {
+ return get_basename_filesize_duplicate(basename, filesize).is_valid();
+ }
+
+ public LibraryPhoto? get_trashed_by_file(File file) {
+ LibraryPhoto? photo = (LibraryPhoto?) get_trashcan().fetch_by_master_file(file);
+ if (photo == null)
+ photo = (LibraryPhoto?) ((LibraryPhotoSourceHoldingTank) get_trashcan()).
+ fetch_by_backing_file(file);
+
+ return photo;
+ }
+
+ public LibraryPhoto? get_trashed_by_md5(string md5) {
+ return (LibraryPhoto?) get_trashcan().fetch_by_md5(md5);
+ }
+
+ public LibraryPhoto? get_offline_by_file(File file) {
+ LibraryPhoto? photo = (LibraryPhoto?) get_offline_bin().fetch_by_master_file(file);
+ if (photo == null)
+ photo = (LibraryPhoto?) ((LibraryPhotoSourceHoldingTank) get_offline_bin()).
+ fetch_by_backing_file(file);
+
+ return photo;
+ }
+
+ public LibraryPhoto? get_offline_by_md5(string md5) {
+ return (LibraryPhoto?) get_offline_bin().fetch_by_md5(md5);
+ }
+
+ public int get_offline_count() {
+ return get_offline_bin().get_count();
+ }
+
+ public LibraryPhoto? get_state_by_file(File file, out State state) {
+ LibraryPhoto? photo = (LibraryPhoto?) fetch_by_master_file(file);
+ if (photo != null) {
+ state = State.ONLINE;
+
+ return photo;
+ }
+
+ photo = fetch_by_editable_file(file);
+ if (photo != null) {
+ state = State.EDITABLE;
+
+ return photo;
+ }
+
+ photo = fetch_by_raw_development_file(file);
+ if (photo != null) {
+ state = State.DEVELOPER;
+
+ return photo;
+ }
+
+ photo = get_trashed_by_file(file) as LibraryPhoto;
+ if (photo != null) {
+ state = State.TRASH;
+
+ return photo;
+ }
+
+ photo = get_offline_by_file(file) as LibraryPhoto;
+ if (photo != null) {
+ state = State.OFFLINE;
+
+ return photo;
+ }
+
+ state = State.UNKNOWN;
+
+ return null;
+ }
+
+ public override bool has_backlink(SourceBacklink backlink) {
+ if (base.has_backlink(backlink))
+ return true;
+
+ if (get_trashcan().has_backlink(backlink))
+ return true;
+
+ if (get_offline_bin().has_backlink(backlink))
+ return true;
+
+ return false;
+ }
+
+ public override void remove_backlink(SourceBacklink backlink) {
+ get_trashcan().remove_backlink(backlink);
+ get_offline_bin().remove_backlink(backlink);
+
+ base.remove_backlink(backlink);
+ }
+}
+
+//
+// LibraryPhoto
+//
+
+public class LibraryPhoto : Photo, Flaggable, Monitorable {
+ // Top 16 bits are reserved for Photo
+ // Warning: FLAG_HIDDEN and FLAG_FAVORITE have been deprecated for ratings and rating filters.
+ private const uint64 FLAG_HIDDEN = 0x0000000000000001;
+ private const uint64 FLAG_FAVORITE = 0x0000000000000002;
+ private const uint64 FLAG_TRASH = 0x0000000000000004;
+ private const uint64 FLAG_OFFLINE = 0x0000000000000008;
+ private const uint64 FLAG_FLAGGED = 0x0000000000000010;
+
+ public static LibraryPhotoSourceCollection global = null;
+
+ private bool block_thumbnail_generation = false;
+ private OneShotScheduler thumbnail_scheduler = null;
+ private Gee.Collection<string>? import_keywords;
+
+ private LibraryPhoto(PhotoRow row) {
+ base (row);
+
+ this.import_keywords = null;
+
+ thumbnail_scheduler = new OneShotScheduler("LibraryPhoto", generate_thumbnails);
+
+ // if marked in a state where they're held in an orphanage, rehydrate their backlinks
+ if ((row.flags & (FLAG_TRASH | FLAG_OFFLINE)) != 0)
+ rehydrate_backlinks(global, row.backlinks);
+
+ if ((row.flags & (FLAG_HIDDEN | FLAG_FAVORITE)) != 0)
+ upgrade_rating_flags(row.flags);
+ }
+
+ private LibraryPhoto.from_import_params(PhotoImportParams import_params) {
+ base (import_params.row);
+
+ this.import_keywords = import_params.keywords;
+ thumbnail_scheduler = new OneShotScheduler("LibraryPhoto", generate_thumbnails);
+
+ // if marked in a state where they're held in an orphanage, rehydrate their backlinks
+ if ((import_params.row.flags & (FLAG_TRASH | FLAG_OFFLINE)) != 0)
+ rehydrate_backlinks(global, import_params.row.backlinks);
+
+ if ((import_params.row.flags & (FLAG_HIDDEN | FLAG_FAVORITE)) != 0)
+ upgrade_rating_flags(import_params.row.flags);
+ }
+
+ public static void init(ProgressMonitor? monitor = null) {
+ init_photo();
+
+ global = new LibraryPhotoSourceCollection();
+
+ // prefetch all the photos from the database and add them to the global collection ...
+ // do in batches to take advantage of add_many()
+ Gee.ArrayList<PhotoRow?> all = PhotoTable.get_instance().get_all();
+ Gee.ArrayList<LibraryPhoto> all_photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<LibraryPhoto> trashed_photos = new Gee.ArrayList<LibraryPhoto>();
+ Gee.ArrayList<LibraryPhoto> offline_photos = new Gee.ArrayList<LibraryPhoto>();
+ int count = all.size;
+ for (int ctr = 0; ctr < count; ctr++) {
+ PhotoRow row = all.get(ctr);
+ LibraryPhoto photo = new LibraryPhoto(row);
+ uint64 flags = row.flags;
+
+ if ((flags & FLAG_TRASH) != 0)
+ trashed_photos.add(photo);
+ else if ((flags & FLAG_OFFLINE) != 0)
+ offline_photos.add(photo);
+ else
+ all_photos.add(photo);
+
+ if (monitor != null)
+ monitor(ctr, count);
+ }
+
+ global.add_many(all_photos);
+ global.add_many_to_trash(trashed_photos);
+ global.add_many_to_offline(offline_photos);
+ }
+
+ public static void terminate() {
+ terminate_photo();
+ }
+
+ // This accepts a PhotoRow that was prepared with Photo.prepare_for_import and
+ // has not already been inserted in the database. See PhotoTable.add() for which fields are
+ // used and which are ignored. The PhotoRow itself will be modified with the remaining values
+ // as they are stored in the database.
+ public static ImportResult import_create(PhotoImportParams params, out LibraryPhoto photo) {
+ // add to the database
+ PhotoID photo_id = PhotoTable.get_instance().add(params.row);
+ if (photo_id.is_invalid()) {
+ photo = null;
+
+ return ImportResult.DATABASE_ERROR;
+ }
+
+ // create local object but don't add to global until thumbnails generated
+ photo = new LibraryPhoto.from_import_params(params);
+
+ return ImportResult.SUCCESS;
+ }
+
+ public static void import_failed(LibraryPhoto photo) {
+ try {
+ PhotoTable.get_instance().remove(photo.get_photo_id());
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+ }
+
+ protected override void notify_master_reimported(PhotoMetadata? metadata) {
+ base.notify_master_reimported(metadata);
+
+ global.notify_master_reimported(this, metadata);
+ }
+
+ protected override void notify_editable_reimported(PhotoMetadata? metadata) {
+ base.notify_editable_reimported(metadata);
+
+ global.notify_editable_reimported(this, metadata);
+ }
+
+ protected override void notify_source_reimported(PhotoMetadata? metadata) {
+ base.notify_source_reimported(metadata);
+
+ global.notify_source_reimported(this, metadata);
+ }
+
+ protected override void notify_baseline_reimported(PhotoMetadata? metadata) {
+ base.notify_baseline_reimported(metadata);
+
+ global.notify_baseline_reimported(this, metadata);
+ }
+
+ private void generate_thumbnails() {
+ try {
+ ThumbnailCache.import_from_source(this, true);
+ } catch (Error err) {
+ warning("Unable to generate thumbnails for %s: %s", to_string(), err.message);
+ }
+
+ // fire signal that thumbnails have changed
+ notify_thumbnail_altered();
+ }
+
+ // These keywords are only used during import and should not be relied upon elsewhere.
+ public Gee.Collection<string>? get_import_keywords() {
+ return import_keywords;
+ }
+
+ public void clear_import_keywords() {
+ import_keywords = null;
+ }
+
+ public override void notify_altered(Alteration alteration) {
+ // generate new thumbnails in the background
+ if (!block_thumbnail_generation && alteration.has_subject("image"))
+ thumbnail_scheduler.at_priority_idle(Priority.LOW);
+
+ base.notify_altered(alteration);
+ }
+
+ public override Gdk.Pixbuf get_preview_pixbuf(Scaling scaling) throws Error {
+ Gdk.Pixbuf pixbuf = get_thumbnail(ThumbnailCache.Size.BIG);
+
+ return scaling.perform_on_pixbuf(pixbuf, Gdk.InterpType.BILINEAR, true);
+ }
+
+ public override void rotate(Rotation rotation) {
+ // block thumbnail generation for this operation; taken care of below
+ block_thumbnail_generation = true;
+ base.rotate(rotation);
+ block_thumbnail_generation = false;
+
+ // because rotations are (a) common and available everywhere in the app, (b) the user expects
+ // a level of responsiveness not necessarily required by other modifications, (c) can be
+ // performed on multiple images simultaneously, and (d) can't cache a lot of full-sized
+ // pixbufs for rotate-and-scale ops, perform the rotation directly on the already-modified
+ // thumbnails.
+ try {
+ ThumbnailCache.rotate(this, rotation);
+ } catch (Error err) {
+ // TODO: Mark thumbnails as dirty in database
+ warning("Unable to update thumbnails for %s: %s", to_string(), err.message);
+ }
+
+ notify_thumbnail_altered();
+ }
+
+ // Returns unscaled thumbnail with all modifications applied applicable to the scale
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return ThumbnailCache.fetch(this, scale);
+ }
+
+ // Duplicates a backing photo row, returning the ID.
+ // An invalid ID will be returned if the backing photo row is not set or is invalid.
+ private BackingPhotoID duplicate_backing_photo(BackingPhotoRow? backing) throws Error {
+ BackingPhotoID backing_id = BackingPhotoID();
+ if (backing == null || backing.filepath == null)
+ return backing_id; // empty, invalid ID
+
+ File file = File.new_for_path(backing.filepath);
+ if (file.query_exists()) {
+ File dupe_file = LibraryFiles.duplicate(file, on_duplicate_progress, true);
+
+ DetectedPhotoInformation detected;
+ BackingPhotoRow? state = query_backing_photo_row(dupe_file, PhotoFileSniffer.Options.NO_MD5,
+ out detected);
+ if (state != null) {
+ BackingPhotoTable.get_instance().add(state);
+ backing_id = state.id;
+ }
+ }
+
+ return backing_id;
+ }
+
+ public LibraryPhoto duplicate() throws Error {
+ // clone the master file
+ File dupe_file = LibraryFiles.duplicate(get_master_file(), on_duplicate_progress, true);
+
+ // Duplicate editable and raw developments (if they exist)
+ BackingPhotoID dupe_editable_id = duplicate_backing_photo(get_editable_photo_row());
+ BackingPhotoID dupe_raw_shotwell_id = duplicate_backing_photo(
+ get_raw_development_photo_row(RawDeveloper.SHOTWELL));
+ BackingPhotoID dupe_raw_camera_id = duplicate_backing_photo(
+ get_raw_development_photo_row(RawDeveloper.CAMERA));
+ BackingPhotoID dupe_raw_embedded_id = duplicate_backing_photo(
+ get_raw_development_photo_row(RawDeveloper.EMBEDDED));
+
+ // clone the row in the database for these new backing files
+ PhotoID dupe_id = PhotoTable.get_instance().duplicate(get_photo_id(), dupe_file.get_path(),
+ dupe_editable_id, dupe_raw_shotwell_id, dupe_raw_camera_id, dupe_raw_embedded_id);
+ PhotoRow dupe_row = PhotoTable.get_instance().get_row(dupe_id);
+
+ // build the DataSource for the duplicate
+ LibraryPhoto dupe = new LibraryPhoto(dupe_row);
+
+ // clone thumbnails
+ ThumbnailCache.duplicate(this, dupe);
+
+ // add it to the SourceCollection; this notifies everyone interested of its presence
+ global.add(dupe);
+
+ // if it is not in "No Event" attach to event
+ if (dupe.get_event() != null)
+ dupe.get_event().attach(dupe);
+
+ // attach tags
+ Gee.Collection<Tag>? tags = Tag.global.fetch_for_source(this);
+ if (tags != null) {
+ foreach (Tag tag in tags) {
+ tag.attach(dupe);
+ }
+ }
+
+ return dupe;
+ }
+
+ private void on_duplicate_progress(int64 current, int64 total) {
+ spin_event_loop();
+ }
+
+ private void upgrade_rating_flags(uint64 flags) {
+ if ((flags & FLAG_HIDDEN) != 0) {
+ set_rating(Rating.REJECTED);
+ remove_flags(FLAG_HIDDEN);
+ }
+
+ if ((flags & FLAG_FAVORITE) != 0) {
+ set_rating(Rating.FIVE);
+ remove_flags(FLAG_FAVORITE);
+ }
+ }
+
+ // Blotto even!
+ public override bool is_trashed() {
+ return is_flag_set(FLAG_TRASH);
+ }
+
+ public override void trash() {
+ add_flags(FLAG_TRASH);
+ }
+
+ public override void untrash() {
+ remove_flags(FLAG_TRASH);
+ }
+
+ public override bool is_offline() {
+ return is_flag_set(FLAG_OFFLINE);
+ }
+
+ public override void mark_offline() {
+ add_flags(FLAG_OFFLINE);
+ }
+
+ public override void mark_online() {
+ remove_flags(FLAG_OFFLINE);
+ }
+
+ public bool is_flagged() {
+ return is_flag_set(FLAG_FLAGGED);
+ }
+
+ public void mark_flagged() {
+ add_flags(FLAG_FLAGGED, new Alteration("metadata", "flagged"));
+ }
+
+ public void mark_unflagged() {
+ remove_flags(FLAG_FLAGGED, new Alteration("metadata", "flagged"));
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ // allow the base classes to work first because delete_original_file() will attempt to
+ // remove empty directories as well
+ if (!base.internal_delete_backing())
+ return false;
+
+ return delete_original_file();
+ }
+
+ public override void destroy() {
+ PhotoID photo_id = get_photo_id();
+
+ // remove all cached thumbnails
+ ThumbnailCache.remove(this);
+
+ // remove from photo table -- should be wiped from storage now (other classes may have added
+ // photo_id to other parts of the database ... it's their responsibility to remove them
+ // when removed() is called)
+ try {
+ PhotoTable.get_instance().remove(photo_id);
+ } catch (DatabaseError err) {
+ AppWindow.database_error(err);
+ }
+
+ base.destroy();
+ }
+
+ public static bool has_nontrash_duplicate(File? file, string? thumbnail_md5, string? full_md5,
+ PhotoFileFormat file_format) {
+ return get_nontrash_duplicate(file, thumbnail_md5, full_md5, file_format).is_valid();
+ }
+
+ public static PhotoID get_nontrash_duplicate(File? file, string? thumbnail_md5,
+ string? full_md5, PhotoFileFormat file_format) {
+ PhotoID[]? ids = get_duplicate_ids(file, thumbnail_md5, full_md5, file_format);
+
+ if (ids == null || ids.length == 0)
+ return PhotoID(); // return an invalid PhotoID
+
+ foreach (PhotoID id in ids) {
+ LibraryPhoto photo = LibraryPhoto.global.fetch(id);
+ if (photo != null && !photo.is_trashed())
+ return id;
+ }
+
+ return PhotoID();
+ }
+
+ protected override bool has_user_generated_metadata() {
+ Gee.List<Tag>? tags = Tag.global.fetch_for_source(this);
+
+ PhotoMetadata? metadata = get_metadata();
+ if (metadata == null)
+ return tags != null || tags.size > 0 || get_rating() != Rating.UNRATED;
+
+ if (get_rating() != metadata.get_rating())
+ return true;
+
+ Gee.Set<string>? keywords = metadata.get_keywords();
+ int tags_count = (tags != null) ? tags.size : 0;
+ int keywords_count = (keywords != null) ? keywords.size : 0;
+
+ if (tags_count != keywords_count)
+ return true;
+
+ if (tags != null && keywords != null) {
+ foreach (Tag tag in tags) {
+ if (!keywords.contains(tag.get_name().normalize()))
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected override void set_user_metadata_for_export(PhotoMetadata metadata) {
+ Gee.List<Tag>? photo_tags = Tag.global.fetch_for_source(this);
+ if(photo_tags != null) {
+ Gee.Collection<string> string_tags = new Gee.ArrayList<string>();
+ foreach (Tag tag in photo_tags) {
+ string_tags.add(tag.get_name());
+ }
+ metadata.set_keywords(string_tags);
+ } else
+ metadata.set_keywords(null);
+
+ metadata.set_rating(get_rating());
+ }
+
+ protected override void apply_user_metadata_for_reimport(PhotoMetadata metadata) {
+ HierarchicalTagIndex? new_htag_index = null;
+
+ if (metadata.has_hierarchical_keywords()) {
+ new_htag_index = HierarchicalTagUtilities.process_hierarchical_import_keywords(
+ metadata.get_hierarchical_keywords());
+ }
+
+ Gee.Collection<string>? keywords = metadata.get_keywords();
+ if (keywords != null) {
+ foreach (string keyword in keywords) {
+ if (new_htag_index != null && new_htag_index.is_tag_in_index(keyword))
+ continue;
+
+ string safe_keyword = HierarchicalTagUtilities.make_flat_tag_safe(keyword);
+ string promoted_keyword = HierarchicalTagUtilities.flat_to_hierarchical(
+ safe_keyword);
+
+ if (Tag.global.exists(safe_keyword)) {
+ Tag.for_path(safe_keyword).attach(this);
+ continue;
+ }
+
+ if (Tag.global.exists(promoted_keyword)) {
+ Tag.for_path(promoted_keyword).attach(this);
+ continue;
+ }
+
+ Tag.for_path(keyword).attach(this);
+ }
+ }
+
+ if (new_htag_index != null) {
+ foreach (string path in new_htag_index.get_all_paths())
+ Tag.for_path(path).attach(this);
+ }
+ }
+}
+
+// Used for trash and offline bin of LibraryPhotoSourceCollection
+public class LibraryPhotoSourceHoldingTank : MediaSourceHoldingTank {
+ private Gee.HashMap<File, LibraryPhoto> editable_file_map = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.HashMap<File, LibraryPhoto> development_file_map = new Gee.HashMap<File, LibraryPhoto>(
+ file_hash, file_equal);
+ private Gee.MultiMap<LibraryPhoto, File> reverse_editable_file_map
+ = new Gee.HashMultiMap<LibraryPhoto, File>(null, null, file_hash, file_equal);
+ private Gee.MultiMap<LibraryPhoto, File> reverse_development_file_map
+ = new Gee.HashMultiMap<LibraryPhoto, File>(null, null, file_hash, file_equal);
+
+ public LibraryPhotoSourceHoldingTank(LibraryPhotoSourceCollection sources,
+ SourceHoldingTank.CheckToKeep check_to_keep, GetSourceDatabaseKey get_key) {
+ base (sources, check_to_keep, get_key);
+ }
+
+ public LibraryPhoto? fetch_by_backing_file(File file) {
+ LibraryPhoto? ret = null;
+ ret = editable_file_map.get(file);
+ if (ret != null)
+ return ret;
+
+ return development_file_map.get(file);
+ }
+
+ protected override void notify_contents_altered(Gee.Collection<DataSource>? added,
+ Gee.Collection<DataSource>? removed) {
+ if (added != null) {
+ foreach (DataSource source in added) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+
+ // Editable files.
+ if (photo.get_editable_file() != null) {
+ editable_file_map.set(photo.get_editable_file(), photo);
+ reverse_editable_file_map.set(photo, photo.get_editable_file());
+ }
+
+ // RAW developments.
+ Gee.Collection<File>? raw_files = photo.get_raw_developer_files();
+ if (raw_files != null) {
+ foreach (File f in raw_files) {
+ development_file_map.set(f, photo);
+ reverse_development_file_map.set(photo, f);
+ }
+ }
+
+ photo.editable_replaced.connect(on_editable_replaced);
+ photo.raw_development_modified.connect(on_raw_development_modified);
+ }
+ }
+
+ if (removed != null) {
+ foreach (DataSource source in removed) {
+ LibraryPhoto photo = (LibraryPhoto) source;
+ foreach (File f in reverse_editable_file_map.get(photo))
+ editable_file_map.unset(f);
+
+ foreach (File f in reverse_development_file_map.get(photo))
+ development_file_map.unset(f);
+
+ reverse_editable_file_map.remove_all(photo);
+ reverse_development_file_map.remove_all(photo);
+
+ photo.editable_replaced.disconnect(on_editable_replaced);
+ photo.raw_development_modified.disconnect(on_raw_development_modified);
+ }
+ }
+
+ base.notify_contents_altered(added, removed);
+ }
+
+ private void on_editable_replaced(Photo _photo, File? old_file, File? new_file) {
+ LibraryPhoto? photo = _photo as LibraryPhoto;
+ assert(photo != null);
+
+ if (old_file != null) {
+ editable_file_map.unset(old_file);
+ reverse_editable_file_map.remove(photo, old_file);
+ }
+
+ if (new_file != null)
+ editable_file_map.set(new_file, photo);
+ reverse_editable_file_map.set(photo, new_file);
+ }
+
+ private void on_raw_development_modified(Photo _photo) {
+ LibraryPhoto? photo = _photo as LibraryPhoto;
+ assert(photo != null);
+
+ // Unset existing files.
+ if (reverse_development_file_map.contains(photo)) {
+ foreach (File f in reverse_development_file_map.get(photo))
+ development_file_map.unset(f);
+ reverse_development_file_map.remove_all(photo);
+ }
+
+ // Add new ones.
+ Gee.Collection<File> raw_list = photo.get_raw_developer_files();
+ if (raw_list != null) {
+ foreach (File f in raw_list) {
+ development_file_map.set(f, photo);
+ reverse_development_file_map.set(photo, f);
+ }
+ }
+ }
+}
+