summaryrefslogtreecommitdiff
path: root/src/camera
diff options
context:
space:
mode:
Diffstat (limited to 'src/camera')
-rw-r--r--src/camera/Branch.vala116
-rw-r--r--src/camera/Camera.vala18
-rw-r--r--src/camera/CameraTable.vala417
-rw-r--r--src/camera/GPhoto.vala367
-rw-r--r--src/camera/ImportPage.vala1799
-rw-r--r--src/camera/mk/camera.mk32
6 files changed, 2749 insertions, 0 deletions
diff --git a/src/camera/Branch.vala b/src/camera/Branch.vala
new file mode 100644
index 0000000..63a5443
--- /dev/null
+++ b/src/camera/Branch.vala
@@ -0,0 +1,116 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public class Camera.Branch : Sidebar.Branch {
+ internal static Icon? cameras_icon = null;
+
+ private Gee.HashMap<DiscoveredCamera, Camera.SidebarEntry> camera_map = new Gee.HashMap<
+ DiscoveredCamera, Camera.SidebarEntry>();
+
+ public Branch() {
+ base (new Camera.Grouping(),
+ Sidebar.Branch.Options.HIDE_IF_EMPTY | Sidebar.Branch.Options.AUTO_OPEN_ON_NEW_CHILD,
+ camera_comparator);
+
+ foreach (DiscoveredCamera camera in CameraTable.get_instance().get_cameras())
+ add_camera(camera);
+
+ CameraTable.get_instance().camera_added.connect(on_camera_added);
+ CameraTable.get_instance().camera_removed.connect(on_camera_removed);
+ }
+
+ internal static void init() {
+ cameras_icon = new GLib.ThemedIcon(Resources.ICON_CAMERAS);
+ }
+
+ internal static void terminate() {
+ cameras_icon = null;
+ }
+
+ private static int camera_comparator(Sidebar.Entry a, Sidebar.Entry b) {
+ if (a == b)
+ return 0;
+
+ // Compare based on name.
+ int ret = a.get_sidebar_name().collate(b.get_sidebar_name());
+ if (ret == 0) {
+ // Cameras had same name! Fallback to URI comparison.
+ Camera.SidebarEntry? cam_a = a as Camera.SidebarEntry;
+ Camera.SidebarEntry? cam_b = b as Camera.SidebarEntry;
+ assert (cam_a != null && cam_b != null);
+ ret = cam_a.get_uri().collate(cam_b.get_uri());
+ }
+
+ return ret;
+ }
+
+ public Camera.SidebarEntry? get_entry_for_camera(DiscoveredCamera camera) {
+ return camera_map.get(camera);
+ }
+
+ private void on_camera_added(DiscoveredCamera camera) {
+ add_camera(camera);
+ }
+
+ private void on_camera_removed(DiscoveredCamera camera) {
+ remove_camera(camera);
+ }
+
+ private void add_camera(DiscoveredCamera camera) {
+ assert(!camera_map.has_key(camera));
+
+ Camera.SidebarEntry entry = new Camera.SidebarEntry(camera);
+ camera_map.set(camera, entry);
+
+ // want to show before adding page so the grouping is available to graft onto
+ graft(get_root(), entry);
+ }
+
+ private void remove_camera(DiscoveredCamera camera) {
+ assert(camera_map.has_key(camera));
+
+ Camera.SidebarEntry? entry = camera_map.get(camera);
+ assert(entry != null);
+
+ bool removed = camera_map.unset(camera);
+ assert(removed);
+
+ prune(entry);
+ }
+}
+
+public class Camera.Grouping : Sidebar.Grouping {
+ public Grouping() {
+ base (_("Cameras"), Camera.Branch.cameras_icon);
+ }
+}
+
+public class Camera.SidebarEntry : Sidebar.SimplePageEntry {
+ private DiscoveredCamera camera;
+ private string uri;
+
+ public SidebarEntry(DiscoveredCamera camera) {
+ this.camera = camera;
+ this.uri = camera.uri;
+ }
+
+ public override string get_sidebar_name() {
+ return camera.display_name ?? _("Camera");
+ }
+
+ public override Icon? get_sidebar_icon() {
+ return camera.icon ?? Camera.Branch.cameras_icon;
+ }
+
+ protected override Page create_page() {
+ return new ImportPage(camera.gcamera, uri, get_sidebar_name(), get_sidebar_icon());
+ }
+
+ public string get_uri() {
+ return uri;
+ }
+}
+
diff --git a/src/camera/Camera.vala b/src/camera/Camera.vala
new file mode 100644
index 0000000..f6d1f4b
--- /dev/null
+++ b/src/camera/Camera.vala
@@ -0,0 +1,18 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace Camera {
+
+public void init() throws Error {
+ Camera.Branch.init();
+}
+
+public void terminate() {
+ Camera.Branch.terminate();
+}
+
+}
+
diff --git a/src/camera/CameraTable.vala b/src/camera/CameraTable.vala
new file mode 100644
index 0000000..8466388
--- /dev/null
+++ b/src/camera/CameraTable.vala
@@ -0,0 +1,417 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public class DiscoveredCamera {
+ public GPhoto.Camera gcamera;
+ public string uri;
+ public string display_name;
+ public GLib.Icon? icon;
+
+ public DiscoveredCamera(GPhoto.Camera gcamera, string uri, string display_name, GLib.Icon? icon) {
+ this.gcamera = gcamera;
+ this.uri = uri;
+ this.display_name = display_name;
+ this.icon = icon;
+ }
+}
+
+public class CameraTable {
+ private const int UPDATE_DELAY_MSEC = 1000;
+
+ // list of subsystems being monitored for events
+ private const string[] SUBSYSTEMS = { "usb", "block", null };
+
+ private static CameraTable instance = null;
+
+ private GUdev.Client client = new GUdev.Client(SUBSYSTEMS);
+ private OneShotScheduler camera_update_scheduler = null;
+ private GPhoto.Context null_context = new GPhoto.Context();
+ private GPhoto.CameraAbilitiesList abilities_list;
+ private VolumeMonitor volume_monitor;
+
+ private Gee.HashMap<string, DiscoveredCamera> camera_map = new Gee.HashMap<string, DiscoveredCamera>();
+
+ public signal void camera_added(DiscoveredCamera camera);
+
+ public signal void camera_removed(DiscoveredCamera camera);
+
+ private CameraTable() {
+ camera_update_scheduler = new OneShotScheduler("CameraTable update scheduler",
+ on_update_cameras);
+
+ // listen for interesting events on the specified subsystems
+ client.uevent.connect(on_udev_event);
+ volume_monitor = VolumeMonitor.get();
+ volume_monitor.volume_changed.connect(on_volume_changed);
+ volume_monitor.volume_added.connect(on_volume_changed);
+
+ // because loading the camera abilities list takes a bit of time and slows down app
+ // startup, delay loading it (and notifying any observers) for a small period of time,
+ // after the dust has settled
+ Timeout.add(500, delayed_init);
+ }
+
+ private bool delayed_init() {
+ // We disable this here so cameras that are already connected at the time
+ // the application is launched don't interfere with normal navigation...
+ ((LibraryWindow) AppWindow.get_instance()).set_page_switching_enabled(false);
+
+ try {
+ init_camera_table();
+ } catch (GPhotoError err) {
+ warning("Unable to initialize camera table: %s", err.message);
+
+ return false;
+ }
+
+ try {
+ update_camera_table();
+ } catch (GPhotoError err) {
+ warning("Unable to update camera table: %s", err.message);
+ }
+
+ // ...and re-enable it here, so that cameras connected -after- the initial
+ // populating of the table will trigger a switch to the import page, as before.
+ ((LibraryWindow) AppWindow.get_instance()).set_page_switching_enabled(true);
+ return false;
+ }
+
+ public static CameraTable get_instance() {
+ if (instance == null)
+ instance = new CameraTable();
+
+ return instance;
+ }
+
+ public Gee.Iterable<DiscoveredCamera> get_cameras() {
+ return camera_map.values;
+ }
+
+ public int get_count() {
+ return camera_map.size;
+ }
+
+ public DiscoveredCamera? get_for_uri(string uri) {
+ return camera_map.get(uri);
+ }
+
+ private void do_op(GPhoto.Result res, string op) throws GPhotoError {
+ if (res != GPhoto.Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Unable to %s: %s", (int) res, op, res.as_string());
+ }
+
+ private void init_camera_table() throws GPhotoError {
+ do_op(GPhoto.CameraAbilitiesList.create(out abilities_list), "create camera abilities list");
+ do_op(abilities_list.load(null_context), "load camera abilities list");
+ }
+
+ private string[] get_all_usb_cameras() {
+ string[] cameras = new string[0];
+
+ GLib.List<GUdev.Device> device_list = client.query_by_subsystem(null);
+ foreach (GUdev.Device device in device_list) {
+ string device_file = device.get_device_file();
+ if(
+ // only keep devices that have a non-null device file and that
+ // have both the ID_GPHOTO2 and GPHOTO2_DRIVER properties set
+ (device_file != null) &&
+ (device.has_property("ID_GPHOTO2")) &&
+ (device.has_property("GPHOTO2_DRIVER"))
+ ) {
+ int camera_bus, camera_device;
+ // extract the bus and device IDs from the device file string
+ // TODO: is it safe to parse the absolute path or should we be
+ // smarter and use a regex to only pick up the end of the path?
+ if (device_file.scanf("/dev/bus/usb/%d/%d", out camera_bus, out camera_device) < 2) {
+ critical("get_all_usb_cameras: Failed to scanf device file %s", device_file);
+
+ continue;
+ }
+ string camera = "usb:%.3d,%.3d".printf(camera_bus, camera_device);
+ debug("USB camera detected at %s", camera);
+ cameras += camera;
+ }
+ }
+
+ return cameras;
+ }
+
+ // USB (or libusb) is a funny beast; if only one USB device is present (i.e. the camera),
+ // then a single camera is detected at port usb:. However, if multiple USB devices are
+ // present (including non-cameras), then the first attached camera will be listed twice,
+ // first at usb:, then at usb:xxx,yyy. If the usb: device is removed, another usb:xxx,yyy
+ // device will lose its full-path name and be referred to as usb: only.
+ //
+ // This function gleans the full port name of a particular port, even if it's the unadorned
+ // "usb:", by using GUdev.
+ private bool usb_esp(int current_camera_count, string[] usb_cameras, string port,
+ out string full_port) {
+ // sanity
+ assert(current_camera_count > 0);
+
+ debug("USB ESP: current_camera_count=%d port=%s", current_camera_count, port);
+
+ full_port = null;
+
+ // if GPhoto detects one camera, and USB reports one camera, all is swell
+ if (current_camera_count == 1 && usb_cameras.length == 1) {
+ full_port = usb_cameras[0];
+
+ debug("USB ESP: port=%s full_port=%s", port, full_port);
+
+ return true;
+ }
+
+ // with more than one camera, skip the mirrored "usb:" port
+ if (port == "usb:") {
+ debug("USB ESP: Skipping %s", port);
+
+ return false;
+ }
+
+ // parse out the bus and device ID
+ int bus, device;
+ if (port.scanf("usb:%d,%d", out bus, out device) < 2) {
+ critical("USB ESP: Failed to scanf %s", port);
+
+ return false;
+ }
+
+ foreach (string usb_camera in usb_cameras) {
+ int camera_bus, camera_device;
+ if (usb_camera.scanf("usb:%d,%d", out camera_bus, out camera_device) < 2) {
+ critical("USB ESP: Failed to scanf %s", usb_camera);
+
+ continue;
+ }
+
+ if ((bus == camera_bus) && (device == camera_device)) {
+ full_port = port;
+
+ debug("USB ESP: port=%s full_port=%s", port, full_port);
+
+ return true;
+ }
+ }
+
+ debug("USB ESP: No matching bus/device found for port=%s", port);
+
+ return false;
+ }
+
+ public static string get_port_uri(string port) {
+ return "gphoto2://[%s]/".printf(port);
+ }
+
+ public static string? get_port_path(string port) {
+ // Accepted format is usb:001,005
+ return port.has_prefix("usb:") ?
+ "/dev/bus/usb/%s".printf(port.substring(4).replace(",", "/")) : null;
+ }
+
+ private string? get_name_for_uuid(string uuid) {
+ foreach (Volume volume in volume_monitor.get_volumes()) {
+ if (volume.get_identifier(VolumeIdentifier.UUID) == uuid) {
+ return volume.get_name();
+ }
+ }
+ return null;
+ }
+
+ private GLib.Icon? get_icon_for_uuid(string uuid) {
+ foreach (Volume volume in volume_monitor.get_volumes()) {
+ if (volume.get_identifier(VolumeIdentifier.UUID) == uuid) {
+ return volume.get_icon();
+ }
+ }
+ return null;
+ }
+
+ private void update_camera_table() throws GPhotoError {
+ // need to do this because virtual ports come and go in the USB world (and probably others)
+ GPhoto.PortInfoList port_info_list;
+ do_op(GPhoto.PortInfoList.create(out port_info_list), "create port list");
+ do_op(port_info_list.load(), "load port list");
+
+ GPhoto.CameraList camera_list;
+ do_op(GPhoto.CameraList.create(out camera_list), "create camera list");
+ do_op(abilities_list.detect(port_info_list, camera_list, null_context), "detect cameras");
+
+ Gee.HashMap<string, string> detected_map = new Gee.HashMap<string, string>();
+
+ // walk the USB chain and find all PTP cameras; this is necessary for usb_esp
+ string[] usb_cameras = get_all_usb_cameras();
+
+ // go through the detected camera list and glean their ports
+ for (int ctr = 0; ctr < camera_list.count(); ctr++) {
+ string name;
+ do_op(camera_list.get_name(ctr, out name), "get detected camera name");
+
+ string port;
+ do_op(camera_list.get_value(ctr, out port), "get detected camera port");
+
+ debug("Detected %d/%d %s @ %s", ctr + 1, camera_list.count(), name, port);
+
+ // do some USB ESP, skipping ports that cannot be deduced
+ if (port.has_prefix("usb:")) {
+ string full_port;
+ if (!usb_esp(camera_list.count(), usb_cameras, port, out full_port))
+ continue;
+
+ port = full_port;
+ }
+
+ detected_map.set(port, name);
+ }
+
+ // find cameras that have disappeared
+ DiscoveredCamera[] missing = new DiscoveredCamera[0];
+ foreach (DiscoveredCamera camera in camera_map.values) {
+ GPhoto.PortInfo port_info;
+ string tmp_path;
+
+ do_op(camera.gcamera.get_port_info(out port_info),
+ "retrieve missing camera port information");
+
+#if WITH_GPHOTO_25
+ port_info.get_path(out tmp_path);
+#else
+ tmp_path = port_info.path;
+#endif
+
+ GPhoto.CameraAbilities abilities;
+ do_op(camera.gcamera.get_abilities(out abilities), "retrieve camera abilities");
+
+ if (detected_map.has_key(tmp_path)) {
+ debug("Found camera for %s @ %s in detected map", abilities.model, tmp_path);
+
+ continue;
+ }
+
+ debug("%s @ %s missing", abilities.model, tmp_path);
+
+ missing += camera;
+ }
+
+ // have to remove from hash map outside of iterator
+ foreach (DiscoveredCamera camera in missing) {
+ GPhoto.PortInfo port_info;
+ string tmp_path;
+
+ do_op(camera.gcamera.get_port_info(out port_info),
+ "retrieve missing camera port information");
+#if WITH_GPHOTO_25
+ port_info.get_path(out tmp_path);
+#else
+ tmp_path = port_info.path;
+#endif
+
+ GPhoto.CameraAbilities abilities;
+ do_op(camera.gcamera.get_abilities(out abilities), "retrieve missing camera abilities");
+
+ debug("Removing from camera table: %s @ %s", abilities.model, tmp_path);
+
+ camera_map.unset(get_port_uri(tmp_path));
+
+ camera_removed(camera);
+ }
+
+ // add cameras which were not present before
+ foreach (string port in detected_map.keys) {
+ string name = detected_map.get(port);
+ string display_name = null;
+ GLib.Icon? icon = null;
+ string uri = get_port_uri(port);
+
+ if (camera_map.has_key(uri)) {
+ // already known about
+ debug("%s @ %s already registered, skipping", name, port);
+
+ continue;
+ }
+
+ // Get display name for camera.
+ string path = get_port_path(port);
+ if (null != path) {
+ GUdev.Device device = client.query_by_device_file(path);
+ string serial = device.get_property("ID_SERIAL_SHORT");
+ if (null != serial) {
+ // Try to get the name and icon.
+ display_name = get_name_for_uuid(serial);
+ icon = get_icon_for_uuid(serial);
+ }
+ if (null == display_name) {
+ display_name = device.get_sysfs_attr("product");
+ }
+ if (null == display_name) {
+ display_name = device.get_property("ID_MODEL");
+ }
+ }
+ if (null == display_name) {
+ // Default to GPhoto detected name.
+ display_name = name;
+ }
+
+ int index = port_info_list.lookup_path(port);
+ if (index < 0)
+ do_op((GPhoto.Result) index, "lookup port %s".printf(port));
+
+ GPhoto.PortInfo port_info;
+ string tmp_path;
+
+ do_op(port_info_list.get_info(index, out port_info), "get port info for %s".printf(port));
+#if WITH_GPHOTO_25
+ port_info.get_path(out tmp_path);
+#else
+ tmp_path = port_info.path;
+#endif
+
+ // this should match, every time
+ assert(port == tmp_path);
+
+ index = abilities_list.lookup_model(name);
+ if (index < 0)
+ do_op((GPhoto.Result) index, "lookup camera model %s".printf(name));
+
+ GPhoto.CameraAbilities camera_abilities;
+ do_op(abilities_list.get_abilities(index, out camera_abilities),
+ "lookup camera abilities for %s".printf(name));
+
+ GPhoto.Camera gcamera;
+ do_op(GPhoto.Camera.create(out gcamera), "create camera object for %s".printf(name));
+ do_op(gcamera.set_abilities(camera_abilities), "set camera abilities for %s".printf(name));
+ do_op(gcamera.set_port_info(port_info), "set port info for %s on %s".printf(name, port));
+
+ debug("Adding to camera table: %s @ %s", name, port);
+
+ DiscoveredCamera camera = new DiscoveredCamera(gcamera, uri, display_name, icon);
+ camera_map.set(uri, camera);
+
+ camera_added(camera);
+ }
+ }
+
+ private void on_udev_event(string action, GUdev.Device device) {
+ debug("udev event: %s on %s", action, device.get_name());
+
+ // Device add/removes often arrive in pairs; this allows for a single
+ // update to occur when they come in all at once
+ camera_update_scheduler.after_timeout(UPDATE_DELAY_MSEC, true);
+ }
+
+ public void on_volume_changed(Volume volume) {
+ camera_update_scheduler.after_timeout(UPDATE_DELAY_MSEC, true);
+ }
+
+ private void on_update_cameras() {
+ try {
+ get_instance().update_camera_table();
+ } catch (GPhotoError err) {
+ warning("Error updating camera table: %s", err.message);
+ }
+ }
+}
+
diff --git a/src/camera/GPhoto.vala b/src/camera/GPhoto.vala
new file mode 100644
index 0000000..a1a46cb
--- /dev/null
+++ b/src/camera/GPhoto.vala
@@ -0,0 +1,367 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public errordomain GPhotoError {
+ LIBRARY
+}
+
+namespace GPhoto {
+ // ContextWrapper assigns signals to the various GPhoto.Context callbacks, as well as spins
+ // the event loop at opportune times.
+ public class ContextWrapper {
+ public Context context = new Context();
+
+ public ContextWrapper() {
+ context.set_idle_func(on_idle);
+ context.set_error_func(on_error);
+ context.set_status_func(on_status);
+ context.set_message_func(on_message);
+ context.set_progress_funcs(on_progress_start, on_progress_update, on_progress_stop);
+ }
+
+ public virtual void idle() {
+ }
+
+#if WITH_GPHOTO_25
+
+ public virtual void error(string text, void *data) {
+ }
+
+ public virtual void status(string text, void *data) {
+ }
+
+ public virtual void message(string text, void *data) {
+ }
+
+ public virtual void progress_start(float current, string text, void *data) {
+ }
+
+ public virtual void progress_update(float current, void *data) {
+ }
+
+ public virtual void progress_stop() {
+ }
+
+ private void on_idle(Context context) {
+ idle();
+ }
+
+ private void on_error(Context context, string text) {
+ error(text, null);
+ }
+
+ private void on_status(Context context, string text) {
+ status(text, null);
+ }
+
+ private void on_message(Context context, string text) {
+ message(text, null);
+ }
+
+ private uint on_progress_start(Context context, float target, string text) {
+ progress_start(target, text, null);
+
+ return 0;
+ }
+
+ private void on_progress_update(Context context, uint id, float current) {
+ progress_update(current, null);
+ }
+
+ private void on_progress_stop(Context context, uint id) {
+ progress_stop();
+ }
+
+#else
+
+ public virtual void error(string format, void *va_list) {
+ }
+
+ public virtual void status(string format, void *va_list) {
+ }
+
+ public virtual void message(string format, void *va_list) {
+ }
+
+ public virtual void progress_start(float target, string format, void *va_list) {
+ }
+
+ public virtual void progress_update(float current) {
+ }
+
+ public virtual void progress_stop() {
+ }
+
+ private void on_idle(Context context) {
+ idle();
+ }
+
+ private void on_error(Context context, string format, void *va_list) {
+ error(format, va_list);
+ }
+
+ private void on_status(Context context, string format, void *va_list) {
+ status(format, va_list);
+ }
+
+ private void on_message(Context context, string format, void *va_list) {
+ message(format, va_list);
+ }
+
+ private uint on_progress_start(Context context, float target, string format, void *va_list) {
+ progress_start(target, format, va_list);
+
+ return 0;
+ }
+
+ private void on_progress_update(Context context, uint id, float current) {
+ progress_update(current);
+ }
+
+ private void on_progress_stop(Context context, uint id) {
+ progress_stop();
+ }
+
+#endif
+ }
+
+ public class SpinIdleWrapper : ContextWrapper {
+ public SpinIdleWrapper() {
+ }
+
+ public override void idle() {
+ base.idle();
+
+ spin_event_loop();
+ }
+#if WITH_GPHOTO_25
+ public override void progress_update(float current, void *data) {
+ base.progress_update(current, data);
+
+ spin_event_loop();
+ }
+#else
+ public override void progress_update(float current) {
+ base.progress_update(current);
+
+ spin_event_loop();
+ }
+#endif
+ }
+
+ // For CameraFileInfoFile, CameraFileInfoPreview, and CameraStorageInformation. See:
+ // http://redmine.yorba.org/issues/1851
+ // https://bugzilla.redhat.com/show_bug.cgi?id=585676
+ // https://sourceforge.net/tracker/?func=detail&aid=3000198&group_id=8874&atid=108874
+ public const int MAX_FILENAME_LENGTH = 63;
+ public const int MAX_BASEDIR_LENGTH = 255;
+
+ public bool get_info(Context context, Camera camera, string folder, string filename,
+ out CameraFileInfo info) throws Error {
+ if (folder.length > MAX_BASEDIR_LENGTH || filename.length > MAX_FILENAME_LENGTH) {
+ info = {};
+
+ return false;
+ }
+
+ Result res = camera.get_file_info(folder, filename, out info, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file information for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ return true;
+ }
+
+ // Libgphoto will in some instances refuse to get metadata from a camera, but the camera is accessable as a
+ // filesystem. In these cases shotwell can access the file directly. See:
+ // http://redmine.yorba.org/issues/2959
+ public PhotoMetadata? get_fallback_metadata(Camera camera, Context context, string folder, string filename) {
+ GPhoto.CameraStorageInformation *sifs = null;
+ int count = 0;
+ camera.get_storageinfo(&sifs, out count, context);
+
+ GPhoto.PortInfo port_info;
+ camera.get_port_info(out port_info);
+
+ string path;
+#if WITH_GPHOTO_25
+ port_info.get_path(out path);
+#else
+ path = port_info.path;
+#endif
+
+ string prefix = "disk:";
+ if(path.has_prefix(prefix))
+ path = path[prefix.length:path.length];
+ else
+ return null;
+
+ PhotoMetadata? metadata = new PhotoMetadata();
+ try {
+ metadata.read_from_file(File.new_for_path(path + folder + "/" + filename));
+ } catch {
+ metadata = null;
+ }
+
+ return metadata;
+ }
+
+ public Gdk.Pixbuf? load_preview(Context context, Camera camera, string folder, string filename,
+ out uint8[] raw, out size_t raw_length) throws Error {
+ raw = null;
+ raw_length = 0;
+
+ try {
+ raw = load_file_into_buffer(context, camera, folder, filename, GPhoto.CameraFileType.PREVIEW);
+ } catch {
+ PhotoMetadata metadata = get_fallback_metadata(camera, context, folder, filename);
+ if(null == metadata)
+ return null;
+ if(0 == metadata.get_preview_count())
+ return null;
+ PhotoPreview? preview = metadata.get_preview(metadata.get_preview_count() - 1);
+ raw = preview.flatten();
+ }
+
+ if (raw == null) {
+ raw_length = 0;
+ return null;
+ }
+
+ raw_length = raw.length;
+
+ // Try to make sure last two bytes are JPEG footer.
+ // This is necessary because GPhoto sometimes includes a few extra bytes. See
+ // Yorba bug #2905 and the following GPhoto bug:
+ // http://sourceforge.net/tracker/?func=detail&aid=3141521&group_id=8874&atid=108874
+ if (raw_length > 32) {
+ for (size_t i = raw_length - 2; i > raw_length - 32; i--) {
+ if (raw[i] == Jpeg.MARKER_PREFIX && raw[i + 1] == Jpeg.Marker.EOI) {
+ debug("Adjusted length of thumbnail for: %s", filename);
+ raw_length = i + 2;
+ break;
+ }
+ }
+ }
+
+ MemoryInputStream mins = new MemoryInputStream.from_data(raw, null);
+ return new Gdk.Pixbuf.from_stream_at_scale(mins, ImportPreview.MAX_SCALE, ImportPreview.MAX_SCALE, true, null);
+ }
+
+ public Gdk.Pixbuf? load_image(Context context, Camera camera, string folder, string filename)
+ throws Error {
+ InputStream ins = load_file_into_stream(context, camera, folder, filename, GPhoto.CameraFileType.NORMAL);
+ if (ins == null)
+ return null;
+
+ return new Gdk.Pixbuf.from_stream(ins, null);
+ }
+
+ public void save_image(Context context, Camera camera, string folder, string filename,
+ File dest_file) throws Error {
+ GPhoto.CameraFile camera_file;
+ GPhoto.Result res = GPhoto.CameraFile.create(out camera_file);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error allocating camera file: %s", (int) res, res.as_string());
+
+ res = camera.get_file(folder, filename, GPhoto.CameraFileType.NORMAL, camera_file, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file object for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ res = camera_file.save(dest_file.get_path());
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error copying file %s/%s to %s: %s", (int) res,
+ folder, filename, dest_file.get_path(), res.as_string());
+ }
+
+ public PhotoMetadata? load_metadata(Context context, Camera camera, string folder, string filename)
+ throws Error {
+ uint8[] camera_raw = null;
+ try {
+ camera_raw = load_file_into_buffer(context, camera, folder, filename, GPhoto.CameraFileType.EXIF);
+ } catch {
+ return get_fallback_metadata(camera, context, folder, filename);
+ }
+
+ if (camera_raw == null || camera_raw.length == 0)
+ return null;
+
+ PhotoMetadata metadata = new PhotoMetadata();
+ metadata.read_from_app1_segment(camera_raw);
+
+ return metadata;
+ }
+
+ // Returns an InputStream for the requested camera file. The stream should be used
+ // immediately rather than stored, as the backing is temporary in nature.
+ public InputStream load_file_into_stream(Context context, Camera camera, string folder, string filename,
+ GPhoto.CameraFileType filetype) throws Error {
+ GPhoto.CameraFile camera_file;
+ GPhoto.Result res = GPhoto.CameraFile.create(out camera_file);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error allocating camera file: %s", (int) res, res.as_string());
+
+ res = camera.get_file(folder, filename, filetype, camera_file, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file object for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ // if entire file fits in memory, return a stream from that ... can't merely wrap
+ // MemoryInputStream around the camera_file buffer, as that will be destroyed when the
+ // function returns
+ unowned uint8 *data;
+ ulong data_len;
+ res = camera_file.get_data_and_size(out data, out data_len);
+ if (res == Result.OK) {
+ uint8[] buffer = new uint8[data_len];
+ Memory.copy(buffer, data, buffer.length);
+
+ return new MemoryInputStream.from_data(buffer, on_mins_destroyed);
+ }
+
+ // if not stored in memory, try copying it to a temp file and then reading out of that
+ File temp = AppDirs.get_temp_dir().get_child("import.tmp");
+ res = camera_file.save(temp.get_path());
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error copying file %s/%s to %s: %s", (int) res,
+ folder, filename, temp.get_path(), res.as_string());
+
+ return temp.read(null);
+ }
+
+ private static void on_mins_destroyed(void *data) {
+ free(data);
+ }
+
+ // Returns a buffer with the requested file, if within reason. Use load_file for larger files.
+ public uint8[]? load_file_into_buffer(Context context, Camera camera, string folder,
+ string filename, CameraFileType filetype) throws Error {
+ GPhoto.CameraFile camera_file;
+ GPhoto.Result res = GPhoto.CameraFile.create(out camera_file);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error allocating camera file: %s", (int) res, res.as_string());
+
+ res = camera.get_file(folder, filename, filetype, camera_file, context);
+ if (res != Result.OK)
+ throw new GPhotoError.LIBRARY("[%d] Error retrieving file object for %s/%s: %s",
+ (int) res, folder, filename, res.as_string());
+
+ // if buffer can be loaded into memory, return a copy of that (can't return buffer itself
+ // as it will be destroyed when the camera_file is unref'd)
+ unowned uint8 *data;
+ ulong data_len;
+ res = camera_file.get_data_and_size(out data, out data_len);
+ if (res != Result.OK)
+ return null;
+
+ uint8[] buffer = new uint8[data_len];
+ Memory.copy(buffer, data, buffer.length);
+
+ return buffer;
+ }
+}
+
diff --git a/src/camera/ImportPage.vala b/src/camera/ImportPage.vala
new file mode 100644
index 0000000..823a458
--- /dev/null
+++ b/src/camera/ImportPage.vala
@@ -0,0 +1,1799 @@
+/* Copyright 2009-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+private class ImportSourceCollection : SourceCollection {
+ public ImportSourceCollection(string name) {
+ base (name);
+ }
+
+ public override bool holds_type_of_source(DataSource source) {
+ return source is ImportSource;
+ }
+}
+
+abstract class ImportSource : ThumbnailSource, Indexable {
+ private string camera_name;
+ private GPhoto.Camera camera;
+ private int fsid;
+ private string folder;
+ private string filename;
+ private ulong file_size;
+ private time_t modification_time;
+ private Gdk.Pixbuf? preview = null;
+ private string? indexable_keywords = null;
+
+ public ImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder,
+ string filename, ulong file_size, time_t modification_time) {
+ this.camera_name = camera_name;
+ this.camera = camera;
+ this.fsid = fsid;
+ this.folder = folder;
+ this.filename = filename;
+ this.file_size = file_size;
+ this.modification_time = modification_time;
+ indexable_keywords = prepare_indexable_string(filename);
+ }
+
+ protected void set_preview(Gdk.Pixbuf? preview) {
+ this.preview = preview;
+ }
+
+ public string get_camera_name() {
+ return camera_name;
+ }
+
+ public GPhoto.Camera get_camera() {
+ return camera;
+ }
+
+ public int get_fsid() {
+ return fsid;
+ }
+
+ public string get_folder() {
+ return folder;
+ }
+
+ public string get_filename() {
+ return filename;
+ }
+
+ public ulong get_filesize() {
+ return file_size;
+ }
+
+ public time_t get_modification_time() {
+ return modification_time;
+ }
+
+ public virtual Gdk.Pixbuf? get_preview() {
+ return preview;
+ }
+
+ public virtual time_t get_exposure_time() {
+ return get_modification_time();
+ }
+
+ public string? get_fulldir() {
+ return ImportPage.get_fulldir(get_camera(), get_camera_name(), get_fsid(), get_folder());
+ }
+
+ public override string to_string() {
+ return "%s %s/%s".printf(get_camera_name(), get_folder(), get_filename());
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ debug("Deleting %s from %s", to_string(), camera_name);
+
+ string? fulldir = get_fulldir();
+ if (fulldir == null) {
+ warning("Skipping deleting %s from %s: invalid folder name", to_string(), camera_name);
+
+ return base.internal_delete_backing();
+ }
+
+ GPhoto.Result result = get_camera().delete_file(fulldir, get_filename(),
+ ImportPage.spin_idle_context.context);
+ if (result != GPhoto.Result.OK)
+ warning("Error deleting %s from %s: %s", to_string(), camera_name, result.to_full_string());
+
+ return base.internal_delete_backing() && (result == GPhoto.Result.OK);
+ }
+
+ public unowned string? get_indexable_keywords() {
+ return indexable_keywords;
+ }
+}
+
+class VideoImportSource : ImportSource {
+ public VideoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder,
+ string filename, ulong file_size, time_t modification_time) {
+ base(camera_name, camera, fsid, folder, filename, file_size, modification_time);
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ return create_thumbnail(scale);
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ if (get_preview() == null)
+ return null;
+
+ // this satifies the return-a-new-instance requirement of create_thumbnail( ) because
+ // scale_pixbuf( ) allocates a new pixbuf
+ return (scale > 0) ? scale_pixbuf(get_preview(), scale, Gdk.InterpType.BILINEAR, true) :
+ get_preview();
+ }
+
+ public override string get_typename() {
+ return "videoimport";
+ }
+
+ public override int64 get_instance_id() {
+ return get_object_id();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return PhotoFileFormat.get_system_default_format();
+ }
+
+ public override string get_name() {
+ return get_filename();
+ }
+
+ public void update(Gdk.Pixbuf? preview) {
+ set_preview((preview != null) ? preview : Resources.get_noninterpretable_badge_pixbuf());
+ }
+}
+
+class PhotoImportSource : ImportSource {
+ public const Gdk.InterpType INTERP = Gdk.InterpType.BILINEAR;
+
+ private PhotoFileFormat file_format;
+ private string? preview_md5 = null;
+ private PhotoMetadata? metadata = null;
+ private string? exif_md5 = null;
+ private PhotoImportSource? associated = null; // JPEG source for RAW+JPEG
+
+ public PhotoImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder,
+ string filename, ulong file_size, time_t modification_time, PhotoFileFormat file_format) {
+ base(camera_name, camera, fsid, folder, filename, file_size, modification_time);
+ this.file_format = file_format;
+ }
+
+ public override string get_name() {
+ string? title = get_title();
+
+ return !is_string_empty(title) ? title : get_filename();
+ }
+
+ public override string get_typename() {
+ return "photoimport";
+ }
+
+ public override int64 get_instance_id() {
+ return get_object_id();
+ }
+
+ public override PhotoFileFormat get_preferred_thumbnail_format() {
+ return (file_format.can_write()) ? file_format :
+ PhotoFileFormat.get_system_default_format();
+ }
+
+ public override Gdk.Pixbuf? create_thumbnail(int scale) throws Error {
+ if (get_preview() == null)
+ return null;
+
+ // this satifies the return-a-new-instance requirement of create_thumbnail( ) because
+ // scale_pixbuf( ) allocates a new pixbuf
+ return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview();
+ }
+
+ // Needed because previews and exif are loaded after other information has been gathered.
+ public void update(Gdk.Pixbuf? preview, string? preview_md5, PhotoMetadata? metadata, string? exif_md5) {
+ set_preview(preview);
+ this.preview_md5 = preview_md5;
+ this.metadata = metadata;
+ this.exif_md5 = exif_md5;
+ }
+
+ public override time_t get_exposure_time() {
+ if (metadata == null)
+ return get_modification_time();
+
+ MetadataDateTime? date_time = metadata.get_exposure_date_time();
+
+ return (date_time != null) ? date_time.get_timestamp() : get_modification_time();
+ }
+
+ public string? get_title() {
+ return (metadata != null) ? metadata.get_title() : null;
+ }
+
+ public PhotoMetadata? get_metadata() {
+ if (associated != null)
+ return associated.get_metadata();
+
+ return metadata;
+ }
+
+ public override Gdk.Pixbuf? get_preview() {
+ if (associated != null)
+ return associated.get_preview();
+
+ if (base.get_preview() != null)
+ return base.get_preview();
+
+ return null;
+ }
+
+ public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
+ if (get_preview() == null)
+ return null;
+
+ return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview();
+ }
+
+ public PhotoFileFormat get_file_format() {
+ return file_format;
+ }
+
+ public string? get_preview_md5() {
+ return preview_md5;
+ }
+
+ public void set_associated(PhotoImportSource? associated) {
+ this.associated = associated;
+ }
+
+ public PhotoImportSource? get_associated() {
+ return associated;
+ }
+
+ public override bool internal_delete_backing() throws Error {
+ bool ret = base.internal_delete_backing();
+ if (associated != null)
+ ret &= associated.internal_delete_backing();
+ return ret;
+ }
+}
+
+class ImportPreview : MediaSourceItem {
+ public const int MAX_SCALE = 128;
+
+ private static Gdk.Pixbuf placeholder_preview = null;
+
+ private DuplicatedFile? duplicated_file;
+
+ public ImportPreview(ImportSource source) {
+ base(source, Dimensions(), source.get_name(), null);
+
+ this.duplicated_file = null;
+
+ // draw sprocket holes as visual indications on video previews
+ if (source is VideoImportSource)
+ set_enable_sprockets(true);
+
+ // scale down pixbuf if necessary
+ Gdk.Pixbuf pixbuf = null;
+ try {
+ pixbuf = source.get_thumbnail(0);
+ } catch (Error err) {
+ warning("Unable to fetch loaded import preview for %s: %s", to_string(), err.message);
+ }
+
+ // use placeholder if no preview available
+ bool using_placeholder = (pixbuf == null);
+ if (pixbuf == null) {
+ if (placeholder_preview == null) {
+ placeholder_preview = AppWindow.get_instance().render_icon(Gtk.Stock.MISSING_IMAGE,
+ Gtk.IconSize.DIALOG, null);
+ placeholder_preview = scale_pixbuf(placeholder_preview, MAX_SCALE,
+ Gdk.InterpType.BILINEAR, true);
+ }
+
+ pixbuf = placeholder_preview;
+ }
+
+ // scale down if too large
+ if (pixbuf.get_width() > MAX_SCALE || pixbuf.get_height() > MAX_SCALE)
+ pixbuf = scale_pixbuf(pixbuf, MAX_SCALE, PhotoImportSource.INTERP, false);
+
+ if (source is PhotoImportSource) {
+ // honor rotation for photos -- we don't care about videos since they can't be rotated
+ PhotoImportSource photo_import_source = source as PhotoImportSource;
+ if (!using_placeholder && photo_import_source.get_metadata() != null)
+ pixbuf = photo_import_source.get_metadata().get_orientation().rotate_pixbuf(pixbuf);
+
+ if (photo_import_source.get_associated() != null) {
+ set_subtitle("<small>%s</small>".printf(_("RAW+JPEG")), true);
+ }
+ }
+
+ set_image(pixbuf);
+ }
+
+ public bool is_already_imported() {
+ PhotoImportSource photo_import_source = get_import_source() as PhotoImportSource;
+ if (photo_import_source != null) {
+ string? preview_md5 = photo_import_source.get_preview_md5();
+ PhotoFileFormat file_format = photo_import_source.get_file_format();
+
+ // ignore trashed duplicates
+ if (!is_string_empty(preview_md5)
+ && LibraryPhoto.has_nontrash_duplicate(null, preview_md5, null, file_format)) {
+
+ duplicated_file = DuplicatedFile.create_from_photo_id(
+ LibraryPhoto.get_nontrash_duplicate(null, preview_md5, null, file_format));
+
+ return true;
+ }
+
+ // Because gPhoto doesn't reliably return thumbnails for RAW files, and because we want
+ // to avoid downloading huge RAW files during an "import all" only to determine they're
+ // duplicates, use the image's basename and filesize to do duplicate detection
+ if (file_format == PhotoFileFormat.RAW) {
+ uint64 filesize = get_import_source().get_filesize();
+ // unlikely to be a problem, but what the hay
+ if (filesize <= int64.MAX) {
+ if (LibraryPhoto.global.has_basename_filesize_duplicate(
+ get_import_source().get_filename(), (int64) filesize)) {
+
+ duplicated_file = DuplicatedFile.create_from_photo_id(
+ LibraryPhoto.global.get_basename_filesize_duplicate(
+ get_import_source().get_filename(), (int64) filesize));
+
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ VideoImportSource video_import_source = get_import_source() as VideoImportSource;
+ if (video_import_source != null) {
+ // Unlike photos, if a video does have a thumbnail (i.e. gphoto2 can retrieve one from
+ // a sidecar file), it will be unavailable to Shotwell during the import process, so
+ // no comparison is available. Instead, like RAW files, use name and filesize to
+ // do a less-reliable but better-than-nothing comparison
+ if (Video.global.has_basename_filesize_duplicate(video_import_source.get_filename(),
+ video_import_source.get_filesize())) {
+
+ duplicated_file = DuplicatedFile.create_from_video_id(
+ Video.global.get_basename_filesize_duplicate(
+ video_import_source.get_filename(),
+ video_import_source.get_filesize()));
+
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ public DuplicatedFile? get_duplicated_file() {
+ if (!is_already_imported())
+ return null;
+
+ return duplicated_file;
+ }
+
+ public ImportSource get_import_source() {
+ return (ImportSource) get_source();
+ }
+}
+
+public class CameraViewTracker : Core.ViewTracker {
+ public CameraAccumulator all = new CameraAccumulator();
+ public CameraAccumulator visible = new CameraAccumulator();
+ public CameraAccumulator selected = new CameraAccumulator();
+
+ public CameraViewTracker(ViewCollection collection) {
+ base (collection);
+
+ start(all, visible, selected);
+ }
+}
+
+public class CameraAccumulator : Object, Core.TrackerAccumulator {
+ public int total { get; private set; default = 0; }
+ public int photos { get; private set; default = 0; }
+ public int videos { get; private set; default = 0; }
+ public int raw { get; private set; default = 0; }
+
+ public bool include(DataObject object) {
+ ImportSource source = (ImportSource) ((DataView) object).get_source();
+
+ total++;
+
+ PhotoImportSource? photo = source as PhotoImportSource;
+ if (photo != null && photo.get_file_format() != PhotoFileFormat.RAW)
+ photos++;
+ else if (photo != null && photo.get_file_format() == PhotoFileFormat.RAW)
+ raw++;
+ else if (source is VideoImportSource)
+ videos++;
+
+ // because of total, always fire "updated"
+ return true;
+ }
+
+ public bool uninclude(DataObject object) {
+ ImportSource source = (ImportSource) ((DataView) object).get_source();
+
+ total++;
+
+ PhotoImportSource? photo = source as PhotoImportSource;
+ if (photo != null && photo.get_file_format() != PhotoFileFormat.RAW) {
+ assert(photos > 0);
+ photos--;
+ } else if (photo != null && photo.get_file_format() == PhotoFileFormat.RAW) {
+ assert(raw > 0);
+ raw--;
+ } else if (source is VideoImportSource) {
+ assert(videos > 0);
+ videos--;
+ }
+
+ // because of total, always fire "updated"
+ return true;
+ }
+
+ public bool altered(DataObject object, Alteration alteration) {
+ // no alteration affects accumulated data
+ return false;
+ }
+
+ public string to_string() {
+ return "%d total/%d photos/%d videos/%d raw".printf(total, photos, videos, raw);
+ }
+}
+
+public class ImportPage : CheckerboardPage {
+ private const string UNMOUNT_FAILED_MSG = _("Unable to unmount camera. Try unmounting the camera from the file manager.");
+
+ private class ImportViewManager : ViewManager {
+ private ImportPage owner;
+
+ public ImportViewManager(ImportPage owner) {
+ this.owner = owner;
+ }
+
+ public override DataView create_view(DataSource source) {
+ return new ImportPreview((ImportSource) source);
+ }
+ }
+
+ private class CameraImportJob : BatchImportJob {
+ private GPhoto.ContextWrapper context;
+ private ImportSource import_file;
+ private GPhoto.Camera camera;
+ private string fulldir;
+ private string filename;
+ private uint64 filesize;
+ private PhotoMetadata metadata;
+ private time_t exposure_time;
+ private CameraImportJob? associated = null;
+ private BackingPhotoRow? associated_file = null;
+ private DuplicatedFile? duplicated_file;
+
+ public CameraImportJob(GPhoto.ContextWrapper context, ImportSource import_file,
+ DuplicatedFile? duplicated_file = null) {
+ this.context = context;
+ this.import_file = import_file;
+ this.duplicated_file = duplicated_file;
+
+ // stash everything called in prepare(), as it may/will be called from a separate thread
+ camera = import_file.get_camera();
+ fulldir = import_file.get_fulldir();
+ // this should've been caught long ago when the files were first enumerated
+ assert(fulldir != null);
+ filename = import_file.get_filename();
+ filesize = import_file.get_filesize();
+ metadata = (import_file is PhotoImportSource) ?
+ (import_file as PhotoImportSource).get_metadata() : null;
+ exposure_time = import_file.get_exposure_time();
+ }
+
+ public time_t get_exposure_time() {
+ return exposure_time;
+ }
+
+ public override DuplicatedFile? get_duplicated_file() {
+ return duplicated_file;
+ }
+
+ public override time_t get_exposure_time_override() {
+ return (import_file is VideoImportSource) ? get_exposure_time() : 0;
+ }
+
+ public override string get_dest_identifier() {
+ return filename;
+ }
+
+ public override string get_source_identifier() {
+ return import_file.get_filename();
+ }
+
+ public override string get_basename() {
+ return filename;
+ }
+
+ public override string get_path() {
+ return fulldir;
+ }
+
+ public override void set_associated(BatchImportJob associated) {
+ this.associated = associated as CameraImportJob;
+ }
+
+ public ImportSource get_source() {
+ return import_file;
+ }
+
+ public override bool is_directory() {
+ return false;
+ }
+
+ public override bool determine_file_size(out uint64 filesize, out File file) {
+ file = null;
+ filesize = this.filesize;
+
+ return true;
+ }
+
+ public override bool prepare(out File file_to_import, out bool copy_to_library) throws Error {
+ file_to_import = null;
+ copy_to_library = false;
+
+ File dest_file = null;
+ try {
+ bool collision;
+ dest_file = LibraryFiles.generate_unique_file(filename, metadata, exposure_time,
+ out collision);
+ } catch (Error err) {
+ warning("Unable to generate local file for %s: %s", import_file.get_filename(),
+ err.message);
+ }
+
+ if (dest_file == null) {
+ message("Unable to generate local file for %s", import_file.get_filename());
+
+ return false;
+ }
+
+ // always blacklist the copied images from the LibraryMonitor, otherwise it'll think
+ // they should be auto-imported
+ LibraryMonitor.blacklist_file(dest_file, "CameraImportJob.prepare");
+ try {
+ GPhoto.save_image(context.context, camera, fulldir, filename, dest_file);
+ } finally {
+ LibraryMonitor.unblacklist_file(dest_file);
+ }
+
+ // Copy over associated file, if it exists.
+ if (associated != null) {
+ try {
+ associated_file =
+ RawDeveloper.CAMERA.create_backing_row_for_development(dest_file.get_path(),
+ associated.get_basename());
+ } catch (Error err) {
+ warning("Unable to generate backing associated file for %s: %s", associated.filename,
+ err.message);
+ }
+
+ if (associated_file == null) {
+ message("Unable to generate backing associated file for %s", associated.filename);
+ return false;
+ }
+
+ File assoc_dest = File.new_for_path(associated_file.filepath);
+ LibraryMonitor.blacklist_file(assoc_dest, "CameraImportJob.prepare");
+ try {
+ GPhoto.save_image(context.context, camera, associated.fulldir, associated.filename,
+ assoc_dest);
+ } finally {
+ LibraryMonitor.unblacklist_file(assoc_dest);
+ }
+ }
+
+ file_to_import = dest_file;
+ copy_to_library = false;
+
+ return true;
+ }
+
+ public override bool complete(MediaSource source, BatchImportRoll import_roll) throws Error {
+ bool ret = false;
+ if (source is Photo) {
+ Photo photo = source as Photo;
+
+ // Associate paired JPEG with RAW photo.
+ if (associated_file != null) {
+ photo.add_backing_photo_for_development(RawDeveloper.CAMERA, associated_file);
+ ret = true;
+ photo.set_raw_developer(Config.Facade.get_instance().get_default_raw_developer());
+ }
+ }
+ return ret;
+ }
+ }
+
+ private class ImportPageSearchViewFilter : SearchViewFilter {
+ public override uint get_criteria() {
+ return SearchFilterCriteria.TEXT | SearchFilterCriteria.MEDIA;
+ }
+
+ public override bool predicate(DataView view) {
+ ImportSource source = ((ImportPreview) view).get_import_source();
+
+ // Media type.
+ if ((bool) (SearchFilterCriteria.MEDIA & get_criteria()) && filter_by_media_type()) {
+ if (source is VideoImportSource) {
+ if (!show_media_video)
+ return false;
+ } else if (source is PhotoImportSource) {
+ PhotoImportSource photo = source as PhotoImportSource;
+ if (photo.get_file_format() == PhotoFileFormat.RAW) {
+ if (photo.get_associated() != null) {
+ if (!show_media_photos && !show_media_raw)
+ return false;
+ } else if (!show_media_raw) {
+ return false;
+ }
+ } else if (!show_media_photos)
+ return false;
+ }
+ }
+
+ if ((bool) (SearchFilterCriteria.TEXT & get_criteria())) {
+ unowned string? keywords = source.get_indexable_keywords();
+ if (is_string_empty(keywords))
+ return false;
+
+ // Return false if the word isn't found, true otherwise.
+ foreach (unowned string word in get_search_filter_words()) {
+ if (!keywords.contains(word))
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ // View filter for already imported filter.
+ private class HideImportedViewFilter : ViewFilter {
+ public override bool predicate(DataView view) {
+ return !((ImportPreview) view).is_already_imported();
+ }
+ }
+
+ public static GPhoto.ContextWrapper null_context = null;
+ public static GPhoto.SpinIdleWrapper spin_idle_context = null;
+
+ private SourceCollection import_sources = null;
+ private Gtk.Label camera_label = new Gtk.Label(null);
+ private Gtk.CheckButton hide_imported;
+ private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar();
+ private GPhoto.Camera camera;
+ private string uri;
+ private bool busy = false;
+ private bool refreshed = false;
+ private GPhoto.Result refresh_result = GPhoto.Result.OK;
+ private string refresh_error = null;
+ private string camera_name;
+ private VolumeMonitor volume_monitor = null;
+ private ImportPage? local_ref = null;
+ private GLib.Icon? icon;
+ private ImportPageSearchViewFilter search_filter = new ImportPageSearchViewFilter();
+ private HideImportedViewFilter hide_imported_filter = new HideImportedViewFilter();
+ private CameraViewTracker tracker;
+
+#if UNITY_SUPPORT
+ UnityProgressBar uniprobar = UnityProgressBar.get_instance();
+#endif
+
+ public enum RefreshResult {
+ OK,
+ BUSY,
+ LOCKED,
+ LIBRARY_ERROR
+ }
+
+ public ImportPage(GPhoto.Camera camera, string uri, string? display_name = null, GLib.Icon? icon = null) {
+ base(_("Camera"));
+ this.camera = camera;
+ this.uri = uri;
+ this.import_sources = new ImportSourceCollection("ImportSources for %s".printf(uri));
+ this.icon = icon;
+
+ tracker = new CameraViewTracker(get_view());
+
+ // Get camera name.
+ if (null != display_name) {
+ camera_name = display_name;
+ } else {
+ GPhoto.CameraAbilities abilities;
+ GPhoto.Result res = camera.get_abilities(out abilities);
+ if (res != GPhoto.Result.OK) {
+ debug("Unable to get camera abilities: %s", res.to_full_string());
+ camera_name = _("Camera");
+ }
+ }
+ camera_label.set_text(camera_name);
+ set_page_name(camera_name);
+
+ // Mount.unmounted signal is *only* fired when a VolumeMonitor has been instantiated.
+ this.volume_monitor = VolumeMonitor.get();
+
+ // set up the global null context when needed
+ if (null_context == null)
+ null_context = new GPhoto.ContextWrapper();
+
+ // same with idle-loop wrapper
+ if (spin_idle_context == null)
+ spin_idle_context = new GPhoto.SpinIdleWrapper();
+
+ // monitor source collection to add/remove views
+ get_view().monitor_source_collection(import_sources, new ImportViewManager(this), null);
+
+ // sort by exposure time
+ get_view().set_comparator(preview_comparator, preview_comparator_predicate);
+
+ // monitor selection for UI
+ get_view().items_state_changed.connect(on_view_changed);
+ get_view().contents_altered.connect(on_view_changed);
+ get_view().items_visibility_changed.connect(on_view_changed);
+
+ // Show subtitles.
+ get_view().set_property(CheckerboardItem.PROP_SHOW_SUBTITLES, true);
+
+ // monitor Photos for removals, as that will change the result of the ViewFilter
+ LibraryPhoto.global.contents_altered.connect(on_media_added_removed);
+ Video.global.contents_altered.connect(on_media_added_removed);
+
+ init_item_context_menu("/ImportContextMenu");
+ init_page_context_menu("/ImportContextMenu");
+ }
+
+ ~ImportPage() {
+ LibraryPhoto.global.contents_altered.disconnect(on_media_added_removed);
+ Video.global.contents_altered.disconnect(on_media_added_removed);
+ }
+
+ public override Gtk.Toolbar get_toolbar() {
+ if (toolbar == null) {
+ base.get_toolbar();
+
+ // hide duplicates checkbox
+ hide_imported = new Gtk.CheckButton.with_label(_("Hide photos already imported"));
+ hide_imported.set_tooltip_text(_("Only display photos that have not been imported"));
+ hide_imported.clicked.connect(on_hide_imported);
+ hide_imported.sensitive = false;
+ hide_imported.active = Config.Facade.get_instance().get_hide_photos_already_imported();
+ Gtk.ToolItem hide_item = new Gtk.ToolItem();
+ hide_item.is_important = true;
+ hide_item.add(hide_imported);
+
+ toolbar.insert(hide_item, -1);
+
+ // separator to force buttons to right side of toolbar
+ Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
+ separator.set_draw(false);
+
+ toolbar.insert(separator, -1);
+
+ // progress bar in center of toolbar
+ progress_bar.set_orientation(Gtk.Orientation.HORIZONTAL);
+ progress_bar.visible = false;
+ Gtk.ToolItem progress_item = new Gtk.ToolItem();
+ progress_item.set_expand(true);
+ progress_item.add(progress_bar);
+ progress_bar.set_show_text(true);
+
+ toolbar.insert(progress_item, -1);
+
+ // Find button
+ Gtk.ToggleToolButton find_button = new Gtk.ToggleToolButton();
+ find_button.set_related_action(get_action("CommonDisplaySearchbar"));
+
+ toolbar.insert(find_button, -1);
+
+ // Separator
+ toolbar.insert(new Gtk.SeparatorToolItem(), -1);
+
+ // Import selected
+ Gtk.ToolButton import_selected_button = new Gtk.ToolButton.from_stock(Resources.IMPORT);
+ import_selected_button.set_related_action(get_action("ImportSelected"));
+
+ toolbar.insert(import_selected_button, -1);
+
+ // Import all
+ Gtk.ToolButton import_all_button = new Gtk.ToolButton.from_stock(Resources.IMPORT_ALL);
+ import_all_button.set_related_action(get_action("ImportAll"));
+
+ toolbar.insert(import_all_button, -1);
+
+ // restrain the recalcitrant rascal! prevents the progress bar from being added to the
+ // show_all queue so we have more control over its visibility
+ progress_bar.set_no_show_all(true);
+
+ update_toolbar_state();
+
+ show_all();
+ }
+
+ return toolbar;
+ }
+
+ public override Core.ViewTracker? get_view_tracker() {
+ return tracker;
+ }
+
+ // Ticket #3304 - Import page shouldn't display confusing message
+ // prior to import.
+ // TODO: replace this with approved text for "talking to camera,
+ // please wait" once new strings are being accepted.
+ protected override string get_view_empty_message() {
+ return _("Starting import, please wait...");
+ }
+
+ private static int64 preview_comparator(void *a, void *b) {
+ return ((ImportPreview *) a)->get_import_source().get_exposure_time()
+ - ((ImportPreview *) b)->get_import_source().get_exposure_time();
+ }
+
+ private static bool preview_comparator_predicate(DataObject object, Alteration alteration) {
+ return alteration.has_detail("metadata", "exposure-time");
+ }
+
+ private int64 import_job_comparator(void *a, void *b) {
+ return ((CameraImportJob *) a)->get_exposure_time() - ((CameraImportJob *) b)->get_exposure_time();
+ }
+
+ protected override void init_collect_ui_filenames(Gee.List<string> ui_filenames) {
+ base.init_collect_ui_filenames(ui_filenames);
+
+ ui_filenames.add("import.ui");
+ }
+
+ protected override Gtk.ToggleActionEntry[] init_collect_toggle_action_entries() {
+ Gtk.ToggleActionEntry[] toggle_actions = base.init_collect_toggle_action_entries();
+
+ Gtk.ToggleActionEntry titles = { "ViewTitle", null, TRANSLATABLE, "<Ctrl><Shift>T",
+ TRANSLATABLE, on_display_titles, Config.Facade.get_instance().get_display_photo_titles() };
+ titles.label = _("_Titles");
+ titles.tooltip = _("Display the title of each photo");
+ toggle_actions += titles;
+
+ return toggle_actions;
+ }
+
+ protected override Gtk.ActionEntry[] init_collect_action_entries() {
+ Gtk.ActionEntry[] actions = base.init_collect_action_entries();
+
+ Gtk.ActionEntry import_selected = { "ImportSelected", Resources.IMPORT,
+ TRANSLATABLE, null, null, on_import_selected };
+ import_selected.label = _("Import _Selected");
+ import_selected.tooltip = _("Import the selected photos into your library");
+ actions += import_selected;
+
+ Gtk.ActionEntry import_all = { "ImportAll", Resources.IMPORT_ALL, TRANSLATABLE,
+ null, null, on_import_all };
+ import_all.label = _("Import _All");
+ import_all.tooltip = _("Import all the photos into your library");
+ actions += import_all;
+
+ return actions;
+ }
+
+ public GPhoto.Camera get_camera() {
+ return camera;
+ }
+
+ public string get_uri() {
+ return uri;
+ }
+
+ public bool is_busy() {
+ return busy;
+ }
+
+ protected override void init_actions(int selected_count, int count) {
+ on_view_changed();
+
+ set_action_important("ImportSelected", true);
+ set_action_important("ImportAll", true);
+
+ base.init_actions(selected_count, count);
+ }
+
+ public bool is_refreshed() {
+ return refreshed && !busy;
+ }
+
+ public string? get_refresh_message() {
+ string msg = null;
+ if (refresh_error != null) {
+ msg = refresh_error;
+ } else if (refresh_result == GPhoto.Result.OK) {
+ // all went well
+ } else {
+ msg = refresh_result.to_full_string();
+ }
+
+ return msg;
+ }
+
+ private void update_status(bool busy, bool refreshed) {
+ this.busy = busy;
+ this.refreshed = refreshed;
+
+ on_view_changed();
+ }
+
+ private void update_toolbar_state() {
+ if (hide_imported != null)
+ hide_imported.sensitive = !busy && refreshed && (get_view().get_unfiltered_count() > 0);
+ }
+
+ private void on_view_changed() {
+ set_action_sensitive("ImportSelected", !busy && refreshed && get_view().get_selected_count() > 0);
+ set_action_sensitive("ImportAll", !busy && refreshed && get_view().get_count() > 0);
+ AppWindow.get_instance().set_common_action_sensitive("CommonSelectAll",
+ !busy && (get_view().get_count() > 0));
+
+ update_toolbar_state();
+ }
+
+ private void on_media_added_removed() {
+ search_filter.refresh();
+ }
+
+ private void on_display_titles(Gtk.Action action) {
+ bool display = ((Gtk.ToggleAction) action).get_active();
+
+ set_display_titles(display);
+ Config.Facade.get_instance().set_display_photo_titles(display);
+ }
+
+ public override void switched_to() {
+ set_display_titles(Config.Facade.get_instance().get_display_photo_titles());
+
+ base.switched_to();
+ }
+
+ public override void ready() {
+ try_refreshing_camera(false);
+ hide_imported_filter.refresh();
+ }
+
+ private void try_refreshing_camera(bool fail_on_locked) {
+ // if camera has been refreshed or is in the process of refreshing, go no further
+ if (refreshed || busy)
+ return;
+
+ RefreshResult res = refresh_camera();
+ switch (res) {
+ case ImportPage.RefreshResult.OK:
+ case ImportPage.RefreshResult.BUSY:
+ // nothing to report; if busy, let it continue doing its thing
+ // (although earlier check should've caught this)
+ break;
+
+ case ImportPage.RefreshResult.LOCKED:
+ if (fail_on_locked) {
+ AppWindow.error_message(UNMOUNT_FAILED_MSG);
+
+ break;
+ }
+
+ // if locked because it's mounted, offer to unmount
+ debug("Checking if %s is mounted ...", uri);
+
+ File uri = File.new_for_uri(uri);
+
+ Mount mount = null;
+ try {
+ mount = uri.find_enclosing_mount(null);
+ } catch (Error err) {
+ // error means not mounted
+ }
+
+ if (mount != null) {
+ // it's mounted, offer to unmount for the user
+ string mounted_message = _("Shotwell needs to unmount the camera from the filesystem in order to access it. Continue?");
+
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION,
+ Gtk.ButtonsType.CANCEL, "%s", mounted_message);
+ dialog.title = Resources.APP_TITLE;
+ dialog.add_button(_("_Unmount"), Gtk.ResponseType.YES);
+ int dialog_res = dialog.run();
+ dialog.destroy();
+
+ if (dialog_res != Gtk.ResponseType.YES) {
+ set_page_message(_("Please unmount the camera."));
+ } else {
+ unmount_camera(mount);
+ }
+ } else {
+ string locked_message = _("The camera is locked by another application. Shotwell can only access the camera when it's unlocked. Please close any other application using the camera and try again.");
+
+ // it's not mounted, so another application must have it locked
+ Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(),
+ Gtk.DialogFlags.MODAL, Gtk.MessageType.WARNING,
+ Gtk.ButtonsType.OK, "%s", locked_message);
+ dialog.title = Resources.APP_TITLE;
+ dialog.run();
+ dialog.destroy();
+
+ set_page_message(_("Please close any other application using the camera."));
+ }
+ break;
+
+ case ImportPage.RefreshResult.LIBRARY_ERROR:
+ AppWindow.error_message(_("Unable to fetch previews from the camera:\n%s").printf(
+ get_refresh_message()));
+ break;
+
+ default:
+ error("Unknown result type %d", (int) res);
+ }
+ }
+
+ public bool unmount_camera(Mount mount) {
+ if (busy)
+ return false;
+
+ update_status(true, false);
+ progress_bar.visible = true;
+ progress_bar.set_fraction(0.0);
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text(_("Unmounting..."));
+
+ // unmount_with_operation() can/will complete with the volume still mounted (probably meaning
+ // it's been *scheduled* for unmounting). However, this signal is fired when the mount
+ // really is unmounted -- *if* a VolumeMonitor has been instantiated.
+ mount.unmounted.connect(on_unmounted);
+
+ debug("Unmounting camera ...");
+ mount.unmount_with_operation.begin(MountUnmountFlags.NONE,
+ new Gtk.MountOperation(AppWindow.get_instance()), null, on_unmount_finished);
+
+ return true;
+ }
+
+ private void on_unmount_finished(Object? source, AsyncResult aresult) {
+ debug("Async unmount finished");
+
+ Mount mount = (Mount) source;
+ try {
+ mount.unmount_with_operation.end(aresult);
+ } catch (Error err) {
+ AppWindow.error_message(UNMOUNT_FAILED_MSG);
+
+ // don't trap this signal, even if it does come in, we've backed off
+ mount.unmounted.disconnect(on_unmounted);
+
+ update_status(false, refreshed);
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.visible = false;
+ }
+ }
+
+ private void on_unmounted(Mount mount) {
+ debug("on_unmounted");
+
+ update_status(false, refreshed);
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.visible = false;
+
+ try_refreshing_camera(true);
+ }
+
+ private void clear_all_import_sources() {
+ Marker marker = import_sources.start_marking();
+ marker.mark_all();
+ import_sources.destroy_marked(marker, false);
+ }
+
+ /**
+ * @brief Returns whether the current device has a given directory or not.
+ *
+ * @param fsid The file system id of the camera or other device to search.
+ * @param dir The path to start searching from.
+ * @param search_target The name of the directory to look for.
+ */
+ private bool check_directory_exists(int fsid, string dir, string search_target) {
+ string? fulldir = get_fulldir(camera, camera_name, fsid, dir);
+ GPhoto.Result result;
+ GPhoto.CameraList folders;
+
+ result = GPhoto.CameraList.create(out folders);
+ if (result != GPhoto.Result.OK) {
+ // couldn't create a list - can't determine whether specified dir is present
+ return false;
+ }
+
+ result = camera.list_folders(fulldir, folders, spin_idle_context.context);
+ if (result != GPhoto.Result.OK) {
+ // fetching the list failed - can't determine whether specified dir is present
+ return false;
+ }
+
+ int list_len = folders.count();
+
+ for(int list_index = 0; list_index < list_len; list_index++) {
+ string tmp;
+
+ folders.get_name(list_index, out tmp);
+ if (tmp == search_target) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private RefreshResult refresh_camera() {
+ if (busy)
+ return RefreshResult.BUSY;
+
+ update_status(busy, false);
+
+ refresh_error = null;
+ refresh_result = camera.init(spin_idle_context.context);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to initialize camera: %s", refresh_result.to_full_string());
+
+ return (refresh_result == GPhoto.Result.IO_LOCK) ? RefreshResult.LOCKED : RefreshResult.LIBRARY_ERROR;
+ }
+
+ update_status(true, refreshed);
+
+ on_view_changed();
+
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text(_("Fetching photo information"));
+ progress_bar.set_fraction(0.0);
+ progress_bar.set_pulse_step(0.01);
+ progress_bar.visible = true;
+
+ Gee.ArrayList<ImportSource> import_list = new Gee.ArrayList<ImportSource>();
+
+ GPhoto.CameraStorageInformation *sifs = null;
+ int count = 0;
+ refresh_result = camera.get_storageinfo(&sifs, out count, spin_idle_context.context);
+ if (refresh_result == GPhoto.Result.OK) {
+ for (int fsid = 0; fsid < count; fsid++) {
+ // Check well-known video and image paths first to prevent accidental
+ // scanning of undesired directories (which can cause user annoyance with
+ // some smartphones or camera-equipped media players)
+ bool got_well_known_dir = false;
+
+ // Check common paths for most primarily-still cameras, many (most?) smartphones
+ if (check_directory_exists(fsid, "/", "DCIM")) {
+ enumerate_files(fsid, "/DCIM", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "dcim")) {
+ enumerate_files(fsid, "/dcim", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Check common paths for AVCHD camcorders, primarily-still
+ // cameras that shoot .mts video files
+ if (check_directory_exists(fsid, "/PRIVATE/", "AVCHD")) {
+ enumerate_files(fsid, "/PRIVATE/AVCHD", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/private/", "avchd")) {
+ enumerate_files(fsid, "/private/avchd", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "AVCHD")) {
+ enumerate_files(fsid, "/AVCHD", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "avchd")) {
+ enumerate_files(fsid, "/avchd", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Check common video paths for some Sony primarily-still
+ // cameras
+ if (check_directory_exists(fsid, "/PRIVATE/", "SONY")) {
+ enumerate_files(fsid, "/PRIVATE/SONY", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/private/", "sony")) {
+ enumerate_files(fsid, "/private/sony", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Check common video paths for Sony NEX3, PSP addon camera
+ if (check_directory_exists(fsid, "/", "MP_ROOT")) {
+ enumerate_files(fsid, "/MP_ROOT", import_list);
+ got_well_known_dir = true;
+ }
+ if (check_directory_exists(fsid, "/", "mp_root")) {
+ enumerate_files(fsid, "/mp_root", import_list);
+ got_well_known_dir = true;
+ }
+
+ // Didn't find any of the common directories we know about
+ // already - try scanning from device root.
+ if (!got_well_known_dir) {
+ if (!enumerate_files(fsid, "/", import_list))
+ break;
+ }
+ }
+ }
+
+ clear_all_import_sources();
+
+ // Associate files (for RAW+JPEG)
+ auto_match_raw_jpeg(import_list);
+
+#if UNITY_SUPPORT
+ //UnityProgressBar: try to draw progress bar
+ uniprobar.set_visible(true);
+#endif
+
+ load_previews_and_metadata(import_list);
+
+#if UNITY_SUPPORT
+ //UnityProgressBar: reset
+ uniprobar.reset();
+#endif
+
+ progress_bar.visible = false;
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
+ progress_bar.set_text("");
+ progress_bar.set_fraction(0.0);
+
+ GPhoto.Result res = camera.exit(spin_idle_context.context);
+ if (res != GPhoto.Result.OK) {
+ // log but don't fail
+ warning("Unable to unlock camera: %s", res.to_full_string());
+ }
+
+ if (refresh_result == GPhoto.Result.OK) {
+ update_status(false, true);
+ } else {
+ update_status(false, false);
+
+ // show 'em all or show none
+ clear_all_import_sources();
+ }
+
+ on_view_changed();
+
+ switch (refresh_result) {
+ case GPhoto.Result.OK:
+ return RefreshResult.OK;
+
+ case GPhoto.Result.IO_LOCK:
+ return RefreshResult.LOCKED;
+
+ default:
+ return RefreshResult.LIBRARY_ERROR;
+ }
+ }
+
+ private static string chomp_ch(string str, char ch) {
+ long offset = str.length;
+ while (--offset >= 0) {
+ if (str[offset] != ch)
+ return str.slice(0, offset);
+ }
+
+ return "";
+ }
+
+ public static string append_path(string basepath, string addition) {
+ if (!basepath.has_suffix("/") && !addition.has_prefix("/"))
+ return basepath + "/" + addition;
+ else if (basepath.has_suffix("/") && addition.has_prefix("/"))
+ return chomp_ch(basepath, '/') + addition;
+ else
+ return basepath + addition;
+ }
+
+ // Need to do this because some phones (iPhone, in particular) changes the name of their filesystem
+ // between each mount
+ public static string? get_fs_basedir(GPhoto.Camera camera, int fsid) {
+ GPhoto.CameraStorageInformation *sifs = null;
+ int count = 0;
+ GPhoto.Result res = camera.get_storageinfo(&sifs, out count, null_context.context);
+ if (res != GPhoto.Result.OK)
+ return null;
+
+ if (fsid >= count)
+ return null;
+
+ GPhoto.CameraStorageInformation *ifs = sifs + fsid;
+
+ return (ifs->fields & GPhoto.CameraStorageInfoFields.BASE) != 0 ? ifs->basedir : "/";
+ }
+
+ public static string? get_fulldir(GPhoto.Camera camera, string camera_name, int fsid, string folder) {
+ if (folder.length > GPhoto.MAX_BASEDIR_LENGTH)
+ return null;
+
+ string basedir = get_fs_basedir(camera, fsid);
+ if (basedir == null) {
+ debug("Unable to find base directory for %s fsid %d", camera_name, fsid);
+
+ return folder;
+ }
+
+ return append_path(basedir, folder);
+ }
+
+ private bool enumerate_files(int fsid, string dir, Gee.ArrayList<ImportSource> import_list) {
+ string? fulldir = get_fulldir(camera, camera_name, fsid, dir);
+ if (fulldir == null) {
+ warning("Skipping enumerating %s: invalid folder name", dir);
+
+ return true;
+ }
+
+ GPhoto.CameraList files;
+ refresh_result = GPhoto.CameraList.create(out files);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to create file list: %s", refresh_result.to_full_string());
+
+ return false;
+ }
+
+ refresh_result = camera.list_files(fulldir, files, spin_idle_context.context);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to list files in %s: %s", fulldir, refresh_result.to_full_string());
+
+ // Although an error, don't abort the import because of this
+ refresh_result = GPhoto.Result.OK;
+
+ return true;
+ }
+
+ for (int ctr = 0; ctr < files.count(); ctr++) {
+ string filename;
+ refresh_result = files.get_name(ctr, out filename);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to get the name of file %d in %s: %s", ctr, fulldir,
+ refresh_result.to_full_string());
+
+ return false;
+ }
+
+ try {
+ GPhoto.CameraFileInfo info;
+ if (!GPhoto.get_info(spin_idle_context.context, camera, fulldir, filename, out info)) {
+ warning("Skipping import of %s/%s: name too long", fulldir, filename);
+
+ continue;
+ }
+
+ if ((info.file.fields & GPhoto.CameraFileInfoFields.TYPE) == 0) {
+ message("Skipping %s/%s: No file (file=%02Xh)", fulldir, filename,
+ info.file.fields);
+
+ continue;
+ }
+
+ if (VideoReader.is_supported_video_filename(filename)) {
+ VideoImportSource video_source = new VideoImportSource(camera_name, camera,
+ fsid, dir, filename, info.file.size, info.file.mtime);
+ import_list.add(video_source);
+ } else {
+ // determine file format from type, and then from file extension
+ PhotoFileFormat file_format = PhotoFileFormat.from_gphoto_type(info.file.type);
+ if (file_format == PhotoFileFormat.UNKNOWN) {
+ file_format = PhotoFileFormat.get_by_basename_extension(filename);
+ if (file_format == PhotoFileFormat.UNKNOWN) {
+ message("Skipping %s/%s: Not a supported file extension (%s)", fulldir,
+ filename, info.file.type);
+
+ continue;
+ }
+ }
+ import_list.add(new PhotoImportSource(camera_name, camera, fsid, dir, filename,
+ info.file.size, info.file.mtime, file_format));
+ }
+
+ progress_bar.pulse();
+
+ // spin the event loop so the UI doesn't freeze
+ spin_event_loop();
+ } catch (Error err) {
+ warning("Error while enumerating files in %s: %s", fulldir, err.message);
+
+ refresh_error = err.message;
+
+ return false;
+ }
+ }
+
+ GPhoto.CameraList folders;
+ refresh_result = GPhoto.CameraList.create(out folders);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to create folder list: %s", refresh_result.to_full_string());
+
+ return false;
+ }
+
+ refresh_result = camera.list_folders(fulldir, folders, spin_idle_context.context);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to list folders in %s: %s", fulldir, refresh_result.to_full_string());
+
+ // Although an error, don't abort the import because of this
+ refresh_result = GPhoto.Result.OK;
+
+ return true;
+ }
+
+ for (int ctr = 0; ctr < folders.count(); ctr++) {
+ string subdir;
+ refresh_result = folders.get_name(ctr, out subdir);
+ if (refresh_result != GPhoto.Result.OK) {
+ warning("Unable to get name of folder %d: %s", ctr, refresh_result.to_full_string());
+
+ return false;
+ }
+
+ if (!enumerate_files(fsid, append_path(dir, subdir), import_list))
+ return false;
+ }
+
+ return true;
+ }
+
+ // Try to match RAW+JPEG pairs.
+ private void auto_match_raw_jpeg(Gee.ArrayList<ImportSource> import_list) {
+ for (int i = 0; i < import_list.size; i++) {
+ PhotoImportSource? current = import_list.get(i) as PhotoImportSource;
+ PhotoImportSource? next = (i + 1 < import_list.size) ?
+ import_list.get(i + 1) as PhotoImportSource : null;
+ PhotoImportSource? prev = (i > 0) ?
+ import_list.get(i - 1) as PhotoImportSource : null;
+ if (current != null && current.get_file_format() == PhotoFileFormat.RAW) {
+ string current_name;
+ string ext;
+ disassemble_filename(current.get_filename(), out current_name, out ext);
+
+ // Try to find a matching pair.
+ PhotoImportSource? associated = null;
+ if (next != null && next.get_file_format() == PhotoFileFormat.JFIF) {
+ string next_name;
+ disassemble_filename(next.get_filename(), out next_name, out ext);
+ if (next_name == current_name)
+ associated = next;
+ }
+ if (prev != null && prev.get_file_format() == PhotoFileFormat.JFIF) {
+ string prev_name;
+ disassemble_filename(prev.get_filename(), out prev_name, out ext);
+ if (prev_name == current_name)
+ associated = prev;
+ }
+
+ // Associate!
+ if (associated != null) {
+ debug("Found RAW+JPEG pair: %s and %s", current.get_filename(), associated.get_filename());
+ current.set_associated(associated);
+ if (!import_list.remove(associated)) {
+ debug("Unable to associate files");
+ current.set_associated(null);
+ }
+ }
+ }
+ }
+ }
+
+ private void load_previews_and_metadata(Gee.List<ImportSource> import_list) {
+ int loaded_photos = 0;
+ foreach (ImportSource import_source in import_list) {
+ string filename = import_source.get_filename();
+ string? fulldir = import_source.get_fulldir();
+ if (fulldir == null) {
+ warning("Skipping loading preview of %s: invalid folder name", import_source.to_string());
+
+ continue;
+ }
+
+ // Get JPEG pair, if available.
+ PhotoImportSource? associated = null;
+ if (import_source is PhotoImportSource &&
+ ((PhotoImportSource) import_source).get_associated() != null) {
+ associated = ((PhotoImportSource) import_source).get_associated();
+ }
+
+ progress_bar.set_ellipsize(Pango.EllipsizeMode.MIDDLE);
+ progress_bar.set_text(_("Fetching preview for %s").printf(import_source.get_name()));
+
+ // Ask GPhoto to read the current file's metadata, but only if the file is not a
+ // video. Across every memory card and camera type I've tested (lucas, as of 10/27/2010)
+ // GPhoto always loads null metadata for videos. So without the is-not-video guard,
+ // this code segment just needlessly and annoyingly prints a warning message to the
+ // console.
+ PhotoMetadata? metadata = null;
+ if (!VideoReader.is_supported_video_filename(filename)) {
+ try {
+ metadata = GPhoto.load_metadata(spin_idle_context.context, camera, fulldir,
+ filename);
+ } catch (Error err) {
+ warning("Unable to fetch metadata for %s/%s: %s", fulldir, filename,
+ err.message);
+ }
+ }
+
+ // calculate EXIF's fingerprint
+ string? exif_only_md5 = null;
+ if (metadata != null) {
+ uint8[]? flattened_sans_thumbnail = metadata.flatten_exif(false);
+ if (flattened_sans_thumbnail != null && flattened_sans_thumbnail.length > 0)
+ exif_only_md5 = md5_binary(flattened_sans_thumbnail, flattened_sans_thumbnail.length);
+ }
+
+ // XXX: Cannot use the metadata for the thumbnail preview because libgphoto2
+ // 2.4.6 has a bug where the returned EXIF data object is complete garbage. This
+ // is fixed in 2.4.7, but need to work around this as best we can. In particular,
+ // this means the preview orientation will be wrong and the MD5 is not generated
+ // if the EXIF did not parse properly (see above)
+
+ uint8[] preview_raw = null;
+ size_t preview_raw_length = 0;
+ Gdk.Pixbuf preview = null;
+ try {
+ string preview_fulldir = fulldir;
+ string preview_filename = filename;
+ if (associated != null) {
+ preview_fulldir = associated.get_fulldir();
+ preview_filename = associated.get_filename();
+ }
+ preview = GPhoto.load_preview(spin_idle_context.context, camera, preview_fulldir,
+ preview_filename, out preview_raw, out preview_raw_length);
+ } catch (Error err) {
+ // only issue the warning message if we're not reading a video. GPhoto is capable
+ // of reading video previews about 50% of the time, so we don't want to put a guard
+ // around this entire code segment like we did with the metadata-read segment above,
+ // however video previews being absent is so common that there's no reason
+ // we should generate a warning for one.
+ if (!VideoReader.is_supported_video_filename(filename)) {
+ warning("Unable to fetch preview for %s/%s: %s", fulldir, filename, err.message);
+ }
+ }
+
+ // calculate thumbnail fingerprint
+ string? preview_md5 = null;
+ if (preview != null && preview_raw != null && preview_raw_length > 0)
+ preview_md5 = md5_binary(preview_raw, preview_raw_length);
+
+#if TRACE_MD5
+ debug("camera MD5 %s: exif=%s preview=%s", filename, exif_only_md5, preview_md5);
+#endif
+
+ if (import_source is VideoImportSource)
+ (import_source as VideoImportSource).update(preview);
+
+ if (import_source is PhotoImportSource)
+ (import_source as PhotoImportSource).update(preview, preview_md5, metadata,
+ exif_only_md5);
+
+ if (associated != null) {
+ try {
+ PhotoMetadata? associated_metadata = GPhoto.load_metadata(spin_idle_context.context,
+ camera, associated.get_fulldir(), associated.get_filename());
+ associated.update(preview, preview_md5, associated_metadata, null);
+ } catch (Error err) {
+ warning("Unable to fetch metadata for %s/%s: %s", associated.get_fulldir(),
+ associated.get_filename(), err.message);
+ }
+ }
+
+ // *now* add to the SourceCollection, now that it is completed
+ import_sources.add(import_source);
+
+ progress_bar.set_fraction((double) (++loaded_photos) / (double) import_list.size);
+#if UNITY_SUPPORT
+ //UnityProgressBar: set progress
+ uniprobar.set_progress((double) (loaded_photos) / (double) import_list.size);
+#endif
+
+ // spin the event loop so the UI doesn't freeze
+ spin_event_loop();
+ }
+ }
+
+ private void on_hide_imported() {
+ if (hide_imported.get_active())
+ get_view().install_view_filter(hide_imported_filter);
+ else
+ get_view().remove_view_filter(hide_imported_filter);
+
+ Config.Facade.get_instance().set_hide_photos_already_imported(hide_imported.get_active());
+ }
+
+ private void on_import_selected() {
+ import(get_view().get_selected());
+ }
+
+ private void on_import_all() {
+ import(get_view().get_all());
+ }
+
+ private void import(Gee.Iterable<DataObject> items) {
+ GPhoto.Result res = camera.init(spin_idle_context.context);
+ if (res != GPhoto.Result.OK) {
+ AppWindow.error_message(_("Unable to lock camera: %s").printf(res.to_full_string()));
+
+ return;
+ }
+
+ update_status(true, refreshed);
+
+ on_view_changed();
+ progress_bar.visible = false;
+
+ SortedList<CameraImportJob> jobs = new SortedList<CameraImportJob>(import_job_comparator);
+ Gee.ArrayList<CameraImportJob> already_imported = new Gee.ArrayList<CameraImportJob>();
+
+ foreach (DataObject object in items) {
+ ImportPreview preview = (ImportPreview) object;
+ ImportSource import_file = (ImportSource) preview.get_source();
+
+ if (preview.is_already_imported()) {
+ message("Skipping import of %s: checksum detected in library",
+ import_file.get_filename());
+
+ already_imported.add(new CameraImportJob(null_context, import_file,
+ preview.get_duplicated_file()));
+
+ continue;
+ }
+
+ CameraImportJob import_job = new CameraImportJob(null_context, import_file);
+
+ // Maintain RAW+JPEG association.
+ if (import_file is PhotoImportSource &&
+ ((PhotoImportSource) import_file).get_associated() != null) {
+ import_job.set_associated(new CameraImportJob(null_context,
+ ((PhotoImportSource) import_file).get_associated()));
+ }
+
+ jobs.add(import_job);
+ }
+
+ debug("Importing %d files from %s", jobs.size, camera_name);
+
+ if (jobs.size > 0) {
+ // see import_reporter() to see why this is held during the duration of the import
+ assert(local_ref == null);
+ local_ref = this;
+
+ BatchImport batch_import = new BatchImport(jobs, camera_name, import_reporter,
+ null, already_imported);
+ batch_import.import_job_failed.connect(on_import_job_failed);
+ batch_import.import_complete.connect(close_import);
+
+ LibraryWindow.get_app().enqueue_batch_import(batch_import, true);
+ LibraryWindow.get_app().switch_to_import_queue_page();
+ // camera.exit() and busy flag will be handled when the batch import completes
+ } else {
+ // since failed up-front, build a fake (faux?) ImportManifest and report it here
+ if (already_imported.size > 0)
+ import_reporter(new ImportManifest(null, already_imported));
+
+ close_import();
+ }
+ }
+
+ private void on_import_job_failed(BatchImportResult result) {
+ if (result.file == null || result.result == ImportResult.SUCCESS)
+ return;
+
+ // delete the copied file
+ try {
+ result.file.delete(null);
+ } catch (Error err) {
+ message("Unable to delete downloaded file %s: %s", result.file.get_path(), err.message);
+ }
+ }
+
+ private void import_reporter(ImportManifest manifest) {
+ // TODO: Need to keep the ImportPage around until the BatchImport is completed, but the
+ // page controller (i.e. LibraryWindow) needs to know (a) if ImportPage is busy before
+ // removing and (b) if it is, to be notified when it ain't. Until that's in place, need
+ // to hold the ref so the page isn't destroyed ... this switcheroo keeps the ref alive
+ // until this function returns (at any time)
+ ImportPage? local_ref = this.local_ref;
+ this.local_ref = null;
+
+ if (manifest.success.size > 0) {
+ string photos_string = (ngettext("Delete this photo from camera?",
+ "Delete these %d photos from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+ string videos_string = (ngettext("Delete this video from camera?",
+ "Delete these %d videos from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+ string both_string = (ngettext("Delete this photo/video from camera?",
+ "Delete these %d photos/videos from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+ string neither_string = (ngettext("Delete these files from camera?",
+ "Delete these %d files from camera?",
+ manifest.success.size)).printf(manifest.success.size);
+
+ string question_string = ImportUI.get_media_specific_string(manifest.success,
+ photos_string, videos_string, both_string, neither_string);
+
+ ImportUI.QuestionParams question = new ImportUI.QuestionParams(
+ question_string, Gtk.Stock.DELETE, _("_Keep"));
+
+ if (!ImportUI.report_manifest(manifest, false, question))
+ return;
+ } else {
+ ImportUI.report_manifest(manifest, false, null);
+ return;
+ }
+
+ // delete the photos from the camera and the SourceCollection... for now, this is an
+ // all-or-nothing deal
+ Marker marker = import_sources.start_marking();
+ foreach (BatchImportResult batch_result in manifest.success) {
+ CameraImportJob job = batch_result.job as CameraImportJob;
+
+ marker.mark(job.get_source());
+ }
+
+ ProgressDialog progress = new ProgressDialog(AppWindow.get_instance(),
+ _("Removing photos/videos from camera"), new Cancellable());
+ int error_count = import_sources.destroy_marked(marker, true, progress.monitor);
+ if (error_count > 0) {
+ string error_string =
+ (ngettext("Unable to delete %d photo/video from the camera due to errors.",
+ "Unable to delete %d photos/videos from the camera due to errors.", error_count)).printf(
+ error_count);
+ AppWindow.error_message(error_string);
+ }
+
+ progress.close();
+
+ // to stop build warnings
+ local_ref = null;
+ }
+
+ private void close_import() {
+ GPhoto.Result res = camera.exit(spin_idle_context.context);
+ if (res != GPhoto.Result.OK) {
+ // log but don't fail
+ message("Unable to unlock camera: %s", res.to_full_string());
+ }
+
+ update_status(false, refreshed);
+
+ on_view_changed();
+ }
+
+ public override void set_display_titles(bool display) {
+ base.set_display_titles(display);
+
+ Gtk.ToggleAction? action = get_action("ViewTitle") as Gtk.ToggleAction;
+ if (action != null)
+ action.set_active(display);
+ }
+
+ // Gets the search view filter for this page.
+ public override SearchViewFilter get_search_view_filter() {
+ return search_filter;
+ }
+}
+
diff --git a/src/camera/mk/camera.mk b/src/camera/mk/camera.mk
new file mode 100644
index 0000000..1812a42
--- /dev/null
+++ b/src/camera/mk/camera.mk
@@ -0,0 +1,32 @@
+
+# UNIT_NAME is the Vala namespace. A file named UNIT_NAME.vala must be in this directory with
+# a init() and terminate() function declared in the namespace.
+UNIT_NAME := Camera
+
+# UNIT_DIR should match the subdirectory the files are located in. Generally UNIT_NAME in all
+# lowercase. The name of this file should be UNIT_DIR.mk.
+UNIT_DIR := camera
+
+# All Vala files in the unit should be listed here with no subdirectory prefix.
+#
+# NOTE: Do *not* include the unit's master file, i.e. UNIT_NAME.vala.
+UNIT_FILES := \
+ Branch.vala \
+ CameraTable.vala \
+ GPhoto.vala \
+ ImportPage.vala
+
+# Any unit this unit relies upon (and should be initialized before it's initialized) should
+# be listed here using its Vala namespace.
+#
+# NOTE: All units are assumed to rely upon the unit-unit. Do not include that here.
+UNIT_USES := \
+ Sidebar
+
+# List any additional files that are used in the build process as a part of this unit that should
+# be packaged in the tarball. File names should be relative to the unit's home directory.
+UNIT_RC :=
+
+# unitize.mk must be called at the end of each UNIT_DIR.mk file.
+include unitize.mk
+