Initial commit

This commit is contained in:
Simon Brooke 2020-01-27 17:31:07 +00:00
commit 86c675f22d
42 changed files with 1407 additions and 0 deletions

13
src/clj/geocsv/config.clj Normal file
View file

@ -0,0 +1,13 @@
(ns geocsv.config
(:require
[cprop.core :refer [load-config]]
[cprop.source :as source]
[mount.core :refer [args defstate]]))
(defstate env
:start
(load-config
:merge
[(args)
(source/from-system-props)
(source/from-env)]))

58
src/clj/geocsv/core.clj Normal file
View file

@ -0,0 +1,58 @@
(ns geocsv.core
(:require
[geocsv.handler :as handler]
[geocsv.nrepl :as nrepl]
[luminus.http-server :as http]
[geocsv.config :refer [env]]
[clojure.tools.cli :refer [parse-opts]]
[clojure.tools.logging :as log]
[mount.core :as mount])
(:gen-class))
;; log uncaught exceptions in threads
(Thread/setDefaultUncaughtExceptionHandler
(reify Thread$UncaughtExceptionHandler
(uncaughtException [_ thread ex]
(log/error {:what :uncaught-exception
:exception ex
:where (str "Uncaught exception on" (.getName thread))}))))
(def cli-options
[["-p" "--port PORT" "Port number"
:parse-fn #(Integer/parseInt %)]])
(mount/defstate ^{:on-reload :noop} http-server
:start
(http/start
(-> env
(assoc :handler (handler/app))
(update :io-threads #(or % (* 2 (.availableProcessors (Runtime/getRuntime)))))
(update :port #(or (-> env :options :port) %))))
:stop
(http/stop http-server))
(mount/defstate ^{:on-reload :noop} repl-server
:start
(when (env :nrepl-port)
(nrepl/start {:bind (env :nrepl-bind)
:port (env :nrepl-port)}))
:stop
(when repl-server
(nrepl/stop repl-server)))
(defn stop-app []
(doseq [component (:stopped (mount/stop))]
(log/info component "stopped"))
(shutdown-agents))
(defn start-app [args]
(doseq [component (-> args
(parse-opts cli-options)
mount/start-with-args
:started)]
(log/info component "started"))
(.addShutdownHook (Runtime/getRuntime) (Thread. stop-app)))
(defn -main [& args]
(start-app args))

View file

@ -0,0 +1,35 @@
(ns geocsv.handler
(:require
[geocsv.middleware :as middleware]
[geocsv.layout :refer [error-page]]
[geocsv.routes.home :refer [home-routes]]
[reitit.ring :as ring]
[ring.middleware.content-type :refer [wrap-content-type]]
[ring.middleware.webjars :refer [wrap-webjars]]
[geocsv.env :refer [defaults]]
[mount.core :as mount]))
(mount/defstate init-app
:start ((or (:init defaults) (fn [])))
:stop ((or (:stop defaults) (fn []))))
(mount/defstate app-routes
:start
(ring/ring-handler
(ring/router
[(home-routes)])
(ring/routes
(ring/create-resource-handler
{:path "/"})
(wrap-content-type
(wrap-webjars (constantly nil)))
(ring/create-default-handler
{:not-found
(constantly (error-page {:status 404, :title "404 - Page not found"}))
:method-not-allowed
(constantly (error-page {:status 405, :title "405 - Not allowed"}))
:not-acceptable
(constantly (error-page {:status 406, :title "406 - Not acceptable"}))}))))
(defn app []
(middleware/wrap-base #'app-routes))

39
src/clj/geocsv/layout.clj Normal file
View file

@ -0,0 +1,39 @@
(ns geocsv.layout
(:require
[clojure.java.io]
[selmer.parser :as parser]
[selmer.filters :as filters]
[markdown.core :refer [md-to-html-string]]
[ring.util.http-response :refer [content-type ok]]
[ring.util.anti-forgery :refer [anti-forgery-field]]
[ring.middleware.anti-forgery :refer [*anti-forgery-token*]]
[ring.util.response]))
(parser/set-resource-path! (clojure.java.io/resource "html"))
(parser/add-tag! :csrf-field (fn [_ _] (anti-forgery-field)))
(filters/add-filter! :markdown (fn [content] [:safe (md-to-html-string content)]))
(defn render
"renders the HTML template located relative to resources/html"
[request template & [params]]
(content-type
(ok
(parser/render-file
template
(assoc params
:page template
:csrf-token *anti-forgery-token*)))
"text/html; charset=utf-8"))
(defn error-page
"error-details should be a map containing the following keys:
:status - error status
:title - error title (optional)
:message - detailed error message (optional)
returns a response map with the error page as the body
and the status specified by the status key"
[error-details]
{:status (:status error-details)
:headers {"Content-Type" "text/html; charset=utf-8"}
:body (parser/render-file "error.html" error-details)})

View file

@ -0,0 +1,49 @@
(ns geocsv.middleware
(:require
[geocsv.env :refer [defaults]]
[cheshire.generate :as cheshire]
[cognitect.transit :as transit]
[clojure.tools.logging :as log]
[geocsv.layout :refer [error-page]]
[ring.middleware.anti-forgery :refer [wrap-anti-forgery]]
[geocsv.middleware.formats :as formats]
[muuntaja.middleware :refer [wrap-format wrap-params]]
[geocsv.config :refer [env]]
[ring-ttl-session.core :refer [ttl-memory-store]]
[ring.middleware.defaults :refer [site-defaults wrap-defaults]])
)
(defn wrap-internal-error [handler]
(fn [req]
(try
(handler req)
(catch Throwable t
(log/error t (.getMessage t))
(error-page {:status 500
:title "Something very bad has happened!"
:message "We've dispatched a team of highly trained gnomes to take care of the problem."})))))
(defn wrap-csrf [handler]
(wrap-anti-forgery
handler
{:error-response
(error-page
{:status 403
:title "Invalid anti-forgery token"})}))
(defn wrap-formats [handler]
(let [wrapped (-> handler wrap-params (wrap-format formats/instance))]
(fn [request]
;; disable wrap-formats for websockets
;; since they're not compatible with this middleware
((if (:websocket? request) handler wrapped) request))))
(defn wrap-base [handler]
(-> ((:middleware defaults) handler)
(wrap-defaults
(-> site-defaults
(assoc-in [:security :anti-forgery] false)
(assoc-in [:session :store] (ttl-memory-store (* 60 30)))))
wrap-internal-error))

View file

@ -0,0 +1,15 @@
(ns geocsv.middleware.formats
(:require
[cognitect.transit :as transit]
[luminus-transit.time :as time]
[muuntaja.core :as m]))
(def instance
(m/create
(-> m/default-options
(update-in
[:formats "application/transit+json" :decoder-opts]
(partial merge time/time-deserialization-handlers))
(update-in
[:formats "application/transit+json" :encoder-opts]
(partial merge time/time-serialization-handlers)))))

27
src/clj/geocsv/nrepl.clj Normal file
View file

@ -0,0 +1,27 @@
(ns geocsv.nrepl
(:require
[nrepl.server :as nrepl]
[clojure.tools.logging :as log]))
(defn start
"Start a network repl for debugging on specified port followed by
an optional parameters map. The :bind, :transport-fn, :handler,
:ack-port and :greeting-fn will be forwarded to
clojure.tools.nrepl.server/start-server as they are."
[{:keys [port bind transport-fn handler ack-port greeting-fn]}]
(try
(log/info "starting nREPL server on port" port)
(nrepl/start-server :port port
:bind bind
:transport-fn transport-fn
:handler handler
:ack-port ack-port
:greeting-fn greeting-fn)
(catch Throwable t
(log/error t "failed to start nREPL")
(throw t))))
(defn stop [server]
(nrepl/stop-server server)
(log/info "nREPL server stopped"))

View file

@ -0,0 +1,20 @@
(ns geocsv.routes.home
(:require
[geocsv.layout :as layout]
[clojure.java.io :as io]
[geocsv.middleware :as middleware]
[ring.util.response]
[ring.util.http-response :as response]))
(defn home-page [request]
(layout/render request "home.html"))
(defn home-routes []
[""
{:middleware [middleware/wrap-csrf
middleware/wrap-formats]}
["/" {:get home-page}]
["/docs" {:get (fn [_]
(-> (response/ok (-> "docs/docs.md" io/resource slurp))
(response/header "Content-Type" "text/plain; charset=utf-8")))}]])

View file

@ -0,0 +1,2 @@
(ns geocsv.validation
(:require [struct.core :as st]))

30
src/cljs/geocsv/ajax.cljs Normal file
View file

@ -0,0 +1,30 @@
(ns geocsv.ajax
(:require
[ajax.core :as ajax]
[luminus-transit.time :as time]
[cognitect.transit :as transit]
[re-frame.core :as rf]))
(defn local-uri? [{:keys [uri]}]
(not (re-find #"^\w+?://" uri)))
(defn default-headers [request]
(if (local-uri? request)
(-> request
(update :headers #(merge {"x-csrf-token" js/csrfToken} %)))
request))
;; injects transit serialization config into request options
(defn as-transit [opts]
(merge {:raw false
:format :transit
:response-format :transit
:reader (transit/reader :json time/time-deserialization-handlers)
:writer (transit/writer :json time/time-serialization-handlers)}
opts))
(defn load-interceptors! []
(swap! ajax/default-interceptors
conj
(ajax/to-interceptor {:name "default headers"
:request default-headers})))

91
src/cljs/geocsv/core.cljs Normal file
View file

@ -0,0 +1,91 @@
(ns geocsv.core
(:require
[day8.re-frame.http-fx]
[reagent.core :as r]
[re-frame.core :as rf]
[geocsv.views.map :as mv]
[goog.events :as events]
[goog.history.EventType :as HistoryEventType]
[markdown.core :refer [md->html]]
[geocsv.ajax :as ajax]
[geocsv.events]
[reitit.core :as reitit]
[reitit.frontend.easy :as rfe]
[clojure.string :as string])
(:import goog.History))
(defn nav-link [uri title page]
[:a.navbar-item
{:href uri
:class (when (= page @(rf/subscribe [:page])) :is-active)}
title])
(defn navbar []
(r/with-let [expanded? (r/atom false)]
[:nav.navbar.is-info>div.container
[:div.navbar-brand
[:a.navbar-item {:href "/" :style {:font-weight :bold}} "geocsv"]
[:span.navbar-burger.burger
{:data-target :nav-menu
:on-click #(swap! expanded? not)
:class (when @expanded? :is-active)}
[:span][:span][:span]]]
[:div#nav-menu.navbar-menu
{:class (when @expanded? :is-active)}
[:div.navbar-start
[nav-link "#/" "Home" :home]
[nav-link "#/about" "About" :about]]]]))
(defn about-page []
[:section.section>div.container>div.content
[:img {:src "/img/warning_clojure.png"}]])
(defn home-page []
[:section.section>div.container>div.content
(when-let [docs @(rf/subscribe [:docs])]
[:div {:dangerouslySetInnerHTML {:__html (md->html docs)}}])])
(defn map-page []
"Return the content for the main map page. Map showing current location."
(mv/panel))
(def pages
{:home #'home-page
:about #'about-page})
(defn page []
(if-let [page @(rf/subscribe [:page])]
[:div
[navbar]
[page]]))
(defn navigate! [match _]
(rf/dispatch [:navigate match]))
(def router
(reitit/router
[["/" {:name :home
:view #'home-page
:controllers [{:start (fn [_] (rf/dispatch [:page/init-home]))}]}]
["/map" {:name :home
:view #'map-page
:controllers [{:start (fn [_] (rf/dispatch [:page/init-map]))}]}]
["/about" {:name :about
:view #'about-page}]]))
(defn start-router! []
(rfe/start!
router
navigate!
{}))
;; -------------------------
;; Initialize app
(defn mount-components []
(rf/clear-subscription-cache!)
(r/render [#'page] (.getElementById js/document "app")))
(defn init! []
(start-router!)
(ajax/load-interceptors!)
(mount-components))

125
src/cljs/geocsv/events.cljs Normal file
View file

@ -0,0 +1,125 @@
(ns geocsv.events
(:require
[re-frame.core :as rf]
[ajax.core :as ajax]
[reitit.frontend.easy :as rfe]
[reitit.frontend.controllers :as rfc]))
;;dispatchers
(rf/reg-event-db
:navigate
(fn [db [_ match]]
(let [old-match (:common/route db)
new-match (assoc match :controllers
(rfc/apply-controllers (:controllers old-match) match))]
(assoc db :route new-match))))
(rf/reg-fx
:navigate-fx!
(fn [[k & [params query]]]
(rfe/push-state k params query)))
(rf/reg-event-fx
:navigate!
(fn [_ [_ url-key params query]]
{:navigate-fx! [url-key params query]}))
(rf/reg-event-db
:set-docs
(fn [db [_ docs]]
(assoc db :docs docs)))
(rf/reg-event-fx
:fetch-docs
(fn [_ _]
{:http-xhrio {:method :get
:uri "/docs"
:response-format (ajax/raw-response-format)
:on-success [:set-docs]}}))
(rf/reg-event-db
:common/set-error
(fn [db [_ error]]
(assoc db :common/error error)))
(reg-event-db
:set-view
(fn [db [_ view]]
(assoc db :view view)))
(rf/reg-event-fx
:page/init-home
(fn [_ _]
{:dispatch [:fetch-docs]}))
(rf/reg-event-fx
:page/init-maps
(fn [_ _]
{:dispatch [:fetch-data]}))
(re-frame/reg-event-fx
:fetch-data
(fn [{db :db} _]
(let [uri (assoc source-host
:path "/data/data.json")]
(js/console.log
(str
"Fetching data: " uri))
;; we return a map of (side) effects
{:http-xhrio {:method :get
:uri uri
:format (json-request-format)
:response-format (json-response-format {:keywords? true})
:on-success [:process-data]
:on-failure [:bad-data]}
:db db})))
(re-frame/reg-event-fx
:process-data
;; TODO: why is this an `-fx`? Does it need to be?
(fn
[{db :db} [_ response]]
(let [data (js->clj response)]
(js/console.log (str ":process-data: " response))
{:db (refresh-map-pins (assoc db :data data))})))
(re-frame/reg-event-fx
:bad-data
;; TODO: why is this an `-fx`? Does it need to be?
(fn
[{db :db} [_ response]]
;; TODO: signal something has failed? It doesn't matter very much, unless it keeps failing.
(js/console.log (str "Failed to fetch data data" response))
db))
;;subscriptions
(rf/reg-sub
:route
(fn [db _]
(-> db :route)))
(rf/reg-sub
:page-id
:<- [:route]
(fn [route _]
(-> route :data :name)))
(rf/reg-sub
:page
:<- [:route]
(fn [route _]
(-> route :data :view)))
(rf/reg-sub
:docs
(fn [db _]
(:docs db)))
(rf/reg-sub
:common/error
(fn [db _]
(:common/error db)))

134
src/cljs/geocsv/gis.cljs Normal file
View file

@ -0,0 +1,134 @@
(ns ^{:doc "geocsv app map stuff."
:author "Simon Brooke"}
geocsv.gis
(:require [cljs.reader :refer [read-string]]
[clojure.string :refer [capitalize lower-case]]
[cemerick.url :refer (url url-encode)]
[day8.re-frame.http-fx]
[re-frame.core :refer [dispatch reg-event-db reg-event-fx subscribe]]
[ajax.core :refer [GET]]
[ajax.json :refer [json-request-format json-response-format]]
[youyesyet.locality :refer [locality]]
))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;
;;;; geocsv.gis: stuff to do with maps.
;;;;
;;;; This program is free software; you can redistribute it and/or
;;;; modify it under the terms of the GNU General Public License
;;;; as published by the Free Software Foundation; either version 2
;;;; of the License, or (at your option) any later version.
;;;;
;;;; This program is distributed in the hope that it will be useful,
;;;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;;; GNU General Public License for more details.
;;;;
;;;; You should have received a copy of the GNU General Public License
;;;; along with this program; if not, write to the Free Software
;;;; Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
;;;; USA.
;;;;
;;;; Copyright (C) 2016 Simon Brooke for Radical Independence Campaign
;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; map stuff.
(defn get-current-location []
"Return the current location from the device, setting it in the database and
returning the locality."
(try
(if (.-geolocation js/navigator)
(.getCurrentPosition
(.-geolocation js/navigator)
(fn [position]
(let [view @(subscribe [:view])
lat (.-latitude (.-coords position))
lng (.-longitude (.-coords position))]
(js/console.log (str "Current location is: " lat ", " lng))
(if
(and view (float? lat) (float? lng))
(do
(dispatch [:set-latitude lat])
(dispatch [:set-longitude lng])
(.panTo view (.latLng js/L lat lng))
(locality lat lng))
(do
(js/console.log
(if view
(str "Geolocation failed lat: '" lat "'; lng '" lng "'")
"No value for subscription to [:view]"))
0)))))
(do
(js/console.log "Geolocation not available")
0))
(catch js/Object any
(js/console.log "Exception while trying to access location: " + any)
0)))
(defn map-pin-click-handler
[id]
(js/console.log (str "Click handler for record #" id)))
(defn pin-image
"Return the name of a suitable pin image for this `record`."
[record]
(if
(:category record)
(str
(s/capitalize
(s/replace (s/lower-case (str (:category record))) #"[^a-z0-9]" "-")) "-pin")
"unknown-pin"))
(defn add-map-pin
"Add an appropriate map-pin for this `record` in this map `view`."
[record index view]
(let [lat (:latitude record)
lng (:longitude record)
pin (.icon js/L
(clj->js
{:iconAnchor [16 41]
:iconSize [32 42]
:iconUrl (str "img/map-pins/" (pin-image record) ".png")
:riseOnHover true
:shadowAnchor [16 23]
:shadowSize [57 24]
:shadowUrl "img/map-pins/shadow_pin.png"}))
marker (.marker js/L
(.latLng js/L lat lng)
(clj->js {:icon pin
:title (:name record)}))]
(.on
(.addTo marker view)
"click"
(fn [_] (map-pin-click-handler index)))
marker))
(defn map-remove-pins
"Remove all pins from this map `view`. Side-effecty; liable to be
problematic."
[view]
(if view
(.eachLayer view
#(if
(instance? js/L.Marker %)
(.removeLayer view %)))
view))
(defn refresh-map-pins
"Refresh the map pins on the current map. Side-effecty; liable to be
problematic."
[db]
(let [view (map-remove-pins @(re-frame/subscribe [:view]))
data (:data db)]
(if
view
(do
(js/console.log (str "Adding " (count data) " pins"))
(doall (map #(add-map-pin %1 %2 view) data (range))))
(js/console.log "View is not yet ready"))
db))

View file

@ -0,0 +1,102 @@
(ns ^{:doc "a map onto which to project CSV data."
:author "Simon Brooke"}
geocsv.views.map
(:require [cljsjs.leaflet]
[re-frame.core :refer [reg-sub subscribe dispatch dispatch-sync]]
[reagent.core :as reagent]
[recalcitrant.core :refer [error-boundary]]
[geocsv.gis :refer [refresh-map-pins get-current-location]]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;
;;;; geocsv.map: a map onto which to project CSV data.
;;;;
;;;; This program is free software; you can redistribute it and/or
;;;; modify it under the terms of the GNU General Public License
;;;; as published by the Free Software Foundation; either version 2
;;;; of the License, or (at your option) any later version.
;;;;
;;;; This program is distributed in the hope that it will be useful,
;;;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;;;; GNU General Public License for more details.
;;;;
;;;; You should have received a copy of the GNU General Public License
;;;; along with this program; if not, write to the Free Software
;;;; Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
;;;; USA.
;;;;
;;;; Copyright (C) 2016 Simon Brooke for Radical Independence Campaign
;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Cribbed heavily from
;;; https://github.com/reagent-project/reagent-cookbook/tree/master/recipes/leaflet
;;; but using OSM data because we can't afford commercial, so also cribbed from
;;; https://switch2osm.org/using-tiles/getting-started-with-leaflet/
;;; Note that this is raw reagent stylee; it should be refactoed into re-frame stylee
;;; when I understand it better.
;; which provider to use
(def ^:dynamic *map-provider* :osm)
(def osm-url "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png")
(def osm-attrib "Map data &copy; <a href='http://openstreetmap.org'>OpenStreetMap</a> contributors")
;; My gods mapbox is user-hostile!
(defn map-did-mount-mapbox
"Did-mount function loading map tile data from MapBox (proprietary)."
[]
(get-current-location)
(let [view (.setView
(.map js/L "map" (clj->js {:zoomControl "false"})))]
;; NEED TO REPLACE FIXME with your mapID!
(.addTo (.tileLayer js/L "http://{s}.tiles.mapbox.com/v3/FIXME/{z}/{x}/{y}.png"
(clj->js {:attribution "Map data &copy; [...]"
:maxZoom 18})))
view))
(defn map-did-mount-osm
"Did-mount function loading map tile data from Open Street Map."
[]
(get-current-location) ;; - [Violation] Only request geolocation information in response to a user gesture.
(let [view (.setView
(.map js/L
"map"
(clj->js {:zoomControl false}))
#js [@(subscribe [:latitude]) @(subscribe [:longitude])]
@(subscribe [:zoom]))]
(.addTo (.tileLayer js/L osm-url
(clj->js {:attribution osm-attrib
:maxZoom 18}))
view)
(dispatch-sync [:set-view view])
(.on view "moveend"
(fn [_] (let [c (.getCenter view)]
(js/console.log (str "Moving centre to " c))
(dispatch-sync [:set-latitude (.-lat c)])
(dispatch-sync [:set-longitude (.-lng c)])
(dispatch [:fetch-locality]))))
(refresh-map-pins)
view))
(defn map-did-mount
"Select the actual map provider to use."
[]
(dispatch-sync [:set-view (case *map-provider*
:mapbox (map-did-mount-mapbox)
:osm (map-did-mount-osm)
;; potentially others
)]))
(defn map-render
"Render the actual div containing the map."
[]
[:div#map {:style {:height "500px"}}])
(defn panel
"A reagent class for the map object."
[]
;; (get-current-location)
(reagent/create-class {:reagent-render map-render
:component-did-mount map-did-mount}))