mirror of
https://github.com/penpot/penpot.git
synced 2026-06-30 19:35:18 +00:00
✨ Auto-link libraries during import based on slugified name
When a Penpot file is exported without bundled libraries and then imported into a different environment, external library links are broken because library UUIDs differ across environments. This feature adds a heuristic to auto-relink libraries by matching slugified library names against shared files in the target team: - Export: embed external library metadata (id, name, slug, used-by) in the manifest when libraries are not included in the export. - Import: resolve external libraries by slugifying shared file names in the destination team and matching against manifest slugs. - Single match: auto-link silently (creates file-library-rel row). - Multiple matches: emit SSE event so the frontend shows a selection dialog for the user to pick the correct library. - No match: import continues without linking (current behavior). Backend changes: - Extended manifest schema with optional :external-libraries field - Added slugify-name, get-files-names, get-shared-files-for-team, find-shared-files-by-slug helpers in app.binfile.common - Threaded team-id into import cfg from RPC layer - Added resolve-external-libraries and auto-link-libraries in v3 - Emit :library-candidates SSE event for multi-match cases Frontend changes: - Worker captures library-candidates SSE events and forwards them - Import dialog shows auto-link notification and multi-match selection UI with select dropdowns - Added link-files-to-library! RPC helper for user selections - Added en/es translations for new UI strings Closes #9263 Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
5212e2202b
commit
06ccc7a06e
@ -866,8 +866,8 @@
|
||||
(defn get-resolved-file-libraries
|
||||
"Get all file libraries including itself. Returns an instance of
|
||||
LoadableWeakValueMap that allows do not have strong references to
|
||||
the loaded libraries and reduce possible memory pressure on having
|
||||
all this libraries loaded at same time on processing file validation
|
||||
the loaded libraries and reduce memory pressure on having
|
||||
all this libraries at the same time on processing file validation
|
||||
or file migration.
|
||||
|
||||
This still requires at least one library at time to be loaded while
|
||||
@ -879,3 +879,47 @@
|
||||
(cons (:id file)))
|
||||
load-fn #(get-file cfg % :migrate? false)]
|
||||
(weak/loadable-weak-value-map library-ids load-fn {id file})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; EXTERNAL LIBRARY RESOLUTION HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn slugify-name
|
||||
"Slugify a library name for cross-environment matching.
|
||||
Lowercases, replaces non-alphanumeric runs with '-', strips
|
||||
leading/trailing '-'."
|
||||
[name]
|
||||
(str/slug name))
|
||||
|
||||
(def ^:private sql:get-files-names
|
||||
"SELECT id, name FROM file WHERE id = ANY(?)")
|
||||
|
||||
(defn get-files-names
|
||||
"Return [{:id uuid :name string}] for the given file ids."
|
||||
[cfg ids]
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(let [ids-arr (db/create-array conn "uuid" ids)]
|
||||
(db/exec! conn [sql:get-files-names ids-arr])))))
|
||||
|
||||
(def ^:private sql:get-shared-files-for-team
|
||||
"SELECT f.id, f.name
|
||||
FROM file AS f
|
||||
JOIN project AS p ON (p.id = f.project_id)
|
||||
WHERE p.team_id = ?
|
||||
AND f.is_shared = true
|
||||
AND f.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL")
|
||||
|
||||
(defn get-shared-files-for-team
|
||||
"Return [{:id uuid :name string}] for all shared files in a team."
|
||||
[cfg team-id]
|
||||
(db/run! cfg
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(db/exec! conn [sql:get-shared-files-for-team team-id]))))
|
||||
|
||||
(defn find-shared-files-by-slug
|
||||
"Return all shared files in `team-id` whose slugified name equals `slug`."
|
||||
[cfg team-id slug]
|
||||
(->> (get-shared-files-for-team cfg team-id)
|
||||
(filterv #(= slug (slugify-name (:name %))))))
|
||||
|
||||
@ -67,7 +67,15 @@
|
||||
|
||||
[:relations {:optional true}
|
||||
[:vector
|
||||
[:tuple ::sm/uuid ::sm/uuid]]]])
|
||||
[:tuple ::sm/uuid ::sm/uuid]]]
|
||||
|
||||
[:external-libraries {:optional true}
|
||||
[:vector
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:slug :string]
|
||||
[:used-by {:optional true} [:vector ::sm/uuid]]]]]])
|
||||
|
||||
(def ^:private schema:storage-object
|
||||
[:map {:title "StorageObject"}
|
||||
@ -372,11 +380,33 @@
|
||||
|
||||
(defn- export-files
|
||||
[{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}]
|
||||
(let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
|
||||
(let [original-ids ids
|
||||
ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
|
||||
rels (if include-libraries
|
||||
(->> (bfc/get-files-rels cfg ids)
|
||||
(mapv (juxt :file-id :library-file-id)))
|
||||
[])]
|
||||
[])
|
||||
|
||||
;; Compute external libraries: referenced by original files but
|
||||
;; not included in the export set. Only relevant when libraries
|
||||
;; are NOT bundled in the export.
|
||||
external-libs
|
||||
(when-not include-libraries
|
||||
(let [original-rels (bfc/get-files-rels cfg original-ids)
|
||||
lib-ids (into #{} (map :library-file-id) original-rels)]
|
||||
(when (seq lib-ids)
|
||||
(let [lib-names (bfc/get-files-names cfg lib-ids)]
|
||||
(->> lib-names
|
||||
(mapv (fn [{:keys [id name]}]
|
||||
(let [slug (bfc/slugify-name name)]
|
||||
(when-not (str/blank? slug)
|
||||
{:id id
|
||||
:name name
|
||||
:slug slug
|
||||
:used-by (->> original-rels
|
||||
(filter #(= (:library-file-id %) id))
|
||||
(mapv :file-id))}))))
|
||||
(filterv some?))))))]
|
||||
|
||||
(vswap! bfc/*state* assoc :files (d/ordered-map))
|
||||
|
||||
@ -389,12 +419,14 @@
|
||||
|
||||
;; Write manifest file
|
||||
(let [files (:files @bfc/*state*)
|
||||
params {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:refer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}]
|
||||
params (cond-> {:type "penpot/export-files"
|
||||
:version 1
|
||||
:generated-by (str "penpot/" (:full cf/version))
|
||||
:refer "penpot"
|
||||
:files (vec (vals files))
|
||||
:relations rels}
|
||||
(seq external-libs)
|
||||
(assoc :external-libraries external-libs))]
|
||||
(write-entry! output "manifest.json" params))))
|
||||
|
||||
;; --- IMPORT IMPL
|
||||
@ -880,6 +912,52 @@
|
||||
|
||||
(vswap! bfc/*state* update :index assoc id (:id sobject)))))))
|
||||
|
||||
(defn- resolve-external-libraries
|
||||
"For each external library in the manifest, look for matching shared
|
||||
files in the team by slugified name. Returns a map of
|
||||
old-lib-id -> [{:id uuid :name string}] for libraries with matches."
|
||||
[{:keys [::manifest ::bfc/team-id] :as cfg}]
|
||||
(let [external-libs (:external-libraries manifest)]
|
||||
(when (and team-id (seq external-libs))
|
||||
(reduce (fn [result {:keys [id slug]}]
|
||||
(let [candidates (bfc/find-shared-files-by-slug cfg team-id slug)]
|
||||
(if (seq candidates)
|
||||
(assoc result id candidates)
|
||||
result)))
|
||||
{}
|
||||
external-libs))))
|
||||
|
||||
(defn- auto-link-libraries
|
||||
"Auto-link imported files to libraries that have exactly one candidate
|
||||
match. Returns a vector of {:id old-lib-id :name string :new-id uuid}
|
||||
for each auto-linked library."
|
||||
[{:keys [::db/conn ::manifest ::bfc/timestamp ::bfc/profile-id] :as cfg} resolution file-ids]
|
||||
(let [external-libs (:external-libraries manifest)]
|
||||
(reduce (fn [linked {:keys [id name used-by] :as ext-lib}]
|
||||
(let [candidates (get resolution id)]
|
||||
(if (= 1 (count candidates))
|
||||
(let [new-lib-id (:id (first candidates))
|
||||
perms (bfc/get-file-permissions conn profile-id new-lib-id)]
|
||||
;; Only auto-link when the importer has edit permission
|
||||
;; on the matched library, matching the manual link RPC.
|
||||
(if (:can-edit perms)
|
||||
(let [;; Link only files that actually used this library
|
||||
relevant-file-ids (if (seq used-by)
|
||||
(let [used-set (set (map bfc/lookup-index used-by))]
|
||||
(filterv used-set file-ids))
|
||||
file-ids)]
|
||||
(doseq [fid relevant-file-ids]
|
||||
(let [rel-params {:file-id fid
|
||||
:library-file-id new-lib-id}]
|
||||
(db/insert! conn :file-library-rel rel-params
|
||||
::db/on-conflict-do-nothing? true)
|
||||
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))
|
||||
(conj linked {:id id :name name :new-id new-lib-id}))
|
||||
linked))
|
||||
linked)))
|
||||
[]
|
||||
external-libs)))
|
||||
|
||||
(defn- import-files*
|
||||
[{:keys [::manifest] :as cfg}]
|
||||
(bfc/disable-database-timeouts! cfg)
|
||||
@ -897,9 +975,31 @@
|
||||
files)]
|
||||
|
||||
(import-file-relations cfg)
|
||||
(bfm/apply-pending-migrations! cfg)
|
||||
|
||||
result))
|
||||
;; Resolve external libraries by slug and auto-link single matches
|
||||
(let [resolution (resolve-external-libraries cfg)
|
||||
auto-linked (when (seq resolution)
|
||||
(auto-link-libraries cfg resolution result))
|
||||
|
||||
;; Collect multi-match candidates for frontend resolution
|
||||
candidates (when (seq resolution)
|
||||
(into {}
|
||||
(filter (fn [[_ candidates]] (> (count candidates) 1)))
|
||||
resolution))
|
||||
|
||||
;; Build external-libraries lookup from manifest for the frontend
|
||||
external-libs-info (when (seq resolution)
|
||||
(->> (:external-libraries manifest)
|
||||
(filter #(contains? candidates (:id %)))
|
||||
(mapv (fn [{:keys [id name slug]}]
|
||||
{:id id :name name :slug slug}))))]
|
||||
|
||||
(bfm/apply-pending-migrations! cfg)
|
||||
|
||||
{:file-ids result
|
||||
:auto-linked (or auto-linked [])
|
||||
:library-candidates (or candidates {})
|
||||
:external-libs (or external-libs-info [])})))
|
||||
|
||||
(defn- import-file-and-overwrite*
|
||||
[{:keys [::manifest ::bfc/file-id] :as cfg}]
|
||||
@ -927,7 +1027,10 @@
|
||||
(bfc/invalidate-thumbnails cfg file-id)
|
||||
(bfm/apply-pending-migrations! cfg)
|
||||
|
||||
[file-id])))
|
||||
{:file-ids [file-id]
|
||||
:auto-linked []
|
||||
:library-candidates {}
|
||||
:external-libs []})))
|
||||
|
||||
(defn- import-files
|
||||
[{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}]
|
||||
@ -965,9 +1068,20 @@
|
||||
(events/tap :progress {:section :manifest})
|
||||
|
||||
(binding [bfc/*state* (volatile! {:media [] :index {}})]
|
||||
(if (::bfc/file-id cfg)
|
||||
(db/tx-run! cfg import-file-and-overwrite*)
|
||||
(db/tx-run! cfg import-files*)))))
|
||||
(let [result (if (::bfc/file-id cfg)
|
||||
(db/tx-run! cfg import-file-and-overwrite*)
|
||||
(db/tx-run! cfg import-files*))]
|
||||
|
||||
;; Emit library-candidates event for frontend resolution of
|
||||
;; multi-match cases (if any)
|
||||
(when (seq (:library-candidates result))
|
||||
(events/tap :library-candidates
|
||||
{:file-ids (:file-ids result)
|
||||
:auto-linked (:auto-linked result)
|
||||
:library-candidates (:library-candidates result)
|
||||
:external-libs (:external-libs result)}))
|
||||
|
||||
result))))
|
||||
|
||||
;; --- PUBLIC API
|
||||
|
||||
|
||||
@ -92,6 +92,7 @@
|
||||
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
|
||||
(assoc ::bfc/project-id project-id)
|
||||
(assoc ::bfc/profile-id profile-id)
|
||||
(assoc ::bfc/team-id (:id team))
|
||||
(assoc ::bfc/name name))
|
||||
|
||||
input-path (:path file)
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.thumbnails :as thc]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
@ -103,5 +104,345 @@
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
(t/is (= (count result) 1))
|
||||
(t/is (every? uuid? result)))))
|
||||
(t/is (map? result))
|
||||
(t/is (= 1 (count (:file-ids result))))
|
||||
(t/is (every? uuid? (:file-ids result)))
|
||||
(t/is (= [] (:auto-linked result)))
|
||||
(t/is (= {} (:library-candidates result)))
|
||||
(t/is (= [] (:external-libs result))))))
|
||||
|
||||
(t/deftest slugify-name-test
|
||||
(t/is (= "my-design-system" (bfc/slugify-name "My Design System!")))
|
||||
(t/is (= "icons" (bfc/slugify-name "Icons")))
|
||||
(t/is (= "brand-colors-2024" (bfc/slugify-name "Brand Colors 2024")))
|
||||
(t/is (= "" (bfc/slugify-name "---"))))
|
||||
|
||||
(t/deftest export-includes-external-libraries
|
||||
(let [profile (th/create-profile* 1)
|
||||
;; Create a shared library file
|
||||
library (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
;; Create a file that uses the library
|
||||
file (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
|
||||
;; Link file to library
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
;; Export without including libraries
|
||||
(let [output (tmp/tempfile :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Read the manifest and check external-libraries
|
||||
(let [manifest (v3/get-manifest output)]
|
||||
(t/is (some? (:external-libraries manifest)))
|
||||
(t/is (= 1 (count (:external-libraries manifest))))
|
||||
(let [ext-lib (first (:external-libraries manifest))]
|
||||
(t/is (= (:id library) (:id ext-lib)))
|
||||
(t/is (= "Icons Library" (:name ext-lib)))
|
||||
(t/is (= "icons-library" (:slug ext-lib)))
|
||||
(t/is (= [(:id file)] (:used-by ext-lib))))))))
|
||||
|
||||
(t/deftest import-auto-links-single-candidate
|
||||
(let [profile (th/create-profile* 1)
|
||||
;; Create a shared library file
|
||||
library (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
;; Create a file that uses the library
|
||||
file (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
|
||||
;; Link file to library
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
;; Export without including libraries
|
||||
(let [output (tmp/tempfile :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Remove the source library to simulate a cross-environment import
|
||||
;; where the original library does not exist in the target team.
|
||||
(db/update! th/*system* :file
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id library)})
|
||||
|
||||
;; Now create a new shared library with the same name in the same team
|
||||
;; (simulating the library existing in the target environment)
|
||||
(let [library2 (th/create-file* 3 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
|
||||
result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/team-id (:default-team-id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
|
||||
;; Check that the library was auto-linked
|
||||
(t/is (= 1 (count (:auto-linked result))))
|
||||
(t/is (= (:id library) (:id (first (:auto-linked result)))))
|
||||
(t/is (= (:id library2) (:new-id (first (:auto-linked result)))))
|
||||
(t/is (= {} (:library-candidates result)))
|
||||
|
||||
;; Verify the file-library-rel was created
|
||||
(let [rels (db/query th/*system* :file-library-rel
|
||||
{:library-file-id (:id library2)})]
|
||||
(t/is (= 1 (count rels))))))))
|
||||
|
||||
(t/deftest import-no-auto-link-no-match
|
||||
(let [profile (th/create-profile* 1)
|
||||
;; Create a shared library file
|
||||
library (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
;; Create a file that uses the library
|
||||
file (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
|
||||
;; Link file to library
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
;; Export without including libraries
|
||||
(let [output (tmp/tempfile :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Remove the source library to simulate a cross-environment import
|
||||
;; where no matching library exists in the target team.
|
||||
(db/update! th/*system* :file
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id library)})
|
||||
|
||||
;; Import without any matching library in the team
|
||||
(let [result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/team-id (:default-team-id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
|
||||
;; No auto-linking should happen
|
||||
(t/is (= [] (:auto-linked result)))
|
||||
(t/is (= {} (:library-candidates result)))))))
|
||||
|
||||
(t/deftest import-returns-multi-match-candidates
|
||||
(let [profile (th/create-profile* 1)
|
||||
;; Create a shared library file
|
||||
library (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
;; Create a file that uses the library
|
||||
file (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
|
||||
;; Link file to library
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
;; Export without including libraries
|
||||
(let [output (tmp/tempfile :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Remove the source library to simulate a cross-environment import
|
||||
;; where the original library does not exist in the target team.
|
||||
(db/update! th/*system* :file
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id library)})
|
||||
|
||||
;; Create TWO shared libraries with the same name
|
||||
(let [library2 (th/create-file* 3 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
library3 (th/create-file* 4 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
|
||||
result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/team-id (:default-team-id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
|
||||
;; No auto-linking (multi-match)
|
||||
(t/is (= [] (:auto-linked result)))
|
||||
|
||||
;; Should have candidates
|
||||
(t/is (= 1 (count (:library-candidates result))))
|
||||
(let [candidates (get (:library-candidates result) (:id library))]
|
||||
(t/is (= 2 (count candidates))))
|
||||
|
||||
;; No file-library-rel should be created automatically
|
||||
(let [rels (db/query th/*system* :file-library-rel
|
||||
{:library-file-id (:id library2)})]
|
||||
(t/is (= 0 (count rels))))
|
||||
(let [rels (db/query th/*system* :file-library-rel
|
||||
{:library-file-id (:id library3)})]
|
||||
(t/is (= 0 (count rels))))))))
|
||||
|
||||
(t/deftest import-auto-link-respects-library-permissions
|
||||
(let [owner (th/create-profile* 1)
|
||||
team (th/create-team* 1 {:profile-id (:id owner)})
|
||||
viewer (th/create-profile* 2)
|
||||
_ (th/create-team-role* {:team-id (:id team)
|
||||
:profile-id (:id viewer)
|
||||
:role :viewer})
|
||||
|
||||
library (th/create-file* 1 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
file (th/create-file* 2 {:profile-id (:id owner)
|
||||
:project-id (:default-project-id owner)
|
||||
:is-shared false})]
|
||||
|
||||
;; Link file to library
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
;; Export without including libraries
|
||||
(let [output (tmp/tempfile :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Remove the source library and recreate a matching one owned by owner
|
||||
(db/update! th/*system* :file
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id library)})
|
||||
|
||||
;; Create a project in the team for the matched library and import.
|
||||
(let [project (th/create-project* 1 {:profile-id (:id owner)
|
||||
:team-id (:id team)})
|
||||
|
||||
library2 (th/create-file* 3 {:profile-id (:id owner)
|
||||
:project-id (:id project)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
|
||||
result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:id project))
|
||||
(assoc ::bfc/profile-id (:id viewer))
|
||||
(assoc ::bfc/team-id (:id team))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
|
||||
;; Auto-link must be skipped because viewer cannot edit the library
|
||||
(t/is (= [] (:auto-linked result)))
|
||||
|
||||
;; No file-library-rel should have been created
|
||||
(let [rels (db/query th/*system* :file-library-rel
|
||||
{:library-file-id (:id library2)})]
|
||||
(t/is (= 0 (count rels))))
|
||||
|
||||
;; Control: the same import performed by the owner (who has edit
|
||||
;; permission on the library) should auto-link.
|
||||
(let [result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:id project))
|
||||
(assoc ::bfc/profile-id (:id owner))
|
||||
(assoc ::bfc/team-id (:id team))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
|
||||
(t/is (= 1 (count (:auto-linked result))))
|
||||
(t/is (= (:id library2) (:new-id (first (:auto-linked result)))))
|
||||
(let [rels (db/query th/*system* :file-library-rel
|
||||
{:library-file-id (:id library2)})]
|
||||
(t/is (= 1 (count rels)))))))))
|
||||
|
||||
(t/deftest import-auto-link-only-files-that-used-library
|
||||
(let [profile (th/create-profile* 1)
|
||||
library (th/create-file* 1 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
file1 (th/create-file* 2 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})
|
||||
file2 (th/create-file* 3 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared false})]
|
||||
|
||||
;; Only file1 uses the library
|
||||
(db/insert! th/*system* :file-library-rel
|
||||
{:file-id (:id file1)
|
||||
:library-file-id (:id library)})
|
||||
|
||||
;; Export both files without including libraries
|
||||
(let [output (tmp/tempfile :suffix ".zip")]
|
||||
(v3/export-files!
|
||||
(-> th/*system*
|
||||
(assoc ::bfc/ids #{(:id file1) (:id file2)})
|
||||
(assoc ::bfc/embed-assets false)
|
||||
(assoc ::bfc/include-libraries false))
|
||||
(io/output-stream output))
|
||||
|
||||
;; Remove the source library and recreate a matching one
|
||||
(db/update! th/*system* :file
|
||||
{:deleted-at (ct/now)}
|
||||
{:id (:id library)})
|
||||
|
||||
(let [library2 (th/create-file* 4 {:profile-id (:id profile)
|
||||
:project-id (:default-project-id profile)
|
||||
:is-shared true
|
||||
:name "Icons Library"})
|
||||
|
||||
result (-> th/*system*
|
||||
(assoc ::bfc/project-id (:default-project-id profile))
|
||||
(assoc ::bfc/profile-id (:id profile))
|
||||
(assoc ::bfc/team-id (:default-team-id profile))
|
||||
(assoc ::bfc/input output)
|
||||
(v3/import-files!))]
|
||||
|
||||
;; The library should be auto-linked
|
||||
(t/is (= 1 (count (:auto-linked result))))
|
||||
(t/is (= (:id library2) (:new-id (first (:auto-linked result)))))
|
||||
(t/is (= {} (:library-candidates result)))
|
||||
|
||||
;; But only one file-library-rel should exist (for file1)
|
||||
(let [rels (db/query th/*system* :file-library-rel
|
||||
{:library-file-id (:id library2)})]
|
||||
(t/is (= 1 (count rels))))))))
|
||||
|
||||
@ -15,8 +15,10 @@
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.repo :as rp]
|
||||
[app.main.store :as st]
|
||||
[app.main.ui.components.file-uploader :refer [file-uploader]]
|
||||
[app.main.ui.ds.controls.select :refer [select*]]
|
||||
[app.main.ui.ds.product.loader :refer [loader*]]
|
||||
[app.main.ui.icons :as deprecated-icon]
|
||||
[app.main.ui.notifications.context-notification :refer [context-notification]]
|
||||
@ -173,7 +175,7 @@
|
||||
(swap! state update-with-analyze-result message))))))
|
||||
|
||||
(defn- import-files
|
||||
[state project-id entries]
|
||||
[state library-resolution* project-id entries]
|
||||
(st/emit! (ev/event {::ev/name "import-files"
|
||||
:num-files (count entries)}))
|
||||
|
||||
@ -186,6 +188,11 @@
|
||||
(rx/filter (comp uuid? :file-id))
|
||||
(rx/subs!
|
||||
(fn [message]
|
||||
;; Capture library-resolution data if present (same for all
|
||||
;; entries from the same zip, so first one wins)
|
||||
(when-let [resolution (:library-resolution message)]
|
||||
(when (nil? @library-resolution*)
|
||||
(reset! library-resolution* resolution)))
|
||||
(swap! state update-entry-status message))))))
|
||||
|
||||
(mf/defc import-entry*
|
||||
@ -318,6 +325,63 @@
|
||||
(fn []
|
||||
(mapv #(assoc % :status :analyze) entries)))
|
||||
|
||||
(defn- link-files-to-library!
|
||||
"Call the link-file-to-library RPC for each file-id with the given
|
||||
library-id. Returns an observable that completes when all links are done."
|
||||
[file-ids library-id]
|
||||
(->> (rx/from file-ids)
|
||||
(rx/merge-map (fn [file-id]
|
||||
(->> (rp/cmd! :link-file-to-library
|
||||
{:file-id file-id
|
||||
:library-id library-id})
|
||||
(rx/catch (fn [cause]
|
||||
(log/error :hint "failed to link library"
|
||||
:file-id file-id
|
||||
:library-id library-id
|
||||
:cause cause)
|
||||
(rx/of nil))))))))
|
||||
|
||||
(mf/defc library-resolution*
|
||||
{::mf/private true}
|
||||
[{:keys [resolution selections*]}]
|
||||
(let [external-libs (:external-libs resolution)
|
||||
library-candidates (:library-candidates resolution)
|
||||
selections (deref selections*)]
|
||||
|
||||
;; Pre-select the first candidate for each unresolved library so that
|
||||
;; confirming without interaction still links the default choice.
|
||||
(mf/with-effect []
|
||||
(doseq [[old-lib-id candidates] library-candidates]
|
||||
(when-not (contains? @selections* old-lib-id)
|
||||
(when-let [first-candidate (first candidates)]
|
||||
(swap! selections* assoc old-lib-id (:id first-candidate))))))
|
||||
|
||||
(let [on-select
|
||||
(mf/use-fn
|
||||
(mf/deps selections*)
|
||||
(fn [old-lib-id candidate-id]
|
||||
(swap! selections* assoc old-lib-id candidate-id)))]
|
||||
|
||||
(when (seq library-candidates)
|
||||
[:div {:class (stl/css :library-resolution)}
|
||||
[:p {:class (stl/css :library-resolution-message)}
|
||||
(tr "dashboard.import.resolve-libraries")]
|
||||
|
||||
(for [{:keys [id name]} external-libs]
|
||||
(let [candidates (get library-candidates id)
|
||||
options (mapv (fn [c]
|
||||
{:id (str (:id c))
|
||||
:label (:name c)})
|
||||
candidates)
|
||||
selected (get selections id)]
|
||||
[:div {:class (stl/css :library-resolution-item)
|
||||
:key (dm/str id)}
|
||||
[:div {:class (stl/css :library-resolution-item-name)}
|
||||
name]
|
||||
[:> select* {:options options
|
||||
:default-selected (or (some-> selected str) "")
|
||||
:on-change (partial on-select id)}]]))]))))
|
||||
|
||||
(mf/defc import-dialog
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :import
|
||||
@ -338,13 +402,20 @@
|
||||
edition* (mf/use-state nil)
|
||||
edition (deref edition*)
|
||||
|
||||
;; Library resolution data from the backend (auto-linked + multi-match)
|
||||
library-resolution* (mf/use-state nil)
|
||||
library-resolution (deref library-resolution*)
|
||||
|
||||
;; User selections for multi-match candidates: {old-lib-id candidate-id}
|
||||
library-selections* (mf/use-state {})
|
||||
|
||||
continue-entries
|
||||
(mf/use-fn
|
||||
(mf/deps entries)
|
||||
(fn []
|
||||
(let [entries (filterv has-status-ready? entries)]
|
||||
(reset! status* :import-progress)
|
||||
(import-files state* project-id entries))))
|
||||
(import-files state* library-resolution* project-id entries))))
|
||||
|
||||
continue-template
|
||||
(mf/use-fn
|
||||
@ -407,6 +478,21 @@
|
||||
(continue-template template)
|
||||
(continue-entries))))
|
||||
|
||||
on-confirm-library-links
|
||||
(mf/use-fn
|
||||
(mf/deps library-resolution library-selections*)
|
||||
(fn [event]
|
||||
(dom/prevent-default event)
|
||||
(let [selections @library-selections*
|
||||
file-ids (:file-ids library-resolution)]
|
||||
;; Link each user-selected library to all imported files
|
||||
(->> (rx/from (seq selections))
|
||||
(rx/merge-map (fn [[_old-lib-id candidate-id]]
|
||||
(link-files-to-library! file-ids candidate-id)))
|
||||
(rx/reduce conj [])
|
||||
(rx/subs! (fn [_]
|
||||
(reset! status* :import-success)))))))
|
||||
|
||||
on-accept
|
||||
(mf/use-fn
|
||||
(mf/deps on-finish-import)
|
||||
@ -432,7 +518,12 @@
|
||||
(zero? (count entries))))
|
||||
|
||||
pending-analysis?
|
||||
(some has-status-analyze? entries)]
|
||||
(some has-status-analyze? entries)
|
||||
|
||||
auto-linked-count
|
||||
(if (some? library-resolution)
|
||||
(count (:auto-linked library-resolution))
|
||||
0)]
|
||||
|
||||
(mf/with-effect [entries]
|
||||
(cond
|
||||
@ -445,7 +536,12 @@
|
||||
|
||||
(and (seq entries)
|
||||
(every? #(= :import-success (:status %)) entries))
|
||||
(reset! status* :import-success)
|
||||
;; After all entries are imported, check if there are multi-match
|
||||
;; candidates that need user resolution
|
||||
(if (and (some? @library-resolution*)
|
||||
(seq (:library-candidates @library-resolution*)))
|
||||
(reset! status* :library-resolution)
|
||||
(reset! status* :import-success))
|
||||
|
||||
(and (seq entries)
|
||||
(and (every? #(not= :import-ready (:status %)) entries)
|
||||
@ -473,9 +569,14 @@
|
||||
:content (tr "dashboard.import.import-warning")}])
|
||||
|
||||
(when (= :import-success status)
|
||||
[:& context-notification
|
||||
{:level (if (zero? import-success-total) :warning :success)
|
||||
:content (tr "dashboard.import.import-message" (i18n/c import-success-total))}])
|
||||
[:*
|
||||
[:& context-notification
|
||||
{:level (if (zero? import-success-total) :warning :success)
|
||||
:content (tr "dashboard.import.import-message" (i18n/c import-success-total))}]
|
||||
(when (pos? auto-linked-count)
|
||||
[:& context-notification
|
||||
{:level :success
|
||||
:content (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))}])])
|
||||
|
||||
(when (= :import-error status)
|
||||
[:& context-notification
|
||||
@ -483,6 +584,18 @@
|
||||
:class (stl/css :context-notification-error)
|
||||
:content (tr "dashboard.import.import-error.disclaimer")}])
|
||||
|
||||
(when (= :library-resolution status)
|
||||
[:*
|
||||
[:& context-notification
|
||||
{:level :success
|
||||
:content (tr "dashboard.import.import-message" (i18n/c import-success-total))}]
|
||||
(when (pos? auto-linked-count)
|
||||
[:& context-notification
|
||||
{:level :success
|
||||
:content (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))}])
|
||||
[:> library-resolution* {:resolution library-resolution
|
||||
:selections* library-selections*}]])
|
||||
|
||||
(if (or (= :import-error status) (and (= :analyze status) errors?))
|
||||
[:div {:class (stl/css :import-error-disclaimer)}
|
||||
[:div (tr "dashboard.import.import-error.message1")]
|
||||
@ -512,16 +625,17 @@
|
||||
(tr "dashboard.import.import-error.unknown-error"))])]))]
|
||||
[:div (tr "dashboard.import.import-error.message2")]]
|
||||
|
||||
(for [entry entries]
|
||||
[:> import-entry* {:edition edition
|
||||
:key (dm/str (:uri entry) "/" (:file-id entry))
|
||||
:entry entry
|
||||
:entries entries
|
||||
:importing? (= :import-progress status)
|
||||
:on-edit on-edit
|
||||
:on-change on-entry-change
|
||||
:on-delete on-entry-delete
|
||||
:can-be-deleted (> (count entries) 1)}]))
|
||||
(when-not (= :library-resolution status)
|
||||
(for [entry entries]
|
||||
[:> import-entry* {:edition edition
|
||||
:key (dm/str (:uri entry) "/" (:file-id entry))
|
||||
:entry entry
|
||||
:entries entries
|
||||
:importing? (= :import-progress status)
|
||||
:on-edit on-edit
|
||||
:on-change on-entry-change
|
||||
:on-delete on-entry-delete
|
||||
:can-be-deleted (> (count entries) 1)}])))
|
||||
|
||||
(when (some? template)
|
||||
[:> import-entry* {:entry (assoc template :status status)
|
||||
@ -548,6 +662,12 @@
|
||||
:disabled pending-analysis?
|
||||
:on-click on-continue}])
|
||||
|
||||
(when (= :library-resolution status)
|
||||
[:input {:class (stl/css :accept-btn)
|
||||
:type "button"
|
||||
:value (tr "dashboard.import.confirm-library-links")
|
||||
:on-click on-confirm-library-links}])
|
||||
|
||||
(when (or (= :import-success status)
|
||||
(= :import-error status)
|
||||
(= :import-progress status))
|
||||
|
||||
27
frontend/src/app/main/ui/dashboard/import.scss
vendored
27
frontend/src/app/main/ui/dashboard/import.scss
vendored
@ -273,3 +273,30 @@
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.library-resolution {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-12;
|
||||
}
|
||||
|
||||
.library-resolution-message {
|
||||
@include deprecated.body-small-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
margin-bottom: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.library-resolution-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-4;
|
||||
padding: deprecated.$s-8 0;
|
||||
}
|
||||
|
||||
.library-resolution-item-name {
|
||||
@include deprecated.body-small-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@ -204,33 +204,43 @@
|
||||
(rx/mapcat identity)
|
||||
(rx/merge-map
|
||||
(fn [[uri entries]]
|
||||
(->> (import-blob-via-upload uri
|
||||
{:name (-> entries first :name)
|
||||
:version 3
|
||||
:project-id project-id})
|
||||
(rx/tap (fn [event]
|
||||
(let [payload (sse/get-payload event)
|
||||
type (sse/get-type event)]
|
||||
(if (= type "progress")
|
||||
(log/dbg :hint "import-binfile: progress"
|
||||
:section (:section payload)
|
||||
:name (:name payload))
|
||||
(log/dbg :hint "import-binfile: end")))))
|
||||
(rx/filter sse/end-of-stream?)
|
||||
(rx/mapcat (fn [_]
|
||||
(->> (rx/from entries)
|
||||
(rx/map (fn [entry]
|
||||
{:status :finish
|
||||
:file-id (:file-id entry)})))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(log/error :hint "unexpected error on import process"
|
||||
:project-id project-id
|
||||
::log/sync? true
|
||||
:cause cause)
|
||||
(let [err (import-cause-message cause (tr "labels.error"))]
|
||||
(->> (rx/from entries)
|
||||
(rx/map (fn [entry]
|
||||
{:status :error
|
||||
:error err
|
||||
:file-id (:file-id entry)})))))))))))))
|
||||
(let [library-resolution* (volatile! nil)]
|
||||
(->> (import-blob-via-upload uri
|
||||
{:name (-> entries first :name)
|
||||
:version 3
|
||||
:project-id project-id})
|
||||
(rx/tap (fn [event]
|
||||
(let [payload (sse/get-payload event)
|
||||
type (sse/get-type event)]
|
||||
(cond
|
||||
(= type "progress")
|
||||
(log/dbg :hint "import-binfile: progress"
|
||||
:section (:section payload)
|
||||
:name (:name payload))
|
||||
|
||||
(= type "library-candidates")
|
||||
(vreset! library-resolution* payload)
|
||||
|
||||
:else
|
||||
(log/dbg :hint "import-binfile: end")))))
|
||||
(rx/filter sse/end-of-stream?)
|
||||
(rx/mapcat (fn [_]
|
||||
(let [resolution @library-resolution*]
|
||||
(->> (rx/from entries)
|
||||
(rx/map (fn [entry]
|
||||
(cond-> {:status :finish
|
||||
:file-id (:file-id entry)}
|
||||
(some? resolution)
|
||||
(assoc :library-resolution resolution))))))))
|
||||
(rx/catch
|
||||
(fn [cause]
|
||||
(log/error :hint "import-binfile: unexpected error on importing"
|
||||
:project-id project-id
|
||||
::log/sync? true
|
||||
:cause cause)
|
||||
(let [err (import-cause-message cause (tr "labels.error"))]
|
||||
(->> (rx/from entries)
|
||||
(rx/map (fn [entry]
|
||||
{:status :error
|
||||
:error err
|
||||
:file-id (:file-id entry)}))))))))))))))
|
||||
|
||||
@ -758,6 +758,17 @@ msgstr[1] "%s ficheros se han importado correctamente."
|
||||
msgid "dashboard.import.import-warning"
|
||||
msgstr "Algunos ficheros contenían objetos erroneos que no han sido importados."
|
||||
|
||||
msgid "dashboard.import.auto-linked-libraries"
|
||||
msgid_plural "dashboard.import.auto-linked-libraries"
|
||||
msgstr[0] "1 biblioteca fue vinculada automáticamente por nombre."
|
||||
msgstr[1] "%s bibliotecas fueron vinculadas automáticamente por nombre."
|
||||
|
||||
msgid "dashboard.import.resolve-libraries"
|
||||
msgstr "Algunas bibliotecas no pudieron vincularse automáticamente. Selecciona la biblioteca correcta para cada una:"
|
||||
|
||||
msgid "dashboard.import.confirm-library-links"
|
||||
msgstr "Confirmar vínculos de biblioteca"
|
||||
|
||||
#: src/app/main/ui/dashboard.cljs:260
|
||||
msgid "dashboard.import.no-perms"
|
||||
msgstr "No tienes permisos para importar en este equipo"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user