Browse Source

Add backend

Tienson Qin 5 years ago
parent
commit
ee10679a2f
54 changed files with 2529 additions and 0 deletions
  1. 12 0
      backend/.gitignore
  2. 24 0
      backend/CHANGELOG.md
  3. 277 0
      backend/LICENSE
  4. 22 0
      backend/README.md
  5. 89 0
      backend/dev/user.clj
  6. 3 0
      backend/doc/intro.md
  7. 35 0
      backend/project.clj
  8. 23 0
      backend/resources/config.edn
  9. 52 0
      backend/resources/logback.xml
  10. 24 0
      backend/src/backend/components/hikari.clj
  11. 26 0
      backend/src/backend/components/http.clj
  12. 12 0
      backend/src/backend/config.clj
  13. 58 0
      backend/src/backend/cookie.clj
  14. 21 0
      backend/src/backend/core.clj
  15. 16 0
      backend/src/backend/db_migrate.clj
  16. 23 0
      backend/src/backend/jwt.clj
  17. 103 0
      backend/src/backend/system.clj
  18. 82 0
      backend/src/backend/util.clj
  19. 7 0
      backend/test/backend/core_test.clj
  20. 21 0
      frontend/.gitignore
  21. 0 0
      frontend/deploy.sh
  22. 0 0
      frontend/dev/shadow/user.clj
  23. 0 0
      frontend/images/screenshot.png
  24. 0 0
      frontend/package-lock.json
  25. 0 0
      frontend/package.json
  26. 0 0
      frontend/public/css/highlight.css
  27. 0 0
      frontend/public/css/style.css
  28. 0 0
      frontend/public/index.html
  29. 0 0
      frontend/shadow-cljs.edn
  30. 98 0
      frontend/src/frontend/components/agenda.cljs
  31. 66 0
      frontend/src/frontend/components/file.cljs
  32. 73 0
      frontend/src/frontend/components/home.cljs
  33. 58 0
      frontend/src/frontend/components/link.cljs
  34. 65 0
      frontend/src/frontend/components/settings.cljs
  35. 7 0
      frontend/src/frontend/config.cljs
  36. 38 0
      frontend/src/frontend/core.cljs
  37. 176 0
      frontend/src/frontend/db.cljs
  38. 14 0
      frontend/src/frontend/format.cljs
  39. 10 0
      frontend/src/frontend/format/markdown.cljs
  40. 113 0
      frontend/src/frontend/format/org/block.cljs
  41. 29 0
      frontend/src/frontend/format/org_mode.cljs
  42. 4 0
      frontend/src/frontend/format/protocol.cljs
  43. 19 0
      frontend/src/frontend/fs.cljs
  44. 67 0
      frontend/src/frontend/git.cljs
  45. 370 0
      frontend/src/frontend/handler.cljs
  46. 71 0
      frontend/src/frontend/layout.cljs
  47. 112 0
      frontend/src/frontend/mui.cljs
  48. 12 0
      frontend/src/frontend/page.cljs
  49. 12 0
      frontend/src/frontend/routes.cljs
  50. 59 0
      frontend/src/frontend/rum.cljs
  51. 19 0
      frontend/src/frontend/state.cljs
  52. 19 0
      frontend/src/frontend/storage.cljs
  53. 88 0
      frontend/src/frontend/util.cljs
  54. 0 0
      frontend/yarn.lock

+ 12 - 0
backend/.gitignore

@@ -0,0 +1,12 @@
+/target
+/classes
+/checkouts
+profiles.clj
+pom.xml
+pom.xml.asc
+*.jar
+*.class
+/.lein-*
+/.nrepl-port
+.hgignore
+.hg/

+ 24 - 0
backend/CHANGELOG.md

@@ -0,0 +1,24 @@
+# Change Log
+All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/).
+
+## [Unreleased]
+### Changed
+- Add a new arity to `make-widget-async` to provide a different widget shape.
+
+## [0.1.1] - 2020-02-20
+### Changed
+- Documentation on how to make the widgets.
+
+### Removed
+- `make-widget-sync` - we're all async, all the time.
+
+### Fixed
+- Fixed widget maker to keep working when daylight savings switches over.
+
+## 0.1.0 - 2020-02-20
+### Added
+- Files from the new template.
+- Widget maker public API - `make-widget-sync`.
+
+[Unreleased]: https://github.com/your-name/backend/compare/0.1.1...HEAD
+[0.1.1]: https://github.com/your-name/backend/compare/0.1.0...0.1.1

+ 277 - 0
backend/LICENSE

@@ -0,0 +1,277 @@
+Eclipse Public License - v 2.0
+
+    THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+    PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+    OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+  a) in the case of the initial Contributor, the initial content
+     Distributed under this Agreement, and
+
+  b) in the case of each subsequent Contributor:
+     i) changes to the Program, and
+     ii) additions to the Program;
+  where such changes and/or additions to the Program originate from
+  and are Distributed by that particular Contributor. A Contribution
+  "originates" from a Contributor if it was added to the Program by
+  such Contributor itself or anyone acting on such Contributor's behalf.
+  Contributions do not include changes or additions to the Program that
+  are not Modified Works.
+
+"Contributor" means any person or entity that Distributes the Program.
+
+"Licensed Patents" mean patent claims licensable by a Contributor which
+are necessarily infringed by the use or sale of its Contribution alone
+or when combined with the Program.
+
+"Program" means the Contributions Distributed in accordance with this
+Agreement.
+
+"Recipient" means anyone who receives the Program under this Agreement
+or any Secondary License (as applicable), including Contributors.
+
+"Derivative Works" shall mean any work, whether in Source Code or other
+form, that is based on (or derived from) the Program and for which the
+editorial revisions, annotations, elaborations, or other modifications
+represent, as a whole, an original work of authorship.
+
+"Modified Works" shall mean any work in Source Code or other form that
+results from an addition to, deletion from, or modification of the
+contents of the Program, including, for purposes of clarity any new file
+in Source Code form that contains any contents of the Program. Modified
+Works shall not include works that contain only declarations,
+interfaces, types, classes, structures, or files of the Program solely
+in each case in order to link to, bind by name, or subclass the Program
+or Modified Works thereof.
+
+"Distribute" means the acts of a) distributing or b) making available
+in any manner that enables the transfer of a copy.
+
+"Source Code" means the form of a Program preferred for making
+modifications, including but not limited to software source code,
+documentation source, and configuration files.
+
+"Secondary License" means either the GNU General Public License,
+Version 2.0, or any later versions of that license, including any
+exceptions or additional permissions as identified by the initial
+Contributor.
+
+2. GRANT OF RIGHTS
+
+  a) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free copyright
+  license to reproduce, prepare Derivative Works of, publicly display,
+  publicly perform, Distribute and sublicense the Contribution of such
+  Contributor, if any, and such Derivative Works.
+
+  b) Subject to the terms of this Agreement, each Contributor hereby
+  grants Recipient a non-exclusive, worldwide, royalty-free patent
+  license under Licensed Patents to make, use, sell, offer to sell,
+  import and otherwise transfer the Contribution of such Contributor,
+  if any, in Source Code or other form. This patent license shall
+  apply to the combination of the Contribution and the Program if, at
+  the time the Contribution is added by the Contributor, such addition
+  of the Contribution causes such combination to be covered by the
+  Licensed Patents. The patent license shall not apply to any other
+  combinations which include the Contribution. No hardware per se is
+  licensed hereunder.
+
+  c) Recipient understands that although each Contributor grants the
+  licenses to its Contributions set forth herein, no assurances are
+  provided by any Contributor that the Program does not infringe the
+  patent or other intellectual property rights of any other entity.
+  Each Contributor disclaims any liability to Recipient for claims
+  brought by any other entity based on infringement of intellectual
+  property rights or otherwise. As a condition to exercising the
+  rights and licenses granted hereunder, each Recipient hereby
+  assumes sole responsibility to secure any other intellectual
+  property rights needed, if any. For example, if a third party
+  patent license is required to allow Recipient to Distribute the
+  Program, it is Recipient's responsibility to acquire that license
+  before distributing the Program.
+
+  d) Each Contributor represents that to its knowledge it has
+  sufficient copyright rights in its Contribution, if any, to grant
+  the copyright license set forth in this Agreement.
+
+  e) Notwithstanding the terms of any Secondary License, no
+  Contributor makes additional grants to any Recipient (other than
+  those set forth in this Agreement) as a result of such Recipient's
+  receipt of the Program under the terms of a Secondary License
+  (if permitted under the terms of Section 3).
+
+3. REQUIREMENTS
+
+3.1 If a Contributor Distributes the Program in any form, then:
+
+  a) the Program must also be made available as Source Code, in
+  accordance with section 3.2, and the Contributor must accompany
+  the Program with a statement that the Source Code for the Program
+  is available under this Agreement, and informs Recipients how to
+  obtain it in a reasonable manner on or through a medium customarily
+  used for software exchange; and
+
+  b) the Contributor may Distribute the Program under a license
+  different than this Agreement, provided that such license:
+     i) effectively disclaims on behalf of all other Contributors all
+     warranties and conditions, express and implied, including
+     warranties or conditions of title and non-infringement, and
+     implied warranties or conditions of merchantability and fitness
+     for a particular purpose;
+
+     ii) effectively excludes on behalf of all other Contributors all
+     liability for damages, including direct, indirect, special,
+     incidental and consequential damages, such as lost profits;
+
+     iii) does not attempt to limit or alter the recipients' rights
+     in the Source Code under section 3.2; and
+
+     iv) requires any subsequent distribution of the Program by any
+     party to be under a license that satisfies the requirements
+     of this section 3.
+
+3.2 When the Program is Distributed as Source Code:
+
+  a) it must be made available under this Agreement, or if the
+  Program (i) is combined with other material in a separate file or
+  files made available under a Secondary License, and (ii) the initial
+  Contributor attached to the Source Code the notice described in
+  Exhibit A of this Agreement, then the Program may be made available
+  under the terms of such Secondary Licenses, and
+
+  b) a copy of this Agreement must be included with each copy of
+  the Program.
+
+3.3 Contributors may not remove or alter any copyright, patent,
+trademark, attribution notices, disclaimers of warranty, or limitations
+of liability ("notices") contained within the Program from any copy of
+the Program which they Distribute, provided that Contributors may add
+their own appropriate notices.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain responsibilities
+with respect to end users, business partners and the like. While this
+license is intended to facilitate the commercial use of the Program,
+the Contributor who includes the Program in a commercial product
+offering should do so in a manner which does not create potential
+liability for other Contributors. Therefore, if a Contributor includes
+the Program in a commercial product offering, such Contributor
+("Commercial Contributor") hereby agrees to defend and indemnify every
+other Contributor ("Indemnified Contributor") against any losses,
+damages and costs (collectively "Losses") arising from claims, lawsuits
+and other legal actions brought by a third party against the Indemnified
+Contributor to the extent caused by the acts or omissions of such
+Commercial Contributor in connection with its distribution of the Program
+in a commercial product offering. The obligations in this section do not
+apply to any claims or Losses relating to any actual or alleged
+intellectual property infringement. In order to qualify, an Indemnified
+Contributor must: a) promptly notify the Commercial Contributor in
+writing of such claim, and b) allow the Commercial Contributor to control,
+and cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a commercial
+product offering, Product X. That Contributor is then a Commercial
+Contributor. If that Commercial Contributor then makes performance
+claims, or offers warranties related to Product X, those performance
+claims and warranties are such Commercial Contributor's responsibility
+alone. Under this section, the Commercial Contributor would have to
+defend claims against the other Contributors related to those performance
+claims and warranties, and if a court requires any other Contributor to
+pay any damages as a result, the Commercial Contributor must pay
+those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
+BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
+IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
+TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
+PURPOSE. Each Recipient is solely responsible for determining the
+appropriateness of using and distributing the Program and assumes all
+risks associated with its exercise of rights under this Agreement,
+including but not limited to the risks and costs of program errors,
+compliance with applicable laws, damage to or loss of data, programs
+or equipment, and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
+PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
+SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
+PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
+EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed to the
+minimum extent necessary to make such provision valid and enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging that the
+Program itself (excluding combinations of the Program with other software
+or hardware) infringes such Recipient's patent(s), then such Recipient's
+rights granted under Section 2(b) shall terminate as of the date such
+litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if it
+fails to comply with any of the material terms or conditions of this
+Agreement and does not cure such failure in a reasonable period of
+time after becoming aware of such noncompliance. If all Recipient's
+rights under this Agreement terminate, Recipient agrees to cease use
+and distribution of the Program as soon as reasonably practicable.
+However, Recipient's obligations under this Agreement and any licenses
+granted by Recipient relating to the Program shall continue and survive.
+
+Everyone is permitted to copy and distribute copies of this Agreement,
+but in order to avoid inconsistency the Agreement is copyrighted and
+may only be modified in the following manner. The Agreement Steward
+reserves the right to publish new versions (including revisions) of
+this Agreement from time to time. No one other than the Agreement
+Steward has the right to modify this Agreement. The Eclipse Foundation
+is the initial Agreement Steward. The Eclipse Foundation may assign the
+responsibility to serve as the Agreement Steward to a suitable separate
+entity. Each new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+Distributed subject to the version of the Agreement under which it was
+received. In addition, after a new version of the Agreement is published,
+Contributor may elect to Distribute the Program (including its
+Contributions) under the new version.
+
+Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
+receives no rights or licenses to the intellectual property of any
+Contributor under this Agreement, whether expressly, by implication,
+estoppel or otherwise. All rights in the Program not expressly granted
+under this Agreement are reserved. Nothing in this Agreement is intended
+to be enforceable by any entity that is not a Contributor or Recipient.
+No third-party beneficiary rights are created under this Agreement.
+
+Exhibit A - Form of Secondary Licenses Notice
+
+"This Source Code may also be made available under the following 
+Secondary Licenses when the conditions for such availability set forth 
+in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
+version(s), and exceptions or additional permissions here}."
+
+  Simply including a copy of this Agreement, including this Exhibit A
+  is not sufficient to license the Source Code under Secondary Licenses.
+
+  If it is not possible or desirable to put the notice in a particular
+  file, then You may include the notice in a location (such as a LICENSE
+  file in a relevant directory) where a recipient would be likely to
+  look for such a notice.
+
+  You may add additional accurate notices of copyright ownership.

+ 22 - 0
backend/README.md

@@ -0,0 +1,22 @@
+# backend
+
+A Clojure library designed to ... well, that part is up to you.
+
+## Usage
+
+FIXME
+
+## License
+
+Copyright © 2020 FIXME
+
+This program and the accompanying materials are made available under the
+terms of the Eclipse Public License 2.0 which is available at
+http://www.eclipse.org/legal/epl-2.0.
+
+This Source Code may also be made available under the following Secondary
+Licenses when the conditions for such availability set forth in the Eclipse
+Public License, v. 2.0 are satisfied: GNU General Public License as published by
+the Free Software Foundation, either version 2 of the License, or (at your
+option) any later version, with the GNU Classpath Exception which is available
+at https://www.gnu.org/software/classpath/license.html.

+ 89 - 0
backend/dev/user.clj

@@ -0,0 +1,89 @@
+(ns user
+  (:require [com.stuartsierra.component :as component]
+            [clojure.tools.namespace.repl :as namespace]
+            [backend.config :as config]
+            [backend.db-migrate :as migrate]
+            [io.pedestal.service-tools.dev :as dev]
+            [clj-time
+             [coerce :as tc]
+             [core :as t]]
+            [clojure.java.io :as io]
+            [clojure.string :as string]))
+
+(namespace/disable-reload!)
+(namespace/set-refresh-dirs "src" "dev")
+(defonce *system (atom nil))
+(defonce *db (atom nil))
+
+(defn migrate []
+  (migrate/migrate @*db))
+
+(defn rollback []
+  (migrate/rollback @*db))
+
+(defn stop []
+  (some-> @*system (component/stop))
+  (reset! *system nil))
+
+(defn refresh []
+  (let [res (namespace/refresh)]
+    (when (not= res :ok)
+      (throw res))
+    :ok))
+
+(defn go
+  []
+  (require 'backend.core)
+  (dev/watch)
+  (when-some [f (resolve 'backend.system/new-system)]
+    (when-some [system (f config/config)]
+      (when-some [system' (component/start system)]
+        (reset! *system system')
+        (reset! *db {:datasource (get-in @*system [:hikari :datasource])}))))
+  (migrate))
+
+(defn reset []
+  (stop)
+  (refresh)
+  (go))
+
+(defn get-unix-timestamp []
+  (tc/to-long (t/now)))
+
+(def date-format
+  "Format for DateTime"
+  "yyyyMMddHHmmss")
+(def migrations-dir
+  "Default migrations directory"
+  "resources/migrations/")
+(def ragtime-format-edn
+  "EDN template for SQL migrations"
+  "{:up [\"\"]\n :down [\"\"]}")
+
+(defn migrations-dir-exist?
+  "Checks if 'resources/migrations' directory exists"
+  []
+  (.isDirectory (io/file migrations-dir)))
+
+(defn now
+  "Gets the current DateTime"  []
+  (.format (java.text.SimpleDateFormat. date-format) (new java.util.Date)))
+
+(defn migration-file-path
+  "Complete migration file path"
+  [name]
+  (str migrations-dir (now) "_" (string/replace name #"\s+|-+|_+" "_") ".edn"))
+
+(defn create-migration
+  "Creates a migration file with the current DateTime"
+  [name]
+  (let [migration-file (migration-file-path name)]
+    (if-not (migrations-dir-exist?)
+      (io/make-parents migration-file))
+    (spit migration-file ragtime-format-edn)))
+
+(defn reset-db
+  []
+  (dotimes [i 100]
+    (rollback))
+  (migrate))

+ 3 - 0
backend/doc/intro.md

@@ -0,0 +1,3 @@
+# Introduction to backend
+
+TODO: write [great documentation](http://jacobian.org/writing/what-to-write/)

+ 35 - 0
backend/project.clj

@@ -0,0 +1,35 @@
+(defproject backend "0.1.0-SNAPSHOT"
+  :description "FIXME: write description"
+  :url "http://example.com/FIXME"
+  :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
+            :url "https://www.eclipse.org/legal/epl-2.0/"}
+  :dependencies [[org.clojure/clojure "1.10.0"]
+                 [clj-social "0.1.5"]
+                 [org.postgresql/postgresql "42.2.8"]
+                 [org.clojure/java.jdbc "0.7.10"]
+                 [honeysql "0.9.8"]
+                 [hikari-cp "2.9.0"]
+                 [toucan "1.15.0"]
+                 [ragtime "0.8.0"]
+                 [com.taoensso/timbre "4.10.0"]
+                 [org.clojure/tools.namespace "0.3.1"]
+                 [buddy/buddy-sign "3.1.0"]
+                 [buddy/buddy-hashers "1.4.0"]
+                 [enlive "1.1.6"]
+                 [io.pedestal/pedestal.service "0.5.5"]
+                 [io.pedestal/pedestal.jetty "0.5.5"]
+                 [metosin/reitit-pedestal "0.4.2"]
+                 [metosin/reitit "0.4.2"]
+                 [metosin/jsonista "0.2.5"]
+                 [aero "1.1.6"]
+                 [com.stuartsierra/component "0.4.0"]
+                 ]
+  ;; :main backend.core
+  :profiles {:repl {:dependencies [[io.pedestal/pedestal.service-tools "0.5.7"]]
+                    :source-paths ["src/backend" "dev"]}
+             :uberjar {:main backend.core
+                       :aot :all}}
+  :repl-options {:init-ns user}
+  :jvm-opts ["-Duser.timezone=UTC" "-Dclojure.spec.check-asserts=true"]
+  :aliases {"migrate"  ["run" "-m" "user/migrate"]
+            "rollback" ["run" "-m" "user/rollback"]})

+ 23 - 0
backend/resources/config.edn

@@ -0,0 +1,23 @@
+{:env #or [#env ENVIRONMENT "dev"]
+ :port #or [#env PORT 8080]
+ :oauth {:github {:app-key #env GITHUB_APP_KEY
+                  :app-secret #env GITHUB_APP_SECRET}}
+ :jwt-secret #env JWT_SECRET
+ :cookie-secret #env COOKIE_SECRET
+ :log-path #or [#env LOG_PATH "/tmp/gitnotes"]
+ :hikari-spec {:auto-commit        true
+               :read-only          false
+               :connection-timeout 30000
+               :validation-timeout 5000
+               :idle-timeout       600000
+               :max-lifetime       1800000
+               :minimum-idle       10
+               :maximum-pool-size  48
+               :pool-name          "gitnotes-clj-db-pool"
+               :adapter            "postgresql"
+               :username           #env PG_USERNAME
+               :password           #env PG_PASSWORD
+               :database-name      "gitnotes"
+               :server-name        "localhost"
+               :port-number        5432
+               :register-mbeans    false}}

+ 52 - 0
backend/resources/logback.xml

@@ -0,0 +1,52 @@
+<!-- Logback configuration. See http://logback.qos.ch/manual/index.html -->
+<!-- Scanning is currently turned on; This will impact performance! -->
+<configuration scan="true" scanPeriod="10 seconds">
+  <!-- Silence Logback's own status messages about config parsing
+  <statusListener class="ch.qos.logback.core.status.NopStatusListener" /> -->
+
+  <!-- Simple file output -->
+  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+    <!-- encoder defaults to ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
+    <encoder>
+      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{io.pedestal} - %msg%n</pattern>
+    </encoder>
+
+    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+      <!-- rollover daily -->
+      <fileNamePattern>logs/restream-api-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+      <!-- or whenever the file size reaches 64 MB -->
+      <maxFileSize>64 MB</maxFileSize>
+    </rollingPolicy>
+
+    <!-- Safely log to the same file from multiple JVMs. Degrades performance! -->
+    <prudent>true</prudent>
+  </appender>
+
+
+  <!-- Console output -->
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <!-- encoder defaults to ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
+    <encoder>
+      <pattern>%-5level %logger{36} %X{io.pedestal} - %msg%n</pattern>
+    </encoder>
+    <!-- Only log level INFO and above -->
+    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+      <level>INFO</level>
+    </filter>
+  </appender>
+
+
+  <!-- Enable FILE and STDOUT appenders for all log messages.
+       By default, only log at level INFO and above. -->
+  <root level="INFO">
+    <appender-ref ref="FILE" />
+    <appender-ref ref="STDOUT" />
+  </root>
+
+  <!-- For loggers in the these namespaces, log at all levels. -->
+  <logger name="user" level="ALL" />
+  <!-- To log pedestal internals, enable this and change ThresholdFilter to DEBUG
+    <logger name="io.pedestal" level="ALL" />
+  -->
+
+</configuration>

+ 24 - 0
backend/src/backend/components/hikari.clj

@@ -0,0 +1,24 @@
+(ns backend.components.hikari
+  (:require [com.stuartsierra.component :as component]
+            [hikari-cp.core :as hikari]
+            [clojure.java.jdbc :as j]
+            [toucan.db :as toucan]
+            [backend.db-migrate :as migrate]))
+
+(defrecord Hikari [db-spec datasource]
+  component/Lifecycle
+  (start [component]
+    (let [s (or datasource (hikari/make-datasource db-spec))]
+      ;; set time zone
+      (j/execute! {:datasource s} ["set time zone 'UTC'"])
+      ;; migrate
+      (migrate/migrate {:datasource s})
+      (toucan/set-default-db-connection! {:datasource s})
+      (assoc component :datasource s)))
+  (stop [component]
+    (when datasource
+      (hikari/close-datasource datasource))
+    (assoc component :datasource nil)))
+
+(defn new-hikari-cp [db-spec]
+  (map->Hikari {:db-spec db-spec}))

+ 26 - 0
backend/src/backend/components/http.clj

@@ -0,0 +1,26 @@
+(ns backend.components.http
+  (:require [com.stuartsierra.component :as component]
+            [io.pedestal.http :as http]))
+
+(defn test?
+  [service-map]
+  (= :test (:env service-map)))
+
+(defrecord Server [service-map service]
+  component/Lifecycle
+  (start [this]
+    (prn "service-map: " service-map)
+    (if service
+      this
+      (cond-> service-map
+        true                      http/create-server
+        (not (test? service-map)) http/start
+        true                      ((partial assoc this :service)))))
+  (stop [this]
+    (when (and service (not (test? service-map)))
+      (http/stop service))
+    (assoc this :service nil)))
+
+(defn new-server
+  []
+  (map->Server {}))

+ 12 - 0
backend/src/backend/config.clj

@@ -0,0 +1,12 @@
+(ns backend.config
+  (:require [aero.core :refer (read-config)]
+            [clojure.java.io :as io]))
+
+(def config (read-config (io/resource "config.edn")))
+
+(def production? (= "production" (:env config)))
+(def dev? (= "dev" (:env config)))
+(def test? (= "test" (:env config)))
+(def website-uri (if dev?
+                   "http://localhost:8080"
+                   "https://gitnotes.com"))

+ 58 - 0
backend/src/backend/cookie.clj

@@ -0,0 +1,58 @@
+(ns backend.cookie
+  (:require [buddy.sign.compact :as buddy]
+            [backend.util :as util]
+            [backend.config :as config]))
+
+(defn sign [token]
+  (buddy/sign token (:cookie-secret config/config)))
+
+(defn unsign [cookie]
+  (buddy/unsign cookie (:cookie-secret config/config)))
+
+;; domain path expires
+(defn token-cookie [value & {:keys [max-age path]
+                             :or {path "/"
+                                  max-age (* (* 3600 24) 30)}}]
+  (let [dev? config/dev?
+        xsrf-token (str (util/uuid))
+        domain (if-not dev?
+                 ".chengdongchengxi.com"
+                 "")
+        secure (if-not dev?
+                 true
+                 false)]
+    {"x" (cond->
+           {:value   (sign value)
+            :max-age max-age
+            :http-only true
+            :path path
+            :secure secure}
+           domain
+           (assoc :domain domain))
+     "xsrf-token" (cond->
+                    {:value xsrf-token
+                     :max-age max-age
+                     :http-only true
+                     :path "/"
+                     :secure secure}
+                    domain
+                    (assoc :domain domain))}))
+
+(def delete-token
+  (let [domain (if-not config/dev?
+                 ".chengdongchengxi.com"
+                 "")]
+    {"x" {:value ""
+          :path "/"
+          :expires "Thu, 01 Jan 1970 00:00:00 GMT"
+          :http-only true
+          :domain domain}
+     "xsrf-token" {:value ""
+                   :path "/"
+                   :expires "Thu, 01 Jan 1970 00:00:00 GMT"
+                   :http-only true
+                   :domain domain}}))
+
+(defn get-token [req]
+  (when-let [access-token (get-in req [:cookies "x" :value])]
+    (unsign access-token)))

+ 21 - 0
backend/src/backend/core.clj

@@ -0,0 +1,21 @@
+(ns backend.core
+  (:require [backend.config :as config]
+            [backend.system :as system]
+            [taoensso.timbre :as timbre]
+            [taoensso.timbre.appenders.core :as appenders]
+            [com.stuartsierra.component :as component]))
+
+(defn set-logger!
+  [log-path]
+  (timbre/merge-config! (cond->
+                          {:appenders {:spit (appenders/spit-appender {:fname log-path})}}
+                          config/production?
+                          (assoc :output-fn (partial timbre/default-output-fn {:stacktrace-fonts {}})))))
+
+(defn start []
+  (System/setProperty "https.protocols" "TLSv1.2,TLSv1.1,SSLv3")
+  (set-logger! (:log-path config/config))
+
+  (let [system (system/new-system config/config)]
+    (component/start system))
+  (println "server running in port 3000"))

+ 16 - 0
backend/src/backend/db_migrate.clj

@@ -0,0 +1,16 @@
+(ns backend.db-migrate
+  (:require [ragtime.jdbc :as jdbc]
+            [ragtime.repl :as repl]))
+
+;; db migrations
+(defn load-config
+  [db]
+  {:datastore  (jdbc/sql-database db)
+   :migrations (jdbc/load-resources "migrations")})
+
+(defn migrate [db]
+  (prn "db: " db)
+  (repl/migrate (load-config db)))
+
+(defn rollback [db]
+  (repl/rollback (load-config db)))

+ 23 - 0
backend/src/backend/jwt.clj

@@ -0,0 +1,23 @@
+(ns api.jwt
+  (:require [buddy.sign.jwt :as jwt]
+            [clj-time.core :as time]
+            [backend.config :refer [config]]))
+
+(defonce secret (:jwt-secret config))
+
+(defn sign
+  "Serialize and sign a token with defined claims"
+  ([m]
+   (sign m (* 60 60 12)))
+  ([m expire-secs]
+   (let [claims (assoc m
+                       :exp (time/plus (time/now) (time/seconds expire-secs)))]
+     (jwt/sign claims secret))))
+
+(defn unsign
+  [token]
+  (jwt/unsign token secret))
+
+(defn unsign-skip-validation
+  [token]
+  (jwt/unsign token secret {:skip-validation true}))

+ 103 - 0
backend/src/backend/system.clj

@@ -0,0 +1,103 @@
+(ns backend.system
+  (:require [io.pedestal.http :as server]
+            [reitit.ring :as ring]
+            [reitit.http :as http]
+            [reitit.coercion.spec]
+            [reitit.swagger :as swagger]
+            [reitit.swagger-ui :as swagger-ui]
+            [reitit.http.coercion :as coercion]
+            [reitit.dev.pretty :as pretty]
+            [reitit.http.interceptors.parameters :as parameters]
+            [reitit.http.interceptors.muuntaja :as muuntaja]
+            [reitit.http.interceptors.exception :as exception]
+            [reitit.http.interceptors.multipart :as multipart]
+            [reitit.http.interceptors.dev :as dev]
+            [reitit.http.spec :as spec]
+            [spec-tools.spell :as spell]
+            [io.pedestal.http :as server]
+            [reitit.pedestal :as pedestal]
+            [clojure.core.async :as a]
+            [clojure.java.io :as io]
+            [muuntaja.core :as m]
+            [com.stuartsierra.component :as component]
+            [backend.components.http :as component-http]
+            [backend.components.hikari :as hikari]))
+
+(def router
+  (pedestal/routing-interceptor
+    (http/router
+      [["/swagger.json"
+        {:get {:no-doc true
+               :swagger {:info {:title "gitnotes api"
+                                :description "with pedestal & reitit-http"}}
+               :handler (swagger/create-swagger-handler)}}]
+
+       ["/login"
+        {:swagger {:tags ["Login"]}}
+
+        ["/github"
+         {:get {:summary "Login with github"
+                :swagger {:produces ["image/png"]}
+                :handler (fn [_]
+                           {:status 200
+                            :headers {"Content-Type" "image/png"}
+                            :body (io/input-stream
+                                    (io/resource "reitit.png"))})}}]]       ]
+
+      {;:reitit.interceptor/transform dev/print-context-diffs ;; pretty context diffs
+       ;;:validate spec/validate ;; enable spec validation for route data
+       ;;:reitit.spec/wrap spell/closed ;; strict top-level validation
+       :exception pretty/exception
+       :data {:coercion reitit.coercion.spec/coercion
+              :muuntaja m/instance
+              :interceptors [;; swagger feature
+                             swagger/swagger-feature
+                             ;; query-params & form-params
+                             (parameters/parameters-interceptor)
+                             ;; content-negotiation
+                             (muuntaja/format-negotiate-interceptor)
+                             ;; encoding response body
+                             (muuntaja/format-response-interceptor)
+                             ;; exception handling
+                             (exception/exception-interceptor)
+                             ;; decoding request body
+                             (muuntaja/format-request-interceptor)
+                             ;; coercing response bodys
+                             (coercion/coerce-response-interceptor)
+                             ;; coercing request parameters
+                             (coercion/coerce-request-interceptor)
+                             ;; multipart
+                             (multipart/multipart-interceptor)]}})
+
+    ;; optional default ring handler (if no routes have matched)
+    (ring/routes
+      (swagger-ui/create-swagger-ui-handler
+        {:path "/"
+         :config {:validatorUrl nil
+                  :operationsSorter "alpha"}})
+      (ring/create-resource-handler)
+      (ring/create-default-handler))))
+
+(defn new-system
+  [{:keys [env port hikari-spec] :as config}]
+  (let [service-map (-> {:env env
+                         ::server/type :jetty
+                         ::server/port port
+                         ::server/join? false
+                         ;; no pedestal routes
+                         ::server/routes []
+                         ;; allow serving the swagger-ui styles & scripts from self
+                         ::server/secure-headers {:content-security-policy-settings
+                                                  {:default-src "'self'"
+                                                   :style-src "'self' 'unsafe-inline'"
+                                                   :script-src "'self' 'unsafe-inline'"}}}
+                        (server/default-interceptors)
+                        ;; use the reitit router
+                        (pedestal/replace-last-interceptor router)
+                        (server/dev-interceptors))]
+    (component/system-map :service-map service-map
+                          :hikari (hikari/new-hikari-cp hikari-spec)
+                          :http
+                          (component/using
+                           (component-http/new-server)
+                           [:service-map]))))

+ 82 - 0
backend/src/backend/util.clj

@@ -0,0 +1,82 @@
+(ns api.util
+  (:require [clojure.string :as str]
+            [clj-time
+             [coerce :as tc]
+             [core :as t]
+             [format :as tf]])
+  (:import  [java.util UUID]
+            [java.util TimerTask Timer]))
+(defn uuid
+  "Generate uuid."
+  []
+  (UUID/randomUUID))
+
+(defn ->uuid
+  [s]
+  (if (uuid? s)
+    s
+    (UUID/fromString s)))
+
+(defn update-if
+  "Update m if k exists."
+  [m k f]
+  (if-let [v (get m k)]
+    (assoc m k (f v))
+    m))
+
+(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)))
+
+(defmacro doseq-indexed
+  "loops over a set of values, binding index-sym to the 0-based index of each value"
+  ([[val-sym values index-sym] & code]
+   `(loop [vals# (seq ~values)
+           ~index-sym (long 0)]
+      (if vals#
+        (let [~val-sym (first vals#)]
+          ~@code
+          (recur (next vals#) (inc ~index-sym)))
+        nil))))
+
+(defn indexed [coll] (map-indexed vector coll))
+
+(defn set-timeout [f interval]
+  (let [task (proxy [TimerTask] []
+               (run [] (f)))
+        timer (new Timer)]
+    (.schedule timer task (long interval))
+    timer))
+
+;; http://yellerapp.com/posts/2014-12-11-14-race-condition-in-clojure-println.html
+(defn safe-println [& more]
+  (.write *out* (str (clojure.string/join " " more) "\n")))
+
+(defn safe->int
+  [s]
+  (if (string? s)
+    (Integer/parseInt s)
+    s))
+
+(defn remove-nils
+  [m]
+  (reduce (fn [acc [k v]] (if v (assoc acc k v)
+                              acc))
+          {} m))
+
+(defn deep-merge [& maps]
+  (apply merge-with (fn [& args]
+                      (if (every? map? args)
+                        (apply deep-merge args)
+                        (last args)))
+    maps))

+ 7 - 0
backend/test/backend/core_test.clj

@@ -0,0 +1,7 @@
+(ns backend.core-test
+  (:require [clojure.test :refer :all]
+            [backend.core :refer :all]))
+
+(deftest a-test
+  (testing "FIXME, I fail."
+    (is (= 0 1))))

+ 21 - 0
frontend/.gitignore

@@ -0,0 +1,21 @@
+node_modules/
+public/js
+
+/.cpcache
+/target
+/checkouts
+/src/gen
+
+pom.xml
+pom.xml.asc
+*.iml
+*.jar
+*.log
+.shadow-cljs
+.idea
+.lein-*
+.nrepl-*
+.DS_Store
+
+.hgignore
+.hg/

+ 0 - 0
deploy.sh → frontend/deploy.sh


+ 0 - 0
dev/shadow/user.clj → frontend/dev/shadow/user.clj


+ 0 - 0
images/screenshot.png → frontend/images/screenshot.png


+ 0 - 0
package-lock.json → frontend/package-lock.json


+ 0 - 0
package.json → frontend/package.json


+ 0 - 0
public/css/highlight.css → frontend/public/css/highlight.css


+ 0 - 0
public/css/style.css → frontend/public/css/style.css


+ 0 - 0
public/index.html → frontend/public/index.html


+ 0 - 0
shadow-cljs.edn → frontend/shadow-cljs.edn


+ 98 - 0
frontend/src/frontend/components/agenda.cljs

@@ -0,0 +1,98 @@
+(ns frontend.components.agenda
+  (:require [rum.core :as rum]
+            [frontend.mui :as mui]
+            [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]))
+
+(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 marker-cp
+  [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 [tag tags]
+     [:span.tag {:key tag}
+      [:span
+       tag]])])
+
+(rum/defc agenda
+  [tasks]
+  [:span "TBD"]
+  ;; [:div#agenda
+  ;;  (if (seq tasks)
+  ;;    (for [[section-name tasks] tasks]
+  ;;      [:div.section {:key (str "section-" section-name)}
+  ;;       [:h3 section-name]
+  ;;       (mui/list
+  ;;        (for [[idx {:keys [marker title priority level tags children timestamps meta]}] (util/indexed (block/sort-tasks tasks))]
+  ;;          (mui/list-item
+  ;;           {:key (str "task-" section-name "-" idx)
+  ;;            :style {:padding-left 8
+  ;;                    :padding-right 8}}
+  ;;           [:div.column
+  ;;            [:div.row {:style {:align-items "center"}}
+  ;;             (let [marker (case marker
+  ;;                            (list "DOING" "IN-PROGRESS" "TODO")
+  ;;                            (mui/checkbox {:checked false
+  ;;                                           :on-change (fn [_]
+  ;;                                                        ;; FIXME: Log timestamp
+  ;;                                                        (handler/check marker (:pos meta)))
+  ;;                                           :color "primary"
+  ;;                                           :style {:padding 0}})
+
+  ;;                            "WAIT"
+  ;;                            [:span {:style {:font-weight "bold"}}
+  ;;                             "WAIT"]
+
+  ;;                            "DONE"
+  ;;                            (mui/checkbox {:checked true
+  ;;                                           :on-change (fn [_]
+  ;;                                                        ;; FIXME: rollback to the last state if exists.
+  ;;                                                        ;; it must not be `TODO`
+  ;;                                                        (handler/uncheck (:pos meta)))
+  ;;                                           :color "primary"
+  ;;                                           :style {:padding 0}})
+
+  ;;                            nil)]
+  ;;               (if priority
+  ;;                 (mui/badge {:badge-content (string/lower-case priority)
+  ;;                             :overlay "circle"}
+  ;;                            marker)
+  ;;                 marker))
+
+  ;;             [:div.row {:style {:margin-left 8}}
+  ;;              (title-cp title)
+  ;;              (marker-cp marker)
+  ;;              (when (seq tags)
+  ;;                (tags-cp tags))]]
+  ;;            (when (seq timestamps)
+  ;;              (timestamps-cp timestamps))
+  ;;            ])))])
+  ;;    "Empty")]
+  )

+ 66 - 0
frontend/src/frontend/components/file.cljs

@@ -0,0 +1,66 @@
+(ns frontend.components.file
+  (:require [rum.core :as rum]
+            [frontend.mui :as mui]
+            ["@material-ui/core/colors" :as colors]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.handler :as handler]
+            [clojure.string :as string]))
+
+(rum/defc files-list
+  [files]
+  [:div
+   (if (seq files)
+     (let [files-set (set files)
+           prefix [(files-set "tasks.org") (files-set "links.org")]
+           files (->> (remove (set prefix) files)
+                      (concat prefix)
+                      (remove nil?))]
+       (mui/list
+        (for [file files]
+          (mui/list-item
+           {:button true
+            :key file
+            :style {:overflow "hidden"}
+            :on-click (fn []
+                        (handler/load-file file)
+                        (handler/toggle-drawer? false))}
+           (mui/list-item-text file)))))
+     "Loading...")])
+
+(rum/defc edit < rum/reactive
+  []
+  (let [state (rum/react state/state)
+        {:keys [current-file contents]} state]
+    (mui/container
+     {:id "root-container"
+      :style {:display "flex"
+              :justify-content "center"
+              :margin-top 64}}
+     [:div.column
+      (let [paths [:editing-files current-file]]
+        (mui/textarea {:style {:margin-bottom 12
+                               :padding 8
+                               :min-height 300}
+                       :auto-focus true
+                       :on-change (fn [event]
+                                    (let [v (util/evalue event)]
+                                      (swap! state/state assoc-in paths v)))
+                       :default-value (get contents current-file)
+                       :value (get-in state/state paths)}))
+      (let [path [:commit-message current-file]]
+        (mui/text-field {:id "standard-basic"
+                        :style {:margin-bottom 12}
+                        :label "Commit message"
+                        :auto-focus true
+                        :on-change (fn [event]
+                                     (let [v (util/evalue event)]
+                                       (when-not (string/blank? v)
+                                         (swap! state/state assoc-in path v))))
+                        :default-value (str "Update " current-file)
+                        :value (get-in state/state path)}))
+      (mui/button {:variant "contained"
+                   :color "primary"
+                   :on-click (fn []
+                               (handler/alter-file current-file))}
+        "Submit")])))

+ 73 - 0
frontend/src/frontend/components/home.cljs

@@ -0,0 +1,73 @@
+(ns frontend.components.home
+  (:require [rum.core :as rum]
+            [frontend.mui :as mui]
+            ["@material-ui/core/colors" :as colors]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.handler :as handler]
+            [frontend.components.agenda :as agenda]
+            [frontend.components.file :as file]
+            [frontend.components.settings :as settings]
+            [frontend.format :as format]
+            [clojure.string :as string]))
+
+(rum/defc content-html
+  < {:did-mount (fn [state]
+                  (doseq [block (-> (js/document.querySelectorAll "pre code")
+                                    (array-seq))]
+                    (js/hljs.highlightBlock block))
+                  state)}
+  [current-file html-content]
+  [:div
+   (mui/link {:style {:float "right"}
+              :on-click (fn []
+                          (handler/change-page :edit-file))}
+     "edit")
+   (util/raw-html html-content)])
+
+(rum/defc home < rum/reactive
+  []
+  (let [state (rum/react state/state)
+        {:keys [cloned? github-username github-token github-repo contents loadings current-file files width drawer? tasks links cloning?]} state
+        loading? (get loadings current-file)
+        width (or width (util/get-width))
+        mobile? (and width (<= width 600))]
+    (mui/container
+     {:id "root-container"
+      :style {:display "flex"
+              :justify-content "center"
+              ;; TODO: fewer spacing for mobile, 24px
+              :margin-top 64}}
+     (cond
+       cloned?
+       (mui/grid
+        {:container true
+         :spacing 3}
+        (when-not mobile?
+          (mui/grid {:xs 2}
+                    (file/files-list files)))
+
+        (if (and (not mobile?)
+                 (not drawer?))
+          (mui/divider {:orientation "vertical"
+                        :style {:margin "0 24px"}}))
+        (mui/grid {:xs 9
+                   :style {:margin-left (if (or mobile? drawer?) 24 0)}}
+                  (cond
+                    (nil? current-file)
+                    (agenda/agenda tasks)
+
+                    loading?
+                    [:div "Loading ..."]
+
+                    :else
+                    (let [content (get contents current-file)
+                          suffix (last (string/split current-file #"\."))]
+                      (if (and suffix (contains? #{"md" "markdown" "org"} suffix))
+                        (content-html current-file (format/to-html content suffix))
+                        [:div "File " suffix " is not supported."])))))
+       cloning?
+       [:div "Cloning..."]
+
+       :else
+       (settings/settings-form github-username github-token github-repo)))))

+ 58 - 0
frontend/src/frontend/components/link.cljs

@@ -0,0 +1,58 @@
+(ns frontend.components.link
+  (: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]))
+
+(rum/defc links < rum/reactive
+  []
+  (let [state (rum/react state/state)
+        links (reverse (get state :links))]
+    (mui/container
+     {:id "root-container"
+      :style {:display "flex"
+              :justify-content "center"
+              ;; TODO: fewer spacing for mobile, 24px
+              :margin-top 64}}
+     (if (seq links)
+       (mui/list
+        (for [[idx link] (util/indexed links)]
+          (mui/list-item
+           {:key (str "link-" idx)}
+           (mui/list-item-text
+            [:a {:href link
+                 :target "_blank"}
+             link]))))
+       [:div "Loading..."]))))
+
+(rum/defcs dialog < (rum/local "" :link)
+  [state open?]
+  (let [link (get state :link)]
+    (mui/dialog
+    {:open open?
+     :on-close (fn []
+                 (handler/toggle-link-dialog? false))}
+    (mui/dialog-title "Add new link")
+    (mui/dialog-content
+     (mui/text-field
+      {:auto-focus true
+       :auto-complete "off"
+       :margin "dense"
+       :id "link"
+       :label "Link"
+       :full-width true
+       :value @link
+       :on-change (fn [e] (reset! link (util/evalue e)))}))
+    (mui/dialog-actions
+     (mui/button {:on-click (fn []
+                              (handler/toggle-link-dialog? false))
+                  :color "primary"}
+       "Cancel")
+     (mui/button {:on-click (fn []
+                              (when-not (string/blank? @link)
+                                (handler/add-new-link @link
+                                                      "New link")))
+                  :color "primary"}
+       "Submit")))))

+ 65 - 0
frontend/src/frontend/components/settings.cljs

@@ -0,0 +1,65 @@
+(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-username 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 username"
+                          :auto-focus true
+                          :on-change (fn [event]
+                                       (let [v (util/evalue event)]
+                                         (swap! state/state assoc :github-username v)))
+                          :value github-username})
+         (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/text-field {:id "standard-basic"
+                          :style {:margin-bottom 12}
+                          :label "Github basic token"
+                          :on-change (fn [event]
+                                       (let [v (util/evalue event)]
+                                         (swap! state/state assoc :github-token v)))
+                          :value github-token})
+         (mui/button {:variant "contained"
+                      :color "primary"
+                      :on-click (fn []
+                                  (when (and github-token github-repo)
+                                    (handler/clone github-username github-token github-repo)))}
+           "Sync"))])
+
+(rum/defc settings < rum/reactive
+  []
+  ;; Change username, repo and basic token
+  (let [state (rum/react state/state)
+        {:keys [github-username github-token github-repo]} state]
+    (mui/container
+     {:id "root-container"
+      :style {:display "flex"
+              :justify-content "center"
+              :margin-top 64}}
+
+     [:div
+
+      (settings-form github-username 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")])))

+ 7 - 0
frontend/src/frontend/config.cljs

@@ -0,0 +1,7 @@
+(ns frontend.config)
+
+(defonce dir "/gitnotes")
+
+(defonce tasks-org "tasks.org")
+(defonce links-org "links.org")
+(defonce hidden-file ".hidden")

+ 38 - 0
frontend/src/frontend/core.cljs

@@ -0,0 +1,38 @@
+(ns frontend.core
+  (:require [rum.core :as rum]
+            [frontend.git :as git]
+            [frontend.fs :as fs]
+            [frontend.util :as util]
+            [frontend.state :as state]
+            [frontend.handler :as handler]
+            [frontend.routes :as routes]
+            [frontend.page :as page]))
+
+(defn start []
+  (rum/mount (page/current-page)
+             (.getElementById js/document "root")))
+
+(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/load-from-disk)
+
+  (when (:cloned? @state/state)
+    (handler/initial-db!)
+    (handler/periodically-pull)
+    (handler/periodically-push-tasks))
+
+  (handler/listen-to-resize)
+
+  (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"))

+ 176 - 0
frontend/src/frontend/db.cljs

@@ -0,0 +1,176 @@
+(ns frontend.db
+  (:require [datascript.core :as d]
+            [frontend.util :as util]
+            [medley.core :as medley]))
+
+(def conn (d/create-conn))
+
+;; links
+[:link/id
+ :link/label
+ :link/link]
+
+;; 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
+   :parent-title :heading/parent-title})
+
+(def schema
+  [{:db/ident       :heading/uuid
+    :db/valueType   :db.type/uuid
+    :db/cardinality :db.cardinality/one
+    :db/unique      :db.unique/value}
+
+   {:db/ident       :heading/file
+    :db/valueType   :db.type/string
+    :db/cardinality :db.cardinality/one}
+
+   {:db/ident       :heading/anchor
+    :db/valueType   :db.type/string
+    :db/cardinality :db.cardinality/one}
+
+   {:db/ident       :heading/marker
+    :db/valueType   :db.type/string
+    :db/cardinality :db.cardinality/one}
+
+   {:db/ident       :heading/priority
+    :db/valueType   :db.type/string
+    :db/cardinality :db.cardinality/one}
+
+   {:db/ident       :heading/level
+    :db/valueType   :db.type/long
+    :db/cardinality :db.cardinality/one}
+
+   {:db/ident       :heading/tags
+    :db/valueType   :db.type/ref
+    :db/cardinality :db.cardinality/many
+    :db/isComponent true}               ;TODO: not working as Datomic, can't search :tag/name in datalog queries
+
+   {:db/ident       :task/scheduled
+    ;; :db/valueType   :db.type/string
+    :db/index       true}
+
+   {:db/ident       :task/deadline
+    ;; :db/valueType   :db.type/string
+    :db/index       true}
+
+   {:db/ident       :tag/name
+    :db/valueType   :db.type/string
+    :db/cardinality :db.cardinality/one
+    :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))
+
+(defn init
+  []
+  (d/transact! conn [{:tx-data schema}]))
+
+;; transactions
+(defn transact-headings!
+  [headings]
+  (prn "headings: " headings)
+  (let [headings (safe-headings headings)]
+    (d/transact! conn 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-all-headings
+  []
+  (seq-flatten
+   (d/q '[:find (pull ?h [*])
+          :where
+          [?h :heading/title]]
+     @conn)))
+
+;; marker should be one of: TODO, DOING, IN-PROGRESS
+;; time duration
+(defn get-agenda
+  [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"])]
+      @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)))
+
+(comment
+  (frontend.handler/initial-db!)
+  )

+ 14 - 0
frontend/src/frontend/format.cljs

@@ -0,0 +1,14 @@
+(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]
+  (when-let [record (case suffix
+                 "org"
+                 (->OrgMode content)
+                 (list "md" "markdown")
+                 (->Markdown content)
+                 nil)]
+    (protocol/toHtml record)))

+ 10 - 0
frontend/src/frontend/format/markdown.cljs

@@ -0,0 +1,10 @@
+(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)))

+ 113 - 0
frontend/src/frontend/format/org/block.cljs

@@ -0,0 +1,113 @@
+(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]
+  [blocks]
+  (let [reversed-blocks (reverse blocks)]
+    (loop [child-level 0
+           current-heading-children []
+           children-headings []
+           result []
+           rblocks reversed-blocks
+           timestamps {}]
+      (if (seq rblocks)
+        (let [block (first rblocks)
+              level (:level (second block))]
+          (cond
+            (and (>= level child-level) (heading-block? block))
+            (let [heading (assoc (second block)
+                                 :children (reverse current-heading-children)
+                                 :timestamps timestamps)
+                  children-headings (conj children-headings heading)]
+              (recur level [] children-headings result (rest rblocks) {}))
+
+            (paragraph-timestamp-block? block)
+            (let [timestamp (extract-timestamp block)
+                  timestamps' (conj timestamps timestamp)]
+              (recur child-level current-heading-children children-headings result (rest rblocks) timestamps'))
+
+            :else
+            (let [children (conj current-heading-children block)]
+              (if (and level (< level child-level))
+                (let [parent-title (extract-title block)
+                      children-headings (map (fn [heading]
+                                               (assoc heading :parent-title parent-title))
+                                          children-headings)
+                      result (concat result children-headings)]
+                  (recur 0 children [] result (rest rblocks) timestamps))
+                (recur child-level children children-headings result (rest rblocks) timestamps)))))
+        (reverse result)))))
+
+(defn get-sections
+  [section-headings]
+  (map first section-headings))
+
+(defn get-section-headings
+  [section-name section-headings]
+  (-> (util/find-first
+       (fn [[name headings]]
+         (= name section-name))
+       section-headings)
+      second))
+
+;; 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 (:marker t1) 0)
+                  m2 (get markers (:marker t2) 0)
+                  p1 (get priorities (:priority t1) 0)
+                  p2 (get priorities (:priority t2) 0)]
+              (if (= m1 m2)
+                (> p1 p2)
+                (> m1 m2))))
+          headings)))

+ 29 - 0
frontend/src/frontend/format/org_mode.cljs

@@ -0,0 +1,29 @@
+(ns frontend.format.org-mode
+  (:require ["mldoc_org" :as org]
+            [frontend.format.protocol :as protocol]))
+
+(defrecord OrgMode [content]
+  protocol/Format
+  (toHtml [this]
+    (.parseHtml (.-MldocOrg org) content)))
+
+(defn parse-json
+  [content]
+  (.parseJson (.-MldocOrg org) content))
+
+(defn json->ast
+  [json]
+  (.jsonToAst (.-MldocOrg org) json))
+
+(defn json->html
+  [json]
+  (.jsonToHtmlStr (.-MldocOrg org) json))
+
+(defn inline-list->html
+  [json]
+  (.inlineListToHtmlStr (.-MldocOrg org) json))
+
+(comment
+  (let [text "*** TODO /*great*/ [[https://personal.utdallas.edu/~gupta/courses/acl/papers/datalog-paper.pdf][What You Always Wanted to Know About Datalog]] :datalog:"
+        blocks-json (parse-json text)]
+    (json->html blocks-json)))

+ 4 - 0
frontend/src/frontend/format/protocol.cljs

@@ -0,0 +1,4 @@
+(ns frontend.format.protocol)
+
+(defprotocol Format
+  (toHtml [this]))

+ 19 - 0
frontend/src/frontend/fs.cljs

@@ -0,0 +1,19 @@
+(ns frontend.fs
+  (:require [frontend.config :refer [dir]]))
+
+(defn mkdir
+  []
+  (js/pfs.mkdir dir))
+
+(defn readdir
+  []
+  (js/pfs.readdir dir))
+
+(defn read-file
+  [path]
+  (js/pfs.readFile (str dir "/" path)
+                   (clj->js {:encoding "utf8"})))
+
+(defn write-file
+  [path content]
+  (js/pfs.writeFile (str dir "/" path) content))

+ 67 - 0
frontend/src/frontend/git.cljs

@@ -0,0 +1,67 @@
+(ns frontend.git
+  (:refer-clojure :exclude [clone])
+  (:require [promesa.core :as p]
+            [frontend.util :as util]
+            [frontend.config :refer [dir]]))
+
+(defn clone
+  [username token repo]
+  (js/git.clone (clj->js
+              {:dir dir
+               :url repo
+               :corsProxy "https://cors.isomorphic-git.org"
+               :singleBranch true
+               :depth 1
+               :username username
+               :token token
+               })))
+
+(defn list-files
+  []
+  (js/git.listFiles (clj->js
+                  {:dir dir
+                   :ref "HEAD"})))
+
+(defn pull
+  [username token]
+  (js/git.pull (clj->js
+             {:dir dir
+              :ref "master"
+              :username username
+              :token token
+              :singleBranch true})))
+(defn add
+  [file]
+  (js/git.add (clj->js
+               {:dir dir
+                :filepath file})))
+
+(defn commit
+  [message]
+  (js/git.commit (clj->js
+                  {:dir dir
+                   :author {:name "Orgnote"
+                            :email "[email protected]"}
+                   :message message})))
+
+(defn push
+  [token]
+  (js/git.push (clj->js
+                {:dir dir
+                 :remote "origin"
+                 :ref "master"
+                 :token token})))
+
+(defn add-commit-push
+  [file message token push-ok-handler push-error-handler]
+  (util/p-handle
+   (let [files (if (coll? file) file [file])]
+     (doseq [file files]
+       (add file)))
+   (fn [_]
+     (util/p-handle
+      (commit message)
+      (fn [_]
+        (push token)
+        (push-ok-handler))
+      push-error-handler))))

+ 370 - 0
frontend/src/frontend/handler.cljs

@@ -0,0 +1,370 @@
+(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.format.org-mode :as org]
+            [frontend.format.org.block :as block]
+            [frontend.config :as config]
+            [clojure.walk :as walk]
+            [clojure.string :as string]
+            [promesa.core :as p])
+  (:import [goog.events EventHandler]))
+
+(defn load-file
+  ([path]
+   (util/p-handle (fs/read-file path)
+                  (fn [content]
+                    (let [state @state/state
+                          state' (-> state
+                                     (assoc-in [:contents path] content)
+                                     (assoc-in [:loadings path] false)
+                                     (assoc :current-file path))]
+                      (reset! state/state state')))))
+  ([path state-handler]
+   (util/p-handle (fs/read-file 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 load-files
+  []
+  (util/p-handle (git/list-files)
+                 (fn [files]
+                   (when (> (count files) 0)
+                     (let [files (js->clj files)]
+                       (if (contains? (set files) config/hidden-file)
+                         (load-file config/hidden-file
+                                    (fn [patterns-content]
+                                      (let [patterns (string/split patterns-content #"\n")
+                                            files (remove (fn [path] (hidden? path patterns)) files)]
+                                        (swap! state/state
+                                               assoc :files files))))
+                         (swap! state/state
+                                assoc :files files)))))))
+
+(defn extract-links
+  [form]
+  (let [links (atom [])]
+    (clojure.walk/postwalk
+     (fn [x]
+       (when (and (vector? x)
+                  (= "Link" (first x)))
+         (let [[_ {:keys [url label]}] x
+               [_ {:keys [protocol link]}] url
+               link (str protocol ":" link)]
+           (swap! links conj link)))
+       x)
+     form)
+    @links))
+
+(defn load-links
+  ([]
+   (load-links config/links-org))
+  ([path]
+   (util/p-handle (fs/read-file path)
+                  (fn [content]
+                    (when content
+                      (let [blocks (org/parse-json content)
+                            blocks (-> (.parse js/JSON blocks)
+                                       (js->clj :keywordize-keys true))]
+                        (when (seq blocks)
+                          (swap! state/state assoc :links (extract-links blocks)))))))))
+
+(defn load-from-disk
+  []
+  (let [cloned? (storage/get :cloned?)]
+    (swap! state/state assoc
+           :cloned? cloned?
+           :github-username (storage/get :github-username)
+           :github-token (storage/get :github-token)
+           :github-repo (storage/get :github-repo))
+    (when cloned?
+      (load-files)
+      (load-links))))
+
+(defn periodically-pull
+  []
+  (let [username (storage/get :github-username)
+        token (storage/get :github-token)
+        pull (fn []
+               (util/p-handle (git/pull username token)
+                              (fn [_result]
+                                ;; TODO: diff
+                                (load-files)))
+               (load-links))]
+    (pull)
+    (js/setInterval pull
+                    (* 60 1000))))
+
+(defn add-transaction
+  [tx]
+  (swap! state/state update :tasks-transactions conj tx))
+
+(defn clear-transactions!
+  []
+  (swap! state/state assoc :tasks-transactions nil))
+
+(defn- transactions->commit-msg
+  [transactions]
+  (let [transactions (reverse transactions)]
+    (str
+     "Orgnote auto save tasks.\n\n"
+     (string/join "\n" transactions))))
+
+(defn periodically-push-tasks
+  []
+  (let [github-token (storage/get :github-token)
+        push (fn []
+               (let [transactions (:tasks-transactions @state/state)]
+                 (when (seq transactions)
+                   (git/add-commit-push
+                    config/tasks-org
+                    (transactions->commit-msg transactions)
+                    github-token
+                    (fn []
+                      (prn "Commit tasks to Github.")
+                      (clear-transactions!))
+                    (fn []
+                      (prn "Failed to push."))))))]
+    (js/setInterval push
+                    (* 5 1000))))
+
+(defn clone
+  [github-username github-token github-repo]
+  (storage/set :github-username github-username)
+  (storage/set :github-token github-token)
+  (storage/set :github-repo github-repo)
+
+  (util/p-handle
+   (do
+     (swap! state/state assoc
+            :cloning? true)
+     (git/clone github-username github-token github-repo))
+   (fn []
+     (swap! state/state assoc
+            :cloned? true)
+     (storage/set :cloned? true)
+     (swap! state/state assoc
+            :cloning? false)
+     (periodically-pull))
+   (fn [e]
+     (prn "Clone failed, reason: " e))))
+
+(defonce event-handler (EventHandler.))
+
+(defn listen-to-resize
+  []
+  (util/listen event-handler js/window :resize
+               (fn []
+                 (swap! state/state assoc :width (util/get-width)))))
+
+(defn toggle-drawer?
+  [switch]
+  (swap! state/state assoc :drawer? switch))
+
+(defn change-page
+  [page]
+  (swap! state/state assoc :current-page page))
+
+(defn reset-current-file
+  []
+  (swap! state/state assoc :current-file nil))
+
+(defn toggle-link-dialog?
+  [switch]
+  (swap! state/state assoc :add-link-dialog? switch))
+
+(defn add-new-link
+  [link message]
+  (if-let [github-token (storage/get :github-token)]
+    (util/p-handle (fs/read-file config/links-org)
+                   (fn [content]
+                     (let [content' (str content "\n** " link)]
+                       (util/p-handle
+                        (fs/write-file config/links-org content')
+                        (fn [_]
+                          (git/add-commit-push config/links-org
+                                               message
+                                               github-token
+                                               (fn []
+                                                 (toggle-link-dialog? false))
+                                               (fn []
+                                                 (.log js/console "Failed to push the new link."))))))))
+    (.log js/console "Github token does not exists!")))
+
+(defn new-notification
+  [text]
+  (js/Notification. "Gitnotes" #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 hide-snackbar
+  []
+  (swap! state/state assoc
+         :snackbar? false
+         :snackbar-message nil))
+
+(defn show-snackbar
+  [message]
+  (swap! state/state assoc
+         :snackbar? true
+         :snackbar-message message)
+  (js/setTimeout hide-snackbar 3000))
+
+(defn alter-file
+  [file]
+  (when-let [content (get-in @state/state [:contents file])]
+    (let [content' (get-in @state/state [:editing-files file])]
+      (when-not (= (string/trim content)
+                   (string/trim content'))
+        (let [github-token (:github-token @state/state)
+              path [:commit-message file]
+              message (get-in @state/state path (str "Update " file))]
+          (util/p-handle
+           (fs/write-file file content')
+           (fn [_]
+             (git/add-commit-push file
+                                  message
+                                  github-token
+                                  (fn []
+                                    (swap! state/state util/dissoc-in path)
+                                    (swap! state/state assoc-in [:contents file] content')
+                                    (show-snackbar "File updated!")
+                                    (change-page :home))
+                                  (fn []
+                                    (prn "Failed to update file."))))))))))
+
+(defn clear-storage
+  []
+  (js/window.pfs._idb.wipe)
+  (storage/set :cloned? false)
+  (swap! state/state assoc
+         :cloned? false
+         :contents nil
+         :files nil)
+  (clone (:github-username @state/state)
+         (:github-token @state/state)
+         (:github-repo @state/state)))
+
+(defn check
+  [marker pos]
+  (let [file config/tasks-org
+        github-token (storage/get :github-token)]
+    (when-let [content (get-in @state/state [:contents file])]
+      (let [content' (str (subs content 0 pos)
+                          (-> (subs content pos)
+                              (string/replace-first marker "DONE")))]
+        ;; TODO: optimize, only update the specific block
+        ;; (build-tasks content' file)
+        (util/p-handle
+         (fs/write-file file content')
+         (fn [_]
+           (swap! state/state assoc-in [:contents file] content')
+           (add-transaction (util/format "`%s` marked as DONE." marker))))))))
+
+(defn uncheck
+  [pos]
+  (let [file config/tasks-org
+        github-token (storage/get :github-token)]
+    (when-let [content (get-in @state/state [:contents file])]
+      (let [content' (str (subs content 0 pos)
+                          (-> (subs content pos)
+                              (string/replace-first "DONE" "TODO")))]
+        ;; TODO: optimize, only update the specific block
+        ;; (build-tasks content' file)
+        (util/p-handle
+         (fs/write-file file content')
+         (fn [_]
+           (swap! state/state assoc-in [:contents file] content')
+           (add-transaction "DONE rollbacks to TODO.")))))))
+
+(defn extract-headings
+  [file content]
+  (let [headings (-> content
+                     (org/parse-json)
+                     (util/json->clj))
+        headings (block/extract-headings headings)]
+    (map (fn [heading]
+           (assoc heading :file file))
+      headings)))
+
+(defn load-all-contents!
+  [ok-handler]
+  (let [files (:files @state/state)]
+    (-> (p/all (for [file files]
+                 (load-file file
+                            (fn [content]
+                              (swap! state/state
+                                     assoc-in [:contents file] content) ))))
+        (p/then
+         (fn [_]
+           (prn "Files are loaded!")
+           (ok-handler))))))
+
+(defn extract-all-headings
+  []
+  (let [contents (:contents @state/state)]
+    (vec
+     (mapcat
+      (fn [[file content] contents]
+        (extract-headings file content))
+      contents))))
+
+(defonce headings-atom (atom nil))
+
+(defn initial-db!
+  []
+  (db/init)
+  (load-all-contents!
+   (fn []
+     (let [headings (extract-all-headings)]
+       (reset! headings-atom headings)
+       (db/transact-headings! headings)))))

+ 71 - 0
frontend/src/frontend/layout.cljs

@@ -0,0 +1,71 @@
+(ns frontend.layout
+  (:require [frontend.mui :as mui]
+            [frontend.handler :as handler]
+            [frontend.state :as state]
+            [frontend.components.link :as link]
+            [frontend.components.file :as file]
+            [rum.core :as rum]
+            [clojure.string :as string]))
+
+(rum/defc frame < rum/reactive
+  [content width link-dialog?]
+  (let [state (rum/react state/state)
+        {:keys [files drawer? snackbar? snackbar-message]} state
+        mobile? (and width (<= width 600))]
+    (mui/theme-provider
+     {:theme (mui/custom-theme)}
+     [:div {:class "root"
+            :style {:padding-bottom 100}}
+      (mui/css-baseline)
+      (mui/app-bar
+       {:position "static"}
+       (mui/tool-bar
+        {}
+        (if mobile?
+          (mui/icon-button {:edge "start"
+                            :class "menuButton"
+                            :color "inherit"
+                            :on-click (fn []
+                                        (handler/toggle-drawer? true))}
+                           (mui/menu-icon)))
+        (mui/typography {:class "grow"
+                         :variant "h6"
+                         :color "inherit"
+                         :no-wrap true
+                         :on-click (fn []
+                                     (handler/change-page :home)
+                                     (handler/reset-current-file))}
+                        "Gitnotes")
+
+        (mui/button {:color "inherit"
+                     :on-click (fn []
+                                 (handler/change-page :links))}
+          "Links")
+
+        (mui/button {:color "inherit"
+                     :on-click (fn []
+                                 (handler/change-page :settings))}
+          "Settings")
+
+        (mui/icon-button {:color "inherit"
+                          :class "addButton"
+                          :on-click (fn []
+                                      (handler/toggle-link-dialog? true))}
+                         (mui/add-icon))))
+      content
+
+      (if mobile?
+        (mui/drawer {:open drawer?
+                     :disableBackdropTransition true
+                     :on-open (fn []
+                                (handler/toggle-drawer? true))
+                     :on-close (fn []
+                                 (handler/toggle-drawer? false))}
+                    [:div {:style {:width 240}}
+                     (file/files-list files)]))
+
+      (link/dialog link-dialog?)
+
+      (mui/snackbar {:open snackbar?
+                     :auto-hide-duration 3000
+                     :message snackbar-message})])))

+ 112 - 0
frontend/src/frontend/mui.cljs

@@ -0,0 +1,112 @@
+(ns frontend.mui
+  (:refer-clojure :exclude [list stepper])
+  (:require [rum.core]
+            [frontend.rum :as r]
+            ["@material-ui/core" :refer [MuiThemeProvider]]
+            ["@material-ui/core/styles" :refer [createMuiTheme withStyles makeStyles]]
+            ["@material-ui/core/colors" :as colors]
+            ["@material-ui/core/CssBaseline" :default CssBaseline]
+            ["@material-ui/core/Typography" :default Typography]
+            ["@material-ui/core/Avatar" :default mui-avatar]
+            ["@material-ui/icons/Android" :default AndroidIcon]
+            ["@material-ui/core/AppBar" :default AppBar]
+            ["@material-ui/core/Divider" :default Divider]
+            ["@material-ui/core/Paper" :default Paper]
+            ["@material-ui/core/Toolbar" :default ToolBar]
+            ["@material-ui/core/IconButton" :default IconButton]
+            ["@material-ui/icons/Menu" :default MenuIcon]
+            ["@material-ui/core/Button" :default Button]
+            ["@material-ui/core/SwipeableDrawer" :default SwipeableDrawer]
+            ["@material-ui/core/Chip" :default Chip]
+            ["@material-ui/core/Fab" :default Fab]
+            ["@material-ui/core/List" :default List]
+            ["@material-ui/core/ListItem" :default ListItem]
+            ["@material-ui/core/ListItemText" :default ListItemText]
+            ["@material-ui/core/Container" :default Container]
+            ["@material-ui/core/Box" :default Box]
+            ["@material-ui/core/Snackbar" :default Snackbar]
+            ["@material-ui/core/Link" :default Link]
+            ["@material-ui/core/Checkbox" :default Checkbox]
+            ["@material-ui/core/Grid" :default Grid]
+            ["@material-ui/core/GridList" :default GridList]
+            ["@material-ui/core/Hidden" :default Hidden]
+            ;; ["@material-ui/core/Form" :default Form]
+            ["@material-ui/core/TextField" :default TextField]
+            ["@material-ui/core/TextareaAutosize" :default TextareaAutosize]
+            ["@material-ui/core/Card" :default Card]
+            ["@material-ui/core/CardActions" :default CardActions]
+            ["@material-ui/core/CardContent" :default CardContent]
+            ["@material-ui/core/CardHeader" :default CardHeader]
+            ["@material-ui/core/CardMedia" :default CardMedia]
+            ["@material-ui/core/Collapse" :default Collapse]
+            ["@material-ui/core/Avatar" :default Avatar]
+            ["@material-ui/core/CircularProgress" :default CircularProgress]
+            ["@material-ui/core/Badge" :default Badge]
+            ["@material-ui/core/Tooltip" :default Tooltip]
+            ["@material-ui/core/Dialog" :default Dialog]
+            ["@material-ui/core/DialogTitle" :default DialogTitle]
+            ["@material-ui/core/DialogContent" :default DialogContent]
+            ["@material-ui/core/DialogActions" :default DialogActions]
+            ["@material-ui/icons/Favorite" :default FavoriteIcon]
+            ["@material-ui/icons/Add" :default AddIcon]
+            ["@material-ui/icons/Share" :default ShareIcon]
+            ["@material-ui/icons/MoreVert" :default MoreVertIcon]
+            ))
+
+(defn custom-theme []
+  (createMuiTheme
+   (clj->js
+    {:palette
+     {:type       "light"
+      ;; :primary    (.-purple colors)
+      ;; :secondary  (.-green colors)
+      }
+     :typography
+     {:useNextVariants true}})))
+
+(defonce theme-provider (r/adapt-class MuiThemeProvider))
+(defonce css-baseline (r/adapt-class CssBaseline))
+(defonce app-bar (r/adapt-class AppBar))
+(defonce divider (r/adapt-class Divider))
+(defonce tool-bar (r/adapt-class ToolBar))
+(defonce button (r/adapt-class Button))
+(defonce icon-button (r/adapt-class IconButton))
+(defonce typography (r/adapt-class Typography))
+(defonce container (r/adapt-class Container))
+(defonce box (r/adapt-class Box))
+(defonce snackbar (r/adapt-class Snackbar))
+(defonce link (r/adapt-class Link))
+(defonce checkbox (r/adapt-class Checkbox))
+(defonce grid (r/adapt-class Grid))
+(defonce grid-list (r/adapt-class GridList))
+(defonce paper (r/adapt-class Paper))
+(defonce collapse (r/adapt-class Collapse))
+(defonce avatar (r/adapt-class Avatar))
+(defonce favorite-icon (r/adapt-class FavoriteIcon))
+(defonce add-icon (r/adapt-class AddIcon))
+(defonce fab (r/adapt-class Fab))
+(defonce share-icon (r/adapt-class ShareIcon))
+(defonce more-vert-icon (r/adapt-class MoreVertIcon))
+(defonce circular-progress (r/adapt-class CircularProgress))
+(defonce badge (r/adapt-class Badge))
+(defonce text-field (r/adapt-class TextField))
+(defonce textarea (r/adapt-class TextareaAutosize))
+(defonce tooltip (r/adapt-class Tooltip))
+(defonce dialog (r/adapt-class Dialog))
+(defonce dialog-title (r/adapt-class DialogTitle))
+(defonce dialog-content (r/adapt-class DialogContent))
+(defonce dialog-actions (r/adapt-class DialogActions))
+(defonce menu-icon (r/adapt-class MenuIcon))
+(defonce drawer (r/adapt-class SwipeableDrawer))
+(defonce chip (r/adapt-class Chip))
+(defonce list (r/adapt-class List))
+(defonce list-item (r/adapt-class ListItem))
+(defonce list-item-text (r/adapt-class ListItemText))
+
+;; card
+(defonce card (r/adapt-class Card))
+(defonce card-actions (r/adapt-class CardActions))
+(defonce card-content (r/adapt-class CardContent))
+(defonce card-actions (r/adapt-class CardActions))
+(defonce card-header (r/adapt-class CardHeader))
+(defonce card-media (r/adapt-class CardMedia))

+ 12 - 0
frontend/src/frontend/page.cljs

@@ -0,0 +1,12 @@
+(ns frontend.page
+  (:require [rum.core :as rum]
+            [frontend.layout :as layout]
+            [frontend.routes :as routes]
+            [frontend.state :as state]))
+
+(rum/defc current-page < rum/reactive
+  []
+  (let [state (rum/react state/state)
+        current-page (get state :current-page :home)]
+    (when-let [view (get routes/routes current-page)]
+      (layout/frame (view) (:width state) (:add-link-dialog? state)))))

+ 12 - 0
frontend/src/frontend/routes.cljs

@@ -0,0 +1,12 @@
+(ns frontend.routes
+  (:require [frontend.components.home :as home]
+            [frontend.components.link :as link]
+            [frontend.components.settings :as settings]
+            [frontend.components.file :as file]
+            ))
+
+(def routes
+  {:home home/home
+   :links link/links
+   :settings settings/settings
+   :edit-file file/edit})

+ 59 - 0
frontend/src/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))))

+ 19 - 0
frontend/src/frontend/state.cljs

@@ -0,0 +1,19 @@
+(ns frontend.state
+  (:require [frontend.storage :as storage]))
+
+(def state (atom {:current-page :home
+                  :cloning? false
+                  :cloned? (storage/get :cloned?)
+                  :files []
+                  :contents {}          ; file name -> string
+                  :current-file nil
+                  :loadings {}            ; file name -> bool
+                  :github-username ""
+                  :github-token ""
+                  :github-repo ""
+                  :width nil
+                  :drawer? false
+                  :tasks {}
+                  :links []
+                  :add-link-dialog? false
+                  }))

+ 19 - 0
frontend/src/frontend/storage.cljs

@@ -0,0 +1,19 @@
+(ns frontend.storage
+  (:refer-clojure :exclude [get set remove])
+  (:require [cljs.reader :as reader]))
+
+(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))

+ 88 - 0
frontend/src/frontend/util.cljs

@@ -0,0 +1,88 @@
+(ns frontend.util
+  (:require [goog.object :as gobj]
+            [promesa.core :as p]
+            [clojure.walk :as walk]))
+
+(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 goog.string/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))

+ 0 - 0
yarn.lock → frontend/yarn.lock