diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index 2275a2cc6d..fe85844dc2 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -13,6 +13,7 @@ [app.common.features :as cfeat] [app.common.files.helpers :as cfh] [app.common.files.migrations :as fmg] + [app.common.files.tokens :as cfo] [app.common.files.validate :as fval] [app.common.logging :as l] [app.common.media :as cm] @@ -678,15 +679,34 @@ colors colors)) +(defn absorb-unlinked-tokens-source + "Make the file its own tokens source when its tokens source is not one + of the given library-ids, as happens when the library was deleted. The + tokens lib of the source is copied while the library still exists, even + if deleted; otherwise the tokens source and status are removed." + [cfg data library-ids] + (let [source-id (cfo/get-tokens-source data)] + (if (and (some? source-id) + (not (contains? library-ids source-id))) + (if-let [library (get-file cfg source-id + :include-deleted? true + :throw-if-not-exists? false)] + (cfo/absorb-tokens-lib data (:data library)) + (dissoc data :tokens-source :tokens-status)) + data))) + (defn embed-assets [cfg data file-id] - (let [library-ids (get-libraries cfg [file-id])] - (reduce (fn [data library-id] - (if-let [library (get-file cfg library-id :include-deleted? true)] - (ctf/absorb-assets data (:data library)) - data)) - data - library-ids))) + (let [library-ids (get-libraries cfg [file-id]) + data (reduce (fn [data library-id] + (if-let [library (get-file cfg library-id :include-deleted? true)] + (cond-> (ctf/absorb-assets data (:data library)) + (= library-id (cfo/get-tokens-source data)) + (cfo/absorb-tokens-lib (:data library))) + data)) + data + library-ids)] + (absorb-unlinked-tokens-source cfg data library-ids))) (def ^:const import-transaction-timeout-ms "Ceiling for binfile import transactions (20 minutes). Interpolated diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 1d411336f7..c2fa61b686 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -259,6 +259,10 @@ embed? (update :data #(bfc/embed-assets cfg % file-id)) + (not (or detach? embed?)) + (update :data #(bfc/absorb-unlinked-tokens-source + cfg % (bfc/get-libraries cfg [file-id]))) + :always (bfc/clean-file-features)))))) @@ -800,7 +804,7 @@ {}) (not-empty))) -(defn- read-file-tokens-lib +(defn- read-file-tokens-lib* [{:keys [::bfc/input ::entries-index] :as cfg} file-id] (when-let [{:keys [entry]} (first (get-in entries-index [:tokens (str file-id)]))] (events/tap :progress {:section :tokens-lib :file-id file-id}) @@ -808,6 +812,19 @@ (decode-tokens-lib) (validate-tokens-lib)))) +(defn- read-file-tokens-lib + "Read the tokens lib of a file once per import. Decoding generates new + set ids, so the tokens lib of a library must be the same instance when + it is imported and when it resolves the tokens status of a file that + uses it as tokens source." + [cfg file-id] + (let [cache (:tokens-libs @bfc/*state*)] + (if (contains? cache file-id) + (get cache file-id) + (let [tokens-lib (read-file-tokens-lib* cfg file-id)] + (vswap! bfc/*state* assoc-in [:tokens-libs file-id] tokens-lib) + tokens-lib)))) + (defn- read-file-tokens-status [{:keys [::bfc/input ::entries-index] :as cfg} file-id tokens-lib] (when-let [{:keys [entry]} (first (get-in entries-index [:tokens-status (str file-id)]))] @@ -1313,7 +1330,8 @@ format. There are some options available for customize the output: `::bfc/export-type`: determines how linked libraries are handled. - Valid values: `:include-libraries` (include linked libraries), + Valid values: + `:include-libraries` (include linked libraries), `:merge-libraries` (embed library assets in the file), `:detach-libraries` (treat assets as basic objects), `:link-later` (preserve component metadata for relinking on import)." diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 1679e76204..b71d425040 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -940,7 +940,11 @@ (let [file (-> (bfc/get-file cfg file-id :include-deleted? true :lock-for-update? true) - (update :data ctf/absorb-assets ldata))] + (update :data ctf/absorb-assets ldata) + (update :data (fn [data] + (cond-> data + (cfo/effective-tokens-source? data (:id ldata)) + (cfo/absorb-tokens-lib ldata)))))] (l/trc :hint "library absorbed" :library-id (str (:id ldata)) @@ -953,16 +957,21 @@ :modified-at (ct/now) :has-media-trimmed false})))) +(defn- get-referenced-files + [cfg library-id] + (->> (db/exec! cfg [sql:get-referenced-files library-id]) + (into [] bfc/xf-map-id))) + (defn- absorb-library* - "Find all files using a shared library, and absorb all library assets - into the file local libraries" - [cfg {:keys [id data] :as library}] + "Absorb all library assets, and its tokens lib if it is their tokens + source, into the local libraries of the files that use it. The files + are the given file-ids, or the ones linked to the library if nil." + [cfg {:keys [id data] :as library} file-ids] (assert (db/connection-map? cfg) "expected cfg with valid connection") - (let [ids (->> (db/exec! cfg [sql:get-referenced-files id]) - (sequence bfc/xf-map-id))] + (let [ids (or file-ids (get-referenced-files cfg id))] (l/trc :hint "absorbing library" :library-id (str id) @@ -972,7 +981,9 @@ library)) (defn absorb-library - [{:keys [::db/conn] :as cfg} id] + "Absorb the library into the files that use it. The files are the given + file-ids, or the ones linked to the library if omitted." + [{:keys [::db/conn] :as cfg} id & {:keys [file-ids]}] (let [file (-> (bfc/get-file cfg id :realize? true :lock-for-update? true @@ -988,7 +999,7 @@ (-> (cfeat/get-team-enabled-features cf/flags team) (cfeat/check-file-features! (:features file))) - (absorb-library* cfg file))) + (absorb-library* cfg file file-ids))) (defn- set-file-shared [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}] @@ -1056,12 +1067,15 @@ (defn- mark-file-deleted [conn team file-id] - (let [delay (ldel/get-deletion-delay team) - file (db/update! conn :file - {:deleted-at (ct/in-future delay)} - {:id file-id} - {::db/return-keys [:id :name :is-shared :deleted-at - :project-id :created-at :modified-at]})] + (let [delay (ldel/get-deletion-delay team) + file (db/update! conn :file + {:deleted-at (ct/in-future delay)} + {:id file-id} + {::db/return-keys [:id :name :is-shared :deleted-at + :project-id :created-at :modified-at]}) + ;; Files using the library, captured before their relations are removed + file-ids (when (:is-shared file) + (get-referenced-files conn file-id))] ;; Remove all possible relations for that file (db/delete! conn :file-library-rel @@ -1069,9 +1083,11 @@ (wrk/submit! {::db/conn conn ::wrk/task :delete-object - ::wrk/params {:object :file - :deleted-at (:deleted-at file) - :id file-id}}) + ::wrk/params (cond-> {:object :file + :deleted-at (:deleted-at file) + :id file-id} + (some? file-ids) + (assoc :file-ids file-ids))}) file)) (def ^:private diff --git a/backend/src/app/tasks/delete_object.clj b/backend/src/app/tasks/delete_object.clj index abdc4bc5f3..aa64f2e8c1 100644 --- a/backend/src/app/tasks/delete_object.clj +++ b/backend/src/app/tasks/delete_object.clj @@ -61,7 +61,7 @@ {::db/return-keys false})) (defmethod delete-object :file - [{:keys [::db/conn] :as cfg} {:keys [id deleted-at]}] + [{:keys [::db/conn] :as cfg} {:keys [id deleted-at file-ids]}] (when-let [file (db/get* conn :file {:id id} {::db/remove-deleted false ::sql/columns [:id :is-shared]})] @@ -79,7 +79,7 @@ (not *team-deletion*)) ;; NOTE: we don't prevent file deletion on absorb operation failure (try - (db/tx-run! cfg files/absorb-library id) + (db/tx-run! cfg files/absorb-library id :file-ids file-ids) (catch Throwable cause (l/warn :hint "error on absorbing library" :file-id id diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index b29b2fcf29..1b75b3a6cd 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -12,17 +12,18 @@ [app.binfile.v3 :as v3] [app.common.data :as d] [app.common.features :as cfeat] + [app.common.files.changes-builder :as pcb] + [app.common.files.tokens :as cfo] [app.common.files.validate :as cfv] - [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.shape :as cts] + [app.common.types.token :as cto] [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] - [app.db.sql :as sql] - [app.http :as http] [app.rpc :as-alias rpc] [app.rpc.commands.binfile :as binfile] [app.storage :as sto] @@ -36,7 +37,6 @@ [datoteka.io :as io]) (:import java.io.ByteArrayInputStream - java.io.DataInputStream java.io.OutputStreamWriter java.io.Writer java.util.zip.Deflater @@ -105,6 +105,117 @@ (dissoc file :data)))) +(defn- prepare-file-with-tokens-lib + "A shared library and a linked file with different active token sets. + The file has a rect with two applied tokens. Returns the created files, + and the ids and tokens needed to check them." + [profile] + (let [lib-page-id (uuid/custom 5 1) + file-page-id (uuid/custom 5 2) + shape-id (uuid/custom 5 3) + set-id (uuid/custom 5 4) + theme-id (uuid/custom 5 5) + other-set-id (uuid/custom 5 8) + other-theme-id (uuid/custom 5 9) + + token-radius (ctob/make-token :id (uuid/custom 5 6) + :name "token-radius" + :type :border-radius + :value 10) + + token-opacity (ctob/make-token :id (uuid/custom 5 7) + :name "token-opacity" + :type :opacity + :value 0.7) + + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id + :name "test-token-set")) + (ctob/add-theme (ctob/make-token-theme :id theme-id + :name "test-theme" + :sets #{"test-token-set"})) + (ctob/add-set (ctob/make-token-set :id other-set-id + :name "other-token-set")) + (ctob/add-theme (ctob/make-token-theme :id other-theme-id + :name "other-theme" + :sets #{"other-token-set"})) + (ctob/add-token other-set-id (ctob/make-token :id (uuid/custom 5 10) + :name "other-token" + :type :opacity + :value 0.4)) + (ctob/add-token set-id token-radius) + (ctob/add-token set-id token-opacity)) + + library (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Tokens Library"}) + + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (th/link-file-to-library* {:file-id (:id file) + :library-id (:id library)}) + + (update-file! + :file-id (:id library) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data library)) + (pcb/add-empty-page lib-page-id "library page") + (pcb/set-tokens-lib tokens-lib) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{other-theme-id} + :active-set-ids #{other-set-id})) + (:redo-changes))) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data file)) + (pcb/add-empty-page file-page-id "file page") + (pcb/set-tokens-source (:id library)) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})) + (:redo-changes))) + + (let [page (-> (bfc/get-file th/*system* (:id file) {:realize? true}) + (get-in [:data :pages-index file-page-id])) + shape (as-> (cts/setup-shape + {:id shape-id + :name "rect" + :frame-id uuid/zero + :parent-id uuid/zero + :type :rect}) $ + (cto/apply-token-to-shape {:shape $ + :token token-radius + :attributes [:r1 :r2 :r3 :r4]}) + (cto/apply-token-to-shape {:shape $ + :token token-opacity + :attributes [:opacity]}))] + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + (-> (pcb/empty-changes nil) + (pcb/with-page page) + (pcb/with-objects (:objects page)) + (pcb/add-object shape) + (:redo-changes)))) + + {:library library + :file file + :page-id file-page-id + :shape-id shape-id + :token-radius token-radius + :token-opacity token-opacity})) + (def ^:private svg-raw-page-id (uuid/custom 1 1)) (def ^:private svg-raw-root-id (uuid/custom 3 1)) (def ^:private svg-raw-child-id (uuid/custom 3 2)) @@ -376,6 +487,299 @@ (t/is (= "icons-library" (:slug ext-lib))) (t/is (= [(:id file)] (:used-by ext-lib)))))))) +(defn- zip-entry-names + [path] + (with-open [zin (ZipFile. (fs/file path))] + (into #{} (map #(.getName ^ZipEntry %)) (iterator-seq (.entries zin))))) + +(t/deftest export-detach-libraries-removes-tokens-library-although-keeps-applied-tokens + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile)] + + ;; Precondition: before the export, the file really depends on the + ;; tokens of the library + (let [file-data (:data (bfc/get-file th/*system* (:id file)))] + (t/is (= (:id library) (cfo/get-tokens-source file-data))) + (t/is (some? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))) + + ;; Do the export, with type :detach-libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :detach-libraries)) + (io/output-stream output)) + + ;; Only the file is exported, not the library + (let [manifest (v3/get-manifest th/*system* output) + names (zip-entry-names output)] + (t/is (= [(:id file)] (mapv :id (:files manifest)))) + (t/is (empty? (:relations manifest))) + (t/is (empty? (:external-libraries manifest))) + (t/is (contains? names (str "files/" (:id file) ".json"))) + (t/is (not-any? #(str/includes? % (str (:id library))) names))) + + ;; The exported file has neither tokens library nor tokens status + (let [names (zip-entry-names output)] + (t/is (not (contains? names (str "files/" (:id file) "/tokens.json")))) + (t/is (not (contains? names (str "files/" (:id file) "/tokens-status.json"))))) + + ;; Import the file again + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; ...so the imported file has no tokens source, status or library + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data))) + + ;; The shape keeps the applied tokens + (t/is (some? shape)) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity])))))) + +(t/deftest export-merge-libraries-embeds-tokens-library-and-keeps-applied-tokens + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile)] + + ;; Precondition: before the export, the file depends on the tokens + ;; of the library and has no tokens library of its own + (let [file-data (:data (bfc/get-file th/*system* (:id file)))] + (t/is (= (:id library) (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))) + + (let [library-tokens-lib (-> (bfc/get-file th/*system* (:id library)) + (:data) + (cfo/get-tokens-lib)) + output (tmp/tempfile :suffix ".zip")] + + (t/is (some? library-tokens-lib)) + + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :merge-libraries)) + (io/output-stream output)) + + ;; Only the file is exported, not the library + (let [manifest (v3/get-manifest th/*system* output) + names (zip-entry-names output)] + (t/is (= [(:id file)] (mapv :id (:files manifest)))) + (t/is (empty? (:relations manifest))) + (t/is (empty? (:external-libraries manifest))) + (t/is (contains? names (str "files/" (:id file) ".json"))) + (t/is (not-any? #(str/includes? % (str (:id library))) names))) + + ;; The exported file carries its own tokens library + (t/is (contains? (zip-entry-names output) + (str "files/" (:id file) "/tokens.json"))) + + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/team-id (:default-team-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/input output) + (v3/import-files!)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + + (t/is (= 1 (count (:file-ids result)))) + + ;; The tokens library of the library file is now in the file... + ;; (the import gives new ids to the sets and tokens, so compare + ;; their content) + (t/is (some? (cfo/get-tokens-lib file-data))) + (t/is (= (ctob/export-dtcg-json library-tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + + ;; ...so the file is its own tokens source + (t/is (nil? (cfo/get-tokens-source file-data))) + + ;; Activation follows the consuming file, using the imported ids. + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))) + (t/is (= #{"token-radius" "token-opacity"} + (set (keys (cfo/get-tokens-in-active-sets + (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))))) + + ;; The shape keeps the applied tokens + (t/is (some? shape)) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity])))))) + +(t/deftest export-detach-libraries-preserves-own-tokens-and-status + (let [profile (th/create-profile* 1) + {:keys [library file page-id shape-id token-radius token-opacity]} + (prepare-file-with-tokens-lib profile) + file-data (:data (bfc/get-file th/*system* (:id file))) + library-data (:data (bfc/get-file th/*system* (:id library))) + tokens-lib (cfo/get-tokens-lib library-data) + output (tmp/tempfile :suffix ".zip")] + + ;; Keep the linked library, but use the file's own tokens. + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data file-data) + (pcb/set-tokens-source nil) + (pcb/set-tokens-lib tokens-lib) + (:redo-changes))) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :detach-libraries) + (io/output-stream output)) + + (let [names (zip-entry-names output)] + (t/is (contains? names (str "files/" (:id file) "/tokens.json"))) + (t/is (contains? names (str "files/" (:id file) "/tokens-status.json")))) + + (let [result (v3/import-files! + (assoc th/*system* + ::bfc/project-id (:default-project-id profile) + ::bfc/team-id (:default-team-id profile) + ::bfc/profile-id (:id profile) + ::bfc/input output)) + imported (bfc/get-file th/*system* (first (:file-ids result))) + file-data (:data imported) + shape (get-in file-data [:pages-index page-id :objects shape-id])] + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (some? (cfo/get-tokens-status file-data))) + (when-let [status (cfo/get-tokens-status file-data)] + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names status (cfo/get-tokens-lib file-data))))) + (t/is (cfo/token-applied? token-radius shape [:r1 :r2 :r3 :r4])) + (t/is (cfo/token-applied? token-opacity shape [:opacity]))))) + +(defn- import-output + "Import the exported output in the default project of the profile and + return the imported files, indexed by name." + [profile output] + (let [result (v3/import-files! + (assoc th/*system* + ::bfc/project-id (:default-project-id profile) + ::bfc/team-id (:default-team-id profile) + ::bfc/profile-id (:id profile) + ::bfc/input output))] + (into {} + (map (fn [id] + (let [file (bfc/get-file th/*system* id)] + [(:name file) file]))) + (:file-ids result)))) + +(t/deftest export-include-libraries-keeps-tokens-status-of-the-file + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + output (tmp/tempfile :suffix ".zip")] + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (let [files (import-output profile output) + library' (get files (:name library)) + file' (get files (:name file)) + file-data (:data file') + library-lib (cfo/get-tokens-lib (:data library')) + file-status (cfo/get-tokens-status file-data)] + + (t/is (= 2 (count files))) + (t/is (= (:id library') (cfo/get-tokens-source file-data))) + + ;; The active sets of the file are resolved against the sets of the + ;; imported library, so they are not lost. + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names file-status library-lib))) + (t/is (= #{"token-radius" "token-opacity"} + (set (keys (cfo/get-tokens-in-active-sets file-status library-lib)))))))) + +(t/deftest export-file-with-deleted-tokens-library-embeds-its-tokens + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + tokens-lib (-> (bfc/get-file th/*system* (:id library)) + (:data) + (cfo/get-tokens-lib)) + output (tmp/tempfile :suffix ".zip")] + + ;; The state left by deleting the library: in the trash and unlinked, + ;; but still the tokens source of the file. + (th/db-update! :file {:deleted-at (ct/in-future {:days 7})} {:id (:id library)}) + (th/db-delete! :file-library-rel {:library-file-id (:id library)}) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (t/is (contains? (zip-entry-names output) + (str "files/" (:id file) "/tokens.json"))) + + (let [files (import-output profile output) + file-data (:data (get files (:name file)))] + (t/is (= 1 (count files))) + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-token-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data))))))) + +(t/deftest export-file-with-missing-tokens-library-removes-tokens-source + (let [profile (th/create-profile* 1) + {:keys [library file]} (prepare-file-with-tokens-lib profile) + output (tmp/tempfile :suffix ".zip")] + + ;; The tokens source points to a library that does not exist anymore + (th/db-delete! :file-library-rel {:library-file-id (:id library)}) + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data (bfc/get-file th/*system* (:id file)))) + (pcb/set-tokens-source (uuid/next)) + (:redo-changes))) + + (v3/export-files! + (assoc th/*system* + ::bfc/ids #{(:id file)} + ::bfc/export-type :include-libraries) + (io/output-stream output)) + + (let [files (import-output profile output) + file-data (:data (get files (:name file)))] + (t/is (= 1 (count files))) + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (nil? (cfo/get-tokens-status file-data))) + (t/is (nil? (cfo/get-tokens-lib file-data)))))) + (t/deftest import-auto-links-single-candidate (let [profile (th/create-profile* 1) ;; Create a shared library file diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index aadd976c2d..1bb3a0a09e 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -6,11 +6,16 @@ (ns backend-tests.rpc-file-test (:require + [app.binfile.common :as bfc] [app.common.features :as cfeat] + [app.common.files.changes-builder :as pcb] + [app.common.files.tokens :as cfo] [app.common.pprint :as pp] [app.common.thumbnails :as thc] [app.common.time :as ct] [app.common.types.shape :as cts] + [app.common.types.tokens-lib :as ctob] + [app.common.types.tokens-status :as ctos] [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] @@ -2169,6 +2174,93 @@ (get-in out [:result :data :pages-index f-page :objects inst-id :component-file]))))))))) +(defn- prepare-tokens-library + "A shared library with a tokens lib, a file linked to it that uses it + as its tokens source, and another linked file that does not." + [profile] + (let [set-id (uuid/next) + theme-id (uuid/next) + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id :name "test-set")) + (ctob/add-theme (ctob/make-token-theme :id theme-id + :name "test-theme" + :sets #{"test-set"})) + (ctob/add-token set-id (ctob/make-token :name "token-radius" + :type :border-radius + :value 10))) + params {:profile-id (:id profile) + :project-id (:default-project-id profile)} + library (th/create-file* 1 (assoc params :is-shared true)) + file (th/create-file* 2 params) + other (th/create-file* 3 params)] + + (th/link-file-to-library* {:file-id (:id file) :library-id (:id library)}) + (th/link-file-to-library* {:file-id (:id other) :library-id (:id library)}) + + (update-file! + :file-id (:id library) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data library)) + (pcb/set-tokens-lib tokens-lib) + (:redo-changes))) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + (-> (pcb/empty-changes nil) + (pcb/with-library-data (:data file)) + (pcb/set-tokens-source (:id library)) + (pcb/set-tokens-status (ctos/make-tokens-status :active-theme-ids #{theme-id} + :active-set-ids #{set-id})) + (:redo-changes))) + + {:library library :file file :other other :tokens-lib tokens-lib})) + +(defn- check-tokens-absorbed + "The file is its own tokens source, with a copy of the tokens lib, and + keeps its tokens status; the other file is not changed." + [{:keys [file other tokens-lib]}] + (let [file-data (:data (bfc/get-file th/*system* (:id file))) + other-data (:data (bfc/get-file th/*system* (:id other)))] + (t/is (nil? (cfo/get-tokens-source file-data))) + (t/is (= (ctob/export-dtcg-json tokens-lib) + (ctob/export-dtcg-json (cfo/get-tokens-lib file-data)))) + (t/is (= {:active-themes #{"/test-theme"} + :active-sets #{"test-set"}} + (cfo/ids->names (cfo/get-tokens-status file-data) + (cfo/get-tokens-lib file-data)))) + (t/is (nil? (cfo/get-tokens-lib other-data))))) + +(t/deftest delete-shared-library-absorbs-its-tokens-lib + (let [profile (th/create-profile* 1) + {:keys [library file] :as prepared} (prepare-tokens-library profile) + out (th/command! {::th/type :delete-file + ::rpc/profile-id (:id profile) + :id (:id library)})] + (t/is (nil? (:error out))) + + ;; The relations are removed at once, the absorption is done by the + ;; deletion task. + (t/is (empty? (th/db-query :file-library-rel {:library-file-id (:id library)}))) + (t/is (= (:id library) + (cfo/get-tokens-source (:data (bfc/get-file th/*system* (:id file)))))) + + (th/run-pending-tasks!) + (check-tokens-absorbed prepared))) + +(t/deftest unpublish-shared-library-absorbs-its-tokens-lib + (let [profile (th/create-profile* 1) + {:keys [library] :as prepared} (prepare-tokens-library profile) + out (th/command! {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id library) + :is-shared false})] + (t/is (nil? (:error out))) + (check-tokens-absorbed prepared))) + (t/deftest deleted-files-permanently-delete (let [prof (th/create-profile* 1 {:is-active true}) team-id (:default-team-id prof) diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index 34c5b760d6..e78adf2c47 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -547,6 +547,32 @@ [file-data f & args] (d/update-when file-data :tokens-status #(apply f % args))) +(defn absorb-tokens-lib + "Copy the tokens-lib of the library into the file, replacing the + previous one if any, and unset the tokens-source, so the file becomes + its own tokens source. + + The tokens-status of the file is preserved. If the file has none, the + one of the library is copied along with the tokens-lib. + + If the library has no tokens-lib, only the tokens-source is unset, and + the file keeps its own tokens-lib and tokens-status. If there is no + library-data, there is nothing to absorb and the file is returned + unchanged." + [file-data library-data] + (if (nil? library-data) + file-data + (let [tokens-lib (get-tokens-lib library-data)] + (if (nil? tokens-lib) + (set-tokens-source file-data nil) + (let [tokens-status (or (get-tokens-status file-data) + (get-tokens-status library-data))] + (cond-> (-> file-data + (set-tokens-source nil) + (assoc :tokens-lib tokens-lib)) + (some? tokens-status) + (assoc :tokens-status tokens-status))))))) + ;; Tokens status with tokens lib (defn- calculate-active-sets diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index c4ab29e3da..3a52dd79ca 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -11,6 +11,7 @@ [app.common.features :as cfeat] [app.common.files.defaults :refer [version]] [app.common.files.helpers :as cfh] + [app.common.files.tokens :as cfo] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.geom.shapes.tree-seq :as gsts] @@ -1238,7 +1239,9 @@ (fn [pages-index] (d/update-vals pages-index #(update % :objects detach-objects)))] - (update-in file [:data :pages-index] detach-pages))) + (cond-> (update-in file [:data :pages-index] detach-pages) + (not (cfo/effective-tokens-source? (:data file) file-id)) + (update :data dissoc :tokens-source :tokens-status)))) ;; Base font size diff --git a/common/test/common_tests/files/tokens_test.cljc b/common/test/common_tests/files/tokens_test.cljc index 76da45bd4e..8d6a28b393 100644 --- a/common/test/common_tests/files/tokens_test.cljc +++ b/common/test/common_tests/files/tokens_test.cljc @@ -1494,3 +1494,218 @@ ;; set-theme-active false removes non-existent id (let [status' (cfo/set-theme-active all-invalid tokens-lib invalid-theme-id false)] (t/is (not (ctos/theme-active? status' invalid-theme-id)))))))) + +;; absorb-tokens-lib + +(defn- make-sample-tokens-lib + "A tokens-lib with a set (and a token in it) for each label. Each set + is named as its label, and its id is registered in thi under it." + [& set-labels] + (reduce (fn [tokens-lib set-label] + (let [set-id (thi/new-id! set-label)] + (-> tokens-lib + (ctob/add-set (ctob/make-token-set :id set-id + :name (name set-label))) + (ctob/add-token set-id (ctob/make-token :name (str (name set-label) "-token") + :type :border-radius + :value 10))))) + (ctob/make-tokens-lib) + set-labels)) + +(defn- make-sample-tokens-status + "A tokens-status with the sets of the given labels active. The sets + must have been created before by make-sample-tokens-lib." + [& set-labels] + (ctos/make-tokens-status :active-set-ids (into #{} (map thi/id) set-labels))) + +(defn- make-sample-data + "File data with the given tokens-lib, tokens-status and tokens-source. + Those not given are left unset." + [label & {:keys [tokens-lib tokens-status tokens-source]}] + (-> (ctf/file-data (thf/sample-file label)) + (cfo/ensure-tokens-lib) + (cfo/update-tokens-lib (constantly tokens-lib)) + (cfo/update-tokens-status (constantly tokens-status)) + (cfo/set-tokens-source tokens-source))) + +(t/deftest test-absorb-tokens-lib + (t/testing "copies the tokens-lib of the library and unsets the tokens-source" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))) + (t/is (true? (cfo/effective-tokens-source? file-data' (:id file-data')))) + (t/is (true? (cfo/editable-tokens? file-data'))))) + + (t/testing "replaces the own tokens-lib of the file" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-lib (make-sample-tokens-lib :own-set) + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "copies an empty tokens-lib of the library" + (let [empty-lib (ctob/make-tokens-lib) + library-data (make-sample-data :library :tokens-lib empty-lib) + file-data (make-sample-data :file1 + :tokens-lib (make-sample-tokens-lib :own-set) + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= empty-lib (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "unsets the tokens-source even if the file had no tokens-source" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (cfo/get-tokens-lib library-data) (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "does not change anything else in the file" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= (dissoc file-data :tokens-lib :tokens-status :tokens-source) + (dissoc file-data' :tokens-lib :tokens-status :tokens-source)))))) + +(t/deftest test-absorb-tokens-lib-status + (t/testing "preserves the tokens-status of the file" + (let [library-lib (make-sample-tokens-lib :lib-set :other-set) + file-status (make-sample-tokens-status :lib-set) + library-status (make-sample-tokens-status :other-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-status (cfo/get-tokens-status file-data'))) + (t/is (not= library-status (cfo/get-tokens-status file-data'))))) + + (t/testing "preserves an empty tokens-status of the file" + (let [library-lib (make-sample-tokens-lib :lib-set) + file-status (ctos/make-tokens-status) + library-status (make-sample-tokens-status :lib-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-status (cfo/get-tokens-status file-data'))))) + + (t/testing "copies the tokens-status of the library if the file has none" + (let [library-lib (make-sample-tokens-lib :lib-set) + library-status (make-sample-tokens-status :lib-set) + library-data (make-sample-data :library + :tokens-lib library-lib + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= library-status (cfo/get-tokens-status file-data'))) + (t/is (= #{(thi/id :lib-set)} + (ctos/get-active-set-ids (cfo/get-tokens-status file-data')))) + (t/is (some? (ctob/get-set (cfo/get-tokens-lib file-data') (thi/id :lib-set)))))) + + (t/testing "leaves the tokens-status unset if neither the file nor the library have one" + (let [library-data (make-sample-data :library + :tokens-lib (make-sample-tokens-lib :lib-set)) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-status file-data')))))) + +(t/deftest test-absorb-tokens-lib-library-without-tokens + (t/testing "library without tokens-lib: keeps the tokens-lib and tokens-status of the file, and unsets the tokens-source" + (let [file-lib (make-sample-tokens-lib :own-set) + file-status (make-sample-tokens-status :own-set) + library-status (ctos/make-tokens-status :active-set-ids #{(uuid/next)}) + library-data (make-sample-data :library + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-status file-status + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-lib (cfo/get-tokens-lib file-data'))) + (t/is (= file-status (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "library without tokens-lib: does not copy the tokens-status of the library" + (let [library-status (ctos/make-tokens-status :active-set-ids #{(uuid/next)}) + library-data (make-sample-data :library + :tokens-status library-status) + file-data (make-sample-data :file1 + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "library without any tokens attribute" + (let [library-data (ctf/file-data (thf/sample-file :library)) + file-lib (make-sample-tokens-lib :own-set) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-source (:id library-data)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (= file-lib (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data'))))) + + (t/testing "neither the file nor the library have tokens" + (let [library-data (ctf/file-data (thf/sample-file :library)) + file-data (ctf/file-data (thf/sample-file :file1)) + file-data' (cfo/absorb-tokens-lib file-data library-data)] + (t/is (nil? (cfo/get-tokens-lib file-data'))) + (t/is (nil? (cfo/get-tokens-status file-data'))) + (t/is (nil? (cfo/get-tokens-source file-data')))))) + +(t/deftest test-absorb-tokens-lib-no-library-data + (t/testing "nil library-data: the file is returned unchanged" + (let [file-lib (make-sample-tokens-lib :own-set) + file-data (make-sample-data :file1 + :tokens-lib file-lib + :tokens-status (make-sample-tokens-status :own-set) + :tokens-source (uuid/next))] + (t/is (identical? file-data (cfo/absorb-tokens-lib file-data nil))))) + + (t/testing "nil library-data and a file without tokens" + (let [file-data (ctf/file-data (thf/sample-file :file1))] + (t/is (identical? file-data (cfo/absorb-tokens-lib file-data nil)))))) + +(t/deftest test-detach-external-references-preserves-own-tokens + (let [file (thf/sample-file :file1) + lib (make-sample-tokens-lib :own-set) + status (make-sample-tokens-status :own-set) + file (-> file + (assoc-in [:data :tokens-lib] lib) + (assoc-in [:data :tokens-status] status))] + (doseq [source [nil (:id file)]] + (t/testing (str "local tokens source " source) + (let [file (cond-> file + source (assoc-in [:data :tokens-source] source)) + detached (ctf/detach-external-references file (:id file))] + (t/is (= source (cfo/get-tokens-source (:data detached)))) + (t/is (identical? lib (cfo/get-tokens-lib (:data detached)))) + (t/is (= status (cfo/get-tokens-status (:data detached))))))))) + +(t/deftest test-detach-external-references-removes-external-tokens-status + (let [file (-> (thf/sample-file :file1) + (assoc-in [:data :tokens-source] (uuid/next)) + (assoc-in [:data :tokens-status] (ctos/make-tokens-status))) + detached (ctf/detach-external-references file (:id file))] + (t/is (not (contains? (:data detached) :tokens-source))) + (t/is (not (contains? (:data detached) :tokens-status))) + (t/is (= detached (ctf/check-file detached))))) diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 92a458e772..27d91275c0 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -123,7 +123,6 @@ (let [type (-> (dom/get-target event) (dom/get-data "type") (keyword))] - (prn "AAA" selected type) (swap! state* assoc :selected type))))] (mf/with-effect [has-libs?]