diff --git a/src/lib/components/Scanner.svelte b/src/lib/components/Scanner.svelte new file mode 100644 index 0000000..b3e2446 --- /dev/null +++ b/src/lib/components/Scanner.svelte @@ -0,0 +1,67 @@ + + +
+ {#if error} +
+ {error} +
+ {/if} + + + +

+ Point your camera at a QR code, Code 128, or DataMatrix barcode +

+
diff --git a/src/lib/printing/labelSheet.ts b/src/lib/printing/labelSheet.ts new file mode 100644 index 0000000..a6493b0 --- /dev/null +++ b/src/lib/printing/labelSheet.ts @@ -0,0 +1,72 @@ +import bwipjs from 'bwip-js'; +import jsPDF from 'jspdf'; + +export interface LabelSheetOptions { + cols?: number; + rows?: number; + cellWidth?: number; + cellHeight?: number; + marginX?: number; + marginY?: number; + qrSize?: number; +} + +const DEFAULTS: Required = { + cols: 5, + rows: 10, + cellWidth: 38, + cellHeight: 27, + marginX: 8, + marginY: 10, + qrSize: 18, +}; + +export async function generateLabelSheetPDF( + ids: string[], + options: LabelSheetOptions = {} +): Promise { + const opts = { ...DEFAULTS, ...options }; + const doc = new jsPDF('p', 'mm', 'a4'); + const perPage = opts.cols * opts.rows; + + for (let i = 0; i < ids.length; i++) { + const col = i % opts.cols; + const row = Math.floor(i / opts.cols) % opts.rows; + const page = Math.floor(i / perPage); + + if (i > 0 && i % perPage === 0) { + doc.addPage(); + } + + const x = opts.marginX + col * opts.cellWidth; + const y = opts.marginY + row * opts.cellHeight; + + // Generate QR code as PNG via canvas + const canvas = document.createElement('canvas'); + bwipjs.toCanvas(canvas, { + bcid: 'qrcode', + text: `https://haus.toph.so/${ids[i]}`, + scale: 3, + includetext: false, + }); + + const qrDataUrl = canvas.toDataURL('image/png'); + doc.addImage(qrDataUrl, 'PNG', x + 1, y + 1, opts.qrSize, opts.qrSize); + + // ID text + doc.setFontSize(8); + doc.setFont('courier', 'normal'); + doc.text(ids[i], x + opts.qrSize + 2, y + opts.qrSize / 2 + 1); + } + + return doc.output('blob'); +} + +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/printing/printServer.test.ts b/src/lib/printing/printServer.test.ts new file mode 100644 index 0000000..28fe1d2 --- /dev/null +++ b/src/lib/printing/printServer.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { printLabels, checkPrintServer, type PrintServerConfig } from './printServer'; + +const config: PrintServerConfig = { baseUrl: 'http://printer.local:3030' }; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('printLabels', () => { + it('sends POST request with labels and format', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', mockFetch); + + const labels = [ + { id: 'abc1234', uri: 'https://haus.toph.so/abc1234' }, + { id: 'def5678', uri: 'https://haus.toph.so/def5678' }, + ]; + + await printLabels(config, labels, 'sheet'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://printer.local:3030/api/print', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels, format: 'sheet' }), + }) + ); + }); + + it('defaults to sheet format', async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', mockFetch); + + await printLabels(config, []); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.format).toBe('sheet'); + }); + + it('throws on non-ok response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, statusText: 'Service Unavailable' }) + ); + + await expect(printLabels(config, [])).rejects.toThrow('Print failed: Service Unavailable'); + }); +}); + +describe('checkPrintServer', () => { + it('returns true when server responds OK', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + expect(await checkPrintServer(config)).toBe(true); + }); + + it('returns false when server responds with error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + expect(await checkPrintServer(config)).toBe(false); + }); + + it('returns false when fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + expect(await checkPrintServer(config)).toBe(false); + }); +}); diff --git a/src/lib/printing/printServer.ts b/src/lib/printing/printServer.ts new file mode 100644 index 0000000..5f0bcbe --- /dev/null +++ b/src/lib/printing/printServer.ts @@ -0,0 +1,36 @@ +export interface PrintServerConfig { + baseUrl: string; +} + +export interface PrintLabel { + id: string; + uri: string; +} + +export async function printLabels( + config: PrintServerConfig, + labels: PrintLabel[], + format: 'sheet' | 'strip' = 'sheet' +): Promise { + const response = await fetch(`${config.baseUrl}/api/print`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels, format }), + }); + + if (!response.ok) { + throw new Error(`Print failed: ${response.statusText}`); + } +} + +export async function checkPrintServer(config: PrintServerConfig): Promise { + try { + const response = await fetch(`${config.baseUrl}/api/health`, { + method: 'GET', + signal: AbortSignal.timeout(3000), + }); + return response.ok; + } catch { + return false; + } +} diff --git a/src/lib/scanning/detector.ts b/src/lib/scanning/detector.ts new file mode 100644 index 0000000..9c9266d --- /dev/null +++ b/src/lib/scanning/detector.ts @@ -0,0 +1,131 @@ +import { Capacitor } from '@capacitor/core'; +import { parseItemId } from './parser'; + +export interface ScanResult { + itemId: string; + rawValue: string; + format: string; +} + +export async function scanBarcode(): Promise { + if (Capacitor.isNativePlatform()) { + return scanWithMLKit(); + } else { + return scanWithWebAPI(); + } +} + +async function scanWithMLKit(): Promise { + const { BarcodeScanner, BarcodeFormat } = await import( + '@capacitor-mlkit/barcode-scanning' + ); + + const { barcodes } = await BarcodeScanner.scan({ + formats: [ + BarcodeFormat.QrCode, + BarcodeFormat.Code128, + BarcodeFormat.DataMatrix, + ], + }); + + for (const barcode of barcodes) { + const id = parseItemId(barcode.rawValue ?? ''); + if (id) { + return { + itemId: id, + rawValue: barcode.rawValue ?? '', + format: barcode.format?.toString() ?? 'unknown', + }; + } + } + return null; +} + +declare class BarcodeDetector { + constructor(options?: { formats: string[] }); + detect(source: ImageBitmapSource): Promise>; + static getSupportedFormats(): Promise; +} + +async function scanWithWebAPI(): Promise { + if (typeof window === 'undefined') return null; + + const hasBarcodeAPI = 'BarcodeDetector' in window; + if (!hasBarcodeAPI) { + return null; + } + + const detector = new BarcodeDetector({ + formats: ['qr_code', 'code_128', 'data_matrix'], + }); + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + }); + + const video = document.createElement('video'); + video.srcObject = stream; + video.autoplay = true; + await video.play(); + + try { + // Attempt detection for up to 10 seconds at ~10fps + const maxAttempts = 100; + for (let i = 0; i < maxAttempts; i++) { + const codes = await detector.detect(video); + for (const code of codes) { + const id = parseItemId(code.rawValue); + if (id) { + return { + itemId: id, + rawValue: code.rawValue, + format: code.format, + }; + } + } + await new Promise((r) => setTimeout(r, 100)); + } + } finally { + stream.getTracks().forEach((t) => t.stop()); + video.remove(); + } + + return null; +} + +export async function checkCameraPermission(): Promise { + if (Capacitor.isNativePlatform()) { + const { BarcodeScanner } = await import( + '@capacitor-mlkit/barcode-scanning' + ); + const result = await BarcodeScanner.checkPermissions(); + return result.camera === 'granted'; + } + + try { + const result = await navigator.permissions.query({ + name: 'camera' as PermissionName, + }); + return result.state === 'granted'; + } catch { + return false; + } +} + +export async function requestCameraPermission(): Promise { + if (Capacitor.isNativePlatform()) { + const { BarcodeScanner } = await import( + '@capacitor-mlkit/barcode-scanning' + ); + const result = await BarcodeScanner.requestPermissions(); + return result.camera === 'granted'; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + stream.getTracks().forEach((t) => t.stop()); + return true; + } catch { + return false; + } +}