Fix what the last round of device fixes broke, and two dips it missed
This commit is contained in:
150
plugins/tools/frontend/labelPages.js
Normal file
150
plugins/tools/frontend/labelPages.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// Rows in, printed pages out.
|
||||
//
|
||||
// This is the half of the generator a preview cannot check. Two things live
|
||||
// here for that reason: the CSV columns, which are a contract with whoever
|
||||
// builds the file, and the PAGE ORDER of a two-sided card, which decides
|
||||
// whether card 3's back lands on card 3 or on card 4. Both are the kind of
|
||||
// wrong you only find once the stock is used up.
|
||||
//
|
||||
// Nothing here knows how a code is drawn or how big a label is. That is
|
||||
// utils/codes.js and the component's CSS respectively.
|
||||
|
||||
// Rendering thousands of codes locks the tab, so stop at a number a person
|
||||
// would actually feed a label printer in one run. The cap counts CARDS, not
|
||||
// pages - a two-sided run of 500 is 1000 pages and that is fine.
|
||||
export const MAX_LABELS = 500
|
||||
export const MAX_COPIES = 1000
|
||||
|
||||
const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr']
|
||||
const LABEL_HEADERS = ['label', 'name', 'caption', 'description']
|
||||
const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count']
|
||||
const BACK_HEADERS = ['back', 'backcontent', 'reverse']
|
||||
const BACKLABEL_HEADERS = ['backlabel', 'backcaption', 'backname']
|
||||
|
||||
/** Split one CSV line, honoring quoted fields and "" escapes. */
|
||||
export 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the label CSV into rows.
|
||||
*
|
||||
* Columns: content (required), label, copies, back, backlabel. A header row is
|
||||
* optional; without one the order is the order above. back/backlabel are only
|
||||
* read when the back side is set to take its own content per row.
|
||||
*/
|
||||
export function parseCsv(text) {
|
||||
const lines = String(text || '').split(/\r?\n/).filter(line => line.trim() !== '')
|
||||
if (!lines.length) return []
|
||||
|
||||
let columns = { content: 0, label: 1, copies: 2, back: 3, backlabel: 4 }
|
||||
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),
|
||||
back: indexOf(BACK_HEADERS),
|
||||
backlabel: indexOf(BACKLABEL_HEADERS),
|
||||
}
|
||||
start = 1
|
||||
}
|
||||
|
||||
const at = (fields, index) => (index >= 0 ? (fields[index] || '') : '')
|
||||
|
||||
const rows = []
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const fields = splitCsvLine(lines[i])
|
||||
const content = at(fields, columns.content)
|
||||
if (!content) continue
|
||||
const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1
|
||||
rows.push({
|
||||
content,
|
||||
label: at(fields, columns.label),
|
||||
copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, MAX_COPIES) : 1,
|
||||
back: at(fields, columns.back),
|
||||
backlabel: at(fields, columns.backlabel),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** The back page belonging to one row, per the chosen back source. */
|
||||
function backPageFor(row, back) {
|
||||
const fallback = back.text || ''
|
||||
switch (back.source) {
|
||||
// The same payload again, so either face of a card scans. Worth it on a
|
||||
// badge that can end up in a holder either way round.
|
||||
case 'code':
|
||||
return { content: row.content, label: row.label || '', side: 'back' }
|
||||
// Per-row back: the CSV carries it. A row that left it empty still gets a
|
||||
// page, because a card printer counts pages and a missing one shifts every
|
||||
// back after it onto the wrong front.
|
||||
case 'column':
|
||||
return { content: row.back || '', label: row.backlabel || fallback, side: 'back' }
|
||||
case 'blank':
|
||||
return { content: '', label: '', side: 'back' }
|
||||
case 'text':
|
||||
default:
|
||||
return { content: '', label: fallback, side: 'back' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand rows by their copies count and, when a back side is enabled, pair
|
||||
* each card with its back page.
|
||||
*
|
||||
* `back.order` is the one that matters on the floor:
|
||||
* interleave - front, back, front, back. What a duplex card printer's driver
|
||||
* expects: it takes pages two at a time.
|
||||
* grouped - every front, then every back. For a single-sided printer,
|
||||
* where the stack comes out, gets flipped and goes back in.
|
||||
* Getting this backwards prints readable cards with the wrong names on the
|
||||
* back, which is worse than a jam because nothing looks broken.
|
||||
*/
|
||||
export function buildPages(rows, options = {}) {
|
||||
const { back = {}, max = MAX_LABELS } = options
|
||||
const fronts = []
|
||||
const backs = []
|
||||
let dropped = 0
|
||||
|
||||
for (const row of rows || []) {
|
||||
const wanted = Number.isFinite(row.copies) && row.copies > 0
|
||||
? Math.min(row.copies, MAX_COPIES) : 1
|
||||
for (let i = 0; i < wanted; i++) {
|
||||
if (fronts.length >= max) { dropped += 1; continue }
|
||||
fronts.push({ content: row.content, label: row.label || '', side: 'front' })
|
||||
if (back.enabled) backs.push(backPageFor(row, back))
|
||||
}
|
||||
}
|
||||
|
||||
let pages = fronts
|
||||
if (back.enabled) {
|
||||
pages = back.order === 'grouped'
|
||||
? [...fronts, ...backs]
|
||||
: fronts.flatMap((front, index) => [front, backs[index]])
|
||||
}
|
||||
|
||||
return { pages, fronts, backs, dropped }
|
||||
}
|
||||
113
plugins/tools/frontend/labelPages.spec.js
Normal file
113
plugins/tools/frontend/labelPages.spec.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// Page order is the one thing here that costs money to get wrong: a run of
|
||||
// badge cards with the backs one card out looks perfect until someone reads
|
||||
// one. These tests pin the order and the CSV contract.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { parseCsv, splitCsvLine, buildPages, MAX_LABELS } from './labelPages'
|
||||
|
||||
const sides = pages => pages.map(page => page.side).join(' ')
|
||||
const contents = pages => pages.map(page => page.content || '-').join(' ')
|
||||
|
||||
describe('splitCsvLine', () => {
|
||||
it('keeps a comma that is inside quotes', () => {
|
||||
expect(splitCsvLine('WJ-0001,"Bay 12, press",2')).toEqual(['WJ-0001', 'Bay 12, press', '2'])
|
||||
})
|
||||
|
||||
it('unescapes a doubled quote', () => {
|
||||
expect(splitCsvLine('x,"the ""big"" one"')).toEqual(['x', 'the "big" one'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('reads a named header in any column order', () => {
|
||||
const rows = parseCsv('copies,label,content\n3,Line 1,WJ-0001')
|
||||
expect(rows).toEqual([
|
||||
{ content: 'WJ-0001', label: 'Line 1', copies: 3, back: '', backlabel: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to positional columns with no header', () => {
|
||||
const rows = parseCsv('WJ-0001,Line 1,2,WJ-0001-B,Reverse')
|
||||
expect(rows[0]).toEqual({
|
||||
content: 'WJ-0001', label: 'Line 1', copies: 2,
|
||||
back: 'WJ-0001-B', backlabel: 'Reverse',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a row with no content rather than printing a blank sticker', () => {
|
||||
expect(parseCsv('content,label\n,orphan\nWJ-0002,real')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('defaults copies to 1 when it is missing or junk', () => {
|
||||
expect(parseCsv('content,copies\nA,\nB,zero')[0].copies).toBe(1)
|
||||
expect(parseCsv('content,copies\nA,\nB,zero')[1].copies).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPages without a back side', () => {
|
||||
it('expands copies and emits fronts only', () => {
|
||||
const { pages, backs } = buildPages([{ content: 'A', label: 'a', copies: 3 }])
|
||||
expect(sides(pages)).toBe('front front front')
|
||||
expect(backs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('caps cards, not pages, and says how many it dropped', () => {
|
||||
const { fronts, dropped } = buildPages([{ content: 'A', copies: MAX_LABELS + 5 }])
|
||||
expect(fronts).toHaveLength(MAX_LABELS)
|
||||
expect(dropped).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPages with a back side', () => {
|
||||
const rows = [
|
||||
{ content: 'A', label: 'a', copies: 2, back: 'A2', backlabel: 'a2' },
|
||||
{ content: 'B', label: 'b', copies: 1, back: 'B2', backlabel: 'b2' },
|
||||
]
|
||||
|
||||
it('interleaves front and back, which is what a duplex driver takes', () => {
|
||||
const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'interleave' } })
|
||||
expect(sides(pages)).toBe('front back front back front back')
|
||||
expect(contents(pages)).toBe('A A2 A A2 B B2')
|
||||
})
|
||||
|
||||
it('groups every front then every back, for a flip-and-reload printer', () => {
|
||||
const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'grouped' } })
|
||||
expect(sides(pages)).toBe('front front front back back back')
|
||||
// Same card order in both halves, or the flipped stack pairs up wrong.
|
||||
expect(contents(pages)).toBe('A A B A2 A2 B2')
|
||||
})
|
||||
|
||||
it('still emits a back for a row that left the back column empty', () => {
|
||||
const { pages } = buildPages(
|
||||
[{ content: 'A', copies: 1 }, { content: 'B', copies: 1, back: 'B2' }],
|
||||
{ back: { enabled: true, source: 'column', order: 'interleave' } })
|
||||
// A page count that changes per row would shift every later back onto the
|
||||
// wrong front.
|
||||
expect(sides(pages)).toBe('front back front back')
|
||||
expect(contents(pages)).toBe('A - B B2')
|
||||
})
|
||||
|
||||
it('repeats the front payload when the back is the same code', () => {
|
||||
const { backs } = buildPages(rows, { back: { enabled: true, source: 'code' } })
|
||||
expect(contents(backs)).toBe('A A B')
|
||||
})
|
||||
|
||||
it('puts the same fixed text on every back and no code', () => {
|
||||
const { backs } = buildPages(rows, { back: { enabled: true, source: 'text', text: 'Property of GE' } })
|
||||
expect(backs.every(page => page.content === '')).toBe(true)
|
||||
expect(backs.every(page => page.label === 'Property of GE')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a blank back entirely empty but still counted', () => {
|
||||
const { pages, backs } = buildPages(rows, { back: { enabled: true, source: 'blank' } })
|
||||
expect(pages).toHaveLength(6)
|
||||
expect(backs.every(page => !page.content && !page.label)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the fixed text when a row has no back label', () => {
|
||||
const { backs } = buildPages([{ content: 'A', copies: 1, back: 'A2' }],
|
||||
{ back: { enabled: true, source: 'column', text: 'Return to IT' } })
|
||||
expect(backs[0].label).toBe('Return to IT')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user