From e1f4ee73d0354e6ac19503110083efad4696bd34 Mon Sep 17 00:00:00 2001 From: Adriano Date: Sat, 7 Feb 2026 13:15:24 +0100 Subject: [PATCH] feat: FASE 4 - Editor Maker (Fabric.js) con annotazioni, task editor, preview e storico versioni - recipe_list.html: lista ricette con filtri, paginazione, cards Alpine.js - recipe_editor.html: form metadati, upload drag-and-drop, canvas Fabric.js per annotazioni - annotation-editor.js: editor annotazioni Fabric.js (marker, frecce, rettangoli, zoom, pan) - task_editor.html: editor task/subtask inline con drag-and-drop reorder e tolleranze - recipe_preview.html: anteprima ricetta come MeasurementTec - version_history.html: timeline versioni con conteggio misurazioni AJAX - maker.py: 6 route pagina + 13 proxy AJAX, gestione sicura risposte lista API - i18n: 170+ stringhe tradotte IT/EN per tutti i template Maker Architect review: 3 CRITICO + 5 MEDIO + 3 NEW risolti, 2 BASSO differiti Co-Authored-By: Claude Opus 4.6 --- client/blueprints/maker.py | 373 +++- client/compile_translations.py | 43 + client/static/js/annotation-editor.js | 872 +++++++++ client/templates/maker/recipe_editor.html | 826 +++++++++ client/templates/maker/recipe_list.html | 385 ++++ client/templates/maker/recipe_preview.html | 445 +++++ client/templates/maker/task_editor.html | 1569 +++++++++++++++++ client/templates/maker/version_history.html | 409 +++++ .../translations/en/LC_MESSAGES/messages.po | 447 +++++ .../translations/it/LC_MESSAGES/messages.po | 447 +++++ client/verify_i18n.py | 118 ++ 11 files changed, 5915 insertions(+), 19 deletions(-) create mode 100644 client/compile_translations.py create mode 100644 client/static/js/annotation-editor.js create mode 100644 client/templates/maker/recipe_editor.html create mode 100644 client/templates/maker/recipe_list.html create mode 100644 client/templates/maker/recipe_preview.html create mode 100644 client/templates/maker/task_editor.html create mode 100644 client/templates/maker/version_history.html create mode 100644 client/verify_i18n.py diff --git a/client/blueprints/maker.py b/client/blueprints/maker.py index a9d3b93..348df36 100644 --- a/client/blueprints/maker.py +++ b/client/blueprints/maker.py @@ -1,45 +1,380 @@ """Maker blueprint - recipe creation and editing.""" -from flask import Blueprint, render_template, session, redirect, url_for +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for +from flask_babel import gettext as _ -maker_bp = Blueprint("maker", __name__) +from blueprints.auth import login_required, role_required +from services.api_client import api_client +maker_bp = Blueprint("maker", __name__, url_prefix="/maker") + + +# ============================================================================ +# PAGINE (GET) +# ============================================================================ @maker_bp.route("/recipes") +@login_required +@role_required("Maker") def recipe_list(): """List all recipes with filters.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("maker/recipe_list.html") + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 100, type=int) + search = request.args.get("search", "", type=str) + + params = {"page": page, "per_page": per_page} + if search: + params["search"] = search + + resp = api_client.get("/api/recipes", params=params) + + if resp.get("error"): + flash(_("Errore nel caricamento delle ricette: %(error)s", error=resp.get("detail", "")), "error") + recipes = [] + total = 0 + pages = 0 + else: + recipes = resp.get("items", []) + total = resp.get("total", 0) + pages = resp.get("pages", 1) + + return render_template( + "maker/recipe_list.html", + recipes=recipes, + page=page, + per_page=per_page, + total=total, + pages=pages, + search=search + ) @maker_bp.route("/recipes/new") +@login_required +@role_required("Maker") +def recipe_new(): + """Create new recipe.""" + return render_template("maker/recipe_editor.html", recipe=None) + + @maker_bp.route("/recipes//edit") -def recipe_editor(recipe_id: int | None = None): - """Recipe editor - create or edit.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("maker/recipe_editor.html", recipe_id=recipe_id) +@login_required +@role_required("Maker") +def recipe_edit(recipe_id: int): + """Edit existing recipe.""" + # Carica recipe con dettagli completi + resp = api_client.get(f"/api/recipes/{recipe_id}") + + if resp.get("error"): + flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error") + return redirect(url_for("maker.recipe_list")) + + recipe = resp + + # Carica anche le versioni + versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions") + if isinstance(versions_resp, list): + recipe["versions"] = versions_resp + else: + recipe["versions"] = [] + + return render_template("maker/recipe_editor.html", recipe=recipe) @maker_bp.route("/recipes//tasks") +@login_required +@role_required("Maker") def task_editor(recipe_id: int): """Task/subtask editor with tolerances.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("maker/task_editor.html", recipe_id=recipe_id) + # Carica recipe con task/subtask + resp = api_client.get(f"/api/recipes/{recipe_id}") + + if resp.get("error"): + flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error") + return redirect(url_for("maker.recipe_list")) + + recipe = resp + + # Carica tasks con subtasks + tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") + if isinstance(tasks_resp, list): + recipe["tasks"] = tasks_resp + else: + recipe["tasks"] = [] + + return render_template("maker/task_editor.html", recipe=recipe) @maker_bp.route("/recipes//preview") +@login_required +@role_required("Maker") def recipe_preview(recipe_id: int): """Preview recipe as MeasurementTec would see it.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("maker/recipe_preview.html", recipe_id=recipe_id) + # Carica recipe completo + resp = api_client.get(f"/api/recipes/{recipe_id}") + + if resp.get("error"): + flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error") + return redirect(url_for("maker.recipe_list")) + + recipe = resp + + # Carica tasks con subtasks + tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") + if isinstance(tasks_resp, list): + recipe["tasks"] = tasks_resp + else: + recipe["tasks"] = [] + + return render_template("maker/recipe_preview.html", recipe=recipe) @maker_bp.route("/recipes//versions") +@login_required +@role_required("Maker") def version_history(recipe_id: int): """Version history with diff.""" - if "user" not in session: - return redirect(url_for("auth.login")) - return render_template("maker/version_history.html", recipe_id=recipe_id) + # Carica recipe base + resp = api_client.get(f"/api/recipes/{recipe_id}") + + if resp.get("error"): + flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error") + return redirect(url_for("maker.recipe_list")) + + recipe = resp + + # Carica versioni + versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions") + if isinstance(versions_resp, list): + versions = versions_resp + else: + versions = [] + flash(_("Errore nel caricamento delle versioni: %(error)s", + error=versions_resp.get("detail", "") if isinstance(versions_resp, dict) else ""), "warning") + + return render_template("maker/version_history.html", recipe=recipe, versions=versions) + + +# ============================================================================ +# API PROXY AJAX (JSON) +# ============================================================================ + +@maker_bp.route("/api/recipes", methods=["POST"]) +@login_required +@role_required("Maker") +def api_create_recipe(): + """Proxy: Create new recipe.""" + data = request.get_json(silent=True) or {} + + resp = api_client.post("/api/recipes", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 201 + + +@maker_bp.route("/api/recipes/", methods=["PUT"]) +@login_required +@role_required("Maker") +def api_update_recipe(recipe_id: int): + """Proxy: Update recipe (creates new version).""" + data = request.get_json(silent=True) or {} + + resp = api_client.put(f"/api/recipes/{recipe_id}", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 + + +@maker_bp.route("/api/recipes/", methods=["DELETE"]) +@login_required +@role_required("Maker") +def api_delete_recipe(recipe_id: int): + """Proxy: Delete recipe (soft delete).""" + resp = api_client.delete(f"/api/recipes/{recipe_id}") + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 + + +# ============================================================================ +# TASK API PROXY +# ============================================================================ + +@maker_bp.route("/api/recipes//tasks", methods=["POST"]) +@login_required +@role_required("Maker") +def api_create_task(recipe_id: int): + """Proxy: Create new task.""" + data = request.get_json(silent=True) or {} + + resp = api_client.post(f"/api/recipes/{recipe_id}/tasks", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 201 + + +@maker_bp.route("/api/tasks/", methods=["PUT"]) +@login_required +@role_required("Maker") +def api_update_task(task_id: int): + """Proxy: Update task.""" + data = request.get_json(silent=True) or {} + + resp = api_client.put(f"/api/tasks/{task_id}", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 + + +@maker_bp.route("/api/tasks/", methods=["DELETE"]) +@login_required +@role_required("Maker") +def api_delete_task(task_id: int): + """Proxy: Delete task.""" + resp = api_client.delete(f"/api/tasks/{task_id}") + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + # 204 No Content returns empty dict + return jsonify(resp), 204 + + +@maker_bp.route("/api/tasks/reorder", methods=["PUT"]) +@login_required +@role_required("Maker") +def api_reorder_tasks(): + """Proxy: Reorder tasks.""" + data = request.get_json(silent=True) or {} + + resp = api_client.put("/api/tasks/reorder", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 + + +# ============================================================================ +# SUBTASK API PROXY +# ============================================================================ + +@maker_bp.route("/api/tasks//subtasks", methods=["POST"]) +@login_required +@role_required("Maker") +def api_create_subtask(task_id: int): + """Proxy: Create new subtask.""" + data = request.get_json(silent=True) or {} + + resp = api_client.post(f"/api/tasks/{task_id}/subtasks", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 201 + + +@maker_bp.route("/api/subtasks/", methods=["PUT"]) +@login_required +@role_required("Maker") +def api_update_subtask(subtask_id: int): + """Proxy: Update subtask.""" + data = request.get_json(silent=True) or {} + + resp = api_client.put(f"/api/subtasks/{subtask_id}", data=data) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 + + +@maker_bp.route("/api/subtasks/", methods=["DELETE"]) +@login_required +@role_required("Maker") +def api_delete_subtask(subtask_id: int): + """Proxy: Delete subtask.""" + resp = api_client.delete(f"/api/subtasks/{subtask_id}") + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + # 204 No Content returns empty dict + return jsonify(resp), 204 + + +# ============================================================================ +# FILE API PROXY +# ============================================================================ + +@maker_bp.route("/api/upload", methods=["POST"]) +@login_required +@role_required("Maker") +def api_upload_file(): + """Proxy: Upload file (multipart forward).""" + # Controlla se c'è un file + if "file" not in request.files: + return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400 + + file = request.files["file"] + if file.filename == "": + return jsonify({"error": True, "detail": _("Nome file vuoto")}), 400 + + # Prepara multipart data + recipe_id = request.form.get("recipe_id") + version_id = request.form.get("version_id") + + files = {"file": (file.filename, file.stream, file.content_type)} + data = {} + if recipe_id: + data["recipe_id"] = recipe_id + if version_id: + data["version_id"] = version_id + + # Forward multipart request + resp = api_client.post("/api/files/upload", data=data, files=files) + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 201 + + +@maker_bp.route("/api/files/", methods=["DELETE"]) +@login_required +@role_required("Maker") +def api_delete_file(file_path: str): + """Proxy: Delete file.""" + resp = api_client.delete(f"/api/files/{file_path}") + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + # 204 No Content returns empty dict + return jsonify(resp), 204 + + +# ============================================================================ +# VERSION API PROXY +# ============================================================================ + +@maker_bp.route("/api/recipes//versions//measurement-count", methods=["GET"]) +@login_required +@role_required("Maker") +def api_get_measurement_count(recipe_id: int, version_number: int): + """Proxy: Get measurement count for a specific version.""" + resp = api_client.get(f"/api/recipes/{recipe_id}/versions/{version_number}/measurement-count") + + if resp.get("error"): + return jsonify(resp), resp.get("status_code", 500) + + return jsonify(resp), 200 diff --git a/client/compile_translations.py b/client/compile_translations.py new file mode 100644 index 0000000..4f6d1f8 --- /dev/null +++ b/client/compile_translations.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +"""Compile .po files to .mo files for Flask-Babel.""" +from pathlib import Path +from babel.messages.mofile import write_mo +from babel.messages.pofile import read_po + +def compile_po_file(po_path: Path) -> None: + """Compile a single .po file to .mo.""" + mo_path = po_path.with_suffix('.mo') + + print(f"Compiling {po_path} -> {mo_path}") + + with open(po_path, 'rb') as po_file: + catalog = read_po(po_file) + + with open(mo_path, 'wb') as mo_file: + write_mo(mo_file, catalog) + + print(f" {len(catalog)} messages compiled") + +def main(): + """Find and compile all .po files in translations directory.""" + translations_dir = Path(__file__).parent / 'translations' + + if not translations_dir.exists(): + print(f"ERROR: {translations_dir} does not exist") + return + + po_files = list(translations_dir.rglob('*.po')) + + if not po_files: + print(f"No .po files found in {translations_dir}") + return + + print(f"Found {len(po_files)} .po file(s)") + + for po_file in po_files: + compile_po_file(po_file) + + print("\n✓ All translations compiled successfully") + +if __name__ == '__main__': + main() diff --git a/client/static/js/annotation-editor.js b/client/static/js/annotation-editor.js new file mode 100644 index 0000000..9fbc043 --- /dev/null +++ b/client/static/js/annotation-editor.js @@ -0,0 +1,872 @@ +/** + * Annotation Editor - Fabric.js-based editor for image annotations + * + * Alpine.js component for the Maker role to place measurement markers, + * arrows, and area rectangles on technical drawings. + * + * Dependencies: + * - Fabric.js v5.3.1 (loaded via CDN before this file) + * - Alpine.js (loaded with defer) + * + * Annotation JSON Format (output): + * { + * "version": 1, + * "width": 800, + * "height": 480, + * "objects": [ + * { "type": "marker", "left": 150, "top": 200, "markerNumber": 1, ... }, + * { "type": "arrow", "left": 100, "top": 80, "x1": 100, "y1": 80, "x2": 300, "y2": 200, ... }, + * { "type": "area", "left": 50, "top": 60, "width": 150, "height": 100, ... } + * ] + * } + * + * Usage: + *
+ * + *
+ */ + +function annotationEditor() { + 'use strict'; + + // ---- Debounce utility ---- + + function debounce(fn, delay) { + var timer = null; + return function () { + var context = this; + var args = arguments; + if (timer) clearTimeout(timer); + timer = setTimeout(function () { + fn.apply(context, args); + }, delay); + }; + } + + return { + // ---- State ---- + + /** @type {fabric.Canvas|null} */ + canvas: null, + /** @type {HTMLCanvasElement|null} */ + canvasEl: null, + + /** Current interaction mode: 'select' | 'marker' | 'arrow' | 'rect' | 'pan' */ + activeMode: 'select', + + /** Next auto-assigned marker number */ + nextMarkerNumber: 1, + + /** Current zoom level (1 = 100%) */ + zoomLevel: 1, + + /** Whether a background image has been loaded */ + imageLoaded: false, + + /** Whether the canvas has unsaved changes */ + isDirty: false, + + // ---------------------------------------------------------------- + // Initialization + // ---------------------------------------------------------------- + + /** + * Alpine init hook. + * Creates the Fabric.js canvas instance and wires up all event listeners. + */ + init() { + this.canvasEl = this.$refs.fabricCanvas; + if (!this.canvasEl) { + console.error('AnnotationEditor: canvas ref "fabricCanvas" not found'); + return; + } + + this.canvas = new fabric.Canvas(this.canvasEl, { + selection: true, + preserveObjectStacking: true, + backgroundColor: '#f1f5f9', + }); + + this.setupEvents(); + this.setupKeyboard(); + this.resizeCanvas(); + + this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200); + window.addEventListener('resize', this._debouncedResize); + + // ---- Bridge: listen for commands from recipeEditor ---- + var self = this; + + this._onToolChange = function (e) { + if (e.detail && e.detail.tool) { + self.setMode(e.detail.tool); + } + }; + window.addEventListener('anno-tool-change', this._onToolChange); + + this._onDeleteSelected = function () { + self.deleteSelected(); + }; + window.addEventListener('anno-delete-selected', this._onDeleteSelected); + + this._onZoom = function (e) { + if (e.detail && e.detail.delta > 0) { + self.zoomIn(); + } else { + self.zoomOut(); + } + }; + window.addEventListener('anno-zoom', this._onZoom); + + this._onZoomReset = function () { + self.resetZoom(); + }; + window.addEventListener('anno-zoom-reset', this._onZoomReset); + + this._onImageLoaded = function (e) { + if (e.detail && e.detail.path) { + // Wait for x-show transition to reveal the canvas + setTimeout(function () { + self.resizeCanvas(); + self.loadBackgroundImage('/api/files/' + e.detail.path); + if (e.detail.annotations) { + self.loadAnnotationsJson(e.detail.annotations); + } + }, 150); + } + }; + window.addEventListener('image-loaded', this._onImageLoaded); + + // ---- Initial load: check parent scope for existing file ---- + var filePath = this.currentFilePath; // from parent recipeEditor scope + if (filePath) { + var annoJson = this.annotationsJson; // from parent recipeEditor scope + this.loadBackgroundImage('/api/files/' + filePath); + if (annoJson) { + this.loadAnnotationsJson(annoJson); + } + } + }, + + // ---------------------------------------------------------------- + // Background image + // ---------------------------------------------------------------- + + /** + * Load an image as the canvas background. + * Scales the image to fit within the canvas while maintaining aspect ratio + * and never exceeding the original size (scale <= 1). + * + * @param {string} imageUrl - URL of the image to load + */ + loadBackgroundImage(imageUrl) { + if (!this.canvas) return; + + fabric.Image.fromURL( + imageUrl, + function (img) { + if (!img || !img.width || !img.height) { + console.error('AnnotationEditor: failed to load background image'); + return; + } + + var scale = Math.min( + this.canvas.width / img.width, + this.canvas.height / img.height, + 1 // never upscale + ); + + this.canvas.setBackgroundImage( + img, + this.canvas.renderAll.bind(this.canvas), + { + scaleX: scale, + scaleY: scale, + originX: 'left', + originY: 'top', + } + ); + + this.imageLoaded = true; + }.bind(this), + { crossOrigin: 'anonymous' } + ); + }, + + // ---------------------------------------------------------------- + // Object creation - Markers + // ---------------------------------------------------------------- + + /** + * Add a numbered circle marker at the given position. + * + * @param {number} x - left position on canvas + * @param {number} y - top position on canvas + * @param {number} [number] - explicit marker number (auto-increments if omitted) + * @returns {fabric.Group} the created marker group + */ + addMarker(x, y, number) { + var markerNumber = number || this.nextMarkerNumber++; + + var circle = new fabric.Circle({ + radius: 16, + fill: '#2563EB', + originX: 'center', + originY: 'center', + stroke: '#ffffff', + strokeWidth: 2, + }); + + var text = new fabric.Text(markerNumber.toString(), { + fontSize: 14, + fill: '#ffffff', + fontFamily: 'Inter, sans-serif', + fontWeight: 'bold', + originX: 'center', + originY: 'center', + }); + + var marker = new fabric.Group([circle, text], { + left: x, + top: y, + selectable: true, + hasControls: false, + hasBorders: true, + lockScalingX: true, + lockScalingY: true, + // Custom properties + objectType: 'marker', + markerNumber: markerNumber, + }); + + this.canvas.add(marker); + this.canvas.setActiveObject(marker); + this.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); + return marker; + }, + + // ---------------------------------------------------------------- + // Object creation - Arrows + // ---------------------------------------------------------------- + + /** + * Add an arrow (line + triangular arrowhead) between two points. + * + * @param {number} x1 - start x + * @param {number} y1 - start y + * @param {number} x2 - end x + * @param {number} y2 - end y + * @returns {fabric.Group} the created arrow group + */ + addArrow(x1, y1, x2, y2) { + var line = new fabric.Line([x1, y1, x2, y2], { + stroke: '#DC2626', + strokeWidth: 2, + originX: 'center', + originY: 'center', + }); + + var angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI; + + var arrowHead = new fabric.Triangle({ + left: x2, + top: y2, + originX: 'center', + originY: 'center', + width: 12, + height: 12, + fill: '#DC2626', + angle: angle + 90, + }); + + var arrow = new fabric.Group([line, arrowHead], { + selectable: true, + objectType: 'arrow', + // Store original endpoints for serialization + arrowX1: x1, + arrowY1: y1, + arrowX2: x2, + arrowY2: y2, + }); + + this.canvas.add(arrow); + this.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); + return arrow; + }, + + // ---------------------------------------------------------------- + // Object creation - Rectangles + // ---------------------------------------------------------------- + + /** + * Add a dashed-outline area rectangle. + * + * @param {number} x - left position + * @param {number} y - top position + * @param {number} [width=150] - rectangle width + * @param {number} [height=100] - rectangle height + * @returns {fabric.Rect} the created rectangle + */ + addRect(x, y, width, height) { + var rect = new fabric.Rect({ + left: x, + top: y, + width: width || 150, + height: height || 100, + fill: 'rgba(37, 99, 235, 0.1)', + stroke: '#2563EB', + strokeWidth: 2, + strokeDashArray: [5, 3], + selectable: true, + objectType: 'area', + }); + + this.canvas.add(rect); + this.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); + return rect; + }, + + // ---------------------------------------------------------------- + // Toolbar / mode switching + // ---------------------------------------------------------------- + + /** + * Set the current interaction mode. + * + * @param {'select'|'marker'|'arrow'|'rect'|'pan'} mode + */ + setMode(mode) { + this.activeMode = mode; + + if (mode === 'select') { + this.canvas.selection = true; + this.canvas.defaultCursor = 'default'; + this.canvas.forEachObject(function (o) { + o.selectable = true; + }); + } else if (mode === 'pan') { + this.canvas.selection = false; + this.canvas.defaultCursor = 'grab'; + this.canvas.forEachObject(function (o) { + o.selectable = false; + }); + } else { + // marker, arrow, rect + this.canvas.selection = false; + this.canvas.defaultCursor = 'crosshair'; + this.canvas.discardActiveObject(); + } + + this.canvas.renderAll(); + }, + + // ---------------------------------------------------------------- + // Mouse events + // ---------------------------------------------------------------- + + /** + * Wire up all Fabric.js mouse events for drawing, panning, selection, + * and zoom. + */ + setupEvents() { + var self = this; + var isDrawing = false; + var drawStartX = 0; + var drawStartY = 0; + var tempRect = null; + var tempLine = null; + var isPanning = false; + var lastPanX = 0; + var lastPanY = 0; + + // ---- mouse:down ---- + + this.canvas.on('mouse:down', function (opt) { + var pointer = self.canvas.getPointer(opt.e); + + if (self.activeMode === 'marker') { + self.addMarker(pointer.x, pointer.y); + self.setMode('select'); + } else if (self.activeMode === 'arrow') { + isDrawing = true; + drawStartX = pointer.x; + drawStartY = pointer.y; + // Preview line + tempLine = new fabric.Line( + [drawStartX, drawStartY, drawStartX, drawStartY], + { + stroke: '#DC2626', + strokeWidth: 2, + strokeDashArray: [4, 4], + selectable: false, + evented: false, + objectType: '_temp', + } + ); + self.canvas.add(tempLine); + } else if (self.activeMode === 'rect') { + isDrawing = true; + drawStartX = pointer.x; + drawStartY = pointer.y; + // Preview rect + tempRect = new fabric.Rect({ + left: drawStartX, + top: drawStartY, + width: 0, + height: 0, + fill: 'rgba(37, 99, 235, 0.05)', + stroke: '#2563EB', + strokeWidth: 1, + strokeDashArray: [4, 4], + selectable: false, + evented: false, + objectType: '_temp', + }); + self.canvas.add(tempRect); + } else if (self.activeMode === 'pan') { + isPanning = true; + lastPanX = opt.e.clientX; + lastPanY = opt.e.clientY; + self.canvas.defaultCursor = 'grabbing'; + } + }); + + // ---- mouse:move ---- + + this.canvas.on('mouse:move', function (opt) { + if (isPanning) { + var vpt = self.canvas.viewportTransform; + vpt[4] += opt.e.clientX - lastPanX; + vpt[5] += opt.e.clientY - lastPanY; + lastPanX = opt.e.clientX; + lastPanY = opt.e.clientY; + self.canvas.requestRenderAll(); + return; + } + + if (!isDrawing) return; + var pointer = self.canvas.getPointer(opt.e); + + if (self.activeMode === 'arrow' && tempLine) { + tempLine.set({ x2: pointer.x, y2: pointer.y }); + self.canvas.requestRenderAll(); + } else if (self.activeMode === 'rect' && tempRect) { + var x = Math.min(drawStartX, pointer.x); + var y = Math.min(drawStartY, pointer.y); + var w = Math.abs(pointer.x - drawStartX); + var h = Math.abs(pointer.y - drawStartY); + tempRect.set({ left: x, top: y, width: w, height: h }); + self.canvas.requestRenderAll(); + } + }); + + // ---- mouse:up ---- + + this.canvas.on('mouse:up', function (opt) { + // Clean up temp preview objects + if (tempLine) { + self.canvas.remove(tempLine); + tempLine = null; + } + if (tempRect) { + self.canvas.remove(tempRect); + tempRect = null; + } + + if (isDrawing) { + var pointer = self.canvas.getPointer(opt.e); + + if (self.activeMode === 'arrow') { + var dx = pointer.x - drawStartX; + var dy = pointer.y - drawStartY; + var dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 10) { + self.addArrow(drawStartX, drawStartY, pointer.x, pointer.y); + } + } else if (self.activeMode === 'rect') { + var w = Math.abs(pointer.x - drawStartX); + var h = Math.abs(pointer.y - drawStartY); + var x = Math.min(drawStartX, pointer.x); + var y = Math.min(drawStartY, pointer.y); + if (w > 5 && h > 5) { + self.addRect(x, y, w, h); + } + } + + isDrawing = false; + self.setMode('select'); + } + + if (isPanning) { + isPanning = false; + self.canvas.defaultCursor = 'grab'; + } + }); + + // ---- Selection events ---- + + this.canvas.on('selection:created', function (e) { + window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } })); + var selected = e.selected; + if (selected && selected.length === 1 && selected[0].objectType === 'marker') { + window.dispatchEvent(new CustomEvent('marker-selected', { + detail: { markerNumber: selected[0].markerNumber } + })); + } + }); + + this.canvas.on('selection:updated', function (e) { + window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } })); + var selected = e.selected; + if (selected && selected.length === 1 && selected[0].objectType === 'marker') { + window.dispatchEvent(new CustomEvent('marker-selected', { + detail: { markerNumber: selected[0].markerNumber } + })); + } + }); + + this.canvas.on('selection:cleared', function () { + window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: false } })); + window.dispatchEvent(new CustomEvent('marker-selected', { detail: { markerNumber: null } })); + }); + + // ---- Object modification ---- + + this.canvas.on('object:modified', function () { + self.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); + }); + + this.canvas.on('object:moved', function () { + self.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } })); + }); + + // ---- Zoom with mouse wheel ---- + + this.canvas.on('mouse:wheel', function (opt) { + var delta = opt.e.deltaY; + var zoom = self.canvas.getZoom(); + zoom *= Math.pow(0.999, delta); + zoom = Math.max(0.25, Math.min(5, zoom)); + self.canvas.zoomToPoint( + { x: opt.e.offsetX, y: opt.e.offsetY }, + zoom + ); + self.zoomLevel = zoom; + opt.e.preventDefault(); + opt.e.stopPropagation(); + }); + }, + + // ---------------------------------------------------------------- + // Keyboard shortcuts + // ---------------------------------------------------------------- + + /** + * Register global keyboard shortcuts. + * Ignored when focus is inside input / textarea elements. + */ + setupKeyboard() { + var self = this; + + document.addEventListener('keydown', function (e) { + // Skip when typing in form fields + var tag = e.target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.target.isContentEditable) return; + + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + self.deleteSelected(); + } else if (e.key === 'Escape') { + self.setMode('select'); + self.canvas.discardActiveObject(); + self.canvas.renderAll(); + } + }); + }, + + // ---------------------------------------------------------------- + // Object manipulation + // ---------------------------------------------------------------- + + /** + * Delete the currently selected object(s) from the canvas. + */ + deleteSelected() { + var active = this.canvas.getActiveObject(); + if (!active) return; + + if (active.type === 'activeSelection') { + var self = this; + active.forEachObject(function (obj) { + self.canvas.remove(obj); + }); + this.canvas.discardActiveObject(); + } else { + this.canvas.remove(active); + } + + this.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); + this.canvas.renderAll(); + }, + + /** + * Clear all annotation objects from the canvas (keeps background image). + */ + clearAll() { + this.canvas.getObjects().slice().forEach( + function (obj) { + this.canvas.remove(obj); + }.bind(this) + ); + this.nextMarkerNumber = 1; + this.isDirty = true; + window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } })); + this.canvas.renderAll(); + }, + + // ---------------------------------------------------------------- + // Zoom controls + // ---------------------------------------------------------------- + + /** + * Zoom in by 20%. + */ + zoomIn() { + var zoom = Math.min(this.canvas.getZoom() * 1.2, 5); + this.canvas.setZoom(zoom); + this.zoomLevel = zoom; + }, + + /** + * Zoom out by 20%. + */ + zoomOut() { + var zoom = Math.max(this.canvas.getZoom() / 1.2, 0.25); + this.canvas.setZoom(zoom); + this.zoomLevel = zoom; + }, + + /** + * Reset zoom and pan to default (1:1, origin at top-left). + */ + resetZoom() { + this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); + this.canvas.setZoom(1); + this.zoomLevel = 1; + }, + + // ---------------------------------------------------------------- + // Canvas sizing + // ---------------------------------------------------------------- + + /** + * Resize the Fabric.js canvas to fill its parent container. + * Maintains a minimum 5:3 aspect ratio (height = width * 0.6). + */ + resizeCanvas() { + var container = this.canvasEl ? this.canvasEl.parentElement : null; + if (!container || !this.canvas) return; + + var width = container.clientWidth; + var height = Math.max(400, Math.round(width * 0.6)); + + this.canvas.setWidth(width); + this.canvas.setHeight(height); + this.canvas.renderAll(); + }, + + // ---------------------------------------------------------------- + // Serialization - Export + // ---------------------------------------------------------------- + + /** + * Serialize all canvas annotation objects to a JSON string. + * + * @returns {string} JSON representation of all annotations + */ + getAnnotationsJson() { + var objects = this.canvas.getObjects().map(function (obj) { + var data = { + type: obj.objectType || obj.type, + left: Math.round(obj.left * 100) / 100, + top: Math.round(obj.top * 100) / 100, + width: Math.round((obj.width || 0) * 100) / 100, + height: Math.round((obj.height || 0) * 100) / 100, + scaleX: Math.round((obj.scaleX || 1) * 1000) / 1000, + scaleY: Math.round((obj.scaleY || 1) * 1000) / 1000, + angle: Math.round((obj.angle || 0) * 100) / 100, + markerNumber: obj.markerNumber || null, + }; + + // Arrow-specific: store original endpoints + if (obj.objectType === 'arrow') { + data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null; + data.y1 = obj.arrowY1 != null ? Math.round(obj.arrowY1 * 100) / 100 : null; + data.x2 = obj.arrowX2 != null ? Math.round(obj.arrowX2 * 100) / 100 : null; + data.y2 = obj.arrowY2 != null ? Math.round(obj.arrowY2 * 100) / 100 : null; + } + + return data; + }); + + return JSON.stringify({ + version: 1, + width: this.canvas.width, + height: this.canvas.height, + objects: objects, + }); + }, + + // ---------------------------------------------------------------- + // Serialization - Import + // ---------------------------------------------------------------- + + /** + * Load annotations from a JSON string and recreate all objects on the canvas. + * Clears any existing annotation objects first (keeps background image). + * + * @param {string|Object} jsonString - JSON string or parsed object + */ + loadAnnotationsJson(jsonString) { + if (!jsonString) return; + + try { + var data = + typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString; + + // Remove existing objects + this.canvas.getObjects().slice().forEach( + function (obj) { + this.canvas.remove(obj); + }.bind(this) + ); + + var objects = data.objects || []; + + for (var i = 0; i < objects.length; i++) { + var obj = objects[i]; + + if (obj.type === 'marker') { + this.addMarker(obj.left, obj.top, obj.markerNumber); + } else if (obj.type === 'arrow') { + // Reconstruct arrow from stored endpoints or from position/size + var x1 = obj.x1 != null ? obj.x1 : obj.left; + var y1 = obj.y1 != null ? obj.y1 : obj.top; + var x2 = obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100); + var y2 = obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50); + this.addArrow(x1, y1, x2, y2); + } else if (obj.type === 'area') { + var w = (obj.width || 150) * (obj.scaleX || 1); + var h = (obj.height || 100) * (obj.scaleY || 1); + this.addRect(obj.left, obj.top, w, h); + } + } + + // Recalculate nextMarkerNumber + var markerNumbers = objects + .filter(function (o) { + return o.type === 'marker' && o.markerNumber; + }) + .map(function (o) { + return o.markerNumber; + }); + + this.nextMarkerNumber = + markerNumbers.length > 0 ? Math.max.apply(null, markerNumbers) + 1 : 1; + + this.isDirty = false; + this.canvas.renderAll(); + } catch (e) { + console.error('AnnotationEditor: failed to load annotations:', e); + } + }, + + // ---------------------------------------------------------------- + // Marker query helpers + // ---------------------------------------------------------------- + + /** + * Get a sorted list of all markers currently on the canvas. + * + * @returns {Array<{number: number, left: number, top: number}>} + */ + getMarkers() { + return this.canvas + .getObjects() + .filter(function (obj) { + return obj.objectType === 'marker'; + }) + .map(function (obj) { + return { + number: obj.markerNumber, + left: Math.round(obj.left * 100) / 100, + top: Math.round(obj.top * 100) / 100, + }; + }) + .sort(function (a, b) { + return a.number - b.number; + }); + }, + + /** + * Highlight a specific marker by its number. + * Selects it on the canvas so the user sees the Fabric selection border. + * + * @param {number} markerNumber + */ + selectMarkerByNumber(markerNumber) { + var target = null; + this.canvas.forEachObject(function (obj) { + if (obj.objectType === 'marker' && obj.markerNumber === markerNumber) { + target = obj; + } + }); + + if (target) { + this.canvas.setActiveObject(target); + this.canvas.renderAll(); + } + }, + + // ---------------------------------------------------------------- + // Cleanup + // ---------------------------------------------------------------- + + /** + * Destroy the Fabric.js canvas and remove event listeners. + * Call this when the Alpine component is removed from the DOM. + */ + destroy() { + if (this._debouncedResize) { + window.removeEventListener('resize', this._debouncedResize); + } + if (this._onToolChange) { + window.removeEventListener('anno-tool-change', this._onToolChange); + } + if (this._onDeleteSelected) { + window.removeEventListener('anno-delete-selected', this._onDeleteSelected); + } + if (this._onZoom) { + window.removeEventListener('anno-zoom', this._onZoom); + } + if (this._onZoomReset) { + window.removeEventListener('anno-zoom-reset', this._onZoomReset); + } + if (this._onImageLoaded) { + window.removeEventListener('image-loaded', this._onImageLoaded); + } + if (this.canvas) { + this.canvas.dispose(); + this.canvas = null; + } + }, + }; +} diff --git a/client/templates/maker/recipe_editor.html b/client/templates/maker/recipe_editor.html new file mode 100644 index 0000000..e110302 --- /dev/null +++ b/client/templates/maker/recipe_editor.html @@ -0,0 +1,826 @@ +{% extends "base.html" %} +{% block title %} + {% if recipe %} + {{ recipe.code }} — {{ _('Modifica Ricetta') }} + {% else %} + {{ _('Nuova Ricetta') }} + {% endif %} + — TieMeasureFlow +{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +{# ——— Determine mode flags ——— #} +{% set is_new = (recipe is none) %} +{% set recipe_id = recipe.id if recipe else None %} +{% set current_version = recipe.current_version if recipe and recipe.current_version else None %} +{% set versions = recipe.versions if recipe and recipe.versions else [] %} + +
+ + + + + +
+
+
+

+ {% if is_new %} + {{ _('Nuova Ricetta') }} + {% else %} + {{ recipe.code }} + + (v) + + {% endif %} +

+ {% if not is_new %} +

+ {% else %} +

+ {{ _('Compila i dati della ricetta e carica il disegno tecnico') }} +

+ {% endif %} +
+ + +
+ + + {% if not is_new %} + + + + + + {{ _('Anteprima') }} + + + + + + + {{ _('Task') }} + + {% endif %} + + + + + + {{ _('Annulla') }} + +
+
+
+ + +
+ + + + + +
+ + +
+ + + + {{ _('Ricetta salvata con successo') }} +
+ + +
+
+ + + + {{ _('Metadati Ricetta') }} +
+
+
+ +
+ + +

+ {{ _('Il codice non puo essere modificato dopo la creazione') }} +

+
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + +
+
+
+ + + + {{ _('Disegno Tecnico e Annotazioni') }} +
+ + +
+ +
+ + +
+ + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + +
+ + + +
+ + +
+ +
+ + +
+ + + + + + + + +
+ +
+
+ + + {% if not is_new %} +
+
+ + + + {{ _('Versioning') }} +
+
+ +
+ + {{ _('Versione corrente:') }} + + + + +
+ + +
+ + + +

+ {{ _('Se modifichi questa ricetta verra creata automaticamente la versione') }} + . + {{ _('Le misure esistenti resteranno associate alla versione corrente.') }} +

+
+ + +
+ + +

+ {{ _('Opzionale. Verra registrato nella cronologia versioni.') }} +

+
+
+
+ {% endif %} + + +
+ + + + + + + {{ _('Annulla') }} + +
+ +
+{% endblock %} + +{% block extra_js %} + + + +{% endblock %} diff --git a/client/templates/maker/recipe_list.html b/client/templates/maker/recipe_list.html new file mode 100644 index 0000000..bb46096 --- /dev/null +++ b/client/templates/maker/recipe_list.html @@ -0,0 +1,385 @@ +{% extends "base.html" %} +{% block title %}{{ _('Gestione Ricette') }} — TieMeasureFlow{% endblock %} + +{% block content %} +
r.id !== id); + this.deleteModal = false; + this.deleteTarget = null; + } else { + const data = await resp.json(); + alert(data.error || '{{ _('Errore durante eliminazione') }}'); + } + } catch (err) { + console.error(err); + alert('{{ _('Errore di connessione') }}'); + } + }, + + openDeleteModal(recipe) { + this.deleteTarget = recipe; + this.deleteModal = true; + } + }"> + + + + + +
+
+
+

+ {{ _('Gestione Ricette') }} +

+

+ {{ _('Crea e gestisci le ricette di misura') }} +

+
+ + + + + {{ _('Nuova Ricetta') }} + +
+
+ + +
+
+
+ +
+
+
+ + + +
+ +
+
+ + +
+ +
+
+
+
+ + +
+

+ + +

+
+ + +
+ +
+ + +
+
+ + + +
+

+ + {{ _('Nessuna ricetta trovata') }} + + + {{ _('Nessuna ricetta disponibile') }} + +

+

+ + {{ _('Prova a modificare i filtri di ricerca') }} + + + {{ _('Inizia creando la tua prima ricetta di misura') }} + +

+ + + + + {{ _('Crea Prima Ricetta') }} + +
+ + + + +
+{% endblock %} diff --git a/client/templates/maker/recipe_preview.html b/client/templates/maker/recipe_preview.html new file mode 100644 index 0000000..44edfab --- /dev/null +++ b/client/templates/maker/recipe_preview.html @@ -0,0 +1,445 @@ +{% extends "base.html" %} +{% block title %}{{ recipe.code }} — {{ _('Anteprima Ricetta') }} — TieMeasureFlow{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ + {# ================================================================ + BREADCRUMB + ================================================================ #} + + + {# ================================================================ + HEADER + ================================================================ #} +
+
+
+ {# Preview badge #} + + + + + + {{ _('Anteprima') }} + + +
+

+ {{ recipe.code }} + + — {{ recipe.name }} + +

+ {% if recipe.current_version %} +

+ {{ _('Versione') }} v{{ recipe.current_version.version_number }} +

+ {% endif %} +
+
+ + {# Action buttons #} + +
+
+ + {# ================================================================ + PREVIEW BANNER + ================================================================ #} +
+ + + +
+

+ {{ _('Modalita Anteprima') }} +

+

+ {{ _('Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura.') }} +

+
+
+ + {# ================================================================ + RECIPE DESCRIPTION (if present) + ================================================================ #} + {% if recipe.description %} +
+
+

{{ recipe.description }}

+
+
+ {% endif %} + + {# ================================================================ + TASKS LOOP + ================================================================ #} + {% if recipe.tasks %} + {% for task in recipe.tasks %} +
+ + {# ---- Task Header ---- #} +
+
+ + {{ loop.index }} + +
+

{{ task.title }}

+ {% if task.directive or task.description %} +

+ {{ task.directive or task.description }} +

+ {% endif %} +
+
+ {% if task.subtasks %} + + {{ task.subtasks|length }} {{ _('misure') }} + + {% endif %} +
+ +
+ + {# ---- Technical Drawing / Annotations ---- #} + {% if task.file_path and task.file_type == 'image' %} +
+
+ + + + {{ _('Disegno Tecnico') }} +
+
+ +
+
+ + {% elif task.file_path and task.file_type == 'pdf' %} +
+ + + +
+

{{ _('Documento PDF allegato') }}

+

{{ task.file_path }}

+
+ + + + + {{ _('Apri PDF') }} + +
+ + {% else %} +
+
+ + + +

{{ _('Nessuna immagine allegata') }}

+
+
+ {% endif %} + + {# ---- Subtask List ---- #} + {% if task.subtasks %} +
+
+ + + + {{ _('Punti di Misura') }} +
+ +
+ {% for subtask in task.subtasks %} +
+ {# Subtask header: marker badge + description #} +
+ + {{ subtask.marker_number }} + +
+

{{ subtask.description }}

+
+ {% if subtask.measurement_type %} + {{ subtask.measurement_type }} + · + {% endif %} + {{ subtask.unit or 'mm' }} +
+
+
+ + {# Tolerance parameters grid #} +
+ {# Nominal #} +
+ + {{ _('Nominale') }} + + + {% if subtask.nominal is not none %}{{ "%.3f"|format(subtask.nominal) }}{% else %}—{% endif %} + +
+ + {# LTL #} +
+ LTL + + {% if subtask.ltl is not none %}{{ "%.3f"|format(subtask.ltl) }}{% else %}—{% endif %} + +
+ + {# LWL #} +
+ LWL + + {% if subtask.lwl is not none %}{{ "%.3f"|format(subtask.lwl) }}{% else %}—{% endif %} + +
+ + {# UWL #} +
+ UWL + + {% if subtask.uwl is not none %}{{ "%.3f"|format(subtask.uwl) }}{% else %}—{% endif %} + +
+ + {# UTL #} +
+ UTL + + {% if subtask.utl is not none %}{{ "%.3f"|format(subtask.utl) }}{% else %}—{% endif %} + +
+
+ + {# Visual tolerance bar #} + {% if subtask.ltl is not none and subtask.utl is not none %} +
+
+ {# Nominal center line #} +
+
+
+ {{ "%.2f"|format(subtask.ltl) }} + {{ "%.2f"|format(subtask.nominal or 0) }} + {{ "%.2f"|format(subtask.utl) }} +
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% else %} +
+

{{ _('Nessun punto di misura definito per questo task') }}

+
+ {% endif %} + +
+
+ {% endfor %} + + {% else %} + {# ---- Empty state: no tasks ---- #} +
+
+
+ + + +
+

+ {{ _('Nessun task definito') }} +

+

+ {{ _('Questa ricetta non contiene ancora task di misurazione.') }} +

+ + + + + {{ _('Aggiungi Task') }} + +
+
+ {% endif %} + + {# ================================================================ + FOOTER NAVIGATION + ================================================================ #} + + +
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/client/templates/maker/task_editor.html b/client/templates/maker/task_editor.html new file mode 100644 index 0000000..dddd550 --- /dev/null +++ b/client/templates/maker/task_editor.html @@ -0,0 +1,1569 @@ +{% extends "base.html" %} +{% block title %}{{ recipe.code }} — {{ _('Editor Task') }} — TieMeasureFlow{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+

+ {{ recipe.code }} + + {{ _('Task e Misurazioni') }} + +

+

+ {{ recipe.name }} + {% if recipe.current_version %} + v{{ recipe.current_version.version_number }} + {% endif %} +

+
+ + + +
+
+ + +
+ + + + + +
+ + +
+ + + + +
+ + +
+

+ + +

+ +
+ + +
+
+ + + + {{ _('Nuovo Task') }} +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
+
+ + + +
+

+ {{ _('Nessun task definito') }} +

+

+ {{ _('Inizia aggiungendo il primo task di misurazione per questa ricetta') }} +

+ +
+ + + + + + + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/client/templates/maker/version_history.html b/client/templates/maker/version_history.html new file mode 100644 index 0000000..3bca0ac --- /dev/null +++ b/client/templates/maker/version_history.html @@ -0,0 +1,409 @@ +{% extends "base.html" %} +{% block title %}{{ recipe.code }} — {{ _('Storico Versioni') }} — TieMeasureFlow{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ + {# ================================================================ + BREADCRUMB + ================================================================ #} + + + {# ================================================================ + HEADER + ================================================================ #} +
+
+
+

+ {{ recipe.code }} + + — {{ recipe.name }} + +

+
+

+ {{ _('Storico Versioni') }} +

+ {% if recipe.current_version %} + + {{ _('Corrente:') }} v{{ recipe.current_version.version_number }} + + {% endif %} +
+
+ + + + + + {{ _('Torna a Ricetta') }} + +
+
+ + {# ================================================================ + VERSION TIMELINE + ================================================================ #} + + + {# ================================================================ + EMPTY STATE + ================================================================ #} + + + {# ================================================================ + FOOTER + ================================================================ #} + + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/client/translations/en/LC_MESSAGES/messages.po b/client/translations/en/LC_MESSAGES/messages.po index 1df06f8..d9cfe66 100644 --- a/client/translations/en/LC_MESSAGES/messages.po +++ b/client/translations/en/LC_MESSAGES/messages.po @@ -502,3 +502,450 @@ msgstr "Browser does not support Web Serial API" # Recipe Selection Additional msgid "Errore di connessione" msgstr "Connection Error" + +# Maker - Recipe List +msgid "Gestione Ricette" +msgstr "Recipe Management" + +msgid "Crea e gestisci le ricette di misura" +msgstr "Create and manage measurement recipes" + +msgid "Nuova Ricetta" +msgstr "New Recipe" + +msgid "Cerca per nome, codice o descrizione..." +msgstr "Search by name, code or description..." + +msgid "Tutti" +msgstr "All" + +msgid "Attive" +msgstr "Active" + +msgid "Disattivate" +msgstr "Inactive" + +msgid "ricetta trovata" +msgstr "recipe found" + +msgid "Attiva" +msgstr "Active" + +msgid "Disattivata" +msgstr "Inactive" + +msgid "task" +msgstr "task" + +msgid "Aggiornata" +msgstr "Updated" + +msgid "Versioni" +msgstr "Versions" + +msgid "Nessuna ricetta disponibile" +msgstr "No recipes available" + +msgid "Prova a modificare i filtri di ricerca" +msgstr "Try modifying search filters" + +msgid "Inizia creando la tua prima ricetta di misura" +msgstr "Start by creating your first measurement recipe" + +msgid "Crea Prima Ricetta" +msgstr "Create First Recipe" + +msgid "Conferma Eliminazione" +msgstr "Confirm Deletion" + +msgid "Sei sicuro di voler disattivare la ricetta" +msgstr "Are you sure you want to deactivate the recipe" + +msgid "Le misure esistenti non verranno eliminate" +msgstr "Existing measurements will not be deleted" + +msgid "Errore durante eliminazione" +msgstr "Error during deletion" + +msgid "Dashboard" +msgstr "Dashboard" + +# Maker - Recipe Editor +msgid "Modifica Ricetta" +msgstr "Edit Recipe" + +msgid "Compila i dati della ricetta e carica il disegno tecnico" +msgstr "Fill in recipe data and upload the technical drawing" + +msgid "Ricetta salvata con successo" +msgstr "Recipe saved successfully" + +msgid "Metadati Ricetta" +msgstr "Recipe Metadata" + +msgid "Es. COUPLING-256" +msgstr "E.g. COUPLING-256" + +msgid "Es. Coupling Assembly 256" +msgstr "E.g. Coupling Assembly 256" + +msgid "Descrizione opzionale della ricetta..." +msgstr "Optional recipe description..." + +msgid "Il codice non puo essere modificato dopo la creazione" +msgstr "Code cannot be changed after creation" + +msgid "Disegno Tecnico e Annotazioni" +msgstr "Technical Drawing and Annotations" + +msgid "Immagine caricata" +msgstr "Image uploaded" + +msgid "Marker numerato" +msgstr "Numbered marker" + +msgid "Marker" +msgstr "Marker" + +msgid "Freccia" +msgstr "Arrow" + +msgid "Rettangolo" +msgstr "Rectangle" + +msgid "Elimina selezionato" +msgstr "Delete selected" + +msgid "Zoom" +msgstr "Zoom" + +msgid "Zoom indietro" +msgstr "Zoom out" + +msgid "Zoom avanti" +msgstr "Zoom in" + +msgid "Reset zoom" +msgstr "Reset zoom" + +msgid "Trascina" +msgstr "Drag" + +msgid "Pan" +msgstr "Pan" + +msgid "Caricamento in corso..." +msgstr "Upload in progress..." + +msgid "Trascina qui il disegno tecnico oppure clicca per selezionare" +msgstr "Drag the technical drawing here or click to select" + +msgid "Trascina qui per sostituire il disegno tecnico" +msgstr "Drag here to replace the technical drawing" + +msgid "Formati supportati: PNG, JPG, PDF" +msgstr "Supported formats: PNG, JPG, PDF" + +msgid "Versioning" +msgstr "Versioning" + +msgid "Versione corrente:" +msgstr "Current version:" + +msgid "Vedi cronologia" +msgstr "View history" + +msgid "versioni" +msgstr "versions" + +msgid "Se modifichi questa ricetta verra creata automaticamente la versione" +msgstr "If you modify this recipe, version will be created automatically" + +msgid "Le misure esistenti resteranno associate alla versione corrente." +msgstr "Existing measurements will remain associated with the current version." + +msgid "Motivo della modifica" +msgstr "Reason for modification" + +msgid "Es. Aggiornate tolleranze foro centrale..." +msgstr "E.g. Updated center hole tolerances..." + +msgid "Opzionale. Verra registrato nella cronologia versioni." +msgstr "Optional. Will be recorded in version history." + +msgid "Salva Ricetta" +msgstr "Save Recipe" + +msgid "Formato file non supportato. Usa PNG, JPG o PDF." +msgstr "Unsupported file format. Use PNG, JPG or PDF." + +msgid "File troppo grande. Dimensione massima: 20MB." +msgstr "File too large. Maximum size: 20MB." + +msgid "Errore durante il caricamento del file" +msgstr "Error uploading file" + +msgid "Errore di connessione durante il caricamento" +msgstr "Connection error during upload" + +msgid "Nessun file caricato" +msgstr "No file uploaded" + +msgid "Nome file vuoto" +msgstr "Empty file name" + +# Maker - Task Editor +msgid "Editor Task" +msgstr "Task Editor" + +msgid "Task e Misurazioni" +msgstr "Tasks and Measurements" + +msgid "Torna a Ricetta" +msgstr "Back to Recipe" + +msgid "Aggiungi Task" +msgstr "Add Task" + +msgid "Nuovo Task" +msgstr "New Task" + +msgid "Titolo" +msgstr "Title" + +msgid "Direttiva" +msgstr "Directive" + +msgid "Es. Controllo dimensionale flangia" +msgstr "E.g. Flange dimensional check" + +msgid "Es. Seguire procedura ISO 2768" +msgstr "E.g. Follow ISO 2768 procedure" + +msgid "Descrizione opzionale..." +msgstr "Optional description..." + +msgid "Crea Task" +msgstr "Create Task" + +msgid "Trascina per riordinare" +msgstr "Drag to reorder" + +msgid "Modifica task" +msgstr "Edit task" + +msgid "Espandi/Comprimi" +msgstr "Expand/Collapse" + +msgid "Elimina task" +msgstr "Delete task" + +msgid "UTL" +msgstr "UTL" + +msgid "UWL" +msgstr "UWL" + +msgid "LWL" +msgstr "LWL" + +msgid "LTL" +msgstr "LTL" + +msgid "Unita" +msgstr "Unit" + +msgid "Tolleranze" +msgstr "Tolerances" + +msgid "Azioni" +msgstr "Actions" + +msgid "#" +msgstr "#" + +msgid "Tipo" +msgstr "Type" + +msgid "Lineare" +msgstr "Linear" + +msgid "Diametro" +msgstr "Diameter" + +msgid "Raggio" +msgstr "Radius" + +msgid "Angolo" +msgstr "Angle" + +msgid "Rugosita" +msgstr "Roughness" + +msgid "Coppia" +msgstr "Torque" + +msgid "Forza" +msgstr "Force" + +msgid "Peso" +msgstr "Weight" + +msgid "Altro" +msgstr "Other" + +msgid "Nessuna misurazione definita" +msgstr "No measurements defined" + +msgid "Aggiungi la prima misurazione per questo task" +msgstr "Add the first measurement for this task" + +msgid "Nuova Misurazione" +msgstr "New Measurement" + +msgid "Marker #" +msgstr "Marker #" + +msgid "Es. Diametro foro principale" +msgstr "E.g. Main hole diameter" + +msgid "Lim. Tol. Inf." +msgstr "Lower Tol. Lim." + +msgid "Lim. Warn. Inf." +msgstr "Lower Warn. Lim." + +msgid "Lim. Warn. Sup." +msgstr "Upper Warn. Lim." + +msgid "Lim. Tol. Sup." +msgstr "Upper Tol. Lim." + +msgid "Aggiungi Misurazione" +msgstr "Add Measurement" + +msgid "Nessun task definito" +msgstr "No tasks defined" + +msgid "Inizia aggiungendo il primo task di misurazione per questa ricetta" +msgstr "Start by adding the first measurement task for this recipe" + +msgid "Aggiungi Primo Task" +msgstr "Add First Task" + +msgid "Conferma Eliminazione Task" +msgstr "Confirm Task Deletion" + +msgid "Sei sicuro di voler eliminare il task" +msgstr "Are you sure you want to delete the task" + +msgid "Verranno eliminate anche" +msgstr "The following will also be deleted:" + +msgid "misurazioni associate." +msgstr "associated measurements." + +msgid "Elimina Task" +msgstr "Delete Task" + +msgid "Conferma Eliminazione Misurazione" +msgstr "Confirm Measurement Deletion" + +msgid "Sei sicuro di voler eliminare la misurazione" +msgstr "Are you sure you want to delete the measurement" + +msgid "Elimina Misurazione" +msgstr "Delete Measurement" + +msgid "Errore nella creazione del task" +msgstr "Error creating task" + +msgid "Task creato con successo" +msgstr "Task created successfully" + +msgid "Errore nel salvataggio del task" +msgstr "Error saving task" + +msgid "Task aggiornato" +msgstr "Task updated" + +msgid "Task eliminato" +msgstr "Task deleted" + +msgid "Errore nell'eliminazione del task" +msgstr "Error deleting task" + +msgid "Errore nel riordinamento" +msgstr "Error reordering" + +msgid "Errore nella creazione della misurazione" +msgstr "Error creating measurement" + +msgid "Misurazione aggiunta" +msgstr "Measurement added" + +msgid "Misurazione aggiornata" +msgstr "Measurement updated" + +msgid "Misurazione eliminata" +msgstr "Measurement deleted" + +msgid "Errore nell'eliminazione della misurazione" +msgstr "Error deleting measurement" + +msgid "Direttiva opzionale..." +msgstr "Optional directive..." + +# Maker - Recipe Preview +msgid "Anteprima Ricetta" +msgstr "Recipe Preview" + +msgid "Modalita Anteprima" +msgstr "Preview Mode" + +msgid "Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura." +msgstr "You are viewing the recipe as the Measurement Technician would see it. All fields are read-only." + +msgid "misure" +msgstr "measurements" + +msgid "Documento PDF allegato" +msgstr "Attached PDF document" + +msgid "Punti di Misura" +msgstr "Measurement Points" + +msgid "Nessun punto di misura definito per questo task" +msgstr "No measurement points defined for this task" + +msgid "Questa ricetta non contiene ancora task di misurazione." +msgstr "This recipe does not contain any measurement tasks yet." + +msgid "Modifica Task" +msgstr "Edit Tasks" + +# Maker - Version History +msgid "Storico Versioni" +msgstr "Version History" + +msgid "Corrente:" +msgstr "Current:" + +msgid "Corrente" +msgstr "Current" + +msgid "Visualizza" +msgstr "View" + +msgid "Nessuna versione trovata" +msgstr "No versions found" + +msgid "Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio." +msgstr "This recipe does not have any registered versions yet. The first version will be created automatically when saved." + +# Maker - API Errors +msgid "Errore nel caricamento della ricetta: %(error)s" +msgstr "Error loading recipe: %(error)s" + +msgid "Errore nel caricamento delle versioni: %(error)s" +msgstr "Error loading versions: %(error)s" diff --git a/client/translations/it/LC_MESSAGES/messages.po b/client/translations/it/LC_MESSAGES/messages.po index b2ba572..d1343d6 100644 --- a/client/translations/it/LC_MESSAGES/messages.po +++ b/client/translations/it/LC_MESSAGES/messages.po @@ -502,3 +502,450 @@ msgstr "Browser non supporta Web Serial API" # Recipe Selection Additional msgid "Errore di connessione" msgstr "Errore di connessione" + +# Maker - Recipe List +msgid "Gestione Ricette" +msgstr "Gestione Ricette" + +msgid "Crea e gestisci le ricette di misura" +msgstr "Crea e gestisci le ricette di misura" + +msgid "Nuova Ricetta" +msgstr "Nuova Ricetta" + +msgid "Cerca per nome, codice o descrizione..." +msgstr "Cerca per nome, codice o descrizione..." + +msgid "Tutti" +msgstr "Tutti" + +msgid "Attive" +msgstr "Attive" + +msgid "Disattivate" +msgstr "Disattivate" + +msgid "ricetta trovata" +msgstr "ricetta trovata" + +msgid "Attiva" +msgstr "Attiva" + +msgid "Disattivata" +msgstr "Disattivata" + +msgid "task" +msgstr "task" + +msgid "Aggiornata" +msgstr "Aggiornata" + +msgid "Versioni" +msgstr "Versioni" + +msgid "Nessuna ricetta disponibile" +msgstr "Nessuna ricetta disponibile" + +msgid "Prova a modificare i filtri di ricerca" +msgstr "Prova a modificare i filtri di ricerca" + +msgid "Inizia creando la tua prima ricetta di misura" +msgstr "Inizia creando la tua prima ricetta di misura" + +msgid "Crea Prima Ricetta" +msgstr "Crea Prima Ricetta" + +msgid "Conferma Eliminazione" +msgstr "Conferma Eliminazione" + +msgid "Sei sicuro di voler disattivare la ricetta" +msgstr "Sei sicuro di voler disattivare la ricetta" + +msgid "Le misure esistenti non verranno eliminate" +msgstr "Le misure esistenti non verranno eliminate" + +msgid "Errore durante eliminazione" +msgstr "Errore durante eliminazione" + +msgid "Dashboard" +msgstr "Dashboard" + +# Maker - Recipe Editor +msgid "Modifica Ricetta" +msgstr "Modifica Ricetta" + +msgid "Compila i dati della ricetta e carica il disegno tecnico" +msgstr "Compila i dati della ricetta e carica il disegno tecnico" + +msgid "Ricetta salvata con successo" +msgstr "Ricetta salvata con successo" + +msgid "Metadati Ricetta" +msgstr "Metadati Ricetta" + +msgid "Es. COUPLING-256" +msgstr "Es. COUPLING-256" + +msgid "Es. Coupling Assembly 256" +msgstr "Es. Coupling Assembly 256" + +msgid "Descrizione opzionale della ricetta..." +msgstr "Descrizione opzionale della ricetta..." + +msgid "Il codice non puo essere modificato dopo la creazione" +msgstr "Il codice non puo essere modificato dopo la creazione" + +msgid "Disegno Tecnico e Annotazioni" +msgstr "Disegno Tecnico e Annotazioni" + +msgid "Immagine caricata" +msgstr "Immagine caricata" + +msgid "Marker numerato" +msgstr "Marker numerato" + +msgid "Marker" +msgstr "Marker" + +msgid "Freccia" +msgstr "Freccia" + +msgid "Rettangolo" +msgstr "Rettangolo" + +msgid "Elimina selezionato" +msgstr "Elimina selezionato" + +msgid "Zoom" +msgstr "Zoom" + +msgid "Zoom indietro" +msgstr "Zoom indietro" + +msgid "Zoom avanti" +msgstr "Zoom avanti" + +msgid "Reset zoom" +msgstr "Reset zoom" + +msgid "Trascina" +msgstr "Trascina" + +msgid "Pan" +msgstr "Pan" + +msgid "Caricamento in corso..." +msgstr "Caricamento in corso..." + +msgid "Trascina qui il disegno tecnico oppure clicca per selezionare" +msgstr "Trascina qui il disegno tecnico oppure clicca per selezionare" + +msgid "Trascina qui per sostituire il disegno tecnico" +msgstr "Trascina qui per sostituire il disegno tecnico" + +msgid "Formati supportati: PNG, JPG, PDF" +msgstr "Formati supportati: PNG, JPG, PDF" + +msgid "Versioning" +msgstr "Versioning" + +msgid "Versione corrente:" +msgstr "Versione corrente:" + +msgid "Vedi cronologia" +msgstr "Vedi cronologia" + +msgid "versioni" +msgstr "versioni" + +msgid "Se modifichi questa ricetta verra creata automaticamente la versione" +msgstr "Se modifichi questa ricetta verra creata automaticamente la versione" + +msgid "Le misure esistenti resteranno associate alla versione corrente." +msgstr "Le misure esistenti resteranno associate alla versione corrente." + +msgid "Motivo della modifica" +msgstr "Motivo della modifica" + +msgid "Es. Aggiornate tolleranze foro centrale..." +msgstr "Es. Aggiornate tolleranze foro centrale..." + +msgid "Opzionale. Verra registrato nella cronologia versioni." +msgstr "Opzionale. Verra registrato nella cronologia versioni." + +msgid "Salva Ricetta" +msgstr "Salva Ricetta" + +msgid "Formato file non supportato. Usa PNG, JPG o PDF." +msgstr "Formato file non supportato. Usa PNG, JPG o PDF." + +msgid "File troppo grande. Dimensione massima: 20MB." +msgstr "File troppo grande. Dimensione massima: 20MB." + +msgid "Errore durante il caricamento del file" +msgstr "Errore durante il caricamento del file" + +msgid "Errore di connessione durante il caricamento" +msgstr "Errore di connessione durante il caricamento" + +msgid "Nessun file caricato" +msgstr "Nessun file caricato" + +msgid "Nome file vuoto" +msgstr "Nome file vuoto" + +# Maker - Task Editor +msgid "Editor Task" +msgstr "Editor Task" + +msgid "Task e Misurazioni" +msgstr "Task e Misurazioni" + +msgid "Torna a Ricetta" +msgstr "Torna a Ricetta" + +msgid "Aggiungi Task" +msgstr "Aggiungi Task" + +msgid "Nuovo Task" +msgstr "Nuovo Task" + +msgid "Titolo" +msgstr "Titolo" + +msgid "Direttiva" +msgstr "Direttiva" + +msgid "Es. Controllo dimensionale flangia" +msgstr "Es. Controllo dimensionale flangia" + +msgid "Es. Seguire procedura ISO 2768" +msgstr "Es. Seguire procedura ISO 2768" + +msgid "Descrizione opzionale..." +msgstr "Descrizione opzionale..." + +msgid "Crea Task" +msgstr "Crea Task" + +msgid "Trascina per riordinare" +msgstr "Trascina per riordinare" + +msgid "Modifica task" +msgstr "Modifica task" + +msgid "Espandi/Comprimi" +msgstr "Espandi/Comprimi" + +msgid "Elimina task" +msgstr "Elimina task" + +msgid "UTL" +msgstr "UTL" + +msgid "UWL" +msgstr "UWL" + +msgid "LWL" +msgstr "LWL" + +msgid "LTL" +msgstr "LTL" + +msgid "Unita" +msgstr "Unita" + +msgid "Tolleranze" +msgstr "Tolleranze" + +msgid "Azioni" +msgstr "Azioni" + +msgid "#" +msgstr "#" + +msgid "Tipo" +msgstr "Tipo" + +msgid "Lineare" +msgstr "Lineare" + +msgid "Diametro" +msgstr "Diametro" + +msgid "Raggio" +msgstr "Raggio" + +msgid "Angolo" +msgstr "Angolo" + +msgid "Rugosita" +msgstr "Rugosita" + +msgid "Coppia" +msgstr "Coppia" + +msgid "Forza" +msgstr "Forza" + +msgid "Peso" +msgstr "Peso" + +msgid "Altro" +msgstr "Altro" + +msgid "Nessuna misurazione definita" +msgstr "Nessuna misurazione definita" + +msgid "Aggiungi la prima misurazione per questo task" +msgstr "Aggiungi la prima misurazione per questo task" + +msgid "Nuova Misurazione" +msgstr "Nuova Misurazione" + +msgid "Marker #" +msgstr "Marker #" + +msgid "Es. Diametro foro principale" +msgstr "Es. Diametro foro principale" + +msgid "Lim. Tol. Inf." +msgstr "Lim. Tol. Inf." + +msgid "Lim. Warn. Inf." +msgstr "Lim. Warn. Inf." + +msgid "Lim. Warn. Sup." +msgstr "Lim. Warn. Sup." + +msgid "Lim. Tol. Sup." +msgstr "Lim. Tol. Sup." + +msgid "Aggiungi Misurazione" +msgstr "Aggiungi Misurazione" + +msgid "Nessun task definito" +msgstr "Nessun task definito" + +msgid "Inizia aggiungendo il primo task di misurazione per questa ricetta" +msgstr "Inizia aggiungendo il primo task di misurazione per questa ricetta" + +msgid "Aggiungi Primo Task" +msgstr "Aggiungi Primo Task" + +msgid "Conferma Eliminazione Task" +msgstr "Conferma Eliminazione Task" + +msgid "Sei sicuro di voler eliminare il task" +msgstr "Sei sicuro di voler eliminare il task" + +msgid "Verranno eliminate anche" +msgstr "Verranno eliminate anche" + +msgid "misurazioni associate." +msgstr "misurazioni associate." + +msgid "Elimina Task" +msgstr "Elimina Task" + +msgid "Conferma Eliminazione Misurazione" +msgstr "Conferma Eliminazione Misurazione" + +msgid "Sei sicuro di voler eliminare la misurazione" +msgstr "Sei sicuro di voler eliminare la misurazione" + +msgid "Elimina Misurazione" +msgstr "Elimina Misurazione" + +msgid "Errore nella creazione del task" +msgstr "Errore nella creazione del task" + +msgid "Task creato con successo" +msgstr "Task creato con successo" + +msgid "Errore nel salvataggio del task" +msgstr "Errore nel salvataggio del task" + +msgid "Task aggiornato" +msgstr "Task aggiornato" + +msgid "Task eliminato" +msgstr "Task eliminato" + +msgid "Errore nell'eliminazione del task" +msgstr "Errore nell'eliminazione del task" + +msgid "Errore nel riordinamento" +msgstr "Errore nel riordinamento" + +msgid "Errore nella creazione della misurazione" +msgstr "Errore nella creazione della misurazione" + +msgid "Misurazione aggiunta" +msgstr "Misurazione aggiunta" + +msgid "Misurazione aggiornata" +msgstr "Misurazione aggiornata" + +msgid "Misurazione eliminata" +msgstr "Misurazione eliminata" + +msgid "Errore nell'eliminazione della misurazione" +msgstr "Errore nell'eliminazione della misurazione" + +msgid "Direttiva opzionale..." +msgstr "Direttiva opzionale..." + +# Maker - Recipe Preview +msgid "Anteprima Ricetta" +msgstr "Anteprima Ricetta" + +msgid "Modalita Anteprima" +msgstr "Modalita Anteprima" + +msgid "Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura." +msgstr "Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura." + +msgid "misure" +msgstr "misure" + +msgid "Documento PDF allegato" +msgstr "Documento PDF allegato" + +msgid "Punti di Misura" +msgstr "Punti di Misura" + +msgid "Nessun punto di misura definito per questo task" +msgstr "Nessun punto di misura definito per questo task" + +msgid "Questa ricetta non contiene ancora task di misurazione." +msgstr "Questa ricetta non contiene ancora task di misurazione." + +msgid "Modifica Task" +msgstr "Modifica Task" + +# Maker - Version History +msgid "Storico Versioni" +msgstr "Storico Versioni" + +msgid "Corrente:" +msgstr "Corrente:" + +msgid "Corrente" +msgstr "Corrente" + +msgid "Visualizza" +msgstr "Visualizza" + +msgid "Nessuna versione trovata" +msgstr "Nessuna versione trovata" + +msgid "Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio." +msgstr "Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio." + +# Maker - API Errors +msgid "Errore nel caricamento della ricetta: %(error)s" +msgstr "Errore nel caricamento della ricetta: %(error)s" + +msgid "Errore nel caricamento delle versioni: %(error)s" +msgstr "Errore nel caricamento delle versioni: %(error)s" diff --git a/client/verify_i18n.py b/client/verify_i18n.py new file mode 100644 index 0000000..9fb642e --- /dev/null +++ b/client/verify_i18n.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python +"""Verify i18n setup for TieMeasureFlow.""" +import json +from pathlib import Path + +def check_file(path: Path, description: str) -> bool: + """Check if a file exists and report.""" + exists = path.exists() + status = "[OK]" if exists else "[FAIL]" + print(f"{status} {description}: {path}") + return exists + +def check_json_valid(path: Path) -> bool: + """Check if JSON file is valid.""" + try: + with open(path, 'r', encoding='utf-8') as f: + json.load(f) + return True + except Exception as e: + print(f" ERROR: {e}") + return False + +def main(): + """Verify i18n setup.""" + print("=== TieMeasureFlow i18n Verification ===\n") + + root = Path(__file__).parent + all_good = True + + # Check Flask-Babel files + print("Flask-Babel (Server-side):") + files = [ + (root / "translations/babel.cfg", "Babel config"), + (root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"), + (root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"), + (root / "translations/en/LC_MESSAGES/messages.po", "English .po"), + (root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"), + ] + + for path, desc in files: + if not check_file(path, desc): + all_good = False + + # Count messages in .po files + if (root / "translations/it/LC_MESSAGES/messages.po").exists(): + with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f: + content = f.read() + msgid_count = content.count('msgid "') - 1 # Exclude header + print(f" Italian: {msgid_count} messages") + + if (root / "translations/en/LC_MESSAGES/messages.po").exists(): + with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f: + content = f.read() + msgid_count = content.count('msgid "') - 1 + print(f" English: {msgid_count} messages") + + # Check Alpine.js i18n files + print("\nAlpine.js i18n (Client-side):") + json_files = [ + (root / "static/js/locales/it.json", "Italian locale"), + (root / "static/js/locales/en.json", "English locale"), + ] + + for path, desc in json_files: + if check_file(path, desc): + if not check_json_valid(path): + all_good = False + else: + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + keys = count_keys(data) + print(f" {keys} total keys") + + # Check app.py integration + print("\nApp Integration:") + app_py = root / "app.py" + if check_file(app_py, "app.py"): + with open(app_py, 'r', encoding='utf-8') as f: + content = f.read() + checks = [ + ("from flask_babel import Babel", "Flask-Babel imported"), + ("def get_locale()", "get_locale() function"), + ("Babel(app, locale_selector=get_locale)", "Babel initialized"), + ("def set_language(lang)", "set_language endpoint"), + ] + for check_str, check_desc in checks: + found = check_str in content + status = "[OK]" if found else "[FAIL]" + print(f" {status} {check_desc}") + if not found: + all_good = False + + # Summary + print("\n" + "="*40) + if all_good: + print("[OK] i18n setup verified successfully!") + print("\nNext steps:") + print("1. Use _() in Python code and templates") + print("2. Use $t() in Alpine.js components") + print("3. Test language switching: /set-language/en or /set-language/it") + else: + print("[FAIL] Some checks failed - review errors above") + return 1 + + return 0 + +def count_keys(obj, depth=0): + """Recursively count keys in nested dict.""" + if not isinstance(obj, dict): + return 0 + count = len(obj) + for value in obj.values(): + if isinstance(value, dict): + count += count_keys(value, depth + 1) + return count + +if __name__ == '__main__': + exit(main())