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)))) | ||||||||||||||||||||||||||||