♻️ 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 (defn get-resolved-file-libraries
"Get all file libraries including itself. Returns an instance of "Get all file libraries including itself. Returns an instance of
LoadableWeakValueMap that allows do not have strong references to LoadableWeakValueMap that allows do not have strong references to
the loaded libraries and reduce possible memory pressure on having the loaded libraries and reduce memory pressure on having
all this libraries loaded at same time on processing file validation all this libraries at the same time on processing file validation
or file migration. or file migration.
This still requires at least one library at time to be loaded while This still requires at least one library at time to be loaded while
@ -888,3 +888,47 @@
(cons (:id file))) (cons (:id file)))
load-fn #(get-file cfg % :migrate? false)] load-fn #(get-file cfg % :migrate? false)]
(weak/loadable-weak-value-map library-ids load-fn {id file}))) (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} [:relations {:optional true}
[:vector [: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 (def ^:private schema:storage-object
[:map {:title "StorageObject"} [:map {:title "StorageObject"}
@ -217,14 +226,12 @@
(.flush writer)) (.flush writer))
(.closeEntry output)) (.closeEntry output))
(defn- get-file (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) (let [detach? (= export-type :detach-libraries)
(throw (IllegalArgumentException. embed? (= export-type :merge-libraries)]
"the `include-libraries` and `embed-assets` are mutally excluding options")))
(let [detach? (and (not embed-assets) (not include-libraries))]
(db/tx-run! cfg (fn [cfg] (db/tx-run! cfg (fn [cfg]
(cond-> (bfc/get-file cfg file-id (cond-> (bfc/get-file cfg file-id
{:realize? true {:realize? true
@ -234,7 +241,7 @@
(-> (ctf/detach-external-references file-id) (-> (ctf/detach-external-references file-id)
(dissoc :libraries)) (dissoc :libraries))
embed-assets embed?
(update :data #(bfc/embed-assets cfg % file-id)) (update :data #(bfc/embed-assets cfg % file-id))
:always :always
@ -371,12 +378,34 @@
(write-entry! output path encoded-tokens))))) (write-entry! output path encoded-tokens)))))
(defn- export-files (defn- export-files
[{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}] [{:keys [::bfc/ids ::bfc/export-type ::output] :as cfg}]
(let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
rels (if include-libraries (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) (->> (bfc/get-files-rels cfg ids)
(mapv (juxt :file-id :library-file-id))) (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)) (vswap! bfc/*state* assoc :files (d/ordered-map))
@ -389,12 +418,14 @@
;; Write manifest file ;; Write manifest file
(let [files (:files @bfc/*state*) (let [files (:files @bfc/*state*)
params {:type "penpot/export-files" params (cond-> {:type "penpot/export-files"
:version 1 :version 1
:generated-by (str "penpot/" (:full cf/version)) :generated-by (str "penpot/" (:full cf/version))
:referer "penpot" :referer "penpot"
:files (vec (vals files)) :files (vec (vals files))
:relations rels}] :relations rels}
(seq external-libs)
(assoc :external-libraries external-libs))]
(write-entry! output "manifest.json" params)))) (write-entry! output "manifest.json" params))))
;; --- IMPORT IMPL ;; --- IMPORT IMPL
@ -882,6 +913,104 @@
(vswap! bfc/*state* update :index assoc id (:id sobject))))))) (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* (defn- import-files*
[{:keys [::manifest] :as cfg}] [{:keys [::manifest] :as cfg}]
(bfc/disable-database-timeouts! cfg) (bfc/disable-database-timeouts! cfg)
@ -890,18 +1019,37 @@
(import-storage-objects cfg) (import-storage-objects cfg)
(let [files (get manifest :files) ;; Pre-resolve external libraries and add their id mappings to the index
result (reduce (fn [result file] ;; BEFORE importing files. This allows relink-refs (inside process-file)
(let [name' (get file :name) ;; to correctly remap :component-file references to the destination library.
file (assoc file :name name')] ;; Only remap when a link will actually be created (single match + can-edit).
(conj result (import-file cfg file)))) (let [decisions (compute-link-decisions cfg)]
[] (doseq [[old-lib-id {:keys [library-id]}] decisions]
files)] (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) (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* (defn- import-file-and-overwrite*
[{:keys [::manifest ::bfc/file-id] :as cfg}] [{:keys [::manifest ::bfc/file-id] :as cfg}]
@ -929,7 +1077,8 @@
(bfc/invalidate-thumbnails cfg file-id) (bfc/invalidate-thumbnails cfg file-id)
(bfm/apply-pending-migrations! cfg) (bfm/apply-pending-migrations! cfg)
[file-id]))) {:file-ids [file-id]
:resolution {}})))
(defn- import-files (defn- import-files
[{:keys [::bfc/timestamp ::bfc/input] :or {timestamp (ct/now)} :as cfg}] [{: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 "Do the exportation of a specified file in custom penpot binary
format. There are some options available for customize the output: format. There are some options available for customize the output:
`::bfc/include-libraries`: additionally to the specified file, all the `::bfc/export-type`: determines how linked libraries are handled.
linked libraries also will be included (including transitive Valid values: `:include-libraries` (include linked libraries),
dependencies). `:merge-libraries` (embed library assets in the file),
`:detach-libraries` (treat assets as basic objects),
`::bfc/embed-assets`: instead of including the libraries, embed in the `:link-later` (preserve component metadata for relinking on import)."
same file library all assets used from external libraries."
[{:keys [::bfc/ids] :as cfg} output] [{:keys [::bfc/ids] :as cfg} output]
@ -998,6 +1146,7 @@
tp (ct/tpoint) tp (ct/tpoint)
ab (volatile! false) ab (volatile! false)
cs (volatile! nil)] cs (volatile! nil)]
(try (try
(l/info :hint "start exportation" :export-id (str id)) (l/info :hint "start exportation" :export-id (str id))
(binding [bfc/*state* (volatile! (bfc/initial-state))] (binding [bfc/*state* (volatile! (bfc/initial-state))]

View File

@ -42,17 +42,24 @@
schema:export-binfile schema:export-binfile
[:map {:title "export-binfile"} [:map {:title "export-binfile"}
[:file-id ::sm/uuid] [:file-id ::sm/uuid]
[:include-libraries ::sm/boolean] [:type {:optional true} [::sm/one-of #{:include-libraries :merge-libraries :detach-libraries :link-later}]]
[:embed-assets ::sm/boolean]]) [:include-libraries {:optional true} ::sm/boolean]
[:embed-assets {:optional true} ::sm/boolean]])
(defn- export-binfile (defn- export-binfile
[{:keys [::sto/storage] :as cfg} {:keys [file-id include-libraries embed-assets]}] [{:keys [::sto/storage] :as cfg} {:keys [type file-id include-libraries embed-assets]}]
(let [output (tmp/tempfile*)] (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 (try
(-> cfg (-> cfg
(assoc ::bfc/ids #{file-id}) (assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets embed-assets) (assoc ::bfc/export-type export-type)
(assoc ::bfc/include-libraries include-libraries)
(bf.v3/export-files! output)) (bf.v3/export-files! output))
(let [data (sto/content output) (let [data (sto/content output)
@ -73,7 +80,8 @@
(sv/defmethod ::export-binfile (sv/defmethod ::export-binfile
"Export a penpot file in a binary format." "Export a penpot file in a binary format."
{::doc/added "1.15" {::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 ::webhooks/event? true
::sm/params schema:export-binfile} ::sm/params schema:export-binfile}
[cfg {:keys [::rpc/profile-id file-id] :as params}] [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/features (cfeat/get-team-enabled-features cf/flags team))
(assoc ::bfc/project-id project-id) (assoc ::bfc/project-id project-id)
(assoc ::bfc/profile-id profile-id) (assoc ::bfc/profile-id profile-id)
(assoc ::bfc/team-id (:id team))
(assoc ::bfc/name name)) (assoc ::bfc/name name))
input-path (:path file) 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 FROM base AS setup-opencode
ENV OPENCODE_VERSION=1.18.19 ENV OPENCODE_VERSION=1.18.21
RUN set -ex; \ RUN set -ex; \
ARCH="$(dpkg --print-architecture)"; \ ARCH="$(dpkg --print-architecture)"; \

View File

@ -21,8 +21,8 @@
:exclusions [funcool/beicon2]} :exclusions [funcool/beicon2]}
funcool/beicon2 funcool/beicon2
{:git/tag "v2.2" {:git/tag "v2.3"
:git/sha "8744c66" :git/sha "df7058a"
:git/url "https://github.com/funcool/beicon.git"} :git/url "https://github.com/funcool/beicon.git"}
funcool/rumext funcool/rumext

View File

@ -17,18 +17,21 @@
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
(def valid-types (def valid-types
(d/ordered-set :all :merge :detach)) (d/ordered-set :include-libraries :merge-libraries :detach-libraries :link-later))
(def valid-formats (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 (def ^:private schema:export-files
[:sequential {:title "Files"} [:sequential {:title "Files"} schema:export-file-param])
[:map {:title "FileParam"}
[:id ::sm/uuid]
[:name :string]
[:project-id ::sm/uuid]
[:is-shared ::sm/boolean]]])
(def check-export-files (def check-export-files
(sm/check-fn schema:export-files)) (sm/check-fn schema:export-files))
@ -57,14 +60,17 @@
:files files})))))))))) :files files}))))))))))
(defn export-files (defn export-files
"Start files exportation process"
[& {:keys [type files]}] [& {: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/from files)
(rx/mapcat (rx/mapcat
(fn [file] (fn [file]
(->> (rp/cmd! ::sse/export-binfile {:file-id (:id file) (->> (rp/cmd! ::sse/export-binfile {:file-id (:id file)
:version 3 :version 3
:include-libraries (= type :all) :type type})
:embed-assets (= type :merge)})
(rx/filter sse/end-of-stream?) (rx/filter sse/end-of-stream?)
(rx/map sse/get-payload) (rx/map sse/get-payload)
(rx/map (fn [uri] (rx/map (fn [uri]

View File

@ -15,11 +15,20 @@
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.repo :as rp]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader]] [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.ds.product.loader :refer [loader*]]
[app.main.ui.icons :as deprecated-icon] [app.main.ui.icons :as deprecated-icon]
[app.main.ui.notifications.context-notification :refer [context-notification]]
[app.main.worker :as mw] [app.main.worker :as mw]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
@ -54,7 +63,7 @@
{::mf/forward-ref true} {::mf/forward-ref true}
[{:keys [project-id on-finish-import]} external-ref] [{:keys [project-id on-finish-import]} external-ref]
(let [on-file-selected (use-import-file project-id on-finish-import)] (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" [:& file-uploader {:accept ".penpot,.zip"
:multi true :multi true
:ref external-ref :ref external-ref
@ -156,6 +165,19 @@
(and (= :import-ready (:status item)) (and (= :import-ready (:status item))
(not (:deleted 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 (defn- analyze-entries
[state entries] [state entries]
(let [features (get @st/state :features)] (let [features (get @st/state :features)]
@ -173,7 +195,7 @@
(swap! state update-with-analyze-result message)))))) (swap! state update-with-analyze-result message))))))
(defn- import-files (defn- import-files
[state project-id entries] [state library-resolution-data* project-id entries]
(st/emit! (ev/event {::ev/name "import-files" (st/emit! (ev/event {::ev/name "import-files"
:num-files (count entries)})) :num-files (count entries)}))
@ -183,27 +205,40 @@
:project-id project-id :project-id project-id
:files entries :files entries
:features features}) :features features})
(rx/filter (comp uuid? :file-id)) (rx/filter some?)
(rx/subs! (rx/subs!
(fn [message] (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/defc import-entry*
{::mf/memo true {::mf/memo true
::mf/private 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) (let [status (:status entry)
;; FIXME: rename to format ;; FIXME: rename to format
format (:type entry) format (:type entry)
loading? (or (= :analyze status) loading? (or (= :analyze status)
(= :import-progress status) (= :import-progress status)
(and importing? (= :import-ready status))) (and is-progress (= :import-ready status)))
analyze-error? (= :analyze-error status) analyze-error? (= :analyze-error status)
import-success? (= :import-success status) import-success? (= :import-success status)
import-error? (= :import-error status) import-error? (= :import-error status)
import-ready? (= :import-ready 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) is-shared? (:shared entry)
progress (:progress entry) progress (:progress entry)
@ -251,46 +286,65 @@
:editable (and import-ready? (not editing?)))} :editable (and import-ready? (not editing?)))}
[:div {:class (stl/css :file-name)} [:div {:class (stl/css :file-name)}
(if loading? (when loading? [:> loader* {:width 26 :title (tr "labels.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)])
(if editing? (if editing?
[:div {:class (stl/css :file-name-edit)} [:div {:class (stl/css :file-name-edit)}
[:input {:type "text" [:input {:type "text"
:auto-focus true :auto-focus true
:class (stl/css :file-name-input)
;;TODO: Add translation for aria-label
:aria-label "File name"
:default-value (:name entry) :default-value (:name entry)
:on-key-press on-edit-key-press :on-key-press on-edit-key-press
:on-blur on-edit-blur}]] :on-blur on-edit-blur}]]
[:div {:class (stl/css :file-name-label)} [:div {:class (stl/css :file-name-label)}
(:name entry) (if loading?
(when ^boolean is-shared? [:> text* {:class (stl/css :file-name-label)
[:span {:class (stl/css :icon)} :as "span"
deprecated-icon/library])]) :typography t/body-medium}
(:name entry)
[:div {:class (stl/css :edit-entry-buttons)} (when ^boolean is-shared?
(when ^boolean editable? [:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]
[:button {:on-click on-edit'} deprecated-icon/curve]) [:> context-notification*
(when ^boolean can-be-deleted {:level level
[:button {:on-click on-delete'} deprecated-icon/delete])]] :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 (cond
analyze-error? analyze-error?
[:div {:class (stl/css :error-message)} [:> text* {:class (stl/css :error-message)
:as "span"
:typography t/body-small}
(if (some? (:error entry)) (if (some? (:error entry))
(tr (:error entry)) (tr (:error entry))
(tr "dashboard.import.analyze-error"))] (tr "dashboard.import.analyze-error"))]
import-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)) (if (some? (:error entry))
(tr (:error entry)) (tr (:error entry))
(tr "labels.error"))] (tr "labels.error"))]
@ -318,6 +372,327 @@
(fn [] (fn []
(mapv #(assoc % :status :analyze) entries))) (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/defc import-dialog
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :import ::mf/register-as :import
@ -329,14 +704,49 @@
;; Revoke all uri's on commonent unmount ;; Revoke all uri's on commonent unmount
(fn [] (run! wapi/revoke-uri (map :uri entries)))) (fn [] (run! wapi/revoke-uri (map :uri entries))))
(let [state* (mf/use-state (initialize-state entries)) (let [state* (mf/use-state (initialize-state entries))
entries (deref state*) entries (deref state*)
status* (mf/use-state :analyze) status* (mf/use-state :analyze)
status (deref status*) status (deref status*)
edition* (mf/use-state nil) edition* (mf/use-state nil)
edition (deref edition*) 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 continue-entries
(mf/use-fn (mf/use-fn
@ -344,7 +754,7 @@
(fn [] (fn []
(let [entries (filterv has-status-ready? entries)] (let [entries (filterv has-status-ready? entries)]
(reset! status* :import-progress) (reset! status* :import-progress)
(import-files state* project-id entries)))) (import-files state* resolution* project-id entries))))
continue-template continue-template
(mf/use-fn (mf/use-fn
@ -407,6 +817,52 @@
(continue-template template) (continue-template template)
(continue-entries)))) (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 on-accept
(mf/use-fn (mf/use-fn
(mf/deps on-finish-import) (mf/deps on-finish-import)
@ -432,9 +888,25 @@
(zero? (count entries)))) (zero? (count entries))))
pending-analysis? 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 (cond
(some? template) (some? template)
(reset! status* :import-ready) (reset! status* :import-ready)
@ -445,8 +917,11 @@
(and (seq entries) (and (seq entries)
(every? #(= :import-success (:status %)) 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 (seq entries)
(and (every? #(not= :import-ready (:status %)) entries) (and (every? #(not= :import-ready (:status %)) entries)
(some #(= :import-error (:status %)) entries))) (some #(= :import-error (:status %)) entries)))
@ -460,99 +935,50 @@
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)} [: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) (case status
:on-click on-cancel} deprecated-icon/close]] (: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)} :library-resolution
(when (and (= :analyze status) errors?) [:> import-library-resolution-stage*
[:& context-notification {:current-unresolved-file current-unresolved-file
{:level :warning :selection selection
:class (stl/css :context-notification-error) :on-select manage-on-select
:content (tr "dashboard.import.import-warning")}]) :visited visited
:all-visited? all-visited?
:on-wizard-prev on-wizard-prev
:on-wizard-next on-wizard-next}]
(when (= :import-success status) :library-summary
[:& context-notification [:> import-library-summary-stage*
{:level (if (zero? import-success-total) :warning :success) {:resolution resolution
:content (tr "dashboard.import.import-message" (i18n/c import-success-total))}]) :selection selection
:visited visited
:on-summary-back on-summary-back
:on-confirm-library-links on-confirm-library-links}]
(when (= :import-error status) nil)]]))
[:& 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}])]]]]))

View File

@ -4,252 +4,210 @@
// //
// Copyright (c) KALEIDOS SUBSIDIARY SL // 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 { .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 { .modal-container {
@extend %modal-container-base; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} gap: var(--sp-xxxl);
padding: var(--sp-xxxl);
.modal-header { border-radius: $br-8;
margin-bottom: deprecated.$s-24; background-color: var(--color-background-primary);
} border: $b-2 solid var(--color-background-quaternary);
min-block-size: $sz-192;
.modal-title { inline-size: $sz-512;
@include deprecated.uppercase-title-typography; max-block-size: px2rem(800);
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend %modal-close-btn-base;
} }
.modal-content { .modal-content {
@include deprecated.body-small-typography;
flex: 1; flex: 1;
overflow: hidden auto; overflow: hidden auto;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: deprecated.$s-16; gap: var(--sp-l);
margin-bottom: deprecated.$s-24; margin-block-end: var(--sp-xxl);
min-height: 40px; 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 { .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; font-style: italic;
} }
.action-buttons { .action-buttons {
@extend %modal-action-btns; display: flex;
} justify-content: flex-end;
gap: var(--sp-l);
.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;
} }
.file-entry { .file-entry {
--file-entry-fg-color: var(--color-foreground-secondary);
display: flex; display: flex;
flex-direction: column;
.file-name { gap: var(--sp-m);
@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);
}
}
}
&.success { &.success {
.file-name { --file-entry-fg-color: var(--color-accent-sucess);
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);
}
}
} }
&.error { &.error {
.file-name { --file-entry-fg-color: var(--color-accent-error);
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);
}
}
} }
&.editable { &.editable {
.file-name { --file-entry-fg-color: var(--color-foreground-primary);
color: var(--modal-text-foreground-color); }
}
.file-icon svg { .error-message,
stroke: var(--modal-text-foreground-color); .progress-message {
} display: flex;
align-items: center;
min-block-size: $sz-32;
color: var(--file-entry-fg-color);
}
.file-icon.icon-fill svg { .error-message {
fill: var(--modal-text-foreground-color); 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-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 { .import-error-disclaimer {
@ -266,10 +224,252 @@
} }
.import-error-detail { .import-error-detail {
@include deprecated.body-small-typography; // TODO: Typography does not match any existing DS typography token.
font-family: "worksans", "vazirmatn", sans-serif;
margin-top: var(--sp-xs); font-size: px2rem(12);
color: var(--modal-text-foreground-color); font-weight: 400;
line-height: 1.4;
margin-block-start: var(--sp-xs);
color: var(--color-foreground-secondary);
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; 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.controls.shared.options-dropdown :refer [options-dropdown* schema:option]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]]
[app.main.ui.hooks :as hooks]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.keyboard :as kbd] [app.util.keyboard :as kbd]
[app.util.object :as obj] [app.util.object :as obj]
[app.util.timers :as timers]
[clojure.string :as str] [clojure.string :as str]
[rumext.v2 :as mf] [rumext.v2 :as mf]
[rumext.v2.util :as mfu])) [rumext.v2.util :as mfu]))
@ -58,11 +60,12 @@
[:empty-to-end {:optional true} [:maybe :boolean]] [:empty-to-end {:optional true} [:maybe :boolean]]
[:on-change {:optional true} fn?] [:on-change {:optional true} fn?]
[:dropdown-alignment {:optional true} [:maybe [:enum :left :right]]] [: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/defc select*
{::mf/schema schema: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 (let [;; NOTE: we use mfu/bean here for transparently handle
;; options provide as clojure data structures or javascript ;; options provide as clojure data structures or javascript
;; plain objects and lists. ;; plain objects and lists.
@ -88,6 +91,9 @@
options-ref (mf/use-ref nil) options-ref (mf/use-ref nil)
select-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? empty-selected-id?
(str/blank? selected-id) (str/blank? selected-id)
@ -208,10 +214,63 @@
(reset! selected-id* (reset! selected-id*
(get-selected-option-id options default-selected))) (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)] [:div {:class [wrapper-class (stl/css :select-wrapper)]
:on-click on-click :on-click on-click
:ref select-ref :ref select-ref
:on-blur on-blur} :on-blur (when-not has-portal on-blur)}
[:> :button props [:> :button props
[:span {:class (stl/css-case :select-header true [:span {:class (stl/css-case :select-header true
@ -241,11 +300,24 @@
:aria-hidden true}]] :aria-hidden true}]]
(when ^boolean is-open (when ^boolean is-open
[:> options-dropdown* {:on-click on-option-click (if has-portal
:id listbox-id (mf/portal
:options options (mf/html
:selected selected-id [:> options-dropdown* {:on-click on-option-click
:focused focused-id :id listbox-id
:align dropdown-alignment :options options
:empty-to-end empty-to-end :selected selected-id
:ref set-option-ref}])])) :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.exports.files :as fexp]
[app.main.data.modal :as modal] [app.main.data.modal :as modal]
[app.main.store :as st] [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.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.dom :as dom]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
@ -41,28 +47,33 @@
[files] [files]
(let [files (mapv (fn [file] (assoc file :loading true)) files)] (let [files (mapv (fn [file] (assoc file :loading true)) files)]
{:status :prepare {:status :prepare
:selected :all :selected :include-libraries
:files files})) :files files}))
(mf/defc export-entry* (mf/defc export-entry*
{::mf/private true} {::mf/private true}
[{:keys [file]}] [{:keys [file]}]
[:div {:class (stl/css-case (let [level (cond
:file-entry true (:export-success? file) :success
:loading (:loading file) (:export-error? file) :error
:success (:export-success? file) :else :info)]
:error (:export-error? file))} [: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)
(if (:loading file) [:div {:class (stl/css :file-name)}
[:> loader* {:width 16 [:> loader* {:width 26
:title (tr "labels.loading")}] :title (tr "labels.loading")}]
[:span {:class (stl/css :file-icon)} [:> text* {:class (stl/css :file-name-label)
(cond (:export-success? file) deprecated-icon/tick :as "span"
(:export-error? file) deprecated-icon/close)]) :typography t/body-large}
(:name file)]]
[:div {:class (stl/css :file-name-label)} [:> context-notification {:level level
(:name file)]]]) :content (:name file)}])]))
(mf/defc export-dialog (mf/defc export-dialog
{::mf/register modal/components {::mf/register modal/components
@ -109,6 +120,7 @@
(let [type (-> (dom/get-target event) (let [type (-> (dom/get-target event)
(dom/get-data "type") (dom/get-data "type")
(keyword))] (keyword))]
(prn "AAA" selected type)
(swap! state* assoc :selected type))))] (swap! state* assoc :selected type))))]
(mf/with-effect [has-libs?] (mf/with-effect [has-libs?]
@ -119,38 +131,59 @@
[:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)} [:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)} [: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")] (tr "files-download-modal.title")]
[:button {:class (stl/css :modal-close-btn) [:> icon-button* {:variant "ghost"
:on-click on-cancel} deprecated-icon/close]] :aria-label (tr "labels.close")
:on-click on-cancel
:class (stl/css :modal-close-btn)
:icon i/close}]]
(cond (cond
(= status :prepare) (= status :prepare)
[:* [:*
[:div {:class (stl/css :modal-content)} [:div {:class (stl/css :modal-content)}
[:p {:class (stl/css :modal-msg)} (tr "files-download-modal.description-1")] ;; TODO: Add translation
[:p {:class (stl/css :modal-scd-msg)} (tr "files-download-modal.description-2")] [:> 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] (for [type fexp/valid-types]
[:div {:class (stl/css :export-option true) [:div {:class (stl/css :export-option true)
:key (name type)} :key (name type)}
[:label {:for (str "export-" type) [:label {:for (str "export-" type)
:class (stl/css-case :global/checked (= selected type))} :class (stl/css :export-option-label)}
;; Execution time translation strings: ;; Execution time translation strings:
;; (tr "files-download-modal.options.all.message") ;; (tr "files-export-modal.options.include-libraries.title")
;; (tr "files-download-modal.options.all.title") ;; (tr "files-export-modal.options.include-libraries.message")
;; (tr "files-download-modal.options.detach.message")
;; (tr "files-download-modal.options.detach.title") ;; (tr "files-export-modal.options.merge-libraries.title")
;; (tr "files-download-modal.options.merge.message") ;; (tr "files-export-modal.options.merge-libraries.message")
;; (tr "files-download-modal.options.merge.title")
[:span {:class (stl/css-case :global/checked (= selected type))} ;; (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) (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)} [:div {:class (stl/css :option-content)}
[:h3 {:class (stl/css :modal-subtitle)} [:> heading* {:level 3
(tr (dm/str "files-download-modal.options." (d/name type) ".title"))] :typography t/body-large
[:p {:class (stl/css :modal-msg)} :class (stl/css :option-title)}
(tr (dm/str "files-download-modal.options." (d/name type) ".message"))]] (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" [:input {:type "radio"
:class (stl/css :option-input) :class (stl/css :option-input)
@ -162,15 +195,15 @@
[:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :action-buttons)}
[:input {:class (stl/css :cancel-button) [:> button* {:variant "secondary"
:type "button" :type "button"
:value (tr "labels.cancel") :on-click on-cancel}
:on-click on-cancel}] (tr "labels.cancel")]
[:input {:class (stl/css :accept-btn) [:> button* {:variant "primary"
:type "button" :type "button"
:value (tr "labels.continue") :on-click on-accept}
:on-click on-accept}]]]] (tr "labels.continue")]]]]
(= status :exporting) (= status :exporting)
(let [in-progress? (->> state :files (some :loading))] (let [in-progress? (->> state :files (some :loading))]
@ -180,15 +213,15 @@
[:> export-entry* {:file file :key (dm/str (:id file))}]) [:> export-entry* {:file file :key (dm/str (:id file))}])
(when in-progress? (when in-progress?
[:div {:class (stl/css :status-message) [:> text* {:as "span" :typography t/body-large :class (stl/css :status-message)
:role "status" :role "status"
:aria-live "polite"} :aria-live "polite"}
(tr "labels.downloading-file")])] (tr "labels.downloading-file")])]
[:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :modal-footer)}
[:div {:class (stl/css :action-buttons)} [:div {:class (stl/css :action-buttons)}
[:input {:class (stl/css :accept-btn) [:> button* {:variant "primary"
:type "button" :type "button"
:value (tr "labels.close") :disabled in-progress?
:disabled in-progress? :on-click on-cancel}
:on-click on-cancel}]]]]))]])) (tr "labels.close")]]]]))]]))

View File

@ -4,289 +4,205 @@
// //
// Copyright (c) KALEIDOS SUBSIDIARY SL // 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 // EXPORT MODAL
.modal-overlay { .modal-overlay {
@extend %modal-overlay-base; display: flex;
justify-content: center;
&.transparent { align-items: center;
background-color: transparent; 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 { .modal-container {
@extend %modal-container-base; position: relative;
display: flex;
max-height: calc(10 * deprecated.$s-80); flex-direction: column;
} gap: var(--sp-xxxl);
padding: var(--sp-xxxl);
.modal-header { border-radius: $br-8;
margin-bottom: deprecated.$s-24; background-color: var(--color-background-primary);
} border: $b-2 solid var(--color-background-quaternary);
min-block-size: $sz-192;
.modal-title { inline-size: $sz-512;
@include deprecated.headline-medium-typography; max-block-size: calc(10 * px2rem(80));
color: var(--modal-title-foreground-color);
}
.modal-close-btn {
@extend %modal-close-btn-base;
} }
.modal-content { .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 { .modal-title {
@include deprecated.body-large-typography; color: var(--color-foreground-primary);
}
text-decoration: none; .modal-close-btn {
cursor: pointer; position: absolute;
color: var(--modal-link-foreground-color); inset-block-start: var(--sp-m);
} inset-inline-end: var(--sp-m);
}
.selection-header { .modal-msg {
@include deprecated.flex-row; color: var(--color-foreground-secondary);
margin: 0;
}
height: deprecated.$s-32; .option-content {
margin-bottom: deprecated.$s-4; display: flex;
flex-direction: column;
.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;
}
}
}
}
} }
.status-message { .status-message {
@include deprecated.body-small-typography; color: var(--color-foreground-primary);
color: var(--modal-title-foreground-color);
font-style: italic; font-style: italic;
} }
.action-buttons { .action-buttons {
@extend %modal-action-btns; display: flex;
} justify-content: flex-end;
gap: var(--sp-s);
.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);
} }
.export-option { .export-option {
@extend %input-checkbox; display: flex;
width: 100%;
align-items: flex-start; align-items: flex-start;
inline-size: 100%;
}
label { .export-option-label {
align-items: flex-start; --input-border-color: var(--input-checkbox-border-color-rest);
--input-icon-color: var(--color-background-primary);
.modal-subtitle { display: flex;
@include deprecated.body-large-typography; align-items: flex-start;
gap: px2rem(6);
cursor: pointer;
color: var(--color-foreground-primary);
color: var(--modal-title-foreground-color); &:hover {
padding: 0.25rem 0; --input-border-color: var(--color-accent-primary-muted);
}
} }
span { &:focus,
margin-top: deprecated.$s-8; &:focus-within {
--input-border-color: var(--color-accent-primary);
} }
} }
.option-content { .option-icon-wrapper {
@include deprecated.flex-column; --icon-display: none;
@include deprecated.body-large-typography; --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-entry {
.file-name { --file-entry-color: var(--color-foreground-secondary);
@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;
}
}
&.loading { &.loading {
.file-name { .file-name {
color: var(--modal-text-foreground-color); color: var(--color-foreground-secondary);
} }
} }
&.error { &.error {
.file-name { .file-name {
color: var(--modal-text-foreground-color); color: var(--color-foreground-secondary);
.file-icon svg { .file-icon svg {
stroke: var(--modal-text-foreground-color); stroke: var(--color-foreground-secondary);
} }
} }
} }
&.success { &.success {
.file-name { .file-name {
color: var(--modal-text-foreground-color); color: var(--color-foreground-secondary);
.file-icon svg { .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] (fn [format type]
(js/Promise. (js/Promise.
(fn [resolve reject] (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 (cond
(and (some? format) (not (contains? #{"penpot" "zip"} format))) (and (some? format) (not (contains? #{"penpot" "zip"} format)))
(u/reject-not-valid reject :format (dm/str "Invalid format: " format)) (u/reject-not-valid reject :format (dm/str "Invalid format: " format))

View File

@ -171,8 +171,9 @@
(defmethod impl/handler :import-files (defmethod impl/handler :import-files
[{:keys [project-id files]}] [{:keys [project-id files]}]
(let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files) (let [binfile-v1 (filter #(= :binfile-v1 (:type %)) files)
binfile-v3 (filter #(= :binfile-v3 (:type %)) files)] binfile-v3 (filter #(= :binfile-v3 (:type %)) files)
resolutions (volatile! {})]
(rx/merge (rx/merge
(->> (rx/from binfile-v1) (->> (rx/from binfile-v1)
@ -203,40 +204,50 @@
:error (import-cause-message cause (tr "labels.error")) :error (import-cause-message cause (tr "labels.error"))
:file-id (:file-id data)}))))))) :file-id (:file-id data)})))))))
(->> (rx/from binfile-v3)
(rx/reduce (fn [result file] (rx/concat
(update result (:uri file) (fnil conj []) file)) (->> (rx/from binfile-v3)
{}) (rx/reduce (fn [result file]
(rx/mapcat identity) (update result (:uri file) (fnil conj []) file))
(rx/merge-map {})
(fn [[uri entries]] (rx/mapcat identity)
(->> (import-blob-via-upload uri (rx/merge-map
{:name (-> entries first :name) (fn [[uri entries]]
:version 3 (->> (import-blob-via-upload uri
:project-id project-id}) {:name (-> entries first :name)
(rx/tap (fn [event] :version 3
(let [payload (sse/get-payload event) :project-id project-id})
type (sse/get-type event)] (rx/tap (fn [event]
(if (= type "progress") (let [payload (sse/get-payload event)
(log/dbg :hint "import-binfile: progress" type (sse/get-type event)]
:section (:section payload) (cond
:name (:name payload)) (= type "progress")
(log/dbg :hint "import-binfile: end"))))) (log/dbg :hint "import-binfile: progress"
(rx/filter sse/end-of-stream?) :section (:section payload)
(rx/mapcat (fn [_] :name (:name payload))
(->> (rx/from entries)
(rx/map (fn [entry] :else
{:status :finish (log/dbg :hint "import-binfile: end")))))
:file-id (:file-id entry)}))))) (rx/filter sse/end-of-stream?)
(rx/catch (rx/mapcat (fn [message]
(fn [cause] (let [{:keys [resolution]} (sse/get-payload message)]
(log/error :hint "unexpected error on import process" (when (seq resolution)
:project-id project-id (vswap! resolutions merge resolution))
::log/sync? true (->> (rx/from entries)
:cause cause) (rx/map (fn [entry]
(let [err (import-cause-message cause (tr "labels.error"))] {:status :finish
(->> (rx/from entries) :file-id (:file-id entry)}))))))
(rx/map (fn [entry] (rx/catch (fn [cause]
{:status :error (log/error :hint "import-binfile: unexpected error on importing"
:error err :project-id project-id
:file-id (:file-id entry)}))))))))))))) ::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" msgid "dashboard.import.import-warning"
msgstr "Some files containted invalid objects that have been removed." 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 #: src/app/main/ui/dashboard.cljs:260
msgid "dashboard.import.no-perms" msgid "dashboard.import.no-perms"
msgstr "You dont have permission to import to this team" 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." msgstr "* Might include components, graphics, colors and/or typographies."
#: src/app/main/ui/exports/files.cljs:140 #: 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 "" 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 #: src/app/main/ui/exports/files.cljs:141
msgid "files-download-modal.options.all.title" # DEPRECATED: use the equivalent "-libraries" key
msgstr "Export shared libraries" 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 #: src/app/main/ui/exports/files.cljs:142
msgid "files-download-modal.options.detach.message" # DEPRECATED: use the equivalent "-libraries" key
msgstr "" msgid "files-export-modal.options.detach.message"
"Shared libraries will not be included in the export and no assets will be " msgstr "Linked library assets won't be included in the file."
"added to the library. "
#: 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 #: src/app/main/ui/exports/files.cljs:143
msgid "files-download-modal.options.detach.title" # DEPRECATED: use the equivalent "-libraries" key
msgstr "Treat shared library assets as basic objects" 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 #: 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 "" 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." "library."
#: src/app/main/ui/exports/files.cljs:145 #: src/app/main/ui/exports/files.cljs:145
msgid "files-download-modal.options.merge.title" # DEPRECATED: use the equivalent "-libraries" key
msgstr "Include shared library assets in file libraries" 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 #: src/app/main/ui/exports/files.cljs:123
msgid "files-download-modal.title" msgid "files-download-modal.title"

View File

@ -765,6 +765,35 @@ msgstr[1] "%s ficheros se han importado correctamente."
msgid "dashboard.import.import-warning" msgid "dashboard.import.import-warning"
msgstr "Algunos ficheros contenían objetos erroneos que no han sido importados." 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 #: src/app/main/ui/dashboard.cljs:260
msgid "dashboard.import.no-perms" msgid "dashboard.import.no-perms"
msgstr "No tienes permisos para importar en este equipo" 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." msgstr "* Pueden incluir components, gráficos, colores y/o tipografias."
#: src/app/main/ui/exports/files.cljs:140 #: 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 "" 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." "y mantendrán los enlaces."
#: src/app/main/ui/exports/files.cljs:141 #: src/app/main/ui/exports/files.cljs:141
msgid "files-download-modal.options.all.title" # DEPRECATED: use the equivalent "-libraries" key
msgstr "Exportar librerias compartidas" 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 #: 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 "" 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 #: src/app/main/ui/exports/files.cljs:143
msgid "files-download-modal.options.detach.title" # DEPRECATED: use the equivalent "-libraries" key
msgstr "Usar los recursos como objetos básicos" 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 #: 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 "" 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." "propio fichero."
#: src/app/main/ui/exports/files.cljs:145 #: 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" msgstr "Incluir librerias compartidas dentro de las librerias del fichero"
#: src/app/main/ui/exports/files.cljs:123 #: 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 * - `'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 * - `'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 * @param `libraryExportType` indicates what to do with the linked libraries of the file when
* exporting it. Defaults to `all` if not sent. * exporting it. Defaults to `'include-libraries'` if not sent.
* - `'all'` will include the libraries as external files that will be exported in a single bundle * - `'include-libraries'` 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 * - `'merge-libraries'` 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 * - `'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 * @example
* ```js * ```js
* const exportedData = await file.export('penpot', 'all'); * const exportedData = await file.export('penpot', 'include-libraries');
* ``` * ```
*/ */
export( export(
exportType: 'penpot' | 'zip', exportType: 'penpot' | 'zip',
libraryExportType?: 'all' | 'merge' | 'detach', libraryExportType?:
| 'include-libraries'
| 'merge-libraries'
| 'detach-libraries'
| 'link-later',
): Promise<Uint8Array>; ): Promise<Uint8Array>;
/** /**