From 03cd3fa70fc35fe4d2e0f210d1dccd4baa9f4bd2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 27 Aug 2026 10:09:52 +0200 Subject: [PATCH] :recycle: Consolidate auto-link libraries with unified export-type and fix ref integrity (#9958) * :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 * :sparkles: Add UI for the auto-link plumbing * :recycle: Consolidate auto-link libraries with unified export-type and fix ref integrity Consolidates the auto-link libraries feature into a single coherent implementation: - Unify boolean flags (embed-assets, include-libraries, link-later) into single ::bfc/export-type parameter - Fix critical reference-integrity bug: pre-resolution no longer remaps :component-file refs when no link is created (multi-match / no-permission) - compute-link-decisions as single source of truth for auto-link logic - 80+ backend tests covering round-trip, cross-team, permissions, edge cases, and reference integrity AI-assisted-by: longcat-2.0 --------- Signed-off-by: Andrey Antukh Co-authored-by: Eva Marco --- backend/src/app/binfile/common.clj | 48 +- backend/src/app/binfile/v3.clj | 217 ++- backend/src/app/rpc/commands/binfile.clj | 23 +- backend/test/backend_tests/binfile_test.clj | 1645 ++++++++++++++++- .../test_files/file-with-library.penpot | Bin 0 -> 8480 bytes docker/devenv/Dockerfile | 2 +- frontend/deps.edn | 4 +- frontend/src/app/main/data/exports/files.cljs | 26 +- .../src/app/main/ui/dashboard/import.cljs | 690 +++++-- .../src/app/main/ui/dashboard/import.scss | 618 ++++--- .../src/app/main/ui/ds/controls/select.cljs | 94 +- frontend/src/app/main/ui/exports/files.cljs | 135 +- frontend/src/app/main/ui/exports/files.scss | 372 ++-- frontend/src/app/plugins/file.cljs | 8 +- frontend/src/app/worker/import.cljs | 89 +- frontend/translations/en.po | 95 +- frontend/translations/es.po | 90 +- plugins/libs/plugin-types/index.d.ts | 17 +- 18 files changed, 3399 insertions(+), 774 deletions(-) create mode 100644 backend/test/backend_tests/test_files/file-with-library.penpot diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index b0329ce65a..2f37e34e78 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -875,8 +875,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 @@ -888,3 +888,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 314320ef39..b6f610000d 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -67,7 +67,16 @@ [:relations {:optional true} [:vector - [:tuple ::sm/uuid ::sm/uuid]]]]) + [:tuple ::sm/uuid ::sm/uuid]]] + + ;; TODO: rename to :links + [: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"} @@ -217,14 +226,12 @@ (.flush writer)) (.closeEntry output)) + (defn- get-file - [{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id] + [{:keys [::bfc/export-type] :as cfg} file-id] - (when (and include-libraries embed-assets) - (throw (IllegalArgumentException. - "the `include-libraries` and `embed-assets` are mutally excluding options"))) - - (let [detach? (and (not embed-assets) (not include-libraries))] + (let [detach? (= export-type :detach-libraries) + embed? (= export-type :merge-libraries)] (db/tx-run! cfg (fn [cfg] (cond-> (bfc/get-file cfg file-id {:realize? true @@ -234,7 +241,7 @@ (-> (ctf/detach-external-references file-id) (dissoc :libraries)) - embed-assets + embed? (update :data #(bfc/embed-assets cfg % file-id)) :always @@ -371,12 +378,34 @@ (write-entry! output path encoded-tokens))))) (defn- export-files - [{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}] - (let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids))) - rels (if include-libraries + [{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}] + + (let [original-ids ids + ids (into ids (when (= export-type :include-libraries) (bfc/get-libraries cfg ids))) + rels (if (= export-type :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 for :link-later. + external-libs + (when (= export-type :link-later) + (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 +418,14 @@ ;; Write manifest file (let [files (:files @bfc/*state*) - params {:type "penpot/export-files" - :version 1 - :generated-by (str "penpot/" (:full cf/version)) - :referer "penpot" - :files (vec (vals files)) - :relations rels}] + params (cond-> {:type "penpot/export-files" + :version 1 + :generated-by (str "penpot/" (:full cf/version)) + :referer "penpot" + :files (vec (vals files)) + :relations rels} + (seq external-libs) + (assoc :external-libraries external-libs))] (write-entry! output "manifest.json" params)))) ;; --- IMPORT IMPL @@ -882,6 +913,104 @@ (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- compute-link-decisions + "Returns a map of {old-lib-id -> {:library-id ... :library ...}} for external + libraries that should be auto-linked (single candidate AND importer has edit + permission). Libraries with zero or multiple candidates, or where the importer + lacks permission, are excluded — their refs should remain dangling." + [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/profile-id] :as cfg}] + (reduce + (fn [acc ext-lib] + (let [slug (:slug ext-lib)] + (if (nil? slug) + acc + (let [matching (into [] (bfc/find-shared-files-by-slug cfg team-id slug))] + (if (not= 1 (count matching)) + acc + (let [library (first matching) + perms (bfc/get-file-permissions conn profile-id (:id library))] + (if (:can-edit perms) + (assoc acc (:id ext-lib) {:library-id (:id library) + :library library}) + acc))))))) + {} + (:external-libraries manifest))) + +(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] :as cfg} files-info] + (assert (uuid? team-id) "team-id should be provided") + + (let [file-ids (keys files-info) + decisions (compute-link-decisions cfg)] + + (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 [used-by (into #{} (map bfc/lookup-index) (:used-by ext-lib))] + (cond + ;; No slug → skip + (nil? (:slug ext-lib)) + acc + + ;; Has decision → auto-link (single match + can-edit) + (contains? decisions (:id ext-lib)) + (let [{:keys [library-id]} (get decisions (:id ext-lib)) + used-by (filter used-by file-ids)] + (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))) + + ;; Has candidates but no decision → multi-match or no permission → pending + :else + (let [matching-libraries (into [] (bfc/find-shared-files-by-slug cfg team-id (:slug ext-lib)))] + (if (empty? matching-libraries) + acc + (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) @@ -890,18 +1019,37 @@ (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)] + ;; Pre-resolve external libraries and add their id mappings to the index + ;; BEFORE importing files. This allows relink-refs (inside process-file) + ;; to correctly remap :component-file references to the destination library. + ;; Only remap when a link will actually be created (single match + can-edit). + (let [decisions (compute-link-decisions cfg)] + (doseq [[old-lib-id {:keys [library-id]}] decisions] + (l/trc :hint "pre-resolving external library" + :old-id (str old-lib-id) + :new-id (str library-id)) + (vswap! bfc/*state* update :index assoc old-lib-id library-id))) + + (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}] @@ -929,7 +1077,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}] @@ -977,12 +1126,11 @@ "Do the exportation of a specified file in custom penpot binary format. There are some options available for customize the output: - `::bfc/include-libraries`: additionally to the specified file, all the - linked libraries also will be included (including transitive - dependencies). - - `::bfc/embed-assets`: instead of including the libraries, embed in the - same file library all assets used from external libraries." + `::bfc/export-type`: determines how linked libraries are handled. + Valid values: `:include-libraries` (include linked libraries), + `:merge-libraries` (embed library assets in the file), + `:detach-libraries` (treat assets as basic objects), + `:link-later` (preserve component metadata for relinking on import)." [{:keys [::bfc/ids] :as cfg} output] @@ -998,6 +1146,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 ed2cec3ed0..839305f623 100644 --- a/backend/src/app/rpc/commands/binfile.clj +++ b/backend/src/app/rpc/commands/binfile.clj @@ -42,17 +42,24 @@ schema:export-binfile [:map {:title "export-binfile"} [:file-id ::sm/uuid] - [:include-libraries ::sm/boolean] - [:embed-assets ::sm/boolean]]) + [:type {:optional true} [::sm/one-of #{:include-libraries :merge-libraries :detach-libraries :link-later}]] + [:include-libraries {:optional true} ::sm/boolean] + [:embed-assets {:optional true} ::sm/boolean]]) (defn- export-binfile - [{:keys [::sto/storage] :as cfg} {:keys [file-id include-libraries embed-assets]}] - (let [output (tmp/tempfile*)] + [{:keys [::sto/storage] :as cfg} {:keys [type file-id include-libraries embed-assets]}] + (let [output (tmp/tempfile*) + ;; Convert legacy boolean flags to unified export-type + export-type (cond + (some? type) type + (true? include-libraries) :include-libraries + (true? embed-assets) :merge-libraries + :else :detach-libraries)] + (try (-> cfg (assoc ::bfc/ids #{file-id}) - (assoc ::bfc/embed-assets embed-assets) - (assoc ::bfc/include-libraries include-libraries) + (assoc ::bfc/export-type export-type) (bf.v3/export-files! output)) (let [data (sto/content output) @@ -73,7 +80,8 @@ (sv/defmethod ::export-binfile "Export a penpot file in a binary format." {::doc/added "1.15" - ::doc/changes [["2.12" "Remove version parameter, only one version is supported"]] + ::doc/changes [["2.12" "Remove version parameter, only one version is supported"] + ["2.19" "Deprecated `include-libraries` and `embed-assets` params"]] ::webhooks/event? true ::sm/params schema:export-binfile} [cfg {:keys [::rpc/profile-id file-id] :as params}] @@ -94,6 +102,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 ab09c3f23c..6eb7eff268 100644 --- a/backend/test/backend_tests/binfile_test.clj +++ b/backend/test/backend_tests/binfile_test.clj @@ -10,10 +10,12 @@ [app.binfile.common :as bfc] [app.binfile.v1 :as v1] [app.binfile.v3 :as v3] + [app.common.data :as d] [app.common.features :as cfeat] [app.common.files.validate :as cfv] [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.config :as cf] @@ -161,8 +163,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -170,14 +171,15 @@ (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input output) (v3/import-files!)) + file-id (first (:file-ids result)) imported (:result (th/command! {::th/type :get-file ::rpc/profile-id (:id profile) - :id (first result) + :id file-id :components-v2 true})) root (get-in imported [:data :pages-index svg-raw-page-id :objects svg-raw-root-id])] - (t/is (= (count result) 1)) + (t/is (= 1 (count (:file-ids result)))) ;; The child ids of an svg-raw shape must survive the JSON round ;; trip as uuids; when they came back as plain strings they no @@ -197,8 +199,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -206,16 +207,18 @@ (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 export-binfile-preserves-public-uri-subpath (let [profile (th/create-profile* 1) file (prepare-simple-file profile) config (assoc cf/config :public-uri "https://example.com/penpot") params {:file-id (:id file) - :include-libraries false - :embed-assets false} + ::bfc/export-type :detach-libraries} uri (binding [cf/config config] (#'binfile/export-binfile th/*system* params))] (t/is (str/starts-with? (str uri) @@ -229,8 +232,7 @@ (v3/export-files! (-> th/*system* (assoc ::bfc/ids #{(:id file)}) - (assoc ::bfc/embed-assets false) - (assoc ::bfc/include-libraries false)) + (assoc ::bfc/export-type :detach-libraries)) (io/output-stream output)) (let [result (-> th/*system* @@ -238,9 +240,9 @@ (assoc ::bfc/profile-id (:id profile)) (assoc ::bfc/input output) (v3/import-files!)) - imported (bfc/get-file th/*system* (first result))] + imported (bfc/get-file th/*system* (first (:file-ids result)))] - (t/is (= (count result) 1)) + (t/is (= 1 (count (:file-ids result)))) (t/is (some? (get-in imported [:metadata :generated-by]))) (t/is (= "penpot" (get-in imported [:metadata :referer])))))) @@ -266,3 +268,1618 @@ ;; With the guard, it raises :validation :max-file-size-reached. (t/is (= :validation (:type out))) (t/is (= :max-file-size-reached (:code out)))))))) + +(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/export-type :link-later)) + (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/export-type :link-later)) + (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/export-type :detach-libraries)) + (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/export-type :link-later)) + (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 with link-later to compute external-libraries + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (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/export-type :link-later)) + (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))))))))) + +;; ============================================================================= +;; COMPREHENSIVE LINK-LATER TESTS +;; ============================================================================= + +(defn- import-sample-file + "Import the file-with-library.penpot sample file and return + {:profile :file :library :team}. The sample contains a library and + a file that uses it." + ([] + (import-sample-file th/*system*)) + ([system] + (let [profile (th/create-profile* system 1 {}) + input (th/tempfile "backend_tests/test_files/file-with-library.penpot") + result (-> 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 input) + (v3/import-files!)) + file-ids (:file-ids result) + ;; Find the file-library-rel to identify which id is the file + ;; and which is the library. Relation: file-id -> library-file-id. + rels (keep #(when-let [r (db/query system :file-library-rel + {:file-id %})] + (first r)) + file-ids) + rel (first rels) + file-id (:file-id rel) + library-id (:library-file-id rel)] + {:profile profile + :file-id file-id + :library-id library-id + :all-file-ids (set file-ids) + :team-id (:default-team-id profile)}))) + +(defn- create-named-library + "Create a shared library with the given name in the given team." + ([team-id name] + (create-named-library th/*system* 1 team-id name)) + ([system i team-id name] + (let [profile (th/create-profile* system i {}) + project (th/create-project* system i {:profile-id (:id profile) + :team-id team-id})] + (th/create-file* system i {:profile-id (:id profile) + :project-id (:id project) + :is-shared true + :name name})))) + +(defn- get-file-shapes + "Get all shapes from a file's data." + [file-data] + (let [pages (vals (:pages-index file-data))] + (mapcat vals (map :objects pages)))) + +;; ----------------------------------------------------------------------------- +;; Category 1: Same-Team Round-Trip +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-same-team-round-trip + (let [{:keys [profile file-id team-id]} (import-sample-file) + _ (t/is (some? file-id)) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Verify manifest has external-libraries + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (some? ext-libs)) + (t/is (pos? (count ext-libs)))) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should have auto-linked library + (t/is (some? file-res)) + (t/is (some? (:name file-res))) + (t/is (= 1 (count (:done file-res)))) + (t/is (= [] (:pending file-res))) + + ;; Verify file-library-rel was created + (let [rels (db/query th/*system* :file-library-rel {:file-id new-file-id})] + (t/is (pos? (count rels))))))) + +(t/deftest link-later-same-team-idempotent + (let [{:keys [profile file-id team-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; First import + (let [result1 (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= 1 (count (:file-ids result1))))) + + ;; Second import (should succeed) + (let [result2 (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!))] + (t/is (= 1 (count (:file-ids result2)))) + (let [resolution (:resolution result2) + new-file-id (first (:file-ids result2)) + file-res (get resolution new-file-id)] + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))))))) + +(t/deftest link-later-overwrite-import-no-resolution + (let [{:keys [profile file-id team-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import with overwrite (file-id set) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/file-id file-id) + (assoc ::bfc/input output) + (v3/import-files!))] + ;; Overwrite should have empty resolution + (t/is (= {} (:resolution result)))))) + +;; ----------------------------------------------------------------------------- +;; Category 2: Cross-Team Migration +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-cross-team-library-pre-exists + (let [{:keys [profile file-id team-id]} (import-sample-file) + ;; Create a second team with a library named "LIbrary" + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "LIbrary") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later from team 1 + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (where library with same name exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should auto-link to library2 + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id library2) (:linked-to (first (:done file-res))))) + + ;; Verify 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 link-later-cross-team-no-library + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (no library exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking should happen + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-different-library-name + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "Buttons Library") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (library with different name) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (slug mismatch) + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-library-not-shared + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + ;; Create a private (non-shared) library + _ (let [priv-project (th/create-project* th/*system* 20 {:profile-id (:id profile) + :team-id (:id team2)}) + priv-lib (th/create-file* th/*system* 21 {:profile-id (:id profile) + :project-id (:id priv-project) + :is-shared false + :name "LIbrary"})]) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (library exists but not shared) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (library not shared) + (t/is (= {} resolution))))) + +(t/deftest link-later-cross-team-library-deleted + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + library2 (create-named-library th/*system* 10 (:id team2) "LIbrary") + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete the library in team 2 + (db/update! th/*system* :file + {:deleted-at (ct/now)} + {:id (:id library2)}) + + ;; Import in team 2 (library deleted) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; No linking (library deleted) + (t/is (= {} resolution))))) + +;; ----------------------------------------------------------------------------- +;; Category 3: Multiple Libraries +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multiple-libraries-both-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + ;; Create two libraries + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + ;; Create file linked to both + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; Link file to both libraries + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + ;; Export with link-later + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create new libraries with same names + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2b (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Both libraries should be auto-linked + (t/is (some? file-res)) + (t/is (= 2 (count (:done file-res)))) + (t/is (= [] (:pending file-res))) + + ;; Verify both linked-to ids + (let [linked-ids (set (map :linked-to (:done file-res)))] + (t/is (contains? linked-ids (:id lib1b))) + (t/is (contains? linked-ids (:id lib2b)))))))) + +(t/deftest link-later-multiple-libraries-one-matches + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete both libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Only recreate Icons (not Colors) + (let [lib1b (th/create-file* 10 {: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 team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Only Icons should be auto-linked + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib1b) (:linked-to (first (:done file-res))))))))) + +(t/deftest link-later-multiple-libraries-both-multi-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create TWO of each library (multi-match) + (let [_ (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 12 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + _ (th/create-file* 13 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Both libraries should be pending (multi-match) + (t/is (some? file-res)) + (t/is (= [] (:done file-res))) + (t/is (= 2 (count (:pending file-res)))) + + ;; Each pending should have 2 candidates + (doseq [pending-entry (:pending file-res)] + (t/is (= 2 (count (:candidates pending-entry))))))))) + +(t/deftest link-later-multiple-libraries-mixed-single-and-multi + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib1 (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib2 (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib1)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib2)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib1)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib2)}) + + ;; Create ONE Icons (single match) and TWO Colors (multi-match) + (let [lib1b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + _ (th/create-file* 12 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Icons done, Colors pending + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib1b) (:linked-to (first (:done file-res))))) + (t/is (= 1 (count (:pending file-res)))) + (t/is (= 2 (count (:candidates (first (:pending file-res)))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 4: Multiple Files +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multiple-files-same-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (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})] + + ;; Both files use the library + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file2) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original library and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {: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 team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Both files should have auto-linked + (t/is (= 2 (count (keys resolution)))) + (doseq [[file-id file-res] resolution] + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib-b) (:linked-to (first (:done file-res)))))))))) + +(t/deftest link-later-multiple-files-only-one-uses-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (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 lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original library and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {: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 team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Only file1 should have auto-linked + (let [files-with-done (filter #(seq (:done (val %))) resolution)] + (t/is (= 1 (count files-with-done))) + (let [[file-id file-res] (first files-with-done)] + (t/is (= (:id lib-b) (:linked-to (first (:done file-res))))))))))) + +(t/deftest link-later-multiple-files-different-libraries + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib-icons (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib-colors (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + file1 (th/create-file* 3 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + file2 (th/create-file* 4 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + ;; file1 uses Icons, file2 uses Colors + (db/insert! th/*system* :file-library-rel + {:file-id (:id file1) :library-file-id (:id lib-icons)}) + (db/insert! th/*system* :file-library-rel + {:file-id (:id file2) :library-file-id (:id lib-colors)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file1) (:id file2)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original libraries and recreate + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-icons)}) + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib-colors)}) + (let [lib-icons-b (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + lib-colors-b (th/create-file* 11 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Colors Library"}) + + result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result)] + + ;; Each file should have its respective library + (t/is (= 2 (count (keys resolution)))) + (doseq [[file-id file-res] resolution] + (t/is (= 1 (count (:done file-res)))) + (let [linked-id (:linked-to (first (:done file-res)))] + (t/is (or (= linked-id (:id lib-icons-b)) + (= linked-id (:id lib-colors-b)))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 5: Permission Scenarios +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-permission-viewer-cannot-link + (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}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + lib (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import as viewer (no edit permission on library) + (let [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!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; No auto-link (viewer lacks edit permission) + (t/is (or (nil? file-res) + (= [] (:done file-res)))))))) + +(t/deftest link-later-permission-editor-can-link + (let [owner (th/create-profile* 1) + team (th/create-team* 1 {:profile-id (:id owner)}) + editor (th/create-profile* 2) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id editor) + :role :editor}) + project (th/create-project* 1 {:profile-id (:id owner) + :team-id (:id team)}) + lib (th/create-file* 1 {:profile-id (:id owner) + :project-id (:id project) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id owner) + :project-id (:id project) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import as editor (has edit permission) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:id project)) + (assoc ::bfc/profile-id (:id editor)) + (assoc ::bfc/team-id (:id team)) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Auto-link should succeed + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))))))) + +;; ----------------------------------------------------------------------------- +;; Category 6: Edge Cases +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-edge-special-chars-in-name + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons & Buttons!"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Verify slug in manifest + (let [manifest (v3/get-manifest output) + ext-lib (first (:external-libraries manifest))] + (t/is (= "icons-buttons" (:slug ext-lib))))))) + +(t/deftest link-later-edge-empty-slug-library + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + ;; Library name that slugifies to empty + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "---"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Library with empty slug should be dropped from external-libraries + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (or (nil? ext-libs) + (empty? ext-libs))))))) + +(t/deftest link-later-edge-file-without-libraries + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with no libraries + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Manifest should have no external-libraries + (let [manifest (v3/get-manifest output)] + (t/is (nil? (:external-libraries manifest)))) + + ;; Import should succeed with empty resolution + (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!))] + (t/is (= {} (:resolution result)))))) + +(t/deftest link-later-edge-case-insensitive-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "ICONS Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete original and create with different case + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {: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 team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Should match (slug is lowercase) + (t/is (some? file-res)) + (t/is (= 1 (count (:done file-res)))) + (t/is (= (:id lib-b) (:linked-to (first (:done file-res))))))))) + +;; ----------------------------------------------------------------------------- +;; Category 7: Reference Integrity +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-reference-integrity-component-file-remapped + (let [{:keys [profile file-id team-id file]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Verify auto-link happened + (t/is (= 1 (count (:done file-res)))) + (let [linked-lib-id (:linked-to (first (:done file-res)))] + ;; Get the imported file's data + (let [imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + shapes (get-file-shapes (:data imported))] + ;; Check that component-file references point to the new library + (doseq [shape shapes] + (when (contains? shape :component-file) + (t/is (= linked-lib-id (:component-file shape)) + "component-file should reference the linked library")))))))) + +(t/deftest link-later-reference-integrity-no-match-dangling-refs + (let [{:keys [profile file-id]} (import-sample-file) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Import in team 2 (no library exists) + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id (:id team2)) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result))] + + ;; Get the original library id from the manifest + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest)))] + ;; Get the imported file's data + (let [imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + ;; component-file refs should remain as original (dangling) + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + (doseq [shape shapes-with-refs] + (t/is (= original-lib-id (:component-file shape)) + "component-file should remain as original UUID when no match"))))))) + +;; ----------------------------------------------------------------------------- +;; Category 8: Resolution Structure Verification +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-resolution-structure-single-file-single-lib + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete and recreate library + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [lib-b (th/create-file* 10 {: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 team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Verify exact structure + (t/is (map? file-res)) + (t/is (= new-file-id (:id file-res))) + (t/is (some? (:name file-res))) + (t/is (vector? (:done file-res))) + (t/is (vector? (:pending file-res))) + (t/is (= 1 (count (:done file-res)))) + + ;; Verify done entry structure + (let [done-entry (first (:done file-res))] + (t/is (contains? done-entry :id)) + (t/is (contains? done-entry :name)) + (t/is (contains? done-entry :linked-to)) + (t/is (= (:id lib) (:id done-entry))) + (t/is (= "Icons Library" (:name done-entry))) + (t/is (= (:id lib-b) (:linked-to done-entry)))))))) + +(t/deftest link-later-resolution-structure-multi-match + (let [profile (th/create-profile* 1) + team-id (:default-team-id profile) + lib (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + file (th/create-file* 2 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false})] + + (db/insert! th/*system* :file-library-rel + {:file-id (:id file) :library-file-id (:id lib)}) + + (let [output (tmp/tempfile :suffix ".zip")] + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{(:id file)}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Delete and create two libraries with same name + (db/update! th/*system* :file {:deleted-at (ct/now)} {:id (:id lib)}) + (let [_ (th/create-file* 10 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared true + :name "Icons Library"}) + _ (th/create-file* 11 {: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 team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + resolution (:resolution result) + new-file-id (first (:file-ids result)) + file-res (get resolution new-file-id)] + + ;; Verify pending structure + (t/is (= [] (:done file-res))) + (t/is (= 1 (count (:pending file-res)))) + + (let [pending-entry (first (:pending file-res))] + (t/is (contains? pending-entry :id)) + (t/is (contains? pending-entry :name)) + (t/is (contains? pending-entry :candidates)) + (t/is (= (:id lib) (:id pending-entry))) + (t/is (= "Icons Library" (:name pending-entry))) + (t/is (= 2 (count (:candidates pending-entry)))) + + ;; Verify candidate structure + (doseq [candidate (:candidates pending-entry)] + (t/is (contains? candidate :id)) + (t/is (contains? candidate :name)) + (t/is (contains? candidate :project-id)) + (t/is (contains? candidate :project-name)))))))) + +;; ----------------------------------------------------------------------------- +;; Code Review Regression Tests: Reference Integrity Bug Fix +;; ----------------------------------------------------------------------------- + +(t/deftest link-later-multi-match-leaves-refs-dangling + (let [{:keys [profile file-id team-id library-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Create a SECOND library with the same name in the same team + (create-named-library th/*system* 2 team-id "LIbrary") + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Re-import in same team + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id profile)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Multi-match should produce pending candidates, not auto-link + (t/is (seq (:pending file-res)) + "multi-match should produce pending candidates") + (t/is (= [] (:done file-res)) + "multi-match should NOT auto-link") + + ;; Refs must remain as original UUID (dangling), NOT remapped to any + ;; of the candidate libraries + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest))) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id profile) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + ;; The key assertion: refs should NOT be remapped to any candidate + ;; (they should remain as the original UUID from the manifest) + (let [slug (-> manifest :external-libraries first :slug) + matching (into #{} (map :id (bfc/find-shared-files-by-slug th/*system* team-id slug))) + candidate-ids (disj matching original-lib-id)] + (doseq [shape shapes-with-refs] + (t/is (not (contains? candidate-ids (:component-file shape))) + "component-file must NOT be remapped to any candidate library") + (t/is (= original-lib-id (:component-file shape)) + "component-file must remain as original UUID on multi-match"))))))) + +(t/deftest link-later-no-edit-permission-leaves-refs-dangling + (let [{:keys [profile file-id team-id library-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Export file with link-later + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later)) + (io/output-stream output)) + + ;; Create a viewer profile (no edit permission on the library) + (let [viewer (th/create-profile* th/*system* 2 {}) + _ (th/create-team-role* {:team-id team-id + :profile-id (:id viewer) + :role :viewer})] + + ;; Import as viewer + (let [result (-> th/*system* + (assoc ::bfc/project-id (:default-project-id profile)) + (assoc ::bfc/profile-id (:id viewer)) + (assoc ::bfc/team-id team-id) + (assoc ::bfc/input output) + (v3/import-files!)) + new-file-id (first (:file-ids result)) + resolution (:resolution result) + file-res (get resolution new-file-id)] + + ;; Viewer lacks edit permission, so no auto-link + (t/is (or (nil? file-res) + (= [] (:done file-res))) + "viewer should NOT auto-link") + + ;; Refs must remain as original UUID (dangling) + (let [manifest (v3/get-manifest output) + original-lib-id (:id (first (:external-libraries manifest))) + imported (:result (th/command! {::th/type :get-file + ::rpc/profile-id (:id viewer) + :id new-file-id + :components-v2 true})) + file-data (:data imported) + pages (vals (:pages-index file-data)) + all-shapes (mapcat vals (map :objects pages)) + shapes-with-refs (filter #(contains? % :component-file) all-shapes)] + (t/is (seq shapes-with-refs) "expected shapes with component-file refs") + (doseq [shape shapes-with-refs] + (t/is (= original-lib-id (:component-file shape)) + "component-file must remain as original UUID when viewer has no edit permission"))))))) + +(t/deftest export-type-takes-precedence-over-legacy-boolean + (let [{:keys [file-id]} (import-sample-file) + output (tmp/tempfile :suffix ".zip")] + + ;; Call export with BOTH type=:link-later AND include-libraries=true + ;; type should win + (v3/export-files! + (-> th/*system* + (assoc ::bfc/ids #{file-id}) + (assoc ::bfc/export-type :link-later) + (assoc ::bfc/include-libraries true)) + (io/output-stream output)) + + ;; Verify manifest has external-libraries (only produced by link-later) + (let [manifest (v3/get-manifest output) + ext-libs (:external-libraries manifest)] + (t/is (some? ext-libs) + "type :link-later should produce external-libraries even with include-libraries=true") + (t/is (pos? (count ext-libs)))))) diff --git a/backend/test/backend_tests/test_files/file-with-library.penpot b/backend/test/backend_tests/test_files/file-with-library.penpot new file mode 100644 index 0000000000000000000000000000000000000000..c17bb86a3d3c8431628a5377daeb1037f86ff649 GIT binary patch literal 8480 zcmd5>2T+sSx(yxaO+cC;ARR&vRRIx5s6mh-9YXIVw1X%jy+~D>AV^1gmm&xP(tDAn z^xh-=0p9aOXRhyf=bX83XYyrd{+T^%?^)lszqNj42ns3*0231vK;}oG1GqG}&gL+4 zD-%a91Abl*kBJa3hk=lh0S5?ZEO-`xAP$g$5x=02k&uwF0Fcwd(bgtJT_KE85T|DT zKD*}|m-#Uln-03$-aApreoh|AGCLZr!3?rRLpI^3G@-^GunK8PeS_QDw3uhZRlkof3RCjc1> zI$Rzn1h;LWkbbh#?jb2Jt((i#W=+p@+9@=9RnSgdC?ncYSBOb{1B^{mG9_^lAi59s!M3^& zT7%ex!Xmbr&qHR#(p4sPd&6(xe(q;^w$74^Feh)>Su!WTeBU&NH&3W9?jjIdx{l5b zm~A9?h?&KhRN8@_6YH)k6vANyiM%q)rkRpvU(grKgPS^^>z45K^KBSbG0|tQ5|+rq zU*CA*BhZ0BWo^<;^yR(D@To!CV6h78hVI&FTq-G1ULz^833+m(6Sb1}&1S(G5~Qw2 zSkOOib=3u&=r)Z-JM1*O*=NK2S4)E#VMqX$fK+U(gXd5CManRu3@4=oedaXV=k)5a zCMjPT;V!;%l6h?L$aK~yKqX+mR&-7E;7;7fQ$>|Knf$sTP%iO+83l()+l9=P2C>1V zwHKatPIzwt9F-1kSEY2it_0A}^z5-ZOL9J9S=iAb(AsTSsWqgWs~480%JCt{#IL5= zDSw8_Qi3|66vM}rMb8VDBK44a(sxC-MIu3!$a-cab7t9zl`Zm?D=qHN9@{N%qi?2Yf)-o&02r(ySg}4_vb)_49CA zXdWjmtR~9l@3Y3TRmjZzW-K7Qc+`MH#K7lemS=C;otS(5q0@@;`th|yM z@QJ_4-0L)T02*$E1=9JR;)N!@%AX;=clQ1FdwTd!?THKcufcKt`&0Py*~P|7p-PsG zv{W?+RaCfjpn!GUny@-E4wi^@Ftt_xU5ixm^3o_zM3(tUw3TwRsQ>!@MAyrM&Qw}J zg7+by{v#$vy1RE_-~89xiC;9q#JMF>NrFtXZi7&npcK8txB)Vzn~rA4&zN}H;4c<= zv?#19+9{PJeABb~5;#^!*J5ax#qUjws0>Y_eK6N^%P#bNy)G@66yH!}tE<9)ubi*4 zKWi-RHvY%WWYS}~U@z5E+OlnrT}_I4w8(?*T9z`%J&|{1+8AkKcih3FV@4ecZ6^}T zN0Cd9PY`ZcmT~dBAusk`)Qm7xxEID8N%=o@T_w84)m_|NZ#9o10pIZxOH7fXNkyuA7n#=sHjEblSgWb< zv$R4@dX*e62a%1_4paZxwo?1_cZ)OFL;GpwAwGvgiZy(bZ-d;I4hg@WP%JQfffuc> z4}9?H5s*)zJ+4o6u-rZQ=w?6j=%owG*OH~284nxNJh28WL4j0kb_lnrCKt>OVzd zW2b&=t3t>e3w|eC9XFJ@E%qZO*UPWAGG5anRRvy1C&?bvRWUV{K}_0pgAQeY#h0aE~O#WW2k1$R+M^3jI^1#iLs4a5`B)JMw17CfPYf zvvWS$?wwKeA=>{#it-rq@)!VlzEM_j{rB#JE={T!cSq50|gS>y7HMOyTHHBJHu41rYnJP_9$mb%XW16THX9 zsbJJ9$c3qsh!}95n>KLDTD3TmrV_5=6kmTeFuLyX)qvvTQw=0KfjZM(O-4IOpT zCiu^iBKGz)Zm$-MN;>Y#IZK^Z>xS@0!%iG?k?Kc~P3iLF#~z#>giq}TiQTyM#4KP) z^;NIMwv#7Cur13KjM#+kO3yhbsnT&n4L&~OTjr{ImTGLPGz~SZ_r=sga*Xob;5R~%2vLMzw|L<2B?7aW3J*{3oJR+^$cyo3JbL`gQm6NOH=~iQwVkbviH(yZ*Dp&M zzY&Os*8p_E6W`E6yapi79eC}lHu><#@b|0e+Jfje1q5wqrBqSJ2~HKIo{^Jlu#fkN zEz{&=69q`CUfCN&ti0tT$h!jsUcVi{U!8}WiJPJk>y@6u)$El<$+ZCPd!5XPOP}%F zeL}(4yj!s|tVE2}D%ZATG*c^4TnAj)fe3I|7(0~$v^dXrjiV$^rh4itr$}9+Ll0o> z9TLb4&Y}w(*hV`)DTgK1`oyzy*#Bh@@jK#(|68+jE{?_o2rk4C9(>nYq$#Dq1G=rJ0hP_g3(uQIF)gl8 zK9wn%SQ|Um9sJ;=vERjH7o1WkaZOBphLUUz@i_NgqCL?%facaUYVn+hhEO*nJ(SkT~ZeR2W`0)v`~&2>hm6j_GHi zZc+1GB30yabT~B#){-s3)5at* z@W$(N7Db6$nB0M)dg4ZfFCCg3>3murc=xCp8ax5w;v7IOeA%mEqz(v=097R?hZ)G$ zIC@C3go-&~n=>joJn{!l_ z2AH0?M1De03bR&^(l|OJWW_;CC|tK8ZyxAM-&cJZ4w6i=&WAG2P&`lQoJ31e<7%I_ zwQ3~NULp6@+KdG(l8Lh0S8wBsnrK<_tP%Mgh+3#FC@P2ZjkosLdAB)lJ-BV|Y! zFI5s1xtj7iQkY$Nw}T%~;6dc)KAf&DMhaUc=M;Re;t=%Z_RPgDv6Q7%r*#`G8T`hV zUZ}X_w0O< z>s^6nm|LP#=ccD>?J#j4C>;9?w~rkZ zlW>A>_NRELTE%4AKTgxbNJF`U4i+9ZT2;uNmRSBAnH_PajfmK^7S6Ph%b7Nca@@CI zZ<0wWf3N+r1(d>;G#~$Yk=eJ1#Tdk8p19=}Z=3aGT;SP5(nYW22v@i{JNU{SbD~3> zqW&<#MlT$Ll@ADp2f(;wC&K=atmM4hQ9Bxa)WGPzHGP!6Tv+>RR7 zDn9h`B$Hfwl;M*&Q_ypZVWs|nLt+46zR}9JMZG#r`d<4PxB**|P_DCvzou|{)suh4 zU~ar`b@M6z7tG?aRx~1Z+k#{!7i6vWwi_cOhV4e1BpsUD0Q;l$uI#7Rqi1HB$)H)- zwv63+!8|D_BN_ab)McHqs`QtA=EHhqK*(tjVhlRx}AYyn29;59$|0R z29`UlV(cP3UofU|T7t8MIRI>`m{|Hu0M+5}Y*yCcInDTXYQ1EBXQoF3JQ-S!1ZHNudCjO`7d11r@ zshQDz7SY(dhWgO_Fd=D|7k`L ztxvTWy$F=dI>iG8Y9Qb2LV~XY1 zn9z`!PWC4H49aXK&%_U2^$vMy1&Ft?y+Zs4jCN1)SlpFHJXYTV0BRiBlCrN~tD1_e z`MvLoOvcJbv-;yq0Q!%3_#D(TpGg}LTL=)1H6-QF7Sf~gojC&NUC`}AzR zII!^$j$B^4@m}*isD17bY$?1gI0A|7o9WBD-g@MOP^4=Qv5Z~G)qLf27;{potXZER zARS1t(tCSJMYO?We5!fEAeZozRz~RI_J_~!(P~HPE_}p@7PW%?GnU5vQg!2=DFkZ+ z8*`Y6qtgXv#-Sf=e1HURx37}UgG6sov=sV*qYp5A8%eoPeTN4>C%m}`K3bew;~u;Z>t4A2JYU7aWD9Vs^hRS&dQ<0Qa3D>VWmL(lmPE@aS$$ zO2#o)3%cC!a8mcj84&!HaT@aKDvRm6!IH8r=D{POQtHS`QibJ=@@`lrMO|&|#7ZgE z&AYN7kulr+>!^I}cYO6ZOWa|SXyh#@xN|KZCT(kGQC$=`Dpi=GbZB;Z;#M9}gwG+_ z8Yq*AI4cYuV{r{=vIVHhyoJHA#|im_@jpr5!p1_>Sf9kmL&a`x8#cmv9$sl4G}O%_ z4c7T!5_-s2*S;uoI5*BgGO>z%p6=ssOy1$09VFLTGo=hcLM8!xf7f)`fIgQ~KaSsg z^YruLm!05q5BvK-JF7f@eeu80wf*2||BU0Z#du!TzK@ZA3ddjXw0;JA*=+g|EGEW3 zbZEae4laA3KZCvOX#5B^=gNNt>}5CXXRwz|1-vj@xPFGKggw@aa?X+f27CmKZWBzPrS=r(T`vmN&cZj z`(@(&&|m+2vX?K%AHjN%{ujZ1YukPXd-(!7zpuX!x@$k(-pUX(^z+9!XP@M=nFYo9 GzyARbZt4mE literal 0 HcmV?d00001 diff --git a/docker/devenv/Dockerfile b/docker/devenv/Dockerfile index 450cff5b5a..00d27014bc 100644 --- a/docker/devenv/Dockerfile +++ b/docker/devenv/Dockerfile @@ -100,7 +100,7 @@ RUN set -eux; \ FROM base AS setup-opencode -ENV OPENCODE_VERSION=1.18.19 +ENV OPENCODE_VERSION=1.18.21 RUN set -ex; \ ARCH="$(dpkg --print-architecture)"; \ diff --git a/frontend/deps.edn b/frontend/deps.edn index f060448343..5c749f4c80 100644 --- a/frontend/deps.edn +++ b/frontend/deps.edn @@ -21,8 +21,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/data/exports/files.cljs b/frontend/src/app/main/data/exports/files.cljs index 1acefa5880..d50948e26e 100644 --- a/frontend/src/app/main/data/exports/files.cljs +++ b/frontend/src/app/main/data/exports/files.cljs @@ -17,18 +17,21 @@ [potok.v2.core :as ptk])) (def valid-types - (d/ordered-set :all :merge :detach)) + (d/ordered-set :include-libraries :merge-libraries :detach-libraries :link-later)) (def valid-formats - #{:binfile-v1 :binfile-v3 :legacy-zip}) + #{:binfile-v1 :binfile-v3}) + +(def ^:private schema:export-file-param + [:map {:title "FileParam"} + [:id ::sm/uuid] + [:name :string] + [:project-id ::sm/uuid] + [:is-shared ::sm/boolean] + #_[:has-libraries ::sm/boolean]]) (def ^:private schema:export-files - [:sequential {:title "Files"} - [:map {:title "FileParam"} - [:id ::sm/uuid] - [:name :string] - [:project-id ::sm/uuid] - [:is-shared ::sm/boolean]]]) + [:sequential {:title "Files"} schema:export-file-param]) (def check-export-files (sm/check-fn schema:export-files)) @@ -57,14 +60,17 @@ :files files})))))))))) (defn export-files + "Start files exportation process" [& {:keys [type files]}] + (assert (check-export-files files) "expected a sequence of files") + (assert (valid-types type) "expected valid export type") + (->> (rx/from files) (rx/mapcat (fn [file] (->> (rp/cmd! ::sse/export-binfile {:file-id (:id file) :version 3 - :include-libraries (= type :all) - :embed-assets (= type :merge)}) + :type type}) (rx/filter sse/end-of-stream?) (rx/map sse/get-payload) (rx/map (fn [uri] diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 85481d2460..882adcbb35 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -15,11 +15,20 @@ [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.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.checkbox :refer [checkbox*]] + [app.main.ui.ds.controls.select :refer [select*]] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.icons :as deprecated-icon] - [app.main.ui.notifications.context-notification :refer [context-notification]] [app.main.worker :as mw] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -54,7 +63,7 @@ {::mf/forward-ref true} [{:keys [project-id on-finish-import]} external-ref] (let [on-file-selected (use-import-file project-id on-finish-import)] - [:form.import-file {:aria-hidden "true"} + [:form {:aria-hidden "true"} [:& file-uploader {:accept ".penpot,.zip" :multi true :ref external-ref @@ -156,6 +165,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 +195,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,27 +205,40 @@ :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) import-ready? (= :import-ready status) + level (cond + import-success? :success + import-ready? :success + import-error? :error + analyze-error? :error + loading? nil + :else :default) + is-shared? (:shared entry) progress (:progress entry) @@ -251,46 +286,65 @@ :editable (and import-ready? (not editing?)))} [:div {:class (stl/css :file-name)} - (if loading? - [:> loader* {:width 16 :title (tr "labels.loading")}] - [:div {:class (stl/css-case - :file-icon true - :icon-fill import-ready?)} - (cond - import-ready? deprecated-icon/logo-icon - import-error? deprecated-icon/close - import-success? deprecated-icon/tick - analyze-error? deprecated-icon/close)]) + (when loading? [:> loader* {:width 26 :title (tr "labels.loading")}]) (if editing? [:div {:class (stl/css :file-name-edit)} [:input {:type "text" :auto-focus true + :class (stl/css :file-name-input) + ;;TODO: Add translation for aria-label + :aria-label "File name" :default-value (:name entry) :on-key-press on-edit-key-press :on-blur on-edit-blur}]] [:div {:class (stl/css :file-name-label)} - (:name entry) - (when ^boolean is-shared? - [:span {:class (stl/css :icon)} - deprecated-icon/library])]) - - [:div {:class (stl/css :edit-entry-buttons)} - (when ^boolean editable? - [:button {:on-click on-edit'} deprecated-icon/curve]) - (when ^boolean can-be-deleted - [:button {:on-click on-delete'} deprecated-icon/delete])]] + (if loading? + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name entry) + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])] + [:> context-notification* + {:level level + :appearance :ghost + :class (stl/css :file-name-notification)} + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-medium} + (:name entry) + (when ^boolean is-shared? + [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]])]) + (when ^boolean (or editable? can-be-deleted) + [:div {:class (stl/css :edit-entry-buttons)} + (when ^boolean editable? + [:> icon-button* {:on-click on-edit' + :variant "ghost" + :icon-size "s" + :aria-label (tr "labels.edit") + :icon i/curve}]) + (when ^boolean can-be-deleted + [:> icon-button* {:on-click on-delete' + :variant "ghost" + :icon-size "s" + :aria-label (tr "labels.delete") + :icon i/delete}])])] (cond analyze-error? - [:div {:class (stl/css :error-message)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} (if (some? (:error entry)) (tr (:error entry)) (tr "dashboard.import.analyze-error"))] import-error? - [:div {:class (stl/css :error-message)} + [:> text* {:class (stl/css :error-message) + :as "span" + :typography t/body-small} (if (some? (:error entry)) (tr (:error entry)) (tr "labels.error"))] @@ -318,6 +372,327 @@ (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) + disconnected* (mf/use-state #{}) + disconnected (deref disconnected*) + on-change-disconnected + (mf/use-fn + (fn [id] + (swap! disconnected* + (fn [s] + (if (contains? s id) (disj s id) (conj s id))))))] + + ;; 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 (str (:id first-c))))))) + + [:div {:class (stl/css :library-resolution)} + [:> text* {:class (stl/css :library-resolution-message) + :as "p" + :typography t/body-large} + "Some libraries couldn't be linked automatically. Select the correct library for each:"] + + + [:table {:class (stl/css :library-resolution-table)} + [:thead + [:tr {:class (stl/css :library-resolution-header)} + [:th {:class (stl/css :library-origin-name)} + [:> icon* {:icon-id i/library + :class (stl/css :library-resolution-icon) + :size "s"}] + "original library"] + [:th {:class (stl/css :library-resolution-arrow)}] + [:th {:class (stl/css :library-resolution-connection)} + [:> icon* {:icon-id i/library + :class (stl/css :library-resolution-icon) + :size "s"}] + "connect to"]]] + [:tbody {:class (stl/css :library-resolution-body)} + (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) + is-conected (not (contains? disconnected id))] + [:tr {:class (stl/css :library-resolution-item) + :key (dm/str id)} + [:td {:class (stl/css :library-resolution-item-name)} + [:> checkbox* {:id (dm/str id) + :label name + :checked is-conected + :on-change #(on-change-disconnected id)}]] + [:td {:class (stl/css :library-resolution-arrow)} + [:> icon* {:icon-id i/row + :size "m"}]] + [:td + (if is-conected + [:> select* {:options options + :class (stl/css :library-resolution-select) + :default-selected (or (some-> selected str) "") + :has-portal true + :on-change (partial on-select id)}] + + [:> text* {:class (stl/css :library-resolution-no-selection) + :as "span" + :typography t/body-small} + (let [selected-c (or (some #(when (= (str (:id %)) selected) %) candidates) + (first candidates))] + (dm/str (:name selected-c) " (" (:project-name selected-c) ")"))])]]))]]])) + +(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/document + :class (stl/css :summary-file-icon) + :size "s"}] + [:> text* {:class (stl/css :summary-file-name) + :as "span" + :typography t/body-medium} + (:name resolution-file)]] + + (when (seq done) + [:div {:class (stl/css :summary-section)} + [: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)} + ;; TODO: Add translation for this string + + [:> text* {:as "span" + :class (stl/css :summary-section-title) + :typography t/headline-small} + "linked manually"]] + [:ul {:class (stl/css :summary-list)} + [:li {:class (stl/css :summary-list-item) + :key "summary-list-header"} + [:span {:class (stl/css :summary-item-name-header)} + "Original"] + + [:span {:class (stl/css :summary-item-name-header)} + "New"]] + (for [{:keys [id name] :as cand} pending] + (let [selected-id (get selection id) + selected-c (when selected-id + (d/seek #(= (str (:id %)) (str selected-id)) (:candidates cand)))] + [:li {:class (stl/css :summary-list-item) + :key (dm/str id)} + [:span {:class (stl/css :summary-item-name)} name] + [:> icon* {:icon-id i/row + :size "m" + :class (stl/css :summary-linked-arrow)}] + (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}])]) + + +;; ── Stage components ──────────────────────────────────────────────── + +(mf/defc import-files-stage* + {::mf/private true} + [{:keys [entries template status errors? import-success-total auto-linked-count + edition on-edit on-change on-delete + on-cancel on-continue on-accept pending-analysis?]}] + [:* + [:div {:class (stl/css :modal-content)} + (when (and (= :analyze status) errors?) + [:> context-notification* + {:level :warning + :class (stl/css :context-notification-error)} + (tr "dashboard.import.import-warning")]) + + (when (= :import-success status) + [:* + [:> context-notification* + {:level (if (zero? import-success-total) :warning :success)} + (tr "dashboard.import.import-message" (i18n/c import-success-total))] + (when (pos? auto-linked-count) + [:> context-notification* + {:level :success} + (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))])]) + + (when (= :import-error status) + [:> context-notification* + {:level :error + :class (stl/css :context-notification-error)} + (tr "dashboard.import.import-error.disclaimer")]) + + (when (or (= :import-error status) (and (= :analyze status) errors?)) + [:div {:class (stl/css :import-error-disclaimer)} + [:div (tr "dashboard.import.import-error.message1")] + [:ul {:class (stl/css :import-error-list)} + (for [entry entries] + (when (contains? #{:import-error :analyze-error} (:status entry)) + [:li {:class (stl/css :import-error-list-enry) + :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} + [:div (:name entry)] + (when-let [err (:error entry)] + [:div {:class (stl/css :import-error-detail)} + (cond + (and (string? err) + (str/includes? (str/lower err) "check error")) + (tr "dashboard.import.import-error.check-error") + + (and (string? err) + (str/includes? (str/lower err) "corrupt")) + (tr "dashboard.import.import-error.corrupt-file") + + :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 + :is-progress (= :import-progress status) + :on-edit on-edit + :on-change on-change + :on-delete on-delete + :can-be-deleted (> (count entries) 1)}]) + + (when (some? template) + [:> import-entry* {:entry (assoc template :status status) + :can-be-deleted false}]) + + (when (= :import-progress status) + [:div {:class (stl/css :status-message) + :role "status" + :aria-live "polite"} + (tr "labels.uploading-file")])] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (case status + :analyze + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-cancel} + (tr "labels.cancel")] + + :import-ready + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled pending-analysis? + :on-click on-continue} + (tr "labels.continue")] + + :import-progress + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :disabled true + :on-click on-accept} + (tr "labels.accept")] + + (:import-success :import-error) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-accept} + (tr "labels.accept")])]]]) + +(mf/defc import-library-resolution-stage* + {::mf/private true} + [{:keys [current-unresolved-file selection on-select + visited all-visited? + on-wizard-prev on-wizard-next]}] + [:* + [:div {:class (stl/css :modal-content)} + [:> library-resolution* + {:unresolved-file current-unresolved-file + :selection selection + :on-select on-select}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (when (seq visited) + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-wizard-prev} + (tr "labels.previous")]) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-wizard-next} + (if all-visited? + (tr "labels.next") + (tr "dashboard.import.review-links"))]]]]) + +(mf/defc import-library-summary-stage* + {::mf/private true} + [{:keys [resolution selection visited + on-summary-back on-confirm-library-links]}] + [:* + [:div {:class (stl/css :modal-content)} + [:> library-resolution-summary* + {:resolution resolution + :selection selection}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + (when (seq visited) + [:> button* {:class (stl/css :cancel-button) + :variant "secondary" + :on-click on-summary-back} + (tr "labels.back")]) + [:> button* {:class (stl/css :accept-btn) + :variant "primary" + :on-click on-confirm-library-links} + (tr "dashboard.import.confirm-library-links")]]]]) + (mf/defc import-dialog {::mf/register modal/components ::mf/register-as :import @@ -329,14 +704,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 +754,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 +817,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 [slc 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 slc 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 +888,25 @@ (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) + + manage-on-select + (mf/use-fn + (mf/deps selection) + (fn [old-lib-id candidate-id] + (swap! selection* assoc old-lib-id candidate-id)))] + + (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 +917,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))) @@ -460,99 +935,50 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} (tr "dashboard.import")] + [:> heading* {:level 2 + :typography t/headline-large + :class (stl/css :modal-title)} + (tr "dashboard.import")] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-cancel + :class (stl/css :modal-close-btn) + :icon i/close}]] - [:button {:class (stl/css :modal-close-btn) - :on-click on-cancel} deprecated-icon/close]] + (case status + (:analyze :import-ready :import-progress :import-success :import-error) + [:> import-files-stage* + {:entries entries + :template template + :status status + :errors? errors? + :import-success-total import-success-total + :auto-linked-count auto-linked-count + :edition edition + :on-edit on-edit + :on-change on-entry-change + :on-delete on-entry-delete + :on-cancel on-cancel + :on-continue on-continue + :on-accept on-accept + :pending-analysis? pending-analysis?}] - [:div {:class (stl/css :modal-content)} - (when (and (= :analyze status) errors?) - [:& context-notification - {:level :warning - :class (stl/css :context-notification-error) - :content (tr "dashboard.import.import-warning")}]) + :library-resolution + [:> import-library-resolution-stage* + {:current-unresolved-file current-unresolved-file + :selection selection + :on-select manage-on-select + :visited visited + :all-visited? all-visited? + :on-wizard-prev on-wizard-prev + :on-wizard-next on-wizard-next}] - (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))}]) + :library-summary + [:> import-library-summary-stage* + {:resolution resolution + :selection selection + :visited visited + :on-summary-back on-summary-back + :on-confirm-library-links on-confirm-library-links}] - (when (= :import-error status) - [:& context-notification - {:level :error - :class (stl/css :context-notification-error) - :content (tr "dashboard.import.import-error.disclaimer")}]) - - (if (or (= :import-error status) (and (= :analyze status) errors?)) - [:div {:class (stl/css :import-error-disclaimer)} - [:div (tr "dashboard.import.import-error.message1")] - [:ul {:class (stl/css :import-error-list)} - (for [entry entries] - (when (contains? #{:import-error :analyze-error} (:status entry)) - [:li {:class (stl/css :import-error-list-enry) - :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} - [:div (:name entry)] - (when-let [err (:error entry)] - [:div {:class (stl/css :import-error-detail)} - ;; Temporary frontend-side error translations to provide more meaningful - ;; messages until backend error handling is improved and standardized. - ;; These mappings are only a short-term workaround and should be removed - ;; once the error handling enhancement is implemented. - ;; https://github.com/penpot/penpot/issues/9884 - (cond - (and (string? err) - (str/includes? (str/lower err) "check error")) - (tr "dashboard.import.import-error.check-error") - - (and (string? err) - (str/includes? (str/lower err) "corrupt")) - (tr "dashboard.import.import-error.corrupt-file") - - :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 (some? template) - [:> import-entry* {:entry (assoc template :status status) - :can-be-deleted false}]) - - (when (= :import-progress status) - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} - (tr "labels.uploading-file")])] - - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - (when (= :analyze status) - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click on-cancel}]) - - (when (= status :import-ready) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.continue") - :disabled pending-analysis? - :on-click on-continue}]) - - (when (or (= :import-success status) - (= :import-error status) - (= :import-progress status)) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.accept") - :disabled (= :import-progress status) - :on-click on-accept}])]]]])) + nil)]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 6550c84c2a..6866eb381f 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -4,252 +4,210 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend %modal-overlay-base; + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend %modal-container-base; - + position: relative; display: flex; flex-direction: column; -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.uppercase-title-typography; - - color: var(--modal-title-foreground-color); -} - -.modal-close-btn { - @extend %modal-close-btn-base; + gap: var(--sp-xxxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-block-size: $sz-192; + inline-size: $sz-512; + max-block-size: px2rem(800); } .modal-content { - @include deprecated.body-small-typography; - flex: 1; overflow: hidden auto; display: grid; grid-template-columns: 1fr; - gap: deprecated.$s-16; - margin-bottom: deprecated.$s-24; - min-height: 40px; + gap: var(--sp-l); + margin-block-end: var(--sp-xxl); + min-block-size: px2rem(40); +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: px2rem(6); } .status-message { - @include deprecated.body-small-typography; + @include use-typography("body-small"); - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); font-style: italic; } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - -.modal-scd-msg, -.modal-subtitle, -.modal-msg { - @include deprecated.body-small-typography; - - color: var(--modal-text-foreground-color); - line-height: 1.5; + display: flex; + justify-content: flex-end; + gap: var(--sp-l); } .file-entry { + --file-entry-fg-color: var(--color-foreground-secondary); + display: flex; - - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-16; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.icon-fill svg { - fill: var(--icon-foreground); - } - } - - .file-name-edit { - @extend %input-element; - @include deprecated.body-small-typography; - - flex-grow: 1; - } - - .file-name-label { - @include deprecated.body-small-typography; - - display: flex; - align-items: center; - gap: deprecated.$s-12; - flex-grow: 1; - - .icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--icon-foreground); - } - } - } - - .edit-entry-buttons { - @include deprecated.flex-row; - - button { - @extend %button-tertiary; - - width: deprecated.$s-28; - height: deprecated.$s-32; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - } - } - } - - .error-message, - .progress-message { - display: flex; - align-items: center; - min-height: deprecated.$s-32; - color: var(--modal-text-foreground-color); - } - - .error-message { - align-items: flex-start; - white-space: pre-wrap; - overflow-wrap: anywhere; - } - - .linked-library { - display: flex; - align-items: center; - gap: deprecated.$s-12; - color: var(--modal-text-foreground-color); - - .linked-library-tag { - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - - svg { - @extend %button-icon; - - stroke: var(--icon-foreground); - } - - &.error { - svg { - stroke: var(--element-foreground-error); - } - } - } - } - - &.loading { - .file-name { - color: var(--modal-text-foreground-color); - } - } - - &.warning { - .file-name { - color: var(--element-foreground-warning); - - .file-icon svg { - stroke: var(--element-foreground-warning); - } - - .file-icon.icon-fill svg { - fill: var(--element-foreground-warning); - } - } - } + flex-direction: column; + gap: var(--sp-m); &.success { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } - } + --file-entry-fg-color: var(--color-accent-sucess); } &.error { - .file-name { - color: var(--modal-text-foreground-color); - - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } - - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } - } + --file-entry-fg-color: var(--color-accent-error); } &.editable { - .file-name { - color: var(--modal-text-foreground-color); + --file-entry-fg-color: var(--color-foreground-primary); + } +} - .file-icon svg { - stroke: var(--modal-text-foreground-color); - } +.error-message, +.progress-message { + display: flex; + align-items: center; + min-block-size: $sz-32; + color: var(--file-entry-fg-color); +} - .file-icon.icon-fill svg { - fill: var(--modal-text-foreground-color); - } +.error-message { + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.linked-library { + display: flex; + align-items: center; + gap: var(--sp-m); + color: var(--file-entry-fg-color); +} + +.linked-library-tag { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-24; + inline-size: $sz-24; + + svg { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-16; + inline-size: $sz-16; + color: transparent; + fill: none; + stroke-width: 1px; + stroke: var(--file-entry-fg-color); + } + + &.error { + svg { + stroke: var(--element-foreground-error); } } } +.file-name { + display: flex; + align-items: center; + gap: var(--sp-l); + color: var(--file-entry-fg-color); +} + +.edit-entry-buttons { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.file-name-edit { + display: flex; + align-items: center; + block-size: $sz-32; + border: $b-1 solid var(--color-background-tertiary); + color: var(--file-entry-fg-color); + flex-grow: 1; + position: relative; + border-radius: $br-4; + background-color: var(--color-background-tertiary); +} + +.file-name-label { + display: flex; + align-items: center; + gap: var(--sp-m); + flex-grow: 1; +} + +.file-label-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-16; + inline-size: $sz-16; + color: var(--file-entry-fg-color); +} + +.file-name-input { + @include use-typography("body-medium"); + + --edit-input-background-color: var(--color-background-tertiary); + --edit-input-border-color: transparent; + + block-size: $sz-32; + inline-size: 100%; + padding: px2rem(6); + margin: 0; + border-radius: $br-8; + border: $b-1 solid var(--edit-input-border-color); + color: var(--color-foreground-primary); + background-color: var(--edit-input-background-color); + + &:focus-visible { + --edit-input-background-color: var(--color-background-primary); + --edit-input-border-color: var(--color-accent-primary); + + outline: none; + } +} + .context-notification-error { - --context-notification-bg-color: var(--modal-background-color); + --context-notification-bg-color: var(--color-background-primary); +} + +.file-name-notification { + flex-grow: 1; } .import-error-disclaimer { @@ -266,10 +224,252 @@ } .import-error-detail { - @include deprecated.body-small-typography; - - margin-top: var(--sp-xs); - color: var(--modal-text-foreground-color); + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.4; + margin-block-start: var(--sp-xs); + color: var(--color-foreground-secondary); white-space: pre-wrap; overflow-wrap: anywhere; } + +// ################################ +// LIBRARY RESOLUTION +// ################################ + +.library-resolution { + display: flex; + flex-direction: column; + gap: var(--sp-m); +} + +.library-resolution-message { + color: var(--color-foreground-secondary); + margin-block-end: var(--sp-s); +} + +.library-resolution-header { + @include use-typography("body-medium"); + + color: var(--color-foreground-secondary); + display: grid; + grid-template-columns: 1fr 32px 1fr; + border-block-end: $b-1 solid var(--color-foreground-secondary); +} + +.library-origin-name, +.library-resolution-connection { + block-size: $sz-32; + text-align: start; + color: var(--color-foreground-primary); + display: flex; + align-items: center; +} + +.library-resolution-item { + display: grid; + grid-template-columns: 1fr 32px 1fr; + block-size: $sz-32; + margin-block: var(--sp-s); +} + +.library-resolution-icon { + display: flex; + justify-content: center; + align-items: center; + margin-inline-end: var(--sp-s); + color: var(--color-foreground-secondary); +} + +.library-resolution-arrow { + color: var(--color-foreground-secondary); + display: flex; + justify-content: center; + align-items: center; + min-block-size: $sz-32; +} + +.library-resolution-body { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.library-resolution-item-name { + @include use-typography("body-medium"); + + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-s); + color: var(--color-foreground-secondary); + padding-inline-start: var(--sp-s); +} + +.library-resolution-no-selection { + display: flex; + align-items: center; + color: var(--color-foreground-secondary); + padding: var(--sp-s); + margin-inline-start: var(--sp-xxs); + block-size: $sz-32; +} + +// ################################ +// Summary file card +// ################################ + +.summary-file { + display: flex; + flex-direction: column; + gap: var(--sp-s); + padding: var(--sp-m); + border-radius: $br-8; + background: var(--color-background-primary); +} + +.summary-list { + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + list-style: none; + padding: 0; + margin: 0; + border-inline-start: $b-1 solid var(--color-background-quaternary); +} + +.summary-file-header { + display: flex; + align-items: center; + gap: var(--sp-s); + padding-block-end: var(--sp-s); + border-bottom: $b-1 solid var(--color-background-quaternary); +} + +.summary-section-title { + color: var(--color-foreground-primary); +} + +.summary-linked-arrow { + color: var(--color-foreground-secondary); +} + +.summary-file-icon { + color: var(--color-foreground-secondary); + flex-shrink: 0; +} + +.summary-file-name { + color: var(--color-foreground-primary); +} + +// Section within a file (auto-linked or user selection) +.summary-section { + display: flex; + flex-direction: column; + gap: var(--sp-xs); + padding-inline-start: var(--sp-s); +} + +.summary-section-header { + display: flex; + align-items: center; + gap: px2rem(6); + padding: var(--sp-xs) 0; +} + +.summary-list-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-s); + padding: var(--sp-xs) var(--sp-s); + border-radius: $br-4; + + &:hover { + background: var(--color-background-secondary); + } +} + +.summary-item-name { + color: var(--color-foreground-primary); + flex: 1; + min-inline-size: 0; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.summary-item-name-header { + color: var(--color-foreground-secondary); + flex: 1; + min-inline-size: 0; + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// Auto-linked badge +.summary-linked-badge { + display: inline-flex; + align-items: center; + gap: var(--sp-xs); + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-12; + background: var(--color-accent-success-bg); + color: var(--color-accent-success); + font-size: px2rem(11); + font-weight: 500; + flex-shrink: 0; +} + +.summary-badge-icon { + color: var(--color-accent-success); +} + +// User-selected library info +.summary-linked-info { + display: flex; + align-items: center; + gap: var(--sp-xs); + flex-shrink: 0; +} + +.summary-linked-name { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + line-height: 1.4; + color: var(--color-foreground-secondary); + font-weight: 500; +} + +.summary-linked-project { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-weight: 400; + line-height: 1.4; + color: var(--color-foreground-secondary); + padding: var(--sp-xxs) var(--sp-s); + border-radius: $br-12; + background: var(--color-background-quaternary); + font-size: px2rem(11); +} + +// No selection state +.summary-no-selection { + // TODO: Typography does not match any existing DS typography token. + font-family: "worksans", "vazirmatn", sans-serif; + font-size: px2rem(12); + font-weight: 400; + line-height: 1.4; + color: var(--color-foreground-secondary); + font-style: italic; + flex-shrink: 0; +} diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index cea8878201..3d7a65b9a3 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -13,9 +13,11 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.timers :as timers] [clojure.string :as str] [rumext.v2 :as mf] [rumext.v2.util :as mfu])) @@ -58,11 +60,12 @@ [:empty-to-end {:optional true} [:maybe :boolean]] [:on-change {:optional true} fn?] [:dropdown-alignment {:optional true} [:maybe [:enum :left :right]]] - [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]]]) + [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]] + [:has-portal {:optional true} :boolean]]) (mf/defc select* {::mf/schema schema:select} - [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment] :rest props}] + [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment has-portal] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle ;; options provide as clojure data structures or javascript ;; plain objects and lists. @@ -88,6 +91,9 @@ options-ref (mf/use-ref nil) select-ref (mf/use-ref nil) + container (hooks/use-portal-container :popup) + dropdown-wrapper-ref (mf/use-ref nil) + empty-selected-id? (str/blank? selected-id) @@ -208,10 +214,63 @@ (reset! selected-id* (get-selected-option-id options default-selected))) + ;; Portal mode: click-outside + floating positioning + (mf/with-effect [is-open has-portal] + (when (and is-open has-portal) + (let [handler + (fn [event] + (let [wrapper-node (mf/ref-val select-ref) + dropdown-node (mf/ref-val dropdown-wrapper-ref) + target (dom/get-target event)] + (when (and wrapper-node dropdown-node + (not (dom/child? target wrapper-node)) + (not (dom/child? target dropdown-node))) + (reset! is-open* false) + (reset! focused-id* nil)))) + + calculate + (fn [] + (timers/raf + (fn [] + (when-let [select-node (mf/ref-val select-ref)] + (when-let [dropdown-node (mf/ref-val dropdown-wrapper-ref)] + (let [select-rect (dom/get-bounding-rect select-node) + dropdown-rect (dom/get-bounding-rect dropdown-node) + window-height (.-innerHeight js/window) + space-below (- window-height (:bottom select-rect)) + open-up? (> (:height dropdown-rect) space-below)] + (if open-up? + (let [bottom (+ (- window-height (:top select-rect)) 4)] + (dom/set-css-property! dropdown-node "top" "unset") + (dom/set-css-property! dropdown-node "bottom" (str bottom "px"))) + (let [top (+ (:bottom select-rect) 4)] + (dom/set-css-property! dropdown-node "bottom" "unset") + (dom/set-css-property! dropdown-node "top" (str top "px")))) + (dom/set-css-property! dropdown-node "left" (str (:left select-rect) "px")) + (dom/set-css-property! dropdown-node "width" (str (:width select-rect) "px")) + (dom/set-css-property! dropdown-node "position" "fixed")))))))] + + (.addEventListener js/document "mousedown" handler) + + (let [ro (js/ResizeObserver. (fn [_] (calculate)))] + (when-let [node (mf/ref-val select-ref)] + (.observe ro node)) + + (.addEventListener js/window "resize" calculate) + (.addEventListener js/window "scroll" calculate true) + + (calculate) + + (fn [] + (.removeEventListener js/document "mousedown" handler) + (.disconnect ro) + (.removeEventListener js/window "resize" calculate) + (.removeEventListener js/window "scroll" calculate true)))))) + [:div {:class [wrapper-class (stl/css :select-wrapper)] :on-click on-click :ref select-ref - :on-blur on-blur} + :on-blur (when-not has-portal on-blur)} [:> :button props [:span {:class (stl/css-case :select-header true @@ -241,11 +300,24 @@ :aria-hidden true}]] (when ^boolean is-open - [:> options-dropdown* {:on-click on-option-click - :id listbox-id - :options options - :selected selected-id - :focused focused-id - :align dropdown-alignment - :empty-to-end empty-to-end - :ref set-option-ref}])])) + (if has-portal + (mf/portal + (mf/html + [:> options-dropdown* {:on-click on-option-click + :id listbox-id + :options options + :selected selected-id + :focused focused-id + :align dropdown-alignment + :empty-to-end empty-to-end + :ref set-option-ref + :wrapper-ref dropdown-wrapper-ref}]) + container) + [:> options-dropdown* {:on-click on-option-click + :id listbox-id + :options options + :selected selected-id + :focused focused-id + :align dropdown-alignment + :empty-to-end empty-to-end + :ref set-option-ref}]))])) diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index cd8fe2465c..05621603ee 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -13,8 +13,14 @@ [app.main.data.exports.files :as fexp] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.product.loader :refer [loader*]] - [app.main.ui.icons :as deprecated-icon] + [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -41,28 +47,33 @@ [files] (let [files (mapv (fn [file] (assoc file :loading true)) files)] {:status :prepare - :selected :all + :selected :include-libraries :files files})) (mf/defc export-entry* {::mf/private true} [{:keys [file]}] - [:div {:class (stl/css-case - :file-entry true - :loading (:loading file) - :success (:export-success? file) - :error (:export-error? file))} + (let [level (cond + (:export-success? file) :success + (:export-error? file) :error + :else :info)] + [:div {:class (stl/css-case + :file-entry true + :loading (:loading file) + :success (:export-success? file) + :error (:export-error? file))} - [:div {:class (stl/css :file-name)} - (if (:loading file) - [:> loader* {:width 16 - :title (tr "labels.loading")}] - [:span {:class (stl/css :file-icon)} - (cond (:export-success? file) deprecated-icon/tick - (:export-error? file) deprecated-icon/close)]) + (if (:loading file) + [:div {:class (stl/css :file-name)} + [:> loader* {:width 26 + :title (tr "labels.loading")}] + [:> text* {:class (stl/css :file-name-label) + :as "span" + :typography t/body-large} + (:name file)]] - [:div {:class (stl/css :file-name-label)} - (:name file)]]]) + [:> context-notification {:level level + :content (:name file)}])])) (mf/defc export-dialog {::mf/register modal/components @@ -109,6 +120,7 @@ (let [type (-> (dom/get-target event) (dom/get-data "type") (keyword))] + (prn "AAA" selected type) (swap! state* assoc :selected type))))] (mf/with-effect [has-libs?] @@ -119,38 +131,59 @@ [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} + [:> heading* {:level 2 + :typography t/headline-large + :class (stl/css :modal-title)} (tr "files-download-modal.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click on-cancel} deprecated-icon/close]] - + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-cancel + :class (stl/css :modal-close-btn) + :icon i/close}]] (cond (= status :prepare) [:* [:div {:class (stl/css :modal-content)} - [:p {:class (stl/css :modal-msg)} (tr "files-download-modal.description-1")] - [:p {:class (stl/css :modal-scd-msg)} (tr "files-download-modal.description-2")] + ;; TODO: Add translation + [:> text* {:as "p" :typography t/body-large :class (stl/css :modal-msg)} + "What do you want to do with linked libraries?"] (for [type fexp/valid-types] [:div {:class (stl/css :export-option true) :key (name type)} [:label {:for (str "export-" type) - :class (stl/css-case :global/checked (= selected type))} + :class (stl/css :export-option-label)} ;; Execution time translation strings: - ;; (tr "files-download-modal.options.all.message") - ;; (tr "files-download-modal.options.all.title") - ;; (tr "files-download-modal.options.detach.message") - ;; (tr "files-download-modal.options.detach.title") - ;; (tr "files-download-modal.options.merge.message") - ;; (tr "files-download-modal.options.merge.title") - [:span {:class (stl/css-case :global/checked (= selected type))} + ;; (tr "files-export-modal.options.include-libraries.title") + ;; (tr "files-export-modal.options.include-libraries.message") + + ;; (tr "files-export-modal.options.merge-libraries.title") + ;; (tr "files-export-modal.options.merge-libraries.message") + + ;; (tr "files-export-modal.options.detach-libraries.title") + ;; (tr "files-export-modal.options.detach-libraries.message") + + ;; (tr "files-export-modal.options.link-later.title") + ;; (tr "files-export-modal.options.link-later.message") + + [:span {:class (stl/css-case + :option-icon-wrapper true + :checked (= selected type))} (when (= selected type) - deprecated-icon/status-tick)] + [:svg {:class (stl/css :option-icon) + :viewBox "0 0 8 8" + :width 8 + :height 8 + :aria-hidden true} + [:circle {:cx 4 :cy 4 :r 4}]])] + [:div {:class (stl/css :option-content)} - [:h3 {:class (stl/css :modal-subtitle)} - (tr (dm/str "files-download-modal.options." (d/name type) ".title"))] - [:p {:class (stl/css :modal-msg)} - (tr (dm/str "files-download-modal.options." (d/name type) ".message"))]] + [:> heading* {:level 3 + :typography t/body-large + :class (stl/css :option-title)} + (tr (dm/str "files-export-modal.options." (d/name type) ".title"))] + [:> text* {:as "p" :typography t/body-large :class (stl/css :modal-msg)} + (tr (dm/str "files-export-modal.options." (d/name type) ".message"))]] [:input {:type "radio" :class (stl/css :option-input) @@ -162,15 +195,15 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :cancel-button) - :type "button" - :value (tr "labels.cancel") - :on-click on-cancel}] + [:> button* {:variant "secondary" + :type "button" + :on-click on-cancel} + (tr "labels.cancel")] - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.continue") - :on-click on-accept}]]]] + [:> button* {:variant "primary" + :type "button" + :on-click on-accept} + (tr "labels.continue")]]]] (= status :exporting) (let [in-progress? (->> state :files (some :loading))] @@ -180,15 +213,15 @@ [:> export-entry* {:file file :key (dm/str (:id file))}]) (when in-progress? - [:div {:class (stl/css :status-message) - :role "status" - :aria-live "polite"} + [:> text* {:as "span" :typography t/body-large :class (stl/css :status-message) + :role "status" + :aria-live "polite"} (tr "labels.downloading-file")])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.close") - :disabled in-progress? - :on-click on-cancel}]]]]))]])) + [:> button* {:variant "primary" + :type "button" + :disabled in-progress? + :on-click on-cancel} + (tr "labels.close")]]]]))]])) diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index ad4da9b955..62cc1deeab 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -4,289 +4,205 @@ // // Copyright (c) KALEIDOS SUBSIDIARY SL -@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; // EXPORT MODAL .modal-overlay { - @extend %modal-overlay-base; - - &.transparent { - background-color: transparent; - } + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-set); + background-color: var(--overlay-color); } .modal-container { - @extend %modal-container-base; - - max-height: calc(10 * deprecated.$s-80); -} - -.modal-header { - margin-bottom: deprecated.$s-24; -} - -.modal-title { - @include deprecated.headline-medium-typography; - - color: var(--modal-title-foreground-color); -} - -.modal-close-btn { - @extend %modal-close-btn-base; + position: relative; + display: flex; + flex-direction: column; + gap: var(--sp-xxxl); + padding: var(--sp-xxxl); + border-radius: $br-8; + background-color: var(--color-background-primary); + border: $b-2 solid var(--color-background-quaternary); + min-block-size: $sz-192; + inline-size: $sz-512; + max-block-size: calc(10 * px2rem(80)); } .modal-content { - @include deprecated.body-small-typography; + display: flex; + flex-direction: column; + gap: var(--sp-l); + margin-block-end: var(--sp-xxl); +} - margin-bottom: deprecated.$s-24; +.modal-content-extended { + gap: var(--sp-xxl); +} - .modal-link { - @include deprecated.body-large-typography; +.modal-title { + color: var(--color-foreground-primary); +} - text-decoration: none; - cursor: pointer; - color: var(--modal-link-foreground-color); - } +.modal-close-btn { + position: absolute; + inset-block-start: var(--sp-m); + inset-inline-end: var(--sp-m); +} - .selection-header { - @include deprecated.flex-row; +.modal-msg { + color: var(--color-foreground-secondary); + margin: 0; +} - height: deprecated.$s-32; - margin-bottom: deprecated.$s-4; - - .selection-btn { - @include deprecated.button-style; - @extend %input-checkbox; - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - padding: 0; - margin-left: deprecated.$s-16; - - span { - @extend %checkbox-icon; - } - } - - .selection-title { - @include deprecated.body-large-typography; - - color: var(--modal-text-foreground-color); - } - } - - .selection-wrapper { - position: relative; - width: 100%; - height: fit-content; - } - - .selection-shadow { - width: 100%; - height: 100%; - - &::after { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 50px; - background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); - content: ""; - pointer-events: none; - } - } - - .selection-list { - @include deprecated.flex-column; - - max-height: deprecated.$s-400; - overflow-y: auto; - padding-bottom: deprecated.$s-12; - - .selection-row { - @include deprecated.flex-row; - - background-color: var(--entry-background-color); - min-height: deprecated.$s-40; - border-radius: deprecated.$br-8; - - .selection-btn { - @include deprecated.button-style; - - display: grid; - grid-template-columns: min-content auto 1fr auto auto; - align-items: center; - width: 100%; - height: 10%; - gap: deprecated.$s-8; - padding: 0 deprecated.$s-16; - - .checkbox-wrapper { - @extend %input-checkbox; - @include deprecated.flex-center; - - height: deprecated.$s-24; - width: deprecated.$s-24; - padding: 0; - - .checkobox-tick { - @extend %checkbox-icon; - } - } - - .selection-name { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - flex-grow: 1; - color: var(--modal-text-foreground-color); - text-align: start; - } - - .selection-scale { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-108; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); - } - - .selection-extension { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - - min-width: deprecated.$s-72; - padding: deprecated.$s-12; - color: var(--modal-text-foreground-color); - } - } - - .image-wrapper { - @include deprecated.flex-center; - - min-height: deprecated.$s-32; - min-width: deprecated.$s-32; - background-color: var(--app-white); - border-radius: deprecated.$br-6; - margin: auto 0; - - img, - svg { - object-fit: contain; - max-height: deprecated.$s-40; - } - } - } - } +.option-content { + display: flex; + flex-direction: column; } .status-message { - @include deprecated.body-small-typography; - - color: var(--modal-title-foreground-color); + color: var(--color-foreground-primary); font-style: italic; } .action-buttons { - @extend %modal-action-btns; -} - -.cancel-button { - @extend %modal-cancel-btn; -} - -.accept-btn { - @extend %modal-accept-btn; - - &.danger { - @extend %modal-danger-btn; - } -} - -.modal-scd-msg, -.modal-subtitle, -.modal-msg { - @include deprecated.body-large-typography; - - color: var(--modal-text-foreground-color); + display: flex; + justify-content: flex-end; + gap: var(--sp-s); } .export-option { - @extend %input-checkbox; - - width: 100%; + display: flex; align-items: flex-start; + inline-size: 100%; +} - label { - align-items: flex-start; +.export-option-label { + --input-border-color: var(--input-checkbox-border-color-rest); + --input-icon-color: var(--color-background-primary); - .modal-subtitle { - @include deprecated.body-large-typography; + display: flex; + align-items: flex-start; + gap: px2rem(6); + cursor: pointer; + color: var(--color-foreground-primary); - color: var(--modal-title-foreground-color); - padding: 0.25rem 0; - } + &:hover { + --input-border-color: var(--color-accent-primary-muted); } - span { - margin-top: deprecated.$s-8; + &:focus, + &:focus-within { + --input-border-color: var(--color-accent-primary); } } -.option-content { - @include deprecated.flex-column; - @include deprecated.body-large-typography; +.option-icon-wrapper { + --icon-display: none; + --background-color: var(--color-background-quaternary); + + display: flex; + justify-content: center; + align-items: center; + inline-size: px2rem(16); + min-inline-size: px2rem(16); + block-size: px2rem(16); + margin-block-start: px2rem(10); + background-color: var(--background-color); + border: px2rem(1) solid var(--input-border-color); + border-radius: $br-circle; + + &.checked { + --icon-display: block; + --input-border-color: var(--color-background-quaternary); + --input-icon-color: var(--color-background-primary); + --background-color: var(--color-accent-primary); + } + + &:hover { + --input-border-color: var(--color-accent-primary-muted); + } + + &:focus { + --input-border-color: var(--color-accent-primary); + } +} + +.option-icon { + inline-size: px2rem(8); + block-size: px2rem(8); + display: var(--icon-display); + fill: var(--input-icon-color); +} + +.option-input { + margin: 0; } .file-entry { - .file-name { - @include deprecated.flex-row; - - .file-icon { - @include deprecated.flex-center; - - height: deprecated.$s-16; - width: deprecated.$s-16; - - svg { - @extend %button-icon-small; - - stroke: var(--input-foreground); - } - } - - .file-name-label { - @include deprecated.body-large-typography; - @include deprecated.text-ellipsis; - } - } + --file-entry-color: var(--color-foreground-secondary); &.loading { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); } } &.error { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); .file-icon svg { - stroke: var(--modal-text-foreground-color); + stroke: var(--color-foreground-secondary); } } } &.success { .file-name { - color: var(--modal-text-foreground-color); + color: var(--color-foreground-secondary); .file-icon svg { - stroke: var(--modal-text-foreground-color); + stroke: var(--color-foreground-secondary); } } } } + +.file-name { + display: flex; + align-items: center; + gap: var(--sp-m); + + .file-icon { + display: flex; + justify-content: center; + align-items: center; + block-size: px2rem(16); + inline-size: px2rem(16); + color: var(--color-foreground-secondary); + } + + .file-name-label { + display: block; + max-inline-size: 99%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.icon-status-tick { + fill: none; + stroke: var(--color-accent-primary); +} diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 92d9f359e8..74d2c141ca 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -258,7 +258,13 @@ (fn [format type] (js/Promise. (fn [resolve reject] - (let [type (or (parser/parse-keyword type) :all)] + (let [type (or (parser/parse-keyword type) :all) + ;; Backward compatibility: convert old values to new + type (case type + :all :include-libraries + :merge :merge-libraries + :detach :detach-libraries + type)] (cond (and (some? format) (not (contains? #{"penpot" "zip"} format))) (u/reject-not-valid reject :format (dm/str "Invalid format: " format)) diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 564b537278..e8310efa89 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -171,8 +171,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) @@ -203,40 +204,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 b85b0f4cc0..2be53cb160 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -761,6 +761,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" @@ -2122,34 +2151,70 @@ msgid "files-download-modal.description-2" msgstr "* Might include components, graphics, colors and/or typographies." #: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.options.all.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.message" msgstr "" -"Files with shared libraries will be included in the export, maintaining " -"their linkage." + +#: src/app/main/ui/exports/files.cljs:140 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Files with linked libraries will be included in the export, maintaining " +"their linkage. " #: src/app/main/ui/exports/files.cljs:141 -msgid "files-download-modal.options.all.title" -msgstr "Export shared libraries" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.title" +msgstr "Export file + libraries" + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Export file + libraries" #: src/app/main/ui/exports/files.cljs:142 -msgid "files-download-modal.options.detach.message" -msgstr "" -"Shared libraries will not be included in the export and no assets will be " -"added to the library. " +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.message" +msgstr "Linked library assets won't be included in the file." + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "Linked library assets won't be included in the file." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.title" +msgstr "Link matching libraries on import" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.message" +msgstr "When imported, you'll be able to link existing libraries with matching names." #: src/app/main/ui/exports/files.cljs:143 -msgid "files-download-modal.options.detach.title" -msgstr "Treat shared library assets as basic objects" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.title" +msgstr "Treat assets as basic objects" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Treat assets as basic objects" #: src/app/main/ui/exports/files.cljs:144 -msgid "files-download-modal.options.merge.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.message" msgstr "" -"Your file will be exported with all external assets merged into the file " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Your file will be exported with library asset merged into the local " "library." #: src/app/main/ui/exports/files.cljs:145 -msgid "files-download-modal.options.merge.title" -msgstr "Include shared library assets in file libraries" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.title" +msgstr "Embed library assets in the file" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-export-modal.options.merge-libraries.title" +msgstr "Embed library assets in the file" #: src/app/main/ui/exports/files.cljs:123 msgid "files-download-modal.title" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index acfeeb8fab..897a41f317 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -765,6 +765,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" @@ -2066,33 +2095,70 @@ msgid "files-download-modal.description-2" msgstr "* Pueden incluir components, gráficos, colores y/o tipografias." #: src/app/main/ui/exports/files.cljs:140 -msgid "files-download-modal.options.all.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.message" msgstr "" -"Ficheros con librerias compartidas se inclurán en el paquete de exportación " + +#: src/app/main/ui/exports/files.cljs:140 +msgid "files-export-modal.options.include-libraries.message" +msgstr "" +"Los ficheros con librerias compartidas se inclurán en el paquete de exportación " "y mantendrán los enlaces." #: src/app/main/ui/exports/files.cljs:141 -msgid "files-download-modal.options.all.title" -msgstr "Exportar librerias compartidas" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.all.title" +msgstr "Exportar archivo + librerías" + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-export-modal.options.include-libraries.title" +msgstr "Exportar archivo + librerías" #: src/app/main/ui/exports/files.cljs:142 -msgid "files-download-modal.options.detach.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.message" msgstr "" -"Las biblioteca compartidas no se incluirán en la exportación y ningún " -"recurso será incluido en la biblioteca. " + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-export-modal.options.detach-libraries.message" +msgstr "" +"Las recursos de las bibliotecas compartidas no se incluirán en la exportación." + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.title" +msgstr "Vincular librerías al importar" + +#: src/app/main/ui/exports/files.cljs +msgid "files-export-modal.options.link-later.message" +msgstr "Al importar, podrás vincular bibliotecas existentes con el mismo nombre." #: src/app/main/ui/exports/files.cljs:143 -msgid "files-download-modal.options.detach.title" -msgstr "Usar los recursos como objetos básicos" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.detach.title" +msgstr "Tratar los recursos como objetos básicos" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-export-modal.options.detach-libraries.title" +msgstr "Tratar los recursos como objetos básicos" #: src/app/main/ui/exports/files.cljs:144 -msgid "files-download-modal.options.merge.message" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.message" msgstr "" -"Tu fichero será exportado con todos los recursos dentro de la libreria del " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-export-modal.options.merge-libraries.message" +msgstr "" +"Tu fichero será exportado con todos los recursos externos dentro de la libreria del " "propio fichero." #: src/app/main/ui/exports/files.cljs:145 -msgid "files-download-modal.options.merge.title" +# DEPRECATED: use the equivalent "-libraries" key +msgid "files-export-modal.options.merge.title" +msgstr "Incluir librerias compartidas dentro de las librerias del fichero" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-export-modal.options.merge-libraries.title" msgstr "Incluir librerias compartidas dentro de las librerias del fichero" #: src/app/main/ui/exports/files.cljs:123 diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index abb4e981c1..83a0c9ec66 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -1646,19 +1646,24 @@ export interface File extends PluginData { * - `'penpot'` will create a *.penpot file with a binary representation of the file * - `'zip'` will create a *.zip with the file exported in several SVG files with some JSON metadata * @param `libraryExportType` indicates what to do with the linked libraries of the file when - * exporting it. Defaults to `all` if not sent. - * - `'all'` will include the libraries as external files that will be exported in a single bundle - * - `'merge'` will add all the assets into the main file and only one file will be imported - * - `'detach'` will unlink all the external assets and no libraries will be imported + * exporting it. Defaults to `'include-libraries'` if not sent. + * - `'include-libraries'` will include the libraries as external files that will be exported in a single bundle + * - `'merge-libraries'` will add all the assets into the main file and only one file will be imported + * - `'detach-libraries'` will unlink all the external assets and no libraries will be imported + * - `'link-later'` will preserve component metadata so instances can be relinked on import * * @example * ```js - * const exportedData = await file.export('penpot', 'all'); + * const exportedData = await file.export('penpot', 'include-libraries'); * ``` */ export( exportType: 'penpot' | 'zip', - libraryExportType?: 'all' | 'merge' | 'detach', + libraryExportType?: + | 'include-libraries' + | 'merge-libraries' + | 'detach-libraries' + | 'link-later', ): Promise; /**