[feat](trx-frontend-http): add batch move bookmarks between scopes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -380,6 +380,10 @@
|
|||||||
<input type="search" id="bm-text-filter" class="status-input" placeholder="Search bookmarks…" aria-label="Search bookmarks" />
|
<input type="search" id="bm-text-filter" class="status-input" placeholder="Search bookmarks…" aria-label="Search bookmarks" />
|
||||||
<button id="bm-add-btn" type="button" class="bm-add-btn" style="display:none;">+ Add Bookmark</button>
|
<button id="bm-add-btn" type="button" class="bm-add-btn" style="display:none;">+ Add Bookmark</button>
|
||||||
<button id="bm-del-selected-btn" type="button" class="bm-del-btn" style="display:none;">Delete Selected (<span id="bm-del-selected-count">0</span>)</button>
|
<button id="bm-del-selected-btn" type="button" class="bm-del-btn" style="display:none;">Delete Selected (<span id="bm-del-selected-count">0</span>)</button>
|
||||||
|
<span id="bm-move-selected-wrap" style="display:none;">
|
||||||
|
<select id="bm-move-target" class="status-input" aria-label="Move destination"></select>
|
||||||
|
<button id="bm-move-selected-btn" type="button" class="bm-add-btn">Move (<span id="bm-move-selected-count">0</span>)</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="bm-form-wrap" style="display:none;">
|
<div id="bm-form-wrap" style="display:none;">
|
||||||
<form id="bm-form" class="bm-form">
|
<form id="bm-form" class="bm-form">
|
||||||
|
|||||||
@@ -416,10 +416,66 @@ async function bmApply(bm) {
|
|||||||
|
|
||||||
function bmUpdateSelectionUi() {
|
function bmUpdateSelectionUi() {
|
||||||
const count = bmSelected.size;
|
const count = bmSelected.size;
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const visible = count > 0 && canCtrl;
|
||||||
const btn = document.getElementById("bm-del-selected-btn");
|
const btn = document.getElementById("bm-del-selected-btn");
|
||||||
const countEl = document.getElementById("bm-del-selected-count");
|
const countEl = document.getElementById("bm-del-selected-count");
|
||||||
if (btn) btn.style.display = count > 0 && bmCanControl() ? "" : "none";
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
if (countEl) countEl.textContent = count;
|
if (countEl) countEl.textContent = count;
|
||||||
|
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
||||||
|
const moveCountEl = document.getElementById("bm-move-selected-count");
|
||||||
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
|
if (moveCountEl) moveCountEl.textContent = count;
|
||||||
|
if (visible) bmPopulateMoveTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Populate the move-target dropdown with all scopes except the current one. */
|
||||||
|
function bmPopulateMoveTarget() {
|
||||||
|
const sel = document.getElementById("bm-move-target");
|
||||||
|
if (!sel) return;
|
||||||
|
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||||
|
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (bmScope !== "general") {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "general";
|
||||||
|
opt.textContent = "General";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
if (id === bmScope) return;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||||
|
sel.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmMoveSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const target = document.getElementById("bm-move-target")?.value;
|
||||||
|
if (!target) return;
|
||||||
|
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
|
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks/batch_move" + bmScopeParam(false), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids, to: target }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move bookmarks:", err);
|
||||||
|
alert("Failed to move bookmarks: " + err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmSyncSelectAllCheckbox() {
|
function bmSyncSelectAllCheckbox() {
|
||||||
@@ -566,6 +622,11 @@ function bmPopulateScopePicker() {
|
|||||||
bmDeleteSelected();
|
bmDeleteSelected();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Move Selected button
|
||||||
|
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
|
bmMoveSelected();
|
||||||
|
});
|
||||||
|
|
||||||
// Table action buttons and row checkboxes (event delegation)
|
// Table action buttons and row checkboxes (event delegation)
|
||||||
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||||
const checkbox = e.target.closest(".bm-row-sel");
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
|
|||||||
@@ -1641,6 +1641,34 @@ pub async fn batch_delete_bookmarks(
|
|||||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
|
Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": deleted })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct BatchMoveRequest {
|
||||||
|
ids: Vec<String>,
|
||||||
|
to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/bookmarks/batch_move")]
|
||||||
|
pub async fn batch_move_bookmarks(
|
||||||
|
req: HttpRequest,
|
||||||
|
body: web::Json<BatchMoveRequest>,
|
||||||
|
store_map: web::Data<Arc<crate::server::bookmarks::BookmarkStoreMap>>,
|
||||||
|
query: web::Query<BookmarkScopeQuery>,
|
||||||
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
require_control(&req, &auth_state)?;
|
||||||
|
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||||
|
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
|
||||||
|
let mut moved = 0usize;
|
||||||
|
for id in &body.ids {
|
||||||
|
if let Some(bm) = from_store.get(id) {
|
||||||
|
if to_store.insert(&bm) && from_store.remove(id) {
|
||||||
|
moved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({ "moved": moved })))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct RigListItem {
|
struct RigListItem {
|
||||||
remote: String,
|
remote: String,
|
||||||
@@ -1937,6 +1965,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(update_bookmark)
|
.service(update_bookmark)
|
||||||
.service(delete_bookmark)
|
.service(delete_bookmark)
|
||||||
.service(batch_delete_bookmarks)
|
.service(batch_delete_bookmarks)
|
||||||
|
.service(batch_move_bookmarks)
|
||||||
// Scheduler
|
// Scheduler
|
||||||
.service(crate::server::scheduler::get_scheduler)
|
.service(crate::server::scheduler::get_scheduler)
|
||||||
.service(crate::server::scheduler::put_scheduler)
|
.service(crate::server::scheduler::put_scheduler)
|
||||||
|
|||||||
Reference in New Issue
Block a user