diff --git a/README.md b/README.md deleted file mode 100644 index c9c71cd..0000000 --- a/README.md +++ /dev/null @@ -1,488 +0,0 @@ -# Therp Timer - -Therp Timer is a browser extension for recording time on Odoo project work and posting that time to Odoo timesheets. - -It lets you: - -- connect to one or more Odoo instances -- load assigned issues or tasks -- start and stop a timer from the browser popup -- write the result back to Odoo timesheets -- optionally export timesheet data to CSV - -The extension has separate manifests for Chromium-based browsers and Firefox because background script support differs between the two browser families. - -## What it does - -At a high level, the extension works like this: - -1. You configure one or more Odoo remotes in the options page. -2. You open the popup and choose a remote. -3. The extension either reuses an existing Odoo browser session or logs in with a username and password. -4. It loads project items from Odoo, usually `project.task` or `project.issue` depending on the configured data source. -5. You start a timer on a selected row. -6. When you stop the timer, the extension creates the correct timesheet record in Odoo. -7. Optionally, it can download CSV timesheet data. - -## Odoo behavior - -The extension supports two common flows. - -### `project.task` - -When the remote is configured to use `project.task`, the extension writes time to: - -- `account.analytic.line` - -### `project.issue` - -When the remote is configured to use `project.issue`, the extension writes time to: - -- `hr.analytic.timesheet` - -The extension also tries to find the correct analytic account from the issue/task or its related project. - -## Main features - -- popup-based timer UI -- support for multiple Odoo remotes -- use existing browser Odoo session or manual login -- task/issue filtering and search -- automatic timer state persistence -- CSV export for current month timesheets -- optional download of issue/task-specific timesheets -- browser-specific manifests for Chromium and Firefox - -## Recommended project layout - -A practical layout is: - -```text -therp_timer_owl/ - src/ - popup.html - options_main_page.html - js/ - css/ - img/ - - dist/ - chrome/ - manifest.json - popup.html - options_main_page.html - js/ - css/ - img/ - - firefox/ - manifest.json - popup.html - options_main_page.html - js/ - css/ - img/ -``` - -In this structure: - -- `src` is your editable source -- `dist/chrome` is the loadable Chromium build -- `dist/firefox` is the loadable Firefox build - -Each browser folder must contain its own root `manifest.json`. - -## Why there are separate manifests - -Chromium-based browsers and Firefox handle extension background execution differently. - -### Chromium-based browsers - -Use a service worker background definition: - -```json -{ - "background": { - "service_worker": "js/background.js" - } -} -``` - -### Firefox - -Use background scripts: - -```json -{ - "background": { - "scripts": ["js/background.js"], - "type": "module" - } -} -``` - -Because of that difference, keeping separate browser build folders is the easiest setup. - -## Setup - -### Requirements - -Before loading the extension, make sure you have: - -- a working Odoo instance -- access to tasks or issues you want to track -- a browser supported by one of the two builds -- the extension files arranged so the correct `manifest.json` is at the root of the browser-specific folder - -## Usage - -![How Odoo timer app works](src/img/usage.gif "How it Works") - -### Chromium-based browsers - -Examples: - -- Brave -- Chrome -- Chromium -- Edge -- Vivaldi - -#### Setup steps - -1. Open the extensions page in your browser. -2. Enable **Developer mode**. -3. Choose **Load unpacked**. -4. Select the browser-specific extension folder, for example: - -```text -/dist/chrome -``` - -5. Confirm that the extension appears in the extensions list. -6. Pin the extension to the toolbar if desired. - -#### Chromium manifest notes - -The Chromium build should use a `manifest.json` with a background service worker, for example: - -```json -{ - "manifest_version": 3, - "background": { - "service_worker": "js/background.js" - } -} -``` - -#### First use in Chromium - -1. Click the extension icon. -2. Open **Options**. -3. Add an Odoo remote: - - name - - base URL - - database - - data source such as `project.task` or `project.issue` -4. Save the remote. -5. Return to the popup. -6. Select the remote. -7. Either: - - keep **Use Existing Session** enabled if already logged into Odoo in the browser, or - - disable it and log in manually -8. Start a timer on a task or issue. -9. Stop the timer to write the timesheet back to Odoo. - -### Firefox-based browsers - -Examples: - -- Firefox -- Firefox Developer Edition - -#### Setup steps - -1. Open: - -```text -about:debugging#/runtime/this-firefox -``` - -2. Click **Load Temporary Add-on...**. -3. Select the actual Firefox manifest file: - -```text -/dist/firefox/manifest.json -``` - -4. The temporary add-on should load into Firefox. - -#### Important Firefox note - -Firefox expects the file to be named exactly: - -```text -manifest.json -``` - -Do not try to load a file called `manifest.firefox.json` directly unless you have copied or renamed it to `manifest.json` inside the Firefox build folder. - -#### Firefox manifest notes - -The Firefox build should use background scripts instead of a service worker: - -```json -{ - "manifest_version": 3, - "background": { - "scripts": ["js/background.js"], - "type": "module" - } -} -``` - -#### First use in Firefox - -The usage flow is the same as Chromium: - -1. Open **Options**. -2. Add a remote. -3. Return to the popup. -4. Select the remote. -5. Reuse an existing Odoo session or log in manually. -6. Start and stop timers as needed. - -## Remote configuration - -A remote usually includes: - -- **Name**: friendly label shown in the popup -- **URL**: base Odoo URL, for example `https://example.odoo.com` -- **Database**: target database name -- **Data source**: usually `project.task` or `project.issue` - -If the wrong data source is selected, the extension may load the wrong model or fail to create the expected timesheet record type. - -## Typical workflow - -### Using an existing session - -This is the easiest option when you are already logged into Odoo in the same browser. - -1. Log into Odoo normally in a browser tab. -2. Open the extension popup. -3. Choose the remote. -4. Leave **Use Existing Session** enabled. -5. Click **Login**. - -If the session is valid, the extension will load your tasks/issues. - -### Manual login - -Use this when there is no active browser Odoo session. - -1. Open the popup. -2. Disable **Use Existing Session**. -3. Enter username and password. -4. Click **Login**. - -## Timer behavior - -When you start a timer: - -- the active item is remembered -- the start time is stored in extension storage -- the popup can continue showing elapsed time - -When you stop a timer: - -- the elapsed time is calculated -- rounding logic is applied -- you may be prompted for a description -- a new timesheet record is created in Odoo - -## CSV export - -The extension can export: - -- current month timesheets -- issue/task-specific timesheets - -This is useful for local backup, manual review, or reporting. - -## Troubleshooting - -### Firefox does not load the extension - -Check the following: - -- the selected file is named `manifest.json` -- the file is inside the Firefox build folder -- the Firefox manifest uses `background.scripts` -- the JSON is valid - -### Chromium loads but Firefox fails - -This usually means the Chromium manifest was used in Firefox. Switch to the Firefox build folder. - -### Session restore fails - -Possible causes: - -- Odoo session expired -- remote URL does not match the actual Odoo login host -- browser cookies are missing or blocked - -Try disabling **Use Existing Session** and log in manually. - -### Tasks do not appear - -Possible causes: - -- wrong data source configured -- current user has no assigned records -- Odoo model fields differ from what the extension expects -- slow Odoo response during startup - -### Timer stops but no timesheet is created - -Check: - -- the related project or task has an analytic account -- the remote is using the correct model -- your Odoo user has permission to create timesheets -- the correct journal exists for the issue-based flow - -## Development notes - -A good workflow is: - -1. maintain common source files in `src` -2. produce separate `dist/chrome` and `dist/firefox` builds -3. keep the code shared as much as possible -4. only vary the manifest and browser-specific behavior where required - - -# For Developers - -## Owl template compilation for developers - -The extension currently keeps its working runtime templates in the JavaScript -entry files under: - -- `dist/chrome/js/components/popup-app.js` -- `dist/chrome/js/components/options-app.js` -- `dist/chrome/js/components/readmore.js` -- `dist/firefox/js/components/popup-app.js` -- `dist/firefox/js/components/options-app.js` -- `dist/firefox/js/components/readmore.js` - -Those `createBlock(...)` templates remain the runtime fallback so that the -extension keeps working even when ahead-of-time compiled Owl templates are not -available. - -Editable source XML templates live under: - -- `src/templates/popup_app.xml` -- `src/templates/options_app.xml` -- `src/templates/readmore.xml` - -At runtime, the extension loads `js/templates.js` before the app modules. -If that file registers compiled templates on -`globalThis.__THERP_TIMER_TEMPLATES__`, the app will use them. If not, it -falls back to the current JavaScript `createBlock(...)` templates. - -### Why this fallback exists - -Browser extensions often cannot rely on runtime template compilation in the way -standard Odoo web builds do. Keeping the JavaScript fallback avoids breaking -the popup or options page while developers gradually move templates into XML -source files. - -### Set up Node tooling with Python `nodeenv` - -To avoid changing the host system Node installation, create an isolated Node -environment with Python: - -```bash -python3 -m pip install --user nodeenv -python3 -m nodeenv .nodeenv -. .nodeenv/bin/activate -``` - -A helper script is included: - -```bash -./scripts/setup_nodeenv.sh -. .nodeenv/bin/activate -``` - -### Compile Owl templates ahead of time - -1. Clone the Owl repository locally. -2. Activate the `nodeenv` environment. -3. Build the runtime and compiler. -4. Compile the XML templates from `src/templates`. - -Example workflow: - -```bash -git clone https://github.com/odoo/owl.git -cd owl -npm install -npm run build:runtime -npm run build:compiler -npm run compile_templates -- /path/to/odoo-timer/src/templates -``` - -A helper script is also included in this repository: - -```bash -./scripts/compile_owl_templates.sh /path/to/owl #(i.e path to owl is the cloned Odoo's OWL repo => https://github.com/odoo/owl.git) -``` - -**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 - -The extension expects compiled templates to be made available through: - -```js -globalThis.__THERP_TIMER_TEMPLATES__ = { - ReadMore: /* compiled template */, - PopupApp: /* compiled template */, - OptionsApp: /* compiled template */, -}; -``` - -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/` - -Application entry modules now live under: - -- `dist/chrome/js/components/` -- `dist/firefox/js/components/` - -This keeps library-style files such as `browser-polyfill.js`, `owl.iife.js`, -and `common.js` separated from the popup and options app modules. - -## Summary - -Therp Timer is a practical browser-based Odoo timer that helps users quickly record work against Odoo project items. - -Use: - -- the **Chrome/Brave** build for Chromium-based browsers -- the **Firefox** build for Firefox-based browsers - -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 diff --git a/dist/chrome/css/options_main_page.css b/dist/chrome/css/options_main_page.css index 57b866d..d73ed79 100644 --- a/dist/chrome/css/options_main_page.css +++ b/dist/chrome/css/options_main_page.css @@ -381,3 +381,298 @@ hr { .small-note{font-size:12px;opacity:.8;} .timesheet-download-link{display:inline-block;margin-right:8px;color:inherit;} .no-remotes-set{padding-left:0;} +/* THERP LAYOUT PREFS — Preferences page */ +.layout-pref-card { + max-width: 760px; + padding: 16px 18px; + border: 1px solid #d9e4ec; + border-radius: 10px; + background: #fff; + margin-bottom: 16px; +} +.layout-pref-grid { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr); + gap: 14px 18px; +} +.layout-pref-field label { + display: block; + margin-bottom: 5px; + font-weight: 600; +} +.layout-pref-field input, +.layout-pref-field select { + width: 100%; + min-height: 38px; + box-sizing: border-box; +} +.layout-pref-help { + margin-top: 5px; + font-size: 12px; + opacity: .72; +} +.layout-pref-actions { + display: flex; + gap: 10px; + margin-top: 18px; +} +.layout-pref-actions button { min-width: 120px; } +@media (max-width: 760px) { + .layout-pref-grid { grid-template-columns: 1fr; } +} + +/* THERP ADVANCED LAYOUT PREFS V4 — Options page + * Preferences form is always styled. Saved browser-specific values apply only + * while .tt-layout-customized is present. + */ +html.tt-layout-customized body { + font-size: var(--tt-options-font-size); +} +html.tt-layout-customized #navigation .chooser { + font-size: var(--tt-options-sidebar-font-size); +} +html.tt-layout-customized .remotes-table-info table { + min-width: var(--tt-remotes-min-width); + font-size: var(--tt-remotes-font-size); +} +html.tt-layout-customized .remotes-table-info thead th { + white-space: var(--tt-remotes-header-wrap); + font-size: var(--tt-remotes-header-font-size); +} +html.tt-layout-customized .remotes-table-info th, +html.tt-layout-customized .remotes-table-info td { + padding-top: var(--tt-remotes-cell-pad-y); + padding-bottom: var(--tt-remotes-cell-pad-y); +} + +/* Saved remotes: keep one outer page horizontal scrollbar, not a nested one. */ +.remotes-table-info { + overflow: visible !important; +} +.remotes-table-info table { + width: max-content; + min-width: 760px; +} +.remotes-table-info .remote-row-actions { + white-space: nowrap; + min-width: 64px; +} + +/* Rounded, bolder, well-positioned left-hand Back button. */ +#navigation .footer-app { + position: absolute; + left: 0; + bottom: 18px; + width: 100%; + padding: 0 0 0 18px; + box-sizing: border-box; + display: flex; + justify-content: flex-start; + align-items: center; +} +#navigation .footer-app .nav-hand-button { + width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 2px solid #1fa7e6; + border-radius: 50%; + background: #ffffff; + color: #1fa7e6; + text-decoration: none; + box-sizing: border-box; + box-shadow: 0 2px 7px rgba(31, 167, 230, 0.16); +} +#navigation .footer-app .nav-hand-button:hover { + background: #eef8ff; + color: #1fa7e6; +} +#navigation .footer-app .nav-hand-button .fa { + margin: 0; + font-size: 24px; + -webkit-text-stroke: 0.45px currentColor; + transform: scale(1.08); +} + +/* Advanced Preferences form */ +.layout-pref-card { + max-width: 940px; + padding: 16px 18px; + border: 1px solid #d9e4ec; + border-radius: 10px; + background: #fff; + margin-bottom: 16px; +} +.layout-pref-section { + margin: 0 0 16px; + padding: 12px 14px; + border: 1px solid #e3e8ee; + border-radius: 8px; + background: #fbfcfe; +} +.layout-pref-section > summary { + cursor: pointer; + font-weight: 700; + font-size: 15px; +} +.layout-pref-grid { + display: grid; + grid-template-columns: repeat(3, minmax(160px, 1fr)); + gap: 12px 16px; + margin-top: 12px; +} +.layout-pref-field label { + display: block; + margin-bottom: 5px; + font-weight: 600; +} +.layout-pref-field input, +.layout-pref-field select { + width: 100%; + min-height: 36px; + box-sizing: border-box; +} +.layout-pref-field input[type="checkbox"] { + width: auto; + min-height: 0; +} +.layout-pref-help { + margin-top: 5px; + font-size: 12px; + opacity: .76; +} +.layout-pref-error { + margin: 10px 0; + padding: 10px 12px; + border: 1px solid #dc3545; + background: #fff4f4; + color: #a51d2a; + border-radius: 6px; +} +.layout-pref-actions { + position: sticky; + bottom: 0; + z-index: 5; + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 12px 0; + background: #fff; + border-top: 1px solid #e5e7eb; +} +.layout-pref-actions button { + min-width: 135px; +} +@media (max-width: 900px) { + .layout-pref-grid { + grid-template-columns: repeat(2, minmax(160px, 1fr)); + } +} +@media (max-width: 650px) { + .layout-pref-grid { + grid-template-columns: 1fr; + } +} + +/* THERP FINAL POLISH V5 — Preferences usability and overflow */ + +/* The fixed sidebar stays visually complete while the page uses only its outer + * scrollbars. Preferences content never slides underneath it. */ +#navigation { + height: 100vh !important; + min-height: 100vh; +} +#maincontent { + position: relative !important; + width: 100% !important; + min-width: 100% !important; + min-height: 100vh !important; + height: auto !important; + overflow: visible !important; + box-sizing: border-box; +} +.options-box { + width: calc(100% - 180px); + max-width: calc(100% - 180px); + min-width: 0; + box-sizing: border-box; +} +.layout-pref-card { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +/* Do not let a sticky action bar drift under the fixed navigation when the + * document is horizontally scrolled. Keep it in normal form flow. */ +.layout-pref-actions { + position: static !important; + width: 100%; + box-sizing: border-box; + padding: 12px 0 4px; + margin-top: 8px; + background: transparent; +} + +.layout-pref-suggested { + position: relative; + padding: 8px; + margin: -8px; + border: 1px solid #38bdf8; + border-radius: 7px; + background: #eff9ff; + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.10); + transition: background .18s ease, border-color .18s ease, box-shadow .18s ease; +} +.layout-pref-suggested input, +.layout-pref-suggested select { + border-color: #38bdf8 !important; + box-shadow: 0 0 0 1px rgba(56, 189, 248, .16); +} +.layout-pref-suggested::after { + content: "Suggested"; + position: absolute; + top: -9px; + right: 7px; + padding: 1px 6px; + border-radius: 999px; + background: #38bdf8; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; +} +.layout-suggestion-note { + display: flex; + gap: 10px; + align-items: flex-start; + margin: 12px 0 4px; + padding: 10px 12px; + border: 1px solid #8fdcff; + border-radius: 8px; + background: #f0faff; + color: #344054; + line-height: 1.45; +} +.layout-suggestion-note > .fa { + flex: 0 0 auto; + margin-top: 2px; + color: #1fa7e6; + font-size: 18px; +} + +/* Native color inputs are small by default; make the suggested/highlight state + * and normal editing target clear and consistent. */ +.layout-pref-field input[type="color"] { + min-height: 38px; + padding: 3px; + cursor: pointer; +} + +/* Logo help is readable but secondary. */ +.logo-file-help { + max-width: 760px; + margin: 7px 0 0; + line-height: 1.4; +} diff --git a/dist/chrome/css/popup.css b/dist/chrome/css/popup.css index cc7ca67..a63281c 100644 --- a/dist/chrome/css/popup.css +++ b/dist/chrome/css/popup.css @@ -467,3 +467,137 @@ body.unselectable { .pen-title span .fa, .pen-title span a { color: var(--brand-danger); } .pen-title span a { font-weight: 600; text-decoration: none; } + +/* THERP ADVANCED LAYOUT PREFS V4 + * Shared dynamic selectors. Inactive until a user saves preferences. + */ +html.tt-layout-customized, +body.tt-layout-customized { + width: var(--tt-main-width); + min-width: var(--tt-main-width); + height: var(--tt-main-height); +} +body.tt-layout-customized #app, +body.tt-layout-customized .app-root { + width: var(--tt-main-width); +} +body.tt-layout-customized #wrapper { + width: var(--tt-main-width); + padding: + var(--tt-wrapper-pad-top) + var(--tt-wrapper-pad-x) + var(--tt-wrapper-pad-bottom); + box-sizing: border-box; +} + +body.tt-layout-customized .toolbar-row, +body.tt-layout-customized .footer-btns { + gap: var(--tt-toolbar-gap); +} +body.tt-layout-customized #searchIssue, +body.tt-layout-customized #limitTo, +body.tt-layout-customized #stageFilter { + min-height: var(--tt-toolbar-control-height); + font-size: var(--tt-search-font-size); +} +body.tt-layout-customized .footer-btns .fa-2x { + font-size: var(--tt-toolbar-icon-size) !important; +} + +/* One vertical page scrollbar. The data table may scroll horizontally only. */ +body.tt-layout-customized .table-scroll { + overflow-x: auto !important; + overflow-y: visible !important; + max-width: 100%; + overscroll-behavior-x: contain; + border-color: var(--tt-table-border-color); + border-radius: var(--tt-table-radius); +} +body.tt-layout-customized #table-task-issues { + font-size: var(--tt-table-font-size); + table-layout: var(--tt-table-layout); + width: max(100%, var(--tt-table-min-width)); + min-width: var(--tt-table-min-width); +} +body.tt-layout-customized #table-task-issues th { + font-size: var(--tt-table-header-font-size); + white-space: var(--tt-table-header-wrap); + color: var(--tt-table-header-color); + background: var(--tt-table-header-bg); +} +body.tt-layout-customized #table-task-issues td { + white-space: var(--tt-table-cell-wrap); +} +body.tt-layout-customized #table-task-issues th, +body.tt-layout-customized #table-task-issues td { + padding-top: var(--tt-table-cell-pad-y); + padding-bottom: var(--tt-table-cell-pad-y); + border-color: var(--tt-table-border-color); +} +body.tt-layout-customized #table-task-issues tbody > tr > td { + background: var(--tt-table-row-bg); +} +body.tt-layout-customized #table-task-issues tbody > tr:nth-child(even) > td { + background: var(--tt-table-stripe-bg); +} +body.tt-layout-customized #table-task-issues tbody > tr:hover > td { + background: var(--tt-table-hover-bg); +} +body.tt-layout-customized #table-task-issues tbody > tr.active-row > td { + background: var(--tt-table-active-bg); +} +body.tt-layout-customized #table-task-issues th.action-col, +body.tt-layout-customized #table-task-issues td.action-col { + width: var(--tt-col-action) !important; + min-width: var(--tt-col-action) !important; +} +body.tt-layout-customized #table-task-issues .priority-col, +body.tt-layout-customized #table-task-issues .priority-cell { + width: var(--tt-col-priority) !important; +} +body.tt-layout-customized #table-task-issues .stage-col, +body.tt-layout-customized #table-task-issues .stage-cell { + width: var(--tt-col-stage) !important; +} +body.tt-layout-customized #table-task-issues .item-col, +body.tt-layout-customized #table-task-issues .issue-desc-cell { + width: var(--tt-col-item) !important; +} +body.tt-layout-customized #table-task-issues th:nth-child(5), +body.tt-layout-customized #table-task-issues th:nth-child(6), +body.tt-layout-customized #table-task-issues td:nth-child(5), +body.tt-layout-customized #table-task-issues td:nth-child(6) { + width: var(--tt-col-hours) !important; +} +body.tt-layout-customized #table-task-issues .project-col, +body.tt-layout-customized #table-task-issues .project-cell { + width: var(--tt-col-project) !important; +} + +body.tt-layout-customized .remote-info-block { + font-size: var(--tt-info-font-size); + color: var(--tt-info-label-color); + background: var(--tt-info-bg); + border-color: var(--tt-info-border-color); + border-radius: var(--tt-info-radius); + padding: var(--tt-info-pad-y) var(--tt-info-pad-x); + line-height: var(--tt-info-line-height); +} +body.tt-layout-customized .remote-info-block b { + color: var(--tt-info-label-color); +} +body.tt-layout-customized .remote-info-block .remote-value { + font-size: var(--tt-info-value-font-size); + color: var(--tt-info-value-color); +} + +/* Make both navigation hand icons look like deliberate buttons. */ +.remote-info-block a[aria-label="Open Options"], +.remote-info-block a[title="Open Options"] { + border-width: 2px !important; + box-shadow: 0 2px 7px rgba(31, 167, 230, 0.16); +} +.remote-info-block .fa-hand-o-right { + -webkit-text-stroke: 0.45px currentColor; + transform: scale(1.08); +} diff --git a/dist/chrome/js/components/options-app.js b/dist/chrome/js/components/options-app.js index 4a9cf31..44e7b9d 100644 --- a/dist/chrome/js/components/options-app.js +++ b/dist/chrome/js/components/options-app.js @@ -3,6 +3,7 @@ import { writeRemotes, validURL, normalizeHost, + remoteIdentity, storage, clearOdooSessionCookies, notify, @@ -15,11 +16,23 @@ const { Component, mount, useState, onWillStart } = owl; const PAGE_ABOUT = 'about'; const PAGE_OPTIONS = 'options'; const DEFAULT_DATA_SOURCE = 'project.issue'; +const DEFAULT_LOGO_SRC = '/img/logo.png'; +const MAX_REMOTE_LOGO_BYTES = 512 * 1024; +const ALLOWED_REMOTE_LOGO_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); const STORAGE_KEYS = { remoteHostInfo: 'remote_host_info', }; +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function getTemplateRegistry() { return globalThis.__THERP_TIMER_TEMPLATES__ || {}; @@ -51,14 +64,15 @@ function createOptionsAppTemplate(app, bdom, helpers) { const readMoreState = app.createComponent('ReadMore', true, false, false, ['text', 'limit']); const rootBlock = createBlock( - `


Description


This is a standalone Owl rewrite of the original cross-platform timer extension for posting work hours to Odoo timesheets.

Features


  • Support for both tasks and issues
  • Start and stop the timer for the selected item
  • Create Odoo timesheet lines against the linked analytic account
  • Show assigned items or everyone’s items
  • Add, remove, or clear remote hosts
  • Switch between remote sessions
  • Download current month or current item timesheets as CSV

Add Remote


Controls
` + `


Description


This is a standalone Owl rewrite of the original cross-platform timer extension for posting work hours to Odoo timesheets.

Features


  • Support for both tasks and issues
  • Start and stop the timer for the selected item
  • Create Odoo timesheet lines against the linked analytic account
  • Show assigned items or everyone’s items
  • Add, remove, or clear remote hosts
  • Switch between remote sessions
  • Download current month or current item timesheets as CSV

General Settings


Store timesheet locally each time you stop the timer on an item.


Add Remote


Controls
` ); const errorBlock = createBlock(`
`); const remotesTableBlock = createBlock( `
List of Available Remotes
RemoteHostDatabaseSourceState
` ); const remoteRowBlock = createBlock( - `` + ` + ` ); return function template(ctx, node, key = '') { @@ -95,6 +109,8 @@ function createOptionsAppTemplate(app, bdom, helpers) { const reloadRemotesHandler = [ctx.loadRemotes, ctx]; const toggleListHandler = [() => { ctx.state.showList = !ctx.state.showList; }, ctx]; const removeAllRemotesHandler = [ctx.removeAllRemotes, ctx]; + const autoDownloadChecked = ctx.state.autoDownloadIssueTimesheet; + const autoDownloadHandler = [(ev) => { ctx.toggleAutoDownload(ev); }]; if (ctx.state.error) { errorNode = errorBlock([ctx.state.error]); @@ -107,7 +123,7 @@ function createOptionsAppTemplate(app, bdom, helpers) { for (let i = 0; i < remoteCount; i++) { ctx.remote = remoteItems[i]; - const remoteKey = ctx.remote.url + ctx.remote.database; + const remoteKey = ctx.remoteKey(ctx.remote); if (seenRemoteKeys.has(String(remoteKey))) { throw new OwlError(`Got duplicate key in t-foreach: ${remoteKey}`); @@ -120,10 +136,11 @@ function createOptionsAppTemplate(app, bdom, helpers) { const sourceNode = readMoreSource({ text: ctx.remote.datasrc || DEFAULT_DATA_SOURCE, limit: 18 }, key + `__4__${remoteKey}`, node, this, null); const stateNode = readMoreState({ text: ctx.remote.state || 'Inactive', limit: 18 }, key + `__5__${remoteKey}`, node, this, null); const remoteItem = ctx.remote; + const editHandler = [() => ctx.editRemote(remoteItem), ctx]; const deleteHandler = [() => ctx.removeRemote(remoteItem), ctx]; remoteChildren[i] = withKey( - remoteRowBlock([deleteHandler], [nameNode, hostNode, databaseNode, sourceNode, stateNode]), + remoteRowBlock([editHandler, deleteHandler], [nameNode, hostNode, databaseNode, sourceNode, stateNode]), remoteKey ); } @@ -155,6 +172,8 @@ function createOptionsAppTemplate(app, bdom, helpers) { reloadRemotesHandler, toggleListHandler, removeAllRemotesHandler, + autoDownloadChecked, + autoDownloadHandler, ], [errorNode, remoteListNode] ); @@ -169,21 +188,74 @@ class OptionsApp extends Component { static template = 'OptionsApp'; setup() { + this.removeRemote = this.removeRemote.bind(this); + this.editRemote = this.editRemote.bind(this); + this.state = useState({ activePage: PAGE_OPTIONS, + layoutBrowser: 'Browser', + layoutCanCustomize: true, + layoutError: '', + layoutSuggestionActive: false, + layoutSuggestedFields: {}, + layoutStored: false, + layoutPrefs: { + main: { width: 960, height: 820, wrapperPaddingX: 16, wrapperPaddingTop: 18, wrapperPaddingBottom: 24 }, + toolbar: { searchFontSize: 13, controlHeight: 48, iconSize: 30, gap: 10 }, + table: { + fontSize: 15, headerFontSize: 15, cellPaddingY: 7, headerWrap: true, cellWrap: true, + layoutMode: 'fixed', minWidth: 0, stripeEnabled: false, + headerBg: '#f8fafc', headerColor: '#3f4854', rowBg: '#ffffff', + stripeBg: '#f8fafc', hoverBg: '#f3fbff', activeBg: '#eaf8ff', + borderColor: '#dde3ee', borderRadius: 12, + actionWidth: 66, priorityWidth: 58, stageWidth: 118, + itemWidth: 320, hoursWidth: 76, projectWidth: 160, + }, + info: { + fontSize: 13, valueFontSize: 14, labelColor: '#3f4854', valueColor: '#33baf6', + background: '#ffffff', borderColor: '#dde3ee', + paddingY: 14, paddingX: 16, borderRadius: 12, lineHeight: 17, + }, + options: { + pageFontSize: 14, sidebarFontSize: 14, remotesTableFontSize: 14, + remotesHeaderFontSize: 14, remotesHeaderWrap: true, + remotesCellPaddingY: 7, remotesMinWidth: 760, + }, + }, + layoutDefaults: { popupWidth: 800, popupHeight: 600, density: 'compact', fontSize: 13, spacing: 'compact' }, + layoutLimits: { popupWidth: [720, 800], popupHeight: [520, 600], fontSize: [12, 15] }, remotes: [], showList: true, error: '', + autoDownloadIssueTimesheet: false, + logoPickerNeedsPersistentTab: false, form: { remote_host: '', remote_name: '', remote_database: '', remote_datasrc: DEFAULT_DATA_SOURCE, + remote_logo: '', }, }); onWillStart(async () => { await this.loadRemotes(); + await this.loadLayoutPreferences(); + await this.detectPersistentLogoPickerContext(); + // THERP UX: Firefox popup logo picker bridge. + try { + const firefox = globalThis.browser?.runtime?.getBrowserInfo + && globalThis.browser?.tabs?.getCurrent; + if (firefox) { + const currentTab = await globalThis.browser.tabs.getCurrent(); + this.state.logoPickerNeedsPersistentTab = !currentTab; + } + } catch (err) { + this.state.logoPickerNeedsPersistentTab = false; + console.debug('[OptionsApp] Could not inspect Firefox Options context.', err); + } + const saved = await storage.get('auto_download_issue_timesheet', false); + this.state.autoDownloadIssueTimesheet = !!saved; }); } @@ -192,10 +264,171 @@ class OptionsApp extends Component { * * @returns {Promise} */ + async loadLayoutPreferences() { + const api = globalThis.TherpLayoutPrefs; + if (!api) return; + const loaded = await api.getState(); + this.state.layoutCanCustomize = true; + this.state.layoutBrowser = api.browserLabel(); + this.state.layoutStored = loaded.stored; + this.state.layoutPrefs = loaded.value; + this.state.layoutError = ''; + if (loaded.stored) api.apply(loaded.value); + } + + async saveLayoutPreferences() { + const api = globalThis.TherpLayoutPrefs; + if (!api) return; + const result = api.validate(this.state.layoutPrefs); + if (!result.ok) { + this.state.layoutError = result.errors.join(' '); + return; + } + const confirmed = await confirmDialog(`Save these validated ${api.browserLabel()} layout preferences?`); + if (!confirmed) return; + const saved = await api.save(result.value); + this.state.layoutPrefs = saved; + this.state.layoutStored = true; + this.state.layoutSuggestionActive = false; + this.state.layoutSuggestedFields = {}; + this.state.layoutError = ''; + api.apply(saved); + await notify('Layout preferences saved. Reopen the timer popup for all main-page changes.'); + } + + async resetLayoutPreferences() { + const api = globalThis.TherpLayoutPrefs; + if (!api) return; + const confirmed = await confirmDialog(`Reset ${api.browserLabel()} layout preferences to the source CSS defaults?`); + if (!confirmed) return; + const defaults = await api.reset(); + this.state.layoutPrefs = defaults; + this.state.layoutStored = false; + this.state.layoutSuggestionActive = false; + this.state.layoutSuggestedFields = {}; + this.state.layoutError = ''; + await notify('Layout preferences reset to source CSS defaults.'); + } + + + + async getCurrentExtensionTab() { + try { + if (globalThis.browser?.tabs?.getCurrent) return await globalThis.browser.tabs.getCurrent(); + if (globalThis.chrome?.tabs?.getCurrent) { + return await new Promise((resolve) => globalThis.chrome.tabs.getCurrent(resolve)); + } + } catch (err) { + console.debug('[OptionsApp] Could not inspect extension page context.', err); + } + return null; + } + + async detectPersistentLogoPickerContext() { + const currentTab = await this.getCurrentExtensionTab(); + this.state.logoPickerNeedsPersistentTab = !currentTab; + } + + onRemoteLogoPickerClick(ev) { + if (!this.state.logoPickerNeedsPersistentTab) return; + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + const runtime = globalThis.browser?.runtime || globalThis.chrome?.runtime; + if (!runtime?.openOptionsPage) return; + Promise.resolve(runtime.openOptionsPage()).catch((err) => { + console.debug('[OptionsApp] Could not open persistent Options tab.', err); + }); + } + + markLayoutFieldEdited(fieldKey) { + const current = this.state.layoutSuggestedFields || {}; + if (!current[fieldKey]) return; + const next = { ...current }; + delete next[fieldKey]; + this.state.layoutSuggestedFields = next; + } + + async loadSuggestedLayoutSettings() { + const api = globalThis.TherpLayoutPrefs; + if (!api) return; + this.state.layoutPrefs = api.suggestedPreset(); + this.state.layoutSuggestedFields = { + 'main.width': true, + 'table.headerWrap': true, + 'table.layoutMode': true, + 'table.minWidth': true, + 'table.stripeEnabled': true, + 'table.headerBg': true, + 'table.stripeBg': true, + 'table.actionWidth': true, + }; + this.state.layoutSuggestionActive = true; + this.state.layoutError = ''; + } + async loadRemotes() { this.state.remotes = await readRemotes(); } + + remoteKey(remote) { return remoteIdentity(remote); } + remoteLogoSrc(remote) { return remote?.logoDataUrl || DEFAULT_LOGO_SRC; } + get formLogoSrc() { return this.state.form.remote_logo || DEFAULT_LOGO_SRC; } + + async readLogoFile(file) { + if (!file) return ''; + if (!ALLOWED_REMOTE_LOGO_TYPES.has(file.type)) { + throw new Error('Company logo must be PNG, JPEG, WebP, or GIF.'); + } + if (file.size > MAX_REMOTE_LOGO_BYTES) { + throw new Error('Company logo is too large. Maximum size is 512 KB.'); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Could not read the selected company logo.')); + reader.readAsDataURL(file); + }); + } + + /** THERP UX: Firefox popup logo picker bridge: only redirect from Firefox's temporary toolbar popup. */ + onRemoteLogoPickerClick(ev) { + if (!this.state.logoPickerNeedsPersistentTab) { + return; + } + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + const runtime = globalThis.browser?.runtime; + if (runtime?.openOptionsPage) { + Promise.resolve(runtime.openOptionsPage()).catch((err) => { + console.debug('[OptionsApp] Could not open persistent Firefox Options page.', err); + }); + } + } + + async onRemoteLogoChange(ev) { + const input = ev?.target; + const file = input?.files?.[0]; + if (!file) return; + try { + this.state.form.remote_logo = await this.readLogoFile(file); + } catch (err) { + if (input) input.value = ''; + await notify(err.message || 'Could not use the selected company logo.'); + } + } + + clearRemoteLogo() { + this.state.form.remote_logo = ''; + const input = document.getElementById('remote-logo'); + if (input) input.value = ''; + } + + duplicateRemoteMessage(remote) { + return `A remote for ${remote.url} / ${remote.database} / ${remote.datasrc || DEFAULT_DATA_SOURCE} already exists. ` + + 'Change the host, database, or data source.'; + } + /** * Reset the add-remote form back to defaults. */ @@ -204,6 +437,16 @@ class OptionsApp extends Component { this.state.form.remote_name = ''; this.state.form.remote_database = ''; this.state.form.remote_datasrc = DEFAULT_DATA_SOURCE; + this.state.form.remote_logo = ''; + } + + /** + * Toggle the auto-download timesheet preference and persist it. + * @param {Event} ev + */ + async toggleAutoDownload(ev) { + this.state.autoDownloadIssueTimesheet = ev.target.checked; + await storage.set('auto_download_issue_timesheet', !!this.state.autoDownloadIssueTimesheet); } /** @@ -246,8 +489,9 @@ class OptionsApp extends Component { const { host, name, database, datasrc } = validated; const remotes = await readRemotes(); - if (remotes.some((remote) => remote.url === host && remote.database === database)) { - this.state.error = `${host} and ${database} already exist; duplicates are not allowed`; + const candidate = {url: host, database, datasrc}; + if (remotes.some((remote) => remoteIdentity(remote) === remoteIdentity(candidate))) { + await notify(this.duplicateRemoteMessage(candidate)); return; } @@ -256,6 +500,7 @@ class OptionsApp extends Component { name, database, datasrc, + logoDataUrl: this.state.form.remote_logo || '', state: 'Inactive', }); @@ -277,21 +522,235 @@ class OptionsApp extends Component { return; } - await clearOdooSessionCookies(remote.url); + const targetKey = remoteIdentity(remote); const remotes = (await readRemotes()).filter( - (currentRemote) => !(currentRemote.url === remote.url && currentRemote.database === remote.database) + (currentRemote) => remoteIdentity(currentRemote) !== targetKey ); await writeRemotes(remotes); - await storage.remove(remote.database); + if (!remotes.some((item) => normalizeHost(item.url || '') === normalizeHost(remote.url || ''))) { + await clearOdooSessionCookies(remote.url); + } + if (!remotes.some((item) => String(item.database || '').trim() === String(remote.database || '').trim())) { + await storage.remove(remote.database); + } await this.loadRemotes(); await notify(`[${remote.url}] removed successfully!`); } + /** - * Expose the About page constant to Owl XML expressions. + * Edit an existing remote configuration. * - * @returns {string} + * @param {object} remote Remote row to edit. + * @returns {Promise} */ + async editRemote(remote) { + this.state.error = ''; + + const currentHost = remote.url || ''; + const currentName = remote.name || ''; + const currentDatabase = remote.database || ''; + const currentDatasource = remote.datasrc || DEFAULT_DATA_SOURCE; + + const customAlert = globalThis.alert && typeof globalThis.alert.show === 'function' + ? globalThis.alert + : null; + + let host = currentHost; + let name = currentName; + let database = currentDatabase; + let datasrc = currentDatasource; + let logoDataUrl = remote.logoDataUrl || ''; + + if (customAlert) { + const inputSuffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const hostId = `edit-remote-host-${inputSuffix}`; + const nameId = `edit-remote-name-${inputSuffix}`; + const databaseId = `edit-remote-database-${inputSuffix}`; + const datasourceId = `edit-remote-datasource-${inputSuffix}`; + // THERP UX FIX: build Edit Remote dialog with DOM APIs. + // alert.js intentionally does not parse dynamic HTML strings. + const dialog = document.createElement('div'); + dialog.style.cssText = 'min-width:340px;max-width:520px;text-align:left;'; + + const dialogTitle = document.createElement('div'); + dialogTitle.style.cssText = + 'margin-bottom:14px;font-weight:700;font-size:18px;color:#42475a;text-align:center;'; + dialogTitle.textContent = 'Edit Remote'; + + const form = document.createElement('div'); + form.style.cssText = 'display:flex;flex-direction:column;gap:12px;'; + + const inputStyle = + 'width:100%;box-sizing:border-box;padding:10px;border:1px solid #cbd5e1;' + + 'border-radius:4px;font:14px Arial,Helvetica,sans-serif;color:#111827;background:#fff;'; + + const addField = (captionText, control) => { + const label = document.createElement('label'); + label.style.cssText = + 'display:flex;flex-direction:column;gap:6px;font-weight:600;color:#334155;'; + const caption = document.createElement('span'); + caption.textContent = captionText; + label.append(caption, control); + form.appendChild(label); + }; + + const hostInput = document.createElement('input'); + hostInput.id = hostId; + hostInput.type = 'text'; + hostInput.value = currentHost; + hostInput.style.cssText = inputStyle; + addField('Odoo Host', hostInput); + + const nameInput = document.createElement('input'); + nameInput.id = nameId; + nameInput.type = 'text'; + nameInput.value = currentName; + nameInput.style.cssText = inputStyle; + addField('Display Name', nameInput); + + const databaseInput = document.createElement('input'); + databaseInput.id = databaseId; + databaseInput.type = 'text'; + databaseInput.value = currentDatabase; + databaseInput.style.cssText = inputStyle; + addField('Odoo Database', databaseInput); + + const datasourceSelect = document.createElement('select'); + datasourceSelect.id = datasourceId; + datasourceSelect.style.cssText = inputStyle; + for (const [value, caption] of [ + ['project.issue', 'From Issues'], + ['project.task', 'From Tasks'], + ['helpdesk.ticket', 'From Helpdesk Tickets'], + ]) { + const option = document.createElement('option'); + option.value = value; + option.textContent = caption; + option.selected = currentDatasource === value; + datasourceSelect.appendChild(option); + } + addField('Data Source', datasourceSelect); + + const logoRow = document.createElement('div'); + logoRow.style.cssText = 'display:flex;gap:12px;align-items:center;'; + + const logoPreview = document.createElement('img'); + logoPreview.src = this.remoteLogoSrc(remote); + logoPreview.alt = 'Remote logo'; + logoPreview.style.cssText = + 'width:64px;height:48px;object-fit:contain;border:1px solid #e2e8f0;' + + 'border-radius:5px;background:#fff;padding:3px;'; + + const logoBox = document.createElement('div'); + logoBox.style.cssText = 'flex:1;'; + + const logoHeading = document.createElement('div'); + logoHeading.style.cssText = 'font-weight:600;color:#334155;margin-bottom:6px;'; + logoHeading.textContent = 'Company Logo (optional)'; + + const logoInput = document.createElement('input'); + logoInput.id = `edit-remote-logo-${inputSuffix}`; + logoInput.type = 'file'; + logoInput.addEventListener('click', this.onRemoteLogoPickerClick.bind(this)); + logoInput.accept = 'image/png,image/jpeg,image/webp,image/gif'; + logoInput.style.cssText = 'width:100%;font-size:13px;'; + + const removeLabel = document.createElement('label'); + removeLabel.style.cssText = + 'display:block;margin-top:7px;font-size:12px;color:#64748b;font-weight:400;'; + const removeInput = document.createElement('input'); + removeInput.id = `edit-remote-remove-logo-${inputSuffix}`; + removeInput.type = 'checkbox'; + removeLabel.append(removeInput, document.createTextNode(' Use the default Therp logo')); + + const logoHelp = document.createElement('div'); + logoHelp.style.cssText = 'margin-top:4px;font-size:11px;color:#94a3b8;'; + logoHelp.textContent = 'PNG, JPEG, WebP or GIF; maximum 512 KB.'; + + logoBox.append(logoHeading, logoInput, removeLabel, logoHelp); + logoRow.append(logoPreview, logoBox); + form.appendChild(logoRow); + + dialog.append(dialogTitle, form); + const result = await customAlert.show(dialog, ['Cancel', 'Save']); + if (result !== 'Save') { + return; + } + + const hostEl = document.getElementById(hostId); + const nameEl = document.getElementById(nameId); + const databaseEl = document.getElementById(databaseId); + const datasourceEl = document.getElementById(datasourceId); + const logoEl = document.getElementById(`edit-remote-logo-${inputSuffix}`); + const removeLogoEl = document.getElementById(`edit-remote-remove-logo-${inputSuffix}`); + + host = normalizeHost(hostEl ? hostEl.value : currentHost); + name = ((nameEl ? nameEl.value : currentName) || '').trim(); + database = ((databaseEl ? databaseEl.value : currentDatabase) || '').trim(); + datasrc = datasourceEl ? datasourceEl.value : currentDatasource; + if (removeLogoEl?.checked) { + logoDataUrl = ''; + } else if (logoEl?.files?.[0]) { + try { logoDataUrl = await this.readLogoFile(logoEl.files[0]); } + catch (err) { await notify(err.message || 'Could not use the selected company logo.'); return; } + } + } else { + return; + } + + if (!host || !name || !database) { + this.state.error = 'Fields cannot be empty'; + return; + } + + if (!validURL(host)) { + this.state.error = 'Invalid URL syntax'; + return; + } + + const remotes = await readRemotes(); + const originalKey = remoteIdentity(remote); + const candidate = {url: host, database, datasrc}; + const duplicate = remotes.some( + (item) => remoteIdentity(item) !== originalKey && remoteIdentity(item) === remoteIdentity(candidate) + ); + if (duplicate) { + await notify(this.duplicateRemoteMessage(candidate)); + return; + } + + const index = remotes.findIndex((item) => remoteIdentity(item) === originalKey); + if (index === -1) { + await notify('Remote not found. Refresh the list and try again.'); + return; + } + + const oldHost = normalizeHost(remotes[index].url || ''); + const oldDb = String(remotes[index].database || '').trim(); + + remotes[index] = { + ...remotes[index], + url: host, + name, + database, + datasrc, + logoDataUrl, + }; + + await writeRemotes(remotes); + + if (oldHost !== host && !remotes.some((item) => normalizeHost(item.url || '') === oldHost)) { + await clearOdooSessionCookies(oldHost); + } + if (oldDb !== database && !remotes.some((item) => String(item.database || '').trim() === oldDb)) { + await storage.remove(oldDb); + } + + await this.loadRemotes(); + await notify(`Remote [${name}] updated successfully.`); + } + get PAGE_ABOUT() { return PAGE_ABOUT; } @@ -406,9 +865,11 @@ class OptionsApp extends Component { } const remotes = await readRemotes(); - for (const remote of remotes) { - await clearOdooSessionCookies(remote.url); - await storage.remove(remote.database); + for (const host of [...new Set(remotes.map((remote) => normalizeHost(remote.url || '')).filter(Boolean))]) { + await clearOdooSessionCookies(host); + } + for (const database of [...new Set(remotes.map((remote) => String(remote.database || '').trim()).filter(Boolean))]) { + await storage.remove(database); } await writeRemotes([]); @@ -429,4 +890,4 @@ const templates = { mount(OptionsApp, document.getElementById('app'), { dev: true, templates, -}); \ No newline at end of file +}); diff --git a/dist/chrome/js/components/popup-app.js b/dist/chrome/js/components/popup-app.js index 64eb316..0249641 100644 --- a/dist/chrome/js/components/popup-app.js +++ b/dist/chrome/js/components/popup-app.js @@ -12,11 +12,26 @@ import { priorityStars, matchesIssue, extractMessageSummary, + remoteIdentity, notify, confirmDialog, promptDialog, } from '../lib/common.js'; import { ReadMore, createReadMoreTemplate } from './readmore.js'; +import { + DATA_SOURCE_ISSUE, + DATA_SOURCE_TASK, + DATA_SOURCE_HELPDESK, + resourceLabels, + inspectHelpdeskCapabilities, + helpdeskUnavailableMessage, + helpdeskTimesheetUnavailableMessage, + isAssignedToUser, + isHelpdeskTimesheetEnabled, + resourceRelation, + resourceHours, + timesheetBinding, +} from '../lib/resource-adapters.js'; const {Component, mount, useState, onMounted, onWillUnmount} = owl; @@ -24,14 +39,12 @@ const VIEW_LOADING = 'loading'; const VIEW_LOGIN = 'login'; const VIEW_MAIN = 'main'; -const DATA_SOURCE_ISSUE = 'project.issue'; -const DATA_SOURCE_TASK = 'project.task'; - const STORAGE_KEYS = { useExistingSession: 'useExistingSession', autoDownloadIssueTimesheet: 'auto_download_issue_timesheet', timerStartIso: 'start_date_time', activeTimerId: 'active_timer_id', + activeTimerContext: 'active_timer_context', currentHost: 'current_host', currentDatabase: 'current_host_db', currentDataSource: 'current_host_datasrc', @@ -49,7 +62,7 @@ const TIMEOUTS = { const DEFAULTS = { selectedRemoteIndex: '0', searchLimit: '10', - busyMessage: 'Loading current session and projects…', + busyMessage: 'Loading current session…', dataSource: DATA_SOURCE_ISSUE, }; @@ -79,7 +92,7 @@ function createPopupAppTemplate(app, bdom, helpers) { const readMoreProject = app.createComponent('ReadMore', true, false, false, ['text', 'limit']); const rootBlock = createBlock( - `
Loading current session and projects…
Please wait — or grab a cup of coffee ☕
PriorityStage
[]
Project
` + `
Loading current session and projects…
Please wait — or grab a cup of coffee ☕
PriorityStage
[]
Project
` ); const bootErrorBlock = createBlock(`

`); const noRemotesBlock = createBlock( @@ -106,8 +119,8 @@ function createPopupAppTemplate(app, bdom, helpers) { `
Host:
` ); const activeTimerDurationBlock = createBlock(``); - const hoursSpentHeaderBlock = createBlock(`Hours Spent`); - const remainingHoursHeaderBlock = createBlock(`Remaining Hours`); + const hoursSpentHeaderBlock = createBlock(`Hours Spent`); + const remainingHoursHeaderBlock = createBlock(`Hours Left`); const issueRowBlock = createBlock( `` ); @@ -119,8 +132,8 @@ function createPopupAppTemplate(app, bdom, helpers) { ); const priorityStarBlock = createBlock(``); const priorityStarOutlineBlock = createBlock(``); - const effectiveHoursCellBlock = createBlock(``); - const remainingHoursCellBlock = createBlock(``); + const effectiveHoursCellBlock = createBlock(``); + const remainingHoursCellBlock = createBlock(``); const emptyIssuesRowBlock = createBlock( `No matching items are currently available` ); @@ -200,14 +213,14 @@ function createPopupAppTemplate(app, bdom, helpers) { for (let i = 0; i < remoteCount; i++) { ctx.remote = remoteItems[i]; - const remoteKey = ctx.remote.database + ctx.remote.url; + const remoteKey = ctx.remoteKey(ctx.remote); if (seenRemoteKeys.has(String(remoteKey))) { throw new OwlError(`Got duplicate key in t-foreach: ${remoteKey}`); } seenRemoteKeys.add(String(remoteKey)); const optionValue = String(ctx.remote.__index); const optionSelected = selectedRemoteIndex === String(ctx.remote.__index); - const optionText = ctx.remote.name; + const optionText = ctx.remoteOptionLabel(ctx.remote); remoteChildren[i] = withKey(remoteOptionBlock([optionValue, optionSelected, optionText]), remoteKey); } @@ -250,8 +263,6 @@ function createPopupAppTemplate(app, bdom, helpers) { const limitHandler = [(ev) => { ctx.updateLimitPreference(ev.target.value); }]; - const autoDownloadChecked = ctx.state.autoDownloadIssueTimesheet; - const autoDownloadHandler = [ctx.toggleAutoDownload, ctx]; const downloadTimesheetHandler = [ctx.downloadCurrentMonthTimesheets, ctx]; const switchRemotesHandler = [ctx.switchBetweenRemotes, ctx]; const refreshHandler = [ctx.refreshAll, ctx]; @@ -400,8 +411,6 @@ function createPopupAppTemplate(app, bdom, helpers) { searchQueryHandler, limitValue, limitHandler, - autoDownloadChecked, - autoDownloadHandler, downloadTimesheetHandler, switchRemotesHandler, refreshHandler, @@ -456,6 +465,7 @@ class PopupApp extends Component { projects: [], issues: [], searchQuery: '', + stageFilter: '', limitTo: DEFAULTS.searchLimit, allIssues: false, autoDownloadIssueTimesheet: false, @@ -465,9 +475,17 @@ class PopupApp extends Component { currentHost: '', currentDatabase: '', dataSource: DEFAULTS.dataSource, + currentCompanyId: null, + allowedCompanyIds: [], + availableCompanyIds: [], + currentUserId: null, + companyNames: {}, serverVersion: '', odooOWLVersion: owl.__info__.version, supportedFields: {}, + sourceCapabilities: {}, + sourceError: '', + activeTimerContext: null, busyMessage: DEFAULTS.busyMessage, loadingTable: false, }); @@ -485,6 +503,8 @@ class PopupApp extends Component { this.togglePassword = this.togglePassword.bind(this); this.updateLimitPreference = this.updateLimitPreference.bind(this); this.updateShowAllPreference = this.updateShowAllPreference.bind(this); + this.updateStageFilter = this.updateStageFilter.bind(this); + this.showHelpdeskTimesheetInfo = this.showHelpdeskTimesheetInfo.bind(this); onMounted(() => { const bootLoader = document.getElementById('boot-loader'); @@ -514,9 +534,76 @@ class PopupApp extends Component { return this.state.remotes[idx] || null; } + + remoteKey(remote) { return remoteIdentity(remote); } + remoteOptionLabel(remote) { + const source = remote?.datasrc || DATA_SOURCE_ISSUE; + const sourceLabel = source === DATA_SOURCE_HELPDESK + ? 'Helpdesk Tickets' + : source === DATA_SOURCE_TASK ? 'Tasks' : 'Issues'; + return `${remote?.name || remote?.database || 'Remote'} — ${sourceLabel}`; + } + /** + * Logo for the remote currently selected on the login screen. + * Reactive because currentRemote depends on state.selectedRemoteIndex. + */ + get currentRemoteLogoSrc() { + const remote = this.currentRemote; + const candidate = + remote?.logoDataUrl || + remote?.logo_data_url || + remote?.companyLogo || + remote?.logo || + ''; + + if (typeof candidate !== 'string' || !candidate.trim()) { + return '/img/logo.png'; + } + + const src = candidate.trim(); + if ( + src.startsWith('data:image/') || + src.startsWith('blob:') || + src.startsWith('chrome-extension://') || + src.startsWith('moz-extension://') || + src.startsWith('/') + ) { + return src; + } + + return '/img/logo.png'; + } + get currentDataSourceLabel() { + return this.state.dataSource === DATA_SOURCE_HELPDESK + ? 'Helpdesk Tickets' + : this.state.dataSource === DATA_SOURCE_TASK ? 'Tasks' : 'Issues'; + } + get currentCompanyLabel() { + const id = Number(this.state.currentCompanyId || 0); + return id ? (this.state.companyNames?.[id] || `Company #${id}`) : '—'; + } + get allowedCompanyLabels() { + const ids = (this.state.allowedCompanyIds?.length + ? this.state.allowedCompanyIds + : this.state.availableCompanyIds) || []; + const labels = ids.map((id) => this.state.companyNames?.[Number(id)] || `Company #${id}`); + return labels.length ? labels.join(', ') : '—'; + } + get allowedCompanySummary() { + const text = this.allowedCompanyLabels; + return text.length > 46 ? `${text.slice(0, 43)}…` : text; + } + /** * Formatted active timer duration. */ + onLoginLogoError(ev) { + const image = ev?.target; + if (!image) return; + if (image.getAttribute('src') !== '/img/logo.png') { + image.setAttribute('src', '/img/logo.png'); + } + } get formattedTimer() { if (!this.state.timerStartIso) { return '00:00:00'; @@ -524,12 +611,261 @@ class PopupApp extends Component { return formatDuration(this.state.timerNow - new Date(this.state.timerStartIso).getTime()); } - get itemLabelSingular() { - return this.state.dataSource === DATA_SOURCE_TASK ? 'task' : 'issue'; + get itemLabelSingular() { return resourceLabels(this.state.dataSource).singular; } + get itemLabelPlural() { return resourceLabels(this.state.dataSource).plural; } + get isHelpdeskSource() { return this.state.dataSource === DATA_SOURCE_HELPDESK; } + get showHelpdeskAssignee() { + return this.isHelpdeskSource && this.state.allIssues && + Boolean(this.state.sourceCapabilities.assignmentField); + } + get showHelpdeskTeam() { return this.isHelpdeskSource && Boolean(this.state.sourceCapabilities.teamField); } + get showHelpdeskDescription() { return this.isHelpdeskSource && Boolean(this.state.sourceCapabilities.descriptionField); } + get showHelpdeskTherpLink() { return this.isHelpdeskSource && Boolean(this.state.sourceCapabilities.therpLinkField); } + get showHelpdeskProject() { return this.isHelpdeskSource && Boolean(this.state.sourceCapabilities.projectField); } + get showHelpdeskHours() { return this.isHelpdeskSource && Boolean(this.state.sourceCapabilities.hoursField); } + + get stageOptions() { + // THERP UX FIX: stage options are always iterable during reactive bootstrap. + const stages = new Map(); + const records = Array.isArray(this.state.issues) ? this.state.issues : []; + for (const issue of records) { + const value = issue?.stage_id; + if (!Array.isArray(value) || !value[0]) continue; + const id = Number(value[0]); + if (!id) continue; + const candidate = { + id, + name: this.normalizeText(value[1]) || `#${id}`, + sequence: Number(issue.stage_sequence ?? 9999), + }; + const previous = stages.get(id); + if (!previous || candidate.sequence < previous.sequence) { + stages.set(id, candidate); + } + } + return [...stages.values()].sort((a, b) => + a.sequence - b.sequence || a.name.localeCompare(b.name) + ); + } + + get relationHeaderLabel() { + return this.isHelpdeskSource && !this.state.sourceCapabilities.projectField ? 'Team' : 'Project'; + } + + get tableColumnCount() { + if (this.state.dataSource === DATA_SOURCE_TASK) return 7; + if (!this.isHelpdeskSource) return 5; + // Compact Helpdesk layout: action, optional Therp link, priority, ticket, + // stage, optional assignee (only in Show for everyone), optional hours. + return 4 + + Number(this.showHelpdeskTherpLink) + + Number(this.showHelpdeskAssignee) + + Number(this.showHelpdeskHours); + } + + loadingMessage(includeSession = false) { + const noun = this.state.dataSource === DATA_SOURCE_HELPDESK + ? 'Helpdesk tickets' + : this.state.dataSource === DATA_SOURCE_TASK ? 'tasks' : 'issues'; + return includeSession ? `Loading current session and ${noun}…` : `Loading ${noun}…`; + } + + relationId(value) { + if (!value) return null; + if (Array.isArray(value)) return Number(value[0]) || null; + if (typeof value === 'object') return Number(value.id) || null; + return Number(value) || null; + } + + captureSessionCompanies(sessionInfo) { + const contextIds = Array.isArray(sessionInfo?.user_context?.allowed_company_ids) + ? sessionInfo.user_context.allowed_company_ids.map(Number).filter(Boolean) + : []; + const rawAllowed = sessionInfo?.user_companies?.allowed_companies; + let availableIds = []; + const companyNames = {}; + if (Array.isArray(rawAllowed)) { + for (const item of rawAllowed) { + const id = Number(typeof item === 'object' ? item?.id : item); + if (!id) continue; + availableIds.push(id); + if (typeof item === 'object' && item?.name) companyNames[id] = String(item.name); + } + } else if (rawAllowed && typeof rawAllowed === 'object') { + availableIds = Object.keys(rawAllowed).map(Number).filter(Boolean); + for (const [id, item] of Object.entries(rawAllowed)) { + if (item?.name) companyNames[Number(id)] = String(item.name); + } + } + const rawCurrent = sessionInfo?.user_companies?.current_company; + const current = Number( + (typeof rawCurrent === 'object' ? rawCurrent?.id : rawCurrent) || + contextIds[0] || availableIds[0] || 0 + ) || null; + if (current && typeof rawCurrent === 'object' && rawCurrent?.name) { + companyNames[current] = String(rawCurrent.name); + } + this.state.currentUserId = Number(sessionInfo?.uid || 0) || null; + this.state.currentCompanyId = current; + this.state.availableCompanyIds = availableIds.length + ? [...new Set(availableIds)] + : [...new Set(contextIds.length ? contextIds : (current ? [current] : []))]; + this.state.allowedCompanyIds = contextIds.length + ? [...new Set(contextIds)] + : [...this.state.availableCompanyIds]; + this.state.companyNames = companyNames; + } + + helpdeskTimesheetCompanyId(issue) { + // hr_timesheet derives the timesheet company from project_id first, so + // mirror that choice when building the RPC company context. + const projectId = this.relationId(issue?.project_id); + const project = this.state.projects.find((item) => Number(item.id) === Number(projectId)); + const projectCompany = this.relationId(project?.company_id); + if (projectCompany) return projectCompany; + const field = this.state.sourceCapabilities.companyField; + const ticketCompany = this.relationId(field ? issue?.[field] : issue?.company_id); + return ticketCompany || this.state.currentCompanyId || null; + } + + helpdeskTimesheetContext(companyId) { + if (!companyId) return {}; + const allowed = [ + companyId, + ...(this.state.allowedCompanyIds || []).filter((id) => Number(id) !== Number(companyId)), + ]; + return {allowed_company_ids: allowed}; + } + + + helpdeskCompanyLabel(companyId) { + if (!companyId) return 'the ticket/project company'; + return this.state.companyNames?.[companyId] || `company #${companyId}`; + } + + helpdeskTimesheetBlocked(issue) { + return this.isHelpdeskSource && issue?.__timesheetWritable === false; + } + + helpdeskTimesheetBlockMessage(issue) { + return issue?.__timesheetBlockMessage || 'Odoo is not currently able to create a timesheet for this ticket.'; + } + + async showHelpdeskTimesheetInfo(issue) { + const message = issue?.__timesheetBlockMessage || + 'Odoo is not currently able to create a timesheet for this ticket.'; + const companyDetails = [ + this?.currentCompanyLabel && `Current company: ${this.currentCompanyLabel}`, + this?.allowedCompanyLabels && this.allowedCompanyLabels !== '—' && + `Allowed companies: ${this.allowedCompanyLabels}`, + ].filter(Boolean); + await notify([message, ...companyDetails].join('\n\n')); + } + + staticHelpdeskTimesheetReadiness(issue) { + if (!this.state.sourceCapabilities.canRecordTime) { + return {writable: false, message: helpdeskTimesheetUnavailableMessage()}; + } + if (!isHelpdeskTimesheetEnabled(issue, this.state.sourceCapabilities)) { + return { + writable: false, + message: 'Timesheets are disabled for this ticket team/project. Enable Helpdesk Timesheets in Odoo before starting the timer.', + }; + } + const projectId = this.relationId(issue?.project_id); + const analyticAccount = this.resolveAnalyticAccount(issue); + if (!projectId && !analyticAccount) { + return { + writable: false, + message: 'This Helpdesk ticket has no timesheet project or analytic account. Configure its Helpdesk team/project in Odoo before starting the timer.', + }; + } + const companyId = this.helpdeskTimesheetCompanyId(issue); + const accessible = this.state.availableCompanyIds || []; + if (companyId && accessible.length && !accessible.map(Number).includes(Number(companyId))) { + return { + writable: false, + message: `The ticket/project belongs to ${this.helpdeskCompanyLabel(companyId)}, which is not an allowed company for this Odoo user.`, + }; + } + return {writable: null, message: '', companyId}; + } + + async annotateHelpdeskTimesheetReadiness(records) { + if (!this.isHelpdeskSource) return records; + const annotated = records.map((issue) => { + const check = this.staticHelpdeskTimesheetReadiness(issue); + return { + ...issue, + __timesheetWritable: check.writable, + __timesheetBlockMessage: check.message || '', + __timesheetCompanyId: check.companyId || this.helpdeskTimesheetCompanyId(issue), + }; + }); + + const pending = annotated.filter((issue) => issue.__timesheetWritable === null); + if (!pending.length || !this.state.currentUserId) return annotated; + + const companyIds = [...new Set(pending.map((issue) => Number(issue.__timesheetCompanyId || 0)).filter(Boolean))]; + const domain = [ + ['user_id', '=', this.state.currentUserId], + ['active', '=', true], + ]; + if (companyIds.length) domain.push(['company_id', 'in', companyIds]); + else if (this.state.allowedCompanyIds?.length) domain.push(['company_id', 'in', this.state.allowedCompanyIds]); + + let employees; + try { + const contextIds = companyIds.length + ? [...new Set([...companyIds, ...(this.state.allowedCompanyIds || [])])] + : (this.state.allowedCompanyIds || []); + const result = await this.rpc.searchRead('hr.employee', domain, ['id', 'company_id'], { + limit: 100, + context: contextIds.length ? {allowed_company_ids: contextIds} : {}, + }); + employees = result.records || []; + } catch (err) { + // Some users cannot read hr.employee even though account.analytic.line + // create() can resolve the employee with sudo. In that case the check + // is inconclusive, so do not disable a timer that Odoo may accept. + console.warn('Helpdesk timesheet employee preflight could not be verified', err); + return annotated; + } + + const employeeCompanies = new Set( + employees.map((employee) => this.relationId(employee.company_id)).filter(Boolean) + ); + for (const issue of annotated) { + if (issue.__timesheetWritable !== null) continue; + const companyId = Number(issue.__timesheetCompanyId || 0) || null; + if (companyId) { + if (employeeCompanies.has(companyId)) { + issue.__timesheetWritable = true; + } else { + issue.__timesheetWritable = false; + issue.__timesheetBlockMessage = + `Odoo cannot create this timesheet because your user has no active employee in ${this.helpdeskCompanyLabel(companyId)}. ` + + 'Check the employee company and allowed companies, then reconnect.'; + } + } else if (!employees.length) { + issue.__timesheetWritable = false; + issue.__timesheetBlockMessage = + 'Odoo cannot create this timesheet because your user has no active employee in the selected companies. ' + + 'Check the employee company and allowed companies, then reconnect.'; + } else if (employees.length === 1) { + issue.__timesheetWritable = true; + } + } + return annotated; } - get itemLabelPlural() { - return this.state.dataSource === DATA_SOURCE_TASK ? 'Tasks' : 'Issues'; + async refreshHelpdeskTimesheetReadiness(issue) { + if (!this.isHelpdeskSource) return issue; + const [checked] = await this.annotateHelpdeskTimesheetReadiness([{...issue}]); + if (!checked) return issue; + const index = this.state.issues.findIndex((item) => Number(item.id) === Number(issue.id)); + if (index >= 0) this.state.issues[index] = {...this.state.issues[index], ...checked}; + return checked; } async updateLimitPreference(value) { @@ -537,6 +873,10 @@ class PopupApp extends Component { await storage.set(STORAGE_KEYS.searchLimit, value); } + updateStageFilter(value) { + this.state.stageFilter = String(value || ''); + } + async updateShowAllPreference(value) { this.state.allIssues = !!value; await storage.set(STORAGE_KEYS.showAllItems, !!value); @@ -549,30 +889,49 @@ class PopupApp extends Component { get filteredIssues() { const limit = this.state.limitTo ? Number(this.state.limitTo) : null; const query = (this.state.searchQuery || '').trim(); - let issues = [...this.state.issues]; + let issues = Array.isArray(this.state.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 (this.isActiveTimerItem(a)) return -1; + if (this.isActiveTimerItem(b)) 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; }); - const matchesSearch = (issue) => matchesIssue(issue, query); + // THERP UX PATCH: generic dynamic stage filter. + // Only apply a stored selection when that stage is present in the + // currently loaded source. This prevents a stage selected on one + // remote/source from hiding everything after switching remotes. + if (this.state.stageFilter) { + const stageId = Number(this.state.stageFilter); + const availableStageIds = new Set(this.stageOptions.map((stage) => Number(stage.id))); + if (availableStageIds.has(stageId)) { + issues = issues.filter((issue) => + this.isActiveTimerItem(issue) || Number(issue.stage_id?.[0]) === stageId + ); + } + } + + const matchesSearch = (issue) => this.matchesCurrentSearch(issue, query); if (this.state.allIssues) { - issues = issues.filter(matchesSearch); + issues = issues.filter((issue) => this.isActiveTimerItem(issue) || matchesSearch(issue)); } 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)) - ); + issues = issues.filter( + (issue) => + this.isActiveTimerItem(issue) || + (isAssignedToUser( + issue, + this.state.sourceCapabilities.assignmentField || 'user_id', + this.state.user.id, + this.state.sourceCapabilities.assignmentType + ) && matchesSearch(issue)) + ); } else { - issues = issues.filter(matchesSearch); + issues = issues.filter((issue) => this.isActiveTimerItem(issue) || matchesSearch(issue)); } return limit ? issues.slice(0, limit) : issues; @@ -627,9 +986,16 @@ class PopupApp extends Component { this.state.user = null; this.state.projects = []; this.state.issues = []; + this.state.currentCompanyId = null; + this.state.allowedCompanyIds = []; + this.state.availableCompanyIds = []; + this.state.currentUserId = null; + this.state.companyNames = {}; this.state.serverVersion = ''; this.state.odooOWLVersion = owl.__info__.version; this.state.supportedFields = {}; + this.state.sourceCapabilities = {}; + this.state.sourceError = ''; this.state.view = VIEW_LOGIN; this.state.loginLoading = false; this.state.loadingTable = false; @@ -689,6 +1055,7 @@ class PopupApp extends Component { autoDownloadIssueTimesheet, timerStartIso, activeTimerIdRaw, + activeTimerContext, currentHost, currentDb, currentSrc, @@ -699,6 +1066,7 @@ class PopupApp extends Component { storage.get(STORAGE_KEYS.autoDownloadIssueTimesheet, false), storage.get(STORAGE_KEYS.timerStartIso, null), storage.get(STORAGE_KEYS.activeTimerId, null), + storage.get(STORAGE_KEYS.activeTimerContext, null), storage.get(STORAGE_KEYS.currentHost, ''), storage.get(STORAGE_KEYS.currentDatabase, ''), storage.get(STORAGE_KEYS.currentDataSource, DEFAULTS.dataSource), @@ -713,8 +1081,20 @@ class PopupApp extends Component { this.state.currentHost = currentHost || ''; this.state.currentDatabase = currentDb || ''; this.state.dataSource = currentSrc || DEFAULTS.dataSource; + this.state.activeTimerContext = activeTimerContext || ( + this.state.activeTimerId + ? { + host: this.state.currentHost, + database: this.state.currentDatabase, + model: this.state.dataSource, + resId: this.state.activeTimerId, + startedAt: this.state.timerStartIso, + } + : null + ); this.state.limitTo = searchLimit ?? DEFAULTS.searchLimit; this.state.allIssues = !!showAllItems; + this.state.busyMessage = this.loadingMessage(true); } /** @@ -743,11 +1123,19 @@ class PopupApp extends Component { this.rpc.setHost(this.state.currentHost); - const remoteIndex = this.state.remotes.findIndex( - (remote) => - remote.url === this.state.currentHost && - remote.database === this.state.currentDatabase + const storedRemoteKey = remoteIdentity({ + url: this.state.currentHost, + database: this.state.currentDatabase, + datasrc: this.state.dataSource, + }); + let remoteIndex = this.state.remotes.findIndex( + (remote) => remoteIdentity(remote) === storedRemoteKey ); + if (remoteIndex < 0) { + remoteIndex = this.state.remotes.findIndex( + (remote) => remote.url === this.state.currentHost && remote.database === this.state.currentDatabase + ); + } if (remoteIndex >= 0) { this.state.selectedRemoteIndex = String(remoteIndex); @@ -837,6 +1225,74 @@ class PopupApp extends Component { return this.normalizeText(value); } + helpdeskAssigneeValue(issue) { + const field = this.state.sourceCapabilities.assignmentField; + return field ? issue?.[field] : null; + } + + helpdeskTeamValue(issue) { + const field = this.state.sourceCapabilities.teamField; + return field ? issue?.[field] : null; + } + + helpdeskProjectValue(issue) { + const field = this.state.sourceCapabilities.projectField; + return field ? issue?.[field] : null; + } + + helpdeskDescriptionText(issue) { + const field = this.state.sourceCapabilities.descriptionField; + return field ? extractMessageSummary(issue?.[field] || '') : ''; + } + + helpdeskTherpLinkValue(issue) { + const field = this.state.sourceCapabilities.therpLinkField; + return field ? this.normalizeText(issue?.[field]) : ''; + } + + helpdeskTherpLinkHref(issue) { + const value = this.helpdeskTherpLinkValue(issue).trim(); + if (/^https?:\/\//i.test(value)) return value; + if (value.startsWith('/') && this.state.currentHost) { + return `${this.state.currentHost}${value}`; + } + return null; + } + + matchesCurrentSearch(issue, query) { + if (matchesIssue(issue, query)) return true; + if (!query || !this.isHelpdeskSource) return false; + const q = query.trim().toLowerCase(); + if (!q) return true; + return [ + issue.stage_id?.[0], + this.helpdeskDescriptionText(issue), + this.helpdeskTherpLinkValue(issue), + this.relationLabel(this.helpdeskAssigneeValue(issue)), + this.relationLabel(this.helpdeskTeamValue(issue)), + this.relationLabel(this.helpdeskProjectValue(issue)), + ].filter(Boolean).join(' ').toLowerCase().includes(q); + } + + resourceRelationValue(issue) { + return resourceRelation(issue, this.state.dataSource, this.state.sourceCapabilities); + } + + helpdeskHoursValue(issue) { + return resourceHours(issue, this.state.dataSource, this.state.sourceCapabilities); + } + + isActiveTimerItem(issue) { + if (!issue?.id || Number(issue.id) !== Number(this.state.activeTimerId)) return false; + const context = this.state.activeTimerContext; + if (!context) return true; + return ( + context.model === this.state.dataSource && + context.host === this.state.currentHost && + context.database === this.state.currentDatabase + ); + } + issueHref(issue) { if (!this.state.currentHost || !issue?.id) { return null; @@ -845,15 +1301,21 @@ class PopupApp extends Component { } issueLabel(issue) { - const issueName = this.normalizeText( - issue.display_name || issue.name || issue.message_summary || issue.description || '' - ); - + if (this.state.dataSource === DATA_SOURCE_HELPDESK) { + const referenceField = this.state.sourceCapabilities.ticketReferenceField; + const reference = this.normalizeText(referenceField ? issue[referenceField] : ''); + const name = this.normalizeText(issue.name || issue.display_name || issue.description || ''); + return [reference || `#${issue.id}`, name].filter(Boolean).join(' - '); + } if (this.state.dataSource === DATA_SOURCE_TASK) { const code = this.normalizeText(issue.code); + const issueName = this.normalizeText(issue.name || issue.description || ''); return [code, issueName].filter(Boolean).join(' - ') || `#${issue.id}`; } + const issueName = this.normalizeText( + issue.display_name || issue.name || issue.message_summary || issue.description || '' + ); return [`#${issue.id}`, issueName].filter(Boolean).join(' - '); } @@ -919,7 +1381,6 @@ class PopupApp extends Component { * to the login screen. */ async completeSession(sessionInfo, remote) { - this.state.busyMessage = 'Loading tasks…'; this.state.loadingTable = true; const remoteInfo = remote || this.currentRemote || null; @@ -930,6 +1391,9 @@ class PopupApp extends Component { remoteInfo?.url || sessionInfo['web.base.url'] || this.state.currentHost; this.state.dataSource = remoteInfo?.datasrc || this.state.dataSource || DEFAULTS.dataSource; + this.resetSupportedFieldCache(); + this.captureSessionCompanies(sessionInfo); + this.state.busyMessage = this.loadingMessage(true); try { await storage.set(this.state.currentDatabase, JSON.stringify(sessionInfo)); @@ -975,8 +1439,10 @@ class PopupApp extends Component { const [userResult, serverInfo] = await Promise.all([ userPromise, serverInfoPromise, - this.loadProjects().catch((err) => console.warn('loadProjects failed', err)), - this.loadIssues().catch((err) => console.warn('loadIssues failed', err)), + (async () => { + await this.loadProjects().catch((err) => console.warn('loadProjects failed', err)); + await this.loadIssues().catch((err) => console.warn('loadIssues failed', err)); + })(), ]); this.state.user = userResult.records?.[0] || { @@ -999,11 +1465,29 @@ class PopupApp extends Component { } } + /** + * Drop model capability metadata when the remote/session changes. Odoo + * versions and installed modules expose different model fields. + */ + resetSupportedFieldCache() { + this.state.supportedFields = {}; + this.state.sourceCapabilities = {}; + this.state.sourceError = ''; + } + /** * Load project records required to resolve analytic accounts. */ async loadProjects() { - const result = await this.rpc.searchRead('project.project', [], ['analytic_account_id']); + const projectFields = await this.getSupportedFieldsForModel('project.project'); + if (!projectFields) { + this.state.projects = []; + return; + } + const fields = ['name', 'analytic_account_id', 'account_id', 'company_id'].filter((field) => + Object.prototype.hasOwnProperty.call(projectFields, field) + ); + const result = await this.searchReadWithInvalidFieldRetry('project.project', [], fields); this.state.projects = result.records || []; } @@ -1025,6 +1509,55 @@ class PopupApp extends Component { return availableFields; } + /** + * Detect the installed Helpdesk implementation and its timesheet relation. + * Enterprise uses helpdesk_ticket_id; OCA uses ticket_id. + */ + async inspectSelectedSource() { + this.state.sourceError = ''; + if (this.state.dataSource !== DATA_SOURCE_HELPDESK) { + this.state.sourceCapabilities = { canRecordTime: true, assignmentField: 'user_id' }; + return true; + } + + let ticketFields; + try { + ticketFields = await this.rpc.fieldsGet(DATA_SOURCE_HELPDESK, [ + 'type', 'string', 'relation', 'required', 'readonly', + ]); + this.state.supportedFields[DATA_SOURCE_HELPDESK] = ticketFields || {}; + } catch (err) { + this.state.sourceCapabilities = { available: false, canRecordTime: false }; + this.state.sourceError = helpdeskUnavailableMessage(err); + this.state.issues = []; + return false; + } + + let timesheetFields = {}; + let timesheetError = null; + try { + timesheetFields = await this.rpc.fieldsGet('account.analytic.line', [ + 'type', 'string', 'relation', 'required', 'readonly', + ]); + this.state.supportedFields['account.analytic.line'] = timesheetFields || {}; + } catch (err) { + timesheetError = err; + } + + const capabilities = inspectHelpdeskCapabilities(ticketFields, timesheetFields); + this.state.sourceCapabilities = capabilities; + + const warnings = []; + if (!capabilities.assignmentField) { + warnings.push('The ticket assignment field could not be detected; assigned-only filtering is unavailable.'); + } + if (!capabilities.canRecordTime) { + warnings.push(helpdeskTimesheetUnavailableMessage(timesheetError)); + } + this.state.sourceError = warnings.join(' '); + return true; + } + /** * Execute a search_read and remove unsupported fields recursively if Odoo * reports an invalid field. @@ -1033,8 +1566,16 @@ class PopupApp extends Component { try { return await this.rpc.searchRead(model, domain, requestedFields); } catch (err) { - const message = String(err?.message || ''); - const invalidFieldMatch = message.match(/Invalid field ['"]([^'"]+)['"]/i); + // Field sets vary by Odoo version and by installed/custom modules. + // Inspect both the normalized error and the original RPC payload so + // an unsupported optional field never breaks loading the whole table. + const details = [ + err?.message, + err?.fullTrace?.data?.message, + err?.fullTrace?.data?.debug, + err?.fullTrace?.message, + ].filter(Boolean).join('\n'); + const invalidFieldMatch = String(details).match(/Invalid field ['"]([^'"]+)['"]/i); if (!invalidFieldMatch) { throw err; } @@ -1044,6 +1585,10 @@ class PopupApp extends Component { if (!narrowedFields.length || narrowedFields.length === requestedFields.length) { throw err; } + const cachedFields = this.state.supportedFields[model]; + if (cachedFields && Object.prototype.hasOwnProperty.call(cachedFields, invalidField)) { + delete cachedFields[invalidField]; + } console.warn(`Retrying ${model} search_read without unsupported field: ${invalidField}`); return this.searchReadWithInvalidFieldRetry(model, domain, narrowedFields); @@ -1056,18 +1601,48 @@ class PopupApp extends Component { async loadIssues() { const model = this.state.dataSource; this.state.loadingTable = true; - this.state.busyMessage = 'Loading tasks…'; + this.state.busyMessage = this.loadingMessage(false); try { - const domain = [ - '|', - ['id', '=', this.state.activeTimerId || 0], - '&', - ['stage_id.name', 'not ilike', '%Done%'], - '&', - ['stage_id.name', 'not ilike', '%Cancel%'], - ['stage_id.name', 'not ilike', '%Hold%'], - ]; + if (!(await this.inspectSelectedSource())) return; + + const capabilities = this.state.sourceCapabilities; + const availableFields = await this.getSupportedFieldsForModel(model); + if (!availableFields) { + if (model === DATA_SOURCE_HELPDESK) { + this.state.sourceError = helpdeskUnavailableMessage(); + this.state.issues = []; + return; + } + throw new Error(`Could not inspect fields for ${model}.`); + } + + let openDomain; + if (model === DATA_SOURCE_HELPDESK && capabilities.closedField) { + openDomain = [[capabilities.closedField, '=', false]]; + } else if (model === DATA_SOURCE_HELPDESK && capabilities.activeField) { + openDomain = [[capabilities.activeField, '=', true]]; + } else { + openDomain = [ + '&', + ['stage_id.name', 'not ilike', '%Done%'], + '&', + ['stage_id.name', 'not ilike', '%Cancel%'], + ['stage_id.name', 'not ilike', '%Hold%'], + ]; + } + + const timerContext = this.state.activeTimerContext; + const activeId = this.state.activeTimerId && ( + !timerContext || ( + timerContext.model === model && + timerContext.host === this.state.currentHost && + timerContext.database === this.state.currentDatabase + ) + ) ? this.state.activeTimerId : null; + const domain = activeId + ? ['|', ['id', '=', activeId], ...openDomain] + : openDomain; const baseFields = [ 'id', @@ -1077,16 +1652,32 @@ class PopupApp extends Component { 'stage_id', 'priority', 'create_date', - 'analytic_account_id', ]; const extraFieldsByModel = { [DATA_SOURCE_ISSUE]: ['working_hours_open', 'message_summary', 'message_unread', 'description'], [DATA_SOURCE_TASK]: ['effective_hours', 'remaining_hours', 'code', 'description', 'display_name'], + [DATA_SOURCE_HELPDESK]: [ + 'display_name', + 'description', + 'partner_id', + capabilities.assignmentField, + capabilities.projectField, + capabilities.companyField, + capabilities.teamField, + capabilities.descriptionField, + capabilities.therpLinkField, + capabilities.analyticAccountField, + capabilities.stageField, + capabilities.timeEnabledField, + capabilities.hoursField, + capabilities.ticketReferenceField, + capabilities.closedField, + capabilities.activeField, + ].filter(Boolean), }; - const desiredFields = [...baseFields, ...(extraFieldsByModel[model] || [])]; - const availableFields = await this.getSupportedFieldsForModel(model); + const desiredFields = [...new Set([...baseFields, ...(extraFieldsByModel[model] || [])])]; let fields = availableFields ? desiredFields.filter((field) => @@ -1107,15 +1698,21 @@ class PopupApp extends Component { const records = result.records || []; let stageSequenceById = new Map(); - if (model === DATA_SOURCE_TASK) { + const stageModel = model === DATA_SOURCE_TASK + ? 'project.task.type' + : capabilities.stageModel; + if (stageModel) { const stageIds = [...new Set(records.map((issue) => issue.stage_id?.[0]).filter(Boolean))]; if (stageIds.length) { - const stages = await this.rpc.searchRead('project.task.type', [['id', 'in', stageIds]], ['id', 'sequence']); - stageSequenceById = new Map((stages.records || []).map((stage) => [stage.id, Number(stage.sequence || 0)])); + const stageFields = await this.getSupportedFieldsForModel(stageModel); + if (stageFields?.sequence) { + const stages = await this.rpc.searchRead(stageModel, [['id', 'in', stageIds]], ['id', 'sequence']); + stageSequenceById = new Map((stages.records || []).map((stage) => [stage.id, Number(stage.sequence || 0)])); + } } } - this.state.issues = records.map((issue) => ({ + let preparedRecords = records.map((issue) => ({ ...issue, message_summary: extractMessageSummary( issue.message_summary || issue.description || issue.display_name || issue.name || '' @@ -1123,6 +1720,17 @@ class PopupApp extends Component { priority_level: priorityStars(issue.priority), stage_sequence: stageSequenceById.get(issue.stage_id?.[0]) ?? 9999, })); + if (model === DATA_SOURCE_HELPDESK) { + preparedRecords = await this.annotateHelpdeskTimesheetReadiness(preparedRecords); + } + this.state.issues = preparedRecords; + } catch (err) { + if (model === DATA_SOURCE_HELPDESK) { + this.state.issues = []; + this.state.sourceError = `Helpdesk tickets could not be loaded: ${err.message || err}`; + return; + } + throw err; } finally { this.state.loadingTable = false; } @@ -1132,6 +1740,7 @@ class PopupApp extends Component { * Reload projects and issues. */ async refreshAll() { + this.resetSupportedFieldCache(); try { await this.loadProjects(); await this.loadIssues(); @@ -1144,11 +1753,31 @@ class PopupApp extends Component { * Start timing the selected issue/task. */ async startTimer(issue) { + if (this.state.activeTimerId) { + await notify(`Stop the active ${this.itemLabelSingular} timer before starting another one.`); + return; + } + if (this.isHelpdeskSource) { + const checkedIssue = await this.refreshHelpdeskTimesheetReadiness(issue); + if (this.helpdeskTimesheetBlocked(checkedIssue)) { + await this.showHelpdeskTimesheetInfo(checkedIssue); + return; + } + } + const now = new Date().toISOString(); this.state.activeTimerId = issue.id; this.state.timerStartIso = now; + this.state.activeTimerContext = { + host: this.state.currentHost, + database: this.state.currentDatabase, + model: this.state.dataSource, + resId: issue.id, + startedAt: now, + }; await storage.set(STORAGE_KEYS.timerStartIso, now); await storage.set(STORAGE_KEYS.activeTimerId, issue.id); + await storage.set(STORAGE_KEYS.activeTimerContext, this.state.activeTimerContext); await sendTimerStateToBackground(true); } @@ -1156,13 +1785,12 @@ class PopupApp extends Component { * Resolve the analytic account for the given issue/task. */ resolveAnalyticAccount(issue) { - if (issue.analytic_account_id) { - return issue.analytic_account_id; - } + if (issue.analytic_account_id) return issue.analytic_account_id; + if (issue.account_id) return issue.account_id; const project = this.state.projects.find( (currentProject) => currentProject.id === issue.project_id?.[0] ); - return project?.analytic_account_id; + return project?.account_id || project?.analytic_account_id; } /** @@ -1206,12 +1834,68 @@ class PopupApp extends Component { }], {}); } + /** + * Create a native Enterprise or OCA Helpdesk timesheet entry. + */ + async createHelpdeskTimesheet(params) { + const capabilities = this.state.sourceCapabilities; + const binding = timesheetBinding(DATA_SOURCE_HELPDESK, capabilities); + if (!binding) throw new Error(helpdeskTimesheetUnavailableMessage()); + + const values = { + date: params.date, + user_id: this.state.user.id, + name: params.issueName, + unit_amount: params.durationInHours, + [binding.linkField]: params.issue.id, + }; + const lineFields = capabilities.timesheetFields || {}; + const projectId = params.issue.project_id?.[0]; + const analyticAccountId = params.analyticAccount?.[0]; + const companyId = this.helpdeskTimesheetCompanyId(params.issue); + if (projectId && Object.prototype.hasOwnProperty.call(lineFields, 'project_id')) { + values.project_id = projectId; + } + if (analyticAccountId && Object.prototype.hasOwnProperty.call(lineFields, 'account_id')) { + values.account_id = analyticAccountId; + } + if (companyId && Object.prototype.hasOwnProperty.call(lineFields, 'company_id')) { + values.company_id = companyId; + } + + try { + await this.rpc.call(binding.model, 'create', [values], { + context: this.helpdeskTimesheetContext(companyId), + }); + } catch (err) { + if (String(err?.message || '').includes('active employee in the selected companies')) { + throw new Error( + 'Odoo could not match your user to an active employee in the ticket/project company. ' + + 'Check the employee company and allowed companies, then reconnect.' + ); + } + throw err; + } + } + /** * Stop the active timer and create the Odoo timesheet row. */ async stopTimer(issue) { try { - const issueDescription = (await promptDialog(`${this.itemLabelSingular.charAt(0).toUpperCase() + this.itemLabelSingular.slice(1)} #${issue.id} Description`, issue.name)) || ''; + if (!this.isActiveTimerItem(issue)) { + throw new Error('This row does not match the active timer context. Return to the original remote and item before stopping it.'); + } + if (this.isHelpdeskSource && !this.state.sourceCapabilities.canRecordTime) { + throw new Error(helpdeskTimesheetUnavailableMessage()); + } + if (this.isHelpdeskSource && !isHelpdeskTimesheetEnabled(issue, this.state.sourceCapabilities)) { + throw new Error('Timesheets are disabled for this ticket team/project in Odoo.'); + } + const promptTitle = this.isHelpdeskSource + ? `Timesheet Description for Ticket #${issue.id}` + : `${this.itemLabelSingular.charAt(0).toUpperCase() + this.itemLabelSingular.slice(1)} #${issue.id} Description`; + const issueDescription = (await promptDialog(promptTitle, issue.name)) || ''; const startIso = this.state.timerStartIso || (await storage.get(STORAGE_KEYS.timerStartIso, null)); @@ -1228,12 +1912,19 @@ class PopupApp extends Component { const durationInHours = Math.floor(durationMinutes / 60) + roundedMinutes / 60; const analyticAccount = this.resolveAnalyticAccount(issue); - if (!analyticAccount) { + if (!analyticAccount && this.state.dataSource !== DATA_SOURCE_HELPDESK) { throw new Error('No analytic account is defined on the project.'); } + if ( + this.state.dataSource === DATA_SOURCE_HELPDESK && + !analyticAccount && + !issue.project_id + ) { + throw new Error('This ticket has no timesheet project or analytic account. Configure one on its Helpdesk team/project first.'); + } const issueName = issueDescription.trim() || `${issue.name} (#${issue.id})`; - const formattedDate = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; + const formattedDate = now.toISOString().slice(0, 10); const commonPayload = { issue, issueName, @@ -1244,11 +1935,12 @@ class PopupApp extends Component { if (this.state.dataSource === DATA_SOURCE_ISSUE) { await this.createIssueTimesheet(commonPayload); - await notify(`Time for ${this.itemLabelSingular} #${issue.id} was successfully recorded in Odoo timesheets.`); + } else if (this.state.dataSource === DATA_SOURCE_HELPDESK) { + await this.createHelpdeskTimesheet(commonPayload); } else { await this.createTaskTimesheet(commonPayload); - await notify(`Time for ${this.itemLabelSingular} #${issue.id} was successfully recorded in Odoo timesheets.`); } + await notify(`Time for ${this.itemLabelSingular} #${issue.id} was successfully recorded in Odoo timesheets.`); if (this.state.autoDownloadIssueTimesheet) { await this.downloadCurrentIssueTimesheet(issue); @@ -1256,8 +1948,10 @@ class PopupApp extends Component { this.state.activeTimerId = null; this.state.timerStartIso = null; + this.state.activeTimerContext = null; await storage.remove(STORAGE_KEYS.timerStartIso); await storage.remove(STORAGE_KEYS.activeTimerId); + await storage.remove(STORAGE_KEYS.activeTimerContext); await sendTimerStateToBackground(false); await this.loadIssues(); } catch (err) { @@ -1281,8 +1975,10 @@ class PopupApp extends Component { this.state.activeTimerId = null; this.state.timerStartIso = null; + this.state.activeTimerContext = null; await storage.remove(STORAGE_KEYS.timerStartIso); await storage.remove(STORAGE_KEYS.activeTimerId); + await storage.remove(STORAGE_KEYS.activeTimerContext); await sendTimerStateToBackground(false); await this.loadIssues(); } @@ -1291,6 +1987,24 @@ class PopupApp extends Component { * Download a CSV with current month timesheet rows. */ async downloadCurrentMonthTimesheets() { + const autoDownloadEnabled = await storage.get('auto_download_issue_timesheet', false); + if (!autoDownloadEnabled) { + const blockingText = + '
' + + '
⚠️ Auto-Download Not Enabled
' + + '

' + + 'Please enable Auto Download Current Item Timesheet ' + + 'in the Options menu before downloading timesheets.' + + '

' + + '

' + + 'Click the ⚙️ Options icon, then check the box under General Settings.' + + '

' + + '
'; + + await alert.show(blockingText, ['OK']); + return; // STOP - do not proceed with download + } + try { if (!this.state.user?.id) { throw new Error('Login first.'); @@ -1301,15 +2015,17 @@ class PopupApp extends Component { .toISOString() .slice(0, 10); const currentDay = new Date().toISOString().slice(0, 10); - const model = - this.state.dataSource === DATA_SOURCE_TASK - ? 'account.analytic.line' - : 'hr.analytic.timesheet'; + const binding = timesheetBinding(this.state.dataSource, this.state.sourceCapabilities); + if (!binding) throw new Error(helpdeskTimesheetUnavailableMessage()); + const model = binding.model; const domain = [ ['user_id', '=', this.state.user.id], - ['create_date', '>=', firstDay], - ['create_date', '<=', currentDay], + ['date', '>=', firstDay], + ['date', '<=', currentDay], ]; + if (this.state.dataSource === DATA_SOURCE_HELPDESK) { + domain.push([binding.linkField, '!=', false]); + } const result = await this.rpc.searchRead(model, domain, []); const csv = toCSV(result.records || []); @@ -1337,18 +2053,14 @@ class PopupApp extends Component { .toISOString() .slice(0, 10); const currentDay = new Date().toISOString().slice(0, 10); - const model = - this.state.dataSource === DATA_SOURCE_TASK - ? 'account.analytic.line' - : 'hr.analytic.timesheet'; - const keyDomain = - this.state.dataSource === DATA_SOURCE_TASK - ? ['task_id', '=', issue.id] - : ['issue_id', '=', issue.id]; + const binding = timesheetBinding(this.state.dataSource, this.state.sourceCapabilities); + if (!binding) throw new Error(helpdeskTimesheetUnavailableMessage()); + const model = binding.model; + const keyDomain = [binding.linkField, '=', issue.id]; const domain = [ ['user_id', '=', this.state.user.id], - ['create_date', '>=', firstDay], - ['create_date', '<=', currentDay], + ['date', '>=', firstDay], + ['date', '<=', currentDay], keyDomain, ]; @@ -1373,6 +2085,7 @@ class PopupApp extends Component { return; } + this.resetSupportedFieldCache(); this.state.view = VIEW_LOGIN; this.state.useExistingSession = true; await storage.set(STORAGE_KEYS.useExistingSession, true); @@ -1409,6 +2122,11 @@ class PopupApp extends Component { this.state.user = null; this.state.issues = []; this.state.projects = []; + this.state.currentCompanyId = null; + this.state.allowedCompanyIds = []; + this.state.availableCompanyIds = []; + this.state.currentUserId = null; + this.state.companyNames = {}; this.state.view = VIEW_LOGIN; this.state.useExistingSession = true; } @@ -1425,4 +2143,4 @@ const templates = { mount(PopupApp, document.getElementById('app'), { dev: true, templates, -}); \ No newline at end of file +}); diff --git a/dist/chrome/js/lib/alert.js b/dist/chrome/js/lib/alert.js index 2e304f9..923b8a3 100644 --- a/dist/chrome/js/lib/alert.js +++ b/dist/chrome/js/lib/alert.js @@ -18,7 +18,7 @@ //Add styles & elements css = document.createElement("style"); css.type = "text/css"; - css.innerHTML = "#alertBox{position:absolute;left:50%;top:50%;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);max-height:90vh;max-width:95vw;z-index:5;background:#F5F5F5;box-shadow:0 0 10px 0 rgba(0,0,0,.7);-webkit-animation:alertoutro .3s cubic-bezier(.4,0,1,1);animation:alertoutro .3s cubic-bezier(.4,0,1,1);overflow:hidden}#alertBox[show]{-webkit-animation:alertintro .3s cubic-bezier(0,0,.2,1);animation:alertintro .3s cubic-bezier(0,0,.2,1)}#alertDropShadow[show]{transition:opacity .3s cubic-bezier(0,0,.2,1);opacity:1;pointer-events:all}#alertDropShadow{transition:opacity .3s cubic-bezier(.4,0,1,1);position:absolute;left:0;top:0;bottom:0;width:100%;pointer-events:none;opacity:0;background:rgba(0,0,0,.65);z-index:4}#alertBox>.body{padding:20px;box-sizing:border-box;max-height:calc(90vh - 30px);overflow:auto}#alertBox>.actionfooter{padding:0 10px 0 30px;overflow:auto;width:100%;box-sizing:border-box;display:-webkit-flex;display:-ms-flexbox;display:flex}#alertBox>.actionfooter>.button{height:32px;padding:0 10px;margin:0 10px 0 auto;cursor:pointer;box-sizing:content-box;font:700 18px/32px Arial,Helvetica,sans-serif}.noSelect{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}@-webkit-keyframes alertoutro{from{display:block!important;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}to{display:block!important;-webkit-transform:translate(-50%,calc(-50% + 50px));transform:translate(-50%,calc(-50% + 50px));opacity:0}}@keyframes alertoutro{from{display:block!important;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}to{display:block!important;-webkit-transform:translate(-50%,calc(-50% + 50px));transform:translate(-50%,calc(-50% + 50px));opacity:0}}@-webkit-keyframes alertintro{0%{-webkit-transform:translate(-50%,calc(-50% - 100px));transform:translate(-50%,calc(-50% - 100px));opacity:0}30%{opacity:0}100%{-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}}@keyframes alertintro{0%{-webkit-transform:translate(-50%,calc(-50% - 100px));transform:translate(-50%,calc(-50% - 100px));opacity:0}30%{opacity:0}100%{-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}}"; + css.innerHTML = "#alertBox{position:fixed;left:50%;top:50%;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);max-height:90vh;max-width:95vw;z-index:5;background:#F5F5F5;box-shadow:0 0 10px 0 rgba(0,0,0,.7);-webkit-animation:alertoutro .3s cubic-bezier(.4,0,1,1);animation:alertoutro .3s cubic-bezier(.4,0,1,1);overflow:hidden}#alertBox[show]{-webkit-animation:alertintro .3s cubic-bezier(0,0,.2,1);animation:alertintro .3s cubic-bezier(0,0,.2,1)}#alertDropShadow[show]{transition:opacity .3s cubic-bezier(0,0,.2,1);opacity:1;pointer-events:all}#alertDropShadow{transition:opacity .3s cubic-bezier(.4,0,1,1);position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;opacity:0;background:rgba(0,0,0,.65);z-index:4}#alertBox>.body{padding:20px;box-sizing:border-box;max-height:calc(90vh - 30px);overflow:auto}#alertBox>.actionfooter{padding:0 10px 0 30px;overflow:auto;width:100%;box-sizing:border-box;display:-webkit-flex;display:-ms-flexbox;display:flex}#alertBox>.actionfooter>.button{height:32px;padding:0 10px;margin:0 10px 0 auto;cursor:pointer;box-sizing:content-box;font:700 18px/32px Arial,Helvetica,sans-serif}.noSelect{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}html.therp-alert-open,body.therp-alert-open{overflow:hidden!important}/* THERP ALERT OVERLAY V2 */@-webkit-keyframes alertoutro{from{display:block!important;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}to{display:block!important;-webkit-transform:translate(-50%,calc(-50% + 50px));transform:translate(-50%,calc(-50% + 50px));opacity:0}}@keyframes alertoutro{from{display:block!important;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}to{display:block!important;-webkit-transform:translate(-50%,calc(-50% + 50px));transform:translate(-50%,calc(-50% + 50px));opacity:0}}@-webkit-keyframes alertintro{0%{-webkit-transform:translate(-50%,calc(-50% - 100px));transform:translate(-50%,calc(-50% - 100px));opacity:0}30%{opacity:0}100%{-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}}@keyframes alertintro{0%{-webkit-transform:translate(-50%,calc(-50% - 100px));transform:translate(-50%,calc(-50% - 100px));opacity:0}30%{opacity:0}100%{-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);opacity:1}}"; document.head.appendChild(css); el = document.createElement("div"); @@ -131,7 +131,16 @@ } visible = true; - alertBox.childNodes[0].innerHTML = text; + // THERP SECURITY PATCH: dynamic alert content is rendered without innerHTML. + const alertBody = alertBox.childNodes[0]; + while (alertBody.firstChild) { + alertBody.removeChild(alertBody.firstChild); + } + if (text && typeof text === "object" && typeof text.nodeType === "number") { + alertBody.appendChild(text); + } else { + alertBody.textContent = String(text ?? ""); + } while (alertBox.childNodes[1].firstChild) { alertBox.childNodes[1].removeChild(alertBox.childNodes[1].firstChild); } @@ -154,13 +163,15 @@ el.setAttribute("ripple-cancel-on-move", ""); - el.innerHTML = actions[i]; + el.textContent = String(actions[i] ?? ""); el.style.minWidth = getTextWidth(actions[i]) + "px"; alertBox.childNodes[1].appendChild(el); } ripple.registerRipples(); + document.documentElement.classList.add("therp-alert-open"); + document.body.classList.add("therp-alert-open"); alertBox.style.display = "block"; alertBox.setAttribute("show", ""); alertDropShadow.setAttribute("show", ""); @@ -182,6 +193,8 @@ alert.open = false; if (queue.length == 0) { alertDropShadow.removeAttribute("show"); + document.documentElement.classList.remove("therp-alert-open"); + document.body.classList.remove("therp-alert-open"); } } diff --git a/dist/chrome/js/lib/common.js b/dist/chrome/js/lib/common.js index 5b52c63..2b832fc 100644 --- a/dist/chrome/js/lib/common.js +++ b/dist/chrome/js/lib/common.js @@ -43,24 +43,36 @@ export async function promptDialog(title, defaultValue = '', options = {}) { return globalThis.prompt(String(title ?? ''), String(defaultValue ?? '')); } - const inputId = `therp-timer-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const escapedTitle = escapeHtml(title || 'Input'); - const escapedValue = escapeHtml(defaultValue || ''); const accentColor = options.accentColor || customAlert.accentColor || 'orange'; - const html = ` -
-
${escapedTitle}
- -
- `; - - const result = await customAlert.show(html, ['close', 'Save'], { ...options, accentColor }); + + // THERP SECURITY PATCH: build the prompt with DOM APIs instead of injecting + // a dynamic HTML string into alert.js. + const container = document.createElement('div'); + container.style.cssText = 'min-width:320px;max-width:520px;text-align:left;'; + + const heading = document.createElement('div'); + heading.style.cssText = + 'margin-bottom:12px;font-weight:700;font-size:18px;color:#42475a;text-align:center;'; + heading.textContent = String(title || 'Input'); + + const textarea = document.createElement('textarea'); + textarea.style.cssText = + 'width:100%;min-height:180px;box-sizing:border-box;padding:12px;' + + 'border:1px solid #cbd5e1;border-radius:4px;resize:vertical;' + + 'font:14px/1.4 Arial,Helvetica,sans-serif;color:#334155;background:#fff;'; + textarea.value = String(defaultValue ?? ''); + + container.append(heading, textarea); + + const result = await customAlert.show( + container, + ['close', 'Save'], + { ...options, accentColor } + ); if (result !== 'Save') { return null; } - - const el = document.getElementById(inputId); - return el ? el.value : String(defaultValue ?? ''); + return textarea.value; } export const storage = { @@ -136,6 +148,22 @@ export function normalizeHost(host) { 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]); +} + + export function toCSV(rows) { if (!rows || !rows.length) return ''; const headers = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))); @@ -187,9 +215,13 @@ export function matchesIssue(issue, query) { 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, @@ -239,7 +271,14 @@ export class OdooRpc { } getSessionInfo() { return this.send('/web/session/get_session_info', {}); } getServerInfo() { return this.send('/web/webclient/version_info', {}); } - searchRead(model, domain, fields = []) { return this.send('/web/dataset/search_read', { model, domain, fields }); } + 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 }); } diff --git a/dist/chrome/js/lib/layout-preferences.js b/dist/chrome/js/lib/layout-preferences.js new file mode 100644 index 0000000..4d4a2f2 --- /dev/null +++ b/dist/chrome/js/lib/layout-preferences.js @@ -0,0 +1,439 @@ +/* THERP ADVANCED LAYOUT PREFS V5 + * + * Same preference features in Chrome/Chromium and Firefox. + * Browser defaults are separate. No dynamic style is applied until the user + * explicitly saves preferences, so each browser keeps its source CSS by default. + */ +(function () { + 'use strict'; + + const STORAGE_KEY = 'therp_timer_layout_preferences_v4'; + const OLD_KEYS = [ + 'therp_timer_layout_preferences_v1', + 'therp_timer_layout_preferences_v2', + 'therp_timer_layout_preferences_v3', + ]; + + const isFirefox = /Firefox\//i.test(navigator.userAgent || ''); + const browserKind = isFirefox ? 'firefox' : 'chromium'; + + const COMMON = Object.freeze({ + toolbar: Object.freeze({ + searchFontSize: 13, + controlHeight: 48, + iconSize: 30, + gap: 10, + }), + table: Object.freeze({ + fontSize: 15, + headerFontSize: 15, + cellPaddingY: 7, + headerWrap: true, + cellWrap: true, + layoutMode: 'fixed', + minWidth: 0, + stripeEnabled: false, + headerBg: '#f8fafc', + headerColor: '#2f3640', + rowBg: '#ffffff', + stripeBg: '#f8fafc', + hoverBg: '#f3fbff', + activeBg: '#eaf8ff', + borderColor: '#dde3ee', + borderRadius: 12, + actionWidth: 66, + priorityWidth: 58, + stageWidth: 118, + itemWidth: 320, + hoursWidth: 76, + projectWidth: 160, + }), + options: Object.freeze({ + pageFontSize: 14, + sidebarFontSize: 14, + remotesTableFontSize: 14, + remotesHeaderFontSize: 14, + remotesHeaderWrap: true, + remotesCellPaddingY: 7, + remotesMinWidth: 760, + }), + }); + + const DEFAULTS = Object.freeze({ + chromium: Object.freeze({ + main: Object.freeze({ + width: 960, + height: 820, + wrapperPaddingX: 16, + wrapperPaddingTop: 18, + wrapperPaddingBottom: 24, + }), + toolbar: COMMON.toolbar, + table: COMMON.table, + info: Object.freeze({ + fontSize: 13, + valueFontSize: 14, + labelColor: '#3f4854', + valueColor: '#33baf6', + background: '#ffffff', + borderColor: '#dde3ee', + paddingY: 14, + paddingX: 16, + borderRadius: 12, + lineHeight: 17, + }), + options: COMMON.options, + }), + firefox: Object.freeze({ + main: Object.freeze({ + width: 960, + height: 820, + wrapperPaddingX: 16, + wrapperPaddingTop: 18, + wrapperPaddingBottom: 24, + }), + toolbar: COMMON.toolbar, + table: COMMON.table, + info: Object.freeze({ + fontSize: 14, + valueFontSize: 14, + labelColor: '#3f4854', + valueColor: '#33baf6', + background: '#ffffff', + borderColor: '#dde3ee', + paddingY: 14, + paddingX: 16, + borderRadius: 12, + lineHeight: 17, + }), + options: COMMON.options, + }), + }); + + const LIMITS = Object.freeze({ + mainWidth: [700, 1200], + mainHeight: [500, 1000], + fontSize: [10, 22], + padding: [0, 60], + radius: [0, 30], + lineHeight: [10, 30], + tableWidth: [0, 2400], + columnWidth: [30, 700], + optionsMinWidth: [600, 1800], + }); + + const clone = (value) => JSON.parse(JSON.stringify(value)); + const integerToken = (value) => /^\d{1,4}$/.test(String(value ?? '')); + const hexColor = (value) => /^#[0-9a-fA-F]{6}$/.test(String(value ?? '')); + + function readInteger(value, range, label, errors) { + if (!integerToken(value)) { + errors.push(`${label} must contain whole-number digits only.`); + return Number(value) || range[0]; + } + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < range[0] || parsed > range[1]) { + errors.push(`${label} must be ${range[0]}–${range[1]}.`); + } + return parsed; + } + + function readColor(value, label, errors) { + if (!hexColor(value)) { + errors.push(`${label} must be a six-digit hex color such as #33baf6.`); + } + return String(value || '#000000').toLowerCase(); + } + + function readEnum(value, allowed, label, errors) { + if (!allowed.includes(value)) { + errors.push(`${label} is invalid.`); + return allowed[0]; + } + return value; + } + + function readBoolean(value, label, errors) { + if (typeof value !== 'boolean') { + errors.push(`${label} must be true or false.`); + return Boolean(value); + } + return value; + } + + function validate(input) { + const errors = []; + const raw = input && typeof input === 'object' ? input : {}; + const m = raw.main || {}; + const tb = raw.toolbar || {}; + const t = raw.table || {}; + const i = raw.info || {}; + const o = raw.options || {}; + + const value = { + main: { + width: readInteger(m.width, LIMITS.mainWidth, 'Main width', errors), + height: readInteger(m.height, LIMITS.mainHeight, 'Main height', errors), + wrapperPaddingX: readInteger(m.wrapperPaddingX, LIMITS.padding, 'Wrapper horizontal padding', errors), + wrapperPaddingTop: readInteger(m.wrapperPaddingTop, LIMITS.padding, 'Wrapper top padding', errors), + wrapperPaddingBottom: readInteger(m.wrapperPaddingBottom, LIMITS.padding, 'Wrapper bottom padding', errors), + }, + toolbar: { + searchFontSize: readInteger(tb.searchFontSize, LIMITS.fontSize, 'Search font size', errors), + controlHeight: readInteger(tb.controlHeight, [28, 72], 'Toolbar control height', errors), + iconSize: readInteger(tb.iconSize, [16, 48], 'Toolbar icon size', errors), + gap: readInteger(tb.gap, [0, 30], 'Toolbar gap', errors), + }, + table: { + fontSize: readInteger(t.fontSize, LIMITS.fontSize, 'Table font size', errors), + headerFontSize: readInteger(t.headerFontSize, LIMITS.fontSize, 'Table header font size', errors), + cellPaddingY: readInteger(t.cellPaddingY, [0, 24], 'Table cell vertical padding', errors), + headerWrap: readBoolean(t.headerWrap, 'Header wrapping', errors), + cellWrap: readBoolean(t.cellWrap, 'Cell wrapping', errors), + layoutMode: readEnum(t.layoutMode, ['fixed', 'auto'], 'Table layout mode', errors), + minWidth: readInteger(t.minWidth, LIMITS.tableWidth, 'Table minimum width', errors), + stripeEnabled: readBoolean(t.stripeEnabled, 'Alternating row stripes', errors), + headerBg: readColor(t.headerBg, 'Header background', errors), + headerColor: readColor(t.headerColor, 'Header text color', errors), + rowBg: readColor(t.rowBg, 'Row background', errors), + stripeBg: readColor(t.stripeBg, 'Stripe background', errors), + hoverBg: readColor(t.hoverBg, 'Row hover background', errors), + activeBg: readColor(t.activeBg, 'Active row background', errors), + borderColor: readColor(t.borderColor, 'Table border color', errors), + borderRadius: readInteger(t.borderRadius, LIMITS.radius, 'Table border radius', errors), + actionWidth: readInteger(t.actionWidth, LIMITS.columnWidth, 'Action column width', errors), + priorityWidth: readInteger(t.priorityWidth, LIMITS.columnWidth, 'Priority column width', errors), + stageWidth: readInteger(t.stageWidth, LIMITS.columnWidth, 'Stage column width', errors), + itemWidth: readInteger(t.itemWidth, LIMITS.columnWidth, 'Item column width', errors), + hoursWidth: readInteger(t.hoursWidth, LIMITS.columnWidth, 'Hours column width', errors), + projectWidth: readInteger(t.projectWidth, LIMITS.columnWidth, 'Project column width', errors), + }, + info: { + fontSize: readInteger(i.fontSize, LIMITS.fontSize, 'Info footer font size', errors), + valueFontSize: readInteger(i.valueFontSize, LIMITS.fontSize, 'Info value font size', errors), + labelColor: readColor(i.labelColor, 'Info label color', errors), + valueColor: readColor(i.valueColor, 'Info value color', errors), + background: readColor(i.background, 'Info background', errors), + borderColor: readColor(i.borderColor, 'Info border color', errors), + paddingY: readInteger(i.paddingY, LIMITS.padding, 'Info vertical padding', errors), + paddingX: readInteger(i.paddingX, LIMITS.padding, 'Info horizontal padding', errors), + borderRadius: readInteger(i.borderRadius, LIMITS.radius, 'Info border radius', errors), + lineHeight: readInteger(i.lineHeight, LIMITS.lineHeight, 'Info line height', errors), + }, + options: { + pageFontSize: readInteger(o.pageFontSize, LIMITS.fontSize, 'Options page font size', errors), + sidebarFontSize: readInteger(o.sidebarFontSize, LIMITS.fontSize, 'Options sidebar font size', errors), + remotesTableFontSize: readInteger(o.remotesTableFontSize, LIMITS.fontSize, 'Remotes table font size', errors), + remotesHeaderFontSize: readInteger(o.remotesHeaderFontSize, LIMITS.fontSize, 'Remotes header font size', errors), + remotesHeaderWrap: readBoolean(o.remotesHeaderWrap, 'Remotes header wrapping', errors), + remotesCellPaddingY: readInteger(o.remotesCellPaddingY, [0, 24], 'Remotes cell vertical padding', errors), + remotesMinWidth: readInteger(o.remotesMinWidth, LIMITS.optionsMinWidth, 'Remotes table minimum width', errors), + }, + }; + return { ok: errors.length === 0, value, errors }; + } + + async function storageGet(key) { + try { + if (globalThis.browser?.storage?.local) { + const result = await globalThis.browser.storage.local.get(key); + return result?.[key] ?? null; + } + if (globalThis.chrome?.storage?.local) { + return await new Promise((resolve) => { + globalThis.chrome.storage.local.get([key], (result) => resolve(result?.[key] ?? null)); + }); + } + } catch (err) { + console.debug('[LayoutPrefsV4] storage read failed.', err); + } + return null; + } + + async function storageSet(key, value) { + if (globalThis.browser?.storage?.local) { + await globalThis.browser.storage.local.set({ [key]: value }); + return; + } + if (globalThis.chrome?.storage?.local) { + await new Promise((resolve, reject) => { + globalThis.chrome.storage.local.set({ [key]: value }, () => { + const error = globalThis.chrome.runtime?.lastError; + if (error) reject(error); + else resolve(); + }); + }); + } + } + + async function storageRemove(keys) { + const list = Array.isArray(keys) ? keys : [keys]; + if (globalThis.browser?.storage?.local) { + await globalThis.browser.storage.local.remove(list); + return; + } + if (globalThis.chrome?.storage?.local) { + await new Promise((resolve, reject) => { + globalThis.chrome.storage.local.remove(list, () => { + const error = globalThis.chrome.runtime?.lastError; + if (error) reject(error); + else resolve(); + }); + }); + } + } + + function px(root, name, value) { + root.style.setProperty(name, `${value}px`); + } + + function addCustomizedClass() { + document.documentElement.classList.add('tt-layout-customized'); + document.body?.classList.add('tt-layout-customized'); + } + + function removeCustomizedClass() { + document.documentElement.classList.remove('tt-layout-customized'); + document.body?.classList.remove('tt-layout-customized'); + } + + function apply(value) { + const result = validate(value); + if (!result.ok) { + console.warn('[LayoutPrefsV4] refusing invalid values:', result.errors); + return; + } + const v = result.value; + const root = document.documentElement; + + addCustomizedClass(); + + px(root, '--tt-main-width', v.main.width); + px(root, '--tt-main-height', v.main.height); + px(root, '--tt-wrapper-pad-x', v.main.wrapperPaddingX); + px(root, '--tt-wrapper-pad-top', v.main.wrapperPaddingTop); + px(root, '--tt-wrapper-pad-bottom', v.main.wrapperPaddingBottom); + + px(root, '--tt-search-font-size', v.toolbar.searchFontSize); + px(root, '--tt-toolbar-control-height', v.toolbar.controlHeight); + px(root, '--tt-toolbar-icon-size', v.toolbar.iconSize); + px(root, '--tt-toolbar-gap', v.toolbar.gap); + + px(root, '--tt-table-font-size', v.table.fontSize); + px(root, '--tt-table-header-font-size', v.table.headerFontSize); + px(root, '--tt-table-cell-pad-y', v.table.cellPaddingY); + root.style.setProperty('--tt-table-header-wrap', v.table.headerWrap ? 'normal' : 'nowrap'); + root.style.setProperty('--tt-table-cell-wrap', v.table.cellWrap ? 'normal' : 'nowrap'); + root.style.setProperty('--tt-table-layout', v.table.layoutMode); + px(root, '--tt-table-min-width', v.table.minWidth); + root.style.setProperty('--tt-table-header-bg', v.table.headerBg); + root.style.setProperty('--tt-table-header-color', v.table.headerColor); + root.style.setProperty('--tt-table-row-bg', v.table.rowBg); + root.style.setProperty('--tt-table-stripe-bg', v.table.stripeEnabled ? v.table.stripeBg : v.table.rowBg); + root.style.setProperty('--tt-table-hover-bg', v.table.hoverBg); + root.style.setProperty('--tt-table-active-bg', v.table.activeBg); + root.style.setProperty('--tt-table-border-color', v.table.borderColor); + px(root, '--tt-table-radius', v.table.borderRadius); + px(root, '--tt-col-action', v.table.actionWidth); + px(root, '--tt-col-priority', v.table.priorityWidth); + px(root, '--tt-col-stage', v.table.stageWidth); + px(root, '--tt-col-item', v.table.itemWidth); + px(root, '--tt-col-hours', v.table.hoursWidth); + px(root, '--tt-col-project', v.table.projectWidth); + + px(root, '--tt-info-font-size', v.info.fontSize); + px(root, '--tt-info-value-font-size', v.info.valueFontSize); + root.style.setProperty('--tt-info-label-color', v.info.labelColor); + root.style.setProperty('--tt-info-value-color', v.info.valueColor); + root.style.setProperty('--tt-info-bg', v.info.background); + root.style.setProperty('--tt-info-border-color', v.info.borderColor); + px(root, '--tt-info-pad-y', v.info.paddingY); + px(root, '--tt-info-pad-x', v.info.paddingX); + px(root, '--tt-info-radius', v.info.borderRadius); + root.style.setProperty('--tt-info-line-height', String(v.info.lineHeight / 10)); + + px(root, '--tt-options-font-size', v.options.pageFontSize); + px(root, '--tt-options-sidebar-font-size', v.options.sidebarFontSize); + px(root, '--tt-remotes-font-size', v.options.remotesTableFontSize); + px(root, '--tt-remotes-header-font-size', v.options.remotesHeaderFontSize); + root.style.setProperty('--tt-remotes-header-wrap', v.options.remotesHeaderWrap ? 'normal' : 'nowrap'); + px(root, '--tt-remotes-cell-pad-y', v.options.remotesCellPaddingY); + px(root, '--tt-remotes-min-width', v.options.remotesMinWidth); + } + + function clear() { + const root = document.documentElement; + for (const name of Array.from(root.style)) { + if (name.startsWith('--tt-')) root.style.removeProperty(name); + } + removeCustomizedClass(); + } + + async function getState() { + const stored = await storageGet(STORAGE_KEY); + const defaults = clone(DEFAULTS[browserKind]); + if (stored == null) { + return { stored: false, value: defaults }; + } + const result = validate(stored); + if (!result.ok) { + console.warn('[LayoutPrefsV4] corrupt stored settings removed:', result.errors); + await storageRemove(STORAGE_KEY).catch(() => {}); + return { stored: false, value: defaults }; + } + return { stored: true, value: result.value }; + } + + async function save(value) { + const result = validate(value); + if (!result.ok) { + const error = new Error(result.errors.join(' ')); + error.validationErrors = result.errors; + throw error; + } + await storageSet(STORAGE_KEY, result.value); + await storageRemove(OLD_KEYS).catch(() => {}); + return result.value; + } + + async function reset() { + await storageRemove([STORAGE_KEY, ...OLD_KEYS]).catch(() => {}); + clear(); + return clone(DEFAULTS[browserKind]); + } + + function suggestedPreset() { + const value = clone(DEFAULTS[browserKind]); + value.main.width = 800; + value.table.headerWrap = false; + value.table.layoutMode = 'auto'; + value.table.stripeEnabled = true; + value.table.actionWidth = 42; + value.table.headerBg = '#f8fafc'; + value.table.stripeBg = '#f8fafc'; + value.table.minWidth = 800; + return value; + } + + globalThis.TherpLayoutPrefs = { + validate, + apply, + clear, + getState, + save, + reset, + defaults: () => clone(DEFAULTS[browserKind]), + suggestedPreset, + canCustomize: () => true, + browserKind: () => browserKind, + browserLabel: () => (isFirefox ? 'Firefox' : 'Chrome / Chromium'), + }; + + getState().then((state) => { + if (state.stored) apply(state.value); + else clear(); + }).catch((err) => { + console.warn('[LayoutPrefsV4] using source CSS defaults.', err); + clear(); + }); +})(); diff --git a/dist/chrome/js/lib/owl.iife.js b/dist/chrome/js/lib/owl.iife.js index c26b625..fe52d00 100644 --- a/dist/chrome/js/lib/owl.iife.js +++ b/dist/chrome/js/lib/owl.iife.js @@ -1,6294 +1,6279 @@ (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; - }, + 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); + // ----------------------------------------------------------------------------- + // 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 { + // 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); - } - } + 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); + /** + * 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; + 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$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$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