mirror of
https://github.com/penpot/penpot.git
synced 2026-07-19 12:38:25 +00:00
✨ Auto-link libraries during import based on slugified name
When a Penpot file is exported without bundled libraries and then imported into a different environment, external library links are broken because library UUIDs differ across environments. This feature adds a heuristic to auto-relink libraries by matching slugified library names against shared files in the target team: - Export: embed external library metadata (id, name, slug, used-by) in the manifest when libraries are not included in the export. - Import: resolve external libraries by slugifying shared file names in the destination team and matching against manifest slugs. - Single match: auto-link silently (creates file-library-rel row). - Multiple matches: emit SSE event so the frontend shows a selection dialog for the user to pick the correct library. - No match: import continues without linking (current behavior). Backend changes: - Extended manifest schema with optional :external-libraries field - Added slugify-name, get-files-names, get-shared-files-for-team, find-shared-files-by-slug helpers in app.binfile.common - Threaded team-id into import cfg from RPC layer - Added resolve-external-libraries and auto-link-libraries in v3 - Emit :library-candidates SSE event for multi-match cases Frontend changes: - Worker captures library-candidates SSE events and forwards them - Import dialog shows auto-link notification and multi-match selection UI with select dropdowns - Added link-files-to-library! RPC helper for user selections - Added en/es translations for new UI strings Closes #9263 Signed-off-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
920c6d4e76
commit
d67a805956
@ -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 %))))))
|
||||
|
||||
@ -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))]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
179
frontend/src/app/main/ui/dashboard/import.scss
vendored
179
frontend/src/app/main/ui/dashboard/import.scss
vendored
@ -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;
|
||||
}
|
||||
|
||||
@ -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})))))))
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user