feat(set): mass-edit metadata across selected sets (#146)

This commit is contained in:
2026-06-28 21:43:43 +02:00
parent c93a14f70b
commit 14c49d3950
9 changed files with 596 additions and 0 deletions
+4
View File
@@ -53,6 +53,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all(self, /) -> Self:
# Load the sets from the database with metadata context for filtering
filter_context = {
'custom_fields': BrickSetCustomFieldList.as_columns(),
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
@@ -65,6 +66,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all_consolidated(self, /) -> Self:
# Load the sets from the database using consolidated query with metadata context
filter_context = {
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -118,9 +120,11 @@ class BrickSetList(BrickRecordList[BrickSet]):
'tag_filter': tag_filter,
'year_filter': year_filter,
'duplicate_filter': duplicate_filter,
'custom_fields': BrickSetCustomFieldList.as_columns(),
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -43,6 +43,15 @@ SELECT
{% endfor %}
{% endif %}
{% endblock %}
{% block custom_fields %}
{% if custom_fields_dict %}
{% for column, uuid in custom_fields_dict.items() %}
-- Representative value for the group (shared value when all
-- instances agree; used for bulk-edit read across instances).
, MAX("bricktracker_set_custom_fields"."{{ column }}") AS "{{ column }}"
{% endfor %}
{% endif %}
{% endblock %}
FROM "bricktracker_sets"
INNER JOIN "rebrickable_sets"
@@ -84,6 +93,11 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
{% if custom_fields_dict %}
LEFT JOIN "bricktracker_set_custom_fields"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_custom_fields"."id"
{% endif %}
{% block where %}
WHERE 1=1
{% if search_query %}
+83
View File
@@ -31,6 +31,7 @@ from ..set_status_list import BrickSetStatusList
from ..set_storage_list import BrickSetStorageList
from ..set_tag_list import BrickSetTagList
from ..socket import MESSAGES
from ..sql import BrickSQL
logger = logging.getLogger(__name__)
@@ -243,6 +244,88 @@ def update_custom_field(*, id: str, metadata_id: str) -> Response:
return jsonify({'value': value})
# Mass-edit metadata across several selected sets in one request. Only the
# fields the user actually touched are present in "changes"; tri-state booleans
# arrive as explicit true/false, value fields as a string ('' clears).
@set_page.route('/bulk/edit', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def bulk_edit() -> Response:
payload = request.json or {}
set_ids = payload.get('set_ids', [])
changes = payload.get('changes', {})
if not set_ids:
raise ErrorException('No sets were selected')
tags = changes.get('tags', {})
statuses = changes.get('statuses', {})
owners = changes.get('owners', {})
custom_fields = changes.get('custom_fields', {})
has_storage = 'storage' in changes
has_purchase_location = 'purchase_location' in changes
has_purchase_date = 'purchase_date' in changes
has_purchase_price = 'purchase_price' in changes
for set_id in set_ids:
brickset = BrickSet().select_light(set_id)
# Boolean metadata: defer so the batch is grouped (committed below).
for metadata_id, state in tags.items():
BrickSetTagList.get(metadata_id).update_set_state(
brickset, state=state, commit=False
)
for metadata_id, state in statuses.items():
BrickSetStatusList.get(metadata_id).update_set_state(
brickset, state=state, commit=False
)
for metadata_id, state in owners.items():
BrickSetOwnerList.get(metadata_id).update_set_state(
brickset, state=state, commit=False
)
# Value metadata (each commits on its own).
if has_storage:
storage = BrickSetStorageList.get(
changes.get('storage', ''), allow_none=True
)
storage.update_set_value(brickset, value=storage.fields.id)
if has_purchase_location:
purchase_location = BrickSetPurchaseLocationList.get(
changes.get('purchase_location', ''), allow_none=True
)
purchase_location.update_set_value(
brickset, value=purchase_location.fields.id
)
if has_purchase_date:
brickset.update_purchase_date(
{'value': changes.get('purchase_date', '')}
)
if has_purchase_price:
brickset.update_purchase_price(
{'value': changes.get('purchase_price', '')}
)
for metadata_id, value in custom_fields.items():
BrickSetCustomFieldList.get(metadata_id).update_value(
brickset, value=value
)
# Flush any deferred boolean updates that did not ride along with a
# value update's commit.
BrickSQL().commit()
logger.info('Bulk edit applied to {count} set(s)'.format(
count=len(set_ids),
))
return jsonify({'updated': len(set_ids)})
# Ask for deletion of a set
@set_page.route('/<id>/delete', methods=['GET'])
@login_required
+309
View File
@@ -0,0 +1,309 @@
// Mass-edit of sets from the grid (Feature 4).
//
// Lets an authenticated user select several cards on the sets grid and edit
// their metadata in one pass. Each editable control is pre-filled from the
// selected cards: a shared value is shown and editable, differing values show
// "varies" and only overwrite when actually touched. Boolean metadata
// (tags/statuses/owners) is tri-state: all-on / all-off / mixed.
(function () {
// Convert a stored purchase-date timestamp (epoch seconds) to the value
// expected by <input type="date"> (yyyy-mm-dd), using local time since the
// stored timestamp was built from a local midnight date.
function tsToDateInput(ts) {
if (!ts) return "";
const d = new Date(parseFloat(ts) * 1000);
if (isNaN(d.getTime())) return "";
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
// Custom date fields are stored as yyyy/mm/dd text (matching the single-set
// editor), so just swap the separators for the date input.
function slashToDateInput(v) {
return v ? v.replaceAll("/", "-") : "";
}
// Reverse of the above: the backend (purchase date + custom date fields)
// expects yyyy/mm/dd.
function dateInputToSlash(v) {
return v ? v.replaceAll("-", "/") : "";
}
function removeVariesOption(sel) {
if (sel.tagName !== "SELECT") return;
const opt = sel.querySelector('option[data-varies="true"]');
if (opt) opt.remove();
}
function addVariesOption(sel) {
removeVariesOption(sel);
const opt = document.createElement("option");
opt.value = "__varies__";
opt.textContent = "— varies —";
opt.dataset.varies = "true";
opt.disabled = true;
opt.selected = true;
sel.insertBefore(opt, sel.firstChild);
sel.value = "__varies__";
}
function initBulkEdit() {
const toggle = document.getElementById("bulk-select-toggle");
const grid = document.getElementById("grid");
const panel = document.getElementById("bulk-edit-panel");
const bar = document.getElementById("bulk-select-bar");
// Only on the authenticated sets grid
if (!toggle || !grid || !panel || !bar) return;
const countEl = document.getElementById("bulk-select-count");
const editCountEl = document.getElementById("bulk-edit-count");
const openBtn = document.getElementById("bulk-edit-open");
const applyBtn = document.getElementById("bulk-edit-apply");
const bulkUrl = bar.dataset.bulkEditUrl;
const selected = new Set();
const cards = () => Array.from(grid.querySelectorAll(".card[data-set-id]"));
// Inject a checkbox overlay into every card (revealed by CSS in select
// mode).
cards().forEach((card) => {
if (card.querySelector(".bulk-select-check")) return;
const overlay = document.createElement("div");
overlay.className = "bulk-select-check";
overlay.innerHTML =
'<input type="checkbox" class="form-check-input" tabindex="-1">';
card.appendChild(overlay);
});
const isSelected = (card) => card.classList.contains("bulk-selected");
function setSelected(card, on) {
card.classList.toggle("bulk-selected", on);
const cb = card.querySelector(".bulk-select-check input");
if (cb) cb.checked = on;
if (on) selected.add(card);
else selected.delete(card);
}
function refreshCount() {
const n = selected.size;
countEl.textContent = n;
editCountEl.textContent = n;
openBtn.disabled = n === 0;
}
function enterMode() {
document.body.classList.add("bulk-select-mode");
bar.classList.add("show");
toggle.classList.add("active");
}
function exitMode() {
document.body.classList.remove("bulk-select-mode");
bar.classList.remove("show");
toggle.classList.remove("active");
cards().forEach((c) => setSelected(c, false));
refreshCount();
}
toggle.addEventListener("click", () => {
if (document.body.classList.contains("bulk-select-mode")) exitMode();
else enterMode();
});
document
.getElementById("bulk-select-exit")
.addEventListener("click", exitMode);
document
.getElementById("bulk-select-clear")
.addEventListener("click", () => {
cards().forEach((c) => setSelected(c, false));
refreshCount();
});
document.getElementById("bulk-select-all").addEventListener("click", () => {
cards().forEach((c) => {
// Skip cards hidden by client-side search/filter
if (c.offsetParent === null) return;
setSelected(c, true);
});
refreshCount();
});
// Clicking a card while in select mode toggles it (capture phase so it
// wins over the card's own links).
grid.addEventListener(
"click",
(e) => {
if (!document.body.classList.contains("bulk-select-mode")) return;
const card = e.target.closest(".card[data-set-id]");
if (!card || !grid.contains(card)) return;
e.preventDefault();
e.stopPropagation();
setSelected(card, !isSelected(card));
refreshCount();
},
true,
);
// --- Field computation ----------------------------------------------
function computeTristate(ctrl, cardEls) {
const attr = "data-" + ctrl.dataset.cardAttr;
let on = 0;
cardEls.forEach((c) => {
if (c.getAttribute(attr) !== null) on++;
});
if (on === 0) {
ctrl.checked = false;
ctrl.indeterminate = false;
} else if (on === cardEls.length) {
ctrl.checked = true;
ctrl.indeterminate = false;
} else {
ctrl.checked = false;
ctrl.indeterminate = true;
}
}
function computeValue(ctrl, cardEls) {
const attr = "data-" + ctrl.dataset.cardAttr;
const raw = cardEls.map((c) => {
const v = c.getAttribute(attr);
return v === null ? "" : v;
});
const allEqual = raw.every((v) => v === raw[0]);
removeVariesOption(ctrl);
if (allEqual) {
let v = raw[0];
if (ctrl.type === "date") {
v =
ctrl.dataset.bulkField === "purchase_date"
? tsToDateInput(v)
: slashToDateInput(v);
}
if (ctrl.tagName === "SELECT") ctrl.value = v == null ? "" : v;
else ctrl.value = v;
ctrl.placeholder = "";
} else if (ctrl.tagName === "SELECT") {
addVariesOption(ctrl);
} else {
ctrl.value = "";
ctrl.placeholder = "— varies —";
}
}
function computePanel(cardEls) {
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
if (ctrl.classList.contains("bulk-tristate")) {
computeTristate(ctrl, cardEls);
} else {
computeValue(ctrl, cardEls);
}
ctrl.dataset.pristine = "true";
ctrl.classList.remove("bulk-touched");
});
}
// Touched tracking: only fields the user actually changes are applied.
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
const onTouch = () => {
ctrl.dataset.pristine = "false";
ctrl.classList.add("bulk-touched");
removeVariesOption(ctrl);
if (ctrl.classList.contains("bulk-tristate"))
ctrl.indeterminate = false;
};
ctrl.addEventListener("change", onTouch);
ctrl.addEventListener("input", onTouch);
});
openBtn.addEventListener("click", () => {
if (selected.size === 0) return;
computePanel(Array.from(selected));
bootstrap.Offcanvas.getOrCreateInstance(panel).show();
});
// --- Apply ----------------------------------------------------------
applyBtn.addEventListener("click", async () => {
const cardEls = Array.from(selected);
if (cardEls.length === 0) return;
// Expand consolidated cards into their real instance ids.
const setIds = [];
const seen = new Set();
cardEls.forEach((c) => {
const inst = c.getAttribute("data-instance-ids");
const ids = inst
? inst.split(/[\s,|]+/).filter(Boolean)
: [c.getAttribute("data-set-id")].filter(Boolean);
ids.forEach((id) => {
if (!seen.has(id)) {
seen.add(id);
setIds.push(id);
}
});
});
const changes = { tags: {}, statuses: {}, owners: {}, custom_fields: {} };
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
if (ctrl.dataset.pristine !== "false") return; // untouched
const field = ctrl.dataset.bulkField;
if (ctrl.classList.contains("bulk-tristate")) {
const id = ctrl.dataset.metaId;
const map =
field === "tag"
? changes.tags
: field === "status"
? changes.statuses
: changes.owners;
map[id] = ctrl.checked;
} else if (field === "custom_field") {
let v = ctrl.value;
if (ctrl.type === "date") v = dateInputToSlash(v);
changes.custom_fields[ctrl.dataset.cfId] = v;
} else if (field === "storage") {
changes.storage = ctrl.value;
} else if (field === "purchase_location") {
changes.purchase_location = ctrl.value;
} else if (field === "purchase_price") {
changes.purchase_price = ctrl.value;
} else if (field === "purchase_date") {
changes.purchase_date = dateInputToSlash(ctrl.value);
}
});
// Drop empty boolean/custom maps so the backend skips them.
["tags", "statuses", "owners", "custom_fields"].forEach((k) => {
if (Object.keys(changes[k]).length === 0) delete changes[k];
});
applyBtn.disabled = true;
try {
const resp = await fetch(bulkUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set_ids: setIds, changes: changes }),
});
if (!resp.ok)
throw new Error(`Response status: ${resp.status} (${resp.statusText})`);
const json = await resp.json();
if ("error" in json) throw new Error(json.error);
window.location.reload();
} catch (err) {
applyBtn.disabled = false;
alert("Bulk edit failed: " + err.message);
}
});
}
document.addEventListener("DOMContentLoaded", initBulkEdit);
})();
+51
View File
@@ -353,3 +353,54 @@
tr.parts-filtered-out {
display: none;
}
/* --- Mass-edit (Feature 4) --- */
.bulk-select-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1040;
display: none;
background-color: var(--bs-body-bg);
border-top: 1px solid var(--bs-border-color);
}
.bulk-select-bar.show {
display: block;
}
/* Keep the sticky bar from covering the last row of cards */
body.bulk-select-mode {
padding-bottom: 4.5rem;
}
body.bulk-select-mode #grid .card {
cursor: pointer;
}
.bulk-select-check {
display: none;
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 5;
}
.bulk-select-check .form-check-input {
width: 1.4rem;
height: 1.4rem;
}
body.bulk-select-mode .bulk-select-check {
display: block;
}
#grid .card.bulk-selected {
outline: 3px solid var(--bs-primary);
outline-offset: -3px;
}
.bulk-control.bulk-touched {
border-color: var(--bs-primary);
}
+3
View File
@@ -190,6 +190,9 @@
{% endif %}
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% if g.login.is_authenticated() %}
<script src="{{ url_for('static', filename='scripts/sets-bulk-edit.js') }}"></script>
{% endif %}
{% endif %}
{% if request.endpoint == 'set.details' %}
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
+114
View File
@@ -0,0 +1,114 @@
{# Mass-edit UI (Feature 4). Rendered server-side with the full metadata lists
so the offcanvas form has every editable field. All logic (selection,
shared-vs-varies detection, tri-state, apply) lives in sets-bulk-edit.js. #}
<!-- Sticky bottom bar shown while in bulk-select mode -->
<div id="bulk-select-bar" class="bulk-select-bar shadow-lg" data-bulk-edit-url="{{ url_for('set.bulk_edit') }}">
<div class="container-fluid d-flex flex-wrap align-items-center gap-2 py-2">
<span class="fw-bold"><span id="bulk-select-count">0</span> selected</span>
<div class="ms-auto d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-select-all"><i class="ri-checkbox-multiple-line"></i> Select all visible</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-select-clear"><i class="ri-close-circle-line"></i> Clear</button>
<button type="button" class="btn btn-sm btn-primary" id="bulk-edit-open" disabled><i class="ri-edit-line"></i> Edit selected</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="bulk-select-exit"><i class="ri-logout-box-line"></i> Done</button>
</div>
</div>
</div>
<!-- Bulk edit panel -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="bulk-edit-panel" aria-labelledby="bulk-edit-panel-label" data-bs-scroll="true" data-bs-backdrop="true">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="bulk-edit-panel-label">Edit <span id="bulk-edit-count">0</span> sets</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<p class="text-muted small">
Only fields you actually change are applied. Fields where the selected sets
differ show <em>varies</em> and stay untouched unless you edit them.
</p>
{# --- Value fields --- #}
<div class="mb-3">
<label class="form-label" for="bulk-purchase-price">Purchase price</label>
<input type="number" step="0.01" class="form-control bulk-control" id="bulk-purchase-price" data-bulk-field="purchase_price" data-card-attr="purchase-price">
</div>
<div class="mb-3">
<label class="form-label" for="bulk-purchase-date">Purchase date</label>
<input type="date" class="form-control bulk-control" id="bulk-purchase-date" data-bulk-field="purchase_date" data-card-attr="purchase-date">
</div>
<div class="mb-3">
<label class="form-label" for="bulk-storage">Storage</label>
<select class="form-select bulk-control" id="bulk-storage" data-bulk-field="storage" data-card-attr="storage">
<option value="">-- No storage --</option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label" for="bulk-purchase-location">Purchase location</label>
<select class="form-select bulk-control" id="bulk-purchase-location" data-bulk-field="purchase_location" data-card-attr="purchase-location">
<option value="">-- No purchase location --</option>
{% for location in brickset_purchase_locations %}
<option value="{{ location.fields.id }}">{{ location.fields.name }}</option>
{% endfor %}
</select>
</div>
{# --- Custom fields --- #}
{% if brickset_custom_fields | length %}
<hr>
<h6 class="text-muted">Custom fields</h6>
{% for custom_field in brickset_custom_fields %}
<div class="mb-3">
<label class="form-label" for="bulk-cf-{{ custom_field.fields.id }}">{{ custom_field.fields.name }}</label>
{% if custom_field.fields.type == 'number' %}
<input type="number" step="any" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-card-attr="{{ custom_field.as_dataset() }}">
{% elif custom_field.fields.type == 'date' %}
<input type="date" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-cf-date="true" data-card-attr="{{ custom_field.as_dataset() }}">
{% else %}
<input type="text" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-card-attr="{{ custom_field.as_dataset() }}">
{% endif %}
</div>
{% endfor %}
{% endif %}
{# --- Tri-state boolean metadata --- #}
{% if brickset_tags | length %}
<hr>
<h6 class="text-muted">Tags</h6>
{% for tag in brickset_tags %}
<div class="form-check">
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-tag-{{ tag.fields.id }}" data-bulk-field="tag" data-meta-id="{{ tag.fields.id }}" data-card-attr="{{ tag.as_dataset() }}">
<label class="form-check-label" for="bulk-tag-{{ tag.fields.id }}">{{ tag.fields.name }}</label>
</div>
{% endfor %}
{% endif %}
{% if brickset_statuses | length %}
<hr>
<h6 class="text-muted">Statuses</h6>
{% for status in brickset_statuses %}
<div class="form-check">
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-status-{{ status.fields.id }}" data-bulk-field="status" data-meta-id="{{ status.fields.id }}" data-card-attr="{{ status.as_dataset() }}">
<label class="form-check-label" for="bulk-status-{{ status.fields.id }}">{{ status.fields.name }}</label>
</div>
{% endfor %}
{% endif %}
{% if brickset_owners | length %}
<hr>
<h6 class="text-muted">Owners</h6>
{% for owner in brickset_owners %}
<div class="form-check">
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-owner-{{ owner.fields.id }}" data-bulk-field="owner" data-meta-id="{{ owner.fields.id }}" data-card-attr="{{ owner.as_dataset() }}">
<label class="form-check-label" for="bulk-owner-{{ owner.fields.id }}">{{ owner.fields.name }}</label>
</div>
{% endfor %}
{% endif %}
</div>
<div class="offcanvas-footer border-top p-3 d-flex gap-2">
<button type="button" class="btn btn-secondary flex-fill" data-bs-dismiss="offcanvas">Cancel</button>
<button type="button" class="btn btn-primary flex-fill" id="bulk-edit-apply"><i class="ri-save-line"></i> Apply changes</button>
</div>
</div>
+9
View File
@@ -52,6 +52,15 @@
{% endif %}
{% endwith %}
{% endfor %}
{# Custom field values for bulk-edit read (only emitted when set). The attr
absence means "empty/varies" to the bulk editor. #}
{% for custom_field in brickset_custom_fields %}
{% with value=item.fields[custom_field.as_column()] %}
{% if value is not none and value != '' %}
data-{{ custom_field.as_dataset() }}="{{ value }}"
{% endif %}
{% endwith %}
{% endfor %}
{% endif %}
>
{{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
+9
View File
@@ -27,6 +27,11 @@
<i class="ri-stack-line"></i> Duplicates
</button>
{% endif %}
{% if g.login.is_authenticated() %}
<button class="btn btn-outline-primary" type="button" id="bulk-select-toggle" title="Select multiple sets to edit at once">
<i class="ri-checkbox-multiple-line"></i> Select
</button>
{% endif %}
</div>
</div>
</div>
@@ -168,6 +173,10 @@
</div>
{% endif %}
{% if g.login.is_authenticated() %}
{% include 'set/bulk_edit.html' %}
{% endif %}
</div>
{% else %}
{% include 'set/empty.html' %}