WIP: plan for improvement

This commit is contained in:
Andrey Antukh 2026-06-26 13:36:29 +02:00
parent 2979f354af
commit 73d9f60d6d
6 changed files with 254 additions and 114 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 dont have permission to import to this team"

View File

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