diff --git a/.gitignore b/.gitignore index 486b3a05ca4..04b8167d61e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ resources/static/js/cljs-runtime resources/static/js/manifest.edn resources/static/style.css -/.cpcache +.cpcache/ /target /checkouts /src/gen diff --git a/deploy_cdn.sh b/deploy_cdn.sh index 3d2945cfb12..bea21e5fc35 100755 --- a/deploy_cdn.sh +++ b/deploy_cdn.sh @@ -1,3 +1,5 @@ #!/bin/sh +cd web yarn clean && yarn release +cd ../ aws s3 sync ./resources/static/ s3://logseq-site/static/ diff --git a/package.json b/package.json index f3208982914..8d0b4af5dce 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "shadow-cljs": "2.8.81" }, "scripts": { - "watch": "clojure -A:cljs watch app", - "release": "clojure -A:cljs release app", - "debug": "clojure -A:cljs release app --debug", - "report": "clojure -A:cljs run shadow.cljs.build-report app report.html", - "clean": "/usr/bin/rm -rf target; /usr/bin/rm -rf resources/static/js/compiled; /usr/bin/rm -rf resources/static/js/cljs-runtime; /usr/bin/rm resources/static/js/main.js.map; /usr/bin/rm resources/static/js/manifest.edn" + "watch": "npx shadow-cljs watch app", + "release": "npx shadow-cljs release app", + "debug": "npx shadow-cljs release app --debug", + "report": "npx shadow-cljs run shadow.cljs.build-report app report.html", + "clean": "/usr/bin/rm -rf target; /usr/bin/rm -rf ../resources/static/js/compiled; /usr/bin/rm -rf ../resources/static/js/cljs-runtime; /usr/bin/rm ../resources/static/js/main.js.map; /usr/bin/rm ../resources/static/js/manifest.edn" }, "dependencies": { "@tailwindcss/ui": "^0.1.3", diff --git a/web/deps.edn b/web/deps.edn new file mode 100755 index 00000000000..ea5ab5b4417 --- /dev/null +++ b/web/deps.edn @@ -0,0 +1,26 @@ +{:paths ["src/main"] + :deps + {org.clojure/clojure {:mvn/version "1.10.0"} + rum {:mvn/version "0.11.4"} + datascript-transit {:mvn/version "0.3.0"} + funcool/promesa {:mvn/version "4.0.2"} + medley {:mvn/version "1.2.0"} + metosin/reitit-frontend {:mvn/version "0.3.10"} + cljs-bean {:mvn/version "1.5.0"}} + + :aliases {:cljs {:extra-paths ["src/dev-cljs/"] + :extra-deps {org.clojure/clojurescript {:mvn/version "1.10.520"} + thheller/shadow-cljs {:mvn/version "RELEASE"} + binaryage/devtools {:mvn/version "0.9.10"} + org.clojure/tools.namespace {:mvn/version "0.2.11"} + cider/cider-nrepl {:mvn/version "0.23.0-SNAPSHOT"}} + :main-opts ["-m" "shadow.cljs.devtools.cli"]} + :test + {:extra-paths ["test"], + :extra-deps {org.clojure/test.check {:mvn/version "RELEASE"}}} + :runner + {:extra-deps + {com.cognitect/test-runner + {:git/url "https://github.com/cognitect-labs/test-runner", + :sha "76568540e7f40268ad2b646110f237a60295fa3c"}}, + :main-opts ["-m" "cognitect.test-runner" "-d" "test"]}}} diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000000..f3208982914 --- /dev/null +++ b/web/package.json @@ -0,0 +1,29 @@ +{ + "name": "logseq", + "version": "0.0.1", + "private": true, + "devDependencies": { + "shadow-cljs": "2.8.81" + }, + "scripts": { + "watch": "clojure -A:cljs watch app", + "release": "clojure -A:cljs release app", + "debug": "clojure -A:cljs release app --debug", + "report": "clojure -A:cljs run shadow.cljs.build-report app report.html", + "clean": "/usr/bin/rm -rf target; /usr/bin/rm -rf resources/static/js/compiled; /usr/bin/rm -rf resources/static/js/cljs-runtime; /usr/bin/rm resources/static/js/main.js.map; /usr/bin/rm resources/static/js/manifest.edn" + }, + "dependencies": { + "@tailwindcss/ui": "^0.1.3", + "browserfs": "^1.4.3", + "dev": "^0.1.3", + "isomorphic-git": "^1.3.1", + "mldoc_org": "^0.2.6", + "purgecss": "^2.1.0", + "react": "^16.12.0", + "react-dom": "^16.12.0", + "react-textarea-autosize": "^7.1.2", + "react-transition-group": "^4.3.0", + "showdown": "^1.9.1", + "tailwindcss": "^1.2.0" + } +} diff --git a/shadow-cljs.edn b/web/shadow-cljs.edn similarity index 95% rename from shadow-cljs.edn rename to web/shadow-cljs.edn index bc3235bf0f5..7c95e972af2 100644 --- a/shadow-cljs.edn +++ b/web/shadow-cljs.edn @@ -7,7 +7,7 @@ {:target :browser :modules {:main {:init-fn frontend.core/init}} - :output-dir "resources/static/js" + :output-dir "../resources/static/js" :asset-path "/static/js" :compiler-options {:infer-externs :auto diff --git a/web/src/dev-cljs/shadow/hooks.clj b/web/src/dev-cljs/shadow/hooks.clj new file mode 100644 index 00000000000..409bf35a1d0 --- /dev/null +++ b/web/src/dev-cljs/shadow/hooks.clj @@ -0,0 +1,31 @@ +(ns shadow.hooks + (:require [clojure.java.shell :refer [sh]] + [clojure.string :as str])) + +;; copied from https://gist.github.com/mhuebert/ba885b5e4f07923e21d1dc4642e2f182 +(defn exec [& cmd] + (let [cmd (str/split (str/join " " (flatten cmd)) #"\s+") + _ (println (str/join " " cmd)) + {:keys [exit out err]} (apply sh cmd)] + (if (zero? exit) + (when-not (str/blank? out) + (println out)) + (println err)))) + +(defn purge-css + {:shadow.build/stage :flush} + [state {:keys [css-source + js-globs + public-dir]}] + (case (:shadow.build/mode state) + :release + (exec "purgecss --css " css-source + (for [content (if (string? js-globs) [js-globs] js-globs)] + (str "--content " content)) + "-o" public-dir) + + :dev + (do + (exec "mkdir -p" public-dir) + (exec "cp" css-source (str public-dir "/" (last (str/split css-source #"/")))))) + state) diff --git a/web/src/dev-cljs/shadow/user.clj b/web/src/dev-cljs/shadow/user.clj new file mode 100644 index 00000000000..84dd6be4234 --- /dev/null +++ b/web/src/dev-cljs/shadow/user.clj @@ -0,0 +1,7 @@ +(ns shadow.user + (:require [shadow.cljs.devtools.api :as api])) + +(defn cljs-repl + [] + (api/watch :app) + (api/repl :app)) diff --git a/web/src/main/frontend/blob.cljs b/web/src/main/frontend/blob.cljs new file mode 100644 index 00000000000..6232b91206f --- /dev/null +++ b/web/src/main/frontend/blob.cljs @@ -0,0 +1,29 @@ +(ns frontend.blob) + +(defn- decode + "Decodes the data portion of a data url from base64" + [[media-type data]] + [media-type (js/atob data)]) + +(defn- uint8 + "Converts a base64 decoded data string to a Uint8Array" + [[media-type data]] + (->> (map #(.charCodeAt %1) data) + js/Uint8Array. + (vector media-type))) + +(defn- make-blob + "Creates a JS Blob object from a media type and a Uint8Array" + [[media-type uint8]] + (js/Blob. (array uint8) (js-obj "type" media-type))) + +(defn blob + "Converts a data-url into a JS Blob. This is useful for uploading + image data from JavaScript." + [data-url] + {:pre [(string? data-url)]} + (-> (re-find #"^data:([^;]+);base64,(.*)$" data-url) + rest + decode + uint8 + make-blob)) diff --git a/web/src/main/frontend/components/agenda.cljs b/web/src/main/frontend/components/agenda.cljs new file mode 100644 index 00000000000..940d8af2c24 --- /dev/null +++ b/web/src/main/frontend/components/agenda.cljs @@ -0,0 +1,104 @@ +(ns frontend.components.agenda + (:require [rum.core :as rum] + [frontend.util :as util] + [frontend.handler :as handler] + [frontend.format.org.block :as block] + [frontend.state :as state] + [clojure.string :as string] + [frontend.format.org-mode :as org] + [frontend.components.sidebar :as sidebar] + [frontend.db :as db] + [frontend.ui :as ui])) + +(rum/defc timestamps-cp + [timestamps] + [:ul + (for [[type {:keys [date time]}] timestamps] + (let [{:keys [year month day]} date + {:keys [hour min]} time] + [:li {:key type} + [:span {:style {:margin-right 6}} type] + [:span (if time + (str year "-" month "-" day " " hour ":" min) + (str year "-" month "-" day))]]))]) + +(rum/defc title-cp + [title] + (let [title-json (js/JSON.stringify (clj->js title)) + html (org/inline-list->html title-json)] + (util/raw-html html))) + +(rum/defc children-cp + [children] + (let [children-json (js/JSON.stringify (clj->js children)) + html (org/json->html children-json)] + (util/raw-html html))) + +(rum/defc marker-cp + [marker] + (if marker + [:span {:class (str "marker-" (string/lower-case marker)) + :style {:margin-left 8}} + (if (contains? #{"DOING" "IN-PROGRESS"} marker) + (str " (" marker ")"))])) + +(rum/defc tags-cp + [tags] + [:span + (for [{:keys [tag/name]} tags] + [:span.tag {:key name} + [:span + name]])]) + +(rum/defc agenda + [] + (let [tasks (db/get-agenda)] + (sidebar/sidebar + [:div#agenda + [:h2.mb-3 "Agenda"] + (if (seq tasks) + [:div.ml-1 + (let [tasks (block/sort-tasks tasks)] + (for [{:heading/keys [uuid marker title priority level tags children timestamps meta repo file] :as task} tasks] + [:div.mb-2 + {:key (str "task-" uuid) + :style {:padding-left 8 + :padding-right 8}} + [:div.column + [:div.row {:style {:align-items "center"}} + (case marker + (list "DOING" "IN-PROGRESS" "TODO") + (ui/checkbox {:on-change (fn [_] + ;; FIXME: Log timestamp + (handler/check task))}) + + "WAIT" + [:span {:style {:font-weight "bold"}} + "WAIT"] + + "DONE" + (ui/checkbox {:checked true + :on-change (fn [_] + ;; FIXME: Log timestamp + (handler/uncheck task) + )}) + + nil) + [:div.row.ml-2 + (if priority + [:span.priority.mr-1 + (str "#[" priority "]")]) + (title-cp title) + (marker-cp marker) + (when (seq tags) + (tags-cp tags))]] + (when (seq timestamps) + (timestamps-cp timestamps)) + + ;; FIXME: parse error + ;; (when (seq children) + ;; (children-cp children)) + + ]] + ))] + "Empty")]))) diff --git a/web/src/main/frontend/components/content.cljs b/web/src/main/frontend/components/content.cljs new file mode 100644 index 00000000000..f928e878f97 --- /dev/null +++ b/web/src/main/frontend/components/content.cljs @@ -0,0 +1,27 @@ +(ns frontend.components.content + (:require [rum.core :as rum] + [frontend.format :as format] + [frontend.format.org-mode :as org] + [frontend.handler :as handler] + [frontend.util :as util])) + +(defn- highlight! + [] + (doseq [block (-> (js/document.querySelectorAll "pre code") + (array-seq))] + (js/hljs.highlightBlock block))) + +(rum/defc html < + {:did-mount (fn [state] + (highlight!) + (handler/render-local-images!) + state) + :did-update (fn [state] + (highlight!) + state)} + [content format config] + (case format + (list :png :jpg :jpeg) + content + (util/raw-html (format/to-html content format + config)))) diff --git a/web/src/main/frontend/components/file.cljs b/web/src/main/frontend/components/file.cljs new file mode 100644 index 00000000000..93c07ee92df --- /dev/null +++ b/web/src/main/frontend/components/file.cljs @@ -0,0 +1,86 @@ +(ns frontend.components.file + (:require [rum.core :as rum] + [frontend.util :as util] + [frontend.handler :as handler] + [clojure.string :as string] + [frontend.db :as db] + [frontend.components.sidebar :as sidebar] + [frontend.ui :as ui] + [frontend.format :as format] + [frontend.format.org-mode :as org] + [frontend.components.content :as content] + [goog.crypt.base64 :as b64])) + +(defn- get-path + [state] + (let [route-match (first (:rum/args state)) + encoded-path (get-in route-match [:parameters :path :path]) + decoded-path (b64/decodeString encoded-path)] + [encoded-path decoded-path])) + +(rum/defcs file < + [state] + (let [[encoded-path path] (get-path state) + suffix (keyword (string/lower-case (last (string/split path #"\."))))] + (sidebar/sidebar + (cond + (and suffix (contains? #{:md :markdown :org} suffix)) + [:div.content + [:a {:href (str "/file/" encoded-path "/edit")} + "edit"] + (let [content (db/get-file (last (get-path state)))] + (cond + (string/blank? content) + [:span] + + content + (content/html content suffix org/default-config) + + :else + "Loading ..."))] + + ;; image type + (and suffix (contains? #{:png :jpg :jpeg} suffix)) + (content/html [:img {:src path}] suffix org/default-config) + + :else + [:div "Format ." (name suffix) " is not supported."])))) + +(defn- count-newlines + [s] + (count (re-seq #"\n" (or s "")))) + +(rum/defcs edit < + (rum/local nil ::content) + (rum/local "" ::commit-message) + {:will-mount (fn [state] + (assoc state ::initial-content (db/get-file (last (get-path state)))))} + [state] + (let [initial-content (get state ::initial-content) + initial-rows (+ 3 (count-newlines initial-content)) + content (get state ::content) + commit-message (get state ::commit-message) + rows (if (nil? @content) initial-rows (+ 3 (count-newlines @content))) + [_encoded-path path] (get-path state)] + (prn {:rows rows}) + (sidebar/sidebar + [:div.content + [:h3.mb-2 (str "Update " path)] + [:textarea + {:rows rows + :default-value initial-content + :on-change #(reset! content (.. % -target -value)) + :auto-focus true}] + [:div.mt-1.mb-1.relative.rounded-md.shadow-sm + [:input.form-input.block.w-full.sm:text-sm.sm:leading-5 + {:placeholder "Commit message" + :on-change (fn [e] + (reset! commit-message (util/evalue e)))}]] + (ui/button "Save" (fn [] + (when (and (not (string/blank? @content)) + (not (= initial-content + @content))) + (let [commit-message (if (string/blank? @commit-message) + (str "Update " path) + @commit-message)] + (handler/alter-file path commit-message @content)))))]))) diff --git a/web/src/main/frontend/components/home.cljs b/web/src/main/frontend/components/home.cljs new file mode 100644 index 00000000000..08b92be4da0 --- /dev/null +++ b/web/src/main/frontend/components/home.cljs @@ -0,0 +1,12 @@ +(ns frontend.components.home + (:require [frontend.state :as state] + [frontend.handler :as handler] + [rum.core :as rum] + [frontend.components.sidebar :as sidebar])) + +(rum/defc home < rum/reactive + ;; {:will-mount (fn [state] + ;; (handler/get-me) + ;; state)} + [] + (sidebar/sidebar (sidebar/main-content))) diff --git a/web/src/main/frontend/components/journal.cljs b/web/src/main/frontend/components/journal.cljs new file mode 100644 index 00000000000..4c4d8abf1d1 --- /dev/null +++ b/web/src/main/frontend/components/journal.cljs @@ -0,0 +1,101 @@ +(ns frontend.components.journal + (:require [rum.core :as rum] + [frontend.util :as util] + [frontend.handler :as handler] + [clojure.string :as string] + [frontend.ui :as ui] + [frontend.format :as format] + [frontend.mixins :as mixins] + [frontend.db :as db] + [frontend.state :as state] + [frontend.format.org-mode :as org] + [goog.object :as gobj] + [frontend.image :as image] + [frontend.components.content :as content])) + +(def edit-content (atom "")) +(rum/defc editor-box < + (mixins/event-mixin + (fn [state] + (let [heading (first (:rum/args state))] + (mixins/hide-when-esc-or-outside + state + nil + :show-fn (fn [] + (:edit? @state/state)) + :on-hide (fn [] + (handler/save-current-edit-journal! (str heading "\n" (string/trimr @edit-content) "\n\n"))))))) + [heading content] + [:div.flex-1 + (ui/textarea-autosize + {:on-change (fn [e] + (reset! edit-content (util/evalue e))) + :default-value content + :auto-focus true + :style {:border "none" + :border-radius 0 + :background "transparent" + :margin-top 12.5}}) + [:input + {:id "files" + :type "file" + :on-change (fn [e] + (let [files (.-files (.-target e))] + (image/upload + files + (fn [file file-form-data file-name file-type] + ;; TODO: set uploading + (.append file-form-data "name" file-name) + (.append file-form-data file-type true) + + ;; (citrus/dispatch! + ;; :image/upload + ;; file-form-data + ;; (fn [url] + ;; (reset! uploading? false) + ;; (swap! form assoc name url) + ;; (if on-uploaded + ;; (on-uploaded form name url)))) + )))) + ;; :hidden true + }]]) + +(defn split-first [re s] + (clojure.string/split s re 2)) + +(defn- split-heading-body + [content] + (let [result (split-first #"\n" content)] + (if (= 1 (count result)) + [result ""] + result))) + +(rum/defc journal-cp < rum/reactive + [{:keys [uuid title content] :as journal}] + (let [{:keys [edit? edit-journal]} (rum/react state/state) + [heading content] (split-heading-body content)] + [:div.flex-1 + [:h1.text-gray-600 {:style {:font-weight "450"}} + title] + + (if (and edit? (= uuid (:uuid edit-journal))) + (editor-box heading content) + [:div {:on-click (fn [] + (handler/edit-journal! content journal) + (reset! edit-content content)) + :style {:padding 8 + :min-height 200}} + (if (or (not content) + (string/blank? content)) + [:div] + (content/html content "org" org/config-with-line-break))])])) + +(rum/defc journals + [latest-journals] + [:div#journals + (ui/infinite-list + (for [journal latest-journals] + [:div.journal.content {:key (cljs.core/random-uuid)} + (journal-cp journal)]) + {:on-load (fn [] + (handler/load-more-journals!))})]) diff --git a/web/src/main/frontend/components/repo.cljs b/web/src/main/frontend/components/repo.cljs new file mode 100644 index 00000000000..38f91d3a4a2 --- /dev/null +++ b/web/src/main/frontend/components/repo.cljs @@ -0,0 +1,42 @@ +(ns frontend.components.repo + (:require [rum.core :as rum] + [frontend.util :as util] + [frontend.handler :as handler] + [clojure.string :as string] + [frontend.ui :as ui])) + +(defn repos + [repos] + (when (seq repos) + [:div#repos + [:ul + (for [url repos] + [:li {:key url} + [:button {:on-click (fn [] + ;; (handler/set-current-repo url) + )} + (string/replace url "https://github.com/" "")]])]])) + +(rum/defcs add-repo < (rum/local "https://github.com/" ::repo-url) + [state] + (let [prefix "https://github.com/" + repo-url (get state ::repo-url)] + [:div.p-8.flex.items-center.justify-center.bg-white + [:div.w-full.max-w-xs.mx-auto + [:div + [:div + [:h2 "Specify your repo:"] + [:div.mt-2.mb-2.relative.rounded-md.shadow-sm + [:div.absolute.inset-y-0.left-0.pl-3.flex.items-center.pointer-events-none + [:span.text-gray-500.sm:text-sm.sm:leading-5 + prefix]] + [:input#repo.form-input.block.w-full.pl-16.sm:pl-14.sm:text-sm.sm:leading-5 + {:autoFocus true + :placeholder "username/repo" + :on-change (fn [e] + (reset! repo-url (util/evalue e))) + :style {:padding-left "9.1em"}}]]]] + (ui/button + "Clone" + (fn [] + (handler/clone-and-pull (str prefix @repo-url))))]])) diff --git a/web/src/main/frontend/components/settings.cljs b/web/src/main/frontend/components/settings.cljs new file mode 100644 index 00000000000..dd2976e5713 --- /dev/null +++ b/web/src/main/frontend/components/settings.cljs @@ -0,0 +1,51 @@ +(ns frontend.components.settings + ;; (:require [rum.core :as rum] + ;; [frontend.mui :as mui] + ;; [frontend.util :as util] + ;; [frontend.state :as state] + ;; [frontend.handler :as handler] + ;; [clojure.string :as string]) + ) + +;; (defn settings-form +;; [github-token github-repo] +;; [:form {:style {:min-width 300}} +;; (mui/grid +;; {:container true +;; :direction "column"} +;; (mui/text-field {:id "standard-basic" +;; :style {:margin-bottom 12} +;; :label "Github repo" +;; :on-change (fn [event] +;; (let [v (util/evalue event)] +;; (swap! state/state assoc :github-repo v))) +;; :value github-repo +;; }) +;; (mui/button {:variant "contained" +;; :color "primary" +;; :on-click (fn [] +;; (when (and github-token github-repo) +;; (handler/clone github-token github-repo)))} +;; "Sync"))]) + +;; (rum/defc settings < rum/reactive +;; [] +;; ;; Change repo and basic token +;; (let [state (rum/react state/state) +;; {:keys [github-token github-repo]} state] +;; (mui/container +;; {:id "root-container" +;; :style {:display "flex" +;; :justify-content "center" +;; :margin-top 64}} + +;; [:div + +;; (settings-form github-token github-repo) + +;; (mui/divider {:style {:margin "24px 0"}}) + +;; ;; clear storage +;; (mui/button {:on-click handler/clear-storage +;; :color "primary"} +;; "Clear storage and clone")]))) diff --git a/web/src/main/frontend/components/sidebar.cljs b/web/src/main/frontend/components/sidebar.cljs new file mode 100644 index 00000000000..df413511b09 --- /dev/null +++ b/web/src/main/frontend/components/sidebar.cljs @@ -0,0 +1,174 @@ +(ns frontend.components.sidebar + (:require [rum.core :as rum] + [frontend.ui :as ui] + [frontend.mixins :as mixins] + [frontend.db :as db] + [frontend.components.repo :as repo] + [frontend.components.journal :as journal] + [goog.crypt.base64 :as b64] + [frontend.util :as util] + [frontend.state :as state] + [frontend.handler :as handler])) + +(defonce active-button :a.group.flex.items-center.px-2.py-2.text-base.leading-6.font-medium.rounded-md.text-white.bg-gray-900.focus:outline-none.focus:bg-gray-700.transition.ease-in-out.duration-150) +(defonce inactive-button :a.mt-1.group.flex.items-center.px-2.py-2.text-base.leading-6.font-medium.rounded-md.text-gray-300.hover:text-white.hover:bg-gray-700.focus:outline-none.focus:text-white.focus:bg-gray-700.transition.ease-in-out.duration-150) + +(defn nav-item + ([title href svg-d] + (nav-item title href svg-d false)) + ([title href svg-d active?] + (let [a (if active? active-button inactive-button)] + [a {:href href} + [:svg.mr-4.h-6.w-6.text-gray-400.group-hover:text-gray-300.group-focus:text-gray-300.transition.ease-in-out.duration-150 + {:viewBox "0 0 24 24", :fill "none", :stroke "currentColor"} + [:path + {:d svg-d + :stroke-width "2", + :stroke-linejoin "round", + :stroke-linecap "round"}]] + title]))) + +(rum/defc files-list + [file-active?] + (let [files (db/get-files)] + [:div.cursor-pointer.my-1.flex.flex-col.ml-2 + (if (seq files) + (for [file files] + (let [encoded-path (b64/encodeString file)] + [:a {:key file + :class (util/hiccup->class "mt-1.group.flex.items-center.px-2.py-1.text-base.leading-6.font-medium.rounded-md.text-gray-500.hover:text-white.hover:bg-gray-700.focus:outline-none.focus:text-white.focus:bg-gray-700.transition.ease-in-out.duration-150") + :style {:color (if (file-active? encoded-path) "#FFF")} + :href (str "/file/" encoded-path)} + file])))])) + +(rum/defc sidebar-nav < rum/reactive + [] + (let [{:keys [:route-match]} (rum/react state/state) + active? (fn [route] (= route (get-in route-match [:data :name]))) + file-active? (fn [path] + (= path (get-in route-match [:parameters :path :path])))] + [:nav.flex-1.px-2.py-4.bg-gray-800 + (nav-item "Journals" "/" + "M3 12l9-9 9 9M5 10v10a1 1 0 001 1h3a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1h3a1 1 0 001-1V10M9 21h6" + (active? :home)) + (nav-item "Agenda" "/agenda" + "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" + (active? :agenda)) + (files-list file-active?)])) + +(rum/defc main-content < rum/reactive + {:will-mount (fn [state] + (handler/set-latest-journals!) + state)} + [] + (let [{:repo/keys [cloning? loading-files?] + :keys [latest-journals]} (rum/react state/state)] + [:div.max-w-7xl.mx-auto.px-4.sm:px-6.md:px-8 + (cond + ;; importing-to-db? + ;; [:div "Parsing files ..."] + + (seq latest-journals) + (journal/journals latest-journals) + + loading-files? + [:div "Loading files ..."] + + cloning? + [:div "Cloning ..."] + + :else + (repo/add-repo))])) + +(rum/defcs sidebar < (mixins/modal) + [state main-content] + (let [{:keys [open? close-fn open-fn]} state] + [:div.h-screen.flex.overflow-hidden.bg-gray-100 + [:div.md:hidden + [:div.fixed.inset-0.z-30.bg-gray-600.opacity-0.pointer-events-none.transition-opacity.ease-linear.duration-300 + {:class (if @open? + "opacity-75 pointer-events-auto" + "opacity-0 pointer-events-none") + :on-click close-fn}] + [:div.fixed.inset-y-0.left-0.flex.flex-col.z-40.max-w-xs.w-full.bg-gray-800.transform.ease-in-out.duration-300 + {:class (if @open? + "translate-x-0" + "-translate-x-full")} + (if @open? + [:div.absolute.top-0.right-0.-mr-14.p-1 + [:button.flex.items-center.justify-center.h-12.w-12.rounded-full.focus:outline-none.focus:bg-gray-600 + {:on-click close-fn} + [:svg.h-6.w-6.text-white + {:viewBox "0 0 24 24", :fill "none", :stroke "currentColor"} + [:path + {:d "M6 18L18 6M6 6l12 12", + :stroke-width "2", + :stroke-linejoin "round", + :stroke-linecap "round"}]]]]) + [:div.flex-shrink-0.flex.items-center.h-16.px-4.bg-gray-900 + [:img.h-8.w-auto + {:alt "Logseq", + :src "/static/img/logo.png"}]] + [:div.flex-1.h-0.overflow-y-auto + (sidebar-nav)] + ]] + [:div.hidden.md:flex.md:flex-shrink-0 + [:div.flex.flex-col.w-64 + [:div.flex.items-center.h-16.flex-shrink-0.px-4.bg-gray-900 + [:img.h-8.w-auto + {:alt "Logseq", + :src "/static/img/logo.png"}]] + [:div.h-0.flex-1.flex.flex-col.overflow-y-auto + (sidebar-nav)]]] + [:div.flex.flex-col.w-0.flex-1.overflow-hidden + [:div.relative.z-10.flex-shrink-0.flex.h-16.bg-white.shadow + [:button.px-4.border-r.border-gray-200.text-gray-500.focus:outline-none.focus:bg-gray-100.focus:text-gray-600.md:hidden + {:on-click open-fn} + [:svg.h-6.w-6 + {:viewBox "0 0 24 24", :fill "none", :stroke "currentColor"} + [:path + {:d "M4 6h16M4 12h16M4 18h7", + :stroke-width "2", + :stroke-linejoin "round", + :stroke-linecap "round"}]]] + [:div.flex-1.px-4.flex.justify-between + [:div.flex-1.flex + [:div.w-full.flex.md:ml-0 + [:label.sr-only {:for "search_field"} "Search"] + [:div.relative.w-full.text-gray-400.focus-within:text-gray-600 + [:div.absolute.inset-y-0.left-0.flex.items-center.pointer-events-none + [:svg.h-5.w-5 + {:viewBox "0 0 20 20", :fill "currentColor"} + [:path + {:d + "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + :clip-rule "evenodd", + :fill-rule "evenodd"}]]] + [:input#search_field.block.w-full.h-full.pl-8.pr-3.py-2.rounded-md.text-gray-900.placeholder-gray-500.focus:outline-none.focus:placeholder-gray-400.sm:text-sm + {:placeholder "Search"}]]]] + [:div.ml-4.flex.items-center.md:ml-6 + [:button.p-1.text-gray-400.rounded-full.hover:bg-gray-100.hover:text-gray-500.focus:outline-none.focus:shadow-outline.focus:text-gray-500 + [:svg.h-6.w-6 + {:viewBox "0 0 24 24", :fill "none", :stroke "currentColor"} + [:path + {:d + "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9", + :stroke-width "2", + :stroke-linejoin "round", + :stroke-linecap "round"}]]] + (ui/dropdown-with-links + [{:title "Your Profile" + :options {:href "#"}} + {:title "Settings" + :options {:href "#"}} + {:title "Sign out" + :options {:href "#"}}])]]] + [:main.flex-1.relative.z-0.overflow-y-auto.py-6.focus:outline-none + ;; {:x-init "$el.focus()", :x-data "x-data", :tabindex "0"} + {:tabIndex "0"} + [:div.flex.justify-center + [:div.flex-1.m-6 {:style {:position "relative" + :max-width 800}} + main-content]]] + + (ui/notification)]])) diff --git a/web/src/main/frontend/config.cljs b/web/src/main/frontend/config.cljs new file mode 100644 index 00000000000..60a0c3ac6f4 --- /dev/null +++ b/web/src/main/frontend/config.cljs @@ -0,0 +1,14 @@ +(ns frontend.config) + +(defonce tasks-org "tasks.org") +(defonce hidden-file ".hidden") +(defonce dev? ^boolean goog.DEBUG) +(def website + (if dev? + "http://localhost:3000" + "https://logseq.com")) + +(def api + (if dev? + "http://localhost:3000/api/v1/" + (str website "/api/v1/"))) diff --git a/web/src/main/frontend/core.cljs b/web/src/main/frontend/core.cljs new file mode 100644 index 00000000000..99d303c472d --- /dev/null +++ b/web/src/main/frontend/core.cljs @@ -0,0 +1,40 @@ +(ns frontend.core + (:require [rum.core :as rum] + [frontend.handler :as handler] + [frontend.page :as page] + [frontend.routes :as routes] + [reitit.frontend :as rf] + [reitit.frontend.easy :as rfe])) + +(defn set-router! + [] + (rfe/start! + (rf/router routes/routes {}) + handler/set-route-match! + ;; set to false to enable HistoryAPI + {:use-fragment false})) + +(defn start [] + (rum/mount + (page/current-page) + (.getElementById js/document "root")) + (set-router!)) + +(defn ^:export init [] + ;; init is called ONCE when the page loads + ;; this is called in the index.html and must be exported + ;; so it is available even in :advanced release builds + + (handler/start!) + + ;; popup to notify user, could be toggled in settings + ;; (handler/request-notifications-if-not-asked) + + ;; (handler/run-notify-worker!) + + (start)) + +(defn stop [] + ;; stop is called before any code is reloaded + ;; this is controlled by :before-load in the config + (js/console.log "stop")) diff --git a/web/src/main/frontend/db.cljs b/web/src/main/frontend/db.cljs new file mode 100644 index 00000000000..3e6521ed713 --- /dev/null +++ b/web/src/main/frontend/db.cljs @@ -0,0 +1,473 @@ +(ns frontend.db + (:require [datascript.core :as d] + [frontend.util :as util] + [medley.core :as medley] + [datascript.transit :as dt] + [frontend.format.org-mode :as org] + [frontend.format.org.block :as block] + [clojure.string :as string] + [frontend.utf8 :as utf8])) + +(def datascript-db "logseq/DB") +(def schema + {:db/ident {:db/unique :db.unique/identity} + ;; repo + :repo/url {:db/unique :db.unique/identity} + :repo/cloning? {} + :repo/cloned? {} + :repo/current {:db/valueType :db.type/ref} + + ;; file + :file/path {:db/unique :db.unique/identity} + :file/repo {:db/valueType :db.type/ref} + :file/raw {} + :file/html {} + ;; TODO: calculate memory/disk usage + ;; :file/size {} + + ;; heading + :heading/uuid {:db/unique :db.unique/identity} + :heading/repo {:db/valueType :db.type/ref} + :heading/file {:db/valueType :db.type/ref} + :heading/anchor {} + :heading/marker {} + :heading/priority {} + :heading/level {} + :heading/tags {:db/valueType :db.type/ref + :db/cardinality :db.cardinality/many + :db/isComponent true} + + ;; tag + :tag/name {:db/unique :db.unique/identity} + + ;; task + :task/scheduled {:db/index true} + :task/deadline {:db/index true} + }) + +(defonce conn + (d/create-conn schema)) + +;; transit serialization + +(defn db->string [db] + (dt/write-transit-str db)) + +(defn string->db [s] + (dt/read-transit-str s)) + +;; persisting DB between page reloads +(defn persist [db] + (js/localStorage.setItem datascript-db (db->string db))) + +(defn reset-conn! [db] + (reset! conn db)) + +;; (new TextEncoder().encode('foo')).length +(defn db-size + [] + (when-let [store (js/localStorage.getItem datascript-db)] + (let [bytes (.-length (.encode (js/TextEncoder.) store))] + (/ bytes 1000)))) + +(defn restore! [] + (when-let [stored (js/localStorage.getItem datascript-db)] + (let [stored-db (string->db stored)] + (when (= (:schema stored-db) schema) ;; check for code update + (reset-conn! stored-db))))) + +;; TODO: added_at, started_at, schedule, deadline +(def qualified-map + {:file :heading/file + :anchor :heading/anchor + :title :heading/title + :marker :heading/marker + :priority :heading/priority + :level :heading/level + :timestamps :heading/timestamps + :children :heading/children + :tags :heading/tags + :meta :heading/meta + }) + +;; (def schema +;; [{:db/ident {:db/unique :db.unique/identity}} + +;; ;; {:db/ident :heading/title +;; ;; :db/valueType :db.type/string +;; ;; :db/cardinality :db.cardinality/one} + +;; ;; {:db/ident :heading/parent-title +;; ;; :db/valueType :db.type/string +;; ;; :db/cardinality :db.cardinality/one} + +;; ;; TODO: timestamps, meta +;; ;; scheduled, deadline +;; ]) + +(defn ->tags + [tags] + (map (fn [tag] + {:db/id tag + :tag/name tag}) + tags)) + +(defn extract-timestamps + [{:keys [meta] :as heading}] + (let [{:keys [pos timestamps]} meta] + )) + +(defn- safe-headings + [headings] + (mapv (fn [heading] + (let [heading (-> (util/remove-nils heading) + (assoc :heading/uuid (d/squuid))) + heading (assoc heading :tags + (->tags (:tags heading)))] + (medley/map-keys + (fn [k] (get qualified-map k k)) + heading))) + headings)) + +;; queries + +(defn- distinct-result + [query-result] + (-> query-result + seq + flatten + distinct)) + +(def seq-flatten (comp flatten seq)) + +(defn get-all-tags + [] + (distinct-result + (d/q '[:find ?tags + :where + [?h :heading/tags ?tags]] + @conn))) + +(defn get-repo-headings + [repo-url] + (-> (d/q '[:find ?heading + :in $ ?repo-url + :where + [?repo :repo/url ?repo-url] + [?heading :heading/repo ?repo]] + @conn repo-url) + seq-flatten)) + +(defn delete-headings! + [repo-url] + (let [headings (get-repo-headings repo-url) + headings (mapv (fn [eid] [:db.fn/retractEntity eid]) headings)] + (d/transact! conn headings))) + +(defn get-file-headings + [repo-url path] + (-> (d/q '[:find ?heading + :in $ ?repo-url ?path + :where + [?repo :repo/url ?repo-url] + [?file :file/path ?path] + [?heading :heading/file ?file] + [?heading :heading/repo ?repo]] + @conn repo-url path) + seq-flatten)) + +(defn delete-file-headings! + [repo-url path] + (let [headings (get-file-headings repo-url path)] + (mapv (fn [eid] [:db.fn/retractEntity eid]) headings))) + +;; transactions +(defn reset-headings! + [repo-url headings] + (delete-headings! repo-url) + (let [headings (safe-headings headings)] + (d/transact! conn headings))) + +(defn get-all-headings + [] + (seq-flatten + (d/q '[:find (pull ?h [*]) + :where + [?h :heading/title]] + @conn))) + +(defn search-headings-by-title + [title]) + +(defn get-headings-by-tag + [tag] + (let [pred (fn [db tags] + (some #(= tag %) tags))] + (d/q '[:find (flatten (pull ?h [*])) + :in $ ?pred + :where + [?h :heading/tags ?tags] + [(?pred $ ?tags)]] + @conn pred))) + +(defn transact! + [tx-data] + (d/transact! conn tx-data)) + +(defn set-key-value + [key value] + (transact! [{:db/id -1 + :db/ident key + key value}])) + +(defn get-key-value + [key] + (some-> (d/entity (d/db conn) key) + key)) + +(defn set-current-repo! + [repo] + (set-key-value :repo/current [:repo/url repo])) + +(defn get-current-repo + [] + (:repo/url (get-key-value :repo/current))) + +(defn get-repos + [] + (->> (d/q '[:find ?url + :where [_ :repo/url ?url]] + @conn) + (map first) + distinct)) + +(defn get-files + [] + (->> (d/q '[:find ?path + :where + [_ :repo/current ?repo] + [?file :file/repo ?repo] + [?file :file/path ?path]] + @conn) + (map first) + distinct)) + +(defn mark-repo-as-cloned + [repo-url] + (d/transact! conn + [{:repo/url repo-url + :repo/cloned? true}])) + +;; file +(defn transact-files! + [repo-url files] + (d/transact! conn + (for [file files] + {:file/repo [:repo/url repo-url] + :file/path file}))) + +(defn get-repo-files + [repo-url] + (->> (d/q '[:find ?path + :in $ ?repo-url + :where + [?repo :repo/url ?repo-url] + [?file :file/repo ?repo] + [?file :file/path ?path]] + @conn repo-url) + (map first) + distinct)) + +(defn set-file-content! + [repo-url file content] + (d/transact! conn + [{:file/repo [:repo/url repo-url] + :file/path file + :file/content content}])) + +(defn extract-headings + [repo-url file content] + (if (string/blank? content) + [] + (let [headings (org/->clj content) + headings (block/extract-headings headings)] + (map (fn [heading] + (assoc heading + :heading/repo [:repo/url repo-url] + :heading/file [:file/path file])) + headings)))) + +(defn get-all-files-content + [repo-url] + (d/q '[:find ?path ?content + :in $ ?repo-url + :where + [?repo :repo/url ?repo-url] + [?file :file/repo ?repo] + [?file :file/content ?content] + [?file :file/path ?path]] + @conn repo-url)) + +(defn extract-all-headings + [repo-url] + (let [contents (get-all-files-content repo-url)] + (vec + (mapcat + (fn [[file content] contents] + (extract-headings repo-url file content)) + contents)))) + +(defn reset-file! + [repo-url file content] + (let [file-content [{:file/repo [:repo/url repo-url] + :file/path file + :file/content content}] + delete-headings (delete-file-headings! repo-url file) + headings (extract-headings repo-url file content) + headings (safe-headings headings)] + (d/transact! conn (concat file-content delete-headings headings)))) + +(defn get-file-content + [repo-url path] + (->> (d/q '[:find ?content + :in $ ?repo-url ?path + :where + [?repo :repo/url ?repo-url] + [?file :file/repo ?repo] + [?file :file/path ?path] + [?file :file/content ?content]] + @conn repo-url path) + (map first) + first)) + +(defn get-file + [path] + (-> + (d/q '[:find ?content + :in $ ?path + :where + [_ :repo/current ?repo] + [?file :file/repo ?repo] + [?file :file/path ?path] + [?file :file/content ?content]] + @conn + path) + ffirst)) + +;; marker should be one of: TODO, DOING, IN-PROGRESS +;; time duration +(defn get-agenda + ([] + (get-agenda :week)) + ([time] + (let [duration (case time + :today [] + :week [] + :month [])] + (-> + (d/q '[:find (pull ?h [*]) + :where + (or [?h :heading/marker "TODO"] + [?h :heading/marker "DOING"] + [?h :heading/marker "IN-PROGRESS"] + [?h :heading/marker "DONE"])] + @conn) + seq-flatten)))) + +(defn entity + [id-or-lookup-ref] + (d/entity (d/db conn) id-or-lookup-ref)) + +(defn get-current-journal + [] + (get-file (util/current-journal-path))) + +(defn valid-journal-title? + [title] + (and title + (not (js/isNaN (js/Date.parse title))))) + +(defn get-month-journals + [journal-path content before-date days] + (let [[month day year] (string/split before-date #"/") + day' (util/zero-pad (inc (util/parse-int day))) + before-date (string/join "/" [month day' year]) + content-arr (utf8/encode content) + end-pos (utf8/length content-arr) + blocks (reverse (org/->clj content)) + headings (some->> + blocks + (filter (fn [block] + (and + (block/heading-block? block) + + (= 1 (:level (second block))) + + (let [[_ {:keys [title meta]}] block] + (when-let [title (last (first title))] + (and + (valid-journal-title? title) + (let [date (last (string/split title #", "))] + (<= (compare date before-date) 0)))))))) + (map (fn [[_ {:keys [title meta]}]] + {:title (last (first title)) + :file-path journal-path + :start-pos (:pos meta)})) + (take (inc days))) + [_ journals] (reduce (fn [[last-end-pos acc] heading] + (let [end-pos last-end-pos + acc (conj acc (assoc heading + :uuid (cljs.core/random-uuid) + :end-pos end-pos + :content (utf8/substring content-arr + (:start-pos heading) + end-pos)))] + [(:start-pos heading) acc])) [end-pos []] headings)] + (if (> (count journals) days) + (drop 1 journals) + journals))) + +(defn- compute-journal-path + [before-date] + (let [[month day year] (->> (string/split before-date #"/") + (mapv util/parse-int)) + [year month] (cond + (and (= month 1) + (= day 1)) + [(dec year) 12] + + (= day 1) + [year (dec month)] + + :else + [year month])] + (util/journals-path year month))) + +;; before-date should be a string joined with "/", like "month/day/year" +(defn get-latest-journals + ([] + (get-latest-journals {})) + ([{:keys [content before-date days] + :or {days 3}}] + (let [before-date (if before-date + before-date + (let [{:keys [year month day]} (util/year-month-day-padded)] + (string/join "/" [month day year]))) + + journal-path (compute-journal-path before-date)] + (when-let [content (or content (get-file journal-path))] + (get-month-journals journal-path content before-date days))))) + +(comment + (d/transact! conn [{:db/id -1 + :repo/url "https://github.com/tiensonqin/notes" + :repo/cloned? false}]) + (d/entity (d/db conn) [:repo/url "https://github.com/tiensonqin/notes"]) + (d/transact! conn + (safe-headings [{:heading/repo [:repo/url "https://github.com/tiensonqin/notes"] + :heading/file "test.org" + :heading/anchor "hello" + :heading/marker "TODO" + :heading/priority "A" + :heading/level "10" + :heading/title "hello world"}]))) diff --git a/web/src/main/frontend/exif.js b/web/src/main/frontend/exif.js new file mode 100644 index 00000000000..57a133824c6 --- /dev/null +++ b/web/src/main/frontend/exif.js @@ -0,0 +1,54 @@ +// copied from https://stackoverflow.com/questions/7584794/accessing-jpeg-exif-rotation-data-in-javascript-on-the-client-side + +function objectURLToBlob(url, callback) { + var http = new XMLHttpRequest(); + http.open("GET", url, true); + http.responseType = "blob"; + http.onload = function(e) { + if (this.status == 200 || this.status === 0) { + callback(this.response); + } + }; + http.send(); +} + +export var getEXIFOrientation = function (img, callback) { + var reader = new FileReader(); + reader.onload = e => { + var view = new DataView(e.target.result) + + if (view.getUint16(0, false) !== 0xFFD8) { + return callback(-2) + } + var length = view.byteLength + var offset = 2 + while (offset < length) { + var marker = view.getUint16(offset, false) + offset += 2 + if (marker === 0xFFE1) { + if (view.getUint32(offset += 2, false) !== 0x45786966) { + return callback(-1) + } + var little = view.getUint16(offset += 6, false) === 0x4949 + offset += view.getUint32(offset + 4, little) + var tags = view.getUint16(offset, little) + offset += 2 + for (var i = 0; i < tags; i++) { + if (view.getUint16(offset + (i * 12), little) === 0x0112) { + var o = view.getUint16(offset + (i * 12) + 8, little); + return callback(o) + } + } + } else if ((marker & 0xFF00) !== 0xFF00) { + break + } else { + offset += view.getUint16(offset, false) + } + } + return callback(-1) + }; + + objectURLToBlob(img.src, function (blob) { + reader.readAsArrayBuffer(blob.slice(0, 65536)); + }); +} diff --git a/web/src/main/frontend/format.cljs b/web/src/main/frontend/format.cljs new file mode 100644 index 00000000000..bfc5d75595a --- /dev/null +++ b/web/src/main/frontend/format.cljs @@ -0,0 +1,18 @@ +(ns frontend.format + (:require [frontend.format.org-mode :as org :refer [->OrgMode]] + [frontend.format.markdown :as markdown :refer [->Markdown]] + [frontend.format.protocol :as protocol])) + +(defn to-html + ([content suffix] + (to-html content suffix nil)) + ([content suffix config] + (when-let [record (case (keyword suffix) + :org + (->OrgMode content) + (list :md :markdown) + (->Markdown content) + nil)] + (if config + (protocol/toHtml record config) + (protocol/toHtml record))))) diff --git a/web/src/main/frontend/format/markdown.cljs b/web/src/main/frontend/format/markdown.cljs new file mode 100644 index 00000000000..40a2521b0b6 --- /dev/null +++ b/web/src/main/frontend/format/markdown.cljs @@ -0,0 +1,13 @@ +(ns frontend.format.markdown + (:require ["showdown" :refer [Converter]] + [frontend.format.protocol :as protocol])) + +(defonce converter (Converter.)) + +(defrecord Markdown [content] + protocol/Format + (toHtml [this] + (.makeHtml converter content)) + (toHtml [this config] + ;; TODO: + (.makeHtml converter content))) diff --git a/web/src/main/frontend/format/org/block.cljs b/web/src/main/frontend/format/org/block.cljs new file mode 100644 index 00000000000..bdfdf5f7b8a --- /dev/null +++ b/web/src/main/frontend/format/org/block.cljs @@ -0,0 +1,99 @@ +(ns frontend.format.org.block + (:require [frontend.util :as util])) + +(defn heading-block? + [block] + (and + (vector? block) + (= "Heading" (first block)))) + +(defn task-block? + [block] + (and + (heading-block? block) + (some? (:marker (second block))))) + +;; FIXME: +(defn extract-title + [block] + (-> (:title (second block)) + first + second)) + +(defn- paragraph-block? + [block] + (and + (vector? block) + (= "Paragraph" (first block)))) + +(defn- timestamp-block? + [block] + (and + (vector? block) + (= "Timestamp" (first block)))) + +(defn- paragraph-timestamp-block? + [block] + (and (paragraph-block? block) + (timestamp-block? (first (second block))))) + +(defn extract-timestamp + [block] + (-> block + second + first + second)) + +(defn extract-headings + [blocks] + (loop [headings [] + heading-children [] + blocks (reverse blocks) + timestamps {} + last-pos nil] + (if (seq blocks) + (let [block (first blocks) + level (:level (second block))] + (cond + (paragraph-timestamp-block? block) + (let [timestamp (extract-timestamp block) + timestamps' (conj timestamps timestamp)] + (recur headings heading-children (rest blocks) timestamps' last-pos)) + + (heading-block? block) + (let [heading (-> (assoc (second block) + :children (reverse heading-children) + :timestamps timestamps) + (assoc-in [:meta :end-pos] last-pos)) + last-pos' (get-in heading [:meta :pos])] + (recur (conj headings heading) [] (rest blocks) {} last-pos')) + + :else + (let [heading-children' (conj heading-children block)] + (recur headings heading-children' (rest blocks) timestamps last-pos)))) + (reverse headings)))) + +;; marker: DOING | IN-PROGRESS > TODO > WAITING | WAIT > DONE > CANCELED | CANCELLED +;; priority: A > B > C +(defn sort-tasks + [headings] + (let [markers ["DOING" "IN-PROGRESS" "TODO" "WAITING" "WAIT" "DONE" "CANCELED" "CANCELLED"] + markers (zipmap markers (reverse (range 1 (count markers)))) + priorities ["A" "B" "C" "D" "E" "F" "G"] + priorities (zipmap priorities (reverse (range 1 (count priorities))))] + (sort (fn [t1 t2] + (let [m1 (get markers (:heading/marker t1) 0) + m2 (get markers (:heading/marker t2) 0) + p1 (get priorities (:heading/priority t1) 0) + p2 (get priorities (:heading/priority t2) 0)] + (cond + (and (= m1 m2) + (= p1 p2)) + (compare (str (:heading/title t1)) + (str (:heading/title t2))) + + (= m1 m2) + (> p1 p2) + :else + (> m1 m2)))) + headings))) diff --git a/web/src/main/frontend/format/org_mode.cljs b/web/src/main/frontend/format/org_mode.cljs new file mode 100644 index 00000000000..d4892f5f25f --- /dev/null +++ b/web/src/main/frontend/format/org_mode.cljs @@ -0,0 +1,48 @@ +(ns frontend.format.org-mode + (:require ["mldoc_org" :as org] + [frontend.format.protocol :as protocol] + [frontend.util :as util] + [clojure.string :as string])) + +(def default-config + (js/JSON.stringify + #js {:toc false + :heading_number false + :keep_line_break false})) + +(def config-with-line-break + (js/JSON.stringify + #js {:toc false + :heading_number false + :keep_line_break true})) + +(def Org (.-MldocOrg org)) + +(defrecord OrgMode [content] + protocol/Format + (toHtml [this] + (.parseHtml Org content default-config)) + (toHtml [this config] + (.parseHtml Org content config))) + +(defn parse-json + ([content] + (parse-json content default-config)) + ([content config] + (.parseJson Org content config))) + +(defn ->clj + [content] + (if (string/blank? content) + {} + (-> content + (parse-json) + (util/json->clj)))) + +(defn inline-list->html + [json] + (.inlineListToHtmlStr Org json)) + +(defn json->html + [json] + (.jsonToHtmlStr Org json default-config)) diff --git a/web/src/main/frontend/format/protocol.cljs b/web/src/main/frontend/format/protocol.cljs new file mode 100644 index 00000000000..41cb922251e --- /dev/null +++ b/web/src/main/frontend/format/protocol.cljs @@ -0,0 +1,4 @@ +(ns frontend.format.protocol) + +(defprotocol Format + (toHtml [this] [this config])) diff --git a/web/src/main/frontend/fs.cljs b/web/src/main/frontend/fs.cljs new file mode 100644 index 00000000000..9010991e3fd --- /dev/null +++ b/web/src/main/frontend/fs.cljs @@ -0,0 +1,44 @@ +(ns frontend.fs + (:require [frontend.util :as util] + [promesa.core :as p])) + +(defn mkdir + [dir] + (js/pfs.mkdir dir)) + +(defn readdir + [dir] + (js/pfs.readdir dir)) + +(defn read-file + [dir path] + (js/pfs.readFile (str dir "/" path) + (clj->js {:encoding "utf8"}))) + +(defn read-file-2 + [dir path] + (js/pfs.readFile (str dir "/" path) + (clj->js {}))) + +(defn write-file + [dir path content] + (js/pfs.writeFile (str dir "/" path) content)) + +(defn stat + [dir path] + (js/pfs.stat (str dir "/" path))) + +(defn create-if-not-exists + ([dir path] + (create-if-not-exists dir path "")) + ([dir path initial-content] + (util/p-handle + (stat dir path) + (fn [_stat] true) + (fn [error] + (write-file dir path initial-content) + false)))) + +(comment + (def dir "/notes") + ) diff --git a/web/src/main/frontend/git.cljs b/web/src/main/frontend/git.cljs new file mode 100644 index 00000000000..9098fa52629 --- /dev/null +++ b/web/src/main/frontend/git.cljs @@ -0,0 +1,133 @@ +(ns frontend.git + (:refer-clojure :exclude [clone]) + (:require [promesa.core :as p] + [frontend.util :as util] + [clojure.string :as string])) + +;; only support Github now +(defn auth + [token] + {:username token + :password "x-oauth-basic"}) + +(defn set-username-email + [dir username email] + (prn {:dir dir + :username username + :email email}) + (util/p-handle (js/git.config (clj->js + {:dir dir + :path "user.name" + :value username})) + (fn [result] + (js/git.config (clj->js + {:dir dir + :path "user.email" + :value email}))) + (fn [error] + (prn "error:" error)))) + +(defn with-auth + [token m] + (clj->js + (merge (auth token) + m))) + +(defn get-repo-dir + [repo-url] + (str "/" (last (string/split repo-url #"/")))) + +(defn clone + [repo-url token] + (js/git.clone (with-auth token + {:dir (get-repo-dir repo-url) + :url repo-url + :corsProxy "https://cors.isomorphic-git.org" + :singleBranch true + :depth 1}))) + +(defn list-files + [repo-url] + (js/git.listFiles (clj->js + {:dir (get-repo-dir repo-url) + :ref "HEAD"}))) + +(defn fetch + [repo-url token] + (js/git.fetch (with-auth token + {:dir (get-repo-dir repo-url) + :ref "master" + :singleBranch true}))) + +(defn log + [repo-url token depth] + (js/git.log (with-auth token + {:dir (get-repo-dir repo-url) + :ref "master" + :depth depth + :singleBranch true}))) + +(defn pull + [repo-url token] + (js/git.pull (with-auth token + {:dir (get-repo-dir repo-url) + :ref "master" + :singleBranch true}))) +(defn add + [repo-url file] + (js/git.add (clj->js + {:dir (get-repo-dir repo-url) + :filepath file}))) + +;; TODO: cache email and name +(defn commit + [repo-url message] + (js/git.commit (clj->js + {:dir (get-repo-dir repo-url) + :author {:name "Orgnote" + :email "orgnote@hello.world"} + :message message}))) + +(defn push + [repo-url token] + (js/git.push (with-auth token + {:dir (get-repo-dir repo-url) + :remote "origin" + :ref "master" + }))) + +(defn add-commit-push + [repo-url file message token push-ok-handler push-error-handler] + (util/p-handle + (let [files (if (coll? file) file [file])] + (doseq [file files] + (add repo-url file))) + (fn [_] + (util/p-handle + (commit repo-url message) + (fn [_] + (push repo-url token) + (push-ok-handler)) + push-error-handler)))) + +(defn add-commit + [repo-url file message commit-ok-handler commit-error-handler] + (let [get-seconds (fn [] + (/ (.getTime (js/Date.)) 1000))] + (let [t1 (get-seconds)] + (util/p-handle + (add repo-url file) + (fn [_] + (let [t2 (get-seconds)] + (prn "Add time: " (- t2 t1)) + (util/p-handle + (commit repo-url message) + (fn [] + (let [t3 (get-seconds)] + (prn "Commit time: " (- t3 t2))) + (prn "Commited") + (commit-ok-handler)) + (fn [error] + (commit-error-handler error)))) + ))) + )) diff --git a/web/src/main/frontend/handler.cljs b/web/src/main/frontend/handler.cljs new file mode 100644 index 00000000000..c5473bf4fc9 --- /dev/null +++ b/web/src/main/frontend/handler.cljs @@ -0,0 +1,563 @@ +(ns frontend.handler + (:refer-clojure :exclude [clone load-file]) + (:require [frontend.git :as git] + [frontend.fs :as fs] + [frontend.state :as state] + [frontend.db :as db] + [frontend.storage :as storage] + [frontend.util :as util] + [frontend.config :as config] + [clojure.walk :as walk] + [clojure.string :as string] + [promesa.core :as p] + [cljs-bean.core :as bean] + [reitit.frontend.easy :as rfe] + [goog.crypt.base64 :as b64] + [goog.object :as gobj] + [goog.dom :as gdom] + [rum.core :as rum] + [datascript.core :as d] + [frontend.utf8 :as utf8] + [frontend.image :as image] + [clojure.set :as set]) + (:import [goog.events EventHandler])) + +(defn set-state-kv! + [key value] + (swap! state/state assoc key value)) + +(defn get-github-token + [] + (get-in @state/state [:me :access-token])) + +;; We only support Github token now +(defn load-file + [repo-url path state-handler] + (util/p-handle (fs/read-file (git/get-repo-dir repo-url) path) + (fn [content] + (state-handler content)))) + +(defn- hidden? + [path patterns] + (some (fn [pattern] + (or + (= path pattern) + (and (string/starts-with? pattern "/") + (= (str "/" (first (string/split path #"/"))) + pattern)))) patterns)) + +(defn- get-format + [file] + (string/lower-case (last (string/split file #"\.")))) + +(def text-formats + #{"org" "md" "markdown" "adoc" "asciidoc" "rst" "dat" "txt" "json" "yml" "xml" + ;; maybe should support coding + }) + +(def img-formats + #{"png" "jpg" "jpeg" "gif" "svg" "bmp" "ico"}) + +(def all-formats + (set/union text-formats img-formats)) + +(defn- keep-formats + [files formats] + (filter + (fn [file] + (let [format (get-format file)] + (contains? formats format))) + files)) + +(defn load-files + [repo-url] + (set-state-kv! :repo/loading-files? true) + (util/p-handle (git/list-files repo-url) + (fn [files] + (when (> (count files) 0) + (let [files (js->clj files)] + ;; FIXME: don't load blobs + (if (contains? (set files) config/hidden-file) + (load-file repo-url config/hidden-file + (fn [patterns-content] + (when patterns-content + (let [patterns (string/split patterns-content #"\n") + files (remove (fn [path] (hidden? path patterns)) files)] + (set-state-kv! :repo/loading-files? false) + (db/transact-files! repo-url files))))) + (p/promise + (do + (set-state-kv! :repo/loading-files? false) + (db/transact-files! repo-url files))))))))) + + +;; TODO: remove this +(declare load-repo-to-db!) + +(defn get-latest-commit + [handler] + (-> (git/log (db/get-current-repo) + (get-github-token) + 1) + (.then (fn [commits] + (handler (first commits)))) + (.catch (fn [error] + (prn "get latest commit failed: " error))))) + +(defonce latest-commit (atom nil)) + +;; TODO: Maybe replace with fetch? +;; TODO: callback hell +(defn pull + [repo-url token] + (when (and (nil? (:git-error @state/state)) + (nil? (:git-status @state/state))) + (util/p-handle + (git/pull repo-url token) + (fn [result] + (prn "pull successfully!") + (get-latest-commit + (fn [commit] + (when (or (nil? @latest-commit) + (and @latest-commit + commit + (not= (gobj/get commit "oid") + (gobj/get @latest-commit "oid")))) + (prn "New commit oid: " (gobj/get commit "oid")) + (-> (load-files repo-url) + (p/then + (fn [] + (load-repo-to-db! repo-url))))) + (reset! latest-commit commit))))))) + +(defn periodically-pull + [repo-url] + (when-let [token (get-github-token)] + (pull repo-url token) + (js/setInterval #(pull repo-url token) + (* 60 1000)))) + +(defn git-add-commit + [repo-url file message content] + (swap! state/state assoc :git-status :commit) + (db/reset-file! repo-url file content) + (git/add-commit repo-url file message + (fn [] + (swap! state/state assoc + :git-status :should-push)) + (fn [error] + (prn "Commit failed, " + {:repo repo-url + :file file + :message message}) + (swap! state/state assoc + :git-status :commit-failed + :git-error error)))) + +;; TODO: update latest commit +(defn push + [repo-url file] + (when (and (= :should-push (:git-status @state/state)) + (nil? (:git-error @state/state))) + (swap! state/state assoc :git-status :push) + (let [token (get-github-token)] + (util/p-handle + (git/push repo-url token) + (fn [] + (prn "Push successfully!") + (swap! state/state assoc + :git-status nil + :git-error nil) + ;; TODO: update latest-commit + (get-latest-commit + (fn [commit] + (reset! latest-commit commit)))) + (fn [error] + (prn "Failed to push, error: " error) + (swap! state/state assoc + :git-status :push-failed + :git-error error)))))) + +(defn clone + [repo] + (let [token (get-github-token)] + (util/p-handle + (do + (set-state-kv! :repo/cloning? true) + (git/clone repo token)) + (fn [] + (set-state-kv! :repo/cloning? false) + (db/mark-repo-as-cloned repo) + (db/set-current-repo! repo) + ;; load contents + (load-files repo)) + (fn [e] + (set-state-kv! :repo/cloning? false) + (prn "Clone failed, reason: " e))))) + +(defn new-notification + [text] + (js/Notification. "Logseq" #js {:body text + ;; :icon logo + })) + +(defn request-notifications + [] + (util/p-handle (.requestPermission js/Notification) + (fn [result] + (storage/set :notification-permission-asked? true) + + (when (= "granted" result) + (storage/set :notification-permission? true))))) + +(defn request-notifications-if-not-asked + [] + (when-not (storage/get :notification-permission-asked?) + (request-notifications))) + +;; notify deadline or scheduled tasks +(defn run-notify-worker! + [] + (when (storage/get :notification-permission?) + (let [notify-fn (fn [] + (let [tasks (:tasks @state/state) + tasks (flatten (vals tasks))] + (doseq [{:keys [marker title] :as task} tasks] + (when-not (contains? #{"DONE" "CANCElED" "CANCELLED"} marker) + (doseq [[type {:keys [date time] :as timestamp}] (:timestamps task)] + (let [{:keys [year month day]} date + {:keys [hour min] + :or {hour 9 + min 0}} time + now (util/get-local-date)] + (when (and (contains? #{"Scheduled" "Deadline"} type) + (= (assoc date :hour hour :minute min) now)) + (let [notification-text (str type ": " (second (first title)))] + (new-notification notification-text)))))))))] + (notify-fn) + (js/setInterval notify-fn (* 1000 60))))) + +(defn show-notification! + [text] + (swap! state/state assoc + :notification/show? true + :notification/text text) + (js/setTimeout #(swap! state/state assoc + :notification/show? false + :notification/text nil) + 3000)) + +(defn alter-file + ([path commit-message content] + (alter-file path commit-message content true)) + ([path commit-message content redirect?] + (let [token (get-github-token) + repo-url (db/get-current-repo)] + (util/p-handle + (fs/write-file (git/get-repo-dir repo-url) path content) + (fn [_] + (when redirect? + (rfe/push-state :file {:path (b64/encodeString path)})) + (git-add-commit repo-url path commit-message content)))))) + +(defn clear-storage + [repo-url] + (js/window.pfs._idb.wipe) + (clone repo-url)) + +;; TODO: utf8 encode performance +(defn check + [heading] + (let [{:heading/keys [repo file marker meta uuid]} heading + pos (:pos meta) + repo (db/entity (:db/id repo)) + file (db/entity (:db/id file)) + repo-url (:repo/url repo) + file (:file/path file) + token (get-github-token)] + (when-let [content (db/get-file-content repo-url file)] + (let [encoded-content (utf8/encode content) + content' (str (utf8/substring encoded-content 0 pos) + (-> (utf8/substring encoded-content pos) + (string/replace-first marker "DONE")))] + (util/p-handle + (fs/write-file (git/get-repo-dir repo-url) file content') + (fn [_] + (prn "check successfully, " file) + (git-add-commit repo-url file + (str marker " marked as DONE") + content'))))))) + +(defn uncheck + [heading] + (let [{:heading/keys [repo file marker meta]} heading + pos (:pos meta) + repo (db/entity (:db/id repo)) + file (db/entity (:db/id file)) + repo-url (:repo/url repo) + file (:file/path file) + token (get-github-token)] + (when-let [content (db/get-file-content repo-url file)] + (let [encoded-content (utf8/encode content) + content' (str (utf8/substring encoded-content 0 pos) + (-> (utf8/substring encoded-content pos) + (string/replace-first "DONE" "TODO")))] + (util/p-handle + (fs/write-file (git/get-repo-dir repo-url) file content') + (fn [_] + (prn "uncheck successfully, " file) + (git-add-commit repo-url file + "DONE rollbacks to TODO." + content'))))))) + +(defn load-all-contents! + [repo-url ok-handler] + (let [files (db/get-repo-files repo-url) + files (keep-formats files text-formats)] + (-> (p/all (for [file files] + (load-file repo-url file + (fn [content] + (db/set-file-content! repo-url file content))))) + (p/then + (fn [_] + (ok-handler)))))) + +(defonce headings-atom (atom nil)) + +(defn load-repo-to-db! + [repo-url] + (set-state-kv! :repo/importing-to-db? true) + (load-all-contents! + repo-url + (fn [] + (let [headings (db/extract-all-headings repo-url)] + (reset! headings-atom headings) + (db/reset-headings! repo-url headings) + (set-state-kv! :repo/importing-to-db? false))))) + + +;; (defn sync +;; [] +;; (let [[_user token repos] (get-user-token-repos)] +;; (doseq [repo repos] +;; (pull repo token)))) + +(defn get-me + [] + (util/fetch (str config/api "me") + (fn [resp] + (when resp + (set-state-kv! :me resp))) + (fn [_error] + ;; (prn "Get token failed, error: " error) + ))) + +;; org-journal format, something like `* Tuesday, 06/04/13` +(defn default-month-journal-content + [] + (let [{:keys [year month day]} (util/get-date) + last-day (util/get-month-last-day) + month-pad (if (< month 10) (str "0" month) month)] + (->> (map + (fn [day] + (let [day-pad (if (< day 10) (str "0" day) day) + weekday (util/get-weekday (js/Date. year (dec month) day))] + (str "* " weekday ", " month-pad "/" day-pad "/" year "\n\n"))) + (range 1 (inc last-day))) + (apply str)))) + +;; journals +(defn set-latest-journals! + [] + (set-state-kv! :latest-journals (db/get-latest-journals {}))) + +(defn create-month-journal-if-not-exists + [repo-url] + (let [repo-dir (git/get-repo-dir repo-url) + path (util/current-journal-path) + file-path (str "/" path) + default-content (default-month-journal-content)] + (-> + (util/p-handle + (fs/mkdir (str repo-dir "/journals")) + (fn [result] + (fs/create-if-not-exists repo-dir file-path default-content)) + (fn [error] + (fs/create-if-not-exists repo-dir file-path default-content))) + (util/p-handle + (fn [file-exists?] + (when-not file-exists? + (prn "create a month journal") + (git-add-commit repo-url path "create a month journal" default-content) + (set-latest-journals!))) + (fn [error] + (prn error)))))) + +(defn clone-and-pull + [repo] + (p/then (clone repo) + (fn [] + (create-month-journal-if-not-exists repo) + (periodically-pull repo)))) + +(defn set-route-match! + [route] + (swap! state/state assoc :route-match route)) + +(defn set-ref-component! + [k ref] + (swap! state/state assoc :ref-components k ref)) + +(defn set-root-component! + [comp] + (swap! state/state assoc :root-component comp)) + +(defn re-render! + [] + (when-let [comp (get @state/state :root-component)] + (when-not (:edit? @state/state) + (rum/request-render comp)))) + +(defn db-listen-to-tx! + [] + (d/listen! db/conn :persistence + (fn [tx-report] ;; FIXME do not notify with nil as db-report + ;; FIXME do not notify if tx-data is empty + (when-let [db (:db-after tx-report)] + (prn "DB changed, re-rendered!") + (re-render!) + (js/setTimeout (fn [] + (db/persist db)) 0))))) + +(defn periodically-push-tasks + [repo-url] + (let [token (get-github-token) + push (fn [] + (push repo-url token))] + (js/setInterval push + (* 10 1000)))) + +(defn periodically-pull-and-push + [repo-url] + (periodically-pull repo-url) + (periodically-push-tasks repo-url)) + +(defn edit-journal! + [content journal] + (swap! state/state assoc + :edit? true + :edit-journal journal)) + +(defn set-journal-content! + [uuid content] + (swap! state/state update :latest-journals + (fn [journals] + (mapv + (fn [journal] + (if (= (:uuid journal) uuid) + (assoc journal :content content) + journal)) + journals)))) + +(defn save-current-edit-journal! + [edit-content] + (prn {:edit-content edit-content}) + (let [{:keys [edit-journal]} @state/state + {:keys [start-pos end-pos]} edit-journal] + (swap! state/state assoc + :edit? false + :edit-journal nil) + (when-not (= edit-content (:content edit-journal)) ; if new changes + (let [path (:file-path edit-journal) + current-journals (db/get-file path) + new-content (utf8/insert! current-journals start-pos end-pos edit-content)] + (set-state-kv! :latest-journals (db/get-latest-journals {:content new-content})) + (alter-file path "Auto save" new-content false))))) + +(defn render-local-images! + [] + (let [images (array-seq (gdom/getElementsByTagName "img")) + get-src (fn [image] (.getAttribute image "src")) + local-images (filter + (fn [image] + (let [src (get-src image)] + (and src + (not (or (string/starts-with? src "http://") + (string/starts-with? src "https://")))))) + images)] + (doseq [img local-images] + (gobj/set img + "onerror" + (fn [] + (gobj/set (gobj/get img "style") + "display" "none"))) + (let [path (get-src img) + path (if (= (first path) \.) + (subs path 1) + path)] + (util/p-handle + (fs/read-file-2 (git/get-repo-dir (db/get-current-repo)) + path) + (fn [blob] + (let [blob (js/Blob. (array blob) (clj->js {:type "image"})) + img-url (image/create-object-url blob)] + (gobj/set img "src" img-url) + (gobj/set (gobj/get img "style") + "display" "initial")))))))) + +;; FIXME: +(defn set-username-email + [] + (git/set-username-email + (git/get-repo-dir (db/get-current-repo)) + "Tienson Qin" + "tiensonqin@gmail.com")) + +(defn load-more-journals! + [] + (let [journals (:latest-journals @state/state)] + (when-let [title (:title (last journals))] + (let [before-date (last (string/split title #", ")) + more-journals (->> (db/get-latest-journals {:before-date before-date + :days 4}) + (drop 1)) + journals (concat journals more-journals)] + (set-state-kv! :latest-journals journals))))) + +(defn request-presigned-url + [folder filename mime-type] + (util/post (str config/api "presigned_url") + {:folder folder + :filename filename + :mime-type mime-type} + (fn [resp] + (prn {:resp resp})) + (fn [_error] + ;; (prn "Get token failed, error: " error) + ))) + +(defn set-me-if-exists! + [] + (when js/window.user + (let [user (js->clj js/window.user :keywordize-keys true)] + (set-state-kv! :me user)))) + +(defn start! + [] + (set-me-if-exists!) + (db/restore!) + (db-listen-to-tx!) + (when-let [first-repo (first (db/get-repos))] + (db/set-current-repo! first-repo)) + (let [repos (db/get-repos)] + (doseq [repo repos] + (create-month-journal-if-not-exists repo) + (periodically-pull-and-push repo)))) + +(comment + (util/p-handle (fs/read-file (git/get-repo-dir (db/get-current-repo)) "/journals/2020_04.org") + (fn [content] + (prn content))) + + (pull (db/get-current-repo) (get-github-token)) + ) diff --git a/web/src/main/frontend/image.cljs b/web/src/main/frontend/image.cljs new file mode 100644 index 00000000000..25a96749fde --- /dev/null +++ b/web/src/main/frontend/image.cljs @@ -0,0 +1,103 @@ +(ns frontend.image + (:require [goog.object :as gobj] + [frontend.blob :as blob] + ["/frontend/exif" :as exif] + [frontend.util :as util] + [clojure.string :as string])) + +(defn reverse? + [exif-orientation] + (contains? #{5 6 7 8} exif-orientation)) + +(defn re-scale + [exif-orientation width height max-width max-height] + (let [[width height] + (if (reverse? exif-orientation) + [height width] + [width height])] + (let [ratio (/ width height) + to-width (if (> width max-width) max-width width) + to-height (if (> height max-height) max-height height) + new-ratio (/ to-width to-height)] + (let [[w h] (cond + (> new-ratio ratio) + [(* ratio to-height) to-height] + + (< new-ratio ratio) + [to-width (/ to-width ratio)] + + :else + [to-width to-height])] + [(int w) (int h)])))) + + +(defn fix-orientation + "Given image and exif orientation, ensure the photo is displayed + rightside up" + [img exif-orientation cb max-width max-height] + (let [off-canvas (js/document.createElement "canvas") + ctx ^js (.getContext off-canvas "2d") + width (gobj/get img "width") + height (gobj/get img "height") + [to-width to-height] (re-scale exif-orientation width height max-width max-height)] + (gobj/set ctx "imageSmoothingEnabled" false) + (set! (.-width off-canvas) to-width) + (set! (.-height off-canvas) to-height) + ;; rotate + (let [[width height] (if (reverse? exif-orientation) + [to-height to-width] + [to-width to-height])] + (case exif-orientation + 2 (.transform ctx -1 0 0 1 width 0) + 3 (.transform ctx -1 0 0 -1 width height) + 4 (.transform ctx 1 0 0 -1 0 height) + 5 (.transform ctx 0 1 1 0 0 0) + 6 (.transform ctx 0 1 -1 0 height 0) + 7 (.transform ctx 0 -1 -1 0 height width) + 8 (.transform ctx 0 -1 1 0 0 width) + (.transform ctx 1 0 0 1 0 0)) + (.drawImage ctx img 0 0 width height)) + (cb off-canvas))) + +(defn get-orientation + [img cb max-width max-height] + (exif/getEXIFOrientation + img + (fn [orientation] + (fix-orientation img orientation cb max-width max-height)))) + +(defn create-object-url + [file] + (.createObjectURL (or (.-URL js/window) + (.-webkitURL js/window)) + file)) + +;; (defn build-image +;; [] +;; (let [img (js/Image.)] +;; )) + +(defn upload + [files file-cb & {:keys [max-width max-height] + :or {max-width 1920 + max-height 1080}}] + (doseq [file (array-seq files)] + (let [file-type (gobj/get file "type") + ymd (->> (vals (util/year-month-day-padded)) + (string/join "_")) + file-name (str ymd "_" (gobj/get file "name"))] + (if (= 0 (.indexOf type "image/")) + (let [img (js/Image.)] + (set! (.-onload img) + (fn [] + (get-orientation img + (fn [^js off-canvas] + (let [file-form-data ^js (js/FormData.) + data-url (.toDataURL off-canvas) + blob (blob/blob data-url)] + (.append file-form-data "file" blob) + (file-cb file file-form-data file-name file-type))) + max-width + max-height))) + (set! (.-src img) + (create-object-url file))))))) diff --git a/web/src/main/frontend/mixins.cljs b/web/src/main/frontend/mixins.cljs new file mode 100644 index 00000000000..9925d346333 --- /dev/null +++ b/web/src/main/frontend/mixins.cljs @@ -0,0 +1,117 @@ +(ns frontend.mixins + (:require [rum.core :as rum] + [goog.dom :as dom]) + (:import [goog.events EventHandler])) + +(defn detach + "Detach all event listeners." + [state] + (some-> state ::event-handler .removeAll)) + +(defn listen + "Register an event `handler` for events of `type` on `target`." + [state target type handler & [opts]] + (when-let [event-handler (::event-handler state)] + (.listen event-handler target (name type) handler (clj->js opts)))) + +(def event-handler-mixin + "The event handler mixin." + {:will-mount + (fn [state] + (assoc state ::event-handler (EventHandler.))) + :will-unmount + (fn [state] + (detach state) + (dissoc state ::event-handler))}) + +;; (defn timeout-mixin +;; "The setTimeout mixin." +;; [name t f] +;; {:will-mount +;; (fn [state] +;; (assoc state name (util/set-timeout t f))) +;; :will-unmount +;; (fn [state] +;; (let [timeout (get state name)] +;; (util/clear-timeout timeout) +;; (dissoc state name)))}) + +;; (defn interval-mixin +;; "The setInterval mixin." +;; [name t f] +;; {:will-mount +;; (fn [state] +;; (assoc state name (util/set-interval t f))) +;; :will-unmount +;; (fn [state] +;; (when-let [interval (get state name)] +;; (util/clear-interval interval)) +;; (dissoc state name))}) + +(defn hide-when-esc-or-outside + [state show? & {:keys [on-hide node show-fn]}] + (let [node (or node (rum/dom-node state)) + show? (if (and show-fn (fn? show-fn)) + (show-fn) + @show?)] + (when show? + (listen state js/window "click" + (fn [e] + ;; If the click target is outside of current node + (when-not (dom/contains node (.. e -target)) + (on-hide e)))) + + (listen state js/window "keydown" + (fn [e] + (case (.-keyCode e) + ;; Esc + 27 (on-hide e) + nil)))))) + +(defn event-mixin + ([attach-listeners] + (event-mixin attach-listeners identity)) + ([attach-listeners init-callback] + (merge + event-handler-mixin + {:init (fn [state props] + (init-callback state)) + :did-mount (fn [state] + (attach-listeners state) + state) + :did-remount (fn [old-state new-state] + (detach old-state) + (attach-listeners new-state) + new-state)}))) + +;; TODO: is it possible that multiple nested components using the same key `:open?`? +(defn modal + [] + (let [k :open?] + (event-mixin + (fn [state] + (let [open? (get state k)] + (hide-when-esc-or-outside state + open? + :on-hide (fn [] + (reset! open? false))))) + (fn [state] + (let [open? (atom false) + component (:rum/react-component state)] + (add-watch open? ::open + (fn [_ _ _ _] + (rum/request-render component))) + (assoc state + k open? + :close-fn (fn [] + (reset! open? false)) + :open-fn (fn [] + (reset! open? true)) + :toggle-fn (fn [] + (swap! open? not)))))))) + +(defn will-mount-effect + [handler] + {:will-mount (fn [state] + (handler (:rum/args state)) + state)}) diff --git a/web/src/main/frontend/page.cljs b/web/src/main/frontend/page.cljs new file mode 100644 index 00000000000..fefe57fb1ec --- /dev/null +++ b/web/src/main/frontend/page.cljs @@ -0,0 +1,16 @@ +(ns frontend.page + (:require [rum.core :as rum] + [frontend.state :as state] + [frontend.handler :as handler])) + +(rum/defc current-page < rum/reactive + {:did-mount (fn [state] + (handler/set-root-component! (:rum/react-component state)) + state)} + [] + (let [state (rum/react state/state) + route-match (:route-match state)] + (if route-match + (when-let [view (:view (:data route-match))] + (view route-match)) + [:div "404 Page"]))) diff --git a/web/src/main/frontend/routes.cljs b/web/src/main/frontend/routes.cljs new file mode 100644 index 00000000000..dba848f5e87 --- /dev/null +++ b/web/src/main/frontend/routes.cljs @@ -0,0 +1,37 @@ +(ns frontend.routes + (:require [frontend.components.home :as home] + [frontend.components.sidebar :as sidebar] + [frontend.components.repo :as repo] + [frontend.components.file :as file] + [frontend.components.agenda :as agenda] + )) + +(def routes + [["/" + {:name :home + :view home/home}] + + ["/repo/add" + {:name :repo-add + :view repo/add-repo}] + + ["/file/:path" + {:name :file + :view file/file}] + + ["/file/:path/edit" + {:name :file-edit + :view file/edit}] + + ["/agenda" + {:name :agenda + :view agenda/agenda}] + + ;; TODO: edit file + ;; Settings + ;; ["/item/:id" + ;; {:name ::item + ;; :view item-page + ;; :parameters {:path {:id int?} + ;; :query {(ds/opt :foo) keyword?}}}] + ]) diff --git a/web/src/main/frontend/rum.cljs b/web/src/main/frontend/rum.cljs new file mode 100644 index 00000000000..df04da01627 --- /dev/null +++ b/web/src/main/frontend/rum.cljs @@ -0,0 +1,59 @@ +(ns frontend.rum + (:require [clojure.string :as s] + [clojure.set :as set] + [clojure.walk :as w])) + +;; copy from https://github.com/priornix/antizer/blob/35ba264cf48b84e6597743e28b3570d8aa473e74/src/antizer/core.cljs + +(defn kebab-case->camel-case + "Converts from kebab case to camel case, eg: on-click to onClick" + [input] + (let [words (s/split input #"-") + capitalize (->> (rest words) + (map #(apply str (s/upper-case (first %)) (rest %))))] + (apply str (first words) capitalize))) + +(defn map-keys->camel-case + "Stringifys all the keys of a cljs hashmap and converts them + from kebab case to camel case. If :html-props option is specified, + then rename the html properties values to their dom equivalent + before conversion" + [data & {:keys [html-props]}] + (let [convert-to-camel (fn [[key value]] + [(kebab-case->camel-case (name key)) value])] + (w/postwalk (fn [x] + (if (map? x) + (let [new-map (if html-props + (set/rename-keys x {:class :className :for :htmlFor}) + x)] + (into {} (map convert-to-camel new-map))) + x)) + data))) + +;; adapted from https://github.com/tonsky/rum/issues/20 +(defn adapt-class [react-class] + (fn [& args] + (let [[opts children] (if (map? (first args)) + [(first args) (rest args)] + [{} args]) + type# (first children) + ;; we have to make sure to check if the children is sequential + ;; as a list can be returned, eg: from a (for) + new-children (if (sequential? type#) + (let [result (sablono.interpreter/interpret children)] + (if (sequential? result) + result + [result])) + children) + ;; convert any options key value to a react element, if + ;; a valid html element tag is used, using sablono + vector->react-elems (fn [[key val]] + (if (sequential? val) + [key (sablono.interpreter/interpret val)] + [key val])) + new-options (into {} (map vector->react-elems opts))] + ;; (.dir js/console new-children) + (apply js/React.createElement react-class + ;; sablono html-to-dom-attrs does not work for nested hashmaps + (clj->js (map-keys->camel-case new-options :html-props true)) + new-children)))) diff --git a/web/src/main/frontend/state.cljs b/web/src/main/frontend/state.cljs new file mode 100644 index 00000000000..be525be589b --- /dev/null +++ b/web/src/main/frontend/state.cljs @@ -0,0 +1,17 @@ +(ns frontend.state + (:require [frontend.storage :as storage])) + +;; TODO: replace this with datascript +(def state (atom + {:route-match nil + :notification/show? false + :notification/text nil + :root-component nil + :git-status nil + :git-error nil + :edit? false + :latest-journals [] + :repo/cloning? nil + :repo/loading-files? nil + :repo/importing-to-db? nil + :me nil})) diff --git a/web/src/main/frontend/storage.cljs b/web/src/main/frontend/storage.cljs new file mode 100644 index 00000000000..86c1b157982 --- /dev/null +++ b/web/src/main/frontend/storage.cljs @@ -0,0 +1,20 @@ +(ns frontend.storage + (:refer-clojure :exclude [get set remove]) + (:require [cljs.reader :as reader])) + +;; TODO: deprecate this, will persistent datascript +(defn get + [key] + (reader/read-string ^js (.getItem js/localStorage (name key)))) + +(defn set + [key value] + (.setItem ^js js/localStorage (name key) (pr-str value))) + +(defn remove + [key] + (.removeItem ^js js/localStorage (name key))) + +(defn clear + [] + (.clear ^js js/localStorage)) diff --git a/web/src/main/frontend/ui.cljs b/web/src/main/frontend/ui.cljs new file mode 100644 index 00000000000..0d37aae1a2a --- /dev/null +++ b/web/src/main/frontend/ui.cljs @@ -0,0 +1,144 @@ +(ns frontend.ui + (:require [rum.core :as rum] + [frontend.rum :as r] + ["react-transition-group" :refer [TransitionGroup CSSTransition]] + ["react-textarea-autosize" :as Textarea] + [frontend.util :as util] + [frontend.mixins :as mixins] + [frontend.state :as state] + [goog.object :as gobj] + [goog.dom :as gdom])) + +(defonce transition-group (r/adapt-class TransitionGroup)) +(defonce css-transition (r/adapt-class CSSTransition)) + +(defonce textarea-autosize (r/adapt-class (gobj/get Textarea "default"))) + +(rum/defc dropdown-content-wrapper [state content] + [:div.origin-top-right.absolute.right-0.mt-2.w-48.rounded-md.shadow-lg + {:class (case state + "entering" "transition ease-out duration-100 transform opacity-0 scale-95" + "entered" "transition ease-out duration-100 transform opacity-100 scale-100" + "exiting" "transition ease-in duration-75 transform opacity-100 scale-100" + "exited" "transition ease-in duration-75 transform opacity-0 scale-95")} + content]) + +;; public exports +(rum/defcs dropdown < rum/reactive + (mixins/modal) + [state content] + (let [{:keys [me]} (rum/react state/state) + {:keys [open? toggle-fn]} state] + [:div.ml-3.relative + [:div + [:button.max-w-xs.flex.items-center.text-sm.rounded-full.focus:outline-none.focus:shadow-outline + {:on-click toggle-fn} + [:img.h-8.w-8.rounded-full + {:src (:avatar me)}]]] + (css-transition + {:in @open? :timeout 0} + (fn [state] + (dropdown-content-wrapper state content)))])) + +(defn dropdown-with-links + [links] + (dropdown + [:div.py-1.rounded-md.bg-white.shadow-xs + (for [{:keys [options title]} links] + [:a.block.px-4.py-2.text-sm.text-gray-700.hover:bg-gray-100.transition.ease-in-out.duration-150 + (merge {:key (cljs.core/random-uuid)} + options) + title])])) + +(rum/defc button + [text on-click] + [:button.inline-flex.items-center.px-3.py-2.border.border-transparent.text-sm.leading-4.font-medium.rounded-md.text-white.bg-indigo-600.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.active:bg-indigo-700.transition.ease-in-out.duration-150.mt-1 + {:type "button" + :on-click on-click} + text]) + +(rum/defc notification-content + [state text] + [:div.fixed.inset-0.flex.items-end.justify-center.px-4.py-6.pointer-events-none.sm:p-6.sm:items-start.sm:justify-end + [:div.max-w-sm.w-full.bg-white.shadow-lg.rounded-lg.pointer-events-auto + {:class (case state + "entering" "transition ease-out duration-300 transform opacity-0 translate-y-2 sm:translate-x-0" + "entered" "transition ease-out duration-300 transform translate-y-0 opacity-100 sm:translate-x-0" + "exiting" "transition ease-in duration-100 opacity-100" + "exited" "transition ease-in duration-100 opacity-0")} + [:div.rounded-lg.shadow-xs.overflow-hidden + [:div.p-4 + [:div.flex.items-start + [:div.flex-shrink-0 + [:svg.h-6.w-6.text-green-400 + {:stroke "currentColor", :viewBox "0 0 24 24", :fill "none"} + [:path + {:d "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z", + :stroke-width "2", + :stroke-linejoin "round", + :stroke-linecap "round"}]]] + [:div.ml-3.w-0.flex-1.pt-0.5 + [:p.text-sm.leading-5.font-medium.text-gray-900 + text]] + [:div.ml-4.flex-shrink-0.flex + [:button.inline-flex.text-gray-400.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150 + {:on-click (fn [] + (swap! state/state assoc :notification/show? false))} + [:svg.h-5.w-5 + {:fill "currentColor", :viewBox "0 0 20 20"} + [:path + {:clip-rule "evenodd", + :d + "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", + :fill-rule "evenodd"}]]]]]]]]]) + +(rum/defc notification < rum/reactive + [] + (let [{:keys [:notification/show? :notification/text]} (rum/react state/state)] + (css-transition + {:in show? :timeout 100} + (fn [state] + (notification-content state text))))) + +(rum/defc checkbox + [option] + [:input.form-checkbox.h-4.w-4.text-indigo-600.transition.duration-150.ease-in-out + (merge {:type "checkbox"} option)]) + +(rum/defc badge + [text option] + [:span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.leading-4.bg-purple-100.text-purple-800 + option + text]) + +;; scroll +(defn main-node + [] + (first (array-seq (js/document.querySelectorAll "main")))) + +(defn get-scroll-top [] + (.-scrollTop (main-node))) + +(defn on-scroll + [on-load] + (let [node (main-node) + full-height (gobj/get node "scrollHeight") + scroll-top (gobj/get node "scrollTop") + client-height (gobj/get node "clientHeight") + bottom-reached? (<= (- full-height scroll-top client-height) 200)] + (when bottom-reached? + (on-load)))) + +(defn attach-listeners + "Attach scroll and resize listeners." + [state] + (let [opts (-> state :rum/args second) + debounced-on-scroll (util/debounce 500 #(on-scroll (:on-load opts)))] + (mixins/listen state (main-node) :scroll debounced-on-scroll))) + +(rum/defcs infinite-list < + (mixins/event-mixin attach-listeners) + "Render an infinite list." + [state body {:keys [on-load] + :as opts}] + body) diff --git a/web/src/main/frontend/utf8.cljs b/web/src/main/frontend/utf8.cljs new file mode 100644 index 00000000000..16cb364997e --- /dev/null +++ b/web/src/main/frontend/utf8.cljs @@ -0,0 +1,31 @@ +(ns frontend.utf8 + (:require [goog.object :as gobj])) + +(defonce encoder + (js/TextEncoder. "utf-8")) + +(defonce decoder + (js/TextDecoder. "utf-8")) + +(defn encode + [s] + (.encode encoder s)) + +(defn substring + ([arr start] + (->> (.subarray arr start) + (.decode decoder))) + ([arr start end] + (->> (.subarray arr start end) + (.decode decoder)))) + +(defn length + [arr] + (gobj/get arr "length")) + +(defn insert! + [s start-pos end-pos content] + (let [arr (encode s)] + (str (substring arr 0 start-pos) + content + (substring arr end-pos)))) diff --git a/web/src/main/frontend/util.cljs b/web/src/main/frontend/util.cljs new file mode 100644 index 00000000000..446f9c95073 --- /dev/null +++ b/web/src/main/frontend/util.cljs @@ -0,0 +1,193 @@ +(ns frontend.util + (:require [goog.object :as gobj] + [promesa.core :as p] + [clojure.walk :as walk] + [clojure.string :as string] + [cljs-bean.core :as bean])) + +(defn evalue + [event] + (gobj/getValueByKeys event "target" "value")) + +(defn p-handle + ([p ok-handler] + (p-handle p ok-handler (fn [error] (prn "p-handle error: " error)))) + ([p ok-handler error-handler] + (-> p + (p/then (fn [result] + (ok-handler result))) + (p/catch (fn [error] + (error-handler error)))))) + +(defn get-width + [] + (gobj/get js/window "innerWidth")) + +(defn listen + "Register an event `handler` for events of `type` on `target`." + [event-handler target type handler & [opts]] + (.listen event-handler target (name type) handler (clj->js opts))) + +(defn indexed + [coll] + (map-indexed vector coll)) + +(defn find-first + [pred coll] + (first (filter pred coll))) + +(defn get-local-date + [] + (let [date (js/Date.) + year (.getFullYear date) + month (inc (.getMonth date)) + day (.getDate date) + hour (.getHours date) + minute (.getMinutes date)] + {:year year + :month month + :day day + :hour hour + :minute minute})) + +(defn dissoc-in + "Dissociates an entry from a nested associative structure returning a new + nested structure. keys is a sequence of keys. Any empty maps that result + will not be present in the new structure." + [m [k & ks :as keys]] + (if ks + (if-let [nextmap (get m k)] + (let [newmap (dissoc-in nextmap ks)] + (if (seq newmap) + (assoc m k newmap) + (dissoc m k))) + m) + (dissoc m k))) + +;; (defn format +;; [fmt & args] +;; (apply gstring/format fmt args)) + +(defn raw-html + [content] + [:div {:dangerouslySetInnerHTML + {:__html content}}]) + +(defn json->clj + [json-string] + (-> json-string + (js/JSON.parse) + (js->clj :keywordize-keys true))) + +(defn remove-nils + "remove pairs of key-value that has nil value from a (possibly nested) map. also transform map to nil if all of its value are nil" + [nm] + (walk/postwalk + (fn [el] + (if (map? el) + (not-empty (into {} (remove (comp nil? second)) el)) + el)) + nm)) + +(defn index-by + [col k] + (->> (map (fn [entry] [(get entry k) entry]) + col) + (into {}))) + +;; ".lg:absolute.lg:inset-y-0.lg:right-0.lg:w-1/2" +(defn hiccup->class + [class] + (some->> (string/split class #"\.") + (string/join " ") + (string/trim))) + +(defn fetch + ([url on-ok on-failed] + (fetch url #js {} on-ok on-failed)) + ([url opts on-ok on-failed] + (prn {:opts opts}) + (-> (js/fetch url opts) + (.then #(if (.-ok %) + (.json %) + (on-failed %))) + (.then bean/->clj) + (.then #(on-ok %))))) + +(defn post + [url body on-ok on-failed] + (fetch url (clj->js {:method "post" + :headers {:Content-Type "application/json"} + :body (js/JSON.stringify (clj->js body))}) + on-ok + on-failed)) + +(defn get-weekday + [date] + (.toLocaleString date "en-us" (clj->js {:weekday "long"}))) + +(defn get-date + [] + (let [date (js/Date.)] + {:year (.getFullYear date) + :month (inc (.getMonth date)) + :day (.getDate date) + :weekday (get-weekday date)})) + +(defn journals-path + [year month] + (let [month (if (< month 10) (str "0" month) month)] + (str "journals/" year "_" month ".org"))) + +(defn current-journal-path + [] + (let [{:keys [year month]} (get-date)] + (journals-path year month))) + +(defn today + [] + (.toLocaleDateString (js/Date.) "default" + (clj->js {:month "long" + :year "numeric" + :day "numeric" + :weekday "long"}))) + +(defn zero-pad + [n] + (if (< n 10) + (str "0" n) + (str n))) + +(defn year-month-day-padded + [] + (let [{:keys [year month day]} (get-date)] + {:year year + :month (zero-pad month) + :day (zero-pad day)})) + +(defn get-month-last-day + [] + (let [today (js/Date.) + date (js/Date. (.getFullYear today) (inc (.getMonth today)) 0)] + (.getDate date))) + +(defn parse-int + [x] + (if (string? x) + (js/parseInt x) + x)) + +(defn debounce + "Returns a function that will call f only after threshold has passed without new calls + to the function. Calls prep-fn on the args in a sync way, which can be used for things like + calling .persist on the event object to be able to access the event attributes in f" + ([threshold f] (debounce threshold f (constantly nil))) + ([threshold f prep-fn] + (let [t (atom nil)] + (fn [& args] + (when @t (js/clearTimeout @t)) + (apply prep-fn args) + (reset! t (js/setTimeout #(do + (reset! t nil) + (apply f args)) + threshold)))))) diff --git a/yarn.lock b/web/yarn.lock similarity index 100% rename from yarn.lock rename to web/yarn.lock