Lagrange [work/v1.12]

Exporting and importing user data

=> 75197707e0bb149cb9c2e4a983d92fdfb381c17f

diff --git a/CMakeLists.txt b/CMakeLists.txt
index bcf1c7bf..69ac3df0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -150,6 +150,8 @@ set (SOURCES
     src/bookmarks.c
     src/bookmarks.h
     src/defs.h
+    src/export.c
+    src/export.h
     src/feeds.c
     src/feeds.h
     src/fontpack.c
diff --git a/lib/the_Foundation b/lib/the_Foundation
index 05fe3fdb..32e91939 160000
--- a/lib/the_Foundation
+++ b/lib/the_Foundation
@@ -1 +1 @@
-Subproject commit 05fe3fdb17ebf64f1830dc7bbe3359e5f062b1f0
+Subproject commit 32e91939443d6e831eb438a81b6a8bc5e17ac021
diff --git a/po/en.po b/po/en.po
index 699fabb9..f8cc0caa 100644
--- a/po/en.po
+++ b/po/en.po
@@ -9,6 +9,9 @@ msgstr "Loading"
 msgid "doc.archive"
 msgstr "%s is a compressed archive."
 
+msgid "heading.archive.userdata"
+msgstr "User Data Archive"
+
 msgid "doc.archive.view"
 msgstr "View archive contents"
 
@@ -181,6 +184,12 @@ msgstr "Open Location…"
 msgid "menu.downloads"
 msgstr "Show Downloads"
 
+msgid "menu.export"
+msgstr "Export User Data"
+
+msgid "menu.import"
+msgstr "Import User Data…"
+
 msgid "menu.pageinfo"
 msgstr "Show Page Information"
 
@@ -971,6 +980,9 @@ msgstr "Import Certificate/Key File"
 msgid "dlg.certimport.import"
 msgstr "Import"
 
+msgid "dlg.certimport.paste"
+msgstr "Paste from Clipboard"
+
 msgid "dlg.certimport.notes"
 msgstr "Notes:"
 
@@ -989,6 +1001,48 @@ msgstr "Audio"
 msgid "link.hint.image"
 msgstr "Image"
 
+msgid "heading.import.userdata"
+msgstr "Import User Data"
+
+msgid "heading.import.userdata.error"
+msgstr "Import Failed"
+
+msgid "import.userdata.bookmarks"
+msgstr "Bookmarks:"
+
+msgid "import.userdata.idents"
+msgstr "Identities:"
+
+msgid "import.userdata.history"
+msgstr "History:"
+
+msgid "import.userdata.trusted"
+msgstr "Trusted certificates:"
+
+msgid "import.userdata.sitespec"
+msgstr "Site settings:"
+
+msgid "dlg.userdata.no"
+msgstr "None"
+
+msgid "dlg.userdata.missing"
+msgstr "If Missing"
+
+msgid "dlg.userdata.all"
+msgstr "All"
+
+msgid "dlg.userdata.alldup"
+msgstr "All (Keep Duplicates)"
+
+msgid "import.userdata"
+msgstr "Import Selected Data"
+
+msgid "import.userdata.dupfolder"
+msgstr "Imported Duplicates"
+
+msgid "import.userdata.error"
+msgstr "%s is not a valid Lagrange export archive."
+
 msgid "bookmark.title.blank"
 msgstr "Blank Page"
 
diff --git a/res/lang/cs.bin b/res/lang/cs.bin
index e70b4c84..d2889c1a 100644
Binary files a/res/lang/cs.bin and b/res/lang/cs.bin differ
diff --git a/res/lang/de.bin b/res/lang/de.bin
index a96ffb17..cc4b305e 100644
Binary files a/res/lang/de.bin and b/res/lang/de.bin differ
diff --git a/res/lang/en.bin b/res/lang/en.bin
index c7ce6d11..b1801eec 100644
Binary files a/res/lang/en.bin and b/res/lang/en.bin differ
diff --git a/res/lang/eo.bin b/res/lang/eo.bin
index bdb009e9..155ef35c 100644
Binary files a/res/lang/eo.bin and b/res/lang/eo.bin differ
diff --git a/res/lang/es.bin b/res/lang/es.bin
index f20d84da..3f8b7a61 100644
Binary files a/res/lang/es.bin and b/res/lang/es.bin differ
diff --git a/res/lang/es_MX.bin b/res/lang/es_MX.bin
index 8770ba16..b19bfa09 100644
Binary files a/res/lang/es_MX.bin and b/res/lang/es_MX.bin differ
diff --git a/res/lang/fi.bin b/res/lang/fi.bin
index 28f7f22e..b7c7acb8 100644
Binary files a/res/lang/fi.bin and b/res/lang/fi.bin differ
diff --git a/res/lang/fr.bin b/res/lang/fr.bin
index 97a48bb2..28eb98ae 100644
Binary files a/res/lang/fr.bin and b/res/lang/fr.bin differ
diff --git a/res/lang/gl.bin b/res/lang/gl.bin
index 274bcf2b..e79c0db3 100644
Binary files a/res/lang/gl.bin and b/res/lang/gl.bin differ
diff --git a/res/lang/hu.bin b/res/lang/hu.bin
index b2bbc538..c72f74ad 100644
Binary files a/res/lang/hu.bin and b/res/lang/hu.bin differ
diff --git a/res/lang/ia.bin b/res/lang/ia.bin
index 9aa98b70..0527016f 100644
Binary files a/res/lang/ia.bin and b/res/lang/ia.bin differ
diff --git a/res/lang/ie.bin b/res/lang/ie.bin
index 317b9ddb..85cfd1a9 100644
Binary files a/res/lang/ie.bin and b/res/lang/ie.bin differ
diff --git a/res/lang/isv.bin b/res/lang/isv.bin
index 343d17e4..8e7c8169 100644
Binary files a/res/lang/isv.bin and b/res/lang/isv.bin differ
diff --git a/res/lang/it.bin b/res/lang/it.bin
index ef145008..e1296993 100644
Binary files a/res/lang/it.bin and b/res/lang/it.bin differ
diff --git a/res/lang/nl.bin b/res/lang/nl.bin
index 7bec0514..85470de6 100644
Binary files a/res/lang/nl.bin and b/res/lang/nl.bin differ
diff --git a/res/lang/pl.bin b/res/lang/pl.bin
index 171356c7..ec8e61da 100644
Binary files a/res/lang/pl.bin and b/res/lang/pl.bin differ
diff --git a/res/lang/ru.bin b/res/lang/ru.bin
index 92169af5..1467d74c 100644
Binary files a/res/lang/ru.bin and b/res/lang/ru.bin differ
diff --git a/res/lang/sk.bin b/res/lang/sk.bin
index 4d197933..b92fdc80 100644
Binary files a/res/lang/sk.bin and b/res/lang/sk.bin differ
diff --git a/res/lang/sr.bin b/res/lang/sr.bin
index d733cbef..75e3ac79 100644
Binary files a/res/lang/sr.bin and b/res/lang/sr.bin differ
diff --git a/res/lang/tok.bin b/res/lang/tok.bin
index 6ac6ac9f..f501f91c 100644
Binary files a/res/lang/tok.bin and b/res/lang/tok.bin differ
diff --git a/res/lang/tr.bin b/res/lang/tr.bin
index 0dc81e68..7ac94fc4 100644
Binary files a/res/lang/tr.bin and b/res/lang/tr.bin differ
diff --git a/res/lang/uk.bin b/res/lang/uk.bin
index 4ada5dfe..82e8208a 100644
Binary files a/res/lang/uk.bin and b/res/lang/uk.bin differ
diff --git a/res/lang/zh_Hans.bin b/res/lang/zh_Hans.bin
index 818445b6..4697c37e 100644
Binary files a/res/lang/zh_Hans.bin and b/res/lang/zh_Hans.bin differ
diff --git a/res/lang/zh_Hant.bin b/res/lang/zh_Hant.bin
index 17d95f10..54050a83 100644
Binary files a/res/lang/zh_Hant.bin and b/res/lang/zh_Hant.bin differ
diff --git a/src/app.c b/src/app.c
index e2802644..e317e4c6 100644
--- a/src/app.c
+++ b/src/app.c
@@ -23,17 +23,17 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 #include "app.h"
 #include "bookmarks.h"
 #include "defs.h"
-#include "resources.h"
+#include "export.h"
 #include "feeds.h"
-#include "mimehooks.h"
 #include "gmcerts.h"
 #include "gmdocument.h"
 #include "gmutil.h"
 #include "history.h"
 #include "ipc.h"
+#include "mimehooks.h"
 #include "periodic.h"
+#include "resources.h"
 #include "sitespec.h"
-#include "updater.h"
 #include "ui/certimportwidget.h"
 #include "ui/color.h"
 #include "ui/command.h"
@@ -43,13 +43,15 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 #include "ui/labelwidget.h"
 #include "ui/root.h"
 #include "ui/sidebarwidget.h"
-#include "ui/touch.h"
 #include "ui/text.h"
+#include "ui/touch.h"
 #include "ui/uploadwidget.h"
 #include "ui/util.h"
 #include "ui/window.h"
+#include "updater.h"
 #include "visited.h"
 
+#include 
 #include 
 #include 
 #include 
@@ -3744,6 +3746,53 @@ iBool handleCommand_App(const char *cmd) {
         }
         return iTrue;
     }
+    else if (equal_Command(cmd, "export")) {
+        iExport *export = new_Export();
+        iBuffer *zip    = new_Buffer();
+        generate_Export(export);
+        openEmpty_Buffer(zip);
+        serialize_Archive(archive_Export(export), stream_Buffer(zip));
+        iDocumentWidget *expTab = newTab_App(NULL, iTrue);
+        iDate now;
+        initCurrent_Date(&now);
+        setUrlAndSource_DocumentWidget(
+            expTab,
+            collect_String(format_Date(&now, "file:Lagrange_User_Data_%Y-%m-%d_%H%M%S.zip")),
+            collectNewCStr_String("application/zip"),
+            data_Buffer(zip));
+        iRelease(zip);
+        delete_Export(export);
+        return iTrue;
+    }
+    else if (equal_Command(cmd, "import")) {
+        const iString *path = collect_String(suffix_Command(cmd, "path"));
+        iArchive *zip = iClob(new_Archive());
+        if (openFile_Archive(zip, path)) {
+            if (!arg_Command(cmd)) {
+                makeUserDataImporter_Dialog(path);
+                return iTrue;
+            }
+            const int bookmarks = argLabel_Command(cmd, "bookmarks");
+            const int trusted   = argLabel_Command(cmd, "trusted");
+            const int idents    = argLabel_Command(cmd, "idents");
+            const int visited   = argLabel_Command(cmd, "visited");
+            const int siteSpec  = argLabel_Command(cmd, "sitespec");
+            iExport *export = new_Export();
+            if (load_Export(export, zip)) {
+                import_Export(export, bookmarks, idents, trusted, visited, siteSpec);
+            }
+            else {
+                makeSimpleMessage_Widget(uiHeading_ColorEscape "${heading.import.userdata.error}",
+                                         format_Lang("${import.userdata.error}", cstr_String(path)));                    
+            }
+            delete_Export(export);
+        }
+        else {
+            makeSimpleMessage_Widget(uiHeading_ColorEscape "${heading.import.userdata.error}",
+                                     format_Lang("${import.userdata.error}", cstr_String(path)));
+        }
+        return iTrue;
+    }
 #if defined (LAGRANGE_ENABLE_IPC)
     else if (equal_Command(cmd, "ipc.list.urls")) {
         iProcessId pid = argLabel_Command(cmd, "pid");
diff --git a/src/bookmarks.c b/src/bookmarks.c
index 500caa38..6f4b13ca 100644
--- a/src/bookmarks.c
+++ b/src/bookmarks.c
@@ -277,21 +277,43 @@ static void loadOldFormat_Bookmarks(iBookmarks *d, const char *dirPath) {
 iDeclareType(BookmarkLoader)
     
 struct Impl_BookmarkLoader {
-    iTomlParser *toml;
-    iBookmarks * bookmarks;
-    iBookmark *  bm;
+    iTomlParser       *toml;
+    iBookmarks        *bookmarks;
+    iBookmark         *bm;
+    uint32_t           loadId;
+    enum iImportMethod method;
+    uint32_t           baseId;
+    uint32_t           dupFolderId;
+    iBool              didImportDuplicates;
 };
 
 static void handleTable_BookmarkLoader_(void *context, const iString *table, iBool isStart) {
     iBookmarkLoader *d = context;
     if (isStart) {
         iAssert(!d->bm);
+        iAssert(d->method != none_ImportMethod);
         d->bm = new_Bookmark();
-        const int id = toInt_String(table);
-        d->bookmarks->idEnum = iMax(d->bookmarks->idEnum, id);
-        insertId_Bookmarks_(d->bookmarks, d->bm, id);
-    }
-    else {
+        d->loadId = toInt_String(table) + d->baseId;
+    }
+    else if (d->bm) {
+        /* Check if import rules. */
+        if (d->baseId && !isFolder_Bookmark(d->bm)) {
+            const uint32_t existing = findUrl_Bookmarks(d->bookmarks, &d->bm->url);
+            if (existing) {
+                if (d->method == ifMissing_ImportMethod) {
+                    /* Already have this one. */
+                    delete_Bookmark(d->bm);
+                    d->bm = NULL;
+                    return;
+                }
+                else {
+                    d->bm->parentId = d->dupFolderId;
+                    d->didImportDuplicates = iTrue;
+                }
+            }
+        }
+        d->bookmarks->idEnum = iMax(d->bookmarks->idEnum, d->loadId);
+        insertId_Bookmarks_(d->bookmarks, d->bm, d->loadId);
         d->bm = NULL;
     }
 }
@@ -319,7 +341,7 @@ static void handleKeyValue_BookmarkLoader_(void *context, const iString *table,
             initSeconds_Time(&bm->when, tv->value.int64);
         }
         else if (!cmp_String(key, "parent") && tv->type == int64_TomlType) {
-            bm->parentId = tv->value.int64;
+            bm->parentId = tv->value.int64 + d->baseId;
         }
         else if (!cmp_String(key, "order") && tv->type == int64_TomlType) {
             bm->order = tv->value.int64;
@@ -335,16 +357,29 @@ static void init_BookmarkLoader(iBookmarkLoader *d, iBookmarks *bookmarks) {
     setHandlers_TomlParser(d->toml, handleTable_BookmarkLoader_, handleKeyValue_BookmarkLoader_, d);
     d->bookmarks = bookmarks;
     d->bm = NULL;
+    d->loadId = 0;
+    d->method = all_ImportMethod;
+    d->baseId = bookmarks->idEnum; /* allows importing bookmarks without ID conflicts */
+    d->dupFolderId = 0;
+    d->didImportDuplicates = iFalse;
 }
 
 static void deinit_BookmarkLoader(iBookmarkLoader *d) {
     delete_TomlParser(d->toml);
 }
 
-static void load_BookmarkLoader(iBookmarkLoader *d, iFile *file) {
-    if (!parse_TomlParser(d->toml, collect_String(readString_File(file)))) {
-        fprintf(stderr, "[Bookmarks] syntax error(s) in %s\n", cstr_String(path_File(file)));
-    }    
+static void load_BookmarkLoader(iBookmarkLoader *d, iStream *stream) {
+    if (d->baseId && d->method == all_ImportMethod) {
+        /* Make a folder for possible duplicate bookmarks. */
+        d->dupFolderId =
+            add_Bookmarks(d->bookmarks, NULL, string_Lang("import.userdata.dupfolder"), NULL, 0);
+    }
+    if (!parse_TomlParser(d->toml, collect_String(readString_Stream(stream)))) {
+        fprintf(stderr, "[Bookmarks] syntax error in bookmarks.ini\n");
+    }
+    if (d->dupFolderId && !d->didImportDuplicates) {
+        remove_Bookmarks(d->bookmarks, d->dupFolderId);
+    }
 }
 
 iDefineTypeConstructionArgs(BookmarkLoader, (iBookmarks *b), b)
@@ -364,6 +399,46 @@ void sort_Bookmarks(iBookmarks *d, uint32_t parentId, iBookmarksCompareFunc cmp)
     unlock_Mutex(d->mtx);
 }
 
+static void mergeFolders_BookmarkLoader(iBookmarkLoader *d) {
+    if (!d->baseId) {
+        /* Only merge after importing. */
+        return;
+    }
+    iHash *hash = &d->bookmarks->bookmarks;
+    iForEach(Hash, i, hash) {
+        iBookmark *imported = (iBookmark *) i.value;
+        if (isFolder_Bookmark(imported) && id_Bookmark(imported) >= d->baseId) {
+            /* If there already is a folder with a matching name, merge this one into it. */
+            iForEach(Hash, j, hash) {
+                iBookmark *old = (iBookmark *) j.value;
+                if (isFolder_Bookmark(old) && id_Bookmark(old) < d->baseId &&
+                    equal_String(&imported->title, &old->title)) {
+                    iForEach(Hash, k, hash) {
+                        iBookmark *bm = (iBookmark *) k.value;
+                        if (bm->parentId == id_Bookmark(imported)) {
+                            bm->parentId = id_Bookmark(old);
+                        }
+                    }
+                    remove_HashIterator(&i);
+                    delete_Bookmark(imported);
+                    break;
+                }
+            }
+        }
+    }
+}
+
+void deserialize_Bookmarks(iBookmarks *d, iStream *ins, enum iImportMethod method) {
+    lock_Mutex(d->mtx);
+    iBookmarkLoader loader;
+    init_BookmarkLoader(&loader, d);
+    loader.method = method;
+    load_BookmarkLoader(&loader, ins);
+    mergeFolders_BookmarkLoader(&loader);
+    deinit_BookmarkLoader(&loader);
+    unlock_Mutex(d->mtx);
+}
+
 void load_Bookmarks(iBookmarks *d, const char *dirPath) {
     clear_Bookmarks(d);
     /* Load new .ini bookmarks, if present. */
@@ -377,49 +452,53 @@ void load_Bookmarks(iBookmarks *d, const char *dirPath) {
     }
     iBookmarkLoader loader;
     init_BookmarkLoader(&loader, d);
-    load_BookmarkLoader(&loader, f);
+    load_BookmarkLoader(&loader, stream_File(f));
     deinit_BookmarkLoader(&loader);
 }
 
+void serialize_Bookmarks(const iBookmarks *d, iStream *out) {
+    iString *str = collectNew_String();
+    format_String(str, "recentfolder = %u\n\n", d->recentFolderId);
+    writeData_Stream(out, cstr_String(str), size_String(str));
+    iConstForEach(Hash, i, &d->bookmarks) {
+        const iBookmark *bm = (const iBookmark *) i.value;
+        if (bm->flags & remote_BookmarkFlag) {
+            /* Remote bookmarks are not saved. */
+            continue;
+        }
+        iBeginCollect();
+        const iString *packedTags = collect_String(packedDotTags_Bookmark_(bm));
+        format_String(str,
+                      "[%d]\n"
+                      "url = \"%s\"\n"
+                      "title = \"%s\"\n"
+                      "tags = \"%s\"\n"
+                      "icon = 0x%x\n"
+                      "created = %.0f  # %s\n",
+                      id_Bookmark(bm),
+                      cstrCollect_String(quote_String(&bm->url, iFalse)),
+                      cstrCollect_String(quote_String(&bm->title, iFalse)),
+                      cstrCollect_String(quote_String(packedTags, iFalse)),
+                      bm->icon,
+                      seconds_Time(&bm->when),
+                      cstrCollect_String(format_Time(&bm->when, "%Y-%m-%d")));
+        if (bm->parentId) {
+            appendFormat_String(str, "parent = %d\n", bm->parentId);
+        }
+        if (bm->order) {
+            appendFormat_String(str, "order = %d\n", bm->order);
+        }
+        appendCStr_String(str, "\n");
+        writeData_Stream(out, cstr_String(str), size_String(str));
+        iEndCollect();
+    }
+}
+
 void save_Bookmarks(const iBookmarks *d, const char *dirPath) {
     lock_Mutex(d->mtx);
     iFile *f = newCStr_File(concatPath_CStr(dirPath, fileName_Bookmarks_));
-    if (open_File(f, writeOnly_FileMode | text_FileMode)) {        
-        iString *str = collectNew_String();
-        format_String(str, "recentfolder = %u\n\n", d->recentFolderId);
-        writeData_File(f, cstr_String(str), size_String(str));
-        iConstForEach(Hash, i, &d->bookmarks) {
-            const iBookmark *bm = (const iBookmark *) i.value;
-            if (bm->flags & remote_BookmarkFlag) {
-                /* Remote bookmarks are not saved. */
-                continue;
-            }
-            iBeginCollect();
-            const iString *packedTags = collect_String(packedDotTags_Bookmark_(bm));
-            format_String(str,
-                          "[%d]\n"
-                          "url = \"%s\"\n"
-                          "title = \"%s\"\n"
-                          "tags = \"%s\"\n"
-                          "icon = 0x%x\n"
-                          "created = %.0f  # %s\n",
-                          id_Bookmark(bm),
-                          cstrCollect_String(quote_String(&bm->url, iFalse)),
-                          cstrCollect_String(quote_String(&bm->title, iFalse)),
-                          cstrCollect_String(quote_String(packedTags, iFalse)),
-                          bm->icon,
-                          seconds_Time(&bm->when),
-                          cstrCollect_String(format_Time(&bm->when, "%Y-%m-%d")));
-            if (bm->parentId) {
-                appendFormat_String(str, "parent = %d\n", bm->parentId);
-            }
-            if (bm->order) {
-                appendFormat_String(str, "order = %d\n", bm->order);
-            }
-            appendCStr_String(str, "\n");
-            writeData_File(f, cstr_String(str), size_String(str));
-            iEndCollect();
-        }        
+    if (open_File(f, writeOnly_FileMode | text_FileMode)) {
+        serialize_Bookmarks(d, stream_File(f));
     }
     iRelease(f);
     unlock_Mutex(d->mtx);
diff --git a/src/bookmarks.h b/src/bookmarks.h
index 08afdd8b..13a93748 100644
--- a/src/bookmarks.h
+++ b/src/bookmarks.h
@@ -22,6 +22,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 
 #pragma once
 
+#include "defs.h"
 #include 
 #include 
 #include 
@@ -97,6 +98,8 @@ typedef int   (*iBookmarksCompareFunc)  (const iBookmark **, const iBookmark **)
 void        clear_Bookmarks             (iBookmarks *);
 void        load_Bookmarks              (iBookmarks *, const char *dirPath);
 void        save_Bookmarks              (const iBookmarks *, const char *dirPath);
+void        serialize_Bookmarks         (const iBookmarks *, iStream *outs);
+void        deserialize_Bookmarks       (iBookmarks *, iStream *ins, enum iImportMethod);
 
 uint32_t    add_Bookmarks               (iBookmarks *, const iString *url, const iString *title,
                                          const iString *tags, iChar icon);
diff --git a/src/defs.h b/src/defs.h
index fed7fe82..93db7fa0 100644
--- a/src/defs.h
+++ b/src/defs.h
@@ -31,6 +31,12 @@ enum iSourceFormat {
     markdown_SourceFormat,
 };
 
+enum iImportMethod {
+    none_ImportMethod = 0,
+    ifMissing_ImportMethod,
+    all_ImportMethod,
+};
+
 enum iFileVersion {
     initial_FileVersion                 = 0,
     addedResponseTimestamps_FileVersion = 1,
@@ -127,7 +133,8 @@ iLocalDef int acceptKeyMod_ReturnKeyBehavior(int behavior) {
 #define backArrow_Icon      "\U0001f870"
 #define forwardArrow_Icon   "\U0001f872"
 #define upArrow_Icon        "\U0001f871"
-#define upArrowBar_Icon     "\u2912"
+#define upArrowBar_Icon     "\u2b71"
+#define keyUpArrow_Icon     "\u2191"
 #define downArrowBar_Icon   "\u2913"
 #define rightArrowWhite_Icon "\u21e8"
 #define rightArrow_Icon     "\u279e"
@@ -152,7 +159,7 @@ iLocalDef int acceptKeyMod_ReturnKeyBehavior(int behavior) {
 #define check_Icon          "\u2714"
 #define ballotChecked_Icon  "\u2611"
 #define ballotUnchecked_Icon "\u2610"
-#define inbox_Icon          "\U0001f4e5"
+#define import_Icon          "\U0001f4e5"
 #define book_Icon           "\U0001f56e"
 #define bookmark_Icon       "\U0001f516"
 #define folder_Icon         "\U0001f4c1"
diff --git a/src/export.c b/src/export.c
new file mode 100644
index 00000000..a343020a
--- /dev/null
+++ b/src/export.c
@@ -0,0 +1,205 @@
+/* Copyright 2022 Jaakko Keränen 
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
+
+#include "export.h"
+
+#include "app.h"
+#include "bookmarks.h"
+#include "gmcerts.h"
+#include "sitespec.h"
+#include "visited.h"
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+const char *mimeType_Export = "application/lagrange-export+zip";
+
+struct Impl_Export {
+    iArchive *arch;
+};
+
+iDefineTypeConstruction(Export)
+    
+static const char *metadataEntryName_Export_ = "lagrange-export.ini";
+    
+void init_Export(iExport *d) {
+    d->arch = new_Archive();
+}
+
+void deinit_Export(iExport *d) {
+    iRelease(d->arch);
+}
+
+void generate_Export(iExport *d) {
+    openWritable_Archive(d->arch);
+    iBuffer *buf  = new_Buffer();
+    iString *meta = new_String();
+    iDate    today;
+    iTime    now;
+    initCurrent_Date(&today);
+    initCurrent_Time(&now);
+    format_String(meta,
+                  "# Lagrange user data exported on %s\n"
+                  "version = \"" LAGRANGE_APP_VERSION "\"\n"
+                  "timestamp = %llu\n",
+                  cstrCollect_String(format_Date(&today, "%Y-%m-%d %H:%M")),
+                  (unsigned long long) integralSeconds_Time(&now));
+    /* Bookmarks. */ {
+        openEmpty_Buffer(buf);
+        serialize_Bookmarks(bookmarks_App(), stream_Buffer(buf));
+        setDataCStr_Archive(d->arch, "bookmarks.ini", data_Buffer(buf));
+        close_Buffer(buf);
+    }
+    /* Identities. */ {
+        iBuffer *buf2 = new_Buffer();
+        openEmpty_Buffer(buf2);
+        openEmpty_Buffer(buf);
+        serialize_GmCerts(certs_App(), stream_Buffer(buf), stream_Buffer(buf2));
+        setDataCStr_Archive(d->arch, "trusted.txt", data_Buffer(buf));
+        setDataCStr_Archive(d->arch, "idents.lgr", data_Buffer(buf2));
+        iRelease(buf2);
+        iForEach(DirFileInfo,
+                 info,
+                 iClob(new_DirFileInfo(collect_String(concatCStr_Path(dataDir_App(), "idents"))))) {
+            const iString *idPath = path_FileInfo(info.value);
+            const iRangecc baseName = baseName_Path(idPath);
+            if (!startsWith_Rangecc(baseName, ".") &&
+                (endsWith_Rangecc(baseName, ".crt") || endsWith_Rangecc(baseName, ".key"))) {
+                iFile *f = new_File(idPath);
+                if (open_File(f, readOnly_FileMode)) {
+                    setData_Archive(d->arch,
+                                    collectNewFormat_String("idents/%s", cstr_Rangecc(baseName)),
+                                    collect_Block(readAll_File(f)));
+                }
+                iRelease(f);
+            }
+        }
+        close_Buffer(buf);
+    }
+    /* Site-specific settings. */ {
+        openEmpty_Buffer(buf);
+        serialize_SiteSpec(stream_Buffer(buf));
+        setDataCStr_Archive(d->arch, "sitespec.ini", data_Buffer(buf));
+        close_Buffer(buf);
+    }
+    /* History of visited URLs. */ {
+        openEmpty_Buffer(buf);
+        serialize_Visited(visited_App(), stream_Buffer(buf));
+        setDataCStr_Archive(d->arch, "visited.txt", data_Buffer(buf));
+        close_Buffer(buf);
+    }    
+    /* Export metadata. */
+    setDataCStr_Archive(d->arch, metadataEntryName_Export_, utf8_String(meta));
+    delete_String(meta);
+    iRelease(buf);
+}
+
+iBool load_Export(iExport *d, const iArchive *archive) {
+    if (!detect_Export(archive)) {
+        return iFalse;
+    }
+    iRelease(d->arch);
+    d->arch = ref_Object(archive);
+    /* TODO: Check that at least one of the expected files is there. */
+    return iTrue;
+}
+
+iBuffer *openEntryBuffer_Export_(const iExport *d, const char *entryPath) {
+    iBuffer *buf = new_Buffer();
+    if (open_Buffer(buf, dataCStr_Archive(d->arch, entryPath))) {
+        return buf;
+    }
+    iRelease(buf);
+    return NULL;
+}
+
+void import_Export(const iExport *d, enum iImportMethod bookmarks, enum iImportMethod identities,
+                   enum iImportMethod trusted, enum iImportMethod visited,
+                   enum iImportMethod siteSpec) {
+    if (bookmarks) {
+        iBuffer *buf = openEntryBuffer_Export_(d, "bookmarks.ini");
+        if (buf) {
+            deserialize_Bookmarks(bookmarks_App(), stream_Buffer(buf), bookmarks);
+            iRelease(buf);
+            postCommand_App("bookmarks.changed");
+        }
+    }
+    if (trusted) {
+        iBuffer *buf = openEntryBuffer_Export_(d, "trusted.txt");
+        if (buf) {
+            deserializeTrusted_GmCerts(certs_App(), stream_Buffer(buf), trusted);
+            iRelease(buf);
+        }
+    }
+    if (identities) {
+        /* First extract any missing .crt/.key files to the idents directory. */
+        const iString *identsDir = collect_String(concatCStr_Path(dataDir_App(), "idents"));
+        iConstForEach(StringSet, i,
+                      iClob(listDirectory_Archive(d->arch, collectNewCStr_String("idents/")))) {
+            iString *dataPath = concatCStr_Path(identsDir, cstr_Rangecc(baseName_Path(i.value)));
+            if (identities == all_ImportMethod || !fileExists_FileInfo(dataPath)) {
+                iFile *f = new_File(dataPath);
+                if (open_File(f, writeOnly_FileMode)) {
+                    write_File(f, data_Archive(d->arch, i.value));
+                }
+                iRelease(f);
+            }
+            delete_String(dataPath);
+        }
+        iBuffer *buf = openEntryBuffer_Export_(d, "idents.lgr");
+        if (buf) {
+            deserializeIdentities_GmCerts(certs_App(), stream_Buffer(buf), identities);
+            iRelease(buf);
+            postCommand_App("idents.changed");
+        }
+    }
+    if (visited) {
+        iBuffer *buf = openEntryBuffer_Export_(d, "visited.txt");
+        if (buf) {
+            deserialize_Visited(visited_App(), stream_Buffer(buf), iTrue /* keep latest */);
+            iRelease(buf);
+            postCommand_App("visited.changed");
+        }
+    }
+    if (siteSpec) {
+        iBuffer *buf = openEntryBuffer_Export_(d, "sitespec.ini");
+        if (buf) {
+            deserialize_SiteSpec(stream_Buffer(buf), siteSpec);
+            iRelease(buf);
+        }
+    }
+}
+
+iBool detect_Export(const iArchive *d) {
+    if (entryCStr_Archive(d, metadataEntryName_Export_)) {
+        return iTrue;
+    }
+    /* TODO: Additional checks? */
+    return iFalse;
+}
+
+const iArchive *archive_Export(const iExport *d) {
+    return d->arch;
+}
diff --git a/src/export.h b/src/export.h
new file mode 100644
index 00000000..149a08c8
--- /dev/null
+++ b/src/export.h
@@ -0,0 +1,44 @@
+/* Copyright 2022 Jaakko Keränen 
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
+
+#pragma once
+
+#include "defs.h"
+#include 
+
+extern const char *mimeType_Export;
+
+iDeclareType(Export)
+iDeclareTypeConstruction(Export)    
+    
+void    generate_Export (iExport *);
+iBool   load_Export     (iExport *, const iArchive *archive);
+void    import_Export   (const iExport *,
+                         enum iImportMethod bookmarks,
+                         enum iImportMethod identities,
+                         enum iImportMethod trusted,
+                         enum iImportMethod visited,
+                         enum iImportMethod siteSpec);
+
+iBool   detect_Export   (const iArchive *);
+
+const iArchive *    archive_Export  (const iExport *);
diff --git a/src/gmcerts.c b/src/gmcerts.c
index 7b05103b..dc36cb59 100644
--- a/src/gmcerts.c
+++ b/src/gmcerts.c
@@ -36,7 +36,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 #include 
 #include 
 
-static const char *filename_GmCerts_          = "trusted.2.txt";
+static const char *trustedFilename_GmCerts_   = "trusted.2.txt";
 static const char *identsDir_GmCerts_         = "idents";
 static const char *oldIdentsFilename_GmCerts_ = "idents.binary";
 static const char *identsFilename_GmCerts_    = "idents.lgr";
@@ -248,26 +248,8 @@ static const char *magicIdentity_GmCerts_ = "iden";
 
 iDefineTypeConstructionArgs(GmCerts, (const char *saveDir), saveDir)
 
-void saveIdentities_GmCerts(const iGmCerts *d) {
-    iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, identsFilename_GmCerts_)));
-    if (open_File(f, writeOnly_FileMode)) {
-        writeData_File(f, magicIdMeta_GmCerts_, 4);
-        writeU32_File(f, idents_FileVersion); /* version */
-        iConstForEach(PtrArray, i, &d->idents) {
-            const iGmIdentity *ident = i.ptr;
-            if (~ident->flags & temporary_GmIdentityFlag) {
-                writeData_File(f, magicIdentity_GmCerts_, 4);
-                serialize_GmIdentity(ident, stream_File(f));
-            }
-        }
-    }
-    iRelease(f);
-}
-
-static void save_GmCerts_(const iGmCerts *d) {
-    iBeginCollect();
-    iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, filename_GmCerts_)));
-    if (open_File(f, writeOnly_FileMode | text_FileMode)) {
+void serialize_GmCerts(const iGmCerts *d, iStream *trusted, iStream *identsMeta) {
+    if (trusted) {
         iString line;
         init_String(&line);
         iConstForEach(StringHash, i, d->trusted) {
@@ -277,57 +259,39 @@ static void save_GmCerts_(const iGmCerts *d) {
                           cstr_String(key_StringHashConstIterator(&i)),
                           integralSeconds_Time(&trust->validUntil),
                           cstrCollect_String(hexEncode_Block(&trust->fingerprint)));
-            write_File(f, &line.chars);
+            write_Stream(trusted, &line.chars);
         }
-        deinit_String(&line);
+        deinit_String(&line);        
     }
-    iRelease(f);
-    iEndCollect();
-}
-
-static void loadIdentities_GmCerts_(iGmCerts *d) {
-    const iString *oldPath = collect_String(concatCStr_Path(&d->saveDir, oldIdentsFilename_GmCerts_));
-    const iString *path    = collect_String(concatCStr_Path(&d->saveDir, identsFilename_GmCerts_));
-    iFile *f = iClob(new_File(fileExists_FileInfo(path) ? path : oldPath));
-    if (open_File(f, readOnly_FileMode)) {
-        char magic[4];
-        readData_File(f, sizeof(magic), magic);
-        if (memcmp(magic, magicIdMeta_GmCerts_, sizeof(magic))) {
-            printf("%s: format not recognized\n", cstr_String(path_File(f)));
-            return;
-        }
-        const uint32_t version = readU32_File(f);
-        if (version > latest_FileVersion) {
-            printf("%s: unsupported version\n", cstr_String(path_File(f)));
-            return;
-        }
-        setVersion_Stream(stream_File(f), version);
-        while (!atEnd_File(f)) {
-            readData_File(f, sizeof(magic), magic);
-            if (!memcmp(magic, magicIdentity_GmCerts_, sizeof(magic))) {
-                iGmIdentity *id = new_GmIdentity();
-                deserialize_GmIdentity(id, stream_File(f));
-                pushBack_PtrArray(&d->idents, id);
-            }
-            else {
-                printf("%s: invalid file contents\n", cstr_String(path_File(f)));
-                break;
+    if (identsMeta) {
+        writeData_Stream(identsMeta, magicIdMeta_GmCerts_, 4);
+        writeU32_Stream(identsMeta, idents_FileVersion); /* version */
+        iConstForEach(PtrArray, i, &d->idents) {
+            const iGmIdentity *ident = i.ptr;
+            if (~ident->flags & temporary_GmIdentityFlag) {
+                writeData_Stream(identsMeta, magicIdentity_GmCerts_, 4);
+                serialize_GmIdentity(ident, identsMeta);
             }
-        }
+        }        
     }
 }
 
-iGmIdentity *findIdentity_GmCerts(iGmCerts *d, const iBlock *fingerprint) {
-    if (isEmpty_Block(fingerprint)) {
-        return NULL;
+void saveIdentities_GmCerts(const iGmCerts *d) {
+    iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, identsFilename_GmCerts_)));
+    if (open_File(f, writeOnly_FileMode)) {
+        serialize_GmCerts(d, NULL, stream_File(f));
     }
-    iForEach(PtrArray, i, &d->idents) {
-        iGmIdentity *ident = i.ptr;
-        if (cmp_Block(fingerprint, &ident->fingerprint) == 0) { /* TODO: could use a hash */
-            return ident;
-        }
+    iRelease(f);
+}
+
+static void save_GmCerts_(const iGmCerts *d) {
+    iBeginCollect();
+    iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, trustedFilename_GmCerts_)));
+    if (open_File(f, writeOnly_FileMode | text_FileMode)) {
+        serialize_GmCerts(d, stream_File(f), NULL);        
     }
-    return NULL;
+    iRelease(f);
+    iEndCollect();
 }
 
 static void loadIdentityFromCertificate_GmCerts_(iGmCerts *d, const iString *crtPath) {
@@ -354,53 +318,127 @@ static void loadIdentityFromCertificate_GmCerts_(iGmCerts *d, const iString *crt
     delete_Block(finger);
 }
 
-static void load_GmCerts_(iGmCerts *d) {
-    iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, filename_GmCerts_)));
-    if (open_File(f, readOnly_FileMode | text_FileMode)) {
-        iRegExp *      pattern = new_RegExp("([^\\s]+) ([0-9]+) ([a-z0-9]+)", 0);
-        const iRangecc src     = range_Block(collect_Block(readAll_File(f)));
-        iRangecc       line    = iNullRange;
-        while (nextSplit_Rangecc(src, "\n", &line)) {
-            iRegExpMatch m;
-            init_RegExpMatch(&m);
-            if (matchRange_RegExp(pattern, line, &m)) {
-                const iRangecc key    = capturedRange_RegExpMatch(&m, 1);
-                const iRangecc until  = capturedRange_RegExpMatch(&m, 2);
-                const iRangecc fp     = capturedRange_RegExpMatch(&m, 3);
-                time_t sec;
-                sscanf(until.start, "%ld", &sec);
-                iDate untilDate;
-                initSinceEpoch_Date(&untilDate, sec);
-                insert_StringHash(d->trusted,
-                                  collect_String(newRange_String(key)),
-                                  new_TrustEntry(collect_Block(hexDecode_Rangecc(fp)),
-                                                 &untilDate));
-            }
+static void loadIdentityCertsAndDiscardInvalid_GmCerts_(iGmCerts *d) {
+    const iString *idDir = collect_String(concatCStr_Path(&d->saveDir, identsDir_GmCerts_));
+    if (!fileExists_FileInfo(idDir)) {
+        makeDirs_Path(idDir);
+    }
+    iForEach(DirFileInfo, i, iClob(directoryContents_FileInfo(iClob(new_FileInfo(idDir))))) {
+        const iFileInfo *entry = i.value;
+        if (endsWithCase_String(path_FileInfo(entry), ".crt")) {
+            loadIdentityFromCertificate_GmCerts_(d, path_FileInfo(entry));
         }
-        iRelease(pattern);
     }
-    iRelease(f);
-    /* Load all identity certificates. */ {
-        loadIdentities_GmCerts_(d);
-        const iString *idDir = collect_String(concatCStr_Path(&d->saveDir, identsDir_GmCerts_));
-        if (!fileExists_FileInfo(idDir)) {
-            makeDirs_Path(idDir);
+    /* Remove certificates whose crt/key files were missing. */
+    iForEach(PtrArray, j, &d->idents) {
+        iGmIdentity *ident = j.ptr;
+        if (!isValid_GmIdentity_(ident)) {
+            delete_GmIdentity(ident);
+            remove_PtrArrayIterator(&j);
         }
-        iForEach(DirFileInfo, i, iClob(directoryContents_FileInfo(iClob(new_FileInfo(idDir))))) {
-            const iFileInfo *entry = i.value;
-            if (endsWithCase_String(path_FileInfo(entry), ".crt")) {
-                loadIdentityFromCertificate_GmCerts_(d, path_FileInfo(entry));
+    }    
+}
+
+iBool deserializeIdentities_GmCerts(iGmCerts *d, iStream *ins, enum iImportMethod method) {
+    char magic[4];
+    readData_Stream(ins, sizeof(magic), magic);
+    if (memcmp(magic, magicIdMeta_GmCerts_, sizeof(magic))) {
+        fprintf(stderr, "[GmCerts] idents file format not recognized\n");
+        return iFalse;
+    }
+    const uint32_t version = readU32_Stream(ins);
+    if (version > latest_FileVersion) {
+        fprintf(stderr, "[GmCerts] unsupported version (%u)\n", version);
+        return iFalse;
+    }
+    setVersion_Stream(ins, version);
+    while (!atEnd_Stream(ins)) {
+        readData_Stream(ins, sizeof(magic), magic);
+        if (!memcmp(magic, magicIdentity_GmCerts_, sizeof(magic))) {
+            iGmIdentity *id = new_GmIdentity();
+            deserialize_GmIdentity(id, ins);
+            if (method == all_ImportMethod ||
+                (method == ifMissing_ImportMethod && !findIdentity_GmCerts(d, &id->fingerprint))) {
+                pushBack_PtrArray(&d->idents, id);
+            }
+            else {
+                delete_GmIdentity(id);
             }
         }
-        /* Remove certificates whose crt/key files were missing. */
-        iForEach(PtrArray, j, &d->idents) {
-            iGmIdentity *ident = j.ptr;
-            if (!isValid_GmIdentity_(ident)) {
-                delete_GmIdentity(ident);
-                remove_PtrArrayIterator(&j);
+        else {
+            fprintf(stderr, "[GmCerts] invalid idents file\n");
+            return iFalse;
+        }
+    }
+    loadIdentityCertsAndDiscardInvalid_GmCerts_(d);
+    return iTrue;
+}
+
+static void loadIdentities_GmCerts_(iGmCerts *d) {
+    const iString *oldPath = collect_String(concatCStr_Path(&d->saveDir, oldIdentsFilename_GmCerts_));
+    const iString *path    = collect_String(concatCStr_Path(&d->saveDir, identsFilename_GmCerts_));
+    iFile *f = iClob(new_File(fileExists_FileInfo(path) ? path : oldPath));
+    if (open_File(f, readOnly_FileMode)) {
+        deserializeIdentities_GmCerts(d, stream_File(f), all_ImportMethod);
+    }
+    else {
+        /* In any case, load any .crt/.key files that may be present in the "idents" dir. */
+        loadIdentityCertsAndDiscardInvalid_GmCerts_(d);        
+    }
+}
+
+iGmIdentity *findIdentity_GmCerts(iGmCerts *d, const iBlock *fingerprint) {
+    if (isEmpty_Block(fingerprint)) {
+        return NULL;
+    }
+    iForEach(PtrArray, i, &d->idents) {
+        iGmIdentity *ident = i.ptr;
+        if (cmp_Block(fingerprint, &ident->fingerprint) == 0) { /* TODO: could use a hash */
+            return ident;
+        }
+    }
+    return NULL;
+}
+
+void deserializeTrusted_GmCerts(iGmCerts *d, iStream *ins, enum iImportMethod method) {
+    iRegExp *      pattern = new_RegExp("([^\\s]+) ([0-9]+) ([a-z0-9]+)", 0);
+    const iRangecc src     = range_Block(collect_Block(readAll_Stream(ins)));
+    iRangecc       line    = iNullRange;
+    lock_Mutex(d->mtx);
+    while (nextSplit_Rangecc(src, "\n", &line)) {
+        iRegExpMatch m;
+        init_RegExpMatch(&m);
+        if (matchRange_RegExp(pattern, line, &m)) {
+            iBeginCollect();
+            const iRangecc key   = capturedRange_RegExpMatch(&m, 1);
+            const iRangecc until = capturedRange_RegExpMatch(&m, 2);
+            const iRangecc fp    = capturedRange_RegExpMatch(&m, 3);
+            time_t sec;
+            sscanf(until.start, "%ld", &sec);
+            iDate untilDate;
+            initSinceEpoch_Date(&untilDate, sec);
+            /* TODO: import method? */
+            const iString *hashKey = collect_String(newRange_String(key));
+            if (method == all_ImportMethod ||
+                (method == ifMissing_ImportMethod && !contains_StringHash(d->trusted, hashKey))) {
+                insert_StringHash(d->trusted,
+                                  hashKey,
+                                  new_TrustEntry(collect_Block(hexDecode_Rangecc(fp)), &untilDate));
             }
+            iEndCollect();
         }
     }
+    unlock_Mutex(d->mtx);
+    iRelease(pattern);
+}
+
+static void load_GmCerts_(iGmCerts *d) {
+    iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, trustedFilename_GmCerts_)));
+    if (open_File(f, readOnly_FileMode | text_FileMode)) {
+        deserializeTrusted_GmCerts(d, stream_File(f), all_ImportMethod);
+    }
+    iRelease(f);
+    loadIdentities_GmCerts_(d);
 }
 
 iBool verify_GmCerts_(iTlsRequest *request, const iTlsCertificate *cert, int depth) {
diff --git a/src/gmcerts.h b/src/gmcerts.h
index 6ece1954..b451a690 100644
--- a/src/gmcerts.h
+++ b/src/gmcerts.h
@@ -22,6 +22,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 
 #pragma once
 
+#include "defs.h"
 #include 
 #include 
 #include 
@@ -87,6 +88,9 @@ void                importIdentity_GmCerts  (iGmCerts *, iTlsCertificate *cert,
                                              const iString *notes); /* takes ownership */
 void                deleteIdentity_GmCerts  (iGmCerts *, iGmIdentity *identity);
 void                saveIdentities_GmCerts  (const iGmCerts *);
+void                serialize_GmCerts       (const iGmCerts *, iStream *trusted, iStream *identsMeta);
+void                deserializeTrusted_GmCerts      (iGmCerts *, iStream *ins, enum iImportMethod method);
+iBool               deserializeIdentities_GmCerts   (iGmCerts *, iStream *ins, enum iImportMethod method);
 
 const iString *     certificatePath_GmCerts (const iGmCerts *, const iGmIdentity *identity);
 
diff --git a/src/gmrequest.c b/src/gmrequest.c
index 55b7b471..aa55aca2 100644
--- a/src/gmrequest.c
+++ b/src/gmrequest.c
@@ -711,7 +711,7 @@ void submit_GmRequest(iGmRequest *d) {
             iString *page = collectNew_String();
             iString *parentDir = collectNewRange_String(dirName_Path(path));
 #if !defined (iPlatformMobile)
-            appendFormat_String(page, "=> %s " upArrow_Icon " %s" iPathSeparator "\n\n",
+            appendFormat_String(page, "=> %s " keyUpArrow_Icon " %s" iPathSeparator "\n\n",
                                 cstrCollect_String(makeFileUrl_String(parentDir)),
                                 cstr_String(parentDir));
 #endif
@@ -792,14 +792,14 @@ void submit_GmRequest(iGmRequest *d) {
                             if (!equal_Rangecc(parentDir, ".")) {
                                 /* A subdirectory. */
                                 appendFormat_String(page,
-                                                    "=> ../ " upArrow_Icon " %s" iPathSeparator
+                                                    "=> ../ " keyUpArrow_Icon " %s" iPathSeparator
                                                     "\n",
                                                     cstr_Rangecc(parentDir));
                             }
                             else {
                                 /* Top-level directory. */
                                 appendFormat_String(page,
-                                                    "=> %s/ " upArrow_Icon " Root\n",
+                                                    "=> %s/ " keyUpArrow_Icon " Root\n",
                                                     cstr_String(containerUrl));
                             }
                             appendFormat_String(page, "# %s\n\n", cstr_Rangecc(baseName_Path(collectNewRange_String(curDir))));
diff --git a/src/sitespec.c b/src/sitespec.c
index 0db471d8..5f29e55f 100644
--- a/src/sitespec.c
+++ b/src/sitespec.c
@@ -75,6 +75,7 @@ struct Impl_SiteSpec {
     iString     saveDir;
     iStringHash sites;
     iSiteParams *loadParams;
+    enum iImportMethod loadMethod;
 };
 
 static iSiteSpec   siteSpec_;
@@ -127,7 +128,10 @@ static void handleIniTable_SiteSpec_(void *context, const iString *table, iBool
     }
     else {
         iAssert(d->loadParams != NULL);
-        insert_StringHash(&d->sites, table, d->loadParams);
+        if (d->loadMethod == all_ImportMethod ||
+            (d->loadMethod == ifMissing_ImportMethod && !contains_StringHash(&d->sites, table))) {
+            insert_StringHash(&d->sites, table, d->loadParams);
+        }
         iReleasePtr(&d->loadParams);
     }
 }
@@ -163,62 +167,74 @@ static void handleIniKeyValue_SiteSpec_(void *context, const iString *table, con
 }
 
 static iBool load_SiteSpec_(iSiteSpec *d) {
-    iBool ok = iFalse;
+    iBool ok = iFalse;   
     iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, fileName_SiteSpec_)));
     if (open_File(f, readOnly_FileMode | text_FileMode)) {
-        iTomlParser *toml = new_TomlParser();
-        setHandlers_TomlParser(toml, handleIniTable_SiteSpec_, handleIniKeyValue_SiteSpec_, d);
-        ok = parse_TomlParser(toml, collect_String(readString_File(f)));
-        delete_TomlParser(toml);
+        ok = deserialize_SiteSpec(stream_File(f), all_ImportMethod);
     }
     iRelease(f);
     iAssert(d->loadParams == NULL);
     return ok;
 }
 
+iBool deserialize_SiteSpec(iStream *ins, enum iImportMethod loadMethod) {
+    iSiteSpec *d = &siteSpec_;
+    d->loadMethod = loadMethod;
+    iTomlParser *toml = new_TomlParser();
+    setHandlers_TomlParser(toml, handleIniTable_SiteSpec_, handleIniKeyValue_SiteSpec_, d);
+    iBool ok = parse_TomlParser(toml, collect_String(readString_Stream(ins)));
+    delete_TomlParser(toml);
+    return ok;
+}
+
+void serialize_SiteSpec(iStream *out) {
+    iSiteSpec *d = &siteSpec_;
+    iString *buf = new_String();
+    iConstForEach(StringHash, i, &d->sites) {
+        iBeginCollect();
+        const iBlock *     key    = &i.value->keyBlock;
+        const iSiteParams *params = i.value->object;
+        clear_String(buf);
+        if (params->titanPort) {
+            appendFormat_String(buf, "titanPort = %u\n", params->titanPort);
+        }
+        if (!isEmpty_String(¶ms->titanIdentity)) {
+            appendFormat_String(
+                buf, "titanIdentity = \"%s\"\n", cstr_String(¶ms->titanIdentity));
+        }
+        if (params->dismissWarnings) {
+            appendFormat_String(buf, "dismissWarnings = 0x%x\n", params->dismissWarnings);
+        }
+        if (!isEmpty_StringArray(¶ms->usedIdentities)) {
+            appendFormat_String(
+                buf,
+                "usedIdentities = \"%s\"\n",
+                cstrCollect_String(joinCStr_StringArray(¶ms->usedIdentities, " ")));
+        }
+        if (!isEmpty_String(¶ms->paletteSeed)) {
+            appendCStr_String(buf, "paletteSeed = \"");
+            append_String(buf, collect_String(quote_String(¶ms->paletteSeed, iFalse)));
+            appendCStr_String(buf, "\"\n");
+        }
+        if (!params->tlsSessionCache) {
+            appendCStr_String(buf, "tlsSessionCache = false\n");
+        }
+        if (!isEmpty_String(buf)) {
+            writeData_Stream(out, "[", 1);
+            writeData_Stream(out, constData_Block(key), size_Block(key));
+            writeData_Stream(out, "]\n", 2);
+            appendCStr_String(buf, "\n");
+            write_Stream(out, utf8_String(buf));
+        }
+        iEndCollect();
+    }
+    delete_String(buf);    
+}
+
 static void save_SiteSpec_(iSiteSpec *d) {
     iFile *f = new_File(collect_String(concatCStr_Path(&d->saveDir, fileName_SiteSpec_)));
     if (open_File(f, writeOnly_FileMode | text_FileMode)) {
-        iString *buf = new_String();
-        iConstForEach(StringHash, i, &d->sites) {
-            iBeginCollect();
-            const iBlock *     key    = &i.value->keyBlock;
-            const iSiteParams *params = i.value->object;
-            clear_String(buf);
-            if (params->titanPort) {
-                appendFormat_String(buf, "titanPort = %u\n", params->titanPort);
-            }
-            if (!isEmpty_String(¶ms->titanIdentity)) {
-                appendFormat_String(
-                    buf, "titanIdentity = \"%s\"\n", cstr_String(¶ms->titanIdentity));
-            }
-            if (params->dismissWarnings) {
-                appendFormat_String(buf, "dismissWarnings = 0x%x\n", params->dismissWarnings);
-            }
-            if (!isEmpty_StringArray(¶ms->usedIdentities)) {
-                appendFormat_String(
-                    buf,
-                    "usedIdentities = \"%s\"\n",
-                    cstrCollect_String(joinCStr_StringArray(¶ms->usedIdentities, " ")));
-            }
-            if (!isEmpty_String(¶ms->paletteSeed)) {
-                appendCStr_String(buf, "paletteSeed = \"");
-                append_String(buf, collect_String(quote_String(¶ms->paletteSeed, iFalse)));
-                appendCStr_String(buf, "\"\n");
-            }
-            if (!params->tlsSessionCache) {
-                appendCStr_String(buf, "tlsSessionCache = false\n");
-            }
-            if (!isEmpty_String(buf)) {
-                writeData_File(f, "[", 1);
-                writeData_File(f, constData_Block(key), size_Block(key));
-                writeData_File(f, "]\n", 2);
-                appendCStr_String(buf, "\n");
-                write_File(f, utf8_String(buf));
-            }
-            iEndCollect();
-        }
-        delete_String(buf);
+        serialize_SiteSpec(stream_File(f));
     }
     iRelease(f);
 }
diff --git a/src/sitespec.h b/src/sitespec.h
index 15219c62..a12bdb00 100644
--- a/src/sitespec.h
+++ b/src/sitespec.h
@@ -22,6 +22,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 
 #pragma once
 
+#include "defs.h"
 #include 
 
 iDeclareType(SiteSpec)
@@ -38,6 +39,9 @@ enum iSiteSpecKey {
 void    init_SiteSpec       (const char *saveDir);
 void    deinit_SiteSpec     (void);
 
+void    serialize_SiteSpec      (iStream *);
+iBool   deserialize_SiteSpec    (iStream *, enum iImportMethod);
+
 /* changes saved immediately */
 void    setValue_SiteSpec       (const iString *site, enum iSiteSpecKey key, int value); 
 void    setValueString_SiteSpec (const iString *site, enum iSiteSpecKey key, const iString *value);
diff --git a/src/ui/documentwidget.c b/src/ui/documentwidget.c
index d6c63a30..14fd2433 100644
--- a/src/ui/documentwidget.c
+++ b/src/ui/documentwidget.c
@@ -31,6 +31,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 #include "bookmarks.h"
 #include "command.h"
 #include "defs.h"
+#include "export.h"
 #include "gempub.h"
 #include "gmcerts.h"
 #include "gmdocument.h"
@@ -2415,6 +2416,9 @@ static const char *zipPageHeading_(const iRangecc mime) {
     else if (equalCase_Rangecc(mime, mimeType_FontPack)) {
         return fontpack_Icon " Fontpack";
     }
+    else if (equalCase_Rangecc(mime, mimeType_Export)) {
+        return package_Icon " ${heading.archive.userdata}";
+    }
     iRangecc type = iNullRange;
     nextSplit_Rangecc(mime, "/", &type); /* skip the part before the slash */
     nextSplit_Rangecc(mime, "/", &type);
@@ -2701,13 +2705,15 @@ static void updateDocument_DocumentWidget_(iDocumentWidget *d,
                          (equal_Rangecc(param, "application/zip") ||
                          (startsWith_Rangecc(param, "application/") &&
                           endsWithCase_Rangecc(param, "+zip")))) {
+                    iArray *footerItems = collectNew_Array(sizeof(iMenuItem));
                     clear_String(&str);
                     docFormat = gemini_SourceFormat;
                     setRange_String(&d->sourceMime, param);
+                    iArchive *zip = new_Archive();
+                    openData_Archive(zip, &response->body);
                     if (equal_Rangecc(param, mimeType_FontPack)) {
                         /* Show some information about fontpacks, and set up footer actions. */
-                        iArchive *zip = iClob(new_Archive());
-                        if (openData_Archive(zip, &response->body)) {
+                        if (isOpen_Archive(zip)) {
                             iFontPack *fp = new_FontPack();
                             setUrl_FontPack(fp, d->mod.url);
                             setStandalone_FontPack(fp, iTrue);
@@ -2726,28 +2732,54 @@ static void updateDocument_DocumentWidget_(iDocumentWidget *d,
                         }
                     }
                     else {
-                        format_String(&str, "# %s\n", zipPageHeading_(param));
+                        if (detect_Export(zip)) {
+                            setCStr_String(&d->sourceMime, mimeType_Export);
+                            pushBack_Array(footerItems,
+                                           &(iMenuItem){ openExt_Icon " ${menu.open.external}",
+                                                         SDLK_RETURN,
+                                                         KMOD_PRIMARY,
+                                                         "document.save extview:1" });
+                        }
+                        format_String(&str, "# %s\n", zipPageHeading_(range_String(&d->sourceMime)));
                         appendFormat_String(&str,
                                             cstr_Lang("doc.archive"),
                                             cstr_Rangecc(baseName_Path(d->mod.url)));
                         appendCStr_String(&str, "\n");
                     }
+                    iRelease(zip);
                     appendCStr_String(&str, "\n");
                     iString *localPath = localFilePathFromUrl_String(d->mod.url);
-                    if (!localPath) {
+                    if (!localPath || !fileExists_FileInfo(localPath)) {
                         iString *key = collectNew_String();
                         toString_Sym(SDLK_s, KMOD_PRIMARY, key);
                         appendFormat_String(&str, "%s\n\n",
                                             format_CStr(cstr_Lang("error.unsupported.suggestsave"),
                                                         cstr_String(key),
                                                         saveToDownloads_Label));
+                        pushBack_Array(footerItems,
+                                       &(iMenuItem){ translateCStr_Lang(download_Icon
+                                                                        " " saveToDownloads_Label),
+                                                     0,
+                                                     0,
+                                                     "document.save" });
                     }
-                    delete_String(localPath);
-                    if (equalCase_Rangecc(urlScheme_String(d->mod.url), "file")) {
-                        appendFormat_String(&str, "=> %s/ " folder_Icon " ${doc.archive.view}\n",
+                    if (localPath && fileExists_FileInfo(localPath)) {
+                        if (!cmp_String(&d->sourceMime, mimeType_Export)) {
+                            pushFront_Array(footerItems,
+                                            &(iMenuItem){ import_Icon " ${menu.import}",
+                                                          SDLK_RETURN,
+                                                          0,
+                                                          format_CStr("!import path:%s",
+                                                                      cstr_String(localPath)) });
+                        }
+                        appendFormat_String(&str,
+                                            "=> %s/ " folder_Icon " ${doc.archive.view}\n",
                                             cstr_String(withSpacesEncoded_String(d->mod.url)));
                     }
+                    delete_String(localPath);
                     translate_Lang(&str);
+                    makeFooterButtons_DocumentWidget_(
+                        d, constData_Array(footerItems), size_Array(footerItems));
                 }
                 else if (startsWith_Rangecc(param, "image/") ||
                          startsWith_Rangecc(param, "audio/")) {
@@ -4287,7 +4319,8 @@ static iBool handleCommand_DocumentWidget_(iDocumentWidget *d, const char *cmd)
         }
         else if (!isEmpty_Block(&d->sourceContent)) {
             if (argLabel_Command(cmd, "extview")) {
-                if (equalCase_Rangecc(urlScheme_String(d->mod.url), "file")) {
+                if (equalCase_Rangecc(urlScheme_String(d->mod.url), "file") &&
+                    fileExists_FileInfo(collect_String(localFilePathFromUrl_String(d->mod.url)))) {
                     /* Already a file so just open it directly. */
                     postCommandf_Root(w->root, "!open default:1 url:%s", cstr_String(d->mod.url));
                 }
diff --git a/src/ui/util.c b/src/ui/util.c
index c3e39821..608ee116 100644
--- a/src/ui/util.c
+++ b/src/ui/util.c
@@ -29,6 +29,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */
 #include "command.h"
 #include "defs.h"
 #include "documentwidget.h"
+#include "export.h"
 #include "feeds.h"
 #include "gmutil.h"
 #include "inputwidget.h"
@@ -2658,7 +2659,7 @@ iWidget *makePreferences_Widget(void) {
         const iMenuItem identityPanelItems[] = {
             { "title id:sidebar.identities" },
             { "certlist" },
-            { "navi.action id:prefs.ident.import text:" inbox_Icon, 0, 0, "ident.import" },
+            { "navi.action id:prefs.ident.import text:" import_Icon, 0, 0, "ident.import" },
             { "navi.action id:prefs.ident.new text:" add_Icon, 0, 0, "ident.new" },
             { NULL }  
         };
@@ -3718,6 +3719,118 @@ iWidget *makeGlyphFinder_Widget(void) {
     return dlg;
 }
 
+static enum iImportMethod checkImportMethod_(const iWidget *dlg, const char *id) {
+    return isSelected_Widget(findChild_Widget(dlg, format_CStr("%s.0", id))) ? none_ImportMethod
+           : isSelected_Widget(findChild_Widget(dlg, format_CStr("%s.1", id)))
+               ? ifMissing_ImportMethod
+               : all_ImportMethod;
+}
+
+static iBool handleUserDataImporterCommands_(iWidget *dlg, const char *cmd) {
+    if (equalWidget_Command(cmd, dlg, "importer.cancel") ||
+        equalWidget_Command(cmd, dlg, "importer.accept")) {
+        if (equal_Command(cmd, "importer.accept")) {
+            /* Compose the final import command. */
+            enum iImportMethod bookmarkMethod = checkImportMethod_(dlg, "importer.bookmark");
+            enum iImportMethod identMethod    = checkImportMethod_(dlg, "importer.idents");
+            enum iImportMethod trustedMethod  = checkImportMethod_(dlg, "importer.trusted");
+            enum iImportMethod sitespecMethod = checkImportMethod_(dlg, "importer.sitespec");
+            enum iImportMethod visitedMethod =
+                isSelected_Widget(findChild_Widget(dlg, "importer.history")) ? all_ImportMethod
+                                                                             : none_ImportMethod;
+            postCommandf_App(
+                "import arg:1 bookmarks:%d idents:%d trusted:%d visited:%d sitespec:%d path:%s",
+                bookmarkMethod,
+                identMethod,
+                trustedMethod,
+                visitedMethod,
+                sitespecMethod,
+                suffixPtr_Command(cmd, "path"));
+        }
+        setupSheetTransition_Mobile(dlg, dialogTransitionDir_Widget(dlg));
+        destroy_Widget(dlg);
+        return iTrue;
+    }
+    else if (equalWidget_Command(cmd, dlg, "importer.selectall")) {
+        postCommand_Widget(findChild_Widget(dlg, "importer.bookmark.1"), "trigger");
+        postCommand_Widget(findChild_Widget(dlg, "importer.trusted.1"), "trigger");
+        postCommand_Widget(findChild_Widget(dlg, "importer.idents.1"), "trigger");
+        postCommand_Widget(findChild_Widget(dlg, "importer.sitespec.1"), "trigger");
+        setToggle_Widget(findChild_Widget(dlg, "importer.history"), iTrue);
+        return iTrue;
+    }
+    return iFalse;
+}
+
+iWidget *makeUserDataImporter_Dialog(const iString *archivePath) {
+    iWidget *dlg;
+    const iMenuItem actions[] = {
+        { "${menu.selectall}", 0, 0, "importer.selectall" },
+        { "---" },
+        { "${cancel}", SDLK_ESCAPE, 0, "importer.cancel" },
+        { uiTextAction_ColorEscape "${import.userdata}",
+          SDLK_RETURN, KMOD_PRIMARY,
+          format_CStr("importer.accept path:%s", cstr_String(archivePath)) },
+    };
+    if (isUsingPanelLayout_Mobile()) {
+        dlg = makePanels_Mobile("importer", (iMenuItem[]){
+                                                           { NULL }
+                                            }, actions, iElemCount(actions));
+    }
+    else {
+        dlg = makeSheet_Widget("importer");
+        addDialogTitle_(dlg, "${heading.import.userdata}", NULL);
+        iWidget *headings, *values;
+        addChild_Widget(dlg, iClob(makeTwoColumns_Widget(&headings, &values)));
+        /* Bookmarks. */
+        addChild_Widget(headings, iClob(makeHeading_Widget("${import.userdata.bookmarks}")));
+        iWidget *radio = new_Widget(); {
+            addRadioButton_(radio, "importer.bookmark.0", "${dlg.userdata.no}", ".");
+            addRadioButton_(radio, "importer.bookmark.1", "${dlg.userdata.missing}", ".");
+            addRadioButton_(radio, "importer.bookmark.2", "${dlg.userdata.alldup}", ".");
+        }
+        addChildFlags_Widget(values, iClob(radio), arrangeHorizontal_WidgetFlag | arrangeSize_WidgetFlag);
+        /* Site-specific. */
+        addChild_Widget(headings, iClob(makeHeading_Widget("${import.userdata.sitespec}")));
+        radio = new_Widget(); {
+            addRadioButton_(radio, "importer.sitespec.0", "${dlg.userdata.no}", ".");
+            addRadioButton_(radio, "importer.sitespec.1", "${dlg.userdata.missing}", ".");
+            addRadioButton_(radio, "importer.sitespec.2", "${dlg.userdata.all}", ".");
+        }
+        addChildFlags_Widget(values, iClob(radio), arrangeHorizontal_WidgetFlag | arrangeSize_WidgetFlag);
+        /* Trusted certs. */
+        addChild_Widget(headings, iClob(makeHeading_Widget("${import.userdata.trusted}")));
+        radio = new_Widget(); {
+            addRadioButton_(radio, "importer.trusted.0", "${dlg.userdata.no}", ".");
+            addRadioButton_(radio, "importer.trusted.1", "${dlg.userdata.missing}", ".");
+            addRadioButton_(radio, "importer.trusted.2", "${dlg.userdata.all}", ".");
+        }
+        addChildFlags_Widget(values, iClob(radio), arrangeHorizontal_WidgetFlag | arrangeSize_WidgetFlag);
+        /* Identities. */
+        addChild_Widget(headings, iClob(makeHeading_Widget("${import.userdata.idents}")));
+        radio = new_Widget(); {
+            addRadioButton_(radio, "importer.idents.0", "${dlg.userdata.no}", ".");
+            addRadioButton_(radio, "importer.idents.1", "${dlg.userdata.missing}", ".");
+            addRadioButton_(radio, "importer.idents.2", "${dlg.userdata.all}", ".");
+        }
+        addChildFlags_Widget(values, iClob(radio), arrangeHorizontal_WidgetFlag | arrangeSize_WidgetFlag);
+        addDialogToggle_(headings, values, "${import.userdata.history}", "importer.history");
+        addDialogPadding_(headings, values);
+        addChild_Widget(dlg, iClob(makeDialogButtons_Widget(actions, iElemCount(actions))));
+        addChild_Widget(dlg->root->widget, iClob(dlg));
+        arrange_Widget(dlg);
+        arrange_Widget(dlg);
+        setupSheetTransition_Mobile(dlg, incoming_TransitionFlag | dialogTransitionDir_Widget(dlg));
+    }
+    /* Initialize. */
+    setToggle_Widget(findChild_Widget(dlg, "importer.bookmark.0"), iTrue);
+    setToggle_Widget(findChild_Widget(dlg, "importer.idents.0"), iTrue);
+    setToggle_Widget(findChild_Widget(dlg, "importer.sitespec.0"), iTrue);
+    setToggle_Widget(findChild_Widget(dlg, "importer.trusted.0"), iTrue);
+    setCommandHandler_Widget(dlg, handleUserDataImporterCommands_);
+    return dlg;
+}
+
 /*----------------------------------------------------------------------------------------------*/
 
 void init_PerfTimer(iPerfTimer *d) {
diff --git a/src/ui/util.h b/src/ui/util.h
index 50440137..cda1af61 100644
--- a/src/ui/util.h
+++ b/src/ui/util.h
@@ -346,6 +346,7 @@ iWidget *   makeFeedSettings_Widget         (uint32_t bookmarkId);
 iWidget *   makeSiteSpecificSettings_Widget (const iString *url);
 iWidget *   makeTranslation_Widget          (iWidget *parent);
 iWidget *   makeGlyphFinder_Widget          (void);
+iWidget *   makeUserDataImporter_Dialog     (const iString *archivePath);
 
 const char *    languageId_String   (const iString *menuItemLabel);
 int             languageIndex_CStr  (const char *langId);
diff --git a/src/ui/window.c b/src/ui/window.c
index f58d7b46..c981e7cf 100644
--- a/src/ui/window.c
+++ b/src/ui/window.c
@@ -87,6 +87,7 @@ static const iMenuItem fileMenuItems_[] = {
     { saveToDownloads_Label, SDLK_s, KMOD_PRIMARY, "document.save" },
     { "---", 0, 0, NULL },
     { "${menu.downloads}", 0, 0, "downloads.open" },
+    { "${menu.export}", 0, 0, "export" },
 };
 
 static const iMenuItem editMenuItems_[] = {
diff --git a/src/visited.c b/src/visited.c
index f6299ddb..c1c091e2 100644
--- a/src/visited.c
+++ b/src/visited.c
@@ -72,53 +72,71 @@ void deinit_Visited(iVisited *d) {
     delete_Mutex(d->mtx);
 }
 
-void save_Visited(const iVisited *d, const char *dirPath) {
+void serialize_Visited(const iVisited *d, iStream *out) {
     iString *line = new_String();
+    lock_Mutex(d->mtx);
+    iConstForEach(Array, i, &d->visited.values) {
+        const iVisitedUrl *item = i.value;
+        format_String(line,
+                      "%llu %04x %s\n",
+                      (unsigned long long) integralSeconds_Time(&item->when),
+                      item->flags,
+                      cstr_String(&item->url));
+        writeData_Stream(out, cstr_String(line), size_String(line));
+    }
+    unlock_Mutex(d->mtx);
+    delete_String(line);
+}
+
+void save_Visited(const iVisited *d, const char *dirPath) {
     iFile *f = newCStr_File(concatPath_CStr(dirPath, "visited.2.txt"));
     if (open_File(f, writeOnly_FileMode | text_FileMode)) {
-        lock_Mutex(d->mtx);
-        iConstForEach(Array, i, &d->visited.values) {
-            const iVisitedUrl *item = i.value;
-            format_String(line,
-                          "%llu %04x %s\n",
-                          (unsigned long long) integralSeconds_Time(&item->when),
-                          item->flags,
-                          cstr_String(&item->url));
-            writeData_File(f, cstr_String(line), size_String(line));
-        }
-        unlock_Mutex(d->mtx);
+        serialize_Visited(d, stream_File(f));
     }
     iRelease(f);
-    delete_String(line);
+}
+
+void deserialize_Visited(iVisited *d, iStream *ins, iBool mergeKeepingLatest) {
+    const iRangecc src  = range_Block(collect_Block(readAll_Stream(ins)));
+    iRangecc       line = iNullRange;
+    iTime          now;
+    initCurrent_Time(&now);
+    lock_Mutex(d->mtx);
+    while (nextSplit_Rangecc(src, "\n", &line)) {
+        if (size_Range(&line) < 8) continue;
+        char *endp = NULL;
+        const unsigned long long ts = strtoull(line.start, &endp, 10);
+        if (ts == 0) break;
+        const uint32_t flags = (uint32_t) strtoul(skipSpace_CStr(endp), &endp, 16);
+        const char *urlStart = skipSpace_CStr(endp);
+        iVisitedUrl item;
+        item.when.ts = (struct timespec){ .tv_sec = ts };
+        if (~flags & kept_VisitedUrlFlag &&
+            secondsSince_Time(&now, &item.when) > maxAge_Visited) {
+            continue; /* Too old. */
+        }
+        item.flags = flags;
+        initRange_String(&item.url, (iRangecc){ urlStart, line.end });
+        set_String(&item.url, &item.url);
+        if (mergeKeepingLatest) {
+            /* Check if we already have this. */
+            size_t existingPos;
+            if (locate_SortedArray(&d->visited, &item, &existingPos)) {
+                iVisitedUrl *existing = at_SortedArray(&d->visited, existingPos);
+                max_Time(&existing->when, &item.when);
+                existing->flags = item.flags;
+                continue;
+            }
+        }
+        insert_SortedArray(&d->visited, &item);
+    }
+    unlock_Mutex(d->mtx);
 }
 
 void load_Visited(iVisited *d, const char *dirPath) {
     iFile *f = newCStr_File(concatPath_CStr(dirPath, "visited.2.txt"));
     if (open_File(f, readOnly_FileMode | text_FileMode)) {
-        lock_Mutex(d->mtx);
-        const iRangecc src  = range_Block(collect_Block(readAll_File(f)));
-        iRangecc       line = iNullRange;
-        iTime          now;
-        initCurrent_Time(&now);
-        while (nextSplit_Rangecc(src, "\n", &line)) {
-            if (size_Range(&line) < 8) continue;
-            char *endp = NULL;
-            const unsigned long long ts = strtoull(line.start, &endp, 10);
-            if (ts == 0) break;
-            const uint32_t flags = (uint32_t) strtoul(skipSpace_CStr(endp), &endp, 16);
-            const char *urlStart = skipSpace_CStr(endp);
-            iVisitedUrl item;
-            item.when.ts = (struct timespec){ .tv_sec = ts };
-            if (~flags & kept_VisitedUrlFlag &&
-                secondsSince_Time(&now, &item.when) > maxAge_Visited) {
-                continue; /* Too old. */
-            }
-            item.flags = flags;
-            initRange_String(&item.url, (iRangecc){ urlStart, line.end });
-            set_String(&item.url, &item.url);
-            insert_SortedArray(&d->visited, &item);
-        }
-        unlock_Mutex(d->mtx);
+        deserialize_Visited(d, stream_File(f), iFalse /* no merge */);
     }
     iRelease(f);
 }
diff --git a/src/visited.h b/src/visited.h
index 0fde1d1f..1484492e 100644
--- a/src/visited.h
+++ b/src/visited.h
@@ -50,6 +50,8 @@ iDeclareTypeConstruction(Visited)
 void    clear_Visited           (iVisited *);
 void    load_Visited            (iVisited *, const char *dirPath);
 void    save_Visited            (const iVisited *, const char *dirPath);
+void    serialize_Visited       (const iVisited *, iStream *out);
+void    deserialize_Visited     (iVisited *, iStream *ins, iBool mergeKeepingLatest);
 
 iTime   urlVisitTime_Visited    (const iVisited *, const iString *url);
 void    visitUrl_Visited        (iVisited *, const iString *url, uint16_t visitFlags); /* adds URL to the visited URLs set */
Proxy Information
Original URL
gemini://git.skyjake.fi/lagrange/work%2Fv1.12/cdiff/75197707e0bb149cb9c2e4a983d92fdfb381c17f
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
37.330206 milliseconds
Gemini-to-HTML Time
2.356236 milliseconds

This content has been proxied by September (ba2dc).