🔧 Refactor token json file import/export

This commit is contained in:
Andrés Moya 2025-05-20 17:28:51 +02:00 committed by Andrés Moya
parent 3ee3ee2059
commit 5e8929e504
11 changed files with 686 additions and 615 deletions

View file

@ -11,14 +11,10 @@
[app.common.files.tokens :as cft]
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.transit :as t]
[app.common.types.tokens-lib :as ctob]
[app.main.data.notifications :as ntf]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.warnings :as wtw]
[app.main.store :as st]
[app.util.i18n :refer [tr]]
[app.util.time :as dt]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@ -249,116 +245,6 @@
#(get tokens (sd-token-name %))
(StyleDictionary. (assoc default-config :log {:verbosity "verbose"}))))
;; === Import
(defn- decode-single-set-json
"Decodes parsed json containing single token set and converts to library"
[this set-name tokens]
(assert (map? tokens) "expected a map data structure for `data`")
(ctob/add-set this (ctob/make-token-set :name (ctob/normalize-set-name set-name)
:tokens (ctob/flatten-nested-tokens-json tokens ""))))
(defn- decode-single-set-legacy-json
"Decodes parsed legacy json containing single token set and converts to library"
[this set-name tokens]
(assert (map? tokens) "expected a map data structure for `data`")
(decode-single-set-json this set-name (ctob/legacy-nodes->dtcg-nodes tokens)))
(defn- reference-errors
"Extracts reference errors from StyleDictionary."
[err]
(let [[header-1 header-2 & errors] (str/split err "\n")]
(when (and
(= header-1 "Error: ")
(= header-2 "Reference Errors:"))
errors)))
(defn name-error
"Extracts name error out of malli schema error during import."
[err]
(let [schema-error (some-> (ex-data err)
(get-in [:app.common.schema/explain :errors])
(first))
name-error? (= (:in schema-error) [:name])]
(when name-error?
(wte/error-ex-info :error.import/invalid-token-name (:value schema-error) err))))
(defn- group-by-value [m]
(reduce (fn [acc [k v]]
(update acc v conj k)) {} m))
(defn- tokens-of-unknown-type-warning [unknown-tokens]
(let [type->tokens (group-by-value unknown-tokens)]
(ntf/show {:content (tr "workspace.tokens.unknown-token-type")
:detail (->> (for [[token-type tokens] type->tokens]
(tr "workspace.tokens.unknown-token-type-section" token-type (count tokens)))
(str/join "\n"))
:type :toast
:level :info})))
(defn parse-json [data]
(try
(t/decode-str data)
(catch js/Error e
(throw (wte/error-ex-info :error.import/json-parse-error data e)))))
(defn decode-json-data [data file-name]
(let [single-set? (ctob/single-set? data)
json-format (ctob/get-json-format data)
unknown-tokens (ctob/get-tokens-of-unknown-type
data
""
(= json-format :json-format/dtcg))]
{:tokens-lib
(try
(cond
(and single-set?
(= :json-format/legacy json-format))
(decode-single-set-legacy-json (ctob/ensure-tokens-lib nil) file-name data)
(and single-set?
(= :json-format/dtcg json-format))
(decode-single-set-json (ctob/ensure-tokens-lib nil) file-name data)
(= :json-format/legacy json-format)
(ctob/decode-legacy-json (ctob/ensure-tokens-lib nil) data)
:else
(ctob/decode-dtcg-json (ctob/ensure-tokens-lib nil) data))
(catch js/Error e
(let [err (or (name-error e)
(wte/error-ex-info :error.import/invalid-json-data data e))]
(throw err))))
:unknown-tokens unknown-tokens}))
(defn process-json-stream
([data-stream]
(process-json-stream nil data-stream))
([params data-stream]
(let [{:keys [file-name]} params]
(->> data-stream
(rx/map parse-json)
(rx/map #(decode-json-data % file-name))
(rx/mapcat (fn [{:keys [tokens-lib unknown-tokens]}]
(when unknown-tokens
(st/emit! (tokens-of-unknown-type-warning unknown-tokens)))
(try
(->> (ctob/get-all-tokens tokens-lib)
(resolve-tokens-with-errors)
(rx/map (fn [_]
tokens-lib))
(rx/catch (fn [sd-error]
(let [reference-errors (reference-errors sd-error)]
;; We allow reference errors for the users to resolve in the ui and throw on any other errors
(if reference-errors
(rx/of tokens-lib)
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error sd-error sd-error)))))))
(catch js/Error e
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error "" e))))))))))
;; === Hooks
(defonce !tokens-cache (atom nil))

View file

@ -0,0 +1,134 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.data.workspace.tokens.import-export
(:require
[app.common.files.helpers :as cfh]
[app.common.json :as json]
[app.common.types.tokens-lib :as ctob]
[app.main.data.notifications :as ntf]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.store :as st]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]))
(defn- extract-reference-errors
"Extracts reference errors from errors produced by StyleDictionary."
[err]
(let [[header-1 header-2 & errors] (str/split err "\n")]
(when (and
(= header-1 "Error: ")
(= header-2 "Reference Errors:"))
errors)))
(defn- extract-name-error
"Extracts name error out of malli schema error during import."
[err]
(let [schema-error (some-> (ex-data err)
(get-in [:app.common.schema/explain :errors])
(first))
name-error? (= (:in schema-error) [:name])]
(when name-error?
(wte/error-ex-info :error.import/invalid-token-name (:value schema-error) err))))
(defn- group-by-value [m]
(reduce (fn [acc [k v]]
(update acc v conj k)) {} m))
(defn- show-unknown-types-warning [unknown-tokens]
(let [type->tokens (group-by-value unknown-tokens)]
(ntf/show {:content (tr "workspace.tokens.unknown-token-type")
:detail (->> (for [[token-type tokens] type->tokens]
(tr "workspace.tokens.unknown-token-type-section" token-type (count tokens)))
(str/join "\n"))
:type :toast
:level :info})))
(defn- decode-json
[json-string]
(try
(json/decode json-string {:key-fn identity})
(catch js/Error e
(throw (wte/error-ex-info :error.import/json-parse-error json-string e)))))
(defn- parse-decoded-json
[decoded-json file-name]
(try
{:tokens-lib (ctob/parse-decoded-json decoded-json file-name)
:unknown-tokens (ctob/get-tokens-of-unknown-type decoded-json)}
(catch js/Error e
(let [err (or (extract-name-error e)
(wte/error-ex-info :error.import/invalid-json-data decoded-json e))]
(throw err)))))
(defn- validate-library
"Resolve tokens in the library and search for errors. Reference errors are ignored, since
it can be resolved by the user in the UI. All the other errors are thrown as exceptions."
[{:keys [tokens-lib unknown-tokens]}]
(when unknown-tokens
(st/emit! (show-unknown-types-warning unknown-tokens)))
(try
(->> (ctob/get-all-tokens tokens-lib)
(sd/resolve-tokens-with-errors)
(rx/map (fn [_]
tokens-lib))
(rx/catch (fn [sd-error]
(let [reference-errors (extract-reference-errors sd-error)]
(if reference-errors
(rx/of tokens-lib)
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error sd-error sd-error)))))))
(catch js/Error e
(throw (wte/error-ex-info :error.import/style-dictionary-unknown-error "" e)))))
(defn- drop-parent-directory
[path]
(->> (cfh/split-path path)
(rest)
(str/join "/")))
(defn- remove-path-extension
[path]
(-> (str/split path ".")
(butlast)
(str/join)))
(defn- file-path->set-name
[path]
(-> path
(drop-parent-directory)
(remove-path-extension)))
(defn import-file-stream
[file-path file-text]
(let [file-name (remove-path-extension file-path)]
(->> file-text
(rx/map decode-json)
(rx/map #(parse-decoded-json % file-name))
(rx/mapcat validate-library))))
(defn import-directory-stream
[file-stream]
(->> file-stream
(rx/map (fn [[file-path file-text]]
(let [set-name (file-path->set-name file-path)]
(try
{set-name (decode-json file-text)}
(catch js/Error e
;; Ignore files with json parse errors
{:path file-path :error e})))))
(rx/reduce (fn [merged-json decoded-json]
(if (:error decoded-json)
merged-json
(conj merged-json decoded-json)))
{})
(rx/map (fn [merged-json]
(parse-decoded-json (if (= 1 (count merged-json))
(val (first merged-json))
merged-json)
(ffirst merged-json))))
(rx/mapcat validate-library)))

View file

@ -7,12 +7,11 @@
(ns app.main.ui.workspace.tokens.modals.import
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.helpers :as cfh]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.errors :as dwte]
[app.main.data.workspace.tokens.import-export :as dwti]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
@ -29,23 +28,8 @@
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
(defn- drop-parent-directory [path]
(->> (cfh/split-path path)
(rest)
(str/join "/")))
(defn- remove-path-extension [path]
(-> (str/split path ".")
(butlast)
(str/join)))
(defn- file-path->set-name
[path]
(-> path
(drop-parent-directory)
(remove-path-extension)))
(defn- on-import-stream [tokens-lib-stream]
(defn- on-stream-imported
[tokens-lib-stream]
(rx/sub!
tokens-lib-stream
(fn [lib]
@ -53,9 +37,8 @@
(dwtl/import-tokens-lib lib))
(modal/hide!))
(fn [err]
(js/console.error err)
(st/emit! (ntf/show {:content (wte/humanize-errors [(ex-data err)])
:detail (wte/detail-errors [(ex-data err)])
(st/emit! (ntf/show {:content (dwte/humanize-errors [(ex-data err)])
:detail (dwte/detail-errors [(ex-data err)])
:type :toast
:level :error})))))
@ -81,44 +64,28 @@
type (.-type file)]
(or
(= type "application/json")
(str/ends-with? name ".json")))))
;; Read files as text, ignore files with json parse errors
(map (fn [file]
(->> (wapi/read-file-as-text file)
(rx/mapcat (fn [json]
(let [path (.-webkitRelativePath file)]
(rx/of
(try
{(file-path->set-name path) (sd/parse-json json)}
(catch js/Error e
{:path path :error e}))))))))))]
(->> (apply rx/merge files)
(rx/reduce (fn [acc cur]
(if (:error cur)
acc
(conj acc cur)))
{})
(rx/map #(sd/decode-json-data (if (= 1 (count %))
(val (first %))
%)
(ffirst %)))
(rx/map :tokens-lib)
(on-import-stream))
(str/ends-with? name ".json"))))))]
(->> (rx/from files)
(rx/mapcat (fn [file]
(->> (wapi/read-file-as-text file)
(rx/map (fn [file-text]
[(.-webkitRelativePath file)
file-text])))))
(dwti/import-directory-stream)
(on-stream-imported))
(-> (mf/ref-val dir-input-ref)
(dom/set-value! "")))))
on-import
on-import-file
(mf/use-fn
(fn [event]
(let [file (-> (dom/get-target event)
(dom/get-files)
(first))
file-name (remove-path-extension (.-name file))]
(first))]
(->> (wapi/read-file-as-text file)
(sd/process-json-stream {:file-name file-name})
(on-import-stream))
(dwti/import-file-stream (.-name file))
(on-stream-imported))
(-> (mf/ref-val file-input-ref)
(dom/set-value! "")))))]
@ -142,7 +109,7 @@
:ref file-input-ref
:style {:display "none"}
:accept ".json"
:on-change on-import}]
:on-change on-import-file}]
[:input {:type "file"
:ref dir-input-ref
:style {:display "none"}

View file

@ -381,7 +381,7 @@
(fn []
(st/emit! (ptk/data-event ::ev/event {::ev/name "export-tokens"}))
(let [tokens-json (some-> (deref refs/tokens-lib)
(ctob/encode-dtcg)
(ctob/export-dtcg-json)
(json/encode :key-fn identity))]
(->> (wapi/create-blob (or tokens-json "{}") "application/json")
(dom/trigger-download "tokens.json")))))

View file

@ -9,6 +9,7 @@
[frontend-tests.logic.frame-guides-test]
[frontend-tests.logic.groups-test]
[frontend-tests.plugins.context-shapes-test]
[frontend-tests.tokens.import-export-test]
[frontend-tests.tokens.logic.token-actions-test]
[frontend-tests.tokens.logic.token-data-test]
[frontend-tests.tokens.style-dictionary-test]
@ -40,5 +41,6 @@
'frontend-tests.basic-shapes-test
'frontend-tests.tokens.logic.token-actions-test
'frontend-tests.tokens.logic.token-data-test
'frontend-tests.tokens.import-export-test
'frontend-tests.tokens.style-dictionary-test
'frontend-tests.tokens.token-form-test))

View file

@ -0,0 +1,101 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns frontend-tests.tokens.import-export-test
(:require
[app.common.json :as json]
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.import-export :as dwti]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]))
(t/deftest import-file-stream-test
(t/async
done
(t/testing "import simple color token value"
(let [json (-> {"core" {"color" {"$value" "red"
"$type" "color"}}
"$metadata" {"tokenSetOrder" ["core"]}}
(json/encode {:type :json-verbose}))]
(->> (rx/of json)
(dwti/import-file-stream "core")
(rx/subs! (fn [tokens-lib]
(t/is (instance? ctob/TokensLib tokens-lib))
(t/is (= "red" (-> (ctob/get-set tokens-lib "core")
(ctob/get-token "color")
(:value))))
(done))))))))
(t/deftest reference-errors-test
(t/testing "Extracts reference errors from StyleDictionary errors"
;; Using unicode for the white-space after "Error: " as some editors might remove it and its more visible
(t/is (=
["Some token references (2) could not be found."
""
"foo.value tries to reference missing, which is not defined."
"color.value tries to reference missing, which is not defined."]
(#'dwti/extract-reference-errors "Error:\u0020
Reference Errors:
Some token references (2) could not be found.
foo.value tries to reference missing, which is not defined.
color.value tries to reference missing, which is not defined.")))
(t/is (nil? (#'dwti/extract-reference-errors nil))) ;; #' is used to access private functions
(t/is (nil? (#'dwti/extract-reference-errors "none")))))
(t/deftest import-empty-json-stream-test
(t/async
done
(t/testing "fails on empty json string"
(->> (rx/of "{}")
(dwti/import-file-stream "")
(rx/subs!
(fn [_]
(throw (js/Error. "Should be an error")))
(fn [err]
(t/is (= :error.import/invalid-json-data (:error/code (ex-data err))))
(done)))))))
(t/deftest import-invalid-json-stream-test
(t/async
done
(t/testing "fails on invalid json"
(->> (rx/of "{,}")
(dwti/import-file-stream "")
(rx/subs!
(fn [_]
(throw (js/Error. "Should be an error")))
(fn [err]
(t/is (= :error.import/json-parse-error (:error/code (ex-data err))))
(done)))))))
(t/deftest import-non-token-json-stream-test
(t/async
done
(t/testing "fails on non-token json"
(->> (rx/of "{\"foo\": \"bar\"}")
(dwti/import-file-stream "")
(rx/subs!
(fn []
(throw (js/Error. "Should be an error")))
(fn [err]
(t/is (= :error.import/invalid-json-data (:error/code (ex-data err))))
(done)))))))
(t/deftest import-missing-references-json-test
(t/async
done
(t/testing "allows missing references in tokens"
(let [json (-> {"core" {"color" {"$value" "{missing}"
"$type" "color"}}
"$metadata" {"tokenSetOrder" ["core"]}}
(json/encode {:type :json-verbose}))]
(->> (rx/of json)
(dwti/import-file-stream "")
(rx/subs! (fn [tokens-lib]
(t/is (instance? ctob/TokensLib tokens-lib))
(t/is (= "{missing}" (:value (ctob/get-token-in-set tokens-lib "core" "color"))))
(done))))))))

View file

@ -6,7 +6,6 @@
(ns frontend-tests.tokens.style-dictionary-test
(:require
[app.common.transit :as tr]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[beicon.v2.core :as rx]
@ -51,114 +50,3 @@
(t/is (= :error.token/number-too-large
(get-in resolved-tokens ["borderRadius.largeFn" :errors 0 :error/code])))
(done))))))))
(t/deftest process-json-stream-test
(t/async
done
(t/testing "process simple color token value"
(let [json (-> {"core" {"color" {"$value" "red"
"$type" "color"}}
"$metadata" {"tokenSetOrder" ["core"]}}
(tr/encode-str {:type :json-verbose}))]
(->> (rx/of json)
(sd/process-json-stream)
(rx/subs! (fn [tokens-lib]
(t/is (instance? ctob/TokensLib tokens-lib))
(t/is (= "red" (-> (ctob/get-set tokens-lib "core")
(ctob/get-token "color")
(:value))))
(done))))))))
(t/deftest reference-errros-test
(t/testing "Extracts reference errors from StyleDictionary errors"
;; Using unicode for the white-space after "Error: " as some editors might remove it and its more visible
(t/is (=
["Some token references (2) could not be found."
""
"foo.value tries to reference missing, which is not defined."
"color.value tries to reference missing, which is not defined."]
(sd/reference-errors "Error:\u0020
Reference Errors:
Some token references (2) could not be found.
foo.value tries to reference missing, which is not defined.
color.value tries to reference missing, which is not defined.")))
(t/is (nil? (sd/reference-errors nil)))
(t/is (nil? (sd/reference-errors "none")))))
(t/deftest process-empty-json-stream-test
(t/async
done
(t/testing "processes empty json string"
(->> (rx/of "{}")
(sd/process-json-stream)
(rx/subs! (fn [tokens-lib]
(t/is (instance? ctob/TokensLib tokens-lib))
(done)))))))
(t/deftest process-invalid-json-stream-test
(t/async
done
(t/testing "fails on invalid json"
(->> (rx/of "{,}")
(sd/process-json-stream)
(rx/subs!
(fn []
(throw (js/Error. "Should be an error")))
(fn [err]
(t/is (= :error.import/json-parse-error (:error/code (ex-data err))))
(done)))))))
(t/deftest process-non-token-json-stream-test
(t/async
done
(t/testing "fails on non-token json"
(->> (rx/of "{\"foo\": \"bar\"}")
(sd/process-json-stream)
(rx/subs!
(fn []
(throw (js/Error. "Should be an error")))
(fn [err]
(t/is (= :error.import/invalid-json-data (:error/code (ex-data err))))
(done)))))))
(t/deftest process-missing-references-json-test
(t/async
done
(t/testing "allows missing references in tokens"
(let [json (-> {"core" {"color" {"$value" "{missing}"
"$type" "color"}}
"$metadata" {"tokenSetOrder" ["core"]}}
(tr/encode-str {:type :json-verbose}))]
(->> (rx/of json)
(sd/process-json-stream)
(rx/subs! (fn [tokens-lib]
(t/is (instance? ctob/TokensLib tokens-lib))
(t/is (= "{missing}" (:value (ctob/get-token-in-set tokens-lib "core" "color"))))
(done))))))))
(t/deftest single-set-legacy-json-decoding
(let [decode-single-set-legacy-json #'sd/decode-single-set-legacy-json
json {"color" {"red" {"100" {"value" "red"
"type" "color"
"description" ""}}}}
lib (decode-single-set-legacy-json (ctob/ensure-tokens-lib nil) "single_set" json)
get-set-token (fn [set-name token-name]
(some-> (ctob/get-set lib set-name)
(ctob/get-token token-name)))]
(t/is (= '("single_set") (ctob/get-ordered-set-names lib)))
(t/testing "token added"
(t/is (some? (get-set-token "single_set" "color.red.100"))))))
(t/deftest single-set-dtcg-json-decoding
(let [decode-single-set-json #'sd/decode-single-set-json
json (-> {"color" {"red" {"100" {"$value" "red"
"$type" "color"
"$description" ""}}}})
lib (decode-single-set-json (ctob/ensure-tokens-lib nil) "single_set" json)
get-set-token (fn [set-name token-name]
(some-> (ctob/get-set lib set-name)
(ctob/get-token token-name)))]
(t/is (= '("single_set") (ctob/get-ordered-set-names lib)))
(t/testing "token added"
(t/is (some? (get-set-token "single_set" "color.red.100"))))))