Commit 86f3ec05 authored by Jim Nelson's avatar Jim Nelson

#1110: Send pictures via Nautilus Send To integration. #2248: Added View...

#1110: Send pictures via Nautilus Send To integration.  #2248: Added View Event for Photo to the full-window page context menu.  #2735: Better temporary file management.  Now using /tmp and relying on the system to manage the files (i.e. delete them at boot time).  This change affects all users of temp files, the largest of which right now are the WebConnectors and Send To.
parent 94ff0331
......@@ -132,7 +132,9 @@ SRC_FILES = \
Application.vala \
TimedQueue.vala \
MediaPage.vala \
MediaDataRepresentation.vala
MediaDataRepresentation.vala \
file_util.vala \
DesktopIntegration.vala
ifndef LINUX
SRC_FILES += \
......
......@@ -90,18 +90,11 @@ class AppDirs {
return exec_dir;
}
// Not using system temp directory for a couple of reasons: Temp files are often generated for
// drag-and-drop and the temporary filename is the name transferred to the destination, and so
// it's possible for various instances to generate same-name temp files. Also, the file may
// need to remain available after it's closed by Shotwell. Vala bindings
// guarantee temp files by returning an OutputStream, but that's not how the temp files are
// generated in Shotwell many times
//
// TODO: At startup, clean out temp directory of old files.
public static File get_temp_dir() {
// Because multiple instances of the app can run at the same time, place temp files in
// subdir named after process ID
File tmp_dir = get_data_subdir("tmp").get_child("%d".printf((int) Posix.getpid()));
File tmp_dir = File.new_for_path(Environment.get_tmp_dir()).get_child("shotwell").get_child(
"%d".printf((int) Posix.getpid()));
try {
if (!tmp_dir.query_exists(null))
tmp_dir.make_directory_with_parents(null);
......
......@@ -424,7 +424,7 @@ public abstract class AppWindow : PageWindow {
private static FullscreenWindow fullscreen_window = null;
private static CommandManager command_manager = null;
protected bool maximized = false;
protected Dimensions dimensions;
protected int pos_x = 0;
......
......@@ -188,7 +188,7 @@ public abstract class CollectionPage : MediaPage {
InjectionGroup group = new InjectionGroup("/MediaMenuBar/MenubarExtrasPlaceholder/EventsMenu");
group.add_menu_item("NewEvent");
group.add_menu_item("JumpToEvent");
group.add_menu_item("CommonJumpToEvent");
return group;
}
......@@ -323,12 +323,6 @@ public abstract class CollectionPage : MediaPage {
new_event.tooltip = Resources.NEW_EVENT_TOOLTIP;
actions += new_event;
Gtk.ActionEntry jump_to_event = { "JumpToEvent", null, TRANSLATABLE, null, TRANSLATABLE,
on_jump_to_event };
jump_to_event.label = _("View Eve_nt for Photo");
jump_to_event.tooltip = _("Go to this photo's event");
actions += jump_to_event;
Gtk.ActionEntry tags = { "TagsMenu", null, TRANSLATABLE, null, null, null };
tags.label = _("Ta_gs");
actions += tags;
......@@ -420,7 +414,6 @@ public abstract class CollectionPage : MediaPage {
set_action_visible("ExternalEdit", (!primary_is_video));
set_action_sensitive("ExternalEdit",
one_selected && !is_string_empty(Config.get_instance().get_external_photo_app()));
set_action_visible("PlayVideo", primary_is_video && one_selected);
#if !NO_RAW
set_action_visible("ExternalEditRAW",
one_selected && (!primary_is_video)
......@@ -431,7 +424,6 @@ public abstract class CollectionPage : MediaPage {
set_action_sensitive("Revert", (!selection_has_video) && can_revert_selected());
set_action_sensitive("Enhance", (!selection_has_video) && has_selected);
set_action_important("Enhance", true);
set_action_sensitive("JumpToEvent", can_jump_to_event());
set_action_sensitive("RotateClockwise", (!selection_has_video) && has_selected);
set_action_important("RotateClockwise", true);
set_action_sensitive("RotateCounterclockwise", (!selection_has_video) && has_selected);
......@@ -463,7 +455,6 @@ public abstract class CollectionPage : MediaPage {
// since the photo can be altered externally to Shotwell now, need to make the revert
// command available appropriately, even if the selection doesn't change
set_action_sensitive("Revert", can_revert_selected());
set_action_sensitive("JumpToEvent", can_jump_to_event());
}
#if !NO_PRINTING
......@@ -604,7 +595,7 @@ public abstract class CollectionPage : MediaPage {
return;
exporter = new ExporterUI(new Exporter(export_list, export_dir,
scaling, quality, format));
scaling, quality, format, false));
exporter.export(on_export_completed);
}
......@@ -796,7 +787,7 @@ public abstract class CollectionPage : MediaPage {
return;
AppWindow.get_instance().set_busy_cursor();
set_desktop_background(photo);
DesktopIntegration.set_background(photo);
AppWindow.get_instance().set_normal_cursor();
}
#endif
......@@ -850,22 +841,6 @@ public abstract class CollectionPage : MediaPage {
get_command_manager().execute(new NewEventCommand(get_view().get_selected()));
}
private bool can_jump_to_event() {
if (get_view().get_selected_count() != 1)
return false;
return ((MediaSource) get_view().get_selected_at(0).get_source()).get_event() != null;
}
private void on_jump_to_event() {
if (get_view().get_selected_count() != 1)
return;
Event? event = ((Photo) get_view().get_selected_at(0).get_source()).get_event();
if (event != null)
LibraryWindow.get_app().switch_to_event(event);
}
private void on_add_tags() {
if (get_view().get_selected_count() == 0)
return;
......
......@@ -617,8 +617,10 @@ public class Config {
Gee.ArrayList<string> preferred_apps = new Gee.ArrayList<string>();
preferred_apps.add("GIMP");
AppInfo? app = get_default_app_for_mime_types(PhotoFileFormat.get_editable_mime_types(), preferred_apps);
return (app != null) ? get_app_open_command(app) : "";
AppInfo? app = DesktopIntegration.get_default_app_for_mime_types(
PhotoFileFormat.get_editable_mime_types(), preferred_apps);
return (app != null) ? DesktopIntegration.get_app_open_command(app) : "";
}
public void set_external_photo_app(string external_photo_app) {
......@@ -637,8 +639,10 @@ public class Config {
Gee.ArrayList<string> preferred_apps = new Gee.ArrayList<string>();
preferred_apps.add("UFRaw");
AppInfo? app = get_default_app_for_mime_types(PhotoFileFormat.RAW.get_mime_types(), preferred_apps);
return (app != null) ? get_app_open_command(app) : "";
AppInfo? app = DesktopIntegration.get_default_app_for_mime_types(
PhotoFileFormat.RAW.get_mime_types(), preferred_apps);
return (app != null) ? DesktopIntegration.get_app_open_command(app) : "";
}
#endif
......
......@@ -2736,6 +2736,12 @@ public class ViewCollection : DataCollection {
return sources;
}
public DataSource? get_selected_source_at(int index) {
DataObject? object = selected.get_at(index);
return (object != null) ? ((DataView) object).get_source() : null;
}
// This is only used by DataView.
public void internal_notify_view_altered(DataView view) {
if (!are_notifications_frozen()) {
......
/* Copyright 2009-2010 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
namespace DesktopIntegration {
private const string SENDTO_EXEC = "nautilus-sendto";
private int init_count = 0;
private bool send_to_installed = false;
private ExporterUI send_to_exporter = null;
public void init() {
if (init_count++ != 0)
return;
send_to_installed = Environment.find_program_in_path(SENDTO_EXEC) != null;
}
public void terminate() {
if (--init_count == 0)
return;
}
public AppInfo? get_default_app_for_mime_types(string[] mime_types,
Gee.ArrayList<string> preferred_apps) {
SortedList<AppInfo> external_apps = get_apps_for_mime_types(mime_types);
foreach (string preferred_app in preferred_apps) {
foreach (AppInfo external_app in external_apps) {
if (external_app.get_name().contains(preferred_app))
return external_app;
}
}
return null;
}
// compare the app names, case insensitive
public static int64 app_info_comparator(void *a, void *b) {
return ((AppInfo) a).get_name().down().collate(((AppInfo) b).get_name().down());
}
public SortedList<AppInfo> get_apps_for_mime_types(string[] mime_types) {
SortedList<AppInfo> external_apps = new SortedList<AppInfo>(app_info_comparator);
if (mime_types.length == 0)
return external_apps;
// 3 loops because SortedList.contains() wasn't paying nicely with AppInfo,
// probably because it has a special equality function
foreach (string mime_type in mime_types) {
string content_type = g_content_type_from_mime_type(mime_type);
if (content_type == null)
break;
foreach (AppInfo external_app in
AppInfo.get_all_for_type(content_type)) {
bool already_contains = false;
foreach (AppInfo app in external_apps) {
if (app.get_name() == external_app.get_name()) {
already_contains = true;
break;
}
}
// dont add Shotwell to app list
if (!already_contains && !external_app.get_name().contains(Resources.APP_TITLE))
external_apps.add(external_app);
}
}
return external_apps;
}
public string? get_app_open_command(AppInfo app_info) {
string? str = app_info.get_commandline();
return str != null ? str : app_info.get_executable();
}
public bool is_send_to_installed() {
return send_to_installed;
}
public void files_send_to(File[] files) {
if (files.length == 0)
return;
string[] argv = new string[files.length + 1];
argv[0] = SENDTO_EXEC;
for (int ctr = 0; ctr < files.length; ctr++)
argv[ctr + 1] = files[ctr].get_path();
try {
AppWindow.get_instance().set_busy_cursor();
Pid child_pid;
Process.spawn_async(
get_root_directory(),
argv,
null, // environment
SpawnFlags.SEARCH_PATH,
null, // child setup
out child_pid);
AppWindow.get_instance().set_normal_cursor();
} catch (Error err) {
AppWindow.get_instance().set_normal_cursor();
AppWindow.error_message(_("Unable to launch Nautilus Send-To: %s").printf(err.message));
}
}
public void send_to(Gee.Collection<MediaSource> media) {
if (media.size == 0 || send_to_exporter != null)
return;
ExportDialog dialog = new ExportDialog(_("Send To"));
int scale;
ScaleConstraint constraint;
Jpeg.Quality quality;
PhotoFileFormat format = PhotoFileFormat.get_system_default_format();
if (!dialog.execute(out scale, out constraint, out quality, ref format))
return;
send_to_exporter = new ExporterUI(new Exporter.for_temp_file(media,
Scaling.for_constraint(constraint, scale, false), quality, format, true));
send_to_exporter.export(on_send_to_export_completed);
}
private void on_send_to_export_completed(Exporter exporter) {
files_send_to(exporter.get_exported_files());
send_to_exporter = null;
}
#if !NO_SET_BACKGROUND
public void set_background(Photo photo) {
// attempt to set the wallpaper to the photo's native format, but if not writeable, go to the
// system default
PhotoFileFormat file_format = photo.get_best_export_file_format();
File save_as = AppDirs.get_data_subdir("wallpaper").get_child(
file_format.get_default_basename("wallpaper"));
if (Config.get_instance().get_background() == save_as.get_path()) {
save_as = AppDirs.get_data_subdir("wallpaper").get_child(
file_format.get_default_basename("wallpaper_alt"));
}
try {
photo.export(save_as, Scaling.for_original(), Jpeg.Quality.MAXIMUM, file_format);
} catch (Error err) {
AppWindow.error_message(_("Unable to export background to %s: %s").printf(save_as.get_path(),
err.message));
return;
}
Config.get_instance().set_background(save_as.get_path());
GLib.FileUtils.chmod(save_as.get_parse_name(), 0644);
}
#endif
}
......@@ -167,6 +167,7 @@ public class ExportDialog : Gtk.Dialog {
ok_button.grab_focus();
}
// unlike other parameters, which should be persisted across dialog executions, format
// must be set each time the dialog is executed, so that the format displayed in the
// format combo box matches the format of the backing photo -- this is why it's passed
......@@ -1421,7 +1422,7 @@ public class PreferencesDialog {
string current_app_executable, out SortedList<AppInfo> external_apps) {
// get list of all applications for the given mime types
assert(mime_types.length != 0);
external_apps = get_apps_for_mime_types(mime_types);
external_apps = DesktopIntegration.get_apps_for_mime_types(mime_types);
if (external_apps.size == 0)
return;
......@@ -1531,9 +1532,9 @@ public class PreferencesDialog {
private void on_photo_editor_changed() {
AppInfo app = external_photo_apps.get_at(photo_editor_combo.get_active());
Config.get_instance().set_external_photo_app(get_app_open_command(app));
Config.get_instance().set_external_photo_app(DesktopIntegration.get_app_open_command(app));
debug("setting external photo editor to: %s", get_app_open_command(app));
debug("setting external photo editor to: %s", DesktopIntegration.get_app_open_command(app));
}
......
......@@ -411,9 +411,6 @@ public class EventPage : CollectionPage {
init_page_context_menu("/EventContextMenu");
// hide this command in CollectionPage, as it does not apply here
set_action_visible("JumpToEvent", false);
Event.global.items_altered.connect(on_events_altered);
}
......@@ -445,6 +442,13 @@ public class EventPage : CollectionPage {
return new_actions;
}
protected override void init_actions(int selected_count, int count) {
// hide this command in CollectionPage, as it does not apply here
set_action_visible("CommonJumpToEvent", false);
base.init_actions(selected_count, count);
}
protected override void update_actions(int selected_count, int count) {
set_action_sensitive("MakePrimary", selected_count == 1);
......
......@@ -20,69 +20,73 @@ public class Exporter : Object {
Error err);
private class ExportJob : BackgroundJob {
public Photo? photo;
public Video? video;
public MediaSource media;
public File dest;
public Scaling scaling;
public Jpeg.Quality quality;
public PhotoFileFormat format;
public Scaling? scaling;
public Jpeg.Quality? quality;
public PhotoFileFormat? format;
public Error? err = null;
public ExportJob(Exporter owner, Photo photo, File dest, Scaling scaling,
Jpeg.Quality quality, PhotoFileFormat format, Cancellable? cancellable) {
public ExportJob(Exporter owner, MediaSource media, File dest, Scaling? scaling,
Jpeg.Quality? quality, PhotoFileFormat? format, Cancellable cancellable) {
base (owner, owner.on_exported, cancellable, owner.on_export_cancelled);
this.photo = photo;
this.video = null;
assert(media is Photo || media is Video);
this.media = media;
this.dest = dest;
this.scaling = scaling;
this.quality = quality;
this.format = format;
}
public ExportJob.for_video(Exporter owner, Video video, File dest,
Cancellable? cancellable) {
base(owner, owner.on_exported, cancellable, owner.on_export_cancelled);
this.photo = null;
this.video = video;
this.dest = dest;
}
public override void execute() {
try {
if (photo != null)
photo.export(dest, scaling, quality, format);
if (media is Photo)
((Photo) media).export(dest, scaling, quality, format);
else
video.export(dest);
((Video) media).export(dest);
} catch (Error err) {
this.err = err;
}
}
}
private Gee.Collection<ThumbnailSource> to_export = new Gee.ArrayList<ThumbnailSource>();
private File dir;
private Gee.Collection<MediaSource> to_export = new Gee.ArrayList<MediaSource>();
private File[] exported_files;
private File? dir;
private Scaling scaling;
private Jpeg.Quality quality;
private PhotoFileFormat file_format;
private bool avoid_copying;
private int completed_count = 0;
private Workers workers = new Workers(Workers.threads_per_cpu(), false);
private CompletionCallback? completion_callback = null;
private ExportFailedCallback? error_callback = null;
private OverwriteCallback? overwrite_callback = null;
private ProgressMonitor? monitor = null;
private Cancellable? cancellable = null;
private Cancellable cancellable;
private bool replace_all = false;
private bool aborted = false;
public Exporter(Gee.Collection<ThumbnailSource> to_export, File dir, Scaling scaling,
Jpeg.Quality quality, PhotoFileFormat file_format) {
public Exporter(Gee.Collection<MediaSource> to_export, File? dir, Scaling scaling,
Jpeg.Quality quality, PhotoFileFormat file_format, bool avoid_copying) {
this.to_export.add_all(to_export);
this.dir = dir;
this.scaling = scaling;
this.quality = quality;
this.file_format = file_format;
this.avoid_copying = avoid_copying;
}
public Exporter.for_temp_file(Gee.Collection<MediaSource> to_export, Scaling scaling,
Jpeg.Quality quality, PhotoFileFormat file_format, bool avoid_copying) {
this.to_export.add_all(to_export);
this.dir = null;
this.scaling = scaling;
this.quality = quality;
this.file_format = file_format;
this.avoid_copying = avoid_copying;
}
// This should be called only once; the object does not reset its internal state when completed.
......@@ -92,7 +96,7 @@ public class Exporter : Object {
this.error_callback = error_callback;
this.overwrite_callback = overwrite_callback;
this.monitor = monitor;
this.cancellable = cancellable;
this.cancellable = cancellable ?? new Cancellable();
if (!process_queue())
export_completed();
......@@ -122,6 +126,8 @@ public class Exporter : Object {
if (!completed)
return;
} else {
exported_files += job.dest;
}
}
......@@ -134,47 +140,89 @@ public class Exporter : Object {
export_completed();
}
public File[] get_exported_files() {
return exported_files;
}
private bool process_queue() {
int submitted = 0;
foreach (ThumbnailSource source in to_export) {
string basename = (source is Photo) ? ((Photo) source).get_export_basename(file_format) :
((Video) source).get_basename();
File dest = dir.get_child(basename);
foreach (MediaSource source in to_export) {
File? use_source_file = null;
if (avoid_copying) {
if (source is Video)
use_source_file = source.get_master_file();
else if (!((Photo) source).is_export_required(scaling, file_format))
use_source_file = ((Photo) source).get_source_file();
}
if (!replace_all && dest.query_exists(null)) {
switch (overwrite_callback(this, dest)) {
case Overwrite.YES:
// continue
break;
case Overwrite.REPLACE_ALL:
replace_all = true;
break;
case Overwrite.CANCEL:
if (cancellable != null)
cancellable.cancel();
if (use_source_file != null) {
exported_files += use_source_file;
completed_count++;
if (monitor != null) {
if (!monitor(completed_count, to_export.size)) {
cancellable.cancel();
return false;
}
}
continue;
}
File? export_dir = dir;
File? dest = null;
if (export_dir == null) {
try {
bool collision;
dest = generate_unique_file(AppDirs.get_temp_dir(), source.get_file().get_basename(),
out collision);
} catch (Error err) {
AppWindow.error_message(_("Unable to generate a temporary file for %s: %s").printf(
source.get_file().get_basename(), err.message));
case Overwrite.NO:
default:
if (monitor != null) {
if (!monitor(++completed_count, to_export.size))
return false;
}
break;
}
} else {
string basename = (source is Photo)
? ((Photo) source).get_export_basename(((Photo) source).get_best_export_file_format())
: ((Video) source).get_basename();
dest = dir.get_child(basename);
if (!replace_all && dest.query_exists(null)) {
switch (overwrite_callback(this, dest)) {
case Overwrite.YES:
// continue
break;
case Overwrite.REPLACE_ALL:
replace_all = true;
break;
case Overwrite.CANCEL:
cancellable.cancel();
return false;
continue;
case Overwrite.NO:
default:
completed_count++;
if (monitor != null) {
if (!monitor(completed_count, to_export.size)) {
cancellable.cancel();
return false;
}
}
continue;
}
}
}
ExportJob job = null;
if (source is Photo)
job = new ExportJob(this, (Photo) source, dest, scaling, quality, file_format,
cancellable);
else
job = new ExportJob.for_video(this, (Video) source, dest, cancellable);
workers.enqueue(job);
workers.enqueue(new ExportJob(this, source, dest, scaling, quality, file_format,
cancellable));
submitted++;
}
......
......@@ -7,27 +7,8 @@
namespace LibraryFiles {
public const int DIRECTORY_DEPTH = 3;
// Returns true if the file is claimed, false if it exists, and throws an Error otherwise.
private bool claim_file(File file) throws Error {
try {
file.create(FileCreateFlags.NONE, null);
// created; success
return true;
} catch (Error err) {
// check for file-exists error
if (!(err is IOError.EXISTS)) {
debug("claim_file %s: %s", file.get_path(), err.message);
throw err;
}
return false;
}
}
// This method uses File.create() in order to "claim" a file in the filesystem. Thus, when the
// method returns success a file may exist already, and should be overwritten.
// This method uses global::generate_unique_file_at in order to "claim" a file in the filesystem.
// Thus, when the method returns success a file may exist already, and should be overwritten.
//
// This function is thread safe.
public File? generate_unique_file(string basename, PhotoMetadata? metadata, time_t ts, out bool collision)
......@@ -61,42 +42,7 @@ public File? generate_unique_file(string basename, PhotoMetadata? metadata, time
// silently ignore not creating a directory that already exists
}
return generate_unique_file_at(dir, basename, out collision);
}
// Like generate_unique_file(), this function "claims" a file on the filesystem in the directory
// specified with a basename the same or similar as what has been requested. The file may exist
// when this function returns, and it should be overwritten. It does *not* attempt to create the
// parent directory, however.
//
// This function is thread-safe.
public File? generate_unique_file_at(File dir, string basename, out bool collision) throws Error {
// create the file to atomically "claim" it
File file = dir.get_child(basename);
if (claim_file(file)) {
collision = false;
return file;
}
// file exists, collision and keep searching
collision = true;
string name, ext;
disassemble_filename(basename, out name, out ext);
// generate a unique filename
for (int ctr = 1; ctr < int.MAX; ctr++) {
string new_name = (ext != null) ? "%s_%d.%s".printf(name, ctr, ext) : "%s_%d".printf(name, ctr);
file = dir.get_child(new_name);
if (claim_file(file))
return file;
}
debug("generate_unique_filename_at %s for %s: unable to claim file", dir.get_path(), basename);
return null;
return global::generate_unique_file(dir, basename, out collision);
}
// This function is thread-safe.
......
......@@ -252,6 +252,9 @@ public class LibraryWindow : AppWindow {
Video.global.contents_altered.connect(sync_videos_visibility);
sync_videos_visibility();
foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
media_sources.items_altered.connect(on_media_altered);
MetadataWriter.get_instance().progress.connect(on_metadata_writer_progress);
LibraryPhoto.library_monitor.auto_update_progress.connect(on_library_monitor_auto_update_progress);
LibraryPhoto.library_monitor.auto_import_preparing.connect(on_library_monitor_auto_import_preparing);
......@@ -280,6 +283,9 @@ public class LibraryWindow : AppWindow {
LibraryPhoto.global.trashcan_contents_altered.disconnect(on_trashcan_contents_altered);
Video.global.trashcan_contents_altered.disconnect(on_trashcan_contents_altered);
foreach (MediaSourceCollection media_sources in MediaCollectionRegistry.get_instance().get_all())
media_sources.items_altered.disconnect(on_media_altered);
MetadataWriter.get_instance().progress.disconnect(on_metadata_writer_progress);
LibraryPhoto.library_monitor.auto_update_progress.disconnect(on_library_monitor_auto_update_progress);
LibraryPhoto.library_monitor.auto_import_preparing.disconnect(on_library_monitor_auto_import_preparing);
......@@ -319,6 +325,12 @@ public class LibraryWindow : AppWindow {