Ship the Tech Tools plugin that the previous export silently dropped
This commit is contained in:
5
plugins/tools/__init__.py
Normal file
5
plugins/tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Tech Tools plugin: browser-side utilities for the shop floor."""
|
||||||
|
|
||||||
|
from .plugin import ToolsPlugin
|
||||||
|
|
||||||
|
__all__ = ['ToolsPlugin']
|
||||||
26
plugins/tools/frontend/routes.js
Normal file
26
plugins/tools/frontend/routes.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Tech Tools plugin routes.
|
||||||
|
*
|
||||||
|
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
|
||||||
|
*
|
||||||
|
* The index sits inside the app shell. Each tool that PRINTS is toplevel, the
|
||||||
|
* way every other label page in this repo is: printing from inside AppLayout
|
||||||
|
* would put the sidebar and header on the label stock.
|
||||||
|
*/
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'tools',
|
||||||
|
name: 'tools',
|
||||||
|
component: () => import('./views/ToolsIndex.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'tools' }
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const toplevel = [
|
||||||
|
{
|
||||||
|
path: '/tools/codes',
|
||||||
|
name: 'tools-codes',
|
||||||
|
component: () => import('./views/CodeGenerator.vue'),
|
||||||
|
meta: { requiresAuth: true, plugin: 'tools' }
|
||||||
|
},
|
||||||
|
]
|
||||||
67
plugins/tools/frontend/tools.js
Normal file
67
plugins/tools/frontend/tools.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// The Tech Tools catalog.
|
||||||
|
//
|
||||||
|
// This is the ONLY place a tool has to be declared. Adding one is: write the
|
||||||
|
// view, add a route in routes.js, add an entry here. The index page groups by
|
||||||
|
// category and searches over name + description + keywords.
|
||||||
|
//
|
||||||
|
// Fields:
|
||||||
|
// id - stable slug, also the key the index uses
|
||||||
|
// name - card title
|
||||||
|
// description - one line, says what it does, not how
|
||||||
|
// category - group heading on the index
|
||||||
|
// route - path to open
|
||||||
|
// standalone - true when the route lives OUTSIDE AppLayout (print pages),
|
||||||
|
// so the index opens it as a normal link rather than a
|
||||||
|
// router-link and the user keeps this tab
|
||||||
|
// keywords - extra search terms someone might type instead of the name
|
||||||
|
export const TOOLS = [
|
||||||
|
{
|
||||||
|
id: 'code-generator',
|
||||||
|
name: 'Barcode / QR Generator',
|
||||||
|
description: 'Make QR or CODE128 labels from typed text, a URL, or a CSV, sized for your label stock.',
|
||||||
|
category: 'labels',
|
||||||
|
route: '/tools/codes',
|
||||||
|
standalone: true,
|
||||||
|
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CATEGORY_LABELS = {
|
||||||
|
labels: 'Labels & Printing',
|
||||||
|
convert: 'Conversion',
|
||||||
|
network: 'Network',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryLabel(category) {
|
||||||
|
return CATEGORY_LABELS[category] || category
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools matching a search string, or all of them when the box is empty.
|
||||||
|
export function searchTools(query) {
|
||||||
|
const needle = (query || '').trim().toLowerCase()
|
||||||
|
if (!needle) return TOOLS
|
||||||
|
return TOOLS.filter(tool => {
|
||||||
|
const haystack = [tool.name, tool.description, ...(tool.keywords || [])]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
return haystack.includes(needle)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools grouped for the index, categories in CATEGORY_LABELS order so the
|
||||||
|
// page does not reshuffle as tools are added.
|
||||||
|
export function groupTools(query) {
|
||||||
|
const matched = searchTools(query)
|
||||||
|
const order = Object.keys(CATEGORY_LABELS)
|
||||||
|
const seen = [...new Set(matched.map(tool => tool.category))]
|
||||||
|
seen.sort((a, b) => {
|
||||||
|
const ai = order.indexOf(a)
|
||||||
|
const bi = order.indexOf(b)
|
||||||
|
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
|
||||||
|
})
|
||||||
|
return seen.map(category => ({
|
||||||
|
category,
|
||||||
|
label: categoryLabel(category),
|
||||||
|
tools: matched.filter(tool => tool.category === category),
|
||||||
|
}))
|
||||||
|
}
|
||||||
678
plugins/tools/frontend/views/CodeGenerator.vue
Normal file
678
plugins/tools/frontend/views/CodeGenerator.vue
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="no-print">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="controls-head">
|
||||||
|
<h3>Barcode / QR Generator</h3>
|
||||||
|
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- What to encode -->
|
||||||
|
<div class="control-row">
|
||||||
|
<label>
|
||||||
|
Source
|
||||||
|
<select v-model="source">
|
||||||
|
<option value="single">One code</option>
|
||||||
|
<option value="csv">CSV list</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Code type
|
||||||
|
<select v-model="codetype">
|
||||||
|
<option value="qr">QR</option>
|
||||||
|
<option value="barcode">CODE128</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="codetype === 'qr'">
|
||||||
|
Error correction
|
||||||
|
<select v-model="errorcorrection">
|
||||||
|
<option value="L">L - smallest code</option>
|
||||||
|
<option value="M">M - standard</option>
|
||||||
|
<option value="Q">Q</option>
|
||||||
|
<option value="H">H - most robust</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="source === 'single'" class="control-row">
|
||||||
|
<label class="grow">
|
||||||
|
Content (text or URL)
|
||||||
|
<textarea v-model="singleContent" rows="2"
|
||||||
|
placeholder="https://example.com or any text"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="grow">
|
||||||
|
Label (optional)
|
||||||
|
<input v-model="singleLabel" type="text" placeholder="Printed under or beside the code" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Copies
|
||||||
|
<input v-model.number="singleCopies" type="number" min="1" max="1000" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="csv-block">
|
||||||
|
<div class="control-row">
|
||||||
|
<label>
|
||||||
|
CSV file
|
||||||
|
<input type="file" accept=".csv,text/csv" @change="onCsvFile" />
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-secondary" @click="downloadTemplate">Download template</button>
|
||||||
|
</div>
|
||||||
|
<label class="grow">
|
||||||
|
...or paste rows here
|
||||||
|
<textarea v-model="csvText" rows="5"
|
||||||
|
placeholder="content,label,copies https://example.com,Front desk,1 WJ-LF-0001,Line 1,2"></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="control-note" v-if="csvError">{{ csvError }}</p>
|
||||||
|
<p class="control-hint">
|
||||||
|
Columns: <code>content</code> (required), <code>label</code>, <code>copies</code>.
|
||||||
|
A header row is optional; without one the order is content, label, copies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media -->
|
||||||
|
<h4 class="section-heading">Label stock</h4>
|
||||||
|
<div class="control-row">
|
||||||
|
<label>
|
||||||
|
Preset
|
||||||
|
<select v-model="preset" @change="applyPreset">
|
||||||
|
<option value="">Custom</option>
|
||||||
|
<option v-for="option in PRESETS" :key="option.id" :value="option.id">
|
||||||
|
{{ option.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Label W (in)<input v-model.number="labelwidth" type="number" step="0.01" min="0.25" /></label>
|
||||||
|
<label>Label H (in)<input v-model.number="labelheight" type="number" step="0.01" min="0.25" /></label>
|
||||||
|
<label>Padding (in)<input v-model.number="padding" type="number" step="0.005" min="0" /></label>
|
||||||
|
<label>
|
||||||
|
Layout
|
||||||
|
<select v-model="layout">
|
||||||
|
<option value="side">Code left, label right</option>
|
||||||
|
<option value="stack">Code above label</option>
|
||||||
|
<option value="codeonly">Code only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row">
|
||||||
|
<label v-if="codetype === 'qr'">Code size (in)<input v-model.number="codesize" type="number" step="0.0001" min="0.1" /></label>
|
||||||
|
<label v-else>Bar height (in)<input v-model.number="barheight" type="number" step="0.01" min="0.1" /></label>
|
||||||
|
<label>Quiet zone (in)<input v-model.number="quiet" type="number" step="0.005" min="0" /></label>
|
||||||
|
<label>Label font (pt)<input v-model.number="labelfont" type="number" step="0.5" min="3" /></label>
|
||||||
|
<label>Nudge X (in)<input v-model.number="nudgex" type="number" step="0.01" /></label>
|
||||||
|
<label>Nudge Y (in)<input v-model.number="nudgey" type="number" step="0.01" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The dot-size trap: a thermal head cannot render a fraction of a dot,
|
||||||
|
so a code sized off the dot grid gets uneven modules and scans
|
||||||
|
badly. This says what the current size actually lands on. -->
|
||||||
|
<div class="control-row">
|
||||||
|
<label>
|
||||||
|
Printer DPI
|
||||||
|
<select v-model.number="dpi">
|
||||||
|
<option :value="203">203</option>
|
||||||
|
<option :value="300">300</option>
|
||||||
|
<option :value="600">600</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p v-if="codetype === 'qr' && fitAdvice" class="control-hint fit-advice">
|
||||||
|
{{ fitAdvice }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-row">
|
||||||
|
<button class="btn btn-primary" :disabled="!labels.length" @click="printAll">
|
||||||
|
Print {{ labels.length }} label{{ labels.length === 1 ? '' : 's' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" :disabled="!labels.length" @click="printTest">
|
||||||
|
Print 1 test label
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="resetSettings">Reset sizing</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="truncated" class="control-note">
|
||||||
|
Showing the first {{ MAX_LABELS }} rows. {{ truncated }} more were left out
|
||||||
|
- split the CSV and print it in batches.
|
||||||
|
</p>
|
||||||
|
<p v-if="overlong.length" class="control-note">
|
||||||
|
{{ overlong.length }} row(s) could not be encoded as CODE128 or were empty:
|
||||||
|
{{ overlong.slice(0, 3).join(', ') }}{{ overlong.length > 3 ? ' ...' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview doubles as the print surface: what is on screen is what prints. -->
|
||||||
|
<div class="sheet">
|
||||||
|
<div
|
||||||
|
v-for="(label, index) in visibleLabels"
|
||||||
|
:key="index"
|
||||||
|
class="label"
|
||||||
|
:class="['layout-' + layout, 'type-' + codetype]"
|
||||||
|
>
|
||||||
|
<div class="codebox">
|
||||||
|
<img v-if="images[index]" :src="images[index]" class="code-img" alt="" />
|
||||||
|
</div>
|
||||||
|
<div v-if="layout !== 'codeonly' && label.label" class="label-text">
|
||||||
|
{{ label.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import JsBarcode from 'jsbarcode'
|
||||||
|
|
||||||
|
// Rendering thousands of codes locks the tab, so stop at a number a person
|
||||||
|
// would actually feed a label printer in one run and SAY what was dropped.
|
||||||
|
const MAX_LABELS = 500
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
|
||||||
|
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
|
||||||
|
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
|
||||||
|
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
|
||||||
|
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const source = ref('single')
|
||||||
|
const codetype = ref('qr')
|
||||||
|
const errorcorrection = ref('M')
|
||||||
|
|
||||||
|
const singleContent = ref('')
|
||||||
|
const singleLabel = ref('')
|
||||||
|
const singleCopies = ref(1)
|
||||||
|
|
||||||
|
const csvText = ref('')
|
||||||
|
const csvError = ref('')
|
||||||
|
|
||||||
|
const preset = ref('zebra1x05')
|
||||||
|
const labelwidth = ref(1.0)
|
||||||
|
const labelheight = ref(0.5)
|
||||||
|
const padding = ref(0)
|
||||||
|
const quiet = ref(0.035)
|
||||||
|
const codesize = ref(0.4286)
|
||||||
|
const barheight = ref(0.3)
|
||||||
|
const labelfont = ref(7)
|
||||||
|
const layout = ref('side')
|
||||||
|
const nudgex = ref(0)
|
||||||
|
const nudgey = ref(0)
|
||||||
|
const dpi = ref(203)
|
||||||
|
|
||||||
|
const images = ref([])
|
||||||
|
const testMode = ref(false)
|
||||||
|
const overlong = ref([])
|
||||||
|
|
||||||
|
const SETTING_KEYS = [
|
||||||
|
'codetype', 'errorcorrection', 'preset', 'labelwidth', 'labelheight', 'padding',
|
||||||
|
'quiet', 'codesize', 'barheight', 'labelfont', 'layout', 'nudgex', 'nudgey', 'dpi',
|
||||||
|
]
|
||||||
|
const settingRefs = {
|
||||||
|
codetype, errorcorrection, preset, labelwidth, labelheight, padding,
|
||||||
|
quiet, codesize, barheight, labelfont, layout, nudgex, nudgey, dpi,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rows -------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Split one CSV line, honoring quoted fields and "" escapes.
|
||||||
|
function splitCsvLine(line) {
|
||||||
|
const fields = []
|
||||||
|
let current = ''
|
||||||
|
let inQuotes = false
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i]
|
||||||
|
if (inQuotes) {
|
||||||
|
if (char === '"' && line[i + 1] === '"') { current += '"'; i++ }
|
||||||
|
else if (char === '"') { inQuotes = false }
|
||||||
|
else { current += char }
|
||||||
|
} else if (char === '"') {
|
||||||
|
inQuotes = true
|
||||||
|
} else if (char === ',') {
|
||||||
|
fields.push(current); current = ''
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(current)
|
||||||
|
return fields.map(field => field.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr']
|
||||||
|
const LABEL_HEADERS = ['label', 'name', 'caption', 'description']
|
||||||
|
const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count']
|
||||||
|
|
||||||
|
function parseCsv(text) {
|
||||||
|
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '')
|
||||||
|
if (!lines.length) return []
|
||||||
|
|
||||||
|
let columns = { content: 0, label: 1, copies: 2 }
|
||||||
|
let start = 0
|
||||||
|
const first = splitCsvLine(lines[0]).map(field => field.toLowerCase())
|
||||||
|
if (first.some(field => CONTENT_HEADERS.includes(field))) {
|
||||||
|
// Named header: map by name so column order does not matter.
|
||||||
|
const indexOf = names => first.findIndex(field => names.includes(field))
|
||||||
|
columns = {
|
||||||
|
content: indexOf(CONTENT_HEADERS),
|
||||||
|
label: indexOf(LABEL_HEADERS),
|
||||||
|
copies: indexOf(COPIES_HEADERS),
|
||||||
|
}
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = []
|
||||||
|
for (let i = start; i < lines.length; i++) {
|
||||||
|
const fields = splitCsvLine(lines[i])
|
||||||
|
const content = columns.content >= 0 ? (fields[columns.content] || '') : ''
|
||||||
|
if (!content) continue
|
||||||
|
const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1
|
||||||
|
rows.push({
|
||||||
|
content,
|
||||||
|
label: columns.label >= 0 ? (fields[columns.label] || '') : '',
|
||||||
|
copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, 1000) : 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every row expanded by its copies count, capped.
|
||||||
|
const allLabels = computed(() => {
|
||||||
|
let rows = []
|
||||||
|
if (source.value === 'single') {
|
||||||
|
const content = singleContent.value.trim()
|
||||||
|
if (content) {
|
||||||
|
const copies = Number.isFinite(singleCopies.value) && singleCopies.value > 0
|
||||||
|
? singleCopies.value : 1
|
||||||
|
rows = [{ content, label: singleLabel.value.trim(), copies }]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
csvError.value = ''
|
||||||
|
rows = parseCsv(csvText.value)
|
||||||
|
if (csvText.value.trim() && !rows.length) {
|
||||||
|
csvError.value = 'No usable rows found. Every row needs a content value.'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
csvError.value = 'Could not read that CSV: ' + error.message
|
||||||
|
rows = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = []
|
||||||
|
for (const row of rows) {
|
||||||
|
for (let i = 0; i < row.copies; i++) {
|
||||||
|
expanded.push({ content: row.content, label: row.label })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expanded
|
||||||
|
})
|
||||||
|
|
||||||
|
const truncated = computed(() =>
|
||||||
|
Math.max(0, allLabels.value.length - MAX_LABELS))
|
||||||
|
|
||||||
|
const labels = computed(() => allLabels.value.slice(0, MAX_LABELS))
|
||||||
|
|
||||||
|
// Printing one test label prints the first only; the preview follows so what
|
||||||
|
// you see is what comes out.
|
||||||
|
const visibleLabels = computed(() =>
|
||||||
|
testMode.value ? labels.value.slice(0, 1) : labels.value)
|
||||||
|
|
||||||
|
// --- fit advice -------------------------------------------------------------
|
||||||
|
|
||||||
|
// Modules across the QR at the current content and error correction, quiet
|
||||||
|
// zone excluded (the blank label supplies that).
|
||||||
|
const qrModules = computed(() => {
|
||||||
|
const content = labels.value[0]?.content
|
||||||
|
if (!content || codetype.value !== 'qr') return 0
|
||||||
|
try {
|
||||||
|
return QRCode.create(content, { errorCorrectionLevel: errorcorrection.value }).modules.size
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fitAdvice = computed(() => {
|
||||||
|
const modules = qrModules.value
|
||||||
|
if (!modules) return ''
|
||||||
|
const dotsPerModule = (codesize.value * dpi.value) / modules
|
||||||
|
const whole = Math.floor(dotsPerModule)
|
||||||
|
if (whole < 2) {
|
||||||
|
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each - too small to scan `
|
||||||
|
+ `reliably. Shorten the content, drop error correction, or use bigger stock.`
|
||||||
|
}
|
||||||
|
const snapped = (modules * whole) / dpi.value
|
||||||
|
const moduleMm = (whole / dpi.value) * 25.4
|
||||||
|
if (Math.abs(snapped - codesize.value) < 0.002) {
|
||||||
|
return `${modules} modules at exactly ${whole} dots each (${moduleMm.toFixed(3)} mm). Good.`
|
||||||
|
}
|
||||||
|
return `${modules} modules at ${dotsPerModule.toFixed(2)} dots each. Uneven - `
|
||||||
|
+ `use ${snapped.toFixed(4)} in for a whole ${whole} dots per module.`
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- rendering --------------------------------------------------------------
|
||||||
|
|
||||||
|
// Each code renders to an SVG data URI, for two reasons. An <img> prints
|
||||||
|
// reliably where a live canvas or inline SVG does not (same finding the asset
|
||||||
|
// label pages are built on), and SVG rasterizes at the printer's resolution
|
||||||
|
// with hard module edges - a PNG would be downscaled to the label size and
|
||||||
|
// smear the very edges a scanner reads.
|
||||||
|
//
|
||||||
|
// margin 0 on the QR: the quiet zone is blank label supplied by --tool-quiet,
|
||||||
|
// so none of the code box is spent on white we cannot then adjust.
|
||||||
|
function svgDataUri(svg) {
|
||||||
|
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOne(row) {
|
||||||
|
const text = row.content
|
||||||
|
if (!text) return ''
|
||||||
|
if (codetype.value === 'qr') {
|
||||||
|
const svg = await QRCode.toString(text, {
|
||||||
|
type: 'svg',
|
||||||
|
errorCorrectionLevel: errorcorrection.value,
|
||||||
|
margin: 0,
|
||||||
|
})
|
||||||
|
return svgDataUri(svg)
|
||||||
|
}
|
||||||
|
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||||
|
JsBarcode(element, text, {
|
||||||
|
format: 'CODE128',
|
||||||
|
displayValue: false,
|
||||||
|
margin: 0,
|
||||||
|
width: 2,
|
||||||
|
height: 100,
|
||||||
|
})
|
||||||
|
return svgDataUri(new XMLSerializer().serializeToString(element))
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderToken = 0
|
||||||
|
async function renderAll() {
|
||||||
|
const token = ++renderToken
|
||||||
|
const failed = []
|
||||||
|
const next = []
|
||||||
|
for (const row of labels.value) {
|
||||||
|
try {
|
||||||
|
next.push(await renderOne(row))
|
||||||
|
} catch {
|
||||||
|
// CODE128 rejects some characters; an unencodable row must be named,
|
||||||
|
// not silently dropped to a blank sticker.
|
||||||
|
next.push('')
|
||||||
|
failed.push(row.content.slice(0, 20))
|
||||||
|
}
|
||||||
|
if (token !== renderToken) return
|
||||||
|
}
|
||||||
|
images.value = next
|
||||||
|
overlong.value = failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- media vars -------------------------------------------------------------
|
||||||
|
|
||||||
|
// @page cannot read a scoped style, so the sizing lives in custom properties on
|
||||||
|
// the root element. Chromium honors var() in @page size.
|
||||||
|
function applyVars() {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.style.setProperty('--tool-label-w', labelwidth.value + 'in')
|
||||||
|
root.style.setProperty('--tool-label-h', labelheight.value + 'in')
|
||||||
|
root.style.setProperty('--tool-pad', padding.value + 'in')
|
||||||
|
root.style.setProperty('--tool-code', codesize.value + 'in')
|
||||||
|
root.style.setProperty('--tool-barh', barheight.value + 'in')
|
||||||
|
// Both symbologies need clear space: a QR wants a quiet zone around it, and
|
||||||
|
// CODE128 wants one at each end (10x the narrow bar). It doubles as the gap
|
||||||
|
// between the code and the label text, so zeroing it for barcodes let the
|
||||||
|
// bars run into the caption.
|
||||||
|
root.style.setProperty('--tool-quiet', quiet.value + 'in')
|
||||||
|
root.style.setProperty('--tool-font', labelfont.value + 'pt')
|
||||||
|
root.style.setProperty('--tool-nudge-x', nudgex.value + 'in')
|
||||||
|
root.style.setProperty('--tool-nudge-y', nudgey.value + 'in')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVars() {
|
||||||
|
const root = document.documentElement
|
||||||
|
for (const name of ['--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code',
|
||||||
|
'--tool-barh', '--tool-quiet', '--tool-font', '--tool-nudge-x', '--tool-nudge-y']) {
|
||||||
|
root.style.removeProperty(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset() {
|
||||||
|
const chosen = PRESETS.find(option => option.id === preset.value)
|
||||||
|
if (!chosen) return
|
||||||
|
labelwidth.value = chosen.labelwidth
|
||||||
|
labelheight.value = chosen.labelheight
|
||||||
|
codesize.value = chosen.codesize
|
||||||
|
padding.value = chosen.padding
|
||||||
|
quiet.value = chosen.quiet
|
||||||
|
labelfont.value = chosen.labelfont
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSettings() {
|
||||||
|
preset.value = 'zebra1x05'
|
||||||
|
applyPreset()
|
||||||
|
layout.value = 'side'
|
||||||
|
barheight.value = 0.3
|
||||||
|
nudgex.value = 0
|
||||||
|
nudgey.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- csv helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
function onCsvFile(event) {
|
||||||
|
const file = event.target.files && event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => { csvText.value = String(reader.result || '') }
|
||||||
|
reader.onerror = () => { csvError.value = 'Could not read that file.' }
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadTemplate() {
|
||||||
|
const csv = [
|
||||||
|
'content,label,copies',
|
||||||
|
'https://example.com/asset/1,Bay 12 press,1',
|
||||||
|
'WJ-LF-0001,Line 1 fixture,2',
|
||||||
|
].join('\n')
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||||
|
link.download = 'code_labels_template.csv'
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- printing ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function printAll() {
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printTest() {
|
||||||
|
testMode.value = true
|
||||||
|
await nextTick()
|
||||||
|
window.print()
|
||||||
|
testMode.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lifecycle --------------------------------------------------------------
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
const state = {}
|
||||||
|
for (const key of SETTING_KEYS) state[key] = settingRefs[key].value
|
||||||
|
try { localStorage.setItem('toolsCodeGenerator', JSON.stringify(state)) } catch { /* private mode */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('toolsCodeGenerator') || '{}')
|
||||||
|
for (const key of SETTING_KEYS) {
|
||||||
|
if (saved[key] !== undefined) settingRefs[key].value = saved[key]
|
||||||
|
}
|
||||||
|
} catch { /* ignore a corrupt entry */ }
|
||||||
|
applyVars()
|
||||||
|
renderAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(clearVars)
|
||||||
|
|
||||||
|
watch(SETTING_KEYS.map(key => settingRefs[key]), () => {
|
||||||
|
applyVars()
|
||||||
|
saveSettings()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([labels, codetype, errorcorrection], renderAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* One label per page. Chromium reads var() here; the print dialog must be set
|
||||||
|
to Margins = None and Scale = 100%. */
|
||||||
|
@page { size: var(--tool-label-w) var(--tool-label-h); margin: 0; }
|
||||||
|
|
||||||
|
.no-print { padding: 20px; }
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-head h3 { margin: 0; }
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row label.grow { flex: 1 1 18rem; }
|
||||||
|
|
||||||
|
.control-row input,
|
||||||
|
.control-row select,
|
||||||
|
.control-row textarea,
|
||||||
|
.csv-block input,
|
||||||
|
.csv-block textarea {
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row input[type="number"] { width: 7rem; }
|
||||||
|
|
||||||
|
.csv-block label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 8px 0 0; }
|
||||||
|
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 0; }
|
||||||
|
.fit-advice { flex: 1 1 22rem; align-self: center; }
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: var(--tool-label-w);
|
||||||
|
height: var(--tool-label-h);
|
||||||
|
padding: var(--tool-pad);
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
left: var(--tool-nudge-x);
|
||||||
|
top: var(--tool-nudge-y);
|
||||||
|
outline: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label.layout-stack { flex-direction: column; justify-content: center; }
|
||||||
|
.label.layout-codeonly { justify-content: center; }
|
||||||
|
|
||||||
|
.codebox {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: var(--tool-quiet);
|
||||||
|
width: var(--tool-code);
|
||||||
|
height: var(--tool-code);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A QR is square, so its box is code-size on a side. A CODE128 is wide and
|
||||||
|
short: it takes the width left over on the label and only its own bar
|
||||||
|
height, or the whole width when nothing shares the label with it. */
|
||||||
|
.label.type-barcode .codebox {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: var(--tool-barh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-family: "Arial Narrow", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: var(--tool-font);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.05;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.sheet { padding: 0; display: block; gap: 0; }
|
||||||
|
.label {
|
||||||
|
outline: none;
|
||||||
|
break-after: page;
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
.label:last-of-type { break-after: auto; page-break-after: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
plugins/tools/frontend/views/ToolsIndex.vue
Normal file
93
plugins/tools/frontend/views/ToolsIndex.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Tech Tools</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search tools..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="group in groups" :key="group.category" class="tool-group">
|
||||||
|
<h2 class="group-title">{{ group.label }}</h2>
|
||||||
|
<div class="tools-grid">
|
||||||
|
<router-link
|
||||||
|
v-for="tool in group.tools"
|
||||||
|
:key="tool.id"
|
||||||
|
:to="tool.route"
|
||||||
|
class="tool-card card"
|
||||||
|
>
|
||||||
|
<h3>{{ tool.name }}</h3>
|
||||||
|
<p>{{ tool.description }}</p>
|
||||||
|
<span class="badge">{{ group.label }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!groups.length" class="empty-state">
|
||||||
|
No tools match your search.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { groupTools } from '../tools'
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const groups = computed(() => groupTools(search.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card h3 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
plugins/tools/manifest.json
Normal file
11
plugins/tools/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)",
|
||||||
|
"author": "ShopDB Team",
|
||||||
|
"dependencies": [],
|
||||||
|
"core_version": ">=0.16.0,<1.0.0",
|
||||||
|
"provides": {
|
||||||
|
"features": ["code_generator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
56
plugins/tools/plugin.py
Normal file
56
plugins/tools/plugin.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tech Tools plugin.
|
||||||
|
|
||||||
|
A section for small technician utilities. Every tool here runs entirely in the
|
||||||
|
browser: no API, no tables, no state. That is deliberate - these are the things
|
||||||
|
someone reaches for at a bench with no network guarantee, and an air-gapped
|
||||||
|
site gets them for free.
|
||||||
|
|
||||||
|
The plugin exists so the section can carry a nav entry and be included in or
|
||||||
|
left out of a per-site build (ADR-013). The tools themselves are frontend-only,
|
||||||
|
which is why get_blueprint and get_models are both empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Type
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask
|
||||||
|
|
||||||
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsPlugin(BasePlugin):
|
||||||
|
"""Registers the Tech Tools section. All tools are client-side."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta(self) -> PluginMeta:
|
||||||
|
return PluginMeta(
|
||||||
|
name='tools',
|
||||||
|
version='1.0.0',
|
||||||
|
description='Tech Tools: browser-side technician utilities',
|
||||||
|
author='ShopDB Team',
|
||||||
|
dependencies=[],
|
||||||
|
core_version='>=0.16.0,<1.0.0',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_blueprint(self) -> Optional[Blueprint]:
|
||||||
|
# No API surface. The tools never leave the browser.
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_models(self) -> List[Type]:
|
||||||
|
# No tables. Nothing here is persisted.
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_navigation_items(self) -> List[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'name': 'Tech Tools',
|
||||||
|
'icon': 'wrench',
|
||||||
|
'route': '/tools',
|
||||||
|
'position': 47,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def on_install(self, app: Flask) -> None:
|
||||||
|
logger.info('Tools plugin installed')
|
||||||
Reference in New Issue
Block a user