Pārlūkot izejas kodu

feat(editor/copy-paste): refactor copy/export in cljs (#8530)

Huge performance improvement for copy and export

Refactor copy/export in cljs instead of Mldoc
rcmerci 2 gadi atpakaļ
vecāks
revīzija
2a12ffc331

+ 3 - 34
src/main/frontend/components/block.cljs

@@ -50,6 +50,7 @@
             [frontend.handler.route :as route-handler]
             [frontend.handler.ui :as ui-handler]
             [frontend.handler.whiteboard :as whiteboard-handler]
+            [frontend.handler.export.common :as export-common-handler]
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.outliner.tree :as tree]
             [frontend.search :as search]
@@ -455,40 +456,8 @@
                       (get-file-absolute-path config href)))]
          (resizable-image config title href metadata full_text false))))))
 
-(defn repetition-to-string
-  [[[kind] [duration] n]]
-  (let [kind (case kind
-               "Dotted" "."
-               "Plus" "+"
-               "DoublePlus" "++")]
-    (str kind n (string/lower-case (str (first duration))))))
-
-(defn timestamp-to-string
-  [{:keys [_active date time repetition wday active]}]
-  (let [{:keys [year month day]} date
-        {:keys [hour min]} time
-        [open close] (if active ["<" ">"] ["[" "]"])
-        repetition (if repetition
-                     (str " " (repetition-to-string repetition))
-                     "")
-        hour (when hour (util/zero-pad hour))
-        min  (when min (util/zero-pad min))
-        time (cond
-               (and hour min)
-               (util/format " %s:%s" hour min)
-               hour
-               (util/format " %s" hour)
-               :else
-               "")]
-    (util/format "%s%s-%s-%s %s%s%s%s"
-                 open
-                 (str year)
-                 (util/zero-pad month)
-                 (util/zero-pad day)
-                 wday
-                 time
-                 repetition
-                 close)))
+
+(def timestamp-to-string export-common-handler/timestamp-to-string)
 
 (defn timestamp [{:keys [active _date _time _repetition _wday] :as t} kind]
   (let [prefix (case kind

+ 85 - 46
src/main/frontend/components/export.cljs

@@ -1,5 +1,8 @@
 (ns frontend.components.export
   (:require [frontend.context.i18n :refer [t]]
+            [frontend.handler.export.text :as export-text]
+            [frontend.handler.export.html :as export-html]
+            [frontend.handler.export.opml :as export-opml]
             [frontend.handler.export :as export]
             [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
@@ -21,19 +24,20 @@
         (t :export-json)]]
       (when (util/electron?)
         [:li.mb-4
-         [:a.font-medium {:on-click #(export/export-repo-as-html! current-repo)}
+         [:a.font-medium {:on-click #(export/download-repo-as-html! current-repo)}
           (t :export-public-pages)]])
       (when-not (mobile-util/native-platform?)
         [:li.mb-4
-         [:a.font-medium {:on-click #(export/export-repo-as-markdown! current-repo)}
-          (t :export-markdown)]]
+         [:a.font-medium {:on-click #(export-text/export-repo-as-markdown! current-repo)}
+          (t :export-markdown)]])
+      (when-not (mobile-util/native-platform?)
         [:li.mb-4
-         [:a.font-medium {:on-click #(export/export-repo-as-opml! current-repo)}
+         [:a.font-medium {:on-click #(export-opml/export-repo-as-opml! current-repo)}
           (t :export-opml)]])
       (when-not (mobile-util/native-platform?)
-       [:li.mb-4
-        [:a.font-medium {:on-click #(export/export-repo-as-roam-json! current-repo)}
-         (t :export-roam-json)]])]
+        [:li.mb-4
+         [:a.font-medium {:on-click #(export/export-repo-as-roam-json! current-repo)}
+          (t :export-roam-json)]])]
      [:a#download-as-edn-v2.hidden]
      [:a#download-as-json-v2.hidden]
      [:a#download-as-roam-json.hidden]
@@ -53,49 +57,72 @@
                                 {:label "no-indent"
                                  :selected false}])
 
-(rum/defcs export-blocks
-  < rum/reactive
-  (rum/local false ::copied?)
-  [state root-block-ids]
+(defn- export-helper
+  [block-uuids-or-page-name]
   (let [current-repo (state/get-current-repo)
-        type (rum/react *export-block-type)
-        text-indent-style (state/sub :copy/export-block-text-indent-style)
-        text-remove-options (state/sub :copy/export-block-text-remove-options)
-        copied? (::copied? state)
-        content
-        (case type
-          :text (export/export-blocks-as-markdown current-repo root-block-ids text-indent-style (into [] text-remove-options))
-          :opml (export/export-blocks-as-opml current-repo root-block-ids)
-          :html (export/export-blocks-as-html current-repo root-block-ids)
-          (export/export-blocks-as-markdown current-repo root-block-ids text-indent-style (into [] text-remove-options)))]
+        text-indent-style (state/get-export-block-text-indent-style)
+        text-remove-options (set (state/get-export-block-text-remove-options))
+        tp @*export-block-type]
+    (case tp
+      :text (export-text/export-blocks-as-markdown
+             current-repo block-uuids-or-page-name
+             {:indent-style text-indent-style :remove-options text-remove-options})
+      :opml (export-opml/export-blocks-as-opml
+             current-repo block-uuids-or-page-name {:remove-options text-remove-options})
+      :html (export-html/export-blocks-as-html
+             current-repo block-uuids-or-page-name {:remove-options text-remove-options})
+      "")))
+
+(rum/defcs export-blocks < rum/static
+  (rum/local false ::copied?)
+  (rum/local nil ::text-remove-options)
+  (rum/local nil ::text-indent-style)
+  (rum/local nil ::content)
+  {:will-mount (fn [state]
+                 (let [content (export-helper (last (:rum/args state)))]
+                   (reset! (::content state) content)
+                   (reset! (::text-remove-options state) (set (state/get-export-block-text-remove-options)))
+                   (reset! (::text-indent-style state) (state/get-export-block-text-indent-style))
+                   state))}
+  [state root-block-uuids-or-page-name]
+  (let [tp @*export-block-type
+        *text-remove-options (::text-remove-options state)
+        *text-indent-style (::text-indent-style state)
+        *copied? (::copied? state)
+        *content (::content state)]
     [:div.export.resize
      [:div.flex
       {:class "mb-2"}
       (ui/button "Text"
                  :class "mr-4 w-20"
-                 :on-click #(reset! *export-block-type :text))
+                 :on-click #(do (reset! *export-block-type :text)
+                                (reset! *content (export-helper root-block-uuids-or-page-name))))
       (ui/button "OPML"
                  :class "mr-4 w-20"
-                 :on-click #(reset! *export-block-type :opml))
+                 :on-click #(do (reset! *export-block-type :opml)
+                                (reset! *content (export-helper root-block-uuids-or-page-name))))
       (ui/button "HTML"
                  :class "w-20"
-                 :on-click #(reset! *export-block-type :html))]
-     [:textarea.overflow-y-auto.h-96 {:value content}]
+                 :on-click #(do (reset! *export-block-type :html)
+                                (reset! *content (export-helper root-block-uuids-or-page-name))))]
+     [:textarea.overflow-y-auto.h-96 {:value @*content :read-only true}]
      (let [options (->> text-indent-style-options
                         (mapv (fn [opt]
-                                (if (= text-indent-style (:label opt))
+                                (if (= @*text-indent-style (:label opt))
                                   (assoc opt :selected true)
                                   opt))))]
        [:div [:div.flex.items-center
               [:label.mr-4
-               {:style {:visibility (if (= :text type) "visible" "hidden")}}
+               {:style {:visibility (if (= :text tp) "visible" "hidden")}}
                "Indentation style:"]
               [:select.block.my-2.text-lg.rounded.border
                {:style     {:padding "0 0 0 12px"
-                            :visibility (if (= :text type) "visible" "hidden")}
+                            :visibility (if (= :text tp) "visible" "hidden")}
                 :on-change (fn [e]
                              (let [value (util/evalue e)]
-                               (state/set-export-block-text-indent-style! value)))}
+                               (state/set-export-block-text-indent-style! value)
+                               (reset! *text-indent-style value)
+                               (reset! *content (export-helper root-block-uuids-or-page-name))))}
                (for [{:keys [label value selected]} options]
                  [:option (cond->
                            {:key   label
@@ -105,29 +132,41 @@
                   label])]]
         [:div.flex.items-center
          (ui/checkbox {:style {:margin-right 6
-                               :visibility (if (= :text type) "visible" "hidden")}
-                       :checked (contains? text-remove-options :page-ref)
+                               :visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :page-ref)
                        :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :page-ref))})
-
-         [:div
-          {:style {:visibility (if (= :text type) "visible" "hidden")}}
+                                    (state/update-export-block-text-remove-options! e :page-ref)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
           "[[text]] -> text"]
 
          (ui/checkbox {:style {:margin-right 6
                                :margin-left "1em"
-                               :visibility (if (= :text type) "visible" "hidden")}
-                       :checked (contains? text-remove-options :emphasis)
+                               :visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :emphasis)
+                       :on-change (fn [e]
+                                    (state/update-export-block-text-remove-options! e :emphasis)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
+
+         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+          "remove emphasis"]
+
+         (ui/checkbox {:style {:margin-right 6
+                               :margin-left "1em"
+                               :visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                       :checked (contains? @*text-remove-options :tag)
                        :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :emphasis))})
+                                    (state/update-export-block-text-remove-options! e :tag)
+                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
 
-         [:div
-          {:style {:visibility (if (= :text type) "visible" "hidden")}}
-          "remove emphasis"]]])
+         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+          "remove #tags"]]])
 
      [:div.mt-4
-      (ui/button (if @copied? "Copied to clipboard!" "Copy to clipboard")
-        :on-click (fn []
-                    (util/copy-to-clipboard! content (when (= type :html)
-                                                       content))
-                    (reset! copied? true)))]]))
+      (ui/button (if @*copied? "Copied to clipboard!" "Copy to clipboard")
+                 :on-click (fn []
+                             (util/copy-to-clipboard! @*content (when (= tp :html) @*content))
+                             (reset! *copied? true)))]]))

+ 1 - 1
src/main/frontend/components/page_menu.cljs

@@ -137,7 +137,7 @@
             {:title   (t :export-page)
              :options {:on-click #(state/set-modal!
                                    (fn []
-                                     (export/export-blocks [(:block/uuid page)])))}})
+                                     (export/export-blocks (:block/name page))))}})
 
           (when (util/electron?)
             {:title   (t (if public? :page/make-private :page/make-public))

+ 7 - 6
src/main/frontend/handler/editor.cljs

@@ -15,7 +15,8 @@
             [frontend.fs :as fs]
             [frontend.handler.block :as block-handler]
             [frontend.handler.common :as common-handler]
-            [frontend.handler.export :as export]
+            [frontend.handler.export.text :as export-text]
+            [frontend.handler.export.html :as export-html]
             [frontend.handler.notification :as notification]
             [frontend.handler.repeated :as repeated]
             [frontend.handler.route :as route-handler]
@@ -948,10 +949,10 @@
   (let [blocks (db-utils/pull-many repo '[*] (mapv (fn [id] [:block/uuid id]) block-ids))
         top-level-block-uuids (->> (outliner-core/get-top-level-blocks blocks)
                                    (map :block/uuid))
-        content (export/export-blocks-as-markdown
+        content (export-text/export-blocks-as-markdown
                  repo top-level-block-uuids
-                 (state/get-export-block-text-indent-style)
-                 (into [] (state/get-export-block-text-remove-options)))]
+                 {:indent-style (state/get-export-block-text-indent-style)
+                  :remove-options (set (state/get-export-block-text-remove-options))})]
     [top-level-block-uuids content]))
 
 (defn- get-all-blocks-by-ids
@@ -973,7 +974,7 @@
           [top-level-block-uuids content] (compose-copied-blocks-contents repo ids)
           block (db/entity [:block/uuid (first ids)])]
       (when block
-        (let [html (export/export-blocks-as-html repo top-level-block-uuids)]
+        (let [html (export-html/export-blocks-as-html repo top-level-block-uuids nil)]
           (common-handler/copy-to-clipboard-without-id-property! (:block/format block) content (when html? html)))
         (state/set-copied-blocks! content (get-all-blocks-by-ids repo top-level-block-uuids))
         (notification/show! "Copied!" :success)))))
@@ -1189,7 +1190,7 @@
     (let [repo (state/get-current-repo)
           ;; TODO: support org mode
           [_top-level-block-uuids md-content] (compose-copied-blocks-contents repo [block-id])
-          html (export/export-blocks-as-html repo [block-id])
+          html (export-html/export-blocks-as-html repo [block-id] nil)
           sorted-blocks (tree/get-sorted-block-and-children repo (:db/id block))]
       (state/set-copied-blocks! md-content sorted-blocks)
       (common-handler/copy-to-clipboard-without-id-property! (:block/format block) md-content html)

+ 45 - 190
src/main/frontend/handler/export.cljs

@@ -1,33 +1,31 @@
 (ns ^:no-doc frontend.handler.export
-  (:require ["@capacitor/filesystem" :refer [Encoding Filesystem]]
-            [cljs.pprint :as pprint]
-            [clojure.set :as s]
-            [clojure.string :as string]
-            [clojure.walk :as walk]
-            [datascript.core :as d]
-            [frontend.config :as config]
-            [frontend.db :as db]
-            [frontend.extensions.zip :as zip]
-            [frontend.external.roam-export :as roam-export]
-            [frontend.format :as f]
-            [frontend.format.mldoc :as mldoc]
-            [frontend.format.protocol :as fp]
-            [frontend.mobile.util :as mobile-util]
-            [frontend.modules.file.core :as outliner-file]
-            [frontend.modules.outliner.tree :as outliner-tree]
-            [frontend.publishing.html :as html]
-            [frontend.state :as state]
-            [frontend.util :as util]
-            [frontend.util.property :as property]
-            [goog.dom :as gdom]
-            [lambdaisland.glogi :as log]
-            [logseq.graph-parser.mldoc :as gp-mldoc]
-            [logseq.graph-parser.util :as gp-util]
-            [logseq.graph-parser.util.block-ref :as block-ref]
-            [logseq.graph-parser.util.page-ref :as page-ref]
-            [logseq.graph-parser.property :as gp-property]
-            [promesa.core :as p]
-            [frontend.handler.notification :as notification])
+  (:require
+   ["@capacitor/filesystem" :refer [Encoding Filesystem]]
+   [cljs.pprint :as pprint]
+   [clojure.set :as s]
+   [clojure.string :as string]
+   [clojure.walk :as walk]
+   [datascript.core :as d]
+   [frontend.config :as config]
+   [frontend.db :as db]
+   [frontend.extensions.zip :as zip]
+   [frontend.external.roam-export :as roam-export]
+   [frontend.format.mldoc :as mldoc]
+   [frontend.handler.notification :as notification]
+   [frontend.mobile.util :as mobile-util]
+   [frontend.modules.file.core :as outliner-file]
+   [frontend.modules.outliner.tree :as outliner-tree]
+   [frontend.publishing.html :as html]
+   [frontend.state :as state]
+   [frontend.util :as util]
+   [frontend.util.property :as property]
+   [goog.dom :as gdom]
+   [lambdaisland.glogi :as log]
+   [logseq.graph-parser.mldoc :as gp-mldoc]
+   [logseq.graph-parser.property :as gp-property]
+   [logseq.graph-parser.util.block-ref :as block-ref]
+   [logseq.graph-parser.util.page-ref :as page-ref]
+   [promesa.core :as p])
   (:import
    [goog.string StringBuffer]))
 
@@ -63,13 +61,6 @@
    (outliner-tree/blocks->vec-tree (str root-block-uuid))
    (outliner-file/tree->file-content {:init-level 1})))
 
-(defn- get-block-content
-  [block]
-  (->
-   [block]
-   (outliner-tree/blocks->vec-tree (str (:block/uuid block)))
-   (outliner-file/tree->file-content {:init-level 1})))
-
 (defn download-file!
   [file-path]
   (when-let [repo (state/get-current-repo)]
@@ -82,7 +73,8 @@
         (.setAttribute anchor "download" file-path)
         (.click anchor)))))
 
-(defn export-repo-as-html!
+(defn download-repo-as-html!
+  "download public pages as html"
   [repo]
   (when-let [db (db/get-db repo)]
     (let [[db asset-filenames]           (if (state/all-pages-public?)
@@ -143,16 +135,6 @@
           (.setAttribute anchor "download" (.-name zipfile))
           (.click anchor))))))
 
-(defn get-md-file-contents
-  [repo]
-  #_:clj-kondo/ignore
-  (filter (fn [[path _]]
-            (let [path (string/lower-case path)]
-              (re-find #"\.(?:md|markdown)$" path)))
-          (get-file-contents repo {:init-level 1
-                                   :heading-to-list? true})))
-
-
 (defn- get-embed-pages-from-ast [ast]
   (let [result (transient #{})]
     (doseq [item ast]
@@ -168,9 +150,9 @@
                         (let [arguments (:arguments (second i))
                               page-ref (first arguments)
                               page-name (-> page-ref
-                                          (subs 2)
-                                          (#(subs % 0 (- (count %) 2)))
-                                          (string/lower-case))]
+                                            (subs 2)
+                                            (#(subs % 0 (- (count %) 2)))
+                                            (string/lower-case))]
                           (conj! result page-name)
                           i)
                         :else
@@ -248,15 +230,6 @@
      :embed-blocks (s/union embed-blocks-1 embed-blocks-2 embed-blocks*)
      :block-refs (s/union block-refs-1 block-refs-2 block-refs*)}))
 
-(defn get-blocks-page&block-refs [repo block-uuids embed-pages embed-blocks block-refs]
-  (let [[embed-pages embed-blocks block-refs]
-        (reduce (fn [[embed-pages embed-blocks block-refs] block-uuid]
-                  (let [result (get-block-page&block-refs repo block-uuid embed-pages embed-blocks block-refs)]
-                    [(:embed-pages result) (:embed-blocks result) (:block-refs result)]))
-                [embed-pages embed-blocks block-refs] block-uuids)]
-    {:embed-pages embed-pages
-     :embed-blocks embed-blocks
-     :block-refs block-refs}))
 
 (defn get-page-page&block-refs [repo page-name embed-pages embed-blocks block-refs]
   (let [page-name* (util/page-name-sanity-lc page-name)
@@ -292,137 +265,19 @@
      :embed-blocks (s/union embed-blocks-1 embed-blocks-2 embed-blocks*)
      :block-refs (s/union block-refs-1 block-refs-2 block-refs*)}))
 
-(defn- get-export-references [repo {:keys [embed-pages embed-blocks block-refs]}]
-  (let [embed-blocks-and-contents
-        (mapv (fn [id]
-                (let [id-s (str id)
-                      id (uuid id-s)]
-                  [id-s
-                   [(get-blocks-contents repo id)
-                    (get-block-content (db/pull [:block/uuid id]))]]))
-              (s/union embed-blocks block-refs))
-
-        embed-pages-and-contents
-        (mapv (fn [page-name] [page-name (get-page-content repo page-name)]) embed-pages)]
-    {:embed_blocks embed-blocks-and-contents
-     :embed_pages embed-pages-and-contents}))
-
-(defn- export-files-as-markdown [repo files heading-to-list?]
-  (->> files
-       (mapv (fn [{:keys [path content names format]}]
-               (when (first names)
-                 [path (fp/exportMarkdown f/mldoc-record content
-                                          (f/get-default-config format {:export-heading-to-list? heading-to-list?})
-                                          (js/JSON.stringify
-                                           (clj->js (get-export-references
-                                                     repo
-                                                     (get-page-page&block-refs repo (first names) #{} #{} #{})))))])))))
-
-(defn- export-files-as-opml [repo files]
-  (->> files
-       (mapv (fn [{:keys [path content names format]}]
-               (when (first names)
-                   (let [path
-                         (string/replace
-                          (string/lower-case path) #"(.+)\.(md|markdown|org)" "$1.opml")]
-                     [path (fp/exportOPML f/mldoc-record content
-                                          (f/get-default-config format)
-                                          (first names)
-                                          (js/JSON.stringify
-                                           (clj->js (get-export-references
-                                                     repo
-                                                     (get-page-page&block-refs repo (first names) #{} #{} #{})))))]))))))
-
-(defn export-blocks-as-aux
-  [repo root-block-uuids auxf]
-  {:pre [(> (count root-block-uuids) 0)]}
-  (let [f #(get-export-references repo (get-blocks-page&block-refs repo % #{} #{} #{}))
-        root-blocks (mapv #(db/entity [:block/uuid %]) root-block-uuids)
-        blocks (mapcat #(db/get-block-and-children repo %) root-block-uuids)
-        refs (f (mapv #(str (:block/uuid %)) blocks))
-        contents (mapv #(get-blocks-contents repo %) root-block-uuids)
-        content (string/join "\n" (mapv string/trim-newline contents))
-        format (or (:block/format (first root-blocks)) (state/get-preferred-format))]
-    (auxf content format refs)))
-
-(defn export-blocks-as-opml
-  [repo root-block-uuids]
-  (export-blocks-as-aux repo root-block-uuids
-                        #(fp/exportOPML f/mldoc-record %1
-                                        (f/get-default-config %2)
-                                        "untitled"
-                                        (js/JSON.stringify (clj->js %3)))))
-
-(defn export-blocks-as-markdown
-  [repo root-block-uuids indent-style remove-options]
-  (export-blocks-as-aux repo root-block-uuids
-                        #(fp/exportMarkdown f/mldoc-record %1
-                                            (f/get-default-config
-                                             %2
-                                             {:export-md-indent-style indent-style
-                                              :export-md-remove-options remove-options})
-                                            (js/JSON.stringify (clj->js %3)))))
-(defn export-blocks-as-html
-  [repo root-block-uuids]
-  (export-blocks-as-aux repo root-block-uuids
-                        #(fp/toHtml f/mldoc-record %1
-                                    (f/get-default-config %2)
-                                    (js/JSON.stringify (clj->js %3)))))
-
-(defn- get-file-contents-with-suffix
-  [repo]
-  (let [db (db/get-db repo)
-        md-files (get-md-file-contents repo)]
-    (->>
-     md-files
-     (map (fn [[path content]] {:path path :content content
-                                :names (d/q '[:find [?n ?n2]
-                                              :in $ ?p
-                                              :where [?e :file/path ?p]
-                                              [?e2 :block/file ?e]
-                                              [?e2 :block/name ?n]
-                                              [?e2 :block/original-name ?n2]] db path)
-                                :format (gp-util/get-format path)})))))
-
 
 (defn- export-file-on-mobile [data path]
   (p/catch
-      (.writeFile Filesystem (clj->js {:path path
-                                       :data data
-                                       :encoding (.-UTF8 Encoding)
-                                       :recursive true}))
-      (notification/show! "Export succeeded! You can find you exported file in the root directory of your graph." :success)
+   (.writeFile Filesystem (clj->js {:path path
+                                    :data data
+                                    :encoding (.-UTF8 Encoding)
+                                    :recursive true}))
+   (notification/show! "Export succeeded! You can find you exported file in the root directory of your graph." :success)
     (fn [error]
-        (notification/show! "Export failed!" :error)
-        (log/error :export-file-failed error))))
+      (notification/show! "Export failed!" :error)
+      (log/error :export-file-failed error))))
 
 
-(defn export-repo-as-markdown!
-  [repo]
-  (when-let [files (get-file-contents-with-suffix repo)]
-    (let [heading-to-list? (state/export-heading-to-list?)
-          files
-          (export-files-as-markdown repo files heading-to-list?)
-          zip-file-name (str repo "_markdown_" (quot (util/time-ms) 1000))]
-      (p/let [zipfile (zip/make-zip zip-file-name files repo)]
-        (when-let [anchor (gdom/getElement "export-as-markdown")]
-          (.setAttribute anchor "href" (js/window.URL.createObjectURL zipfile))
-          (.setAttribute anchor "download" (.-name zipfile))
-          (.click anchor))))))
-
-(defn export-repo-as-opml!
-  #_:clj-kondo/ignore
-  [repo]
-  (when-let [repo (state/get-current-repo)]
-    (when-let [files (get-file-contents-with-suffix repo)]
-      (let [files (export-files-as-opml repo files)
-            zip-file-name (str repo "_opml_" (quot (util/time-ms) 1000))]
-        (p/let [zipfile (zip/make-zip zip-file-name files repo)]
-               (when-let [anchor (gdom/getElement "export-as-opml")]
-                 (.setAttribute anchor "href" (js/window.URL.createObjectURL zipfile))
-                 (.setAttribute anchor "download" (.-name zipfile))
-                 (.click anchor)))))))
-
 (defn- dissoc-properties [m ks]
   (if (:block/properties m)
     (update m :block/properties
@@ -506,12 +361,12 @@
                             js/encodeURIComponent
                             (str "data:text/edn;charset=utf-8,"))
           filename (file-name repo :edn)]
-     (if (mobile-util/native-platform?)
-       (export-file-on-mobile edn-str filename)
-       (when-let [anchor (gdom/getElement "download-as-edn-v2")]
-         (.setAttribute anchor "href" data-str)
-         (.setAttribute anchor "download" filename)
-         (.click anchor))))))
+      (if (mobile-util/native-platform?)
+        (export-file-on-mobile edn-str filename)
+        (when-let [anchor (gdom/getElement "download-as-edn-v2")]
+          (.setAttribute anchor "href" data-str)
+          (.setAttribute anchor "download" filename)
+          (.click anchor))))))
 
 (defn- nested-update-id
   [vec-tree]

+ 776 - 0
src/main/frontend/handler/export/common.cljs

@@ -0,0 +1,776 @@
+(ns frontend.handler.export.common
+  "common fns for exporting.
+  exclude some fns which produce lazy-seq, which can cause strange behaviors
+  when use together with dynamic var."
+  (:refer-clojure :exclude [map filter mapcat concat remove])
+  (:require [cljs.core.match :refer [match]]
+            [clojure.string :as string]
+            [datascript.core :as d]
+            [frontend.db :as db]
+            [frontend.modules.file.core :as outliner-file]
+            [frontend.modules.outliner.tree :as outliner-tree]
+            [frontend.state :as state]
+            [frontend.util :as util :refer [concatv mapcatv removev]]
+            [logseq.graph-parser.mldoc :as gp-mldoc]
+            [logseq.graph-parser.util :as gp-util]
+            [malli.core :as m]
+            [malli.util :as mu]))
+
+;;; TODO: split frontend.handler.export.text related states
+(def ^:dynamic *state*
+  "dynamic var, state used for exporting"
+  {;; current level of Heading, start from 1(same as mldoc), use when `block-ast->simple-ast`
+   :current-level 1
+   ;; emphasis symbol (use when `block-ast->simple-ast`)
+   :outside-em-symbol nil
+   ;; (use when `block-ast->simple-ast`)
+   :indent-after-break-line? false
+   ;; TODO: :last-empty-heading? false
+   ;;       current:  |  want:
+   ;;       -         |  - xxx
+   ;;         xxx     |    yyy
+   ;;         yyy     |
+
+   ;; this submap is used when replace block-reference, block-embed, page-embed
+   :replace-ref-embed
+   {;; start from 1
+    :current-level 1
+    :block-ref-replaced? false
+    :block&page-embed-replaced? false}
+
+   ;; export-options submap
+   :export-options
+   {;; dashes, spaces, no-indent
+    :indent-style "dashes"
+    :remove-page-ref-brackets? false
+    :remove-emphasis? false
+    :remove-tags? false}})
+
+;;; internal utils
+(defn- get-blocks-contents
+  [repo root-block-uuid]
+  (->
+   (db/get-block-and-children repo root-block-uuid)
+   (outliner-tree/blocks->vec-tree (str root-block-uuid))
+   (outliner-file/tree->file-content {:init-level 1})))
+
+(defn get-page-content
+  [page]
+  (-> page
+      db/get-page
+      :block/file
+      :file/content))
+
+(defn root-block-uuids->content
+  [repo root-block-uuids]
+  (let [contents (mapv #(get-blocks-contents repo %) root-block-uuids)]
+    (string/join "\n" (mapv string/trim-newline contents))))
+
+(declare remove-block-ast-pos Properties-block-ast?)
+
+(defn- block-uuid->ast
+  [block-uuid]
+  (let [block (into {} (db/get-block-by-uuid block-uuid))
+        content (outliner-file/tree->file-content [block] {:init-level 1})
+        format :markdown]
+    (when content
+      (removev Properties-block-ast?
+               (mapv remove-block-ast-pos
+                     (gp-mldoc/->edn content (gp-mldoc/default-config format)))))))
+
+(defn- block-uuid->ast-with-children
+  [block-uuid]
+  (let [content (get-blocks-contents (state/get-current-repo) block-uuid)
+        format :markdown]
+    (when content
+      (removev Properties-block-ast?
+               (mapv remove-block-ast-pos
+                     (gp-mldoc/->edn content (gp-mldoc/default-config format)))))))
+
+(defn- page-name->ast
+  [page-name]
+  (let [content (get-page-content page-name)
+        format :markdown]
+    (when content
+      (removev Properties-block-ast?
+               (mapv remove-block-ast-pos
+                     (gp-mldoc/->edn content (gp-mldoc/default-config format)))))))
+
+(defn- update-level-in-block-ast-coll
+  [block-ast-coll origin-level]
+  (mapv
+   (fn [[ast-type ast-content]]
+     (if (= ast-type "Heading")
+       [ast-type (update ast-content :level #(+ (dec %) origin-level))]
+       [ast-type ast-content]))
+   block-ast-coll))
+
+(defn- plain-indent-inline-ast
+  [level & {:keys [spaces] :or {spaces "  "}}]
+  ["Plain" (str (reduce str (repeat (dec level) "\t")) spaces)])
+
+;;; internal utils (ends)
+
+;;; utils
+
+(defn priority->string
+  [priority]
+  (str "[#" priority "]"))
+
+(defn- repetition-to-string
+  [[[kind] [duration] n]]
+  (let [kind (case kind
+               "Dotted" "."
+               "Plus" "+"
+               "DoublePlus" "++")]
+    (str kind n (string/lower-case (str (first duration))))))
+
+(defn timestamp-to-string
+  [{:keys [date time repetition wday active]}]
+  (let [{:keys [year month day]} date
+        {:keys [hour min]} time
+        [open close] (if active ["<" ">"] ["[" "]"])
+        repetition (if repetition
+                     (str " " (repetition-to-string repetition))
+                     "")
+        hour (when hour (util/zero-pad hour))
+        min  (when min (util/zero-pad min))
+        time (cond
+               (and hour min)
+               (util/format " %s:%s" hour min)
+               hour
+               (util/format " %s" hour)
+               :else
+               "")]
+    (util/format "%s%s-%s-%s %s%s%s%s"
+                 open
+                 (str year)
+                 (util/zero-pad month)
+                 (util/zero-pad day)
+                 wday
+                 time
+                 repetition
+                 close)))
+(defn hashtag-value->string
+  [inline-coll]
+  (reduce str
+          (mapv
+           (fn [inline]
+             (let [[ast-type ast-content] inline]
+               (case ast-type
+                 "Nested_link"
+                 (:content ast-content)
+                 "Link"
+                 (:full_text ast-content)
+                 "Plain"
+                 ast-content)))
+           inline-coll)))
+
+(defn- get-file-contents
+  [repo]
+  (let [db (db/get-db repo)]
+    (->> (d/q '[:find ?fp
+                :where
+                [?e :block/file ?f]
+                [?f :file/path ?fp]] db)
+         (mapv (fn [[file-path]]
+                 [file-path
+                  (db/get-file file-path)])))))
+
+(defn- get-md-file-contents
+  [repo]
+  (filterv (fn [[path _]]
+             (let [path (string/lower-case path)]
+               (re-find #"\.(?:md|markdown)$" path)))
+           (get-file-contents repo)))
+
+(defn get-file-contents-with-suffix
+  [repo]
+  (let [db (db/get-db repo)
+        md-files (get-md-file-contents repo)]
+    (->>
+     md-files
+     (mapv (fn [[path content]] {:path path :content content
+                                 :names (d/q '[:find [?n ?n2]
+                                               :in $ ?p
+                                               :where [?e :file/path ?p]
+                                               [?e2 :block/file ?e]
+                                               [?e2 :block/name ?n]
+                                               [?e2 :block/original-name ?n2]] db path)
+                                 :format (gp-util/get-format path)})))))
+
+;;; utils (ends)
+
+;;; replace block-ref, block-embed, page-embed
+
+(defn- replace-block-reference-in-heading
+  [{:keys [title] :as ast-content}]
+  (let [inline-coll  title
+        inline-coll*
+        (mapcatv
+         #(match [%]
+            [["Link" {:url ["Block_ref" block-uuid]}]]
+            (let [[[_ {title-inline-coll :title}]]
+                  (block-uuid->ast (uuid block-uuid))]
+              (set! *state* (assoc-in *state* [:replace-ref-embed :block-ref-replaced?] true))
+              title-inline-coll)
+
+            :else [%])
+         inline-coll)]
+    (assoc ast-content :title inline-coll*)))
+
+(defn- replace-block-reference-in-paragraph
+  [inline-coll]
+  (mapcatv
+   #(match [%]
+      [["Link" {:url ["Block_ref" block-uuid]}]]
+      (let [[[_ {title-inline-coll :title}]]
+            (block-uuid->ast (uuid block-uuid))]
+        (set! *state* (assoc-in *state* [:replace-ref-embed :block-ref-replaced?] true))
+        title-inline-coll)
+      :else [%])
+   inline-coll))
+
+(declare replace-block-references)
+
+(defn- replace-block-reference-in-list
+  [list-items]
+  (mapv
+   (fn [{block-ast-coll :content sub-items :items :as item}]
+     (assoc item
+            :content (mapv replace-block-references block-ast-coll)
+            :items (replace-block-reference-in-list sub-items)))
+   list-items))
+
+(defn- replace-block-reference-in-quote
+  [block-ast-coll]
+  (mapv replace-block-references block-ast-coll))
+
+(defn- replace-block-reference-in-table
+  [{:keys [header groups] :as table}]
+  (let [header*
+        (mapv
+         (fn [col]
+           (mapcatv
+            #(match [%]
+               [["Link" {:url ["Block_ref" block-uuid]}]]
+               (let [[[_ {title-inline-coll :title}]]
+                     (block-uuid->ast (uuid block-uuid))]
+                 (set! *state* (assoc-in *state* [:replace-ref-embed :block-ref-replaced?] true))
+                 title-inline-coll)
+               :else [%])
+            col))
+         header)
+        groups*
+        (mapv
+         (fn [group]
+           (mapv
+            (fn [row]
+              (mapv
+               (fn [col]
+                 (mapcatv
+                  #(match [%]
+                     [["Link" {:url ["Block_ref" block-uuid]}]]
+                     (let [[[_ {title-inline-coll :title}]]
+                           (block-uuid->ast (uuid block-uuid))]
+                       (set! *state* (assoc-in *state* [:replace-ref-embed :block-ref-replaced?] true))
+                       title-inline-coll)
+                     :else [%])
+                  col))
+               row))
+            group))
+         groups)]
+    (assoc table :header header* :groups groups*)))
+
+(defn- replace-block-references
+  [block-ast]
+  (let [[ast-type ast-content] block-ast]
+    (case ast-type
+      "Heading"
+      [ast-type (replace-block-reference-in-heading ast-content)]
+
+      "Paragraph"
+      [ast-type (replace-block-reference-in-paragraph ast-content)]
+
+      "List"
+      [ast-type (replace-block-reference-in-list ast-content)]
+
+      "Quote"
+      [ast-type (replace-block-reference-in-quote ast-content)]
+
+      "Table"
+      [ast-type (replace-block-reference-in-table ast-content)]
+      ;; else
+      block-ast)))
+
+(defn- replace-block-references-until-stable
+  [block-ast]
+  (binding [*state* *state*]
+    (loop [block-ast block-ast]
+      (let [block-ast* (replace-block-references block-ast)]
+        (if (get-in *state* [:replace-ref-embed :block-ref-replaced?])
+          (do (set! *state* (assoc-in *state* [:replace-ref-embed :block-ref-replaced?] false))
+              (recur block-ast*))
+          block-ast*)))))
+
+(defn- replace-block-embeds-helper
+  [current-paragraph-inlines block-uuid blocks-tcoll level]
+  (let [block-uuid* (subs block-uuid 2 (- (count block-uuid) 2))
+        ast-coll (update-level-in-block-ast-coll
+                  (block-uuid->ast-with-children (uuid block-uuid*))
+                  level)]
+    (cond-> blocks-tcoll
+      (seq current-paragraph-inlines)
+      (conj! ["Paragraph" current-paragraph-inlines])
+      true
+      (#(reduce conj! % ast-coll)))))
+
+(defn- replace-page-embeds-helper
+  [current-paragraph-inlines page-name blocks-tcoll level]
+  (let [page-name* (subs page-name 2 (- (count page-name) 2))
+        ast-coll (update-level-in-block-ast-coll
+                  (page-name->ast page-name*)
+                  level)]
+    (cond-> blocks-tcoll
+      (seq current-paragraph-inlines)
+      (conj! ["Paragraph" current-paragraph-inlines])
+      true
+      (#(reduce conj! % ast-coll)))))
+
+(defn- replace-block&page-embeds-in-heading
+  [{inline-coll :title origin-level :level :as ast-content}]
+  (set! *state* (assoc-in *state* [:replace-ref-embed :current-level] origin-level))
+  (if (empty? inline-coll)
+    ;; it's just a empty Heading, return itself
+    [["Heading" ast-content]]
+    (loop [[inline & other-inlines] inline-coll
+           heading-exist? false
+           current-paragraph-inlines []
+           r (transient [])]
+      (if-not inline
+        (persistent!
+         (if (seq current-paragraph-inlines)
+           (conj! r (if heading-exist?
+                      ["Paragraph" current-paragraph-inlines]
+                      ["Heading" (assoc ast-content :title current-paragraph-inlines)]))
+           r))
+        (match [inline]
+          [["Macro" {:name "embed" :arguments [block-uuid-or-page-name]}]]
+          (cond
+            (and (string/starts-with? block-uuid-or-page-name "((")
+                 (string/ends-with? block-uuid-or-page-name "))"))
+            (do (set! *state* (assoc-in *state* [:replace-ref-embed :block&page-embed-replaced?] true))
+                (recur other-inlines true []
+                       (replace-block-embeds-helper
+                        current-paragraph-inlines block-uuid-or-page-name r origin-level)))
+            (and (string/starts-with? block-uuid-or-page-name "[[")
+                 (string/ends-with? block-uuid-or-page-name "]]"))
+            (do (set! *state* (assoc-in *state* [:replace-ref-embed :block&page-embed-replaced?] true))
+                (recur other-inlines true []
+                       (replace-page-embeds-helper
+                        current-paragraph-inlines block-uuid-or-page-name r origin-level)))
+            :else ;; not ((block-uuid)) or [[page-name]], just drop the original ast
+            (recur other-inlines heading-exist? current-paragraph-inlines r))
+
+          :else
+          (let [current-paragraph-inlines*
+                (if (and (empty? current-paragraph-inlines)
+                         heading-exist?)
+                  (conj current-paragraph-inlines (plain-indent-inline-ast origin-level))
+                  current-paragraph-inlines)]
+            (recur other-inlines heading-exist? (conj current-paragraph-inlines* inline) r)))))))
+
+(defn- replace-block&page-embeds-in-paragraph
+  [inline-coll]
+  (let [current-level (get-in *state* [:replace-ref-embed :current-level])]
+    (loop [[inline & other-inlines] inline-coll
+           current-paragraph-inlines []
+           just-after-embed? false
+           blocks (transient [])]
+      (if-not inline
+        (persistent!
+         (if (seq current-paragraph-inlines)
+           (conj! blocks ["Paragraph" current-paragraph-inlines])
+           blocks))
+        (match [inline]
+          [["Macro" {:name "embed" :arguments [block-uuid-or-page-name]}]]
+          (cond
+            (and (string/starts-with? block-uuid-or-page-name "((")
+                 (string/ends-with? block-uuid-or-page-name "))"))
+            (do (set! *state* (assoc-in *state* [:replace-ref-embed :block&page-embed-replaced?] true))
+                (recur other-inlines [] true
+                       (replace-block-embeds-helper
+                        current-paragraph-inlines block-uuid-or-page-name blocks current-level)))
+            (and (string/starts-with? block-uuid-or-page-name "[[")
+                 (string/ends-with? block-uuid-or-page-name "]]"))
+            (do (set! *state* (assoc-in *state* [:replace-ref-embed :block&page-embed-replaced?] true))
+                (recur other-inlines [] true
+                       (replace-page-embeds-helper
+                        current-paragraph-inlines block-uuid-or-page-name blocks current-level)))
+            :else ;; not ((block-uuid)) or [[page-name]], just drop the original ast
+            (recur other-inlines current-paragraph-inlines false blocks))
+
+          :else
+          (let [current-paragraph-inlines*
+                (if just-after-embed?
+                  (conj current-paragraph-inlines (plain-indent-inline-ast current-level))
+                  current-paragraph-inlines)]
+            (recur other-inlines (conj current-paragraph-inlines* inline) false blocks)))))))
+
+(declare replace-block&page-embeds)
+
+(defn- replace-block&page-embeds-in-list-helper
+  [list-items]
+  (binding [*state* (update-in *state* [:replace-ref-embed :current-level] inc)]
+    (mapv
+     (fn [{block-ast-coll :content sub-items :items :as item}]
+       (assoc item
+              :content (mapcatv replace-block&page-embeds block-ast-coll)
+              :items (replace-block&page-embeds-in-list-helper sub-items)))
+     list-items)))
+
+(defn- replace-block&page-embeds-in-list
+  [list-items]
+  [["List" (replace-block&page-embeds-in-list-helper list-items)]])
+
+(defn- replace-block&page-embeds-in-quote
+  [block-ast-coll]
+  (->> block-ast-coll
+       (mapcatv replace-block&page-embeds)
+       (vector "Quote")
+       vector))
+
+(defn- replace-block&page-embeds
+  [block-ast]
+  (let [[ast-type ast-content] block-ast]
+    (case ast-type
+      "Heading"
+      (replace-block&page-embeds-in-heading ast-content)
+      "Paragraph"
+      (replace-block&page-embeds-in-paragraph ast-content)
+      "List"
+      (replace-block&page-embeds-in-list ast-content)
+      "Quote"
+      (replace-block&page-embeds-in-quote ast-content)
+      "Table"
+      ;; TODO: block&page embeds in table are not replaced yet
+      [block-ast]
+      ;; else
+      [block-ast])))
+
+(defn replace-block&page-reference&embed
+  "add meta :embed-depth to the embed replaced block-ast,
+  to avoid too deep block-ref&embed (or maybe it's a cycle)"
+  [block-ast-coll]
+  (loop [block-ast-coll block-ast-coll
+         result-block-ast-tcoll (transient [])
+         block-ast-coll-to-replace-references []
+         block-ast-coll-to-replace-embeds []]
+    (cond
+      (seq block-ast-coll-to-replace-references)
+      (let [[block-ast-to-replace-ref & other-block-asts-to-replace-ref]
+            block-ast-coll-to-replace-references
+            embed-depth (:embed-depth (meta block-ast-to-replace-ref) 0)
+            block-ast-replaced (-> (replace-block-references-until-stable block-ast-to-replace-ref)
+                                   (with-meta {:embed-depth embed-depth}))]
+        (if (>= embed-depth 5)
+          ;; if :embed-depth >= 5, dont replace embed for this block anymore
+          ;; there is too deep, or maybe it just a ref/embed cycle
+          (recur block-ast-coll (conj! result-block-ast-tcoll block-ast-replaced)
+                 (vec other-block-asts-to-replace-ref) block-ast-coll-to-replace-embeds)
+          (recur block-ast-coll result-block-ast-tcoll (vec other-block-asts-to-replace-ref)
+                 (conj block-ast-coll-to-replace-embeds block-ast-replaced))))
+
+      (seq block-ast-coll-to-replace-embeds)
+      (let [[block-ast-to-replace-embed & other-block-asts-to-replace-embed]
+            block-ast-coll-to-replace-embeds
+            embed-depth (:embed-depth (meta block-ast-to-replace-embed) 0)
+            block-ast-coll-replaced (->> (replace-block&page-embeds block-ast-to-replace-embed)
+                                         (mapv #(with-meta % {:embed-depth (inc embed-depth)})))]
+        (if (get-in *state* [:replace-ref-embed :block&page-embed-replaced?])
+          (do (set! *state* (assoc-in *state* [:replace-ref-embed :block&page-embed-replaced?] false))
+              (recur block-ast-coll result-block-ast-tcoll
+                     (concatv block-ast-coll-to-replace-references block-ast-coll-replaced)
+                     (vec other-block-asts-to-replace-embed)))
+          (recur block-ast-coll (reduce conj! result-block-ast-tcoll block-ast-coll-replaced)
+                 (vec block-ast-coll-to-replace-references) (vec other-block-asts-to-replace-embed))))
+
+      :else
+      (let [[block-ast & other-block-ast] block-ast-coll]
+        (if-not block-ast
+          (persistent! result-block-ast-tcoll)
+          (recur other-block-ast result-block-ast-tcoll
+                 (conj block-ast-coll-to-replace-references block-ast)
+                 (vec block-ast-coll-to-replace-embeds)))))))
+
+;;; replace block-ref, block-embed, page-embed (ends)
+
+(def remove-block-ast-pos
+  "[[ast-type ast-content] _pos] -> [ast-type ast-content]"
+  first)
+
+(defn Properties-block-ast?
+  [[tp _]]
+  (= tp "Properties"))
+
+(defn replace-Heading-with-Paragraph
+  "works on block-ast
+  replace all heading with paragraph when indent-style is no-indent"
+  [heading-ast]
+  (let [[heading-type {:keys [title marker priority size]}] heading-ast]
+    (if (= heading-type "Heading")
+      (let [inline-coll
+            (cond->> title
+              priority (cons ["Plain" (str (priority->string priority) " ")])
+              marker (cons ["Plain" (str marker " ")])
+              size (cons ["Plain" (str (reduce str (repeat size "#")) " ")])
+              true vec)]
+        ["Paragraph" inline-coll])
+      heading-ast)))
+
+;;; inline transformers
+
+(defn remove-emphasis
+  ":mapcat-fns-on-inline-ast"
+  [inline-ast]
+  (let [[ast-type ast-content] inline-ast]
+    (case ast-type
+      "Emphasis"
+      (let [[_ inline-coll] ast-content]
+        inline-coll)
+      ;; else
+      [inline-ast])))
+
+(defn remove-page-ref-brackets
+  ":map-fns-on-inline-ast"
+  [inline-ast]
+  (let [[ast-type ast-content] inline-ast]
+    (case ast-type
+      "Link"
+      (let [{:keys [url label]} ast-content]
+        (if (and (= "Page_ref" (first url))
+                 (or (empty? label)
+                     (= label [["Plain" ""]])))
+          ["Plain" (second url)]
+          inline-ast))
+      ;; else
+      inline-ast)))
+
+(defn remove-tags
+  ":mapcat-fns-on-inline-ast"
+  [inline-ast]
+  (let [[ast-type _ast-content] inline-ast]
+    (case ast-type
+      "Tag"
+      []
+      ;; else
+      [inline-ast])))
+
+;;; inline transformers (ends)
+
+;;; walk on block-ast, apply inline transformers
+
+(defn- walk-block-ast-helper
+  [inline-coll map-fns-on-inline-ast mapcat-fns-on-inline-ast]
+  (->>
+   inline-coll
+   (mapv #(reduce (fn [inline-ast f] (f inline-ast)) % map-fns-on-inline-ast))
+   (mapcatv #(reduce
+              (fn [inline-ast-coll f] (mapcatv f inline-ast-coll)) [%] mapcat-fns-on-inline-ast))))
+
+(declare walk-block-ast)
+
+(defn- walk-block-ast-for-list
+  [list-items map-fns-on-inline-ast mapcat-fns-on-inline-ast]
+  (mapv
+   (fn [{block-ast-coll :content sub-items :items :as item}]
+     (assoc item
+            :content
+            (mapv
+             (partial walk-block-ast
+                      {:map-fns-on-inline-ast map-fns-on-inline-ast
+                       :mapcat-fns-on-inline-ast mapcat-fns-on-inline-ast})
+             block-ast-coll)
+            :items
+            (walk-block-ast-for-list sub-items map-fns-on-inline-ast mapcat-fns-on-inline-ast)))
+   list-items))
+
+(defn walk-block-ast
+  [{:keys [map-fns-on-inline-ast mapcat-fns-on-inline-ast] :as fns}
+   block-ast]
+  (let [[ast-type ast-content] block-ast]
+    (case ast-type
+      "Paragraph"
+      ["Paragraph" (walk-block-ast-helper ast-content map-fns-on-inline-ast mapcat-fns-on-inline-ast)]
+      "Heading"
+      (let [{:keys [title]} ast-content]
+        ["Heading"
+         (assoc ast-content
+                :title
+                (walk-block-ast-helper title map-fns-on-inline-ast mapcat-fns-on-inline-ast))])
+      "List"
+      ["List" (walk-block-ast-for-list ast-content map-fns-on-inline-ast mapcat-fns-on-inline-ast)]
+      "Quote"
+      ["Quote" (mapv (partial walk-block-ast fns) ast-content)]
+      "Footnote_Definition"
+      (let [[name contents] (rest block-ast)]
+        ["Footnote_Definition"
+         name (walk-block-ast-helper contents map-fns-on-inline-ast mapcat-fns-on-inline-ast)])
+      "Table"
+      (let [{:keys [header groups]} ast-content
+            header* (mapv
+                     #(walk-block-ast-helper % map-fns-on-inline-ast mapcat-fns-on-inline-ast)
+                     header)
+            groups* (mapv
+                     (fn [group]
+                       (mapv
+                        (fn [row]
+                          (mapv
+                           (fn [col]
+                             (walk-block-ast-helper col map-fns-on-inline-ast mapcat-fns-on-inline-ast))
+                           row))
+                        group))
+                     groups)]
+        ["Table" (assoc ast-content :header header* :groups groups*)])
+
+       ;; else
+      block-ast)))
+
+;;; walk on block-ast, apply inline transformers (ends)
+
+;;; simple ast
+(def simple-ast-malli-schema
+  (mu/closed-schema
+   [:or
+    [:map
+     [:type [:= :raw-text]]
+     [:content :string]]
+    [:map
+     [:type [:= :space]]]
+    [:map
+     [:type [:= :newline]]
+     [:line-count :int]]
+    [:map
+     [:type [:= :indent]]
+     [:level :int]
+     [:extra-space-count :int]]]))
+
+(defn raw-text [& contents]
+  {:type :raw-text :content (reduce str contents)})
+(def space {:type :space})
+(defn newline* [line-count]
+  {:type :newline :line-count line-count})
+(defn indent [level extra-space-count]
+  {:type :indent :level level :extra-space-count extra-space-count})
+
+(defn- simple-ast->string
+  [simple-ast]
+  {:pre [(m/validate simple-ast-malli-schema simple-ast)]}
+  (case (:type simple-ast)
+    :raw-text (:content simple-ast)
+    :space " "
+    :newline (reduce str (repeat (:line-count simple-ast) "\n"))
+    :indent (reduce str (concatv (repeat (:level simple-ast) "\t")
+                                 (repeat (:extra-space-count simple-ast) " ")))))
+
+(defn- merge-adjacent-spaces&newlines
+  [simple-ast-coll]
+  (loop [r                             (transient [])
+         last-ast                      nil
+         last-raw-text-space-suffix?   false
+         last-raw-text-newline-suffix? false
+         [simple-ast & other-ast-coll] simple-ast-coll]
+    (if (nil? simple-ast)
+      (persistent! (if last-ast (conj! r last-ast) r))
+      (let [tp            (:type simple-ast)
+            last-ast-type (:type last-ast)]
+        (case tp
+          :space
+          (if (or (contains? #{:space :newline :indent} last-ast-type)
+                  last-raw-text-space-suffix?
+                  last-raw-text-newline-suffix?)
+            ;; drop this :space
+            (recur r last-ast last-raw-text-space-suffix? last-raw-text-newline-suffix? other-ast-coll)
+            (recur (if last-ast (conj! r last-ast) r) simple-ast false false other-ast-coll))
+
+          :newline
+          (case last-ast-type
+            (:space :indent) ;; drop last-ast
+            (recur r simple-ast false false other-ast-coll)
+            :newline
+            (let [last-newline-count (:line-count last-ast)
+                  current-newline-count (:line-count simple-ast)
+                  kept-ast (if (> last-newline-count current-newline-count) last-ast simple-ast)]
+              (recur r kept-ast false false other-ast-coll))
+            :raw-text
+            (if last-raw-text-newline-suffix?
+              (recur r last-ast last-raw-text-space-suffix? last-raw-text-newline-suffix? other-ast-coll)
+              (recur (if last-ast (conj! r last-ast) r) simple-ast false false other-ast-coll))
+            ;; no-last-ast
+            (recur r simple-ast false false other-ast-coll))
+
+          :indent
+          (case last-ast-type
+            (:space :indent)            ; drop last-ast
+            (recur r simple-ast false false other-ast-coll)
+            :newline
+            (recur (if last-ast (conj! r last-ast) r) simple-ast false false other-ast-coll)
+            :raw-text
+            (if last-raw-text-space-suffix?
+              ;; drop this :indent
+              (recur r last-ast last-raw-text-space-suffix? last-raw-text-newline-suffix? other-ast-coll)
+              (recur (if last-ast (conj! r last-ast) r) simple-ast false false other-ast-coll))
+            ;; no-last-ast
+            (recur r simple-ast false false other-ast-coll))
+
+          :raw-text
+          (let [content         (:content simple-ast)
+                empty-content?  (empty? content)
+                first-ch        (first content)
+                last-ch         (let [num (count content)]
+                                  (when (pos? num)
+                                    (nth content (dec num))))
+                newline-prefix? (some-> first-ch #{"\r" "\n"} boolean)
+                newline-suffix? (some-> last-ch #{"\n"} boolean)
+                space-prefix?   (some-> first-ch #{" "} boolean)
+                space-suffix?   (some-> last-ch #{" "} boolean)]
+            (cond
+              empty-content?            ;drop this raw-text
+              (recur r last-ast last-raw-text-space-suffix? last-raw-text-newline-suffix? other-ast-coll)
+              newline-prefix?
+              (case last-ast-type
+                (:space :indent :newline) ;drop last-ast
+                (recur r simple-ast space-suffix? newline-suffix? other-ast-coll)
+                :raw-text
+                (recur (if last-ast (conj! r last-ast) r) simple-ast space-suffix? newline-suffix? other-ast-coll)
+                ;; no-last-ast
+                (recur r simple-ast space-suffix? newline-suffix? other-ast-coll))
+              space-prefix?
+              (case last-ast-type
+                (:space :indent)        ;drop last-ast
+                (recur r simple-ast space-suffix? newline-suffix? other-ast-coll)
+                (:newline :raw-text)
+                (recur (if last-ast (conj! r last-ast) r) simple-ast space-suffix? newline-suffix? other-ast-coll)
+                ;; no-last-ast
+                (recur r simple-ast space-suffix? newline-suffix? other-ast-coll))
+              :else
+              (recur (if last-ast (conj! r last-ast) r) simple-ast space-suffix? newline-suffix? other-ast-coll))))))))
+
+(defn simple-asts->string
+  [simple-ast-coll]
+  (->> simple-ast-coll
+       merge-adjacent-spaces&newlines
+       merge-adjacent-spaces&newlines
+       (mapv simple-ast->string)
+       string/join))
+
+;;; simple ast (ends)
+
+
+;;; TODO: walk the hiccup tree,
+;;; and call escape-html on all its contents
+;;;
+
+
+;;; walk the hiccup tree,
+;;; and call escape-html on all its contents (ends)

+ 417 - 0
src/main/frontend/handler/export/html.cljs

@@ -0,0 +1,417 @@
+(ns frontend.handler.export.html
+  "export blocks/pages as html"
+  (:require
+   [clojure.edn :as edn]
+   [clojure.string :as string]
+   [clojure.zip :as z]
+   [frontend.db :as db]
+   [frontend.handler.export.common :as common :refer [*state*]]
+   [frontend.handler.export.zip-helper :refer [get-level goto-last goto-level]]
+   [frontend.state :as state]
+   [frontend.util :as util :refer [concatv mapcatv removev]]
+   [hiccups.runtime :as h]
+   [logseq.graph-parser.mldoc :as gp-mldoc]
+   [malli.core :as m]))
+
+(def ^:private hiccup-malli-schema
+  [:cat :keyword [:* :any]])
+
+;;; utils for construct hiccup
+;; - a
+;;   - b
+;;     - c
+;;   - d
+;; [:ul [:li "a" [:p "xxx"]] [:ul [:li "b"] [:ul [:li "c"]] [:li "d"]]]
+(defn- branch? [node] (= :ul (first node)))
+
+(defn- ul-hiccup-zip
+  [root]
+  (z/zipper branch?
+            rest
+            (fn [node children] (with-meta (apply vector :ul children) (meta node)))
+            root))
+
+(def ^:private empty-ul-hiccup (ul-hiccup-zip [:ul [:placeholder]]))
+
+(defn- add-same-level-li-at-right
+  "[:ul [:li ]"
+  [loc]
+  (-> loc
+      (z/insert-right [:li])
+      z/right))
+
+(defn- add-next-level-li-at-right
+  [loc]
+  (-> loc
+      (z/insert-right [:ul [:li]])
+      z/right
+      z/down))
+
+(defn- add-next-level-ul-at-right
+  [loc]
+  (-> loc
+      (z/insert-right [:ul])
+      z/right
+      z/down))
+
+(defn- replace-same-level-li
+  [loc]
+  (z/replace loc [:li]))
+
+(defn- add-items-in-li
+  [loc items]
+  (z/edit loc (fn [li] (concatv li items))))
+
+;;; utils for contruct hiccup(ends)
+
+;;; block/inline-ast -> hiccup
+(declare inline-ast->hiccup
+         block-ast->hiccup)
+
+(defn- inline-emphasis
+  [[[type] inline-coll]]
+  (apply vector
+         (case type
+           "Bold"           :b
+           "Italic"         :i
+           "Underline"      :ins
+           "Strike_through" :del
+           "Highlight"      :mark
+               ;; else
+           :b)
+         (mapv inline-ast->hiccup inline-coll)))
+
+(defn- inline-tag
+  [inline-coll]
+  [:a (str "#" (common/hashtag-value->string inline-coll))])
+
+(defn- inline-link
+  [{:keys [url label full_text]}]
+  (let [href (when (= "Search" (first url)) (second url))]
+    (cond-> [:a]
+      href (conj {:href href})
+      href (concatv (mapv inline-ast->hiccup label))
+      (not href) (conj full_text))))
+
+(defn- inline-nested-link
+  [{:keys [content]}]
+  [:a content])
+
+(defn- inline-subscript
+  [inline-coll]
+  (concatv [:sub] (mapv inline-ast->hiccup inline-coll)))
+
+(defn- inline-superscript
+  [inline-coll]
+  (concatv [:sup] (mapv inline-ast->hiccup inline-coll)))
+
+(defn- inline-footnote-reference
+  [{:keys [name]}]
+  [:sup [:a {:href (str "#fnd." name)} name]])
+
+(defn- inline-cookie
+  [ast-content]
+  [:span
+   (case (first ast-content)
+     "Absolute"
+     (let [[_ current total] ast-content]
+       (str "[" current "/" total "]"))
+     "Percent"
+     (str "[" (second ast-content) "%]"))])
+
+(defn- inline-latex-fragment
+  [ast-content]
+  (let [[type content] ast-content
+        wrapper (case type
+                  "Inline" "$"
+                  "Displayed" "$$")]
+    [:span (str wrapper content wrapper)]))
+
+(defn- inline-macro
+  [{:keys [name arguments]}]
+  [:code
+   (if (= name "cloze")
+     (string/join "," arguments)
+     (let [l (cond-> ["{{" name]
+               (pos? (count arguments)) (conj "(" (string/join "," arguments) ")")
+               true (conj "}}"))]
+       (string/join l)))])
+
+(defn- inline-entity
+  [{unicode :unicode}]
+  unicode)
+
+(defn- inline-timestamp
+  [ast-content]
+  (let [[type timestamp-content] ast-content]
+    (->> (case type
+           "Scheduled" ["SCHEDULED: " (common/timestamp-to-string timestamp-content)]
+           "Deadline" ["DEADLINE: " (common/timestamp-to-string timestamp-content)]
+           "Date" [(common/timestamp-to-string timestamp-content)]
+           "Closed" ["CLOSED: " (common/timestamp-to-string timestamp-content)]
+           "Clock" ["CLOCK: " (common/timestamp-to-string (second timestamp-content))]
+           "Range" (let [{:keys [start stop]} timestamp-content]
+                     [(str (common/timestamp-to-string start) "--" (common/timestamp-to-string stop))]))
+         string/join
+         (vector :span))))
+
+(defn- inline-email
+  [{:keys [local_part domain]}]
+  (str local_part "@" domain))
+
+(defn- block-paragraph
+  [loc inline-coll]
+  (-> loc
+      goto-last
+      (add-items-in-li
+       [(apply vector :p (mapv inline-ast->hiccup inline-coll))])))
+
+(defn- block-heading
+  [loc {:keys [title _tags marker level _numbering priority _anchor _meta _unordered _size]}]
+  (let [loc (goto-last loc)
+        current-level (get-level loc)
+        title* (mapv inline-ast->hiccup title)
+        items (cond-> []
+                marker (conj marker " ")
+                priority (conj (common/priority->string priority) " ")
+                true (concatv title*))]
+    (if (> level current-level)
+      (-> loc
+          add-next-level-li-at-right
+          (add-items-in-li items))
+      (-> loc
+          (goto-level level)
+          z/rightmost
+          add-same-level-li-at-right
+          (add-items-in-li items)))))
+
+(declare block-list)
+(defn- block-list-item
+  [loc {:keys [content items]}]
+  (let [current-level (get-level loc)
+        ;; [:ul ] or [:ul [:li]]
+        ;;     ^          ^
+        ;;    loc        loc
+        loc* (if (nil? (z/node loc))
+               (replace-same-level-li loc)
+               (add-same-level-li-at-right loc))
+        loc** (reduce block-ast->hiccup loc* content)
+        loc*** (if (seq items) (block-list loc** items) loc**)]
+    (-> loc***
+        (goto-level current-level)
+        z/rightmost)))
+
+(defn- block-list
+  [loc list-items]
+  (reduce block-list-item (add-next-level-ul-at-right loc) list-items))
+
+(defn- block-example
+  [loc str-coll]
+  (add-items-in-li loc [[:pre str-coll]]))
+
+(defn- block-src
+  [loc {:keys [language lines]}]
+  (let [code (cond-> [:pre]
+               (some? language) (conj {:data-lang language})
+               true (concatv lines))]
+    (add-items-in-li loc [code])))
+
+(defn- block-quote
+  [loc block-ast-coll]
+  (add-items-in-li
+   loc
+   [(z/root (reduce block-ast->hiccup (goto-last (ul-hiccup-zip [:blockquote])) block-ast-coll))]))
+
+(defn- block-latex-env
+  [loc [name options content]]
+  (add-items-in-li
+   loc
+   [[:pre
+     (str "\\begin{" name "}" options)
+     [:br]
+     content
+     [:br]
+     (str "\\end{" name "}")]]))
+
+(defn- block-displayed-math
+  [loc s]
+  (add-items-in-li loc [[:span s]]))
+
+(defn- block-footnote-definition
+  [loc [name inline-coll]]
+  (let [inline-hiccup-coll (mapv inline-ast->hiccup inline-coll)]
+    (add-items-in-li
+     loc
+     [(concatv [:div]
+               inline-hiccup-coll
+               [[:sup {:id (str "fnd." name)} (str name "↩")]])])))
+
+(defn- block-table
+  [loc {:keys [header groups]}]
+  (let [header*
+        (concatv [:tr]
+                 (mapv
+                  (fn [col]
+                    (concatv [:th] (mapv inline-ast->hiccup col)))
+                  header))
+        groups*
+        (mapcatv
+         (fn [group]
+           (mapv
+            (fn [row]
+              (concatv [:tr]
+                       (mapv
+                        (fn [col]
+                          (concatv [:td] (mapv inline-ast->hiccup col)))
+                        row)))
+            group))
+         groups)]
+    (add-items-in-li loc [(concatv [:table {:style "width:100%"} header*] groups*)])))
+
+(defn- block-comment
+  [loc s]
+  (add-items-in-li loc [(str "<!---\n" s "\n-->\n")]))
+
+(m/=> inline-ast->hiccup [:=> [:cat [:sequential :any]] [:or hiccup-malli-schema :string :nil]])
+(defn- inline-ast->hiccup
+  [inline-ast]
+  (let [[ast-type ast-content] inline-ast]
+    (case ast-type
+      "Emphasis"
+      (inline-emphasis ast-content)
+      ("Break_Line" "Hard_Break_Line")
+      [:br]
+      ("Verbatim" "Code")
+      [:code ast-content]
+      "Tag"
+      (inline-tag ast-content)
+      "Spaces"                          ; what's this ast-type for ?
+      nil
+      "Plain"
+      ast-content
+      "Link"
+      (inline-link ast-content)
+      "Nested_link"
+      (inline-nested-link ast-content)
+      "Target"
+      [:a ast-content]
+      "Subscript"
+      (inline-subscript ast-content)
+      "Superscript"
+      (inline-superscript ast-content)
+      "Footnote_Reference"
+      (inline-footnote-reference ast-content)
+      "Cookie"
+      (inline-cookie ast-content)
+      "Latex_Fragment"
+      (inline-latex-fragment ast-content)
+      "Macro"
+      (inline-macro ast-content)
+      "Entity"
+      (inline-entity ast-content)
+      "Timestamp"
+      (inline-timestamp ast-content)
+      "Email"
+      (inline-email ast-content)
+      "Inline_Hiccup"
+      (edn/read-string ast-content)
+      ("Radio_Target" "Inline_Html" "Export_Snippet" "Inline_Source_Block")
+      nil
+      (assert false (str :inline-ast->simple-ast " " ast-type " not implemented yet")))))
+
+(m/=> block-ast->hiccup [:=> [:cat :some [:sequential :any]] :some])
+(defn- block-ast->hiccup
+  [loc block-ast]
+  (let [[ast-type ast-content] block-ast]
+    (case ast-type
+      "Paragraph"
+      (block-paragraph loc ast-content)
+      "Paragraph_line"
+      (assert false "Paragraph_line is mldoc internal ast")
+      "Paragraph_Sep"
+      (-> loc
+          goto-last
+          (add-items-in-li (repeat ast-content [:br])))
+      "Heading"
+      (block-heading loc ast-content)
+      "List"
+      (block-list loc ast-content)
+      ("Directive" "Results" "Property_Drawer" "Export" "CommentBlock" "Custom")
+      loc
+      "Example"
+      (block-example loc ast-content)
+      "Src"
+      (block-src loc ast-content)
+      "Quote"
+      (block-quote loc ast-content)
+      "Latex_Fragment"
+      (add-items-in-li loc [(inline-latex-fragment ast-content)])
+      "Latex_Environment"
+      (block-latex-env loc (rest block-ast))
+      "Displayed_Math"
+      (block-displayed-math loc ast-content)
+      "Drawer"
+      loc
+      "Footnote_Definition"
+      (block-footnote-definition loc (rest block-ast))
+      "Horizontal_Rule"
+      (add-items-in-li loc [[:hr]])
+      "Table"
+      (block-table loc ast-content)
+      "Comment"
+      (block-comment loc ast-content)
+      "Raw_Html"
+      loc
+      "Hiccup"
+      (add-items-in-li loc [(edn/read-string ast-content)])
+      (assert false (str :block-ast->simple-ast " " ast-type " not implemented yet")))))
+
+;;; block/inline-ast -> hiccup (ends)
+
+;;; export fns
+(defn- export-helper
+  [content format options]
+  (let [remove-options (set (:remove-options options))]
+    (binding [*state* (merge *state*
+                             {:export-options
+                              {:remove-emphasis? (contains? remove-options :emphasis)
+                               :remove-page-ref-brackets? (contains? remove-options :page-ref)
+                               :remove-tags? (contains? remove-options :tag)}})]
+      (let [ast (util/profile :gp-mldoc/->edn (gp-mldoc/->edn content (gp-mldoc/default-config format)))
+            ast (util/profile :remove-pos (mapv common/remove-block-ast-pos ast))
+            ast (removev common/Properties-block-ast? ast)
+            ast* (util/profile :replace-block&page-reference&embed (common/replace-block&page-reference&embed ast))
+            ast** (if (= "no-indent" (get-in *state* [:export-options :indent-style]))
+                    (util/profile :replace-Heading-with-Paragraph (mapv common/replace-Heading-with-Paragraph ast*))
+                    ast*)
+            config-for-walk-block-ast (cond-> {}
+                                        (get-in *state* [:export-options :remove-emphasis?])
+                                        (update :mapcat-fns-on-inline-ast conj common/remove-emphasis)
+
+                                        (get-in *state* [:export-options :remove-page-ref-brackets?])
+                                        (update :map-fns-on-inline-ast conj common/remove-page-ref-brackets)
+
+                                        (get-in *state* [:export-options :remove-tags?])
+                                        (update :mapcat-fns-on-inline-ast conj common/remove-tags))
+            ast*** (if-not (empty? config-for-walk-block-ast)
+                     (util/profile :walk-block-ast (mapv (partial common/walk-block-ast config-for-walk-block-ast) ast**))
+                     ast**)
+            hiccup (util/profile :block-ast->hiccup  (z/root (reduce block-ast->hiccup empty-ul-hiccup ast***)))]
+        (h/render-html hiccup)))))
+
+(defn export-blocks-as-html
+  "options:
+  :remove-options [:emphasis :page-ref :tag]"
+  [repo root-block-uuids-or-page-name options]
+  {:pre [(or (coll? root-block-uuids-or-page-name)
+             (string? root-block-uuids-or-page-name))]}
+  (let [content
+        (if (string? root-block-uuids-or-page-name)
+          ;; page
+          (common/get-page-content root-block-uuids-or-page-name)
+          (common/root-block-uuids->content repo root-block-uuids-or-page-name))
+        first-block (db/entity [:block/uuid (first root-block-uuids-or-page-name)])
+        format (or (:block/format first-block) (state/get-preferred-format))]
+    (export-helper content format options)))
+
+;;; export fns (ends)

+ 460 - 0
src/main/frontend/handler/export/opml.cljs

@@ -0,0 +1,460 @@
+(ns frontend.handler.export.opml
+  "export blocks/pages as opml"
+  (:refer-clojure :exclude [map filter mapcat concat remove newline])
+  (:require
+   [clojure.string :as string]
+   [clojure.zip :as z]
+   [frontend.db :as db]
+   [frontend.extensions.zip :as zip]
+   [frontend.handler.export.common :as common :refer
+             [*state* raw-text simple-asts->string space]]
+   [frontend.handler.export.zip-helper :refer [get-level goto-last goto-level]]
+   [frontend.state :as state]
+   [frontend.util :as util :refer [concatv mapcatv removev]]
+   [hiccups.runtime :as h]
+   [logseq.graph-parser.mldoc :as gp-mldoc]
+   [promesa.core :as p]
+   [goog.dom :as gdom]))
+
+;;; *opml-state*
+(def ^:private ^:dynamic
+  *opml-state*
+  {:outside-em-symbol nil})
+
+;;; utils for construct opml hiccup
+;; - a
+;;   - b
+;;     - c
+;;   - d
+;; [:outline
+;;  {:text "a"}
+;;  [:outline {:text "b"} [:outline {:text "c"}]]
+;;  [:outline {:text "d"}]]
+
+(defn- branch? [node] (= :outline (first node)))
+
+(defn- outline-hiccup-zip
+  [root]
+  (z/zipper branch?
+            rest
+            (fn [node children] (with-meta (apply vector :outline children) (meta node)))
+            root))
+
+(def ^:private init-opml-body-hiccup
+  (z/down (outline-hiccup-zip [:outline [:placeholder]])))
+
+(defn- goto-last-outline
+  "[:outline [:outline [:outline]]]
+                       ^
+                   goto here"
+
+  [loc]
+  (-> loc
+      goto-last
+      z/up))
+
+(defn- add-same-level-outline-at-right
+  [loc attr-map]
+  {:pre [(map? attr-map)]}
+  (-> loc
+      (z/insert-right [:outline attr-map])
+      z/right))
+
+(defn- add-next-level-outline
+  [loc attr-map]
+  {:pre [(map? attr-map)]}
+  (-> loc
+      (z/append-child [:outline attr-map])
+      goto-last-outline))
+
+(defn- append-text-to-current-outline
+  [loc text]
+  (-> loc
+      z/down
+      (z/edit #(update % :text str text))
+      z/up))
+
+(defn- append-text-to-current-outline*
+  "if current-level = 0(it's just `init-opml-body-hiccup`), need to add a new outline item."
+  [loc text]
+  (if (pos? (get-level loc))
+    (append-text-to-current-outline loc text)
+    ;; at root
+    (-> loc
+        z/down
+        (add-same-level-outline-at-right {:text nil})
+        (append-text-to-current-outline text))))
+
+(defn- zip-loc->opml
+  [hiccup title]
+  (let [[_ _ & body] hiccup]
+    (str
+     "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+     (h/render-html
+      [:opml {:version "2.0"}
+       [:head [:title title]]
+       (concatv [:body] body)]))))
+
+;;; utils for construct opml hiccup (ends)
+
+;;; block/inline-ast -> hiccup & simple-ast
+
+(declare inline-ast->simple-ast
+         block-ast->hiccup)
+
+(defn- emphasis-wrap-with
+  [inline-coll em-symbol]
+  (binding [*opml-state* (assoc *opml-state* :outside-em-symbol (first em-symbol))]
+    (concatv [(raw-text em-symbol)]
+             (mapcatv inline-ast->simple-ast inline-coll)
+             [(raw-text em-symbol)])))
+
+(defn- inline-emphasis
+  [[[type] inline-coll]]
+  (let [outside-em-symbol (:outside-em-symbol *opml-state*)]
+    (case type
+      "Bold"
+      (emphasis-wrap-with
+       inline-coll (if (= outside-em-symbol "*") "__" "**"))
+      "Italic"
+      (emphasis-wrap-with
+       inline-coll (if (= outside-em-symbol "*") "_" "*"))
+      "Underline"
+      (binding [*opml-state* (assoc *opml-state* :outside-em-symbol outside-em-symbol)]
+        (mapcatv (fn [inline] (cons space (inline-ast->simple-ast inline))) inline-coll))
+      "Strike_through"
+      (emphasis-wrap-with inline-coll "~~")
+      "Highlight"
+      (emphasis-wrap-with inline-coll "^^")
+      ;; else
+      (assert false (print-str :inline-emphasis type "is invalid")))))
+
+;; FIXME: how to add newlines to opml text attr?
+(defn- inline-break-line
+  []
+  [space])
+
+(defn- inline-link
+  [{full-text :full_text}]
+  [(raw-text full-text)])
+
+(defn- inline-nested-link
+  [{content :content}]
+  [(raw-text content)])
+
+(defn- inline-subscript
+  [inline-coll]
+  (concatv [(raw-text "_{")]
+           (mapcatv (fn [inline] (cons space (inline-ast->simple-ast inline))) inline-coll)
+           [(raw-text "}")]))
+
+
+(defn- inline-superscript
+  [inline-coll]
+  (concatv [(raw-text "^{")]
+           (mapcatv (fn [inline] (cons space (inline-ast->simple-ast inline))) inline-coll)
+           [(raw-text "}")]))
+
+(defn- inline-footnote-reference
+  [{name :name}]
+  [(raw-text  "[" name "]")])
+
+(defn- inline-cookie
+  [ast-content]
+  [(raw-text
+    (case (first ast-content)
+      "Absolute"
+      (let [[_ current total] ast-content]
+        (str "[" current "/" total "]"))
+      "Percent"
+      (str "[" (second ast-content) "%]")))])
+
+(defn- inline-latex-fragment
+  [ast-content]
+  (let [[type content] ast-content
+        wrapper (case type
+                  "Inline" "$"
+                  "Displayed" "$$")]
+    [space (raw-text (str wrapper content wrapper)) space]))
+
+(defn- inline-macro
+  [{:keys [name arguments]}]
+  (->
+   (if (= name "cloze")
+     (string/join "," arguments)
+     (let [l (cond-> ["{{" name]
+               (pos? (count arguments)) (conj "(" (string/join "," arguments) ")")
+               true (conj "}}"))]
+       (string/join l)))
+   raw-text
+   vector))
+
+(defn- inline-entity
+  [{unicode :unicode}]
+  [(raw-text unicode)])
+
+(defn- inline-timestamp
+  [ast-content]
+  (let [[type timestamp-content] ast-content]
+    (-> (case type
+          "Scheduled" ["SCHEDULED: " (common/timestamp-to-string timestamp-content)]
+          "Deadline" ["DEADLINE: " (common/timestamp-to-string timestamp-content)]
+          "Date" [(common/timestamp-to-string timestamp-content)]
+          "Closed" ["CLOSED: " (common/timestamp-to-string timestamp-content)]
+          "Clock" ["CLOCK: " (common/timestamp-to-string (second timestamp-content))]
+          "Range" (let [{:keys [start stop]} timestamp-content]
+                    [(str (common/timestamp-to-string start) "--" (common/timestamp-to-string stop))]))
+        string/join
+        raw-text
+        vector)))
+
+(defn- inline-email
+  [{:keys [local_part domain]}]
+  [(raw-text (str "<" local_part "@" domain ">"))])
+
+
+(defn- inline-ast->simple-ast
+  [inline]
+  (let [[ast-type ast-content] inline]
+    (case ast-type
+      "Emphasis"
+      (inline-emphasis ast-content)
+      ("Break_Line" "Hard_Break_Line")
+      (inline-break-line)
+      "Verbatim"
+      [(raw-text ast-content)]
+      "Code"
+      [(raw-text "`" ast-content "`")]
+      "Tag"
+      [(raw-text "#" (common/hashtag-value->string ast-content))]
+      "Spaces"                          ; what's this ast-type for ?
+      nil
+      "Plain"
+      [(raw-text ast-content)]
+      "Link"
+      (inline-link ast-content)
+      "Nested_link"
+      (inline-nested-link ast-content)
+      "Target"
+      [(raw-text (str "<<" ast-content ">>"))]
+      "Subscript"
+      (inline-subscript ast-content)
+      "Superscript"
+      (inline-superscript ast-content)
+      "Footnote_Reference"
+      (inline-footnote-reference ast-content)
+      "Cookie"
+      (inline-cookie ast-content)
+      "Latex_Fragment"
+      (inline-latex-fragment ast-content)
+      "Macro"
+      (inline-macro ast-content)
+      "Entity"
+      (inline-entity ast-content)
+      "Timestamp"
+      (inline-timestamp ast-content)
+      "Radio_Target"
+      [(raw-text (str "<<<" ast-content ">>>"))]
+      "Email"
+      (inline-email ast-content)
+      "Inline_Hiccup"
+      [(raw-text ast-content)]
+      "Inline_Html"
+      [(raw-text ast-content)]
+      ("Export_Snippet" "Inline_Source_Block")
+      nil
+      (assert false (print-str :inline-ast->simple-ast ast-type "not implemented yet")))))
+
+(defn- block-paragraph
+  [loc inline-coll]
+  (-> loc
+      goto-last-outline
+      (append-text-to-current-outline*
+       (simple-asts->string
+        (cons space (mapcatv inline-ast->simple-ast inline-coll))))))
+
+(defn- block-heading
+  [loc {:keys [title _tags marker level _numbering priority _anchor _meta _unordered _size]}]
+  (let [loc (goto-last-outline loc)
+        current-level (get-level loc)
+        title* (mapcatv inline-ast->simple-ast title)
+        marker* (and marker (raw-text marker))
+        priority* (and priority (raw-text (common/priority->string priority)))
+        simple-asts (removev nil? (concatv [marker* space priority* space] title*))
+        ;; remove leading spaces
+        simple-asts (drop-while #(= % space) simple-asts)
+        s (simple-asts->string simple-asts)]
+    (if (> level current-level)
+      (add-next-level-outline loc {:text s})
+      (-> loc
+          (goto-level level)
+          z/rightmost
+          (add-same-level-outline-at-right {:text s})))))
+
+(declare block-list)
+(defn- block-list-item
+  [loc {:keys [content items]}]
+  (let [current-level (get-level loc)
+        ;; if current loc node is empty(= {}),
+        ;; the outline node is already created.
+        loc (if (empty? (second (z/node loc)))
+              loc
+              (add-same-level-outline-at-right loc {:text nil}))
+        loc* (reduce block-ast->hiccup loc content)
+        loc** (if (seq items) (block-list loc* items) loc*)]
+    (-> loc**
+        (goto-level current-level)
+        z/rightmost)))
+
+(defn- block-list
+  [loc list-items]
+  (reduce block-list-item (add-next-level-outline loc {}) list-items))
+
+(defn- block-example
+  [loc str-coll]
+  (append-text-to-current-outline* loc (string/join " " str-coll)))
+
+(defn- block-src
+  [loc {:keys [_language lines]}]
+  (append-text-to-current-outline* loc (string/join " " lines)))
+
+(defn- block-quote
+  [loc block-ast-coll]
+  (reduce block-ast->hiccup loc block-ast-coll))
+
+(defn- block-latex-env
+  [loc [name options content]]
+  (append-text-to-current-outline*
+   loc
+   (str "\\begin{" name "}" options "\n"
+        content "\n"
+        "\\end{" name "}")))
+
+(defn- block-displayed-math
+  [loc s]
+  (append-text-to-current-outline* loc s))
+
+(defn- block-footnote-definition
+  [loc [name inline-coll]]
+  (let [inline-simple-asts (mapcatv inline-ast->simple-ast inline-coll)]
+    (append-text-to-current-outline*
+     loc
+     (str "[^" name "]: " (simple-asts->string inline-simple-asts)))))
+
+(defn- block-ast->hiccup
+  [loc block-ast]
+  (let [[ast-type ast-content] block-ast]
+    (case ast-type
+      "Paragraph"
+      (block-paragraph loc ast-content)
+      "Paragraph_line"
+      (assert false "Paragraph_line is mldoc internal ast")
+      "Paragraph_Sep"
+      loc
+      "Heading"
+      (block-heading loc ast-content)
+      "List"
+      (block-list loc ast-content)
+      ("Directive" "Results" "Property_Drawer" "Export" "CommentBlock" "Custom")
+      loc
+      "Example"
+      (block-example loc ast-content)
+      "Src"
+      (block-src loc ast-content)
+      "Quote"
+      (block-quote loc ast-content)
+      "Latex_Fragment"
+      (append-text-to-current-outline* loc (simple-asts->string (inline-latex-fragment ast-content)))
+      "Latex_Environment"
+      (block-latex-env loc (rest block-ast))
+      "Displayed_Math"
+      (block-displayed-math loc ast-content)
+      "Drawer"
+      loc
+      "Footnote_Definition"
+      (block-footnote-definition loc (rest block-ast))
+      "Horizontal_Rule"
+      loc
+      "Table"
+      loc
+      "Comment"
+      loc
+      "Raw_Html"
+      loc
+      "Hiccup"
+      loc
+      (assert false (print-str :block-ast->simple-ast ast-type "not implemented yet")))))
+
+;;; block/inline-ast -> hiccup (ends)
+
+;;; export fns
+(defn- export-helper
+  [content format options]
+  (let [remove-options (set (:remove-options options))]
+    (binding [*state* (merge *state*
+                             {:export-options
+                              {:remove-emphasis? (contains? remove-options :emphasis)
+                               :remove-page-ref-brackets? (contains? remove-options :page-ref)
+                               :remove-tags? (contains? remove-options :tag)}})
+              *opml-state* *opml-state*]
+      (let [ast (gp-mldoc/->edn content (gp-mldoc/default-config format))
+            ast (mapv common/remove-block-ast-pos ast)
+            ast (removev common/Properties-block-ast? ast)
+            ast* (common/replace-block&page-reference&embed ast)
+            ast** (if (= "no-indent" (get-in *state* [:export-options :indent-style]))
+                    (mapv common/replace-Heading-with-Paragraph ast*)
+                    ast*)
+            config-for-walk-block-ast (cond-> {}
+                                        (get-in *state* [:export-options :remove-emphasis?])
+                                        (update :mapcat-fns-on-inline-ast conj common/remove-emphasis)
+
+                                        (get-in *state* [:export-options :remove-page-ref-brackets?])
+                                        (update :map-fns-on-inline-ast conj common/remove-page-ref-brackets)
+
+                                        (get-in *state* [:export-options :remove-tags?])
+                                        (update :mapcat-fns-on-inline-ast conj common/remove-tags))
+            ast*** (if-not (empty? config-for-walk-block-ast)
+                     (mapv (partial common/walk-block-ast config-for-walk-block-ast) ast**)
+                     ast**)
+            hiccup (z/root (reduce block-ast->hiccup init-opml-body-hiccup ast***))]
+        (zip-loc->opml hiccup "untitled")))))
+
+(defn export-blocks-as-opml
+  "options:
+  :remove-options [:emphasis :page-ref :tag]"
+  [repo root-block-uuids-or-page-name options]
+  {:pre [(or (coll? root-block-uuids-or-page-name)
+             (string? root-block-uuids-or-page-name))]}
+  (util/profile
+   :export-blocks-as-opml
+   (let [content
+         (if (string? root-block-uuids-or-page-name)
+           ;; page
+           (common/get-page-content root-block-uuids-or-page-name)
+           (common/root-block-uuids->content repo root-block-uuids-or-page-name))
+         first-block (db/entity [:block/uuid (first root-block-uuids-or-page-name)])
+         format (or (:block/format first-block) (state/get-preferred-format))]
+     (export-helper content format options))))
+
+(defn export-files-as-opml
+  "options see also `export-blocks-as-opml`"
+  [files options]
+  (mapv
+   (fn [{:keys [path content names format]}]
+     (when (first names)
+       (util/profile (print-str :export-files-as-opml path)
+                     [path (export-helper content format options)])))
+   files))
+
+(defn export-repo-as-opml!
+  [repo]
+  (when-let [files (common/get-file-contents-with-suffix repo)]
+    (let [files (export-files-as-opml files nil)
+          zip-file-name (str repo "_opml_" (quot (util/time-ms) 1000))]
+      (p/let [zipfile (zip/make-zip zip-file-name files repo)]
+        (when-let [anchor (gdom/getElement "export-as-opml")]
+          (.setAttribute anchor "href" (js/window.URL.createObjectURL zipfile))
+          (.setAttribute anchor "download" (.-name zipfile))
+          (.click anchor))))))
+
+;;; export fns (ends)

+ 487 - 0
src/main/frontend/handler/export/text.cljs

@@ -0,0 +1,487 @@
+(ns frontend.handler.export.text
+  "export blocks/pages as text"
+  (:refer-clojure :exclude [map filter mapcat concat remove newline])
+  (:require
+   [clojure.string :as string]
+   [frontend.db :as db]
+   [frontend.extensions.zip :as zip]
+   [frontend.handler.export.common :as common :refer
+    [*state*
+     simple-ast-malli-schema
+     raw-text space newline* indent simple-asts->string]]
+   [frontend.state :as state]
+   [frontend.util :as util :refer [mapcatv concatv removev]]
+   [goog.dom :as gdom]
+   [logseq.graph-parser.mldoc :as gp-mldoc]
+   [malli.core :as m]
+   [promesa.core :as p]))
+
+
+;;; block-ast, inline-ast -> simple-ast
+
+(defn indent-with-2-spaces
+  "also consider (get-in *state* [:export-options :indent-style])"
+  [level]
+  (let [indent-style (get-in *state* [:export-options :indent-style])]
+    (case indent-style
+      "dashes"               (indent level 2)
+      ("spaces" "no-indent") (indent level 0)
+      (assert false (print-str "unknown indent-style:" indent-style)))))
+
+(declare inline-ast->simple-ast
+         block-ast->simple-ast)
+
+(defn- block-heading
+  [{:keys [title _tags marker level _numbering priority _anchor _meta _unordered size]}]
+  (let [indent-style (get-in *state* [:export-options :indent-style])
+        priority* (and priority (raw-text (common/priority->string priority)))
+        heading* (if (= indent-style "dashes")
+                   [(indent (dec level) 0) (raw-text "-")]
+                   [(indent (dec level) 0)])
+        size* (and size [space (raw-text (reduce str (repeat size "#")))])
+        marker* (and marker (raw-text marker))]
+    (set! *state* (assoc *state* :current-level level))
+    (removev nil? (concatv heading* size*
+                           [space marker* space priority* space]
+                           (mapcatv inline-ast->simple-ast title)
+                           [(newline* 1)]))))
+
+(declare block-list)
+(defn- block-list-item
+  [{:keys [content items number _name checkbox]}]
+  (let [content* (mapcatv block-ast->simple-ast content)
+        number* (raw-text
+                 (if number
+                   (str number ". ")
+                   "* "))
+        checkbox* (raw-text
+                   (if (some? checkbox)
+                     (if (boolean checkbox)
+                       "[X]" "[ ]")
+                     ""))
+        current-level (get *state* :current-level 1)
+        indent (when (> current-level 1)
+                 (indent (dec current-level) 0))
+        items* (block-list items :in-list? true)]
+    (concatv [indent number* checkbox* space]
+             content*
+             [(newline* 1)]
+             items*
+             [(newline* 1)])))
+
+(defn- block-list
+  [l & {:keys [in-list?]}]
+  (binding [*state* (update *state* :current-level inc)]
+    (concatv (mapcatv block-list-item l)
+             (when (and (pos? (count l))
+                        (not in-list?))
+               [(newline* 2)]))))
+
+(defn- block-example
+  [l]
+  (let [level (dec (get *state* :current-level 1))]
+    (mapcatv
+     (fn [line]
+       [(indent-with-2-spaces level)
+        (raw-text "    ")
+        (raw-text line)
+        (newline* 1)])
+     l)))
+
+(defn- block-src
+  [{:keys [lines language]}]
+  (let [level (dec (get *state* :current-level 1))]
+    (concatv
+     [(indent-with-2-spaces level) (raw-text "```")]
+     (when language [space (raw-text language)])
+     [(newline* 1)]
+     (mapv raw-text lines)
+     [(indent-with-2-spaces level) (raw-text "```") (newline* 1)])))
+
+(defn- block-quote
+  [block-coll]
+  (let [level (dec (get *state* :current-level 1))]
+    (binding [*state* (assoc *state* :indent-after-break-line? true)]
+      (concatv (mapcatv (fn [block]
+                          (let [block-simple-ast (block-ast->simple-ast block)]
+                            (when (seq block-simple-ast)
+                              (concatv [(indent-with-2-spaces level) (raw-text ">") space]
+                                       block-simple-ast))))
+                        block-coll)
+               [(newline* 2)]))))
+
+(declare inline-latex-fragment)
+(defn- block-latex-fragment
+  [ast-content]
+  (inline-latex-fragment ast-content))
+
+(defn- block-latex-env
+  [[name options content]]
+  (let [level (dec (get *state* :current-level 1))]
+    [(indent-with-2-spaces level) (raw-text "\\begin{" name "}" options)
+     (newline* 1)
+     (indent-with-2-spaces level) (raw-text content)
+     (newline* 1)
+     (indent-with-2-spaces level) (raw-text "\\end{" name "}")
+     (newline* 1)]))
+
+(defn- block-displayed-math
+  [ast-content]
+  [space (raw-text "$$" ast-content "$$") space])
+
+(defn- block-drawer
+  [[name lines]]
+  (let [level (dec (get *state* :current-level))]
+    (concatv
+     [(raw-text ":" name ":")
+      (newline* 1)]
+     (mapcatv (fn [line] [(indent-with-2-spaces level) (raw-text line)]) lines)
+     [(newline* 1) (raw-text ":END:") (newline* 1)])))
+
+(defn- block-footnote-definition
+  [[name content]]
+  (concatv
+   [(raw-text "[^" name "]:") space]
+   (mapcatv inline-ast->simple-ast content)
+   [(newline* 1)]))
+
+(def ^:private block-horizontal-rule [(newline* 1) (raw-text "---") (newline* 1)])
+
+(defn- block-table
+  [{:keys [header groups]}]
+  (when (seq header)
+    (let [level    (dec (get *state* :current-level 1))
+          sep-line (raw-text "|" (string/join "|" (repeat (count header) "---")) "|")
+          header-line
+          (concatv (mapcatv
+                    (fn [h] (concatv [space (raw-text "|") space] (mapcatv inline-ast->simple-ast h)))
+                    header)
+                   [space (raw-text "|")])
+          group-lines
+          (mapcatv
+           (fn [group]
+             (mapcatv
+              (fn [row]
+                (concatv [(indent-with-2-spaces level)]
+                         (mapcatv
+                          (fn [col]
+                            (concatv [(raw-text "|") space]
+                                     (mapcatv inline-ast->simple-ast col)
+                                     [space]))
+                          row)
+                         [(raw-text "|") (newline* 1)]))
+              group))
+           groups)]
+      (concatv [(newline* 1) (indent-with-2-spaces level)]
+               header-line
+               [(newline* 1) (indent-with-2-spaces level) sep-line (newline* 1)]
+               group-lines))))
+
+(defn- block-comment
+  [s]
+  (let [level (dec (get *state* :current-level 1))]
+    [(indent-with-2-spaces level) (raw-text "<!---") (newline* 1)
+     (indent-with-2-spaces level) (raw-text s) (newline* 1)
+     (indent-with-2-spaces level) (raw-text "-->") (newline* 1)]))
+
+(defn- block-raw-html
+  [s]
+  (let [level (dec (get *state* :current-level 1))]
+    [(indent-with-2-spaces level) (raw-text s) (newline* 1)]))
+
+(defn- block-hiccup
+  [s]
+  (let [level (dec (get *state* :current-level 1))]
+    [(indent-with-2-spaces level) (raw-text s) space]))
+
+(defn- inline-link
+  [{full-text :full_text}]
+  [(raw-text full-text)])
+
+(defn- inline-nested-link
+  [{content :content}]
+  [(raw-text content)])
+
+(defn- inline-subscript
+  [inline-coll]
+  (concatv [(raw-text "_{")]
+           (mapcatv (fn [inline] (cons space (inline-ast->simple-ast inline))) inline-coll)
+           [(raw-text "}")]))
+
+(defn- inline-superscript
+  [inline-coll]
+  (concatv [(raw-text "^{")]
+           (mapcatv (fn [inline] (cons space (inline-ast->simple-ast inline))) inline-coll)
+           [(raw-text "}")]))
+
+(defn- inline-footnote-reference
+  [{name :name}]
+  [(raw-text  "[" name "]")])
+
+(defn- inline-cookie
+  [ast-content]
+  [(raw-text
+    (case (first ast-content)
+      "Absolute"
+      (let [[_ current total] ast-content]
+        (str "[" current "/" total "]"))
+      "Percent"
+      (str "[" (second ast-content) "%]")))])
+
+(defn- inline-latex-fragment
+  [ast-content]
+  (let [[type content] ast-content
+        wrapper (case type
+                  "Inline" "$"
+                  "Displayed" "$$")]
+    [space (raw-text (str wrapper content wrapper)) space]))
+
+(defn- inline-macro
+  [{:keys [name arguments]}]
+  (->
+   (if (= name "cloze")
+     (string/join "," arguments)
+     (let [l (cond-> ["{{" name]
+               (pos? (count arguments)) (conj "(" (string/join "," arguments) ")")
+               true (conj "}}"))]
+       (string/join l)))
+   raw-text
+   vector))
+
+(defn- inline-entity
+  [{unicode :unicode}]
+  [(raw-text unicode)])
+
+(defn- inline-timestamp
+  [ast-content]
+  (let [[type timestamp-content] ast-content]
+    (-> (case type
+          "Scheduled" ["SCHEDULED: " (common/timestamp-to-string timestamp-content)]
+          "Deadline" ["DEADLINE: " (common/timestamp-to-string timestamp-content)]
+          "Date" [(common/timestamp-to-string timestamp-content)]
+          "Closed" ["CLOSED: " (common/timestamp-to-string timestamp-content)]
+          "Clock" ["CLOCK: " (common/timestamp-to-string (second timestamp-content))]
+          "Range" (let [{:keys [start stop]} timestamp-content]
+                    [(str (common/timestamp-to-string start) "--" (common/timestamp-to-string stop))]))
+        string/join
+        raw-text
+        vector)))
+
+(defn- inline-email
+  [{:keys [local_part domain]}]
+  [(raw-text (str "<" local_part "@" domain ">"))])
+
+(defn- emphasis-wrap-with
+  [inline-coll em-symbol]
+  (binding [*state* (assoc *state* :outside-em-symbol (first em-symbol))]
+    (concatv [(raw-text em-symbol)]
+             (mapcatv inline-ast->simple-ast inline-coll)
+             [(raw-text em-symbol)])))
+
+(defn- inline-emphasis
+  [emphasis]
+  (let [[[type] inline-coll] emphasis
+        outside-em-symbol (:outside-em-symbol *state*)]
+    (case type
+      "Bold"
+      (emphasis-wrap-with inline-coll (if (= outside-em-symbol "*") "__" "**"))
+      "Italic"
+      (emphasis-wrap-with inline-coll (if (= outside-em-symbol "*") "_" "*"))
+      "Underline"
+      (binding [*state* (assoc *state* :outside-em-symbol outside-em-symbol)]
+        (mapcatv (fn [inline] (cons space (inline-ast->simple-ast inline))) inline-coll))
+      "Strike_through"
+      (emphasis-wrap-with inline-coll "~~")
+      "Highlight"
+      (emphasis-wrap-with inline-coll "^^")
+      ;; else
+      (assert false (print-str :inline-emphasis emphasis "is invalid")))))
+
+(defn- inline-break-line
+  []
+  [(raw-text "  \n")
+   (when (:indent-after-break-line? *state*)
+     (let [current-level (get *state* :current-level 1)]
+       (when (> current-level 1)
+         (indent-with-2-spaces (dec current-level)))))])
+
+;; {:malli/schema ...} only works on public vars, so use m/=> here
+(m/=> block-ast->simple-ast [:=> [:cat [:sequential :any]] [:sequential simple-ast-malli-schema]])
+(defn- block-ast->simple-ast
+  [block]
+  (removev
+   nil?
+   (let [[ast-type ast-content] block]
+     (case ast-type
+       "Paragraph"
+       (concatv (mapcatv inline-ast->simple-ast ast-content) [(newline* 1)])
+       "Paragraph_line"
+       (assert false "Paragraph_line is mldoc internal ast")
+       "Paragraph_Sep"
+       [(newline* ast-content)]
+       "Heading"
+       (block-heading ast-content)
+       "List"
+       (block-list ast-content)
+       ("Directive" "Results" "Property_Drawer" "Export" "CommentBlock" "Custom")
+       nil
+       "Example"
+       (block-example ast-content)
+       "Src"
+       (block-src ast-content)
+       "Quote"
+       (block-quote ast-content)
+       "Latex_Fragment"
+       (block-latex-fragment ast-content)
+       "Latex_Environment"
+       (block-latex-env (rest block))
+       "Displayed_Math"
+       (block-displayed-math ast-content)
+       "Drawer"
+       (block-drawer (rest block))
+       ;; TODO: option: toggle Property_Drawer
+       ;; "Property_Drawer"
+       ;; (block-property-drawer ast-content)
+       "Footnote_Definition"
+       (block-footnote-definition (rest block))
+       "Horizontal_Rule"
+       block-horizontal-rule
+       "Table"
+       (block-table ast-content)
+       "Comment"
+       (block-comment ast-content)
+       "Raw_Html"
+       (block-raw-html ast-content)
+       "Hiccup"
+       (block-hiccup ast-content)
+       (assert false (print-str :block-ast->simple-ast ast-type "not implemented yet"))))))
+
+(defn- inline-ast->simple-ast
+  [inline]
+  (let [[ast-type ast-content] inline]
+    (case ast-type
+      "Emphasis"
+      (inline-emphasis ast-content)
+      ("Break_Line" "Hard_Break_Line")
+      (inline-break-line)
+      "Verbatim"
+      [(raw-text ast-content)]
+      "Code"
+      [(raw-text "`" ast-content "`")]
+      "Tag"
+      [(raw-text (str "#" (common/hashtag-value->string ast-content)))]
+      "Spaces"                          ; what's this ast-type for ?
+      nil
+      "Plain"
+      [(raw-text ast-content)]
+      "Link"
+      (inline-link ast-content)
+      "Nested_link"
+      (inline-nested-link ast-content)
+      "Target"
+      [(raw-text (str "<<" ast-content ">>"))]
+      "Subscript"
+      (inline-subscript ast-content)
+      "Superscript"
+      (inline-superscript ast-content)
+      "Footnote_Reference"
+      (inline-footnote-reference ast-content)
+      "Cookie"
+      (inline-cookie ast-content)
+      "Latex_Fragment"
+      (inline-latex-fragment ast-content)
+      "Macro"
+      (inline-macro ast-content)
+      "Entity"
+      (inline-entity ast-content)
+      "Timestamp"
+      (inline-timestamp ast-content)
+      "Radio_Target"
+      [(raw-text (str "<<<" ast-content ">>>"))]
+      "Email"
+      (inline-email ast-content)
+      "Inline_Hiccup"
+      [(raw-text ast-content)]
+      "Inline_Html"
+      [(raw-text ast-content)]
+      ("Export_Snippet" "Inline_Source_Block")
+      nil
+      (assert false (print-str :inline-ast->simple-ast ast-type "not implemented yet")))))
+
+;;; block-ast, inline-ast -> simple-ast (ends)
+
+
+;;; export fns
+
+(defn- export-helper
+  [content format options]
+  (let [remove-options (set (:remove-options options))]
+    (binding [*state* (merge *state*
+                             {:export-options
+                              {:indent-style (or (:indent-style options) "dashes")
+                               :remove-emphasis? (contains? remove-options :emphasis)
+                               :remove-page-ref-brackets? (contains? remove-options :page-ref)
+                               :remove-tags? (contains? remove-options :tag)}})]
+      (let [ast (gp-mldoc/->edn content (gp-mldoc/default-config format))
+            ast (mapv common/remove-block-ast-pos ast)
+            ast (removev common/Properties-block-ast? ast)
+            ast* (common/replace-block&page-reference&embed ast)
+            ast** (if (= "no-indent" (get-in *state* [:export-options :indent-style]))
+                    (mapv common/replace-Heading-with-Paragraph ast*)
+                    ast*)
+            config-for-walk-block-ast (cond-> {}
+                                        (get-in *state* [:export-options :remove-emphasis?])
+                                        (update :mapcat-fns-on-inline-ast conj common/remove-emphasis)
+
+                                        (get-in *state* [:export-options :remove-page-ref-brackets?])
+                                        (update :map-fns-on-inline-ast conj common/remove-page-ref-brackets)
+
+                                        (get-in *state* [:export-options :remove-tags?])
+                                        (update :mapcat-fns-on-inline-ast conj common/remove-tags))
+            ast*** (if-not (empty? config-for-walk-block-ast)
+                     (mapv (partial common/walk-block-ast config-for-walk-block-ast) ast**)
+                     ast**)
+            simple-asts (mapcatv block-ast->simple-ast ast***)]
+        (simple-asts->string simple-asts)))))
+
+(defn export-blocks-as-markdown
+  "options:
+  :indent-style \"dashes\" | \"spaces\" | \"no-indent\"
+  :remove-options [:emphasis :page-ref :tag]"
+  [repo root-block-uuids-or-page-name options]
+  {:pre [(or (coll? root-block-uuids-or-page-name)
+             (string? root-block-uuids-or-page-name))]}
+  (util/profile
+   :export-blocks-as-markdown
+   (let [content
+         (if (string? root-block-uuids-or-page-name)
+           ;; page
+           (common/get-page-content root-block-uuids-or-page-name)
+           (common/root-block-uuids->content repo root-block-uuids-or-page-name))
+         first-block (db/entity [:block/uuid (first root-block-uuids-or-page-name)])
+         format (or (:block/format first-block) (state/get-preferred-format))]
+     (export-helper content format options))))
+
+(defn export-files-as-markdown
+  "options see also `export-blocks-as-markdown`"
+  [files options]
+  (mapv
+   (fn [{:keys [path content names format]}]
+     (when (first names)
+       (util/profile (print-str :export-files-as-markdown path)
+                     [path (export-helper content format options)])))
+   files))
+
+(defn export-repo-as-markdown!
+  "TODO: indent-style and remove-options"
+  [repo]
+  (when-let [files (util/profile :get-file-content (common/get-file-contents-with-suffix repo))]
+    (let [files (export-files-as-markdown files nil)
+          zip-file-name (str repo "_markdown_" (quot (util/time-ms) 1000))]
+      (p/let [zipfile (zip/make-zip zip-file-name files repo)]
+        (when-let [anchor (gdom/getElement "export-as-markdown")]
+          (.setAttribute anchor "href" (js/window.URL.createObjectURL zipfile))
+          (.setAttribute anchor "download" (.-name zipfile))
+          (.click anchor))))))
+
+;;; export fns (ends)

+ 27 - 0
src/main/frontend/handler/export/zip_helper.cljs

@@ -0,0 +1,27 @@
+(ns frontend.handler.export.zip-helper
+  "zipper helpers used in opml&html exporting"
+  (:require [clojure.zip :as z]))
+
+(defn goto-last
+  [loc]
+  (let [loc* (z/next loc)]
+    (if (z/end? loc*)
+      loc
+      (recur loc*))))
+
+(defn get-level
+  [loc]
+  (count (z/path loc)))
+
+(defn goto-level
+  [loc level]
+  (let [current-level (get-level loc)]
+    (assert (<= level (inc current-level))
+            (print-str :level level :current-level current-level))
+    (let [diff (- level current-level)
+          up-or-down (if (pos? diff) z/down z/up)
+          diff* (abs diff)]
+      (loop [loc loc count* diff*]
+        (if (zero? count*)
+          loc
+          (recur (up-or-down loc) (dec count*)))))))

+ 1 - 1
src/main/frontend/modules/shortcut/config.cljs

@@ -313,7 +313,7 @@
                                                 (editor-handler/escape-editing)
                                                 (state/pub-event! [:modal/command-palette]))}
 
-   :graph/export-as-html           {:fn #(export-handler/export-repo-as-html!
+   :graph/export-as-html           {:fn #(export-handler/download-repo-as-html!
                                           (state/get-current-repo))
                                     :binding false}
 

+ 0 - 4
src/main/frontend/state.cljs

@@ -601,10 +601,6 @@ Similar to re-frame subscriptions"
     ((resolve 'frontend.handler.user/feature-available?) :whiteboard) ;; using resolve to avoid circular dependency
     (:feature/enable-whiteboards? (sub-config repo)))))
 
-(defn export-heading-to-list?
-  []
-  (not (false? (:export/heading-to-list? (sub-config)))))
-
 (defn enable-git-auto-push?
   [repo]
   (not (false? (:git-auto-push (sub-config repo)))))

+ 16 - 0
src/main/frontend/util.cljc

@@ -1497,3 +1497,19 @@ Arg *stop: atom, reset to true to stop the loop"
                (vreset! *last-activated-at now-epoch)
                (async/<! (async/timeout 5000))
                (recur))))))))
+
+
+(defmacro concatv
+  "Vector version of concat. non-lazy"
+  [& args]
+  `(vec (concat ~@args)))
+
+(defmacro mapcatv
+  "Vector version of mapcat. non-lazy"
+  [f coll & colls]
+  `(vec (mapcat ~f ~coll ~@colls)))
+
+(defmacro removev
+  "Vector version of remove. non-lazy"
+  [pred coll]
+  `(vec (remove ~pred ~coll)))

+ 17 - 16
src/test/frontend/handler/export_test.cljs

@@ -3,6 +3,7 @@
             [frontend.test.helper :as test-helper :include-macros true :refer [deftest-async]]
             [clojure.edn :as edn]
             [frontend.handler.export :as export]
+            [frontend.handler.export.text :as export-text]
             [frontend.state :as state]
             [promesa.core :as p]))
 
@@ -11,18 +12,18 @@
     :file/content
     "- 1
   id:: 61506710-484c-46d5-9983-3d1651ec02c8
-        - 2
-          id:: 61506711-5638-4899-ad78-187bdc2eaffc
-                - 3
-                  id:: 61506712-3007-407e-b6d3-d008a8dfa88b
-                - ((61506712-3007-407e-b6d3-d008a8dfa88b))
+	- 2
+	  id:: 61506711-5638-4899-ad78-187bdc2eaffc
+		- 3
+		  id:: 61506712-3007-407e-b6d3-d008a8dfa88b
+		- ((61506712-3007-407e-b6d3-d008a8dfa88b))
 - 4
   id:: 61506712-b8a7-491d-ad84-b71651c3fdab"}
    {:file/path "pages/page2.md"
     :file/content
     "- 3
   id:: 97a00e55-48c3-48d8-b9ca-417b16e3a616
-        - {{embed [[page1]]}}"}])
+	- {{embed [[page1]]}}"}])
 
 (use-fixtures :once
   {:before (fn []
@@ -36,23 +37,23 @@
   (p/do!
    (are [expect block-uuid-s]
         (= expect
-           (export/export-blocks-as-markdown (state/get-current-repo) [(uuid block-uuid-s)] "dashes" []))
-        "- 1  \n\t- 2  \n\t\t- 3  \n\t\t- 3  "
-        "61506710-484c-46d5-9983-3d1651ec02c8"
+           (export-text/export-blocks-as-markdown (state/get-current-repo) [(uuid block-uuid-s)] {}))
+     "- 1\n\t- 2\n\t\t- 3\n\t\t- 3\n"
+     "61506710-484c-46d5-9983-3d1651ec02c8"
 
-        "- 3  \n\t- 1  \n\t\t- 2  \n\t\t\t- 3  \n\t\t\t- 3  \n\t- 4  "
-        "97a00e55-48c3-48d8-b9ca-417b16e3a616")))
+     "- 3\n\t- 1\n\t\t- 2\n\t\t\t- 3\n\t\t\t- 3\n\t- 4\n"
+     "97a00e55-48c3-48d8-b9ca-417b16e3a616")))
 
 (deftest-async export-files-as-markdown
   (p/do!
    (are [expect files]
         (= expect
-           (@#'export/export-files-as-markdown (state/get-current-repo) files true))
-        [["pages/page1.md" "- 1  \n\t- 2  \n\t\t- 3  \n\t\t- 3  \n- 4  "]]
-        [{:path "pages/page1.md" :content (:file/content (nth test-files 0)) :names ["page1"] :format :markdown}]
+           (@#'export-text/export-files-as-markdown files nil))
+     [["pages/page1.md" "- 1\n\t- 2\n\t\t- 3\n\t\t- 3\n- 4\n"]]
+     [{:path "pages/page1.md" :content (:file/content (nth test-files 0)) :names ["page1"] :format :markdown}]
 
-        [["pages/page2.md" "- 3  \n\t- 1  \n\t\t- 2  \n\t\t\t- 3  \n\t\t\t- 3  \n\t- 4  "]]
-        [{:path "pages/page2.md" :content (:file/content (nth test-files 1)) :names ["page2"] :format :markdown}])))
+     [["pages/page2.md" "- 3\n\t- 1\n\t\t- 2\n\t\t\t- 3\n\t\t\t- 3\n\t- 4\n"]]
+     [{:path "pages/page2.md" :content (:file/content (nth test-files 1)) :names ["page2"] :format :markdown}])))
 
 (deftest-async export-repo-as-edn-str
   (p/do!