Commit b4c6f9c6 authored by Jim Nelson's avatar Jim Nelson

#1290: Store tags and other metadata (title/caption, rating, exposure date/time) in photo files

parent e9334e4d
......@@ -125,7 +125,10 @@ SRC_FILES = \
FSpotDatabaseTables.vala \
VideoSupport.vala \
VideosPage.vala \
Tombstone.vala
Tombstone.vala \
MetadataWriter.vala \
Application.vala \
DelayedQueue.vala
ifndef LINUX
SRC_FILES += \
......
......@@ -429,7 +429,7 @@ public abstract class AppWindow : PageWindow {
protected Dimensions dimensions;
protected int pos_x = 0;
protected int pos_y = 0;
public AppWindow() {
// although there are multiple AppWindow types, only one may exist per-process
assert(instance == null);
......@@ -455,8 +455,6 @@ public abstract class AppWindow : PageWindow {
command_manager = new CommandManager();
}
public signal void user_quit();
private Gtk.ActionEntry[] create_actions() {
Gtk.ActionEntry[] actions = new Gtk.ActionEntry[0];
......@@ -518,7 +516,11 @@ public abstract class AppWindow : PageWindow {
}
protected abstract void on_fullscreen();
public static bool has_instance() {
return instance != null;
}
public static AppWindow get_instance() {
return instance;
}
......@@ -618,7 +620,7 @@ public abstract class AppWindow : PageWindow {
critical(msg);
error_message(msg);
Posix.exit(1);
Application.get_instance().panic();
}
public abstract string get_app_role();
......@@ -655,8 +657,7 @@ public abstract class AppWindow : PageWindow {
}
protected virtual void on_quit() {
user_quit();
Gtk.main_quit();
Application.get_instance().exit();
}
protected void on_jump_to_file() {
......
/* Copyright 2010 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class Application {
private static Application instance = null;
public virtual signal void starting() {
}
public virtual signal void exiting(bool panicked) {
}
private bool running = false;
private bool exiting_fired = false;
private Application() {
}
public static void init() {
get_instance();
}
public static void terminate() {
get_instance().exit();
}
public static Application get_instance() {
if (instance == null)
instance = new Application();
return instance;
}
public void start() {
if (running)
return;
running = true;
starting();
Gtk.main();
running = false;
}
public void exit() {
// only fire this once, but thanks to terminate(), it will be fired at least once (even
// if start() is not called and "starting" is not fired)
if (exiting_fired)
return;
exiting_fired = true;
exiting(false);
Gtk.main_quit();
}
// This will fire the exiting signal with panicked set to true, but only if exit() hasn't
// already been called. This call will immediately halt the application.
public void panic() {
if (!exiting_fired) {
exiting_fired = true;
exiting(true);
}
Posix.exit(1);
}
}
......@@ -277,7 +277,7 @@ public class BatchImport : Object {
}
// watch for user exit in the application
AppWindow.get_instance().user_quit.connect(user_halt);
Application.get_instance().exiting.connect(user_halt);
// Use a timer to report imported photos to observers
Timeout.add(200, display_imported_timer);
......@@ -287,7 +287,7 @@ public class BatchImport : Object {
#if TRACE_DTORS
debug("DTOR: BatchImport (%s)", name);
#endif
AppWindow.get_instance().user_quit.disconnect(user_halt);
Application.get_instance().exiting.disconnect(user_halt);
}
public string get_name() {
......
......@@ -21,7 +21,7 @@ public class DatabaseTable {
* tables are created on demand and tables and columns are easily ignored when already present.
* However, the change should be noted in upgrade_database() as a comment.
***/
public const int SCHEMA_VERSION = 8;
public const int SCHEMA_VERSION = 9;
protected static Sqlite.Database db;
......@@ -471,7 +471,18 @@ private DatabaseVerifyResult upgrade_database(int version) {
return DatabaseVerifyResult.UPGRADE_ERROR;
}
version = 8;
//
// Version 9:
// * Added metadata_dirty flag to PhotoTable.
//
if (!DatabaseTable.has_column("PhotoTable", "metadata_dirty")) {
message("upgrade_database: adding metadata_dirty column to PhotoTable");
if (!DatabaseTable.add_column("PhotoTable", "metadata_dirty", "INTEGER DEFAULT 0"))
return DatabaseVerifyResult.UPGRADE_ERROR;
}
version = 9;
assert(version == DatabaseTable.SCHEMA_VERSION);
VersionTable.get_instance().update_version(version, Resources.APP_VERSION);
......@@ -688,6 +699,7 @@ public struct PhotoRow {
public string? backlinks;
public time_t time_reimported;
public BackingPhotoID editable_id;
public bool metadata_dirty;
public PhotoRow() {
editable_id = BackingPhotoID();
......@@ -722,7 +734,8 @@ public class PhotoTable : DatabaseTable {
+ "title TEXT, "
+ "backlinks TEXT, "
+ "time_reimported INTEGER, "
+ "editable_id INTEGER DEFAULT -1"
+ "editable_id INTEGER DEFAULT -1, "
+ "metadata_dirty INTEGER DEFAULT 0"
+ ")", -1, out stmt);
assert(res == Sqlite.OK);
......@@ -916,7 +929,7 @@ public class PhotoTable : DatabaseTable {
"SELECT filename, width, height, filesize, timestamp, exposure_time, orientation, "
+ "original_orientation, import_id, event_id, transformations, md5, thumbnail_md5, "
+ "exif_md5, time_created, flags, rating, file_format, title, backlinks, "
+ "time_reimported, editable_id "
+ "time_reimported, editable_id, metadata_dirty "
+ "FROM PhotoTable WHERE id=?",
-1, out stmt);
assert(res == Sqlite.OK);
......@@ -950,6 +963,7 @@ public class PhotoTable : DatabaseTable {
row.backlinks = stmt.column_text(19);
row.time_reimported = (time_t) stmt.column_int64(20);
row.editable_id = BackingPhotoID(stmt.column_int64(21));
row.metadata_dirty = stmt.column_int(22) != 0;
return row;
}
......@@ -960,7 +974,7 @@ public class PhotoTable : DatabaseTable {
"SELECT id, filename, width, height, filesize, timestamp, exposure_time, orientation, "
+ "original_orientation, import_id, event_id, transformations, md5, thumbnail_md5, "
+ "exif_md5, time_created, flags, rating, file_format, title, backlinks, time_reimported, "
+ "editable_id FROM PhotoTable",
+ "editable_id, metadata_dirty FROM PhotoTable",
-1, out stmt);
assert(res == Sqlite.OK);
......@@ -990,6 +1004,7 @@ public class PhotoTable : DatabaseTable {
row.backlinks = stmt.column_text(20);
row.time_reimported = (time_t) stmt.column_int64(21);
row.editable_id = BackingPhotoID(stmt.column_int64(22));
row.metadata_dirty = stmt.column_int(23) != 0;
all.add(row);
}
......@@ -1540,6 +1555,10 @@ public class PhotoTable : DatabaseTable {
row.editable_id = BackingPhotoID();
}
public void set_metadata_dirty(PhotoID photo_id, bool dirty) throws DatabaseError {
update_int_by_id_2(photo_id.id, "metadata_dirty", dirty ? 1 : 0);
}
}
//
......
/* Copyright 2010 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
// DelayedQueue is a specialized collection class. It holds items in order, but rather than being
// manually dequeued, they are dequeued automatically after a specified amount of time has elapsed
// for that item. As of today, it's possible the item will be dequeued a bit later than asked
// for, but it will never be early. Future implementations might tighten up the lateness.
//
// The original design was to use a signal to notify when an item has been dequeued, but Vala has
// a bug with passing an unnamed type as a signal parameter:
// https://bugzilla.gnome.org/show_bug.cgi?id=628639
//
// The rate the items come off the queue can be spaced out. Note that this can cause hysteresis.
// As of today, DelayedQueue makes no effort to combat this.
public delegate void DequeuedCallback<G>(G item);
public class DelayedQueue<G> {
private class Element<G> {
public G item;
public time_t ready;
public Element(G item, time_t ready) {
this.item = item;
this.ready = ready;
}
public static int64 comparator(void *a, void *b) {
return (int64) ((Element *) a)->ready - (int64) ((Element *) b)->ready;
}
}
private uint hold_msec;
private DequeuedCallback<G> callback;
private EqualFunc equal_func;
private int priority;
private uint timer_id = 0;
private SortedList<Element<G>> queue;
private uint dequeue_spacing_msec = 0;
private time_t last_dequeue = 0;
// Initial design was to have a signal that passed the dequeued G, but bug in valac meant
// finding a workaround, namely using a delegate:
// https://bugzilla.gnome.org/show_bug.cgi?id=628639
public DelayedQueue(uint hold_msec, DequeuedCallback<G> callback, EqualFunc? equal_func = null,
int priority = Priority.DEFAULT) requires (hold_msec > 0) {
this.hold_msec = hold_msec;
this.callback = callback;
this.equal_func = (equal_func != null) ? equal_func : Gee.Functions.get_equal_func_for(typeof(G));
this.priority = priority;
queue = new SortedList<Element<G>>(Element.comparator);
timer_id = Timeout.add(get_heartbeat_timeout(), on_heartbeat, priority);
}
~DelayedQueue() {
if (timer_id != 0)
Source.remove(timer_id);
}
public uint get_dequeue_spacing_msec() {
return dequeue_spacing_msec;
}
public void set_dequeue_spacing_msec(uint msec) {
if (msec == dequeue_spacing_msec)
return;
if (timer_id != 0)
Source.remove(timer_id);
dequeue_spacing_msec = msec;
timer_id = Timeout.add(get_heartbeat_timeout(), on_heartbeat, priority);
}
private uint get_heartbeat_timeout() {
return ((dequeue_spacing_msec == 0)
? (hold_msec / 10)
: (dequeue_spacing_msec / 2)).clamp(10, uint.MAX);
}
protected virtual void notify_dequeued(G item) {
callback(item);
}
public virtual void clear() {
queue.clear();
}
public virtual bool contains(G item) {
foreach (Element<G> e in queue) {
if (equal_func(item, e.item))
return true;
}
return false;
}
public virtual bool enqueue(G item) {
return queue.add(new Element<G>(item, calc_ready_time()));
}
public virtual bool remove_first(G item) {
Gee.Iterator<Element<G>> iter = queue.iterator();
while (iter.next()) {
Element<G> e = iter.get();
if (equal_func(item, e.item)) {
iter.remove();
return true;
}
}
return false;
}
public virtual int size {
get {
return queue.size;
}
}
private time_t calc_ready_time() {
return (time_t) (now_ms() + hold_msec);
}
private bool on_heartbeat() {
time_t now = 0;
for (;;) {
if (queue.size == 0)
break;
Element<G>? head = queue.get_at(0);
assert(head != null);
if (now == 0)
now = (time_t) now_ms();
if (head.ready > now)
break;
// if a space of time is required between dequeues, check now
if ((dequeue_spacing_msec != 0) && ((now - last_dequeue) < dequeue_spacing_msec))
break;
Element<G>? h = queue.remove_at(0);
assert(head == h);
notify_dequeued(head.item);
last_dequeue = now;
// if a dequeue spacing is in place, it's a lock that only one item is dequeued per
// heartbeat
if (dequeue_spacing_msec != 0)
break;
}
return true;
}
}
// HashDelayedQueue uses a HashMap for quick lookups of elements via contains().
public class HashDelayedQueue<G> : DelayedQueue<G> {
private Gee.HashMap<G, int> item_count;
public HashDelayedQueue(uint hold_msec, DequeuedCallback<G> callback, HashFunc? hash_func = null,
EqualFunc? equal_func = null, int priority = Priority.DEFAULT) {
base (hold_msec, callback, equal_func, priority);
item_count = new Gee.HashMap<G, int>(hash_func, equal_func);
}
protected override void notify_dequeued(G item) {
removed(item);
base.notify_dequeued(item);
}
public override void clear() {
item_count.clear();
base.clear();
}
public override bool contains(G item) {
return item_count.has_key(item);
}
public override bool enqueue(G item) {
if (!base.enqueue(item))
return false;
item_count.set(item, item_count.has_key(item) ? item_count.get(item) + 1 : 1);
return true;
}
public override bool remove_first(G item) {
if (!base.remove_first(item))
return false;
removed(item);
return true;
}
private void removed(G item) {
assert(item_count.has_key(item));
int count = item_count.get(item);
assert(count > 0);
if (--count == 0)
item_count.unset(item);
else
item_count.set(item, count);
}
}
......@@ -117,7 +117,7 @@ public class ExportDialog : Gtk.Dialog {
}
format_combo = new Gtk.ComboBox.text();
foreach (PhotoFileFormat format in PhotoFileFormat.get_writable()) {
foreach (PhotoFileFormat format in PhotoFileFormat.get_writeable()) {
format_combo.append_text(format.get_properties().get_user_visible_name());
}
......@@ -179,7 +179,7 @@ public class ExportDialog : Gtk.Dialog {
user_format = PhotoFileFormat.get_system_default_format();
int ctr = 0;
foreach (PhotoFileFormat format in PhotoFileFormat.get_writable()) {
foreach (PhotoFileFormat format in PhotoFileFormat.get_writeable()) {
if (format == user_format)
format_combo.set_active(ctr);
ctr++;
......@@ -205,7 +205,7 @@ public class ExportDialog : Gtk.Dialog {
index = format_combo.get_active();
assert(index >= 0);
user_format = PhotoFileFormat.get_writable()[index];
user_format = PhotoFileFormat.get_writeable()[index];
}
destroy();
......
......@@ -713,15 +713,7 @@ public class DirectoryMonitor : Object {
}
public bool is_in_root(File file) {
File? parent = file;
do {
if (parent.equal(root))
return true;
parent = parent.get_parent();
} while (parent != null);
return false;
return file.has_prefix(root);
}
public void start_discovery() throws Error {
......
......@@ -28,7 +28,11 @@ public class JfifFileFormatDriver : PhotoFileFormatDriver {
return new PhotoMetadata();
}
public override bool can_write() {
public override bool can_write_image() {
return true;
}
public override bool can_write_metadata() {
return true;
}
......@@ -36,6 +40,10 @@ public class JfifFileFormatDriver : PhotoFileFormatDriver {
return new JfifWriter(filepath);
}
public override PhotoFileMetadataWriter? create_metadata_writer(string filepath) {
return new JfifMetadataWriter(filepath);
}
public override PhotoFileSniffer create_sniffer(File file, PhotoFileSniffer.Options options) {
return new JfifSniffer(file, options);
}
......@@ -118,15 +126,21 @@ public class JfifWriter : PhotoFileWriter {
base (filepath, PhotoFileFormat.JFIF);
}
public override void write_metadata(PhotoMetadata metadata) throws Error {
metadata.write_to_file(get_file());
}
public override void write(Gdk.Pixbuf pixbuf, Jpeg.Quality quality) throws Error {
pixbuf.save(get_filepath(), "jpeg", "quality", quality.get_pct_text());
}
}
public class JfifMetadataWriter : PhotoFileMetadataWriter {
public JfifMetadataWriter(string filepath) {
base (filepath, PhotoFileFormat.JFIF);
}
public override void write_metadata(PhotoMetadata metadata) throws Error {
metadata.write_to_file(get_file());
}
}
namespace Jpeg {
public const uint8 MARKER_PREFIX = 0xFF;
......
......@@ -34,8 +34,7 @@ public class LibraryMonitor : DirectoryMonitor {
private class ReimportMasterJob : BackgroundJob {
public LibraryPhoto photo;
public PhotoRow updated_row = PhotoRow();
public PhotoMetadata? metadata = null;
public Photo.ReimportMasterState reimport_state = null;
public bool mark_online = false;
public Error err = null;
......@@ -48,7 +47,7 @@ public class LibraryMonitor : DirectoryMonitor {
public override void execute() {
try {
mark_online = photo.prepare_for_reimport_master(out updated_row, out metadata);
mark_online = photo.prepare_for_reimport_master(out reimport_state);
} catch (Error err) {
this.err = err;
}
......@@ -57,8 +56,7 @@ public class LibraryMonitor : DirectoryMonitor {
private class ReimportEditableJob : BackgroundJob {
public LibraryPhoto photo;
public BackingPhotoState state = BackingPhotoState();
public PhotoMetadata? metadata = null;
public Photo.ReimportEditableState state = null;
public bool success = false;
public Error err = null;
......@@ -71,7 +69,7 @@ public class LibraryMonitor : DirectoryMonitor {
public override void execute() {
try {
success = photo.prepare_for_reimport_editable(out state, out metadata);
success = photo.prepare_for_reimport_editable(out state);
} catch (Error err) {
this.err = err;
}
......@@ -296,7 +294,8 @@ public class LibraryMonitor : DirectoryMonitor {
// After the checksumming is complete, the only use of the unknown photo files is for
// auto-import, so don't bother checksumming the remainder for duplicates/tombstones unless
// going to do that work
if (startup_auto_import) {
if (startup_auto_import && LibraryPhoto.global.get_count() > 0
&& Tombstone.global.get_count() > 0) {
foreach (File file in unknown_photo_files) {
checksums_total++;
workers.enqueue(new ChecksumJob(this, file));
......@@ -309,7 +308,7 @@ public class LibraryMonitor : DirectoryMonitor {
discovery_stage_completed();
} else {
mdbg("%d checksum jobs initiated to verify unknown photo files".printf(checksums_total));
LibraryWindow.get_app().update_background_progress_bar(_("Updating library..."),
LibraryWindow.update_background_progress_bar(_("Updating library..."),
checksums_completed, checksums_total);
}
}
......@@ -318,11 +317,11 @@ public class LibraryMonitor : DirectoryMonitor {
assert(checksums_completed < checksums_total);
checksums_completed++;
LibraryWindow.get_app().update_background_progress_bar(_("Updating library..."),
LibraryWindow.update_background_progress_bar(_("Updating library..."),
checksums_completed, checksums_total);
if (checksums_completed == checksums_total) {
LibraryWindow.get_app().clear_background_progress_bar();
LibraryWindow.clear_background_progress_bar();
discovery_stage_completed();
}
}
......@@ -797,7 +796,7 @@ public class LibraryMonitor : DirectoryMonitor {
}
try {
job.photo.finish_reimport_master(ref job.updated_row, job.metadata);
job.photo.finish_reimport_master(job.reimport_state);
} catch (DatabaseError err) {
AppWindow.database_error(err);
}
......@@ -828,7 +827,7 @@ public class LibraryMonitor : DirectoryMonitor {
}
try {
job.photo.finish_reimport_editable(job.state, job.metadata);
job.photo.finish_reimport_editable(job.state);
} catch (DatabaseError err) {
AppWindow.database_error(err);
}
......@@ -842,7 +841,7 @@ public class LibraryMonitor : DirectoryMonitor {
}
private void on_import_progress(uint64 completed_bytes, uint64 total_bytes) {
LibraryWindow.get_app().update_background_progress_bar(_("Auto-importing..."),
LibraryWindow.update_background_progress_bar(_("Auto-importing..."),
completed_bytes, total_bytes);
}
......@@ -852,7 +851,7 @@ public class LibraryMonitor : DirectoryMonitor {
mdbg("auto-import batch completed %d".printf(manifest.all.size));
LibraryWindow.get_app().clear_background_progress_bar();
LibraryWindow.clear_background_progress_bar();
foreach (BatchImportResult result in manifest.all) {
if (result.file != null)
......
......@@ -238,6 +238,8 @@ public class LibraryWindow : AppWindow {
Video.global.contents_altered.connect(sync_videos_visibility);
sync_videos_visibility();
MetadataWriter.get_instance().progress.connect(on_metadata_writer_progress);
}
~LibraryWindow() {
......@@ -259,6 +261,8 @@ public class LibraryWindow : AppWindow {
extended_properties.show.disconnect(show_extended_properties);
LibraryPhoto.global.trashcan_contents_altered.disconnect(on_trashcan_contents_altered);
MetadataWriter.get_instance().progress.disconnect(on_metadata_writer_progress);
}
private Gtk.ActionEntry[] create_actions() {
......@@ -345,8 +349,8 @@ public class LibraryWindow : AppWindow {
if (!basic_properties_action.get_active()) {
bottom_frame.hide();
}
}
}
public static LibraryWindow get_app() {
assert(instance is LibraryWindow);
......@@ -1407,7 +1411,10 @@ public class LibraryWindow : AppWindow {
sort_events_action.set_active(events_sort_ascending);
}
public void update_background_progress_bar(string label, double count, double total) {
public static void update_background_progress_bar(string label, double count, double total) {
if (instance == null)
return;
if (total <= 0.0) {
clear_background_progress_bar();
......@@ -1415,15 +1422,18 @@ public class LibraryWindow : AppWindow {
}
double fraction = count / total;
background_progress_bar.set_fraction(fraction);
background_progress_bar.set_text(_("%s (%d%%)").printf(label, (int) (fraction * 100.0)));
show_background_progress_bar();
get_app().background_progress_bar.set_fraction(fraction);
get_app().background_progress_bar.set_text(_("%s (%d%%)").printf(label, (int) (fraction * 100.0)));
get_app().show_background_progress_bar();
}
public void clear_background_progress_bar() {
background_progress_bar.set_fraction(0.0);
background_progress_bar.set_text("");
hide_background_progress_bar();
public static void clear_background_progress_bar() {
if (instance == null)
return;
get_app().background_progress_bar.set_fraction(0.0);
get_app().background_progress_bar.set_text("");
get_app().hide_background_progress_bar();
}
private void show_background_progress_bar() {
......@@ -1441,6 +1451,13 @@ public class LibraryWindow : AppWindow {
}
}
private void on_metadata_writer_progress(uint completed, uint total) {
if (completed >= total || completed == 0 || total == 0)
clear_background_progress_bar();
else
update_background_progress_bar(_("Writing metadata to files..."), completed, total);
}
private void create_layout(Page start_page) {