Quellcode durchsuchen

Initial work on PPRF publish happy path

Billy Barrow vor 1 Jahr
Ursprung
Commit
7b534ac8da

+ 74 - 0
src/AddCollectionWindow.vala

@@ -0,0 +1,74 @@
+using Gtk;
+using Adw;
+
+namespace Publicate {
+
+
+    public class AddCollectionWindow : Adw.Window {
+
+        private Adw.HeaderBar header_bar;
+        private Entry url;
+        private Button cancel_button;
+        private Button add_button;
+
+        private ViewerWindow window;
+
+        public AddCollectionWindow(ViewerWindow window) {
+            this.window = window;
+            modal = true;
+            transient_for = window;
+
+            var box = new Box(Orientation.VERTICAL, 0);
+            header_bar = new Adw.HeaderBar();
+            header_bar.show_end_title_buttons = false;
+            title = "Add Collection";
+
+            default_width = 400;
+
+            box.append (header_bar);
+
+            var label = new Label ("Collection URI");
+            label.halign = Align.START;
+            label.margin_top = 18;
+            label.margin_start = 18;
+            label.margin_bottom = 8;
+            box.append (label);
+
+            url = new Entry ();
+            url.margin_bottom = 18;
+            url.margin_start = 18;
+            url.margin_end = 18;
+            box.append (url);
+
+            cancel_button = new Button.with_label ("Cancel");
+            cancel_button.clicked.connect (() => this.close ());
+            header_bar.pack_start (cancel_button);
+
+            add_button = new Button.with_label ("Add");
+            add_button.add_css_class ("suggested-action");
+            add_button.clicked.connect (() => this.add.begin());
+            header_bar.pack_end (add_button);
+
+            content = box;
+        }
+
+        private async void add() {
+            cancel_button.sensitive = false;
+            add_button.sensitive = false;
+            url.sensitive = false;
+            try {
+                yield window.collection_service.add_collection_by_uri(url.text);
+                yield window.startup_menu.load_collection_list();
+                close();
+            }
+            catch(Error e) {
+                close();
+                var prompt = new Adw.MessageDialog(window, "Collection Error", @"Could not add collection: $(e.message)");
+                prompt.add_response("ok", "Close");
+                prompt.present();
+            }
+        }
+
+    }
+
+}

+ 17 - 0
src/App.vala

@@ -26,6 +26,7 @@ namespace Publicate {
     }
 
     int main (string[] argv) {
+        seed();
         // Create a new application
         var app = new PpubViewerApplication();
         return app.run (argv);
@@ -34,5 +35,21 @@ namespace Publicate {
     string get_publicate_path() {
         return Environment.get_home_dir () + "/.publicate";
     }
+
+    void seed() {
+        if(!FileUtils.test (get_publicate_path(), FileTest.EXISTS)) {
+            DirUtils.create (get_publicate_path (), 0600);
+        }
+        if(!FileUtils.test (get_publicate_path() + "/credentials", FileTest.EXISTS)) {
+            DirUtils.create (get_publicate_path()  + "/credentials", 0600);
+        }
+        if(!FileUtils.test (get_publicate_path() + "/collections", FileTest.EXISTS)) {
+            DirUtils.create (get_publicate_path()  + "/collections", 0600);
+        }
+        if(!FileUtils.test (get_publicate_path() + "/collections.config", FileTest.EXISTS)) {
+            var template = "{\"collections\": []}";
+            FileUtils.set_contents (get_publicate_path() + "/collections.config", template, template.length);
+        }
+    }
     
 }

+ 115 - 0
src/CollectionService.vala

@@ -0,0 +1,115 @@
+using Invercargill;
+
+namespace Publicate {
+
+    public class CollectionService {
+
+        public async Enumerable<CollectionConfig> get_collection_config() throws Error {
+            var file = File.new_for_path(get_publicate_path() + "/collections.config");
+            var stream  = yield file.read_async(1, null);
+            var parser = new Json.Parser();
+            yield parser.load_from_stream_async(stream);
+
+            var items = new Vector<CollectionConfig>();
+            var collections = parser.get_root().get_object().get_array_member("collections");
+            for(var i = 0; i < collections.get_length(); i++) {
+                var item = collections.get_element(i);
+                items.add((CollectionConfig)Json.gobject_deserialize(typeof(CollectionConfig), item));
+            }
+
+            return items;
+        }
+
+        public async void add_collection(CollectionConfig collection) throws Error {
+            var config = yield get_collection_config();
+            yield save_collection(config.with(collection));
+        }
+
+        public async void save_collection(Enumerable<CollectionConfig> config) throws Error {
+
+            var collections = new Json.Array();
+            foreach (var item in config) {
+                collections.add_element(Json.gobject_serialize(item));
+            }
+
+            var cfg = new Json.Object();
+            cfg.set_array_member("collections", collections);
+
+            var generator = new Json.Generator();
+            var root = new Json.Node(Json.NodeType.OBJECT);
+            root.take_object(cfg);
+            generator.set_root(root);
+            generator.pretty = true;
+            generator.to_file(get_publicate_path() + "/collections.config");
+        }
+
+        public async void add_collection_by_uri(string uri) throws Error {
+            var col_uri = new Ppub.CollectionUri.from_string(uri);
+            var client = yield do_in_bg<Pprf.Client>(() => Pprf.get_client_for_uri(col_uri));
+            var collection = yield do_in_bg<Ppub.Collection>(() => client.get_collection(new BinaryData.from_byte_array(col_uri.collection_id)));
+
+            get_collection_folder(collection.id);
+            var config = new CollectionConfig();
+            config.domain = col_uri.via_server_record;
+            config.name = collection.name;
+            config.collection_id = Base64.encode(collection.id);
+            yield add_collection(config);
+        }
+
+        public async Pprf.Client get_client(CollectionConfig config) throws Error {
+            var col_uri = new Ppub.CollectionUri(Base64.decode(config.collection_id), null, null, config.domain);
+            var client = yield do_in_bg<Pprf.Client>(() => Pprf.get_client_for_uri(col_uri));
+            return client;
+        }
+
+        private string get_collection_folder(uint8[] collection_id) throws Error {
+            var path = get_publicate_path() + "/collections/" + Base64.encode(collection_id).replace("/", "_");
+            if(!FileUtils.test (path, FileTest.EXISTS)) {
+                DirUtils.create(path, 0600);
+            }
+            return path;
+        }
+
+    }
+
+    public class CollectionConfig : Object{
+        public string name { get; set; }
+        public string domain { get; set; }
+        public string? last_used_identity { get; set; }
+        public string collection_id { get; set; }
+    }
+
+    public delegate T BgFunc<T>() throws Error;
+    public async T do_in_bg<T>(owned BgFunc<T> delegat) throws Error {
+        SourceFunc callback = do_in_bg.callback;
+        T result = null;
+        Error? error = null;
+
+        owned ThreadFunc<bool> run = () => {
+            try {
+                result = delegat();
+            }
+            catch(Error e) {
+                error = e;
+            }
+            Idle.add((owned)callback);
+            return true;
+        };
+        new Thread<bool>(null, run);
+
+        yield;
+        if(error != null) {
+            throw error;
+        }
+        return result;
+    }
+
+    public delegate void VoidBgFunc() throws Error;
+    public async void do_void_in_bg(owned VoidBgFunc delegat) throws Error {
+        yield do_in_bg<bool>(() => {
+            delegat();
+            return false;
+        });
+    }
+
+}

+ 56 - 0
src/CollectionView.vala

@@ -0,0 +1,56 @@
+using Adw;
+using Gtk;
+
+namespace Publicate {
+
+    public class CollectionView : Box {
+
+        private Adw.HeaderBar header_bar;
+        private MenuButton menu_button;
+        private ListBox publication_list;
+        private WindowTitle header_title;
+        private ProgressBar osd_bar;
+
+        private ViewerWindow window;
+
+        public CollectionView(ViewerWindow win) {
+            window = win;
+            orientation = Orientation.VERTICAL;
+            vexpand = true;
+            add_css_class("view");
+
+            header_bar = new Adw.HeaderBar ();
+            header_bar.show_end_title_buttons = true;
+            header_bar.title_widget = new Adw.WindowTitle ("", "");
+
+            header_title = new WindowTitle("", "");
+            header_bar.title_widget = header_title;
+            
+            menu_button = new MenuButton();
+            menu_button.icon_name = "open-menu-symbolic";
+            header_bar.pack_end(menu_button);
+            menu_button.menu_model = win.window_menu;
+
+            append(header_bar);
+
+            osd_bar = new ProgressBar();
+            osd_bar.add_css_class("osd");
+            append(osd_bar);
+
+            var scroll_window = new ScrolledWindow();
+            scroll_window.hscrollbar_policy = PolicyType.NEVER;
+            scroll_window.propagate_natural_width = true;
+            scroll_window.vexpand = true;
+            append(scroll_window);
+
+            publication_list = new ListBox ();
+            scroll_window.child = publication_list;
+        }
+
+        public async void show_collection(CollectionConfig config) {
+            header_title.title = config.name;
+            header_title.subtitle = @"via $(config.domain)";
+        }
+
+    }
+}

+ 32 - 3
src/Editor.vala

@@ -22,6 +22,7 @@ namespace Publicate {
         private Button remove_file_button;
 
         private SaveProgress progress_window;
+        private Menu document_menu;
 
         private Gee.HashMap<string, Editors.EditorWidget> open_editors = new Gee.HashMap<string, Editors.EditorWidget>();
 
@@ -44,12 +45,13 @@ namespace Publicate {
             menu_button.menu_model = menu;
             menu.append_section(null, win.window_menu);
 
-            var document_menu = new Menu();
+            document_menu = new Menu();
             document_menu.append("Save", "win.save");
             document_menu.append("Save as…", "win.save-as");
             document_menu.append("Save all", "win.save-all");
             menu.append_section(null, document_menu);
 
+
             var file_box = new Box(Orientation.VERTICAL, 0);
             file_box.vexpand = true;
             file_explorer = new FileExplorer();
@@ -104,7 +106,7 @@ namespace Publicate {
             append(leaflet);
         }
 
-        public async void load_ppub(File file) throws Error {
+        public async void load_ppub(File file, bool initial = false) throws Error {
 
             window.publication = new Ppub.Publication(file.get_path());
             publication_file = file;
@@ -114,7 +116,26 @@ namespace Publicate {
             window.title = header_title.title + " - Publicate!";
 
             file_explorer.set_assets(window.publication.assets);
+            window.maximize();
+            if(initial) {
+                yield open_asset(window.publication.get_default_asset());
+                yield add_publish_menu();
+            }
+        }
 
+        public async void add_publish_menu() throws Error {
+            var collections = yield window.collection_service.get_collection_config();
+            if(collections.any(() => true)) {
+                var publish_menu = new Menu();
+                foreach (var collection in collections) {
+                    var action = new SimpleAction("publish-to-" + collection.collection_id, null);
+                    action.activate.connect(() => publish.begin(collection));
+                    window.add_action(action);
+
+                    publish_menu.append(collection.name, "win.publish-to-" + collection.collection_id);
+                }
+                document_menu.append_submenu("Publish to", publish_menu);
+            }
         }
 
         public async void open_asset(Ppub.Asset? asset) {
@@ -221,7 +242,7 @@ namespace Publicate {
                 temp_file.move(publication_file, FileCopyFlags.OVERWRITE);
 
                 Idle.add(() => {
-                    load_ppub.begin(publication_file, () => {
+                    load_ppub.begin(publication_file, false, () => {
                         select_file_from_tab();
                         callback();
                     });
@@ -242,6 +263,7 @@ namespace Publicate {
 
         public async void save_as() {
             var dialog = new FileDialog();
+            dialog.initial_name = publication_file.get_basename();
             var filter = new FileFilter();
             var filters = new GLib.ListStore(Type.OBJECT);
             filters.append(filter);
@@ -386,6 +408,13 @@ namespace Publicate {
             yield save(to_save);
         }
 
+        private async void publish(CollectionConfig collection) {
+            yield save_all();
+            var publish_window = new PublishWindow(window);
+            publish_window.present();
+            yield publish_window.publish_to(collection, publication_file);
+        }
+
         
     }
 }

+ 25 - 0
src/Identities/IdentityList.vala

@@ -65,5 +65,30 @@ namespace Publicate {
             }
         }
 
+        public void populate_identities(Ppub.Collection collection) throws Error {
+            identity_list.remove_all();
+            var dir = Dir.open(get_publicate_path() + "/credentials", 0);
+            string name = null;
+            var creds = new Invercargill.Vector<Ppub.CollectionMemberCredentials>();
+            while((name = dir.read_name()) != null) {
+                string cred_str;
+                FileUtils.get_contents(get_publicate_path() + "/credentials/" + name, out cred_str, null);
+                creds.add(new Ppub.CollectionMemberCredentials.from_string(cred_str));                
+            }
+            
+            var usable_ids = Pprf.MemberIdentity.get_usable_identities(creds, collection);
+            
+            bool first = true;
+            foreach (var id in usable_ids) {
+                has_entries = true;
+                var row = new IdentityActionRow(window, id.credentials, id, null);
+                identity_list.append(row);
+                if(first) {
+                    identity_list.select_row(row);
+                    first = false;
+                }
+            }
+        }
+
     }
 }

+ 2 - 1
src/Identities/ManageIdentitiesWindow.vala

@@ -20,7 +20,7 @@ namespace Publicate {
 
             var box = new Box(Orientation.VERTICAL, 8);
             header_bar = new Adw.HeaderBar();
-            title = "Manage identities";
+            title = "Manage Identities";
 
             box.append (header_bar);
             
@@ -85,6 +85,7 @@ namespace Publicate {
 
         public async void export_credentials() throws Error {
             var dialog = new FileDialog();
+            dialog.initial_name = identity_list.selected_name;
             dialog.accept_label = "Export";
             var file = yield dialog.save(window, null);
 

+ 318 - 0
src/PublishWindow.vala

@@ -0,0 +1,318 @@
+using Gtk;
+using Adw;
+using Invercargill;
+
+namespace Publicate {
+
+
+    public class PublishWindow : Adw.Window {
+
+        private Adw.HeaderBar header_bar;
+        private ViewerWindow window;
+        private Stack stack;
+
+        private Box loader;
+        private Label loader_status;
+        private ProgressBar loader_progress;
+        private bool loader_pulsing = false;
+
+        private Box identity_select;
+        private IdentityList identity_list;
+        private Button publish_button;
+
+        private Box decision;
+        private Label decision_heading;
+        private Label decision_body;
+        private Button cancel_button;
+        private Button continue_button;
+
+        private Box complete;
+
+        private delegate void DecisionCallback();
+        private DecisionCallback cancel_action;
+        private DecisionCallback continue_action;
+
+        public PublishWindow(ViewerWindow window) {
+            this.window = window;
+            modal = true;
+            transient_for = window;
+
+            var box = new Box(Orientation.VERTICAL, 0);
+            header_bar = new Adw.HeaderBar();
+            header_bar.show_end_title_buttons = false;
+            title = "Publish";
+
+            default_height = 500;
+            default_width = 700;
+
+            box.append (header_bar);
+
+            stack = new Stack();
+            stack.vexpand = true;
+            stack.transition_type = StackTransitionType.CROSSFADE;
+            box.append(stack);
+            content = box;
+
+            loader = new Box(Orientation.VERTICAL, 8);
+            loader_status = new Label ("Please wait…");
+            loader_status.halign = Align.START;
+            loader_progress = new ProgressBar ();
+            loader_progress.hexpand = true;
+            loader.append (loader_status);
+            loader.append (loader_progress);
+            loader.width_request = 550;
+            loader.valign = Align.CENTER;
+            loader.halign = Align.CENTER;
+            
+            stack.add_child (loader);
+            stack.visible_child = loader;
+
+            identity_select = new Box(Orientation.VERTICAL, 8);
+            identity_select.margin_top = 18;
+            identity_select.margin_start = 18;
+            identity_select.margin_end = 18;
+            identity_select.margin_bottom = 18;
+
+            var identity_header = new Label("Select identity to publish with");
+            identity_header.halign = Align.START;
+            identity_header.add_css_class("title-2");
+            identity_select.append(identity_header);
+
+            identity_list = new IdentityList(window);
+            identity_select.append(identity_list);
+            
+            publish_button = new Button.with_label("Publish");
+            publish_button.add_css_class("suggested-action");
+            publish_button.halign = Align.END;
+            publish_button.clicked.connect(() => publish_with.begin(false));
+            identity_select.append(publish_button);
+            
+            stack.add_child(identity_select);
+
+            decision = new Box(Orientation.VERTICAL, 8);
+            decision.halign = Align.CENTER;
+            decision.valign = Align.CENTER;
+            decision.margin_top = 18;
+            decision.margin_start = 64;
+            decision.margin_end = 64;
+            decision.margin_bottom = 18;
+            decision.width_request = 200;
+
+            decision_heading = new Label("Decision");
+            decision_heading.halign = Align.START;
+            decision_heading.add_css_class("title-2");
+            decision.append(decision_heading);
+
+            decision_body = new Label("Details");
+            decision_body.halign = Align.START;
+            decision_body.hexpand = true;
+            decision_body.wrap = true;
+            decision_body.wrap_mode = Pango.WrapMode.WORD;
+            decision_body.natural_wrap_mode = NaturalWrapMode.WORD;
+            decision.append(decision_body);
+
+            var button_box = new Box(Orientation.HORIZONTAL, 0);
+            button_box.add_css_class("linked");
+
+            cancel_button = new Button.with_label("Cancel");
+            cancel_button.hexpand = true;
+            cancel_button.clicked.connect(() => cancel_action());
+            button_box.append(cancel_button);
+
+            continue_button = new Button.with_label("Continue");
+            continue_button.hexpand = true;
+            continue_button.clicked.connect(() => continue_action());
+            button_box.append(continue_button);
+
+            decision.append(button_box);
+            stack.add_child(decision);
+
+            complete = new Box(Orientation.VERTICAL, 8);
+            complete.halign = Align.CENTER;
+            complete.valign = Align.CENTER;
+            complete.margin_top = 18;
+            complete.margin_start = 18;
+            complete.margin_end = 18;
+            complete.margin_bottom = 18;
+
+            var status_page = new StatusPage();
+            complete.append(status_page);
+            status_page.icon_name = "emblem-ok-symbolic";
+            status_page.title = "Publish Completed!";
+
+            var close_button = new Button.with_label("Close");
+            status_page.child = close_button;
+            close_button.width_request = 400;
+            close_button.clicked.connect(() => close());
+
+            stack.add_child(complete);
+        }
+
+        private void pulse_loader() {
+            if(loader_pulsing) {
+                return;
+            }
+            loader_pulsing = true;
+            Timeout.add(100, () => {
+                if(!loader_pulsing) {
+                    return false;
+                }
+                loader_progress.pulse();
+                return true;
+            });
+        }
+
+        private void show_decision(string heading, string body, bool is_destructive, DecisionCallback cancel_cb, DecisionCallback continue_cb) {
+            decision_heading.set_text(heading);
+            decision_body.set_text(body);
+
+            continue_button.set_css_classes(new string[] { is_destructive ? "destructive-action" : "suggested-action"});
+            continue_action = continue_cb;
+            cancel_action = cancel_cb;
+
+            stack.visible_child = decision;
+        }
+
+        private Ppub.Collection collection;
+        private CollectionConfig config;
+        private Pprf.Client client;
+        private File publication_file;
+        private string dest_name;
+        private BinaryData cid;
+        private bool unpublish = false;
+        private DateTime? old_publish_timestamp;
+        public async void publish_to(CollectionConfig config, File publication_file) {
+            // TODO wrap in try catch
+
+            this.config = config;
+            this.publication_file = publication_file;
+            dest_name = publication_file.get_basename();
+            if(!dest_name.has_suffix(".ppub")) {
+                dest_name += ".ppub";
+            }
+
+            pulse_loader();
+            loader_status.set_text(@"Looking up $(config.domain)…");
+            client = yield window.collection_service.get_client(config);
+            cid = new BinaryData.from_base64(config.collection_id);
+
+            loader_status.set_text(@"Querying $(config.domain) for collection information…");
+            collection = yield do_in_bg<Ppub.Collection>(() => client.get_collection(cid));
+
+            loader_pulsing = false;
+            loader_progress.fraction = 0;
+
+            if(collection.publications.any(p => p.file_name == dest_name)) {
+                show_decision("Replace Existing Publication?", @"There is already a published publication with the name \"$dest_name\", if you continue it will be replaced.", true, () => close(), () => select_identity(true));
+            }
+            else {
+                select_identity(false);
+            }
+        }
+
+        private void select_identity(bool unpub) {
+            if(unpub) {
+                old_publish_timestamp = collection.publications.first(p => p.file_name == dest_name).publication_time;
+            }
+            unpublish = unpub;
+            stack.visible_child = identity_select;
+            identity_list.populate_identities(collection);
+            publish_button.sensitive = identity_list.has_entries;
+            header_bar.show_end_title_buttons = true;
+        }
+
+        private async void publish_with(bool overwrite) {
+            // TODO wrap in try catch
+            
+            header_bar.show_end_title_buttons = false;
+            stack.visible_child = loader;
+
+            loader_status.set_text(@"Registering name \"$(dest_name)\"…");
+            pulse_loader();
+            try {
+                yield do_void_in_bg(() => client.register_name(cid, dest_name, identity_list.selected_identity));
+            }
+            catch(Pprf.Messages.PprfFailureError.NAME_EXISTS e) {
+                // Ignore if unpublishing anyway
+                if(unpublish) {
+                    yield upload_and_publish(true);
+                    return;
+                }
+                else{
+                    show_decision("Overwrite file?", @"There is already a file on this server with the name \"$dest_name\", if you continue it will be overwritten.", true, () => close(), () => upload_and_publish.begin(true));
+                }
+            }
+
+            yield upload_and_publish(false);
+        }
+
+        private async void upload_and_publish(bool replace_destination) {
+            stack.visible_child = loader;
+            loader_status.set_text(@"Preparing to upload publication…");
+            pulse_loader();
+
+            var file_info = yield publication_file.query_info_async("*", FileQueryInfoFlags.NONE, 1);
+            var file_size = file_info.get_size();
+            var file_stream = yield publication_file.read_async(1);
+            var flags = replace_destination ? Pprf.Messages.FinaliseUploadFlags.OVERWRITE_DESTINATION : 0;
+            yield do_void_in_bg(() => client.upload(cid, file_stream, file_size, dest_name, unpublish, identity_list.selected_identity, upload_callback, flags));
+            
+            pulse_loader();
+            loader_status.set_text(@"Computing publication checksum…");
+            var checksum = yield do_in_bg<BinaryData>(() => new BinaryData.from_byte_array(Pprf.Util.file_checksum(publication_file)));
+
+            loader_status.set_text(@"Signing publication…");
+            var timestamp = new DateTime.now_local();
+            if(old_publish_timestamp != null) {
+                timestamp = old_publish_timestamp;
+            }
+
+            var publication = yield do_in_bg<Ppub.CollectionPublication>(() => new Ppub.CollectionPublication(dest_name, timestamp, identity_list.selected_identity.name, identity_list.selected_credentials, checksum.to_array()));
+
+            loader_status.set_text(@"Publishing…");
+            yield do_void_in_bg(() => client.publish(cid, publication, identity_list.selected_identity));
+
+            stack.visible_child = complete;
+            loader_pulsing = false;
+        }
+        
+        private double upload_frac;
+        private void upload_callback(uint64 bytes_sent, uint64 bytes_total, Pprf.UploadStatus status) {
+            print(@"Got update: $status\n");
+            upload_frac = ((double)bytes_sent)/((double)bytes_total);
+
+            switch(status) {
+                case Pprf.UploadStatus.INITIATING_SESSION:
+                    Idle.add_once(() => loader_status.set_text(@"Establishing upload session with server…"));
+                break;
+                case Pprf.UploadStatus.UPLOADING_CHUNK:
+                case Pprf.UploadStatus.UPLOADED_CHUNK:
+                    Idle.add_once(() => {
+                        loader_pulsing = false;
+                        loader_status.set_text(@"Uploading publication…");
+                        loader_progress.fraction = upload_frac;
+                    });
+                    break;
+                case Pprf.UploadStatus.UNPUBLISHING:
+                    Idle.add_once(() => {
+                        loader_status.set_text(@"Unpublishing previous publication…");
+                        pulse_loader();
+                    });
+                    break;
+                case Pprf.UploadStatus.FINALISING_SESSION:
+                    Idle.add_once(() => {
+                        loader_status.set_text(@"Finalising upload…");
+                        pulse_loader();
+                    });
+                    break;
+                case Pprf.UploadStatus.COMPLETE:
+                    Idle.add_once(() => {
+                        loader_status.set_text(@"Upload completed!");
+                        pulse_loader();
+                    });
+                    break;
+            }
+        }
+    }
+
+}

+ 48 - 1
src/StartupMenu.vala

@@ -13,6 +13,7 @@ namespace Publicate {
         private Wizards.StandardWizard standard_wizard;
         private Wizards.VideoWizard video_wizard;
         private ViewerWindow window;
+        private ListBox collection_list;
 
         public StartupMenu(ViewerWindow win) {
             window = win;
@@ -35,7 +36,7 @@ namespace Publicate {
             append(header_bar);
             append(stack);
 
-            box = new Box(Orientation.VERTICAL, 18);
+            box = new Box(Orientation.VERTICAL, 0);
             box.valign = Align.CENTER;
             box.halign = Align.CENTER;
             box.vexpand = true;
@@ -48,27 +49,35 @@ namespace Publicate {
             box.append(title);
 
             var action_list = new ListBox ();
+            action_list.margin_top = 18;
+            action_list.margin_bottom = 18;
             action_list.add_css_class ("boxed-list");
             box.append(action_list);
 
             var new_action_row = new ActionRow();
             new_action_row.title = "New Publication";
             new_action_row.subtitle = "Create a new publication using Markdown for text formatting.";
+            new_action_row.add_prefix(new Image.from_icon_name("document-new-symbolic"));
             new_action_row.activatable = true;
+            new_action_row.selectable = false;
             new_action_row.activated.connect(() => stack.visible_child = standard_wizard);
             action_list.append (new_action_row);
 
             var new_video_action_row = new ActionRow();
             new_video_action_row.title = "New Video Publication";
             new_video_action_row.subtitle = "Create a video publication from an existing video.";
+            new_video_action_row.add_prefix(new Image.from_icon_name("camera-video-symbolic"));
             new_video_action_row.activatable = true;
+            new_video_action_row.selectable = false;
             new_video_action_row.activated.connect(() => stack.visible_child = video_wizard);
             action_list.append (new_video_action_row);
 
             var edit_action_row = new ActionRow();
             edit_action_row.title = "Open Existing Publication";
             edit_action_row.subtitle = "Edit a publication that has already been created.";
+            edit_action_row.add_prefix(new Image.from_icon_name("document-open-symbolic"));
             edit_action_row.activatable = true;
+            edit_action_row.selectable = false;
             action_list.append (edit_action_row);
             edit_action_row.activated.connect(() => window.open_ppub.begin());
 
@@ -81,6 +90,44 @@ namespace Publicate {
             video_wizard.cancelled.connect(wizard_cancelled);
             video_wizard.open.connect(open_file);
             stack.add_child(video_wizard);
+
+            var collection_label = new Label("Collections");
+            collection_label.add_css_class("heading");
+            collection_label.halign = Align.START;
+            collection_label.margin_bottom = 8;
+            box.append(collection_label);
+
+            collection_list = new ListBox();
+            collection_list.add_css_class ("boxed-list");
+            box.append(collection_list);
+            load_collection_list.begin();
+        }
+        
+        public async void load_collection_list() {
+            collection_list.remove_all();
+            var add_collection_row = new ActionRow();
+            add_collection_row.title = "Add Collection";
+            add_collection_row.subtitle = "Add a collection to Publicate using its URL.";
+            add_collection_row.activatable = true;
+            add_collection_row.selectable = false;
+            add_collection_row.activated.connect(() => add_collection());
+            add_collection_row.add_prefix(new Image.from_icon_name("list-add-symbolic"));
+            collection_list.append (add_collection_row);
+
+            var collections = yield window.collection_service.get_collection_config();
+            foreach (var collection in collections) {
+                var collection_row = new ActionRow();
+                collection_row.title = collection.name;
+                collection_row.subtitle = collection.domain;
+                collection_row.activatable = true;
+                collection_row.activated.connect(() => window.load_collection.begin(collection));
+                collection_list.append (collection_row);
+            }
+        }
+
+        private void add_collection() {
+            var add_collection_window = new AddCollectionWindow(window);
+            add_collection_window.present();
         }
 
 

+ 11 - 2
src/Window.vala

@@ -7,10 +7,12 @@ namespace Publicate {
     public class ViewerWindow : Adw.ApplicationWindow {
 
         private Stack stack;
-        private StartupMenu startup_menu;
+        public StartupMenu startup_menu;
         public PpubEditor editor {get; set;}
         public Ppub.Publication publication {get; set;}
         public Menu window_menu { get; set; }
+        public CollectionService collection_service { get; set;}
+        public CollectionView collection_view { get; set; }
 
         private SimpleAction extract_action;
 
@@ -21,9 +23,11 @@ namespace Publicate {
             window_menu.append("New window", "win.new-window");
             window_menu.append("Manage identities", "win.manage-identities");
 
+            collection_service = new CollectionService();
             stack = new Stack();
             startup_menu = new StartupMenu(this);
             editor = new PpubEditor(this);
+            collection_view = new CollectionView(this);
 
             default_height = 600;
             default_width = 800;
@@ -31,6 +35,7 @@ namespace Publicate {
             content = stack;
             stack.add_child(startup_menu);
             stack.add_child(editor);
+            stack.add_child(collection_view);
             stack.transition_type = StackTransitionType.CROSSFADE;
 
             var save_action = new SimpleAction("save", null);
@@ -78,7 +83,7 @@ namespace Publicate {
 
         public async void load_ppub(File file) {
             stack.visible_child = editor;
-            yield editor.load_ppub(file);
+            yield editor.load_ppub(file, true);
         }
 
         public void manage_identities() {
@@ -87,6 +92,10 @@ namespace Publicate {
             window.present();
         }
 
+        public async void load_collection(CollectionConfig collection) {
+            stack.visible_child = collection_view;
+            yield collection_view.show_collection(collection);
+        }
 
     }
 

+ 1 - 0
src/Wizards/Standard.vala

@@ -62,6 +62,7 @@ namespace Publicate.Wizards {
         private async void create_ppub() {
 
             var dialog = new FileDialog();
+            dialog.initial_name = title.text.replace(" ", "_") + ".ppub";
             var filter = new FileFilter();
             var filters = new GLib.ListStore(Type.OBJECT);
             filters.append(filter);

+ 1 - 0
src/Wizards/Video.vala

@@ -76,6 +76,7 @@ namespace Publicate.Wizards {
         private async void create_ppub() {
 
             var dialog = new FileDialog();
+            dialog.initial_name = title.text.replace(" ", "_") + ".ppub";
             var filter = new FileFilter();
             var filters = new GLib.ListStore(Type.OBJECT);
             filters.append(filter);

+ 4 - 0
src/meson.build

@@ -13,6 +13,10 @@ sources += files('Savable.vala')
 sources += files('FileChooser.vala')
 sources += files('FileCreationPopover.vala')
 sources += files('LicenceChooser.vala')
+sources += files('CollectionView.vala')
+sources += files('AddCollectionWindow.vala')
+sources += files('CollectionService.vala')
+sources += files('PublishWindow.vala')
 sources += files('Editors/EditorWidget.vala')
 sources += files('Editors/MarkdownEditor.vala')
 sources += files('Editors/PlainTextEditor.vala')