From 109539dbcd780da9ea0b9dd7d12edf82478847b4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 1 Jun 2026 12:43:51 +0200 Subject: [PATCH] :sparkles: 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 --- backend/src/app/binfile/common.clj | 48 ++++- backend/src/app/binfile/v3.clj | 139 ++++++++++-- backend/src/app/rpc/commands/binfile.clj | 1 + backend/test/backend_tests/binfile_test.clj | 198 +++++++++++++++++- .../src/app/main/ui/dashboard/import.cljs | 147 +++++++++++-- .../src/app/main/ui/dashboard/import.scss | 27 +++ frontend/src/app/worker/import.cljs | 70 ++++--- frontend/translations/en.po | 72 ++++--- frontend/translations/es.po | 17 ++ 9 files changed, 629 insertions(+), 90 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index a5b73564ea..402dc30750 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -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 %)))))) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 952cb69e8f..dd39a3635c 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -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 diff --git a/backend/src/app/rpc/commands/binfile.clj b/backend/src/app/rpc/commands/binfile.clj index ec27d455b8..34bbc80b63 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -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) diff --git a/backend/test/backend_tests/binfile_test.clj b/backend/test/backend_tests/binfile_test.clj index 232a44f87c..d1b12df41d 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -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)))))))) diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index a279c62420..f2c882d601 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -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)) diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 7ae3efc2c6..88d524183f 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -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; +} diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index e03bcaa4c8..0edcefa474 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -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)})))))))))))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a3559f9c1d..7b1dbc80b0 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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 don’t 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…" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 35393cf0f7..b172def39a 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"