diff --git a/.github/workflows/desktop-exe-ext.yml b/.github/workflows/desktop-exe-ext.yml new file mode 100644 index 00000000..c306f8bc --- /dev/null +++ b/.github/workflows/desktop-exe-ext.yml @@ -0,0 +1,125 @@ +name: Therp Timer Desktop Builds + +on: + pull_request: + branches: [master] + paths: + - "dist/desktop/**" + - ".github/workflows/desktop.yml" + - "src/templates/**" + - "scripts/**" + push: + branches: [master] + tags: + - "v*" + paths: + - "dist/desktop/**" + - ".github/workflows/desktop.yml" + - "src/templates/**" + - "scripts/**" + workflow_dispatch: + +jobs: + build-desktop: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: linux + build_cmd: npm run build:linux + + - os: windows-latest + name: windows + build_cmd: npm run build:win + + - os: macos-latest + name: macos + build_cmd: npm run build:mac + + runs-on: ${{ matrix.os }} + + defaults: + run: + working-directory: dist/desktop + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: dist/desktop/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build desktop app + run: ${{ matrix.build_cmd }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_IDENTITY_AUTO_DISCOVERY: "false" + + - name: Prepare Linux portable bundle + if: matrix.name == 'linux' + run: | + APPIMAGE="$(find release -maxdepth 1 -name '*.AppImage' | head -n 1)" + mkdir -p portable/Therp-Timer + cp "$APPIMAGE" portable/Therp-Timer/ + cp build/icon_512.png portable/Therp-Timer/icon.png + + cat > portable/Therp-Timer/therp-timer.desktop < https://github.com/odoo/owl.git) +# Browser targets: +bash scripts/setup_nodeenv.sh && . .nodeenv/bin/activate +bash scripts/compile_owl_templates.sh ``` -**NB:** -- **Make sure you have templates to be compile by the scrip in `src/template/*.xml*` otherwise script might fail.** -- **Please note you need to clone Odoo's OWL repo branch that is identical to your owl js library when compiling templates otherwise you will get templat errors when generating `template.js`. For instance: this current project is using OWL lib v2.8.2. So we need to clone owl repo branch owl-2.x (i.e https://github.com/odoo/owl/tree/owl-2.x) to compile template with.** +--- -### Register generated templates +## Architecture -The extension expects compiled templates to be made available through: - -```js -globalThis.__THERP_TIMER_TEMPLATES__ = { - ReadMore: /* compiled template */, - PopupApp: /* compiled template */, - OptionsApp: /* compiled template */, -}; -``` +| Layer | Technology | +|---|---| +| Timer / Options UI | OWL 2.8.2 reactive components | +| Messages UI | Vanilla JS (no framework needed) | +| Desktop shell | Electron 29 + electron-store | +| Odoo API | JSON-RPC (`fetch` + Electron Chromium session cookies) | +| Notifications | Electron `Notification` API | +| Screen capture | `desktopCapturer` + `MediaRecorder` (WebM/VP8) | +| Dialogs | `alert.js` custom modal library | -The distributed `dist/*/js/templates.js` files are safe placeholders. -Replace them during development with your generated template registration code, -or adapt the generated Owl output into that registry shape. +--- -### Current runtime layout - -Shared libraries now live under: - -- `dist/chrome/js/lib/` -- `dist/firefox/js/lib/` +## Troubleshooting -Application entry modules now live under: +**"Not connected" in Messages** +Log in via the Timer window first. Messages reads the session from storage. -- `dist/chrome/js/components/` -- `dist/firefox/js/components/` +**No tasks in Messages sidebar** +Check the data source in Options matches your Odoo setup (`project.task` vs `project.issue`). -This keeps library-style files such as `browser-polyfill.js`, `owl.iife.js`, -and `common.js` separated from the popup and options app modules. +**Screen recorder — no sources found** +On Wayland (Linux) add `--enable-features=WebRTCPipeWireCapturer` to the Electron launch flags. -## Summary +**"No active Odoo session found"** +Uncheck "Use Existing Session" and enter your credentials. -Therp Timer is a practical browser-based Odoo timer that helps users quickly record work against Odoo project items. +**Tasks show "No matching items"** +Tasks are filtered to active stages (not Done/Cancelled/Hold). Check your Odoo stages or tick "Show for everyone". -Use: +--- -- the **Chrome/Brave** build for Chromium-based browsers -- the **Firefox** build for Firefox-based browsers +## License -Keep a separate `manifest.json` in each browser build folder, and load that folder with the browser’s extension developer tools. \ No newline at end of file +LGPL-3.0 — see `LICENSE.md`. diff --git a/dist/chrome/.gitignore b/dist/chrome/.gitignore index 6311779b..c2658d7d 100644 --- a/dist/chrome/.gitignore +++ b/dist/chrome/.gitignore @@ -1 +1 @@ -web-ext-artifacts/ +node_modules/ diff --git a/dist/chrome/js/components/popup-app.js b/dist/chrome/js/components/popup-app.js index 04c47c10..393fcb65 100644 --- a/dist/chrome/js/components/popup-app.js +++ b/dist/chrome/js/components/popup-app.js @@ -483,6 +483,8 @@ class PopupApp extends Component { this.toggleAutoDownload = this.toggleAutoDownload.bind(this); this.toggleUseExistingSession = this.toggleUseExistingSession.bind(this); this.togglePassword = this.togglePassword.bind(this); + this.updateLimitPreference = this.updateLimitPreference.bind(this); + this.updateShowAllPreference = this.updateShowAllPreference.bind(this); onMounted(() => { const bootLoader = document.getElementById('boot-loader'); @@ -540,34 +542,38 @@ class PopupApp extends Component { await storage.set(STORAGE_KEYS.showAllItems, !!value); } - /** * Issues filtered by current UI settings. */ get filteredIssues() { const limit = this.state.limitTo ? Number(this.state.limitTo) : null; + const query = (this.state.searchQuery || '').trim(); let issues = [...this.state.issues]; issues.sort((a, b) => { - if (a.id === this.state.activeTimerId) return -1; - if (b.id === this.state.activeTimerId) return 1; - - const priorityDelta = Number(b.priority || 0) - Number(a.priority || 0); - if (priorityDelta !== 0) return priorityDelta; - - const stageDelta = Number(a.stage_sequence ?? 9999) - Number(b.stage_sequence ?? 9999); - if (stageDelta !== 0) return stageDelta; - - return a.id - b.id; + if (a.id === this.state.activeTimerId) return -1; + if (b.id === this.state.activeTimerId) return 1; + const priorityDelta = Number(b.priority || 0) - Number(a.priority || 0); + if (priorityDelta !== 0) return priorityDelta; + const stageDelta = Number(a.stage_sequence ?? 9999) - Number(b.stage_sequence ?? 9999); + if (stageDelta !== 0) return stageDelta; + return a.id - b.id; }); - if (!this.state.allIssues && this.state.user?.id) { - issues = issues.filter( - (issue) => issue.id === this.state.activeTimerId || issue.user_id?.[0] === this.state.user.id - ); + const matchesSearch = (issue) => matchesIssue(issue, query); + + if (this.state.allIssues) { + issues = issues.filter(matchesSearch); + } else if (this.state.user?.id) { + issues = issues.filter( + (issue) => + issue.id === this.state.activeTimerId || + (issue.user_id?.[0] === this.state.user.id && matchesSearch(issue)) + ); + } else { + issues = issues.filter(matchesSearch); } - issues = issues.filter((issue) => matchesIssue(issue, this.state.searchQuery)); return limit ? issues.slice(0, limit) : issues; } diff --git a/dist/chrome/js/templates.js b/dist/chrome/js/templates.js index a87b1cac..63b85ba8 100644 --- a/dist/chrome/js/templates.js +++ b/dist/chrome/js/templates.js @@ -1,5 +1,259 @@ export const templates = { - "PopupApp": function PopupApp(app, bdom, helpers + "MessagesApp": function MessagesApp(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + let { prepareList, safeOutput, withKey } = helpers; + + let block1 = createBlock(`
Inbox
unread recent messages · latest 10 per tracked item · checking every s
`); + let block3 = createBlock(`
`); + let block8 = createBlock(`
No matching tasks or issues.
`); + let block11 = createBlock(``); + let block20 = createBlock(``); + + return function template(ctx, node, key = "") { + let b2, b8, b9, b10, b11, b13, b14, b18, b19; + let prop1 = new String((ctx['state'].search) === 0 ? 0 : ((ctx['state'].search) || "")); + if (ctx['state'].tasks.length) { + ctx = Object.create(ctx); + const [k_block2, v_block2, l_block2, c_block2] = prepareList(ctx['state'].tasks);; + for (let i1 = 0; i1 < l_block2; i1++) { + ctx[`task`] = k_block2[i1]; + const key1 = ctx['task'].id; + let attr1 = ctx['task'].id===ctx['state'].selectedTaskId?'task-card active':'task-card'; + let attr2 = ctx['task'].id; + const b4 = safeOutput(ctx['issueLabel'](ctx['state'].dataSource,ctx['task'])); + const b5 = safeOutput(ctx['normalizeText'](ctx['task'].project_id)||'No project'); + const b6 = safeOutput(ctx['normalizeText'](ctx['task'].stage_id)||'No stage'); + const b7 = safeOutput(ctx['normalizeText'](ctx['task'].user_id)||'Unassigned'); + c_block2[i1] = withKey(block3([attr1, attr2], [b4, b5, b6, b7]), key1); + } + ctx = ctx.__proto__; + b2 = list(c_block2); + } else { + b8 = block8(); + } + b9 = safeOutput(ctx['state'].messageTotal); + b10 = safeOutput(ctx['state'].polling); + if (ctx['currentTask']()) { + const b12 = safeOutput(ctx['issueLabel'](ctx['state'].dataSource,ctx['currentTask']())); + b11 = block11([], [b12]); + } else { + b13 = text(`Select a task or issue`); + } + if (ctx['currentTask']()) { + const b15 = safeOutput(ctx['normalizeText'](ctx['currentTask']().project_id)||'No project'); + const b16 = text(` · `); + const b17 = safeOutput(ctx['normalizeText'](ctx['currentTask']().stage_id)||'No stage'); + b14 = multi([b15, b16, b17]); + } else { + b18 = text(`Choose a task from the left pane`); + } + ctx = Object.create(ctx); + const [k_block19, v_block19, l_block19, c_block19] = prepareList(Object.entries(ctx['TYPE_META']));; + for (let i1 = 0; i1 < l_block19; i1++) { + ctx[`entry`] = k_block19[i1]; + const key1 = ctx['entry'][0]; + let attr3 = ctx['state'].filter===ctx['entry'][0]?'filter-chip active':'filter-chip'; + let attr4 = ctx['entry'][0]; + let attr5 = 'background:'+ctx['entry'][1].color; + const b21 = safeOutput(ctx['entry'][1].label); + c_block19[i1] = withKey(block20([attr3, attr4, attr5], [b21]), key1); + } + ctx = ctx.__proto__; + b19 = list(c_block19); + return block1([prop1], [b2, b8, b9, b10, b11, b13, b14, b18, b19]); + } +}, + +"MessagesApp": function MessagesApp(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + let { prepareList, safeOutput, withKey } = helpers; + + let block1 = createBlock(`
`); + let block2 = createBlock(`
Loading…
`); + let block3 = createBlock(`
`); + let block6 = createBlock(`
`); + let block7 = createBlock(`Check "Show all tasks" to see more.`); + let block10 = createBlock(`
`); + let block12 = createBlock(``); + let block14 = createBlock(``); + let block16 = createBlock(`

Select a task from the sidebar to view its messages

Showing recent 10 messages per task

`); + let block18 = createBlock(`
Public Internal System
+
`; + const result = await ca.show(html, ['close', 'Save'], { ...options, accentColor }); + if (result !== 'Save') return null; + const el = document.getElementById(inputId); + return el ? el.value : String(defaultValue ?? ''); +} + +// ─── Storage (Electron IPC → electron-store in main process) ───────────────── + +const _api = () => globalThis.electronAPI?.storage; + +export const storage = { + async get(key, fallback = null) { + try { return await _api().get(key, fallback); } catch { return fallback; } + }, + async set(key, value) { + try { await _api().set(key, value); } catch (e) { console.error('storage.set', e); } + }, + async remove(key) { + try { await _api().remove(key); } catch (e) { console.error('storage.remove', e); } + }, + async clear() { + try { await _api().clear(); } catch (e) { console.error('storage.clear', e); } + }, +}; + +// ─── Remotes ────────────────────────────────────────────────────────────────── + +export async function readRemotes() { + const raw = await storage.get('remote_host_info', []); + if (!Array.isArray(raw)) return []; + return raw.map((item) => { + if (typeof item === 'string') { try { return JSON.parse(item); } catch { return null; } } + return item; + }).filter(Boolean); +} + +export async function writeRemotes(remotes) { + await storage.set('remote_host_info', remotes.map((r) => JSON.stringify(r))); +} + +// ─── Timer / cookie helpers ─────────────────────────────────────────────────── + +export async function sendTimerStateToBackground(state, taskName = '') { + try { await globalThis.electronAPI?.updateTimerState(state, taskName); } catch {} +} + +export async function clearOdooSessionCookies(host) { + if (!host) return; + try { await globalThis.electronAPI?.clearCookies(host); } + catch (err) { console.warn('Could not clear cookies for', host, err); } +} + +// ─── URL utilities ──────────────────────────────────────────────────────────── + +export function validURL(str) { + try { const u = new URL(str); return ['http:', 'https:'].includes(u.protocol); } + catch { return false; } +} + +export function normalizeHost(host) { + if (!host) return ''; + let out = host.trim(); + if (!/^https?:\/\//i.test(out)) out = 'https://' + out; + return out.replace(/\/$/, ''); +} + +/** + * Return the functional identity of a saved remote. + * + * A single Odoo database can legitimately be configured more than once when + * each entry targets a different timer resource (for example Tasks and + * Helpdesk Tickets). Display name, logo and UI state are intentionally not + * part of this identity. + */ +export function remoteIdentity(remote) { + const host = normalizeHost(remote?.url || ''); + const database = String(remote?.database || '').trim(); + const dataSource = String(remote?.datasrc || 'project.issue').trim() || 'project.issue'; + return JSON.stringify([host, database, dataSource]); +} + + +// ─── File / CSV helpers ─────────────────────────────────────────────────────── + +export function toCSV(rows) { + if (!rows?.length) return ''; + const headers = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))); + const esc = (v) => `"${(v == null ? '' : String(v)).replace(/"/g, '""')}"`; + return [headers.join(','), ...rows.map((row) => headers.map((h) => esc(row[h])).join(','))].join('\n'); +} + +export function downloadTextFile(filename, content, mime = 'text/plain;charset=utf-8;') { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// ─── Formatters ─────────────────────────────────────────────────────────────── + +export function formatDuration(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const h = String(Math.floor(total / 3600)).padStart(2, '0'); + const m = String(Math.floor((total % 3600) / 60)).padStart(2, '0'); + const s = String(total % 60).padStart(2, '0'); + return `${h}:${m}:${s}`; +} + +export function formatHoursMins(decimalHours) { + if (decimalHours == null || Number.isNaN(Number(decimalHours))) return ''; + const value = Number(decimalHours); + const sign = value < 0 ? '-' : ''; + const abs = Math.abs(value); + const hours = Math.floor(abs); + const mins = Math.round((abs - hours) * 60); + return `${sign}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +export function priorityStars(priority) { + const n = Number(priority || 0); + return n > 0 ? Array.from({ length: n }, (_, i) => i) : []; +} + +export function matchesIssue(issue, query) { + if (!query) return true; + const q = query.trim().toLowerCase(); + if (!q) return true; + const hay = [ + issue.id, issue.code, issue.name, issue.display_name, + issue.ticket_ref, issue.number, issue.message_summary, + issue.stage_id?.[1], issue.project_id?.[1], issue.team_id?.[1], issue.user_id?.[1], + issue.priority, issue.create_date, + ].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q); +} + +export function extractMessageSummary(summary) { + if (!summary) return ''; + try { + const match = String(summary).match(/(?=You have)(.*?)(?='><|$)/); + return match ? match[0] : String(summary).replace(/<[^>]+>/g, ' '); + } catch { return String(summary); } +} + +// ─── OdooRpc — JSON-RPC client ──────────────────────────────────────────────── +// +// Electron embeds Chromium, so fetch() with credentials:'include' handles +// session cookies exactly as the browser extension does. No XML-RPC needed. + +export class OdooRpc { + constructor(host = '') { this.host = host; } + + setHost(host) { this.host = normalizeHost(host); } + + async send(path, params = {}) { + if (!this.host) throw new Error('No Odoo host selected'); + const response = await fetch(this.host + path, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params }), + }); + let payload; + try { payload = await response.json(); } + catch { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + if (!response.ok || payload.error) { + const err = payload.error || {}; + const msg = err.data?.message || err.message || `HTTP ${response.status}`; + const e = new Error(msg); + e.fullTrace = payload.error || payload; + throw e; + } + return payload.result; + } + + login(db, login, password) { + return this.send('/web/session/authenticate', { db, login, password }); + } + getSessionInfo() { return this.send('/web/session/get_session_info', {}); } + getServerInfo() { return this.send('/web/webclient/version_info', {}); } + + async searchRead(model, domain, fields = [], kwargs = {}) { + const { sort, ...rest } = kwargs; + const callKwargs = { fields, ...rest }; + if (sort !== undefined && callKwargs.order === undefined) callKwargs.order = sort; + const records = await this.call(model, 'search_read', [domain], callKwargs); + const normalized = Array.isArray(records) ? records : []; + return { records: normalized, length: normalized.length }; + } + fieldsGet(model, attributes = []) { + return this.send('/web/dataset/call_kw', { + model, method: 'fields_get', args: [], + kwargs: attributes.length ? { attributes } : {}, + }); + } + call(model, method, args = [], kwargs = {}) { + return this.send('/web/dataset/call_kw', { model, method, args, kwargs }); + } + callBtn(model, method, args = [], kwargs = {}) { + return this.send('/web/dataset/call_button', { model, method, args, kwargs }); + } + async logout() { + try { await this.send('/web/session/destroy', {}); } + catch (err) { console.warn('Logout endpoint failed', err); } + } +} diff --git a/dist/desktop/renderer/js/lib/gif-encoder.js b/dist/desktop/renderer/js/lib/gif-encoder.js new file mode 100644 index 00000000..2e3c07d8 --- /dev/null +++ b/dist/desktop/renderer/js/lib/gif-encoder.js @@ -0,0 +1,258 @@ +/** + * gif-encoder.js — Streaming GIF89a encoder (pure JS, no dependencies). + * + * Each frame is quantized and LZW-encoded immediately inside addFrame(), + * so raw RGBA data is never held in memory across frames. Only the compressed + * output bytes accumulate. This prevents OOM for long recordings and avoids + * the blocking synchronous finish() that previously caused IPC transfer timeouts. + * + * Usage: + * const enc = new GifEncoder(width, height, { loop: 0 }); + * enc.addFrame(rgbaUint8ClampedArray, delayMs); // process immediately + * const bytes = enc.finish(); // Uint8Array — write to file + */ + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function w(n) { return [n & 0xff, (n >> 8) & 0xff]; } + +/** + * Push all elements of src into dst without spread (avoids stack-overflow for + * large arrays, which happens when src.length > ~65,536 with push(...src)). + */ +function pushAll(dst, src) { + for (let i = 0; i < src.length; i++) dst.push(src[i]); +} + +/** + * Wrap a flat byte array into GIF sub-blocks (max 255 bytes each). + * Returns a flat array: [len, b0, b1, …, len, b0, b1, …, 0] + */ +function subBlocks(bytes) { + const out = []; + let i = 0; + while (i < bytes.length) { + const len = Math.min(255, bytes.length - i); + out.push(len); + for (let j = 0; j < len; j++) out.push(bytes[i++]); + } + out.push(0); // block terminator + return out; +} + +// ── LZW compression ─────────────────────────────────────────────────────────── + +function lzwEncode(indices, minCodeSize) { + const clearCode = 1 << minCodeSize; + const eofCode = clearCode + 1; + let codeSize = minCodeSize + 1; + let nextCode = eofCode + 1; + + const table = new Map(); + const initTable = () => { + table.clear(); + for (let i = 0; i < clearCode; i++) table.set(String(i), i); + codeSize = minCodeSize + 1; + nextCode = eofCode + 1; + }; + + const out = []; + initTable(); + out.push(clearCode); + + let prefix = ''; + for (let i = 0; i < indices.length; i++) { + const idx = indices[i]; + const key = prefix === '' ? String(idx) : `${prefix},${idx}`; + if (table.has(key)) { + prefix = key; + } else { + out.push(table.get(prefix)); + if (nextCode < 4096) { + table.set(key, nextCode++); + if (nextCode > (1 << codeSize)) codeSize++; + } else { + out.push(clearCode); + initTable(); + } + prefix = String(idx); + } + } + if (prefix !== '') out.push(table.get(prefix)); + out.push(eofCode); + + // Pack codes into bytes + const bytes = []; + let buf = 0, bits = 0; + for (const code of out) { + buf |= code << bits; + bits += codeSize; + while (bits >= 8) { bytes.push(buf & 0xff); buf >>= 8; bits -= 8; } + } + if (bits > 0) bytes.push(buf & 0xff); + return bytes; +} + +// ── Median-cut color quantization ───────────────────────────────────────────── + +function quantize(pixels, maxColors) { + const n = pixels.length >> 2; + + // Sample every Nth pixel for speed on large images + const step = Math.max(1, Math.floor(n / 20000)); + const hist = new Map(); + for (let i = 0; i < n; i += step) { + const o = i << 2; + const key = (pixels[o] >> 2) << 12 | (pixels[o + 1] >> 2) << 6 | (pixels[o + 2] >> 2); + hist.set(key, (hist.get(key) || 0) + 1); + } + + let colors = []; + for (const [key, cnt] of hist) { + colors.push({ + r: ((key >> 12) & 0x3f) << 2, + g: ((key >> 6) & 0x3f) << 2, + b: (key & 0x3f) << 2, + cnt, + }); + } + + function range(list, ch) { + let mn = 255, mx = 0; + for (const c of list) { if (c[ch] < mn) mn = c[ch]; if (c[ch] > mx) mx = c[ch]; } + return mx - mn; + } + + function cut(list, depth) { + if (depth === 0 || list.length <= 1) { + const tot = list.reduce((s, c) => s + c.cnt, 0); + if (!tot) return [[0, 0, 0]]; + let r = 0, g = 0, b = 0; + for (const c of list) { r += c.r * c.cnt; g += c.g * c.cnt; b += c.b * c.cnt; } + return [[Math.round(r / tot), Math.round(g / tot), Math.round(b / tot)]]; + } + const rr = range(list, 'r'), gr = range(list, 'g'), br = range(list, 'b'); + const ch = rr >= gr && rr >= br ? 'r' : gr >= br ? 'g' : 'b'; + list.sort((a, b) => a[ch] - b[ch]); + const mid = Math.floor(list.length / 2); + return [...cut(list.slice(0, mid), depth - 1), ...cut(list.slice(mid), depth - 1)]; + } + + const depth = Math.ceil(Math.log2(maxColors)); + let palette = cut(colors, depth); + + // Pad to maxColors if needed + while (palette.length < maxColors) palette.push([0, 0, 0]); + palette = palette.slice(0, maxColors); + + // Map each pixel to nearest palette entry + const map = new Uint8Array(n); + for (let i = 0; i < n; i++) { + const o = i << 2; + const pr = pixels[o], pg = pixels[o + 1], pb = pixels[o + 2]; + let best = 0, bestDist = Infinity; + for (let j = 0; j < palette.length; j++) { + const dr = pr - palette[j][0], dg = pg - palette[j][1], db = pb - palette[j][2]; + const d = dr * dr + dg * dg + db * db; + if (d < bestDist) { bestDist = d; best = j; } + } + map[i] = best; + } + + return { palette, map }; +} + +// ── Streaming GIF encoder ───────────────────────────────────────────────────── + +export class GifEncoder { + /** + * @param {number} width + * @param {number} height + * @param {{ loop?: number, colors?: number }} opts + */ + constructor(width, height, { loop = 0, colors = 256 } = {}) { + this.width = width; + this.height = height; + this.loop = loop; + this.maxColors = Math.min(256, Math.max(2, colors)); + this._colorBits = Math.ceil(Math.log2(this.maxColors)); + this._colorCount = 1 << this._colorBits; + this._out = []; // compressed output bytes accumulate here + this._started = false; // header written? + this._frames = 0; + } + + /** + * Encode one frame immediately. Raw rgba data is quantized and LZW-encoded + * right now; no reference to the rgba buffer is retained after this call. + * + * @param {Uint8ClampedArray} rgba - canvas getImageData().data + * @param {number} delayMs - frame delay in milliseconds + */ + addFrame(rgba, delayMs = 100) { + const { width, height, _colorBits, _colorCount } = this; + const delay = Math.round(delayMs / 10); // GIF uses centiseconds + + // Quantize this frame + const q = quantize(rgba, _colorCount); + + // ── Write GIF header before the first frame ────────────────────────────── + if (!this._started) { + this._started = true; + + // Signature + version + pushAll(this._out, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); // 'GIF89a' + + // Logical Screen Descriptor + pushAll(this._out, w(width)); + pushAll(this._out, w(height)); + this._out.push(0x80 | (_colorBits - 1)); // Global Color Table Flag + this._out.push(0); // background color index + this._out.push(0); // pixel aspect ratio + + // Global Color Table (from first frame's palette — improves compat) + for (const [r, g, b] of q.palette) this._out.push(r, g, b); + + // Netscape Application Extension (loop) + if (this.loop !== null) { + pushAll(this._out, [0x21, 0xff, 0x0b]); + pushAll(this._out, [78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48]); // NETSCAPE2.0 + pushAll(this._out, [3, 1, ...w(this.loop), 0]); + } + } + + // ── Graphics Control Extension ─────────────────────────────────────────── + this._out.push(0x21, 0xf9, 0x04); + this._out.push(0x04); // disposal: restore to background + pushAll(this._out, w(delay)); + this._out.push(0); // transparent color index (none) + this._out.push(0); // block terminator + + // ── Image Descriptor ───────────────────────────────────────────────────── + this._out.push(0x2c); + pushAll(this._out, w(0)); pushAll(this._out, w(0)); // left, top + pushAll(this._out, w(width)); pushAll(this._out, w(height)); + this._out.push(0x80 | (_colorBits - 1)); // Local Color Table Flag + + // Local Color Table + for (const [r, g, b] of q.palette) this._out.push(r, g, b); + + // Image Data + const minCode = Math.max(2, _colorBits); + this._out.push(minCode); + // Use pushAll (not spread) to avoid JS argument-count limit on large arrays + pushAll(this._out, subBlocks(lzwEncode(q.map, minCode))); + + this._frames++; + } + + /** + * Finalize the GIF and return the complete byte array. + * After calling finish(), do not call addFrame() again. + * @returns {Uint8Array} + */ + finish() { + this._out.push(0x3b); // GIF Trailer + return new Uint8Array(this._out); + } +} diff --git a/dist/desktop/renderer/js/lib/logger.js b/dist/desktop/renderer/js/lib/logger.js new file mode 100644 index 00000000..94db625a --- /dev/null +++ b/dist/desktop/renderer/js/lib/logger.js @@ -0,0 +1,101 @@ +/** + * logger.js — Client-side ring-buffer logger for Therp Timer renderer processes. + * + * Writes log entries both to the console and to an in-memory ring buffer that + * can be queried and displayed in the Logs panel. IPC bridge to main process + * is used so entries survive renderer reload within the same app session. + * + * Usage: + * import { log } from './lib/logger.js'; + * log.info('Component mounted'); + * log.warn('Retrying request…'); + * log.error('RPC failed', err); + * + * @module logger + */ + +/** Maximum number of log entries kept in the ring buffer. */ +const MAX_ENTRIES = 500; + +/** In-memory ring buffer (used when IPC is unavailable). */ +const _localBuffer = []; + +/** + * Pretty-print a value for log output. + * @param {*} val + * @returns {string} + */ +function _fmt(val) { + if (val == null) return String(val); + if (val instanceof Error) return `${val.message}\n${val.stack || ''}`; + if (typeof val === 'object') { + try { return JSON.stringify(val); } catch { return String(val); } + } + return String(val); +} + +/** + * Core write function — adds entry to local buffer and forwards to main via IPC. + * @param {'debug'|'info'|'warn'|'error'} level + * @param {string} msg + */ +function _write(level, msg) { + const entry = { ts: new Date().toISOString(), level, msg }; + _localBuffer.push(entry); + if (_localBuffer.length > MAX_ENTRIES) _localBuffer.shift(); + + // Forward to main-process ring buffer via IPC (best-effort) + try { + window.electronAPI?.logs?.append?.(level, msg); + } catch (_) {} + + // Mirror to dev-tools console + const fn = level === 'error' ? console.error + : level === 'warn' ? console.warn + : level === 'debug' ? console.debug + : console.log; + fn(`[${level.toUpperCase()}]`, msg); +} + +/** + * Public logger API. + * @namespace log + */ +export const log = { + /** + * Debug-level message (verbose, shown in Logs panel only in dev mode). + * @param {...*} args + */ + debug(...args) { _write('debug', args.map(_fmt).join(' ')); }, + + /** + * Informational message. + * @param {...*} args + */ + info(...args) { _write('info', args.map(_fmt).join(' ')); }, + + /** + * Warning — something unexpected but non-fatal. + * @param {...*} args + */ + warn(...args) { _write('warn', args.map(_fmt).join(' ')); }, + + /** + * Error — operation failed. + * @param {...*} args + */ + error(...args) { _write('error', args.map(_fmt).join(' ')); }, + + /** + * Return a copy of all local log entries. + * @returns {Array<{ts:string, level:string, msg:string}>} + */ + getEntries() { return [..._localBuffer]; }, + + /** + * Clear the local ring buffer. + */ + clear() { _localBuffer.length = 0; }, +}; + +export default log; diff --git a/dist/desktop/renderer/js/lib/owl.iife.js b/dist/desktop/renderer/js/lib/owl.iife.js new file mode 100644 index 00000000..c26b6259 --- /dev/null +++ b/dist/desktop/renderer/js/lib/owl.iife.js @@ -0,0 +1,6340 @@ +(function (exports) { + 'use strict'; + + function filterOutModifiersFromData(dataList) { + dataList = dataList.slice(); + const modifiers = []; + let elm; + while ((elm = dataList[0]) && typeof elm === "string") { + modifiers.push(dataList.shift()); + } + return { modifiers, data: dataList }; + } + const config = { + // whether or not blockdom should normalize DOM whenever a block is created. + // Normalizing dom mean removing empty text nodes (or containing only spaces) + shouldNormalizeDom: true, + // this is the main event handler. Every event handler registered with blockdom + // will go through this function, giving it the data registered in the block + // and the event + mainEventHandler: (data, ev, currentTarget) => { + if (typeof data === "function") { + data(ev); + } + else if (Array.isArray(data)) { + data = filterOutModifiersFromData(data).data; + data[0](data[1], ev); + } + return false; + }, + }; + + // ----------------------------------------------------------------------------- + // Toggler node + // ----------------------------------------------------------------------------- + class VToggler { + constructor(key, child) { + this.key = key; + this.child = child; + } + mount(parent, afterNode) { + this.parentEl = parent; + this.child.mount(parent, afterNode); + } + moveBeforeDOMNode(node, parent) { + this.child.moveBeforeDOMNode(node, parent); + } + moveBeforeVNode(other, afterNode) { + this.moveBeforeDOMNode((other && other.firstNode()) || afterNode); + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + let child1 = this.child; + let child2 = other.child; + if (this.key === other.key) { + child1.patch(child2, withBeforeRemove); + } + else { + child2.mount(this.parentEl, child1.firstNode()); + if (withBeforeRemove) { + child1.beforeRemove(); + } + child1.remove(); + this.child = child2; + this.key = other.key; + } + } + beforeRemove() { + this.child.beforeRemove(); + } + remove() { + this.child.remove(); + } + firstNode() { + return this.child.firstNode(); + } + toString() { + return this.child.toString(); + } + } + function toggler(key, child) { + return new VToggler(key, child); + } + + // Custom error class that wraps error that happen in the owl lifecycle + class OwlError extends Error { + } + + const { setAttribute: elemSetAttribute, removeAttribute } = Element.prototype; + const tokenList = DOMTokenList.prototype; + const tokenListAdd = tokenList.add; + const tokenListRemove = tokenList.remove; + const isArray = Array.isArray; + const { split, trim } = String.prototype; + const wordRegexp = /\s+/; + /** + * We regroup here all code related to updating attributes in a very loose sense: + * attributes, properties and classs are all managed by the functions in this + * file. + */ + function setAttribute(key, value) { + switch (value) { + case false: + case undefined: + removeAttribute.call(this, key); + break; + case true: + elemSetAttribute.call(this, key, ""); + break; + default: + elemSetAttribute.call(this, key, value); + } + } + function createAttrUpdater(attr) { + return function (value) { + setAttribute.call(this, attr, value); + }; + } + function attrsSetter(attrs) { + if (isArray(attrs)) { + if (attrs[0] === "class") { + setClass.call(this, attrs[1]); + } + else { + setAttribute.call(this, attrs[0], attrs[1]); + } + } + else { + for (let k in attrs) { + if (k === "class") { + setClass.call(this, attrs[k]); + } + else { + setAttribute.call(this, k, attrs[k]); + } + } + } + } + function attrsUpdater(attrs, oldAttrs) { + if (isArray(attrs)) { + const name = attrs[0]; + const val = attrs[1]; + if (name === oldAttrs[0]) { + if (val === oldAttrs[1]) { + return; + } + if (name === "class") { + updateClass.call(this, val, oldAttrs[1]); + } + else { + setAttribute.call(this, name, val); + } + } + else { + removeAttribute.call(this, oldAttrs[0]); + setAttribute.call(this, name, val); + } + } + else { + for (let k in oldAttrs) { + if (!(k in attrs)) { + if (k === "class") { + updateClass.call(this, "", oldAttrs[k]); + } + else { + removeAttribute.call(this, k); + } + } + } + for (let k in attrs) { + const val = attrs[k]; + if (val !== oldAttrs[k]) { + if (k === "class") { + updateClass.call(this, val, oldAttrs[k]); + } + else { + setAttribute.call(this, k, val); + } + } + } + } + } + function toClassObj(expr) { + const result = {}; + switch (typeof expr) { + case "string": + // we transform here a list of classes into an object: + // 'hey you' becomes {hey: true, you: true} + const str = trim.call(expr); + if (!str) { + return {}; + } + let words = split.call(str, wordRegexp); + for (let i = 0, l = words.length; i < l; i++) { + result[words[i]] = true; + } + return result; + case "object": + // this is already an object but we may need to split keys: + // {'a': true, 'b c': true} should become {a: true, b: true, c: true} + for (let key in expr) { + const value = expr[key]; + if (value) { + key = trim.call(key); + if (!key) { + continue; + } + const words = split.call(key, wordRegexp); + for (let word of words) { + result[word] = value; + } + } + } + return result; + case "undefined": + return {}; + case "number": + return { [expr]: true }; + default: + return { [expr]: true }; + } + } + function setClass(val) { + val = val === "" ? {} : toClassObj(val); + // add classes + const cl = this.classList; + for (let c in val) { + tokenListAdd.call(cl, c); + } + } + function updateClass(val, oldVal) { + oldVal = oldVal === "" ? {} : toClassObj(oldVal); + val = val === "" ? {} : toClassObj(val); + const cl = this.classList; + // remove classes + for (let c in oldVal) { + if (!(c in val)) { + tokenListRemove.call(cl, c); + } + } + // add classes + for (let c in val) { + if (!(c in oldVal)) { + tokenListAdd.call(cl, c); + } + } + } + + /** + * Creates a batched version of a callback so that all calls to it in the same + * microtick will only call the original callback once. + * + * @param callback the callback to batch + * @returns a batched version of the original callback + */ + function batched(callback) { + let scheduled = false; + return async (...args) => { + if (!scheduled) { + scheduled = true; + await Promise.resolve(); + scheduled = false; + callback(...args); + } + }; + } + /** + * Determine whether the given element is contained in its ownerDocument: + * either directly or with a shadow root in between. + */ + function inOwnerDocument(el) { + if (!el) { + return false; + } + if (el.ownerDocument.contains(el)) { + return true; + } + const rootNode = el.getRootNode(); + return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); + } + /** + * Determine whether the given element is contained in a specific root documnet: + * either directly or with a shadow root in between or in an iframe. + */ + function isAttachedToDocument(element, documentElement) { + let current = element; + const shadowRoot = documentElement.defaultView.ShadowRoot; + while (current) { + if (current === documentElement) { + return true; + } + if (current.parentNode) { + current = current.parentNode; + } + else if (current instanceof shadowRoot && current.host) { + current = current.host; + } + else { + return false; + } + } + return false; + } + function validateTarget(target) { + // Get the document and HTMLElement corresponding to the target to allow mounting in iframes + const document = target && target.ownerDocument; + if (document) { + if (!document.defaultView) { + throw new OwlError("Cannot mount a component: the target document is not attached to a window (defaultView is missing)"); + } + const HTMLElement = document.defaultView.HTMLElement; + if (target instanceof HTMLElement || target instanceof ShadowRoot) { + if (!isAttachedToDocument(target, document)) { + throw new OwlError("Cannot mount a component on a detached dom node"); + } + return; + } + } + throw new OwlError("Cannot mount component: the target is not a valid DOM element"); + } + class EventBus extends EventTarget { + trigger(name, payload) { + this.dispatchEvent(new CustomEvent(name, { detail: payload })); + } + } + function whenReady(fn) { + return new Promise(function (resolve) { + if (document.readyState !== "loading") { + resolve(true); + } + else { + document.addEventListener("DOMContentLoaded", resolve, false); + } + }).then(fn || function () { }); + } + async function loadFile(url) { + const result = await fetch(url); + if (!result.ok) { + throw new OwlError("Error while fetching xml templates"); + } + return await result.text(); + } + /* + * This class just transports the fact that a string is safe + * to be injected as HTML. Overriding a JS primitive is quite painful though + * so we need to redfine toString and valueOf. + */ + class Markup extends String { + } + function htmlEscape(str) { + if (str instanceof Markup) { + return str; + } + if (str === undefined) { + return markup(""); + } + if (typeof str === "number") { + return markup(String(str)); + } + [ + ["&", "&"], + ["<", "<"], + [">", ">"], + ["'", "'"], + ['"', """], + ["`", "`"], + ].forEach((pairs) => { + str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]); + }); + return markup(str); + } + function markup(valueOrStrings, ...placeholders) { + if (!Array.isArray(valueOrStrings)) { + return new Markup(valueOrStrings); + } + const strings = valueOrStrings; + let acc = ""; + let i = 0; + for (; i < placeholders.length; ++i) { + acc += strings[i] + htmlEscape(placeholders[i]); + } + acc += strings[i]; + return new Markup(acc); + } + + function createEventHandler(rawEvent) { + const eventName = rawEvent.split(".")[0]; + const capture = rawEvent.includes(".capture"); + if (rawEvent.includes(".synthetic")) { + return createSyntheticHandler(eventName, capture); + } + else { + return createElementHandler(eventName, capture); + } + } + // Native listener + let nextNativeEventId = 1; + function createElementHandler(evName, capture = false) { + let eventKey = `__event__${evName}_${nextNativeEventId++}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + function listener(ev) { + const currentTarget = ev.currentTarget; + if (!currentTarget || !inOwnerDocument(currentTarget)) + return; + const data = currentTarget[eventKey]; + if (!data) + return; + config.mainEventHandler(data, ev, currentTarget); + } + function setup(data) { + this[eventKey] = data; + this.addEventListener(evName, listener, { capture }); + } + function remove() { + delete this[eventKey]; + this.removeEventListener(evName, listener, { capture }); + } + function update(data) { + this[eventKey] = data; + } + return { setup, update, remove }; + } + // Synthetic handler: a form of event delegation that allows placing only one + // listener per event type. + let nextSyntheticEventId = 1; + function createSyntheticHandler(evName, capture = false) { + let eventKey = `__event__synthetic_${evName}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + setupSyntheticEvent(evName, eventKey, capture); + const currentId = nextSyntheticEventId++; + function setup(data) { + const _data = this[eventKey] || {}; + _data[currentId] = data; + this[eventKey] = _data; + } + function remove() { + delete this[eventKey]; + } + return { setup, update: setup, remove }; + } + function nativeToSyntheticEvent(eventKey, event) { + let dom = event.target; + while (dom !== null) { + const _data = dom[eventKey]; + if (_data) { + for (const data of Object.values(_data)) { + const stopped = config.mainEventHandler(data, event, dom); + if (stopped) + return; + } + } + dom = dom.parentNode; + } + } + const CONFIGURED_SYNTHETIC_EVENTS = {}; + function setupSyntheticEvent(evName, eventKey, capture = false) { + if (CONFIGURED_SYNTHETIC_EVENTS[eventKey]) { + return; + } + document.addEventListener(evName, (event) => nativeToSyntheticEvent(eventKey, event), { + capture, + }); + CONFIGURED_SYNTHETIC_EVENTS[eventKey] = true; + } + + const getDescriptor$3 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$4 = Node.prototype; + const nodeInsertBefore$3 = nodeProto$4.insertBefore; + const nodeSetTextContent$1 = getDescriptor$3(nodeProto$4, "textContent").set; + const nodeRemoveChild$3 = nodeProto$4.removeChild; + // ----------------------------------------------------------------------------- + // Multi NODE + // ----------------------------------------------------------------------------- + class VMulti { + constructor(children) { + this.children = children; + } + mount(parent, afterNode) { + const children = this.children; + const l = children.length; + const anchors = new Array(l); + for (let i = 0; i < l; i++) { + let child = children[i]; + if (child) { + child.mount(parent, afterNode); + } + else { + const childAnchor = document.createTextNode(""); + anchors[i] = childAnchor; + nodeInsertBefore$3.call(parent, childAnchor, afterNode); + } + } + this.anchors = anchors; + this.parentEl = parent; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeDOMNode(node, parent); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, node); + } + } + } + moveBeforeVNode(other, afterNode) { + if (other) { + const next = other.children[0]; + afterNode = (next ? next.firstNode() : other.anchors[0]) || null; + } + const children = this.children; + const parent = this.parentEl; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeVNode(null, afterNode); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, afterNode); + } + } + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + const children1 = this.children; + const children2 = other.children; + const anchors = this.anchors; + const parentEl = this.parentEl; + for (let i = 0, l = children1.length; i < l; i++) { + const vn1 = children1[i]; + const vn2 = children2[i]; + if (vn1) { + if (vn2) { + vn1.patch(vn2, withBeforeRemove); + } + else { + const afterNode = vn1.firstNode(); + const anchor = document.createTextNode(""); + anchors[i] = anchor; + nodeInsertBefore$3.call(parentEl, anchor, afterNode); + if (withBeforeRemove) { + vn1.beforeRemove(); + } + vn1.remove(); + children1[i] = undefined; + } + } + else if (vn2) { + children1[i] = vn2; + const anchor = anchors[i]; + vn2.mount(parentEl, anchor); + nodeRemoveChild$3.call(parentEl, anchor); + } + } + } + beforeRemove() { + const children = this.children; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.beforeRemove(); + } + } + } + remove() { + const parentEl = this.parentEl; + if (this.isOnlyChild) { + nodeSetTextContent$1.call(parentEl, ""); + } + else { + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.remove(); + } + else { + nodeRemoveChild$3.call(parentEl, anchors[i]); + } + } + } + } + firstNode() { + const child = this.children[0]; + return child ? child.firstNode() : this.anchors[0]; + } + toString() { + return this.children.map((c) => (c ? c.toString() : "")).join(""); + } + } + function multi(children) { + return new VMulti(children); + } + + const getDescriptor$2 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$3 = Node.prototype; + const characterDataProto$1 = CharacterData.prototype; + const nodeInsertBefore$2 = nodeProto$3.insertBefore; + const characterDataSetData$1 = getDescriptor$2(characterDataProto$1, "data").set; + const nodeRemoveChild$2 = nodeProto$3.removeChild; + class VSimpleNode { + constructor(text) { + this.text = text; + } + mountNode(node, parent, afterNode) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, node, afterNode); + this.el = node; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, this.el, node); + } + moveBeforeVNode(other, afterNode) { + nodeInsertBefore$2.call(this.parentEl, this.el, other ? other.el : afterNode); + } + beforeRemove() { } + remove() { + nodeRemoveChild$2.call(this.parentEl, this.el); + } + firstNode() { + return this.el; + } + toString() { + return this.text; + } + } + class VText$1 extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createTextNode(toText(this.text)), parent, afterNode); + } + patch(other) { + const text2 = other.text; + if (this.text !== text2) { + characterDataSetData$1.call(this.el, toText(text2)); + this.text = text2; + } + } + } + class VComment extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createComment(toText(this.text)), parent, afterNode); + } + patch() { } + } + function text(str) { + return new VText$1(str); + } + function comment(str) { + return new VComment(str); + } + function toText(value) { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + default: + return value || ""; + } + } + + const getDescriptor$1 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$2 = Node.prototype; + const elementProto = Element.prototype; + const characterDataProto = CharacterData.prototype; + const characterDataSetData = getDescriptor$1(characterDataProto, "data").set; + const nodeGetFirstChild = getDescriptor$1(nodeProto$2, "firstChild").get; + const nodeGetNextSibling = getDescriptor$1(nodeProto$2, "nextSibling").get; + const NO_OP$1 = () => { }; + function makePropSetter(name) { + return function setProp(value) { + // support 0, fallback to empty string for other falsy values + this[name] = value === 0 ? 0 : value ? value.valueOf() : ""; + }; + } + const cache$1 = {}; + /** + * Compiling blocks is a multi-step process: + * + * 1. build an IntermediateTree from the HTML element. This intermediate tree + * is a binary tree structure that encode dynamic info sub nodes, and the + * path required to reach them + * 2. process the tree to build a block context, which is an object that aggregate + * all dynamic info in a list, and also, all ref indexes. + * 3. process the context to build appropriate builder/setter functions + * 4. make a dynamic block class, which will efficiently collect references and + * create/update dynamic locations/children + * + * @param str + * @returns a new block type, that can build concrete blocks + */ + function createBlock(str) { + if (str in cache$1) { + return cache$1[str]; + } + // step 0: prepare html base element + const doc = new DOMParser().parseFromString(`${str}`, "text/xml"); + const node = doc.firstChild.firstChild; + if (config.shouldNormalizeDom) { + normalizeNode(node); + } + // step 1: prepare intermediate tree + const tree = buildTree(node); + // step 2: prepare block context + const context = buildContext(tree); + // step 3: build the final block class + const template = tree.el; + const Block = buildBlock(template, context); + cache$1[str] = Block; + return Block; + } + // ----------------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------------- + function normalizeNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + if (!/\S/.test(node.textContent)) { + node.remove(); + return; + } + } + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === "pre") { + return; + } + } + for (let i = node.childNodes.length - 1; i >= 0; --i) { + normalizeNode(node.childNodes.item(i)); + } + } + function buildTree(node, parent = null, domParentTree = null) { + switch (node.nodeType) { + case Node.ELEMENT_NODE: { + // HTMLElement + let currentNS = domParentTree && domParentTree.currentNS; + const tagName = node.tagName; + let el = undefined; + const info = []; + if (tagName.startsWith("block-text-")) { + const index = parseInt(tagName.slice(11), 10); + info.push({ type: "text", idx: index }); + el = document.createTextNode(""); + } + if (tagName.startsWith("block-child-")) { + if (!domParentTree.isRef) { + addRef(domParentTree); + } + const index = parseInt(tagName.slice(12), 10); + info.push({ type: "child", idx: index }); + el = document.createTextNode(""); + } + currentNS || (currentNS = node.namespaceURI); + if (!el) { + el = currentNS + ? document.createElementNS(currentNS, tagName) + : document.createElement(tagName); + } + if (el instanceof Element) { + if (!domParentTree) { + // some html elements may have side effects when setting their attributes. + // For example, setting the src attribute of an will trigger a + // request to get the corresponding image. This is something that we + // don't want at compile time. We avoid that by putting the content of + // the block in a