ImportPage.vala 53.7 KB
Newer Older
1
/* Copyright 2009-2010 Yorba Foundation
2 3 4 5
 *
 * This software is licensed under the GNU LGPL (version 2.1 or later).
 * See the COPYING file in this distribution. 
 */
6

7 8
#if !NO_CAMERA

9
abstract class ImportSource : ThumbnailSource {
10 11 12 13 14 15
    private string camera_name;
    private GPhoto.Camera camera;
    private int fsid;
    private string folder;
    private string filename;
    private ulong file_size;
16
    private time_t modification_time;
17
    private Gdk.Pixbuf? preview = null;
18
    
19 20
    public ImportSource(string camera_name, GPhoto.Camera camera, int fsid, string folder,
        string filename, ulong file_size, time_t modification_time) {
21 22
        this.camera_name = camera_name;
        this.camera = camera;
23
        this.fsid = fsid;
24 25
        this.folder = folder;
        this.filename = filename;
26
        this.file_size = file_size;
27
        this.modification_time = modification_time;
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
    }
    
    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 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());
    }
}

class VideoImportSource : ImportSource {
    protected new const string THUMBNAIL_NAME_PREFIX = "videoimport";
    
    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_unique_thumbnail_name() {
        return (THUMBNAIL_NAME_PREFIX + "-%" + int64.FORMAT).printf(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 {
    protected new const string THUMBNAIL_NAME_PREFIX = "photoimport";
    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;
    
    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);
130
        this.file_format = file_format;
131 132 133
    }
    
    public override string get_name() {
134 135
        string? title = get_title();
        
136
        return !is_string_empty(title) ? title : get_filename();
137 138
    }
    
139 140 141 142 143 144 145 146 147 148
    public override string? get_unique_thumbnail_name() {
        return (THUMBNAIL_NAME_PREFIX + "-%" + int64.FORMAT).printf(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 {
149
        if (get_preview() == null)
150 151 152 153
            return null;
        
        // this satifies the return-a-new-instance requirement of create_thumbnail( ) because
        // scale_pixbuf( ) allocates a new pixbuf
154
        return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview();
155 156
    }

157
    // Needed because previews and exif are loaded after other information has been gathered.
158
    public void update(Gdk.Pixbuf? preview, string? preview_md5, PhotoMetadata? metadata, string? exif_md5) {
159
        set_preview(preview);
160
        this.preview_md5 = preview_md5;
161
        this.metadata = metadata;
162
        this.exif_md5 = exif_md5;
163
    }
164

165
    public override time_t get_exposure_time() {
166
        if (metadata == null)
167
            return get_modification_time();
168 169 170
        
        MetadataDateTime? date_time = metadata.get_exposure_date_time();
        
171
        return (date_time != null) ? date_time.get_timestamp() : get_modification_time();
172
    }
173 174
    
    public string? get_title() {
175
        return (metadata != null) ? metadata.get_title() : null;
176 177
    }
    
178
    public PhotoMetadata? get_metadata() {
179
        return metadata;
180 181 182
    }
    
    public override Gdk.Pixbuf? get_thumbnail(int scale) throws Error {
183
        if (get_preview() == null)
184 185
            return null;
        
186
        return (scale > 0) ? scale_pixbuf(get_preview(), scale, INTERP, true) : get_preview();
187
    }
188
    
189 190 191 192
    public PhotoFileFormat get_file_format() {
        return file_format;
    }
    
193
    public string? get_preview_md5() {
194 195
        return preview_md5;
    }
196 197 198 199
    
    public override bool internal_delete_backing() throws Error {
        debug("Deleting %s", to_string());
        
200 201 202 203 204 205 206
        string? fulldir = get_fulldir();
        if (fulldir == null) {
            warning("Skipping deleting %s: invalid folder name", to_string());
            
            return true;
        }
        
207
        GPhoto.Result result = get_camera().delete_file(fulldir, get_filename(),
208
            ImportPage.spin_idle_context.context);
209
        if (result != GPhoto.Result.OK)
210
            warning("Error deleting %s: %s", to_string(), result.to_full_string());
211 212 213
        
        return result == GPhoto.Result.OK;
    }
214 215
}

216
class ImportPreview : CheckerboardItem {
217 218
    public const int MAX_SCALE = 128;
    
219 220
    private static Gdk.Pixbuf placeholder_preview = null;
    
221
    public ImportPreview(ImportSource source) {
222
        base(source, Dimensions(), source.get_name());
223 224
        
        // scale down pixbuf if necessary
225 226 227 228
        Gdk.Pixbuf pixbuf = null;
        try {
            pixbuf = source.get_thumbnail(0);
        } catch (Error err) {
229 230 231 232 233 234 235 236 237 238 239 240 241 242
            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;
243 244 245
        }
        
        // scale down if too large
246
        if (pixbuf.get_width() > MAX_SCALE || pixbuf.get_height() > MAX_SCALE)
247
            pixbuf = scale_pixbuf(pixbuf, MAX_SCALE, PhotoImportSource.INTERP, false);
248

249 250 251 252 253 254
        // honor rotation for photos -- we don't care about videos since they can't be rotated
        if (source is PhotoImportSource) {
            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);
        }
255 256
        
        set_image(pixbuf);
257
    }
258 259
    
    public bool is_already_imported() {
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283
        // TODO: dupe detection for video

        if (get_import_source() is PhotoImportSource) {
            PhotoImportSource photo_import_source = get_import_source() as PhotoImportSource;
            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)) {
                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)) {
                        return true;
                    }
284 285
                }
            }
286 287
            
            return false;
288 289 290
        }
        
        return false;
291
    }
292 293 294 295
    
    public ImportSource get_import_source() {
        return (ImportSource) get_source();
    }
296 297
}

298
public class ImportPage : CheckerboardPage {
299 300
    private const string UNMOUNT_FAILED_MSG = _("Unable to unmount camera.  Try unmounting the camera from the file manager.");
    
301 302 303 304 305 306 307 308
    private class ImportViewManager : ViewManager {
        private ImportPage owner;
        
        public ImportViewManager(ImportPage owner) {
            this.owner = owner;
        }
        
        public override DataView create_view(DataSource source) {
309
            return new ImportPreview((ImportSource) source);
310 311 312
        }
    }
    
313
    private class CameraImportJob : BatchImportJob {
314
        private GPhoto.ContextWrapper context;
315
        private ImportSource import_file;
316 317 318
        private GPhoto.Camera camera;
        private string fulldir;
        private string filename;
319
        private uint64 filesize;
320 321
        private PhotoMetadata metadata;
        private time_t exposure_time;
322
        
323
        public CameraImportJob(GPhoto.ContextWrapper context, ImportSource import_file) {
324
            this.context = context;
325
            this.import_file = import_file;
326 327 328 329
            
            // 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();
330 331
            // this should've been caught long ago when the files were first enumerated
            assert(fulldir != null);
332
            filename = import_file.get_filename();
333
            filesize = import_file.get_filesize();
334 335
            metadata = (import_file is PhotoImportSource) ?
                (import_file as PhotoImportSource).get_metadata() : null;
336
            exposure_time = import_file.get_exposure_time();
337 338
        }
        
339
        public time_t get_exposure_time() {
340
            return exposure_time;
341 342
        }
        
343
        public override string get_identifier() {
344
            return filename;
345 346
        }
        
347 348 349 350
        public ImportSource get_source() {
            return import_file;
        }
        
351 352 353 354
        public override bool is_directory() {
            return false;
        }
        
355 356 357 358 359 360
        public override bool determine_file_size(out uint64 filesize, out File file) {
            filesize = this.filesize;
            
            return true;
        }
        
361
        public override bool prepare(out File file_to_import, out bool copy_to_library) throws Error {
362 363 364 365 366 367 368 369 370 371 372 373 374
            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());
                
375
                return false;
376
            }
377
            
378
            GPhoto.save_image(context.context, camera, fulldir, filename, dest_file);
379 380
            
            file_to_import = dest_file;
381
            copy_to_library = false;
382 383 384 385 386
            
            return true;
        }
    }
    
387
    public static GPhoto.ContextWrapper null_context = null;
388
    public static GPhoto.SpinIdleWrapper spin_idle_context = null;
389

390
    private SourceCollection import_sources = null;
391
    private Gtk.Label camera_label = new Gtk.Label(null);
392
    private Gtk.CheckButton hide_imported;
393
    private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar();
394
    private GPhoto.Camera camera;
395
    private string uri;
396
    private bool busy = false;
397
    private bool refreshed = false;
398 399
    private GPhoto.Result refresh_result = GPhoto.Result.OK;
    private string refresh_error = null;
Jim Nelson's avatar
Jim Nelson committed
400
    private string camera_name;
401
    private VolumeMonitor volume_monitor = null;
402
    private ImportPage? local_ref = null;
403
    
404 405 406 407 408 409 410
    public enum RefreshResult {
        OK,
        BUSY,
        LOCKED,
        LIBRARY_ERROR
    }
    
411
    public ImportPage(GPhoto.Camera camera, string uri) {
412 413
        base(_("Camera"));
        camera_name = _("Camera");
414

415 416
        this.camera = camera;
        this.uri = uri;
417
        this.import_sources = new SourceCollection("ImportSources for %s".printf(uri));
418
        
419 420 421
        // Mount.unmounted signal is *only* fired when a VolumeMonitor has been instantiated.
        this.volume_monitor = VolumeMonitor.get();
        
422 423 424 425
        // set up the global null context when needed
        if (null_context == null)
            null_context = new GPhoto.ContextWrapper();
        
426 427 428 429
        // same with idle-loop wrapper
        if (spin_idle_context == null)
            spin_idle_context = new GPhoto.SpinIdleWrapper();
        
430
        // monitor source collection to add/remove views
431
        get_view().monitor_source_collection(import_sources, new ImportViewManager(this), null);
432
        
433
        // sort by exposure time
434
        get_view().set_comparator(preview_comparator, preview_comparator_predicate);
435
        
436
        // monitor selection for UI
437 438 439
        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);
440 441
        
        // monitor Photos for removals, at that will change the result of the ViewFilter
442
        LibraryPhoto.global.contents_altered.connect(on_photos_added_removed);
443
        
444 445 446 447
        // Adds one menu entry per alien database driver
        AlienDatabaseHandler.get_instance().add_menu_entries(
            ui, "/ImportMenuBar/FileMenu/ImportFromAlienDbPlaceholder"
        );
448 449
        init_item_context_menu("/ImportContextMenu");
        init_page_context_menu("/ImportContextMenu");
450
        
451 452 453
        // Set up toolbar
        Gtk.Toolbar toolbar = get_toolbar();
        
454
        // hide duplicates checkbox
455 456
        hide_imported = new Gtk.CheckButton.with_label(_("Hide photos already imported"));
        hide_imported.set_tooltip_text(_("Only display photos that have not been imported"));
457
        hide_imported.clicked.connect(on_hide_imported);
458
        hide_imported.sensitive = false;
459 460
        hide_imported.active = false;
        Gtk.ToolItem hide_item = new Gtk.ToolItem();
461
        hide_item.is_important = true;
462
        hide_item.add(hide_imported);
463
        
464
        toolbar.insert(hide_item, -1);
465
        
466 467 468 469 470
        // separator to force buttons to right side of toolbar
        Gtk.SeparatorToolItem separator = new Gtk.SeparatorToolItem();
        separator.set_draw(false);
        
        toolbar.insert(separator, -1);
471
        
472
        progress_bar.set_orientation(Gtk.ProgressBarOrientation.LEFT_TO_RIGHT);
473
        progress_bar.visible = false;
474
        Gtk.ToolItem progress_item = new Gtk.ToolItem();
475
        progress_item.set_expand(true);
476
        progress_item.add(progress_bar);
477
        
478
        toolbar.insert(progress_item, -1);
479

480 481
        Gtk.ToolButton import_selected_button = new Gtk.ToolButton.from_stock(Resources.IMPORT);
        import_selected_button.set_related_action(action_group.get_action("ImportSelected"));
482
        
483
        toolbar.insert(import_selected_button, -1);
484
        
485 486
        Gtk.ToolButton import_all_button = new Gtk.ToolButton.from_stock(Resources.IMPORT_ALL);
        import_all_button.set_related_action(action_group.get_action("ImportAll"));
487
        
488
        toolbar.insert(import_all_button, -1);
489
        
490 491
        GPhoto.CameraAbilities abilities;
        GPhoto.Result res = camera.get_abilities(out abilities);
492
        if (res != GPhoto.Result.OK) {
493
            debug("Unable to get camera abilities: %s", res.to_full_string());
494
        } else {
Jim Nelson's avatar
Jim Nelson committed
495
            camera_name = abilities.model;
496
            camera_label.set_text(abilities.model);
497 498
            
            set_page_name(camera_name);
499
        }
500 501 502 503

        // 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);
504
        
505
        show_all();
506
    }
507 508
    
    ~ImportPage() {
509
        LibraryPhoto.global.contents_altered.disconnect(on_photos_added_removed);
510
    }
511
    
512 513 514 515
    public override string? get_icon_name() {
        return Resources.ICON_SINGLE_PHOTO;
    }

516
    private static int64 preview_comparator(void *a, void *b) {
517 518 519 520
        return ((ImportPreview *) a)->get_import_source().get_exposure_time()
            - ((ImportPreview *) b)->get_import_source().get_exposure_time();
    }
    
521 522 523 524
    private static bool preview_comparator_predicate(DataObject object, Alteration alteration) {
        return alteration.has_detail("metadata", "exposure-time");
    }
    
525 526 527 528
    private int64 import_job_comparator(void *a, void *b) {
        return ((CameraImportJob *) a)->get_exposure_time() - ((CameraImportJob *) b)->get_exposure_time();
    }
    
529 530 531 532 533 534 535 536 537 538 539 540
    protected override string? get_menubar_path() {
        return "/ImportMenuBar";
    }
    
    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();
541 542 543 544 545 546 547 548 549 550

        Gtk.ToggleActionEntry titles = { "ViewTitle", null, TRANSLATABLE, "<Ctrl><Shift>T",
            TRANSLATABLE, on_display_titles, Config.get_instance().get_display_photo_titles() };
        titles.label = _("_Titles");
        titles.tooltip = _("Display the title of each photo");
        toggle_actions += titles;

        return toggle_actions;
    }

551 552
    protected override Gtk.ActionEntry[] init_collect_action_entries() {
        Gtk.ActionEntry[] actions = base.init_collect_action_entries();
553
        
554
        Gtk.ActionEntry file = { "FileMenu", null, TRANSLATABLE, null, null, null };
555 556 557 558 559 560
        file.label = _("_File");
        actions += file;

        Gtk.ActionEntry import_selected = { "ImportSelected", Resources.IMPORT,
            TRANSLATABLE, null, null, on_import_selected };
        import_selected.label = _("Import _Selected");
561
        import_selected.tooltip = _("Import the selected photos into your library");
562 563 564 565 566
        actions += import_selected;

        Gtk.ActionEntry import_all = { "ImportAll", Resources.IMPORT_ALL, TRANSLATABLE,
            null, null, on_import_all };
        import_all.label = _("Import _All");
567
        import_all.tooltip = _("Import all the photos into your library");
568
        actions += import_all;
569
        
570
        Gtk.ActionEntry edit = { "EditMenu", null, TRANSLATABLE, null, null, null };
571 572 573 574 575 576 577 578 579 580 581 582 583
        edit.label = _("_Edit");
        actions += edit;

        Gtk.ActionEntry view = { "ViewMenu", null, TRANSLATABLE, null, null, null };
        view.label = _("_View");
        actions += view;

        Gtk.ActionEntry help = { "HelpMenu", null, TRANSLATABLE, null, null, null };
        help.label = _("_Help");
        actions += help;

        return actions;
    }
584 585 586 587 588
    
    public GPhoto.Camera get_camera() {
        return camera;
    }
    
589 590 591 592
    public string get_uri() {
        return uri;
    }
    
593 594
    public bool is_busy() {
        return busy;
595 596
    }
    
597 598 599 600 601 602 603 604 605
    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);
    }
    
606 607 608 609 610 611
    public bool is_refreshed() {
        return refreshed && !busy;
    }
    
    public string? get_refresh_message() {
        string msg = null;
612 613 614
        if (refresh_error != null) {
            msg = refresh_error;
        } else if (refresh_result == GPhoto.Result.OK) {
615
            // all went well
616
        } else {
617
            msg = refresh_result.to_full_string();
618
        }
619 620
        
        return msg;
621 622
    }
    
623 624 625 626 627 628 629
    private void update_status(bool busy, bool refreshed) {
        this.busy = busy;
        this.refreshed = refreshed;
        
        on_view_changed();
    }
    
630
    private void on_view_changed() {
631 632
        set_action_sensitive("ImportSelected", !busy && refreshed && get_view().get_selected_count() > 0);
        set_action_sensitive("ImportAll", !busy && refreshed && get_view().get_count() > 0);
633
        hide_imported.sensitive = !busy && refreshed && (get_view().get_unfiltered_count() > 0);
634 635
        AppWindow.get_instance().set_common_action_sensitive("CommonSelectAll",
            !busy && (get_view().get_count() > 0));
636
    }
637
    
638 639 640
    private void on_photos_added_removed() {
        get_view().reapply_view_filter();
    }
641 642 643 644 645 646 647

    private void on_display_titles(Gtk.Action action) {
        bool display = ((Gtk.ToggleAction) action).get_active();

        set_display_titles(display);
        Config.get_instance().set_display_photo_titles(display);
    }
648
    
649
    public override CheckerboardItem? get_fullscreen_photo() {
650 651 652
        error("No fullscreen support for import pages");
    }
    
653
    public override void switched_to() {
654 655
        set_display_titles(Config.get_instance().get_display_photo_titles());
        
656 657
        base.switched_to();
        
658
        try_refreshing_camera(false);
659 660
    }

661
    private void try_refreshing_camera(bool fail_on_locked) {
662 663 664 665 666 667 668 669 670 671 672 673 674
        // 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:
675 676 677 678 679 680
                if (fail_on_locked) {
                    AppWindow.error_message(UNMOUNT_FAILED_MSG);
                    
                    break;
                }
                
681 682 683 684 685 686 687 688 689 690 691 692 693 694
                // 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
695
                    string mounted_message = _("Shotwell needs to unmount the camera from the filesystem in order to access it.  Continue?");
696

697 698
                    Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(), 
                        Gtk.DialogFlags.MODAL, Gtk.MessageType.QUESTION,
699
                        Gtk.ButtonsType.CANCEL, "%s", mounted_message);
700
                    dialog.title = Resources.APP_TITLE;
Jim Nelson's avatar
Jim Nelson committed
701
                    dialog.add_button(_("_Unmount"), Gtk.ResponseType.YES);
702 703 704 705
                    int dialog_res = dialog.run();
                    dialog.destroy();
                    
                    if (dialog_res != Gtk.ResponseType.YES) {
706
                        set_page_message(_("Please unmount the camera."));
707 708 709 710
                    } else {
                        unmount_camera(mount);
                    }
                } else {
711 712
                    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.");

713 714 715
                    // 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,
716
                        Gtk.ButtonsType.OK, "%s", locked_message);
717 718 719 720
                    dialog.title = Resources.APP_TITLE;
                    dialog.run();
                    dialog.destroy();
                    
721
                    set_page_message(_("Please close any other application using the camera."));
722 723 724 725
                }
            break;
            
            case ImportPage.RefreshResult.LIBRARY_ERROR:
726
                AppWindow.error_message(_("Unable to fetch previews from the camera:\n%s").printf(
727 728 729 730 731 732 733 734
                    get_refresh_message()));
            break;
            
            default:
                error("Unknown result type %d", (int) res);
        }
    }
    
735 736 737
    public bool unmount_camera(Mount mount) {
        if (busy)
            return false;
738
        
739
        update_status(true, false);
740 741
        progress_bar.visible = true;
        progress_bar.set_fraction(0.0);
742
        progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
743
        progress_bar.set_text(_("Unmounting..."));
744 745 746 747
        
        // 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.
748
        mount.unmounted.connect(on_unmounted);
749
        
750
        debug("Unmounting camera ...");
751 752
        mount.unmount_with_operation.begin(MountUnmountFlags.NONE, 
            new Gtk.MountOperation(AppWindow.get_instance()), null, on_unmount_finished);
753 754 755
        
        return true;
    }
756 757 758
    
    private void on_unmount_finished(Object? source, AsyncResult aresult) {
        debug("Async unmount finished");
759
        
760 761
        Mount mount = (Mount) source;
        try {
762
            mount.unmount_with_operation.end(aresult);
763
        } catch (Error err) {
764
            AppWindow.error_message(UNMOUNT_FAILED_MSG);
765
            
766
            // don't trap this signal, even if it does come in, we've backed off
767
            mount.unmounted.disconnect(on_unmounted);
768
            
769
            update_status(false, refreshed);
770
            progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
771 772
            progress_bar.set_text("");
            progress_bar.visible = false;
773
        }
774 775 776 777
    }
    
    private void on_unmounted(Mount mount) {
        debug("on_unmounted");
778
        
779
        update_status(false, refreshed);
780
        progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
781 782
        progress_bar.set_text("");
        progress_bar.visible = false;
783
        
784
        try_refreshing_camera(true);
785
    }
786
    
787 788 789 790 791 792
    private void clear_all_import_sources() {
        Marker marker = import_sources.start_marking();
        marker.mark_all();
        import_sources.destroy_marked(marker, false);
    }
    
793
    private RefreshResult refresh_camera() {
794
        if (busy)
795
            return RefreshResult.BUSY;
796
            
797
        update_status(busy, false);
798
        
799
        refresh_error = null;
800
        refresh_result = camera.init(spin_idle_context.context);
801
        if (refresh_result != GPhoto.Result.OK) {
802
            warning("Unable to initialize camera: %s", refresh_result.to_full_string());
803
            
804
            return (refresh_result == GPhoto.Result.IO_LOCK) ? RefreshResult.LOCKED : RefreshResult.LIBRARY_ERROR;
805
        }
806
        
807
        update_status(true, refreshed);
808
        
809
        on_view_changed();
810
        
811
        progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
812
        progress_bar.set_text(_("Fetching photo information"));
813
        progress_bar.set_fraction(0.0);
814
        progress_bar.set_pulse_step(0.01);
815
        progress_bar.visible = true;
816 817 818
        
        Gee.ArrayList<ImportSource> import_list = new Gee.ArrayList<ImportSource>();
        
819 820
        GPhoto.CameraStorageInformation *sifs = null;
        int count = 0;
821
        refresh_result = camera.get_storageinfo(&sifs, out count, spin_idle_context.context);
822
        if (refresh_result == GPhoto.Result.OK) {
823
            for (int fsid = 0; fsid < count; fsid++) {
824
                if (!enumerate_files(fsid, "/", import_list))
825
                    break;
826
            }
827
        }
828
        
829
        clear_all_import_sources();
830
        load_previews(import_list);
831 832
        
        progress_bar.visible = false;
833
        progress_bar.set_ellipsize(Pango.EllipsizeMode.NONE);
834 835
        progress_bar.set_text("");
        progress_bar.set_fraction(0.0);
836
        
837
        GPhoto.Result res = camera.exit(spin_idle_context.context);
838 839
        if (res != GPhoto.Result.OK) {
            // log but don't fail
840
            warning("Unable to unlock camera: %s", res.to_full_string());
841 842
        }
        
843
        if (refresh_result == GPhoto.Result.OK) {
844
            update_status(false, true);
845
        } else {
846
            update_status(false, false);
847 848
            
            // show 'em all or show none
849
            clear_all_import_sources();
850
        }
851
        
852
        on_view_changed();
853

854 855 856 857 858 859 860 861 862 863
        switch (refresh_result) {
            case GPhoto.Result.OK:
                return RefreshResult.OK;
            
            case GPhoto.Result.IO_LOCK:
                return RefreshResult.LOCKED;
            
            default:
                return RefreshResult.LIBRARY_ERROR;
        }
864 865
    }
    
866 867 868 869 870 871 872 873 874 875
    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 "";
    }
    
876 877 878
    public static string append_path(string basepath, string addition) {
        if (!basepath.has_suffix("/") && !addition.has_prefix("/"))
            return basepath + "/" + addition;
879 880
        else if (basepath.has_suffix("/") && addition.has_prefix("/"))
            return chomp_ch(basepath, '/') + addition;
881 882 883 884
        else
            return basepath + addition;
    }
    
885 886
    // Need to do this because some phones (iPhone, in particular) changes the name of their filesystem
    // between each mount
887
    public static string? get_fs_basedir(GPhoto.Camera camera, int fsid) {
888 889
        GPhoto.CameraStorageInformation *sifs = null;
        int count = 0;
890
        GPhoto.Result res = camera.get_storageinfo(&sifs, out count, null_context.context);
891 892 893
        if (res != GPhoto.Result.OK)
            return null;
        
894 895
        if (fsid >= count)
            return null;
896
        
897
        GPhoto.CameraStorageInformation *ifs = sifs + fsid;
898
        
899
        return (ifs->fields & GPhoto.CameraStorageInfoFields.BASE) != 0 ? ifs->basedir : "/";
900 901
    }
    
902 903 904 905 906
    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);
907
        if (basedir == null) {
908
            debug("Unable to find base directory for %s fsid %d", camera_name, fsid);
909
            
910
            return folder;
911
        }
912
        
913
        return append_path(basedir, folder);
914
    }
915

916
    private bool enumerate_files(int fsid, string dir, Gee.List<ImportSource> import_list) {
917 918 919 920 921 922
        string? fulldir = get_fulldir(camera, camera_name, fsid, dir);
        if (fulldir == null) {
            warning("Skipping enumerating %s: invalid folder name", dir);
            
            return true;
        }
923 924
        
        GPhoto.CameraList files;
925
        refresh_result = GPhoto.CameraList.create(out files);
926 927 928
        if (refresh_result != GPhoto.Result.OK) {
            warning("Unable to create file list: %s", refresh_result.to_full_string());
            
929
            return false;
930
        }
931
        
932
        refresh_result = camera.list_files(fulldir, files, spin_idle_context.context);
933 934 935 936 937 938 939 940
        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;
        }
941 942 943
        
        for (int ctr = 0; ctr < files.count(); ctr++) {
            string filename;
944
            refresh_result = files.get_name(ctr, out filename);
945 946 947 948
            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());
                
949
                return false;
950
            }
951 952 953
            
            try {
                GPhoto.CameraFileInfo info;
954 955 956 957 958
                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;
                }
959
                
960 961 962
                if ((info.file.fields & GPhoto.CameraFileInfoFields.TYPE) == 0) {
                    message("Skipping %s/%s: No file (file=%02Xh)", fulldir, filename,
                        info.file.fields);
963
                        
964 965 966
                    continue;
                }
                
967 968 969 970 971 972 973
                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);               
974
                    if (file_format == PhotoFileFormat.UNKNOWN) {
975 976 977 978 979 980 981
                        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;
                        }
982
                    }
983 984
                    import_list.add(new PhotoImportSource(camera_name, camera, fsid, dir, filename,
                        info.file.size, info.file.mtime, file_format));
985 986
                }
                
987 988
                progress_bar.pulse();
                
989
                // spin the event loop so the UI doesn't freeze
990 991
                if (!spin_event_loop())
                    return false;
992
            } catch (Error err) {
993 994
                warning("Error while enumerating files in %s: %s", fulldir, err.message);
                
995
                refresh_error = err.message;
996 997
                
                return false;
998 999 1000 1001
            }
        }
        
        GPhoto.CameraList folders;
1002
        refresh_result = GPhoto.CameraList.create(out folders);
1003 1004 1005
        if (refresh_result != GPhoto.Result.OK) {
            warning("Unable to create folder list: %s", refresh_result.to_full_string());
            
1006
            return false;
1007 1008
        }
        
1009
        refresh_result = camera.list_folders(fulldir, folders, spin_idle_context.context);
1010 1011 1012 1013 1014 1015 1016 1017
        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;
        }
1018
        
1019 1020
        for (int ctr = 0; ctr < folders.count(); ctr++) {
            string subdir;
1021
            refresh_result = folders.get_name(ctr, out subdir);
1022 1023 1024
            if (refresh_result != GPhoto.Result.OK) {
                warning("Unable to get name of folder %d: %s", ctr, refresh_result.to_full_string());
                
1025
                return false;
1026
            }
1027
            
1028
            if (!enumerate_files(fsid, append_path(dir, subdir), import_list))
1029
                return false;
1030 1031
        }
        
1032
        return true;
1033 1034
    }
    
1035
    private void load_previews(Gee.List<ImportSource> import_list) {
1036
        int loaded_photos = 0;
1037 1038
        foreach (ImportSource import_source in import_list) {
            string filename = import_source.get_filename();
1039 1040 1041 1042 1043 1044
            string? fulldir = import_source.get_fulldir();
            if (fulldir == null) {
                warning("Skipping loading preview of %s: invalid folder name", import_source.to_string());
                
                continue;
            }
1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076
            
            progress_bar.set_ellipsize(Pango.EllipsizeMode.MIDDLE);
            progress_bar.set_text(_("Fetching preview for %s").printf(import_source.get_name()));
            
            PhotoMetadata? metadata = null;
            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 {
                preview = GPhoto.load_preview(spin_idle_context.context, camera, fulldir,
1077
                    filename, out preview_raw, out preview_raw_length);
1078 1079 1080 1081 1082 1083 1084 1085 1086
            } catch (Error err) {
                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);
            
1087
#if TRACE_MD5
1088
            debug("camera MD5 %s: exif=%s preview=%s", filename, exif_only_md5, preview_md5);
1089
#endif
1090 1091 1092 1093 1094 1095 1096

            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);
1097 1098 1099 1100 1101 1102 1103 1104 1105
            
            // *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);
            
            // spin the event loop so the UI doesn't freeze
            if (!spin_event_loop())
                break;
1106 1107 1108
        }
    }
    
1109
    private bool show_unimported_filter(DataView view) {
1110
        return !((ImportPreview) view).is_already_imported();
1111 1112 1113 1114 1115 1116 1117 1118 1119
    }
    
    private void on_hide_imported() {
        if (hide_imported.get_active())
            get_view().install_view_filter(show_unimported_filter);
        else
            get_view().reset_view_filter();
    }
    
1120
    private void on_import_selected() {
1121
        import(get_view().get_selected());
1122 1123 1124
    }
    
    private void on_import_all() {
1125
        import(get_view().get_all());
1126 1127
    }
    
1128
    private void import(Gee.Iterable<DataObject> items) {
1129
        GPhoto.Result res = camera.init(spin_idle_context.context);
1130
        if (res != GPhoto.Result.OK) {
1131
            AppWindow.error_message(_("Unable to lock camera: %s").printf(res.to_full_string()));
1132 1133 1134
            
            return;
        }
1135
        
1136
        update_status(true, refreshed);
1137 1138
        
        on_view_changed();
1139
        progress_bar.visible = false;
1140

1141
        SortedList<CameraImportJob> jobs = new SortedList<CameraImportJob>(import_job_comparator);
1142
        Gee.ArrayList<CameraImportJob> already_imported = new Gee.ArrayList<CameraImportJob>();
1143
        
1144 1145 1146
        foreach (DataObject object in items) {
            ImportPreview preview = (ImportPreview) object;
            ImportSource import_file = (ImportSource) preview.get_source();
1147
            
1148 1149 1150
            if (preview.is_already_imported()) {
                message("Skipping import of %s: checksum detected in library", 
                    import_file.get_filename());
1151
                already_imported.add(new CameraImportJob(null_context, import_file));
1152
                
1153
                continue;
1154
            }
1155
            
1156
            jobs.add(new CameraImportJob(null_context, import_file));