✨ Handle tokens libraries when exporting .penpot files (#12077)

* ✨ Handle tokens libraries when exporting .penpot files

Exporting a file that takes its tokens from an external library left
the :tokens-source of the file pointing at a library that is not in the
export, and the tokens lib of that library was not exported.

With :detach-libraries, unset the :tokens-source and the tokens status
in detach-external-references. The file is exported without any link to
the library, and the shapes keep their applied tokens.

With :merge-libraries, add cfo/absorb-tokens-lib, which copies the tokens
lib of a library into a file and unsets its :tokens-source, so the file
becomes its own tokens source. The tokens status of the file is kept; if
it has none, the one of the library is copied. If the library has no
tokens lib, only the source is unset. embed-assets calls it for the
library that is the tokens source of the file, and ignores the other
linked libraries.

Add unit tests for absorb-tokens-lib, and binfile tests that export a
file with tokens from an external library with both modes, checking the
exported files, the tokens lib, the tokens source and the applied
tokens.

Also reformat the export-type docstring in binfile v3 and remove a
leftover debug prn in the export dialog.

AI-assisted-by: claude-sonnet-5-5

* 🐛 Preserve active token sets when importing linked libraries

Cache each decoded tokens library for the duration of an import so files
and their tokens source resolve active sets against the same generated IDs.
Add a round-trip regression test for exports that include linked libraries.

* 🐛 Preserve tokens when deleting or unpublishing libraries

Absorb the tokens library into files that use it as their tokens source.
Keep referencing file IDs in the deletion task before removing relations.

Embed tokens from deleted, unlinked sources during export, and clear stale
source and status data when the library no longer exists. Add regression
tests for deletion, unpublishing, and exports with unavailable libraries.

---------

Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
This commit is contained in:
Andrés Moya 2026-10-15 16:53:30 +02:00 committed by GitHub
parent 98f8b7e625
commit feb73f0fd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 827 additions and 34 deletions

View File

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

View File

@ -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)."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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?]