diff options
Diffstat (limited to 'src/camera')
-rw-r--r-- | src/camera/Branch.vala | 116 | ||||
-rw-r--r-- | src/camera/Camera.vala | 18 | ||||
-rw-r--r-- | src/camera/CameraTable.vala | 417 | ||||
-rw-r--r-- | src/camera/GPhoto.vala | 367 | ||||
-rw-r--r-- | src/camera/ImportPage.vala | 1799 | ||||
-rw-r--r-- | src/camera/mk/camera.mk | 32 |
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 + |