Spaces:
Running
Running
Download tagwren/app.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 33.3 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/tagwren/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/tagwren/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/tagwren/app.js
33.3 kB
| /* Tagwren app: products -> label sheet preview -> print / PDF. Everything stays in this browser. | |
| * (c) 2026 CyberMax. All rights reserved. */ | |
| (function () { | |
| "use strict"; | |
| var T = window.Tagwren, PDF = window.TagwrenPDF, PRO = window.CMXPro; | |
| var KEY = "tagwren:v1", FREE_LIMIT = 30; | |
| var $ = function (id) { return document.getElementById(id); }; | |
| function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } | |
| function isPro() { return !!(PRO && PRO.isPro()); } | |
| function toast(m) { if (PRO) PRO.toast(m); } | |
| var EXAMPLE = [ | |
| "Handle,Title,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Compare At Price,Variant Barcode,Variant Inventory Qty,Image Src", | |
| "oat-honey-soap,Oat & Honey Soap Bar,Title,Default Title,SOAP-OAT,6.50,,2003100000114,14,", | |
| "beeswax-candle,Beeswax Pillar Candle,Size,Small,CND-S,14.00,18.00,2003100000282,6,", | |
| "beeswax-candle,,Size,Large,CND-L,22.00,28.00,2003100000350,4,", | |
| "beeswax-candle,,,,,,,,,https://example.com/candle-2.jpg", | |
| "linen-tea-towel,Linen Tea Towel,Color,Sage,TT-SAGE,12.00,,200310000046,9,", | |
| "linen-tea-towel,,Color,Oat,TT-OAT,12.00,,200310000053,7,", | |
| "speckled-mug,Speckled Stoneware Mug,Title,Default Title,MUG-SPK,24.00,,,5,", | |
| "wildflower-seeds,Wildflower Seed Packet,Title,Default Title,SEED-WF,4.25,,200310000060,30,", | |
| "cotton-tote,Organic Cotton Tote Bag,Title,Default Title,TOTE-ORG,18.00,22.00,200310000077,8," | |
| ].join("\n"); | |
| var CURRENCIES = ["USD", "CAD", "GBP", "EUR", "AUD", "NZD", "JPY", "INR", "ZAR", "CHF", "SEK", "NOK", "DKK", "MXN", "BRL", "SGD", "AED"]; | |
| var LOCALES = [["en-US", "1,234.50 (US)"], ["en-GB", "1,234.50 (UK)"], ["en-CA", "1,234.50 (Canada)"], ["en-AU", "1,234.50 (Australia)"], ["en-IN", "1,234.50 (India)"], | |
| ["de-DE", "1.234,50 (Germany)"], ["fr-FR", "1 234,50 (France)"], ["es-ES", "1234,50 (Spain)"], ["it-IT", "1.234,50 (Italy)"], ["nl-NL", "1.234,50 (Netherlands)"], ["sv-SE", "1 234,50 (Sweden)"], ["ja-JP", "1,234 (Japan)"]]; | |
| var FIELD_LABEL = { name: "Name", variant: "Variant / option", price: "Price", compare: "Compare-at (was) price", sku: "SKU", barcode: "Barcode (UPC/EAN/GTIN)", qty: "Inventory quantity", custom: "Custom text" }; | |
| var PRO_FIELDS = { compare: 1, qty: 1 }; | |
| function defaults() { | |
| return { | |
| items: [], table: null, mapping: null, sourceName: "", | |
| st: { sheet: "5160", custom: { unit: "in", paper: "letter", w: 2, h: 1, cols: 3, rows: 10, left: 0.25, top: 0.5, hpitch: 2.75, vpitch: 1 }, | |
| barcode: "auto", source: "barcode", fields: { name: true, variant: true, price: true, compare: false, save: false, sku: false, custom: false, hri: true }, | |
| customText: "", currency: "USD", locale: "en-US", start: 0, offX: 0, offY: 0, outlines: false, qtyMode: "fixed", qtyAll: 3 }, | |
| setups: [], mappings: {} | |
| }; | |
| } | |
| var state = defaults(); | |
| try { | |
| var saved = JSON.parse(localStorage.getItem(KEY) || "null"); | |
| if (saved && saved.st) { state = Object.assign(defaults(), saved); state.st = Object.assign(defaults().st, saved.st); state.st.fields = Object.assign(defaults().st.fields, saved.st.fields || {}); } | |
| } catch (e) { /* private mode or bad data: start fresh */ } | |
| var saveTimer = null; | |
| function flush() { | |
| clearTimeout(saveTimer); saveTimer = null; | |
| try { localStorage.setItem(KEY, JSON.stringify(state)); } | |
| catch (e) { try { var s = Object.assign({}, state, { table: null }); localStorage.setItem(KEY, JSON.stringify(s)); } catch (e2) { /* full or private mode */ } } | |
| } | |
| function save() { clearTimeout(saveTimer); saveTimer = setTimeout(flush, 150); } | |
| window.addEventListener("pagehide", function () { if (saveTimer) flush(); }); | |
| /** Settings with Pro-only options switched off for free users. */ | |
| function eff() { | |
| var st = JSON.parse(JSON.stringify(state.st)), pro = isPro(); | |
| var sheet = T.sheetById(st.sheet, st.custom); | |
| if (!pro && !sheet.free) st.sheet = "5160"; | |
| if (!pro) { st.fields.compare = false; st.fields.save = false; st.qtyMode = "fixed"; } | |
| return st; | |
| } | |
| /* ------------------------------------------------------------ controls setup */ | |
| function fillSelects() { | |
| var sel = $("sheet"), free = T.SHEETS.filter(function (s) { return s.free; }), pro = T.SHEETS.filter(function (s) { return !s.free; }); | |
| function opt(s, p) { return '<option value="' + s.id + '">' + esc(s.short + " · " + s.title) + (p ? " · Pro" : "") + "</option>"; } | |
| sel.innerHTML = '<optgroup label="Free">' + free.map(function (s) { return opt(s, false); }).join("") + '</optgroup><optgroup label="Pro">' + | |
| pro.map(function (s) { return opt(s, true); }).join("") + '<option value="custom">Custom size · Pro</option></optgroup>'; | |
| $("currency").innerHTML = CURRENCIES.map(function (c) { return "<option>" + c + "</option>"; }).join(""); | |
| $("locale").innerHTML = LOCALES.map(function (l) { return '<option value="' + l[0] + '">' + esc(l[1]) + "</option>"; }).join(""); | |
| } | |
| function syncControls() { | |
| var st = state.st; | |
| $("sheet").value = st.sheet; | |
| $("bctype").value = st.barcode; $("bcsrc").value = st.source; | |
| document.querySelectorAll(".fields input[data-f]").forEach(function (i) { i.checked = !!st.fields[i.getAttribute("data-f")]; }); | |
| $("custom-text").value = st.customText || ""; | |
| $("currency").value = st.currency; $("locale").value = st.locale; | |
| $("start").value = (st.start || 0) + 1; $("off-x").value = st.offX || 0; $("off-y").value = st.offY || 0; | |
| $("outlines").checked = !!st.outlines; | |
| $("qty-all").value = st.qtyAll; | |
| document.querySelectorAll("input[name=qtymode]").forEach(function (r) { r.checked = r.value === st.qtyMode; }); | |
| var c = st.custom; | |
| [["c-unit", "unit"], ["c-paper", "paper"], ["c-w", "w"], ["c-h", "h"], ["c-cols", "cols"], ["c-rows", "rows"], ["c-left", "left"], ["c-top", "top"], ["c-hp", "hpitch"], ["c-vp", "vpitch"]] | |
| .forEach(function (p) { $(p[0]).value = c[p[1]]; }); | |
| $("custom-box").hidden = st.sheet !== "custom"; | |
| } | |
| function proGate(reason, from) { | |
| if (isPro()) return true; | |
| if (PRO) PRO.upsell(reason, from); | |
| return false; | |
| } | |
| function bind() { | |
| $("btn-example").onclick = $("btn-example-top").onclick = function (e) { | |
| loadExample(); | |
| if (e.currentTarget.id === "btn-example-top") $("app").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }; | |
| function onFile(e) { | |
| var f = e.target.files && e.target.files[0]; | |
| if (!f) return; | |
| var r = new FileReader(); | |
| r.onload = function () { importText(String(r.result), f.name); $("app").scrollIntoView({ behavior: "smooth", block: "start" }); }; | |
| r.readAsText(f); | |
| e.target.value = ""; | |
| } | |
| $("file").onchange = onFile; $("file-top").onchange = onFile; | |
| $("btn-paste").onclick = function () { importText($("paste").value, "pasted rows"); }; | |
| $("btn-add").onclick = function () { | |
| state.items.push({ name: "New product", variant: "", price: null, compare: null, sku: "", barcode: "", custom: "", inv: null, qty: state.st.qtyAll || 1 }); | |
| if (!state.sourceName) state.sourceName = "typed in"; | |
| changedItems(); setTimeout(function () { var ins = document.querySelectorAll("#items tbody tr:last-child input[data-k=name]"); if (ins[0]) { ins[0].focus(); ins[0].select(); } }, 30); | |
| }; | |
| $("btn-clear").onclick = function () { | |
| if (state.items.length && !confirm("Remove all products from this browser?")) return; | |
| state.items = []; state.table = null; state.mapping = null; state.sourceName = ""; changedItems(); renderMapping(); | |
| }; | |
| $("btn-qty-all").onclick = function () { | |
| var q = Math.max(0, Math.floor(+$("qty-all").value || 0)); | |
| state.st.qtyAll = q; state.items.forEach(function (it) { it.qty = q; }); | |
| state.st.qtyMode = "fixed"; syncControls(); changedItems(); | |
| }; | |
| $("qty-all").onchange = function () { state.st.qtyAll = Math.max(0, Math.floor(+this.value || 0)); save(); }; | |
| document.querySelectorAll("input[name=qtymode]").forEach(function (r) { | |
| r.onchange = function () { | |
| if (r.value === "column") { | |
| if (!proGate("Label quantities from your inventory column are part of Tagwren Pro.", "qty")) { syncControls(); return; } | |
| if (!state.mapping || state.mapping.qty == null) { toast("Pick the inventory column under Column mapping first."); $("map-box").open = true; } | |
| state.st.qtyMode = "column"; | |
| state.items.forEach(function (it) { if (it.inv != null) it.qty = it.inv; }); | |
| } else state.st.qtyMode = "fixed"; | |
| changedItems(); | |
| }; | |
| }); | |
| $("sheet").onchange = function () { | |
| var v = this.value, s = T.sheetById(v, state.st.custom); | |
| if ((v === "custom" || !s.free) && !proGate("The " + (v === "custom" ? "custom size" : s.short) + " template is part of Tagwren Pro. Free: 5160, 5163 and the 2.25 × 1.25 in roll.", "sheet")) { | |
| this.value = state.st.sheet; return; | |
| } | |
| state.st.sheet = v; state.st.start = 0; syncControls(); changed(); | |
| }; | |
| ["c-unit", "c-paper", "c-w", "c-h", "c-cols", "c-rows", "c-left", "c-top", "c-hp", "c-vp"].forEach(function (id) { | |
| $(id).oninput = $(id).onchange = function () { | |
| var k = { "c-unit": "unit", "c-paper": "paper", "c-w": "w", "c-h": "h", "c-cols": "cols", "c-rows": "rows", "c-left": "left", "c-top": "top", "c-hp": "hpitch", "c-vp": "vpitch" }[id]; | |
| if (id === "c-unit" && this.value !== state.st.custom.unit) { | |
| var f = this.value === "mm" ? 25.4 : 1 / 25.4, c = state.st.custom; | |
| ["w", "h", "left", "top", "hpitch", "vpitch"].forEach(function (p) { c[p] = Math.round(c[p] * f * 100) / 100; }); | |
| c.unit = this.value; syncControls(); | |
| } else state.st.custom[k] = this.value; | |
| changed(); | |
| }; | |
| }); | |
| $("bctype").onchange = function () { state.st.barcode = this.value; changed(); renderTableStatus(); }; | |
| $("bcsrc").onchange = function () { state.st.source = this.value; if (this.value === "custom") { state.st.fields.custom = state.st.fields.custom; } changed(); }; | |
| document.querySelectorAll(".fields input[data-f]").forEach(function (i) { | |
| i.onchange = function () { | |
| var f = i.getAttribute("data-f"); | |
| if ((f === "compare" || f === "save") && i.checked && !proGate("Sale price tags (old price struck through) are part of Tagwren Pro.", "sale")) { i.checked = false; return; } | |
| state.st.fields[f] = i.checked; changed(); | |
| }; | |
| }); | |
| $("custom-text").oninput = function () { state.st.customText = this.value; changed(); }; | |
| $("currency").onchange = function () { state.st.currency = this.value; changed(); }; | |
| $("locale").onchange = function () { state.st.locale = this.value; changed(); }; | |
| $("start").oninput = function () { state.st.start = Math.max(0, (Math.floor(+this.value) || 1) - 1); changed(); }; | |
| $("off-x").oninput = function () { state.st.offX = +this.value || 0; changed(); }; | |
| $("off-y").oninput = function () { state.st.offY = +this.value || 0; changed(); }; | |
| $("outlines").onchange = function () { state.st.outlines = this.checked; changed(); }; | |
| $("btn-print").onclick = function () { output("print"); }; | |
| $("btn-pdf").onclick = function () { output("pdf"); }; | |
| $("btn-batch").onclick = batchCSV; | |
| $("btn-save-setup").onclick = saveSetup; | |
| $("btn-export").onclick = function () { | |
| var data = { app: "tagwren", v: 1, exported: new Date().toISOString(), items: state.items, st: state.st, setups: state.setups, mappings: state.mappings, sourceName: state.sourceName, mapping: state.mapping, table: state.table }; | |
| PRO.download("tagwren-backup-" + new Date().toISOString().slice(0, 10) + ".json", JSON.stringify(data, null, 1), "application/json"); | |
| }; | |
| $("file-backup").onchange = function (e) { | |
| var f = e.target.files && e.target.files[0]; if (!f) return; | |
| var r = new FileReader(); | |
| r.onload = function () { | |
| try { | |
| var d = JSON.parse(String(r.result)); | |
| if (!d || d.app !== "tagwren") throw new Error("not a Tagwren backup"); | |
| var base = defaults(); | |
| state = Object.assign(base, { items: d.items || [], st: Object.assign(base.st, d.st || {}), setups: d.setups || [], mappings: d.mappings || {}, sourceName: d.sourceName || "backup", mapping: d.mapping || null, table: d.table || null }); | |
| state.st.fields = Object.assign(defaults().st.fields, (d.st || {}).fields || {}); | |
| syncControls(); renderMapping(); changedItems(); renderSetups(); toast("Backup restored: " + state.items.length + " products."); | |
| } catch (err) { toast("That file is not a Tagwren backup."); } | |
| }; | |
| r.readAsText(f); e.target.value = ""; | |
| }; | |
| $("pages").onclick = function (e) { | |
| var slot = e.target.closest && e.target.closest("[data-slot]"); | |
| if (!slot) return; | |
| state.st.start = +slot.getAttribute("data-slot"); $("start").value = state.st.start + 1; changed(); | |
| toast("Printing starts at label " + (state.st.start + 1) + " of the first sheet."); | |
| }; | |
| $("pages").onkeydown = function (e) { if ((e.key === "Enter" || e.key === " ") && e.target.hasAttribute("data-slot")) { e.preventDefault(); e.target.click(); } }; | |
| window.addEventListener("beforeprint", function () { if (!printing) $("print-root").innerHTML = '<div class="print-note">Use the “Print labels” button in Tagwren to print your label sheet.</div>'; }); | |
| window.addEventListener("afterprint", function () { printing = false; document.body.classList.remove("printing"); }); | |
| } | |
| /* ------------------------------------------------------------ import + mapping */ | |
| function loadExample() { | |
| importText(EXAMPLE, "example Shopify export", { example: true }); | |
| $("paste").value = EXAMPLE; | |
| } | |
| function importText(text, name, o) { | |
| o = o || {}; | |
| var table = T.parseCSV(text); | |
| if (table.length < 2) { toast("No product rows found. The first row should be the column names."); return; } | |
| var headers = table[0].map(function (h) { return String(h).trim(); }); | |
| table[0] = headers; | |
| var sig = headers.join("|").toLowerCase(); | |
| var remembered = isPro() && state.mappings[sig]; | |
| state.mapping = remembered ? Object.assign({}, remembered) : T.guessMapping(headers); | |
| state.table = table; state.sourceName = name; | |
| if (o.example) { state.st.qtyAll = 3; state.st.start = 0; } | |
| rebuildItems(); | |
| var mapped = state.mapping; | |
| var shopify = headers.indexOf("Handle") > -1 && headers.indexOf("Variant Price") > -1; | |
| var square = headers.some(function (h) { return /^item name$/i.test(h); }) && headers.some(function (h) { return /^variation name$/i.test(h); }); | |
| $("src-status").textContent = state.items.length + " products from " + name + (shopify ? " · Shopify export recognised" : square ? " · Square export recognised" : "") + (remembered ? " · your saved column mapping used" : "") + "."; | |
| if (mapped.name == null && mapped.price == null) toast("Check the column mapping: no name or price column was recognised."); | |
| renderMapping(); $("map-box").open = !o.example && !shopify; | |
| syncControls(); changedItems(); | |
| } | |
| function rebuildItems() { | |
| if (!state.table) return; | |
| var col = state.st.qtyMode === "column" && isPro(); | |
| state.items = T.normalizeRows(state.table, state.mapping, { qty: state.st.qtyAll, qtyFromColumn: col }); | |
| } | |
| function renderMapping() { | |
| var g = $("map-grid"); | |
| if (!state.table) { g.innerHTML = '<p class="hint">Import a CSV to map its columns.</p>'; $("map-sum").textContent = ""; return; } | |
| var headers = state.table[0], n = 0; | |
| g.innerHTML = T.FIELDS.map(function (f) { | |
| var v = state.mapping[f]; | |
| if (v != null) n++; | |
| return '<div><label for="map-' + f + '">' + esc(FIELD_LABEL[f]) + (PRO_FIELDS[f] ? ' <span class="pro-tag">Pro</span>' : "") + '</label><select id="map-' + f + '" data-map="' + f + '"><option value="">(none)</option>' + | |
| headers.map(function (h, i) { return '<option value="' + i + '"' + (v === i ? " selected" : "") + ">" + esc(h || "Column " + (i + 1)) + "</option>"; }).join("") + "</select></div>"; | |
| }).join("") + (isPro() ? '<p class="hint full">Pro remembers this mapping for files with the same columns.</p>' : '<p class="hint full">Pro remembers your mapping for the next file with the same columns.</p>'); | |
| $("map-sum").textContent = "(" + n + " of " + T.FIELDS.length + " mapped)"; | |
| g.querySelectorAll("select[data-map]").forEach(function (s) { | |
| s.onchange = function () { | |
| var f = s.getAttribute("data-map"); | |
| state.mapping[f] = s.value === "" ? null : +s.value; | |
| if (isPro()) state.mappings[state.table[0].join("|").toLowerCase()] = Object.assign({}, state.mapping); | |
| rebuildItems(); changedItems(); renderMapping(); | |
| }; | |
| }); | |
| } | |
| /* ------------------------------------------------------------ product table */ | |
| var TABLE_MAX = 300; | |
| function fmtNum(v) { return v == null ? "" : String(v); } | |
| function renderTable() { | |
| var tb = document.querySelector("#items tbody"), items = state.items; | |
| $("table-wrap").hidden = !items.length; | |
| tb.innerHTML = items.slice(0, TABLE_MAX).map(function (it, i) { | |
| return "<tr>" + | |
| '<td><input type="number" min="0" max="9999" class="num" data-i="' + i + '" data-k="qty" value="' + (it.qty == null ? 0 : it.qty) + '" aria-label="Labels for row ' + (i + 1) + '"></td>' + | |
| '<td><input data-i="' + i + '" data-k="name" value="' + esc(it.name) + '" aria-label="Name, row ' + (i + 1) + '"></td>' + | |
| '<td><input data-i="' + i + '" data-k="variant" value="' + esc(it.variant) + '" aria-label="Variant, row ' + (i + 1) + '" class="sm"></td>' + | |
| '<td><input data-i="' + i + '" data-k="price" inputmode="decimal" value="' + esc(fmtNum(it.price)) + '" aria-label="Price, row ' + (i + 1) + '" class="num"></td>' + | |
| '<td><input data-i="' + i + '" data-k="compare" inputmode="decimal" value="' + esc(fmtNum(it.compare)) + '" aria-label="Compare-at price, row ' + (i + 1) + '" class="num"></td>' + | |
| '<td><input data-i="' + i + '" data-k="sku" value="' + esc(it.sku) + '" aria-label="SKU, row ' + (i + 1) + '" class="sm"></td>' + | |
| '<td><input data-i="' + i + '" data-k="barcode" value="' + esc(it.barcode) + '" aria-label="Barcode, row ' + (i + 1) + '" class="code"></td>' + | |
| '<td class="st" data-st="' + i + '"></td>' + | |
| '<td><button class="x" data-del="' + i + '" aria-label="Remove row ' + (i + 1) + '">×</button></td></tr>'; | |
| }).join(""); | |
| $("table-note").textContent = items.length > TABLE_MAX ? "Showing the first " + TABLE_MAX + " of " + items.length + " products here; all of them are printed." : ""; | |
| tb.oninput = function (e) { | |
| var t = e.target, i = +t.getAttribute("data-i"), k = t.getAttribute("data-k"); | |
| if (!k || !state.items[i]) return; | |
| var v = t.value; | |
| if (k === "qty") v = Math.max(0, Math.floor(+v || 0)); | |
| else if (k === "price" || k === "compare") v = T.parsePrice(v); | |
| state.items[i][k] = v; | |
| changed(); renderTableStatus(); | |
| }; | |
| tb.onclick = function (e) { | |
| var b = e.target.closest && e.target.closest("[data-del]"); | |
| if (!b) return; | |
| state.items.splice(+b.getAttribute("data-del"), 1); changedItems(); | |
| }; | |
| renderTableStatus(); | |
| } | |
| function renderTableStatus() { | |
| var st = eff(); | |
| document.querySelectorAll("#items td[data-st]").forEach(function (td) { | |
| var it = state.items[+td.getAttribute("data-st")]; | |
| if (!it) return; | |
| var c = T.labelContent(it, st, isPro()), b = c.bc; | |
| if (!b) { td.innerHTML = ""; return; } | |
| if (b.empty) td.innerHTML = '<span class="chip warn" title="No barcode value">none</span>'; | |
| else if (!b.ok) td.innerHTML = '<span class="chip bad" title="' + esc(b.error) + '">⚠ fix</span>'; | |
| else td.innerHTML = '<span class="chip ok" title="' + esc(b.note || "valid") + '">' + esc(T.TYPES[b.type]) + (b.note ? " *" : " ✓") + "</span>"; | |
| }); | |
| } | |
| /* ------------------------------------------------------------ preview */ | |
| var raf = 0, printing = false, lastPlan = null; | |
| function changed() { save(); if (!raf) raf = requestAnimationFrame(function () { raf = 0; render(); }); } | |
| function changedItems() { renderTable(); changed(); if (!state.items.length) $("src-status").textContent = "No products yet. Load the example or import a CSV."; } | |
| /** Everything needed to draw a batch: settings, sheet, plan and one layout per product. */ | |
| function batch() { | |
| var st = eff(), pro = isPro(), sheet = T.sheetById(st.sheet, st.custom); | |
| var refs = T.expand(state.items), plan = T.planBatch(refs, sheet, st.start); | |
| var wPt = sheet.w * 72, hPt = sheet.h * 72, cache = {}; | |
| function lay(i) { | |
| if (!cache[i]) { | |
| var c = T.labelContent(state.items[i], st, pro), L = T.layoutLabel(c, wPt, hPt); | |
| cache[i] = { c: c, ops: L.ops, warnings: L.warnings }; | |
| } | |
| return cache[i]; | |
| } | |
| return { st: st, pro: pro, sheet: sheet, refs: refs, plan: plan, lay: lay, wPt: wPt, hPt: hPt }; | |
| } | |
| function render() { | |
| var B = batch(), st = B.st, sheet = B.sheet, plan = B.plan, pro = B.pro; | |
| var per = T.perPage(sheet), n = plan.count; | |
| var pw = sheet.page[0], ph = sheet.page[1], ox = (st.offX || 0) / 25.4, oy = (st.offY || 0) / 25.4; | |
| var maxPages = sheet.roll ? 12 : 4; | |
| var pages = plan.pages.length ? plan.pages : [{ labels: [] }]; | |
| var html = []; | |
| var svgCache = {}; | |
| function svgFor(ref) { if (!svgCache[ref]) svgCache[ref] = T.toSVG(B.lay(ref).ops, B.wPt, B.hPt, ' class="lbl" aria-hidden="true"'); return svgCache[ref]; } | |
| var markOK = !pro && !sheet.roll && T.bottomMargin(sheet) >= 0.15; | |
| pages.slice(0, maxPages).forEach(function (p, pi) { | |
| var bySlot = {}; | |
| p.labels.forEach(function (l) { bySlot[l.slot] = l; }); | |
| var cells = []; | |
| for (var k = 0; k < per; k++) { | |
| var col = k % sheet.cols, row = Math.floor(k / sheet.cols); | |
| var x = sheet.x0 + col * sheet.dx + ox, y = sheet.y0 + row * sheet.dy + oy; | |
| var style = "left:" + (x / pw * 100) + "%;top:" + (y / ph * 100) + "%;width:" + (sheet.w / pw * 100) + "%;height:" + (sheet.h / ph * 100) + "%;border-radius:" + (sheet.r / sheet.w * 100) + "%/" + (sheet.r / sheet.h * 100) + "%"; | |
| var l = bySlot[k], used = pi === 0 && k < plan.skipped; | |
| var bad = l && B.lay(l.ref).warnings.length; | |
| var attrs = !sheet.roll && pi === 0 ? ' data-slot="' + k + '" tabindex="0" role="button" aria-label="Start at label ' + (k + 1) + '"' : ""; | |
| cells.push('<div class="slot' + (l ? " full" : used ? " used" : "") + (bad ? " bad" : "") + '" style="' + style + '"' + attrs + (used ? ' title="Already used: skipped"' : "") + ">" + (l ? svgFor(l.ref) : "") + "</div>"); | |
| } | |
| html.push('<div class="sheetprev' + (sheet.roll ? " roll" : "") + '" style="aspect-ratio:' + pw + "/" + ph + (sheet.roll ? ";width:" + Math.min(100, pw / 2.25 * 46) + "%" : "") + '" aria-label="' + (sheet.roll ? "Label " : "Sheet ") + (pi + 1) + '">' + cells.join("") + | |
| (markOK ? '<span class="mark" style="top:' + ((ph - T.bottomMargin(sheet) / 2) / ph * 100) + '%">Tagwren by CyberMax</span>' : "") + "</div>"); | |
| }); | |
| $("pages").innerHTML = html.join(""); | |
| $("pages").className = "pages" + (sheet.roll ? " rolls" : ""); | |
| var more = plan.pages.length - maxPages; | |
| $("pages-more").textContent = more > 0 ? "+ " + more + " more " + (sheet.roll ? "labels" : "sheets") + " (all included when you print)." : ""; | |
| var nPages = plan.pages.length; | |
| var left = sheet.roll || !n ? 0 : nPages * per - n - plan.skipped; | |
| $("prev-sum").textContent = n ? n + " label" + (n === 1 ? "" : "s") + " · " + (sheet.roll ? n + " roll label" + (n === 1 ? "" : "s") : nPages + " sheet" + (nPages === 1 ? "" : "s") + " of " + sheet.short + (left ? " · " + left + " spare spot" + (left === 1 ? "" : "s") + " on the last sheet" : "")) : "Add products to see your labels. Click a spot to start there on a used sheet."; | |
| // warnings (one line per product) | |
| var warns = [], seen = {}; | |
| B.refs.forEach(function (r) { | |
| if (seen[r]) return; seen[r] = 1; | |
| var L = B.lay(r), it = state.items[r]; | |
| L.warnings.forEach(function (w) { warns.push("<li><b>" + esc(it.name || "Row " + (r + 1)) + (it.variant ? " · " + esc(it.variant) : "") + ":</b> " + esc(w) + "</li>"); }); | |
| if (L.c.bc && L.c.bc.empty && (st.barcode === "ean13" || st.barcode === "upca")) warns.push("<li><b>" + esc(it.name || "Row " + (r + 1)) + ":</b> no barcode value, so no barcode is printed.</li>"); | |
| }); | |
| if (sheet.id === "custom" && !T.sheetFits(sheet)) warns.unshift("<li><b>Custom size:</b> the labels run off the page. Check the margins and pitch.</li>"); | |
| $("warns").innerHTML = warns.slice(0, 12).join("") + (warns.length > 12 ? "<li>… and " + (warns.length - 12) + " more.</li>" : ""); | |
| // limit line | |
| var ll = $("limit-line"); | |
| if (pro) { ll.className = "limit pro"; ll.textContent = "Pro: unlimited labels and pages, no margin mark."; } | |
| else if (n > FREE_LIMIT) { ll.className = "limit over"; ll.innerHTML = "This batch has <b>" + n + " labels</b>. Free prints up to " + FREE_LIMIT + " per print or PDF: lower the quantities or <a href=\"#cmx-pro\">go Pro for unlimited</a>."; } | |
| else { ll.className = "limit"; ll.innerHTML = "Free: <b>" + n + " of " + FREE_LIMIT + "</b> labels per print or PDF used by this batch."; } | |
| $("mid-cta").hidden = pro || !n; | |
| $("btn-print").disabled = $("btn-pdf").disabled = !n; | |
| lastPlan = B; | |
| } | |
| /* ------------------------------------------------------------ output */ | |
| function allowed(B, what) { | |
| if (!B.plan.count) { toast("Add products (or load the example) first."); return false; } | |
| if (!B.pro && B.plan.count > FREE_LIMIT) { | |
| PRO.upsell("Free " + what + " up to " + FREE_LIMIT + " labels at a time; this batch has " + B.plan.count + ". Pro prints unlimited labels.", "limit"); | |
| return false; | |
| } | |
| return true; | |
| } | |
| function pageOps(B, p) { | |
| var sheet = B.sheet, st = B.st, ops = [], ox = (st.offX || 0) / 25.4 * 72, oy = (st.offY || 0) / 25.4 * 72; | |
| p.labels.forEach(function (l) { | |
| var bx = l.x * 72 + ox, by = l.y * 72 + oy; | |
| if (st.outlines) ops.push({ t: "frame", x: bx, y: by, w: B.wPt, h: B.hPt, lw: 0.4, c: "#999999" }); | |
| B.lay(l.ref).ops.forEach(function (o) { | |
| var c = Object.assign({}, o); | |
| if (o.t === "line") { c.x1 += bx; c.x2 += bx; c.y1 += by; c.y2 += by; } else { c.x += bx; c.y += by; } | |
| ops.push(c); | |
| }); | |
| }); | |
| if (!B.pro && !sheet.roll && T.bottomMargin(sheet) >= 0.15) { | |
| ops.push({ t: "text", x: sheet.page[0] * 72 / 2, y: (sheet.page[1] - T.bottomMargin(sheet) / 2) * 72 + 2, s: 6, b: false, a: "middle", str: "Tagwren by CyberMax", c: "#8A8A8A" }); | |
| } | |
| return ops; | |
| } | |
| function buildPDF(B) { | |
| B = B || batch(); | |
| var pages = B.plan.pages.map(function (p) { return { w: B.sheet.page[0] * 72, h: B.sheet.page[1] * 72, ops: pageOps(B, p) }; }); | |
| return PDF.build(pages, { title: "Labels · " + B.sheet.short }); | |
| } | |
| function output(kind) { | |
| var B = batch(); | |
| if (!allowed(B, kind === "pdf" ? "PDFs hold" : "prints take")) return; | |
| if (kind === "pdf") { | |
| var bytes = buildPDF(B); | |
| PRO.download("tagwren-labels-" + B.sheet.id + "-" + new Date().toISOString().slice(0, 10) + ".pdf", new Blob([bytes], { type: "application/pdf" }), "application/pdf"); | |
| toast("PDF ready: " + B.plan.count + " labels on " + B.plan.pages.length + (B.sheet.roll ? " pages" : " sheet" + (B.plan.pages.length === 1 ? "" : "s")) + ". Print it at 100% / Actual size."); | |
| } else { | |
| buildPrintRoot(B); | |
| printing = true; | |
| document.body.classList.add("printing"); | |
| setTimeout(function () { window.print(); }, 50); | |
| } | |
| lastPlan = B; lastPrinted = B; | |
| } | |
| function buildPrintRoot(B) { | |
| var sheet = B.sheet, st = B.st, pw = sheet.page[0], ph = sheet.page[1], ox = (st.offX || 0) / 25.4, oy = (st.offY || 0) / 25.4; | |
| $("page-style").textContent = "@page{size:" + pw + "in " + ph + "in;margin:0}"; | |
| var svgCache = {}, mark = !B.pro && !sheet.roll && T.bottomMargin(sheet) >= 0.15; | |
| $("print-root").innerHTML = B.plan.pages.map(function (p) { | |
| return '<div class="psheet" style="width:' + pw + "in;height:" + ph + 'in">' + p.labels.map(function (l) { | |
| if (!svgCache[l.ref]) svgCache[l.ref] = T.toSVG(B.lay(l.ref).ops, B.wPt, B.hPt, ' width="' + sheet.w + 'in" height="' + sheet.h + 'in"'); | |
| return '<div class="plabel' + (st.outlines ? " outl" : "") + '" style="left:' + (l.x + ox) + "in;top:" + (l.y + oy) + "in;width:" + sheet.w + "in;height:" + sheet.h + 'in">' + svgCache[l.ref] + "</div>"; | |
| }).join("") + (mark ? '<span class="pmark" style="top:' + (ph - T.bottomMargin(sheet) / 2 - 0.05) + 'in">Tagwren by CyberMax</span>' : "") + "</div>"; | |
| }).join(""); | |
| } | |
| var lastPrinted = null; | |
| function batchCSV() { | |
| if (!proGate("A CSV of each printed batch is part of Tagwren Pro.", "batch-csv")) return; | |
| var B = lastPrinted || batch(); | |
| if (!B.plan.count) { toast("Nothing printed yet."); return; } | |
| var rows = [["label", "sheet", "row", "column", "name", "variant", "price", "compare_at", "sku", "barcode_type", "barcode", "template"]]; | |
| B.plan.pages.forEach(function (p, pi) { | |
| p.labels.forEach(function (l) { | |
| var it = state.items[l.ref] || {}, bc = B.lay(l.ref).c.bc; | |
| rows.push([l.n + 1, pi + 1, l.row + 1, l.col + 1, it.name, it.variant, it.price, it.compare, it.sku, bc && bc.ok ? T.TYPES[bc.type] : "", bc && bc.ok ? bc.code : "", B.sheet.short]); | |
| }); | |
| }); | |
| PRO.download("tagwren-batch-" + new Date().toISOString().slice(0, 10) + ".csv", T.toCSV(rows)); | |
| } | |
| /* ------------------------------------------------------------ saved setups (Pro) */ | |
| function saveSetup() { | |
| if (!proGate("Saved setups and column mappings are part of Tagwren Pro.", "setup")) return; | |
| var name = ($("setup-name").value || "").trim() || (T.sheetById(state.st.sheet, state.st.custom).short + " setup"); | |
| state.setups = state.setups.filter(function (s) { return s.name !== name; }); | |
| state.setups.unshift({ name: name, st: JSON.parse(JSON.stringify(state.st)), mapping: state.mapping ? Object.assign({}, state.mapping) : null, at: new Date().toISOString() }); | |
| state.setups = state.setups.slice(0, 30); | |
| $("setup-name").value = ""; save(); renderSetups(); toast("Saved setup “" + name + "”."); | |
| } | |
| function renderSetups() { | |
| var el = $("setups"); | |
| if (!state.setups.length) { el.innerHTML = ""; return; } | |
| el.innerHTML = '<p class="hint">Saved setups:</p>' + state.setups.map(function (s, i) { | |
| return '<span class="setup"><button class="btn ghost sm" data-load="' + i + '">' + esc(s.name) + '</button><button class="x" data-rm="' + i + '" aria-label="Delete ' + esc(s.name) + '">×</button></span>'; | |
| }).join(""); | |
| el.onclick = function (e) { | |
| var l = e.target.closest("[data-load]"), r = e.target.closest("[data-rm]"); | |
| if (l) { | |
| if (!proGate("Saved setups are part of Tagwren Pro.", "setup")) return; | |
| var s = state.setups[+l.getAttribute("data-load")]; | |
| state.st = Object.assign(defaults().st, JSON.parse(JSON.stringify(s.st))); | |
| if (s.mapping && state.table) { state.mapping = Object.assign({}, s.mapping); rebuildItems(); renderMapping(); } | |
| syncControls(); changedItems(); toast("Loaded “" + s.name + "”."); | |
| } | |
| if (r) { state.setups.splice(+r.getAttribute("data-rm"), 1); save(); renderSetups(); } | |
| }; | |
| } | |
| /* ------------------------------------------------------------ PWA */ | |
| var deferred = null; | |
| window.addEventListener("beforeinstallprompt", function (e) { e.preventDefault(); deferred = e; $("btn-install").hidden = false; }); | |
| $("btn-install").onclick = function () { if (!deferred) return; deferred.prompt(); deferred.userChoice.then(function () { deferred = null; $("btn-install").hidden = true; }); }; | |
| window.addEventListener("appinstalled", function () { $("btn-install").hidden = true; }); | |
| if ("serviceWorker" in navigator && (location.protocol === "https:" || /^(localhost|127\.0\.0\.1)$/.test(location.hostname))) { | |
| window.addEventListener("load", function () { navigator.serviceWorker.register("sw.js").catch(function () { /* offline cache is optional */ }); }); | |
| } | |
| /* ------------------------------------------------------------ start */ | |
| fillSelects(); syncControls(); bind(); renderMapping(); renderTable(); renderSetups(); | |
| if (state.items.length) $("src-status").textContent = state.items.length + " products" + (state.sourceName ? " from " + state.sourceName : "") + " (saved in this browser)."; | |
| render(); | |
| if (PRO) PRO.onChange(function (pro) { | |
| var pill = $("pro-pill"); | |
| pill.textContent = pro ? "Pro on" : "Pro"; pill.classList.toggle("on", pro); | |
| document.documentElement.classList.toggle("is-pro", pro); | |
| if (state.table) renderMapping(); | |
| renderTableStatus(); render(); | |
| }); | |
| window.TagwrenApp = { state: function () { return state; }, render: render, buildPDF: function () { return buildPDF(); }, importText: importText, loadExample: loadExample, batch: batch }; | |
| })(); | |