diff options
Diffstat (limited to 'plugins')
61 files changed, 14687 insertions, 0 deletions
diff --git a/plugins/Makefile b/plugins/Makefile new file mode 100644 index 0000000..c622063 --- /dev/null +++ b/plugins/Makefile @@ -0,0 +1,33 @@ + +include plugins.mk + +DIST_FILES := \ + Makefile \ + Makefile.plugin.mk \ + plugins.mk + +.PHONY: all +all: $(ALL_PLUGINS) + +.PHONY: $(ALL_PLUGINS) +$(ALL_PLUGINS): + @$(MAKE) --directory=$@ PLUGINS_VERSION="$(PLUGINS_VERSION)" USER_VALAFLAGS="$(USER_VALAFLAGS)" \ + PLUGIN_CFLAGS="$(PLUGIN_CFLAGS)" + +.PHONY: clean +clean: + $(foreach plugin,$(ALL_PLUGINS),$(MAKE) --directory=$(plugin) clean;) + +.PHONY: cleantemps +cleantemps: + $(foreach plugin,$(ALL_PLUGINS),$(MAKE) --directory=$(plugin) cleantemps;) + +.PHONY: distclean +distclean: + $(foreach plugin,$(ALL_PLUGINS),$(MAKE) --directory=$(plugin) distclean;) + +.PHONY: listfiles +listfiles: + @printf "$(foreach file,$(DIST_FILES), plugins/$(file)) " + @$(foreach plugin,$(ALL_PLUGINS),$(MAKE) --directory=$(plugin) --no-print-directory listfiles;) + diff --git a/plugins/Makefile.plugin.mk b/plugins/Makefile.plugin.mk new file mode 100644 index 0000000..539de86 --- /dev/null +++ b/plugins/Makefile.plugin.mk @@ -0,0 +1,78 @@ + +# Generic plug-in Makefile for Shotwell standard plugins. +# +# Requires PLUGIN and SRC_FILES be set to the name of the plugin binary (minus extension) and that +# the directory is registered in plugins.mk. +# +# To use, include this file in each plug-in directory's Makefile after setting appropriate variables. +# Also be sure that each plug-in has a dummy_main() function to satisfy valac's linkage. +# +# NOTE: This file is called from the cwd of each directory, hence the relative paths should be +# read as such. + +ifndef VALAC +VALAC := valac +endif +MAKE_FILES := Makefile ../Makefile.plugin.mk ../plugins.mk +HEADER_FILES := ../shotwell-plugin-dev-1.0.vapi ../shotwell-plugin-dev-1.0.h \ + ../shotwell-plugin-dev-1.0.deps + +include ../plugins.mk + +# automatically include shotwell-plugin-dev-1.0's dependencies +# NOTE: This line will generate an error when running "make dist" from the master Makefile. +# It can be safely ignored. +PKGS := $(shell sed ':a;N;$$!ba;s/\n/ /g' ../shotwell-plugin-dev-1.0.deps) $(PKGS) + +# automatically include the shotwell-plugin-dev-1.0 package as a local dependency +EXT_PKGS := $(PKGS) +PKGS := shotwell-plugin-dev-1.0 $(PKGS) $(PLUGIN_PKGS) + +# automatically include the Resources.vala common file +SRC_FILES := ../common/Resources.vala $(SRC_FILES) + +CFILES := $(notdir $(SRC_FILES:.vala=.c)) +OFILES := $(notdir $(SRC_FILES:.vala=.o)) + +CFLAGS := `pkg-config --print-errors --cflags $(EXT_PKGS) $(PLUGIN_PKGS)` -nostdlib -export-dynamic $(PLUGIN_CFLAGS) +LIBS := `pkg-config --print-errors --libs $(EXT_PKGS) $(PLUGIN_PKGS)` +DEFINES := -D_VERSION='"$(PLUGINS_VERSION)"' -DGETTEXT_PACKAGE='"shotwell"' + +all: $(PLUGIN).so + +.stamp: $(SRC_FILES) $(MAKE_FILES) $(HEADER_FILES) + $(VALAC) --target-glib=$(MIN_GLIB_VERSION) -g --enable-checking --fatal-warnings --save-temps --compile --enable-deprecated \ + --vapidir=../ $(foreach pkg,$(PKGS),--pkg=$(pkg)) $(foreach pkg,$(CUSTOM_VAPI_PKGS),--pkg=$(pkg)) \ + -X -I../.. -X -fPIC \ + $(foreach dfn,$(DEFINES),-X $(dfn)) \ + $(USER_VALAFLAGS) \ + --vapidir=../../vapi \ + $(SRC_FILES) + @touch .stamp + +$(CFILES): .stamp + @ + +$(OFILES): %.o: %.c $(CFILES) + $(CC) -c $(CFLAGS) $(DEFINES) -I../.. $(CFILES) + +$(PLUGIN).so: $(OFILES) + $(CC) $(CFLAGS) $(LDFLAGS) -shared $(OFILES) $(LIBS) -o $@ + +.PHONY: cleantemps +cleantemps: + @rm -f $(notdir $(SRC_FILES:.vala=.c)) $(notdir $(SRC_FILES:.vala=.o)) + @rm -f .stamp + +.PHONY: clean +clean: cleantemps + @rm -f $(PLUGIN).so $(OFILES) $(CFILES) + +.PHONY: distclean +distclean: clean + +.PHONY: listfiles +listfiles: + @printf "plugins/$(PLUGIN)/Makefile $(foreach file,$(SRC_FILES),plugins/$(PLUGIN)/$(file)) " + @printf "$(foreach rc,$(RC_FILES),plugins/$(PLUGIN)/$(rc)) " + diff --git a/plugins/common/RESTSupport.vala b/plugins/common/RESTSupport.vala new file mode 100644 index 0000000..6a64025 --- /dev/null +++ b/plugins/common/RESTSupport.vala @@ -0,0 +1,1162 @@ +/* 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. + */ + +extern Soup.Message soup_form_request_new_from_multipart(string uri, Soup.Multipart multipart); + +namespace Publishing.RESTSupport { + +public abstract class Session { + private string? endpoint_url = null; + private Soup.Session soup_session = null; + private bool transactions_stopped = false; + + public signal void wire_message_unqueued(Soup.Message message); + public signal void authenticated(); + public signal void authentication_failed(Spit.Publishing.PublishingError err); + + public Session(string? endpoint_url = null) { + this.endpoint_url = endpoint_url; + soup_session = new Soup.SessionAsync(); + } + + protected void notify_wire_message_unqueued(Soup.Message message) { + wire_message_unqueued(message); + } + + protected void notify_authenticated() { + authenticated(); + } + + protected void notify_authentication_failed(Spit.Publishing.PublishingError err) { + authentication_failed(err); + } + + public abstract bool is_authenticated(); + + public string? get_endpoint_url() { + return endpoint_url; + } + + public void stop_transactions() { + transactions_stopped = true; + soup_session.abort(); + } + + public bool are_transactions_stopped() { + return transactions_stopped; + } + + public void send_wire_message(Soup.Message message) { + if (are_transactions_stopped()) + return; + + soup_session.request_unqueued.connect(notify_wire_message_unqueued); + soup_session.send_message(message); + + soup_session.request_unqueued.disconnect(notify_wire_message_unqueued); + } +} + +public enum HttpMethod { + GET, + POST, + PUT; + + public string to_string() { + switch (this) { + case HttpMethod.GET: + return "GET"; + + case HttpMethod.PUT: + return "PUT"; + + case HttpMethod.POST: + return "POST"; + + default: + error("unrecognized HTTP method enumeration value"); + } + } + + public static HttpMethod from_string(string str) { + if (str == "GET") { + return HttpMethod.GET; + } else if (str == "PUT") { + return HttpMethod.PUT; + } else if (str == "POST") { + return HttpMethod.POST; + } else { + error("unrecognized HTTP method name: %s", str); + } + } +} + +public class Argument { + public string key; + public string value; + + public Argument(string key, string value) { + this.key = key; + this.value = value; + } + + public static int compare(Argument arg1, Argument arg2) { + return strcmp(arg1.key, arg2.key); + } + + public static Argument[] sort(Argument[] inputArray) { + FixedTreeSet<Argument> sorted_args = new FixedTreeSet<Argument>(Argument.compare); + + foreach (Argument arg in inputArray) + sorted_args.add(arg); + + return sorted_args.to_array(); + } +} + +public class Transaction { + private Argument[] arguments; + private bool is_executed = false; + private weak Session parent_session = null; + private Soup.Message message = null; + private int bytes_written = 0; + private Spit.Publishing.PublishingError? err = null; + private string? endpoint_url = null; + private bool use_custom_payload; + + public signal void chunk_transmitted(int bytes_written_so_far, int total_bytes); + public signal void network_error(Spit.Publishing.PublishingError err); + public signal void completed(); + + public Transaction(Session parent_session, HttpMethod method = HttpMethod.POST) { + // if our creator doesn't specify an endpoint url by using the Transaction.with_endpoint_url + // constructor, then our parent session must have a non-null endpoint url + assert(parent_session.get_endpoint_url() != null); + + this.parent_session = parent_session; + + message = new Soup.Message(method.to_string(), parent_session.get_endpoint_url()); + message.wrote_body_data.connect(on_wrote_body_data); + } + + public Transaction.with_endpoint_url(Session parent_session, string endpoint_url, + HttpMethod method = HttpMethod.POST) { + this.parent_session = parent_session; + this.endpoint_url = endpoint_url; + message = new Soup.Message(method.to_string(), endpoint_url); + } + + private void on_wrote_body_data(Soup.Buffer written_data) { + bytes_written += (int) written_data.length; + chunk_transmitted(bytes_written, (int) message.request_body.length); + } + + private void on_message_unqueued(Soup.Message message) { + if (this.message != message) + return; + + try { + check_response(message); + } catch (Spit.Publishing.PublishingError err) { + warning("Publishing error: %s", err.message); + warning("response validation failed. bad response = '%s'.", get_response()); + this.err = err; + } + } + + protected void check_response(Soup.Message message) throws Spit.Publishing.PublishingError { + switch (message.status_code) { + case Soup.KnownStatusCode.OK: + case Soup.KnownStatusCode.CREATED: // HTTP code 201 (CREATED) signals that a new + // resource was created in response to a PUT or POST + break; + + case Soup.KnownStatusCode.CANT_RESOLVE: + case Soup.KnownStatusCode.CANT_RESOLVE_PROXY: + throw new Spit.Publishing.PublishingError.NO_ANSWER("Unable to resolve %s (error code %u)", + get_endpoint_url(), message.status_code); + + case Soup.KnownStatusCode.CANT_CONNECT: + case Soup.KnownStatusCode.CANT_CONNECT_PROXY: + throw new Spit.Publishing.PublishingError.NO_ANSWER("Unable to connect to %s (error code %u)", + get_endpoint_url(), message.status_code); + + default: + // status codes below 100 are used by Soup, 100 and above are defined HTTP codes + if (message.status_code >= 100) { + throw new Spit.Publishing.PublishingError.NO_ANSWER("Service %s returned HTTP status code %u %s", + get_endpoint_url(), message.status_code, message.reason_phrase); + } else { + throw new Spit.Publishing.PublishingError.NO_ANSWER("Failure communicating with %s (error code %u)", + get_endpoint_url(), message.status_code); + } + } + + // All valid communication involves body data in the response + if (message.response_body.data == null || message.response_body.data.length == 0) + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("No response data from %s", + get_endpoint_url()); + } + + public Argument[] get_arguments() { + return arguments; + } + + public Argument[] get_sorted_arguments() { + return Argument.sort(get_arguments()); + } + + protected void set_is_executed(bool is_executed) { + this.is_executed = is_executed; + } + + protected void send() throws Spit.Publishing.PublishingError { + parent_session.wire_message_unqueued.connect(on_message_unqueued); + message.wrote_body_data.connect(on_wrote_body_data); + parent_session.send_wire_message(message); + + parent_session.wire_message_unqueued.disconnect(on_message_unqueued); + message.wrote_body_data.disconnect(on_wrote_body_data); + + if (err != null) + network_error(err); + else + completed(); + + if (err != null) + throw err; + } + + public HttpMethod get_method() { + return HttpMethod.from_string(message.method); + } + + protected virtual void add_header(string key, string value) { + message.request_headers.append(key, value); + } + + // set custom_payload to null to have this transaction send the default payload of + // key-value pairs appended through add_argument(...) (this is how most REST requests work). + // To send a payload other than traditional key-value pairs (such as an XML document or a JPEG + // image) to the endpoint, set the custom_payload parameter to a non-null value. If the + // custom_payload you specify is text data, then it's null terminated, and its length is just + // custom_payload.length, so you don't have to pass in a payload_length parameter in this case. + // If, however, custom_payload is binary data (such as a JEPG), then the caller must set + // payload_length to the byte length of the custom_payload buffer + protected void set_custom_payload(string? custom_payload, string payload_content_type, + ulong payload_length = 0) { + assert (get_method() != HttpMethod.GET); // GET messages don't have payloads + + if (custom_payload == null) { + use_custom_payload = false; + return; + } + + ulong length = (payload_length > 0) ? payload_length : custom_payload.length; + message.set_request(payload_content_type, Soup.MemoryUse.COPY, custom_payload.data[0:length]); + + use_custom_payload = true; + } + + // When writing a specialized transaction subclass you should rarely need to + // call this method. In general, it's better to leave the underlying Soup message + // alone and let the Transaction class manage it for you. You should only need + // to install a new message if your subclass has radically different behavior from + // normal Transactions -- like multipart encoding. + protected void set_message(Soup.Message message) { + this.message = message; + } + + public bool get_is_executed() { + return is_executed; + } + + public uint get_status_code() { + assert(get_is_executed()); + return message.status_code; + } + + public virtual void execute() throws Spit.Publishing.PublishingError { + // if a custom payload is being used, we don't need to peform the tasks that are necessary + // to prepare a traditional key-value pair REST request; Instead (since we don't + // know anything about the custom payload), we just put it on the wire and return + if (use_custom_payload) { + is_executed = true; + send(); + + return; + } + + // REST POST requests must transmit at least one argument + if (get_method() == HttpMethod.POST) + assert(arguments.length > 0); + + // concatenate the REST arguments array into an HTTP formdata string + string formdata_string = ""; + for (int i = 0; i < arguments.length; i++) { + formdata_string += ("%s=%s".printf(arguments[i].key, arguments[i].value)); + if (i < arguments.length - 1) + formdata_string += "&"; + } + + // for GET requests with arguments, append the formdata string to the endpoint url after a + // query divider ('?') -- but make sure to save the old (caller-specified) endpoint URL + // and restore it after the GET so that the underlying Soup message remains consistent + string old_url = null; + string url_with_query = null; + if (get_method() == HttpMethod.GET && arguments.length > 0) { + old_url = message.get_uri().to_string(false); + url_with_query = get_endpoint_url() + "?" + formdata_string; + message.set_uri(new Soup.URI(url_with_query)); + } else { + message.set_request("application/x-www-form-urlencoded", Soup.MemoryUse.COPY, + formdata_string.data); + } + + is_executed = true; + + try { + debug("sending message to URI = '%s'", message.get_uri().to_string(false)); + send(); + } finally { + // if old_url is non-null, then restore it + if (old_url != null) + message.set_uri(new Soup.URI(old_url)); + } + } + + public string get_response() { + assert(get_is_executed()); + return (string) message.response_body.data; + } + + public unowned Soup.MessageHeaders get_response_headers() { + assert(get_is_executed()); + return message.response_headers; + } + + public void add_argument(string name, string value) { + arguments += new Argument(name, value); + } + + public string? get_endpoint_url() { + return (endpoint_url != null) ? endpoint_url : parent_session.get_endpoint_url(); + } + + public Session get_parent_session() { + return parent_session; + } +} + +public class UploadTransaction : Transaction { + protected GLib.HashTable<string, string> binary_disposition_table = null; + protected Spit.Publishing.Publishable publishable = null; + protected string mime_type; + protected Gee.HashMap<string, string> message_headers = null; + + public UploadTransaction(Session session, Spit.Publishing.Publishable publishable) { + base (session); + this.publishable = publishable; + this.mime_type = media_type_to_mime_type(publishable.get_media_type()); + + binary_disposition_table = create_default_binary_disposition_table(); + + message_headers = new Gee.HashMap<string, string>(); + } + + public UploadTransaction.with_endpoint_url(Session session, + Spit.Publishing.Publishable publishable, string endpoint_url) { + base.with_endpoint_url(session, endpoint_url); + this.publishable = publishable; + this.mime_type = media_type_to_mime_type(publishable.get_media_type()); + + binary_disposition_table = create_default_binary_disposition_table(); + + message_headers = new Gee.HashMap<string, string>(); + } + + protected override void add_header(string key, string value) { + message_headers.set(key, value); + } + + private static string media_type_to_mime_type(Spit.Publishing.Publisher.MediaType media_type) { + if (media_type == Spit.Publishing.Publisher.MediaType.PHOTO) + return "image/jpeg"; + else if (media_type == Spit.Publishing.Publisher.MediaType.VIDEO) + return "video/mpeg"; + else + error("UploadTransaction: unknown media type %s.", media_type.to_string()); + } + + private GLib.HashTable<string, string> create_default_binary_disposition_table() { + GLib.HashTable<string, string> result = + new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); + + result.insert("filename", Soup.URI.encode(publishable.get_serialized_file().get_basename(), + null)); + + return result; + } + + protected void set_binary_disposition_table(GLib.HashTable<string, string> new_disp_table) { + binary_disposition_table = new_disp_table; + } + + public override void execute() throws Spit.Publishing.PublishingError { + Argument[] request_arguments = get_arguments(); + assert(request_arguments.length > 0); + + Soup.Multipart message_parts = new Soup.Multipart("multipart/form-data"); + + foreach (Argument arg in request_arguments) + message_parts.append_form_string(arg.key, arg.value); + + string payload; + size_t payload_length; + try { + FileUtils.get_contents(publishable.get_serialized_file().get_path(), out payload, + out payload_length); + } catch (FileError e) { + throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A temporary file needed for publishing is unavailable")); + } + + int payload_part_num = message_parts.get_length(); + + Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.COPY, payload.data[0:payload_length]); + message_parts.append_form_file("", publishable.get_serialized_file().get_path(), mime_type, + bindable_data); + + unowned Soup.MessageHeaders image_part_header; + unowned Soup.Buffer image_part_body; + message_parts.get_part(payload_part_num, out image_part_header, out image_part_body); + image_part_header.set_content_disposition("form-data", binary_disposition_table); + + Soup.Message outbound_message = + soup_form_request_new_from_multipart(get_endpoint_url(), message_parts); + // TODO: there must be a better way to iterate over a map + Gee.MapIterator<string, string> i = message_headers.map_iterator(); + bool cont = i.next(); + while(cont) { + outbound_message.request_headers.append(i.get_key(), i.get_value()); + cont = i.next(); + } + set_message(outbound_message); + + set_is_executed(true); + send(); + } +} + +public class XmlDocument { + // Returns non-null string if an error condition is discovered in the XML (such as a well-known + // node). The string is used when generating a PublishingError exception. This delegate does + // not need to check for general-case malformed XML. + public delegate string? CheckForErrorResponse(XmlDocument doc); + + private Xml.Doc* document; + + private XmlDocument(Xml.Doc* doc) { + document = doc; + } + + ~RESTXmlDocument() { + delete document; + } + + public Xml.Node* get_root_node() { + return document->get_root_element(); + } + + public Xml.Node* get_named_child(Xml.Node* parent, string child_name) + throws Spit.Publishing.PublishingError { + Xml.Node* doc_node_iter = parent->children; + + for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) { + if (doc_node_iter->name == child_name) + return doc_node_iter; + } + + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("Can't find XML node %s", + child_name); + } + + public string get_property_value(Xml.Node* node, string property_key) + throws Spit.Publishing.PublishingError { + string value_string = node->get_prop(property_key); + if (value_string == null) + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("Can't find XML " + + "property %s on node %s", property_key, node->name); + + return value_string; + } + + public static XmlDocument parse_string(string? input_string, + CheckForErrorResponse check_for_error_response) throws Spit.Publishing.PublishingError { + if (input_string == null || input_string.length == 0) + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("Empty XML string"); + + // Does this even start and end with the right characters? + if (!input_string.chug().chomp().has_prefix("<") || + !input_string.chug().chomp().has_suffix(">")) { + // Didn't start or end with a < or > and can't be parsed as XML - treat as malformed. + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("Unable to parse XML " + + "document"); + } + + // Don't want blanks to be included as text nodes, and want the XML parser to tolerate + // tolerable XML + Xml.Doc* doc = Xml.Parser.read_memory(input_string, (int) input_string.length, null, null, + Xml.ParserOption.NOBLANKS | Xml.ParserOption.RECOVER); + if (doc == null) + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("Unable to parse XML " + + "document"); + + // Since 'doc' is the top level, if it has no children, something is wrong + // with the XML; we cannot continue normally here. + if (doc->children == null) { + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("Unable to parse XML " + + "document"); + } + + XmlDocument rest_doc = new XmlDocument(doc); + + string? result = check_for_error_response(rest_doc); + if (result != null) + throw new Spit.Publishing.PublishingError.SERVICE_ERROR("%s", result); + + return rest_doc; + } +} + +/* Encoding strings in XML decimal encoding is a relatively esoteric operation. Most web services + prefer to have non-ASCII character entities encoded using "symbolic encoding," where common + entities are encoded in short, symbolic names (e.g. "ñ" -> ñ). Picasa Web Albums, + however, doesn't like symbolic encoding, and instead wants non-ASCII entities encoded directly + as their Unicode code point numbers (e.g. "ñ" -> &241;). */ +public string decimal_entity_encode(string source) { + StringBuilder encoded_str_builder = new StringBuilder(); + string current_char = source; + while (true) { + int current_char_value = (int) (current_char.get_char_validated()); + + // null character signals end of string + if (current_char_value < 1) + break; + + // no need to escape ASCII characters except the ampersand, greater-than sign and less-than + // signs, which are special in the world of XML + if ((current_char_value < 128) && (current_char_value != '&') && (current_char_value != '<') && + (current_char_value != '>')) + encoded_str_builder.append_unichar(current_char.get_char_validated()); + else + encoded_str_builder.append("&#%d;".printf(current_char_value)); + + current_char = current_char.next_char(); + } + + return encoded_str_builder.str; +} + +internal abstract class BatchUploader { + private int current_file = 0; + private Spit.Publishing.Publishable[] publishables = null; + private Session session = null; + private unowned Spit.Publishing.ProgressCallback? status_updated = null; + + public signal void upload_complete(int num_photos_published); + public signal void upload_error(Spit.Publishing.PublishingError err); + + public BatchUploader(Session session, Spit.Publishing.Publishable[] publishables) { + this.publishables = publishables; + this.session = session; + } + + private void send_files() { + current_file = 0; + bool stop = false; + foreach (Spit.Publishing.Publishable publishable in publishables) { + GLib.File? file = publishable.get_serialized_file(); + + // if the current publishable hasn't been serialized, then skip it + if (file == null) { + current_file++; + continue; + } + + double fraction_complete = ((double) current_file) / publishables.length; + if (status_updated != null) + status_updated(current_file + 1, fraction_complete); + + Transaction txn = create_transaction(publishables[current_file]); + + txn.chunk_transmitted.connect(on_chunk_transmitted); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + upload_error(err); + stop = true; + } + + txn.chunk_transmitted.disconnect(on_chunk_transmitted); + + if (stop) + break; + + current_file++; + } + + if (!stop) + upload_complete(current_file); + } + + private void on_chunk_transmitted(int bytes_written_so_far, int total_bytes) { + double file_span = 1.0 / publishables.length; + double this_file_fraction_complete = ((double) bytes_written_so_far) / total_bytes; + double fraction_complete = (current_file * file_span) + (this_file_fraction_complete * + file_span); + + if (status_updated != null) + status_updated(current_file + 1, fraction_complete); + } + + protected Session get_session() { + return session; + } + + protected Spit.Publishing.Publishable get_current_publishable() { + return publishables[current_file]; + } + + protected abstract Transaction create_transaction(Spit.Publishing.Publishable publishable); + + public void upload(Spit.Publishing.ProgressCallback? status_updated = null) { + this.status_updated = status_updated; + + if (publishables.length > 0) + send_files(); + } +} + +// Remove diacritics in a string, yielding ASCII. If the given string is in +// a character set not based on Latin letters (e.g. Cyrillic), the result +// may be empty. +public string asciify_string(string s) { + string t = s.normalize(); // default normalization yields a maximally decomposed form + + StringBuilder b = new StringBuilder(); + for (unowned string u = t; u.get_char() != 0 ; u = u.next_char()) { + unichar c = u.get_char(); + if ((int) c < 128) + b.append_unichar(c); + } + + return b.str; +} + +/** @brief Work-around for a problem in libgee where a TreeSet can leak references when it + * goes out of scope; please see https://bugzilla.gnome.org/show_bug.cgi?id=695045 for more + * details. This class merely wraps it and adds a call to clear() to the destructor. + */ +public class FixedTreeSet<G> : Gee.TreeSet<G> { + public FixedTreeSet(owned CompareDataFunc<G>? comp_func = null) { + base((owned) comp_func); + } + + ~FixedTreeSet() { + clear(); + } +} + +public abstract class GoogleSession : Session { + public abstract string get_user_name(); + public abstract string get_access_token(); + public abstract string get_refresh_token(); + public abstract void deauthenticate(); +} + +public abstract class GooglePublisher : Object, Spit.Publishing.Publisher { + private const string OAUTH_CLIENT_ID = "1073902228337-gm4uf5etk25s0hnnm0g7uv2tm2bm1j0b.apps.googleusercontent.com"; + private const string OAUTH_CLIENT_SECRET = "_kA4RZz72xqed4DqfO7xMmMN"; + + private class GoogleSessionImpl : GoogleSession { + public string? access_token; + public string? user_name; + public string? refresh_token; + + public GoogleSessionImpl() { + this.access_token = null; + this.user_name = null; + this.refresh_token = null; + } + + public override bool is_authenticated() { + return (access_token != null); + } + + public override string get_user_name() { + assert (user_name != null); + return user_name; + } + + public override string get_access_token() { + assert(is_authenticated()); + return access_token; + } + + public override string get_refresh_token() { + assert(refresh_token != null); + return refresh_token; + } + + public override void deauthenticate() { + access_token = null; + user_name = null; + refresh_token = null; + } + } + + private class WebAuthenticationPane : Spit.Publishing.DialogPane, Object { + public static bool cache_dirty = false; + + private WebKit.WebView webview; + private Gtk.Box pane_widget; + private Gtk.ScrolledWindow webview_frame; + private string auth_sequence_start_url; + + public signal void authorized(string auth_code); + + public WebAuthenticationPane(string auth_sequence_start_url) { + this.auth_sequence_start_url = auth_sequence_start_url; + + pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + + webview_frame = new Gtk.ScrolledWindow(null, null); + webview_frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN); + webview_frame.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + + webview = new WebKit.WebView(); + webview.get_settings().enable_plugins = false; + webview.get_settings().enable_default_context_menu = false; + + webview.load_finished.connect(on_page_load); + webview.load_started.connect(on_load_started); + + webview_frame.add(webview); + pane_widget.pack_start(webview_frame, true, true, 0); + } + + public static bool is_cache_dirty() { + return cache_dirty; + } + + private void on_page_load(WebKit.WebFrame origin_frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + + string page_title = webview.get_title(); + if (page_title.index_of("state=connect") > 0) { + int auth_code_field_start = page_title.index_of("code="); + if (auth_code_field_start < 0) + return; + + string auth_code = + page_title.substring(auth_code_field_start + 5); // 5 = "code=".length + + cache_dirty = true; + + authorized(auth_code); + } + } + + private void on_load_started(WebKit.WebFrame frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH)); + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public void on_pane_installed() { + webview.open(auth_sequence_start_url); + } + + public void on_pane_uninstalled() { + } + } + + private class GetAccessTokensTransaction : Publishing.RESTSupport.Transaction { + private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token"; + + public GetAccessTokensTransaction(Session session, string auth_code) { + base.with_endpoint_url(session, ENDPOINT_URL); + + add_argument("code", auth_code); + add_argument("client_id", OAUTH_CLIENT_ID); + add_argument("client_secret", OAUTH_CLIENT_SECRET); + add_argument("redirect_uri", "urn:ietf:wg:oauth:2.0:oob"); + add_argument("grant_type", "authorization_code"); + } + } + + private class RefreshAccessTokenTransaction : Publishing.RESTSupport.Transaction { + private const string ENDPOINT_URL = "https://accounts.google.com/o/oauth2/token"; + + public RefreshAccessTokenTransaction(Session session) { + base.with_endpoint_url(session, ENDPOINT_URL); + + add_argument("client_id", OAUTH_CLIENT_ID); + add_argument("client_secret", OAUTH_CLIENT_SECRET); + add_argument("refresh_token", ((GoogleSession) session).get_refresh_token()); + add_argument("grant_type", "refresh_token"); + } + } + + public class AuthenticatedTransaction : Publishing.RESTSupport.Transaction { + private AuthenticatedTransaction.with_endpoint_url(GoogleSession session, + string endpoint_url, Publishing.RESTSupport.HttpMethod method) { + base.with_endpoint_url(session, endpoint_url, method); + } + + public AuthenticatedTransaction(GoogleSession session, string endpoint_url, + Publishing.RESTSupport.HttpMethod method) { + base.with_endpoint_url(session, endpoint_url, method); + assert(session.is_authenticated()); + + add_header("Authorization", "Bearer " + session.get_access_token()); + } + } + + private class UsernameFetchTransaction : AuthenticatedTransaction { + private const string ENDPOINT_URL = "https://www.googleapis.com/oauth2/v1/userinfo"; + + public UsernameFetchTransaction(GoogleSession session) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); + } + } + + private string scope; + private GoogleSessionImpl session; + private WebAuthenticationPane? web_auth_pane; + private weak Spit.Publishing.PluginHost host; + private weak Spit.Publishing.Service service; + + protected GooglePublisher(Spit.Publishing.Service service, Spit.Publishing.PluginHost host, + string scope) { + this.scope = scope; + this.session = new GoogleSessionImpl(); + this.service = service; + this.host = host; + this.web_auth_pane = null; + } + + private void on_web_auth_pane_authorized(string auth_code) { + web_auth_pane.authorized.disconnect(on_web_auth_pane_authorized); + + debug("EVENT: user authorized scope %s with auth_code %s", scope, auth_code); + + if (!is_running()) + return; + + do_get_access_tokens(auth_code); + } + + private void on_get_access_tokens_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_get_access_tokens_complete); + txn.network_error.disconnect(on_get_access_tokens_error); + + debug("EVENT: network transaction to exchange authorization code for access tokens " + + "completed successfully."); + + if (!is_running()) + return; + + do_extract_tokens(txn.get_response()); + } + + private void on_get_access_tokens_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_get_access_tokens_complete); + txn.network_error.disconnect(on_get_access_tokens_error); + + debug("EVENT: network transaction to exchange authorization code for access tokens " + + "failed; response = '%s'", txn.get_response()); + + if (!is_running()) + return; + + host.post_error(err); + } + + private void on_refresh_access_token_transaction_completed(Publishing.RESTSupport.Transaction + txn) { + txn.completed.disconnect(on_refresh_access_token_transaction_completed); + txn.network_error.disconnect(on_refresh_access_token_transaction_error); + + debug("EVENT: refresh access token transaction completed successfully."); + + if (!is_running()) + return; + + if (session.is_authenticated()) // ignore these events if the session is already auth'd + return; + + do_extract_tokens(txn.get_response()); + } + + private void on_refresh_access_token_transaction_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_refresh_access_token_transaction_completed); + txn.network_error.disconnect(on_refresh_access_token_transaction_error); + + debug("EVENT: refresh access token transaction caused a network error."); + + if (!is_running()) + return; + + if (session.is_authenticated()) // ignore these events if the session is already auth'd + return; + + // 400 errors indicate that the OAuth client ID and secret have become invalid. In most + // cases, this can be fixed by logging the user out + if (txn.get_status_code() == 400) { + do_logout(); + return; + } + + host.post_error(err); + } + + private void on_refresh_token_available(string token) { + debug("EVENT: an OAuth refresh token has become available; token = '%s'.", token); + + if (!is_running()) + return; + + session.refresh_token = token; + } + + private void on_access_token_available(string token) { + debug("EVENT: an OAuth access token has become available; token = '%s'.", token); + + if (!is_running()) + return; + + session.access_token = token; + + do_fetch_username(); + } + + private void on_fetch_username_transaction_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_fetch_username_transaction_completed); + txn.network_error.disconnect(on_fetch_username_transaction_error); + + debug("EVENT: username fetch transaction completed successfully."); + + if (!is_running()) + return; + + do_extract_username(txn.get_response()); + } + + private void on_fetch_username_transaction_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_fetch_username_transaction_completed); + txn.network_error.disconnect(on_fetch_username_transaction_error); + + debug("EVENT: username fetch transaction caused a network error"); + + if (!is_running()) + return; + + host.post_error(err); + } + + private void do_get_access_tokens(string auth_code) { + debug("ACTION: exchanging authorization code for access & refresh tokens"); + + host.install_login_wait_pane(); + + GetAccessTokensTransaction tokens_txn = new GetAccessTokensTransaction(session, auth_code); + tokens_txn.completed.connect(on_get_access_tokens_complete); + tokens_txn.network_error.connect(on_get_access_tokens_error); + + try { + tokens_txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_hosted_web_authentication() { + debug("ACTION: running OAuth authentication flow in hosted web pane."); + + string user_authorization_url = "https://accounts.google.com/o/oauth2/auth?" + + "response_type=code&" + + "client_id=" + OAUTH_CLIENT_ID + "&" + + "redirect_uri=" + Soup.URI.encode("urn:ietf:wg:oauth:2.0:oob", null) + "&" + + "scope=" + Soup.URI.encode(scope, null) + "+" + + Soup.URI.encode("https://www.googleapis.com/auth/userinfo.profile", null) + "&" + + "state=connect&" + + "access_type=offline&" + + "approval_prompt=force"; + + web_auth_pane = new WebAuthenticationPane(user_authorization_url); + web_auth_pane.authorized.connect(on_web_auth_pane_authorized); + + host.install_dialog_pane(web_auth_pane); + + } + + private void do_exchange_refresh_token_for_access_token() { + debug("ACTION: exchanging OAuth refresh token for OAuth access token."); + + host.install_login_wait_pane(); + + RefreshAccessTokenTransaction txn = new RefreshAccessTokenTransaction(session); + + txn.completed.connect(on_refresh_access_token_transaction_completed); + txn.network_error.connect(on_refresh_access_token_transaction_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_extract_tokens(string response_body) { + debug("ACTION: extracting OAuth tokens from body of server response"); + + Json.Parser parser = new Json.Parser(); + + try { + parser.load_from_data(response_body); + } catch (Error err) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "Couldn't parse JSON response: " + err.message)); + return; + } + + Json.Object response_obj = parser.get_root().get_object(); + + if ((!response_obj.has_member("access_token")) && (!response_obj.has_member("refresh_token"))) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "neither access_token nor refresh_token not present in server response")); + return; + } + + if (response_obj.has_member("refresh_token")) { + string refresh_token = response_obj.get_string_member("refresh_token"); + + if (refresh_token != "") + on_refresh_token_available(refresh_token); + } + + if (response_obj.has_member("access_token")) { + string access_token = response_obj.get_string_member("access_token"); + + if (access_token != "") + on_access_token_available(access_token); + } + } + + private void do_fetch_username() { + debug("ACTION: running network transaction to fetch username."); + + host.install_login_wait_pane(); + host.set_service_locked(true); + + UsernameFetchTransaction txn = new UsernameFetchTransaction(session); + txn.completed.connect(on_fetch_username_transaction_completed); + txn.network_error.connect(on_fetch_username_transaction_error); + + try { + txn.execute(); + } catch (Error err) { + host.post_error(err); + } + } + + private void do_extract_username(string response_body) { + debug("ACTION: extracting username from body of server response"); + + Json.Parser parser = new Json.Parser(); + + try { + parser.load_from_data(response_body); + } catch (Error err) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "Couldn't parse JSON response: " + err.message)); + return; + } + + Json.Object response_obj = parser.get_root().get_object(); + + if (response_obj.has_member("name")) { + string username = response_obj.get_string_member("name"); + + if (username != "") + session.user_name = username; + } + + if (response_obj.has_member("access_token")) { + string access_token = response_obj.get_string_member("access_token"); + + if (access_token != "") + session.access_token = access_token; + } + + // by the time we get a username, the session should be authenticated, or else something + // really tragic has happened + assert(session.is_authenticated()); + + on_login_flow_complete(); + } + + protected unowned Spit.Publishing.PluginHost get_host() { + return host; + } + + protected GoogleSession get_session() { + return session; + } + + protected void start_oauth_flow(string? refresh_token = null) { + if (refresh_token != null && refresh_token != "") { + session.refresh_token = refresh_token; + do_exchange_refresh_token_for_access_token(); + } else { + if (WebAuthenticationPane.is_cache_dirty()) { + host.install_static_message_pane(_("You have already logged in and out of a Google service during this Shotwell session.\n\nTo continue publishing to Google services, quit and restart Shotwell, then try publishing again.")); + return; + } + + do_hosted_web_authentication(); + } + } + + protected abstract void on_login_flow_complete(); + + protected abstract void do_logout(); + + public abstract bool is_running(); + + public abstract void start(); + + public abstract void stop(); + + public Spit.Publishing.Service get_service() { + return service; + } +} + +} + diff --git a/plugins/common/Resources.vala b/plugins/common/Resources.vala new file mode 100644 index 0000000..bcdc590 --- /dev/null +++ b/plugins/common/Resources.vala @@ -0,0 +1,54 @@ +/* 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 Resources { + +public const string WEBSITE_NAME = _("Visit the Yorba web site"); +public const string WEBSITE_URL = "https://wiki.gnome.org/Apps/Shotwell"; + +public const string LICENSE = """ +Shotwell is free software; you can redistribute it and/or modify it under the +terms of the GNU Lesser General Public License as published by the Free +Software Foundation; either version 2.1 of the License, or (at your option) +any later version. + +Shotwell is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for +more details. + +You should have received a copy of the GNU Lesser General Public License +along with Shotwell; if not, write to the Free Software Foundation, Inc., +51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +"""; + +public const string TRANSLATORS = _("translator-credits"); + +// TODO: modify to load multiple icons +// +// provided all the icons in the set follow a known naming convention (such as iconName_nn.png, +// where 'nn' is a size value in pixels, for example plugins_16.png -- this convention seems +// pretty common in the GNOME world), then this function can be modified to load an entire icon +// set without its interface needing to change, since given one icon filename, we can +// determine the others. +public Gdk.Pixbuf[]? load_icon_set(GLib.File? icon_file) { + Gdk.Pixbuf? icon = null; + try { + icon = new Gdk.Pixbuf.from_file(icon_file.get_path()); + } catch (Error err) { + warning("couldn't load icon set from %s.", icon_file.get_path()); + } + + if (icon_file != null) { + Gdk.Pixbuf[] icon_pixbuf_set = new Gdk.Pixbuf[0]; + icon_pixbuf_set += icon; + return icon_pixbuf_set; + } + + return null; +} + +} diff --git a/plugins/common/SqliteSupport.vala b/plugins/common/SqliteSupport.vala new file mode 100644 index 0000000..1c4a2bf --- /dev/null +++ b/plugins/common/SqliteSupport.vala @@ -0,0 +1,76 @@ +/* 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 DatabaseError { + ERROR, + BACKING, + MEMORY, + ABORT, + LIMITS, + TYPESPEC +} + +public abstract class ImportableDatabaseTable { + + protected static Sqlite.Database db; + + public string table_name = null; + + protected void set_table_name(string table_name) { + this.table_name = table_name; + } + + // This method will throw an error on an SQLite return code unless it's OK, DONE, or ROW, which + // are considered normal results. + protected static void throw_error(string method, int res) throws DatabaseError { + string msg = "(%s) [%d] - %s".printf(method, res, db.errmsg()); + + switch (res) { + case Sqlite.OK: + case Sqlite.DONE: + case Sqlite.ROW: + return; + + case Sqlite.PERM: + case Sqlite.BUSY: + case Sqlite.READONLY: + case Sqlite.IOERR: + case Sqlite.CORRUPT: + case Sqlite.CANTOPEN: + case Sqlite.NOLFS: + case Sqlite.AUTH: + case Sqlite.FORMAT: + case Sqlite.NOTADB: + throw new DatabaseError.BACKING(msg); + + case Sqlite.NOMEM: + throw new DatabaseError.MEMORY(msg); + + case Sqlite.ABORT: + case Sqlite.LOCKED: + case Sqlite.INTERRUPT: + throw new DatabaseError.ABORT(msg); + + case Sqlite.FULL: + case Sqlite.EMPTY: + case Sqlite.TOOBIG: + case Sqlite.CONSTRAINT: + case Sqlite.RANGE: + throw new DatabaseError.LIMITS(msg); + + case Sqlite.SCHEMA: + case Sqlite.MISMATCH: + throw new DatabaseError.TYPESPEC(msg); + + case Sqlite.ERROR: + case Sqlite.INTERNAL: + case Sqlite.MISUSE: + default: + throw new DatabaseError.ERROR(msg); + } + } +} + diff --git a/plugins/common/VersionNumber.vala b/plugins/common/VersionNumber.vala new file mode 100644 index 0000000..5a69cfe --- /dev/null +++ b/plugins/common/VersionNumber.vala @@ -0,0 +1,50 @@ +/* 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 Utils { + +/** + * A class that represents a version number in the form x.y.z and is able to compare + * different versions. + */ +public class VersionNumber : Object, Gee.Comparable<VersionNumber> { + private int[] version; + + public VersionNumber(int[] version) { + this.version = version; + } + + public VersionNumber.from_string(string str_version, string separator = ".") { + string[] version_items = str_version.split(separator); + this.version = new int[version_items.length]; + for (int i = 0; i < version_items.length; i++) + this.version[i] = int.parse(version_items[i]); + } + + public string to_string() { + string[] version_items = new string[this.version.length]; + for (int i = 0; i < this.version.length; i++) + version_items[i] = this.version[i].to_string(); + return string.joinv(".", version_items); + } + + public int compare_to(VersionNumber other) { + int max_len = ((this.version.length > other.version.length) ? + this.version.length : other.version.length); + int res = 0; + for(int i = 0; i < max_len; i++) { + int this_v = (i < this.version.length ? this.version[i] : 0); + int other_v = (i < other.version.length ? other.version[i] : 0); + res = this_v - other_v; + if (res != 0) + break; + } + return res; + } +} + +} + diff --git a/plugins/plugins.mk b/plugins/plugins.mk new file mode 100644 index 0000000..35bb80c --- /dev/null +++ b/plugins/plugins.mk @@ -0,0 +1,33 @@ + +PLUGINS := \ + shotwell-transitions \ + shotwell-publishing \ + shotwell-data-imports + +PLUGINS_RC := \ + plugins/shotwell-publishing/facebook.png \ + plugins/shotwell-publishing/facebook_publishing_options_pane.glade \ + plugins/shotwell-publishing/flickr.png \ + plugins/shotwell-publishing/flickr_publishing_options_pane.glade \ + plugins/shotwell-publishing/flickr_pin_entry_pane.glade \ + plugins/shotwell-publishing/picasa.png \ + plugins/shotwell-publishing/picasa_publishing_options_pane.glade \ + plugins/shotwell-publishing/youtube.png \ + plugins/shotwell-publishing/youtube_publishing_options_pane.glade \ + plugins/shotwell-publishing/piwigo.png \ + plugins/shotwell-publishing/piwigo_authentication_pane.glade \ + plugins/shotwell-publishing/piwigo_publishing_options_pane.glade \ + plugins/shotwell-transitions/slideshow-plugin.png + +EXTRA_PLUGINS := \ + shotwell-publishing-extras + +EXTRA_PLUGINS_RC := \ + plugins/shotwell-publishing-extras/yandex_publish_model.glade \ + plugins/shotwell-data-imports/f-spot-24.png \ + plugins/shotwell-publishing-extras/tumblr.png \ + plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade \ + plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade + +ALL_PLUGINS := $(PLUGINS) $(EXTRA_PLUGINS) + diff --git a/plugins/shotwell-data-imports/FSpotDatabase.vala b/plugins/shotwell-data-imports/FSpotDatabase.vala new file mode 100644 index 0000000..634c5c9 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotDatabase.vala @@ -0,0 +1,58 @@ +/* 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 DataImports.FSpot.Db { + +public const int64 NULL_ID = 0; +public const int64 INVALID_ID = -1; + +/** + * Initialization method for the whole module. + */ +public void init() { + FSpotDatabaseBehavior.create_behavior_map(); +} + +/** + * An object that is able to read from the F-Spot + * database and extract the relevant objects. + */ +public class FSpotDatabase : Object { + private Sqlite.Database fspot_db; + private FSpotMetaTable meta_table; + public FSpotPhotosTable photos_table; + public FSpotPhotoVersionsTable photo_versions_table; + public FSpotTagsTable tags_table; + public FSpotRollsTable rolls_table; + public int64 hidden_tag_id; + + public FSpotDatabase(File db_file) throws DatabaseError, Spit.DataImports.DataImportError { + string filename = db_file.get_path(); + int res = Sqlite.Database.open_v2(filename, out fspot_db, + Sqlite.OPEN_READONLY, null); + if (res != Sqlite.OK) + throw new DatabaseError.ERROR("Unable to open F-Spot database %s: %d", filename, res); + meta_table = new FSpotMetaTable(fspot_db); + hidden_tag_id = meta_table.get_hidden_tag_id(); + + FSpotDatabaseBehavior db_behavior = new FSpotDatabaseBehavior(get_version()); + + photos_table = new FSpotPhotosTable(fspot_db, db_behavior); + photo_versions_table = new FSpotPhotoVersionsTable(fspot_db, db_behavior); + tags_table = new FSpotTagsTable(fspot_db, db_behavior); + rolls_table = new FSpotRollsTable(fspot_db, db_behavior); + } + + ~FSpotDatabase() { + } + + private Utils.VersionNumber get_version() throws DatabaseError { + return new Utils.VersionNumber.from_string(meta_table.get_db_version()); + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotDatabaseBehavior.vala b/plugins/shotwell-data-imports/FSpotDatabaseBehavior.vala new file mode 100644 index 0000000..973eb38 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotDatabaseBehavior.vala @@ -0,0 +1,208 @@ +/* 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 DataImports.FSpot.Db { + +private class FSpotBehaviorEntry { + private Utils.VersionNumber version; + private FSpotTableBehavior behavior; + + public FSpotBehaviorEntry(Utils.VersionNumber version, FSpotTableBehavior behavior) { + this.version = version; + this.behavior = behavior; + } + + public Utils.VersionNumber get_version() { + return version; + } + + public FSpotTableBehavior get_behavior() { + return behavior; + } +} + +/** + * A class that consolidates the behavior of all F-Spot tables (apart from meta) + * and is the one place to check whether the database version is supported. + */ +public class FSpotDatabaseBehavior : Object { + // Minimum unsupported version: any database from that version and above + // is not supported as it's too new and support has not been provided + // In practice, the code may work with future versions but this cannot be + // guaranteed as it hasn't been tested so it's probably better to just + // bomb out at that point rather than risk importing incorrect data + public static Utils.VersionNumber MIN_UNSUPPORTED_VERSION = + new Utils.VersionNumber({ 19 }); + private static Gee.Map<string, Gee.List<FSpotBehaviorEntry>> behavior_map; + + private FSpotTableBehavior<FSpotPhotoRow> photos_behavior; + private FSpotTableBehavior<FSpotTagRow> tags_behavior; + private FSpotTableBehavior<FSpotPhotoTagRow> photo_tags_behavior; + private FSpotTableBehavior<FSpotPhotoVersionRow> photo_versions_behavior; + private FSpotTableBehavior<FSpotRollRow> rolls_behavior; + + public static void create_behavior_map() { + behavior_map = new Gee.HashMap<string, Gee.List<FSpotBehaviorEntry>>(); + // photos table + Gee.List<FSpotBehaviorEntry> photos_list = new Gee.ArrayList<FSpotBehaviorEntry>(); + // v0-4 + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 0 }), + FSpotPhotosV0Behavior.get_instance() + )); + // v5-6 + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 5 }), + FSpotPhotosV5Behavior.get_instance() + )); + // v7-10 + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 7 }), + FSpotPhotosV7Behavior.get_instance() + )); + // v11-15 + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 11 }), + FSpotPhotosV11Behavior.get_instance() + )); + // v16 + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 16 }), + FSpotPhotosV16Behavior.get_instance() + )); + // v17 + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 17 }), + FSpotPhotosV17Behavior.get_instance() + )); + // v18+ + photos_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 18 }), + FSpotPhotosV18Behavior.get_instance() + )); + behavior_map.set(FSpotPhotosTable.TABLE_NAME, photos_list); + // tags table + Gee.List<FSpotBehaviorEntry> tags_list = new Gee.ArrayList<FSpotBehaviorEntry>(); + // v0+ + tags_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 0 }), + FSpotTagsV0Behavior.get_instance() + )); + behavior_map.set(FSpotTagsTable.TABLE_NAME, tags_list); + // photo_tags table + Gee.List<FSpotBehaviorEntry> photo_tags_list = new Gee.ArrayList<FSpotBehaviorEntry>(); + // v0+ + photo_tags_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 0 }), + FSpotPhotoTagsV0Behavior.get_instance() + )); + behavior_map.set(FSpotPhotoTagsTable.TABLE_NAME, photo_tags_list); + // photo_versions table + Gee.List<FSpotBehaviorEntry> photo_versions_list = new Gee.ArrayList<FSpotBehaviorEntry>(); + // v0-8 + photo_versions_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 0 }), + FSpotPhotoVersionsV0Behavior.get_instance() + )); + // v9-15 + photo_versions_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 9 }), + FSpotPhotoVersionsV9Behavior.get_instance() + )); + // v16 + photo_versions_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 16 }), + FSpotPhotoVersionsV16Behavior.get_instance() + )); + // v17 + photo_versions_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 17 }), + FSpotPhotoVersionsV17Behavior.get_instance() + )); + // v18+ + photo_versions_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 18 }), + FSpotPhotoVersionsV18Behavior.get_instance() + )); + behavior_map.set(FSpotPhotoVersionsTable.TABLE_NAME, photo_versions_list); + // rolls table + Gee.List<FSpotBehaviorEntry> rolls_list = new Gee.ArrayList<FSpotBehaviorEntry>(); + // v0-4 + rolls_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 0 }), + FSpotRollsV0Behavior.get_instance() + )); + // v5+ + rolls_list.add(new FSpotBehaviorEntry( + new Utils.VersionNumber({ 5 }), + FSpotRollsV5Behavior.get_instance() + )); + behavior_map.set(FSpotRollsTable.TABLE_NAME, rolls_list); + } + + public static FSpotTableBehavior? find_behavior(string table_name, Utils.VersionNumber version) { + FSpotTableBehavior behavior = null; + Gee.List<FSpotBehaviorEntry> behavior_list = behavior_map.get(table_name); + if (behavior_list != null) + foreach (FSpotBehaviorEntry entry in behavior_list) { + if (version.compare_to(entry.get_version()) >= 0) + behavior = entry.get_behavior(); + } + else + warning("Could not find behavior list for table %s", table_name); + return behavior; + + } + public FSpotDatabaseBehavior(Utils.VersionNumber version) throws Spit.DataImports.DataImportError { + if (version.compare_to(MIN_UNSUPPORTED_VERSION) >= 0) + throw new Spit.DataImports.DataImportError.UNSUPPORTED_VERSION("Version %s is not yet supported", version.to_string()); + + FSpotTableBehavior? photos_generic_behavior = find_behavior(FSpotPhotosTable.TABLE_NAME, version); + if (photos_generic_behavior != null) + photos_behavior = photos_generic_behavior as FSpotTableBehavior<FSpotPhotoRow>; + FSpotTableBehavior? tags_generic_behavior = find_behavior(FSpotTagsTable.TABLE_NAME, version); + if (tags_generic_behavior != null) + tags_behavior = tags_generic_behavior as FSpotTableBehavior<FSpotTagRow>; + FSpotTableBehavior? photo_tags_generic_behavior = find_behavior(FSpotPhotoTagsTable.TABLE_NAME, version); + if (photo_tags_generic_behavior != null) + photo_tags_behavior = photo_tags_generic_behavior as FSpotTableBehavior<FSpotPhotoTagRow>; + FSpotTableBehavior? photo_versions_generic_behavior = find_behavior(FSpotPhotoVersionsTable.TABLE_NAME, version); + if (photo_versions_generic_behavior != null) + photo_versions_behavior = photo_versions_generic_behavior as FSpotTableBehavior<FSpotPhotoVersionRow>; + FSpotTableBehavior? rolls_generic_behavior = find_behavior(FSpotRollsTable.TABLE_NAME, version); + if (rolls_generic_behavior != null) + rolls_behavior = rolls_generic_behavior as FSpotTableBehavior<FSpotRollRow>; + + if (photos_behavior == null || tags_behavior == null || + photo_tags_behavior == null || photo_versions_behavior == null || + rolls_behavior == null + ) + throw new Spit.DataImports.DataImportError.UNSUPPORTED_VERSION("Version %s is not supported", version.to_string()); + } + + public FSpotTableBehavior<FSpotPhotoRow> get_photos_behavior() { + return photos_behavior; + } + + public FSpotTableBehavior<FSpotTagRow> get_tags_behavior() { + return tags_behavior; + } + + public FSpotTableBehavior<FSpotPhotoTagRow> get_photo_tags_behavior() { + return photo_tags_behavior; + } + + public FSpotTableBehavior<FSpotPhotoVersionRow> get_photo_versions_behavior() { + return photo_versions_behavior; + } + + public FSpotTableBehavior<FSpotRollRow> get_rolls_behavior() { + return rolls_behavior; + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotDatabaseTable.vala b/plugins/shotwell-data-imports/FSpotDatabaseTable.vala new file mode 100644 index 0000000..eba64be --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotDatabaseTable.vala @@ -0,0 +1,54 @@ +/* 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. + */ + +namespace DataImports.FSpot.Db { + +/** + * This class represents a generic F-Spot table. + */ +public abstract class FSpotDatabaseTable<T> : ImportableDatabaseTable { + protected unowned Sqlite.Database fspot_db; + protected FSpotTableBehavior<T> behavior; + + public FSpotDatabaseTable(Sqlite.Database db) { + this.fspot_db = db; + } + + public void set_behavior(FSpotTableBehavior<T> behavior) { + this.behavior = behavior; + set_table_name(behavior.get_table_name()); + } + + public FSpotTableBehavior<T> get_behavior() { + return behavior; + } + + protected string get_joined_column_list(bool with_table = false) { + string[] columns = behavior.list_columns(); + if (with_table) + for (int i = 0; i < columns.length; i++) + columns[i] = "%s.%s".printf(table_name, columns[i]); + return string.joinv(", ", columns); + } + + protected int select_all(out Sqlite.Statement stmt) throws DatabaseError { + string column_list = get_joined_column_list(); + string sql = "SELECT %s FROM %s".printf(column_list, table_name); + + int res = fspot_db.prepare_v2(sql, -1, out stmt); + if (res != Sqlite.OK) + throw_error("Statement failed: %s".printf(sql), res); + + res = stmt.step(); + if (res != Sqlite.ROW && res != Sqlite.DONE) + throw_error("select_all %s %s".printf(table_name, column_list), res); + + return res; + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotImporter.vala b/plugins/shotwell-data-imports/FSpotImporter.vala new file mode 100644 index 0000000..03abe01 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotImporter.vala @@ -0,0 +1,567 @@ +/* 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 FSpotService : Object, Spit.Pluggable, Spit.DataImports.Service { + private const string ICON_FILENAME = "f-spot-24.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public FSpotService(GLib.File resource_directory) { + // initialize the database layer + DataImports.FSpot.Db.init(); + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.DataImports.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.dataimports.fspot"; + } + + public unowned string get_pluggable_name() { + return "F-Spot"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Bruno Girin"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public void activation(bool enabled) { + } + + public Spit.DataImports.DataImporter create_data_importer(Spit.DataImports.PluginHost host) { + return new DataImports.FSpot.FSpotDataImporter(this, host); + } +} + +namespace DataImports.FSpot { + +internal const string SERVICE_NAME = "F-Spot"; +internal const string SERVICE_WELCOME_MESSAGE = + _("Welcome to the F-Spot library import service.\n\nPlease select a library to import, either by selecting one of the existing libraries found by Shotwell or by selecting an alternative F-Spot database file."); +internal const string SERVICE_WELCOME_MESSAGE_FILE_ONLY = + _("Welcome to the F-Spot library import service.\n\nPlease select an F-Spot database file."); +internal const string FILE_IMPORT_LABEL = + _("Manually select an F-Spot database file to import:"); +internal const string ERROR_CANT_OPEN_DB_FILE = + _("Cannot open the selected F-Spot database file: the file does not exist or is not an F-Spot database"); +internal const string ERROR_UNSUPPORTED_DB_VERSION = + _("Cannot open the selected F-Spot database file: this version of the F-Spot database is not supported by Shotwell"); +internal const string ERROR_CANT_READ_TAGS_TABLE = + _("Cannot read the selected F-Spot database file: error while reading tags table"); +internal const string ERROR_CANT_READ_PHOTOS_TABLE = + _("Cannot read the selected F-Spot database file: error while reading photos table"); +internal const string MESSAGE_FINAL_SCREEN = + _("Shotwell has found %d photos in the F-Spot library and is currently importing them. Duplicates will be automatically detected and removed.\n\nYou can close this dialog and start using Shotwell while the import is taking place in the background."); + +public class FSpotImportableLibrary : Spit.DataImports.ImportableLibrary, GLib.Object { + private File db_file; + + public FSpotImportableLibrary(File db_file) { + this.db_file = db_file; + } + + public File get_db_file() { + return db_file; + } + + public string get_display_name() { + return _("F-Spot library: %s").printf(db_file.get_path()); + } +} + +public class FSpotImportableItem : Spit.DataImports.ImportableMediaItem, GLib.Object { + private DataImports.FSpot.Db.FSpotPhotoRow photo_row; + private DataImports.FSpot.Db.FSpotPhotoVersionRow? photo_version_row; + private DataImports.FSpot.Db.FSpotRollRow? roll_row; + private FSpotImportableTag[] tags; + private FSpotImportableEvent? event; + private FSpotImportableRating rating; + private string folder_path; + private string filename; + + public FSpotImportableItem( + DataImports.FSpot.Db.FSpotPhotoRow photo_row, + DataImports.FSpot.Db.FSpotPhotoVersionRow? photo_version_row, + DataImports.FSpot.Db.FSpotRollRow? roll_row, + FSpotImportableTag[] tags, + FSpotImportableEvent? event, + bool is_hidden, + bool is_favorite + ) { + this.photo_row = photo_row; + this.photo_version_row = photo_version_row; + this.roll_row = roll_row; + this.tags = tags; + this.event = event; + if (photo_row.rating > 0) + this.rating = new FSpotImportableRating(photo_row.rating); + else if (is_hidden) + this.rating = new FSpotImportableRating(FSpotImportableRating.REJECTED); + else if (is_favorite) + this.rating = new FSpotImportableRating(5); + else + this.rating = new FSpotImportableRating(FSpotImportableRating.UNRATED); + + // store path and filename + folder_path = (photo_version_row != null) ? + photo_version_row.base_path.get_path() : + photo_row.base_path.get_path(); + filename = (photo_version_row != null) ? + photo_version_row.filename : + photo_row.filename; + + // In theory, neither field should be null at that point but belts + // and braces don't hurt + if (folder_path != null && filename != null) { + // check if file exist and if not decode as URL + File photo = File.new_for_path(folder_path).get_child(filename); + + // If file not found, parse as URI and store back + if (!photo.query_exists()) { + folder_path = decode_url(folder_path); + filename = decode_url(filename); + } + } + } + + public Spit.DataImports.ImportableTag[] get_tags() { + Spit.DataImports.ImportableTag[] importable_tags = new Spit.DataImports.ImportableTag[0]; + foreach (FSpotImportableTag tag in tags) + importable_tags += tag; + return importable_tags; + } + + public Spit.DataImports.ImportableEvent? get_event() { + return event; + } + + public string get_folder_path() { + return folder_path; + } + + public string get_filename() { + return filename; + } + + public string? get_title() { + return (photo_row.description == null || photo_row.description == "") ? null : photo_row.description; + } + + public Spit.DataImports.ImportableRating get_rating() { + return rating; + } + + private string decode_url(string url) { + StringBuilder builder = new StringBuilder(); + for (int idx = 0; idx < url.length; ) { + int cidx = url.index_of_char('%', idx); + if (cidx > idx) { + builder.append(url.slice(idx, cidx)); + } + if (cidx >= 0) { + if (cidx < url.length - 2) { + char c1 = url.get(cidx + 1); + char c2 = url.get(cidx + 2); + if (c1.isxdigit() && c1.isxdigit()) { + int ccode = 0x10 * c1.xdigit_value() + c2.xdigit_value(); + builder.append_c((char)ccode); + } + idx = cidx + 3; + } else { + idx = cidx + 1; + } + } else { + builder.append(url.substring(idx)); + idx = url.length; + } + } + return builder.str; + } +} + +public class FSpotImportableTag : Spit.DataImports.ImportableTag, GLib.Object { + private DataImports.FSpot.Db.FSpotTagRow row; + private FSpotImportableTag? parent; + + public FSpotImportableTag(DataImports.FSpot.Db.FSpotTagRow row, FSpotImportableTag? parent) { + this.row = row; + this.parent = parent; + } + + public int64 get_id() { + return row.tag_id; + } + + public string get_name() { + return row.name; + } + + public Spit.DataImports.ImportableTag? get_parent() { + return parent; + } + + public FSpotImportableTag? get_fspot_parent() { + return parent; + } + + public string get_stock_icon() { + return row.stock_icon; + } + + public bool is_stock() { + return (row.stock_icon.has_prefix(DataImports.FSpot.Db.FSpotTagsTable.PREFIX_STOCK_ICON)); + } + + public FSpotImportableEvent to_event() { + return new FSpotImportableEvent(this.row); + } +} + +public class FSpotImportableEvent : Spit.DataImports.ImportableEvent, GLib.Object { + private DataImports.FSpot.Db.FSpotTagRow row; + + public FSpotImportableEvent(DataImports.FSpot.Db.FSpotTagRow row) { + this.row = row; + } + + public string get_name() { + return row.name; + } +} + +public class FSpotImportableRating : Spit.DataImports.ImportableRating, GLib.Object { + public static const int REJECTED = -1; + public static const int UNRATED = 0; + + private int rating_value; + + public FSpotImportableRating(int rating_value) { + if (rating_value < -1) + rating_value = -1; + else if (rating_value > 5) + rating_value = 5; + this.rating_value = rating_value; + } + + public bool is_rejected() { + return (rating_value == REJECTED); + } + + public bool is_unrated() { + return (rating_value == UNRATED); + } + + public int get_value() { + return rating_value; + } +} + +internal class FSpotTagsCache : Object { + private DataImports.FSpot.Db.FSpotTagsTable tags_table; + private Gee.HashMap<int64?, FSpotImportableTag> tags_map; + + public FSpotTagsCache(DataImports.FSpot.Db.FSpotTagsTable tags_table) throws DatabaseError { + this.tags_table = tags_table; + tags_map = new Gee.HashMap<int64?, FSpotImportableTag> (); + } + + public FSpotImportableTag get_tag(DataImports.FSpot.Db.FSpotTagRow tag_row) throws DatabaseError { + FSpotImportableTag? tag = tags_map.get(tag_row.tag_id); + if (tag != null) { + return tag; + } else { + FSpotImportableTag? parent_tag = get_tag_from_id(tag_row.category_id); + FSpotImportableTag new_tag = new FSpotImportableTag(tag_row, parent_tag); + tags_map[tag_row.tag_id] = new_tag; + return new_tag; + } + } + + private FSpotImportableTag? get_tag_from_id(int64 tag_id) throws DatabaseError { + // check whether the tag ID is valid first, otherwise return null + if (tag_id < 1) + return null; + FSpotImportableTag? tag = tags_map.get(tag_id); + if (tag != null) + return tag; + DataImports.FSpot.Db.FSpotTagRow? tag_row = tags_table.get_by_id(tag_id); + if (tag_row != null) { + FSpotImportableTag? parent_tag = get_tag_from_id(tag_row.category_id); + FSpotImportableTag new_tag = new FSpotImportableTag(tag_row, parent_tag); + tags_map[tag_id] = new_tag; + return new_tag; + } + return null; + } +} + +public class FSpotDataImporter : Spit.DataImports.DataImporter, GLib.Object { + + private weak Spit.DataImports.PluginHost host = null; + private weak Spit.DataImports.Service service = null; + private bool running = false; + + public FSpotDataImporter(Spit.DataImports.Service service, + Spit.DataImports.PluginHost host) { + debug("FSpotDataImporter instantiated."); + this.service = service; + this.host = host; + } + + private bool is_running() { + return running; + } + + public Spit.DataImports.Service get_service() { + return service; + } + + public void start() { + if (is_running()) + return; + + debug("FSpotDataImporter: starting interaction."); + + running = true; + + do_discover_importable_libraries(); + } + + public void stop() { + debug("FSpotDataImporter: stopping interaction."); + + running = false; + } + + // Actions and event implementation + + /** + * Action that discovers importable libraries based on standard locations. + */ + private void do_discover_importable_libraries() { + Spit.DataImports.ImportableLibrary[] discovered_libraries = + new Spit.DataImports.ImportableLibrary[0]; + + File[] db_files = { + // where the DB is in Ubuntu Lucid + File.new_for_path(Environment.get_user_config_dir()). + get_child("f-spot").get_child("photos.db"), + // where it seems to be in Ubuntu Jaunty + File.new_for_path(Environment.get_home_dir()).get_child(".gnome2"). + get_child("f-spot").get_child("photos.db"), + // where it should really be if it followed the XDG spec + File.new_for_path(Environment.get_user_data_dir()). + get_child("f-spot").get_child("photos.db") + }; + + foreach (File db_file in db_files) { + if (db_file.query_exists(null)) { + discovered_libraries += new FSpotImportableLibrary(db_file); + message("Discovered importable library: %s", db_file.get_path()); + } + } + + host.install_library_selection_pane( + (discovered_libraries.length > 0 ? SERVICE_WELCOME_MESSAGE : SERVICE_WELCOME_MESSAGE_FILE_ONLY), + discovered_libraries, + FILE_IMPORT_LABEL + ); + } + + public void on_library_selected(Spit.DataImports.ImportableLibrary library) { + on_file_selected(((FSpotImportableLibrary)library).get_db_file()); + } + + public void on_file_selected(File file) { + DataImports.FSpot.Db.FSpotDatabase database; + FSpotTagsCache tags_cache; + Gee.ArrayList<DataImports.FSpot.Db.FSpotPhotoRow> all_photos; + double progress_delta_per_photo = 1.0; + double progress_plugin_to_host_ratio = 0.5; + double current_progress = 0.0; + try { + database = new DataImports.FSpot.Db.FSpotDatabase(file); + } catch(DatabaseError e) { + debug("FSpotDataImporter: Can't open database file: %s".printf(e.message)); + host.post_error_message(ERROR_CANT_OPEN_DB_FILE); + return; + } catch(Spit.DataImports.DataImportError e) { + debug("FSpotDataImporter: Unsupported F-Spot database version: %s".printf(e.message)); + host.post_error_message(ERROR_UNSUPPORTED_DB_VERSION); + return; + } + try { + tags_cache = new FSpotTagsCache(database.tags_table); + } catch(DatabaseError e) { + debug("FSpotDataImporter: Can't read tags table: %s".printf(e.message)); + host.post_error_message(ERROR_CANT_READ_TAGS_TABLE); + return; + } + host.install_import_progress_pane(_("Preparing to import")); + try { + all_photos = database.photos_table.get_all(); + } catch(DatabaseError e) { + debug("FSpotDataImporter: Can't read photos table: %s".printf(e.message)); + host.post_error_message(ERROR_CANT_READ_PHOTOS_TABLE); + return; + } + if (all_photos.size > 0) + progress_delta_per_photo = 1.0 / all_photos.size; + foreach (DataImports.FSpot.Db.FSpotPhotoRow photo_row in all_photos) { + bool hidden = false; + bool favorite = false; + FSpotImportableTag[] tags = new FSpotImportableTag[0]; + FSpotImportableEvent? event = null; + DataImports.FSpot.Db.FSpotRollRow? roll_row = null; + + // TODO: We do not convert F-Spot events to Shotwell events because F-Spot's events + // are essentially tags. We would need to detect if the tag is an event (use + // is_tag_event) and then assign the event to the photo ... since a photo can be + // in multiple F-Spot events, we would need to pick one, and since their tags + // are hierarchical, we would need to pick a name (probably the leaf) + try { + foreach ( + DataImports.FSpot.Db.FSpotTagRow tag_row in + database.tags_table.get_by_photo_id(photo_row.photo_id) + ) { + FSpotImportableTag tag = tags_cache.get_tag(tag_row); + if (is_tag_hidden(tag, database.hidden_tag_id)) + hidden = true; + else if (is_tag_favorite(tag)) + favorite = true; + else + tags += tag; + } + } catch(DatabaseError e) { + // log the error and leave the tag list empty + message("Failed to retrieve tags for photo ID %ld: %s", (long) photo_row.photo_id, + e.message); + } + + try { + roll_row = database.rolls_table.get_by_id(photo_row.roll_id); + } catch (DatabaseError e) { + // log the error and leave the roll row null + message("Failed to retrieve roll for photo ID %ld: %s", (long) photo_row.photo_id, + e.message); + } + + Spit.DataImports.ImportableMediaItem[] importable_items = new Spit.DataImports.ImportableMediaItem[0]; + try { + Gee.ArrayList<DataImports.FSpot.Db.FSpotPhotoVersionRow> photo_versions = + database.photo_versions_table.get_by_photo_id(photo_row.photo_id); + bool photo_versions_added = false; // set to true if at least one version was added + bool photo_versions_skipped = false; // set to true if at least one version was skipped due to missing file details + foreach (DataImports.FSpot.Db.FSpotPhotoVersionRow photo_version_row in photo_versions) { + if (photo_version_row.base_path != null && photo_version_row.filename != null) { + importable_items += new FSpotImportableItem( + photo_row, photo_version_row, roll_row, tags, event, hidden, favorite + ); + photo_versions_added = true; + } else { + photo_versions_skipped = true; + } + } + + // Older versions of F-Spot (0.4.3.1 at least, perhaps later) did not maintain photo_versions, + // this handles that case + // It also handles the case when we had to skip any photo version due to missing + // file details + if (photo_versions_skipped || !photo_versions_added) { + if (photo_row.base_path != null && photo_row.filename != null) { + importable_items += new FSpotImportableItem( + photo_row, null, roll_row, tags, event, hidden, favorite + ); + } + } + } catch (DatabaseError e) { + // if we can't load the different versions, do the best we can + // and create one photo from the photo row that was found earlier + message("Failed to retrieve versions for photo ID %ld: %s", (long) photo_row.photo_id, + e.message); + if (photo_row.base_path != null && photo_row.filename != null) { + importable_items += new FSpotImportableItem( + photo_row, null, roll_row, tags, event, hidden, favorite + ); + } + } + // If the importer is still running, import the items and loop, + // otherwise break the loop + if (running) { + host.prepare_media_items_for_import( + importable_items, + current_progress + (progress_delta_per_photo * progress_plugin_to_host_ratio), + progress_delta_per_photo * (1 - progress_plugin_to_host_ratio), + null + ); + current_progress += progress_delta_per_photo; + host.update_import_progress_pane(current_progress); + } else { + break; + } + } + host.finalize_import(on_imported_items_count); + } + + public void on_imported_items_count(int imported_items_count) { + host.install_static_message_pane( + MESSAGE_FINAL_SCREEN.printf(imported_items_count), + Spit.DataImports.PluginHost.ButtonMode.CLOSE + ); + } + + private bool is_tag_event(FSpotImportableTag tag) { + bool result = (DataImports.FSpot.Db.FSpotTagsTable.STOCK_ICON_EVENTS == tag.get_stock_icon()); + if (!result) { + FSpotImportableTag? parent = tag.get_fspot_parent(); + if (parent == null) + result = false; + else + result = is_tag_event(parent); + } + return result; + } + + private bool is_tag_hidden(FSpotImportableTag tag, int64 hidden_tag_id) { + bool result = (hidden_tag_id == tag.get_id()); + if (!result) { + FSpotImportableTag? parent = tag.get_fspot_parent(); + if (parent == null) + result = false; + else + result = is_tag_hidden(parent, hidden_tag_id); + } + return result; + } + + private bool is_tag_favorite(FSpotImportableTag tag) { + bool result = (DataImports.FSpot.Db.FSpotTagsTable.STOCK_ICON_FAV == tag.get_stock_icon()); + if (!result) { + FSpotImportableTag? parent = tag.get_fspot_parent(); + if (parent == null) + result = false; + else + result = is_tag_favorite(parent); + } + return result; + } +} + +} // namespace + diff --git a/plugins/shotwell-data-imports/FSpotMetaTable.vala b/plugins/shotwell-data-imports/FSpotMetaTable.vala new file mode 100644 index 0000000..b2d16eb --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotMetaTable.vala @@ -0,0 +1,113 @@ +/* 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 DataImports.FSpot.Db { + +/** + * The value object for the "meta" table, representing a single database row. + */ +public class FSpotMetaRow : Object { + // ignore the ID + public string name; + public string data; +} + +/** + * This class represents the F-Spot meta table, which stores some essential + * meta-data for the whole database. It is implemented as a simple dictionary + * where each row in the table is a key/value pair. + * + * The meta table implementation is the only one that throws a database error + * if something goes wrong because: + * * it is essential to read the content of that table in order to identify + * the version of the database and select the correct behavior, + * * this table is read at the very beginning of the process so any failure + * will occur immediately, + * * failing to read this table means that there is no point in reading the + * attempting to read the rest of the database so we might as well abort. + */ +public class FSpotMetaTable : FSpotDatabaseTable<FSpotMetaRow> { + + public FSpotMetaTable(Sqlite.Database db) { + base(db); + set_behavior(FSpotMetaBehavior.get_instance()); + } + + public string? get_data(string name) throws DatabaseError { + string[] columns = behavior.list_columns(); + string column_list = string.joinv(", ", columns); + string sql = "SELECT %s FROM %s WHERE name=?".printf(column_list, table_name); + Sqlite.Statement stmt; + int res = fspot_db.prepare_v2(sql, -1, out stmt); + if (res != Sqlite.OK) + throw_error("Statement failed: %s".printf(sql), res); + + res = stmt.bind_text(1, name); + if (res != Sqlite.OK) + throw_error("Bind failed for name %s".printf(name), res); + + res = stmt.step(); + if (res != Sqlite.ROW) { + if (res != Sqlite.DONE) + throw_error("FSpotMetaTable.get_data", res); + + return null; + } + + FSpotMetaRow row; + behavior.build_row(stmt, out row); + return row.data; + } + + public string? get_app_version() throws DatabaseError { + return get_data("F-Spot Version"); + } + + public string? get_db_version() throws DatabaseError { + return get_data("F-Spot Database Version"); + } + + public int64 get_hidden_tag_id() throws DatabaseError { + string id_str = get_data("Hidden Tag Id"); + if(id_str != null) { + return int64.parse(id_str); + } else { + return -1; + } + } +} + +public class FSpotMetaBehavior : FSpotTableBehavior<FSpotMetaRow>, Object { + public static const string TABLE_NAME = "Meta"; + + private static FSpotMetaBehavior instance; + + private FSpotMetaBehavior() { + } + + public static FSpotMetaBehavior get_instance() { + if (instance == null) + instance = new FSpotMetaBehavior(); + return instance; + } + + public string get_table_name() { + return TABLE_NAME; + } + + public string[] list_columns() { + return { "name", "data" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotMetaRow row, int offset = 0) { + row = new FSpotMetaRow(); + row.name = stmt.column_text(offset + 0); + row.data = stmt.column_text(offset + 1); + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotPhotoTagsTable.vala b/plugins/shotwell-data-imports/FSpotPhotoTagsTable.vala new file mode 100644 index 0000000..7d65594 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotPhotoTagsTable.vala @@ -0,0 +1,57 @@ +/* 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 DataImports.FSpot.Db { + +/** + * The value object for the "photo_tags" table, representing a single database row. + */ +public class FSpotPhotoTagRow : Object { + public int64 photo_id; + public int64 tag_id; +} + +/** + * This class represents the F-Spot photo_tags table. + */ +public class FSpotPhotoTagsTable : FSpotDatabaseTable<FSpotPhotoTagRow> { + public static const string TABLE_NAME = "Photo_Tags"; + + public FSpotPhotoTagsTable(Sqlite.Database db, FSpotDatabaseBehavior db_behavior) { + base(db); + set_behavior(db_behavior.get_photo_tags_behavior()); + } +} + +public class FSpotPhotoTagsV0Behavior : FSpotTableBehavior<FSpotPhotoTagRow>, Object { + private static FSpotPhotoTagsV0Behavior instance; + + private FSpotPhotoTagsV0Behavior() { + } + + public static FSpotPhotoTagsV0Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotoTagsV0Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotoTagsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "photo_id", "tag_id" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoTagRow row, int offset = 0) { + row = new FSpotPhotoTagRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.tag_id = stmt.column_int64(offset + 1); + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotPhotoVersionsTable.vala b/plugins/shotwell-data-imports/FSpotPhotoVersionsTable.vala new file mode 100644 index 0000000..8378884 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotPhotoVersionsTable.vala @@ -0,0 +1,271 @@ +/* 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 DataImports.FSpot.Db { + +/** + * The value object for the "photo_versions" table, representing a single database row. + */ +public class FSpotPhotoVersionRow : Object { + public int64 photo_id; + public int64 version_id; + public string name; + public File? base_path; + public string? filename; + public string md5_sum; + public bool is_protected; +} + +/** + * This class represents the F-Spot photo_versions table. + */ +public class FSpotPhotoVersionsTable : FSpotDatabaseTable<FSpotPhotoVersionRow> { + public static const string TABLE_NAME = "Photo_versions"; + + public FSpotPhotoVersionsTable(Sqlite.Database db, FSpotDatabaseBehavior db_behavior) { + base(db); + set_behavior(db_behavior.get_photo_versions_behavior()); + } + + public Gee.ArrayList<FSpotPhotoVersionRow> get_by_photo_id(int64 photo_id) throws DatabaseError { + Gee.ArrayList<FSpotPhotoVersionRow> rows = new Gee.ArrayList<FSpotPhotoVersionRow?>(); + + Sqlite.Statement stmt; + + string column_list = get_joined_column_list(); + string sql = "SELECT %s FROM %s WHERE photo_id=?".printf( + column_list, table_name + ); + + int res = fspot_db.prepare_v2(sql, -1, out stmt); + if (res != Sqlite.OK) + throw_error("Statement failed: %s".printf(sql), res); + + res = stmt.bind_int64(1, photo_id); + if (res != Sqlite.OK) + throw_error("Bind failed for photo_id", res); + + res = stmt.step(); + while (res == Sqlite.ROW) { + FSpotPhotoVersionRow row; + behavior.build_row(stmt, out row); + rows.add(row); + res = stmt.step(); + } + + return rows; + } +} + +// Photo_versions table behavior for v0-8 +// Note: there is a change in the URI format in version 8 but the File.new_for_uri +// constructor should be able to deal with the variation, so the v8 behavior should +// be handled in a way identical to v0-7 +public class FSpotPhotoVersionsV0Behavior : FSpotTableBehavior<FSpotPhotoVersionRow>, Object { + private static FSpotPhotoVersionsV0Behavior instance; + + private FSpotPhotoVersionsV0Behavior() { + } + + public static FSpotPhotoVersionsV0Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotoVersionsV0Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotoVersionsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "photo_id", "version_id", "name", "uri" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoVersionRow row, int offset = 0) { + row = new FSpotPhotoVersionRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.version_id = stmt.column_int64(offset + 1); + row.name = stmt.column_text(offset + 2); + + string? full_path = stmt.column_text(offset + 3); + if (full_path != null) { + File uri = File.new_for_uri(full_path); + row.base_path = uri.get_parent(); + row.filename = uri.get_basename(); + } + + row.md5_sum = ""; + row.is_protected = false; + } +} + +// Photo_versions table behavior for v9-15 +// add protected field +public class FSpotPhotoVersionsV9Behavior : FSpotTableBehavior<FSpotPhotoVersionRow>, Object { + private static FSpotPhotoVersionsV9Behavior instance; + + private FSpotPhotoVersionsV9Behavior() { + } + + public static FSpotPhotoVersionsV9Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotoVersionsV9Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotoVersionsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "photo_id", "version_id", "name", "uri", + "protected" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoVersionRow row, int offset = 0) { + row = new FSpotPhotoVersionRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.version_id = stmt.column_int64(offset + 1); + row.name = stmt.column_text(offset + 2); + + string? full_path = stmt.column_text(offset + 3); + if (full_path != null) { + File uri = File.new_for_uri(full_path); + row.base_path = uri.get_parent(); + row.filename = uri.get_basename(); + } + + row.md5_sum = ""; + row.is_protected = (stmt.column_int(offset + 4) > 0); + } +} + +// Photo_versions table behavior for v16 +// add md5_sum in photo_versions +public class FSpotPhotoVersionsV16Behavior : FSpotTableBehavior<FSpotPhotoVersionRow>, Object { + private static FSpotPhotoVersionsV16Behavior instance; + + private FSpotPhotoVersionsV16Behavior() { + } + + public static FSpotPhotoVersionsV16Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotoVersionsV16Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotoVersionsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "photo_id", "version_id", "name", "uri", + "md5_sum", "protected" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoVersionRow row, int offset = 0) { + row = new FSpotPhotoVersionRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.version_id = stmt.column_int64(offset + 1); + row.name = stmt.column_text(offset + 2); + + string? full_path = stmt.column_text(offset + 3); + if (full_path != null) { + File uri = File.new_for_uri(full_path); + row.base_path = uri.get_parent(); + row.filename = uri.get_basename(); + } + + row.md5_sum = stmt.column_text(offset + 4); + row.is_protected = (stmt.column_int(offset + 5) > 0); + } +} + +// Photo_versions table behavior for v17 +// v17 split the URI into base_uri and filename (reverting back to the original +// design introduced in v0, albeit with a URI rather than a file system path) +public class FSpotPhotoVersionsV17Behavior : FSpotTableBehavior<FSpotPhotoVersionRow>, Object { + private static FSpotPhotoVersionsV17Behavior instance; + + private FSpotPhotoVersionsV17Behavior() { + } + + public static FSpotPhotoVersionsV17Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotoVersionsV17Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotoVersionsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "photo_id", "version_id", "name", "base_uri", "filename", + "md5_sum", "protected" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoVersionRow row, int offset = 0) { + row = new FSpotPhotoVersionRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.version_id = stmt.column_int64(offset + 1); + row.name = stmt.column_text(offset + 2); + + string? base_path = stmt.column_text(offset + 3); + string? filename = stmt.column_text(offset + 4); + if (base_path != null && filename != null) { + row.base_path = File.new_for_uri(base_path); + row.filename = filename; + } + + row.md5_sum = stmt.column_text(offset + 5); + row.is_protected = (stmt.column_int(offset + 6) > 0); + } +} + +// Photo_versions table behavior for v18 +// md5_sum renamed import_md5 +public class FSpotPhotoVersionsV18Behavior : FSpotTableBehavior<FSpotPhotoVersionRow>, Object { + private static FSpotPhotoVersionsV18Behavior instance; + + private FSpotPhotoVersionsV18Behavior() { + } + + public static FSpotPhotoVersionsV18Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotoVersionsV18Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotoVersionsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "photo_id", "version_id", "name", "base_uri", "filename", + "import_md5", "protected" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoVersionRow row, int offset = 0) { + row = new FSpotPhotoVersionRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.version_id = stmt.column_int64(offset + 1); + row.name = stmt.column_text(offset + 2); + + string? base_path = stmt.column_text(offset + 3); + string? filename = stmt.column_text(offset + 4); + if (base_path != null && filename != null) { + row.base_path = File.new_for_uri(base_path); + row.filename = filename; + } + + row.md5_sum = stmt.column_text(offset + 5); + row.is_protected = (stmt.column_int(offset + 6) > 0); + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotPhotosTable.vala b/plugins/shotwell-data-imports/FSpotPhotosTable.vala new file mode 100644 index 0000000..4231102 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotPhotosTable.vala @@ -0,0 +1,356 @@ +/* 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 DataImports.FSpot.Db { + +/** + * The value object for the "photos" table, representing a single database row. + */ +public class FSpotPhotoRow : Object { + public int64 photo_id; + public time_t time; + public File? base_path; + public string? filename; + public string description; + public int64 roll_id; + public int64 default_version_id; + public int rating; + public string md5_sum; +} + +/** + * This class represents the F-Spot photos table. + */ +public class FSpotPhotosTable : FSpotDatabaseTable<FSpotPhotoRow> { + public static const string TABLE_NAME = "Photos"; + + public FSpotPhotosTable(Sqlite.Database db, FSpotDatabaseBehavior db_behavior) { + base(db); + set_behavior(db_behavior.get_photos_behavior()); + } + + public Gee.ArrayList<FSpotPhotoRow> get_all() throws DatabaseError { + Gee.ArrayList<FSpotPhotoRow> all = new Gee.ArrayList<FSpotPhotoRow?>(); + + Sqlite.Statement stmt; + int res = select_all(out stmt); + while (res == Sqlite.ROW) { + FSpotPhotoRow row; + behavior.build_row(stmt, out row); + all.add(row); + res = stmt.step(); + } + + return all; + } +} + +// Photos table behavior for v0-4 +// The original table format +public class FSpotPhotosV0Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV0Behavior instance; + + private FSpotPhotosV0Behavior() { + } + + public static FSpotPhotosV0Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV0Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "directory_path", "name", "description", + "default_version_id" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? base_path = stmt.column_text(offset + 2); + string? filename = stmt.column_text(offset + 3); + if (base_path != null && filename != null) { + row.base_path = File.new_for_uri(base_path); + row.filename = filename; + } + + row.description = stmt.column_text(offset + 4); + row.roll_id = INVALID_ID; + row.default_version_id = stmt.column_int64(offset + 5); + row.rating = 0; + row.md5_sum = ""; + } +} + +// Photos table behavior for v5-6 +// v5 introduced a roll_id to reference the imported roll (rolls were a new +// table migrated from imports) +public class FSpotPhotosV5Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV5Behavior instance; + + private FSpotPhotosV5Behavior() { + } + + public static FSpotPhotosV5Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV5Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "directory_path", "name", "description", "roll_id", + "default_version_id" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? base_path = stmt.column_text(offset + 2); + string? filename = stmt.column_text(offset + 3); + if (base_path != null && filename != null) { + row.base_path = File.new_for_uri(base_path); + row.filename = filename; + } + + row.description = stmt.column_text(offset + 4); + row.roll_id = stmt.column_int64(offset + 5); + row.default_version_id = stmt.column_int64(offset + 6); + row.rating = 0; + row.md5_sum = ""; + } +} + +// Photos table behavior for v7-10 +// v7 merged directory_path and name into a single URI value with a file:// +// prefix; presumaly this is meant to be able to handle remote files using a +// different URI prefix such as remote files +public class FSpotPhotosV7Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV7Behavior instance; + + private FSpotPhotosV7Behavior() { + } + + public static FSpotPhotosV7Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV7Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "uri", "description", "roll_id", + "default_version_id" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? full_path = stmt.column_text(offset + 2); + if (full_path != null) { + File uri = File.new_for_uri(full_path); + row.base_path = uri.get_parent(); + row.filename = uri.get_basename(); + } + + row.description = stmt.column_text(offset + 3); + row.roll_id = stmt.column_int64(offset + 4); + row.default_version_id = stmt.column_int64(offset + 5); + row.rating = 0; + row.md5_sum = ""; + } +} + +// Photos table behavior for v11-15 +// v11 introduced the concept of rating so add this to the list of fields +public class FSpotPhotosV11Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV11Behavior instance; + + private FSpotPhotosV11Behavior() { + } + + public static FSpotPhotosV11Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV11Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "uri", "description", "roll_id", + "default_version_id", "rating" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? full_path = stmt.column_text(offset + 2); + if (full_path != null) { + File uri = File.new_for_uri(full_path); + row.base_path = uri.get_parent(); + row.filename = uri.get_basename(); + } + + row.description = stmt.column_text(offset + 3); + row.roll_id = stmt.column_int64(offset + 4); + row.default_version_id = stmt.column_int64(offset + 5); + row.rating = stmt.column_int(offset + 6); + row.md5_sum = ""; + } +} + +// Photos table behavior for v16 +// v16 introduced the MD5 sum so add this to the list of fields +public class FSpotPhotosV16Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV16Behavior instance; + + private FSpotPhotosV16Behavior() { + } + + public static FSpotPhotosV16Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV16Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "uri", "description", "roll_id", + "default_version_id", "rating", "md5_sum" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? full_path = stmt.column_text(offset + 2); + if (full_path != null) { + File uri = File.new_for_uri(full_path); + row.base_path = uri.get_parent(); + row.filename = uri.get_basename(); + } + + row.description = stmt.column_text(offset + 3); + row.roll_id = stmt.column_int64(offset + 4); + row.default_version_id = stmt.column_int64(offset + 5); + row.rating = stmt.column_int(offset + 6); + row.md5_sum = stmt.column_text(offset + 7); + } +} + +// Photos table behavior for v17 +// v17 split the URI into base_uri and filename (reverting back to the original +// design introduced in v0, albeit with a URI rather than a file system path) +public class FSpotPhotosV17Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV17Behavior instance; + + private FSpotPhotosV17Behavior() { + } + + public static FSpotPhotosV17Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV17Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "base_uri", "filename", "description", "roll_id", + "default_version_id", "rating", "md5_sum" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? base_path = stmt.column_text(offset + 2); + string? filename = stmt.column_text(offset + 3); + if (base_path != null && filename != null) { + row.base_path = File.new_for_uri(base_path); + row.filename = filename; + } + + row.description = stmt.column_text(offset + 4); + row.roll_id = stmt.column_int64(offset + 5); + row.default_version_id = stmt.column_int64(offset + 6); + row.rating = stmt.column_int(offset + 7); + row.md5_sum = stmt.column_text(offset + 8); + } +} + +// v18: no more MD5 hash in the photos table: moved to photo_versions table +public class FSpotPhotosV18Behavior : FSpotTableBehavior<FSpotPhotoRow>, Object { + private static FSpotPhotosV18Behavior instance; + + private FSpotPhotosV18Behavior() { + } + + public static FSpotPhotosV18Behavior get_instance() { + if (instance == null) + instance = new FSpotPhotosV18Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotPhotosTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time", "base_uri", "filename", "description", "roll_id", + "default_version_id", "rating" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotPhotoRow row, int offset = 0) { + row = new FSpotPhotoRow(); + row.photo_id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + + string? base_path = stmt.column_text(offset + 2); + string? filename = stmt.column_text(offset + 3); + if (base_path != null && filename != null) { + row.base_path = File.new_for_uri(base_path); + row.filename = filename; + } + + row.description = stmt.column_text(offset + 4); + row.roll_id = stmt.column_int64(offset + 5); + row.default_version_id = stmt.column_int64(offset + 6); + row.rating = stmt.column_int(offset + 7); + row.md5_sum = ""; + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotRollsTable.vala b/plugins/shotwell-data-imports/FSpotRollsTable.vala new file mode 100644 index 0000000..fd5362d --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotRollsTable.vala @@ -0,0 +1,111 @@ +/* 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 DataImports.FSpot.Db { + +/** + * The value object for the "rolls" table, representing a single database row. + */ +public class FSpotRollRow : Object { + public int64 id; + public time_t time; +} + +/** + * This class represents the F-Spot rolls table. + */ +public class FSpotRollsTable : FSpotDatabaseTable<FSpotRollRow> { + public static const string TABLE_NAME = "Rolls"; + public static const string TABLE_NAME_PRE_V5 = "Imports"; + + public FSpotRollsTable(Sqlite.Database db, FSpotDatabaseBehavior db_behavior) { + base(db); + set_behavior(db_behavior.get_rolls_behavior()); + } + + public FSpotRollRow? get_by_id(int64 roll_id) throws DatabaseError { + Sqlite.Statement stmt; + FSpotRollRow? row = null; + string column_list = get_joined_column_list(); + string sql = "SELECT %s FROM %s WHERE id=?".printf(column_list, table_name); + + int res = fspot_db.prepare_v2(sql, -1, out stmt); + if (res != Sqlite.OK) + throw_error("Statement failed: %s".printf(sql), res); + + res = stmt.bind_int64(1, roll_id); + if (res != Sqlite.OK) + throw_error("Bind failed for roll_id", res); + + res = stmt.step(); + if (res == Sqlite.ROW) + behavior.build_row(stmt, out row); + else if (res == Sqlite.DONE) + message("Could not find roll row with ID %d", (int)roll_id); + + return row; + } +} + +// Rolls table behavior for v0-4 +public class FSpotRollsV0Behavior : FSpotTableBehavior<FSpotRollRow>, Object { + private static FSpotRollsV0Behavior instance; + + private FSpotRollsV0Behavior() { + } + + public static FSpotRollsV0Behavior get_instance() { + if (instance == null) + instance = new FSpotRollsV0Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotRollsTable.TABLE_NAME_PRE_V5; + } + + public string[] list_columns() { + return { "id", "time" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotRollRow row, int offset = 0) { + row = new FSpotRollRow(); + row.id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + } +} + +// Rolls table behavior for v5+ +// Table name changed from "imports" to "rolls" +public class FSpotRollsV5Behavior : FSpotTableBehavior<FSpotRollRow>, Object { + private static FSpotRollsV5Behavior instance; + + private FSpotRollsV5Behavior() { + } + + public static FSpotRollsV5Behavior get_instance() { + if (instance == null) + instance = new FSpotRollsV5Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotRollsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "time" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotRollRow row, int offset = 0) { + row = new FSpotRollRow(); + row.id = stmt.column_int64(offset + 0); + row.time = (time_t) stmt.column_int64(offset + 1); + } +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotTableBehavior.vala b/plugins/shotwell-data-imports/FSpotTableBehavior.vala new file mode 100644 index 0000000..2d94427 --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotTableBehavior.vala @@ -0,0 +1,28 @@ +/* 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 DataImports.FSpot.Db { + +/** + * This class defines a generic table behavior. In practice, it implements + * the concept of a DAO (Data Access Object) in ORM terms and is responsible + * for transforming the data extracted from a relational statement into a + * lightweight value object. + * + * The type T defined in the generic is the value object type a behavior + * implementation is designed to handle. Value object types are designed to + * contain the data for a single database row. + */ +public interface FSpotTableBehavior<T> : Object { + public abstract string get_table_name(); + + public abstract string[] list_columns(); + + public abstract void build_row(Sqlite.Statement stmt, out T row, int offset = 0); +} + +} + diff --git a/plugins/shotwell-data-imports/FSpotTagsTable.vala b/plugins/shotwell-data-imports/FSpotTagsTable.vala new file mode 100644 index 0000000..07045cf --- /dev/null +++ b/plugins/shotwell-data-imports/FSpotTagsTable.vala @@ -0,0 +1,129 @@ +/* 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 DataImports.FSpot.Db { + +/** + * The value object for the "tags" table, representing a single database row. + */ +public class FSpotTagRow : Object { + public int64 tag_id; + public string name; + public int64 category_id; + public bool is_category; + public int sort_priority; + public string stock_icon; // only store stock icons +} + +/** + * This class represents the F-Spot tags table. + */ +public class FSpotTagsTable : FSpotDatabaseTable<FSpotTagRow> { + public static const string TABLE_NAME = "Tags"; + + public static const string PREFIX_STOCK_ICON = "stock_icon:"; + public static const string STOCK_ICON_FAV = "stock_icon:emblem-favorite"; + public static const string STOCK_ICON_PEOPLE = "stock_icon:emblem-people"; + public static const string STOCK_ICON_PLACES = "stock_icon:emblem-places"; + public static const string STOCK_ICON_EVENTS = "stock_icon:emblem-event"; + + private FSpotTableBehavior<FSpotPhotoTagRow> photo_tags_behavior; + + public FSpotTagsTable(Sqlite.Database db, FSpotDatabaseBehavior db_behavior) { + base(db); + set_behavior(db_behavior.get_tags_behavior()); + photo_tags_behavior = db_behavior.get_photo_tags_behavior(); + } + + public FSpotTagRow? get_by_id(int64 tag_id) throws DatabaseError { + Sqlite.Statement stmt; + FSpotTagRow? row = null; + string column_list = get_joined_column_list(); + string sql = "SELECT %s FROM %s WHERE id=?".printf(column_list, table_name); + + int res = fspot_db.prepare_v2(sql, -1, out stmt); + if (res != Sqlite.OK) + throw_error("Statement failed: %s".printf(sql), res); + + res = stmt.bind_int64(1, tag_id); + assert(res == Sqlite.OK); + + res = stmt.step(); + if (res == Sqlite.ROW) + behavior.build_row(stmt, out row); + else if (res == Sqlite.DONE) + message("Could not find tag row with ID %d", (int)tag_id); + + return row; + } + + public Gee.ArrayList<FSpotTagRow> get_by_photo_id(int64 photo_id) throws DatabaseError { + Gee.ArrayList<FSpotTagRow> rows = new Gee.ArrayList<FSpotTagRow?>(); + + Sqlite.Statement stmt; + + string column_list = get_joined_column_list(true); + string sql = "SELECT %1$s FROM %2$s, %3$s WHERE %3$s.photo_id=? AND %3$s.tag_id = %2$s.id".printf( + column_list, table_name, photo_tags_behavior.get_table_name() + ); + + int res = fspot_db.prepare_v2(sql, -1, out stmt); + if (res != Sqlite.OK) + throw_error("Statement failed: %s".printf(sql), res); + + res = stmt.bind_int64(1, photo_id); + if (res != Sqlite.OK) + throw_error("Bind failed for photo_id", res); + + res = stmt.step(); + while (res == Sqlite.ROW) { + FSpotTagRow row; + behavior.build_row(stmt, out row); + rows.add(row); + res = stmt.step(); + } + + return rows; + } +} + +public class FSpotTagsV0Behavior : FSpotTableBehavior<FSpotTagRow>, Object { + private static FSpotTagsV0Behavior instance; + + private FSpotTagsV0Behavior() { + } + + public static FSpotTagsV0Behavior get_instance() { + if (instance == null) + instance = new FSpotTagsV0Behavior(); + return instance; + } + + public string get_table_name() { + return FSpotTagsTable.TABLE_NAME; + } + + public string[] list_columns() { + return { "id", "name", "category_id", "is_category", "sort_priority", "icon" }; + } + + public void build_row(Sqlite.Statement stmt, out FSpotTagRow row, int offset = 0) { + row = new FSpotTagRow(); + row.tag_id = stmt.column_int64(offset + 0); + row.name = stmt.column_text(offset + 1); + row.category_id = stmt.column_int64(offset + 2); + row.is_category = (stmt.column_int(offset + 3) > 0); + row.sort_priority = stmt.column_int(offset + 4); + string icon_str = stmt.column_text(offset + 5); + if (icon_str != null && icon_str.has_prefix(FSpotTagsTable.PREFIX_STOCK_ICON)) + row.stock_icon = icon_str; + else + row.stock_icon = ""; + } +} + +} + diff --git a/plugins/shotwell-data-imports/Makefile b/plugins/shotwell-data-imports/Makefile new file mode 100644 index 0000000..52329e7 --- /dev/null +++ b/plugins/shotwell-data-imports/Makefile @@ -0,0 +1,30 @@ + +PLUGIN := shotwell-data-imports + +PLUGIN_PKGS := \ + gtk+-3.0 \ + gexiv2 \ + gee-0.8 \ + sqlite3 + +SRC_FILES := \ + shotwell-data-imports.vala \ + ../common/VersionNumber.vala \ + ../common/SqliteSupport.vala \ + FSpotImporter.vala \ + FSpotDatabaseBehavior.vala \ + FSpotDatabase.vala \ + FSpotDatabaseTable.vala \ + FSpotTableBehavior.vala \ + FSpotMetaTable.vala \ + FSpotPhotosTable.vala \ + FSpotPhotoTagsTable.vala \ + FSpotPhotoVersionsTable.vala \ + FSpotRollsTable.vala \ + FSpotTagsTable.vala + +RC_FILES := \ + f-spot-24.png + +include ../Makefile.plugin.mk + diff --git a/plugins/shotwell-data-imports/f-spot-24.png b/plugins/shotwell-data-imports/f-spot-24.png Binary files differnew file mode 100644 index 0000000..fda9672 --- /dev/null +++ b/plugins/shotwell-data-imports/f-spot-24.png diff --git a/plugins/shotwell-data-imports/shotwell-data-imports.vala b/plugins/shotwell-data-imports/shotwell-data-imports.vala new file mode 100644 index 0000000..c6e7d46 --- /dev/null +++ b/plugins/shotwell-data-imports/shotwell-data-imports.vala @@ -0,0 +1,46 @@ +/* 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. + */ + +extern const string _VERSION; + +// "core services" are: F-Spot +private class ShotwellDataImportsCoreServices : Object, Spit.Module { + private Spit.Pluggable[] pluggables = new Spit.Pluggable[0]; + + // we need to get a module file handle because our pluggables have to load resources from the + // module file directory + public ShotwellDataImportsCoreServices(GLib.File module_file) { + GLib.File resource_directory = module_file.get_parent(); + + pluggables += new FSpotService(resource_directory); + } + + public unowned string get_module_name() { + return _("Core Data Import Services"); + } + + public unowned string get_version() { + return _VERSION; + } + + public unowned string get_id() { + return "org.yorba.shotwell.data_imports.core_services"; + } + + public unowned Spit.Pluggable[]? get_pluggables() { + return pluggables; + } +} + +// This entry point is required for all SPIT modules. +public Spit.Module? spit_entry_point(Spit.EntryPointParams *params) { + params->module_spit_interface = Spit.negotiate_interfaces(params->host_min_spit_interface, + params->host_max_spit_interface, Spit.CURRENT_INTERFACE); + + return (params->module_spit_interface != Spit.UNSUPPORTED_INTERFACE) + ? new ShotwellDataImportsCoreServices(params->module_file) : null; +} + diff --git a/plugins/shotwell-publishing-extras/Makefile b/plugins/shotwell-publishing-extras/Makefile new file mode 100644 index 0000000..51f649f --- /dev/null +++ b/plugins/shotwell-publishing-extras/Makefile @@ -0,0 +1,27 @@ + +PLUGIN := shotwell-publishing-extras + +PLUGIN_PKGS := \ + gtk+-3.0 \ + libsoup-2.4 \ + libxml-2.0 \ + webkitgtk-3.0 \ + gee-0.8 \ + rest-0.7 \ + json-glib-1.0 + +SRC_FILES := \ + shotwell-publishing-extras.vala \ + YandexPublishing.vala \ + TumblrPublishing.vala \ + ../../src/util/string.vala \ + ../common/RESTSupport.vala + +RC_FILES := \ + yandex_publish_model.glade \ + tumblr.png \ + tumblr_authentication_pane.glade \ + tumblr_publishing_options_pane.glade + +include ../Makefile.plugin.mk + diff --git a/plugins/shotwell-publishing-extras/TumblrPublishing.vala b/plugins/shotwell-publishing-extras/TumblrPublishing.vala new file mode 100644 index 0000000..6bafb21 --- /dev/null +++ b/plugins/shotwell-publishing-extras/TumblrPublishing.vala @@ -0,0 +1,1157 @@ +/* Copyright 2012 BJA Electronics + * Author: Jeroen Arnoldus (b.j.arnoldus@bja-electronics.nl) + * + * This software is licensed under the GNU Lesser General Public License + * (version 2.1 or later). See the COPYING file in this distribution. + */ + + +extern string hmac_sha1(string key, string message); +public class TumblrService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "tumblr.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public TumblrService(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.tumblr"; + } + + public unowned string get_pluggable_name() { + return "Tumblr"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Jeroen Arnoldus"; + info.copyright = _("Copyright 2012 BJA Electronics"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public void activation(bool enabled) { + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Tumblr.TumblrPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } +} + +namespace Publishing.Tumblr { + +internal const string SERVICE_NAME = "Tumblr"; +internal const string ENDPOINT_URL = "http://www.tumblr.com/"; +internal const string API_KEY = "NdXvXQuKVccOsCOj0H4k9HUJcbcjDBYSo2AkaHzXFECHGNuP9k"; +internal const string API_SECRET = "BN0Uoig0MwbeD27OgA0IwYlp3Uvonyfsrl9pf1cnnMj1QoEUvi"; +internal const string ENCODE_RFC_3986_EXTRA = "!*'();:@&=+$,/?%#[] \\"; +internal const int ORIGINAL_SIZE = -1; + + + +private class BlogEntry { + public string blog; + public string url; + public BlogEntry(string creator_blog, string creator_url) { + blog = creator_blog; + url = creator_url; + } +} + +private class SizeEntry { + public string title; + public int size; + + public SizeEntry(string creator_title, int creator_size) { + title = creator_title; + size = creator_size; + } +} + +public class TumblrPublisher : Spit.Publishing.Publisher, GLib.Object { + private Spit.Publishing.Service service; + private Spit.Publishing.PluginHost host; + private Spit.Publishing.ProgressCallback progress_reporter = null; + private bool running = false; + private bool was_started = false; + private Session session = null; + private PublishingOptionsPane publishing_options_pane = null; + private SizeEntry[] sizes = null; + private BlogEntry[] blogs = null; + private string username = ""; + + + private SizeEntry[] create_sizes() { + SizeEntry[] result = new SizeEntry[0]; + + result += new SizeEntry(_("500 x 375 pixels"), 500); + result += new SizeEntry(_("1024 x 768 pixels"), 1024); + result += new SizeEntry(_("1280 x 853 pixels"), 1280); +//Larger images make no sense for Tumblr +// result += new SizeEntry(_("2048 x 1536 pixels"), 2048); +// result += new SizeEntry(_("4096 x 3072 pixels"), 4096); +// result += new SizeEntry(_("Original size"), ORIGINAL_SIZE); + + return result; + } + + private BlogEntry[] create_blogs() { + BlogEntry[] result = new BlogEntry[0]; + + + return result; + } + + public TumblrPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("TumblrPublisher instantiated."); + this.service = service; + this.host = host; + this.session = new Session(); + this.sizes = this.create_sizes(); + this.blogs = this.create_blogs(); + session.authenticated.connect(on_session_authenticated); + } + + ~TumblrPublisher() { + session.authenticated.disconnect(on_session_authenticated); + } + + private void invalidate_persistent_session() { + set_persistent_access_phase_token(""); + set_persistent_access_phase_token_secret(""); + } + // Publisher interface implementation + + public Spit.Publishing.Service get_service() { + return service; + } + + public Spit.Publishing.PluginHost get_host() { + return host; + } + + public bool is_running() { + return running; + } + + private bool is_persistent_session_valid() { + string? access_phase_token = get_persistent_access_phase_token(); + string? access_phase_token_secret = get_persistent_access_phase_token_secret(); + + bool valid = ((access_phase_token != null) && (access_phase_token_secret != null)); + + if (valid) + debug("existing Tumblr session found in configuration database; using it."); + else + debug("no persisted Tumblr session exists."); + + return valid; + } + + + + + public string? get_persistent_access_phase_token() { + return host.get_config_string("token", null); + } + + private void set_persistent_access_phase_token(string? token) { + host.set_config_string("token", token); + } + + public string? get_persistent_access_phase_token_secret() { + return host.get_config_string("token_secret", null); + } + + private void set_persistent_access_phase_token_secret(string? token_secret) { + host.set_config_string("token_secret", token_secret); + } + + internal int get_persistent_default_size() { + return host.get_config_int("default_size", 1); + } + + internal void set_persistent_default_size(int size) { + host.set_config_int("default_size", size); + } + + internal int get_persistent_default_blog() { + return host.get_config_int("default_blog", 0); + } + + internal void set_persistent_default_blog(int blog) { + host.set_config_int("default_blog", blog); + } + + // Actions and events implementation + + /** + * Action that shows the authentication pane. + * + * This action method shows the authentication pane. It is shown at the + * very beginning of the interaction when no persistent parameters are found + * or after a failed login attempt using persisted parameters. It can be + * given a mode flag to specify whether it should be displayed in initial + * mode or in any of the error modes that it supports. + * + * @param mode the mode for the authentication pane + */ + private void do_show_authentication_pane(AuthenticationPane.Mode mode = AuthenticationPane.Mode.INTRO) { + debug("ACTION: installing authentication pane"); + + host.set_service_locked(false); + AuthenticationPane authentication_pane = + new AuthenticationPane(this, mode); + authentication_pane.login.connect(on_authentication_pane_login_clicked); + host.install_dialog_pane(authentication_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE); + host.set_dialog_default_widget(authentication_pane.get_default_widget()); + } + + /** + * Event triggered when the login button in the authentication panel is + * clicked. + * + * This event is triggered when the login button in the authentication + * panel is clicked. It then triggers a network login interaction. + * + * @param username the name of the Tumblr user as entered in the dialog + * @param password the password of the Tumblr as entered in the dialog + */ + private void on_authentication_pane_login_clicked( string username, string password ) { + debug("EVENT: on_authentication_pane_login_clicked"); + if (!running) + return; + + do_network_login(username, password); + } + + /** + * Action to perform a network login to a Tumblr blog. + * + * This action performs a network login a Tumblr blog specified the given user name and password as credentials. + * + * @param username the name of the Tumblr user used to login + * @param password the password of the Tumblr user used to login + */ + private void do_network_login(string username, string password) { + debug("ACTION: logging in"); + host.set_service_locked(true); + host.install_login_wait_pane(); + + + AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction(session,username,password); + txn.completed.connect(on_auth_request_txn_completed); + txn.network_error.connect(on_auth_request_txn_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + + private void on_auth_request_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_auth_request_txn_completed); + txn.network_error.disconnect(on_auth_request_txn_error); + + if (!is_running()) + return; + + debug("EVENT: OAuth authentication request transaction completed; response = '%s'", + txn.get_response()); + + do_parse_token_info_from_auth_request(txn.get_response()); + } + + private void on_auth_request_txn_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_auth_request_txn_completed); + txn.network_error.disconnect(on_auth_request_txn_error); + + if (!is_running()) + return; + + debug("EVENT: OAuth authentication request transaction caused a network error"); + host.post_error(err); + } + + + private void do_parse_token_info_from_auth_request(string response) { + debug("ACTION: parsing authorization request response '%s' into token and secret", response); + + string? oauth_token = null; + string? oauth_token_secret = null; + + string[] key_value_pairs = response.split("&"); + foreach (string pair in key_value_pairs) { + string[] split_pair = pair.split("="); + + if (split_pair.length != 2) + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + _("'%s' isn't a valid response to an OAuth authentication request"))); + + if (split_pair[0] == "oauth_token") + oauth_token = split_pair[1]; + else if (split_pair[0] == "oauth_token_secret") + oauth_token_secret = split_pair[1]; + } + + if (oauth_token == null || oauth_token_secret == null) + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + _("'%s' isn't a valid response to an OAuth authentication request"))); + + session.set_access_phase_credentials(oauth_token, oauth_token_secret); + } + + + + private void on_session_authenticated() { + if (!is_running()) + return; + + debug("EVENT: a fully authenticated session has become available"); + set_persistent_access_phase_token(session.get_access_phase_token()); + set_persistent_access_phase_token_secret(session.get_access_phase_token_secret()); + do_get_blogs(); + +} + + private void do_get_blogs() { + debug("ACTION: obtain all blogs of the tumblr user"); + UserInfoFetchTransaction txn = new UserInfoFetchTransaction(session); + txn.completed.connect(on_info_request_txn_completed); + txn.network_error.connect(on_info_request_txn_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + + + } + + + private void on_info_request_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_info_request_txn_completed); + txn.network_error.disconnect(on_info_request_txn_error); + + if (!is_running()) + return; + + debug("EVENT: user info request transaction completed; response = '%s'", + txn.get_response()); + do_parse_token_info_from_user_request(txn.get_response()); + do_show_publishing_options_pane(); + } + + + private void do_parse_token_info_from_user_request(string response) { + debug("ACTION: parsing info request response '%s' into list of available blogs", response); + try { + var parser = new Json.Parser(); + parser.load_from_data (response, -1); + var root_object = parser.get_root().get_object(); + this.username = root_object.get_object_member("response").get_object_member("user").get_string_member ("name"); + debug("Got user name: %s",username); + foreach (var blognode in root_object.get_object_member("response").get_object_member("user").get_array_member("blogs").get_elements ()) { + var blog = blognode.get_object (); + string name = blog.get_string_member ("name"); + string url = blog.get_string_member ("url").replace("http://","").replace("/",""); + debug("Got blog name: %s and url: %s", name, url); + this.blogs += new BlogEntry(name,url); + } + } catch (Error err) { + host.post_error(err); + } + } + + private void on_info_request_txn_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_info_request_txn_completed); + txn.network_error.disconnect(on_info_request_txn_error); + + if (!is_running()) + return; + + session.deauthenticate(); + invalidate_persistent_session(); + debug("EVENT: user info request transaction caused a network error"); + host.post_error(err); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: displaying publishing options pane"); + host.set_service_locked(false); + PublishingOptionsPane publishing_options_pane = + new PublishingOptionsPane(this, host.get_publishable_media_type(), this.sizes, this.blogs, this.username); + publishing_options_pane.publish.connect(on_publishing_options_pane_publish); + publishing_options_pane.logout.connect(on_publishing_options_pane_logout); + host.install_dialog_pane(publishing_options_pane); + } + + + + private void on_publishing_options_pane_publish() { + if (publishing_options_pane != null) { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + } + + if (!is_running()) + return; + + debug("EVENT: user clicked the 'Publish' button in the publishing options pane"); + do_publish(); + } + + private void on_publishing_options_pane_logout() { + if (publishing_options_pane != null) { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + } + + if (!is_running()) + return; + + debug("EVENT: user clicked the 'Logout' button in the publishing options pane"); + + do_logout(); + } + + public static int tumblr_date_time_compare_func(Spit.Publishing.Publishable a, + Spit.Publishing.Publishable b) { + return a.get_exposure_date_time().compare(b.get_exposure_date_time()); + } + + private void do_publish() { + debug("ACTION: uploading media items to remote server."); + + host.set_service_locked(true); + + progress_reporter = host.serialize_publishables(sizes[get_persistent_default_size()].size); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + // Sort publishables in reverse-chronological order. + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + Gee.ArrayList<Spit.Publishing.Publishable> sorted_list = + new Gee.ArrayList<Spit.Publishing.Publishable>(); + foreach (Spit.Publishing.Publishable p in publishables) { + debug("ACTION: add publishable"); + sorted_list.add(p); + } + sorted_list.sort(tumblr_date_time_compare_func); + string blog_url = this.blogs[get_persistent_default_blog()].url; + + Uploader uploader = new Uploader(session, sorted_list.to_array(),blog_url); + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + uploader.upload(on_upload_status_updated); + } + + private void do_show_success_pane() { + debug("ACTION: showing success pane."); + + host.set_service_locked(false); + host.install_success_pane(); + } + + + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, + int num_published) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + do_show_success_pane(); + } + + private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, + Spit.Publishing.PublishingError err) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + host.post_error(err); + } + + + private void do_logout() { + debug("ACTION: logging user out, deauthenticating session, and erasing stored credentials"); + + session.deauthenticate(); + invalidate_persistent_session(); + + running = false; + + attempt_start(); + } + + public void attempt_start() { + if (is_running()) + return; + + debug("TumblrPublisher: starting interaction."); + + running = true; + if (is_persistent_session_valid()) { + debug("attempt start: a persistent session is available; using it"); + + session.authenticate_from_persistent_credentials(get_persistent_access_phase_token(), + get_persistent_access_phase_token_secret()); + } else { + debug("attempt start: no persistent session available; showing login welcome pane"); + + do_show_authentication_pane(); + } + } + + public void start() { + if (is_running()) + return; + + if (was_started) + error(_("TumblrPublisher: start( ): can't start; this publisher is not restartable.")); + + debug("TumblrPublisher: starting interaction."); + + attempt_start(); + } + + public void stop() { + debug("TumblrPublisher: stop( ) invoked."); + +// if (session != null) +// session.stop_transactions(); + + running = false; + } + + +// UI elements + +/** + * The authentication pane used when asking service URL, user name and password + * from the user. + */ +internal class AuthenticationPane : Spit.Publishing.DialogPane, Object { + public enum Mode { + INTRO, + FAILED_RETRY_USER + } + private static string INTRO_MESSAGE = _("Enter the username and password associated with your Tumblr account."); + private static string FAILED_RETRY_USER_MESSAGE = _("Username and/or password invalid. Please try again"); + + private Gtk.Box pane_widget = null; + private Gtk.Builder builder; + private Gtk.Entry username_entry; + private Gtk.Entry password_entry; + private Gtk.Button login_button; + + public signal void login(string user, string password); + + public AuthenticationPane(TumblrPublisher publisher, Mode mode = Mode.INTRO) { + this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + + File ui_file = publisher.get_host().get_module_file().get_parent(). + get_child("tumblr_authentication_pane.glade"); + + try { + builder = new Gtk.Builder(); + builder.add_from_file(ui_file.get_path()); + builder.connect_signals(null); + Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment; + + Gtk.Label message_label = builder.get_object("message_label") as Gtk.Label; + switch (mode) { + case Mode.INTRO: + message_label.set_text(INTRO_MESSAGE); + break; + + case Mode.FAILED_RETRY_USER: + message_label.set_markup("<b>%s</b>\n\n%s".printf(_( + "Invalid User Name or Password"), FAILED_RETRY_USER_MESSAGE)); + break; + } + + username_entry = builder.get_object ("username_entry") as Gtk.Entry; + + password_entry = builder.get_object ("password_entry") as Gtk.Entry; + + + + login_button = builder.get_object("login_button") as Gtk.Button; + + username_entry.changed.connect(on_user_changed); + password_entry.changed.connect(on_password_changed); + login_button.clicked.connect(on_login_button_clicked); + + align.reparent(pane_widget); + publisher.get_host().set_dialog_default_widget(login_button); + } catch (Error e) { + warning(_("Could not load UI: %s"), e.message); + } + } + + public Gtk.Widget get_default_widget() { + return login_button; + } + + private void on_login_button_clicked() { + login(username_entry.get_text(), + password_entry.get_text()); + } + + + private void on_user_changed() { + update_login_button_sensitivity(); + } + + private void on_password_changed() { + update_login_button_sensitivity(); + } + + private void update_login_button_sensitivity() { + login_button.set_sensitive( + !is_string_empty(username_entry.get_text()) && + !is_string_empty(password_entry.get_text()) + ); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + username_entry.grab_focus(); + password_entry.set_activates_default(true); + login_button.can_default = true; + update_login_button_sensitivity(); + } + + public void on_pane_uninstalled() { + } +} + + +/** + * The publishing options pane. + */ + + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + + + + private Gtk.Builder builder; + private Gtk.Box pane_widget = null; + private Gtk.Label upload_info_label = null; + private Gtk.Label size_label = null; + private Gtk.Label blog_label = null; + private Gtk.Button logout_button = null; + private Gtk.Button publish_button = null; + private Gtk.ComboBoxText size_combo = null; + private Gtk.ComboBoxText blog_combo = null; + private SizeEntry[] sizes = null; + private BlogEntry[] blogs = null; + private string username = ""; + private TumblrPublisher publisher = null; + private Spit.Publishing.Publisher.MediaType media_type; + + public signal void publish(); + public signal void logout(); + + public PublishingOptionsPane(TumblrPublisher publisher, Spit.Publishing.Publisher.MediaType media_type, SizeEntry[] sizes, BlogEntry[] blogs, string username) { + + this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + this.username = username; + this.publisher = publisher; + this.media_type = media_type; + this.sizes = sizes; + this.blogs=blogs; + File ui_file = publisher.get_host().get_module_file().get_parent(). + get_child("tumblr_publishing_options_pane.glade"); + + try { + builder = new Gtk.Builder(); + builder.add_from_file(ui_file.get_path()); + builder.connect_signals(null); + + // pull in the necessary widgets from the glade file + pane_widget = (Gtk.Box) this.builder.get_object("tumblr_pane"); + upload_info_label = (Gtk.Label) this.builder.get_object("upload_info_label"); + logout_button = (Gtk.Button) this.builder.get_object("logout_button"); + publish_button = (Gtk.Button) this.builder.get_object("publish_button"); + size_combo = (Gtk.ComboBoxText) this.builder.get_object("size_combo"); + size_label = (Gtk.Label) this.builder.get_object("size_label"); + blog_combo = (Gtk.ComboBoxText) this.builder.get_object("blog_combo"); + blog_label = (Gtk.Label) this.builder.get_object("blog_label"); + + + string upload_label_text = _("You are logged into Tumblr as %s.\n\n").printf(this.username); + upload_info_label.set_label(upload_label_text); + + populate_blog_combo(); + blog_combo.changed.connect(on_blog_changed); + + if ((media_type != Spit.Publishing.Publisher.MediaType.VIDEO)) { + populate_size_combo(); + size_combo.changed.connect(on_size_changed); + } else { + // publishing -only- video - don't let the user manipulate the photo size choices. + size_combo.set_sensitive(false); + size_label.set_sensitive(false); + } + + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } catch (Error e) { + warning(_("Could not load UI: %s"), e.message); + } + } + + + + + + private void on_logout_clicked() { + logout(); + } + + private void on_publish_clicked() { + + + publish(); + } + + + private void populate_blog_combo() { + if (blogs != null) { + foreach (BlogEntry b in blogs) + blog_combo.append_text(b.blog); + blog_combo.set_active(publisher.get_persistent_default_blog()); + } + } + + private void on_blog_changed() { + publisher.set_persistent_default_blog(blog_combo.get_active()); + } + + private void populate_size_combo() { + if (sizes != null) { + foreach (SizeEntry e in sizes) + size_combo.append_text(e.title); + size_combo.set_active(publisher.get_persistent_default_size()); + } + } + + private void on_size_changed() { + publisher.set_persistent_default_size(size_combo.get_active()); + } + + + protected void notify_publish() { + publish(); + } + + protected void notify_logout() { + logout(); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + publish.connect(notify_publish); + logout.connect(notify_logout); + } + + public void on_pane_uninstalled() { + publish.disconnect(notify_publish); + logout.disconnect(notify_logout); + } +} + + +// REST support classes +internal class Transaction : Publishing.RESTSupport.Transaction { + public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = + Publishing.RESTSupport.HttpMethod.POST) { + base(session, method); + + } + + public Transaction.with_uri(Session session, string uri, + Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) { + base.with_endpoint_url(session, uri, method); + + add_argument("oauth_nonce", session.get_oauth_nonce()); + add_argument("oauth_signature_method", "HMAC-SHA1"); + add_argument("oauth_version", "1.0"); + add_argument("oauth_timestamp", session.get_oauth_timestamp()); + add_argument("oauth_consumer_key", API_KEY); + if (session.get_access_phase_token() != null) { + add_argument("oauth_token", session.get_access_phase_token()); + } + } + + public override void execute() throws Spit.Publishing.PublishingError { + ((Session) get_parent_session()).sign_transaction(this); + + base.execute(); + } + +} + + +internal class AccessTokenFetchTransaction : Transaction { + public AccessTokenFetchTransaction(Session session, string username, string password) { + base.with_uri(session, "https://www.tumblr.com/oauth/access_token", + Publishing.RESTSupport.HttpMethod.POST); + add_argument("x_auth_username", Soup.URI.encode(username, ENCODE_RFC_3986_EXTRA)); + add_argument("x_auth_password", password); + add_argument("x_auth_mode", "client_auth"); + } +} + +internal class UserInfoFetchTransaction : Transaction { + public UserInfoFetchTransaction(Session session) { + base.with_uri(session, "http://api.tumblr.com/v2/user/info", + Publishing.RESTSupport.HttpMethod.POST); + } +} + + +internal class UploadTransaction : Publishing.RESTSupport.UploadTransaction { + private Session session; + private Publishing.RESTSupport.Argument[] auth_header_fields; + + +//Workaround for Soup.URI.encode() to support binary data (i.e. string with \0) + private string encode( uint8[] data ){ + var s = new StringBuilder(); + char[] bytes = new char[2]; + bytes[1] = 0; + foreach( var byte in data ) + { + if(byte == 0) { + s.append( "%00" ); + } else { + bytes[0] = (char)byte; + s.append( Soup.URI.encode((string) bytes, ENCODE_RFC_3986_EXTRA) ); + } + } + return s.str; + } + + + public UploadTransaction(Session session,Spit.Publishing.Publishable publishable, string blog_url) { + debug("Init upload transaction"); + base.with_endpoint_url(session, publishable,"http://api.tumblr.com/v2/blog/%s/post".printf(blog_url) ); + this.session = session; + + } + + + + public void add_authorization_header_field(string key, string value) { + auth_header_fields += new Publishing.RESTSupport.Argument(key, value); + } + + public Publishing.RESTSupport.Argument[] get_authorization_header_fields() { + return auth_header_fields; + } + + public string get_authorization_header_string() { + string result = "OAuth "; + + for (int i = 0; i < auth_header_fields.length; i++) { + result += auth_header_fields[i].key; + result += "="; + result += ("\"" + auth_header_fields[i].value + "\""); + + if (i < auth_header_fields.length - 1) + result += ", "; + } + + return result; + } + + public override void execute() throws Spit.Publishing.PublishingError { + add_authorization_header_field("oauth_nonce", session.get_oauth_nonce()); + add_authorization_header_field("oauth_signature_method", "HMAC-SHA1"); + add_authorization_header_field("oauth_version", "1.0"); + add_authorization_header_field("oauth_timestamp", session.get_oauth_timestamp()); + add_authorization_header_field("oauth_consumer_key", API_KEY); + add_authorization_header_field("oauth_token", session.get_access_phase_token()); + + + string payload; + size_t payload_length; + try { + FileUtils.get_contents(base.publishable.get_serialized_file().get_path(), out payload, + out payload_length); + + string reqdata = this.encode(payload.data[0:payload_length]); + + + + add_argument("data[0]", reqdata); + add_argument("type", "photo"); + string[] keywords = base.publishable.get_publishing_keywords(); + string tags = ""; + if (keywords != null) { + foreach (string tag in keywords) { + if (!is_string_empty(tags)) { + tags += ","; + } + tags += tag; + } + } + add_argument("tags", Soup.URI.encode(tags, ENCODE_RFC_3986_EXTRA)); + + } catch (FileError e) { + throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A temporary file needed for publishing is unavailable")); + + } + + + session.sign_transaction(this); + + string authorization_header = get_authorization_header_string(); + + debug("executing upload transaction: authorization header string = '%s'", + authorization_header); + add_header("Authorization", authorization_header); + + Publishing.RESTSupport.Argument[] request_arguments = get_arguments(); + assert(request_arguments.length > 0); + + string request_data = ""; + for (int i = 0; i < request_arguments.length; i++) { + request_data += (request_arguments[i].key + "=" + request_arguments[i].value); + if (i < request_arguments.length - 1) + request_data += "&"; + } + Soup.Message outbound_message = new Soup.Message( "POST", get_endpoint_url()); + outbound_message.set_request("application/x-www-form-urlencoded", Soup.MemoryUse.COPY, request_data.data); + + // TODO: there must be a better way to iterate over a map + Gee.MapIterator<string, string> i = base.message_headers.map_iterator(); + bool cont = i.next(); + while(cont) { + outbound_message.request_headers.append(i.get_key(), i.get_value()); + cont = i.next(); + } + set_message(outbound_message); + + set_is_executed(true); + + send(); + } +} + + + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private string blog_url = ""; + public Uploader(Session session, Spit.Publishing.Publishable[] publishables, string blog_url) { + base(session, publishables); + this.blog_url=blog_url; + + } + + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + debug("Create upload transaction"); + return new UploadTransaction((Session) get_session(), get_current_publishable(), this.blog_url); + + } +} + +/** + * Session class that keeps track of the authentication status and of the + * user token tumblr. + */ +internal class Session : Publishing.RESTSupport.Session { + private string? access_phase_token = null; + private string? access_phase_token_secret = null; + + + public Session() { + base(ENDPOINT_URL); + } + + public override bool is_authenticated() { + return (access_phase_token != null && access_phase_token_secret != null); + } + + public void authenticate_from_persistent_credentials(string token, string secret) { + this.access_phase_token = token; + this.access_phase_token_secret = secret; + + + authenticated(); + } + + public void deauthenticate() { + access_phase_token = null; + access_phase_token_secret = null; + } + + public void sign_transaction(Publishing.RESTSupport.Transaction txn) { + string http_method = txn.get_method().to_string(); + + debug("signing transaction with parameters:"); + debug("HTTP method = " + http_method); + string? signing_key = null; + if (access_phase_token_secret != null) { + debug("access phase token secret available; using it as signing key"); + + signing_key = API_SECRET + "&" + this.get_access_phase_token_secret(); + } else { + debug("Access phase token secret not available; using API " + + "key as signing key"); + + signing_key = API_SECRET + "&"; + } + + + Publishing.RESTSupport.Argument[] base_string_arguments = txn.get_arguments(); + + UploadTransaction? upload_txn = txn as UploadTransaction; + if (upload_txn != null) { + debug("this transaction is an UploadTransaction; including Authorization header " + + "fields in signature base string"); + + Publishing.RESTSupport.Argument[] auth_header_args = + upload_txn.get_authorization_header_fields(); + + foreach (Publishing.RESTSupport.Argument arg in auth_header_args) + base_string_arguments += arg; + } + + Publishing.RESTSupport.Argument[] sorted_args = + Publishing.RESTSupport.Argument.sort(base_string_arguments); + + string arguments_string = ""; + for (int i = 0; i < sorted_args.length; i++) { + arguments_string += (sorted_args[i].key + "=" + sorted_args[i].value); + if (i < sorted_args.length - 1) + arguments_string += "&"; + } + + + string signature_base_string = http_method + "&" + Soup.URI.encode( + txn.get_endpoint_url(), ENCODE_RFC_3986_EXTRA) + "&" + + Soup.URI.encode(arguments_string, ENCODE_RFC_3986_EXTRA); + + debug("signature base string = '%s'", signature_base_string); + debug("signing key = '%s'", signing_key); + + // compute the signature + string signature = hmac_sha1(signing_key, signature_base_string); + debug("signature = '%s'", signature); + signature = Soup.URI.encode(signature, ENCODE_RFC_3986_EXTRA); + + debug("signature after RFC encode = '%s'", signature); + + if (upload_txn != null) + upload_txn.add_authorization_header_field("oauth_signature", signature); + else + txn.add_argument("oauth_signature", signature); + + + } + + public void set_access_phase_credentials(string token, string secret) { + this.access_phase_token = token; + this.access_phase_token_secret = secret; + + + authenticated(); + } + + public string get_access_phase_token() { + return access_phase_token; + } + + + public string get_access_phase_token_secret() { + return access_phase_token_secret; + } + + public string get_oauth_nonce() { + TimeVal currtime = TimeVal(); + currtime.get_current_time(); + + return Checksum.compute_for_string(ChecksumType.MD5, currtime.tv_sec.to_string() + + currtime.tv_usec.to_string()); + } + + public string get_oauth_timestamp() { + return GLib.get_real_time().to_string().substring(0, 10); + } + +} + + +} //class TumblrPublisher + +} //namespace Publishing.Tumblr + diff --git a/plugins/shotwell-publishing-extras/YandexPublishing.vala b/plugins/shotwell-publishing-extras/YandexPublishing.vala new file mode 100644 index 0000000..36a3ede --- /dev/null +++ b/plugins/shotwell-publishing-extras/YandexPublishing.vala @@ -0,0 +1,665 @@ +/* Copyright 2010+ Evgeniy Polyakov <zbr@ioremap.net> + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public class YandexService : Object, Spit.Pluggable, Spit.Publishing.Service { + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.yandex-fotki"; + } + + public unowned string get_pluggable_name() { + return "Yandex.Fotki"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Evgeniy Polyakov <zbr@ioremap.net>"; + info.copyright = _("Copyright 2010+ Evgeniy Polyakov <zbr@ioremap.net>"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = _("Visit the Yandex.Fotki web site"); + info.website_url = "http://fotki.yandex.ru/"; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Yandex.YandexPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO); + } + + public void activation(bool enabled) { + } +} + +namespace Publishing.Yandex { + +internal const string SERVICE_NAME = "Yandex.Fotki"; + +private const string client_id = "52be4756dee3438792c831a75d7cd360"; + +internal class Transaction: Publishing.RESTSupport.Transaction { + public Transaction.with_url(Session session, string url, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.GET) { + base.with_endpoint_url(session, url, method); + add_headers(); + } + + private void add_headers() { + if (((Session) get_parent_session()).is_authenticated()) { + add_header("Authorization", "OAuth %s".printf(((Session) get_parent_session()).get_auth_token())); + add_header("Connection", "close"); + } + } + + public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.GET) { + base(session, method); + add_headers(); + } + + public void add_data(string type, string data) { + set_custom_payload(data, type); + } +} + +internal class Session : Publishing.RESTSupport.Session { + private string? auth_token = null; + + public Session() { + } + + public override bool is_authenticated() { + return (auth_token != null); + } + + public void deauthenticate() { + auth_token = null; + } + + public void set_auth_token(string token) { + this.auth_token = token; + } + + public string? get_auth_token() { + return auth_token; + } +} + +internal class WebAuthPane : Spit.Publishing.DialogPane, GLib.Object { + private WebKit.WebView webview = null; + private Gtk.Box pane_widget = null; + private Gtk.ScrolledWindow webview_frame = null; + + private Regex re; + private string? login_url = null; + + public signal void login_succeeded(string success_url); + public signal void login_failed(); + + public WebAuthPane(string login_url) { + this.login_url = login_url; + + try { + this.re = new Regex("(.*)#access_token=([a-zA-Z0-9]*)&"); + } catch (RegexError e) { + critical("%s", e.message); + } + + pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + + webview_frame = new Gtk.ScrolledWindow(null, null); + webview_frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN); + webview_frame.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + + webview = new WebKit.WebView(); + webview.get_settings().enable_plugins = false; + webview.get_settings().enable_default_context_menu = false; + + webview.load_finished.connect(on_page_load); + webview.load_started.connect(on_load_started); + webview.navigation_requested.connect(navigation_requested); + + webview_frame.add(webview); + pane_widget.pack_start(webview_frame, true, true, 0); + } + + private void on_page_load(WebKit.WebFrame origin_frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + } + + private WebKit.NavigationResponse navigation_requested (WebKit.WebFrame frame, WebKit.NetworkRequest req) { + debug("Navigating to '%s'", req.uri); + + MatchInfo info = null; + + if (re.match(req.uri, 0, out info)) { + string access_token = info.fetch_all()[2]; + + debug("Load completed: %s", access_token); + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + if (access_token != null) { + login_succeeded(access_token); + return WebKit.NavigationResponse.IGNORE; + } else + login_failed(); + } + return WebKit.NavigationResponse.ACCEPT; + } + + private void on_load_started(WebKit.WebFrame frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH)); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.RESIZABLE; + } + + public void on_pane_installed() { + webview.open(login_url); + } + + public void on_pane_uninstalled() { + } +} + +internal class PublishOptions { + public bool disable_comments = false; + public bool hide_original = false; + public string access_type; + + public string destination_album = null; + public string destination_album_url = null; +} + +internal class PublishingOptionsPane: Spit.Publishing.DialogPane, GLib.Object { + private Gtk.Box box; + private Gtk.Builder builder; + private Gtk.Button logout_button; + private Gtk.Button publish_button; + private Gtk.ComboBoxText album_list; + + private weak PublishOptions options; + + public signal void publish(); + public signal void logout(); + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + public void on_pane_installed() { + } + public void on_pane_uninstalled() { + } + public Gtk.Widget get_widget() { + return box; + } + + public PublishingOptionsPane(PublishOptions options, Gee.HashMap<string, string> list, + Spit.Publishing.PluginHost host) { + this.options = options; + + box = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + + File ui_file = host.get_module_file().get_parent().get_child("yandex_publish_model.glade"); + + try { + builder = new Gtk.Builder(); + builder.add_from_file(ui_file.get_path()); + builder.connect_signals(null); + Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment; + + album_list = builder.get_object ("album_list") as Gtk.ComboBoxText; + foreach (string key in list.keys) + album_list.append_text(key); + + album_list.set_active(0); + + publish_button = builder.get_object("publish_button") as Gtk.Button; + logout_button = builder.get_object("logout_button") as Gtk.Button; + + publish_button.clicked.connect(on_publish_clicked); + logout_button.clicked.connect(on_logout_clicked); + + align.reparent(box); + box.set_child_packing(align, true, true, 0, Gtk.PackType.START); + } catch (Error e) { + warning("Could not load UI: %s", e.message); + } + } + + private void on_logout_clicked() { + logout(); + } + + private void on_publish_clicked() { + options.destination_album = album_list.get_active_text(); + + Gtk.CheckButton tmp = builder.get_object("hide_original_check") as Gtk.CheckButton; + options.hide_original = tmp.active; + + tmp = builder.get_object("disable_comments_check") as Gtk.CheckButton; + options.disable_comments = tmp.active; + + Gtk.ComboBoxText access_type = builder.get_object("access_type_list") as Gtk.ComboBoxText; + options.access_type = access_type.get_active_text(); + + publish(); + } +} + +private class Uploader: Publishing.RESTSupport.BatchUploader { + private weak PublishOptions options; + + public Uploader(Session session, PublishOptions options, Spit.Publishing.Publishable[] photos) { + base(session, photos); + + this.options = options; + } + + protected override Publishing.RESTSupport.Transaction create_transaction(Spit.Publishing.Publishable publishable) { + debug("create transaction"); + return new UploadTransaction(((Session) get_session()), options, get_current_publishable()); + } +} + +private class UploadTransaction: Transaction { + public UploadTransaction(Session session, PublishOptions options, Spit.Publishing.Publishable photo) { + base.with_url(session, options.destination_album_url, Publishing.RESTSupport.HttpMethod.POST); + + set_custom_payload("qwe", "image/jpeg", 1); + + debug("Uploading '%s' -> %s : %s", photo.get_publishing_name(), options.destination_album, options.destination_album_url); + + Soup.Multipart message_parts = new Soup.Multipart("multipart/form-data"); + message_parts.append_form_string("title", photo.get_publishing_name()); + message_parts.append_form_string("hide_original", options.hide_original.to_string()); + message_parts.append_form_string("disable_comments", options.disable_comments.to_string()); + message_parts.append_form_string("access", options.access_type.down()); + + string photo_data; + size_t data_length; + + try { + FileUtils.get_contents(photo.get_serialized_file().get_path(), out photo_data, out data_length); + } catch (GLib.FileError e) { + critical("Failed to read data file '%s': %s", photo.get_serialized_file().get_path(), e.message); + } + + int image_part_num = message_parts.get_length(); + + Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.COPY, photo_data.data[0:data_length]); + message_parts.append_form_file("", photo.get_serialized_file().get_path(), "image/jpeg", bindable_data); + + unowned Soup.MessageHeaders image_part_header; + unowned Soup.Buffer image_part_body; + message_parts.get_part(image_part_num, out image_part_header, out image_part_body); + + GLib.HashTable<string, string> result = new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); + result.insert("name", "image"); + result.insert("filename", "unused"); + + image_part_header.set_content_disposition("form-data", result); + + Soup.Message outbound_message = soup_form_request_new_from_multipart(get_endpoint_url(), message_parts); + outbound_message.request_headers.append("Authorization", ("OAuth %s").printf(session.get_auth_token())); + outbound_message.request_headers.append("Connection", "close"); + set_message(outbound_message); + } +} + +public class YandexPublisher : Spit.Publishing.Publisher, GLib.Object { + private weak Spit.Publishing.PluginHost host = null; + private Spit.Publishing.ProgressCallback progress_reporter = null; + private weak Spit.Publishing.Service service = null; + + private string service_url = null; + + private Gee.HashMap<string, string> album_list = null; + private PublishOptions options; + + private bool running = false; + + private WebAuthPane web_auth_pane = null; + + private Session session; + + public YandexPublisher(Spit.Publishing.Service service, Spit.Publishing.PluginHost host) { + this.service = service; + this.host = host; + this.session = new Session(); + this.album_list = new Gee.HashMap<string, string>(); + this.options = new PublishOptions(); + } + + internal string? get_persistent_auth_token() { + return host.get_config_string("auth_token", null); + } + + internal void set_persistent_auth_token(string auth_token) { + host.set_config_string("auth_token", auth_token); + } + + internal void invalidate_persistent_session() { + host.unset_config_key("auth_token"); + } + + internal bool is_persistent_session_available() { + return (get_persistent_auth_token() != null); + } + + public bool is_running() { + return running; + } + + public Spit.Publishing.Service get_service() { + return service; + } + + private new string? check_response(Publishing.RESTSupport.XmlDocument doc) { + return null; + } + + private void parse_album_entry(Xml.Node *e) throws Spit.Publishing.PublishingError { + string title = null; + string link = null; + + for (Xml.Node* c = e->children ; c != null; c = c->next) { + if (c->name == "title") + title = c->get_content(); + + if ((c->name == "link") && (c->get_prop("rel") == "photos")) + link = c->get_prop("href"); + + if (title != null && link != null) { + debug("Added album: '%s', link: %s", title, link); + album_list.set(title, link); + title = null; + link = null; + break; + } + } + } + + public void parse_album_creation(string data) throws Spit.Publishing.PublishingError { + Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(data, check_response); + Xml.Node *root = doc.get_root_node(); + + parse_album_entry(root); + } + + public void parse_album_list(string data) throws Spit.Publishing.PublishingError { + Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(data, check_response); + Xml.Node *root = doc.get_root_node(); + + for (Xml.Node *e = root->children ; e != null; e = e->next) { + if (e->name != "entry") + continue; + + parse_album_entry(e); + } + } + + private void album_creation_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) { + t.completed.disconnect(album_creation_complete); + t.network_error.disconnect(album_creation_error); + + warning("Album creation error: %s", err.message); + } + + private void album_creation_complete(Publishing.RESTSupport.Transaction t) { + t.completed.disconnect(album_creation_complete); + t.network_error.disconnect(album_creation_error); + + try { + parse_album_creation(t.get_response()); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + return; + } + + if (album_list.get(options.destination_album) != null) + start_upload(); + else + host.post_error(new Spit.Publishing.PublishingError.PROTOCOL_ERROR("Server did not create album")); + } + + private void create_destination_album() { + string album = options.destination_album; + string data = "<entry xmlns=\"http://www.w3.org/2005/Atom\" xmlns:f=\"yandex:fotki\"><title>%s</title></entry>".printf(album); + + Transaction t = new Transaction.with_url(session, service_url, Publishing.RESTSupport.HttpMethod.POST); + + t.add_data("application/atom+xml; charset=utf-8; type=entry", data); + + t.completed.connect(album_creation_complete); + t.network_error.connect(album_creation_error); + + try { + t.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, int num_published) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + if (num_published == 0) + host.post_error(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR("")); + + host.set_service_locked(false); + + host.install_success_pane(); + } + + private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, Spit.Publishing.PublishingError err) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + warning("Photo upload error: %s", err.message); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void start_upload() { + host.set_service_locked(true); + + progress_reporter = host.serialize_publishables(0); + + options.destination_album_url = album_list.get(options.destination_album); + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + Uploader uploader = new Uploader(session, options, publishables); + + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + uploader.upload(on_upload_status_updated); + } + + private void on_logout() { + if (!is_running()) + return; + + session.deauthenticate(); + invalidate_persistent_session(); + + running = false; + + start(); + } + + private void on_publish() { + debug("Going to publish to '%s' : %s", options.destination_album, album_list.get(options.destination_album)); + if (album_list.get(options.destination_album) == null) + create_destination_album(); + else + start_upload(); + } + + public void service_get_album_list_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) { + t.completed.disconnect(service_get_album_list_complete); + t.network_error.disconnect(service_get_album_list_error); + + invalidate_persistent_session(); + warning("Failed to get album list: %s", err.message); + } + + public void service_get_album_list_complete(Publishing.RESTSupport.Transaction t) { + t.completed.disconnect(service_get_album_list_complete); + t.network_error.disconnect(service_get_album_list_error); + + debug("service_get_album_list_complete: %s", t.get_response()); + try { + parse_album_list(t.get_response()); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + + PublishingOptionsPane publishing_options_pane = new PublishingOptionsPane(options, album_list, + host); + + publishing_options_pane.publish.connect(on_publish); + publishing_options_pane.logout.connect(on_logout); + host.install_dialog_pane(publishing_options_pane); + } + + public void service_get_album_list(string url) { + service_url = url; + + Transaction t = new Transaction.with_url(session, url); + t.completed.connect(service_get_album_list_complete); + t.network_error.connect(service_get_album_list_error); + + try { + t.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + public void fetch_account_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) { + t.completed.disconnect(fetch_account_complete); + t.network_error.disconnect(fetch_account_error); + + warning("Failed to fetch account info: %s", err.message); + } + + public void fetch_account_complete(Publishing.RESTSupport.Transaction t) { + t.completed.disconnect(fetch_account_complete); + t.network_error.disconnect(fetch_account_error); + + debug("account info: %s", t.get_response()); + try { + Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(t.get_response(), check_response); + Xml.Node* root = doc.get_root_node(); + + for (Xml.Node* work = root->children ; work != null; work = work->next) { + if (work->name != "workspace") + continue; + for (Xml.Node* c = work->children ; c != null; c = c->next) { + if (c->name != "collection") + continue; + + if (c->get_prop("id") == "album-list") { + string url = c->get_prop("href"); + + set_persistent_auth_token(session.get_auth_token()); + service_get_album_list(url); + break; + } + } + } + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + public void fetch_account_information(string auth_token) { + session.set_auth_token(auth_token); + + Transaction t = new Transaction.with_url(session, "http://api-fotki.yandex.ru/api/me/"); + t.completed.connect(fetch_account_complete); + t.network_error.connect(fetch_account_error); + + try { + t.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void web_auth_login_succeeded(string access_token) { + debug("login succeeded with token %s", access_token); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + fetch_account_information(access_token); + } + + private void web_auth_login_failed() { + debug("login failed"); + } + + private void start_web_auth() { + host.set_service_locked(false); + + web_auth_pane = new WebAuthPane(("http://oauth.yandex.ru/authorize?client_id=%s&response_type=token").printf(client_id)); + web_auth_pane.login_succeeded.connect(web_auth_login_succeeded); + web_auth_pane.login_failed.connect(web_auth_login_failed); + + host.install_dialog_pane(web_auth_pane, Spit.Publishing.PluginHost.ButtonMode.CANCEL); + } + + private void show_welcome_page() { + host.install_welcome_pane(_("You are not currently logged into Yandex.Fotki."), + start_web_auth); + } + + public void start() { + if (is_running()) + return; + + if (host == null) + error("YandexPublisher: start( ): can't start; this publisher is not restartable."); + + debug("YandexPublisher: starting interaction."); + + running = true; + + if (is_persistent_session_available()) { + session.set_auth_token(get_persistent_auth_token()); + + fetch_account_information(get_persistent_auth_token()); + } else { + show_welcome_page(); + } + } + + public void stop() { + debug("YandexPublisher: stop( ) invoked."); + + host = null; + running = false; + } +} + +} + diff --git a/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala b/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala new file mode 100644 index 0000000..c83acf1 --- /dev/null +++ b/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala @@ -0,0 +1,42 @@ +/* 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. + */ + +extern const string _VERSION; + +private class ShotwellPublishingExtraServices : Object, Spit.Module { + private Spit.Pluggable[] pluggables = new Spit.Pluggable[0]; + + public ShotwellPublishingExtraServices(GLib.File module_file) { + pluggables += new YandexService(); + pluggables += new TumblrService(module_file.get_parent()); + } + + public unowned string get_module_name() { + return _("Shotwell Extra Publishing Services"); + } + + public unowned string get_version() { + return _VERSION; + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.extras"; + } + + public unowned Spit.Pluggable[]? get_pluggables() { + return pluggables; + } +} + +// This entry point is required for all SPIT modules. +public Spit.Module? spit_entry_point(Spit.EntryPointParams *params) { + params->module_spit_interface = Spit.negotiate_interfaces(params->host_min_spit_interface, + params->host_max_spit_interface, Spit.CURRENT_INTERFACE); + + return (params->module_spit_interface != Spit.UNSUPPORTED_INTERFACE) + ? new ShotwellPublishingExtraServices(params->module_file) : null; +} + diff --git a/plugins/shotwell-publishing-extras/tumblr.png b/plugins/shotwell-publishing-extras/tumblr.png Binary files differnew file mode 100644 index 0000000..d5bf02b --- /dev/null +++ b/plugins/shotwell-publishing-extras/tumblr.png diff --git a/plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade b/plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade new file mode 100644 index 0000000..9b43309 --- /dev/null +++ b/plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade @@ -0,0 +1,132 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkWindow" id="authentication_pane"> + <property name="can_focus">False</property> + <child> + <object class="GtkAlignment" id="alignment"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xscale">0.5</property> + <property name="yscale">0.5</property> + <child> + <object class="GtkVBox" id="vbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">30</property> + <property name="margin_right">30</property> + <property name="hexpand">True</property> + <property name="spacing">8</property> + <child> + <object class="GtkLabel" id="message_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">label</property> + <property name="wrap">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkTable" id="field_table"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="n_rows">2</property> + <property name="n_columns">2</property> + <property name="column_spacing">8</property> + <property name="row_spacing">2</property> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">_Email address</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">username_entry</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">_Password</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">password_entry</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="username_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="invisible_char">●</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="password_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="visibility">False</property> + <property name="invisible_char">●</property> + <property name="invisible_char_set">True</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkHButtonBox" id="hbuttonbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkButton" id="login_button"> + <property name="label" translatable="yes">Login</property> + <property name="use_action_appearance">False</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + </object> + </child> + </object> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade b/plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade new file mode 100644 index 0000000..102e260 --- /dev/null +++ b/plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade @@ -0,0 +1,167 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="tumblr_pane"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">16</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkBox" id="inner_wrapper"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">40</property> + <child> + <object class="GtkLabel" id="upload_info_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="no">You are logged into Tumblr as (name). + +(this label's string is populated and set inside the code, +so changes made here will not display)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">16</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkGrid" id="visibility_and_size_grid"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="row_spacing">8</property> + <property name="column_spacing">24</property> + <child> + <object class="GtkLabel" id="blog_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Blogs:</property> + <property name="use_underline">True</property> + <property name="justify">right</property> + <property name="mnemonic_widget">blog_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="size_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="label" translatable="yes">Photo _size:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">size_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="blog_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="size_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">18</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="spacing">48</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</property> + <property name="use_action_appearance">False</property> + <property name="width_request">96</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">24</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">_Publish</property> + <property name="use_action_appearance">False</property> + <property name="width_request">96</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">24</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="padding">1</property> + <property name="position">2</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">8</property> + <property name="position">0</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing-extras/yandex_publish_model.glade b/plugins/shotwell-publishing-extras/yandex_publish_model.glade new file mode 100644 index 0000000..175bafd --- /dev/null +++ b/plugins/shotwell-publishing-extras/yandex_publish_model.glade @@ -0,0 +1,175 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="2.16"/> + <!-- interface-naming-policy project-wide --> + <object class="GtkWindow" id="publish_options_window"> + <child> + <object class="GtkAlignment" id="alignment"> + <property name="visible">True</property> + <property name="xalign">0.30000001192092896</property> + <property name="xscale">0.10000000149011612</property> + <property name="yscale">0.10000000149011612</property> + <child> + <object class="GtkVBox" id="vbox1"> + <property name="visible">True</property> + <child> + <object class="GtkTable" id="table1"> + <property name="visible">True</property> + <property name="n_rows">2</property> + <property name="n_columns">2</property> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="xalign">0.30000001192092896</property> + <property name="label" translatable="yes">_Albums (or write new):</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">album_list</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="xalign">0</property> + <property name="ypad">6</property> + <property name="label" translatable="yes">Access _type:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">access_type_list</property> + </object> + </child> + <child> + <object class="GtkComboBoxText" id="access_type_list"> + <property name="visible">True</property> + <property name="model">liststore1</property> + <property name="active">0</property> + <property name="text_column">0</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="y_padding">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="album_list"> + <property name="visible">True</property> + <property name="model">liststore2</property> + <property name="active">0</property> + <property name="text_column">0</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + <property name="y_padding">1</property> + </packing> + </child> + </object> + <packing> + <property name="position">0</property> + </packing> + </child> + <child> + <placeholder/> + </child> + <child> + <object class="GtkCheckButton" id="disable_comments_check"> + <property name="label" translatable="yes">Disable _comments</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0.30000001192092896</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="padding">2</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="hide_original_check"> + <property name="label" translatable="yes">_Forbid downloading original photo</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0.30000001192092896</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkHButtonBox" id="hbuttonbox1"> + <property name="visible">True</property> + <property name="spacing">2</property> + <property name="layout_style">spread</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">_Publish</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="padding">12</property> + <property name="position">4</property> + </packing> + </child> + </object> + </child> + </object> + </child> + </object> + <object class="GtkListStore" id="liststore1"> + <columns> + <!-- column-name text --> + <column type="gchararray"/> + </columns> + <data> + <row> + <col id="0" translatable="yes">Public</col> + </row> + <row> + <col id="0" translatable="yes">Friends</col> + </row> + <row> + <col id="0" translatable="yes">Private</col> + </row> + </data> + </object> + <object class="GtkListStore" id="liststore2"> + <columns> + <!-- column-name gchararray1 --> + <column type="gchararray"/> + </columns> + </object> +</interface> diff --git a/plugins/shotwell-publishing/FacebookPublishing.vala b/plugins/shotwell-publishing/FacebookPublishing.vala new file mode 100644 index 0000000..d3f5a0f --- /dev/null +++ b/plugins/shotwell-publishing/FacebookPublishing.vala @@ -0,0 +1,1676 @@ +/* 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 FacebookService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "facebook.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public FacebookService(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.facebook"; + } + + public unowned string get_pluggable_name() { + return "Facebook"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public void activation(bool enabled) { + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Facebook.FacebookPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } +} + +namespace Publishing.Facebook { +// global parameters for the Facebook publishing plugin -- don't touch these (unless you really, +// truly, deep-down know what you're doing) +public const string SERVICE_NAME = "facebook"; +internal const string USER_VISIBLE_NAME = "Facebook"; +internal const string APPLICATION_ID = "162702932093"; +internal const string DEFAULT_ALBUM_NAME = _("Shotwell Connect"); +internal const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into Facebook.\n\nIf you don't yet have a Facebook account, you can create one during the login process. During login, Shotwell Connect may ask you for permission to upload photos and publish to your feed. These permissions are required for Shotwell Connect to function."); +internal const string RESTART_ERROR_MESSAGE = + _("You have already logged in and out of Facebook during this Shotwell session.\nTo continue publishing to Facebook, quit and restart Shotwell, then try publishing again."); +internal const string USER_AGENT = "Java/1.6.0_16"; +internal const int EXPIRED_SESSION_STATUS_CODE = 400; + +internal class Album { + public string name; + public string id; + + public Album(string name, string id) { + this.name = name; + this.id = id; + } +} + +internal enum Resolution { + STANDARD, + HIGH; + + public string get_name() { + switch (this) { + case STANDARD: + return _("Standard (720 pixels)"); + + case HIGH: + return _("Large (2048 pixels)"); + + default: + error("Unknown resolution %s", this.to_string()); + } + } + + public int get_pixels() { + switch (this) { + case STANDARD: + return 720; + + case HIGH: + return 2048; + + default: + error("Unknown resolution %s", this.to_string()); + } + } +} + +internal class PublishingParameters { + public const int UNKNOWN_ALBUM = -1; + + public bool strip_metadata; + public Album[] albums; + public int target_album; + public string? new_album_name; // the name of the new album being created during this + // publishing interaction or null if publishing to an existing + // album + + public string? privacy_object; // a serialized JSON object encoding the privacy settings of the + // published resources + public Resolution resolution; + + public PublishingParameters() { + this.albums = null; + this.privacy_object = null; + this.target_album = UNKNOWN_ALBUM; + this.new_album_name = null; + this.strip_metadata = false; + this.resolution = Resolution.HIGH; + } + + public void add_album(string name, string id) { + if (albums == null) + albums = new Album[0]; + + Album new_album = new Album(name, id); + albums += new_album; + } + + public void set_target_album_by_name(string? name) { + if (name == null) { + target_album = UNKNOWN_ALBUM; + return; + } + + for (int i = 0; i < albums.length; i++) { + + if (albums[i].name == name) { + target_album = i; + return; + } + } + + target_album = UNKNOWN_ALBUM; + } + + public string? get_target_album_name() { + if (albums == null || target_album == UNKNOWN_ALBUM) + return null; + + return albums[target_album].name; + } + + public string? get_target_album_id() { + if (albums == null || target_album == UNKNOWN_ALBUM) + return null; + + return albums[target_album].id; + } +} + +public class FacebookPublisher : Spit.Publishing.Publisher, GLib.Object { + private PublishingParameters publishing_params; + private weak Spit.Publishing.PluginHost host = null; + private WebAuthenticationPane web_auth_pane = null; + private Spit.Publishing.ProgressCallback progress_reporter = null; + private weak Spit.Publishing.Service service = null; + private bool running = false; + private GraphSession graph_session; + private PublishingOptionsPane? publishing_options_pane = null; + private Uploader? uploader = null; + private string? uid = null; + private string? username = null; + + public FacebookPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("FacebookPublisher instantiated."); + + this.service = service; + this.host = host; + + this.publishing_params = new PublishingParameters(); + + this.graph_session = new GraphSession(); + graph_session.authenticated.connect(on_session_authenticated); + } + + private bool is_persistent_session_valid() { + string? token = get_persistent_access_token(); + + if (token != null) + debug("existing Facebook session found in configuration database (access_token = %s).", + token); + else + debug("no existing Facebook session available."); + + return token != null; + } + + private string? get_persistent_access_token() { + return host.get_config_string("access_token", null); + } + + private bool get_persistent_strip_metadata() { + return host.get_config_bool("strip_metadata", false); + } + + private void set_persistent_access_token(string access_token) { + host.set_config_string("access_token", access_token); + } + + private void set_persistent_strip_metadata(bool strip_metadata) { + host.set_config_bool("strip_metadata", strip_metadata); + } + + // Part of the fix for #3232. These have to be + // public so the legacy options pane may use them. + public int get_persistent_default_size() { + return host.get_config_int("default_size", 0); + } + + public void set_persistent_default_size(int size) { + host.set_config_int("default_size", size); + } + + private void invalidate_persistent_session() { + debug("invalidating saved Facebook session."); + + set_persistent_access_token(""); + } + + private void do_show_service_welcome_pane() { + debug("ACTION: showing service welcome pane."); + + host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_login_clicked); + host.set_service_locked(false); + } + + private void do_test_connection_to_endpoint() { + debug("ACTION: testing connection to Facebook endpoint."); + host.set_service_locked(true); + + host.install_static_message_pane(_("Testing connection to Facebook...")); + + GraphMessage endpoint_test_message = graph_session.new_endpoint_test(); + endpoint_test_message.completed.connect(on_endpoint_test_completed); + endpoint_test_message.failed.connect(on_endpoint_test_error); + + graph_session.send_message(endpoint_test_message); + } + + private void do_fetch_user_info() { + debug("ACTION: fetching user information."); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + GraphMessage user_info_message = graph_session.new_query("/me"); + + user_info_message.completed.connect(on_fetch_user_info_completed); + user_info_message.failed.connect(on_fetch_user_info_error); + + graph_session.send_message(user_info_message); + } + + private void do_fetch_album_descriptions() { + debug("ACTION: fetching album list."); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + GraphMessage albums_message = graph_session.new_query("/%s/albums".printf(uid)); + + albums_message.completed.connect(on_fetch_albums_completed); + albums_message.failed.connect(on_fetch_albums_error); + + graph_session.send_message(albums_message); + } + + private void do_extract_user_info_from_json(string json) { + debug("ACTION: extracting user info from JSON response."); + + try { + Json.Parser parser = new Json.Parser(); + parser.load_from_data(json); + + Json.Node root = parser.get_root(); + Json.Object response_object = root.get_object(); + uid = response_object.get_string_member("id"); + username = response_object.get_string_member("name"); + } catch (Error error) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message)); + return; + } + + on_user_info_extracted(); + } + + private void do_extract_albums_from_json(string json) { + debug("ACTION: extracting album info from JSON response."); + + try { + Json.Parser parser = new Json.Parser(); + parser.load_from_data(json); + + Json.Node root = parser.get_root(); + Json.Object response_object = root.get_object(); + Json.Array album_list = response_object.get_array_member("data"); + + publishing_params.albums = new Album[0]; + + for (int i = 0; i < album_list.get_length(); i++) { + Json.Object current_album = album_list.get_object_element(i); + string album_id = current_album.get_string_member("id"); + string album_name = current_album.get_string_member("name"); + + // Note that we are completely ignoring the "can_upload" flag in the list of albums + // that we pulled from facebook eariler -- effectively, we add every album to the + // publishing_params album list regardless of the value of its can_upload flag. In + // the future we may wish to make adding to the publishing_params album list + // conditional on the value of the can_upload flag being true + publishing_params.add_album(album_name, album_id); + } + } catch (Error error) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message)); + return; + } + + on_albums_extracted(); + } + + private void do_create_new_album() { + debug("ACTION: creating a new album named \"%s\".\n", publishing_params.new_album_name); + + host.set_service_locked(true); + host.install_static_message_pane(_("Creating album...")); + + GraphMessage create_album_message = graph_session.new_create_album( + publishing_params.new_album_name, publishing_params.privacy_object); + + create_album_message.completed.connect(on_create_album_completed); + create_album_message.failed.connect(on_create_album_error); + + graph_session.send_message(create_album_message); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: showing publishing options pane."); + + host.set_service_locked(false); + Gtk.Builder builder = new Gtk.Builder(); + + try { + // the trailing get_path() is required, since add_from_file can't cope + // with File objects directly and expects a pathname instead. + builder.add_from_file( + host.get_module_file().get_parent(). + get_child("facebook_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + host.post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Facebook can't continue."))); + return; + } + + publishing_options_pane = new PublishingOptionsPane(username, publishing_params.albums, + host.get_publishable_media_type(), this, builder, get_persistent_strip_metadata()); + publishing_options_pane.logout.connect(on_publishing_options_pane_logout); + publishing_options_pane.publish.connect(on_publishing_options_pane_publish); + host.install_dialog_pane(publishing_options_pane, + Spit.Publishing.PluginHost.ButtonMode.CANCEL); + } + + private void do_logout() { + debug("ACTION: clearing persistent session information and restaring interaction."); + + invalidate_persistent_session(); + + running = false; + start(); + } + + private void do_add_new_local_album_from_json(string album_name, string json) { + try { + Json.Parser parser = new Json.Parser(); + parser.load_from_data(json); + + Json.Node root = parser.get_root(); + Json.Object response_object = root.get_object(); + string album_id = response_object.get_string_member("id"); + + publishing_params.add_album(album_name, album_id); + } catch (Error error) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message)); + return; + } + + publishing_params.set_target_album_by_name(album_name); + do_upload(); + } + + private void do_hosted_web_authentication() { + debug("ACTION: doing hosted web authentication."); + + host.set_service_locked(false); + + web_auth_pane = new WebAuthenticationPane(); + web_auth_pane.login_succeeded.connect(on_web_auth_pane_login_succeeded); + web_auth_pane.login_failed.connect(on_web_auth_pane_login_failed); + + host.install_dialog_pane(web_auth_pane, + Spit.Publishing.PluginHost.ButtonMode.CANCEL); + + } + + private void do_authenticate_session(string good_login_uri) { + debug("ACTION: preparing to extract session information encoded in uri = '%s'", + good_login_uri); + + // the raw uri is percent-encoded, so decode it + string decoded_uri = Soup.URI.decode(good_login_uri); + + // locate the access token within the URI + string? access_token = null; + int index = decoded_uri.index_of("#access_token="); + if (index >= 0) + access_token = decoded_uri[index:decoded_uri.length]; + if (access_token == null) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "Server redirect URL contained no access token")); + return; + } + + // remove any trailing parameters from the session description string + string? trailing_params = null; + index = access_token.index_of_char('&'); + if (index >= 0) + trailing_params = access_token[index:access_token.length]; + if (trailing_params != null) + access_token = access_token.replace(trailing_params, ""); + + // remove the key from the session description string + access_token = access_token.replace("#access_token=", ""); + + // we've got an access token! + graph_session.authenticated.connect(on_session_authenticated); + graph_session.authenticate(access_token); + } + + private void do_save_session_information() { + debug("ACTION: saving session information to configuration system."); + + set_persistent_access_token(graph_session.get_access_token()); + } + + private void do_upload() { + debug("ACTION: uploading photos to album '%s'", + publishing_params.target_album == PublishingParameters.UNKNOWN_ALBUM ? "(none)" : + publishing_params.get_target_album_name()); + + host.set_service_locked(true); + + progress_reporter = host.serialize_publishables(publishing_params.resolution.get_pixels(), + publishing_params.strip_metadata); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + uploader = new Uploader(graph_session, publishing_params, publishables); + + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + + uploader.upload(on_upload_status_updated); + } + + private void do_show_success_pane() { + debug("ACTION: showing success pane."); + + host.set_service_locked(false); + host.install_success_pane(); + } + + private void on_generic_error(Spit.Publishing.PublishingError error) { + if (error is Spit.Publishing.PublishingError.EXPIRED_SESSION) + do_logout(); + else + host.post_error(error); + } + + private void on_login_clicked() { + if (!is_running()) + return; + + debug("EVENT: user clicked 'Login' on welcome pane."); + + do_test_connection_to_endpoint(); + } + + private void on_endpoint_test_completed(GraphMessage message) { + message.completed.disconnect(on_endpoint_test_completed); + message.failed.disconnect(on_endpoint_test_error); + + if (!is_running()) + return; + + debug("EVENT: endpoint test transaction detected that the Facebook endpoint is alive."); + + do_hosted_web_authentication(); + } + + private void on_endpoint_test_error(GraphMessage message, + Spit.Publishing.PublishingError error) { + message.completed.disconnect(on_endpoint_test_completed); + message.failed.disconnect(on_endpoint_test_error); + + if (!is_running()) + return; + + debug("EVENT: endpoint test transaction failed to detect a connection to the Facebook " + + "endpoint"); + + on_generic_error(error); + } + + private void on_web_auth_pane_login_succeeded(string success_url) { + if (!is_running()) + return; + + debug("EVENT: hosted web login succeeded."); + + do_authenticate_session(success_url); + } + + + + private void on_web_auth_pane_login_failed() { + if (!is_running()) + return; + + debug("EVENT: hosted web login failed."); + + // In this case, "failed" doesn't mean that the user didn't enter the right username and + // password -- Facebook handles that case inside the Facebook Connect web control. Instead, + // it means that no session was initiated in response to our login request. The only + // way this happens is if the user clicks the "Cancel" button that appears inside + // the web control. In this case, the correct behavior is to return the user to the + // service welcome pane so that they can start the web interaction again. + do_show_service_welcome_pane(); + } + + private void on_session_authenticated() { + graph_session.authenticated.disconnect(on_session_authenticated); + + if (!is_running()) + return; + + assert(graph_session.is_authenticated()); + debug("EVENT: an authenticated session has become available."); + + do_save_session_information(); + do_fetch_user_info(); + } + + private void on_fetch_user_info_completed(GraphMessage message) { + message.completed.disconnect(on_fetch_user_info_completed); + message.failed.disconnect(on_fetch_user_info_error); + + if (!is_running()) + return; + + debug("EVENT: user info fetch completed; response = '%s'.", message.get_response_body()); + + do_extract_user_info_from_json(message.get_response_body()); + } + + private void on_fetch_user_info_error(GraphMessage message, + Spit.Publishing.PublishingError error) { + message.completed.disconnect(on_fetch_user_info_completed); + message.failed.disconnect(on_fetch_user_info_error); + + if (!is_running()) + return; + + debug("EVENT: fetching user info generated and error."); + + on_generic_error(error); + } + + private void on_user_info_extracted() { + if (!is_running()) + return; + + debug("EVENT: user info extracted from JSON response: uid = %s; name = %s.", uid, username); + + do_fetch_album_descriptions(); + } + + private void on_fetch_albums_completed(GraphMessage message) { + message.completed.disconnect(on_fetch_albums_completed); + message.failed.disconnect(on_fetch_albums_error); + + if (!is_running()) + return; + + debug("EVENT: album descriptions fetch transaction completed; response = '%s'.", + message.get_response_body()); + + do_extract_albums_from_json(message.get_response_body()); + } + + private void on_fetch_albums_error(GraphMessage message, + Spit.Publishing.PublishingError err) { + message.completed.disconnect(on_fetch_albums_completed); + message.failed.disconnect(on_fetch_albums_error); + + if (!is_running()) + return; + + debug("EVENT: album description fetch attempt generated an error."); + + on_generic_error(err); + } + + private void on_albums_extracted() { + if (!is_running()) + return; + + debug("EVENT: successfully extracted %d albums from JSON response", + publishing_params.albums.length); + + do_show_publishing_options_pane(); + } + + private void on_publishing_options_pane_logout() { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + + if (!is_running()) + return; + + debug("EVENT: user clicked 'Logout' in publishing options pane."); + + do_logout(); + } + + private void on_publishing_options_pane_publish(string? target_album, string privacy_setting, + Resolution resolution, bool strip_metadata) { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + + if (!is_running()) + return; + + debug("EVENT: user clicked 'Publish' in publishing options pane."); + + publishing_params.strip_metadata = strip_metadata; + set_persistent_strip_metadata(strip_metadata); + publishing_params.resolution = resolution; + set_persistent_default_size(resolution); + publishing_params.privacy_object = privacy_setting; + + if (target_album != null) { + // we are publishing at least one photo so we need the name of an album to which + // we'll upload the photo(s) + publishing_params.set_target_album_by_name(target_album); + if (publishing_params.target_album != PublishingParameters.UNKNOWN_ALBUM) { + do_upload(); + } else { + publishing_params.new_album_name = target_album; + do_create_new_album(); + } + } else { + // we're publishing only videos and we don't need an album name + do_upload(); + } + } + + private void on_create_album_completed(GraphMessage message) { + message.completed.disconnect(on_create_album_completed); + message.failed.disconnect(on_create_album_error); + + assert(publishing_params.new_album_name != null); + + if (!is_running()) + return; + + debug("EVENT: created new album resource on remote host; response body = %s.\n", + message.get_response_body()); + + do_add_new_local_album_from_json(publishing_params.new_album_name, + message.get_response_body()); + } + + private void on_create_album_error(GraphMessage message, Spit.Publishing.PublishingError err) { + message.completed.disconnect(on_create_album_completed); + message.failed.disconnect(on_create_album_error); + + if (!is_running()) + return; + + debug("EVENT: attempt to create new album generated an error."); + + on_generic_error(err); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void on_upload_complete(Uploader uploader, int num_published) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + if (!is_running()) + return; + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + do_show_success_pane(); + } + + private void on_upload_error(Uploader uploader, Spit.Publishing.PublishingError err) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + host.post_error(err); + } + + public Spit.Publishing.Service get_service() { + return service; + } + + public string get_service_name() { + return SERVICE_NAME; + } + + public string get_user_visible_name() { + return USER_VISIBLE_NAME; + } + + public void start() { + if (is_running()) + return; + + debug("FacebookPublisher: starting interaction."); + + running = true; + + // reset all publishing parameters to their default values -- in case this start is + // actually a restart + publishing_params = new PublishingParameters(); + + // Do we have saved user credentials? If so, go ahead and authenticate the session + // with the saved credentials and proceed with the publishing interaction. Otherwise, show + // the Welcome pane + if (is_persistent_session_valid()) { + graph_session.authenticate(get_persistent_access_token()); + } else { + if (WebAuthenticationPane.is_cache_dirty()) { + host.set_service_locked(false); + host.install_static_message_pane(RESTART_ERROR_MESSAGE, + Spit.Publishing.PluginHost.ButtonMode.CANCEL); + } else { + do_show_service_welcome_pane(); + } + } + } + + public void stop() { + debug("FacebookPublisher: stop( ) invoked."); + + if (graph_session != null) + graph_session.stop_transactions(); + + host = null; + running = false; + } + + public bool is_running() { + return running; + } +} + +internal class WebAuthenticationPane : Spit.Publishing.DialogPane, Object { + private WebKit.WebView webview = null; + private Gtk.Box pane_widget = null; + private Gtk.ScrolledWindow webview_frame = null; + private static bool cache_dirty = false; + + public signal void login_succeeded(string success_url); + public signal void login_failed(); + + public WebAuthenticationPane() { + pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + + webview_frame = new Gtk.ScrolledWindow(null, null); + webview_frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN); + webview_frame.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC); + + webview = new WebKit.WebView(); + webview.get_settings().enable_plugins = false; + webview.get_settings().enable_default_context_menu = false; + + webview.load_finished.connect(on_page_load); + webview.load_started.connect(on_load_started); + + webview_frame.add(webview); + pane_widget.pack_start(webview_frame, true, true, 0); + } + + private class LocaleLookup { + public string prefix; + public string translation; + public string? exception_code; + public string? exception_translation; + public string? exception_code_2; + public string? exception_translation_2; + + public LocaleLookup(string prefix, string translation, string? exception_code = null, + string? exception_translation = null, string? exception_code_2 = null, + string? exception_translation_2 = null) { + this.prefix = prefix; + this.translation = translation; + this.exception_code = exception_code; + this.exception_translation = exception_translation; + this.exception_code_2 = exception_code_2; + this.exception_translation_2 = exception_translation_2; + } + + } + + private LocaleLookup[] locale_lookup_table = { + new LocaleLookup( "es", "es-la", "ES", "es-es" ), + new LocaleLookup( "en", "en-gb", "US", "en-us" ), + new LocaleLookup( "fr", "fr-fr", "CA", "fr-ca" ), + new LocaleLookup( "pt", "pt-br", "PT", "pt-pt" ), + new LocaleLookup( "zh", "zh-cn", "HK", "zh-hk", "TW", "zh-tw" ), + new LocaleLookup( "af", "af-za" ), + new LocaleLookup( "ar", "ar-ar" ), + new LocaleLookup( "nb", "nb-no" ), + new LocaleLookup( "no", "nb-no" ), + new LocaleLookup( "id", "id-id" ), + new LocaleLookup( "ms", "ms-my" ), + new LocaleLookup( "ca", "ca-es" ), + new LocaleLookup( "cs", "cs-cz" ), + new LocaleLookup( "cy", "cy-gb" ), + new LocaleLookup( "da", "da-dk" ), + new LocaleLookup( "de", "de-de" ), + new LocaleLookup( "tl", "tl-ph" ), + new LocaleLookup( "ko", "ko-kr" ), + new LocaleLookup( "hr", "hr-hr" ), + new LocaleLookup( "it", "it-it" ), + new LocaleLookup( "lt", "lt-lt" ), + new LocaleLookup( "hu", "hu-hu" ), + new LocaleLookup( "nl", "nl-nl" ), + new LocaleLookup( "ja", "ja-jp" ), + new LocaleLookup( "nb", "nb-no" ), + new LocaleLookup( "no", "nb-no" ), + new LocaleLookup( "pl", "pl-pl" ), + new LocaleLookup( "ro", "ro-ro" ), + new LocaleLookup( "ru", "ru-ru" ), + new LocaleLookup( "sk", "sk-sk" ), + new LocaleLookup( "sl", "sl-si" ), + new LocaleLookup( "sv", "sv-se" ), + new LocaleLookup( "th", "th-th" ), + new LocaleLookup( "vi", "vi-vn" ), + new LocaleLookup( "tr", "tr-tr" ), + new LocaleLookup( "el", "el-gr" ), + new LocaleLookup( "bg", "bg-bg" ), + new LocaleLookup( "sr", "sr-rs" ), + new LocaleLookup( "he", "he-il" ), + new LocaleLookup( "hi", "hi-in" ), + new LocaleLookup( "bn", "bn-in" ), + new LocaleLookup( "pa", "pa-in" ), + new LocaleLookup( "ta", "ta-in" ), + new LocaleLookup( "te", "te-in" ), + new LocaleLookup( "ml", "ml-in" ) + }; + + private string get_system_locale_as_facebook_locale() { + unowned string? raw_system_locale = Intl.setlocale(LocaleCategory.ALL, ""); + if (raw_system_locale == null || raw_system_locale == "") + return "www"; + + string system_locale = raw_system_locale.split(".")[0]; + + foreach (LocaleLookup locale_lookup in locale_lookup_table) { + if (!system_locale.has_prefix(locale_lookup.prefix)) + continue; + + if (locale_lookup.exception_code != null) { + assert(locale_lookup.exception_translation != null); + + if (system_locale.contains(locale_lookup.exception_code)) + return locale_lookup.exception_translation; + } + + if (locale_lookup.exception_code_2 != null) { + assert(locale_lookup.exception_translation_2 != null); + + if (system_locale.contains(locale_lookup.exception_code_2)) + return locale_lookup.exception_translation_2; + } + + return locale_lookup.translation; + } + + // default + return "www"; + } + + private string get_login_url() { + string facebook_locale = get_system_locale_as_facebook_locale(); + + return "https://%s.facebook.com/dialog/oauth?client_id=%s&redirect_uri=https://www.facebook.com/connect/login_success.html&scope=publish_actions,user_photos,user_videos&response_type=token".printf(facebook_locale, APPLICATION_ID); + } + + private void on_page_load(WebKit.WebFrame origin_frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + + string loaded_url = origin_frame.get_uri().dup(); + + // strip parameters from the loaded url + if (loaded_url.contains("?")) { + int index = loaded_url.index_of_char('?'); + string params = loaded_url[index:loaded_url.length]; + loaded_url = loaded_url.replace(params, ""); + } + + // were we redirected to the facebook login success page? + if (loaded_url.contains("login_success")) { + cache_dirty = true; + login_succeeded(origin_frame.get_uri()); + return; + } + + // were we redirected to the login total failure page? + if (loaded_url.contains("login_failure")) { + login_failed(); + return; + } + } + + private void on_load_started(WebKit.WebFrame frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH)); + } + + public static bool is_cache_dirty() { + return cache_dirty; + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + webview.open(get_login_url()); + } + + public void on_pane_uninstalled() { + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private Gtk.Builder builder; + private Gtk.Box pane_widget = null; + private Gtk.RadioButton use_existing_radio = null; + private Gtk.RadioButton create_new_radio = null; + private Gtk.ComboBoxText existing_albums_combo = null; + private Gtk.ComboBoxText visibility_combo = null; + private Gtk.Entry new_album_entry = null; + private Gtk.CheckButton strip_metadata_check = null; + private Gtk.Button publish_button = null; + private Gtk.Button logout_button = null; + private Gtk.Label how_to_label = null; + private Album[] albums = null; + private FacebookPublisher publisher = null; + private PrivacyDescription[] privacy_descriptions; + + private Resolution[] possible_resolutions; + private Gtk.ComboBoxText resolution_combo = null; + + private Spit.Publishing.Publisher.MediaType media_type; + + private const string HEADER_LABEL_TEXT = _("You are logged into Facebook as %s.\n\n"); + private const string PHOTOS_LABEL_TEXT = _("Where would you like to publish the selected photos?"); + private const string RESOLUTION_LABEL_TEXT = _("Upload _size:"); + private const int CONTENT_GROUP_SPACING = 32; + private const int STANDARD_ACTION_BUTTON_WIDTH = 128; + + public signal void logout(); + public signal void publish(string? target_album, string privacy_setting, + Resolution target_resolution, bool strip_metadata); + + private class PrivacyDescription { + public string description; + public string privacy_setting; + + public PrivacyDescription(string description, string privacy_setting) { + this.description = description; + this.privacy_setting = privacy_setting; + } + } + + public PublishingOptionsPane(string username, Album[] albums, + Spit.Publishing.Publisher.MediaType media_type, FacebookPublisher publisher, + Gtk.Builder builder, bool strip_metadata) { + + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + this.albums = albums; + this.privacy_descriptions = create_privacy_descriptions(); + + this.possible_resolutions = create_resolution_list(); + this.publisher = publisher; + + // we'll need to know if the user is importing video or not when sorting out visibility. + this.media_type = media_type; + + pane_widget = (Gtk.Box) builder.get_object("facebook_pane_box"); + pane_widget.set_border_width(16); + + use_existing_radio = (Gtk.RadioButton) this.builder.get_object("use_existing_radio"); + create_new_radio = (Gtk.RadioButton) this.builder.get_object("create_new_radio"); + existing_albums_combo = (Gtk.ComboBoxText) this.builder.get_object("existing_albums_combo"); + visibility_combo = (Gtk.ComboBoxText) this.builder.get_object("visibility_combo"); + publish_button = (Gtk.Button) this.builder.get_object("publish_button"); + logout_button = (Gtk.Button) this.builder.get_object("logout_button"); + new_album_entry = (Gtk.Entry) this.builder.get_object("new_album_entry"); + resolution_combo = (Gtk.ComboBoxText) this.builder.get_object("resolution_combo"); + how_to_label = (Gtk.Label) this.builder.get_object("how_to_label"); + strip_metadata_check = (Gtk.CheckButton) this.builder.get_object("strip_metadata_check"); + + create_new_radio.clicked.connect(on_create_new_toggled); + use_existing_radio.clicked.connect(on_use_existing_toggled); + + string label_text = HEADER_LABEL_TEXT.printf(username); + if ((media_type & Spit.Publishing.Publisher.MediaType.PHOTO) != 0) + label_text += PHOTOS_LABEL_TEXT; + how_to_label.set_label(label_text); + strip_metadata_check.set_active(strip_metadata); + + setup_visibility_combo(); + visibility_combo.set_active(0); + + publish_button.clicked.connect(on_publish_button_clicked); + logout_button.clicked.connect(on_logout_button_clicked); + + setup_resolution_combo(); + resolution_combo.set_active(publisher.get_persistent_default_size()); + resolution_combo.changed.connect(on_size_changed); + + // Ticket #3175, part 2: make sure this widget starts out sensitive + // if it needs to by checking whether we're starting with a video + // or a new gallery. + visibility_combo.set_sensitive( + (create_new_radio != null && create_new_radio.active) || + ((media_type & Spit.Publishing.Publisher.MediaType.VIDEO) != 0)); + + // if publishing only videos, disable all photo-specific controls + if (media_type == Spit.Publishing.Publisher.MediaType.VIDEO) { + strip_metadata_check.set_active(false); + strip_metadata_check.set_sensitive(false); + resolution_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + create_new_radio.set_sensitive(false); + existing_albums_combo.set_sensitive(false); + new_album_entry.set_sensitive(false); + } + } + + private bool publishing_photos() { + return (media_type & Spit.Publishing.Publisher.MediaType.PHOTO) != 0; + } + + private void setup_visibility_combo() { + foreach (PrivacyDescription p in privacy_descriptions) + visibility_combo.append_text(p.description); + } + + private void setup_resolution_combo() { + foreach (Resolution res in possible_resolutions) + resolution_combo.append_text(res.get_name()); + } + + private void on_use_existing_toggled() { + if (use_existing_radio.active) { + existing_albums_combo.set_sensitive(true); + new_album_entry.set_sensitive(false); + + // Ticket #3175 - if we're not adding a new gallery + // or a video, then we shouldn't be allowed tof + // choose visibility, since it has no effect. + visibility_combo.set_sensitive((media_type & Spit.Publishing.Publisher.MediaType.VIDEO) != 0); + + existing_albums_combo.grab_focus(); + } + } + + private void on_create_new_toggled() { + if (create_new_radio.active) { + existing_albums_combo.set_sensitive(false); + new_album_entry.set_sensitive(true); + new_album_entry.grab_focus(); + + // Ticket #3175 - if we're creating a new gallery, make sure this is + // active, since it may have possibly been set inactive. + visibility_combo.set_sensitive(true); + } + } + + private void on_size_changed() { + publisher.set_persistent_default_size(resolution_combo.get_active()); + } + + private void on_logout_button_clicked() { + logout(); + } + + private void on_publish_button_clicked() { + string album_name; + string privacy_setting = privacy_descriptions[visibility_combo.get_active()].privacy_setting; + + Resolution resolution_setting; + + if (publishing_photos()) { + resolution_setting = possible_resolutions[resolution_combo.get_active()]; + if (use_existing_radio.active) { + album_name = existing_albums_combo.get_active_text(); + } else { + album_name = new_album_entry.get_text(); + } + } else { + resolution_setting = Resolution.STANDARD; + album_name = null; + } + + publish(album_name, privacy_setting, resolution_setting, strip_metadata_check.get_active()); + } + + private PrivacyDescription[] create_privacy_descriptions() { + PrivacyDescription[] result = new PrivacyDescription[0]; + + result += new PrivacyDescription(_("Just me"), "{ 'value' : 'SELF' }"); + result += new PrivacyDescription(_("Friends"), "{ 'value' : 'ALL_FRIENDS' }"); + result += new PrivacyDescription(_("Everyone"), "{ 'value' : 'EVERYONE' }"); + + return result; + } + + private Resolution[] create_resolution_list() { + Resolution[] result = new Resolution[0]; + + result += Resolution.STANDARD; + result += Resolution.HIGH; + + return result; + } + + public void installed() { + if (publishing_photos()) { + if (albums.length == 0) { + create_new_radio.set_active(true); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + existing_albums_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + } else { + int default_album_seq_num = -1; + int ticker = 0; + foreach (Album album in albums) { + existing_albums_combo.append_text(album.name); + if (album.name == DEFAULT_ALBUM_NAME) + default_album_seq_num = ticker; + ticker++; + } + if (default_album_seq_num != -1) { + existing_albums_combo.set_active(default_album_seq_num); + use_existing_radio.set_active(true); + new_album_entry.set_sensitive(false); + } + else { + create_new_radio.set_active(true); + existing_albums_combo.set_active(0); + existing_albums_combo.set_sensitive(false); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + } + } + } + + publish_button.grab_focus(); + } + + private void notify_logout() { + logout(); + } + + private void notify_publish(string? target_album, string privacy_setting, Resolution target_resolution) { + publish(target_album, privacy_setting, target_resolution, strip_metadata_check.get_active()); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + logout.connect(notify_logout); + publish.connect(notify_publish); + + installed(); + } + + public void on_pane_uninstalled() { + logout.disconnect(notify_logout); + publish.disconnect(notify_publish); + } +} + +internal enum Endpoint { + DEFAULT, + VIDEO, + TEST_CONNECTION; + + public string to_uri() { + switch (this) { + case DEFAULT: + return "https://graph.facebook.com/"; + + case VIDEO: + return "https://graph-video.facebook.com/"; + + case TEST_CONNECTION: + return "https://www.facebook.com/"; + + default: + assert_not_reached(); + } + } +} + +internal abstract class GraphMessage { + public signal void completed(); + public signal void failed(Spit.Publishing.PublishingError err); + public signal void data_transmitted(int bytes_sent_so_far, int total_bytes); + + public abstract string get_uri(); + public abstract string get_response_body(); +} + +internal class GraphSession { + private abstract class GraphMessageImpl : GraphMessage { + public Publishing.RESTSupport.HttpMethod method; + public string uri; + public string access_token; + public Soup.Message soup_message; + public weak GraphSession host_session; + public int bytes_so_far; + + public GraphMessageImpl(GraphSession host_session, Publishing.RESTSupport.HttpMethod method, + string relative_uri, string access_token, Endpoint endpoint = Endpoint.DEFAULT) { + this.method = method; + this.access_token = access_token; + this.host_session = host_session; + this.bytes_so_far = 0; + + string endpoint_uri = endpoint.to_uri(); + try { + Regex starting_slashes = new Regex("^/+"); + this.uri = endpoint_uri + starting_slashes.replace(relative_uri, -1, 0, ""); + } catch (RegexError err) { + assert_not_reached(); + } + } + + public virtual bool prepare_for_transmission() { + return true; + } + + public override string get_uri() { + return uri; + } + + public override string get_response_body() { + return (string) soup_message.response_body.data; + } + + public void on_wrote_body_data(Soup.Buffer chunk) { + bytes_so_far += (int) chunk.length; + + data_transmitted(bytes_so_far, (int) soup_message.request_body.length); + } + } + + private class GraphQueryMessage : GraphMessageImpl { + public GraphQueryMessage(GraphSession host_session, string relative_uri, + string access_token) { + base(host_session, Publishing.RESTSupport.HttpMethod.GET, relative_uri, access_token); + + Soup.URI destination_uri = new Soup.URI(uri + "?access_token=" + access_token); + soup_message = new Soup.Message.from_uri(method.to_string(), destination_uri); + soup_message.wrote_body_data.connect(on_wrote_body_data); + } + } + + private class GraphEndpointProbeMessage : GraphMessageImpl { + public GraphEndpointProbeMessage(GraphSession host_session) { + base(host_session, Publishing.RESTSupport.HttpMethod.GET, "/", "", + Endpoint.TEST_CONNECTION); + + soup_message = new Soup.Message.from_uri(method.to_string(), new Soup.URI(uri)); + soup_message.wrote_body_data.connect(on_wrote_body_data); + } + } + + private class GraphUploadMessage : GraphMessageImpl { + private MappedFile mapped_file = null; + private Spit.Publishing.Publishable publishable; + + public GraphUploadMessage(GraphSession host_session, string access_token, + string relative_uri, Spit.Publishing.Publishable publishable, + bool suppress_titling, string? resource_privacy = null) { + base(host_session, Publishing.RESTSupport.HttpMethod.POST, relative_uri, access_token, + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + Endpoint.VIDEO : Endpoint.DEFAULT); + + // Video uploads require a privacy string at the per-resource level. Since they aren't + // placed in albums, they can't inherit their privacy settings from their containing + // album like photos do + assert(publishable.get_media_type() != Spit.Publishing.Publisher.MediaType.VIDEO || + resource_privacy != null); + + this.publishable = publishable; + + // attempt to map the binary payload from disk into memory + try { + this.mapped_file = new MappedFile(publishable.get_serialized_file().get_path(), + false); + } catch (FileError e) { + return; + } + + this.soup_message = new Soup.Message.from_uri(method.to_string(), new Soup.URI(uri)); + soup_message.wrote_body_data.connect(on_wrote_body_data); + + unowned uint8[] payload = (uint8[]) mapped_file.get_contents(); + payload.length = (int) mapped_file.get_length(); + + Soup.Buffer image_data = new Soup.Buffer(Soup.MemoryUse.TEMPORARY, payload); + + Soup.Multipart mp_envelope = new Soup.Multipart("multipart/form-data"); + + mp_envelope.append_form_string("access_token", access_token); + + if (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) + mp_envelope.append_form_string("privacy", resource_privacy); + + string publishable_title = publishable.get_publishing_name(); + if (!suppress_titling && publishable_title != "") + mp_envelope.append_form_string("name", publishable_title); + + string source_file_mime_type = + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + "video" : "image/jpeg"; + mp_envelope.append_form_file("source", publishable.get_serialized_file().get_basename(), + source_file_mime_type, image_data); + + mp_envelope.to_message(soup_message.request_headers, soup_message.request_body); + } + + public override bool prepare_for_transmission() { + if (mapped_file == null) { + failed(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + "File %s is unavailable.".printf(publishable.get_serialized_file().get_path()))); + return false; + } else { + return true; + } + } + } + + private class GraphCreateAlbumMessage : GraphMessageImpl { + public GraphCreateAlbumMessage(GraphSession host_session, string access_token, + string album_name, string album_privacy) { + base(host_session, Publishing.RESTSupport.HttpMethod.POST, "/me/albums", access_token); + + assert(album_privacy != null && album_privacy != ""); + + this.soup_message = new Soup.Message.from_uri(method.to_string(), new Soup.URI(uri)); + + Soup.Multipart mp_envelope = new Soup.Multipart("multipart/form-data"); + + mp_envelope.append_form_string("access_token", access_token); + mp_envelope.append_form_string("name", album_name); + mp_envelope.append_form_string("privacy", album_privacy); + + mp_envelope.to_message(soup_message.request_headers, soup_message.request_body); + } + } + + public signal void authenticated(); + + private Soup.Session soup_session; + private string? access_token; + private GraphMessage? current_message; + + public GraphSession() { + this.soup_session = new Soup.SessionAsync(); + this.soup_session.request_unqueued.connect(on_request_unqueued); + this.soup_session.timeout = 15; + this.access_token = null; + this.current_message = null; + } + + ~GraphSession() { + soup_session.request_unqueued.disconnect(on_request_unqueued); + } + + private void manage_message(GraphMessage msg) { + assert(current_message == null); + + current_message = msg; + } + + private void unmanage_message(GraphMessage msg) { + assert(current_message != null); + + current_message = null; + } + + private void on_request_unqueued(Soup.Message msg) { + assert(current_message != null); + GraphMessageImpl real_message = (GraphMessageImpl) current_message; + assert(real_message.soup_message == msg); + + // these error types are always recoverable given the unique behavior of the Facebook + // endpoint, so try again + if (msg.status_code == Soup.KnownStatusCode.IO_ERROR || + msg.status_code == Soup.KnownStatusCode.MALFORMED || + msg.status_code == Soup.KnownStatusCode.TRY_AGAIN) { + real_message.bytes_so_far = 0; + soup_session.queue_message(msg, null); + return; + } + + unmanage_message(real_message); + msg.wrote_body_data.disconnect(real_message.on_wrote_body_data); + + Spit.Publishing.PublishingError? error = null; + switch (msg.status_code) { + case Soup.KnownStatusCode.OK: + case Soup.KnownStatusCode.CREATED: // HTTP code 201 (CREATED) signals that a new + // resource was created in response to a PUT + // or POST + break; + + case EXPIRED_SESSION_STATUS_CODE: + error = new Spit.Publishing.PublishingError.EXPIRED_SESSION( + "OAuth Access Token has Expired. Logout user.", real_message.get_uri(), msg.status_code); + break; + + case Soup.KnownStatusCode.CANT_RESOLVE: + case Soup.KnownStatusCode.CANT_RESOLVE_PROXY: + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Unable to resolve %s (error code %u)", real_message.get_uri(), msg.status_code); + break; + + case Soup.KnownStatusCode.CANT_CONNECT: + case Soup.KnownStatusCode.CANT_CONNECT_PROXY: + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Unable to connect to %s (error code %u)", real_message.get_uri(), msg.status_code); + break; + + default: + // status codes below 100 are used by Soup, 100 and above are defined HTTP + // codes + if (msg.status_code >= 100) { + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Service %s returned HTTP status code %u %s", real_message.get_uri(), + msg.status_code, msg.reason_phrase); + } else { + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Failure communicating with %s (error code %u)", real_message.get_uri(), + msg.status_code); + } + break; + } + + // All valid communication with Facebook involves body data in the response + if (error == null) + if (msg.response_body.data == null || msg.response_body.data.length == 0) + error = new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "No response data from %s", real_message.get_uri()); + + if (error == null) + real_message.completed(); + else + real_message.failed(error); + } + + public void authenticate(string access_token) { + this.access_token = access_token; + authenticated(); + } + + public bool is_authenticated() { + return access_token != null; + } + + public string get_access_token() { + assert(is_authenticated()); + return access_token; + } + + public GraphMessage new_endpoint_test() { + return new GraphEndpointProbeMessage(this); + } + + public GraphMessage new_query(string resource_path) { + return new GraphQueryMessage(this, resource_path, access_token); + } + + public GraphMessage new_upload(string resource_path, Spit.Publishing.Publishable publishable, + bool suppress_titling, string? resource_privacy = null) { + return new GraphUploadMessage(this, access_token, resource_path, publishable, + suppress_titling, resource_privacy); + } + + public GraphMessage new_create_album(string album_name, string privacy) { + return new GraphSession.GraphCreateAlbumMessage(this, access_token, album_name, privacy); + } + + public void send_message(GraphMessage message) { + GraphMessageImpl real_message = (GraphMessageImpl) message; + + debug("making HTTP request to URI: " + real_message.soup_message.uri.to_string(false)); + + if (real_message.prepare_for_transmission()) { + manage_message(message); + soup_session.queue_message(real_message.soup_message, null); + } + } + + public void stop_transactions() { + soup_session.abort(); + } +} + +internal class Uploader { + private int current_file; + private Spit.Publishing.Publishable[] publishables; + private GraphSession session; + private PublishingParameters publishing_params; + private unowned Spit.Publishing.ProgressCallback? status_updated = null; + + public signal void upload_complete(int num_photos_published); + public signal void upload_error(Spit.Publishing.PublishingError err); + + public Uploader(GraphSession session, PublishingParameters publishing_params, + Spit.Publishing.Publishable[] publishables) { + this.current_file = 0; + this.publishables = publishables; + this.session = session; + this.publishing_params = publishing_params; + } + + private void send_current_file() { + Spit.Publishing.Publishable publishable = publishables[current_file]; + GLib.File? file = publishable.get_serialized_file(); + + // if the current publishable hasn't been serialized, then skip it + if (file == null) { + current_file++; + return; + } + + string resource_uri = + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.PHOTO) ? + "/%s/photos".printf(publishing_params.get_target_album_id()) : "/me/videos"; + string? resource_privacy = + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + publishing_params.privacy_object : null; + GraphMessage upload_message = session.new_upload(resource_uri, publishable, + publishing_params.strip_metadata, resource_privacy); + + upload_message.data_transmitted.connect(on_chunk_transmitted); + upload_message.completed.connect(on_message_completed); + upload_message.failed.connect(on_message_failed); + + session.send_message(upload_message); + } + + private void send_files() { + current_file = 0; + send_current_file(); + } + + private void on_chunk_transmitted(int bytes_written_so_far, int total_bytes) { + double file_span = 1.0 / publishables.length; + double this_file_fraction_complete = ((double) bytes_written_so_far) / total_bytes; + double fraction_complete = (current_file * file_span) + (this_file_fraction_complete * + file_span); + + if (status_updated != null) + status_updated(current_file + 1, fraction_complete); + } + + private void on_message_completed(GraphMessage message) { + message.data_transmitted.disconnect(on_chunk_transmitted); + message.completed.disconnect(on_message_completed); + message.failed.disconnect(on_message_failed); + + current_file++; + if (current_file < publishables.length) { + send_current_file(); + } else { + upload_complete(current_file); + } + } + + private void on_message_failed(GraphMessage message, Spit.Publishing.PublishingError error) { + message.data_transmitted.disconnect(on_chunk_transmitted); + message.completed.disconnect(on_message_completed); + message.failed.disconnect(on_message_failed); + + upload_error(error); + } + + public void upload(Spit.Publishing.ProgressCallback? status_updated = null) { + this.status_updated = status_updated; + + if (publishables.length > 0) + send_files(); + } +} + +} + diff --git a/plugins/shotwell-publishing/FlickrPublishing.vala b/plugins/shotwell-publishing/FlickrPublishing.vala new file mode 100644 index 0000000..dc483cc --- /dev/null +++ b/plugins/shotwell-publishing/FlickrPublishing.vala @@ -0,0 +1,1371 @@ +/* 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. + */ + +extern string hmac_sha1(string key, string message); + +public class FlickrService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "flickr.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public FlickrService(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.flickr"; + } + + public unowned string get_pluggable_name() { + return "Flickr"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public void activation(bool enabled) { + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Flickr.FlickrPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } +} + +namespace Publishing.Flickr { + +internal const string SERVICE_NAME = "Flickr"; +internal const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into Flickr.\n\nClick Login to log into Flickr in your Web browser. You will have to authorize Shotwell Connect to link to your Flickr account."); +internal const string RESTART_ERROR_MESSAGE = + _("You have already logged in and out of Flickr during this Shotwell session.\nTo continue publishing to Flickr, quit and restart Shotwell, then try publishing again."); +internal const string ENDPOINT_URL = "https://api.flickr.com/services/rest"; +internal const string API_KEY = "60dd96d4a2ad04888b09c9e18d82c26f"; +internal const string API_SECRET = "d0960565e03547c1"; +internal const int ORIGINAL_SIZE = -1; +internal const string EXPIRED_SESSION_ERROR_CODE = "98"; +internal const string ENCODE_RFC_3986_EXTRA = "!*'();:@&=+$,/?%#[] \\"; + +internal enum UserKind { + PRO, + FREE, +} + +internal class VisibilitySpecification { + public int friends_level; + public int family_level; + public int everyone_level; + + public VisibilitySpecification(int friends_level, int family_level, int everyone_level) { + this.friends_level = friends_level; + this.family_level = family_level; + this.everyone_level = everyone_level; + } +} + +// not a struct because we want reference semantics +internal class PublishingParameters { + public UserKind user_kind; + public int quota_free_mb; + public int photo_major_axis_size; + public string username; + public VisibilitySpecification visibility_specification; + + public PublishingParameters() { + } +} + +public class FlickrPublisher : Spit.Publishing.Publisher, GLib.Object { + private Spit.Publishing.Service service; + private Spit.Publishing.PluginHost host; + private Spit.Publishing.ProgressCallback progress_reporter = null; + private bool running = false; + private bool was_started = false; + private Session session = null; + private PublishingOptionsPane publishing_options_pane = null; + + private PublishingParameters parameters = null; + + public FlickrPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("FlickrPublisher instantiated."); + this.service = service; + this.host = host; + this.session = new Session(); + this.parameters = new PublishingParameters(); + + session.authenticated.connect(on_session_authenticated); + } + + ~FlickrPublisher() { + session.authenticated.disconnect(on_session_authenticated); + } + + private void invalidate_persistent_session() { + set_persistent_access_phase_token(""); + set_persistent_access_phase_token_secret(""); + set_persistent_access_phase_username(""); + } + + private bool is_persistent_session_valid() { + return (get_persistent_access_phase_username() != null && + get_persistent_access_phase_token() != null && + get_persistent_access_phase_token_secret() != null); + } + + private string? get_persistent_access_phase_username() { + return host.get_config_string("access_phase_username", null); + } + + private void set_persistent_access_phase_username(string username) { + host.set_config_string("access_phase_username", username); + } + + private string? get_persistent_access_phase_token() { + return host.get_config_string("access_phase_token", null); + } + + private void set_persistent_access_phase_token(string token) { + host.set_config_string("access_phase_token", token); + } + + private string? get_persistent_access_phase_token_secret() { + return host.get_config_string("access_phase_token_secret", null); + } + + private void set_persistent_access_phase_token_secret(string secret) { + host.set_config_string("access_phase_token_secret", secret); + } + + private bool get_persistent_strip_metadata() { + return host.get_config_bool("strip_metadata", false); + } + + private void set_persistent_strip_metadata(bool strip_metadata) { + host.set_config_bool("strip_metadata", strip_metadata); + } + + private void on_welcome_pane_login_clicked() { + if (!running) + return; + + debug("EVENT: user clicked 'Login' button in the welcome pane"); + + do_run_authentication_request_transaction(); + } + + private void on_auth_request_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_auth_request_txn_completed); + txn.network_error.disconnect(on_auth_request_txn_error); + + if (!is_running()) + return; + + debug("EVENT: OAuth authentication request transaction completed; response = '%s'", + txn.get_response()); + + do_parse_token_info_from_auth_request(txn.get_response()); + } + + private void on_auth_request_txn_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_auth_request_txn_completed); + txn.network_error.disconnect(on_auth_request_txn_error); + + if (!is_running()) + return; + + debug("EVENT: OAuth authentication request transaction caused a network error"); + host.post_error(err); + } + + private void on_authentication_token_available(string token, string token_secret) { + debug("EVENT: OAuth authentication token (%s) and token secret (%s) available", + token, token_secret); + + session.set_request_phase_credentials(token, token_secret); + + do_launch_system_browser(token); + } + + private void on_system_browser_launched() { + if (!is_running()) + return; + + debug("EVENT: system browser launched."); + + do_show_pin_entry_pane(); + } + + private void on_pin_entry_proceed(PinEntryPane sender, string pin) { + sender.proceed.disconnect(on_pin_entry_proceed); + + if (!is_running()) + return; + + debug("EVENT: user clicked 'Continue' in PIN entry pane."); + + do_verify_pin(pin); + } + + private void on_access_token_fetch_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_access_token_fetch_txn_completed); + txn.network_error.disconnect(on_access_token_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: fetching OAuth access token over the network succeeded"); + + do_extract_access_phase_credentials_from_reponse(txn.get_response()); + } + + private void on_access_token_fetch_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_access_token_fetch_txn_completed); + txn.network_error.disconnect(on_access_token_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: fetching OAuth access token over the network caused an error."); + + host.post_error(err); + } + + private void on_session_authenticated() { + if (!is_running()) + return; + + debug("EVENT: a fully authenticated session has become available"); + + parameters.username = session.get_username(); + + set_persistent_access_phase_token(session.get_access_phase_token()); + set_persistent_access_phase_token_secret(session.get_access_phase_token_secret()); + set_persistent_access_phase_username(session.get_username()); + + do_fetch_account_info(); + } + + private void on_account_fetch_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_account_fetch_txn_completed); + txn.network_error.disconnect(on_account_fetch_txn_error); + + if (!is_running()) + return; + + debug("EVENT: account fetch transaction response received over the network"); + do_parse_account_info_from_xml(txn.get_response()); + } + + private void on_account_fetch_txn_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_account_fetch_txn_completed); + txn.network_error.disconnect(on_account_fetch_txn_error); + + if (!is_running()) + return; + + debug("EVENT: account fetch transaction caused a network error"); + host.post_error(err); + } + + private void on_account_info_available() { + if (!is_running()) + return; + + debug("EVENT: account information has become available"); + do_show_publishing_options_pane(); + } + + private void on_publishing_options_pane_publish(bool strip_metadata) { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + + if (!is_running()) + return; + + debug("EVENT: user clicked the 'Publish' button in the publishing options pane"); + do_publish(strip_metadata); + } + + private void on_publishing_options_pane_logout() { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + + if (!is_running()) + return; + + debug("EVENT: user clicked the 'Logout' button in the publishing options pane"); + + do_logout(); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, + int num_published) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + do_show_success_pane(); + } + + private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, + Spit.Publishing.PublishingError err) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + host.post_error(err); + } + + private void do_show_login_welcome_pane() { + debug("ACTION: installing login welcome pane"); + + host.set_service_locked(false); + host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_welcome_pane_login_clicked); + } + + private void do_run_authentication_request_transaction() { + debug("ACTION: running authentication request transaction"); + + host.set_service_locked(true); + host.install_static_message_pane(_("Preparing for login...")); + + AuthenticationRequestTransaction txn = new AuthenticationRequestTransaction(session); + txn.completed.connect(on_auth_request_txn_completed); + txn.network_error.connect(on_auth_request_txn_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_parse_token_info_from_auth_request(string response) { + debug("ACTION: parsing authorization request response '%s' into token and secret", response); + + string? oauth_token = null; + string? oauth_token_secret = null; + + string[] key_value_pairs = response.split("&"); + foreach (string pair in key_value_pairs) { + string[] split_pair = pair.split("="); + + if (split_pair.length != 2) + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "'%s' isn't a valid response to an OAuth authentication request")); + + if (split_pair[0] == "oauth_token") + oauth_token = split_pair[1]; + else if (split_pair[0] == "oauth_token_secret") + oauth_token_secret = split_pair[1]; + } + + if (oauth_token == null || oauth_token_secret == null) + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "'%s' isn't a valid response to an OAuth authentication request")); + + + on_authentication_token_available(oauth_token, oauth_token_secret); + } + + private void do_launch_system_browser(string token) { + string login_uri = "https://www.flickr.com/services/oauth/authorize?oauth_token=" + token + + "&perms=write"; + + debug("ACTION: launching system browser with uri = '%s'", login_uri); + + try { + Process.spawn_command_line_async("xdg-open " + login_uri); + } catch (SpawnError e) { + host.post_error(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + "couldn't launch system web browser to complete Flickr login")); + return; + } + + on_system_browser_launched(); + } + + private void do_show_pin_entry_pane() { + debug("ACTION: showing PIN entry pane"); + + Gtk.Builder builder = new Gtk.Builder(); + + try { + builder.add_from_file(host.get_module_file().get_parent().get_child("flickr_pin_entry_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + host.post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Flickr can't continue."))); + return; + } + + PinEntryPane pin_entry_pane = new PinEntryPane(builder); + pin_entry_pane.proceed.connect(on_pin_entry_proceed); + host.install_dialog_pane(pin_entry_pane); + } + + private void do_verify_pin(string pin) { + debug("ACTION: validating authorization PIN %s", pin); + + host.set_service_locked(true); + host.install_static_message_pane(_("Verifying authorization...")); + + AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction(session, pin); + txn.completed.connect(on_access_token_fetch_txn_completed); + txn.network_error.connect(on_access_token_fetch_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_extract_access_phase_credentials_from_reponse(string response) { + debug("ACTION: extracting access phase credentials from '%s'", response); + + string[] key_value_pairs = response.split("&"); + + string? token = null; + string? token_secret = null; + string? username = null; + foreach (string key_value_pair in key_value_pairs) { + string[] split_pair = key_value_pair.split("="); + + if (split_pair.length != 2) + continue; + + string key = split_pair[0]; + string value = split_pair[1]; + + if (key == "oauth_token") + token = value; + else if (key == "oauth_token_secret") + token_secret = value; + else if (key == "username") + username = value; + } + + debug("access phase credentials: { token = '%s'; token_secret = '%s'; username = '%s' }", + token, token_secret, username); + + if (token == null || token_secret == null || username == null) + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("expected " + + "access phase credentials to contain token, token secret, and username but at " + + "least one of these is absent")); + + session.set_access_phase_credentials(token, token_secret, username); + } + + private void do_fetch_account_info() { + debug("ACTION: running network transaction to fetch account information"); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + AccountInfoFetchTransaction txn = new AccountInfoFetchTransaction(session); + txn.completed.connect(on_account_fetch_txn_completed); + txn.network_error.connect(on_account_fetch_txn_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_parse_account_info_from_xml(string xml) { + debug("ACTION: parsing account information from xml = '%s'", xml); + try { + Publishing.RESTSupport.XmlDocument response_doc = Transaction.parse_flickr_response(xml); + Xml.Node* root_node = response_doc.get_root_node(); + + Xml.Node* user_node = response_doc.get_named_child(root_node, "user"); + + string is_pro_str = response_doc.get_property_value(user_node, "ispro"); + + Xml.Node* bandwidth_node = response_doc.get_named_child(user_node, "bandwidth"); + + string remaining_kb_str = response_doc.get_property_value(bandwidth_node, "remainingkb"); + + UserKind user_kind; + if (is_pro_str == "0") + user_kind = UserKind.FREE; + else if (is_pro_str == "1") + user_kind = UserKind.PRO; + else + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "Unable to determine if user has free or pro account"); + + int quota_mb_left = int.parse(remaining_kb_str) / 1024; + + parameters.quota_free_mb = quota_mb_left; + parameters.user_kind = user_kind; + + } catch (Spit.Publishing.PublishingError err) { + // expired session errors are recoverable, so handle it and then short-circuit return. + // don't call post_error( ) on the plug-in host because that's intended for + // unrecoverable errors and will halt publishing + if (err is Spit.Publishing.PublishingError.EXPIRED_SESSION) { + do_logout(); + return; + } + + host.post_error(err); + return; + } + + on_account_info_available(); + } + + private void do_logout() { + debug("ACTION: logging user out, deauthenticating session, and erasing stored credentials"); + + session.deauthenticate(); + invalidate_persistent_session(); + + running = false; + + attempt_start(); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: displaying publishing options pane"); + + host.set_service_locked(false); + + Gtk.Builder builder = new Gtk.Builder(); + + try { + // the trailing get_path() is required, since add_from_file can't cope + // with File objects directly and expects a pathname instead. + builder.add_from_file( + host.get_module_file().get_parent(). + get_child("flickr_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + host.post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Flickr can't continue."))); + return; + } + + publishing_options_pane = new PublishingOptionsPane(this, parameters, + host.get_publishable_media_type(), builder, get_persistent_strip_metadata()); + publishing_options_pane.publish.connect(on_publishing_options_pane_publish); + publishing_options_pane.logout.connect(on_publishing_options_pane_logout); + host.install_dialog_pane(publishing_options_pane); + } + + public static int flickr_date_time_compare_func(Spit.Publishing.Publishable a, + Spit.Publishing.Publishable b) { + return a.get_exposure_date_time().compare(b.get_exposure_date_time()); + } + + private void do_publish(bool strip_metadata) { + set_persistent_strip_metadata(strip_metadata); + debug("ACTION: uploading media items to remote server."); + + host.set_service_locked(true); + progress_reporter = host.serialize_publishables(parameters.photo_major_axis_size, strip_metadata); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + // Sort publishables in reverse-chronological order. + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + Gee.ArrayList<Spit.Publishing.Publishable> sorted_list = + new Gee.ArrayList<Spit.Publishing.Publishable>(); + foreach (Spit.Publishing.Publishable p in publishables) { + sorted_list.add(p); + } + sorted_list.sort(flickr_date_time_compare_func); + + Uploader uploader = new Uploader(session, sorted_list.to_array(), parameters, strip_metadata); + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + uploader.upload(on_upload_status_updated); + } + + private void do_show_success_pane() { + debug("ACTION: showing success pane."); + + host.set_service_locked(false); + host.install_success_pane(); + } + + internal int get_persistent_visibility() { + return host.get_config_int("visibility", 0); + } + + internal void set_persistent_visibility(int vis) { + host.set_config_int("visibility", vis); + } + + internal int get_persistent_default_size() { + return host.get_config_int("default_size", 1); + } + + internal void set_persistent_default_size(int size) { + host.set_config_int("default_size", size); + } + + public Spit.Publishing.Service get_service() { + return service; + } + + public bool is_running() { + return running; + } + + // this helper doesn't check state, merely validates and authenticates the session and installs + // the proper panes + private void attempt_start() { + running = true; + was_started = true; + + if (is_persistent_session_valid()) { + debug("attempt start: a persistent session is available; using it"); + + session.authenticate_from_persistent_credentials(get_persistent_access_phase_token(), + get_persistent_access_phase_token_secret(), get_persistent_access_phase_username()); + } else { + debug("attempt start: no persistent session available; showing login welcome pane"); + + do_show_login_welcome_pane(); + } + } + + public void start() { + if (is_running()) + return; + + if (was_started) + error("FlickrPublisher: start( ): can't start; this publisher is not restartable."); + + debug("FlickrPublisher: starting interaction."); + + attempt_start(); + } + + public void stop() { + debug("FlickrPublisher: stop( ) invoked."); + + if (session != null) + session.stop_transactions(); + + running = false; + } +} + +internal class PinEntryPane : Spit.Publishing.DialogPane, GLib.Object { + private Gtk.Box pane_widget = null; + private Gtk.Button continue_button = null; + private Gtk.Entry pin_entry = null; + private Gtk.Label pin_entry_caption = null; + private Gtk.Label explanatory_text = null; + private Gtk.Builder builder = null; + + public signal void proceed(PinEntryPane sender, string authorization_pin); + + public PinEntryPane(Gtk.Builder builder) { + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + explanatory_text = builder.get_object("explanatory_text") as Gtk.Label; + pin_entry_caption = builder.get_object("pin_entry_caption") as Gtk.Label; + pin_entry = builder.get_object("pin_entry") as Gtk.Entry; + continue_button = builder.get_object("continue_button") as Gtk.Button; + + pane_widget = builder.get_object("pane_widget") as Gtk.Box; + + pane_widget.show_all(); + + on_pin_entry_contents_changed(); + } + + private void on_continue_clicked() { + proceed(this, pin_entry.get_text()); + } + + private void on_pin_entry_contents_changed() { + continue_button.set_sensitive(pin_entry.text_length > 0); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + continue_button.clicked.connect(on_continue_clicked); + pin_entry.changed.connect(on_pin_entry_contents_changed); + } + + public void on_pane_uninstalled() { + continue_button.clicked.disconnect(on_continue_clicked); + pin_entry.changed.disconnect(on_pin_entry_contents_changed); + } +} + +internal class Transaction : Publishing.RESTSupport.Transaction { + public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = + Publishing.RESTSupport.HttpMethod.POST) { + base(session, method); + + add_argument("oauth_nonce", session.get_oauth_nonce()); + add_argument("oauth_signature_method", "HMAC-SHA1"); + add_argument("oauth_version", "1.0"); + add_argument("oauth_callback", "oob"); + add_argument("oauth_timestamp", session.get_oauth_timestamp()); + add_argument("oauth_consumer_key", API_KEY); + } + + public Transaction.with_uri(Session session, string uri, + Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) { + base.with_endpoint_url(session, uri, method); + + add_argument("oauth_nonce", session.get_oauth_nonce()); + add_argument("oauth_signature_method", "HMAC-SHA1"); + add_argument("oauth_version", "1.0"); + add_argument("oauth_callback", "oob"); + add_argument("oauth_timestamp", session.get_oauth_timestamp()); + add_argument("oauth_consumer_key", API_KEY); + } + + public override void execute() throws Spit.Publishing.PublishingError { + ((Session) get_parent_session()).sign_transaction(this); + + base.execute(); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* root = doc.get_root_node(); + string? status = root->get_prop("stat"); + + // treat malformed root as an error condition + if (status == null) + return "No status property in root node"; + + if (status == "ok") + return null; + + Xml.Node* errcode; + try { + errcode = doc.get_named_child(root, "err"); + } catch (Spit.Publishing.PublishingError err) { + return "No error code specified"; + } + + // this error format is mandatory, because the parse_flickr_response( ) expects error + // messages to be in this format. If you want to change the error reporting format, you + // need to modify parse_flickr_response( ) to parse the new format too. + return "%s (error code %s)".printf(errcode->get_prop("msg"), errcode->get_prop("code")); + } + + // Flickr responses have a special flavor of expired session reporting. Expired sessions + // are reported as just another service error, so they have to be converted from + // service errors. Always use this wrapper function to parse Flickr response XML instead + // of the generic Publishing.RESTSupport.XmlDocument.parse_string( ) from the Yorba + // REST support classes. While using Publishing.RESTSupport.XmlDocument.parse_string( ) won't + // cause anything really bad to happen, it will make expired session errors unrecoverable, + // which is annoying for users. + public static Publishing.RESTSupport.XmlDocument parse_flickr_response(string xml) + throws Spit.Publishing.PublishingError { + Publishing.RESTSupport.XmlDocument? result = null; + + try { + result = Publishing.RESTSupport.XmlDocument.parse_string(xml, validate_xml); + } catch (Spit.Publishing.PublishingError e) { + if (e.message.contains("(error code %s)".printf(EXPIRED_SESSION_ERROR_CODE))) { + throw new Spit.Publishing.PublishingError.EXPIRED_SESSION(e.message); + } else { + throw e; + } + } + + return result; + } +} + +internal class AuthenticationRequestTransaction : Transaction { + public AuthenticationRequestTransaction(Session session) { + base.with_uri(session, "https://www.flickr.com/services/oauth/request_token", + Publishing.RESTSupport.HttpMethod.GET); + } +} + +internal class AccessTokenFetchTransaction : Transaction { + public AccessTokenFetchTransaction(Session session, string user_verifier) { + base.with_uri(session, "https://www.flickr.com/services/oauth/access_token", + Publishing.RESTSupport.HttpMethod.GET); + add_argument("oauth_verifier", user_verifier); + add_argument("oauth_token", session.get_request_phase_token()); + } +} + +internal class AccountInfoFetchTransaction : Transaction { + public AccountInfoFetchTransaction(Session session) { + base(session, Publishing.RESTSupport.HttpMethod.GET); + add_argument("method", "flickr.people.getUploadStatus"); + add_argument("oauth_token", session.get_access_phase_token()); + } +} + +private class UploadTransaction : Publishing.RESTSupport.UploadTransaction { + private PublishingParameters parameters; + private Session session; + private Publishing.RESTSupport.Argument[] auth_header_fields; + + public UploadTransaction(Session session, PublishingParameters parameters, + Spit.Publishing.Publishable publishable) { + base.with_endpoint_url(session, publishable, "https://api.flickr.com/services/upload"); + + this.parameters = parameters; + this.session = session; + this.auth_header_fields = new Publishing.RESTSupport.Argument[0]; + + add_authorization_header_field("oauth_nonce", session.get_oauth_nonce()); + add_authorization_header_field("oauth_signature_method", "HMAC-SHA1"); + add_authorization_header_field("oauth_version", "1.0"); + add_authorization_header_field("oauth_callback", "oob"); + add_authorization_header_field("oauth_timestamp", session.get_oauth_timestamp()); + add_authorization_header_field("oauth_consumer_key", API_KEY); + add_authorization_header_field("oauth_token", session.get_access_phase_token()); + + add_argument("is_public", ("%d".printf(parameters.visibility_specification.everyone_level))); + add_argument("is_friend", ("%d".printf(parameters.visibility_specification.friends_level))); + add_argument("is_family", ("%d".printf(parameters.visibility_specification.family_level))); + + GLib.HashTable<string, string> disposition_table = + new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); + string? filename = publishable.get_publishing_name(); + if (filename == null || filename == "") + filename = publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME); + + /// TODO: This may need to be revisited to send the title separately; please see + /// http://www.flickr.com/services/api/upload.api.html for more details. + disposition_table.insert("filename", Soup.URI.encode( + publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME), null)); + + disposition_table.insert("name", "photo"); + + set_binary_disposition_table(disposition_table); + } + + public void add_authorization_header_field(string key, string value) { + auth_header_fields += new Publishing.RESTSupport.Argument(key, value); + } + + public Publishing.RESTSupport.Argument[] get_authorization_header_fields() { + return auth_header_fields; + } + + public string get_authorization_header_string() { + string result = "OAuth "; + + for (int i = 0; i < auth_header_fields.length; i++) { + result += auth_header_fields[i].key; + result += "="; + result += ("\"" + auth_header_fields[i].value + "\""); + + if (i < auth_header_fields.length - 1) + result += ", "; + } + + return result; + } + + public override void execute() throws Spit.Publishing.PublishingError { + session.sign_transaction(this); + + string authorization_header = get_authorization_header_string(); + + debug("executing upload transaction: authorization header string = '%s'", + authorization_header); + add_header("Authorization", authorization_header); + + base.execute(); + } +} + +internal class Session : Publishing.RESTSupport.Session { + private string? request_phase_token = null; + private string? request_phase_token_secret = null; + private string? access_phase_token = null; + private string? access_phase_token_secret = null; + private string? username = null; + + public Session() { + base(ENDPOINT_URL); + } + + public override bool is_authenticated() { + return (access_phase_token != null && access_phase_token_secret != null && + username != null); + } + + public void authenticate_from_persistent_credentials(string token, string secret, + string username) { + this.access_phase_token = token; + this.access_phase_token_secret = secret; + this.username = username; + + authenticated(); + } + + public void deauthenticate() { + access_phase_token = null; + access_phase_token_secret = null; + username = null; + } + + public void sign_transaction(Publishing.RESTSupport.Transaction txn) { + string http_method = txn.get_method().to_string(); + + debug("signing transaction with parameters:"); + debug("HTTP method = " + http_method); + + Publishing.RESTSupport.Argument[] base_string_arguments = txn.get_arguments(); + + UploadTransaction? upload_txn = txn as UploadTransaction; + if (upload_txn != null) { + debug("this transaction is an UploadTransaction; including Authorization header " + + "fields in signature base string"); + + Publishing.RESTSupport.Argument[] auth_header_args = + upload_txn.get_authorization_header_fields(); + + foreach (Publishing.RESTSupport.Argument arg in auth_header_args) + base_string_arguments += arg; + } + + Publishing.RESTSupport.Argument[] sorted_args = + Publishing.RESTSupport.Argument.sort(base_string_arguments); + + string arguments_string = ""; + for (int i = 0; i < sorted_args.length; i++) { + arguments_string += (sorted_args[i].key + "=" + sorted_args[i].value); + if (i < sorted_args.length - 1) + arguments_string += "&"; + } + + string? signing_key = null; + if (access_phase_token_secret != null) { + debug("access phase token secret available; using it as signing key"); + + signing_key = API_SECRET + "&" + access_phase_token_secret; + } else if (request_phase_token_secret != null) { + debug("request phase token secret available; using it as signing key"); + + signing_key = API_SECRET + "&" + request_phase_token_secret; + } else { + debug("neither access phase nor request phase token secrets available; using API " + + "key as signing key"); + + signing_key = API_SECRET + "&"; + } + + string signature_base_string = http_method + "&" + Soup.URI.encode( + txn.get_endpoint_url(), ENCODE_RFC_3986_EXTRA) + "&" + + Soup.URI.encode(arguments_string, ENCODE_RFC_3986_EXTRA); + + debug("signature base string = '%s'", signature_base_string); + + debug("signing key = '%s'", signing_key); + + // compute the signature + string signature = hmac_sha1(signing_key, signature_base_string); + signature = Soup.URI.encode(signature, ENCODE_RFC_3986_EXTRA); + + debug("signature = '%s'", signature); + + if (upload_txn != null) + upload_txn.add_authorization_header_field("oauth_signature", signature); + else + txn.add_argument("oauth_signature", signature); + } + + public void set_request_phase_credentials(string token, string secret) { + this.request_phase_token = token; + this.request_phase_token_secret = secret; + } + + public void set_access_phase_credentials(string token, string secret, string username) { + this.access_phase_token = token; + this.access_phase_token_secret = secret; + this.username = username; + + authenticated(); + } + + public string get_oauth_nonce() { + TimeVal currtime = TimeVal(); + currtime.get_current_time(); + + return Checksum.compute_for_string(ChecksumType.MD5, currtime.tv_sec.to_string() + + currtime.tv_usec.to_string()); + } + + public string get_oauth_timestamp() { + return GLib.get_real_time().to_string().substring(0, 10); + } + + public string get_request_phase_token() { + assert(request_phase_token != null); + return request_phase_token; + } + + public string get_access_phase_token() { + assert(access_phase_token != null); + return access_phase_token; + } + + public string get_access_phase_token_secret() { + assert(access_phase_token_secret != null); + return access_phase_token_secret; + } + + public string get_username() { + assert(is_authenticated()); + return username; + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private class SizeEntry { + public string title; + public int size; + + public SizeEntry(string creator_title, int creator_size) { + title = creator_title; + size = creator_size; + } + } + + private class VisibilityEntry { + public VisibilitySpecification specification; + public string title; + + public VisibilityEntry(string creator_title, VisibilitySpecification creator_specification) { + specification = creator_specification; + title = creator_title; + } + } + + private Gtk.Builder builder; + private Gtk.Box pane_widget = null; + private Gtk.Label visibility_label = null; + private Gtk.Label upload_info_label = null; + private Gtk.Label size_label = null; + private Gtk.Button logout_button = null; + private Gtk.Button publish_button = null; + private Gtk.ComboBoxText visibility_combo = null; + private Gtk.ComboBoxText size_combo = null; + private Gtk.CheckButton strip_metadata_check = null; + private VisibilityEntry[] visibilities = null; + private SizeEntry[] sizes = null; + private PublishingParameters parameters = null; + private FlickrPublisher publisher = null; + private Spit.Publishing.Publisher.MediaType media_type; + + public signal void publish(bool strip_metadata); + public signal void logout(); + + public PublishingOptionsPane(FlickrPublisher publisher, PublishingParameters parameters, + Spit.Publishing.Publisher.MediaType media_type, Gtk.Builder builder, bool strip_metadata) { + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + // pull in the necessary widgets from the glade file + pane_widget = (Gtk.Box) this.builder.get_object("flickr_pane"); + visibility_label = (Gtk.Label) this.builder.get_object("visibility_label"); + upload_info_label = (Gtk.Label) this.builder.get_object("upload_info_label"); + logout_button = (Gtk.Button) this.builder.get_object("logout_button"); + publish_button = (Gtk.Button) this.builder.get_object("publish_button"); + visibility_combo = (Gtk.ComboBoxText) this.builder.get_object("visibility_combo"); + size_combo = (Gtk.ComboBoxText) this.builder.get_object("size_combo"); + size_label = (Gtk.Label) this.builder.get_object("size_label"); + strip_metadata_check = (Gtk.CheckButton) this.builder.get_object("strip_metadata_check"); + + this.parameters = parameters; + this.publisher = publisher; + this.media_type = media_type; + + visibilities = create_visibilities(); + sizes = create_sizes(); + + string upload_label_text = _("You are logged into Flickr as %s.\n\n").printf(parameters.username); + if (parameters.user_kind == UserKind.FREE) { + upload_label_text += _("Your free Flickr account limits how much data you can upload per month.\nThis month, you have %d megabytes remaining in your upload quota.").printf(parameters.quota_free_mb); + } else { + upload_label_text += _("Your Flickr Pro account entitles you to unlimited uploads."); + } + + upload_info_label.set_label(upload_label_text); + + string visibility_label_text = _("Photos _visible to:"); + if ((media_type == Spit.Publishing.Publisher.MediaType.VIDEO)) { + visibility_label_text = _("Videos _visible to:"); + } else if ((media_type == (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO))) { + visibility_label_text = _("Photos and videos _visible to:"); + } + + visibility_label.set_label(visibility_label_text); + + populate_visibility_combo(); + visibility_combo.changed.connect(on_visibility_changed); + + if ((media_type != Spit.Publishing.Publisher.MediaType.VIDEO)) { + populate_size_combo(); + size_combo.changed.connect(on_size_changed); + } else { + // publishing -only- video - don't let the user manipulate the photo size choices. + size_combo.set_sensitive(false); + size_label.set_sensitive(false); + } + + strip_metadata_check.set_active(strip_metadata); + + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } + + private void on_logout_clicked() { + logout(); + } + + private void on_publish_clicked() { + parameters.visibility_specification = + visibilities[visibility_combo.get_active()].specification; + + if ((media_type & Spit.Publishing.Publisher.MediaType.PHOTO) != 0) + parameters.photo_major_axis_size = sizes[size_combo.get_active()].size; + + publish(strip_metadata_check.get_active()); + } + + private VisibilityEntry[] create_visibilities() { + VisibilityEntry[] result = new VisibilityEntry[0]; + + result += new VisibilityEntry(_("Everyone"), new VisibilitySpecification(1, 1, 1)); + result += new VisibilityEntry(_("Friends & family only"), new VisibilitySpecification(1, 1, 0)); + result += new VisibilityEntry(_("Family only"), new VisibilitySpecification(0, 1, 0)); + result += new VisibilityEntry(_("Friends only"), new VisibilitySpecification(1, 0, 0)); + result += new VisibilityEntry(_("Just me"), new VisibilitySpecification(0, 0, 0)); + + return result; + } + + private void populate_visibility_combo() { + if (visibilities == null) + visibilities = create_visibilities(); + + foreach (VisibilityEntry v in visibilities) + visibility_combo.append_text(v.title); + + visibility_combo.set_active(publisher.get_persistent_visibility()); + } + + private SizeEntry[] create_sizes() { + SizeEntry[] result = new SizeEntry[0]; + + result += new SizeEntry(_("500 x 375 pixels"), 500); + result += new SizeEntry(_("1024 x 768 pixels"), 1024); + result += new SizeEntry(_("2048 x 1536 pixels"), 2048); + result += new SizeEntry(_("4096 x 3072 pixels"), 4096); + result += new SizeEntry(_("Original size"), ORIGINAL_SIZE); + + return result; + } + + private void populate_size_combo() { + if (sizes == null) + sizes = create_sizes(); + + foreach (SizeEntry e in sizes) + size_combo.append_text(e.title); + + size_combo.set_active(publisher.get_persistent_default_size()); + } + + private void on_size_changed() { + publisher.set_persistent_default_size(size_combo.get_active()); + } + + private void on_visibility_changed() { + publisher.set_persistent_visibility(visibility_combo.get_active()); + } + + protected void notify_publish() { + publish(strip_metadata_check.get_active()); + } + + protected void notify_logout() { + logout(); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + publish.connect(notify_publish); + logout.connect(notify_logout); + } + + public void on_pane_uninstalled() { + publish.disconnect(notify_publish); + logout.disconnect(notify_logout); + } +} + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + private bool strip_metadata; + + public Uploader(Session session, Spit.Publishing.Publishable[] publishables, + PublishingParameters parameters, bool strip_metadata) { + base(session, publishables); + + this.parameters = parameters; + this.strip_metadata = strip_metadata; + } + + private void preprocess_publishable(Spit.Publishing.Publishable publishable) { + if (publishable.get_media_type() != Spit.Publishing.Publisher.MediaType.PHOTO) + return; + + GExiv2.Metadata publishable_metadata = new GExiv2.Metadata(); + try { + publishable_metadata.open_path(publishable.get_serialized_file().get_path()); + } catch (GLib.Error err) { + warning("couldn't read metadata from file '%s' for upload preprocessing.", + publishable.get_serialized_file().get_path()); + } + + // Flickr internationalization issues only affect IPTC tags; XMP, being an XML + // grammar and using standard XML internationalization mechanisms, doesn't need any i18n + // massaging before upload, so if the publishable doesn't have any IPTC metadata, then + // just do a short-circuit return + if (!publishable_metadata.has_iptc()) + return; + + if (publishable_metadata.has_tag("Iptc.Application2.Caption")) + publishable_metadata.set_tag_string("Iptc.Application2.Caption", + Publishing.RESTSupport.asciify_string(publishable_metadata.get_tag_string( + "Iptc.Application2.Caption"))); + + if (publishable_metadata.has_tag("Iptc.Application2.Headline")) + publishable_metadata.set_tag_string("Iptc.Application2.Headline", + Publishing.RESTSupport.asciify_string(publishable_metadata.get_tag_string( + "Iptc.Application2.Headline"))); + + if (publishable_metadata.has_tag("Iptc.Application2.Keywords")) { + Gee.Set<string> keyword_set = new Gee.HashSet<string>(); + string[] iptc_keywords = publishable_metadata.get_tag_multiple("Iptc.Application2.Keywords"); + if (iptc_keywords != null) + foreach (string keyword in iptc_keywords) + keyword_set.add(keyword); + + string[] xmp_keywords = publishable_metadata.get_tag_multiple("Xmp.dc.subject"); + if (xmp_keywords != null) + foreach (string keyword in xmp_keywords) + keyword_set.add(keyword); + + string[] all_keywords = keyword_set.to_array(); + // append a null pointer to the end of all_keywords -- this is a necessary workaround + // for http://trac.yorba.org/ticket/3264. See also http://trac.yorba.org/ticket/3257, + // which describes the user-visible behavior seen in the Flickr Connector as a result + // of the former bug. + all_keywords += null; + + string[] no_keywords = new string[1]; + // append a null pointer to the end of no_keywords -- this is a necessary workaround + // for http://trac.yorba.org/ticket/3264. See also http://trac.yorba.org/ticket/3257, + // which describes the user-visible behavior seen in the Flickr Connector as a result + // of the former bug. + no_keywords[0] = null; + + publishable_metadata.set_tag_multiple("Xmp.dc.subject", all_keywords); + publishable_metadata.set_tag_multiple("Iptc.Application2.Keywords", no_keywords); + + try { + publishable_metadata.save_file(publishable.get_serialized_file().get_path()); + } catch (GLib.Error err) { + warning("couldn't write metadata to file '%s' for upload preprocessing.", + publishable.get_serialized_file().get_path()); + } + } + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + preprocess_publishable(get_current_publishable()); + return new UploadTransaction((Session) get_session(), parameters, + get_current_publishable()); + } +} + +} + diff --git a/plugins/shotwell-publishing/Makefile b/plugins/shotwell-publishing/Makefile new file mode 100644 index 0000000..639fa88 --- /dev/null +++ b/plugins/shotwell-publishing/Makefile @@ -0,0 +1,39 @@ + +PLUGIN := shotwell-publishing + +PLUGIN_PKGS := \ + gtk+-3.0 \ + libsoup-2.4 \ + libxml-2.0 \ + webkitgtk-3.0 \ + gexiv2 \ + rest-0.7 \ + gee-0.8 \ + json-glib-1.0 + +SRC_FILES := \ + shotwell-publishing.vala \ + FacebookPublishing.vala \ + PicasaPublishing.vala \ + FlickrPublishing.vala \ + YouTubePublishing.vala \ + PiwigoPublishing.vala \ + ../../src/util/string.vala \ + ../common/RESTSupport.vala + +RC_FILES := \ + facebook.png \ + facebook_publishing_options_pane.glade \ + flickr.png \ + flickr_publishing_options_pane.glade \ + flickr_pin_entry_pane.glade \ + picasa.png \ + picasa_publishing_options_pane.glade \ + piwigo.png \ + piwigo_authentication_pane.glade \ + piwigo_publishing_options_pane.glade \ + youtube.png \ + youtube_publishing_options_pane.glade + +include ../Makefile.plugin.mk + diff --git a/plugins/shotwell-publishing/PicasaPublishing.vala b/plugins/shotwell-publishing/PicasaPublishing.vala new file mode 100644 index 0000000..c360e59 --- /dev/null +++ b/plugins/shotwell-publishing/PicasaPublishing.vala @@ -0,0 +1,940 @@ +/* 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 PicasaService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "picasa.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public PicasaService(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.picasa"; + } + + public unowned string get_pluggable_name() { + return "Picasa Web Albums"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Picasa.PicasaPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } + + public void activation(bool enabled) { + } +} + +namespace Publishing.Picasa { + +internal const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into Picasa Web Albums.\n\nClick Login to log into Picasa Web Albums in your Web browser. You will have to authorize Shotwell Connect to link to your Picasa Web Albums account."); +internal const string DEFAULT_ALBUM_NAME = _("Shotwell Connect"); + +public class PicasaPublisher : Publishing.RESTSupport.GooglePublisher { + private bool running; + private Spit.Publishing.ProgressCallback progress_reporter; + private PublishingParameters publishing_parameters; + private string? refresh_token; + + public PicasaPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + base(service, host, "http://picasaweb.google.com/data/"); + + this.publishing_parameters = new PublishingParameters(); + load_parameters_from_configuration_system(publishing_parameters); + + Spit.Publishing.Publisher.MediaType media_type = Spit.Publishing.Publisher.MediaType.NONE; + foreach(Spit.Publishing.Publishable p in host.get_publishables()) + media_type |= p.get_media_type(); + publishing_parameters.set_media_type(media_type); + + this.refresh_token = host.get_config_string("refresh_token", null); + this.progress_reporter = null; + } + + private Album[] extract_albums_helper(Xml.Node* document_root) + throws Spit.Publishing.PublishingError { + Album[] result = new Album[0]; + + Xml.Node* doc_node_iter = null; + if (document_root->name == "feed") + doc_node_iter = document_root->children; + else if (document_root->name == "entry") + doc_node_iter = document_root; + else + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("response root node " + + "isn't a <feed> or <entry>"); + + for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) { + if (doc_node_iter->name != "entry") + continue; + + string name_val = null; + string url_val = null; + Xml.Node* album_node_iter = doc_node_iter->children; + for ( ; album_node_iter != null; album_node_iter = album_node_iter->next) { + if (album_node_iter->name == "title") { + name_val = album_node_iter->get_content(); + } else if (album_node_iter->name == "id") { + // we only want nodes in the default namespace -- the feed that we get back + // from Google also defines <entry> child nodes named <id> in the gphoto and + // media namespaces + if (album_node_iter->ns->prefix != null) + continue; + url_val = album_node_iter->get_content(); + } + } + + result += new Album(name_val, url_val); + } + + return result; + } + + private void load_parameters_from_configuration_system(PublishingParameters parameters) { + parameters.set_major_axis_size_selection_id(get_host().get_config_int("default-size", 0)); + parameters.set_strip_metadata(get_host().get_config_bool("strip-metadata", false)); + parameters.set_target_album_name(get_host().get_config_string("last-album", null)); + } + + private void save_parameters_to_configuration_system(PublishingParameters parameters) { + get_host().set_config_int("default-size", parameters.get_major_axis_size_selection_id()); + get_host().set_config_bool("strip_metadata", parameters.get_strip_metadata()); + get_host().set_config_string("last-album", parameters.get_target_album_name()); + } + + private void on_service_welcome_login() { + debug("EVENT: user clicked 'Login' in welcome pane."); + + if (!is_running()) + return; + + start_oauth_flow(refresh_token); + } + + protected override void on_login_flow_complete() { + debug("EVENT: OAuth login flow complete."); + + get_host().set_config_string("refresh_token", get_session().get_refresh_token()); + + publishing_parameters.set_user_name(get_session().get_user_name()); + + do_fetch_account_information(); + } + + private void on_initial_album_fetch_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_initial_album_fetch_complete); + txn.network_error.disconnect(on_initial_album_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: finished fetching account and album information."); + + do_parse_and_display_account_information((AlbumDirectoryTransaction) txn); + } + + private void on_initial_album_fetch_error(Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err) { + bad_txn.completed.disconnect(on_initial_album_fetch_complete); + bad_txn.network_error.disconnect(on_initial_album_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: fetching account and album information failed; response = '%s'.", + bad_txn.get_response()); + + if (bad_txn.get_status_code() == 403 || bad_txn.get_status_code() == 404) { + do_logout(); + } else { + // If we get any other kind of error, we can't recover, so just post it to the user + get_host().post_error(err); + } + } + + private void on_publishing_options_logout() { + if (!is_running()) + return; + + debug("EVENT: user clicked 'Logout' in the publishing options pane."); + + do_logout(); + } + + private void on_publishing_options_publish() { + if (!is_running()) + return; + + debug("EVENT: user clicked 'Publish' in the publishing options pane."); + + save_parameters_to_configuration_system(publishing_parameters); + + if (publishing_parameters.is_to_new_album()) { + do_create_album(); + } else { + do_upload(); + } + } + + private void on_album_creation_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_album_creation_complete); + txn.network_error.disconnect(on_album_creation_error); + + if (!is_running()) + return; + + debug("EVENT: finished creating album on remote server."); + + AlbumCreationTransaction downcast_txn = (AlbumCreationTransaction) txn; + Publishing.RESTSupport.XmlDocument response_doc; + try { + response_doc = Publishing.RESTSupport.XmlDocument.parse_string( + downcast_txn.get_response(), AlbumDirectoryTransaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + Album[] response_albums; + try { + response_albums = extract_albums_helper(response_doc.get_root_node()); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + if (response_albums.length != 1) { + get_host().post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("album " + + "creation transaction responses must contain one and only one album directory " + + "entry")); + return; + } + + publishing_parameters.set_target_album_entry_url(response_albums[0].url); + + do_upload(); + } + + private void on_album_creation_error(Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err) { + bad_txn.completed.disconnect(on_album_creation_complete); + bad_txn.network_error.disconnect(on_album_creation_error); + + if (!is_running()) + return; + + debug("EVENT: creating album on remote server failed; response = '%s'.", + bad_txn.get_response()); + + get_host().post_error(err); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, + int num_published) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + do_show_success_pane(); + } + + private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, + Spit.Publishing.PublishingError err) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + get_host().post_error(err); + } + + private void do_show_service_welcome_pane() { + debug("ACTION: showing service welcome pane."); + + get_host().install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_service_welcome_login); + } + + private void do_fetch_account_information() { + debug("ACTION: fetching account and album information."); + + get_host().install_account_fetch_wait_pane(); + get_host().set_service_locked(true); + + AlbumDirectoryTransaction directory_trans = + new AlbumDirectoryTransaction(get_session()); + directory_trans.network_error.connect(on_initial_album_fetch_error); + directory_trans.completed.connect(on_initial_album_fetch_complete); + + try { + directory_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + // don't post the error here -- some errors are recoverable so let's let the error + // handler function sort out whether the error is recoverable or not. If the error + // isn't recoverable, the error handler will post the error to the host + on_initial_album_fetch_error(directory_trans, err); + } + } + + private void do_parse_and_display_account_information(AlbumDirectoryTransaction transaction) { + debug("ACTION: parsing account and album information from server response XML"); + + Publishing.RESTSupport.XmlDocument response_doc; + try { + response_doc = Publishing.RESTSupport.XmlDocument.parse_string( + transaction.get_response(), AlbumDirectoryTransaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + try { + publishing_parameters.set_albums(extract_albums_helper(response_doc.get_root_node())); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + do_show_publishing_options_pane(); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: showing publishing options pane."); + Gtk.Builder builder = new Gtk.Builder(); + + try { + // the trailing get_path() is required, since add_from_file can't cope + // with File objects directly and expects a pathname instead. + builder.add_from_file( + get_host().get_module_file().get_parent(). + get_child("picasa_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + get_host().post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Picasa can't continue."))); + return; + } + + PublishingOptionsPane opts_pane = new PublishingOptionsPane(builder, publishing_parameters); + opts_pane.publish.connect(on_publishing_options_publish); + opts_pane.logout.connect(on_publishing_options_logout); + get_host().install_dialog_pane(opts_pane); + + get_host().set_service_locked(false); + } + + private void do_create_album() { + assert(publishing_parameters.is_to_new_album()); + + debug("ACTION: creating new album '%s' on remote server.", + publishing_parameters.get_target_album_name()); + + get_host().install_static_message_pane(_("Creating album...")); + + get_host().set_service_locked(true); + + AlbumCreationTransaction creation_trans = new AlbumCreationTransaction(get_session(), + publishing_parameters); + creation_trans.network_error.connect(on_album_creation_error); + creation_trans.completed.connect(on_album_creation_complete); + try { + creation_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + } + } + + private void do_upload() { + debug("ACTION: uploading media items to remote server."); + + get_host().set_service_locked(true); + + progress_reporter = get_host().serialize_publishables( + publishing_parameters.get_major_axis_size_pixels(), + publishing_parameters.get_strip_metadata()); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + Spit.Publishing.Publishable[] publishables = get_host().get_publishables(); + Uploader uploader = new Uploader(get_session(), publishables, publishing_parameters); + + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + + uploader.upload(on_upload_status_updated); + } + + private void do_show_success_pane() { + debug("ACTION: showing success pane."); + + get_host().set_service_locked(false); + get_host().install_success_pane(); + } + + protected override void do_logout() { + debug("ACTION: logging out user."); + + get_session().deauthenticate(); + refresh_token = null; + get_host().unset_config_key("refresh_token"); + + + do_show_service_welcome_pane(); + } + + public override bool is_running() { + return running; + } + + public override void start() { + debug("PicasaPublisher: start( ) invoked."); + + if (is_running()) + return; + + running = true; + + if (refresh_token == null) + do_show_service_welcome_pane(); + else + start_oauth_flow(refresh_token); + } + + public override void stop() { + debug("PicasaPublisher: stop( ) invoked."); + + get_session().stop_transactions(); + + running = false; + } +} + +internal class Album { + public string name; + public string url; + + public Album(string name, string url) { + this.name = name; + this.url = url; + } +} + +internal class AlbumDirectoryTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://picasaweb.google.com/data/feed/api/user/" + + "default"; + + public AlbumDirectoryTransaction(Publishing.RESTSupport.GoogleSession session) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* document_root = doc.get_root_node(); + if ((document_root->name == "feed") || (document_root->name == "entry")) + return null; + else + return "response root node isn't a <feed> or <entry>"; + } +} + +private class AlbumCreationTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://picasaweb.google.com/data/feed/api/user/" + + "default"; + private const string ALBUM_ENTRY_TEMPLATE = "<?xml version='1.0' encoding='utf-8'?><entry xmlns='http://www.w3.org/2005/Atom' xmlns:gphoto='http://schemas.google.com/photos/2007'><title type='text'>%s</title><gphoto:access>%s</gphoto:access><category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/photos/2007#album'></category></entry>"; + + public AlbumCreationTransaction(Publishing.RESTSupport.GoogleSession session, + PublishingParameters parameters) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.POST); + + string post_body = ALBUM_ENTRY_TEMPLATE.printf(Publishing.RESTSupport.decimal_entity_encode( + parameters.get_target_album_name()), parameters.is_new_album_public() ? + "public" : "private"); + + set_custom_payload(post_body, "application/atom+xml"); + } +} + +internal class UploadTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private PublishingParameters parameters; + private const string METADATA_TEMPLATE = "<?xml version=\"1.0\" ?><atom:entry xmlns:atom='http://www.w3.org/2005/Atom' xmlns:mrss='http://search.yahoo.com/mrss/'> <atom:title>%s</atom:title> %s <atom:category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/photos/2007#photo'/> %s </atom:entry>"; + private Publishing.RESTSupport.GoogleSession session; + private string mime_type; + private Spit.Publishing.Publishable publishable; + private MappedFile mapped_file; + + public UploadTransaction(Publishing.RESTSupport.GoogleSession session, + PublishingParameters parameters, Spit.Publishing.Publishable publishable) { + base(session, parameters.get_target_album_feed_url(), + Publishing.RESTSupport.HttpMethod.POST); + assert(session.is_authenticated()); + this.session = session; + this.parameters = parameters; + this.publishable = publishable; + this.mime_type = (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + "video/mpeg" : "image/jpeg"; + } + + public override void execute() throws Spit.Publishing.PublishingError { + // create the multipart request container + Soup.Multipart message_parts = new Soup.Multipart("multipart/related"); + + string summary = ""; + if (publishable.get_publishing_name() != "") { + summary = "<atom:summary>%s</atom:summary>".printf( + Publishing.RESTSupport.decimal_entity_encode(publishable.get_publishing_name())); + } + + string[] keywords = publishable.get_publishing_keywords(); + string keywords_string = ""; + if (keywords.length > 0) { + for (int i = 0; i < keywords.length; i++) { + string[] tmp; + + if (keywords[i].has_prefix("/")) + tmp = keywords[i].substring(1).split("/"); + else + tmp = keywords[i].split("/"); + + if (keywords_string.length > 0) + keywords_string = string.join(", ", keywords_string, string.joinv(", ", tmp)); + else + keywords_string = string.joinv(", ", tmp); + } + + keywords_string = Publishing.RESTSupport.decimal_entity_encode(keywords_string); + keywords_string = "<mrss:group><mrss:keywords>%s</mrss:keywords></mrss:group>".printf(keywords_string); + } + + string metadata = METADATA_TEMPLATE.printf(Publishing.RESTSupport.decimal_entity_encode( + publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME)), + summary, keywords_string); + Soup.Buffer metadata_buffer = new Soup.Buffer(Soup.MemoryUse.COPY, metadata.data); + message_parts.append_form_file("", "", "application/atom+xml", metadata_buffer); + + // attempt to map the binary image data from disk into memory + try { + mapped_file = new MappedFile(publishable.get_serialized_file().get_path(), false); + } catch (FileError e) { + string msg = "Picasa: couldn't read data from %s: %s".printf( + publishable.get_serialized_file().get_path(), e.message); + warning("%s", msg); + + throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(msg); + } + unowned uint8[] photo_data = (uint8[]) mapped_file.get_contents(); + photo_data.length = (int) mapped_file.get_length(); + + // bind the binary image data read from disk into a Soup.Buffer object so that we + // can attach it to the multipart request, then actaully append the buffer + // to the multipart request. Then, set the MIME type for this part. + Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.TEMPORARY, photo_data); + + message_parts.append_form_file("", publishable.get_serialized_file().get_path(), mime_type, + bindable_data); + // create a message that can be sent over the wire whose payload is the multipart container + // that we've been building up + Soup.Message outbound_message = + soup_form_request_new_from_multipart(get_endpoint_url(), message_parts); + outbound_message.request_headers.append("Authorization", "Bearer " + + session.get_access_token()); + set_message(outbound_message); + + // send the message and get its response + set_is_executed(true); + send(); + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private class SizeDescription { + public string name; + public int major_axis_pixels; + + public SizeDescription(string name, int major_axis_pixels) { + this.name = name; + this.major_axis_pixels = major_axis_pixels; + } + } + + private const string DEFAULT_SIZE_CONFIG_KEY = "default_size"; + private const string LAST_ALBUM_CONFIG_KEY = "last_album"; + + private Gtk.Builder builder = null; + private Gtk.Box pane_widget = null; + private Gtk.Label login_identity_label = null; + private Gtk.Label publish_to_label = null; + private Gtk.RadioButton use_existing_radio = null; + private Gtk.ComboBoxText existing_albums_combo = null; + private Gtk.RadioButton create_new_radio = null; + private Gtk.Entry new_album_entry = null; + private Gtk.CheckButton public_check = null; + private Gtk.ComboBoxText size_combo = null; + private Gtk.CheckButton strip_metadata_check = null; + private Gtk.Button publish_button = null; + private Gtk.Button logout_button = null; + private SizeDescription[] size_descriptions; + private PublishingParameters parameters; + + public signal void publish(); + public signal void logout(); + + public PublishingOptionsPane(Gtk.Builder builder, PublishingParameters parameters) { + size_descriptions = create_size_descriptions(); + + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + this.parameters = parameters; + + // pull in all widgets from builder. + pane_widget = (Gtk.Box) builder.get_object("picasa_pane_widget"); + login_identity_label = (Gtk.Label) builder.get_object("login_identity_label"); + publish_to_label = (Gtk.Label) builder.get_object("publish_to_label"); + use_existing_radio = (Gtk.RadioButton) builder.get_object("use_existing_radio"); + existing_albums_combo = (Gtk.ComboBoxText) builder.get_object("existing_albums_combo"); + create_new_radio = (Gtk.RadioButton) builder.get_object("create_new_radio"); + new_album_entry = (Gtk.Entry) builder.get_object("new_album_entry"); + public_check = (Gtk.CheckButton) builder.get_object("public_check"); + size_combo = (Gtk.ComboBoxText) builder.get_object("size_combo"); + strip_metadata_check = (Gtk.CheckButton) this.builder.get_object("strip_metadata_check"); + publish_button = (Gtk.Button) builder.get_object("publish_button"); + logout_button = (Gtk.Button) builder.get_object("logout_button"); + + // populate any widgets whose contents are programmatically-generated. + login_identity_label.set_label(_("You are logged into Picasa Web Albums as %s.").printf( + parameters.get_user_name())); + strip_metadata_check.set_active(parameters.get_strip_metadata()); + + + if((parameters.get_media_type() & Spit.Publishing.Publisher.MediaType.PHOTO) == 0) { + publish_to_label.set_label(_("Videos will appear in:")); + size_combo.set_visible(false); + size_combo.set_sensitive(false); + } + else { + publish_to_label.set_label(_("Photos will appear in:")); + foreach(SizeDescription desc in size_descriptions) { + size_combo.append_text(desc.name); + } + size_combo.set_visible(true); + size_combo.set_sensitive(true); + size_combo.set_active(parameters.get_major_axis_size_selection_id()); + } + + // connect all signals. + use_existing_radio.clicked.connect(on_use_existing_radio_clicked); + create_new_radio.clicked.connect(on_create_new_radio_clicked); + new_album_entry.changed.connect(on_new_album_entry_changed); + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } + + private void on_publish_clicked() { + // size_combo won't have been set to anything useful if this is the first time we've + // published to Picasa, and/or we've only published video before, so it may be negative, + // indicating nothing was selected. Clamp it to a valid value... + int size_combo_last_active = (size_combo.get_active() >= 0) ? size_combo.get_active() : 0; + + parameters.set_major_axis_size_selection_id(size_combo_last_active); + parameters.set_major_axis_size_pixels( + size_descriptions[size_combo_last_active].major_axis_pixels); + parameters.set_strip_metadata(strip_metadata_check.get_active()); + + Album[] albums = parameters.get_albums(); + + if (create_new_radio.get_active()) { + parameters.set_target_album_name(new_album_entry.get_text()); + parameters.set_is_to_new_album(true); + parameters.set_is_new_album_public(public_check.get_active()); + publish(); + } else { + parameters.set_target_album_name(albums[existing_albums_combo.get_active()].name); + parameters.set_is_to_new_album(false); + parameters.set_target_album_entry_url(albums[existing_albums_combo.get_active()].url); + publish(); + } + } + + private void on_use_existing_radio_clicked() { + existing_albums_combo.set_sensitive(true); + new_album_entry.set_sensitive(false); + existing_albums_combo.grab_focus(); + update_publish_button_sensitivity(); + public_check.set_sensitive(false); + } + + private void on_create_new_radio_clicked() { + new_album_entry.set_sensitive(true); + existing_albums_combo.set_sensitive(false); + new_album_entry.grab_focus(); + update_publish_button_sensitivity(); + public_check.set_sensitive(true); + } + + private void on_logout_clicked() { + logout(); + } + + private void update_publish_button_sensitivity() { + string album_name = new_album_entry.get_text(); + publish_button.set_sensitive(!(album_name.strip() == "" && + create_new_radio.get_active())); + } + + private void on_new_album_entry_changed() { + update_publish_button_sensitivity(); + } + + private SizeDescription[] create_size_descriptions() { + SizeDescription[] result = new SizeDescription[0]; + + result += new SizeDescription(_("Small (640 x 480 pixels)"), 640); + result += new SizeDescription(_("Medium (1024 x 768 pixels)"), 1024); + result += new SizeDescription(_("Recommended (1600 x 1200 pixels)"), 1600); + result += new SizeDescription(_("Google+ (2048 x 1536 pixels)"), 2048); + result += new SizeDescription(_("Original Size"), PublishingParameters.ORIGINAL_SIZE); + + return result; + } + + public void installed() { + int default_album_id = -1; + string last_album = parameters.get_target_album_name(); + + Album[] albums = parameters.get_albums(); + + for (int i = 0; i < albums.length; i++) { + existing_albums_combo.append_text(albums[i].name); + if (albums[i].name == last_album || + (albums[i].name == DEFAULT_ALBUM_NAME && default_album_id == -1)) + default_album_id = i; + } + + if (albums.length == 0) { + existing_albums_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + create_new_radio.set_active(true); + new_album_entry.grab_focus(); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + } else { + if (default_album_id >= 0) { + use_existing_radio.set_active(true); + existing_albums_combo.set_active(default_album_id); + new_album_entry.set_sensitive(false); + public_check.set_sensitive(false); + } else { + create_new_radio.set_active(true); + existing_albums_combo.set_active(0); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + new_album_entry.grab_focus(); + public_check.set_sensitive(true); + } + } + update_publish_button_sensitivity(); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + installed(); + } + + public void on_pane_uninstalled() { + } +} + +internal class PublishingParameters { + public const int ORIGINAL_SIZE = -1; + + private string? target_album_name; + private string? target_album_url; + private bool album_public; + private bool strip_metadata; + private int major_axis_size_pixels; + private int major_axis_size_selection_id; + private string user_name; + private Album[] albums; + private Spit.Publishing.Publisher.MediaType media_type; + private bool to_new_album; + + public PublishingParameters() { + this.user_name = "[unknown]"; + this.target_album_name = null; + this.major_axis_size_selection_id = 0; + this.major_axis_size_pixels = ORIGINAL_SIZE; + this.target_album_url = null; + this.album_public = false; + this.albums = null; + this.strip_metadata = false; + this.media_type = Spit.Publishing.Publisher.MediaType.PHOTO; + this.to_new_album = true; + } + + public bool is_to_new_album() { + return to_new_album; + } + + public void set_is_to_new_album(bool to_new_album) { + this.to_new_album = to_new_album; + } + + public void set_is_new_album_public(bool album_public) { + this.album_public = album_public; + } + + public bool is_new_album_public() { + return album_public; + } + + public string get_target_album_name() { + return target_album_name; + } + + public void set_target_album_name(string target_album_name) { + this.target_album_name = target_album_name; + } + + public void set_target_album_entry_url(string target_album_url) { + this.target_album_url = target_album_url; + } + + public string get_target_album_entry_url() { + return target_album_url; + } + + public string get_target_album_feed_url() { + string entry_url = get_target_album_entry_url(); + string feed_url = entry_url.replace("entry", "feed"); + + return feed_url; + } + + public string get_user_name() { + return user_name; + } + + public void set_user_name(string user_name) { + this.user_name = user_name; + } + + public Album[] get_albums() { + return albums; + } + + public void set_albums(Album[] albums) { + this.albums = albums; + } + + public void set_major_axis_size_pixels(int pixels) { + this.major_axis_size_pixels = pixels; + } + + public int get_major_axis_size_pixels() { + return major_axis_size_pixels; + } + + public void set_major_axis_size_selection_id(int selection_id) { + this.major_axis_size_selection_id = selection_id; + } + + public int get_major_axis_size_selection_id() { + return major_axis_size_selection_id; + } + + public void set_strip_metadata(bool strip_metadata) { + this.strip_metadata = strip_metadata; + } + + public bool get_strip_metadata() { + return strip_metadata; + } + + public void set_media_type(Spit.Publishing.Publisher.MediaType media_type) { + this.media_type = media_type; + } + + public Spit.Publishing.Publisher.MediaType get_media_type() { + return media_type; + } +} + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + + public Uploader(Publishing.RESTSupport.GoogleSession session, + Spit.Publishing.Publishable[] publishables, PublishingParameters parameters) { + base(session, publishables); + + this.parameters = parameters; + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + return new UploadTransaction((Publishing.RESTSupport.GoogleSession) get_session(), + parameters, get_current_publishable()); + } +} + +} + diff --git a/plugins/shotwell-publishing/PiwigoPublishing.vala b/plugins/shotwell-publishing/PiwigoPublishing.vala new file mode 100644 index 0000000..8deada4 --- /dev/null +++ b/plugins/shotwell-publishing/PiwigoPublishing.vala @@ -0,0 +1,1736 @@ +/* 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 PiwigoService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "piwigo.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public PiwigoService(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.piwigo"; + } + + public unowned string get_pluggable_name() { + return "Piwigo"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Bruno Girin"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public void activation(bool enabled) { + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Piwigo.PiwigoPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO); + } +} + +namespace Publishing.Piwigo { + +internal const string SERVICE_NAME = "Piwigo"; +internal const string PIWIGO_WS = "ws.php"; +internal const int ORIGINAL_SIZE = -1; + +internal class Category { + public int id; + public string name; + public string comment; + public string display_name; + public string uppercats; + public static const int NO_ID = -1; + + public Category(int id, string name, string uppercats, string? comment = "") { + this.id = id; + this.name = name; + this.uppercats = uppercats; + this.comment = comment; + } + + public Category.local(string name, int parent_id, string? comment = "") { + this.id = NO_ID; + this.name = name; + // for new categories abuse the uppercats value for + // the id of the new parent! + this.uppercats = parent_id.to_string(); + this.comment = comment; + } + + public bool is_local() { + return this.id == NO_ID; + } +} + +internal class PermissionLevel { + public int id; + public string name; + + public PermissionLevel(int id, string name) { + this.id = id; + this.name = name; + } +} + +internal class SizeEntry { + public int id; + public string name; + + public SizeEntry(int id, string name) { + this.id = id; + this.name = name; + } +} + +internal class PublishingParameters { + public Category category = null; + public PermissionLevel perm_level = null; + public SizeEntry photo_size = null; + public bool title_as_comment = false; + public bool no_upload_tags = false; + + public PublishingParameters() { + } +} + +public class PiwigoPublisher : Spit.Publishing.Publisher, GLib.Object { + private Spit.Publishing.Service service; + private Spit.Publishing.PluginHost host; + private bool running = false; + private bool strip_metadata = false; + private Session session; + private Category[] categories = null; + private PublishingParameters parameters = null; + private Spit.Publishing.ProgressCallback progress_reporter = null; + + public PiwigoPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("PiwigoPublisher instantiated."); + this.service = service; + this.host = host; + session = new Session(); + } + + // Publisher interface implementation + + public Spit.Publishing.Service get_service() { + return service; + } + + public Spit.Publishing.PluginHost get_host() { + return host; + } + + public bool is_running() { + return running; + } + + public void start() { + if (is_running()) + return; + + debug("PiwigoPublisher: starting interaction."); + + running = true; + + if (session.is_authenticated()) { + debug("PiwigoPublisher: session is authenticated."); + do_fetch_categories(); + } else { + debug("PiwigoPublisher: session is not authenticated."); + string? persistent_url = get_persistent_url(); + string? persistent_username = get_persistent_username(); + string? persistent_password = get_persistent_password(); + if (persistent_url != null && persistent_username != null && persistent_password != null) + do_network_login(persistent_url, persistent_username, + persistent_password, get_remember_password()); + else + do_show_authentication_pane(); + } + } + + public void stop() { + running = false; + } + + // Session and persistent data + + public string? get_persistent_url() { + return host.get_config_string("url", null); + } + + private void set_persistent_url(string url) { + host.set_config_string("url", url); + } + + public string? get_persistent_username() { + return host.get_config_string("username", null); + } + + private void set_persistent_username(string username) { + host.set_config_string("username", username); + } + + public string? get_persistent_password() { + return host.get_config_string("password", null); + } + + private void set_persistent_password(string? password) { + host.set_config_string("password", password); + } + + public bool get_remember_password() { + return host.get_config_bool("remember-password", false); + } + + private void set_remember_password(bool remember_password) { + host.set_config_bool("remember-password", remember_password); + } + + public int get_last_category() { + return host.get_config_int("last-category", -1); + } + + private void set_last_category(int last_category) { + host.set_config_int("last-category", last_category); + } + + public int get_last_permission_level() { + return host.get_config_int("last-permission-level", -1); + } + + private void set_last_permission_level(int last_permission_level) { + host.set_config_int("last-permission-level", last_permission_level); + } + + public int get_last_photo_size() { + return host.get_config_int("last-photo-size", -1); + } + + private void set_last_photo_size(int last_photo_size) { + host.set_config_int("last-photo-size", last_photo_size); + } + + private bool get_last_title_as_comment() { + return host.get_config_bool("last-title-as-comment", false); + } + + private void set_last_title_as_comment(bool title_as_comment) { + host.set_config_bool("last-title-as-comment", title_as_comment); + } + + private bool get_last_no_upload_tags() { + return host.get_config_bool("last-no-upload-tags", false); + } + + private void set_last_no_upload_tags(bool no_upload_tags) { + host.set_config_bool("last-no-upload-tags", no_upload_tags); + } + + private bool get_metadata_removal_choice() { + return host.get_config_bool("strip_metadata", false); + } + + private void set_metadata_removal_choice(bool strip_metadata) { + host.set_config_bool("strip_metadata", strip_metadata); + } + + // Actions and events implementation + + /** + * Action that shows the authentication pane. + * + * This action method shows the authentication pane. It is shown at the + * very beginning of the interaction when no persistent parameters are found + * or after a failed login attempt using persisted parameters. It can be + * given a mode flag to specify whether it should be displayed in initial + * mode or in any of the error modes that it supports. + * + * @param mode the mode for the authentication pane + */ + private void do_show_authentication_pane(AuthenticationPane.Mode mode = AuthenticationPane.Mode.INTRO) { + debug("ACTION: installing authentication pane"); + + host.set_service_locked(false); + AuthenticationPane authentication_pane = + new AuthenticationPane(this, mode); + authentication_pane.login.connect(on_authentication_pane_login_clicked); + host.install_dialog_pane(authentication_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE); + host.set_dialog_default_widget(authentication_pane.get_default_widget()); + } + + /** + * Event triggered when the login button in the authentication panel is + * clicked. + * + * This event is triggered when the login button in the authentication + * panel is clicked. It then triggers a network login interaction. + * + * @param url the URL of the Piwigo service as entered in the dialog + * @param username the name of the Piwigo user as entered in the dialog + * @param password the password of the Piwigo as entered in the dialog + */ + private void on_authentication_pane_login_clicked( + string url, string username, string password, bool remember_password + ) { + debug("EVENT: on_authentication_pane_login_clicked"); + if (!running) + return; + + do_network_login(url, username, password, remember_password); + } + + /** + * Action to perform a network login to a Piwigo service. + * + * This action performs a network login a Piwigo service specified by a + * URL and using the given user name and password as credentials. + * + * @param url the URL of the Piwigo service; this URL will be normalised + * before being used + * @param username the name of the Piwigo user used to login + * @param password the password of the Piwigo user used to login + */ + private void do_network_login(string url, string username, string password, bool remember_password) { + debug("ACTION: logging in"); + host.set_service_locked(true); + host.install_login_wait_pane(); + + set_remember_password(remember_password); + if (remember_password) + set_persistent_password(password); + else + set_persistent_password(null); + + SessionLoginTransaction login_trans = new SessionLoginTransaction( + session, normalise_url(url), username, password); + login_trans.network_error.connect(on_login_network_error); + login_trans.completed.connect(on_login_network_complete); + + try { + login_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_network_login"); + do_show_error(err); + } + } + + public static string normalise_url(string url) { + string norm_url = url; + + if(!norm_url.has_suffix(".php")) { + if(!norm_url.has_suffix("/")) { + norm_url = norm_url + "/"; + } + norm_url = norm_url + PIWIGO_WS; + } + + if(!norm_url.has_prefix("http://") && !norm_url.has_prefix("https://")) { + norm_url = "http://" + norm_url; + } + + return norm_url; + } + + /** + * Event triggered when the network login action is complete and successful. + * + * This event is triggered on successful completion of a network login. + * Calling this event implies that the URL, user name and password provided + * in the authentication pane are valid and that the transaction should + * contain a Set-Cookie header that includes the value pwg_id for that + * user. As a result, this event will also authenticate the session and + * persist all values so that they can be re-used during the next publishing + * interaction. + * + * @param txn the received REST transaction + */ + private void on_login_network_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_login_network_complete"); + txn.completed.disconnect(on_login_network_complete); + txn.network_error.disconnect(on_login_network_error); + + try { + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + // Get error code first + try { + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.get_error_code); + } catch (Spit.Publishing.PublishingError code) { + int code_int = int.parse(code.message); + if (code_int == 999) { + debug("ERROR: on_login_network_complete, code 999"); + do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_USER); + } else { + debug("ERROR: on_login_network_complete"); + do_show_error(err); + } + } + return; + } + // Get session ID and authenticate the session + string endpoint_url = txn.get_endpoint_url(); + debug("Setting endpoint URL to %s", endpoint_url); + string pwg_id = get_pwg_id_from_transaction(txn); + debug("Setting session pwg_id to %s", pwg_id); + session = new Session(); + session.set_pwg_id(pwg_id); + + do_fetch_session_status(endpoint_url, pwg_id); + } + + /** + * Event triggered when a network login action fails due to a network error. + * + * This event triggered as a result of a network error during the login + * transaction. As a result, it assumes that the service URL entered in the + * authentication dialog is incorrect and re-presents the authentication + * dialog with FAILED_RETRY_URL mode. + * + * @param bad_txn the received REST transaction + * @param err the received error + */ + private void on_login_network_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_login_network_error"); + bad_txn.completed.disconnect(on_login_network_complete); + bad_txn.network_error.disconnect(on_login_network_error); + + if (session.is_authenticated()) // ignore these events if the session is already auth'd + return; + + do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_URL); + } + + /** + * Action to fetch the session status for a known Piwigo user. + * + * This action fetches the session status for a Piwigo user for whom the + * pwg_id is known. If triggered after a network login, it should just + * confirm that the session is OK. It can also be triggered as the first + * action of the interaction for users for who the pwg_id was previously + * persisted. In this case, it will log the user in and confirm the + * identity. + */ + private void do_fetch_session_status(string url = "", string pwg_id = "") { + debug("ACTION: fetching session status"); + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + if (!session.is_authenticated()) { + SessionGetStatusTransaction status_txn = new SessionGetStatusTransaction.unauthenticated(session, url, pwg_id); + status_txn.network_error.connect(on_session_get_status_error); + status_txn.completed.connect(on_session_get_status_complete); + + try { + status_txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_fetch_session_status, not authenticated"); + do_show_error(err); + } + } else { + SessionGetStatusTransaction status_txn = new SessionGetStatusTransaction(session); + status_txn.network_error.connect(on_session_get_status_error); + status_txn.completed.connect(on_session_get_status_complete); + + try { + status_txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_fetch_session_status, authenticated"); + do_show_error(err); + } + } + } + + /** + * Event triggered when the get session status action completes successfully. + * + * This event being triggered confirms that the session is valid and can becyclonic enema + * used. If the session is not fully authenticated yet, this event finalises + * session authentication. It then triggers the fetch categories action. + */ + private void on_session_get_status_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_session_get_status_complete"); + txn.completed.disconnect(on_session_get_status_complete); + txn.network_error.disconnect(on_session_get_status_error); + + if (!session.is_authenticated()) { + string endpoint_url = txn.get_endpoint_url(); + string pwg_id = session.get_pwg_id(); + debug("Fetching session status for pwg_id %s", pwg_id); + // Parse the response + try { + Publishing.RESTSupport.XmlDocument doc = + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + Xml.Node* root = doc.get_root_node(); + Xml.Node* username_node; + try { + username_node = doc.get_named_child(root, "username"); + string username = username_node->get_content(); + debug("Returned username is %s", username); + session.authenticate(endpoint_url, username, pwg_id); + set_persistent_url(session.get_pwg_url()); + set_persistent_username(session.get_username()); + do_fetch_categories(); + } catch (Spit.Publishing.PublishingError err2) { + debug("ERROR: on_session_get_status_complete, inner"); + do_show_error(err2); + return; + } + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_session_get_status_complete, outer"); + do_show_error(err); + return; + } + } else { + // This should never happen as the session should not be + // authenticated at that point so this call is a safeguard + // against the interaction not happening properly. + do_fetch_categories(); + } + } + + /** + * Event triggered when the get session status fails due to a network error. + */ + private void on_session_get_status_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_session_get_status_error"); + bad_txn.completed.disconnect(on_session_get_status_complete); + bad_txn.network_error.disconnect(on_session_get_status_error); + on_network_error(bad_txn, err); + } + + /** + * Action that fetches all available categories from the Piwigo service. + * + * This action fetches all categories from the Piwigo service in order + * to populate the publishing pane presented to the user. + */ + private void do_fetch_categories() { + debug("ACTION: fetching categories"); + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + CategoriesGetListTransaction cat_trans = new CategoriesGetListTransaction(session); + cat_trans.network_error.connect(on_category_fetch_error); + cat_trans.completed.connect(on_category_fetch_complete); + + try { + cat_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_fetch_categories"); + do_show_error(err); + } + } + + /** + * Event triggered when the fetch categories action completes successfully. + * + * This event retrieves all categories from the received transaction and + * populates the categories list. It then triggers the display of the + * publishing options pane. + */ + private void on_category_fetch_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_category_fetch_complete"); + txn.completed.disconnect(on_category_fetch_complete); + txn.network_error.disconnect(on_category_fetch_error); + debug("PiwigoConnector: list of categories: %s", txn.get_response()); + // Empty the categories + if (categories != null) { + categories = null; + } + // Parse the response + try { + Publishing.RESTSupport.XmlDocument doc = + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + Xml.Node* root = doc.get_root_node(); + Xml.Node* categories_node = root->first_element_child(); + Xml.Node* category_node_iter = categories_node->children; + Xml.Node* name_node; + Xml.Node* uppercats_node; + string name = ""; + string id_string = ""; + string uppercats = ""; + for ( ; category_node_iter != null; category_node_iter = category_node_iter->next) { + name_node = doc.get_named_child(category_node_iter, "name"); + name = name_node->get_content(); + uppercats_node = doc.get_named_child(category_node_iter, "uppercats"); + uppercats = (string)uppercats_node->get_content(); + id_string = category_node_iter->get_prop("id"); + if (categories == null) { + categories = new Category[0]; + } + categories += new Category(int.parse(id_string), name, uppercats); + } + // compute the display name for the categories + // currently done by an unnecessary triple loop + // one could make a loop that goes over the categories + // and creates a list of back references cat_id -> index + // but since cat_ids are not guaranteed to be continuous + // that needs a perl hash ;-) + for(int i = 0; i < categories.length; i++) { + string[] upcatids = categories[i].uppercats.split(","); + var builder = new StringBuilder(); + for (int j=0; j < upcatids.length; j++) { + builder.append ("/ "); + // search for the upper category + for (int k=0; k < categories.length; k++) { + if (upcatids[j] == categories[k].id.to_string()) { + builder.append (categories[k].name); + break; + } + } + builder.append (" "); + } + categories[i].display_name = builder.str; + } + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_category_fetch_complete"); + do_show_error(err); + return; + } + + do_show_publishing_options_pane(); + } + + /** + * Event triggered when the fetch categories transaction fails due to a + * network error. + */ + private void on_category_fetch_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_category_fetch_error"); + bad_txn.completed.disconnect(on_category_fetch_complete); + bad_txn.network_error.disconnect(on_category_fetch_error); + on_network_error(bad_txn, err); + } + + /** + * Action that shows the publishing options pane. + * + * This action method shows the publishing options pane. + */ + private void do_show_publishing_options_pane() { + debug("ACTION: installing publishing options pane"); + + host.set_service_locked(false); + PublishingOptionsPane opts_pane = new PublishingOptionsPane( + this, categories, get_last_category(), get_last_permission_level(), get_last_photo_size(), + get_last_title_as_comment(), get_last_no_upload_tags(), get_metadata_removal_choice()); + opts_pane.logout.connect(on_publishing_options_pane_logout_clicked); + opts_pane.publish.connect(on_publishing_options_pane_publish_clicked); + host.install_dialog_pane(opts_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE); + host.set_dialog_default_widget(opts_pane.get_default_widget()); + } + + /** + * Event triggered when the user clicks logout in the publishing options pane. + */ + private void on_publishing_options_pane_logout_clicked() { + debug("EVENT: on_publishing_options_pane_logout_clicked"); + SessionLogoutTransaction logout_trans = new SessionLogoutTransaction(session); + logout_trans.network_error.connect(on_logout_network_error); + logout_trans.completed.connect(on_logout_network_complete); + + try { + logout_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_publishing_options_pane_logout_clicked"); + do_show_error(err); + } + } + + /** + * Event triggered when the logout action completes successfully. + * + * This event de-authenticates the session and shows the authentication + * pane again. + */ + private void on_logout_network_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_logout_network_complete"); + txn.completed.disconnect(on_logout_network_complete); + txn.network_error.disconnect(on_logout_network_error); + + session.deauthenticate(); + + do_show_authentication_pane(AuthenticationPane.Mode.INTRO); + } + + /** + * Event triggered when the logout action fails due to a network error. + */ + private void on_logout_network_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_logout_network_error"); + bad_txn.completed.disconnect(on_logout_network_complete); + bad_txn.network_error.disconnect(on_logout_network_error); + on_network_error(bad_txn, err); + } + + /** + * Event triggered when the user clicks publish in the publishing options pane. + * + * This event first saves the parameters so that they can re-used later. + * If the publishing parameters indicate that the user wants to create a new + * category, the create category action is called. Otherwise, the upload + * action is called. + * + * @param parameters the publishing parameters + */ + private void on_publishing_options_pane_publish_clicked(PublishingParameters parameters, + bool strip_metadata) { + debug("EVENT: on_publishing_options_pane_publish_clicked"); + this.parameters = parameters; + this.strip_metadata = strip_metadata; + + if (parameters.category.is_local()) { + do_create_category(parameters.category); + } else { + do_upload(this.strip_metadata); + } + } + + /** + * Action that creates a new category in the Piwigo library. + * + * This actions runs a REST transaction to create a new category in the + * Piwigo library. It displays a wait pane with an information message + * while the transaction is running. This action should only be called with + * a local cateogory, i.e. one that does not exist on the server and does + * not yet have an ID. + * + * @param category the new category to create on the server + */ + private void do_create_category(Category category) { + debug("ACTION: creating a new category: %s".printf(category.name)); + assert(category.is_local()); + + host.set_service_locked(true); + host.install_static_message_pane(_("Creating album %s...").printf(category.name)); + + CategoriesAddTransaction creation_trans = new CategoriesAddTransaction( + session, category.name.strip(), int.parse(category.uppercats), category.comment); + creation_trans.network_error.connect(on_category_add_error); + creation_trans.completed.connect(on_category_add_complete); + + try { + creation_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_create_category"); + do_show_error(err); + } + } + + /** + * Event triggered when the add category action completes successfully. + * + * This event parses the ID assigned to new category out of the received + * transaction and assigns that ID to the category currently held in + * the publishing parameters. It then calls the upload action. + */ + private void on_category_add_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_category_add_complete"); + txn.completed.disconnect(on_category_add_complete); + txn.network_error.disconnect(on_category_add_error); + + // Parse the response + try { + Publishing.RESTSupport.XmlDocument doc = + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + Xml.Node* rsp = doc.get_root_node(); + Xml.Node* id_node; + id_node = doc.get_named_child(rsp, "id"); + string id_string = id_node->get_content(); + int id = int.parse(id_string); + parameters.category.id = id; + do_upload(strip_metadata); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_category_add_complete"); + do_show_error(err); + } + } + + /** + * Event triggered when the add category action fails due to a network error. + */ + private void on_category_add_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_category_add_error"); + bad_txn.completed.disconnect(on_category_add_complete); + bad_txn.network_error.disconnect(on_category_add_error); + on_network_error(bad_txn, err); + } + + /** + * Upload action: the big one, the one we've been waiting for! + */ + private void do_upload(bool strip_metadata) { + this.strip_metadata = strip_metadata; + debug("ACTION: uploading pictures"); + + host.set_service_locked(true); + // Save last category, permission level and size for next use + set_last_category(parameters.category.id); + set_last_permission_level(parameters.perm_level.id); + set_last_photo_size(parameters.photo_size.id); + set_last_title_as_comment(parameters.title_as_comment); + set_last_no_upload_tags(parameters.no_upload_tags); + set_metadata_removal_choice(strip_metadata); + + progress_reporter = host.serialize_publishables(parameters.photo_size.id, this.strip_metadata); + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + + Uploader uploader = new Uploader(session, publishables, parameters); + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + uploader.upload(on_upload_status_updated); + } + + /** + * Event triggered when the batch uploader reports that at least one of the + * network transactions encapsulating uploads has completed successfully + */ + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, int num_published) { + debug("EVENT: on_upload_complete"); + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + // TODO: should a message be displayed to the user if num_published is zero? + + do_show_success_pane(); + } + + /** + * Event triggered when the batch uploader reports that at least one of the + * network transactions encapsulating uploads has caused a network error + */ + private void on_upload_error( + Publishing.RESTSupport.BatchUploader uploader, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_upload_error"); + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + do_show_error(err); + } + + /** + * Event triggered when upload progresses and the status needs to be updated. + */ + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + /** + * Action to display the success pane in the publishing dialog. + */ + private void do_show_success_pane() { + debug("ACTION: installing success pane"); + + host.set_service_locked(false); + host.install_success_pane(); + } + + /** + * Helper event to handle network errors. + */ + private void on_network_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_network_error"); + do_show_error(err); + } + + /** + * Action to display an error to the user. + */ + private void do_show_error(Spit.Publishing.PublishingError e) { + debug("ACTION: do_show_error"); + string error_type = "UNKNOWN"; + if (e is Spit.Publishing.PublishingError.NO_ANSWER) { + do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_URL); + return; + } else if(e is Spit.Publishing.PublishingError.COMMUNICATION_FAILED) { + error_type = "COMMUNICATION_FAILED"; + } else if(e is Spit.Publishing.PublishingError.PROTOCOL_ERROR) { + error_type = "PROTOCOL_ERROR"; + } else if(e is Spit.Publishing.PublishingError.SERVICE_ERROR) { + error_type = "SERVICE_ERROR"; + } else if(e is Spit.Publishing.PublishingError.MALFORMED_RESPONSE) { + error_type = "MALFORMED_RESPONSE"; + } else if(e is Spit.Publishing.PublishingError.LOCAL_FILE_ERROR) { + error_type = "LOCAL_FILE_ERROR"; + } else if(e is Spit.Publishing.PublishingError.EXPIRED_SESSION) { + error_type = "EXPIRED_SESSION"; + } + + debug("Unhandled error: type=%s; message='%s'".printf(error_type, e.message)); + do_show_error_message(_("An error message occurred when publishing to Piwigo. Please try again.")); + } + + /** + * Action to display an error message to the user. + */ + private void do_show_error_message(string message) { + debug("ACTION: do_show_error_message"); + host.install_static_message_pane(message, + Spit.Publishing.PluginHost.ButtonMode.CLOSE); + } + + // Helper methods + + /** + * Retrieves session ID from a REST Transaction received + * + * This helper method extracts the pwg_id out of the Set-Cookie header if + * present in the received transaction. + * + * @param txn the received transaction + * @return the value of pwg_id if present or null if not found + */ + private new string? get_pwg_id_from_transaction(Publishing.RESTSupport.Transaction txn) { + string cookie = txn.get_response_headers().get_list("Set-Cookie"); + string pwg_id = null; + debug("Full cookie string: %s".printf(cookie)); + if (!is_string_empty(cookie)) { + string[] cookie_segments = cookie.split(";"); + debug("Split full string into %d individual segments".printf(cookie_segments.length)); + foreach(string cookie_segment in cookie_segments) { + debug("Individual cookie segment: %s".printf(cookie_segment)); + string[] cookie_sub_segments = cookie_segment.split(","); + debug("Split segment into %d individual sub-segments".printf(cookie_sub_segments.length)); + foreach(string cookie_sub_segment in cookie_sub_segments) { + debug("Individual cookie sub-segment: %s".printf(cookie_sub_segment)); + string[] cookie_kv = cookie_sub_segment.split("="); + debug("Split sub-segment into %d chunks".printf(cookie_kv.length)); + if (cookie_kv.length > 1 && cookie_kv[0].strip() == "pwg_id") { + debug("Found pwg_id: %s".printf(cookie_kv[1].strip())); + pwg_id = cookie_kv[1].strip(); + } + } + } + } + + return pwg_id; + } +} + +// The uploader + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + + public Uploader(Session session, Spit.Publishing.Publishable[] publishables, + PublishingParameters parameters) { + base(session, publishables); + + this.parameters = parameters; + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + return new ImagesAddTransaction((Session) get_session(), parameters, + publishable); + } +} + +// UI elements + +/** + * The authentication pane used when asking service URL, user name and password + * from the user. + */ +internal class AuthenticationPane : Spit.Publishing.DialogPane, Object { + public enum Mode { + INTRO, + FAILED_RETRY_URL, + FAILED_RETRY_USER + } + private static string INTRO_MESSAGE = _("Enter the URL of your Piwigo photo library as well as the username and password associated with your Piwigo account for that library."); + private static string FAILED_RETRY_URL_MESSAGE = _("Shotwell cannot contact your Piwigo photo library. Please verify the URL you entered"); + private static string FAILED_RETRY_USER_MESSAGE = _("Username and/or password invalid. Please try again"); + + private Gtk.Box pane_widget = null; + private Gtk.Builder builder; + private Gtk.Entry url_entry; + private Gtk.Entry username_entry; + private Gtk.Entry password_entry; + private Gtk.CheckButton remember_password_checkbutton; + private Gtk.Button login_button; + + public signal void login(string url, string user, string password, bool remember_password); + + public AuthenticationPane(PiwigoPublisher publisher, Mode mode = Mode.INTRO) { + this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + + File ui_file = publisher.get_host().get_module_file().get_parent(). + get_child("piwigo_authentication_pane.glade"); + + try { + builder = new Gtk.Builder(); + builder.add_from_file(ui_file.get_path()); + builder.connect_signals(null); + Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment; + + Gtk.Label message_label = builder.get_object("message_label") as Gtk.Label; + switch (mode) { + case Mode.INTRO: + message_label.set_text(INTRO_MESSAGE); + break; + + case Mode.FAILED_RETRY_URL: + message_label.set_markup("<b>%s</b>\n\n%s".printf(_( + "Invalid URL"), FAILED_RETRY_URL_MESSAGE)); + break; + + case Mode.FAILED_RETRY_USER: + message_label.set_markup("<b>%s</b>\n\n%s".printf(_( + "Invalid User Name or Password"), FAILED_RETRY_USER_MESSAGE)); + break; + } + + url_entry = builder.get_object ("url_entry") as Gtk.Entry; + string? persistent_url = publisher.get_persistent_url(); + if (persistent_url != null) { + url_entry.set_text(persistent_url); + } + username_entry = builder.get_object ("username_entry") as Gtk.Entry; + string? persistent_username = publisher.get_persistent_username(); + if (persistent_username != null) { + username_entry.set_text(persistent_username); + } + password_entry = builder.get_object ("password_entry") as Gtk.Entry; + string? persistent_password = publisher.get_persistent_password(); + if (persistent_password != null) { + password_entry.set_text(persistent_password); + } + remember_password_checkbutton = + builder.get_object ("remember_password_checkbutton") as Gtk.CheckButton; + remember_password_checkbutton.set_active(publisher.get_remember_password()); + + login_button = builder.get_object("login_button") as Gtk.Button; + + username_entry.changed.connect(on_user_changed); + url_entry.changed.connect(on_url_changed); + password_entry.changed.connect(on_password_changed); + login_button.clicked.connect(on_login_button_clicked); + + align.reparent(pane_widget); + publisher.get_host().set_dialog_default_widget(login_button); + } catch (Error e) { + warning("Could not load UI: %s", e.message); + } + } + + public Gtk.Widget get_default_widget() { + return login_button; + } + + private void on_login_button_clicked() { + login(url_entry.get_text(), username_entry.get_text(), + password_entry.get_text(), remember_password_checkbutton.get_active()); + } + + private void on_url_changed() { + update_login_button_sensitivity(); + } + + private void on_user_changed() { + update_login_button_sensitivity(); + } + + private void on_password_changed() { + update_login_button_sensitivity(); + } + + private void update_login_button_sensitivity() { + login_button.set_sensitive( + !is_string_empty(url_entry.get_text()) && + !is_string_empty(username_entry.get_text()) && + !is_string_empty(password_entry.get_text()) + ); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + url_entry.grab_focus(); + password_entry.set_activates_default(true); + login_button.can_default = true; + update_login_button_sensitivity(); + } + + public void on_pane_uninstalled() { + } +} + +/** + * The publishing options pane. + */ +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, Object { + + private static string DEFAULT_CATEGORY_NAME = _("Shotwell Connect"); + + private Gtk.Box pane_widget = null; + private Gtk.Builder builder; + private Gtk.RadioButton use_existing_radio; + private Gtk.RadioButton create_new_radio; + private Gtk.ComboBoxText existing_categories_combo; + private Gtk.Entry new_category_entry; + private Gtk.Label within_existing_label; + private Gtk.ComboBoxText within_existing_combo; + private Gtk.ComboBoxText perms_combo; + private Gtk.ComboBoxText size_combo; + private Gtk.CheckButton strip_metadata_check = null; + private Gtk.CheckButton title_as_comment_check = null; + private Gtk.CheckButton no_upload_tags_check = null; + private Gtk.Button logout_button; + private Gtk.Button publish_button; + private Gtk.TextView album_comment; + private Gtk.Label album_comment_label; + + private Category[] existing_categories; + private PermissionLevel[] perm_levels; + private SizeEntry[] photo_sizes; + + private int last_category; + private int last_permission_level; + private int last_photo_size; + private bool last_title_as_comment; + private bool last_no_upload_tags; + + public signal void publish(PublishingParameters parameters, bool strip_metadata); + public signal void logout(); + + public PublishingOptionsPane( + PiwigoPublisher publisher, Category[] categories, + int last_category, int last_permission_level, int last_photo_size, + bool last_title_as_comment, bool last_no_upload_tags, bool strip_metadata_enabled + ) { + this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + this.last_category = last_category; + this.last_permission_level = last_permission_level; + this.last_photo_size = last_photo_size; + this.last_title_as_comment = last_title_as_comment; + this.last_no_upload_tags = last_no_upload_tags; + + File ui_file = publisher.get_host().get_module_file().get_parent(). + get_child("piwigo_publishing_options_pane.glade"); + + try { + builder = new Gtk.Builder(); + builder.add_from_file(ui_file.get_path()); + builder.connect_signals(null); + Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment; + + use_existing_radio = builder.get_object("use_existing_radio") as Gtk.RadioButton; + create_new_radio = builder.get_object("create_new_radio") as Gtk.RadioButton; + existing_categories_combo = builder.get_object("existing_categories_combo") as Gtk.ComboBoxText; + new_category_entry = builder.get_object ("new_category_entry") as Gtk.Entry; + within_existing_label = builder.get_object ("within_existing_label") as Gtk.Label; + within_existing_combo = builder.get_object ("within_existing_combo") as Gtk.ComboBoxText; + + album_comment = builder.get_object ("album_comment") as Gtk.TextView; + album_comment.buffer = new Gtk.TextBuffer(null); + album_comment_label = builder.get_object ("album_comment_label") as Gtk.Label; + + perms_combo = builder.get_object("perms_combo") as Gtk.ComboBoxText; + size_combo = builder.get_object("size_combo") as Gtk.ComboBoxText; + + strip_metadata_check = builder.get_object("strip_metadata_check") as Gtk.CheckButton; + strip_metadata_check.set_active(strip_metadata_enabled); + + title_as_comment_check = builder.get_object("title_as_comment_check") as Gtk.CheckButton; + title_as_comment_check.set_active(last_title_as_comment); + + no_upload_tags_check = builder.get_object("no_upload_tags_check") as Gtk.CheckButton; + no_upload_tags_check.set_active(last_no_upload_tags); + + logout_button = builder.get_object("logout_button") as Gtk.Button; + logout_button.clicked.connect(on_logout_button_clicked); + + publish_button = builder.get_object("publish_button") as Gtk.Button; + publish_button.clicked.connect(on_publish_button_clicked); + + use_existing_radio.clicked.connect(on_use_existing_radio_clicked); + create_new_radio.clicked.connect(on_create_new_radio_clicked); + new_category_entry.changed.connect(on_new_category_entry_changed); + within_existing_combo.changed.connect(on_existing_combo_changed); + + align.reparent(pane_widget); + pane_widget.set_child_packing(align, true, true, 0, Gtk.PackType.START); + } catch (Error e) { + warning("Could not load UI: %s", e.message); + } + + this.existing_categories = categories; + this.perm_levels = create_perm_levels(); + this.photo_sizes = create_sizes(); + this.album_comment.buffer.set_text(get_common_comment_if_possible(publisher)); + } + + public Gtk.Widget get_default_widget() { + return publish_button; + } + + private PermissionLevel[] create_perm_levels() { + PermissionLevel[] result = new PermissionLevel[0]; + + result += new PermissionLevel(0, _("Everyone")); + result += new PermissionLevel(1, _("Admins, Family, Friends, Contacts")); + result += new PermissionLevel(2, _("Admins, Family, Friends")); + result += new PermissionLevel(4, _("Admins, Family")); + result += new PermissionLevel(8, _("Admins")); + + return result; + } + + private SizeEntry[] create_sizes() { + SizeEntry[] result = new SizeEntry[0]; + + result += new SizeEntry(500, _("500 x 375 pixels")); + result += new SizeEntry(1024, _("1024 x 768 pixels")); + result += new SizeEntry(2048, _("2048 x 1536 pixels")); + result += new SizeEntry(4096, _("4096 x 3072 pixels")); + result += new SizeEntry(ORIGINAL_SIZE, _("Original size")); + + return result; + } + + private void on_logout_button_clicked() { + logout(); + } + + private void on_publish_button_clicked() { + PublishingParameters params = new PublishingParameters(); + params.perm_level = perm_levels[perms_combo.get_active()]; + params.photo_size = photo_sizes[size_combo.get_active()]; + params.title_as_comment = title_as_comment_check.get_active(); + params.no_upload_tags = no_upload_tags_check.get_active(); + if (create_new_radio.get_active()) { + string uploadcomment = album_comment.buffer.text.strip(); + int a = within_existing_combo.get_active(); + if (a == 0) { + params.category = new Category.local(new_category_entry.get_text(), 0, uploadcomment); + } else { + // the list in existing_categories and in the within_existing_combo are shifted + // by 1, since we add the root + a--; + params.category = new Category.local(new_category_entry.get_text(), + existing_categories[a].id, uploadcomment); + } + } else { + params.category = existing_categories[existing_categories_combo.get_active()]; + } + publish(params, strip_metadata_check.get_active()); + } + + // UI interaction + private void on_use_existing_radio_clicked() { + existing_categories_combo.set_sensitive(true); + new_category_entry.set_sensitive(false); + within_existing_label.set_sensitive(false); + within_existing_combo.set_sensitive(false); + existing_categories_combo.grab_focus(); + album_comment_label.set_sensitive(false); + album_comment.set_sensitive(false); + update_publish_button_sensitivity(); + } + + private void on_create_new_radio_clicked() { + new_category_entry.set_sensitive(true); + within_existing_label.set_sensitive(true); + within_existing_combo.set_sensitive(true); + album_comment_label.set_sensitive(true); + album_comment.set_sensitive(true); + existing_categories_combo.set_sensitive(false); + new_category_entry.grab_focus(); + update_publish_button_sensitivity(); + } + + private void on_new_category_entry_changed() { + update_publish_button_sensitivity(); + } + + private void on_existing_combo_changed() { + update_publish_button_sensitivity(); + } + + private void update_publish_button_sensitivity() { + string category_name = new_category_entry.get_text().strip(); + int a = within_existing_combo.get_active(); + string search_name; + if (a <= 0) { + search_name = "/ " + category_name; + } else { + a--; + search_name = existing_categories[a].display_name + "/ " + category_name; + } + publish_button.set_sensitive( + !( + create_new_radio.get_active() && + ( + is_string_empty(category_name) || + category_already_exists(search_name) + ) + ) + ); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + create_categories_combo(); + create_within_categories_combo(); + create_permissions_combo(); + create_size_combo(); + + publish_button.can_default = true; + update_publish_button_sensitivity(); + } + + private string get_common_comment_if_possible(PiwigoPublisher publisher) { + // we have to determine whether all the publishing items + // belong to the same event + Spit.Publishing.Publishable[] publishables = publisher.get_host().get_publishables(); + string common = ""; + bool isfirst = true; + if (publishables != null) { + foreach (Spit.Publishing.Publishable pub in publishables) { + string cur = pub.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_EVENTCOMMENT); + if (isfirst) { + common = cur; + isfirst = false; + } else { + if (cur != common) { + common = ""; + break; + } + } + } + } + debug("PiwigoConnector: found common event comment %s\n", common); + return common; + } + + private void create_categories_combo() { + foreach (Category cat in existing_categories) { + existing_categories_combo.append_text(cat.display_name); + } + if (existing_categories.length == 0) { + // if no existing categories, disable the option to choose one + existing_categories_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + create_new_radio.set_active(true); + album_comment.set_sensitive(true); + album_comment_label.set_sensitive(true); + new_category_entry.grab_focus(); + } else { + int last_category_index = find_category_index(last_category); + if (last_category_index < 0) { + existing_categories_combo.set_active(0); + } else { + existing_categories_combo.set_active(last_category_index); + } + new_category_entry.set_sensitive(false); + album_comment.set_sensitive(false); + album_comment_label.set_sensitive(false); + } + if (!category_already_exists(DEFAULT_CATEGORY_NAME)) + new_category_entry.set_text(DEFAULT_CATEGORY_NAME); + } + + private void create_within_categories_combo() { + // root menu + within_existing_combo.append_text("/ "); + foreach (Category cat in existing_categories) { + within_existing_combo.append_text(cat.display_name); + } + // by default select root album as target + within_existing_label.set_sensitive(false); + within_existing_combo.set_active(0); + within_existing_combo.set_sensitive(false); + } + + private void create_permissions_combo() { + foreach (PermissionLevel perm in perm_levels) { + perms_combo.append_text(perm.name); + } + int last_permission_level_index = find_permission_level_index(last_permission_level); + if (last_permission_level_index < 0) { + perms_combo.set_active(0); + } else { + perms_combo.set_active(last_permission_level_index); + } + } + + private void create_size_combo() { + foreach (SizeEntry size in photo_sizes) { + size_combo.append_text(size.name); + } + int last_size_index = find_size_index(last_photo_size); + if (last_size_index < 0) { + size_combo.set_active(find_size_index(ORIGINAL_SIZE)); + } else { + size_combo.set_active(last_size_index); + } + } + + public void on_pane_uninstalled() { + } + + private int find_category_index(int category_id) { + int result = -1; + for(int i = 0; i < existing_categories.length; i++) { + if (existing_categories[i].id == category_id) { + result = i; + break; + } + } + return result; + } + + private int find_permission_level_index(int permission_level_id) { + int result = -1; + for(int i = 0; i < perm_levels.length; i++) { + if (perm_levels[i].id == permission_level_id) { + result = i; + break; + } + } + return result; + } + + private int find_size_index(int size_id) { + int result = -1; + for(int i = 0; i < photo_sizes.length; i++) { + if (photo_sizes[i].id == size_id) { + result = i; + break; + } + } + return result; + } + + private bool category_already_exists(string category_name) { + bool result = false; + foreach(Category category in existing_categories) { + if (category.display_name.strip() == category_name) { + result = true; + break; + } + } + return result; + } +} + +// REST support classes + +/** + * Session class that keeps track of the authentication status and of the + * user token pwg_id. + */ +internal class Session : Publishing.RESTSupport.Session { + private string? pwg_url = null; + private string? pwg_id = null; + private string? username = null; + + public Session() { + base(""); + } + + public override bool is_authenticated() { + return (pwg_id != null && pwg_url != null && username != null); + } + + public void authenticate(string url, string username, string id) { + this.pwg_url = url; + this.username = username; + this.pwg_id = id; + } + + public void deauthenticate() { + pwg_url = null; + pwg_id = null; + username = null; + } + + public string get_username() { + return username; + } + + public string get_pwg_url() { + return pwg_url; + } + + public string get_pwg_id() { + return pwg_id; + } + + public void set_pwg_id(string id) { + pwg_id = id; + } +} + +/** + * Generic REST transaction class. + * + * This class implements the generic logic for all REST transactions used + * by the Piwigo publishing plugin. In particular, it ensures that if the + * session has been authenticated, the pwg_id token is included in the + * transaction header. + */ +internal class Transaction : Publishing.RESTSupport.Transaction { + public Transaction(Session session) { + base(session); + if (session.is_authenticated()) { + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + } + } + + public Transaction.authenticated(Session session) { + base.with_endpoint_url(session, session.get_pwg_url()); + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* root = doc.get_root_node(); + string? status = root->get_prop("stat"); + + // treat malformed root as an error condition + if (status == null) + return "No status property in root node"; + + if (status == "ok") + return null; + + Xml.Node* errcode; + try { + errcode = doc.get_named_child(root, "err"); + } catch (Spit.Publishing.PublishingError err) { + return "No error code specified"; + } + + return "%s (error code %s)".printf(errcode->get_prop("msg"), errcode->get_prop("code")); + } + + public static new string? get_error_code(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* root = doc.get_root_node(); + Xml.Node* errcode; + try { + errcode = doc.get_named_child(root, "err"); + } catch (Spit.Publishing.PublishingError err) { + return "0"; + } + return errcode->get_prop("code"); + } +} + +/** + * Transaction used to implement the network login interaction. + */ +internal class SessionLoginTransaction : Transaction { + public SessionLoginTransaction(Session session, string url, string username, string password) { + base.with_endpoint_url(session, url); + + add_argument("method", "pwg.session.login"); + add_argument("username", username); + add_argument("password", password); + } +} + +/** + * Transaction used to implement the get status interaction. + */ +internal class SessionGetStatusTransaction : Transaction { + public SessionGetStatusTransaction.unauthenticated(Session session, string url, string pwg_id) { + base.with_endpoint_url(session, url); + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + + add_argument("method", "pwg.session.getStatus"); + } + + public SessionGetStatusTransaction(Session session) { + base.authenticated(session); + + add_argument("method", "pwg.session.getStatus"); + } +} + +/** + * Transaction used to implement the fetch categories interaction. + */ +private class CategoriesGetListTransaction : Transaction { + public CategoriesGetListTransaction(Session session) { + base.authenticated(session); + + add_argument("method", "pwg.categories.getList"); + add_argument("recursive", "true"); + } +} + +private class SessionLogoutTransaction : Transaction { + public SessionLogoutTransaction(Session session) { + base.authenticated(session); + + add_argument("method", "pwg.session.logout"); + } +} + +private class CategoriesAddTransaction : Transaction { + public CategoriesAddTransaction(Session session, string category, int parent_id = 0, string? comment = "") { + base.authenticated(session); + + add_argument("method", "pwg.categories.add"); + add_argument("name", category); + + if (parent_id != 0) { + add_argument("parent", parent_id.to_string()); + } + + if (comment != "") { + add_argument("comment", comment); + } + } +} + +private class ImagesAddTransaction : Publishing.RESTSupport.UploadTransaction { + private PublishingParameters parameters = null; + + public ImagesAddTransaction(Session session, PublishingParameters parameters, Spit.Publishing.Publishable publishable) { + base.with_endpoint_url(session, publishable, session.get_pwg_url()); + if (session.is_authenticated()) { + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + } + this.parameters = parameters; + + string[] keywords = publishable.get_publishing_keywords(); + string tags = ""; + if (keywords != null) { + foreach (string tag in keywords) { + if (!is_string_empty(tags)) { + tags += ","; + } + tags += tag; + } + } + + debug("PiwigoConnector: Uploading photo %s to category id %d with perm level %d", + publishable.get_serialized_file().get_basename(), + parameters.category.id, parameters.perm_level.id); + string name = publishable.get_publishing_name(); + string comment = publishable.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_COMMENT); + if (is_string_empty(name)) { + name = publishable.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_BASENAME); + add_argument("name", name); + if (!is_string_empty(comment)) { + add_argument("comment", comment); + } + } else { + // name is set + if (!is_string_empty(comment)) { + add_argument("name", name); + add_argument("comment", comment); + } else { + // name is set, comment is unset + // for backward compatibility with people having used + // the title as comment field, keep this option + if (parameters.title_as_comment) { + add_argument("comment", name); + } else { + add_argument("name", name); + } + } + } + add_argument("method", "pwg.images.addSimple"); + add_argument("category", parameters.category.id.to_string()); + add_argument("level", parameters.perm_level.id.to_string()); + if (!parameters.no_upload_tags) + if (!is_string_empty(tags)) + add_argument("tags", tags); + // TODO: update the Publishable interface so that it gives access to + // the image's meta-data where the author (artist) is kept + /*if (!is_string_empty(author)) + add_argument("author", author);*/ + + // TODO: implement description in APIGlue + /*if (!is_string_empty(publishable.get_publishing_description())) + add_argument("comment", publishable.get_publishing_description());*/ + + GLib.HashTable<string, string> disposition_table = + new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); + disposition_table.insert("filename", Soup.URI.encode( + publishable.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_BASENAME), null)); + disposition_table.insert("name", "image"); + + set_binary_disposition_table(disposition_table); + } +} + +} // namespace + diff --git a/plugins/shotwell-publishing/YouTubePublishing.vala b/plugins/shotwell-publishing/YouTubePublishing.vala new file mode 100644 index 0000000..c84240b --- /dev/null +++ b/plugins/shotwell-publishing/YouTubePublishing.vala @@ -0,0 +1,627 @@ +/* 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 class YouTubeService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "youtube.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public YouTubeService(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Publishing.CURRENT_INTERFACE); + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.youtube"; + } + + public unowned string get_pluggable_name() { + return "YouTube"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Jani Monoses, Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.YouTube.YouTubePublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return Spit.Publishing.Publisher.MediaType.VIDEO; + } + + public void activation(bool enabled) { + } +} + +namespace Publishing.YouTube { + +private const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into YouTube.\n\nYou must have already signed up for a Google account and set it up for use with YouTube to continue. You can set up most accounts by using your browser to log into the YouTube site at least once."); +private const string DEVELOPER_KEY = + "AI39si5VEpzWK0z-pzo4fonEj9E4driCpEs9lK8y3HJsbbebIIRWqW3bIyGr42bjQv-N3siAfqVoM8XNmtbbp5x2gpbjiSAMTQ"; + +private enum PrivacySetting { + PUBLIC, + UNLISTED, + PRIVATE +} + +private class PublishingParameters { + private PrivacySetting privacy; + private string? channel_name; + private string? user_name; + + public PublishingParameters() { + this.privacy = PrivacySetting.PRIVATE; + this.channel_name = null; + this.user_name = null; + } + + public PrivacySetting get_privacy() { + return this.privacy; + } + + public void set_privacy(PrivacySetting privacy) { + this.privacy = privacy; + } + + public string? get_channel_name() { + return channel_name; + } + + public void set_channel_name(string? channel_name) { + this.channel_name = channel_name; + } + + public string? get_user_name() { + return user_name; + } + + public void set_user_name(string? user_name) { + this.user_name = user_name; + } +} + +public class YouTubePublisher : Publishing.RESTSupport.GooglePublisher { + private class ChannelDirectoryTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://gdata.youtube.com/feeds/users/default"; + + public ChannelDirectoryTransaction(Publishing.RESTSupport.GoogleSession session) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* document_root = doc.get_root_node(); + if ((document_root->name == "feed") || (document_root->name == "entry")) + return null; + else + return "response root node isn't a <feed> or <entry>"; + } + } + + private bool running; + private string? refresh_token; + private PublishingParameters publishing_parameters; + private Spit.Publishing.ProgressCallback? progress_reporter; + + public YouTubePublisher(Spit.Publishing.Service service, Spit.Publishing.PluginHost host) { + base(service, host, "https://gdata.youtube.com/"); + + this.running = false; + this.refresh_token = host.get_config_string("refresh_token", null); + this.publishing_parameters = new PublishingParameters(); + this.progress_reporter = null; + } + + public override bool is_running() { + return running; + } + + public override void start() { + debug("YouTubePublisher: started."); + + if (is_running()) + return; + + running = true; + + if (refresh_token == null) + do_show_service_welcome_pane(); + else + start_oauth_flow(refresh_token); + } + + public override void stop() { + debug("YouTubePublisher: stopped."); + + running = false; + + get_session().stop_transactions(); + } + + private string extract_channel_name_helper(Xml.Node* document_root) throws + Spit.Publishing.PublishingError { + string result = ""; + + Xml.Node* doc_node_iter = null; + if (document_root->name == "feed") + doc_node_iter = document_root->children; + else if (document_root->name == "entry") + doc_node_iter = document_root; + else + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "response root node isn't a <feed> or <entry>"); + + for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) { + if (doc_node_iter->name != "entry") + continue; + + string name_val = null; + string url_val = null; + Xml.Node* channel_node_iter = doc_node_iter->children; + for ( ; channel_node_iter != null; channel_node_iter = channel_node_iter->next) { + if (channel_node_iter->name == "title") { + name_val = channel_node_iter->get_content(); + } else if (channel_node_iter->name == "id") { + // we only want nodes in the default namespace -- the feed that we get back + // from Google also defines <entry> child nodes named <id> in the media + // namespace + if (channel_node_iter->ns->prefix != null) + continue; + url_val = channel_node_iter->get_content(); + } + } + + result = name_val; + break; + } + + debug("YouTubePublisher: extracted channel name '%s' from response XML.", result); + + return result; + } + + private void on_service_welcome_login() { + debug("EVENT: user clicked 'Login' in welcome pane."); + + if (!is_running()) + return; + + start_oauth_flow(refresh_token); + } + + protected override void on_login_flow_complete() { + debug("EVENT: OAuth login flow complete."); + + get_host().set_config_string("refresh_token", get_session().get_refresh_token()); + + publishing_parameters.set_user_name(get_session().get_user_name()); + + do_fetch_account_information(); + } + + private void on_initial_channel_fetch_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_initial_channel_fetch_complete); + txn.network_error.disconnect(on_initial_channel_fetch_error); + + debug("EVENT: finished fetching account and channel information."); + + if (!is_running()) + return; + + do_parse_and_display_account_information((ChannelDirectoryTransaction) txn); + } + + private void on_initial_channel_fetch_error(Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err) { + bad_txn.completed.disconnect(on_initial_channel_fetch_complete); + bad_txn.network_error.disconnect(on_initial_channel_fetch_error); + + debug("EVENT: fetching account and channel information failed; response = '%s'.", + bad_txn.get_response()); + + if (!is_running()) + return; + + get_host().post_error(err); + } + + private void on_publishing_options_logout() { + debug("EVENT: user clicked 'Logout' in the publishing options pane."); + + if (!is_running()) + return; + + do_logout(); + } + + private void on_publishing_options_publish() { + debug("EVENT: user clicked 'Publish' in the publishing options pane."); + + if (!is_running()) + return; + + do_upload(); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + if (!is_running()) + return; + + progress_reporter(file_number, completed_fraction); + } + + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, + int num_published) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + if (!is_running()) + return; + + do_show_success_pane(); + } + + private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, + Spit.Publishing.PublishingError err) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + get_host().post_error(err); + } + + private void do_show_service_welcome_pane() { + debug("ACTION: showing service welcome pane."); + + get_host().install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_service_welcome_login); + } + + private void do_fetch_account_information() { + debug("ACTION: fetching channel information."); + + get_host().install_account_fetch_wait_pane(); + get_host().set_service_locked(true); + + ChannelDirectoryTransaction directory_trans = + new ChannelDirectoryTransaction(get_session()); + directory_trans.network_error.connect(on_initial_channel_fetch_error); + directory_trans.completed.connect(on_initial_channel_fetch_complete); + + try { + directory_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + on_initial_channel_fetch_error(directory_trans, err); + } + } + + private void do_parse_and_display_account_information(ChannelDirectoryTransaction transaction) { + debug("ACTION: extracting account and channel information from body of server response"); + + Publishing.RESTSupport.XmlDocument response_doc; + try { + response_doc = Publishing.RESTSupport.XmlDocument.parse_string( + transaction.get_response(), ChannelDirectoryTransaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + try { + publishing_parameters.set_channel_name(extract_channel_name_helper( + response_doc.get_root_node())); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + do_show_publishing_options_pane(); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: showing publishing options pane."); + + Gtk.Builder builder = new Gtk.Builder(); + + try { + builder.add_from_file( + get_host().get_module_file().get_parent().get_child("youtube_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + get_host().post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Youtube can't continue."))); + return; + } + + PublishingOptionsPane opts_pane = new PublishingOptionsPane(get_host(), builder, + publishing_parameters); + opts_pane.publish.connect(on_publishing_options_publish); + opts_pane.logout.connect(on_publishing_options_logout); + get_host().install_dialog_pane(opts_pane); + + get_host().set_service_locked(false); + } + + private void do_upload() { + debug("ACTION: uploading media items to remote server."); + + get_host().set_service_locked(true); + get_host().install_account_fetch_wait_pane(); + + + progress_reporter = get_host().serialize_publishables(-1); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + Spit.Publishing.Publishable[] publishables = get_host().get_publishables(); + Uploader uploader = new Uploader(get_session(), publishables, publishing_parameters); + + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + + uploader.upload(on_upload_status_updated); + } + + private void do_show_success_pane() { + debug("ACTION: showing success pane."); + + get_host().set_service_locked(false); + get_host().install_success_pane(); + } + + protected override void do_logout() { + debug("ACTION: logging out user."); + + get_session().deauthenticate(); + refresh_token = null; + get_host().unset_config_key("refresh_token"); + + + do_show_service_welcome_pane(); + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private class PrivacyDescription { + public string description; + public PrivacySetting privacy_setting; + + public PrivacyDescription(string description, PrivacySetting privacy_setting) { + this.description = description; + this.privacy_setting = privacy_setting; + } + } + + public signal void publish(); + public signal void logout(); + + private Gtk.Box pane_widget = null; + private Gtk.ComboBoxText privacy_combo = null; + private Gtk.Label publish_to_label = null; + private Gtk.Label login_identity_label = null; + private Gtk.Button publish_button = null; + private Gtk.Button logout_button = null; + private Gtk.Builder builder = null; + private Gtk.Label privacy_label = null; + private PrivacyDescription[] privacy_descriptions; + private PublishingParameters publishing_parameters; + + public PublishingOptionsPane(Spit.Publishing.PluginHost host, Gtk.Builder builder, + PublishingParameters publishing_parameters) { + this.privacy_descriptions = create_privacy_descriptions(); + this.publishing_parameters = publishing_parameters; + + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + login_identity_label = this.builder.get_object("login_identity_label") as Gtk.Label; + privacy_combo = this.builder.get_object("privacy_combo") as Gtk.ComboBoxText; + publish_to_label = this.builder.get_object("publish_to_label") as Gtk.Label; + publish_button = this.builder.get_object("publish_button") as Gtk.Button; + logout_button = this.builder.get_object("logout_button") as Gtk.Button; + pane_widget = this.builder.get_object("youtube_pane_widget") as Gtk.Box; + privacy_label = this.builder.get_object("privacy_label") as Gtk.Label; + + login_identity_label.set_label(_("You are logged into YouTube as %s.").printf( + publishing_parameters.get_user_name())); + publish_to_label.set_label(_("Videos will appear in '%s'").printf( + publishing_parameters.get_channel_name())); + + foreach(PrivacyDescription desc in privacy_descriptions) { + privacy_combo.append_text(desc.description); + } + + privacy_combo.set_active(PrivacySetting.PUBLIC); + privacy_label.set_mnemonic_widget(privacy_combo); + + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } + + private void on_publish_clicked() { + publishing_parameters.set_privacy( + privacy_descriptions[privacy_combo.get_active()].privacy_setting); + + publish(); + } + + private void on_logout_clicked() { + logout(); + } + + private void update_publish_button_sensitivity() { + publish_button.set_sensitive(true); + } + + private PrivacyDescription[] create_privacy_descriptions() { + PrivacyDescription[] result = new PrivacyDescription[0]; + + result += new PrivacyDescription(_("Public listed"), PrivacySetting.PUBLIC); + result += new PrivacyDescription(_("Public unlisted"), PrivacySetting.UNLISTED); + result += new PrivacyDescription(_("Private"), PrivacySetting.PRIVATE); + + return result; + } + + public Gtk.Widget get_widget() { + assert (pane_widget != null); + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + update_publish_button_sensitivity(); + } + + public void on_pane_uninstalled() { + } +} + +internal class UploadTransaction : Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://uploads.gdata.youtube.com/feeds/api/users/default/uploads"; + private const string UNLISTED_XML = "<yt:accessControl action='list' permission='denied'/>"; + private const string PRIVATE_XML = "<yt:private/>"; + private const string METADATA_TEMPLATE ="""<?xml version='1.0'?> + <entry xmlns='http://www.w3.org/2005/Atom' + xmlns:media='http://search.yahoo.com/mrss/' + xmlns:yt='http://gdata.youtube.com/schemas/2007'> + <media:group> + <media:title type='plain'>%s</media:title> + <media:category + scheme='http://gdata.youtube.com/schemas/2007/categories.cat'>People + </media:category> + %s + </media:group> + %s + </entry>"""; + private PublishingParameters parameters; + private Publishing.RESTSupport.GoogleSession session; + private Spit.Publishing.Publishable publishable; + + public UploadTransaction(Publishing.RESTSupport.GoogleSession session, + PublishingParameters parameters, Spit.Publishing.Publishable publishable) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.POST); + assert(session.is_authenticated()); + this.session = session; + this.parameters = parameters; + this.publishable = publishable; + } + + public override void execute() throws Spit.Publishing.PublishingError { + // create the multipart request container + Soup.Multipart message_parts = new Soup.Multipart("multipart/related"); + + string unlisted_video = + (parameters.get_privacy() == PrivacySetting.UNLISTED) ? UNLISTED_XML : ""; + + string private_video = + (parameters.get_privacy() == PrivacySetting.PRIVATE) ? PRIVATE_XML : ""; + + // Set title to publishing name, but if that's empty default to filename. + string title = publishable.get_publishing_name(); + if (title == "") { + title = publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME); + } + + string metadata = METADATA_TEMPLATE.printf(Publishing.RESTSupport.decimal_entity_encode(title), + private_video, unlisted_video); + Soup.Buffer metadata_buffer = new Soup.Buffer(Soup.MemoryUse.COPY, metadata.data); + message_parts.append_form_file("", "", "application/atom+xml", metadata_buffer); + + // attempt to read the binary video data from disk + string video_data; + size_t data_length; + try { + FileUtils.get_contents(publishable.get_serialized_file().get_path(), out video_data, + out data_length); + } catch (FileError e) { + string msg = "YouTube: couldn't read data from %s: %s".printf( + publishable.get_serialized_file().get_path(), e.message); + warning("%s", msg); + + throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(msg); + } + + // bind the binary video data read from disk into a Soup.Buffer object so that we + // can attach it to the multipart request, then actaully append the buffer + // to the multipart request. Then, set the MIME type for this part. + Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.COPY, + video_data.data[0:data_length]); + + message_parts.append_form_file("", publishable.get_serialized_file().get_path(), + "video/mpeg", bindable_data); + // create a message that can be sent over the wire whose payload is the multipart container + // that we've been building up + Soup.Message outbound_message = + soup_form_request_new_from_multipart(get_endpoint_url(), message_parts); + outbound_message.request_headers.append("X-GData-Key", "key=%s".printf(DEVELOPER_KEY)); + outbound_message.request_headers.append("Slug", + publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME)); + outbound_message.request_headers.append("Authorization", "Bearer " + + session.get_access_token()); + set_message(outbound_message); + + // send the message and get its response + set_is_executed(true); + send(); + } +} + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + + public Uploader(Publishing.RESTSupport.GoogleSession session, + Spit.Publishing.Publishable[] publishables, PublishingParameters parameters) { + base(session, publishables); + + this.parameters = parameters; + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + return new UploadTransaction((Publishing.RESTSupport.GoogleSession) get_session(), + parameters, get_current_publishable()); + } +} + +} + diff --git a/plugins/shotwell-publishing/facebook.png b/plugins/shotwell-publishing/facebook.png Binary files differnew file mode 100644 index 0000000..384609f --- /dev/null +++ b/plugins/shotwell-publishing/facebook.png diff --git a/plugins/shotwell-publishing/facebook_publishing_options_pane.glade b/plugins/shotwell-publishing/facebook_publishing_options_pane.glade new file mode 100644 index 0000000..7eb30f6 --- /dev/null +++ b/plugins/shotwell-publishing/facebook_publishing_options_pane.glade @@ -0,0 +1,243 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="facebook_pane_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkBox" id="facebook_pane_inner_box"> + <property name="width_request">1</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">16</property> + <child> + <object class="GtkLabel" id="how_to_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0.30000001192092896</property> + <property name="ypad">16</property> + <property name="label" translatable="no"> (text depends on fb username and is modified in the app - +anything put into this field won't display)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkGrid" id="grid1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="row_spacing">8</property> + <property name="column_spacing">32</property> + <property name="column_homogeneous">True</property> + <child> + <object class="GtkRadioButton" id="use_existing_radio"> + <property name="label" translatable="yes">Publish to an e_xisting album:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + <property name="group">create_new_radio</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkRadioButton" id="create_new_radio"> + <property name="label" translatable="yes">Create a _new album named:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="margin_bottom">8</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="new_album_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="margin_bottom">8</property> + <property name="invisible_char">●</property> + <property name="invisible_char_set">True</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="existing_albums_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="size_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Upload _size:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">resolution_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">3</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="resolution_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">start</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">3</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="visibility_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Videos and new photo albums _visible to:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">visibility_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="visibility_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">start</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0.10000000149011612</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">32</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">80</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">_Publish</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">80</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">2</property> + <property name="position">3</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="padding">8</property> + <property name="pack_type">end</property> + <property name="position">0</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/flickr.png b/plugins/shotwell-publishing/flickr.png Binary files differnew file mode 100644 index 0000000..b6cae3e --- /dev/null +++ b/plugins/shotwell-publishing/flickr.png diff --git a/plugins/shotwell-publishing/flickr_pin_entry_pane.glade b/plugins/shotwell-publishing/flickr_pin_entry_pane.glade new file mode 100644 index 0000000..e20fb38 --- /dev/null +++ b/plugins/shotwell-publishing/flickr_pin_entry_pane.glade @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="pane_widget"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkLabel" id="explanatory_text"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">16</property> + <property name="margin_right">16</property> + <property name="margin_top">48</property> + <property name="margin_bottom">16</property> + <property name="label" translatable="yes">Enter the confirmation number which appears after you log into Flickr in your Web browser.</property> + <property name="angle">0.0099999997764825821</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="margin_bottom">80</property> + <child> + <object class="GtkLabel" id="pin_entry_caption"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xpad">1</property> + <property name="ypad">1</property> + <property name="label" translatable="yes">Authorization _Number:</property> + <property name="use_underline">True</property> + <property name="justify">right</property> + <property name="mnemonic_widget">pin_entry</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="pin_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="margin_left">8</property> + <property name="invisible_char">●</property> + <property name="invisible_char_set">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkButton" id="continue_button"> + <property name="label" translatable="yes">Con_tinue</property> + <property name="use_action_appearance">False</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="margin_left">240</property> + <property name="margin_right">240</property> + <property name="margin_bottom">160</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/flickr_publishing_options_pane.glade b/plugins/shotwell-publishing/flickr_publishing_options_pane.glade new file mode 100644 index 0000000..995f2c2 --- /dev/null +++ b/plugins/shotwell-publishing/flickr_publishing_options_pane.glade @@ -0,0 +1,183 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="flickr_pane"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">16</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkBox" id="inner_wrapper"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">24</property> + <child> + <object class="GtkLabel" id="upload_info_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="no">You are logged into Flickr as (name). + +(this label's string is populated and set inside the code, +so changes made here will not display)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">16</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkGrid" id="visibility_and_size_grid"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="margin_bottom">8</property> + <property name="row_spacing">8</property> + <property name="column_spacing">24</property> + <child> + <object class="GtkLabel" id="visibility_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="no">_visibility label (populated in the code)</property> + <property name="use_underline">True</property> + <property name="justify">right</property> + <property name="mnemonic_widget">visibility_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="size_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="label" translatable="yes">Photo _size:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">size_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="visibility_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="size_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="halign">center</property> + <property name="margin_top">16</property> + <property name="use_underline">True</property> + <property name="xalign">0.17000000178813934</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">2</property> + <property name="width">2</property> + <property name="height">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">18</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="margin_bottom">8</property> + <property name="spacing">64</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</property> + <property name="width_request">96</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">24</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">_Publish</property> + <property name="width_request">96</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">24</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">2</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">8</property> + <property name="position">0</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/picasa.png b/plugins/shotwell-publishing/picasa.png Binary files differnew file mode 100644 index 0000000..999be78 --- /dev/null +++ b/plugins/shotwell-publishing/picasa.png diff --git a/plugins/shotwell-publishing/picasa_publishing_options_pane.glade b/plugins/shotwell-publishing/picasa_publishing_options_pane.glade new file mode 100644 index 0000000..adee4d2 --- /dev/null +++ b/plugins/shotwell-publishing/picasa_publishing_options_pane.glade @@ -0,0 +1,263 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="picasa_pane_widget"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">1</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkLabel" id="login_identity_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">12</property> + <property name="margin_bottom">32</property> + <property name="label" translatable="no">'you are logged in as $name' +(populated in the application code)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="album_gallery_layout_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">32</property> + <property name="margin_right">32</property> + <property name="orientation">vertical</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkGrid" id="album_choice_area_grid"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">24</property> + <property name="margin_right">24</property> + <property name="row_spacing">8</property> + <property name="column_homogeneous">True</property> + <child> + <object class="GtkComboBoxText" id="existing_albums_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="new_album_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="invisible_char">•</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkRadioButton" id="use_existing_radio"> + <property name="label" translatable="yes">An _existing album:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + <property name="group">create_new_radio</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkRadioButton" id="create_new_radio"> + <property name="label" translatable="yes">A _new album named:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="yalign">0.47999998927116394</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="public_check"> + <property name="label" translatable="yes">L_ist album in public gallery</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">3</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="publish_to_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">32</property> + <property name="xalign">0</property> + <property name="label" translatable="no">$mediatype will appear in +(populated in code)</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">2</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">32</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Photo _size preset:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">size_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">4</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="size_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">32</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">4</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="halign">center</property> + <property name="margin_top">16</property> + <property name="hexpand">True</property> + <property name="use_underline">True</property> + <property name="xalign">0.20000000298023224</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">5</property> + <property name="width">2</property> + <property name="height">1</property> + </packing> + </child> + <child> + <placeholder/> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <placeholder/> + </child> + <child> + <object class="GtkBox" id="button_area_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">112</property> + <property name="margin_right">112</property> + <property name="margin_top">48</property> + <property name="margin_bottom">24</property> + <property name="spacing">128</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">_Publish</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/piwigo.png b/plugins/shotwell-publishing/piwigo.png Binary files differnew file mode 100644 index 0000000..cf9dbc3 --- /dev/null +++ b/plugins/shotwell-publishing/piwigo.png diff --git a/plugins/shotwell-publishing/piwigo_authentication_pane.glade b/plugins/shotwell-publishing/piwigo_authentication_pane.glade new file mode 100644 index 0000000..367f957 --- /dev/null +++ b/plugins/shotwell-publishing/piwigo_authentication_pane.glade @@ -0,0 +1,175 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="2.16"/> + <object class="GtkWindow" id="authentication_pane"> + <property name="can_focus">False</property> + <child> + <object class="GtkAlignment" id="alignment"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xscale">0.5</property> + <property name="yscale">0.5</property> + <child> + <object class="GtkVBox" id="vbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">30</property> + <property name="margin_right">30</property> + <property name="hexpand">True</property> + <property name="spacing">8</property> + <child> + <object class="GtkLabel" id="message_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">label</property> + <property name="wrap">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkTable" id="field_table"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="n_rows">3</property> + <property name="n_columns">2</property> + <property name="column_spacing">8</property> + <property name="row_spacing">2</property> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">_URL of your Piwigo photo library</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">url_entry</property> + </object> + </child> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">User _name</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">username_entry</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">_Password</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">password_entry</property> + </object> + <packing> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="url_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="invisible_char">●</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="username_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="invisible_char">●</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="password_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="visibility">False</property> + <property name="invisible_char">●</property> + <property name="invisible_char_set">True</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="remember_password_checkbutton"> + <property name="label" translatable="yes">_Remember Password</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkHButtonBox" id="hbuttonbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkButton" id="login_button"> + <property name="label" translatable="yes">Login</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + </object> + </child> + </object> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/piwigo_publishing_options_pane.glade b/plugins/shotwell-publishing/piwigo_publishing_options_pane.glade new file mode 100644 index 0000000..7197603 --- /dev/null +++ b/plugins/shotwell-publishing/piwigo_publishing_options_pane.glade @@ -0,0 +1,314 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkWindow" id="publishing_options_pane"> + <property name="can_focus">False</property> + <child> + <object class="GtkAlignment" id="alignment"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xscale">0.5</property> + <property name="yscale">0.5</property> + <child> + <object class="GtkBox" id="vbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">10</property> + <property name="margin_right">10</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Photos will appear in:</property> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkTable" id="field_table"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="n_rows">7</property> + <property name="n_columns">2</property> + <child> + <object class="GtkRadioButton" id="use_existing_radio"> + <property name="label" translatable="yes">An _existing category:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">1</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + </object> + </child> + <child> + <object class="GtkRadioButton" id="create_new_radio"> + <property name="label" translatable="yes">A _new album named:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">1</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + <property name="group">use_existing_radio</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="existing_categories_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="new_category_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="invisible_char">●</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="within_existing_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkSeparator" id="hseparator1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="right_attach">2</property> + <property name="top_attach">4</property> + <property name="bottom_attach">5</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">Photos will be _visible by:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">perms_combo</property> + </object> + <packing> + <property name="top_attach">5</property> + <property name="bottom_attach">6</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="perms_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">5</property> + <property name="bottom_attach">6</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">Photo size:</property> + </object> + <packing> + <property name="top_attach">6</property> + <property name="bottom_attach">7</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="size_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">6</property> + <property name="bottom_attach">7</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="within_existing_label"> + <property name="visible">True</property> + <property name="sensitive">False</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">within category:</property> + </object> + <packing> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow" id="album_comment_scroll"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="shadow_type">in</property> + <child> + <object class="GtkTextView" id="album_comment"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="wrap_mode">word</property> + </object> + </child> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">3</property> + <property name="bottom_attach">4</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="album_comment_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="yalign">0</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">Album comment:</property> + </object> + <packing> + <property name="top_attach">3</property> + <property name="bottom_attach">4</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="title_as_comment_check"> + <property name="label" translatable="yes">_If a title is set and comment unset, use title as comment</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="no_upload_tags_check"> + <property name="label" translatable="yes">_Do no upload tags</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + <child> + <object class="GtkButtonBox" id="hbuttonbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">Logout</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">Publish</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">5</property> + </packing> + </child> + </object> + </child> + </object> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/shotwell-publishing.vala b/plugins/shotwell-publishing/shotwell-publishing.vala new file mode 100644 index 0000000..f9f2080 --- /dev/null +++ b/plugins/shotwell-publishing/shotwell-publishing.vala @@ -0,0 +1,50 @@ +/* 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. + */ + +extern const string _VERSION; + +// "core services" are: Facebook, Flickr, Picasa Web Albums, Piwigo and YouTube +private class ShotwellPublishingCoreServices : Object, Spit.Module { + private Spit.Pluggable[] pluggables = new Spit.Pluggable[0]; + + // we need to get a module file handle because our pluggables have to load resources from the + // module file directory + public ShotwellPublishingCoreServices(GLib.File module_file) { + GLib.File resource_directory = module_file.get_parent(); + + pluggables += new FacebookService(resource_directory); + pluggables += new PicasaService(resource_directory); + pluggables += new FlickrService(resource_directory); + pluggables += new YouTubeService(resource_directory); + pluggables += new PiwigoService(resource_directory); + } + + public unowned string get_module_name() { + return _("Core Publishing Services"); + } + + public unowned string get_version() { + return _VERSION; + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.core_services"; + } + + public unowned Spit.Pluggable[]? get_pluggables() { + return pluggables; + } +} + +// This entry point is required for all SPIT modules. +public Spit.Module? spit_entry_point(Spit.EntryPointParams *params) { + params->module_spit_interface = Spit.negotiate_interfaces(params->host_min_spit_interface, + params->host_max_spit_interface, Spit.CURRENT_INTERFACE); + + return (params->module_spit_interface != Spit.UNSUPPORTED_INTERFACE) + ? new ShotwellPublishingCoreServices(params->module_file) : null; +} + diff --git a/plugins/shotwell-publishing/youtube.png b/plugins/shotwell-publishing/youtube.png Binary files differnew file mode 100644 index 0000000..214e1de --- /dev/null +++ b/plugins/shotwell-publishing/youtube.png diff --git a/plugins/shotwell-publishing/youtube_publishing_options_pane.glade b/plugins/shotwell-publishing/youtube_publishing_options_pane.glade new file mode 100644 index 0000000..cdf82af --- /dev/null +++ b/plugins/shotwell-publishing/youtube_publishing_options_pane.glade @@ -0,0 +1,137 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="youtube_pane_widget"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">1</property> + <child> + <object class="GtkLabel" id="login_identity_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">24</property> + <property name="margin_bottom">32</property> + <property name="label" comments="This string is altered in the code, so it's safe to ignore it during translation.">'you are logged in as $name' +(populated in the application code)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="publish_to_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">8</property> + <property name="margin_bottom">64</property> + <property name="label" comments="This string does not require translation and may be safely skipped.">videos will appear in +(populated in the application code.)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">40</property> + <property name="margin_right">40</property> + <property name="margin_top">16</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkLabel" id="privacy_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">16</property> + <property name="margin_right">16</property> + <property name="xpad">10</property> + <property name="label" translatable="yes">Video privacy _setting:</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="privacy_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">16</property> + <property name="margin_right">32</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkBox" id="button_area_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">112</property> + <property name="margin_right">112</property> + <property name="margin_top">48</property> + <property name="margin_bottom">24</property> + <property name="spacing">128</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</property> + <property name="use_action_appearance">False</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton" id="publish_button"> + <property name="label" translatable="yes">_Publish</property> + <property name="use_action_appearance">False</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-transitions/BlindsEffect.vala b/plugins/shotwell-transitions/BlindsEffect.vala new file mode 100644 index 0000000..6fdcf18 --- /dev/null +++ b/plugins/shotwell-transitions/BlindsEffect.vala @@ -0,0 +1,96 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class BlindsEffectDescriptor : ShotwellTransitionDescriptor { + public BlindsEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.blinds"; + } + + public override unowned string get_pluggable_name() { + return _("Blinds"); + } + + public override Transitions.Effect create(HostInterface host) { + return new BlindsEffect(); + } +} + +private class BlindsEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 30; + private const int MIN_FPS = 15; + + private const int BLIND_WIDTH = 50; + private int current_blind_width; + + private Cairo.ImageSurface[] to_blinds; + private int blind_count; + + public BlindsEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = BlindsEffect.DESIRED_FPS; + min_fps = BlindsEffect.MIN_FPS; + } + + public bool needs_clear_background() { + return true; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + if (visuals.from_pixbuf != null) { + blind_count = visuals.to_pixbuf.width / BLIND_WIDTH; + current_blind_width = + (int) Math.ceil((double) visuals.to_pixbuf.width / (double) blind_count); + + to_blinds = new Cairo.ImageSurface[blind_count]; + + for (int i = 0; i < blind_count; ++i) { + to_blinds[i] = new Cairo.ImageSurface(Cairo.Format.RGB24, current_blind_width, + visuals.to_pixbuf.height); + Cairo.Context ctx = new Cairo.Context(to_blinds[i]); + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf, -i * current_blind_width, 0); + ctx.paint(); + } + } + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + int y = visuals.to_pos.y; + int x = visuals.to_pos.x; + + if (visuals.from_pixbuf != null){ + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - alpha * 2); + } + + for (int i = 0; i < blind_count; ++i) { + ctx.set_source_surface(to_blinds[i], x + i * current_blind_width, y); + ctx.rectangle(x + i * current_blind_width, y, current_blind_width * (alpha + 0.5), + visuals.to_pixbuf.height); + ctx.fill(); + } + + ctx.clip(); + ctx.paint(); + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} diff --git a/plugins/shotwell-transitions/ChessEffect.vala b/plugins/shotwell-transitions/ChessEffect.vala new file mode 100644 index 0000000..37c9fd2 --- /dev/null +++ b/plugins/shotwell-transitions/ChessEffect.vala @@ -0,0 +1,88 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class ChessEffectDescriptor : ShotwellTransitionDescriptor { + public ChessEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.chess"; + } + + public override unowned string get_pluggable_name() { + return _("Chess"); + } + + public override Transitions.Effect create(HostInterface host) { + return new ChessEffect(); + } +} + +private class ChessEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 10; + private const int SQUARE_SIZE = 100; + private double square_count_x; + private double square_count_y; + + public ChessEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = ChessEffect.DESIRED_FPS; + min_fps = ChessEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + square_count_y = visuals.to_pos.height / SQUARE_SIZE + 2; + square_count_x = visuals.to_pos.width / SQUARE_SIZE + 2; + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + double size = 2 * alpha * SQUARE_SIZE; + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - alpha); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf,visuals.to_pos.x, visuals.to_pos.y); + for (double y = 0; y <= square_count_y; y++) { + for (double x = 0; x <= square_count_x; x++) { + double translation = (x+y) % 2 == 0 ? -1.5 * SQUARE_SIZE : 1.5 * SQUARE_SIZE; + if (motion.direction == Transitions.Direction.FORWARD) { + ctx.rectangle(visuals.to_pos.x + translation + x * SQUARE_SIZE, + visuals.to_pos.y + y * SQUARE_SIZE, size, SQUARE_SIZE); + } else { + ctx.rectangle(visuals.to_pos.x + visuals.to_pos.width + translation - x + * SQUARE_SIZE - size, visuals.to_pos.y + y * SQUARE_SIZE, size, + SQUARE_SIZE); + } + } + } + + ctx.clip(); + ctx.paint_with_alpha(alpha); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} diff --git a/plugins/shotwell-transitions/CircleEffect.vala b/plugins/shotwell-transitions/CircleEffect.vala new file mode 100644 index 0000000..11e7631 --- /dev/null +++ b/plugins/shotwell-transitions/CircleEffect.vala @@ -0,0 +1,71 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class CircleEffectDescriptor : ShotwellTransitionDescriptor { + public CircleEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.circle"; + } + + public override unowned string get_pluggable_name() { + return _("Circle"); + } + + public override Transitions.Effect create(HostInterface host) { + return new CircleEffect(); + } +} + +private class CircleEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 15; + + public CircleEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = CircleEffect.DESIRED_FPS; + min_fps = CircleEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + int radius = (int)((alpha) * Math.fmax(width,height)); + + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - alpha); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf,visuals.to_pos.x, visuals.to_pos.y); + ctx.arc ((int) width / 2, (int) height / 2, radius, 0, 2 * Math.PI); + ctx.clip(); + ctx.paint(); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} diff --git a/plugins/shotwell-transitions/CirclesEffect.vala b/plugins/shotwell-transitions/CirclesEffect.vala new file mode 100644 index 0000000..b01e06f --- /dev/null +++ b/plugins/shotwell-transitions/CirclesEffect.vala @@ -0,0 +1,86 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class CirclesEffectDescriptor : ShotwellTransitionDescriptor { + public CirclesEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.circles"; + } + + public override unowned string get_pluggable_name() { + return _("Circles"); + } + + public override Transitions.Effect create(HostInterface host) { + return new CirclesEffect(); + } +} + +private class CirclesEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 15; + private const double SPEED = 2.5; + + public CirclesEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = CirclesEffect.DESIRED_FPS; + min_fps = CirclesEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + int distance = 60, radius; + int circleCountX = width / (2 * distance); + int circleCountY = height / distance; + double maxRadius = SPEED * distance; + + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - alpha); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf,visuals.to_pos.x, visuals.to_pos.y); + + for(int y = 0; y <= circleCountY; y++){ + for(int x = 0; x <= circleCountX; x++){ + radius = (int) (Math.fmax(0,Math.fmin(1, alpha-((double) (x + y)/(double) + ((circleCountY + circleCountX) * SPEED)))) * maxRadius); + ctx.arc(2 * distance * x, 2 * distance * y, radius, 0, 2 * Math.PI); + ctx.fill(); + } + } + + ctx.clip(); + ctx.paint_with_alpha(alpha); + } + + + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} diff --git a/plugins/shotwell-transitions/ClockEffect.vala b/plugins/shotwell-transitions/ClockEffect.vala new file mode 100644 index 0000000..78b39a6 --- /dev/null +++ b/plugins/shotwell-transitions/ClockEffect.vala @@ -0,0 +1,83 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class ClockEffectDescriptor : ShotwellTransitionDescriptor { + public ClockEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.clock"; + } + + public override unowned string get_pluggable_name() { + return _("Clock"); + } + + public override Transitions.Effect create(HostInterface host) { + return new ClockEffect(); + } +} + +private class ClockEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 15; + private const double TOP_RADIANT = 0.5 * Math.PI; + + public ClockEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = ClockEffect.DESIRED_FPS; + min_fps = ClockEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + double start_angle = -TOP_RADIANT, stop_angle = -TOP_RADIANT; + + if (motion.direction == Transitions.Direction.FORWARD) + stop_angle = alpha*Math.PI * 2 - TOP_RADIANT; + else + start_angle = (2 * (1-alpha)) * Math.PI - TOP_RADIANT; + + int radius = (int) Math.fmax(visuals.to_pos.width, visuals.to_pos.height); + + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - alpha); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf,visuals.to_pos.x, visuals.to_pos.y); + + int x = visuals.to_pos.x + (int) visuals.to_pos.width / 2; + int y = visuals.to_pos.y + (int) visuals.to_pos.height / 2; + + ctx.move_to(x, y); + ctx.arc (x, y, radius, start_angle, stop_angle); + ctx.fill_preserve(); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} diff --git a/plugins/shotwell-transitions/CrumbleEffect.vala b/plugins/shotwell-transitions/CrumbleEffect.vala new file mode 100644 index 0000000..a458811 --- /dev/null +++ b/plugins/shotwell-transitions/CrumbleEffect.vala @@ -0,0 +1,103 @@ +/* Copyright 2010 Maxim Kartashev + * 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. + */ + +using Spit; + +private class CrumbleEffectDescriptor : ShotwellTransitionDescriptor { + public CrumbleEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.crumble"; + } + + public override unowned string get_pluggable_name() { + return _("Crumble"); + } + + public override Transitions.Effect create(Spit.HostInterface host) { + return new CrumbleEffect(); + } +} + +private class CrumbleEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 15; + + private const int STRIPE_WIDTH = 10; + + private Cairo.ImageSurface[] from_stripes; + private double[] accelerations; + private int stripes_count; + + public CrumbleEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = CrumbleEffect.DESIRED_FPS; + min_fps = CrumbleEffect.MIN_FPS; + } + + public bool needs_clear_background() { + return true; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + Rand rand = new Rand(); + + // Cut original image into stripes of STRIPE_WIDTH width; also prepare + // acceleration for each stripe. + if (visuals.from_pixbuf != null) { + stripes_count = visuals.from_pixbuf.width / STRIPE_WIDTH; + from_stripes = new Cairo.ImageSurface[stripes_count]; + accelerations = new double[stripes_count]; + for (int i = 0; i < stripes_count; ++i) { + from_stripes[i] = new Cairo.ImageSurface(Cairo.Format.RGB24, STRIPE_WIDTH, + visuals.from_pixbuf.height); + Cairo.Context ctx = new Cairo.Context(from_stripes[i]); + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, - i * STRIPE_WIDTH, 0); + ctx.paint(); + accelerations[i] = rand.next_double(); + } + } + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + + if (alpha < 0.5) { + // First part: draw stripes that go down with pre-calculated acceleration + alpha = alpha * 2; // stretch alpha to [0, 1] + + // tear down from_pixbuf first + for (int i = 0; i < stripes_count; ++i) { + int x = visuals.from_pos.x + i * STRIPE_WIDTH; + double a = alpha + alpha * accelerations[i]; + int y = visuals.from_pos.y + (int) (visuals.from_pixbuf.height * a * a); + + ctx.set_source_surface(from_stripes[i], x, y); + ctx.paint(); + } + } else if (visuals.to_pixbuf != null) { + // Second part: fade in next image ("to_pixbuf") + alpha = (alpha - 0.5) * 2; // stretch alpha to [0, 1] + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf, visuals.to_pos.x, visuals.to_pos.y); + ctx.paint_with_alpha(alpha); + } else { + // TODO: fade in background color + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} + diff --git a/plugins/shotwell-transitions/FadeEffect.vala b/plugins/shotwell-transitions/FadeEffect.vala new file mode 100644 index 0000000..efe1d74 --- /dev/null +++ b/plugins/shotwell-transitions/FadeEffect.vala @@ -0,0 +1,70 @@ +/* Copyright 2010 Maxim Kartashev + * 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. + */ + +using Spit; + +private class FadeEffectDescriptor : ShotwellTransitionDescriptor { + public FadeEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.fade"; + } + + public override unowned string get_pluggable_name() { + return _("Fade"); + } + + public override Transitions.Effect create(Spit.HostInterface host) { + return new FadeEffect(); + } +} + +private class FadeEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 30; + private const int MIN_FPS = 20; + + public FadeEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = FadeEffect.DESIRED_FPS; + min_fps = FadeEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + + // blend the two pixbufs using an alpha of the appropriate level depending on how far + // the cycle has progressed + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, visuals.from_pos.y); + ctx.paint_with_alpha(1.0 - alpha); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf, visuals.to_pos.x, visuals.to_pos.y); + ctx.paint_with_alpha(alpha); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} + diff --git a/plugins/shotwell-transitions/Makefile b/plugins/shotwell-transitions/Makefile new file mode 100644 index 0000000..043891c --- /dev/null +++ b/plugins/shotwell-transitions/Makefile @@ -0,0 +1,21 @@ + +PLUGIN := shotwell-transitions + +SRC_FILES := \ + shotwell-transitions.vala \ + FadeEffect.vala \ + SlideEffect.vala \ + CrumbleEffect.vala \ + BlindsEffect.vala \ + CircleEffect.vala \ + CirclesEffect.vala \ + SquaresEffect.vala \ + StripesEffect.vala \ + ChessEffect.vala \ + ClockEffect.vala + +RC_FILES := \ + slideshow-plugin.png + +include ../Makefile.plugin.mk + diff --git a/plugins/shotwell-transitions/SlideEffect.vala b/plugins/shotwell-transitions/SlideEffect.vala new file mode 100644 index 0000000..1019f62 --- /dev/null +++ b/plugins/shotwell-transitions/SlideEffect.vala @@ -0,0 +1,75 @@ +/* Copyright 2010 Maxim Kartashev + * 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. + */ + +using Spit; + +private class SlideEffectDescriptor : ShotwellTransitionDescriptor { + public SlideEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.slide"; + } + + public override unowned string get_pluggable_name() { + return _("Slide"); + } + + public override Transitions.Effect create(Spit.HostInterface host) { + return new SlideEffect(); + } +} + +private class SlideEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 15; + + public SlideEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = SlideEffect.DESIRED_FPS; + min_fps = SlideEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + + if (visuals.from_pixbuf != null) { + int from_target_x = (motion.direction == Transitions.Direction.FORWARD) + ? -visuals.from_pixbuf.width : width; + int from_current_x = (int) (visuals.from_pos.x * (1 - alpha) + from_target_x * alpha); + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, from_current_x, visuals.from_pos.y); + ctx.paint(); + } + + if (visuals.to_pixbuf != null) { + int to_target_x = (width - visuals.to_pixbuf.width) / 2; + int from_x = (motion.direction == Transitions.Direction.FORWARD) + ? width : -visuals.to_pixbuf.width; + int to_current_x = (int) (from_x * (1 - alpha) + to_target_x * alpha); + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf, to_current_x, visuals.to_pos.y); + ctx.paint(); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} + diff --git a/plugins/shotwell-transitions/SquaresEffect.vala b/plugins/shotwell-transitions/SquaresEffect.vala new file mode 100644 index 0000000..c5a0163 --- /dev/null +++ b/plugins/shotwell-transitions/SquaresEffect.vala @@ -0,0 +1,86 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class SquaresEffectDescriptor : ShotwellTransitionDescriptor { + public SquaresEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.squares"; + } + + public override unowned string get_pluggable_name() { + return _("Squares"); + } + + public override Transitions.Effect create(HostInterface host) { + return new SquaresEffect(); + } +} + +private class SquaresEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 10; + private const int SQUARE_SIZE = 100; + private double square_count_x; + private double square_count_y; + + public SquaresEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = SquaresEffect.DESIRED_FPS; + min_fps = SquaresEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + square_count_x = visuals.to_pos.width/SQUARE_SIZE + 1; + square_count_y = visuals.to_pos.height/SQUARE_SIZE + 1; + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - alpha); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf,visuals.to_pos.x, visuals.to_pos.y); + for (double y = 0; y<=square_count_y; y++) { + for (double x = 0; x <=square_count_x; x++) { + double size = SQUARE_SIZE * (Math.fmin(1, alpha + ((square_count_x - x) + / square_count_x + (square_count_y - y)/square_count_y)/2.5)); + + ctx.rectangle(visuals.to_pos.x + x * SQUARE_SIZE, visuals.to_pos.y + y + * SQUARE_SIZE, size, size); + + ctx.fill(); + } + } + + ctx.clip(); + ctx.paint_with_alpha(alpha); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} + diff --git a/plugins/shotwell-transitions/StripesEffect.vala b/plugins/shotwell-transitions/StripesEffect.vala new file mode 100644 index 0000000..2edbdfe --- /dev/null +++ b/plugins/shotwell-transitions/StripesEffect.vala @@ -0,0 +1,84 @@ +/* Copyright 2013 Jens Bav + * 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. + */ + +using Spit; + +private class StripesEffectDescriptor : ShotwellTransitionDescriptor { + public StripesEffectDescriptor(GLib.File resource_directory) { + base(resource_directory); + } + + public override unowned string get_id() { + return "org.yorba.shotwell.transitions.stripes"; + } + + public override unowned string get_pluggable_name() { + return _("Stripes"); + } + + public override Transitions.Effect create(HostInterface host) { + return new StripesEffect(); + } +} + +private class StripesEffect : Object, Transitions.Effect { + private const int DESIRED_FPS = 25; + private const int MIN_FPS = 10; + private const int STRIPE_HEIGHT = 100; + private int stripe_count; + + public StripesEffect() { + } + + public void get_fps(out int desired_fps, out int min_fps) { + desired_fps = StripesEffect.DESIRED_FPS; + min_fps = StripesEffect.MIN_FPS; + } + + public void start(Transitions.Visuals visuals, Transitions.Motion motion) { + stripe_count = visuals.to_pos.height / STRIPE_HEIGHT + 1; + } + + public bool needs_clear_background() { + return true; + } + + public void paint(Transitions.Visuals visuals, Transitions.Motion motion, Cairo.Context ctx, + int width, int height, int frame_number) { + double alpha = motion.get_alpha(frame_number); + if (visuals.from_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.from_pixbuf, visuals.from_pos.x, + visuals.from_pos.y); + ctx.paint_with_alpha(1 - Math.fmin(1, alpha * 2)); + } + + if (visuals.to_pixbuf != null) { + Gdk.cairo_set_source_pixbuf(ctx, visuals.to_pixbuf,visuals.to_pos.x, visuals.to_pos.y); + int x = visuals.to_pos.x; + int y = visuals.to_pos.y; + for (int i = 0; i <= stripe_count; i++) { + if (i % 2 == motion.direction) { + ctx.rectangle(x + visuals.to_pos.width - alpha * visuals.to_pos.width, + y + i * STRIPE_HEIGHT, x + visuals.to_pos.width, STRIPE_HEIGHT); + } else { + ctx.rectangle(x, y + STRIPE_HEIGHT * i, visuals.to_pos.width * alpha, + STRIPE_HEIGHT); + } + } + + ctx.clip(); + ctx.paint_with_alpha(alpha); + } + } + + public void advance(Transitions.Visuals visuals, Transitions.Motion motion, int frame_number) { + } + + public void cancel() { + } +} + diff --git a/plugins/shotwell-transitions/shotwell-transitions.vala b/plugins/shotwell-transitions/shotwell-transitions.vala new file mode 100644 index 0000000..bd358c3 --- /dev/null +++ b/plugins/shotwell-transitions/shotwell-transitions.vala @@ -0,0 +1,90 @@ +/* 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. + */ + +extern const string _VERSION; + +private class ShotwellTransitions : Object, Spit.Module { + private Spit.Pluggable[] pluggables = new Spit.Pluggable[0]; + + public ShotwellTransitions(GLib.File module_file) { + GLib.File resource_directory = module_file.get_parent(); + + pluggables += new FadeEffectDescriptor(resource_directory); + pluggables += new SlideEffectDescriptor(resource_directory); + pluggables += new CrumbleEffectDescriptor(resource_directory); + pluggables += new BlindsEffectDescriptor(resource_directory); + pluggables += new CircleEffectDescriptor(resource_directory); + pluggables += new CirclesEffectDescriptor(resource_directory); + pluggables += new ClockEffectDescriptor(resource_directory); + pluggables += new SquaresEffectDescriptor(resource_directory); + pluggables += new ChessEffectDescriptor(resource_directory); + pluggables += new StripesEffectDescriptor(resource_directory); + } + + public unowned string get_module_name() { + return _("Core Slideshow Transitions"); + } + + public unowned string get_version() { + return _VERSION; + } + + public unowned string get_id() { + return "org.yorba.shotwell.transitions"; + } + + public unowned Spit.Pluggable[]? get_pluggables() { + return pluggables; + } +} + +// This entry point is required for all SPIT modules. +public Spit.Module? spit_entry_point(Spit.EntryPointParams *params) { + params->module_spit_interface = Spit.negotiate_interfaces(params->host_min_spit_interface, + params->host_max_spit_interface, Spit.CURRENT_INTERFACE); + + return (params->module_spit_interface != Spit.UNSUPPORTED_INTERFACE) + ? new ShotwellTransitions(params->module_file) : null; +} + +// Base class for all transition descriptors in this module +public abstract class ShotwellTransitionDescriptor : Object, Spit.Pluggable, Spit.Transitions.Descriptor { + private const string ICON_FILENAME = "slideshow-plugin.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public ShotwellTransitionDescriptor(GLib.File resource_directory) { + if (icon_pixbuf_set == null) + icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME)); + } + + public int get_pluggable_interface(int min_host_interface, int max_host_interface) { + return Spit.negotiate_interfaces(min_host_interface, max_host_interface, + Spit.Transitions.CURRENT_INTERFACE); + } + + public abstract unowned string get_id(); + + public abstract unowned string get_pluggable_name(); + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Maxim Kartashev"; + info.copyright = _("Copyright 2010 Maxim Kartashev, Copyright 2011-2014 Yorba Foundation"); + info.translators = Resources.TRANSLATORS; + info.version = _VERSION; + info.website_name = Resources.WEBSITE_NAME; + info.website_url = Resources.WEBSITE_URL; + info.is_license_wordwrapped = false; + info.license = Resources.LICENSE; + info.icons = icon_pixbuf_set; + } + + public void activation(bool enabled) { + } + + public abstract Spit.Transitions.Effect create(Spit.HostInterface host); +} + diff --git a/plugins/shotwell-transitions/slideshow-plugin.png b/plugins/shotwell-transitions/slideshow-plugin.png Binary files differnew file mode 100755 index 0000000..cb4a497 --- /dev/null +++ b/plugins/shotwell-transitions/slideshow-plugin.png |