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 920c6d4e76
commit d67a805956
10 changed files with 1183 additions and 105 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, f.project_id
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)
(filter #(= 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,34 @@
(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 +420,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 +913,89 @@
(vswap! bfc/*state* update :index assoc id (:id sobject)))))))
(defn- add-to-file
"Add a resolved library entry to a file in the file-grouped resolution.
`key` is :done (auto-linked) or :pending (needs resolution)."
[acc file-id file-name key entry]
(update acc file-id (fn [file]
(let [file (or file {:id file-id
:name file-name
:done []
:pending []})]
(update file key conj entry)))))
(defn- resolve-and-link-libraries
"For each external library in the manifest, resolve candidates by slug.
Auto-links single matches (creating DB rows) and builds a file-grouped
resolution map keyed by imported file-id (new UUID)."
[{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/timestamp ::bfc/profile-id] :as cfg} files-info]
(assert (uuid? team-id) "team-id should be provided")
(let [file-ids (keys files-info)]
(reduce
(fn [acc ext-lib]
(assert (contains? ext-lib :id) "expected `:id` on ext-lib")
(assert (contains? ext-lib :name) "expected `:name` on ext-lib")
(assert (contains? ext-lib :used-by) "expected `:used-by` on ext-lib")
(assert (contains? ext-lib :slug) "expected `:slug` on ext-lib")
(let [matching-libraries (-> (into [] (bfc/find-shared-files-by-slug cfg team-id (:slug ext-lib)))
(not-empty))
used-by (into #{} (map bfc/lookup-index) (:used-by ext-lib))]
(cond
;; No candidates → skip this library
(nil? matching-libraries)
acc
;; Single candidate → auto-link if importer has edit permission
(= 1 (count matching-libraries))
(let [library (first matching-libraries)
library-id (get library :id)
perms (bfc/get-file-permissions conn profile-id library-id)]
(if (not (:can-edit perms))
;; no edit permission → skip
acc
(let [used-by (filter used-by file-ids)]
;; Create DB links for the files that used this library
(doseq [file-id used-by]
(let [rel-params {:file-id file-id :library-file-id library-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))))
(let [entry {:id (:id ext-lib)
:name (:name ext-lib)
:linked-to library-id}]
(reduce (fn [acc file-id]
(add-to-file acc file-id (get files-info file-id) :done entry))
acc used-by)))))
;; Multiple candidates → needs user resolution
:else
(let [candidates (mapv (fn [lib]
(let [project-id (:project-id lib)
project (bfc/get-project cfg project-id)
project-name (:name project)]
{:id (:id lib)
:name (:name lib)
:project-id project-id
:project-name project-name}))
matching-libraries)
entry {:id (:id ext-lib)
:name (:name ext-lib)
:candidates candidates}]
(reduce (fn [acc file-id]
(add-to-file acc file-id (get files-info file-id) :pending entry))
acc used-by)))))
{}
(:external-libraries manifest))))
(defn- import-files*
[{:keys [::manifest] :as cfg}]
(bfc/disable-database-timeouts! cfg)
@ -888,18 +1004,26 @@
(import-storage-objects cfg)
(let [files (get manifest :files)
result (reduce (fn [result file]
(let [name' (get file :name)
file (assoc file :name name')]
(conj result (import-file cfg file))))
[]
files)]
(let [files (get manifest :files)
file-ids (reduce (fn [result file]
(let [name' (get file :name)
file (assoc file :name name')]
(conj result (import-file cfg file))))
[]
files)
;; Build map of file-id to file-name for resolution
files-info (into {} (map (fn [file-id manifest-file]
[file-id (:name manifest-file)])
file-ids
files))]
(import-file-relations cfg)
(bfm/apply-pending-migrations! cfg)
result))
(let [resolution (resolve-and-link-libraries cfg files-info)]
(bfm/apply-pending-migrations! cfg)
{:file-ids file-ids
:resolution resolution})))
(defn- import-file-and-overwrite*
[{:keys [::manifest ::bfc/file-id] :as cfg}]
@ -927,7 +1051,8 @@
(bfc/invalidate-thumbnails cfg file-id)
(bfm/apply-pending-migrations! cfg)
[file-id])))
{:file-ids [file-id]
:resolution {}})))
(defn- import-files
[{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}]
@ -996,6 +1121,7 @@
tp (ct/tpoint)
ab (volatile! false)
cs (volatile! nil)]
(try
(l/info :hint "start exportation" :export-id (str id))
(binding [bfc/*state* (volatile! (bfc/initial-state))]

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

@ -9,9 +9,11 @@
(:require
[app.binfile.common :as bfc]
[app.binfile.v3 :as v3]
[app.common.data :as d]
[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 +105,378 @@
(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)))
;; No external libraries in simple case - resolution should be empty
(t/is (= {} (:resolution 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 in the resolution
(let [resolution (:resolution result)
file-id (first (:file-ids result))
file-res (get resolution file-id)]
;; File should have name
(t/is (some? (:name file-res)))
;; File should have one auto-linked library in :done
(t/is (= 1 (count (:done file-res))))
(let [done-entry (first (:done file-res))]
(t/is (= (:id library) (:id done-entry)))
(t/is (= (:id library2) (:linked-to done-entry))))
;; No pending candidates
(t/is (= [] (:pending file-res))))
;; 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 - resolution should be empty
(t/is (= {} (:resolution 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) - check resolution structure
(let [resolution (:resolution result)
file-id (first (:file-ids result))
file-res (get resolution file-id)]
;; File should have name
(t/is (some? (:name file-res)))
;; No auto-linked libraries
(t/is (= [] (:done file-res)))
;; Should have pending candidates
(t/is (= 1 (count (:pending file-res))))
(let [pending-entry (first (:pending file-res))]
(t/is (= (:id library) (:id pending-entry)))
(t/is (= 2 (count (:candidates pending-entry))))
;; Each candidate should have project info
(doseq [candidate (:candidates pending-entry)]
(t/is (some? (:project-id candidate)))
(t/is (some? (:project-name candidate))))))
;; 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
(let [resolution (:resolution result)
file-id (first (:file-ids result))
file-res (get resolution file-id)]
;; No auto-linked libraries - file may not be in resolution map at all
(t/is (or (nil? file-res)
(= [] (:done file-res)))))
;; 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!))]
(let [resolution (:resolution result)
file-id (first (:file-ids result))
file-res (get resolution file-id)]
;; Should have name
(t/is (some? (:name file-res)))
;; Should have one auto-linked library
(t/is (= 1 (count (:done file-res))))
(t/is (= (:id library2) (:linked-to (first (:done file-res))))))
(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 for the file that used it
(let [resolution (:resolution result)
;; Find the file that has auto-linked libraries
file-with-done (d/seek #(seq (:done %)) (vals resolution))]
(t/is (some? file-with-done))
;; Should have name
(t/is (some? (:name file-with-done)))
(t/is (= 1 (count (:done file-with-done))))
(t/is (= (:id library2) (:linked-to (first (:done file-with-done))))))
;; 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))))))))

View File

@ -20,8 +20,8 @@
:exclusions [funcool/beicon2]}
funcool/beicon2
{:git/tag "v2.2"
:git/sha "8744c66"
{:git/tag "v2.3"
:git/sha "df7058a"
:git/url "https://github.com/funcool/beicon.git"}
funcool/rumext

View File

@ -15,8 +15,11 @@
[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.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.context-notification :refer [context-notification]]
@ -156,6 +159,19 @@
(and (= :import-ready (:status item))
(not (:deleted item))))
(defn- has-unresolved?
"Return true if a file-resolution has any :pending needing user choice."
[file-resolution]
(some? (seq (:pending file-resolution))))
(defn- count-auto-linked
"Count auto-linked libraries across all file resolutions."
[resolution]
(reduce-kv (fn [acc _ {:keys [done]}]
(+ acc (count done)))
0
resolution))
(defn- analyze-entries
[state entries]
(let [features (get @st/state :features)]
@ -173,7 +189,7 @@
(swap! state update-with-analyze-result message))))))
(defn- import-files
[state project-id entries]
[state library-resolution-data* project-id entries]
(st/emit! (ev/event {::ev/name "import-files"
:num-files (count entries)}))
@ -183,22 +199,27 @@
:project-id project-id
:files entries
:features features})
(rx/filter (comp uuid? :file-id))
(rx/filter some?)
(rx/subs!
(fn [message]
(swap! state update-entry-status message))))))
;; Capture library-resolution data if present (same for all
;; entries from the same zip, so first one wins)
(if-let [resolution (-> (:libraries-resolution message)
(not-empty))]
(reset! library-resolution-data* resolution)
(swap! state update-entry-status message)))))))
(mf/defc import-entry*
{::mf/memo true
::mf/private true}
[{:keys [entries entry edition can-be-deleted importing? on-edit on-change on-delete]}]
[{:keys [entries entry edition can-be-deleted is-progress on-edit on-change on-delete]}]
(let [status (:status entry)
;; FIXME: rename to format
format (:type entry)
loading? (or (= :analyze status)
(= :import-progress status)
(and importing? (= :import-ready status)))
(and is-progress (= :import-ready status)))
analyze-error? (= :analyze-error status)
import-success? (= :import-success status)
import-error? (= :import-error status)
@ -318,6 +339,120 @@
(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 [unresolved-file selection on-select]}]
(let [candidates (:pending unresolved-file)]
;; Pre-select first candidate for each library
(mf/with-effect [candidates]
(doseq [{:keys [id candidates]} candidates]
(when-not (contains? selection id)
(when-let [first-c (first candidates)]
(on-select id (:id first-c))))))
[:div {:class (stl/css :library-resolution)}
[:p {:class (stl/css :library-resolution-message)}
(tr "dashboard.import.resolve-libraries")]
(for [{:keys [id name candidates]} candidates]
(let [options (mapv (fn [c]
{:id (str (:id c))
:label (str (:name c) " (" (:project-name c) ")")})
candidates)
selected (get selection 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 library-resolution-summary-file*
{::mf/private true}
[{:keys [resolution-file selection]}]
(let [done (:done resolution-file)
pending (:pending resolution-file)]
[:div {:class (stl/css :summary-file)}
[:div {:class (stl/css :summary-file-header)}
[:> icon* {:icon-id i/library
:class (stl/css :summary-file-icon)
:size "s"}]
[:span {:class (stl/css :summary-file-name)}
(:name resolution-file)]]
(when (seq done)
[:div {:class (stl/css :summary-section)}
[:div {:class (stl/css :summary-section-header)}
[:> icon* {:icon-id i/status-tick
:class (stl/css :summary-section-icon)
:size "s"}]
[:span (tr "dashboard.import.summary.auto-linked")]]
[:ul {:class (stl/css :summary-list)}
(for [{:keys [name]} done]
[:li {:class (stl/css :summary-list-item)
:key (dm/str name)}
[:span {:class (stl/css :summary-item-name)} name]
[:span {:class (stl/css :summary-linked-badge)}
[:> icon* {:icon-id i/status-tick
:class (stl/css :summary-badge-icon)
:size "s"}]
(tr "dashboard.import.summary.linked")]])]])
(when (seq pending)
[:div {:class (stl/css :summary-section)}
[:div {:class (stl/css :summary-section-header)}
[:> icon* {:icon-id i/open-link
:class (stl/css :summary-section-icon)
:size "s"}]
[:span (tr "dashboard.import.summary.your-selection")]]
[:ul {:class (stl/css :summary-list)}
(for [{:keys [id name] :as cand} pending]
(let [selected-id (get selection id)
selected-c (when selected-id
(d/seek #(= (:id %) selected-id) (:candidates cand)))]
[:li {:class (stl/css :summary-list-item)
:key (dm/str id)}
[:span {:class (stl/css :summary-item-name)} name]
(if selected-c
[:span {:class (stl/css :summary-linked-info)}
[:span {:class (stl/css :summary-linked-name)}
(:name selected-c)]
[:span {:class (stl/css :summary-linked-project)}
(:project-name selected-c)]]
[:span {:class (stl/css :summary-no-selection)}
(tr "dashboard.import.summary.no-selection")])]))]])]))
(mf/defc library-resolution-summary*
{::mf/private true}
[{:keys [resolution selection]}]
[:div {:class (stl/css :library-resolution)}
[:p {:class (stl/css :library-resolution-message)}
(tr "dashboard.import.resolve-libraries-summary")]
(for [[file-id resolution-file] resolution]
[:> library-resolution-summary-file*
{:key (dm/str file-id)
:resolution-file resolution-file
:selection selection}])])
(mf/defc import-dialog
{::mf/register modal/components
::mf/register-as :import
@ -329,14 +464,49 @@
;; Revoke all uri's on commonent unmount
(fn [] (run! wapi/revoke-uri (map :uri entries))))
(let [state* (mf/use-state (initialize-state entries))
entries (deref state*)
(let [state* (mf/use-state (initialize-state entries))
entries (deref state*)
status* (mf/use-state :analyze)
status (deref status*)
status* (mf/use-state :analyze)
status (deref status*)
edition* (mf/use-state nil)
edition (deref edition*)
edition* (mf/use-state nil)
edition (deref edition*)
;; Library resolution data from the backend (auto-linked + multi-match)
resolution* (mf/use-state nil)
resolution (not-empty (deref resolution*))
;; User selection for multi-match candidates: {old-lib-id candidate-id}
selection* (mf/use-state {})
selection (deref selection*)
;; Wizard progression as an ordered "visited" stack of file-ids.
;; `current-file` is derived: the first unresolved file NOT yet in `visited`.
;; No numeric step counter — forward = conj, back = pop.
visited* (mf/use-state #(d/ordered-set))
visited (deref visited*)
;; Derived: files that need user resolution (have :candidates)
unresolved-files
(mf/with-memo [resolution]
(when resolution
(reduce-kv (fn [acc _ v]
(if (has-unresolved? v)
(conj acc v)
acc))
[]
resolution)))
all-visited?
(mf/with-memo [visited unresolved-files]
(when (seq unresolved-files)
(every? #(contains? visited (:id %)) unresolved-files)))
;; Current file shown in the wizard step: first unresolved file not yet visited.
current-unresolved-file
(mf/with-memo [unresolved-files visited]
(d/seek #(not (contains? visited (:id %))) unresolved-files))
continue-entries
(mf/use-fn
@ -344,7 +514,7 @@
(fn []
(let [entries (filterv has-status-ready? entries)]
(reset! status* :import-progress)
(import-files state* project-id entries))))
(import-files state* resolution* project-id entries))))
continue-template
(mf/use-fn
@ -407,6 +577,52 @@
(continue-template template)
(continue-entries))))
on-confirm-library-links
(mf/use-fn
(mf/deps resolution selection on-finish-import)
(fn [event]
(dom/prevent-default event)
(let [selection @selection*]
;; For each file with pending candidates, link it to the selected libraries
(->> (rx/from (seq resolution))
(rx/merge-map
(fn [[file-id resolution-file]]
(->> (rx/from (:pending resolution-file))
(rx/merge-map
(fn [{:keys [id]}]
(when-let [selected-lib (get selection id)]
(link-files-to-library! [file-id] selected-lib)))))))
(rx/subs! (constantly nil)
(constantly nil)
(fn []
(st/emit! (modal/hide))
(when (fn? on-finish-import)
(on-finish-import))))))))
on-wizard-next
(mf/use-fn
(mf/deps current-unresolved-file visited)
(fn []
(let [file-id (:id current-unresolved-file)]
(swap! visited* conj file-id))))
on-wizard-prev
(mf/use-fn
(mf/deps current-unresolved-file)
(fn []
;; Remove the current file from visited; it becomes current again after re-render,
;; because it's no longer in visited.
(let [file-id (:id current-unresolved-file)]
(swap! visited* disj file-id))))
on-summary-back
(mf/use-fn
(mf/deps visited)
(fn []
(let [last-id (last visited)]
(swap! visited* disj last-id)
(reset! status* :library-resolution))))
on-accept
(mf/use-fn
(mf/deps on-finish-import)
@ -432,9 +648,19 @@
(zero? (count entries))))
pending-analysis?
(some has-status-analyze? entries)]
(some has-status-analyze? entries)
(mf/with-effect [entries]
auto-linked-count
(if (some? resolution)
(count-auto-linked resolution)
0)]
(mf/with-effect [visited unresolved-files]
(when (and (seq unresolved-files)
(every? #(contains? visited (:id %)) unresolved-files))
(reset! status* :library-summary)))
(mf/with-effect [entries resolution]
(cond
(some? template)
(reset! status* :import-ready)
@ -445,8 +671,11 @@
(and (seq entries)
(every? #(= :import-success (:status %)) entries))
(reset! status* :import-success)
(reset! status* (if (seq resolution)
(if (seq (filter has-unresolved? (vals resolution)))
:library-resolution
:library-summary)
:import-success))
(and (seq entries)
(and (every? #(not= :import-ready (:status %)) entries)
(some #(= :import-error (:status %)) entries)))
@ -466,22 +695,43 @@
:on-click on-cancel} deprecated-icon/close]]
[:div {:class (stl/css :modal-content)}
(when (and (= :analyze status) errors?)
(cond
(and (= :analyze status) errors?)
[:& context-notification
{:level :warning
:class (stl/css :context-notification-error)
:content (tr "dashboard.import.import-warning")}])
: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))}])
(= :import-success status)
[:*
[:& 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)
(= :import-error status)
[:& context-notification
{:level :error
:class (stl/css :context-notification-error)
:content (tr "dashboard.import.import-error.disclaimer")}])
:content (tr "dashboard.import.import-error.disclaimer")}]
;; :resolution — wizard step (current derived file)
(= :library-resolution status)
[:> library-resolution*
{:unresolved-file current-unresolved-file
:selection selection
:on-select (fn [old-lib-id candidate-id]
(swap! selection* assoc old-lib-id candidate-id))}]
;; :library-summary — show all files with final resolution
(= :library-summary status)
[:> library-resolution-summary*
{:resolution resolution
:selection selection}])
(if (or (= :import-error status) (and (= :analyze status) errors?))
[:div {:class (stl/css :import-error-disclaimer)}
@ -510,18 +760,21 @@
:else
(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 (or (= :library-resolution status)
(= :library-summary status))
(for [entry entries]
[:> import-entry* {:edition edition
:key (dm/str (:uri entry) "/" (:file-id entry))
:entry entry
:entries entries
:is-progress (= :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)
@ -535,22 +788,53 @@
[:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)}
(when (= :analyze status)
(cond
;; Wizard step: Next / Previous (Previous only shown if stack is non-empty)
(= :library-resolution status)
[:*
(when (seq visited)
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.previous")
:on-click on-wizard-prev}])
;; Label flips to "Review" when this is the last unvisited unresolved file.
[:input {:class (stl/css :accept-btn)
:type "button"
:value (if all-visited?
(tr "labels.next")
(tr "dashboard.import.review-links"))
:on-click on-wizard-next}]]
;; Summary: Confirm / Back
;; Back pops the stack once and re-enters the wizard at the popped file.
(= :library-summary status)
[:*
(when (seq visited)
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.back")
:on-click on-summary-back}])
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "dashboard.import.confirm-library-links")
:on-click on-confirm-library-links}]]
(= :analyze status)
[:input {:class (stl/css :cancel-button)
:type "button"
:value (tr "labels.cancel")
:on-click on-cancel}])
:on-click on-cancel}]
(when (= status :import-ready)
(= status :import-ready)
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.continue")
:disabled pending-analysis?
:on-click on-continue}])
:on-click on-continue}]
(when (or (= :import-success status)
(= :import-error status)
(= :import-progress status))
(or (= :import-success status)
(= :import-error status)
(= :import-progress status))
[:input {:class (stl/css :accept-btn)
:type "button"
:value (tr "labels.accept")

View File

@ -273,3 +273,182 @@
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;
}
.library-resolution-summary-file {
margin-bottom: deprecated.$s-16;
padding-bottom: deprecated.$s-12;
border-bottom: 1px solid var(--color-foreground-secondary);
}
.library-resolution-linked {
color: var(--color-foreground-secondary);
font-size: deprecated.$fs-12;
}
.library-resolution-project {
color: var(--color-foreground-secondary);
font-size: deprecated.$fs-12;
margin-bottom: deprecated.$s-8;
}
// Summary file card
.summary-file {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
padding: deprecated.$s-12;
border: 1px solid var(--color-background-quaternary);
border-radius: deprecated.$br-8;
background: var(--color-background-primary);
}
.summary-file-header {
display: flex;
align-items: center;
gap: deprecated.$s-8;
padding-bottom: deprecated.$s-8;
border-bottom: 1px solid var(--color-background-quaternary);
}
.summary-file-icon {
color: var(--color-foreground-secondary);
flex-shrink: 0;
}
.summary-file-name {
@include deprecated.body-medium-typography;
color: var(--modal-title-foreground-color);
font-weight: 700;
}
// Section within a file (auto-linked or user selection)
.summary-section {
display: flex;
flex-direction: column;
gap: deprecated.$s-4;
padding-left: deprecated.$s-8;
}
.summary-section-header {
display: flex;
align-items: center;
gap: deprecated.$s-6;
padding: deprecated.$s-4 0;
}
.summary-section-icon {
color: var(--color-foreground-secondary);
flex-shrink: 0;
}
.summary-list {
display: flex;
flex-direction: column;
gap: deprecated.$s-2;
list-style: none;
padding: 0;
margin: 0;
}
.summary-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: deprecated.$s-8;
padding: deprecated.$s-4 deprecated.$s-8;
border-radius: deprecated.$br-4;
&:hover {
background: var(--color-background-secondary);
}
}
.summary-item-name {
@include deprecated.body-small-typography;
color: var(--modal-title-foreground-color);
flex: 1;
min-width: 0;
@include deprecated.text-ellipsis;
}
// Auto-linked badge
.summary-linked-badge {
display: inline-flex;
align-items: center;
gap: deprecated.$s-4;
padding: deprecated.$s-2 deprecated.$s-8;
border-radius: deprecated.$br-12;
background: var(--color-accent-success-bg);
color: var(--color-accent-success);
font-size: deprecated.$fs-11;
font-weight: deprecated.$fw500;
flex-shrink: 0;
}
.summary-badge-icon {
color: var(--color-accent-success);
}
// User-selected library info
.summary-linked-info {
display: flex;
align-items: center;
gap: deprecated.$s-4;
flex-shrink: 0;
}
.summary-linked-name {
@include deprecated.body-small-typography;
color: var(--modal-text-foreground-color);
font-weight: deprecated.$fw500;
}
.summary-linked-project {
@include deprecated.body-small-typography;
color: var(--color-foreground-secondary);
padding: deprecated.$s-2 deprecated.$s-8;
border-radius: deprecated.$br-12;
background: var(--color-background-quaternary);
font-size: deprecated.$fs-11;
}
// No selection state
.summary-no-selection {
@include deprecated.body-small-typography;
color: var(--color-foreground-secondary);
font-style: italic;
flex-shrink: 0;
}

View File

@ -165,8 +165,9 @@
(defmethod impl/handler :import-files
[{:keys [project-id files]}]
(let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files)
binfile-v3 (filter #(= :binfile-v3 (:type %)) files)]
(let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files)
binfile-v3 (filter #(= :binfile-v3 (:type %)) files)
resolutions (volatile! {})]
(rx/merge
(->> (rx/from binfile-v1)
@ -197,40 +198,50 @@
:error (import-cause-message cause (tr "labels.error"))
:file-id (:file-id data)})))))))
(->> (rx/from binfile-v3)
(rx/reduce (fn [result file]
(update result (:uri file) (fnil conj []) file))
{})
(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)})))))))))))))
(rx/concat
(->> (rx/from binfile-v3)
(rx/reduce (fn [result file]
(update result (:uri file) (fnil conj []) file))
{})
(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)]
(cond
(= type "progress")
(log/dbg :hint "import-binfile: progress"
:section (:section payload)
:name (:name payload))
:else
(log/dbg :hint "import-binfile: end")))))
(rx/filter sse/end-of-stream?)
(rx/mapcat (fn [message]
(let [{:keys [resolution]} (sse/get-payload message)]
(when (seq resolution)
(vswap! resolutions merge resolution))
(->> (rx/from entries)
(rx/map (fn [entry]
{:status :finish
:file-id (:file-id entry)}))))))
(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)}))))))))))
(->> (rx/defer #(rx/of @resolutions))
(rx/map (fn [resolutions]
{:libraries-resolution resolutions})))))))

View File

@ -756,6 +756,35 @@ msgstr[1] "%s files have been imported successfully."
msgid "dashboard.import.import-warning"
msgstr "Some files containted invalid objects that have been removed."
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."
msgid "dashboard.import.resolve-libraries"
msgstr "Some libraries couldn't be linked automatically. Select the correct library for each:"
msgid "dashboard.import.resolve-libraries-summary"
msgstr "Review the library links before confirming:"
msgid "dashboard.import.confirm-library-links"
msgstr "Confirm library links"
msgid "dashboard.import.review-links"
msgstr "Review links"
msgid "dashboard.import.summary.auto-linked"
msgstr "Auto-linked"
msgid "dashboard.import.summary.your-selection"
msgstr "Your selection"
msgid "dashboard.import.summary.linked"
msgstr "Linked"
msgid "dashboard.import.summary.no-selection"
msgstr "No library selected"
#: src/app/main/ui/dashboard.cljs:260
msgid "dashboard.import.no-perms"
msgstr "You dont have permission to import to this team"

View File

@ -758,6 +758,35 @@ 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"
msgid "dashboard.import.resolve-libraries-summary"
msgstr "Revisa los vínculos de biblioteca antes de confirmar:"
msgid "dashboard.import.review-links"
msgstr "Revisar vínculos"
msgid "dashboard.import.summary.auto-linked"
msgstr "Vinculadas automáticamente"
msgid "dashboard.import.summary.your-selection"
msgstr "Tu selección"
msgid "dashboard.import.summary.linked"
msgstr "Vinculada"
msgid "dashboard.import.summary.no-selection"
msgstr "Ninguna biblioteca seleccionada"
#: src/app/main/ui/dashboard.cljs:260
msgid "dashboard.import.no-perms"
msgstr "No tienes permisos para importar en este equipo"