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:
Andrey Antukh 2026-06-01 12:43:51 +02:00
parent d44c6250ea
commit 109539dbcd
9 changed files with 629 additions and 90 deletions

View File

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

View File

@ -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,47 @@
(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] :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))
;; 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)))
[]
external-libs)))
(defn- import-files*
[{:keys [::manifest] :as cfg}]
(bfc/disable-database-timeouts! cfg)
@ -897,9 +970,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 +1022,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 +1063,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

View File

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

View File

@ -103,5 +103,199 @@
(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))
;; 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))
;; 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))
;; 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))))))))

View File

@ -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,55 @@
(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*)
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 +394,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 +470,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 +510,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 +528,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 +561,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 +576,19 @@
: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*
:file-ids (:file-ids library-resolution)}]])
(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 +618,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 +655,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))

View File

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

View File

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

View File

@ -686,11 +686,11 @@ msgstr ""
msgid "dashboard.go-to-admin-console"
msgstr "Go to Admin Console"
#: src/app/main/ui/dashboard/import.cljs:465, src/app/main/ui/dashboard/project_menu.cljs:109
#: src/app/main/ui/dashboard/import.cljs:553, src/app/main/ui/dashboard/project_menu.cljs:109
msgid "dashboard.import"
msgstr "Import Penpot files"
#: src/app/main/ui/dashboard/import.cljs:292, src/app/worker/import.cljs:138, src/app/worker/import.cljs:141
#: src/app/main/ui/dashboard/import.cljs:299, src/app/worker/import.cljs:138, src/app/worker/import.cljs:141
msgid "dashboard.import.analyze-error"
msgstr "Oops! We couldn't import this file"
@ -698,10 +698,20 @@ msgstr "Oops! We couldn't import this file"
msgid "dashboard.import.analyze-error.components-v2"
msgstr "File with components v2 activated but this team doesn't support it yet."
#: src/app/main/ui/dashboard/import.cljs:573, src/app/main/ui/dashboard/import.cljs:589
msgid "dashboard.import.auto-linked-libraries"
msgid_plural "dashboard.import.auto-linked-libraries"
msgstr[0] "1 library was automatically linked by name."
msgstr[1] "%s libraries were automatically linked by name."
#: src/app/main/ui/dashboard.cljs:259
msgid "dashboard.import.bad-url"
msgstr "Import failed. The template URL is incorrect"
#: src/app/main/ui/dashboard/import.cljs:663
msgid "dashboard.import.confirm-library-links"
msgstr "Confirm library links"
#: src/app/main/ui/dashboard.cljs:241
#, unused
msgid "dashboard.import.error"
@ -712,72 +722,86 @@ msgstr "Import failed. Please try again"
msgid "dashboard.import.import-error"
msgstr "There was a problem importing the file. The file wasn't imported."
#: src/app/main/ui/dashboard/import.cljs:507
#: src/app/main/ui/dashboard/import.cljs:613
msgid "dashboard.import.import-error.check-error"
msgstr "We couldn't verify this file."
#: src/app/main/ui/dashboard/import.cljs:511
#: src/app/main/ui/dashboard/import.cljs:617
msgid "dashboard.import.import-error.corrupt-file"
msgstr "This file appears to be damaged."
#: src/app/main/ui/dashboard/import.cljs:486
#: src/app/main/ui/dashboard/import.cljs:579
msgid "dashboard.import.import-error.disclaimer"
msgstr "Not all files have been imported"
#: src/app/main/ui/dashboard/import.cljs:490
#: src/app/main/ui/dashboard/import.cljs:596
msgid "dashboard.import.import-error.message1"
msgstr "The following files have errors:"
#: src/app/main/ui/dashboard/import.cljs:515
#: src/app/main/ui/dashboard/import.cljs:621
msgid "dashboard.import.import-error.message2"
msgstr "Files with errors will not be uploaded."
#: src/app/main/ui/dashboard/import.cljs:514
#: src/app/main/ui/dashboard/import.cljs:620
msgid "dashboard.import.import-error.unknown-error"
msgstr "Something went wrong while processing this file."
#: src/app/main/ui/dashboard/import.cljs:480
#: src/app/main/ui/dashboard/import.cljs:569, src/app/main/ui/dashboard/import.cljs:585
msgid "dashboard.import.import-message"
msgid_plural "dashboard.import.import-message"
msgstr[0] "1 file has been imported successfully."
msgstr[1] "%s files have been imported successfully."
#: src/app/main/ui/dashboard/import.cljs:475
#: src/app/main/ui/dashboard/import.cljs:563
msgid "dashboard.import.import-warning"
msgstr "Some files containted invalid objects that have been removed."
#, unused
msgid "dashboard.import.library-link-error"
msgstr "Failed to link library"
#, unused
msgid "dashboard.import.library-placeholder"
msgstr "Select a library…"
#: src/app/main/ui/dashboard.cljs:260
msgid "dashboard.import.no-perms"
msgstr "You dont have permission to import to this team"
#: src/app/main/ui/dashboard/import.cljs:127
#: src/app/main/ui/dashboard/import.cljs:129
msgid "dashboard.import.progress.process-colors"
msgstr "Processing colors"
#: src/app/main/ui/dashboard/import.cljs:136, src/app/main/ui/dashboard/import.cljs:139
#: src/app/main/ui/dashboard/import.cljs:138, src/app/main/ui/dashboard/import.cljs:141
msgid "dashboard.import.progress.process-components"
msgstr "Processing components"
#: src/app/main/ui/dashboard/import.cljs:133
#: src/app/main/ui/dashboard/import.cljs:135
msgid "dashboard.import.progress.process-media"
msgstr "Processing media"
#: src/app/main/ui/dashboard/import.cljs:124
#: src/app/main/ui/dashboard/import.cljs:126
msgid "dashboard.import.progress.process-page"
msgstr "Processing page: %s"
#: src/app/main/ui/dashboard/import.cljs:130
#: src/app/main/ui/dashboard/import.cljs:132
msgid "dashboard.import.progress.process-typographies"
msgstr "Processing typographies"
#: src/app/main/ui/dashboard/import.cljs:118
#: src/app/main/ui/dashboard/import.cljs:120
msgid "dashboard.import.progress.upload-data"
msgstr "Uploading data to server (%s/%s)"
#: src/app/main/ui/dashboard/import.cljs:121
#: src/app/main/ui/dashboard/import.cljs:123
msgid "dashboard.import.progress.upload-media"
msgstr "Uploading file: %s"
#: src/app/main/ui/dashboard/import.cljs:362
msgid "dashboard.import.resolve-libraries"
msgstr ""
"Some libraries couldn't be linked automatically. Please select the correct "
"library for each:"
#: src/app/main/ui/dashboard/team.cljs:851
msgid "dashboard.invitation-modal.delete"
msgstr "You're going to delete the invitations to:"
@ -824,7 +848,7 @@ msgstr "Here you have some Libraries and templates you can add to your project"
msgid "dashboard.libraries-and-templates.explore"
msgstr "Explore more of them and know how to contribute"
#: src/app/main/ui/dashboard/import.cljs:366, src/app/main/ui/workspace/libraries.cljs:146
#: src/app/main/ui/dashboard/import.cljs:429, src/app/main/ui/workspace/libraries.cljs:146
msgid "dashboard.libraries-and-templates.import-error"
msgstr "There was a problem importing the template. The template wasn't imported."
@ -2695,7 +2719,7 @@ msgstr "Shortcuts"
msgid "labels.about-penpot"
msgstr "About Penpot"
#: src/app/main/data/common.cljs:90, src/app/main/data/team.cljs:163, src/app/main/data/team.cljs:584, src/app/main/ui/dashboard/import.cljs:558
#: src/app/main/data/common.cljs:90, src/app/main/data/team.cljs:163, src/app/main/data/team.cljs:584, src/app/main/ui/dashboard/import.cljs:671
msgid "labels.accept"
msgstr "Accept"
@ -2755,7 +2779,7 @@ msgstr "Bad Gateway"
msgid "labels.blur"
msgstr "Blur"
#: src/app/main/data/common.cljs:119, src/app/main/ui/dashboard/change_owner.cljs:67, src/app/main/ui/dashboard/change_owner.cljs:174, src/app/main/ui/dashboard/import.cljs:543, src/app/main/ui/dashboard/team.cljs:866, src/app/main/ui/dashboard/team.cljs:1345, src/app/main/ui/delete_shared.cljs:36, src/app/main/ui/exports/assets.cljs:163, src/app/main/ui/exports/files.cljs:167, src/app/main/ui/settings/integrations.cljs:228, src/app/main/ui/viewer/share_link.cljs:204, src/app/main/ui/workspace/sidebar/assets/groups.cljs:178, src/app/main/ui/workspace/tokens/export/modal.cljs:43, src/app/main/ui/workspace/tokens/import/modal.cljs:268, src/app/main/ui/workspace/tokens/import_from_library.cljs:88, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:364, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:73, src/app/main/ui/workspace/tokens/settings/menu.cljs:104, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:242
#: src/app/main/data/common.cljs:119, src/app/main/ui/dashboard/change_owner.cljs:67, src/app/main/ui/dashboard/change_owner.cljs:174, src/app/main/ui/dashboard/import.cljs:650, src/app/main/ui/dashboard/team.cljs:866, src/app/main/ui/dashboard/team.cljs:1345, src/app/main/ui/delete_shared.cljs:36, src/app/main/ui/exports/assets.cljs:163, src/app/main/ui/exports/files.cljs:167, src/app/main/ui/settings/integrations.cljs:228, src/app/main/ui/viewer/share_link.cljs:204, src/app/main/ui/workspace/sidebar/assets/groups.cljs:178, src/app/main/ui/workspace/tokens/export/modal.cljs:43, src/app/main/ui/workspace/tokens/import/modal.cljs:268, src/app/main/ui/workspace/tokens/import_from_library.cljs:88, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:364, src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs:73, src/app/main/ui/workspace/tokens/settings/menu.cljs:104, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:242
msgid "labels.cancel"
msgstr "Cancel"
@ -2815,7 +2839,7 @@ msgstr "Contact support"
msgid "labels.contact-us"
msgstr "Contact us"
#: src/app/main/ui/auth/login.cljs:203, src/app/main/ui/dashboard/deleted.cljs:43, src/app/main/ui/dashboard/deleted.cljs:274, src/app/main/ui/dashboard/file_menu.cljs:210, src/app/main/ui/dashboard/import.cljs:549, src/app/main/ui/dashboard/team.cljs:873, src/app/main/ui/exports/files.cljs:172, src/app/main/ui/settings/subscription.cljs:310, src/app/main/ui/settings/subscription.cljs:344
#: src/app/main/ui/auth/login.cljs:203, src/app/main/ui/dashboard/deleted.cljs:43, src/app/main/ui/dashboard/deleted.cljs:274, src/app/main/ui/dashboard/file_menu.cljs:210, src/app/main/ui/dashboard/import.cljs:656, src/app/main/ui/dashboard/team.cljs:873, src/app/main/ui/exports/files.cljs:172, src/app/main/ui/settings/subscription.cljs:310, src/app/main/ui/settings/subscription.cljs:344
msgid "labels.continue"
msgstr "Continue"
@ -2948,7 +2972,7 @@ msgstr "Editor"
msgid "labels.empty"
msgstr "Empty"
#: src/app/main/ui/dashboard/import.cljs:298, src/app/worker/import.cljs:197, src/app/worker/import.cljs:231
#: src/app/main/ui/dashboard/import.cljs:305, src/app/worker/import.cljs:197, src/app/worker/import.cljs:241
msgid "labels.error"
msgstr "Error"
@ -3107,7 +3131,7 @@ msgstr "Learning Center"
msgid "labels.libraries-and-templates"
msgstr "Libraries & Templates"
#: src/app/main/ui/auth/verify_token.cljs:125, src/app/main/ui/dashboard/grid.cljs:126, src/app/main/ui/dashboard/grid.cljs:146, src/app/main/ui/dashboard/import.cljs:257, src/app/main/ui/dashboard/placeholder.cljs:139, src/app/main/ui/ds/product/loader.cljs:85, src/app/main/ui/exports/files.cljs:59, src/app/main/ui/nitrate/entry.cljs:26, src/app/main/ui/static.cljs:608, src/app/main/ui/viewer.cljs:641, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:247, src/app/main/ui/workspace.cljs:131, src/app/main/ui.cljs:71, src/app/main/ui.cljs:109, src/app/main/ui.cljs:128
#: src/app/main/ui/auth/verify_token.cljs:125, src/app/main/ui/dashboard/grid.cljs:126, src/app/main/ui/dashboard/grid.cljs:146, src/app/main/ui/dashboard/import.cljs:264, src/app/main/ui/dashboard/placeholder.cljs:139, src/app/main/ui/ds/product/loader.cljs:85, src/app/main/ui/exports/files.cljs:59, src/app/main/ui/nitrate/entry.cljs:26, src/app/main/ui/static.cljs:608, src/app/main/ui/viewer.cljs:641, src/app/main/ui/workspace/sidebar/assets/file_library.cljs:247, src/app/main/ui/workspace.cljs:131, src/app/main/ui.cljs:71, src/app/main/ui.cljs:109, src/app/main/ui.cljs:128
msgid "labels.loading"
msgstr "Loading…"
@ -3540,7 +3564,7 @@ msgstr "Upload custom fonts"
msgid "labels.uploading"
msgstr "Uploading…"
#: src/app/main/ui/dashboard/import.cljs:536
#: src/app/main/ui/dashboard/import.cljs:643
msgid "labels.uploading-file"
msgstr "Uploading file…"

View File

@ -748,6 +748,23 @@ 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.library-placeholder"
msgstr "Selecciona una biblioteca…"
msgid "dashboard.import.library-link-error"
msgstr "No se pudo vincular la biblioteca"
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"