excel-to-json

0.1.0-SNAPSHOT


dependencies

org.clojure/clojure
1.5.1
org.clojure/core.async
0.1.267.0-0d7780-alpha
org.clojure/tools.cli
0.3.1
cheshire
5.3.1
myguidingstar/clansi
1.3.0
fswatch
0.2.0-SNAPSHOT
org.flatland/ordered
1.5.2
clj-excel
0.0.1
seesaw
1.4.4



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