diff --git a/CHANGELOG.md b/CHANGELOG.md index b1266f5..9aa5212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Koenig… the systemd service. The container sees the host's `/dev` (opening only the printer), so a printer turned off and on again, or plugged in after startup, is still found. Both files ship in the release archives, next to the binary. +- **Show the ticket configuration**, in the composer (admin only): the ticket as YAML for Home Assistant, ready to paste + into an automation or a script — the `mqtt.publish` action on `printr/print` when Printr is connected to + MQTT, or the `printr.print_ticket` action of the Printr integration — or as JSON for the HTTP API, MQTT + and `printr print`. Copying also works over plain HTTP on the local + network, where browsers refuse the clipboard API. `/api/status` gives the topic prefix (`mqtt_prefix`). ## [1.10.0] “Koenig” - 2026-10-21 diff --git a/docs/guide.fr.md b/docs/guide.fr.md index 65a957d..bdeadd3 100644 --- a/docs/guide.fr.md +++ b/docs/guide.fr.md @@ -414,6 +414,12 @@ papier baisse. La découverte utilise le préfixe `homeassistant` ; `PRINTR_MQTT (ou la coupe avec `off`). Deux Printr sur un même broker doivent avoir des `PRINTR_MQTT_PREFIX` différents : chacun est son propre appareil. +**Un ticket de l'appli dans une automatisation** : dans le composeur, « Afficher la configuration du +ticket » (administrateur seulement) donne le ticket en YAML, prêt à coller dans une automatisation ou un script (édition en +YAML), ou en JSON pour l'API HTTP, MQTT et `printr print`. En YAML, deux formes : l'action +`mqtt.publish` qui publie le ticket sur `printr/print`, proposée quand Printr est relié à MQTT, ou +l'action `printr.print_ticket` de l'[intégration Printr](https://github.com/Matteo-Penaud/printr-ha-integration). + ### Certificats TLS `mqtt://` (sans TLS) fonctionne avec n'importe quel broker de ton réseau. `mqtts://` vérifie le diff --git a/docs/guide.md b/docs/guide.md index cb3b99a..a1754a4 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -412,6 +412,12 @@ So an automation can print a ticket when the doorbell rings, or warn the phone w Discovery uses the `homeassistant` prefix; `PRINTR_MQTT_DISCOVERY` changes it (or turns it `off`). Two Printr on the same broker need different `PRINTR_MQTT_PREFIX` values: each one is its own device. +**A ticket from the app in an automation**: in the composer, “Show the ticket configuration” (admin only) gives +the ticket as YAML, ready to paste into an automation or a script (YAML mode), or as JSON for the HTTP API, +MQTT and `printr print`. In YAML, two forms: the `mqtt.publish` action publishing the ticket on +`printr/print`, offered when Printr is connected to MQTT, or the `printr.print_ticket` action of the +[Printr integration](https://github.com/Matteo-Penaud/printr-ha-integration). + ### TLS certificates `mqtt://` (no TLS) works with any broker on your network. `mqtts://` checks the broker's certificate: diff --git a/src/mqtt.rs b/src/mqtt.rs index 3bf5fa0..2a6b0b2 100644 --- a/src/mqtt.rs +++ b/src/mqtt.rs @@ -245,17 +245,27 @@ fn client_id() -> String { if host.is_empty() { "printr".to_owned() } else { format!("printr-{host}") } } +fn url() -> Option { + std::env::var("PRINTR_MQTT_URL").ok().filter(|u| !u.trim().is_empty()) +} + +/// Préfixe des sujets (`PRINTR_MQTT_PREFIX`, `printr` par défaut), si MQTT est configuré : l'appli +/// web s'en sert pour écrire l'action Home Assistant qui publie un ticket. +pub fn prefix() -> Option { + url()?; + Some(clean_prefix(std::env::var("PRINTR_MQTT_PREFIX").ok().as_deref())) +} + +fn clean_prefix(setting: Option<&str>) -> String { + setting.map(|p| p.trim().trim_matches('/')).filter(|p| !p.is_empty()).unwrap_or("printr").to_owned() +} + /// Lance le client MQTT si `PRINTR_MQTT_URL` est défini. Une adresse invalide est signalée, sans /// empêcher le serveur de tourner. pub fn spawn(app: Arc) { - let Some(url) = std::env::var("PRINTR_MQTT_URL").ok().filter(|u| !u.trim().is_empty()) else { + let (Some(url), Some(prefix)) = (url(), prefix()) else { return; }; - let prefix = std::env::var("PRINTR_MQTT_PREFIX") - .ok() - .map(|p| p.trim().trim_matches('/').to_owned()) - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| "printr".to_owned()); let options = match parse_url(&url).and_then(|broker| options(&broker, &prefix)) { Ok(options) => options, Err(e) => { @@ -473,6 +483,13 @@ fn discover(app: &App, client: &Client, prefix: &str, discovery: &str, events: & mod tests { use super::*; + #[test] + fn prefix_is_trimmed_with_a_default() { + assert_eq!(clean_prefix(None), "printr"); + assert_eq!(clean_prefix(Some(" ")), "printr"); + assert_eq!(clean_prefix(Some("/maison/printr/ ")), "maison/printr"); + } + #[test] fn broker_url() { assert_eq!( diff --git a/src/server.rs b/src/server.rs index 6ab1556..949bda2 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1141,6 +1141,7 @@ fn handle(app: &App, request: &mut Request) -> Reply { "claude": claude.as_ref().map(|c| c.model().to_owned()), "claude_backend": claude.as_ref().map(|c| c.backend()), "version": env!("CARGO_PKG_VERSION"), + "mqtt_prefix": crate::mqtt::prefix(), "update": *app.latest.lock().unwrap_or_else(|e| e.into_inner()), })) } diff --git a/web/app.js b/web/app.js index 99c84a0..223bddc 100644 --- a/web/app.js +++ b/web/app.js @@ -165,6 +165,7 @@ const ICONS = { send: '', search: '', spark: '', + 'file-edit': '', scissors: '', }; @@ -758,6 +759,47 @@ function cleanBlock(data) { } const toTicket = (state) => ({ cut: state.cut, spacing: Number(state.spacing), blocks: state.blocks.map((b) => cleanBlock(b.data)) }); + +// YAML d'un ticket, pour Home Assistant. Une chaîne reste nue si rien ne peut la faire lire +// autrement : sinon entre apostrophes (guillemets JSON s'il y a des caractères de contrôle), pour +// « yes », « on », les nombres, les dates (2026-12-25 deviendrait une date), « : » ou « # ». +const YAML_PLAIN = /^[\p{L}_][^\n:#]*$/u; +const YAML_RESERVED = /^(true|false|yes|no|on|off|y|n|null|~)$/i; +const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); + +function yamlScalar(v, indent) { + if (v === null || v === undefined) return 'null'; + if (typeof v !== 'string') return String(v); + // Texte sur plusieurs lignes : bloc littéral, lignes gardées telles quelles. + if (v.includes('\n') && !/^\s/.test(v) && !v.includes('\r')) { + const chomp = v.endsWith('\n\n') ? '+' : v.endsWith('\n') ? '' : '-'; + const lines = v.replace(/\n$/, '').split('\n'); + return `|${chomp}\n${lines.map((l) => (l ? indent + l : '')).join('\n')}`; + } + if (YAML_PLAIN.test(v) && !/[\x00-\x1f\x7f]|\s$/.test(v) && !YAML_RESERVED.test(v)) return v; + return /[\x00-\x08\x0a-\x1f\x7f]/.test(v) ? JSON.stringify(v) : `'${v.replaceAll("'", "''")}'`; +} + +// Valeur qui suit « clé: » ou « - » : sur la même ligne, ou en dessous, indentée de `indent`. +function yamlNode(v, indent) { + if (Array.isArray(v)) return v.length ? `\n${v.map((x) => `${indent}-${yamlItem(x, `${indent} `)}`).join('\n')}` : ' []'; + if (isMap(v)) return Object.keys(v).length ? `\n${yamlMap(v, indent)}` : ' {}'; + return ` ${yamlScalar(v, indent)}`; +} +const yamlMap = (obj, indent) => + Object.entries(obj).map(([k, v]) => `${indent}${yamlScalar(k, indent)}:${yamlNode(v, `${indent} `)}`).join('\n'); +// Élément de liste : un objet commence sur la ligne du tiret. +const yamlItem = (v, indent) => (isMap(v) && Object.keys(v).length ? ` ${yamlMap(v, indent).slice(indent.length)}` : yamlNode(v, indent)); + +// Action de l'intégration Printr pour Home Assistant, ou action MQTT standard qui publie le ticket +// sur le sujet d'impression (`prefix` : PRINTR_MQTT_PREFIX du serveur). +const haYaml = (ticket, label) => + `${yamlMap({ action: 'printr.print_ticket', data: { ...(label ? { label } : {}), ticket } }, '')}\n`; +const mqttYaml = (ticket, prefix) => + `${yamlMap({ action: 'mqtt.publish', data: { topic: `${prefix}/print`, payload: JSON.stringify(ticket) } }, '')}\n`; + +// Réglages du serveur utiles à l'appli, relus avec /api/status. +const SERVER = { mqtt: null }; const usesAi = (blocks) => blocks.some((b) => BLOCK[b.type || b.data?.type]?.ai); function blockSummary(data) { @@ -1755,11 +1797,67 @@ function SaveSheet({ state, onSave, onClose, focusSchedule }) { `; } -function Composer({ initial, onSaved }) { +// Presse-papiers : l'API moderne n'existe qu'en HTTPS ou sur localhost, et l'appli est souvent +// servie en HTTP sur le réseau local ; repli par le texte sélectionné dans la zone `area`, aussi +// quand le navigateur refuse l'accès au presse-papiers. +async function copyText(text, area) { + try { + if (window.isSecureContext && navigator.clipboard) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { /* refusé : repli ci-dessous */ } + area.focus(); + area.select(); + return document.execCommand('copy'); +} + +// Configuration du ticket composé, à copier : en YAML, l'action Home Assistant (intégration Printr, +// ou MQTT quand le serveur y est relié) ; en JSON, le ticket tel que le prennent l'API, MQTT et +// `printr print`. Le dernier format choisi est gardé. +function TicketConfigSheet({ ticket, label, onClose }) { + const [format, setFormatState] = useState(() => store.get('printr.config.format', 'yaml')); + const setFormat = (f) => { setFormatState(f); store.set('printr.config.format', f); }; + const [via, setVia] = useState('integration'); + const mqtt = format === 'yaml' && via === 'mqtt' && SERVER.mqtt; + const text = useMemo(() => { + if (format === 'json') return `${JSON.stringify(ticket, null, 2)}\n`; + return mqtt ? mqttYaml(ticket, mqtt) : haYaml(ticket, label); + }, [format, ticket, label, mqtt]); + const area = useRef(null); + const copy = async () => { + if (await copyText(text, area.current)) return toast(T('Configuration copiée', 'Configuration copied'), 'ok', '📋'); + area.current.select(); + toast(T('Copie impossible : le texte est sélectionné, copie-le à la main', 'Could not copy: the text is selected, copy it by hand'), 'error'); + }; + const help = format === 'json' + ? T('Le ticket en JSON, tel que le prennent l’API HTTP (/print), MQTT (printr/print) et printr print ticket.json.', + 'The ticket as JSON, as taken by the HTTP API (/print), MQTT (printr/print) and printr print ticket.json.') + : `${mqtt + ? T(`L’action mqtt.publish de Home Assistant, qui publie le ticket sur ${mqtt}/print : rien d’autre à installer que l’intégration MQTT.`, + `Home Assistant’s mqtt.publish action, publishing the ticket on ${mqtt}/print: nothing to install but the MQTT integration.`) + : T('L’action printr.print_ticket de l’intégration Printr pour Home Assistant.', 'The printr.print_ticket action of the Printr integration for Home Assistant.') + } ${T('À coller dans une automatisation ou un script, en édition YAML.', 'Paste it into an automation or a script, in YAML mode.')}`; + return html`<${Sheet} title=${T('Configuration du ticket', 'Ticket configuration')} wide onClose=${onClose} footer=${html` + + `}> +
+ <${Segmented} options=${[['yaml', 'YAML'], ['json', 'JSON']]} value=${format} onChange=${setFormat} /> + ${format === 'yaml' && SERVER.mqtt && html`<${Segmented} options=${[['integration', T('Intégration Printr', 'Printr integration')], ['mqtt', 'MQTT']]} + value=${via} onChange=${setVia} />`} +
+

${help}

+ + `; +} + +function Composer({ initial, onSaved, admin }) { const [state, setState] = useState(initial); // Catalogue ouvert : `{ at }` = position d'insertion (à la fin si absente). const [catalog, setCatalog] = useState(null); const [saveSheet, setSaveSheet] = useState(null); + const [configSheet, setConfigSheet] = useState(false); const [mobilePreview, setMobilePreview] = useState(false); const [printing, setPrinting] = useState(false); const [saving, setSaving] = useState(false); @@ -1846,6 +1944,8 @@ function Composer({ initial, onSaved }) { <${Switch} label=${T('Couper le papier à la fin', 'Cut the paper at the end')} checked=${state.cut} onChange=${(cut) => setState((s) => ({ ...s, cut }))} />
${T('Espace entre les blocs', 'Space between blocks')} <${Segmented} options=${[[0, T('Aucun', 'None')], [1, T('1 ligne', '1 line')], [2, T('2 lignes', '2 lines')]]} value=${Number(state.spacing)} onChange=${(spacing) => setState((s) => ({ ...s, spacing }))} />
+ ${admin && html``}
@@ -1856,6 +1956,7 @@ function Composer({ initial, onSaved }) {
${catalog && html`<${Catalog} onPick=${(type) => add(type, catalog.at)} onClose=${() => setCatalog(null)} />`} + ${configSheet && html`<${TicketConfigSheet} ticket=${ticket} label=${state.name} onClose=${() => setConfigSheet(false)} />`} ${saveSheet && html`<${SaveSheet} state=${state} focusSchedule=${saveSheet.focusSchedule} onSave=${persist} onClose=${() => setSaveSheet(null)} />`} ${mobilePreview && html`<${Sheet} title=${T('Aperçu', 'Preview')} onClose=${() => setMobilePreview(false)} footer=${html` `}> @@ -2259,6 +2360,7 @@ function App() { setVersion(s.version); setUpdate(s.update || null); CATALOG.hidden = s.hidden_blocks || []; CATALOG.claude = !!s.claude; + SERVER.mqtt = s.mqtt_prefix || null; setPrinter((before) => { const status = s.printer_status || (s.printer_ready === false ? 'missing' : 'ready'); const now = { ready: status === 'ready', status, queued: s.queued || 0, paper: s.paper || null }; @@ -2292,7 +2394,7 @@ function App() { : route.title; let page; - if (route.hash === '#/compose') page = html`<${Composer} initial=${composer} onSaved=${loadPresets} />`; + if (route.hash === '#/compose') page = html`<${Composer} initial=${composer} onSaved=${loadPresets} admin=${me.admin} />`; else if (route.hash === '#/message') page = html`<${Message} me=${me} onSaved=${loadPresets} />`; else if (route.hash === '#/history') page = html`<${History} users=${users} me=${me} />`; else if (route.hash === '#/courses') page = html`<${Shopping} users=${users} />`; diff --git a/web/style.css b/web/style.css index 88e82a1..e849cb1 100644 --- a/web/style.css +++ b/web/style.css @@ -400,6 +400,9 @@ select.input { appearance: none; padding-right: 38px; @media (min-width: 1100px) { .preview-fab { display: none; } } .ticket-settings { padding: 14px 16px; margin-top: 10px; } +.ticket-settings .show-config { margin: 10px -6px -4px; } +.config-switches { display: grid; gap: 8px; } +textarea.input.code { font-family: var(--mono); font-size: 13px; line-height: 1.45; white-space: pre; overflow: auto; min-height: 220px; } .spacing-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; margin-top: 6px; } @media (max-width: 480px) { .spacing-row .segmented { width: 100%; } } .actionbar .btn { min-width: 0; }