diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000000..d18f225992 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,12 @@ +/target +/classes +/checkouts +profiles.clj +pom.xml +pom.xml.asc +*.jar +*.class +/.lein-* +/.nrepl-port +.hgignore +.hg/ diff --git a/backend/CHANGELOG.md b/backend/CHANGELOG.md new file mode 100644 index 0000000000..d3ac312aa1 --- /dev/null +++ b/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 diff --git a/backend/LICENSE b/backend/LICENSE new file mode 100644 index 0000000000..d3087e4c54 --- /dev/null +++ b/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. diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000..505096d6a2 --- /dev/null +++ b/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. diff --git a/backend/dev/user.clj b/backend/dev/user.clj new file mode 100644 index 0000000000..eab0b469f7 --- /dev/null +++ b/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)) diff --git a/backend/doc/intro.md b/backend/doc/intro.md new file mode 100644 index 0000000000..728f2829b3 --- /dev/null +++ b/backend/doc/intro.md @@ -0,0 +1,3 @@ +# Introduction to backend + +TODO: write [great documentation](http://jacobian.org/writing/what-to-write/) diff --git a/backend/project.clj b/backend/project.clj new file mode 100644 index 0000000000..32926f4f57 --- /dev/null +++ b/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"]}) diff --git a/backend/resources/config.edn b/backend/resources/config.edn new file mode 100644 index 0000000000..4b65873e2b --- /dev/null +++ b/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}} diff --git a/backend/resources/logback.xml b/backend/resources/logback.xml new file mode 100644 index 0000000000..98c9e4ba1e --- /dev/null +++ b/backend/resources/logback.xml @@ -0,0 +1,52 @@ + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{io.pedestal} - %msg%n + + + + + logs/restream-api-%d{yyyy-MM-dd}.%i.log + + 64 MB + + + + true + + + + + + + + %-5level %logger{36} %X{io.pedestal} - %msg%n + + + + INFO + + + + + + + + + + + + + + + diff --git a/backend/src/backend/components/hikari.clj b/backend/src/backend/components/hikari.clj new file mode 100644 index 0000000000..79cf44e011 --- /dev/null +++ b/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})) diff --git a/backend/src/backend/components/http.clj b/backend/src/backend/components/http.clj new file mode 100644 index 0000000000..aff21a1458 --- /dev/null +++ b/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 {})) diff --git a/backend/src/backend/config.clj b/backend/src/backend/config.clj new file mode 100644 index 0000000000..c334849547 --- /dev/null +++ b/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")) diff --git a/backend/src/backend/cookie.clj b/backend/src/backend/cookie.clj new file mode 100644 index 0000000000..a94afdfc2a --- /dev/null +++ b/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))) diff --git a/backend/src/backend/core.clj b/backend/src/backend/core.clj new file mode 100644 index 0000000000..2b3c5a0e57 --- /dev/null +++ b/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")) diff --git a/backend/src/backend/db_migrate.clj b/backend/src/backend/db_migrate.clj new file mode 100644 index 0000000000..6e3794b2bb --- /dev/null +++ b/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))) diff --git a/backend/src/backend/jwt.clj b/backend/src/backend/jwt.clj new file mode 100644 index 0000000000..990ea977f8 --- /dev/null +++ b/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})) diff --git a/backend/src/backend/system.clj b/backend/src/backend/system.clj new file mode 100644 index 0000000000..d31b80f3bb --- /dev/null +++ b/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])))) diff --git a/backend/src/backend/util.clj b/backend/src/backend/util.clj new file mode 100644 index 0000000000..7fad59724d --- /dev/null +++ b/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)) diff --git a/backend/test/backend/core_test.clj b/backend/test/backend/core_test.clj new file mode 100644 index 0000000000..8698bcf1a6 --- /dev/null +++ b/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)))) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000..72e98501ad --- /dev/null +++ b/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/ diff --git a/deploy.sh b/frontend/deploy.sh similarity index 100% rename from deploy.sh rename to frontend/deploy.sh diff --git a/dev/shadow/user.clj b/frontend/dev/shadow/user.clj similarity index 100% rename from dev/shadow/user.clj rename to frontend/dev/shadow/user.clj diff --git a/images/screenshot.png b/frontend/images/screenshot.png similarity index 100% rename from images/screenshot.png rename to frontend/images/screenshot.png diff --git a/package-lock.json b/frontend/package-lock.json similarity index 100% rename from package-lock.json rename to frontend/package-lock.json diff --git a/package.json b/frontend/package.json similarity index 100% rename from package.json rename to frontend/package.json diff --git a/public/css/highlight.css b/frontend/public/css/highlight.css similarity index 100% rename from public/css/highlight.css rename to frontend/public/css/highlight.css diff --git a/public/css/style.css b/frontend/public/css/style.css similarity index 100% rename from public/css/style.css rename to frontend/public/css/style.css diff --git a/public/index.html b/frontend/public/index.html similarity index 100% rename from public/index.html rename to frontend/public/index.html diff --git a/shadow-cljs.edn b/frontend/shadow-cljs.edn similarity index 100% rename from shadow-cljs.edn rename to frontend/shadow-cljs.edn diff --git a/frontend/src/frontend/components/agenda.cljs b/frontend/src/frontend/components/agenda.cljs new file mode 100644 index 0000000000..3d4063b16a --- /dev/null +++ b/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")] + ) diff --git a/frontend/src/frontend/components/file.cljs b/frontend/src/frontend/components/file.cljs new file mode 100644 index 0000000000..da463b3ae9 --- /dev/null +++ b/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")]))) diff --git a/frontend/src/frontend/components/home.cljs b/frontend/src/frontend/components/home.cljs new file mode 100644 index 0000000000..a637b46804 --- /dev/null +++ b/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))))) diff --git a/frontend/src/frontend/components/link.cljs b/frontend/src/frontend/components/link.cljs new file mode 100644 index 0000000000..32b1498ed5 --- /dev/null +++ b/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"))))) diff --git a/frontend/src/frontend/components/settings.cljs b/frontend/src/frontend/components/settings.cljs new file mode 100644 index 0000000000..d17cf65270 --- /dev/null +++ b/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")]))) diff --git a/frontend/src/frontend/config.cljs b/frontend/src/frontend/config.cljs new file mode 100644 index 0000000000..12fbfa68da --- /dev/null +++ b/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") diff --git a/frontend/src/frontend/core.cljs b/frontend/src/frontend/core.cljs new file mode 100644 index 0000000000..f976dfc4bc --- /dev/null +++ b/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")) diff --git a/frontend/src/frontend/db.cljs b/frontend/src/frontend/db.cljs new file mode 100644 index 0000000000..5c5b1fc326 --- /dev/null +++ b/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!) + ) diff --git a/frontend/src/frontend/format.cljs b/frontend/src/frontend/format.cljs new file mode 100644 index 0000000000..7347327488 --- /dev/null +++ b/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))) diff --git a/frontend/src/frontend/format/markdown.cljs b/frontend/src/frontend/format/markdown.cljs new file mode 100644 index 0000000000..7508e91a77 --- /dev/null +++ b/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))) diff --git a/frontend/src/frontend/format/org/block.cljs b/frontend/src/frontend/format/org/block.cljs new file mode 100644 index 0000000000..2f75837df7 --- /dev/null +++ b/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))) diff --git a/frontend/src/frontend/format/org_mode.cljs b/frontend/src/frontend/format/org_mode.cljs new file mode 100644 index 0000000000..239c9700f5 --- /dev/null +++ b/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))) diff --git a/frontend/src/frontend/format/protocol.cljs b/frontend/src/frontend/format/protocol.cljs new file mode 100644 index 0000000000..58f6cccf4a --- /dev/null +++ b/frontend/src/frontend/format/protocol.cljs @@ -0,0 +1,4 @@ +(ns frontend.format.protocol) + +(defprotocol Format + (toHtml [this])) diff --git a/frontend/src/frontend/fs.cljs b/frontend/src/frontend/fs.cljs new file mode 100644 index 0000000000..8d75ce0c52 --- /dev/null +++ b/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)) diff --git a/frontend/src/frontend/git.cljs b/frontend/src/frontend/git.cljs new file mode 100644 index 0000000000..5bf664f93f --- /dev/null +++ b/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 "orgnote@hello.world"} + :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)))) diff --git a/frontend/src/frontend/handler.cljs b/frontend/src/frontend/handler.cljs new file mode 100644 index 0000000000..5530668830 --- /dev/null +++ b/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))))) diff --git a/frontend/src/frontend/layout.cljs b/frontend/src/frontend/layout.cljs new file mode 100644 index 0000000000..006e7034aa --- /dev/null +++ b/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})]))) diff --git a/frontend/src/frontend/mui.cljs b/frontend/src/frontend/mui.cljs new file mode 100644 index 0000000000..75a45b962c --- /dev/null +++ b/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)) diff --git a/frontend/src/frontend/page.cljs b/frontend/src/frontend/page.cljs new file mode 100644 index 0000000000..74e500ad3c --- /dev/null +++ b/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))))) diff --git a/frontend/src/frontend/routes.cljs b/frontend/src/frontend/routes.cljs new file mode 100644 index 0000000000..ee45a9ec78 --- /dev/null +++ b/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}) diff --git a/frontend/src/frontend/rum.cljs b/frontend/src/frontend/rum.cljs new file mode 100644 index 0000000000..df04da0162 --- /dev/null +++ b/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)))) diff --git a/frontend/src/frontend/state.cljs b/frontend/src/frontend/state.cljs new file mode 100644 index 0000000000..8971ff7046 --- /dev/null +++ b/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 + })) diff --git a/frontend/src/frontend/storage.cljs b/frontend/src/frontend/storage.cljs new file mode 100644 index 0000000000..cf1e09a033 --- /dev/null +++ b/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)) diff --git a/frontend/src/frontend/util.cljs b/frontend/src/frontend/util.cljs new file mode 100644 index 0000000000..ad4a4a9b03 --- /dev/null +++ b/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)) diff --git a/yarn.lock b/frontend/yarn.lock similarity index 100% rename from yarn.lock rename to frontend/yarn.lock