/** * A link that opens the generator with somebody's work already in it. * * WHY THE FRAGMENT * * Everything rides after the `#`. A fragment is never transmitted: it does not * reach Flask, it is not in the IIS log, and the proxy between the two never * sees it. A query string would put the encoded content in all three, and the * whole point of this feature is to send somebody a code they can print - which * means the content is often an asset number, a serial, or a URL somebody would * rather not scatter through logs. * * It is NOT secrecy. The link still sits in a chat message, in browser history * and in whatever archives that chat. Anyone holding the link has the content. * Secure Send exists for the case where that matters. * * WHAT TRAVELS * * What was typed (content, label, copies, the CSV) plus the settings that * change how the label looks. Not the picture: a data URL runs to megabytes and * would produce a link no chat client will carry. */ // Short keys because the whole thing has to survive being pasted into a chat // box. `c` is the content, `t` the code type, `p` the label preset. const SHORT = { c: 'content', l: 'label', n: 'copies', b: 'back', v: 'csv', s: 'source', t: 'codetype', e: 'errorcorrection', p: 'preset', } // Layout values travel under their own names - there are enough of them that // abbreviating would cost more in confusion than it saves in characters. const PASSTHROUGH = [ 'labelwidth', 'labelheight', 'padding', 'quiet', 'gap', 'codesize', 'barheight', 'labelfont', 'layout', 'align', 'valign', 'contentx', 'contenty', 'nudgex', 'nudgey', 'dpi', 'backenabled', 'backsource', 'backcodetype', 'backtext', 'backlayout', 'backorder', ] const LONG = Object.fromEntries(Object.entries(SHORT).map(([k, v]) => [v, k])) /** * Build the fragment for a state object. Returns '' when there is nothing to * share, so a caller can disable its button rather than emit a bare '#'. */ export function buildFragment (state, defaults = {}) { const params = new URLSearchParams() for (const [key, name] of Object.entries(SHORT)) { const value = state[name] if (value === undefined || value === null || value === '') continue // Copies of 1 is what everybody has; it does not need saying. if (name === 'copies' && Number(value) === 1) continue params.set(key, String(value)) } // Only the layout values that DIFFER from this browser's current settings. // Sending all twenty would make a link four times longer for no gain, and the // recipient's own saved settings are a reasonable starting point. for (const name of PASSTHROUGH) { const value = state[name] if (value === undefined || value === null || value === '') continue if (defaults[name] !== undefined && String(defaults[name]) === String(value)) continue params.set(name, String(value)) } const encoded = params.toString() return encoded ? '#' + encoded : '' } /** * Read a fragment back into a partial state object. * * Unknown keys are ignored rather than refused: a link made by a newer version * must still open here, minus whatever this version does not understand. */ export function readFragment (hash) { const raw = String(hash || '').replace(/^#/, '') if (!raw) return {} const params = new URLSearchParams(raw) const state = {} for (const [key, value] of params.entries()) { const name = SHORT[key] || (PASSTHROUGH.includes(key) ? key : null) if (!name) continue state[name] = value } return state } /** * Coerce a fragment value to the type the ref already holds. * * Everything arrives as a string. Assigning '203' to a numeric input renders, * but the moment it is used in arithmetic it concatenates - so a nudge of 0 * plus '2' becomes '02'. Match the current value's type instead. */ export function coerce (value, current) { if (typeof current === 'number') { const number = Number(value) return Number.isFinite(number) ? number : current } if (typeof current === 'boolean') { return value === 'true' || value === '1' } return value }