From d67a805956f2c4d6d8f67e1ad7b04c7a8af6561a 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 | 164 +++++++- backend/src/app/rpc/commands/binfile.clj | 1 + backend/test/backend_tests/binfile_test.clj | 379 +++++++++++++++++- frontend/deps.edn | 4 +- .../src/app/main/ui/dashboard/import.cljs | 366 +++++++++++++++-- .../src/app/main/ui/dashboard/import.scss | 179 +++++++++ frontend/src/app/worker/import.cljs | 89 ++-- frontend/translations/en.po | 29 ++ frontend/translations/es.po | 29 ++ 10 files changed, 1183 insertions(+), 105 deletions(-) diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index a5b73564ea..531d6ec2ca 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, 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 %)))))) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index 952cb69e8f..96c41bc53c 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,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))] 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..60e89ed575 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -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)))))))) diff --git a/frontend/deps.edn b/frontend/deps.edn index 801a990923..da9f37944a 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -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 diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index a279c62420..b21811c4a8 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -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") diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 7ae3efc2c6..cf39bf015f 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -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; +} diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index e03bcaa4c8..e879cc2cd8 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -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}))))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2befc9634b..c4baf1dc14 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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 don’t have permission to import to this team" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index cac40d58be..68843b5f9e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"