♻️ Consolidate auto-link libraries with unified export-type and fix ref integrity (#9958)

*  Auto-link libraries during import based on slugified name

When a Penpot file is exported without bundled libraries and then
imported into a different environment, external library links are
broken because library UUIDs differ across environments.

This feature adds a heuristic to auto-relink libraries by matching
slugified library names against shared files in the target team:

- Export: embed external library metadata (id, name, slug, used-by)
  in the manifest when libraries are not included in the export.
- Import: resolve external libraries by slugifying shared file names
  in the destination team and matching against manifest slugs.
- Single match: auto-link silently (creates file-library-rel row).
- Multiple matches: emit SSE event so the frontend shows a selection
  dialog for the user to pick the correct library.
- No match: import continues without linking (current behavior).

Backend changes:
- Extended manifest schema with optional :external-libraries field
- Added slugify-name, get-files-names, get-shared-files-for-team,
  find-shared-files-by-slug helpers in app.binfile.common
- Threaded team-id into import cfg from RPC layer
- Added resolve-external-libraries and auto-link-libraries in v3
- Emit :library-candidates SSE event for multi-match cases

Frontend changes:
- Worker captures library-candidates SSE events and forwards them
- Import dialog shows auto-link notification and multi-match
  selection UI with select dropdowns
- Added link-files-to-library! RPC helper for user selections
- Added en/es translations for new UI strings

Closes #9263

Signed-off-by: Andrey Antukh <niwi@niwi.nz>

*  Add UI for the auto-link plumbing

* ♻️ 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 <niwi@niwi.nz>
Co-authored-by: Eva Marco <evamarcod@gmail.com>
This commit is contained in:
Andrey Antukh 2026-08-27 10:09:52 +02:00 committed by GitHub
parent 17befc1db9
commit 03cd3fa70f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 3399 additions and 774 deletions

View File

@ -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 %))))))

View File

@ -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))]

View File

@ -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)

File diff suppressed because it is too large Load Diff

View File

@ -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)"; \

View File

@ -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

View File

@ -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]

View File

@ -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)]]))

View File

@ -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;
}

View File

@ -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}]))]))

View File

@ -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")]]]]))]]))

View File

@ -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);
}

View File

@ -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))

View File

@ -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})))))))

View File

@ -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 dont 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"

View File

@ -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

View File

@ -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<Uint8Array>;
/**