tools: a Tech Tools section, starting with codes on label stock
A place for the small utilities a technician reaches for at a bench. The plugin owns no API and no tables: every tool runs entirely in the browser, so an air-gapped site gets them for free and a bad network cannot break them. Adding the next tool is a view, a route, and one entry in tools.js. First tool is a barcode/QR generator. Content is typed text, a URL, or a CSV (content,label,copies - quoted fields and an optional header both handled), so a batch of a few hundred is one paste. Label stock is adjustable in inches with five presets, and the code renders to an SVG data URI rather than a PNG: a bitmap gets downscaled to label size and smears the module edges a scanner reads, where SVG rasterizes at the printer's resolution with hard edges. It also carries the dot-grid rule that is easy to get wrong by eye. A thermal head cannot render a fraction of a dot, so a code sized off the grid gets uneven modules; pick a DPI and the page says what the current size lands on and what to use instead. The quiet zone is blank label rather than white baked into the code, so it can be tuned - and it applies to CODE128 too, which needs clear space at each end and was letting bars run into the caption. Tech Tools is the first bundled plugin that owns no schema, which two guards did not model: it belongs in the universal installer profile, and upgrade-all reports it 'no-migrations' where every plugin was assumed to report 'ok'. The migration test now asserts that status explicitly for schema-less plugins, so a table-owning plugin whose chain went missing still fails.
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"printedparts",
|
||||
"printers",
|
||||
"slides",
|
||||
"tools",
|
||||
"usb",
|
||||
"warranty"
|
||||
],
|
||||
|
||||
@@ -104,7 +104,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut
|
||||
Box, KeyRound, LogOut, Wrench
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -166,6 +166,7 @@ const iconMap = {
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
'wrench': Wrench,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
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')
|
||||
@@ -71,6 +71,8 @@ EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||
# the shared board category.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||
# warranty adds the proof-of-cover document columns on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
|
||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||
# core chain never owned.
|
||||
@@ -239,9 +241,21 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
||||
first = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
second = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
|
||||
assert set(first) == set(PLUGIN_TABLE_OWNERS)
|
||||
assert all(status == 'ok' for status in first.values()), first
|
||||
assert all(status == 'ok' for status in second.values()), second
|
||||
# A plugin that owns no tables (Tech Tools: every tool is
|
||||
# client-side) carries no chain, so upgrade-all reports it
|
||||
# 'no-migrations'. Assert that explicitly rather than letting it
|
||||
# widen the 'ok' check, which would also swallow a table-owning
|
||||
# plugin whose chain silently went missing.
|
||||
schemaless = set(first) - set(PLUGIN_TABLE_OWNERS)
|
||||
for name in schemaless:
|
||||
assert first[name] == 'no-migrations', (name, first[name])
|
||||
assert second[name] == 'no-migrations', (name, second[name])
|
||||
|
||||
migrated = {name: status for name, status in first.items()
|
||||
if name in PLUGIN_TABLE_OWNERS}
|
||||
assert set(migrated) == set(PLUGIN_TABLE_OWNERS)
|
||||
assert all(status == 'ok' for status in migrated.values()), migrated
|
||||
assert all(second[name] == 'ok' for name in PLUGIN_TABLE_OWNERS), second
|
||||
|
||||
insp = inspect(db.engine)
|
||||
for plugin in PLUGIN_TABLE_OWNERS:
|
||||
|
||||
55
tests/test_plugins/test_tools.py
Normal file
55
tests/test_plugins/test_tools.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for the Tech Tools plugin.
|
||||
|
||||
The plugin owns no API and no tables, so the whole backend contract is: it
|
||||
loads, it advertises one nav entry, and it claims no database. That last part
|
||||
matters - a plugin that accidentally returns models would pull the tools
|
||||
section into the migration and prune-schema machinery it has no business in.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from plugins.tools.plugin import ToolsPlugin
|
||||
|
||||
|
||||
PLUGIN_DIR = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))), 'plugins', 'tools')
|
||||
|
||||
|
||||
def test_meta_matches_manifest():
|
||||
"""The manifest is the single source of truth per ADR-002."""
|
||||
with open(os.path.join(PLUGIN_DIR, 'manifest.json'), encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
|
||||
meta = ToolsPlugin().meta
|
||||
assert meta.name == manifest['name']
|
||||
assert meta.version == manifest['version']
|
||||
assert meta.core_version == manifest['core_version']
|
||||
|
||||
|
||||
def test_no_backend_surface():
|
||||
"""Client-side only: no blueprint to register, no tables to migrate."""
|
||||
plugin = ToolsPlugin()
|
||||
assert plugin.get_blueprint() is None
|
||||
assert plugin.get_models() == []
|
||||
|
||||
|
||||
def test_navigation_item():
|
||||
items = ToolsPlugin().get_navigation_items()
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item['name'] == 'Tech Tools'
|
||||
assert item['route'] == '/tools'
|
||||
# The icon has to exist in AppLayout's iconMap or the nav entry renders
|
||||
# with no glyph.
|
||||
assert item['icon'] == 'wrench'
|
||||
|
||||
|
||||
def test_nav_icon_is_mapped_in_the_frontend():
|
||||
"""Guard the one cross-file coupling: nav icon name -> AppLayout iconMap."""
|
||||
layout = os.path.join(os.path.dirname(PLUGIN_DIR), '..', 'frontend', 'src',
|
||||
'views', 'AppLayout.vue')
|
||||
with open(os.path.normpath(layout), encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
for item in ToolsPlugin().get_navigation_items():
|
||||
assert f"'{item['icon']}':" in source
|
||||
Reference in New Issue
Block a user