Commit d0902889 authored by Lucas Beeler's avatar Lucas Beeler

Ports the Picasa publishing client to the new pluggable publishing API....

Ports the Picasa publishing client to the new pluggable publishing API. Services discovered dynamically now automatically have their names placed in the service selector box.
parent 36484811
......@@ -68,7 +68,6 @@ UNUNITIZED_SRC_FILES = \
AppDirs.vala \
PixbufCache.vala \
WebConnectors.vala \
FacebookConnector.vala \
CommandManager.vala \
Commands.vala \
SlideshowPage.vala \
......@@ -78,7 +77,6 @@ UNUNITIZED_SRC_FILES = \
Printing.vala \
Tag.vala \
TagPage.vala \
PicasaConnector.vala \
PiwigoConnector.vala \
YouTubeConnector.vala \
Screensaver.vala \
......
......@@ -4,9 +4,6 @@
* (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);
extern void qsort(void *p, size_t num, size_t size, GLib.CompareFunc func);
public class FacebookService : 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,
......@@ -32,6 +29,11 @@ public class FacebookService : Object, Spit.Pluggable, Spit.Publishing.Service {
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 {
......@@ -600,11 +602,6 @@ public class FacebookPublisher : Spit.Publishing.Publisher, GLib.Object {
return USER_VISIBLE_NAME;
}
public Spit.Publishing.Publisher.MediaType get_supported_media() {
return Spit.Publishing.Publisher.MediaType.PHOTO |
Spit.Publishing.Publisher.MediaType.VIDEO;
}
public void start() {
if (is_running())
return;
......@@ -671,15 +668,15 @@ internal class FacebookRESTSession {
soup_session.user_agent = user_agent;
}
private void notify_wire_message_unqueued(Soup.Message message) {
protected void notify_wire_message_unqueued(Soup.Message message) {
wire_message_unqueued(message);
}
private void notify_authenticated() {
protected void notify_authenticated() {
authenticated();
}
private void notify_authentication_failed(Spit.Publishing.PublishingError err) {
protected void notify_authentication_failed(Spit.Publishing.PublishingError err) {
authentication_failed(err);
}
......
......@@ -9,7 +9,9 @@ PKGS := \
SRC_FILES := \
shotwell-publishing.vala \
FacebookPublishing.vala
FacebookPublishing.vala \
PicasaPublishing.vala \
RESTSupport.vala
include ../Makefile.plugin.mk
/* Copyright 2009-2011 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);
extern void qsort(void *p, size_t num, size_t size, GLib.CompareFunc func);
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 struct Argument {
public string key;
public string value;
public Argument(string key, string value) {
this.key = key;
this.value = value;
}
public static int compare(void* p1, void* p2) {
Argument* arg1 = (Argument*) p1;
Argument* arg2 = (Argument*) p2;
return strcmp(arg1->key, arg2->key);
}
}
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) {
// debug("Transaction.on_message_unqueued( ).");
if (this.message != message)
return;
try {
check_response(message);
} catch (Spit.Publishing.PublishingError err) {
warning("Publishing error: %s", err.message);
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());
}
protected Argument[] get_arguments() {
return arguments;
}
protected Argument[] get_sorted_arguments() {
Argument[] sorted_array = new Argument[0];
foreach (Argument arg in arguments)
sorted_array += arg;
qsort(sorted_array, sorted_array.length, sizeof(Argument),
(CompareFunc) Argument.compare);
return sorted_array;
}
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;
}
protected HttpMethod get_method() {
return HttpMethod.from_string(message.method);
}
protected 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;
}
message.set_request(payload_content_type, Soup.MemoryUse.COPY, custom_payload,
(payload_length > 0) ? payload_length : custom_payload.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 = "";
foreach (Argument arg in arguments) {
formdata_string = formdata_string + ("%s=%s&".printf(Soup.URI.encode(arg.key, "&"),
Soup.URI.encode(arg.value, "&+")));
}
// 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));
}
message.set_request("application/x-www-form-urlencoded", Soup.MemoryUse.COPY,
formdata_string, formdata_string.length);
is_executed = true;
try {
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 void add_argument(string name, string value) {
arguments += 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 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");
// 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");
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());
if (current_char_value < 1)
break;
else if (current_char_value < 128)
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 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();
assert (file != null);
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();
}
}
}
......@@ -12,6 +12,7 @@ private class ShotwellPublishingCoreServices : Object, Spit.Module {
public ShotwellPublishingCoreServices() {
pluggables += new FacebookService();
pluggables += new PicasaService();
}
~ShotwellPublishingCoreServices() {
......
/* Copyright 2009-2011 Yorba Foundation
*
* This software is licensed under the GNU LGPL (version 2.1 or later).
* See the COPYING file in this distribution.
*/
namespace FacebookConnector {
public class Capabilities : ServiceCapabilities {
public override string get_name() {
return "Facebook";
}
public override Spit.Publishing.Publisher.MediaType get_supported_media() {
return Spit.Publishing.Publisher.MediaType.PHOTO | Spit.Publishing.Publisher.MediaType.VIDEO;
}
public override ServiceInteractor factory(PublishingDialog host) {
return Publishing.Glue.GlueFactory.get_instance().create_publisher("facebook");
}
}
}
......@@ -1232,13 +1232,6 @@ public abstract class BatchUploader {
}
}
// TODO: in the future, when we support an arbitrary number of services potentially
// developed by third parties, the ServiceFactory will support dynamic
// registration of services at runtime. For right now, with only two services,
// we just bake the services into the factory. Whatever we do in the future,
// however, only this ServiceFactory class will have to change; all of its
// clients will still see the same interface no matter how it's implemented
// internally.
public class ServiceFactory {
private static ServiceFactory instance = null;
......@@ -1246,12 +1239,18 @@ public class ServiceFactory {
string, ServiceCapabilities>();
private ServiceFactory() {
add_caps(new FacebookConnector.Capabilities());
add_caps(new FlickrConnector.Capabilities());
add_caps(new PicasaConnector.Capabilities());
add_caps(new YandexConnector.Capabilities());
add_caps(new YouTubeConnector.Capabilities());
add_caps(new PiwigoConnector.Capabilities());
// in addition to the baked-in services above, add services dynamically loaded from
// plugins. since everything involving plugins is written in terms of the new publishing
// API, we have to use the glue code.
Publishing.Glue.GlueFactory glue_factory = Publishing.Glue.GlueFactory.get_instance();
ServiceCapabilities[] caps = glue_factory.get_wrapped_services();
foreach (ServiceCapabilities current_caps in caps)
add_caps(current_caps);
}
private void add_caps(ServiceCapabilities caps) {
......
......@@ -27,8 +27,6 @@ public interface Publisher : GLib.Object {
public abstract Service get_service();
public abstract MediaType get_supported_media();
public abstract void start();
public abstract bool is_running();
......@@ -163,6 +161,7 @@ public interface Publishable : GLib.Object {
public interface Service : Object, Spit.Pluggable {
public abstract Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host);
public abstract Spit.Publishing.Publisher.MediaType get_supported_media();
//
// For future expansion.
......
......@@ -28,6 +28,10 @@ public class StandardHostInterface : Object, Spit.HostInterface {
subkey = "facebook";
break;
case "org.yorba.shotwell.publishing.picasa":
subkey = "picasa";
break;
default:
subkey = id;
break;
......
......@@ -23,10 +23,6 @@ public class PublisherWrapperInteractor : ServiceInteractor, Spit.Publishing.Pub
return wrapped.get_service();
}
public Spit.Publishing.Publisher.MediaType get_supported_media() {
return wrapped.get_supported_media();
}
public void start() {
wrapped.start();
}
......@@ -230,8 +226,9 @@ public class MediaSourcePublishableWrapper : Spit.Publishing.Publishable, GLib.O
debug("writing photo '%s' to temporary file '%s' for publishing.",
photo.get_source_id(), to_file.get_path());
try {
photo.export(to_file, Scaling.for_best_fit(content_major_axis, false),
Jpeg.Quality.HIGH, PhotoFileFormat.JFIF);
Scaling scaling = (content_major_axis > 0) ?