excel-to-json0.1.0-SNAPSHOTdependencies
| (this space intentionally left almost blank) | |||||||||||||||||||||||||||
(ns excel-to-json.converter
(:require [flatland.ordered.map :refer [ordered-map]]
[clj-excel.core :as ce])
(:import [org.apache.poi.ss.usermodel DataFormatter Cell])) | ||||||||||||||||||||||||||||
(def ^:dynamic *evaluator*) | ||||||||||||||||||||||||||||
(defn split-keys [k] (map keyword (clojure.string/split (name k) #"\."))) | ||||||||||||||||||||||||||||
(defn safe-keyword [k] (keyword (str (if (instance? Number k) (long k) k)))) | ||||||||||||||||||||||||||||
(defn apply-format [cell] (.formatCellValue (DataFormatter.) cell *evaluator*)) | ||||||||||||||||||||||||||||
(defn safe-value [cell]
(let [value (apply-format cell)]
(try
(. Integer parseInt value)
(catch Exception e
(try
(. Float parseFloat value)
(catch Exception e
(case (clojure.string/lower-case value)
"true" true
"false" false
value))))))) | ||||||||||||||||||||||||||||
(defn safe-key [cell] (keyword (safe-value cell))) | ||||||||||||||||||||||||||||
(defn is-blank? [cell] (or (= (.getCellType cell) Cell/CELL_TYPE_BLANK)) (= (safe-value cell) )) | ||||||||||||||||||||||||||||
(defn with-index [cells]
(into {} (map (fn [c] [(.getColumnIndex c) c]) cells))) | ||||||||||||||||||||||||||||
(defn unpack-keys [header row]
(let [indexed-header (with-index header)
indexed-row (with-index row)]
(reduce (fn [acc [i header]]
(let [cell (get indexed-row i)]
(if (or (is-blank? header) (nil? cell) (is-blank? cell))
acc
(assoc-in acc (split-keys (safe-key header)) (safe-value cell)))))
(ordered-map) indexed-header))) | ||||||||||||||||||||||||||||
(defn non-empty-rows [rows]
(filter
(fn [row]
(let [cell (first row)]
(and
(= (.getColumnIndex cell) 0)
(not (is-blank? cell)))))
rows)) | ||||||||||||||||||||||||||||
(defn headers-and-rows [sheet]
(let [rows (non-empty-rows sheet)]
[(first rows) (rest rows)])) | ||||||||||||||||||||||||||||
(defn ensure-ordered [m k] (if (nil? (k m)) (assoc m k (ordered-map)) m)) | ||||||||||||||||||||||||||||
(defn blank? [value] (cond (integer? value) false :else (clojure.string/blank? value))) | ||||||||||||||||||||||||||||
(defn add-sheet-config [primary-key current-key sheets config]
(reduce (fn [acc0 sheet]
(let [[headers rows] (headers-and-rows sheet)
secondary-key (safe-key (second headers))
unpacked-rows (map #(unpack-keys headers %) rows)
grouped-rows (group-by primary-key unpacked-rows)
secondary-config (get grouped-rows (name current-key))]
;; TODO remove either primary or current key
(reduce (fn [acc row]
(let [nested-key (get row secondary-key)
safe-nested-key (safe-keyword nested-key)
sub (dissoc row primary-key secondary-key)]
(if (empty? sub)
acc
(if (blank? nested-key)
(update-in acc [secondary-key] conj sub)
(assoc-in (ensure-ordered acc secondary-key)
[secondary-key safe-nested-key] sub)))))
acc0 secondary-config)))
config sheets)) | ||||||||||||||||||||||||||||
(defn filename-from-sheet [sheet] (nth (re-find #"^(.*)\.json(#.*)?$" (.getSheetName sheet)) 1)) | ||||||||||||||||||||||||||||
(defn group-sheets [workbook]
(seq (reduce (fn [acc sheet]
(if-let [filename (filename-from-sheet sheet)]
(update-in acc [filename] (fnil conj []) sheet) acc))
{} workbook))) | ||||||||||||||||||||||||||||
(defn parse-sheets [sheets]
(let [[headers rows] (headers-and-rows (first sheets))
primary-key (safe-key (first headers))]
(doall (for [row rows]
(let [config (unpack-keys headers row)
current-key (keyword (get config primary-key))]
(add-sheet-config primary-key current-key (rest sheets) config)))))) | ||||||||||||||||||||||||||||
(defn parse-workbook [workbook]
(binding [*evaluator* (.createFormulaEvaluator (.getCreationHelper workbook))]
(doall (for [[name sheets] (group-sheets workbook)]
[name (parse-sheets sheets)])))) | ||||||||||||||||||||||||||||
(defn convert [file-path] (parse-workbook (ce/workbook-xssf file-path))) | ||||||||||||||||||||||||||||
(ns excel-to-json.core
(:gen-class)
(:require [clojure.core.async :refer [go chan <! >! put!]]
[cheshire.core :refer [generate-string]]
[fswatch.core :as fs]
[clojure.tools.cli :as cli]
[excel-to-json.converter :as converter]
[excel-to-json.logger :as log])
(:import java.io.File
sun.nio.fs.UnixPath
[excel_to_json.logger PrintLogger])) | ||||||||||||||||||||||||||||
(set! *warn-on-reflection* true) | ||||||||||||||||||||||||||||
(def ^:dynamic *logger* (PrintLogger.)) | ||||||||||||||||||||||||||||
'watching' taken from https://github.com/ibdknox/cljs-watch/ | ||||||||||||||||||||||||||||
(defn is-xlsx? [^File file] (re-matches #"^((?!~\$).)*.xlsx$" (.getName file))) | ||||||||||||||||||||||||||||
(defn get-filename [^File file] (first (clojure.string/split (.getName file) #"\."))) | ||||||||||||||||||||||||||||
(defn convert-and-save [^File file target-path]
(try
(let [file-path (.getPath file)]
(doseq [[filename config] (converter/convert file-path)]
(let [output-file (str target-path "/" filename ".json")
json-string (generate-string config {:pretty true})]
(spit output-file json-string)
(log/info *logger* (str "Converted" file-path "->" output-file)))))
(catch Exception e
(log/error *logger* (str "Converting" file "failed with: " e "\n"))
(clojure.pprint/pprint (.getStackTrace e))))) | ||||||||||||||||||||||||||||
(defn watch-callback [source-path target-path file-path]
(let [file (clojure.java.io/file source-path (.toString ^UnixPath file-path))]
(when (is-xlsx? file)
(log/info *logger* "Updating changed file...")
(convert-and-save file target-path)
(log/status *logger* "[done]")))) | ||||||||||||||||||||||||||||
(defn run [{:keys [source-path target-path] :as state}]
(log/info *logger* (format "Converting files from '%s' to '%s'"
source-path target-path))
(let [directory (clojure.java.io/file source-path)
xlsx-files (reduce (fn [acc ^File f]
(if (and (.isFile f) (is-xlsx? f))
(conj acc f)
acc)) [] (.listFiles directory))]
(doseq [file xlsx-files]
(convert-and-save file target-path))
(log/status *logger* "[done]")
state)) | ||||||||||||||||||||||||||||
(defn stop-watching [state]
(if-let [path (:watched-path state)]
(do
(fs/unwatch-path path)
(dissoc state :watched-path))
state)) | ||||||||||||||||||||||||||||
(defn start-watching [{:keys [source-path target-path watched-path] :as state}]
(let [callback #(watch-callback source-path target-path %)
new-state (if (not (= watched-path source-path))
(stop-watching state)
state)]
(fs/watch-path source-path :create callback :modify callback)
(log/info *logger* (format "Starting to watch '%s'" source-path))
(assoc new-state :watched-path source-path))) | ||||||||||||||||||||||||||||
(def option-specs [[nil "--disable-watching" "Disable watching" :default false :flag true] ["-h" "--help" "Show help" :default false :flag true]]) | ||||||||||||||||||||||||||||
re-run on directory-change | ||||||||||||||||||||||||||||
(defn switch-watching! [state enabled?]
(if enabled?
(if (every? #(not (nil? %)) (map state [:source-path :target-path]))
(start-watching state)
state)
(stop-watching state))) | ||||||||||||||||||||||||||||
(defmulti handle-event (fn [state [event-type payload]] event-type)) | ||||||||||||||||||||||||||||
(defmethod handle-event :path-change [state [event-type payload]]
(let [path (.getPath ^File (:file payload))]
(case (:type payload)
:source (assoc state :source-path path)
:target (assoc state :target-path path)))) | ||||||||||||||||||||||||||||
(defmethod handle-event :run [state _] (run state)) | ||||||||||||||||||||||||||||
(defmethod handle-event :watching [state [event-type payload]] (switch-watching! state payload)) | ||||||||||||||||||||||||||||
(defmethod handle-event :default [state [event-type payload]] (log/error *logger* (format "Unknown event-type '%s'" event-type)) state) | ||||||||||||||||||||||||||||
(defn -main [& args]
(let [parsed-options (cli/parse-opts args option-specs)]
(when (:help (:options parsed-options))
(println (:summary parsed-options))
(System/exit 0))
(let [arguments (:arguments parsed-options)]
(if (> (count arguments) 1)
(let [source-path (first arguments) target-path (second arguments)
state {:source-path source-path :target-path
(or target-path source-path)
:watched-path source-path}]
(run state)
(when-not (:disable-watching (:options parsed-options))
(start-watching state)
nil))
(println "Usage: excel-to-json SOURCEDIR [TARGETDIR]"))))) | ||||||||||||||||||||||||||||
(ns excel-to-json.gui
(:gen-class)
(:require [clojure.core.async :refer [go chan <! >! put!]]
[seesaw.core :as sc]
[seesaw.bind :as sb]
[seesaw.chooser :as sch]
[seesaw.mig :as sm]
[excel-to-json.core :as c])
(:import java.util.prefs.Preferences
[excel_to_json.logger StoreLogger])) | ||||||||||||||||||||||||||||
TODO button for applying source -> target | ||||||||||||||||||||||||||||
(sc/native!) | ||||||||||||||||||||||||||||
(defn preferences-node [path-name] (.node (Preferences/userRoot) path-name)) | ||||||||||||||||||||||||||||
(def ^:dynamic *preferences* (preferences-node "excel-to-json")) | ||||||||||||||||||||||||||||
(defn get-preference
([key]
(get-preference key nil))
([key default-value]
(.get *preferences* (name key) default-value))) | ||||||||||||||||||||||||||||
(defn put-preference [key value] (.put *preferences* key value)) | ||||||||||||||||||||||||||||
(defn create-border [] (javax.swing.BorderFactory/createLineBorder java.awt.Color/BLACK)) | ||||||||||||||||||||||||||||
(defn get-select-button [channel tag]
(let [text (keyword (str "#" (name tag) "-text"))
handler (fn [event]
(when-let [file (sch/choose-file :type :open
:selection-mode :dirs-only)]
(sc/text! (sc/select (sc/to-root event) [text]) (.getPath file))
(put-preference (str (name tag) "-directory") (.getPath file))
(put! channel [:path-change {:type tag :file file}])))]
(sc/button :action (sc/action :name "Choose" :handler handler)))) | ||||||||||||||||||||||||||||
(defn create-header [channel source-path target-path]
(let [source-text (sc/text :id :source-text :text source-path :editable? false)
target-text (sc/text :id :target-text :text target-path :editable? false)]
(sm/mig-panel
:constraints ["wrap 3, insets 0"
"[shrink 0]10[200, grow, fill]10[shrink 0]"
"[shrink 0]5[]"]
:items [["Source directory:"]
[source-text]
[(get-select-button channel :source)]
["Target directory:"]
[target-text]
[(get-select-button channel :target)]]))) | ||||||||||||||||||||||||||||
(defn item-renderer [renderer info] (sc/config! renderer :text (:value info))) | ||||||||||||||||||||||||||||
(defn create-log [model]
(let [listbox (sc/listbox :renderer item-renderer)]
(sb/bind model (sb/property listbox :model))
(doto (sc/scrollable listbox)
(.setBorder ,, (create-border))))) | ||||||||||||||||||||||||||||
(defn create-convert-button [channel]
(let [items [(sc/checkbox :text "Watch directory"
:selected? true
:listen [:action #(put! channel [:watching (sc/value %)])])
:fill-h
(sc/button :text "Convert"
:listen [:action (fn [_] (put! channel [:run]))])]]
(sc/horizontal-panel :items items))) | ||||||||||||||||||||||||||||
(defn create-panel [channel source-path target-path log-model]
(sc/border-panel :border 5 :vgap 5 :hgap 5
:north (create-header channel source-path target-path)
:center (create-log log-model)
:south (create-convert-button channel))) | ||||||||||||||||||||||||||||
(defn initialize [channel log-model]
(let [source-path (get-preference :source-directory)
target-path (get-preference :target-directory)
frame (sc/frame :title "Excel > JSON"
:width 1350
:height 650
:on-close :exit)
panel (create-panel channel (or source-path ) (or target-path ) log-model)]
(.add ^javax.swing.JFrame frame panel)
(sc/invoke-later (sc/show! frame))
[frame source-path target-path])) | ||||||||||||||||||||||||||||
(defn -main [& args]
(let [channel (chan)
log (atom [])
[_ source-path target-path] (initialize channel log)
m {:source-path source-path :target-path target-path :watched-path source-path}]
(binding [c/*logger* (StoreLogger. log)]
(let [initial-state (c/switch-watching! m true)]
(go
(loop [event (<! channel)
state initial-state]
(let [new-state (c/handle-event state event)]
(recur (<! channel) new-state)))))))) | ||||||||||||||||||||||||||||
(ns excel-to-json.logger (:require [clansi.core :refer [style]])) | ||||||||||||||||||||||||||||
(defn text-timestamp []
(let [calendar (java.util.Calendar/getInstance)
date-format (java.text.SimpleDateFormat. "HH:mm:ss")]
(.format date-format (.getTime calendar)))) | ||||||||||||||||||||||||||||
(defprotocol Logger (info [this msg]) (error [this msg]) (status [this msg])) | ||||||||||||||||||||||||||||
(deftype PrintLogger []
Logger
(info [this text]
(println (style (str (text-timestamp) " :: watcher :: ") :magenta) text))
(error [this text]
(println (style "error :: " :red) text))
(status [this text]
(println " " (style text :green)))) | ||||||||||||||||||||||||||||
(defn add-line! [store line] (swap! store conj line)) | ||||||||||||||||||||||||||||
(deftype StoreLogger [store]
Logger
(info [this text]
(add-line! store (str (text-timestamp) " " text)))
(error [this text]
(add-line! store (str "error :: " text)))
(status [this text]
(add-line! store (str " " text)))) | ||||||||||||||||||||||||||||