diff --git a/docs/api/README.md b/docs/api/README.md index ae6545a..a990cfa 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -240,6 +240,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/printeragent/_id.patch.md`](routes/printeragent/_id.patch.md) | `PATCH /printeragent/{id}` | | [`routes/printerjob.get.md`](routes/printerjob.get.md) | `GET /printerjob` | | [`routes/printerjob.post.md`](routes/printerjob.post.md) | `POST /printerjob` | +| [`routes/printerjob/cancel-all.md`](routes/printerjob/cancel-all.md) | `POST /printerjob/cancel-all` | | [`routes/printerjob/_id.delete.md`](routes/printerjob/_id.delete.md) | `DELETE /printerjob/{id}` | | [`routes/printerjob/_id.get.md`](routes/printerjob/_id.get.md) | `GET /printerjob/{id}` | | [`routes/printerjob/_id/cancel.md`](routes/printerjob/_id/cancel.md) | `POST /printerjob/{id}/cancel` | @@ -361,7 +362,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A | `modelconversation-` | `POST /modelconversation/{id}/message` | | `order` | `DELETE /order/{id}/file/{fileId}`, `POST /order/{id}/pdf`, `DELETE /shipment/{id}`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}` | | `printer` | `POST /printer`, `PUT /printer/order`, `DELETE /printer/{id}`, `PATCH /printer/{id}`, `PUT /printer/{id}` | -| `printerjob` | `POST /printerjob`, `DELETE /printerjob/{id}`, `POST /printerjob/{id}/cancel`, `POST /zpljob` | +| `printerjob` | `POST /printerjob`, `POST /printerjob/cancel-all`, `DELETE /printerjob/{id}`, `POST /printerjob/{id}/cancel`, `POST /zpljob` | | `propertyname` | `POST /propertyname`, `PUT /propertyname/order`, `DELETE /propertyname/{id}`, `PATCH /propertyname/{id}` | | `quote` | `DELETE /quote/{id}/file/{fileId}`, `POST /quote/{id}/pdf` | | `shipment` | `DELETE /shipment/{id}`, `PATCH /shipment/{id}`, `DELETE /shipment/{id}/file/{fileId}`, `POST /shipment/{id}/pdf` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 579af93..8627f24 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 305 API endpoints across all modules. +Overview of all 306 API endpoints across all modules. ### Notation @@ -96,7 +96,7 @@ _none_ - Params: `query: printeragentId?: int` - Returns: `200 { agentprinters: T13[] }` #### Reorder agent printers — `PUT /agentprinter/order` -- Params: `body: { printeragentId: int, agentprinterIds: int[] }` +- Params: `body: { agentprinterIds: int[], printeragentId?: int }` - Returns: `200 { agentprinterIds: int[] }` #### Delete agent printer — `DELETE /agentprinter/{id}` - Params: `id: int` @@ -795,6 +795,9 @@ _none_ #### Create print job — `POST /printerjob` - Params: `body: { agentprinterId: int, data: str, type?: str }` - Returns: `201 { printerjob: T22 }` +#### Cancel all pending and processing printer jobs — `POST /printerjob/cancel-all` +- Params: `body?: { agentprinterId?: int }` +- Returns: `200 { success: bool, cancelledCount: int, jobs: T22[] }` #### Cancel and delete printer job — `DELETE /printerjob/{id}` - Params: `id: int` - Returns: `200 { success: bool, deletedJob: T22 }` diff --git a/docs/api/routes.md b/docs/api/routes.md index 26c305a..3b1ac61 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -1,6 +1,6 @@ # Dash API Routes Overview -Overview of all 225 API endpoints across all modules. +Overview of all 226 API endpoints across all modules. ### Notation @@ -65,7 +65,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - Params: `query: printeragentId?: int` - Returns: `200 { agentprinters: T9[] }` #### Reorder agent printers — `PUT /agentprinter/order` -- Params: `body: { printeragentId: int, agentprinterIds: int[] }` +- Params: `body: { agentprinterIds: int[], printeragentId?: int }` - Returns: `200 { agentprinterIds: int[] }` #### Delete agent printer — `DELETE /agentprinter/{id}` - Params: `id: int` @@ -574,6 +574,9 @@ _none_ #### Create print job — `POST /printerjob` - Params: `body: { agentprinterId: int, data: str, type?: str }` - Returns: `201 { printerjob: T16 }` +#### Cancel all pending and processing printer jobs — `POST /printerjob/cancel-all` +- Params: `body?: { agentprinterId?: int }` +- Returns: `200 { success: bool, cancelledCount: int, jobs: T16[] }` #### Cancel and delete printer job — `DELETE /printerjob/{id}` - Params: `id: int` - Returns: `200 { success: bool, deletedJob: T16 }` diff --git a/docs/api/routes/agentprinter/order.md b/docs/api/routes/agentprinter/order.md index df351b9..d15ad35 100644 --- a/docs/api/routes/agentprinter/order.md +++ b/docs/api/routes/agentprinter/order.md @@ -11,7 +11,7 @@ Reorders agent printers for a specific printer agent within the selected company | Field | Required | Type | Description | |-------|----------|------|-------------| -| `printeragentId` | yes | integer | | +| `printeragentId` | no | integer | Optional agent id. If omitted, reorders all printers in the company. | | `agentprinterIds` | yes | integer[] | | ```json diff --git a/docs/api/routes/printerjob/cancel-all.md b/docs/api/routes/printerjob/cancel-all.md new file mode 100644 index 0000000..144cc7d --- /dev/null +++ b/docs/api/routes/printerjob/cancel-all.md @@ -0,0 +1,52 @@ +# `POST /printerjob/cancel-all` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Cancel all pending and processing printer jobs + +Cancels all queued or in-flight print jobs for the selected company, optionally filtered by agentprinterId. +Notifies connected agents in real time. + + +## Body (JSON) + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `agentprinterId` | no | integer | | + +```json +{ + "agentprinterId": 1 +} +``` + +## Signals + +On success, emits SSE signal: `printerjob` (see [`GET /events`](../events.md)). + +## Response `200` + +All matching jobs cancelled + +```json +{ + "success": true, + "cancelledCount": 1, + "jobs": [ + { + "id": 1, + "agentprinterId": 1, + "printeragentId": 1, + "type": "zpl", + "state": "pending", + "data": "…", + "createdAt": "2026-08-10T00:00:00.000Z", + "completedAt": "2026-08-10T00:00:00.000Z" + } + ] +} +``` + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/agent/index.js b/src/api/agent/index.js index 06cb62f..98e789c 100644 --- a/src/api/agent/index.js +++ b/src/api/agent/index.js @@ -399,6 +399,21 @@ async function handleState(req, res) { } } + if (rawState?.job?.id && rawState?.job?.state) { + try { + const updatedJob = await printerjobDb.updateState( + { agentId: req.agent.id }, + rawState.job.id, + rawState.job.state + ); + if (req.agent?.companyId) { + emit('printerjob', { companyId: req.agent.companyId, data: { job: updatedJob } }); + } + } catch (jobErr) { + log.debug(`Agent reported job state update for ${rawState.job.id}:`, jobErr?.message); + } + } + if (rawPrinters && req.agent.companyId) { updateAgentPrinters(req.agent.companyId, req.agent.id, req.agent.name, rawPrinters); } else if (anyAgentPrinterCreated && req.agent.companyId) { diff --git a/src/api/routes/article/media.js b/src/api/routes/article/media.js index 640c177..92486b4 100644 --- a/src/api/routes/article/media.js +++ b/src/api/routes/article/media.js @@ -6,6 +6,11 @@ import * as db from '../../../db/article.js'; import * as printerDb from '../../../db/printer.js'; import * as agentprinterDb from '../../../db/agentprinter.js'; import * as labeltemplateDb from '../../../db/labeltemplate.js'; +import * as printerjobDb from '../../../db/printerjob.js'; +import { emit } from '../../sse.js'; +import { notifyAgentJob } from '../../agent/index.js'; +import { pngToZpl } from '../../../services/zplService.js'; +import { applyZplCopies } from '../zpljob.js'; import { AVIF_BODY_LIMIT, emitArticleSignal, @@ -395,13 +400,16 @@ export function register(app, basePath = '') { try { let printer = await printerDb.getById(req.user.id, parsedPrinterId.id); + let agentPrinter = null; if (!printer || printer.company_id !== companyId) { - const agentPrinter = await agentprinterDb.getById(req.user.id, parsedPrinterId.id); + agentPrinter = await agentprinterDb.getById(req.user.id, parsedPrinterId.id); if (agentPrinter) { printer = agentPrinter; } else { return res.status(404).json({ error: 'Printer not found' }); } + } else if (printer.printeragentId != null) { + agentPrinter = printer; } if (printer.type && printer.type.toLowerCase() !== 'zpl') { return res.status(400).json({ error: 'Printer is not a ZPL printer' }); @@ -425,42 +433,67 @@ export function register(app, basePath = '') { return res.status(404).json({ error: 'Article not found' }); } - // Extract and interpolate canvas objects + // Extract and interpolate canvas objects or raw ZPL const content = template.content; - const objects = Array.isArray(content?.objects) - ? content.objects - : Array.isArray(content) - ? content - : []; + let zplData; - const interpolatedObjects = objects.map(obj => { - const cloned = { ...obj }; - if (typeof cloned.text === 'string') { - cloned.text = interpolateString(cloned.text, article); + if (typeof content === 'string' && content.includes('^XA')) { + let interpolated = interpolateString(content, article); + if (parsedQuantity.id > 1) { + interpolated = applyZplCopies(interpolated, parsedQuantity.id); } - if (typeof cloned.code === 'string') { - cloned.code = interpolateString(cloned.code, article); + zplData = interpolated; + } else { + const objects = Array.isArray(content?.objects) + ? content.objects + : Array.isArray(content) + ? content + : []; + + const interpolatedObjects = objects.map(obj => { + const cloned = { ...obj }; + if (typeof cloned.text === 'string') { + cloned.text = interpolateString(cloned.text, article); + } + if (typeof cloned.code === 'string') { + cloned.code = interpolateString(cloned.code, article); + } + return cloned; + }); + + const printPayload = await renderLabel({ + width: template.width, + height: template.height, + objects: interpolatedObjects, + }); + + zplData = await pngToZpl(printPayload, parsedQuantity.id); + } + + if (agentPrinter) { + const job = await printerjobDb.create(req.user.id, { + agentprinterId: agentPrinter.id, + type: 'zpl', + data: zplData, + }); + + if (job.printeragentId) { + notifyAgentJob(job.printeragentId, 'created', job); } - return cloned; - }); - const printPayload = await renderLabel({ - width: template.width, - height: template.height, - objects: interpolatedObjects, - }); + emit('printerjob', { companyId, data: { job } }); + return res.status(200).json({ success: true, printerjob: job }); + } - // Mock network dispatch - const spoolKey = printer.address ? `${printer.address}_${printer.type}` : `agent_${printer.id}_${printer.type || 'zpl'}`; + // Fallback for legacy network printers with static IP / address + const spoolKey = printer.address ? `${printer.address}_${printer.type}` : `printer_${printer.id}_${printer.type || 'zpl'}`; const printDir = path.join(process.cwd(), 'print_spool', spoolKey); await fs.mkdir(printDir, { recursive: true }); - for (let i = 0; i < parsedQuantity.id; i++) { - const timestamp = Date.now(); - const filename = parsedQuantity.id > 1 ? `${timestamp}_${i + 1}.png` : `${timestamp}.png`; - const filePath = path.join(printDir, filename); - await fs.writeFile(filePath, printPayload); - } + const timestamp = Date.now(); + const filename = `${timestamp}.zpl`; + const filePath = path.join(printDir, filename); + await fs.writeFile(filePath, zplData, 'utf8'); res.status(200).json({ success: true }); } catch (error) { diff --git a/src/services/zplService.js b/src/services/zplService.js new file mode 100644 index 0000000..09b5391 --- /dev/null +++ b/src/services/zplService.js @@ -0,0 +1,80 @@ +import { createCanvas, loadImage } from 'canvas'; + +/** + * Converts a PNG image buffer into Zebra ZPL II Graphic Field (^GFA) format. + * Uses Zebra's alternative compression scheme (ACS run-length encoding on hex) + * to minimize payload size over the network and serial/USB ports. + * + * @param {Buffer} pngBuffer - Rendered 1-bit or RGBA PNG buffer. + * @param {number} [quantity=1] - Number of copies to print (^PQ). + * @returns {Promise} Full ZPL string ready to send to a Zebra printer. + */ +export async function pngToZpl(pngBuffer, quantity = 1) { + const img = await loadImage(pngBuffer); + const canvas = createCanvas(img.width, img.height); + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + const imgData = ctx.getImageData(0, 0, img.width, img.height); + const { data, width: w, height: h } = imgData; + + const bytesPerRow = Math.ceil(w / 8); + const totalBytes = bytesPerRow * h; + const buffer = new Uint8Array(totalBytes); + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const idx = (y * w + x) * 4; + const r = data[idx]; + const g = data[idx + 1]; + const b = data[idx + 2]; + const a = data[idx + 3]; + // Ink threshold: non-transparent and dark pixels are black (1) + const isBlack = a > 128 && (r * 0.299 + g * 0.587 + b * 0.114) < 128; + if (isBlack) { + const byteIdx = y * bytesPerRow + Math.floor(x / 8); + const bitIdx = 7 - (x % 8); + buffer[byteIdx] |= 1 << bitIdx; + } + } + } + + const hexMap = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase()); + let hex = ''; + for (let i = 0; i < totalBytes; i++) { + hex += hexMap[buffer[i]]; + } + + // Zebra ACS run-length encoding: + // Repeated characters are replaced by letters: + // 'G'..'Y' = 1..19 repeats + // 'g'..'y' = 20..380 repeats (step 20) + // 'z' = 400 repeats + let acs = ''; + const re = /([0-9A-F])\1{2,}/g; + let match = re.exec(hex); + let offset = 0; + while (match) { + acs += hex.substring(offset, match.index); + let l = match[0].length; + while (l >= 400) { + acs += 'z'; + l -= 400; + } + if (l >= 20) { + acs += '_ghijklmnopqrstuvwxy'[Math.floor(l / 20)]; + l = l % 20; + } + if (l) { + acs += '_GHIJKLMNOPQRSTUVWXY'[l]; + } + acs += match[1]; + offset = re.lastIndex; + match = re.exec(hex); + } + acs += hex.substring(offset); + + const qty = Number(quantity); + const pq = Number.isInteger(qty) && qty > 1 ? `^PQ${qty}\n` : ''; + return `^XA^PW${w}^LL${h}^LS0^FT0,0^GFA,${totalBytes},${totalBytes},${bytesPerRow},${acs}\n${pq}^XZ`; +} +