Andrey Antukh 4b978767ea
🌐 Clean up en translations (#11853)
Drop 277 keys nothing references from en.po (verified against
frontend/src and common/src) and let sync propagate the
deletions to every locale. Clear all 10 fuzzy entries: fill
the 5 empty translations, keep the 4 valid ones, and drop the
duplicated max-quote-reached in favor of max-quota-reached
(the backend code stays, the UI maps it to the quota text).

Recover 22 used-but-missing keys with translations: the 19
shortcuts section/subsection labels plus connected-to,
pixel-grid-color and tokens.add-set. Make the rest
statically visible to rehash instead: :label fns on shortcut
commands, sections and subsections (one debug-only and one
colorpicker-local id exempt); case branches in place of
dm/str-built keys (export modal, text decoration and
transform, undo history with raw-key fallback); hoist
conditionals out of tr calls; pre-translate modal props and
role labels; replace the lone (i18n/tr ...) site with tr.
Turn static :error/code data into eager :error/fn calls in
the common schemas and the auth/password forms. Rename the
two keys containing spaces and point team leave at
max-quota-reached. Backend-driven keys stay dynamic by
design, declared with (tr ...) comments: the five
weak-password details, team and organization notifications.

Tooling: rehash also scans common/src and no longer treats
a missing -l as no locale; new clj-kondo tr-dynamic warning
flags non-literal tr args (lint scripts use --fail-level
error so it never fails CI); tr docstring states the
literal-only rule. Tests cover the shortcut label wiring,
the undo-history fallback and the :error/fn schemas.
Translations memory rewritten to match; es check word list
gains three entries.

Rebased onto develop: adopt the register field-error UX
(the weak-password declarations move onto the :options
code), keep develop's newer keys (connection-error,
account-locked, save-retrying, tokens-source strings) with
fresh references, and reword the shortcuts.cljs prose
comment so rehash does not invent a "literal" key.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-25 11:11:44 +02:00

1040 lines
38 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.dashboard.import
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.main.data.dashboard :as dd]
[app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.file-uploader :refer [file-uploader]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.checkbox :refer [checkbox*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.worker :as mw]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(log/set-level! :debug)
(def ^:const emit-delay 200)
(defn use-import-file
[project-id on-finish-import]
(mf/use-fn
(mf/deps project-id on-finish-import)
(fn [entries]
(let [entries (->> entries
(mapv (fn [file]
{:name (.-name file)
:uri (wapi/create-uri file)}))
(not-empty))]
(when entries
(st/emit! (modal/show
{:type :import
:project-id project-id
:entries entries
:on-finish-import on-finish-import})))))))
(mf/defc import-form*
{::mf/forward-ref true}
[{:keys [project-id on-finish-import]} external-ref]
(let [on-file-selected (use-import-file project-id on-finish-import)]
[:form {:aria-hidden "true"}
[:& file-uploader {:accept ".penpot,.zip"
:multi true
:ref external-ref
:on-selected on-file-selected}]]))
(defn- update-entry-name
[entries file-id new-name]
(mapv (fn [entry]
(let [new-name (str/trim new-name)]
(cond-> entry
(and (= (:file-id entry) file-id)
(not= "" new-name))
(assoc :name new-name))))
entries))
(defn- remove-entry
[entries file-id]
(mapv (fn [entry]
(cond-> entry
(= (:file-id entry) file-id)
(assoc :deleted true)))
entries))
(defn- update-with-analyze-result
[entries {:keys [file-id status] :as updated}]
(let [entries (filterv (comp uuid? :file-id) entries)
status (case status
:success :import-ready
:error :analyze-error)
updated (assoc updated :status status)]
(if (some #(= file-id (:file-id %)) entries)
(mapv (fn [entry]
(if (= (:file-id entry) file-id)
(merge entry updated)
entry))
entries)
(conj entries updated))))
(defn- update-entry-status
[entries message]
(mapv (fn [entry]
(if (= (:file-id entry) (:file-id message))
(let [status (case (:status message)
:progress :import-progress
:finish :import-success
:error :import-error)]
(-> entry
(assoc :progress (:progress message))
(assoc :status status)
(assoc :error (:error message))
(d/without-nils)))
entry))
entries))
(defn- parse-progress-message
[message]
(case (:type message)
:upload-data
(tr "dashboard.import.progress.upload-data" (:current message) (:total message))
:upload-media
(tr "dashboard.import.progress.upload-media" (:file message))
:process-page
(tr "dashboard.import.progress.process-page" (:file message))
:process-colors
(tr "dashboard.import.progress.process-colors")
:process-typographies
(tr "dashboard.import.progress.process-typographies")
:process-media
(tr "dashboard.import.progress.process-media")
:process-components
(tr "dashboard.import.progress.process-components")
:process-deleted-components
(tr "dashboard.import.progress.process-components")
""))
(defn- has-status-analyze?
[item]
(= (:status item) :analyze))
(defn- has-status-import-success?
[item]
(= (:status item) :import-success))
(defn- has-status-error?
[item]
(or (= (:status item) :import-error)
(= (:status item) :analyze-error)))
(defn- has-status-ready?
[item]
(and (= :import-ready (:status item))
(not (:deleted item))))
(defn- has-unresolved?
"Return true if a file-resolution has any :pending needing user choice."
[file-resolution]
(some? (seq (:pending file-resolution))))
(defn- count-auto-linked
"Count auto-linked libraries across all file resolutions."
[resolution]
(reduce-kv (fn [acc _ {:keys [done]}]
(+ acc (count done)))
0
resolution))
(defn- analyze-entries
[state entries]
(let [features (get @st/state :features)]
(->> (mw/ask-many!
{:cmd :analyze-import
:files entries
:features features})
(rx/mapcat #(rx/delay emit-delay (rx/of %)))
(rx/filter some?)
(rx/subs!
(fn [message]
(when (some? (:error message))
(st/emit! (ev/event {::ev/name "import-files-error"
:error (:error message)})))
(swap! state update-with-analyze-result message))))))
(defn- import-files
[state library-resolution-data* project-id entries]
(st/emit! (ev/event {::ev/name "import-files"
:num-files (count entries)}))
(let [features (get @st/state :features)]
(->> (mw/ask-many!
{:cmd :import-files
:project-id project-id
:files entries
:features features})
(rx/filter some?)
(rx/subs!
(fn [message]
;; Capture library-resolution data if present (same for all
;; entries from the same zip, so first one wins)
(if-let [resolution (-> (:libraries-resolution message)
(not-empty))]
(reset! library-resolution-data* resolution)
(swap! state update-entry-status message)))))))
(mf/defc import-entry*
{::mf/memo true
::mf/private true}
[{:keys [entries entry edition can-be-deleted is-progress on-edit on-change on-delete]}]
(let [status (:status entry)
;; FIXME: rename to format
format (:type entry)
loading? (or (= :analyze status)
(= :import-progress status)
(and is-progress (= :import-ready status)))
analyze-error? (= :analyze-error status)
import-success? (= :import-success status)
import-error? (= :import-error status)
import-ready? (= :import-ready status)
level (cond
import-success? :success
import-ready? :success
import-error? :error
analyze-error? :error
loading? nil
:else :default)
is-shared? (:shared entry)
progress (:progress entry)
file-id (:file-id entry)
editing? (and (some? file-id) (= edition file-id))
editable? (and (= :legacy-zip format)
(= status :import-ready))
on-edit-key-press
(mf/use-fn
(fn [event]
(when (or (kbd/enter? event)
(kbd/esc? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(dom/blur! (dom/get-target event)))))
on-edit-blur
(mf/use-fn
(mf/deps file-id on-change)
(fn [event]
(let [value (dom/get-target-val event)]
(on-change file-id value event))))
on-edit'
(mf/use-fn
(mf/deps file-id on-change)
(fn [event]
(when (fn? on-edit)
(on-edit file-id event))))
on-delete'
(mf/use-fn
(mf/deps file-id on-delete)
(fn [event]
(when (fn? on-delete)
(on-delete file-id event))))]
[:div {:class (stl/css-case
:file-entry true
:loading loading?
:success import-success?
:error (or import-error? analyze-error?)
:editable (and import-ready? (not editing?)))}
[:div {:class (stl/css :file-name)}
(when loading? [:> loader* {:width 26 :title (tr "labels.loading")}])
(if editing?
[:div {:class (stl/css :file-name-edit)}
[:input {:type "text"
:auto-focus true
:class (stl/css :file-name-input)
:aria-label (tr "dashboard.import.file-name-label")
:default-value (:name entry)
:on-key-press on-edit-key-press
:on-blur on-edit-blur}]]
[:div {:class (stl/css :file-name-label)}
(if loading?
[:> text* {:class (stl/css :file-name-label)
:as "span"
:typography t/body-medium}
(:name entry)
(when ^boolean is-shared?
[:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]
[:> context-notification*
{:level level
:appearance :ghost
:class (stl/css :file-name-notification)}
[:> text* {:class (stl/css :file-name-label)
:as "span"
:typography t/body-medium}
(:name entry)
(when ^boolean is-shared?
[:> icon* {:icon-id i/library :class (stl/css :file-label-icon)}])]])])
(when ^boolean (or editable? can-be-deleted)
[:div {:class (stl/css :edit-entry-buttons)}
(when ^boolean editable?
[:> icon-button* {:on-click on-edit'
:variant "ghost"
:icon-size "s"
:aria-label (tr "labels.edit")
:icon i/curve}])
(when ^boolean can-be-deleted
[:> icon-button* {:on-click on-delete'
:variant "ghost"
:icon-size "s"
:aria-label (tr "labels.delete")
:icon i/delete}])])]
(cond
analyze-error?
[:> text* {:class (stl/css :error-message)
:as "span"
:typography t/body-small}
;; backend-provided error key, dynamic by design
#_{:clj-kondo/ignore [:penpot/tr-dynamic]}
(if (some? (:error entry))
(tr (:error entry))
(tr "dashboard.import.analyze-error"))]
import-error?
[:> text* {:class (stl/css :error-message)
:as "span"
:typography t/body-small}
;; backend-provided error key, dynamic by design
#_{:clj-kondo/ignore [:penpot/tr-dynamic]}
(if (some? (:error entry))
(tr (:error entry))
(tr "labels.error"))]
(and (not import-success?) (some? progress))
[:div {:class (stl/css :progress-message)} (parse-progress-message progress)])
;; This is legacy code, will be removed when legacy-zip format is removed
[:div {:class (stl/css :linked-libraries)}
(for [library-id (:libraries entry)]
(let [library-data (d/seek #(= library-id (:file-id %)) entries)
error? (or (:deleted library-data)
(:import-error library-data))]
(when (some? library-data)
[:div {:class (stl/css :linked-library)
:key (dm/str library-id)}
(:name library-data)
[:span {:class (stl/css-case
:linked-library-tag true
:error error?)}
deprecated-icon/detach]])))]]))
(defn initialize-state
[entries]
(fn []
(mapv #(assoc % :status :analyze) entries)))
(defn- link-files-to-library!
"Call the link-file-to-library RPC for each file-id with the given
library-id. Returns an observable that completes when all links are done."
[file-ids library-id]
(->> (rx/from file-ids)
(rx/merge-map (fn [file-id]
(->> (rp/cmd! :link-file-to-library
{:file-id file-id
:library-id library-id})
(rx/catch (fn [cause]
(log/error :hint "failed to link library"
:file-id file-id
:library-id library-id
:cause cause)
(rx/of nil))))))))
(mf/defc library-resolution*
{::mf/private true}
[{:keys [unresolved-file selection on-select on-disconnect]}]
(let [file-id (:id unresolved-file)
candidates (:pending unresolved-file)
file-selection (get selection file-id)
on-select-file
(mf/use-fn
(mf/deps file-id on-select)
(partial on-select file-id))
on-disconnect-file
(mf/use-fn
(mf/deps file-id on-disconnect)
(partial on-disconnect file-id))
on-change-disconnected
(mf/use-fn
(mf/deps file-selection candidates on-select-file on-disconnect-file)
(fn [id]
(if (contains? file-selection id)
(on-disconnect-file id)
(let [{:keys [candidates]} (d/seek #(= id (:id %)) candidates)]
(when-let [first-c (first candidates)]
(on-select-file id (str (:id first-c))))))))]
;; Default each pending library to another file's choice for it, if any, else the first candidate.
(mf/with-effect [candidates file-id]
(doseq [{:keys [id candidates]} candidates]
(when-not (contains? file-selection id)
(let [other-choice (some #(get % id) (vals selection))
default-id (or other-choice (some-> candidates first :id str))]
(when default-id
(on-select-file id default-id))))))
[:div {:class (stl/css :library-resolution)}
[:div {:class (stl/css :library-resolution-file-header)}
[:> icon* {:icon-id i/document
:class (stl/css :library-resolution-file-icon)
:size "m"}]
[:> text* {:class (stl/css :library-resolution-file-name)
:as "span"
:typography t/body-large}
(:name unresolved-file)]]
[:> text* {:class (stl/css :library-resolution-message)
:as "p"
:typography t/body-large}
(tr "dashboard.import.resolve-libraries")]
[: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"}]
(tr "dashboard.import.resolve-libraries.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"}]
(tr "dashboard.import.resolve-libraries.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 file-selection id)
is-conected (contains? file-selection 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-file 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)
file-selection (get selection (:id 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)]]
[:div {:class (stl/css :summary-body)}
(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)}
[:> text* {:as "span"
:class (stl/css :summary-section-title)
:typography t/headline-small}
(tr "dashboard.import.summary.manually-linked")]]
[: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)}
(tr "dashboard.import.summary.original")]
[:span {:class (stl/css :summary-item-name-header)}
(tr "dashboard.import.summary.new")]]
(for [{:keys [id name] :as cand} pending]
(let [selected-id (get file-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-case :summary-item-name true
:summary-item-base true)} name]
(if selected-c
[:span {:class (stl/css :summary-linked-info)}
[:> icon* {:icon-id i/row
:size "m"
:class (stl/css :summary-linked-arrow)}]
[:span {:class (stl/css :summary-linked-name)}
(dm/str (:name selected-c) " (" (:project-name selected-c) ")")]]
[:span {:class (stl/css :summary-no-selection)}
[:> icon* {:icon-id i/row
:size "m"
:class (stl/css :summary-linked-arrow)}]
(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 on-disconnect
visited last-file?
on-wizard-prev on-wizard-next on-wizard-skip]}]
[:*
[:div {:class (stl/css :modal-content)}
[:> library-resolution*
{:unresolved-file current-unresolved-file
:selection selection
:on-select on-select
:on-disconnect on-disconnect}]]
[: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")])
[:div {:class (stl/css :action-buttons-end)}
[:> button* {:class (stl/css :secondary-btn)
:variant "secondary"
:on-click on-wizard-skip}
(tr "labels.skip")]
[:> button* {:class (stl/css :accept-btn)
:variant "primary"
:on-click on-wizard-next}
(if last-file?
(tr "dashboard.import.connect-selected-libraries")
(tr "dashboard.import.next-file"))]]]]])
(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")])
[:div {:class (stl/css :action-buttons-end)}
[:> button* {:class (stl/css :accept-btn)
:variant "primary"
:on-click on-confirm-library-links}
(tr "dashboard.import.confirm-library-links")]]]]])
(mf/defc import-dialog
{::mf/register modal/components
::mf/register-as :import
::mf/props :obj}
[{:keys [project-id entries template on-finish-import]}]
(mf/with-effect []
;; Revoke all uri's on commonent unmount
(fn [] (run! wapi/revoke-uri (map :uri entries))))
(let [state* (mf/use-state (initialize-state entries))
entries (deref state*)
status* (mf/use-state :analyze)
status (deref status*)
edition* (mf/use-state nil)
edition (deref edition*)
;; Library resolution data from the backend (auto-linked + multi-match)
resolution* (mf/use-state nil)
resolution (not-empty (deref resolution*))
;; Per-file selection for multi-match candidates: {file-id {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)))
;; 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))
;; True once every other unresolved file has already been visited.
last-unresolved-file?
(mf/with-memo [unresolved-files visited current-unresolved-file]
(when (some? current-unresolved-file)
(every? #(or (= (:id %) (:id current-unresolved-file))
(contains? visited (:id %)))
unresolved-files)))
continue-entries
(mf/use-fn
(mf/deps entries)
(fn []
(let [entries (filterv has-status-ready? entries)]
(reset! status* :import-progress)
(import-files state* resolution* project-id entries))))
continue-template
(mf/use-fn
(mf/deps on-finish-import)
(fn [template]
(let [on-success
(fn [_event]
(reset! status* :import-success)
(when (fn? on-finish-import)
(on-finish-import)))
on-error
(fn [cause]
(reset! status* :error)
(ex/print-throwable cause)
(rx/of (modal/hide)
(ntf/error (tr "dashboard.libraries-and-templates.import-error"))))
params
{:project-id project-id
:template-id (:id template)}]
(reset! status* :import-progress)
(st/emit! (dd/clone-template
(with-meta params
{:on-success on-success
:on-error on-error}))))))
on-edit
(mf/use-fn
(fn [file-id _event]
(reset! edition* file-id)))
on-entry-change
(mf/use-fn
(fn [file-id value]
(swap! edition* (constantly nil))
(swap! state* update-entry-name file-id value)))
on-entry-delete
(mf/use-fn
(fn [file-id]
(swap! state* remove-entry file-id)))
on-cancel
(mf/use-fn
(mf/deps edition)
(fn [event]
(when (nil? edition)
(dom/prevent-default event)
(st/emit! (modal/hide)))))
on-continue
(mf/use-fn
(mf/deps continue-template
continue-entries)
(fn [event]
(dom/prevent-default event)
(if (some? template)
(continue-template template)
(continue-entries))))
on-confirm-library-links
(mf/use-fn
(mf/deps resolution selection on-finish-import)
(fn [event]
(dom/prevent-default event)
(let [slc selection]
;; For each file with pending candidates, link it to the selected libraries
(->> (rx/from (seq resolution))
(rx/merge-map
(fn [[file-id resolution-file]]
(->> (rx/from (:pending resolution-file))
(rx/merge-map
(fn [{:keys [id]}]
(when-let [selected-lib (get-in slc [file-id 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 visited)
(fn []
;; Disj makes the last-visited file current again on next render.
(let [last-id (last visited)]
(swap! visited* disj last-id))))
on-wizard-skip
(mf/use-fn
(mf/deps current-unresolved-file)
(fn []
(let [file-id (:id current-unresolved-file)
pending-ids (mapv :id (:pending current-unresolved-file))]
(swap! selection* update file-id (fn [file-sel] (apply dissoc file-sel pending-ids)))
(swap! visited* conj file-id))))
on-summary-back
(mf/use-fn
(mf/deps visited)
(fn []
(let [last-id (last visited)]
(swap! visited* disj last-id)
(reset! status* :library-resolution))))
on-accept
(mf/use-fn
(mf/deps on-finish-import)
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide))
(when (fn? on-finish-import)
(on-finish-import))))
entries
(mf/with-memo [entries]
(filterv (complement :deleted) entries))
import-success-total
(if (some? template)
1
(count (filterv has-status-import-success? entries)))
errors?
(if (some? template)
(= status :error)
(or (some has-status-error? entries)
(zero? (count entries))))
pending-analysis?
(some has-status-analyze? entries)
auto-linked-count
(if (some? resolution)
(count-auto-linked resolution)
0)
manage-on-select
(mf/use-fn
(fn [file-id old-lib-id candidate-id]
(swap! selection* assoc-in [file-id old-lib-id] candidate-id)))
manage-on-disconnect
(mf/use-fn
(fn [file-id old-lib-id]
(swap! selection* update file-id (fnil dissoc {}) old-lib-id)))]
(mf/with-effect [visited unresolved-files]
(when (and (seq unresolved-files)
(every? #(contains? visited (:id %)) unresolved-files))
(reset! status* :library-summary)))
(mf/with-effect [entries resolution]
(cond
(some? template)
(reset! status* :import-ready)
(and (seq entries)
(every? #(= :import-ready (:status %)) entries))
(reset! status* :import-ready)
(and (seq entries)
(every? #(= :import-success (:status %)) entries))
(reset! status* (if (seq resolution)
(if (seq (filter has-unresolved? (vals resolution)))
:library-resolution
:library-summary)
:import-success))
(and (seq entries)
(and (every? #(not= :import-ready (:status %)) entries)
(some #(= :import-error (:status %)) entries)))
(reset! status* :import-error)))
;; Run analyze operation on component mount
(mf/with-effect []
(let [sub (analyze-entries state* entries)]
(partial rx/dispose! sub)))
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:> 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}]]
(case status
(:analyze :import-ready :import-progress :import-success :import-error)
[:> import-files-stage*
{:entries entries
:template template
:status status
:errors? errors?
:import-success-total import-success-total
:auto-linked-count auto-linked-count
:edition edition
:on-edit on-edit
:on-change on-entry-change
:on-delete on-entry-delete
:on-cancel on-cancel
:on-continue on-continue
:on-accept on-accept
:pending-analysis? pending-analysis?}]
:library-resolution
[:> import-library-resolution-stage*
{:current-unresolved-file current-unresolved-file
:selection selection
:on-select manage-on-select
:on-disconnect manage-on-disconnect
:visited visited
:last-file? last-unresolved-file?
:on-wizard-prev on-wizard-prev
:on-wizard-next on-wizard-next
:on-wizard-skip on-wizard-skip}]
:library-summary
[:> import-library-summary-stage*
{:resolution resolution
:selection selection
:visited visited
:on-summary-back on-summary-back
:on-confirm-library-links on-confirm-library-links}]
nil)]]))