Commit e3afaf17 authored by Jim Nelson's avatar Jim Nelson

Lots of changes working toward events, including some database work.

parent a7c90ca1
......@@ -92,6 +92,8 @@ public class AppWindow : Gtk.Window {
private PhotoPage photoPage = null;
private PhotoTable photoTable = new PhotoTable();
private ImportTable importTable = new ImportTable();
private EventTable eventTable = new EventTable();
construct {
// if this is the first AppWindow, it's the main AppWindow
......@@ -103,6 +105,9 @@ public class AppWindow : Gtk.Window {
destroy += Gtk.main_quit;
message("Verifying databases ...");
verify_databases();
collectionPage = new CollectionPage();
photoPage = new PhotoPage();
......@@ -152,11 +157,36 @@ public class AppWindow : Gtk.Window {
"website", "http://www.yorba.org"
);
}
public class DateComparator : Comparator<int64?> {
private PhotoTable photoTable;
public DateComparator(PhotoTable photoTable) {
this.photoTable = photoTable;
}
public override int64 compare(int64? ida, int64? idb) {
long timea = photoTable.get_exposure_time(PhotoID(ida));
long timeb = photoTable.get_exposure_time(PhotoID(idb));
return timea - timeb;
}
}
private SortedList<int64?> imported_photos = null;
private ImportID import_id = ImportID();
public void start_import_batch() {
imported_photos = new SortedList<int64?>(new Gee.ArrayList<int64?>(), new DateComparator(photoTable));
import_id = importTable.generate();
}
public void import(File file) {
FileType type = file.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
if(type == FileType.REGULAR) {
if (!import_file(file)) {
// TODO: These should be aggregated so the user gets one report and not multiple,
// one for each file imported
Gtk.MessageDialog dialog = new Gtk.MessageDialog(this, Gtk.DialogFlags.MODAL,
Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s already stored",
file.get_path());
......@@ -175,6 +205,87 @@ public class AppWindow : Gtk.Window {
import_dir(file);
}
private static const long EVENT_LULL_MSEC = 3 * 60 * 60 * 1000;
private static const long EVENT_MAX_DURATION_MSEC = 12 * 60 * 60 * 1000;
public void end_import_batch() {
if (imported_photos == null)
return;
split_into_events(imported_photos);
// reset
imported_photos = null;
import_id = ImportID();
}
public void split_into_events(SortedList<int64?> list) {
debug("Processing photos to create events ...");
// walk through photos, splitting into events based on criteria
long last_exposure = 0;
long current_event_start = 0;
EventID current_event_id = EventID();
foreach (int64 id in imported_photos) {
PhotoID photo_id = PhotoID(id);
PhotoRow photo;
bool found = photoTable.get_photo(photo_id, out photo);
assert(found);
if (photo.exposure_time == 0) {
// no time recorded; skip
debug("Skipping %s: No exposure time", photo.file.get_path());
continue;
}
if (photo.event_id.is_valid()) {
// already part of an event; skip
debug("Skipping %s: Already part of event %lld", photo.file.get_path(),
photo.event_id.id);
continue;
}
Time photo_time = Time.local(photo.exposure_time);
string name = photo_time.to_string();
bool create_event = false;
if (last_exposure == 0) {
// first photo, start a new event
create_event = true;
} else {
assert(last_exposure <= photo.exposure_time);
assert(current_event_start <= photo.exposure_time);
if (photo.exposure_time - last_exposure >= EVENT_LULL_MSEC) {
// enough time has passed between photos to signify a new event
create_event = true;
} else if (photo.exposure_time - current_event_start >= EVENT_MAX_DURATION_MSEC) {
// the current event has gone on for too long, stop here and start a new one
create_event = true;
}
}
if (create_event) {
current_event_id = eventTable.create(name, photo_id);
current_event_start = photo.exposure_time;
debug("Creating event [%lld] %s", current_event_id.id, name);
}
assert(current_event_id.is_valid());
debug("Adding %s to event %lld (exposure=%ld last_exposure=%ld)", photo.file.get_path(),
current_event_id.id, photo.exposure_time, last_exposure);
photoTable.set_event(photo_id, current_event_id);
last_exposure = photo.exposure_time;
}
}
private void import_dir(File dir) {
assert(dir.query_file_type(FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) == FileType.DIRECTORY);
......@@ -197,7 +308,7 @@ public class AppWindow : Gtk.Window {
if (type == FileType.REGULAR) {
if (!import_file(file)) {
// TODO: Better error reporting
error("Failed to import %s (already imported?)", file.get_path());
message("Failed to import %s (already imported?)", file.get_path());
}
} else if (type == FileType.DIRECTORY) {
debug("Importing directory %s", file.get_path());
......@@ -214,32 +325,132 @@ public class AppWindow : Gtk.Window {
}
private bool import_file(File file) {
// TODO: This eases the pain until background threads are implemented
while (Gtk.events_pending()) {
if (Gtk.main_iteration()) {
debug("import_dir: Gtk.main_quit called");
return false;
}
}
debug("Importing file %s", file.get_path());
// TODO: Attempt to discover photo information from its metadata
Dimensions dim = Dimensions();
Exif.Orientation orientation = Exif.Orientation.TOP_LEFT;
time_t exposure_time = 0;
// TODO: Try to read JFIF metadata too
PhotoExif exif = PhotoExif.create(file);
if (exif.has_exif()) {
if (!exif.get_dimensions(out dim)) {
error("Unable to read EXIF dimensions for %s", file.get_path());
}
if (!exif.get_datetime_time(out exposure_time)) {
error("Unable to read EXIF orientation for %s", file.get_path());
}
orientation = exif.get_orientation();
}
Gdk.Pixbuf original;
try {
original = new Gdk.Pixbuf.from_file(file.get_path());
if (!exif.has_exif())
dim = Dimensions(original.get_width(), original.get_height());
} catch (Error err) {
error("%s", err.message);
}
Dimensions dim = Dimensions(original.get_width(), original.get_height());
if (photoTable.add(file, dim)) {
PhotoID photoID = photoTable.get_id(file);
ThumbnailCache.import(photoID, original);
collectionPage.add_photo(photoID, file);
collectionPage.refresh();
return true;
FileInfo info = null;
try {
info = file.query_info("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
} catch (Error err) {
error("%s", err.message);
}
debug("Not importing %s (already imported)", file.get_path());
TimeVal timestamp = TimeVal();
info.get_modification_time(timestamp);
PhotoID photoID = photoTable.add(file, dim, info.get_size(), timestamp.tv_sec, exposure_time,
orientation, import_id);
if (photoID.is_invalid()) {
debug("Not importing %s (already imported)", file.get_path());
return false;
}
ThumbnailCache.import(photoID, original);
collectionPage.add_photo(photoID, file);
collectionPage.refresh();
// add to imported list for splitting into events
if (imported_photos != null)
imported_photos.add(photoID.id);
return true;
}
private void verify_databases() {
PhotoID[] ids = photoTable.get_photo_ids();
foreach (PhotoID photoID in ids) {
PhotoRow row = PhotoRow();
photoTable.get_photo(photoID, out row);
FileInfo info = null;
try {
info = row.file.query_info("*", FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
} catch (Error err) {
error("%s", err.message);
}
TimeVal timestamp = TimeVal();
info.get_modification_time(timestamp);
// trust modification time and file size
if ((timestamp.tv_sec == row.timestamp) && (info.get_size() == row.filesize))
continue;
message("Time or filesize changed on %s, reimporting ...", row.file.get_path());
Dimensions dim = Dimensions();
Exif.Orientation orientation = Exif.Orientation.TOP_LEFT;
time_t exposure_time = 0;
// TODO: Try to read JFIF metadata too
PhotoExif exif = PhotoExif.create(row.file);
if (exif.has_exif()) {
if (!exif.get_dimensions(out dim)) {
error("Unable to read EXIF dimensions for %s", row.file.get_path());
}
if (!exif.get_datetime_time(out exposure_time)) {
error("Unable to read EXIF orientation for %s", row.file.get_path());
}
orientation = exif.get_orientation();
}
Gdk.Pixbuf original;
try {
original = new Gdk.Pixbuf.from_file(row.file.get_path());
if (!exif.has_exif())
dim = Dimensions(original.get_width(), original.get_height());
} catch (Error err) {
error("%s", err.message);
}
if (photoTable.update(photoID, row.file, dim, info.get_size(), timestamp.tv_sec, exposure_time,
orientation)) {
ThumbnailCache.import(photoID, original, true);
}
}
}
public override void drag_data_received(Gdk.DragContext context, int x, int y,
Gtk.SelectionData selectionData, uint info, uint time) {
// grab data and release back to system
......@@ -247,9 +458,11 @@ public class AppWindow : Gtk.Window {
Gtk.drag_finish(context, true, false, time);
// import
start_import_batch();
foreach (string uri in uris) {
import(File.new_for_uri(uri));
}
end_import_batch();
collectionPage.refresh();
}
......@@ -287,7 +500,7 @@ public class AppWindow : Gtk.Window {
Gtk.ScrolledWindow scrolledSidebar = new Gtk.ScrolledWindow(null, null);
scrolledSidebar.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
scrolledSidebar.add(sidebar);
// layout the selection tree to the left of the collection/toolbar box with an adjustable
// gutter between them, framed for presentation
Gtk.Frame leftFrame = new Gtk.Frame(null);
......@@ -382,6 +595,9 @@ public class AppWindow : Gtk.Window {
sidebarStore.append(out child, parent);
sidebarStore.set(child, 0, "Parties");
sidebarStore.append(out parent, null);
sidebarStore.set(parent, 0, "Last Import");
sidebarStore.append(out parent, null);
sidebarStore.set(parent, 0, "Cameras");
......
......@@ -112,8 +112,6 @@ public abstract class LayoutItem : Gtk.Alignment {
}
}
public delegate int CompareLayoutItem(LayoutItem a, LayoutItem b);
public class CollectionLayout : Gtk.Layout {
public static const int TOP_PADDING = 16;
public static const int BOTTOM_PADDING = 16;
......@@ -124,9 +122,8 @@ public class CollectionLayout : Gtk.Layout {
public static const int RIGHT_PADDING = 16;
public static const int COLUMN_GUTTER_PADDING = 24;
private Gee.ArrayList<LayoutItem> items = new Gee.ArrayList<LayoutItem>();
private SortedList<LayoutItem> items = new SortedList<LayoutItem>(new Gee.ArrayList<LayoutItem>());
private Gtk.Label message = null;
private CompareLayoutItem cmp = null;
public CollectionLayout() {
modify_bg(Gtk.StateType.NORMAL, AppWindow.BG_COLOR);
......@@ -146,44 +143,21 @@ public class CollectionLayout : Gtk.Layout {
display_message();
}
public void set_comparator(CompareLayoutItem cmp) {
this.cmp = cmp;
public void set_comparator(Comparator<LayoutItem> cmp) {
// re-sort list with new comparator
Gee.ArrayList<LayoutItem> resorted = new Gee.ArrayList<LayoutItem>();
SortedList<LayoutItem> resorted = new SortedList<LayoutItem>(new Gee.ArrayList<LayoutItem>(), cmp);
foreach (LayoutItem item in items) {
// add to new list and remove from Gtk.Layout
sorted_add_item(resorted, cmp, item);
resorted.add(item);
remove(item);
}
items = resorted;
}
private static void sorted_add_item(Gee.ArrayList<LayoutItem> items, CompareLayoutItem cmp,
LayoutItem item) {
if (cmp == null) {
items.add(item);
return;
}
for (int ctr = 0; ctr < items.size; ctr++) {
if (cmp(item, items.get(ctr)) < 0) {
// smaller, insert before this element
items.insert(ctr, item);
return;
}
}
// went off the end of the list, so add at end
items.add(item);
}
public void add_item(LayoutItem item) {
sorted_add_item(items, cmp, item);
items.add(item);
// this demolishes any message that's been set
if (message != null) {
......@@ -238,13 +212,17 @@ public class CollectionLayout : Gtk.Layout {
return;
}
if (items.size == 0)
return;
// don't bother until layout is of some appreciable size
if (allocation.width <= 1)
return;
// need to set_size in case all items were removed and the viewport size has changed
if (items.size == 0) {
set_size(allocation.width, 0);
return;
}
// Step 1: Determine the widest row in the layout, and from it the number of columns
int x = LEFT_PADDING;
int col = 0;
......
......@@ -69,7 +69,7 @@ public class CollectionPage : CheckerboardPage {
init_ui_bind("/CollectionMenuBar");
init_context_menu("/CollectionContextMenu");
set_layout_comparator(thumbnail_name_comparator);
set_layout_comparator(new CompareName());
// set up page's toolbar (used by AppWindow for layout)
//
......@@ -112,14 +112,16 @@ public class CollectionPage : CheckerboardPage {
File[] photoFiles = photoTable.get_photo_files();
foreach (File file in photoFiles) {
PhotoID photoID = photoTable.get_id(file);
debug("Loading [%lld] %s", photoID.id, file.get_path());
add_photo(photoID, file);
}
show_all();
refresh();
schedule_thumbnail_improval();
show_all();
}
public override Gtk.Toolbar get_toolbar() {
......@@ -143,7 +145,7 @@ public class CollectionPage : CheckerboardPage {
Thumbnail thumbnail = (Thumbnail) item;
// switch to full-page view
debug("switching to %s [%d]", thumbnail.get_file().get_path(),
debug("switching to %s [%lld]", thumbnail.get_file().get_path(),
thumbnail.get_photo_id().id);
AppWindow.get_instance().switch_to_photo_page(this, thumbnail);
......@@ -408,38 +410,46 @@ public class CollectionPage : CheckerboardPage {
return value;
}
private static int thumbnail_name_comparator(LayoutItem a, LayoutItem b) {
return strcmp(((Thumbnail) a).get_name(), ((Thumbnail) b).get_name());
private class CompareName : Comparator<LayoutItem> {
public override int64 compare(LayoutItem a, LayoutItem b) {
return strcmp(((Thumbnail) a).get_name(), ((Thumbnail) b).get_name());
}
}
private static int thumbnail_reverse_name_comparator(LayoutItem a, LayoutItem b) {
return strcmp(((Thumbnail) b).get_name(), ((Thumbnail) a).get_name());
private class ReverseCompareName : Comparator<LayoutItem> {
public override int64 compare(LayoutItem a, LayoutItem b) {
return strcmp(((Thumbnail) b).get_name(), ((Thumbnail) a).get_name());
}
}
private static int thumbnail_exposure_comparator(LayoutItem a, LayoutItem b) {
return (int) ((Thumbnail) a).get_time_t() - (int) ((Thumbnail) b).get_time_t();
private class CompareDate : Comparator<LayoutItem> {
public override int64 compare(LayoutItem a, LayoutItem b) {
return (int64) (((Thumbnail) a).get_exposure_time() - ((Thumbnail) b).get_exposure_time());
}
}
private static int thumbnail_reverse_exposure_comparator(LayoutItem a, LayoutItem b) {
return (int) ((Thumbnail) b).get_time_t() - (int) ((Thumbnail) a).get_time_t();
private class ReverseCompareDate : Comparator<LayoutItem> {
public override int64 compare(LayoutItem a, LayoutItem b) {
return (int) (((Thumbnail) b).get_exposure_time() - ((Thumbnail) a).get_exposure_time());
}
}
private void on_sort_changed() {
CompareLayoutItem cmp = null;
Comparator<LayoutItem> cmp = null;
switch (get_sort_criteria()) {
case SORT_BY_NAME: {
if (get_sort_order() == SORT_ORDER_ASCENDING) {
cmp = thumbnail_name_comparator;
cmp = new CompareName();
} else {
cmp = thumbnail_reverse_name_comparator;
cmp = new ReverseCompareName();
}
} break;
case SORT_BY_EXPOSURE_DATE: {
if (get_sort_order() == SORT_ORDER_ASCENDING) {
cmp = thumbnail_exposure_comparator;
cmp = new CompareDate();
} else {
cmp = thumbnail_reverse_exposure_comparator;
cmp = new ReverseCompareDate();
}
} break;
}
......
public struct DatabaseID {
public static const int64 INVALID = 0;
public int64 id;
public DatabaseID(int64 id = INVALID) {
this.id = id;
}
public bool is_invalid() {
return (id == INVALID);
}
public bool is_valid() {
return (id != INVALID);
}
}
public class DatabaseTable : Object {
protected static Sqlite.Database db;
......@@ -24,17 +42,21 @@ public class DatabaseTable : Object {
}
public struct PhotoID {
public static const int INVALID = -1;
public static const int64 INVALID = -1;
public int id;
public int64 id;
public PhotoID(int id = INVALID) {
public PhotoID(int64 id = INVALID) {
this.id = id;
}
public bool is_invalid() {
return (id == INVALID);
}
public bool is_valid() {
return (id != INVALID);
}
}
public class PhotoTable : DatabaseTable {
......@@ -44,7 +66,13 @@ public class PhotoTable : DatabaseTable {
+ "id INTEGER PRIMARY KEY, "
+ "filename TEXT UNIQUE NOT NULL, "
+ "width INTEGER, "
+ "height INTEGER"
+ "height INTEGER, "
+ "filesize INTEGER, "
+ "timestamp INTEGER, "
+ "exposure_time INTEGER, "
+ "orientation INTEGER, "
+ "import_id INTEGER, "
+ "event_id INTEGER"
+ ")", -1, out stmt);
assert(res == Sqlite.OK);
......@@ -54,11 +82,16 @@ public class PhotoTable : DatabaseTable {
}
}
public bool add(File file, Dimensions dim) {
public PhotoID add(File file, Dimensions dim, int64 filesize, long timestamp, long exposure_time,
Exif.Orientation orientation, ImportID importID) {
Sqlite.Statement stmt;
int res = db.prepare_v2("INSERT INTO PhotoTable (filename, width, height) VALUES (?, ?, ?)",
int res = db.prepare_v2(
"INSERT INTO PhotoTable (filename, width, height, filesize, timestamp, exposure_time, orientation, import_id, event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
-1, out stmt);
assert(res == Sqlite.OK);
debug("Import %s %dx%d size=%lld mod=%ld exp=%ld or=%d", file.get_path(), dim.width, dim.height,
filesize, timestamp, exposure_time, (int) orientation);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
......@@ -66,24 +99,106 @@ public class PhotoTable : DatabaseTable {
assert(res == Sqlite.OK);
res = stmt.bind_int(3, dim.height);
assert(res == Sqlite.OK);
res = stmt.bind_int64(4, filesize);
assert(res == Sqlite.OK);
res = stmt.bind_int64(5, timestamp);
assert(res == Sqlite.OK);
res = stmt.bind_int64(6, exposure_time);
assert(res == Sqlite.OK);
res = stmt.bind_int64(7, orientation);
assert(res == Sqlite.OK);
res = stmt.bind_int64(8, importID.id);
assert(res == Sqlite.OK);
res = stmt.bind_int64(9, PhotoID.INVALID);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
if (res != Sqlite.CONSTRAINT)
fatal("add_photo", res);
return PhotoID();
}
return PhotoID(db.last_insert_rowid());
}
public bool update(PhotoID photoID, File file, Dimensions dim, int64 filesize, long timestamp, long exposure_time,
Exif.Orientation orientation) {
TimeVal time_imported = TimeVal();
time_imported.get_current_time();
Sqlite.Statement stmt;
int res = db.prepare_v2(
"UPDATE PhotoTable SET filename = ?, width = ?, height = ?, filesize = ?, timestamp = ?, "
+ "exposure_time = ?, orientation = ?, time_imported = ? WHERE id = ?",
-1, out stmt);
assert(res == Sqlite.OK);
debug("Update [%lld] %s %dx%d size=%lld mod=%ld exp=%ld or=%d", photoID.id, file.get_path(), dim.width,
dim.height, filesize, timestamp, exposure_time, (int) orientation);
res = stmt.bind_text(1, file.get_path());
assert(res == Sqlite.OK);
res = stmt.bind_int(2, dim.width);
assert(res == Sqlite.OK);
res = stmt.bind_int(3, dim.height);
assert(res == Sqlite.OK);
res = stmt.bind_int64(4, filesize);
assert(res == Sqlite.OK);
res = stmt.bind_int64(5, timestamp);
assert(res == Sqlite.OK);
res = stmt.bind_int64(6, exposure_time);
assert(res == Sqlite.OK);
res = stmt.bind_int64(7, orientation);
assert(res == Sqlite.OK);
res = stmt.bind_int64(8, time_imported.tv_sec);
assert(res == Sqlite.OK);
res = stmt.bind_int64(9, photoID.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.DONE) {
if (res != Sqlite.CONSTRAINT)
fatal("update_photo", res);
return false;
}
return true;
}
public bool get_photo(PhotoID photoID, out PhotoRow row) {
Sqlite.Statement stmt;
int res = db.prepare_v2("SELECT filename, width, height, filesize, timestamp, exposure_time, orientation, import_id, event_id FROM PhotoTable WHERE id=?", -1, out stmt);
assert(res == Sqlite.OK);
res = stmt.bind_int64(1, photoID.id);
assert(res == Sqlite.OK);
res = stmt.step();
if (res != Sqlite.ROW)
return false;
row.photo_id = photoID;
row.file = File.new_for_path(stmt.column_text(0));
row.dim = Dimensions(stmt.column_int(1), stmt.column_int(2));
row.filesize = stmt.column_int64(3);
row.timestamp = (long) stmt.column_int64(4);
row.exposure_time = (long) stmt.column_int64(