Ver código fonte

MVP for PPIX and PPCL support

Billy Barrow 1 ano atrás
pai
commit
e05c218668

+ 435 - 0
PpubMarkdown.lang

@@ -0,0 +1,435 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ Author: Jean-Philippe Fleury & Billy Barrow
+ Copyright (C) 2011 Jean-Philippe Fleury <contact@jpfleury.net>
+ Copyright (C) 2024 Billy Barrow
+
+ GtkSourceView 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.
+
+ GtkSourceView 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 this library; if not, see <http://www.gnu.org/licenses/>.
+
+-->
+<!-- Note: this language definition file adds support for Markdown syntax,
+     described in the following websites:
+     * (fr) <http://michelf.com/projets/php-markdown/syntaxe/>
+     * (en) <http://daringfireball.net/projects/markdown/syntax> -->
+<language id="ppub-markdown" name="Ppub Markdown" version="2.0" _section="Markup">
+  <metadata>
+    <property name="mimetypes">text/x-markdown</property>
+    <property name="globs">*.markdown;*.md;*.mkd</property>
+    <property name="block-comment-start">&lt;!--</property>
+    <property name="block-comment-end">--&gt;</property>
+    <property name="suggested-suffix">.md</property>
+  </metadata>
+
+  <styles>
+    <style id="header" name="Header" map-to="def:heading"/>
+    <style id="horizontal-rule" name="Horizontal Rule" map-to="def:thematic-break"/>
+    <style id="list-marker" name="List Marker" map-to="def:list-marker"/>
+    <style id="code-span" name="Code Span" map-to="def:inline-code"/>
+    <style id="code-block" name="Code Block" map-to="def:preformatted-section"/>
+    <style id="blockquote-marker" name="Blockquote Marker" map-to="def:shebang"/>
+    <style id="url" name="URL" map-to="def:link-destination"/>
+    <style id="link-text" name="Link Text" map-to="def:link-text"/>
+    <style id="label" name="Label" map-to="def:preprocessor"/>
+    <style id="attribute-value" name="Attribute Value" map-to="def:constant"/>
+    <style id="image-marker" name="Image Marker" map-to="def:link-symbol"/>
+    <style id="emphasis" name="Emphasis" map-to="def:emphasis"/>
+    <style id="strong-emphasis" name="Strong Emphasis" map-to="def:strong-emphasis"/>
+    <style id="backslash-escape" name="Backslash Escape" map-to="def:special-char"/>
+    <style id="line-break" name="Line Break" map-to="def:note"/>
+  </styles>
+
+  <definitions>
+    <!-- Examples:
+         # Header 1 #
+         ## Header 2
+         ###Header 3###
+    -->
+    <context id="atx-header-1" class="h1" style-ref="header">
+      <match>^# .+</match>
+    </context>
+    <context id="atx-header-2" class="h2" style-ref="header">
+      <match>^## .+</match>
+    </context>
+    <context id="atx-header-3" class="h3" style-ref="header">
+      <match>^### .+</match>
+    </context>
+    <context id="atx-header-4" class="h4" style-ref="header">
+      <match>^#### .+</match>
+    </context>
+    <context id="atx-header-5" class="h5" style-ref="header">
+      <match>^##### .+</match>
+    </context>
+    <context id="atx-header-6" class="h6" style-ref="header">
+      <match>^###### .+</match>
+    </context>
+
+    <!-- Examples:
+         Header 1
+         ========
+         Header 2
+         -
+    -->
+    <!-- Note: line break can't be used in regex, so only underline is matched. -->
+    <context id="setext-header-1" class="setext-h1" style-ref="header">
+      <match>^(=+)[ \t]*$</match>
+    </context>
+    <context id="setext-header-2" class="setext-h1" style-ref="header">
+      <match>^(-+)[ \t]*$</match>
+    </context>
+
+    <!-- Examples:
+         - - -
+         **  **  **  **  **
+         _____
+    -->
+    <context id="horizontal-rule" class="no-format" style-ref="horizontal-rule">
+      <match extended="true">
+        ^[ ]{0,3}            # Maximum 3 spaces at the beginning of the line.
+        (
+          (-[ ]{0,2}){3,} | # 3 or more hyphens, with 2 spaces maximum between each hyphen.
+          (_[ ]{0,2}){3,} | # Idem, but with underscores.
+          (\*[ ]{0,2}){3,}  # Idem, but with asterisks.
+        )
+        [ \t]*$              # Optional trailing spaces or tabs.
+      </match>
+    </context>
+
+    <!-- Note about following list and code block contexts: according to the
+         Markdown syntax, to write several paragraphs in a list item, we have
+         to indent each paragraph. Example:
+
+         - Item A (paragraph 1).
+
+             Item A (paragraph 2).
+
+             Item A (paragraph 3).
+
+         - Item B.
+
+         So there is a conflict in terms of syntax highlighting between an
+         indented paragraph inside a list item (4 spaces or 1 tab) and an
+         indented line of code outside a list (also 4 spaces or 1 tab). In this
+         language file, since a full context analysis can't be done (because
+         line break can't be used in regex), the choice was made ​​to highlight
+         code block only from 2 levels of indentation. -->
+
+    <!-- Example (unordered list):
+         * Item
+         + Item
+         - Item
+
+         Example (ordered list):
+         1. Item
+         2. Item
+         3. Item
+    -->
+    <context id="list" class="list" style-ref="list-marker">
+      <match extended="true">
+        ^[ ]{0,3}  # Maximum 3 spaces at the beginning of the line.
+        (
+          \*|\+|-| # Asterisk, plus or hyphen for unordered list.
+          [0-9]+\. # Number followed by period for ordered list.
+        )
+        [ \t]+     # Must be followed by at least 1 space or 1 tab.
+      </match>
+    </context>
+
+    <!-- Example:
+                 <em>HTML code</em> displayed <strong>literally</strong>.
+    -->
+    <context id="code-block" class="pre no-format no-spell-check">
+      <match>^( {8,}|\t{2,})([^ \t]+.*)</match>
+
+      <include>
+        <context sub-pattern="2" style-ref="code-block"/>
+      </include>
+    </context>
+
+    <!-- Note about following code span contexts: within a paragraph, text
+         wrapped with backticks indicates a code span. Markdown allows to use
+         one or more backticks to wrap text, provided that the number is identical
+         on both sides, and the same number of consecutive backticks is not
+         present within the text. The current language file supports code span
+         highlighting with up to 2 backticks surrounding text. -->
+
+    <!-- Examples:
+         Here's a literal HTML tag: `<p>`.
+         `Here's a code span containing ``backticks``.`
+    -->
+    <context id="1-backtick-code-span" class="pre no-format no-spell-check" style-ref="code-span">
+      <match>(?&lt;!`)`[^`]+(`{2,}[^`]+)*`(?!`)</match>
+    </context>
+
+    <!-- Examples:
+         Here's a literal HTML tag: ``<p>``.
+         ``The grave accent (`) is used in Markdown to indicate a code span.``
+         ``Here's another code span containing ```backticks```.``
+    -->
+    <context id="2-backticks-code-span" class="pre no-format no-spell-check" style-ref="code-span">
+      <match>(?&lt;!`)``[^`]+((`|`{3,})[^`]+)*``(?!`)</match>
+    </context>
+
+    <context id="3-backticks-code-span" class="pre no-format no-spell-check" style-ref="code-block">
+      <start>^```.*$</start>
+      <end>^```$</end>
+    </context>
+
+    <!-- Example:
+         > Quoted text.
+         > Quoted text with `code span`.
+         >> Blockquote **nested**.
+    -->
+    <!-- Note: blockquote can contain block-level and inline Markdown elements,
+         but the current language file only highlights inline ones (emphasis,
+         link, etc.). -->
+    <context id="blockquote" class="quot" end-at-line-end="true">
+      <start>^( {0,3}&gt;(?=.)( {0,4}&gt;)*)</start>
+
+      <include>
+        <context sub-pattern="1" where="start" style-ref="blockquote-marker"/>
+        <context ref="1-backtick-code-span"/>
+        <context ref="2-backticks-code-span"/>
+        <context ref="3-backticks-code-span"/>
+        <context ref="automatic-link"/>
+        <context ref="inline-link"/>
+        <context ref="reference-link"/>
+        <context ref="inline-image"/>
+        <context ref="reference-image"/>
+        <context ref="underscores-emphasis"/>
+        <context ref="asterisks-emphasis"/>
+        <context ref="underscores-strong-emphasis"/>
+        <context ref="asterisks-strong-emphasis"/>
+        <context ref="backslash-escape"/>
+        <context ref="line-break"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         <user@example.com>
+         <http://www.example.com/>
+    -->
+    <!-- Note: regular expressions are based from function `_DoAutoLinks` from
+         Markdown.pl (see <http://daringfireball.net/projects/markdown/>). -->
+    <context id="automatic-link" class="link no-spell-check no-format">
+      <match case-sensitive="false" extended="true">
+        &lt;
+          (((mailto:)?[a-z0-9.-]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+) | # E-mail.
+          ((https?|ftp):[^'">\s]+))                                     # URL.
+        &gt;
+      </match>
+
+      <include>
+        <context sub-pattern="1" style-ref="url"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         [link text](http://www.example.com/)
+         [link text](<http://www.example.com/>)
+         [link text]( /folder/page.html "Title" )
+    -->
+    <context id="inline-link" class="link no-format">
+      <match extended="true">
+        \[(.*?)\]          # Link text.
+        \(                 # Literal opening parenthesis.
+          [ \t]*           # Optional spaces or tabs after the opening parenthesis.
+          (&lt;(.*?)&gt; | # URL with brackets.
+          (.*?))           # URL without brackets.
+          ([ \t]+(".*?"))? # Optional title.
+          [ \t]*           # Optional spaces or tabs before the closing parenthesis.
+        \)                 # Literal closing parenthesis.
+      </match>
+
+      <include>
+        <context sub-pattern="1" style-ref="link-text"/>
+        <context sub-pattern="3" class="no-spell-check" style-ref="url"/>
+        <context sub-pattern="4" class="no-spell-check" style-ref="url"/>
+        <context sub-pattern="6" style-ref="attribute-value"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         [link text]
+         [link text][]
+         [link text][link label]
+         [link text] [link label]
+    -->
+    <!-- Note: some assertions are used to differentiate reference link from
+         link label. -->
+    <context id="reference-link" class="link no-format">
+      <match>(?&lt;!^ |^  |^   )\[(.*?)\]([ \t]?\[(.*?)\])?(?!:)</match>
+
+      <include>
+        <context sub-pattern="1" style-ref="link-text"/>
+        <context sub-pattern="3" class="no-spell-check" style-ref="label"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         [link label]: /folder/page.html
+         [link label]: <http://www.example.com/>
+         [link label]: http://www.example.com/ "Title"
+    -->
+    <context id="link-definition" class="link no-format">
+      <match extended="true">
+        ^[ ]{0,3}             # Maximum 3 spaces at the beginning of the line.
+        \[(.+?)\]:            # Link label and colon.
+        [ \t]*                # Optional spaces or tabs.
+        (&lt;([^ \t]+?)&gt; | # URL with brackets.
+        ([^ \t]+?))           # URL without brackets.
+        ([ \t]+(".*?"))?      # Optional title.
+        [ \t]*$               # Optional trailing spaces or tabs.
+      </match>
+
+      <include>
+        <context sub-pattern="1" class="no-spell-check" style-ref="label"/>
+        <context sub-pattern="3" class="no-spell-check" style-ref="url"/>
+        <context sub-pattern="4" class="no-spell-check" style-ref="url"/>
+        <context sub-pattern="6" style-ref="attribute-value"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         ![alt text](http://www.example.com/image.jpg)
+         ![alt text]( <http://www.example.com/image.jpg> )
+         ![alt text] (/path/to/image.jpg "Title")
+    -->
+    <context id="inline-image" class="image no-format">
+      <match extended="true">
+        (!)                     # Leading ! sign.
+        \[(.*?)\][ ]?           # Alternate text for the image (and optional space).
+        \(                      # Literal parenthesis.
+          [ \t]*                # Optional spaces or tabs after the opening parenthesis.
+          (&lt;([^ \t]*?)&gt; | # Image path or URL with brackets.
+          ([^ \t]*?))           # Image path or URL without brackets.
+          ([ \t]+(".*?"))?      # Optional title.
+          [ \t]*                # Optional spaces or tabs before the closing parenthesis.
+        \)                      # Literal parenthesis.
+      </match>
+
+      <include>
+        <context sub-pattern="1" style-ref="image-marker"/>
+        <context sub-pattern="2" style-ref="attribute-value"/>
+        <context sub-pattern="4" class="no-spell-check" style-ref="url"/>
+        <context sub-pattern="5" class="no-spell-check" style-ref="url"/>
+        <context sub-pattern="6" style-ref="attribute-value"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         ![alt text][image label]
+         ![alt text] [image label]
+    -->
+    <context id="reference-image" class="image no-format">
+      <match>(!)\[(.*?)\] ?\[(.*?)\]</match>
+
+      <include>
+        <context sub-pattern="1" style-ref="image-marker"/>
+        <context sub-pattern="2" style-ref="attribute-value"/>
+        <context sub-pattern="3" class="no-spell-check" style-ref="label"/>
+      </include>
+    </context>
+
+    <!-- Examples:
+         Lorem _ipsum dolor_ sit amet.
+         Here's an _emphasized text containing an underscore (\_)_.
+    -->
+    <context id="underscores-emphasis" style-ref="emphasis" class="i">
+      <match>(?&lt;!_)_[^_ \t].*?(?&lt;!\\|_| |\t)_(?!_)</match>
+    </context>
+
+    <!-- Examples:
+         Lorem *ipsum dolor* sit amet.
+         Here's an *emphasized text containing an asterisk (\*)*.
+    -->
+    <context id="asterisks-emphasis" style-ref="emphasis" class="i">
+      <match>(?&lt;!\*)\*[^\* \t].*?(?&lt;!\\|\*| |\t)\*(?!\*)</match>
+    </context>
+
+    <!-- Examples:
+         Lorem __ipsum dolor__ sit amet.
+         Here's a __strongly emphasized text containing an underscore (\_)__.
+    -->
+    <context id="underscores-strong-emphasis" style-ref="strong-emphasis" class="b">
+      <match>__[^_ \t].*?(?&lt;!\\|_| |\t)__</match>
+    </context>
+
+    <!-- Examples:
+         Lorem **ipsum dolor** sit amet.
+         Here's a **strongly emphasized text containing an asterisk (\*).**
+    -->
+    <context id="asterisks-strong-emphasis" style-ref="strong-emphasis" class="b">
+      <match>\*\*[^\* \t].*?(?&lt;!\\|\*| |\t)\*\*</match>
+    </context>
+
+    <context id="backslash-escape" style-ref="backslash-escape" class="escape">
+      <match>\\[\\`*_{}\[\]()#+-.!]</match>
+    </context>
+
+    <!-- Note: a manual line break should be followed by a line containing text,
+         but since line break can't be used in regex, only trailing spaces or tabs
+         are matched. -->
+    <context id="line-break">
+      <match>(?&lt;=[^ \t])([ \t]{2,})$</match>
+
+      <include>
+        <context sub-pattern="1" style-ref="line-break"/>
+      </include>
+    </context>
+
+    <context id="markdown-syntax">
+      <include>
+        <context ref="atx-header-1"/>
+        <context ref="atx-header-2"/>
+        <context ref="atx-header-3"/>
+        <context ref="atx-header-4"/>
+        <context ref="atx-header-5"/>
+        <context ref="atx-header-6"/>
+        <context ref="setext-header-1"/>
+        <context ref="setext-header-2"/>
+        <context ref="horizontal-rule"/>
+        <context ref="list"/>
+        <context ref="code-block"/>
+        <context ref="1-backtick-code-span"/>
+        <context ref="2-backticks-code-span"/>
+        <context ref="3-backticks-code-span"/>
+        <context ref="blockquote"/>
+        <context ref="automatic-link"/>
+        <context ref="inline-link"/>
+        <context ref="reference-link"/>
+        <context ref="link-definition"/>
+        <context ref="inline-image"/>
+        <context ref="reference-image"/>
+        <context ref="underscores-emphasis"/>
+        <context ref="asterisks-emphasis"/>
+        <context ref="underscores-strong-emphasis"/>
+        <context ref="asterisks-strong-emphasis"/>
+        <context ref="backslash-escape"/>
+        <context ref="line-break"/>
+      </include>
+    </context>
+
+    <replace id="html:embedded-lang-hook-content" ref="markdown-syntax"/>
+
+    <context id="ppub-markdown">
+      <include>
+        <context ref="markdown-syntax"/>
+        <!-- Note: even if it's highlighted, Markdown syntax within HTML blocks
+             (e.g., `<div>`) is not processed. -->
+        <context ref="html:html"/>
+      </include>
+    </context>
+  </definitions>
+</language>

+ 3 - 3
README.md

@@ -17,15 +17,15 @@ It is still a work in progress, and also depends on some of my other home grown
 - Editing PPVM (PPub Video Manifest) files.
 - Guided creation of PPVM based PPUBs (PPUB files containing video).
 - Templates for selecting a publication licence and copyright messages.
+- Save as
+- Easily open multuple instances
 
 ## What is still to come
 
 - Button tooltips
 - Guided link and image insertion in the markdown editor.
-- Save as
 - Templated creation of files that don't already exist in a PPUB.
-- Easily open multuple instances
 - System file association for PPUBs
-- Ability to generate PPIX files (PPub IndeX) once LibPpub has the functionality.
 - Updating the PPUB `date` metadata automatically on all saves (currently only updates when metadata is saved).
 - Drag and drop files into PPUB
+- Format editing for link text

+ 4 - 1
src/App.vala

@@ -51,7 +51,10 @@ namespace Publicate {
         }
         if(!FileUtils.test (get_publicate_path() + "/collections.config", FileTest.EXISTS)) {
             var template = "{\"collections\": []}";
-            FileUtils.set_contents (get_publicate_path() + "/collections.config", template, template.length);
+            try {
+                FileUtils.set_contents (get_publicate_path() + "/collections.config", template, template.length);
+            }
+            catch(Error e) {}
         }
     }
     

+ 5 - 0
src/CollectionService.vala

@@ -25,6 +25,11 @@ namespace Publicate {
             yield save_collection(config.with(collection));
         }
 
+        public async void remove_collection(CollectionConfig collection) throws Error {
+            var config = yield get_collection_config();
+            yield save_collection(config.where(c => c.collection_id != collection.collection_id));
+        }
+
         public async void save_collection(Enumerable<CollectionConfig> config) throws Error {
 
             var collections = new Json.Array();

+ 214 - 1
src/CollectionView.vala

@@ -10,6 +10,7 @@ namespace Publicate {
         private ListBox publication_list;
         private WindowTitle header_title;
         private ProgressBar osd_bar;
+        private CollectionConfig config;
 
         private ViewerWindow window;
 
@@ -26,10 +27,29 @@ namespace Publicate {
             header_title = new WindowTitle("", "");
             header_bar.title_widget = header_title;
             
+            var back_button = new Button.from_icon_name("go-previous-symbolic");
+            back_button.clicked.connect(() => window.back());
+            header_bar.pack_start(back_button);
+
             menu_button = new MenuButton();
             menu_button.icon_name = "open-menu-symbolic";
             header_bar.pack_end(menu_button);
-            menu_button.menu_model = win.window_menu;
+            var menu = new Menu();
+            menu_button.menu_model = menu;
+            menu.append_section(null, win.window_menu);
+
+            var collection_menu = new Menu();
+            collection_menu.append("Publish…", "win.publish");
+            collection_menu.append("Remove Collection", "win.remove-collection");
+            menu.append_section(null, collection_menu);
+
+            var publish_action = new SimpleAction("publish", null);
+            publish_action.activate.connect(() => publish.begin());
+            window.add_action(publish_action);
+
+            var remove_action = new SimpleAction("remove-collection", null);
+            remove_action.activate.connect(() => remove_collection.begin());
+            window.add_action(remove_action);
 
             append(header_bar);
 
@@ -44,13 +64,206 @@ namespace Publicate {
             append(scroll_window);
 
             publication_list = new ListBox ();
+            publication_list.halign = Align.CENTER;
+            publication_list.width_request = 500;
+            publication_list.margin_top = 18;
             scroll_window.child = publication_list;
         }
 
         public async void show_collection(CollectionConfig config) {
+            publication_list.remove_all();
+            this.config = config;
             header_title.title = config.name;
             header_title.subtitle = @"via $(config.domain)";
+
+            osd_bar.show();
+            osd_bar.fraction = 0.125;
+            try {
+                var client = yield window.collection_service.get_client(config);
+                osd_bar.fraction = 0.25;
+                var collection_id = new Invercargill.BinaryData.from_base64(config.collection_id);
+                var results = yield do_in_bg<Pprf.Messages.CollectionListing>(() => client.get_listing(collection_id, 0, 255,  Pprf.Messages.ListingColumn.TITLE | Pprf.Messages.ListingColumn.AUTHOR | Pprf.Messages.ListingColumn.POSTER));
+
+                var with_images = new Invercargill.Series<PublicationRow>();
+                foreach (var res in results.results) {
+                    var row = new PublicationRow(res);
+                    row.unpublish_clicked.connect(name => unpublish.begin(name));
+                    row.download_clicked.connect(name => download.begin(name));
+                    publication_list.append(row);
+                    if(res.metadata.poster != null) {
+                        with_images.add(row);
+                    }
+                }
+                osd_bar.fraction = 0.375;
+                var count = with_images.count();
+                var loaded = 0;
+                foreach (var row in with_images) {
+                    yield row.load_image(client, collection_id);
+                    loaded++;
+                    osd_bar.fraction = 0.375 + (((double)loaded / (double)count) * 0.625);
+                }
+
+                osd_bar.fraction = 1;
+                osd_bar.hide();
+            }
+            catch(Error e) {
+                var prompt = new Adw.MessageDialog(window, "Collection Load Failed", @"Could not load collection: $(e.message)");
+                prompt.add_response("ok", "Close");
+                prompt.present();
+                prompt.response.connect(() => {
+                    window.back();
+                });
+            }
         }
 
+        public async void publish() {
+            var dialog = new FileDialog();
+            var filter = new FileFilter();
+            var filters = new GLib.ListStore(Type.OBJECT);
+            filters.append(filter);
+            filter.add_pattern("*.ppub");
+            filter.name = "Portable Publications";
+            dialog.filters = filters;
+
+            var file = yield dialog.open(window, null);
+            if(file == null) {
+                return;
+            }
+            
+            var publish_window = new PublishWindow(window);
+            publish_window.hide_on_close = true;
+            publish_window.hide.connect(() => show_collection.begin(config));
+            publish_window.present();
+            yield publish_window.publish_to(config, file);
+        }
+
+        private async void unpublish(string name) {
+            var publish_window = new PublishWindow(window);
+            publish_window.hide_on_close = true;
+            publish_window.hide.connect(() => show_collection.begin(config));
+            publish_window.present();
+            yield publish_window.unpublish_from(config, name);
+        }
+
+        public async void download(string name) {
+            var dialog = new FileDialog();
+            var filter = new FileFilter();
+            var filters = new GLib.ListStore(Type.OBJECT);
+            filters.append(filter);
+            filter.add_pattern("*.ppub");
+            filter.name = "Portable Publications";
+            dialog.filters = filters;
+            dialog.initial_name = name;
+            var progress = new SaveProgress(window);
+            
+            var file = yield dialog.save(window, null);
+            
+            if(file == null) {
+                return;
+            }
+            
+            try {
+                progress.display_progress(@"Connecting to $(config.domain)…", 0.0);
+                var client = yield window.collection_service.get_client(config);
+                var collection_id = new Invercargill.BinaryData.from_base64(config.collection_id);
+                progress.display_progress(@"Requesting publication…", 0.0);
+                var result = yield do_in_bg<Pprf.Messages.Publication>(() => client.get_publication(collection_id, name));
+                progress.display_progress(@"Downloading publication…", 0.0);
+    
+                var stream = yield file.replace_async(null, false, FileCreateFlags.REPLACE_DESTINATION, 1);
+                var body = (Pprf.Messages.StreamMessageBody)result.ppub_data;
+                double frac = 0.0;
+                body.data_written.connect((r, t) => {
+                    frac = ((double)r/(double)t);
+                    Idle.add_once(() => progress.display_progress(@"Downloading publication…", frac));
+                });
+    
+                yield do_void_in_bg(() => body.write_to(stream));
+                progress.display_progress(@"Downloading publication…", 1.0);
+    
+                yield stream.close_async(1);
+                progress.close();
+                yield window.load_ppub(file);
+            }
+            catch(Error e) {
+                progress.close();
+                var prompt = new Adw.MessageDialog(window, "Download Failed", @"Could not download publication: $(e.message)");
+                prompt.add_response("ok", "Close");
+                prompt.present();
+            }
+        }
+
+        public async void remove_collection() {
+            var prompt = new Adw.MessageDialog(window, "Remove Collection?", @"Are you sure you want to delete the collection $(config.name)? The collection won't appear in Publicate unless you add it again.");
+            prompt.add_response("cancel", "Cancel");
+            prompt.add_response("delete", "Delete");
+            prompt.set_response_appearance("delete", ResponseAppearance.DESTRUCTIVE);
+            prompt.response.connect(r => {
+                if(r == "delete") {
+                    do_remove.begin();
+                }
+            });
+            prompt.present();
+        }
+
+        private async void do_remove() {
+            try {
+                yield window.collection_service.remove_collection(config);
+                yield window.startup_menu.load_collection_list();
+                window.back();
+            }
+            catch(Error e) {
+                var err = new Adw.MessageDialog(window, "Couldn't Remove Collection", @"$(e.message)");
+                err.add_response("ok", "Close");
+                err.present();
+            }
+        }
+    }
+
+    private class PublicationRow : ActionRow {
+        public Pprf.Messages.CollectionListingItem item;
+        private Image image;
+        public signal void unpublish_clicked(string name);
+        public signal void download_clicked(string name);
+
+        public PublicationRow(Pprf.Messages.CollectionListingItem item) {
+            this.item = item;
+            title = item.metadata.title;
+            subtitle = item.metadata.author;
+            add_css_class("card");
+            margin_bottom = 18;
+            image = new Image.from_icon_name("x-office-document-symbolic");
+            image.set_icon_size(IconSize.LARGE);
+            image.width_request = 128;
+            image.height_request = 128;
+            image.margin_start = 8;
+            selectable = false;
+            add_prefix(image);
+
+            var unpublish_button = new Button.from_icon_name("edit-delete-symbolic");
+            unpublish_button.tooltip_text = "Unpublish";
+            unpublish_button.clicked.connect(() => unpublish_clicked(item.name));
+            unpublish_button.valign = Align.CENTER;
+            add_suffix(unpublish_button);
+
+            var download_button = new Button.from_icon_name("folder-download-symbolic");
+            download_button.margin_end = 8;
+            download_button.tooltip_text = "Download";
+            download_button.clicked.connect(() => download_clicked(item.name));
+            download_button.valign = Align.CENTER;
+            add_suffix(download_button);
+        }
+
+        public async void load_image(Pprf.Client client, Invercargill.BinaryData collection_id) {
+            try {
+                var asset = yield do_in_bg<Pprf.Messages.Asset>(() => client.get_asset(collection_id, item.name, item.metadata.poster));
+                var pixbuf = yield new Gdk.Pixbuf.from_stream_at_scale_async(asset.asset_data.as_stream(), 128, 128, true);
+                var paintable = Gdk.Texture.for_pixbuf(pixbuf);
+                image.set_from_paintable(paintable);
+            }
+            catch(Error e) {
+                image.set_from_icon_name("image-missing-symbolic");
+            }
+        }
     }
 }

+ 75 - 49
src/Editor.vala

@@ -118,7 +118,9 @@ namespace Publicate {
             file_explorer.set_assets(window.publication.assets);
             if(initial) {
                 window.maximize();
-                yield open_asset(window.publication.get_default_asset());
+                var asset = window.publication.get_default_asset();
+                yield open_asset(asset);
+                file_explorer.set_selected_item(asset.name);
                 yield add_publish_menu();
             }
         }
@@ -200,7 +202,7 @@ namespace Publicate {
         }
 
         private void add_editor(Editors.EditorWidget editor, Ppub.Asset asset) {
-            editor.load_asset(window.publication, asset);
+            editor.load_asset.begin(window.publication, asset);
             open_editors.set(asset.name, editor);
             tab_view.selected_page = editor.tab_page;
         }
@@ -216,45 +218,58 @@ namespace Publicate {
             SourceFunc callback = do_save.callback;
             
             ThreadFunc<bool> run = () => {
-                var items = to_save.count();
-                var count = 0;
-                var builder = new Ppub.Builder();
-                foreach (var item in to_save) {
-                    count++;
-                    item.save_asset(builder);
+                try {
+                    var items = to_save.count();
+                    var count = 0;
+                    var builder = new Ppub.Builder();
+                    foreach (var item in to_save) {
+                        count++;
+                        item.save_asset(builder);
+                        Idle.add(() => {
+                            progress_window.display_progress("Processing Assets…", (double)count / (double)items);
+                            return false;
+                        });
+                    }
+        
                     Idle.add(() => {
-                        progress_window.display_progress("Processing Assets…", (double)count / (double)items);
+                        progress_window.display_progress("Writing PPUB…", 1.0);
                         return false;
                     });
-                }
     
-                Idle.add(() => {
-                    progress_window.display_progress("Writing PPUB…", 1.0);
-                    return false;
-                });
-
-                var temp_file = File.new_for_path(publication_file.get_path() + ".tmp");
-                var stream = temp_file.replace(null, false, FileCreateFlags.REPLACE_DESTINATION);
+                    var temp_file = File.new_for_path(publication_file.get_path() + ".tmp");
+                    var stream = temp_file.replace(null, false, FileCreateFlags.REPLACE_DESTINATION);
+        
+                    builder.write(stream);
+                    stream.close();
+                    
+                    temp_file.move(publication_file, FileCopyFlags.OVERWRITE);
     
-                builder.write(stream);
-                stream.close();
-                
-                temp_file.move(publication_file, FileCopyFlags.OVERWRITE);
-
-                Idle.add(() => {
-                    load_ppub.begin(publication_file, false, () => {
-                        select_file_from_tab();
-                        callback();
+                    Idle.add(() => {
+                        load_ppub.begin(publication_file, false, () => {
+                            select_file_from_tab();
+                            callback();
+                        });
+                        return false;
                     });
+                    return true;
+                }
+                catch {
+                    Idle.add_once(() => save_failure("There was a problem saving the publication"));
                     return false;
-                });
-                return true;
+                }
             };
             new Thread<bool>("saver thread", run);
             yield;
 
         }
 
+        private void save_failure(string error) {
+            progress_window.hide();
+            var err = new Adw.MessageDialog(window, "Error Saving Publication", error);
+            err.add_response("ok", "Close");
+            err.present();
+        }
+
         public async void save_tab() {
             var current_editor = (Editors.EditorWidget)tab_view.selected_page.child;
             var to_save = window.publication.assets.select<Savable>(a => a.name == current_editor.asset_name ? (Savable)current_editor : new SavableAsset(window.publication, a));
@@ -271,15 +286,20 @@ namespace Publicate {
             filter.name = "Portable Publications";
             dialog.filters = filters;
             var file = yield dialog.save(window, null);
-
+            
             if(file == null) {
                 return;
             }
-
-            progress_window.display_progress("Saving…", 0.0);
-            yield publication_file.copy_async(file, FileCopyFlags.OVERWRITE, 1, null);
-            publication_file = file;
-            yield save_tab();
+            
+            try {
+                progress_window.display_progress("Saving…", 0.0);
+                yield publication_file.copy_async(file, FileCopyFlags.OVERWRITE, 1, null);
+                publication_file = file;
+                yield save_tab();
+            }
+            catch(Error e) {
+                save_failure(@"There was a problem saving the publication: $(e.message)");
+            }
         }
 
         public async void save_all() {
@@ -320,28 +340,34 @@ namespace Publicate {
         }
 
         public async void import_file() {
-
             var dialog = new FileDialog();
             var file = yield dialog.open(window, null);
-
+            
             if(file == null) {
                 return;
             }
             
-            var stream = yield file.read_async(Priority.DEFAULT, null);
-            var sample = new uint8[2048];
-            size_t sample_size;
-            yield stream.read_all_async(sample, Priority.DEFAULT, null, out sample_size);
-            stream.seek(0, SeekType.SET, null);
-            var mimetype = Ppub.guess_mimetype(file.get_basename(), sample);
-            var compression_policy = Ppub.CompressionPolicy.AUTO;
-            if(mimetype.has_prefix("video/")) {
-                compression_policy = Ppub.CompressionPolicy.NEVER_COMPRESS;
+            try {
+                var stream = yield file.read_async(Priority.DEFAULT, null);
+                var sample = new uint8[2048];
+                size_t sample_size;
+                yield stream.read_all_async(sample, Priority.DEFAULT, null, out sample_size);
+                stream.seek(0, SeekType.SET, null);
+                var mimetype = Ppub.guess_mimetype(file.get_basename(), sample);
+                var compression_policy = Ppub.CompressionPolicy.AUTO;
+                if(mimetype.has_prefix("video/")) {
+                    compression_policy = Ppub.CompressionPolicy.NEVER_COMPRESS;
+                }
+                var compression = new Ppub.CompressionInfo(stream, compression_policy);
+                stream.seek(0, SeekType.SET, null);
+    
+                yield add_asset(file.get_basename(), mimetype, stream, compression);
+            }
+            catch(Error e) {
+                var err = new Adw.MessageDialog(window, "Couldn't Import File", @"There was a problem importing that file: $(e.message)");
+                err.add_response("ok", "Close");
+                err.present();
             }
-            var compression = new Ppub.CompressionInfo(stream, compression_policy);
-            stream.seek(0, SeekType.SET, null);
-
-            yield add_asset(file.get_basename(), mimetype, stream, compression);
         }
 
         private bool app_close_request() {

+ 477 - 4
src/Editors/MarkdownEditor.vala

@@ -8,6 +8,7 @@ namespace Publicate.Editors {
 
         private Paned leaflet;
 
+        private Box toolbar;
         private GtkCommonMark.MarkdownView markdown_view;
         private GtkSource.View text_view;
         private GtkSource.Buffer source_buffer;
@@ -20,8 +21,25 @@ namespace Publicate.Editors {
         private ViewerWindow window;
         private TabPage page;
 
+        private DropDown style_selector;
+        private ToggleButton bold_button;
+        private ToggleButton italic_button;
+        private Button insert_link;
+        private Button insert_image;
+
+        private const string STYLE_HEADING_1 = "Heading 1";
+        private const string STYLE_HEADING_2 = "Heading 2";
+        private const string STYLE_HEADING_3 = "Heading 3";
+        private const string STYLE_HEADING_4 = "Heading 4";
+        private const string STYLE_HEADING_5 = "Heading 5";
+        private const string STYLE_HEADING_6 = "Heading 6";
+        private const string STYLE_QUOTATION = "Quotation";
+        private const string STYLE_PREFORMATTED = "Preformatted";
+        private const string STYLE_PARAGRAPH = "Paragraph";
+
         private Spelling.Checker spell_checker;
         private Spelling.TextBufferAdapter spell_adapter;
+        private bool inhibit_toolbar_updates = false;
 
         private Gee.HashMap<string, Gdk.Pixbuf> pixbuf_cache = new Gee.HashMap<string, Gdk.Pixbuf>();
 
@@ -44,8 +62,8 @@ namespace Publicate.Editors {
 
         public MarkdownEditor(ViewerWindow win, TabView tab_view) {
             window = win;
-
             orientation = Orientation.VERTICAL;
+
             source_scroller = new ScrolledWindow ();
             markdown_scroller = new ScrolledWindow ();
             leaflet = new Paned(Orientation.HORIZONTAL);
@@ -57,7 +75,8 @@ namespace Publicate.Editors {
             text_view.show_line_numbers = true;
             text_view.auto_indent = true;
             source_buffer = (GtkSource.Buffer) text_view.buffer;
-            source_buffer.language = language_manager.guess_language ("file.md", "text/markdown");
+            language_manager.append_search_path("/usr/local/share/publicate/gtk-source-view");
+            source_buffer.language = language_manager.get_language("ppub-markdown");
             text_view.hexpand = true;
             text_view.set_wrap_mode (WrapMode.WORD_CHAR);
             text_view.top_margin = 18;
@@ -88,12 +107,45 @@ namespace Publicate.Editors {
             source_scroller.child = text_view;
             markdown_scroller.child = markdown_view;
 
-            leaflet.start_child = source_scroller;
+            var source_box = new Box(Orientation.VERTICAL, 0);
+            source_box.append(source_scroller);
+            leaflet.start_child = source_box;
             leaflet.end_child = markdown_scroller;
 
             Gtk.Settings.get_default().notify["gtk-application-prefer-dark-theme"].connect(() => configure_tags());
             configure_tags();
 
+            toolbar = new Box(Orientation.HORIZONTAL, 8);
+            toolbar.margin_start = 8;
+            toolbar.margin_end = 8;
+            toolbar.margin_top = 8;
+            toolbar.margin_bottom = 8;
+            style_selector = new DropDown.from_strings(new string[] {STYLE_HEADING_1, STYLE_HEADING_2, STYLE_HEADING_3, STYLE_HEADING_4, STYLE_HEADING_5, STYLE_HEADING_6, STYLE_QUOTATION, STYLE_PREFORMATTED, STYLE_PARAGRAPH});
+            toolbar.append(style_selector);
+            bold_button = new ToggleButton();
+            bold_button.child = new Image.from_icon_name("format-text-bold-symbolic");
+            bold_button.tooltip_text = "Bold";
+            bold_button.clicked.connect(() => toggle_bold());
+            toolbar.append(bold_button);
+            italic_button = new ToggleButton();
+            italic_button.child = new Image.from_icon_name("format-text-italic-symbolic");
+            italic_button.tooltip_text = "Italic";
+            italic_button.clicked.connect(() => toggle_italic());
+            toolbar.append(italic_button);
+            insert_link = new Button.from_icon_name("insert-link-symbolic");
+            insert_link.tooltip_text = "Insert link";
+            insert_link.hexpand = true;
+            insert_link.halign = Align.END;
+            insert_link.clicked.connect(() => insert_link_clicked());
+            toolbar.append(insert_link);
+            insert_image = new Button.from_icon_name("insert-image-symbolic");
+            insert_image.tooltip_text = "Insert image";
+            insert_image.clicked.connect(() => insert_image_clicked());
+            toolbar.append(insert_image);
+            source_box.append(toolbar);
+
+            source_buffer.cursor_moved.connect(() => update_toolbar());
+            source_buffer.highlight_updated.connect(() => update_toolbar());
             append(leaflet);
 
             page = tab_view.add_page (this, null);
@@ -118,7 +170,7 @@ namespace Publicate.Editors {
             text_view.buffer.set_text ((string)text, text.length);
             set_tab_name(false);
 
-            leaflet.position = leaflet.get_width() / 2;
+            Timeout.add_once(100, () => leaflet.position = int.max(300, leaflet.get_width() / 2));
         }
 
         public void save_asset(Ppub.Builder builder) {
@@ -208,6 +260,427 @@ namespace Publicate.Editors {
             }
         }
 
+        private void update_toolbar() {
+            if(inhibit_toolbar_updates) {
+                print("Toolbar update inhibited\n");
+                return;
+            }
+            TextIter iter_start;
+            TextIter iter_end;
+            source_buffer.get_iter_at_offset(out iter_start, source_buffer.cursor_position);
+
+            var multi_line = false;
+            if(source_buffer.has_selection) {
+                source_buffer.get_selection_bounds(out iter_start, out iter_end);
+                multi_line = (iter_start.get_line() != iter_end.get_line());
+            }
+
+            var classes = Invercargill.Convert.ate<string>(source_buffer.get_context_classes_at_iter(iter_start));
+
+            // Disable formatting changes if the selection is over many lines or the "no-format" class is applied
+            toolbar.sensitive = !multi_line && !classes.any(c => c == "no-format");
+
+            print(@"$(classes.to_string(s => s, " "))\n");
+            
+            set_line_style_type_in_dropdown(get_line_style_from_buffer());
+
+            bold_button.active = classes.any(c => c == "b");
+            italic_button.active = classes.any(c => c == "i");
+        }
+
+        private int get_line_style_from_buffer() {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            if(iter.ends_line()) {
+                iter.backward_char();
+            }
+            var classes = Invercargill.Convert.ate<string>(source_buffer.get_context_classes_at_iter(iter));
+            var heading = classes.first_or_default(c => c.has_prefix("h") && c.length == 2);
+            if(heading != null) {
+                var level = int.parse(heading[1:2]);
+                return level - 1;
+            }
+            else if(classes.any(c => c == "quot")) {
+                return 6;
+            }
+            else if(classes.any(c => c == "pre")) {
+                return 7;
+            }
+            return 8;
+        }
+
+        private void remove_line_style() {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            if(iter.ends_line()) {
+                iter.backward_char();
+            }
+            var classes = Invercargill.Convert.ate<string>(source_buffer.get_context_classes_at_iter(iter));
+            var line_no = iter.get_line();
+
+            var heading = classes.first_or_default(c => c.has_prefix("h") && c.length == 2);
+            if(heading != null) {
+                var level = int.parse(heading[1:2]);
+                source_buffer.get_iter_at_line(out iter, line_no);
+                iter.forward_chars(level+1);
+                for(int i = 0; i <= level; i++) {
+                    source_buffer.backspace(ref iter, false, true);
+                }
+            }
+            else if(classes.any(c => c == "quot")) {
+                source_buffer.get_iter_at_line(out iter, line_no);
+                iter.forward_chars(2);
+                source_buffer.backspace(ref iter, false, true);
+                source_buffer.backspace(ref iter, false, true);
+            }
+        }
+
+        private void set_line_style_type_in_dropdown(uint type) {
+            style_selector.notify["selected"].disconnect(set_line_style);
+            style_selector.selected = type;
+            style_selector.notify["selected"].connect(set_line_style);
+        }
+
+        private void set_line_style() {
+            var style = style_selector.selected;
+            inhibit_toolbar_updates = true;
+            source_buffer.begin_user_action();
+            remove_line_style();
+
+            print(@"Set style $style\n");
+            if(style < 6) {
+                // Headings
+                format_line_heading(style + 1);
+            }
+            else if(style == 6) {
+                format_line_quote();
+            }
+            else if(style == 7) {
+                format_line_preformatted();
+            }
+
+            set_line_style_type_in_dropdown(style);
+            inhibit_toolbar_updates = false;
+            source_buffer.end_user_action();
+        }
+
+        private void format_line_heading(uint level) {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            var line_no = iter.get_line();
+            source_buffer.get_iter_at_line(out iter, line_no);
+            for(var i = 0; i < level; i++) {
+                source_buffer.insert(ref iter, "#", 1);
+            }
+            source_buffer.insert(ref iter, " ", 1);
+            iter.forward_line();
+            iter.backward_char();
+            source_buffer.place_cursor(iter);
+        }
+
+        private void format_line_quote() {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            var line_no = iter.get_line();
+            source_buffer.get_iter_at_line(out iter, line_no);
+            source_buffer.insert(ref iter, "> ", 2);
+            iter.forward_line();
+            iter.backward_char();
+            source_buffer.place_cursor(iter);
+        }
+
+        private void format_line_preformatted() {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            var line_no = iter.get_line();
+            source_buffer.get_iter_at_line(out iter, line_no);
+            source_buffer.insert(ref iter, "```\n", 4);
+            iter.forward_line();
+            iter.backward_char();
+            source_buffer.insert(ref iter, "\n```", 4);
+            iter.forward_line();
+            iter.backward_chars(5);
+            source_buffer.place_cursor(iter);
+        }
+
+        private void format_text_bold() {
+            TextIter iter_start;
+            TextIter iter_end;
+            if(source_buffer.has_selection) {
+                source_buffer.get_selection_bounds(out iter_start, out iter_end);
+            }
+            else {
+                source_buffer.get_iter_at_offset(out iter_start, source_buffer.cursor_position);
+                source_buffer.get_iter_at_offset(out iter_end, source_buffer.cursor_position);
+                if(!iter_start.starts_word()) {
+                    iter_start.backward_word_start();
+                }
+                if(!iter_end.ends_word()) {
+                    iter_end.forward_word_end();
+                }
+            }
+
+            var difference = iter_end.get_offset() - iter_start.get_offset();
+            source_buffer.insert(ref iter_start, "**", 2);
+            iter_start.forward_chars(difference);
+            source_buffer.insert(ref iter_start, "**", 2);
+        }
+
+        private void format_text_unbold() {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            source_buffer.iter_forward_to_context_class_toggle(ref iter, "b");
+            var end_pos = iter.get_offset();
+            source_buffer.iter_backward_to_context_class_toggle(ref iter, "b");
+            iter.forward_chars(2);
+            source_buffer.backspace(ref iter, false, true);
+            source_buffer.backspace(ref iter, false, true);
+            iter.set_offset(end_pos-2);
+            source_buffer.backspace(ref iter, false, true);
+            source_buffer.backspace(ref iter, false, true);
+        }
+
+        private void toggle_bold() {
+            source_buffer.begin_user_action();
+            if(!bold_button.active) {
+                format_text_unbold();
+            }
+            else {
+                format_text_bold();
+            }
+            source_buffer.end_user_action();
+        }
+
+        private void format_text_italic() {
+            TextIter iter_start;
+            TextIter iter_end;
+            if(source_buffer.has_selection) {
+                source_buffer.get_selection_bounds(out iter_start, out iter_end);
+            }
+            else {
+                source_buffer.get_iter_at_offset(out iter_start, source_buffer.cursor_position);
+                source_buffer.get_iter_at_offset(out iter_end, source_buffer.cursor_position);
+                if(!iter_start.starts_word()) {
+                    iter_start.backward_word_start();
+                }
+                if(!iter_end.ends_word()) {
+                    iter_end.forward_word_end();
+                }
+            }
+
+            var difference = iter_end.get_offset() - iter_start.get_offset();
+            source_buffer.insert(ref iter_start, "*", 1);
+            iter_start.forward_chars(difference);
+            source_buffer.insert(ref iter_start, "*", 1);
+        }
+
+        private void format_text_unitalic() {
+            TextIter iter;
+            source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            source_buffer.iter_forward_to_context_class_toggle(ref iter, "i");
+            var end_pos = iter.get_offset();
+            source_buffer.iter_backward_to_context_class_toggle(ref iter, "i");
+            iter.forward_chars(1);
+            source_buffer.backspace(ref iter, false, true);
+            iter.set_offset(end_pos-1);
+            source_buffer.backspace(ref iter, false, true);
+        }
+
+        private void toggle_italic() {
+            source_buffer.begin_user_action();
+            if(!italic_button.active) {
+                format_text_unitalic();
+            }
+            else {
+                format_text_italic();
+            }
+            source_buffer.end_user_action();
+        }
+
+        private void insert_link_clicked() {
+            string selected = null;
+            if(source_buffer.has_selection) {
+                TextIter iter_start;
+                TextIter iter_end;
+                source_buffer.get_selection_bounds(out iter_start, out iter_end);
+                selected = iter_start.get_text(iter_end);
+            }
+
+            var win = new AddLinkWindow(selected);
+            win.insert_link.connect(link_insert);
+            win.set_transient_for(window);
+            win.modal = true;
+            win.present();
+        }
+
+        private void link_insert(string url, string? label) {
+            TextIter iter;
+            source_buffer.begin_user_action();
+            if(source_buffer.has_selection) {
+                TextIter iter_end;
+                source_buffer.get_selection_bounds(out iter, out iter_end);
+                source_buffer.delete(ref iter, ref iter_end);
+            }
+            else {
+                source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            }
+            var link = "";
+            if(label != null) {
+                link = @"[$label]($url)";
+            }
+            else {
+                link = @"<$url>";
+            }
+            source_buffer.insert(ref iter, link, link.length);
+            source_buffer.end_user_action();
+        }
+
+        private void insert_image_clicked() {
+            string selected = null;
+            if(source_buffer.has_selection) {
+                TextIter iter_start;
+                TextIter iter_end;
+                source_buffer.get_selection_bounds(out iter_start, out iter_end);
+                selected = iter_start.get_text(iter_end);
+            }
+
+            var win = new AddImageWindow(selected, window);
+            win.insert_image.connect(image_insert);
+            win.set_transient_for(window);
+            win.modal = true;
+            win.present();
+        }
+
+        private void image_insert(string url, string label) {
+            TextIter iter;
+            source_buffer.begin_user_action();
+            if(source_buffer.has_selection) {
+                TextIter iter_end;
+                source_buffer.get_selection_bounds(out iter, out iter_end);
+                source_buffer.delete(ref iter, ref iter_end);
+            }
+            else {
+                source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position);
+            }
+            var link = @"![$label]($url)";
+            source_buffer.insert(ref iter, link, link.length);
+            source_buffer.end_user_action();
+        }
         
     }
+
+    public class AddLinkWindow : Adw.Window {
+
+        public signal void insert_link(string url, string? text);
+        private EntryRow url_entry;
+        private EntryRow text_entry;
+
+        public AddLinkWindow(string? text) {
+
+            title = "Insert link";
+            var box = new Box(Orientation.VERTICAL, 18);
+            var header = new Adw.HeaderBar();
+            header.show_end_title_buttons = false;
+            box.append(header);
+
+            var insert_button = new Button.with_label("Insert");
+            var cancel_button = new Button.with_label("Cancel");
+            insert_button.add_css_class("suggested-action");
+            insert_button.clicked.connect(() => insert());
+            cancel_button.clicked.connect(() => close());
+            header.pack_start(cancel_button);
+            header.pack_end(insert_button);
+
+            var group = new PreferencesGroup();
+            group.width_request = 400;
+            group.title = "Link properties";
+            group.margin_bottom = 18;
+            group.margin_end = 18;
+            group.margin_start = 18;
+
+            url_entry = new EntryRow ();
+            url_entry.title = "URI";
+            group.add(url_entry);
+
+            text_entry = new EntryRow ();
+            text_entry.title = "Label (optional)";
+            group.add(text_entry);
+
+            if(text != null) {
+                try {
+                    Uri.is_valid(text, UriFlags.NONE);
+                    url_entry.text = text;
+                }
+                catch (Error e) {
+                    text_entry.text = text;
+                }
+            }
+
+            box.append(group);
+            content = box;
+        }
+
+        private void insert() {
+            string text = null;
+            if(text_entry.text.chomp().chug() != "" || text_entry.text == url_entry.text) {
+                text = text_entry.text;
+            }
+            insert_link(url_entry.text, text);
+            close();
+        }
+
+    }
+
+    public class AddImageWindow : Adw.Window {
+
+        public signal void insert_image(string url, string text);
+        private FileChooserRow file_entry;
+        private EntryRow text_entry;
+
+        public AddImageWindow(string? text, ViewerWindow win) {
+
+            title = "Insert link";
+            var box = new Box(Orientation.VERTICAL, 18);
+            var header = new Adw.HeaderBar();
+            header.show_end_title_buttons = false;
+            box.append(header);
+
+            var insert_button = new Button.with_label("Insert");
+            var cancel_button = new Button.with_label("Cancel");
+            insert_button.add_css_class("suggested-action");
+            insert_button.clicked.connect(() => insert());
+            cancel_button.clicked.connect(() => close());
+            header.pack_start(cancel_button);
+            header.pack_end(insert_button);
+
+            var group = new PreferencesGroup();
+            group.width_request = 400;
+            group.title = "Link properties";
+            group.margin_bottom = 18;
+            group.margin_end = 18;
+            group.margin_start = 18;
+
+            file_entry = new FileChooserRow(win);
+            file_entry.title = "Image file";
+            file_entry.set_assets(win.publication.assets.where(a => a.mimetype.has_prefix ("image/")));
+            group.add(file_entry);
+
+            text_entry = new EntryRow ();
+            text_entry.title = "Image description";
+            group.add(text_entry);
+
+            if(text != null) {
+                text_entry.text = text;
+            }
+
+            box.append(group);
+            content = box;
+        }
+
+        private void insert() {
+            insert_image(file_entry.selected_asset, text_entry.text);
+            close();
+        }
+
+    }
 }

+ 20 - 6
src/Identities/ManageIdentitiesWindow.vala

@@ -64,9 +64,16 @@ namespace Publicate {
         }
 
         private void refresh_list() {
-            identity_list.populate_credentials();
-            delete_button.sensitive = identity_list.has_entries;
-            export_button.sensitive = identity_list.has_entries;
+            try {
+                identity_list.populate_credentials();
+                delete_button.sensitive = identity_list.has_entries;
+                export_button.sensitive = identity_list.has_entries;
+            }
+            catch(Error e) {
+                var err = new Adw.MessageDialog(this, "Couldn't Load Identities", @"$(e.message)");
+                err.add_response("ok", "Close");
+                err.present();
+            }
         }
 
         private void delete_entry() {
@@ -76,8 +83,15 @@ namespace Publicate {
             prompt.set_response_appearance("delete", ResponseAppearance.DESTRUCTIVE);
             prompt.response.connect(r => {
                 if(r == "delete") {
-                    File.new_for_path(get_publicate_path() + "/credentials/" + identity_list.selected_name).delete();
-                    refresh_list();
+                    try {
+                        File.new_for_path(get_publicate_path() + "/credentials/" + identity_list.selected_name).delete();
+                        refresh_list();
+                    }
+                    catch(Error e) {
+                        var err = new Adw.MessageDialog(this, "Couldn't Delete Identity", @"$(e.message)");
+                        err.add_response("ok", "Close");
+                        err.present();
+                    }
                 }
             });
             prompt.present();
@@ -96,7 +110,7 @@ namespace Publicate {
             yield File.new_for_path(get_publicate_path() + "/credentials/" + identity_list.selected_name).copy_async(file, FileCopyFlags.OVERWRITE, 1, null, null);
         }
 
-        public async void import_credentials() throws Error {
+        public async void import_credentials() {
             var dialog = new FileDialog();
             dialog.accept_label = "Import";
             var file = yield dialog.open(window, null);

+ 79 - 7
src/PublishWindow.vala

@@ -17,6 +17,7 @@ namespace Publicate {
         private bool loader_pulsing = false;
 
         private Box identity_select;
+        private Label identity_header;
         private IdentityList identity_list;
         private Button publish_button;
 
@@ -31,6 +32,7 @@ namespace Publicate {
         private Label error_body;
 
         private Box complete;
+        private StatusPage status_page;
 
         private delegate void DecisionCallback();
         private DecisionCallback cancel_action;
@@ -77,7 +79,7 @@ namespace Publicate {
             identity_select.margin_end = 18;
             identity_select.margin_bottom = 18;
 
-            var identity_header = new Label("Select identity to publish with");
+            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);
@@ -88,7 +90,7 @@ namespace Publicate {
             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));
+            publish_button.clicked.connect(() => continue_action());
             identity_select.append(publish_button);
             
             stack.add_child(identity_select);
@@ -168,7 +170,7 @@ namespace Publicate {
             complete.margin_end = 18;
             complete.margin_bottom = 18;
 
-            var status_page = new StatusPage();
+            status_page = new StatusPage();
             complete.append(status_page);
             status_page.icon_name = "emblem-ok-symbolic";
             status_page.title = "Publish Completed!";
@@ -223,6 +225,7 @@ namespace Publicate {
         private bool unpublish = false;
         private DateTime? old_publish_timestamp = null;
         public async void publish_to(CollectionConfig config, File publication_file) {
+            title = @"Publish to $(config.name)";
             this.config = config;
             this.publication_file = publication_file;
             dest_name = publication_file.get_basename();
@@ -254,19 +257,87 @@ namespace Publicate {
             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));
+                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_publish_identity(true));
             }
             else {
-                select_identity(false);
+                select_publish_identity(false);
             }
         }
 
-        private void select_identity(bool unpub) {
+        private string unpublish_file;
+        public async void unpublish_from(CollectionConfig config, string file) {
+            title = @"Unpublish from $(config.name)";
+            this.config = config;
+            this.unpublish_file = file;
+
+            pulse_loader();
+            loader_status.set_text(@"Looking up $(config.domain)…");
+            try{ 
+                client = yield window.collection_service.get_client(config);
+            }
+            catch(Error e) {
+                show_error("Server not found", @"Could not find PPRF server details for $(config.domain): $(e.message)");
+                return;
+            }
+            cid = new BinaryData.from_base64(config.collection_id);
+
+            loader_status.set_text(@"Querying $(config.domain) for collection information…");
+            try {
+                collection = yield do_in_bg<Ppub.Collection>(() => client.get_collection(cid));
+            }
+            catch(Error e) {
+                show_error("Could not download collection information", @"Failed to download collection information from server: $(e.message)");
+                return;
+            }
+
+            if(collection.publications.no(p => p.file_name == file)) {
+                show_error("Publication not found", @"The publication does not appear in the latest collection data from the server");
+                return;
+            }
+            select_identity("Unpublish", () => complete_unpublish.begin());
+        }
+
+        private async void complete_unpublish() {
+            header_bar.show_end_title_buttons = false;
+            stack.visible_child = loader;
+
+            loader_status.set_text(@"Unpublishing \"$(unpublish_file)\"…");
+            pulse_loader();
+            try {
+                yield do_void_in_bg(() => client.unpublish(cid, unpublish_file, identity_list.selected_identity));
+            }
+            catch(Error e) {
+                show_error("Unpublication failed", @"Failed to unpbulish \"$(unpublish_file)\": $(e.message)");
+                return;
+            }
+
+            loader_status.set_text(@"Rebuilding index…");
+            try {
+                yield do_void_in_bg(() => client.rebuild_index(cid, identity_list.selected_identity));
+            }
+            catch(Error e) {
+                show_error("Index rebuild failed", @"The publication has been unpublished from the server, however there was an error rebuilding the collection's index: $(e.message)");
+                return;
+            }
+
+            status_page.title = "Publication Removed!";
+            stack.visible_child = complete;
+        }
+
+        private void select_publish_identity(bool unpub) {
             if(unpub) {
                 old_publish_timestamp = collection.publications.first_or_default(p => p.file_name == dest_name)?.publication_time;
             }
             unpublish = unpub;
+
+            select_identity("Publish", () => publish_with.begin());
+        }
+
+        private void select_identity(string action_label, DecisionCallback continue_cb) {
+            continue_action = continue_cb;
             stack.visible_child = identity_select;
+            publish_button.label = action_label;
+            identity_header.label = @"Select identity to $(action_label.down()) with";
             
             try {
                 identity_list.populate_identities(collection);
@@ -279,7 +350,7 @@ namespace Publicate {
             header_bar.show_end_title_buttons = true;
         }
 
-        private async void publish_with(bool overwrite) {            
+        private async void publish_with() {            
             header_bar.show_end_title_buttons = false;
             stack.visible_child = loader;
 
@@ -296,6 +367,7 @@ namespace Publicate {
                 }
                 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));
+                    return;
                 }
             }
             catch(Error e) {

+ 16 - 9
src/StartupMenu.vala

@@ -114,14 +114,21 @@ namespace Publicate {
             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);
+            try {
+                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);
+                }
+            }
+            catch(Error e) {
+                var prompt = new Adw.MessageDialog(window, "Problem Loading Collections", @"There was an error while trying to load the list of collections: $(e.message)");
+                prompt.add_response("ok", "Close");
+                prompt.present();
             }
         }
 
@@ -136,7 +143,7 @@ namespace Publicate {
         }
 
         private void open_file(File file) {
-            window.load_ppub(file);
+            window.load_ppub.begin(file);
         }
 
 

+ 7 - 2
src/Video/VideoProcessor.vala

@@ -73,8 +73,13 @@ namespace Publicate.Video {
             var manifest = new Ppub.VideoManifest();
             manifest.streams = encoders.select<Ppub.VideoDescription>(e => e.get_video_description()).to_series();
             manifest.duration = video_info.duration;
-            var ratio_parts = video_info.aspect_ratio.split(":");
-            manifest.ratio = new double[] { double.parse(ratio_parts[0]), double.parse(ratio_parts[1]) };
+            if(video_info.aspect_ratio != null) {
+                var ratio_parts = video_info.aspect_ratio.split(":");
+                manifest.ratio = new double[] { double.parse(ratio_parts[0]), double.parse(ratio_parts[1]) };
+            }
+            else {
+                manifest.ratio = new double[] { video_info.width, video_info.height };
+            }
 
             return new VideoProcessResult(manifest, encoders.select<File>(e => e.get_output_file()).to_series());
         }

+ 12 - 3
src/Window.vala

@@ -14,8 +14,6 @@ namespace Publicate {
         public CollectionService collection_service { get; set;}
         public CollectionView collection_view { get; set; }
 
-        private SimpleAction extract_action;
-
         public ViewerWindow(Adw.Application app) {
             application = app;
 
@@ -83,7 +81,14 @@ namespace Publicate {
 
         public async void load_ppub(File file) {
             stack.visible_child = editor;
-            yield editor.load_ppub(file, true);
+            try{
+                yield editor.load_ppub(file, true);
+            }
+            catch(Error e) {
+                var prompt = new Adw.MessageDialog(this, "Couldn't Open File", @"Could not open publication for editing: $(e.message)");
+                prompt.add_response("ok", "Close");
+                prompt.present();
+            }
         }
 
         public void manage_identities() {
@@ -97,6 +102,10 @@ namespace Publicate {
             yield collection_view.show_collection(collection);
         }
 
+        public void back() {
+            stack.visible_child = startup_menu;
+        }
+
     }
 
 }

+ 2 - 1
src/meson.build

@@ -67,4 +67,5 @@ executable('publicate', sources, dependencies: dependencies, install: true)
 
 install_data('../nz.barrow.billy.publicate.svg', install_dir: get_option('datadir') / 'icons/hicolor/scalable/apps')
 install_data('../nz.barrow.billy.publicate-symbolic.svg', install_dir: get_option('datadir') / 'icons/hicolor/symbolic/apps')
-install_data('../nz.barrow.billy.publicate.desktop', install_dir: get_option('datadir') / 'applications')
+install_data('../nz.barrow.billy.publicate.desktop', install_dir: get_option('datadir') / 'applications')
+install_data('../PpubMarkdown.lang', install_dir: get_option('datadir') / 'publicate' / 'gtk-source-view')