mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-03 23:21:44 +00:00
Adds a "Value calculator" tool (Tools menu) that decodes/encodes the value of common components and generates a clean SVG picture you can attach to a part — handy for assortments imported with blank thumbnails and sparse data. Calculator tabs (bidirectional decode <-> encode), each drawing the part to the selected package with a collapsible appearance panel and an "attach to part" action (optionally as the master picture): - Resistor: 4/5/6-band colour code - Capacitor: value <-> 3-digit code <-> tolerance - SMD resistor: value <-> 3-digit / 4-digit / EIA-96 code - Inductor: colour-band code - SMD inductor: value <-> uH code Bulk "Generate component images" parts-table action: - Classifies each selected part (resistor / capacitor / inductor / diode / LED, THT or SMD) with ComponentValueGuesser and detects value, voltage, tolerance, power, ppm, pitch, diameter, colour, SMD package and marking from the name, description and parameters. - Renders a preview per part, lets you tweak appearance, and attaches the images in one go; can also write matching KiCad symbol/footprint/reference-prefix EDA fields. Backend: ComponentValueGuesser (classification + detection + EDA suggestion), GeneratedImageAttachmentHelper (stores the SVG through the existing attachment pipeline, so it is sanitised on save), two POST endpoints on PartController (generate_image, set_eda) guarded by `edit` + CSRF, a new `@tools.value_calculator` permission, a Tools-tree entry and docs. All drawing is client-side in a Stimulus controller; the un-sanitised live preview escapes part-derived text and validates colours as defence-in-depth. Tests: unit tests for ComponentValueGuesser and functional tests for the endpoints, including permission and CSRF enforcement and the persisted side effects.
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
/*
|
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-server).
|
|
*
|
|
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {Controller} from "@hotwired/stimulus";
|
|
import {AlertSwal} from "../../helpers/swal";
|
|
import {trans} from "../../translator.js";
|
|
|
|
/**
|
|
* Drives the hidden value-calculator to render a preview for every candidate row, then attaches the
|
|
* checked ones to their parts via the per-part generate-image endpoint (with a progress bar).
|
|
*/
|
|
export default class extends Controller {
|
|
static targets = ["row", "progress", "progressBar", "attachBtn", "edaBtn",
|
|
"batchPitch", "batchDiameter", "batchColor", "batchVoltage", "batchShape", "batchLead", "batchTolerance",
|
|
"batchPower", "batchPpm", "batchPackage"];
|
|
|
|
connect() {
|
|
this.tryRenderPreviews(0);
|
|
}
|
|
|
|
/** The value-calculator controller instance (retries briefly, since it may connect after us). */
|
|
calcController() {
|
|
const el = this.element.querySelector('[data-controller*="alueCalculator"], [data-controller*="alue-calculator"]');
|
|
if (!el) {
|
|
return null;
|
|
}
|
|
for (const id of ["pages--valueCalculator", "pages--value-calculator"]) {
|
|
const c = this.application.getControllerForElementAndIdentifier(el, id);
|
|
if (c && typeof c.generateSvg === "function") {
|
|
return c;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
tryRenderPreviews(attempt) {
|
|
const calc = this.calcController();
|
|
if (!calc) {
|
|
if (attempt < 20) {
|
|
setTimeout(() => this.tryRenderPreviews(attempt + 1), 50);
|
|
}
|
|
return;
|
|
}
|
|
this.rowTargets.forEach((row) => this.renderPreview(row, calc));
|
|
}
|
|
|
|
renderPreview(row, calc) {
|
|
//Each row carries its own appearance inputs; shape/lead length are batch-wide.
|
|
const colorEl = row.querySelector("[data-row-color]");
|
|
const diamEl = row.querySelector("[data-row-diameter]");
|
|
const pitchEl = row.querySelector("[data-row-pitch]");
|
|
const voltEl = row.querySelector("[data-row-voltage]");
|
|
const tolEl = row.querySelector("[data-row-tolerance]");
|
|
const powerEl = row.querySelector("[data-row-power]");
|
|
const ppmEl = row.querySelector("[data-row-ppm]");
|
|
const pkgEl = row.querySelector("[data-row-package]");
|
|
const svg = calc.generateSvg(row.dataset.type, parseFloat(row.dataset.value), {
|
|
voltage: voltEl && voltEl.value ? parseFloat(voltEl.value) : (row.dataset.voltage ? parseFloat(row.dataset.voltage) : 0),
|
|
package: pkgEl && pkgEl.value ? pkgEl.value : (row.dataset.package || null),
|
|
power: powerEl && powerEl.value ? parseFloat(powerEl.value) : (row.dataset.power ? parseFloat(row.dataset.power) : 0),
|
|
ppm: ppmEl && ppmEl.value ? parseFloat(ppmEl.value) : (row.dataset.ppm ? parseFloat(row.dataset.ppm) : 0),
|
|
tolerance: tolEl
|
|
? (tolEl.value ? parseFloat(tolEl.value) : null)
|
|
: (row.dataset.tolerance ? parseFloat(row.dataset.tolerance) : null),
|
|
pitch: pitchEl ? pitchEl.value : (row.dataset.pitch || null),
|
|
diameter: diamEl && diamEl.value ? parseFloat(diamEl.value) : (row.dataset.diameter ? parseFloat(row.dataset.diameter) : 0),
|
|
subtype: row.dataset.subtype || null,
|
|
marking: row.dataset.marking || null,
|
|
bodyColor: colorEl ? colorEl.value : null,
|
|
shape: this.hasBatchShapeTarget ? this.batchShapeTarget.value : "disc",
|
|
leadLength: this.hasBatchLeadTarget ? this.batchLeadTarget.value : "medium",
|
|
});
|
|
row.dataset.svg = svg;
|
|
const cell = row.querySelector("[data-bulk-preview]");
|
|
if (cell) {
|
|
cell.innerHTML = svg || "";
|
|
}
|
|
//Clear the calculator's scratch SVG so its leftover copy can't collide (duplicate ids)
|
|
//with the copy we just placed in this row's cell — that made the last preview render off.
|
|
if (typeof calc.clearScratchSvg === "function") {
|
|
calc.clearScratchSvg();
|
|
}
|
|
}
|
|
|
|
/** Re-render every preview (used by batch controls and the shape/lead selects). */
|
|
regenerate() {
|
|
const calc = this.calcController();
|
|
if (calc) {
|
|
this.rowTargets.forEach((row) => this.renderPreview(row, calc));
|
|
}
|
|
}
|
|
|
|
/** Re-render only the row whose per-row appearance input changed. */
|
|
regenerateRow(event) {
|
|
const calc = this.calcController();
|
|
const row = event.target.closest("tr");
|
|
if (calc && row) {
|
|
this.renderPreview(row, calc);
|
|
}
|
|
}
|
|
|
|
/** Copy a batch-bar value into every row's matching input, then re-render. */
|
|
applyToAll(selector, value) {
|
|
if (value === "" || value === null || value === undefined) {
|
|
return;
|
|
}
|
|
this.rowTargets.forEach((row) => {
|
|
const el = row.querySelector(selector);
|
|
if (el) {
|
|
el.value = value;
|
|
}
|
|
});
|
|
this.regenerate();
|
|
}
|
|
|
|
applyColor() {
|
|
this.applyToAll("[data-row-color]", this.hasBatchColorTarget ? this.batchColorTarget.value : "");
|
|
}
|
|
|
|
applyDiameter() {
|
|
this.applyToAll("[data-row-diameter]", this.hasBatchDiameterTarget ? this.batchDiameterTarget.value : "");
|
|
}
|
|
|
|
applyPitch() {
|
|
this.applyToAll("[data-row-pitch]", this.hasBatchPitchTarget ? this.batchPitchTarget.value : "");
|
|
}
|
|
|
|
applyVoltage() {
|
|
this.applyToAll("[data-row-voltage]", this.hasBatchVoltageTarget ? this.batchVoltageTarget.value : "");
|
|
}
|
|
|
|
applyPower() {
|
|
this.applyToAll("[data-row-power]", this.hasBatchPowerTarget ? this.batchPowerTarget.value : "");
|
|
}
|
|
|
|
applyPpm() {
|
|
this.applyToAll("[data-row-ppm]", this.hasBatchPpmTarget ? this.batchPpmTarget.value : "");
|
|
}
|
|
|
|
applyPackage() {
|
|
this.applyToAll("[data-row-package]", this.hasBatchPackageTarget ? this.batchPackageTarget.value : "");
|
|
}
|
|
|
|
/**
|
|
* Applies the batch tolerance to every row that offers that option (tolerance choices differ
|
|
* between capacitors and resistors). "—" always applies, so it can also clear every row.
|
|
*/
|
|
applyTolerance() {
|
|
if (!this.hasBatchToleranceTarget) {
|
|
return;
|
|
}
|
|
const value = this.batchToleranceTarget.value;
|
|
this.rowTargets.forEach((row) => {
|
|
const el = row.querySelector("[data-row-tolerance]");
|
|
if (el && (value === "" || Array.from(el.options).some((o) => o.value === value))) {
|
|
el.value = value;
|
|
}
|
|
});
|
|
this.regenerate();
|
|
}
|
|
|
|
/** A body-colour quick-pick button: set the batch colour input, then apply it to all rows. */
|
|
pickColor(event) {
|
|
if (this.hasBatchColorTarget) {
|
|
this.batchColorTarget.value = event.currentTarget.dataset.color;
|
|
}
|
|
this.applyColor();
|
|
}
|
|
|
|
/** Returns to the previous page (the parts list the action came from); the link's href is the no-JS fallback. */
|
|
goBack(event) {
|
|
if (window.history.length > 1) {
|
|
event.preventDefault();
|
|
window.history.back();
|
|
}
|
|
}
|
|
|
|
/** Header checkbox: check/uncheck every row. */
|
|
toggleAll(event) {
|
|
const checked = event.currentTarget.checked;
|
|
this.rowTargets.forEach((row) => {
|
|
const cb = row.querySelector("input[type=checkbox]");
|
|
if (cb) {
|
|
cb.checked = checked;
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Attaches the generated picture of each checked row to its part. */
|
|
attachSelected() {
|
|
return this.runBatch(
|
|
(row) => {
|
|
if (!(row.dataset.svg || "").includes("<svg")) {
|
|
return null;
|
|
}
|
|
const body = new FormData();
|
|
body.append("svg", row.dataset.svg);
|
|
body.append("name", row.dataset.name || "Generated image");
|
|
body.append("preview", "1");
|
|
//Replace the existing generated image for parts that already had a picture.
|
|
if (row.dataset.overwrite) {
|
|
body.append("overwrite", "1");
|
|
}
|
|
body.append("_token", row.dataset.csrf || "");
|
|
return {url: row.dataset.endpoint, body};
|
|
},
|
|
trans("tools.bulk_gen.attached"),
|
|
this.hasAttachBtnTarget ? this.attachBtnTarget : null
|
|
);
|
|
}
|
|
|
|
/** Writes the (editable) KiCad symbol / footprint / reference of each checked row to its part. */
|
|
writeEda() {
|
|
return this.runBatch(
|
|
(row) => {
|
|
//Prefer the values the user may have edited in the row's inputs; fall back to the suggestions.
|
|
const symInput = row.querySelector("[data-bulk-eda-symbol]");
|
|
const refInput = row.querySelector("[data-bulk-eda-reference]");
|
|
const fpInput = row.querySelector("[data-bulk-eda-footprint]");
|
|
const body = new FormData();
|
|
body.append("kicad_symbol", symInput ? symInput.value.trim() : (row.dataset.kicadSymbol || ""));
|
|
body.append("reference_prefix", refInput ? refInput.value.trim() : (row.dataset.referencePrefix || ""));
|
|
body.append("kicad_footprint", fpInput ? fpInput.value.trim() : (row.dataset.kicadFootprint || ""));
|
|
body.append("_token", row.dataset.edaCsrf || "");
|
|
return {url: row.dataset.edaEndpoint, body};
|
|
},
|
|
trans("tools.bulk_gen.eda_written"),
|
|
this.hasEdaBtnTarget ? this.edaBtnTarget : null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Runs a POST for every checked row, updating the progress bar. buildRequest(row) returns
|
|
* {url, body} or null to skip the row.
|
|
*/
|
|
async runBatch(buildRequest, doneWord, btn) {
|
|
const rows = this.rowTargets.filter((row) => {
|
|
const cb = row.querySelector("input[type=checkbox]");
|
|
return cb && cb.checked;
|
|
});
|
|
if (rows.length === 0) {
|
|
AlertSwal.fire({title: trans("tools.value_calc.attach.nothing")});
|
|
return;
|
|
}
|
|
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
}
|
|
if (this.hasProgressTarget) {
|
|
this.progressTarget.classList.remove("d-none");
|
|
}
|
|
|
|
let done = 0;
|
|
let ok = 0;
|
|
let failed = 0;
|
|
for (const row of rows) {
|
|
const req = buildRequest(row);
|
|
if (!req) {
|
|
done++;
|
|
this.updateProgress(done, rows.length);
|
|
continue;
|
|
}
|
|
try {
|
|
const resp = await fetch(req.url, {method: "POST", body: req.body, headers: {"X-Requested-With": "XMLHttpRequest"}});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (resp.ok && data && data.success) {
|
|
ok++;
|
|
row.classList.add("table-success");
|
|
} else {
|
|
failed++;
|
|
row.classList.add("table-danger");
|
|
}
|
|
} catch (e) {
|
|
failed++;
|
|
row.classList.add("table-danger");
|
|
}
|
|
done++;
|
|
this.updateProgress(done, rows.length);
|
|
}
|
|
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
}
|
|
AlertSwal.fire({
|
|
title: `${ok} / ${rows.length} ${doneWord}${failed ? ` · ${failed} ${trans("tools.bulk_gen.failed")}` : ""}`,
|
|
icon: failed ? "warning" : "success",
|
|
});
|
|
}
|
|
|
|
updateProgress(done, total) {
|
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
if (this.hasProgressBarTarget) {
|
|
this.progressBarTarget.style.width = pct + "%";
|
|
this.progressBarTarget.textContent = `${done}/${total}`;
|
|
}
|
|
}
|
|
}
|