From 73d9f60d6d586afe57609c417291b2b94af89f6f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 26 Jun 2026 13:36:29 +0200 Subject: [PATCH] WIP: plan for improvement --- backend/src/app/binfile/v3.clj | 110 +++++---- .../src/app/main/ui/dashboard/import.cljs | 221 +++++++++++++----- .../src/app/main/ui/dashboard/import.scss | 11 + frontend/src/app/worker/import.cljs | 3 +- frontend/translations/en.po | 17 ++ frontend/translations/es.po | 6 + 6 files changed, 254 insertions(+), 114 deletions(-) diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index ac96860227..1b7a72296a 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -913,58 +913,60 @@ (vswap! bfc/*state* update :index assoc id (:id sobject))))))) -(defn- resolve-external-libraries-references - "For each external library in the manifest, look for matching shared - files in the team by slugified name. Returns a map of - old-lib-id -> [{:id uuid :name string}] for libraries with matches." - [{:keys [::manifest ::bfc/team-id] :as cfg}] +(defn- add-to-file + "Add a resolved library entry to a file in the file-grouped resolution. + `key` is :libraries (auto-linked) or :candidates (needs resolution)." + [acc file-id key entry] + (let [file-entry (get acc file-id {:file-id file-id :libraries [] :candidates []})] + (assoc acc file-id (update file-entry key conj entry)))) + +(defn- resolve-and-link-libraries + "For each external library in the manifest, resolve candidates by slug. + Auto-links single matches (creating DB rows) and builds a file-grouped + resolution map keyed by imported file-id (new UUID). + + Returns {file-id {:file-id :libraries :candidates}} + where :libraries = [{:id :name :linked-to}] + and :candidates = [{:id :name :candidates [...]}]." + [{:keys [::db/conn ::manifest ::bfc/team-id ::bfc/timestamp ::bfc/profile-id] :as cfg} + file-ids] (assert (uuid? team-id) "team-id should be provided") - (reduce (fn [result {:keys [id slug] :as lib}] - (if-let [candidates (-> (bfc/find-shared-files-by-slug cfg team-id slug) - (vec) - (not-empty))] - (assoc result id (assoc lib :candidates candidates)) - result)) - {} - (:external-libraries manifest))) + (reduce + (fn [acc {:keys [id name slug used-by] :as _ext-lib}] + (let [candidates (-> (bfc/find-shared-files-by-slug cfg team-id slug) + (vec) + (not-empty))] + (cond + ;; No candidates → skip this library + (nil? candidates) + acc -(defn- auto-link-libraries - "Auto-link imported files to libraries that have exactly one candidate - match. Returns a vector of {:id old-lib-id :name string :linked-to uuid} - for each auto-linked library." - [{:keys [::db/conn ::manifest ::bfc/timestamp ::bfc/profile-id] :as cfg} resolution file-ids] - (reduce-kv (fn [result id {:keys [name used-by candidates]}] - (if (= 1 (count candidates)) - (let [candidate (first candidates) - lib-id (get candidate :id) - perms (bfc/get-file-permissions conn profile-id lib-id)] + ;; Single candidate → auto-link if importer has edit permission + (= 1 (count candidates)) + (let [candidate (first candidates) + lib-id (:id candidate) + perms (bfc/get-file-permissions conn profile-id lib-id)] + (if (not (:can-edit perms)) + acc ;; no edit permission → skip + (let [used-by-set (into #{} (map bfc/lookup-index) used-by) + linked-fids (filterv #(contains? used-by-set %) file-ids)] + ;; Create DB links for the files that used this library + (doseq [file-id linked-fids] + (let [rel-params {:file-id file-id :library-file-id lib-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)))) + ;; Add to file-grouped structure (reduce on [] returns acc) + (let [entry {:id id :name name :linked-to lib-id}] + (reduce #(add-to-file %1 %2 :libraries entry) acc linked-fids))))) - ;; Only auto-link when the importer has edit permission - ;; on the matched library, matching the manual link RPC. - (if (:can-edit perms) - ;; Link only files that actually used this library - (let [used-by (into #{} (map bfc/lookup-index) used-by) - file-ids (-> (filter #(contains? used-by %) file-ids) - (not-empty))] - - (doseq [file-id file-ids] - (when (contains? used-by file-id) - (let [rel-params {:file-id file-id - :library-file-id lib-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))))) - - (if file-ids - (update result id (fn [lib] - (-> lib - (assoc :linked-to lib-id) - (dissoc :candidates)))) - (dissoc result id))) - result)) - result)) - resolution - resolution)) + ;; Multiple candidates → needs user resolution + :else + (let [new-fids (into [] (keep bfc/lookup-index) used-by) + entry {:id id :name name :candidates candidates}] + (reduce #(add-to-file %1 %2 :candidates entry) acc new-fids))))) + {} + (:external-libraries manifest))) (defn- import-files* [{:keys [::manifest] :as cfg}] @@ -984,15 +986,7 @@ (import-file-relations cfg) - ;; Resolve external libraries by slug and auto-link single matches - (let [resolution - (resolve-external-libraries-references cfg) - - resolution - (auto-link-libraries cfg resolution file-ids)] - - (app.common.pprint/pprint resolution) - + (let [resolution (resolve-and-link-libraries cfg file-ids)] (bfm/apply-pending-migrations! cfg) {:file-ids file-ids :resolution resolution}))) diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index d82c3d2626..a06e78ef13 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -158,6 +158,19 @@ (and (= :import-ready (:status item)) (not (:deleted item)))) +(defn- has-unresolved? + "Return true if a file-resolution has any :candidates needing user choice." + [file-resolution] + (some? (seq (:candidates file-resolution)))) + +(defn- count-auto-linked + "Count auto-linked libraries across all file resolutions." + [resolution] + (reduce-kv (fn [acc _ {:keys [libraries]}] + (+ acc (count libraries))) + 0 + resolution)) + (defn- analyze-entries [state entries] (let [features (get @st/state :features)] @@ -343,18 +356,16 @@ (mf/defc library-resolution* {::mf/private true} - [{:keys [resolution selections*]}] - (let [external-libs (:external-libs resolution) - library-candidates (:library-candidates resolution) - selections (deref selections*)] + [{:keys [file-resolution on-next selections*]}] + (let [candidates (:candidates file-resolution) + selections (deref selections*)] - ;; Pre-select the first candidate for each unresolved library so that - ;; confirming without interaction still links the default choice. - (mf/with-effect [] - (doseq [[old-lib-id candidates] library-candidates] - (when-not (contains? @selections* old-lib-id) - (when-let [first-candidate (first candidates)] - (swap! selections* assoc old-lib-id (:id first-candidate)))))) + ;; Pre-select first candidate for each library + (mf/with-effect [candidates] + (doseq [{:keys [id candidates]} candidates] + (when-not (contains? @selections* id) + (when-let [first-c (first candidates)] + (swap! selections* assoc id (:id first-c)))))) (let [on-select (mf/use-fn @@ -362,25 +373,49 @@ (fn [old-lib-id candidate-id] (swap! selections* assoc old-lib-id candidate-id)))] - (when (seq library-candidates) - [:div {:class (stl/css :library-resolution)} - [:p {:class (stl/css :library-resolution-message)} - (tr "dashboard.import.resolve-libraries")] + [:div {:class (stl/css :library-resolution)} + [:p {:class (stl/css :library-resolution-message)} + (tr "dashboard.import.resolve-libraries")] - (for [{:keys [id name]} external-libs] - (let [candidates (get library-candidates id) - options (mapv (fn [c] - {:id (str (:id c)) - :label (:name c)}) - candidates) - selected (get selections id)] - [:div {:class (stl/css :library-resolution-item) - :key (dm/str id)} - [:div {:class (stl/css :library-resolution-item-name)} - name] - [:> select* {:options options - :default-selected (or (some-> selected str) "") - :on-change (partial on-select id)}]]))])))) + (for [{:keys [id name candidates]} candidates] + (let [options (mapv (fn [c] + {:id (str (:id c)) + :label (:name c)}) + candidates) + selected (get selections id)] + [:div {:class (stl/css :library-resolution-item) + :key (dm/str id)} + [:div {:class (stl/css :library-resolution-item-name)} + name] + [:> select* {:options options + :default-selected (or (some-> selected str) "") + :on-change (partial on-select id)}]]))]))) + +(mf/defc library-resolution-summary + {::mf/private true} + [{:keys [resolution selections]}] + [:div {:class (stl/css :library-resolution)} + [:p {:class (stl/css :library-resolution-message)} + (tr "dashboard.import.resolve-libraries-summary")] + + (for [[file-id file-res] resolution] + [:div {:class (stl/css :library-resolution-summary-file) + :key (dm/str file-id)} + ;; Show auto-linked libraries + (for [{:keys [name linked-to]} (:libraries file-res)] + [:div {:class (stl/css :library-resolution-item)} + [:div {:class (stl/css :library-resolution-item-name)} name] + [:div {:class (stl/css :library-resolution-linked)} "→ linked"]]) + + ;; Show user-selected libraries + (for [{:keys [id name] :as cand} (:candidates file-res)] + (let [selected-id (get selections id) + selected-c (when selected-id + (d/seek #(= (:id %) selected-id) (:candidates cand)))] + [:div {:class (stl/css :library-resolution-item)} + [:div {:class (stl/css :library-resolution-item-name)} name] + [:div {:class (stl/css :library-resolution-linked)} + "→ " (:name selected-c)]]))])]) (mf/defc import-dialog {::mf/register modal/components @@ -410,6 +445,24 @@ library-selections* (mf/use-state {}) library-selections (deref library-selections*) + ;; 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 []) + visited (deref visited*) + visited-set (set visited) + + ;; Derived: files that need user resolution (have :candidates) + unresolved-files + (mf/use-memo [library-resolution] + (when library-resolution + (filterv has-unresolved? (vals library-resolution)))) + + ;; Current file shown in the wizard step: first unresolved file not yet visited. + current-file + (mf/use-memo [unresolved-files visited-set] + (d/seek #(not (contains? visited-set (:file-id %))) unresolved-files)) + continue-entries (mf/use-fn (mf/deps entries) @@ -484,16 +537,41 @@ (mf/deps library-resolution library-selections*) (fn [event] (dom/prevent-default event) - (let [selections @library-selections* - file-ids (:file-ids library-resolution)] - ;; Link each user-selected library to all imported files - (->> (rx/from (seq selections)) - (rx/merge-map (fn [[_old-lib-id candidate-id]] - (link-files-to-library! file-ids candidate-id))) - (rx/reduce conj []) - (rx/subs! (fn [_] + (let [selections @library-selections*] + ;; For each file with candidates, link it to the selected libraries + (->> (rx/from (seq library-resolution)) + (rx/merge-map + (fn [[file-id file-res]] + (let [file-ids [file-id]] ;; this specific file's new UUID + (->> (rx/from (:candidates file-res)) + (rx/merge-map + (fn [{:keys [id]}] + (when-let [selected-lib (get selections id)] + (link-files-to-library! file-ids selected-lib)))))))) + (rx/subs! (fn [] (reset! status* :import-success))))))) + on-wizard-next + (mf/use-fn + (mf/deps current-file unresolved-files visited*) + (fn [] + (let [new-visited (conj @visited* (:file-id current-file)) + new-set (set new-visited) + has-more? (some #(not (contains? new-set (:file-id %))) + unresolved-files)] + (reset! visited* new-visited) + (when-not has-more? + (reset! status* :library-summary))))) + + on-wizard-prev + (mf/use-fn + (mf/deps visited*) + (fn [] + ;; Pop the last visited file-id; it becomes current again after re-render, + ;; because it's no longer in visited-set. + (when (seq @visited*) + (swap! visited* pop)))) + on-accept (mf/use-fn (mf/deps on-finish-import) @@ -538,7 +616,10 @@ (and (seq entries) (every? #(= :import-success (:status %)) entries)) - (reset! status* :import-success) + (reset! status* (if (and (some? @library-resolution*) + (seq (filter has-unresolved? (vals @library-resolution*)))) + :library-resolution + :import-success)) (and (seq entries) (and (every? #(not= :import-ready (:status %)) entries) @@ -581,17 +662,19 @@ :class (stl/css :context-notification-error) :content (tr "dashboard.import.import-error.disclaimer")}]) - (when (some? library-resolution) - [:* - [:& context-notification - {:level :success - :content (tr "dashboard.import.import-message" (i18n/c import-success-total))}] - (when (pos? auto-linked-count) - [:& context-notification - {:level :success - :content (tr "dashboard.import.auto-linked-libraries" (i18n/c auto-linked-count))}]) - [:> library-resolution* {:resolution library-resolution - :selections* library-selections*}]]) + ;; :library-resolution — wizard step (current derived file) + (when (= :library-resolution status) + (when (some? current-file) + [:> library-resolution* + {:file-resolution current-file + :selections* library-selections* + :on-next on-wizard-next}])) + + ;; :library-summary — show all files with final resolution + (when (= :library-summary status) + [:> library-resolution-summary + {:resolution library-resolution + :selections library-selections}]) (if (or (= :import-error status) (and (= :analyze status) errors?)) [:div {:class (stl/css :import-error-disclaimer)} @@ -648,11 +731,41 @@ [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} (cond - (some? library-resolution) - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "dashboard.import.confirm-library-links") - :on-click on-confirm-library-links}] + ;; Wizard step: Next / Previous (Previous only shown if stack is non-empty) + (= :library-resolution status) + [:* + (when (seq @visited*) + [:input {:class (stl/css :cancel-button) + :type "button" + :value (tr "labels.previous") + :on-click on-wizard-prev}]) + ;; Label flips to "Review" when this is the last unvisited unresolved file. + [:input {:class (stl/css :accept-btn) + :type "button" + :value (if (some? current-file) + ;; Peek: is there another unvisited unresolved file after this one? + (if (let [new-set (conj (set @visited*) (:file-id current-file))] + (some #(not (contains? new-set (:file-id %))) unresolved-files)) + (tr "labels.next") + (tr "dashboard.import.review-links")) + (tr "dashboard.import.review-links")) + :on-click on-wizard-next}]] + + ;; Summary: Confirm / Back + ;; Back pops the stack once and re-enters the wizard at the popped file. + (= :library-summary status) + [:* + (when (seq @visited*) + [:input {:class (stl/css :cancel-button) + :type "button" + :value (tr "labels.back") + :on-click (fn [] + (swap! visited* pop) + (reset! status* :library-resolution))}]) + [:input {:class (stl/css :accept-btn) + :type "button" + :value (tr "dashboard.import.confirm-library-links") + :on-click on-confirm-library-links}]] (= :analyze status) [:input {:class (stl/css :cancel-button) diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index 88d524183f..ac57e46534 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -300,3 +300,14 @@ color: var(--modal-title-foreground-color); font-weight: 700; } + +.library-resolution-summary-file { + margin-bottom: deprecated.$s-16; + padding-bottom: deprecated.$s-12; + border-bottom: 1px solid var(--color-foreground-secondary); +} + +.library-resolution-linked { + color: var(--color-foreground-secondary); + font-size: deprecated.$fs-12; +} diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index d78fd70ba1..87a9a2826b 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -243,5 +243,4 @@ :file-id (:file-id entry)})))))))))) (->> (rx/defer #(deref resolutions)) (rx/map (fn [resolutions] - {:type :libraries-resolution - :value resolutions}))))))) + {:libraries-resolution resolutions}))))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 0410ccc003..180a0b8d43 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -756,6 +756,23 @@ msgstr[1] "%s files have been imported successfully." msgid "dashboard.import.import-warning" msgstr "Some files containted invalid objects that have been removed." +msgid "dashboard.import.auto-linked-libraries" +msgid_plural "dashboard.import.auto-linked-libraries" +msgstr[0] "1 library was automatically linked by name." +msgstr[1] "%s libraries were automatically linked by name." + +msgid "dashboard.import.resolve-libraries" +msgstr "Some libraries couldn't be linked automatically. Select the correct library for each:" + +msgid "dashboard.import.resolve-libraries-summary" +msgstr "Review the library links before confirming:" + +msgid "dashboard.import.confirm-library-links" +msgstr "Confirm library links" + +msgid "dashboard.import.review-links" +msgstr "Review links" + #: src/app/main/ui/dashboard.cljs:260 msgid "dashboard.import.no-perms" msgstr "You don’t have permission to import to this team" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2a82ac3c76..f084ea0083 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -769,6 +769,12 @@ msgstr "Algunas bibliotecas no pudieron vincularse automáticamente. Selecciona 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" + #: src/app/main/ui/dashboard.cljs:260 msgid "dashboard.import.no-perms" msgstr "No tienes permisos para importar en este equipo"