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
-
-
-
-### 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
`
+ `
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
`
);
const errorBlock = createBlock(`
`);
const remotesTableBlock = createBlock(
`List of Available Remotes| Remote | Host | Database | Source | State | |
|---|
`
);
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 ☕
`
+ `Loading current session and projects…
Please wait — or grab a cup of coffee ☕
`
);
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 = `
-
- `;
-
- 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 element
- const fragment = document.createElement("template").content;
- fragment.appendChild(el);
- }
- const attrs = node.attributes;
- for (let i = 0; i < attrs.length; i++) {
- const attrName = attrs[i].name;
- const attrValue = attrs[i].value;
- if (attrName.startsWith("block-handler-")) {
- const idx = parseInt(attrName.slice(14), 10);
- info.push({
- type: "handler",
- idx,
- event: attrValue,
- });
- }
- else if (attrName.startsWith("block-attribute-")) {
- const idx = parseInt(attrName.slice(16), 10);
- info.push({
- type: "attribute",
- idx,
- name: attrValue,
- tag: tagName,
- });
- }
- else if (attrName.startsWith("block-property-")) {
- const idx = parseInt(attrName.slice(15), 10);
- info.push({
- type: "property",
- idx,
- name: attrValue,
- tag: tagName,
- });
- }
- else if (attrName === "block-attributes") {
- info.push({
- type: "attributes",
- idx: parseInt(attrValue, 10),
- });
- }
- else if (attrName === "block-ref") {
- info.push({
- type: "ref",
- idx: parseInt(attrValue, 10),
- });
- }
- else {
- el.setAttribute(attrs[i].name, attrValue);
- }
- }
- }
- const tree = {
- parent,
- firstChild: null,
- nextSibling: null,
- el,
- info,
- refN: 0,
- currentNS,
- };
- if (node.firstChild) {
- const childNode = node.childNodes[0];
- if (node.childNodes.length === 1 &&
- childNode.nodeType === Node.ELEMENT_NODE &&
- childNode.tagName.startsWith("block-child-")) {
- const tagName = childNode.tagName;
- const index = parseInt(tagName.slice(12), 10);
- info.push({ idx: index, type: "child", isOnlyChild: true });
- }
- else {
- tree.firstChild = buildTree(node.firstChild, tree, tree);
- el.appendChild(tree.firstChild.el);
- let curNode = node.firstChild;
- let curTree = tree.firstChild;
- while ((curNode = curNode.nextSibling)) {
- curTree.nextSibling = buildTree(curNode, curTree, tree);
- el.appendChild(curTree.nextSibling.el);
- curTree = curTree.nextSibling;
- }
- }
- }
- if (tree.info.length) {
- addRef(tree);
- }
- return tree;
- }
- case Node.TEXT_NODE:
- case Node.COMMENT_NODE: {
- // text node or comment node
- const el = node.nodeType === Node.TEXT_NODE
- ? document.createTextNode(node.textContent)
- : document.createComment(node.textContent);
- return {
- parent: parent,
- firstChild: null,
- nextSibling: null,
- el,
- info: [],
- refN: 0,
- currentNS: null,
- };
- }
- }
- throw new OwlError("boom");
- }
- function addRef(tree) {
- tree.isRef = true;
- do {
- tree.refN++;
- } while ((tree = tree.parent));
- }
- function parentTree(tree) {
- let parent = tree.parent;
- while (parent && parent.nextSibling === tree) {
- tree = parent;
- parent = parent.parent;
- }
- return parent;
- }
- function buildContext(tree, ctx, fromIdx) {
- if (!ctx) {
- const children = new Array(tree.info.filter((v) => v.type === "child").length);
- ctx = { collectors: [], locations: [], children, cbRefs: [], refN: tree.refN, refList: [] };
- fromIdx = 0;
- }
- if (tree.refN) {
- const initialIdx = fromIdx;
- const isRef = tree.isRef;
- const firstChild = tree.firstChild ? tree.firstChild.refN : 0;
- const nextSibling = tree.nextSibling ? tree.nextSibling.refN : 0;
- //node
- if (isRef) {
- for (let info of tree.info) {
- info.refIdx = initialIdx;
- }
- tree.refIdx = initialIdx;
- updateCtx(ctx, tree);
- fromIdx++;
- }
- // right
- if (nextSibling) {
- const idx = fromIdx + firstChild;
- ctx.collectors.push({ idx, prevIdx: initialIdx, getVal: nodeGetNextSibling });
- buildContext(tree.nextSibling, ctx, idx);
- }
- // left
- if (firstChild) {
- ctx.collectors.push({ idx: fromIdx, prevIdx: initialIdx, getVal: nodeGetFirstChild });
- buildContext(tree.firstChild, ctx, fromIdx);
- }
- }
- return ctx;
- }
- function updateCtx(ctx, tree) {
- for (let info of tree.info) {
- switch (info.type) {
- case "text":
- ctx.locations.push({
- idx: info.idx,
- refIdx: info.refIdx,
- setData: setText,
- updateData: setText,
- });
- break;
- case "child":
- if (info.isOnlyChild) {
- // tree is the parentnode here
- ctx.children[info.idx] = {
- parentRefIdx: info.refIdx,
- isOnlyChild: true,
- };
- }
- else {
- // tree is the anchor text node
- ctx.children[info.idx] = {
- parentRefIdx: parentTree(tree).refIdx,
- afterRefIdx: info.refIdx,
- };
- }
- break;
- case "property": {
- const refIdx = info.refIdx;
- const setProp = makePropSetter(info.name);
- ctx.locations.push({
- idx: info.idx,
- refIdx,
- setData: setProp,
- updateData: setProp,
- });
- break;
- }
- case "attribute": {
- const refIdx = info.refIdx;
- let updater;
- let setter;
- if (info.name === "class") {
- setter = setClass;
- updater = updateClass;
- }
- else {
- setter = createAttrUpdater(info.name);
- updater = setter;
- }
- ctx.locations.push({
- idx: info.idx,
- refIdx,
- setData: setter,
- updateData: updater,
- });
- break;
- }
- case "attributes":
- ctx.locations.push({
- idx: info.idx,
- refIdx: info.refIdx,
- setData: attrsSetter,
- updateData: attrsUpdater,
- });
- break;
- case "handler": {
- const { setup, update } = createEventHandler(info.event);
- ctx.locations.push({
- idx: info.idx,
- refIdx: info.refIdx,
- setData: setup,
- updateData: update,
- });
- break;
- }
- case "ref":
- const index = ctx.cbRefs.push(info.idx) - 1;
- ctx.locations.push({
- idx: info.idx,
- refIdx: info.refIdx,
- setData: makeRefSetter(index, ctx.refList),
- updateData: NO_OP$1,
- });
- }
- }
- }
- // -----------------------------------------------------------------------------
- // building the concrete block class
- // -----------------------------------------------------------------------------
- function buildBlock(template, ctx) {
- let B = createBlockClass(template, ctx);
- if (ctx.cbRefs.length) {
- const cbRefs = ctx.cbRefs;
- const refList = ctx.refList;
- let cbRefsNumber = cbRefs.length;
- B = class extends B {
- mount(parent, afterNode) {
- refList.push(new Array(cbRefsNumber));
- super.mount(parent, afterNode);
- for (let cbRef of refList.pop()) {
- cbRef();
- }
- }
- remove() {
- super.remove();
- for (let cbRef of cbRefs) {
- let fn = this.data[cbRef];
- fn(null);
- }
- }
- };
- }
- if (ctx.children.length) {
- B = class extends B {
- constructor(data, children) {
- super(data);
- this.children = children;
- }
- };
- B.prototype.beforeRemove = VMulti.prototype.beforeRemove;
- return (data, children = []) => new B(data, children);
- }
- return (data) => new B(data);
- }
- function createBlockClass(template, ctx) {
- const { refN, collectors, children } = ctx;
- const colN = collectors.length;
- ctx.locations.sort((a, b) => a.idx - b.idx);
- const locations = ctx.locations.map((loc) => ({
- refIdx: loc.refIdx,
- setData: loc.setData,
- updateData: loc.updateData,
- }));
- const locN = locations.length;
- const childN = children.length;
- const childrenLocs = children;
- const isDynamic = refN > 0;
- // these values are defined here to make them faster to lookup in the class
- // block scope
- const nodeCloneNode = nodeProto$2.cloneNode;
- const nodeInsertBefore = nodeProto$2.insertBefore;
- const elementRemove = elementProto.remove;
- class Block {
- constructor(data) {
- this.data = data;
- }
- beforeRemove() { }
- remove() {
- elementRemove.call(this.el);
- }
- firstNode() {
- return this.el;
- }
- moveBeforeDOMNode(node, parent = this.parentEl) {
- this.parentEl = parent;
- nodeInsertBefore.call(parent, this.el, node);
- }
- moveBeforeVNode(other, afterNode) {
- nodeInsertBefore.call(this.parentEl, this.el, other ? other.el : afterNode);
- }
- toString() {
- const div = document.createElement("div");
- this.mount(div, null);
- return div.innerHTML;
- }
- mount(parent, afterNode) {
- const el = nodeCloneNode.call(template, true);
- nodeInsertBefore.call(parent, el, afterNode);
- this.el = el;
- this.parentEl = parent;
- }
- patch(other, withBeforeRemove) { }
- }
- if (isDynamic) {
- Block.prototype.mount = function mount(parent, afterNode) {
- const el = nodeCloneNode.call(template, true);
- // collecting references
- const refs = new Array(refN);
- this.refs = refs;
- refs[0] = el;
- for (let i = 0; i < colN; i++) {
- const w = collectors[i];
- refs[w.idx] = w.getVal.call(refs[w.prevIdx]);
- }
- // applying data to all update points
- if (locN) {
- const data = this.data;
- for (let i = 0; i < locN; i++) {
- const loc = locations[i];
- loc.setData.call(refs[loc.refIdx], data[i]);
- }
- }
- nodeInsertBefore.call(parent, el, afterNode);
- // preparing all children
- if (childN) {
- const children = this.children;
- for (let i = 0; i < childN; i++) {
- const child = children[i];
- if (child) {
- const loc = childrenLocs[i];
- const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null;
- child.isOnlyChild = loc.isOnlyChild;
- child.mount(refs[loc.parentRefIdx], afterNode);
- }
- }
- }
- this.el = el;
- this.parentEl = parent;
- };
- Block.prototype.patch = function patch(other, withBeforeRemove) {
- if (this === other) {
- return;
- }
- const refs = this.refs;
- // update texts/attributes/
- if (locN) {
- const data1 = this.data;
- const data2 = other.data;
- for (let i = 0; i < locN; i++) {
- const val1 = data1[i];
- const val2 = data2[i];
- if (val1 !== val2) {
- const loc = locations[i];
- loc.updateData.call(refs[loc.refIdx], val2, val1);
- }
- }
- this.data = data2;
- }
- // update children
- if (childN) {
- let children1 = this.children;
- const children2 = other.children;
- for (let i = 0; i < childN; i++) {
- const child1 = children1[i];
- const child2 = children2[i];
- if (child1) {
- if (child2) {
- child1.patch(child2, withBeforeRemove);
- }
- else {
- if (withBeforeRemove) {
- child1.beforeRemove();
- }
- child1.remove();
- children1[i] = undefined;
- }
- }
- else if (child2) {
- const loc = childrenLocs[i];
- const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null;
- child2.mount(refs[loc.parentRefIdx], afterNode);
- children1[i] = child2;
- }
- }
- }
- };
- }
- return Block;
- }
- function setText(value) {
- characterDataSetData.call(this, toText(value));
- }
- function makeRefSetter(index, refs) {
- return function setRef(fn) {
- refs[refs.length - 1][index] = () => fn(this);
- };
+ 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 element
+ const fragment = document.createElement("template").content;
+ fragment.appendChild(el);
+ }
+ const attrs = node.attributes;
+ for (let i = 0; i < attrs.length; i++) {
+ const attrName = attrs[i].name;
+ const attrValue = attrs[i].value;
+ if (attrName.startsWith("block-handler-")) {
+ const idx = parseInt(attrName.slice(14), 10);
+ info.push({
+ type: "handler",
+ idx,
+ event: attrValue,
+ });
+ }
+ else if (attrName.startsWith("block-attribute-")) {
+ const idx = parseInt(attrName.slice(16), 10);
+ info.push({
+ type: "attribute",
+ idx,
+ name: attrValue,
+ tag: tagName,
+ });
+ }
+ else if (attrName.startsWith("block-property-")) {
+ const idx = parseInt(attrName.slice(15), 10);
+ info.push({
+ type: "property",
+ idx,
+ name: attrValue,
+ tag: tagName,
+ });
+ }
+ else if (attrName === "block-attributes") {
+ info.push({
+ type: "attributes",
+ idx: parseInt(attrValue, 10),
+ });
+ }
+ else if (attrName === "block-ref") {
+ info.push({
+ type: "ref",
+ idx: parseInt(attrValue, 10),
+ });
+ }
+ else {
+ el.setAttribute(attrs[i].name, attrValue);
+ }
+ }
+ }
+ const tree = {
+ parent,
+ firstChild: null,
+ nextSibling: null,
+ el,
+ info,
+ refN: 0,
+ currentNS,
+ };
+ if (node.firstChild) {
+ const childNode = node.childNodes[0];
+ if (node.childNodes.length === 1 &&
+ childNode.nodeType === Node.ELEMENT_NODE &&
+ childNode.tagName.startsWith("block-child-")) {
+ const tagName = childNode.tagName;
+ const index = parseInt(tagName.slice(12), 10);
+ info.push({ idx: index, type: "child", isOnlyChild: true });
+ }
+ else {
+ tree.firstChild = buildTree(node.firstChild, tree, tree);
+ el.appendChild(tree.firstChild.el);
+ let curNode = node.firstChild;
+ let curTree = tree.firstChild;
+ while ((curNode = curNode.nextSibling)) {
+ curTree.nextSibling = buildTree(curNode, curTree, tree);
+ el.appendChild(curTree.nextSibling.el);
+ curTree = curTree.nextSibling;
+ }
+ }
+ }
+ if (tree.info.length) {
+ addRef(tree);
+ }
+ return tree;
+ }
+ case Node.TEXT_NODE:
+ case Node.COMMENT_NODE: {
+ // text node or comment node
+ const el = node.nodeType === Node.TEXT_NODE
+ ? document.createTextNode(node.textContent)
+ : document.createComment(node.textContent);
+ return {
+ parent: parent,
+ firstChild: null,
+ nextSibling: null,
+ el,
+ info: [],
+ refN: 0,
+ currentNS: null,
+ };
+ }
+ }
+ throw new OwlError("boom");
+ }
+ function addRef(tree) {
+ tree.isRef = true;
+ do {
+ tree.refN++;
+ } while ((tree = tree.parent));
+ }
+ function parentTree(tree) {
+ let parent = tree.parent;
+ while (parent && parent.nextSibling === tree) {
+ tree = parent;
+ parent = parent.parent;
+ }
+ return parent;
+ }
+ function buildContext(tree, ctx, fromIdx) {
+ if (!ctx) {
+ const children = new Array(tree.info.filter((v) => v.type === "child").length);
+ ctx = { collectors: [], locations: [], children, cbRefs: [], refN: tree.refN, refList: [] };
+ fromIdx = 0;
+ }
+ if (tree.refN) {
+ const initialIdx = fromIdx;
+ const isRef = tree.isRef;
+ const firstChild = tree.firstChild ? tree.firstChild.refN : 0;
+ const nextSibling = tree.nextSibling ? tree.nextSibling.refN : 0;
+ //node
+ if (isRef) {
+ for (let info of tree.info) {
+ info.refIdx = initialIdx;
+ }
+ tree.refIdx = initialIdx;
+ updateCtx(ctx, tree);
+ fromIdx++;
+ }
+ // right
+ if (nextSibling) {
+ const idx = fromIdx + firstChild;
+ ctx.collectors.push({ idx, prevIdx: initialIdx, getVal: nodeGetNextSibling });
+ buildContext(tree.nextSibling, ctx, idx);
+ }
+ // left
+ if (firstChild) {
+ ctx.collectors.push({ idx: fromIdx, prevIdx: initialIdx, getVal: nodeGetFirstChild });
+ buildContext(tree.firstChild, ctx, fromIdx);
+ }
+ }
+ return ctx;
+ }
+ function updateCtx(ctx, tree) {
+ for (let info of tree.info) {
+ switch (info.type) {
+ case "text":
+ ctx.locations.push({
+ idx: info.idx,
+ refIdx: info.refIdx,
+ setData: setText,
+ updateData: setText,
+ });
+ break;
+ case "child":
+ if (info.isOnlyChild) {
+ // tree is the parentnode here
+ ctx.children[info.idx] = {
+ parentRefIdx: info.refIdx,
+ isOnlyChild: true,
+ };
+ }
+ else {
+ // tree is the anchor text node
+ ctx.children[info.idx] = {
+ parentRefIdx: parentTree(tree).refIdx,
+ afterRefIdx: info.refIdx,
+ };
+ }
+ break;
+ case "property": {
+ const refIdx = info.refIdx;
+ const setProp = makePropSetter(info.name);
+ ctx.locations.push({
+ idx: info.idx,
+ refIdx,
+ setData: setProp,
+ updateData: setProp,
+ });
+ break;
+ }
+ case "attribute": {
+ const refIdx = info.refIdx;
+ let updater;
+ let setter;
+ if (info.name === "class") {
+ setter = setClass;
+ updater = updateClass;
+ }
+ else {
+ setter = createAttrUpdater(info.name);
+ updater = setter;
+ }
+ ctx.locations.push({
+ idx: info.idx,
+ refIdx,
+ setData: setter,
+ updateData: updater,
+ });
+ break;
+ }
+ case "attributes":
+ ctx.locations.push({
+ idx: info.idx,
+ refIdx: info.refIdx,
+ setData: attrsSetter,
+ updateData: attrsUpdater,
+ });
+ break;
+ case "handler": {
+ const { setup, update } = createEventHandler(info.event);
+ ctx.locations.push({
+ idx: info.idx,
+ refIdx: info.refIdx,
+ setData: setup,
+ updateData: update,
+ });
+ break;
+ }
+ case "ref":
+ const index = ctx.cbRefs.push(info.idx) - 1;
+ ctx.locations.push({
+ idx: info.idx,
+ refIdx: info.refIdx,
+ setData: makeRefSetter(index, ctx.refList),
+ updateData: NO_OP$1,
+ });
+ }
+ }
+ }
+ // -----------------------------------------------------------------------------
+ // building the concrete block class
+ // -----------------------------------------------------------------------------
+ function buildBlock(template, ctx) {
+ let B = createBlockClass(template, ctx);
+ if (ctx.cbRefs.length) {
+ const cbRefs = ctx.cbRefs;
+ const refList = ctx.refList;
+ let cbRefsNumber = cbRefs.length;
+ B = class extends B {
+ mount(parent, afterNode) {
+ refList.push(new Array(cbRefsNumber));
+ super.mount(parent, afterNode);
+ for (let cbRef of refList.pop()) {
+ cbRef();
+ }
+ }
+ remove() {
+ super.remove();
+ for (let cbRef of cbRefs) {
+ let fn = this.data[cbRef];
+ fn(null);
+ }
+ }
+ };
+ }
+ if (ctx.children.length) {
+ B = class extends B {
+ constructor(data, children) {
+ super(data);
+ this.children = children;
+ }
+ };
+ B.prototype.beforeRemove = VMulti.prototype.beforeRemove;
+ return (data, children = []) => new B(data, children);
+ }
+ return (data) => new B(data);
+ }
+ function createBlockClass(template, ctx) {
+ const { refN, collectors, children } = ctx;
+ const colN = collectors.length;
+ ctx.locations.sort((a, b) => a.idx - b.idx);
+ const locations = ctx.locations.map((loc) => ({
+ refIdx: loc.refIdx,
+ setData: loc.setData,
+ updateData: loc.updateData,
+ }));
+ const locN = locations.length;
+ const childN = children.length;
+ const childrenLocs = children;
+ const isDynamic = refN > 0;
+ // these values are defined here to make them faster to lookup in the class
+ // block scope
+ const nodeCloneNode = nodeProto$2.cloneNode;
+ const nodeInsertBefore = nodeProto$2.insertBefore;
+ const elementRemove = elementProto.remove;
+ class Block {
+ constructor(data) {
+ this.data = data;
+ }
+ beforeRemove() { }
+ remove() {
+ elementRemove.call(this.el);
+ }
+ firstNode() {
+ return this.el;
+ }
+ moveBeforeDOMNode(node, parent = this.parentEl) {
+ this.parentEl = parent;
+ nodeInsertBefore.call(parent, this.el, node);
+ }
+ moveBeforeVNode(other, afterNode) {
+ nodeInsertBefore.call(this.parentEl, this.el, other ? other.el : afterNode);
+ }
+ toString() {
+ const div = document.createElement("div");
+ this.mount(div, null);
+ return div.innerHTML;
+ }
+ mount(parent, afterNode) {
+ const el = nodeCloneNode.call(template, true);
+ nodeInsertBefore.call(parent, el, afterNode);
+ this.el = el;
+ this.parentEl = parent;
+ }
+ patch(other, withBeforeRemove) { }
+ }
+ if (isDynamic) {
+ Block.prototype.mount = function mount(parent, afterNode) {
+ const el = nodeCloneNode.call(template, true);
+ // collecting references
+ const refs = new Array(refN);
+ this.refs = refs;
+ refs[0] = el;
+ for (let i = 0; i < colN; i++) {
+ const w = collectors[i];
+ refs[w.idx] = w.getVal.call(refs[w.prevIdx]);
+ }
+ // applying data to all update points
+ if (locN) {
+ const data = this.data;
+ for (let i = 0; i < locN; i++) {
+ const loc = locations[i];
+ loc.setData.call(refs[loc.refIdx], data[i]);
+ }
+ }
+ nodeInsertBefore.call(parent, el, afterNode);
+ // preparing all children
+ if (childN) {
+ const children = this.children;
+ for (let i = 0; i < childN; i++) {
+ const child = children[i];
+ if (child) {
+ const loc = childrenLocs[i];
+ const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null;
+ child.isOnlyChild = loc.isOnlyChild;
+ child.mount(refs[loc.parentRefIdx], afterNode);
+ }
+ }
+ }
+ this.el = el;
+ this.parentEl = parent;
+ };
+ Block.prototype.patch = function patch(other, withBeforeRemove) {
+ if (this === other) {
+ return;
+ }
+ const refs = this.refs;
+ // update texts/attributes/
+ if (locN) {
+ const data1 = this.data;
+ const data2 = other.data;
+ for (let i = 0; i < locN; i++) {
+ const val1 = data1[i];
+ const val2 = data2[i];
+ if (val1 !== val2) {
+ const loc = locations[i];
+ loc.updateData.call(refs[loc.refIdx], val2, val1);
+ }
+ }
+ this.data = data2;
+ }
+ // update children
+ if (childN) {
+ let children1 = this.children;
+ const children2 = other.children;
+ for (let i = 0; i < childN; i++) {
+ const child1 = children1[i];
+ const child2 = children2[i];
+ if (child1) {
+ if (child2) {
+ child1.patch(child2, withBeforeRemove);
+ }
+ else {
+ if (withBeforeRemove) {
+ child1.beforeRemove();
+ }
+ child1.remove();
+ children1[i] = undefined;
+ }
+ }
+ else if (child2) {
+ const loc = childrenLocs[i];
+ const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null;
+ child2.mount(refs[loc.parentRefIdx], afterNode);
+ children1[i] = child2;
+ }
+ }
+ }
+ };
+ }
+ return Block;
+ }
+ function setText(value) {
+ characterDataSetData.call(this, toText(value));
+ }
+ function makeRefSetter(index, refs) {
+ return function setRef(fn) {
+ refs[refs.length - 1][index] = () => fn(this);
+ };
}
- const getDescriptor = (o, p) => Object.getOwnPropertyDescriptor(o, p);
- const nodeProto$1 = Node.prototype;
- const nodeInsertBefore$1 = nodeProto$1.insertBefore;
- const nodeAppendChild = nodeProto$1.appendChild;
- const nodeRemoveChild$1 = nodeProto$1.removeChild;
- const nodeSetTextContent = getDescriptor(nodeProto$1, "textContent").set;
- // -----------------------------------------------------------------------------
- // List Node
- // -----------------------------------------------------------------------------
- class VList {
- constructor(children) {
- this.children = children;
- }
- mount(parent, afterNode) {
- const children = this.children;
- const _anchor = document.createTextNode("");
- this.anchor = _anchor;
- nodeInsertBefore$1.call(parent, _anchor, afterNode);
- const l = children.length;
- if (l) {
- const mount = children[0].mount;
- for (let i = 0; i < l; i++) {
- mount.call(children[i], parent, _anchor);
- }
- }
- this.parentEl = parent;
- }
- moveBeforeDOMNode(node, parent = this.parentEl) {
- this.parentEl = parent;
- const children = this.children;
- for (let i = 0, l = children.length; i < l; i++) {
- children[i].moveBeforeDOMNode(node, parent);
- }
- parent.insertBefore(this.anchor, node);
- }
- moveBeforeVNode(other, afterNode) {
- if (other) {
- const next = other.children[0];
- afterNode = (next ? next.firstNode() : other.anchor) || null;
- }
- const children = this.children;
- for (let i = 0, l = children.length; i < l; i++) {
- children[i].moveBeforeVNode(null, afterNode);
- }
- this.parentEl.insertBefore(this.anchor, afterNode);
- }
- patch(other, withBeforeRemove) {
- if (this === other) {
- return;
- }
- const ch1 = this.children;
- const ch2 = other.children;
- if (ch2.length === 0 && ch1.length === 0) {
- return;
- }
- this.children = ch2;
- const proto = ch2[0] || ch1[0];
- const { mount: cMount, patch: cPatch, remove: cRemove, beforeRemove, moveBeforeVNode: cMoveBefore, firstNode: cFirstNode, } = proto;
- const _anchor = this.anchor;
- const isOnlyChild = this.isOnlyChild;
- const parent = this.parentEl;
- // fast path: no new child => only remove
- if (ch2.length === 0 && isOnlyChild) {
- if (withBeforeRemove) {
- for (let i = 0, l = ch1.length; i < l; i++) {
- beforeRemove.call(ch1[i]);
- }
- }
- nodeSetTextContent.call(parent, "");
- nodeAppendChild.call(parent, _anchor);
- return;
- }
- let startIdx1 = 0;
- let startIdx2 = 0;
- let startVn1 = ch1[0];
- let startVn2 = ch2[0];
- let endIdx1 = ch1.length - 1;
- let endIdx2 = ch2.length - 1;
- let endVn1 = ch1[endIdx1];
- let endVn2 = ch2[endIdx2];
- let mapping = undefined;
- while (startIdx1 <= endIdx1 && startIdx2 <= endIdx2) {
- // -------------------------------------------------------------------
- if (startVn1 === null) {
- startVn1 = ch1[++startIdx1];
- continue;
- }
- // -------------------------------------------------------------------
- if (endVn1 === null) {
- endVn1 = ch1[--endIdx1];
- continue;
- }
- // -------------------------------------------------------------------
- let startKey1 = startVn1.key;
- let startKey2 = startVn2.key;
- if (startKey1 === startKey2) {
- cPatch.call(startVn1, startVn2, withBeforeRemove);
- ch2[startIdx2] = startVn1;
- startVn1 = ch1[++startIdx1];
- startVn2 = ch2[++startIdx2];
- continue;
- }
- // -------------------------------------------------------------------
- let endKey1 = endVn1.key;
- let endKey2 = endVn2.key;
- if (endKey1 === endKey2) {
- cPatch.call(endVn1, endVn2, withBeforeRemove);
- ch2[endIdx2] = endVn1;
- endVn1 = ch1[--endIdx1];
- endVn2 = ch2[--endIdx2];
- continue;
- }
- // -------------------------------------------------------------------
- if (startKey1 === endKey2) {
- // bnode moved right
- cPatch.call(startVn1, endVn2, withBeforeRemove);
- ch2[endIdx2] = startVn1;
- const nextChild = ch2[endIdx2 + 1];
- cMoveBefore.call(startVn1, nextChild, _anchor);
- startVn1 = ch1[++startIdx1];
- endVn2 = ch2[--endIdx2];
- continue;
- }
- // -------------------------------------------------------------------
- if (endKey1 === startKey2) {
- // bnode moved left
- cPatch.call(endVn1, startVn2, withBeforeRemove);
- ch2[startIdx2] = endVn1;
- const nextChild = ch1[startIdx1];
- cMoveBefore.call(endVn1, nextChild, _anchor);
- endVn1 = ch1[--endIdx1];
- startVn2 = ch2[++startIdx2];
- continue;
- }
- // -------------------------------------------------------------------
- mapping = mapping || createMapping(ch1, startIdx1, endIdx1);
- let idxInOld = mapping[startKey2];
- if (idxInOld === undefined) {
- cMount.call(startVn2, parent, cFirstNode.call(startVn1) || null);
- }
- else {
- const elmToMove = ch1[idxInOld];
- cMoveBefore.call(elmToMove, startVn1, null);
- cPatch.call(elmToMove, startVn2, withBeforeRemove);
- ch2[startIdx2] = elmToMove;
- ch1[idxInOld] = null;
- }
- startVn2 = ch2[++startIdx2];
- }
- // ---------------------------------------------------------------------
- if (startIdx1 <= endIdx1 || startIdx2 <= endIdx2) {
- if (startIdx1 > endIdx1) {
- const nextChild = ch2[endIdx2 + 1];
- const anchor = nextChild ? cFirstNode.call(nextChild) || null : _anchor;
- for (let i = startIdx2; i <= endIdx2; i++) {
- cMount.call(ch2[i], parent, anchor);
- }
- }
- else {
- for (let i = startIdx1; i <= endIdx1; i++) {
- let ch = ch1[i];
- if (ch) {
- if (withBeforeRemove) {
- beforeRemove.call(ch);
- }
- cRemove.call(ch);
- }
- }
- }
- }
- }
- beforeRemove() {
- const children = this.children;
- const l = children.length;
- if (l) {
- const beforeRemove = children[0].beforeRemove;
- for (let i = 0; i < l; i++) {
- beforeRemove.call(children[i]);
- }
- }
- }
- remove() {
- const { parentEl, anchor } = this;
- if (this.isOnlyChild) {
- nodeSetTextContent.call(parentEl, "");
- }
- else {
- const children = this.children;
- const l = children.length;
- if (l) {
- const remove = children[0].remove;
- for (let i = 0; i < l; i++) {
- remove.call(children[i]);
- }
- }
- nodeRemoveChild$1.call(parentEl, anchor);
- }
- }
- firstNode() {
- const child = this.children[0];
- return child ? child.firstNode() : undefined;
- }
- toString() {
- return this.children.map((c) => c.toString()).join("");
- }
- }
- function list(children) {
- return new VList(children);
- }
- function createMapping(ch1, startIdx1, endIdx2) {
- let mapping = {};
- for (let i = startIdx1; i <= endIdx2; i++) {
- mapping[ch1[i].key] = i;
- }
- return mapping;
+ const getDescriptor = (o, p) => Object.getOwnPropertyDescriptor(o, p);
+ const nodeProto$1 = Node.prototype;
+ const nodeInsertBefore$1 = nodeProto$1.insertBefore;
+ const nodeAppendChild = nodeProto$1.appendChild;
+ const nodeRemoveChild$1 = nodeProto$1.removeChild;
+ const nodeSetTextContent = getDescriptor(nodeProto$1, "textContent").set;
+ // -----------------------------------------------------------------------------
+ // List Node
+ // -----------------------------------------------------------------------------
+ class VList {
+ constructor(children) {
+ this.children = children;
+ }
+ mount(parent, afterNode) {
+ const children = this.children;
+ const _anchor = document.createTextNode("");
+ this.anchor = _anchor;
+ nodeInsertBefore$1.call(parent, _anchor, afterNode);
+ const l = children.length;
+ if (l) {
+ const mount = children[0].mount;
+ for (let i = 0; i < l; i++) {
+ mount.call(children[i], parent, _anchor);
+ }
+ }
+ this.parentEl = parent;
+ }
+ moveBeforeDOMNode(node, parent = this.parentEl) {
+ this.parentEl = parent;
+ const children = this.children;
+ for (let i = 0, l = children.length; i < l; i++) {
+ children[i].moveBeforeDOMNode(node, parent);
+ }
+ parent.insertBefore(this.anchor, node);
+ }
+ moveBeforeVNode(other, afterNode) {
+ if (other) {
+ const next = other.children[0];
+ afterNode = (next ? next.firstNode() : other.anchor) || null;
+ }
+ const children = this.children;
+ for (let i = 0, l = children.length; i < l; i++) {
+ children[i].moveBeforeVNode(null, afterNode);
+ }
+ this.parentEl.insertBefore(this.anchor, afterNode);
+ }
+ patch(other, withBeforeRemove) {
+ if (this === other) {
+ return;
+ }
+ const ch1 = this.children;
+ const ch2 = other.children;
+ if (ch2.length === 0 && ch1.length === 0) {
+ return;
+ }
+ this.children = ch2;
+ const proto = ch2[0] || ch1[0];
+ const { mount: cMount, patch: cPatch, remove: cRemove, beforeRemove, moveBeforeVNode: cMoveBefore, firstNode: cFirstNode, } = proto;
+ const _anchor = this.anchor;
+ const isOnlyChild = this.isOnlyChild;
+ const parent = this.parentEl;
+ // fast path: no new child => only remove
+ if (ch2.length === 0 && isOnlyChild) {
+ if (withBeforeRemove) {
+ for (let i = 0, l = ch1.length; i < l; i++) {
+ beforeRemove.call(ch1[i]);
+ }
+ }
+ nodeSetTextContent.call(parent, "");
+ nodeAppendChild.call(parent, _anchor);
+ return;
+ }
+ let startIdx1 = 0;
+ let startIdx2 = 0;
+ let startVn1 = ch1[0];
+ let startVn2 = ch2[0];
+ let endIdx1 = ch1.length - 1;
+ let endIdx2 = ch2.length - 1;
+ let endVn1 = ch1[endIdx1];
+ let endVn2 = ch2[endIdx2];
+ let mapping = undefined;
+ while (startIdx1 <= endIdx1 && startIdx2 <= endIdx2) {
+ // -------------------------------------------------------------------
+ if (startVn1 === null) {
+ startVn1 = ch1[++startIdx1];
+ continue;
+ }
+ // -------------------------------------------------------------------
+ if (endVn1 === null) {
+ endVn1 = ch1[--endIdx1];
+ continue;
+ }
+ // -------------------------------------------------------------------
+ let startKey1 = startVn1.key;
+ let startKey2 = startVn2.key;
+ if (startKey1 === startKey2) {
+ cPatch.call(startVn1, startVn2, withBeforeRemove);
+ ch2[startIdx2] = startVn1;
+ startVn1 = ch1[++startIdx1];
+ startVn2 = ch2[++startIdx2];
+ continue;
+ }
+ // -------------------------------------------------------------------
+ let endKey1 = endVn1.key;
+ let endKey2 = endVn2.key;
+ if (endKey1 === endKey2) {
+ cPatch.call(endVn1, endVn2, withBeforeRemove);
+ ch2[endIdx2] = endVn1;
+ endVn1 = ch1[--endIdx1];
+ endVn2 = ch2[--endIdx2];
+ continue;
+ }
+ // -------------------------------------------------------------------
+ if (startKey1 === endKey2) {
+ // bnode moved right
+ cPatch.call(startVn1, endVn2, withBeforeRemove);
+ ch2[endIdx2] = startVn1;
+ const nextChild = ch2[endIdx2 + 1];
+ cMoveBefore.call(startVn1, nextChild, _anchor);
+ startVn1 = ch1[++startIdx1];
+ endVn2 = ch2[--endIdx2];
+ continue;
+ }
+ // -------------------------------------------------------------------
+ if (endKey1 === startKey2) {
+ // bnode moved left
+ cPatch.call(endVn1, startVn2, withBeforeRemove);
+ ch2[startIdx2] = endVn1;
+ const nextChild = ch1[startIdx1];
+ cMoveBefore.call(endVn1, nextChild, _anchor);
+ endVn1 = ch1[--endIdx1];
+ startVn2 = ch2[++startIdx2];
+ continue;
+ }
+ // -------------------------------------------------------------------
+ mapping = mapping || createMapping(ch1, startIdx1, endIdx1);
+ let idxInOld = mapping[startKey2];
+ if (idxInOld === undefined) {
+ cMount.call(startVn2, parent, cFirstNode.call(startVn1) || null);
+ }
+ else {
+ const elmToMove = ch1[idxInOld];
+ cMoveBefore.call(elmToMove, startVn1, null);
+ cPatch.call(elmToMove, startVn2, withBeforeRemove);
+ ch2[startIdx2] = elmToMove;
+ ch1[idxInOld] = null;
+ }
+ startVn2 = ch2[++startIdx2];
+ }
+ // ---------------------------------------------------------------------
+ if (startIdx1 <= endIdx1 || startIdx2 <= endIdx2) {
+ if (startIdx1 > endIdx1) {
+ const nextChild = ch2[endIdx2 + 1];
+ const anchor = nextChild ? cFirstNode.call(nextChild) || null : _anchor;
+ for (let i = startIdx2; i <= endIdx2; i++) {
+ cMount.call(ch2[i], parent, anchor);
+ }
+ }
+ else {
+ for (let i = startIdx1; i <= endIdx1; i++) {
+ let ch = ch1[i];
+ if (ch) {
+ if (withBeforeRemove) {
+ beforeRemove.call(ch);
+ }
+ cRemove.call(ch);
+ }
+ }
+ }
+ }
+ }
+ beforeRemove() {
+ const children = this.children;
+ const l = children.length;
+ if (l) {
+ const beforeRemove = children[0].beforeRemove;
+ for (let i = 0; i < l; i++) {
+ beforeRemove.call(children[i]);
+ }
+ }
+ }
+ remove() {
+ const { parentEl, anchor } = this;
+ if (this.isOnlyChild) {
+ nodeSetTextContent.call(parentEl, "");
+ }
+ else {
+ const children = this.children;
+ const l = children.length;
+ if (l) {
+ const remove = children[0].remove;
+ for (let i = 0; i < l; i++) {
+ remove.call(children[i]);
+ }
+ }
+ nodeRemoveChild$1.call(parentEl, anchor);
+ }
+ }
+ firstNode() {
+ const child = this.children[0];
+ return child ? child.firstNode() : undefined;
+ }
+ toString() {
+ return this.children.map((c) => c.toString()).join("");
+ }
+ }
+ function list(children) {
+ return new VList(children);
+ }
+ function createMapping(ch1, startIdx1, endIdx2) {
+ let mapping = {};
+ for (let i = startIdx1; i <= endIdx2; i++) {
+ mapping[ch1[i].key] = i;
+ }
+ return mapping;
}
- const nodeProto = Node.prototype;
- const nodeInsertBefore = nodeProto.insertBefore;
- const nodeRemoveChild = nodeProto.removeChild;
- class VHtml {
- constructor(html) {
- this.content = [];
- this.html = html;
- }
- mount(parent, afterNode) {
- this.parentEl = parent;
- const template = document.createElement("template");
- template.innerHTML = this.html;
- this.content = [...template.content.childNodes];
- for (let elem of this.content) {
- nodeInsertBefore.call(parent, elem, afterNode);
- }
- if (!this.content.length) {
- const textNode = document.createTextNode("");
- this.content.push(textNode);
- nodeInsertBefore.call(parent, textNode, afterNode);
- }
- }
- moveBeforeDOMNode(node, parent = this.parentEl) {
- this.parentEl = parent;
- for (let elem of this.content) {
- nodeInsertBefore.call(parent, elem, node);
- }
- }
- moveBeforeVNode(other, afterNode) {
- const target = other ? other.content[0] : afterNode;
- this.moveBeforeDOMNode(target);
- }
- patch(other) {
- if (this === other) {
- return;
- }
- const html2 = other.html;
- if (this.html !== html2) {
- const parent = this.parentEl;
- // insert new html in front of current
- const afterNode = this.content[0];
- const template = document.createElement("template");
- template.innerHTML = html2;
- const content = [...template.content.childNodes];
- for (let elem of content) {
- nodeInsertBefore.call(parent, elem, afterNode);
- }
- if (!content.length) {
- const textNode = document.createTextNode("");
- content.push(textNode);
- nodeInsertBefore.call(parent, textNode, afterNode);
- }
- // remove current content
- this.remove();
- this.content = content;
- this.html = other.html;
- }
- }
- beforeRemove() { }
- remove() {
- const parent = this.parentEl;
- for (let elem of this.content) {
- nodeRemoveChild.call(parent, elem);
- }
- }
- firstNode() {
- return this.content[0];
- }
- toString() {
- return this.html;
- }
- }
- function html(str) {
- return new VHtml(str);
+ const nodeProto = Node.prototype;
+ const nodeInsertBefore = nodeProto.insertBefore;
+ const nodeRemoveChild = nodeProto.removeChild;
+ class VHtml {
+ constructor(html) {
+ this.content = [];
+ this.html = html;
+ }
+ mount(parent, afterNode) {
+ this.parentEl = parent;
+ // THERP SECURITY PATCH: raw OWL Markup/HTML rendering is disabled.
+ //
+ // Therp Timer uses precompiled templates and does not require raw
+ // Markup injection. Fail closed: render Markup values literally.
+ const textNode = document.createTextNode(String(this.html ?? ""));
+ this.content = [textNode];
+ nodeInsertBefore.call(parent, textNode, afterNode);
+ }
+ moveBeforeDOMNode(node, parent = this.parentEl) {
+ this.parentEl = parent;
+ for (let elem of this.content) {
+ nodeInsertBefore.call(parent, elem, node);
+ }
+ }
+ moveBeforeVNode(other, afterNode) {
+ const target = other ? other.content[0] : afterNode;
+ this.moveBeforeDOMNode(target);
+ }
+ patch(other) {
+ if (this === other) {
+ return;
+ }
+ const html2 = other.html;
+ if (this.html !== html2) {
+ const parent = this.parentEl;
+ const afterNode = this.content[0];
+ // THERP SECURITY PATCH: keep VHtml fail-closed on updates too.
+ const textNode = document.createTextNode(String(html2 ?? ""));
+ const content = [textNode];
+ nodeInsertBefore.call(parent, textNode, afterNode);
+ // remove current content
+ this.remove();
+ this.content = content;
+ this.html = other.html;
+ }
+ }
+ beforeRemove() { }
+ remove() {
+ const parent = this.parentEl;
+ for (let elem of this.content) {
+ nodeRemoveChild.call(parent, elem);
+ }
+ }
+ firstNode() {
+ return this.content[0];
+ }
+ toString() {
+ return this.html;
+ }
+ }
+ function html(str) {
+ return new VHtml(str);
}
- function createCatcher(eventsSpec) {
- const n = Object.keys(eventsSpec).length;
- class VCatcher {
- constructor(child, handlers) {
- this.handlerFns = [];
- this.afterNode = null;
- this.child = child;
- this.handlerData = handlers;
- }
- mount(parent, afterNode) {
- this.parentEl = parent;
- this.child.mount(parent, afterNode);
- this.afterNode = document.createTextNode("");
- parent.insertBefore(this.afterNode, afterNode);
- this.wrapHandlerData();
- for (let name in eventsSpec) {
- const index = eventsSpec[name];
- const handler = createEventHandler(name);
- this.handlerFns[index] = handler;
- handler.setup.call(parent, this.handlerData[index]);
- }
- }
- wrapHandlerData() {
- for (let i = 0; i < n; i++) {
- let handler = this.handlerData[i];
- // handler = [...mods, fn, comp], so we need to replace second to last elem
- let idx = handler.length - 2;
- let origFn = handler[idx];
- const self = this;
- handler[idx] = function (ev) {
- const target = ev.target;
- let currentNode = self.child.firstNode();
- const afterNode = self.afterNode;
- while (currentNode && currentNode !== afterNode) {
- if (currentNode.contains(target)) {
- return origFn.call(this, ev);
- }
- currentNode = currentNode.nextSibling;
- }
- };
- }
- }
- moveBeforeDOMNode(node, parent = this.parentEl) {
- this.parentEl = parent;
- this.child.moveBeforeDOMNode(node, parent);
- parent.insertBefore(this.afterNode, node);
- }
- moveBeforeVNode(other, afterNode) {
- if (other) {
- // check this with @ged-odoo for use in foreach
- afterNode = other.firstNode() || afterNode;
- }
- this.child.moveBeforeVNode(other ? other.child : null, afterNode);
- this.parentEl.insertBefore(this.afterNode, afterNode);
- }
- patch(other, withBeforeRemove) {
- if (this === other) {
- return;
- }
- this.handlerData = other.handlerData;
- this.wrapHandlerData();
- for (let i = 0; i < n; i++) {
- this.handlerFns[i].update.call(this.parentEl, this.handlerData[i]);
- }
- this.child.patch(other.child, withBeforeRemove);
- }
- beforeRemove() {
- this.child.beforeRemove();
- }
- remove() {
- for (let i = 0; i < n; i++) {
- this.handlerFns[i].remove.call(this.parentEl);
- }
- this.child.remove();
- this.afterNode.remove();
- }
- firstNode() {
- return this.child.firstNode();
- }
- toString() {
- return this.child.toString();
- }
- }
- return function (child, handlers) {
- return new VCatcher(child, handlers);
- };
+ function createCatcher(eventsSpec) {
+ const n = Object.keys(eventsSpec).length;
+ class VCatcher {
+ constructor(child, handlers) {
+ this.handlerFns = [];
+ this.afterNode = null;
+ this.child = child;
+ this.handlerData = handlers;
+ }
+ mount(parent, afterNode) {
+ this.parentEl = parent;
+ this.child.mount(parent, afterNode);
+ this.afterNode = document.createTextNode("");
+ parent.insertBefore(this.afterNode, afterNode);
+ this.wrapHandlerData();
+ for (let name in eventsSpec) {
+ const index = eventsSpec[name];
+ const handler = createEventHandler(name);
+ this.handlerFns[index] = handler;
+ handler.setup.call(parent, this.handlerData[index]);
+ }
+ }
+ wrapHandlerData() {
+ for (let i = 0; i < n; i++) {
+ let handler = this.handlerData[i];
+ // handler = [...mods, fn, comp], so we need to replace second to last elem
+ let idx = handler.length - 2;
+ let origFn = handler[idx];
+ const self = this;
+ handler[idx] = function (ev) {
+ const target = ev.target;
+ let currentNode = self.child.firstNode();
+ const afterNode = self.afterNode;
+ while (currentNode && currentNode !== afterNode) {
+ if (currentNode.contains(target)) {
+ return origFn.call(this, ev);
+ }
+ currentNode = currentNode.nextSibling;
+ }
+ };
+ }
+ }
+ moveBeforeDOMNode(node, parent = this.parentEl) {
+ this.parentEl = parent;
+ this.child.moveBeforeDOMNode(node, parent);
+ parent.insertBefore(this.afterNode, node);
+ }
+ moveBeforeVNode(other, afterNode) {
+ if (other) {
+ // check this with @ged-odoo for use in foreach
+ afterNode = other.firstNode() || afterNode;
+ }
+ this.child.moveBeforeVNode(other ? other.child : null, afterNode);
+ this.parentEl.insertBefore(this.afterNode, afterNode);
+ }
+ patch(other, withBeforeRemove) {
+ if (this === other) {
+ return;
+ }
+ this.handlerData = other.handlerData;
+ this.wrapHandlerData();
+ for (let i = 0; i < n; i++) {
+ this.handlerFns[i].update.call(this.parentEl, this.handlerData[i]);
+ }
+ this.child.patch(other.child, withBeforeRemove);
+ }
+ beforeRemove() {
+ this.child.beforeRemove();
+ }
+ remove() {
+ for (let i = 0; i < n; i++) {
+ this.handlerFns[i].remove.call(this.parentEl);
+ }
+ this.child.remove();
+ this.afterNode.remove();
+ }
+ firstNode() {
+ return this.child.firstNode();
+ }
+ toString() {
+ return this.child.toString();
+ }
+ }
+ return function (child, handlers) {
+ return new VCatcher(child, handlers);
+ };
}
- function mount$1(vnode, fixture, afterNode = null) {
- vnode.mount(fixture, afterNode);
- }
- function patch(vnode1, vnode2, withBeforeRemove = false) {
- vnode1.patch(vnode2, withBeforeRemove);
- }
- function remove(vnode, withBeforeRemove = false) {
- if (withBeforeRemove) {
- vnode.beforeRemove();
- }
- vnode.remove();
+ function mount$1(vnode, fixture, afterNode = null) {
+ vnode.mount(fixture, afterNode);
+ }
+ function patch(vnode1, vnode2, withBeforeRemove = false) {
+ vnode1.patch(vnode2, withBeforeRemove);
+ }
+ function remove(vnode, withBeforeRemove = false) {
+ if (withBeforeRemove) {
+ vnode.beforeRemove();
+ }
+ vnode.remove();
}
- // Maps fibers to thrown errors
- const fibersInError = new WeakMap();
- const nodeErrorHandlers = new WeakMap();
- function _handleError(node, error) {
- if (!node) {
- return false;
- }
- const fiber = node.fiber;
- if (fiber) {
- fibersInError.set(fiber, error);
- }
- const errorHandlers = nodeErrorHandlers.get(node);
- if (errorHandlers) {
- let handled = false;
- // execute in the opposite order
- for (let i = errorHandlers.length - 1; i >= 0; i--) {
- try {
- errorHandlers[i](error);
- handled = true;
- break;
- }
- catch (e) {
- error = e;
- }
- }
- if (handled) {
- return true;
- }
- }
- return _handleError(node.parent, error);
- }
- function handleError(params) {
- let { error } = params;
- // Wrap error if it wasn't wrapped by wrapError (ie when not in dev mode)
- if (!(error instanceof OwlError)) {
- error = Object.assign(new OwlError(`An error occured in the owl lifecycle (see this Error's "cause" property)`), { cause: error });
- }
- const node = "node" in params ? params.node : params.fiber.node;
- const fiber = "fiber" in params ? params.fiber : node.fiber;
- if (fiber) {
- // resets the fibers on components if possible. This is important so that
- // new renderings can be properly included in the initial one, if any.
- let current = fiber;
- do {
- current.node.fiber = current;
- fibersInError.set(current, error);
- current = current.parent;
- } while (current);
- fibersInError.set(fiber.root, error);
- }
- const handled = _handleError(node, error);
- if (!handled) {
- console.warn(`[Owl] Unhandled error. Destroying the root component`);
- try {
- node.app.destroy();
- }
- catch (e) {
- console.error(e);
- }
- throw error;
- }
+ // Maps fibers to thrown errors
+ const fibersInError = new WeakMap();
+ const nodeErrorHandlers = new WeakMap();
+ function _handleError(node, error) {
+ if (!node) {
+ return false;
+ }
+ const fiber = node.fiber;
+ if (fiber) {
+ fibersInError.set(fiber, error);
+ }
+ const errorHandlers = nodeErrorHandlers.get(node);
+ if (errorHandlers) {
+ let handled = false;
+ // execute in the opposite order
+ for (let i = errorHandlers.length - 1; i >= 0; i--) {
+ try {
+ errorHandlers[i](error);
+ handled = true;
+ break;
+ }
+ catch (e) {
+ error = e;
+ }
+ }
+ if (handled) {
+ return true;
+ }
+ }
+ return _handleError(node.parent, error);
+ }
+ function handleError(params) {
+ let { error } = params;
+ // Wrap error if it wasn't wrapped by wrapError (ie when not in dev mode)
+ if (!(error instanceof OwlError)) {
+ error = Object.assign(new OwlError(`An error occured in the owl lifecycle (see this Error's "cause" property)`), { cause: error });
+ }
+ const node = "node" in params ? params.node : params.fiber.node;
+ const fiber = "fiber" in params ? params.fiber : node.fiber;
+ if (fiber) {
+ // resets the fibers on components if possible. This is important so that
+ // new renderings can be properly included in the initial one, if any.
+ let current = fiber;
+ do {
+ current.node.fiber = current;
+ fibersInError.set(current, error);
+ current = current.parent;
+ } while (current);
+ fibersInError.set(fiber.root, error);
+ }
+ const handled = _handleError(node, error);
+ if (!handled) {
+ console.warn(`[Owl] Unhandled error. Destroying the root component`);
+ try {
+ node.app.destroy();
+ }
+ catch (e) {
+ console.error(e);
+ }
+ throw error;
+ }
}
- function makeChildFiber(node, parent) {
- let current = node.fiber;
- if (current) {
- cancelFibers(current.children);
- current.root = null;
- }
- return new Fiber(node, parent);
- }
- function makeRootFiber(node) {
- let current = node.fiber;
- if (current) {
- let root = current.root;
- // lock root fiber because canceling children fibers may destroy components,
- // which means any arbitrary code can be run in onWillDestroy, which may
- // trigger new renderings
- root.locked = true;
- root.setCounter(root.counter + 1 - cancelFibers(current.children));
- root.locked = false;
- current.children = [];
- current.childrenMap = {};
- current.bdom = null;
- if (fibersInError.has(current)) {
- fibersInError.delete(current);
- fibersInError.delete(root);
- current.appliedToDom = false;
- if (current instanceof RootFiber) {
- // it is possible that this fiber is a fiber that crashed while being
- // mounted, so the mounted list is possibly corrupted. We restore it to
- // its normal initial state (which is empty list or a list with a mount
- // fiber.
- current.mounted = current instanceof MountFiber ? [current] : [];
- }
- }
- return current;
- }
- const fiber = new RootFiber(node, null);
- if (node.willPatch.length) {
- fiber.willPatch.push(fiber);
- }
- if (node.patched.length) {
- fiber.patched.push(fiber);
- }
- return fiber;
- }
- function throwOnRender() {
- throw new OwlError("Attempted to render cancelled fiber");
- }
- /**
- * @returns number of not-yet rendered fibers cancelled
- */
- function cancelFibers(fibers) {
- let result = 0;
- for (let fiber of fibers) {
- let node = fiber.node;
- fiber.render = throwOnRender;
- if (node.status === 0 /* NEW */) {
- node.cancel();
- }
- node.fiber = null;
- if (fiber.bdom) {
- // if fiber has been rendered, this means that the component props have
- // been updated. however, this fiber will not be patched to the dom, so
- // it could happen that the next render compare the current props with
- // the same props, and skip the render completely. With the next line,
- // we kindly request the component code to force a render, so it works as
- // expected.
- node.forceNextRender = true;
- }
- else {
- result++;
- }
- result += cancelFibers(fiber.children);
- }
- return result;
- }
- class Fiber {
- constructor(node, parent) {
- this.bdom = null;
- this.children = [];
- this.appliedToDom = false;
- this.deep = false;
- this.childrenMap = {};
- this.node = node;
- this.parent = parent;
- if (parent) {
- this.deep = parent.deep;
- const root = parent.root;
- root.setCounter(root.counter + 1);
- this.root = root;
- parent.children.push(this);
- }
- else {
- this.root = this;
- }
- }
- render() {
- // if some parent has a fiber => register in followup
- let prev = this.root.node;
- let scheduler = prev.app.scheduler;
- let current = prev.parent;
- while (current) {
- if (current.fiber) {
- let root = current.fiber.root;
- if (root.counter === 0 && prev.parentKey in current.fiber.childrenMap) {
- current = root.node;
- }
- else {
- scheduler.delayedRenders.push(this);
- return;
- }
- }
- prev = current;
- current = current.parent;
- }
- // there are no current rendering from above => we can render
- this._render();
- }
- _render() {
- const node = this.node;
- const root = this.root;
- if (root) {
- try {
- this.bdom = true;
- this.bdom = node.renderFn();
- }
- catch (e) {
- node.app.handleError({ node, error: e });
- }
- root.setCounter(root.counter - 1);
- }
- }
- }
- class RootFiber extends Fiber {
- constructor() {
- super(...arguments);
- this.counter = 1;
- // only add stuff in this if they have registered some hooks
- this.willPatch = [];
- this.patched = [];
- this.mounted = [];
- // A fiber is typically locked when it is completing and the patch has not, or is being applied.
- // i.e.: render triggered in onWillUnmount or in willPatch will be delayed
- this.locked = false;
- }
- complete() {
- const node = this.node;
- this.locked = true;
- let current = undefined;
- let mountedFibers = this.mounted;
- try {
- // Step 1: calling all willPatch lifecycle hooks
- for (current of this.willPatch) {
- // because of the asynchronous nature of the rendering, some parts of the
- // UI may have been rendered, then deleted in a followup rendering, and we
- // do not want to call onWillPatch in that case.
- let node = current.node;
- if (node.fiber === current) {
- const component = node.component;
- for (let cb of node.willPatch) {
- cb.call(component);
- }
- }
- }
- current = undefined;
- // Step 2: patching the dom
- node._patch();
- this.locked = false;
- // Step 4: calling all mounted lifecycle hooks
- while ((current = mountedFibers.pop())) {
- current = current;
- if (current.appliedToDom) {
- for (let cb of current.node.mounted) {
- cb();
- }
- }
- }
- // Step 5: calling all patched hooks
- let patchedFibers = this.patched;
- while ((current = patchedFibers.pop())) {
- current = current;
- if (current.appliedToDom) {
- for (let cb of current.node.patched) {
- cb();
- }
- }
- }
- }
- catch (e) {
- // if mountedFibers is not empty, this means that a crash occured while
- // calling the mounted hooks of some component. So, there may still be
- // some component that have been mounted, but for which the mounted hooks
- // have not been called. Here, we remove the willUnmount hooks for these
- // specific component to prevent a worse situation (willUnmount being
- // called even though mounted has not been called)
- for (let fiber of mountedFibers) {
- fiber.node.willUnmount = [];
- }
- this.locked = false;
- node.app.handleError({ fiber: current || this, error: e });
- }
- }
- setCounter(newValue) {
- this.counter = newValue;
- if (newValue === 0) {
- this.node.app.scheduler.flush();
- }
- }
- }
- class MountFiber extends RootFiber {
- constructor(node, target, options = {}) {
- super(node, null);
- this.target = target;
- this.position = options.position || "last-child";
- }
- complete() {
- let current = this;
- try {
- const node = this.node;
- node.children = this.childrenMap;
- node.app.constructor.validateTarget(this.target);
- if (node.bdom) {
- // this is a complicated situation: if we mount a fiber with an existing
- // bdom, this means that this same fiber was already completed, mounted,
- // but a crash occurred in some mounted hook. Then, it was handled and
- // the new rendering is being applied.
- node.updateDom();
- }
- else {
- node.bdom = this.bdom;
- if (this.position === "last-child" || this.target.childNodes.length === 0) {
- mount$1(node.bdom, this.target);
- }
- else {
- const firstChild = this.target.childNodes[0];
- mount$1(node.bdom, this.target, firstChild);
- }
- }
- // unregistering the fiber before mounted since it can do another render
- // and that the current rendering is obviously completed
- node.fiber = null;
- node.status = 1 /* MOUNTED */;
- this.appliedToDom = true;
- let mountedFibers = this.mounted;
- while ((current = mountedFibers.pop())) {
- if (current.appliedToDom) {
- for (let cb of current.node.mounted) {
- cb();
- }
- }
- }
- }
- catch (e) {
- this.node.app.handleError({ fiber: current, error: e });
- }
- }
+ function makeChildFiber(node, parent) {
+ let current = node.fiber;
+ if (current) {
+ cancelFibers(current.children);
+ current.root = null;
+ }
+ return new Fiber(node, parent);
+ }
+ function makeRootFiber(node) {
+ let current = node.fiber;
+ if (current) {
+ let root = current.root;
+ // lock root fiber because canceling children fibers may destroy components,
+ // which means any arbitrary code can be run in onWillDestroy, which may
+ // trigger new renderings
+ root.locked = true;
+ root.setCounter(root.counter + 1 - cancelFibers(current.children));
+ root.locked = false;
+ current.children = [];
+ current.childrenMap = {};
+ current.bdom = null;
+ if (fibersInError.has(current)) {
+ fibersInError.delete(current);
+ fibersInError.delete(root);
+ current.appliedToDom = false;
+ if (current instanceof RootFiber) {
+ // it is possible that this fiber is a fiber that crashed while being
+ // mounted, so the mounted list is possibly corrupted. We restore it to
+ // its normal initial state (which is empty list or a list with a mount
+ // fiber.
+ current.mounted = current instanceof MountFiber ? [current] : [];
+ }
+ }
+ return current;
+ }
+ const fiber = new RootFiber(node, null);
+ if (node.willPatch.length) {
+ fiber.willPatch.push(fiber);
+ }
+ if (node.patched.length) {
+ fiber.patched.push(fiber);
+ }
+ return fiber;
+ }
+ function throwOnRender() {
+ throw new OwlError("Attempted to render cancelled fiber");
+ }
+ /**
+ * @returns number of not-yet rendered fibers cancelled
+ */
+ function cancelFibers(fibers) {
+ let result = 0;
+ for (let fiber of fibers) {
+ let node = fiber.node;
+ fiber.render = throwOnRender;
+ if (node.status === 0 /* NEW */) {
+ node.cancel();
+ }
+ node.fiber = null;
+ if (fiber.bdom) {
+ // if fiber has been rendered, this means that the component props have
+ // been updated. however, this fiber will not be patched to the dom, so
+ // it could happen that the next render compare the current props with
+ // the same props, and skip the render completely. With the next line,
+ // we kindly request the component code to force a render, so it works as
+ // expected.
+ node.forceNextRender = true;
+ }
+ else {
+ result++;
+ }
+ result += cancelFibers(fiber.children);
+ }
+ return result;
+ }
+ class Fiber {
+ constructor(node, parent) {
+ this.bdom = null;
+ this.children = [];
+ this.appliedToDom = false;
+ this.deep = false;
+ this.childrenMap = {};
+ this.node = node;
+ this.parent = parent;
+ if (parent) {
+ this.deep = parent.deep;
+ const root = parent.root;
+ root.setCounter(root.counter + 1);
+ this.root = root;
+ parent.children.push(this);
+ }
+ else {
+ this.root = this;
+ }
+ }
+ render() {
+ // if some parent has a fiber => register in followup
+ let prev = this.root.node;
+ let scheduler = prev.app.scheduler;
+ let current = prev.parent;
+ while (current) {
+ if (current.fiber) {
+ let root = current.fiber.root;
+ if (root.counter === 0 && prev.parentKey in current.fiber.childrenMap) {
+ current = root.node;
+ }
+ else {
+ scheduler.delayedRenders.push(this);
+ return;
+ }
+ }
+ prev = current;
+ current = current.parent;
+ }
+ // there are no current rendering from above => we can render
+ this._render();
+ }
+ _render() {
+ const node = this.node;
+ const root = this.root;
+ if (root) {
+ try {
+ this.bdom = true;
+ this.bdom = node.renderFn();
+ }
+ catch (e) {
+ node.app.handleError({ node, error: e });
+ }
+ root.setCounter(root.counter - 1);
+ }
+ }
+ }
+ class RootFiber extends Fiber {
+ constructor() {
+ super(...arguments);
+ this.counter = 1;
+ // only add stuff in this if they have registered some hooks
+ this.willPatch = [];
+ this.patched = [];
+ this.mounted = [];
+ // A fiber is typically locked when it is completing and the patch has not, or is being applied.
+ // i.e.: render triggered in onWillUnmount or in willPatch will be delayed
+ this.locked = false;
+ }
+ complete() {
+ const node = this.node;
+ this.locked = true;
+ let current = undefined;
+ let mountedFibers = this.mounted;
+ try {
+ // Step 1: calling all willPatch lifecycle hooks
+ for (current of this.willPatch) {
+ // because of the asynchronous nature of the rendering, some parts of the
+ // UI may have been rendered, then deleted in a followup rendering, and we
+ // do not want to call onWillPatch in that case.
+ let node = current.node;
+ if (node.fiber === current) {
+ const component = node.component;
+ for (let cb of node.willPatch) {
+ cb.call(component);
+ }
+ }
+ }
+ current = undefined;
+ // Step 2: patching the dom
+ node._patch();
+ this.locked = false;
+ // Step 4: calling all mounted lifecycle hooks
+ while ((current = mountedFibers.pop())) {
+ current = current;
+ if (current.appliedToDom) {
+ for (let cb of current.node.mounted) {
+ cb();
+ }
+ }
+ }
+ // Step 5: calling all patched hooks
+ let patchedFibers = this.patched;
+ while ((current = patchedFibers.pop())) {
+ current = current;
+ if (current.appliedToDom) {
+ for (let cb of current.node.patched) {
+ cb();
+ }
+ }
+ }
+ }
+ catch (e) {
+ // if mountedFibers is not empty, this means that a crash occured while
+ // calling the mounted hooks of some component. So, there may still be
+ // some component that have been mounted, but for which the mounted hooks
+ // have not been called. Here, we remove the willUnmount hooks for these
+ // specific component to prevent a worse situation (willUnmount being
+ // called even though mounted has not been called)
+ for (let fiber of mountedFibers) {
+ fiber.node.willUnmount = [];
+ }
+ this.locked = false;
+ node.app.handleError({ fiber: current || this, error: e });
+ }
+ }
+ setCounter(newValue) {
+ this.counter = newValue;
+ if (newValue === 0) {
+ this.node.app.scheduler.flush();
+ }
+ }
+ }
+ class MountFiber extends RootFiber {
+ constructor(node, target, options = {}) {
+ super(node, null);
+ this.target = target;
+ this.position = options.position || "last-child";
+ }
+ complete() {
+ let current = this;
+ try {
+ const node = this.node;
+ node.children = this.childrenMap;
+ node.app.constructor.validateTarget(this.target);
+ if (node.bdom) {
+ // this is a complicated situation: if we mount a fiber with an existing
+ // bdom, this means that this same fiber was already completed, mounted,
+ // but a crash occurred in some mounted hook. Then, it was handled and
+ // the new rendering is being applied.
+ node.updateDom();
+ }
+ else {
+ node.bdom = this.bdom;
+ if (this.position === "last-child" || this.target.childNodes.length === 0) {
+ mount$1(node.bdom, this.target);
+ }
+ else {
+ const firstChild = this.target.childNodes[0];
+ mount$1(node.bdom, this.target, firstChild);
+ }
+ }
+ // unregistering the fiber before mounted since it can do another render
+ // and that the current rendering is obviously completed
+ node.fiber = null;
+ node.status = 1 /* MOUNTED */;
+ this.appliedToDom = true;
+ let mountedFibers = this.mounted;
+ while ((current = mountedFibers.pop())) {
+ if (current.appliedToDom) {
+ for (let cb of current.node.mounted) {
+ cb();
+ }
+ }
+ }
+ }
+ catch (e) {
+ this.node.app.handleError({ fiber: current, error: e });
+ }
+ }
}
- // Special key to subscribe to, to be notified of key creation/deletion
- const KEYCHANGES = Symbol("Key changes");
- // Used to specify the absence of a callback, can be used as WeakMap key but
- // should only be used as a sentinel value and never called.
- const NO_CALLBACK = () => {
- throw new Error("Called NO_CALLBACK. Owl is broken, please report this to the maintainers.");
- };
- const objectToString = Object.prototype.toString;
- const objectHasOwnProperty = Object.prototype.hasOwnProperty;
- // Use arrays because Array.includes is faster than Set.has for small arrays
- const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"];
- const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"];
- /**
- * extract "RawType" from strings like "[object RawType]" => this lets us ignore
- * many native objects such as Promise (whose toString is [object Promise])
- * or Date ([object Date]), while also supporting collections without using
- * instanceof in a loop
- *
- * @param obj the object to check
- * @returns the raw type of the object
- */
- function rawType(obj) {
- return objectToString.call(toRaw(obj)).slice(8, -1);
- }
- /**
- * Checks whether a given value can be made into a reactive object.
- *
- * @param value the value to check
- * @returns whether the value can be made reactive
- */
- function canBeMadeReactive(value) {
- if (typeof value !== "object") {
- return false;
- }
- return SUPPORTED_RAW_TYPES.includes(rawType(value));
- }
- /**
- * Creates a reactive from the given object/callback if possible and returns it,
- * returns the original object otherwise.
- *
- * @param value the value make reactive
- * @returns a reactive for the given object when possible, the original otherwise
- */
- function possiblyReactive(val, cb) {
- return canBeMadeReactive(val) ? reactive(val, cb) : val;
- }
- const skipped = new WeakSet();
- /**
- * Mark an object or array so that it is ignored by the reactivity system
- *
- * @param value the value to mark
- * @returns the object itself
- */
- function markRaw(value) {
- skipped.add(value);
- return value;
- }
- /**
- * Given a reactive objet, return the raw (non reactive) underlying object
- *
- * @param value a reactive value
- * @returns the underlying value
- */
- function toRaw(value) {
- return targets.has(value) ? targets.get(value) : value;
- }
- const targetToKeysToCallbacks = new WeakMap();
- /**
- * Observes a given key on a target with an callback. The callback will be
- * called when the given key changes on the target.
- *
- * @param target the target whose key should be observed
- * @param key the key to observe (or Symbol(KEYCHANGES) for key creation
- * or deletion)
- * @param callback the function to call when the key changes
- */
- function observeTargetKey(target, key, callback) {
- if (callback === NO_CALLBACK) {
- return;
- }
- if (!targetToKeysToCallbacks.get(target)) {
- targetToKeysToCallbacks.set(target, new Map());
- }
- const keyToCallbacks = targetToKeysToCallbacks.get(target);
- if (!keyToCallbacks.get(key)) {
- keyToCallbacks.set(key, new Set());
- }
- keyToCallbacks.get(key).add(callback);
- if (!callbacksToTargets.has(callback)) {
- callbacksToTargets.set(callback, new Set());
- }
- callbacksToTargets.get(callback).add(target);
- }
- /**
- * Notify Reactives that are observing a given target that a key has changed on
- * the target.
- *
- * @param target target whose Reactives should be notified that the target was
- * changed.
- * @param key the key that changed (or Symbol `KEYCHANGES` if a key was created
- * or deleted)
- */
- function notifyReactives(target, key) {
- const keyToCallbacks = targetToKeysToCallbacks.get(target);
- if (!keyToCallbacks) {
- return;
- }
- const callbacks = keyToCallbacks.get(key);
- if (!callbacks) {
- return;
- }
- // Loop on copy because clearReactivesForCallback will modify the set in place
- for (const callback of [...callbacks]) {
- clearReactivesForCallback(callback);
- callback();
- }
- }
- const callbacksToTargets = new WeakMap();
- /**
- * Clears all subscriptions of the Reactives associated with a given callback.
- *
- * @param callback the callback for which the reactives need to be cleared
- */
- function clearReactivesForCallback(callback) {
- const targetsToClear = callbacksToTargets.get(callback);
- if (!targetsToClear) {
- return;
- }
- for (const target of targetsToClear) {
- const observedKeys = targetToKeysToCallbacks.get(target);
- if (!observedKeys) {
- continue;
- }
- for (const [key, callbacks] of observedKeys.entries()) {
- callbacks.delete(callback);
- if (!callbacks.size) {
- observedKeys.delete(key);
- }
- }
- }
- targetsToClear.clear();
- }
- function getSubscriptions(callback) {
- const targets = callbacksToTargets.get(callback) || [];
- return [...targets].map((target) => {
- const keysToCallbacks = targetToKeysToCallbacks.get(target);
- let keys = [];
- if (keysToCallbacks) {
- for (const [key, cbs] of keysToCallbacks) {
- if (cbs.has(callback)) {
- keys.push(key);
- }
- }
- }
- return { target, keys };
- });
- }
- // Maps reactive objects to the underlying target
- const targets = new WeakMap();
- const reactiveCache = new WeakMap();
- /**
- * Creates a reactive proxy for an object. Reading data on the reactive object
- * subscribes to changes to the data. Writing data on the object will cause the
- * notify callback to be called if there are suscriptions to that data. Nested
- * objects and arrays are automatically made reactive as well.
- *
- * Whenever you are notified of a change, all subscriptions are cleared, and if
- * you would like to be notified of any further changes, you should go read
- * the underlying data again. We assume that if you don't go read it again after
- * being notified, it means that you are no longer interested in that data.
- *
- * Subscriptions:
- * + Reading a property on an object will subscribe you to changes in the value
- * of that property.
- * + Accessing an object's keys (eg with Object.keys or with `for..in`) will
- * subscribe you to the creation/deletion of keys. Checking the presence of a
- * key on the object with 'in' has the same effect.
- * - getOwnPropertyDescriptor does not currently subscribe you to the property.
- * This is a choice that was made because changing a key's value will trigger
- * this trap and we do not want to subscribe by writes. This also means that
- * Object.hasOwnProperty doesn't subscribe as it goes through this trap.
- *
- * @param target the object for which to create a reactive proxy
- * @param callback the function to call when an observed property of the
- * reactive has changed
- * @returns a proxy that tracks changes to it
- */
- function reactive(target, callback = NO_CALLBACK) {
- if (!canBeMadeReactive(target)) {
- throw new OwlError(`Cannot make the given value reactive`);
- }
- if (skipped.has(target)) {
- return target;
- }
- if (targets.has(target)) {
- // target is reactive, create a reactive on the underlying object instead
- return reactive(targets.get(target), callback);
- }
- if (!reactiveCache.has(target)) {
- reactiveCache.set(target, new WeakMap());
- }
- const reactivesForTarget = reactiveCache.get(target);
- if (!reactivesForTarget.has(callback)) {
- const targetRawType = rawType(target);
- const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
- ? collectionsProxyHandler(target, callback, targetRawType)
- : basicProxyHandler(callback);
- const proxy = new Proxy(target, handler);
- reactivesForTarget.set(callback, proxy);
- targets.set(proxy, target);
- }
- return reactivesForTarget.get(callback);
- }
- /**
- * Creates a basic proxy handler for regular objects and arrays.
- *
- * @param callback @see reactive
- * @returns a proxy handler object
- */
- function basicProxyHandler(callback) {
- return {
- get(target, key, receiver) {
- // non-writable non-configurable properties cannot be made reactive
- const desc = Object.getOwnPropertyDescriptor(target, key);
- if (desc && !desc.writable && !desc.configurable) {
- return Reflect.get(target, key, receiver);
- }
- observeTargetKey(target, key, callback);
- return possiblyReactive(Reflect.get(target, key, receiver), callback);
- },
- set(target, key, value, receiver) {
- const hadKey = objectHasOwnProperty.call(target, key);
- const originalValue = Reflect.get(target, key, receiver);
- const ret = Reflect.set(target, key, toRaw(value), receiver);
- if (!hadKey && objectHasOwnProperty.call(target, key)) {
- notifyReactives(target, KEYCHANGES);
- }
- // While Array length may trigger the set trap, it's not actually set by this
- // method but is updated behind the scenes, and the trap is not called with the
- // new value. We disable the "same-value-optimization" for it because of that.
- if (originalValue !== Reflect.get(target, key, receiver) ||
- (key === "length" && Array.isArray(target))) {
- notifyReactives(target, key);
- }
- return ret;
- },
- deleteProperty(target, key) {
- const ret = Reflect.deleteProperty(target, key);
- // TODO: only notify when something was actually deleted
- notifyReactives(target, KEYCHANGES);
- notifyReactives(target, key);
- return ret;
- },
- ownKeys(target) {
- observeTargetKey(target, KEYCHANGES, callback);
- return Reflect.ownKeys(target);
- },
- has(target, key) {
- // TODO: this observes all key changes instead of only the presence of the argument key
- // observing the key itself would observe value changes instead of presence changes
- // so we may need a finer grained system to distinguish observing value vs presence.
- observeTargetKey(target, KEYCHANGES, callback);
- return Reflect.has(target, key);
- },
- };
- }
- /**
- * Creates a function that will observe the key that is passed to it when called
- * and delegates to the underlying method.
- *
- * @param methodName name of the method to delegate to
- * @param target @see reactive
- * @param callback @see reactive
- */
- function makeKeyObserver(methodName, target, callback) {
- return (key) => {
- key = toRaw(key);
- observeTargetKey(target, key, callback);
- return possiblyReactive(target[methodName](key), callback);
- };
- }
- /**
- * Creates an iterable that will delegate to the underlying iteration method and
- * observe keys as necessary.
- *
- * @param methodName name of the method to delegate to
- * @param target @see reactive
- * @param callback @see reactive
- */
- function makeIteratorObserver(methodName, target, callback) {
- return function* () {
- observeTargetKey(target, KEYCHANGES, callback);
- const keys = target.keys();
- for (const item of target[methodName]()) {
- const key = keys.next().value;
- observeTargetKey(target, key, callback);
- yield possiblyReactive(item, callback);
- }
- };
- }
- /**
- * Creates a forEach function that will delegate to forEach on the underlying
- * collection while observing key changes, and keys as they're iterated over,
- * and making the passed keys/values reactive.
- *
- * @param target @see reactive
- * @param callback @see reactive
- */
- function makeForEachObserver(target, callback) {
- return function forEach(forEachCb, thisArg) {
- observeTargetKey(target, KEYCHANGES, callback);
- target.forEach(function (val, key, targetObj) {
- observeTargetKey(target, key, callback);
- forEachCb.call(thisArg, possiblyReactive(val, callback), possiblyReactive(key, callback), possiblyReactive(targetObj, callback));
- }, thisArg);
- };
- }
- /**
- * Creates a function that will delegate to an underlying method, and check if
- * that method has modified the presence or value of a key, and notify the
- * reactives appropriately.
- *
- * @param setterName name of the method to delegate to
- * @param getterName name of the method which should be used to retrieve the
- * value before calling the delegate method for comparison purposes
- * @param target @see reactive
- */
- function delegateAndNotify(setterName, getterName, target) {
- return (key, value) => {
- key = toRaw(key);
- const hadKey = target.has(key);
- const originalValue = target[getterName](key);
- const ret = target[setterName](key, value);
- const hasKey = target.has(key);
- if (hadKey !== hasKey) {
- notifyReactives(target, KEYCHANGES);
- }
- if (originalValue !== target[getterName](key)) {
- notifyReactives(target, key);
- }
- return ret;
- };
- }
- /**
- * Creates a function that will clear the underlying collection and notify that
- * the keys of the collection have changed.
- *
- * @param target @see reactive
- */
- function makeClearNotifier(target) {
- return () => {
- const allKeys = [...target.keys()];
- target.clear();
- notifyReactives(target, KEYCHANGES);
- for (const key of allKeys) {
- notifyReactives(target, key);
- }
- };
- }
- /**
- * Maps raw type of an object to an object containing functions that can be used
- * to build an appropritate proxy handler for that raw type. Eg: when making a
- * reactive set, calling the has method should mark the key that is being
- * retrieved as observed, and calling the add or delete method should notify the
- * reactives that the key which is being added or deleted has been modified.
- */
- const rawTypeToFuncHandlers = {
- Set: (target, callback) => ({
- has: makeKeyObserver("has", target, callback),
- add: delegateAndNotify("add", "has", target),
- delete: delegateAndNotify("delete", "has", target),
- keys: makeIteratorObserver("keys", target, callback),
- values: makeIteratorObserver("values", target, callback),
- entries: makeIteratorObserver("entries", target, callback),
- [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback),
- forEach: makeForEachObserver(target, callback),
- clear: makeClearNotifier(target),
- get size() {
- observeTargetKey(target, KEYCHANGES, callback);
- return target.size;
- },
- }),
- Map: (target, callback) => ({
- has: makeKeyObserver("has", target, callback),
- get: makeKeyObserver("get", target, callback),
- set: delegateAndNotify("set", "get", target),
- delete: delegateAndNotify("delete", "has", target),
- keys: makeIteratorObserver("keys", target, callback),
- values: makeIteratorObserver("values", target, callback),
- entries: makeIteratorObserver("entries", target, callback),
- [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback),
- forEach: makeForEachObserver(target, callback),
- clear: makeClearNotifier(target),
- get size() {
- observeTargetKey(target, KEYCHANGES, callback);
- return target.size;
- },
- }),
- WeakMap: (target, callback) => ({
- has: makeKeyObserver("has", target, callback),
- get: makeKeyObserver("get", target, callback),
- set: delegateAndNotify("set", "get", target),
- delete: delegateAndNotify("delete", "has", target),
- }),
- };
- /**
- * Creates a proxy handler for collections (Set/Map/WeakMap)
- *
- * @param callback @see reactive
- * @param target @see reactive
- * @returns a proxy handler object
- */
- function collectionsProxyHandler(target, callback, targetRawType) {
- // TODO: if performance is an issue we can create the special handlers lazily when each
- // property is read.
- const specialHandlers = rawTypeToFuncHandlers[targetRawType](target, callback);
- return Object.assign(basicProxyHandler(callback), {
- // FIXME: probably broken when part of prototype chain since we ignore the receiver
- get(target, key) {
- if (objectHasOwnProperty.call(specialHandlers, key)) {
- return specialHandlers[key];
- }
- observeTargetKey(target, key, callback);
- return possiblyReactive(target[key], callback);
- },
- });
+ // Special key to subscribe to, to be notified of key creation/deletion
+ const KEYCHANGES = Symbol("Key changes");
+ // Used to specify the absence of a callback, can be used as WeakMap key but
+ // should only be used as a sentinel value and never called.
+ const NO_CALLBACK = () => {
+ throw new Error("Called NO_CALLBACK. Owl is broken, please report this to the maintainers.");
+ };
+ const objectToString = Object.prototype.toString;
+ const objectHasOwnProperty = Object.prototype.hasOwnProperty;
+ // Use arrays because Array.includes is faster than Set.has for small arrays
+ const SUPPORTED_RAW_TYPES = ["Object", "Array", "Set", "Map", "WeakMap"];
+ const COLLECTION_RAW_TYPES = ["Set", "Map", "WeakMap"];
+ /**
+ * extract "RawType" from strings like "[object RawType]" => this lets us ignore
+ * many native objects such as Promise (whose toString is [object Promise])
+ * or Date ([object Date]), while also supporting collections without using
+ * instanceof in a loop
+ *
+ * @param obj the object to check
+ * @returns the raw type of the object
+ */
+ function rawType(obj) {
+ return objectToString.call(toRaw(obj)).slice(8, -1);
+ }
+ /**
+ * Checks whether a given value can be made into a reactive object.
+ *
+ * @param value the value to check
+ * @returns whether the value can be made reactive
+ */
+ function canBeMadeReactive(value) {
+ if (typeof value !== "object") {
+ return false;
+ }
+ return SUPPORTED_RAW_TYPES.includes(rawType(value));
+ }
+ /**
+ * Creates a reactive from the given object/callback if possible and returns it,
+ * returns the original object otherwise.
+ *
+ * @param value the value make reactive
+ * @returns a reactive for the given object when possible, the original otherwise
+ */
+ function possiblyReactive(val, cb) {
+ return canBeMadeReactive(val) ? reactive(val, cb) : val;
+ }
+ const skipped = new WeakSet();
+ /**
+ * Mark an object or array so that it is ignored by the reactivity system
+ *
+ * @param value the value to mark
+ * @returns the object itself
+ */
+ function markRaw(value) {
+ skipped.add(value);
+ return value;
+ }
+ /**
+ * Given a reactive objet, return the raw (non reactive) underlying object
+ *
+ * @param value a reactive value
+ * @returns the underlying value
+ */
+ function toRaw(value) {
+ return targets.has(value) ? targets.get(value) : value;
+ }
+ const targetToKeysToCallbacks = new WeakMap();
+ /**
+ * Observes a given key on a target with an callback. The callback will be
+ * called when the given key changes on the target.
+ *
+ * @param target the target whose key should be observed
+ * @param key the key to observe (or Symbol(KEYCHANGES) for key creation
+ * or deletion)
+ * @param callback the function to call when the key changes
+ */
+ function observeTargetKey(target, key, callback) {
+ if (callback === NO_CALLBACK) {
+ return;
+ }
+ if (!targetToKeysToCallbacks.get(target)) {
+ targetToKeysToCallbacks.set(target, new Map());
+ }
+ const keyToCallbacks = targetToKeysToCallbacks.get(target);
+ if (!keyToCallbacks.get(key)) {
+ keyToCallbacks.set(key, new Set());
+ }
+ keyToCallbacks.get(key).add(callback);
+ if (!callbacksToTargets.has(callback)) {
+ callbacksToTargets.set(callback, new Set());
+ }
+ callbacksToTargets.get(callback).add(target);
+ }
+ /**
+ * Notify Reactives that are observing a given target that a key has changed on
+ * the target.
+ *
+ * @param target target whose Reactives should be notified that the target was
+ * changed.
+ * @param key the key that changed (or Symbol `KEYCHANGES` if a key was created
+ * or deleted)
+ */
+ function notifyReactives(target, key) {
+ const keyToCallbacks = targetToKeysToCallbacks.get(target);
+ if (!keyToCallbacks) {
+ return;
+ }
+ const callbacks = keyToCallbacks.get(key);
+ if (!callbacks) {
+ return;
+ }
+ // Loop on copy because clearReactivesForCallback will modify the set in place
+ for (const callback of [...callbacks]) {
+ clearReactivesForCallback(callback);
+ callback();
+ }
+ }
+ const callbacksToTargets = new WeakMap();
+ /**
+ * Clears all subscriptions of the Reactives associated with a given callback.
+ *
+ * @param callback the callback for which the reactives need to be cleared
+ */
+ function clearReactivesForCallback(callback) {
+ const targetsToClear = callbacksToTargets.get(callback);
+ if (!targetsToClear) {
+ return;
+ }
+ for (const target of targetsToClear) {
+ const observedKeys = targetToKeysToCallbacks.get(target);
+ if (!observedKeys) {
+ continue;
+ }
+ for (const [key, callbacks] of observedKeys.entries()) {
+ callbacks.delete(callback);
+ if (!callbacks.size) {
+ observedKeys.delete(key);
+ }
+ }
+ }
+ targetsToClear.clear();
+ }
+ function getSubscriptions(callback) {
+ const targets = callbacksToTargets.get(callback) || [];
+ return [...targets].map((target) => {
+ const keysToCallbacks = targetToKeysToCallbacks.get(target);
+ let keys = [];
+ if (keysToCallbacks) {
+ for (const [key, cbs] of keysToCallbacks) {
+ if (cbs.has(callback)) {
+ keys.push(key);
+ }
+ }
+ }
+ return { target, keys };
+ });
+ }
+ // Maps reactive objects to the underlying target
+ const targets = new WeakMap();
+ const reactiveCache = new WeakMap();
+ /**
+ * Creates a reactive proxy for an object. Reading data on the reactive object
+ * subscribes to changes to the data. Writing data on the object will cause the
+ * notify callback to be called if there are suscriptions to that data. Nested
+ * objects and arrays are automatically made reactive as well.
+ *
+ * Whenever you are notified of a change, all subscriptions are cleared, and if
+ * you would like to be notified of any further changes, you should go read
+ * the underlying data again. We assume that if you don't go read it again after
+ * being notified, it means that you are no longer interested in that data.
+ *
+ * Subscriptions:
+ * + Reading a property on an object will subscribe you to changes in the value
+ * of that property.
+ * + Accessing an object's keys (eg with Object.keys or with `for..in`) will
+ * subscribe you to the creation/deletion of keys. Checking the presence of a
+ * key on the object with 'in' has the same effect.
+ * - getOwnPropertyDescriptor does not currently subscribe you to the property.
+ * This is a choice that was made because changing a key's value will trigger
+ * this trap and we do not want to subscribe by writes. This also means that
+ * Object.hasOwnProperty doesn't subscribe as it goes through this trap.
+ *
+ * @param target the object for which to create a reactive proxy
+ * @param callback the function to call when an observed property of the
+ * reactive has changed
+ * @returns a proxy that tracks changes to it
+ */
+ function reactive(target, callback = NO_CALLBACK) {
+ if (!canBeMadeReactive(target)) {
+ throw new OwlError(`Cannot make the given value reactive`);
+ }
+ if (skipped.has(target)) {
+ return target;
+ }
+ if (targets.has(target)) {
+ // target is reactive, create a reactive on the underlying object instead
+ return reactive(targets.get(target), callback);
+ }
+ if (!reactiveCache.has(target)) {
+ reactiveCache.set(target, new WeakMap());
+ }
+ const reactivesForTarget = reactiveCache.get(target);
+ if (!reactivesForTarget.has(callback)) {
+ const targetRawType = rawType(target);
+ const handler = COLLECTION_RAW_TYPES.includes(targetRawType)
+ ? collectionsProxyHandler(target, callback, targetRawType)
+ : basicProxyHandler(callback);
+ const proxy = new Proxy(target, handler);
+ reactivesForTarget.set(callback, proxy);
+ targets.set(proxy, target);
+ }
+ return reactivesForTarget.get(callback);
+ }
+ /**
+ * Creates a basic proxy handler for regular objects and arrays.
+ *
+ * @param callback @see reactive
+ * @returns a proxy handler object
+ */
+ function basicProxyHandler(callback) {
+ return {
+ get(target, key, receiver) {
+ // non-writable non-configurable properties cannot be made reactive
+ const desc = Object.getOwnPropertyDescriptor(target, key);
+ if (desc && !desc.writable && !desc.configurable) {
+ return Reflect.get(target, key, receiver);
+ }
+ observeTargetKey(target, key, callback);
+ return possiblyReactive(Reflect.get(target, key, receiver), callback);
+ },
+ set(target, key, value, receiver) {
+ const hadKey = objectHasOwnProperty.call(target, key);
+ const originalValue = Reflect.get(target, key, receiver);
+ const ret = Reflect.set(target, key, toRaw(value), receiver);
+ if (!hadKey && objectHasOwnProperty.call(target, key)) {
+ notifyReactives(target, KEYCHANGES);
+ }
+ // While Array length may trigger the set trap, it's not actually set by this
+ // method but is updated behind the scenes, and the trap is not called with the
+ // new value. We disable the "same-value-optimization" for it because of that.
+ if (originalValue !== Reflect.get(target, key, receiver) ||
+ (key === "length" && Array.isArray(target))) {
+ notifyReactives(target, key);
+ }
+ return ret;
+ },
+ deleteProperty(target, key) {
+ const ret = Reflect.deleteProperty(target, key);
+ // TODO: only notify when something was actually deleted
+ notifyReactives(target, KEYCHANGES);
+ notifyReactives(target, key);
+ return ret;
+ },
+ ownKeys(target) {
+ observeTargetKey(target, KEYCHANGES, callback);
+ return Reflect.ownKeys(target);
+ },
+ has(target, key) {
+ // TODO: this observes all key changes instead of only the presence of the argument key
+ // observing the key itself would observe value changes instead of presence changes
+ // so we may need a finer grained system to distinguish observing value vs presence.
+ observeTargetKey(target, KEYCHANGES, callback);
+ return Reflect.has(target, key);
+ },
+ };
+ }
+ /**
+ * Creates a function that will observe the key that is passed to it when called
+ * and delegates to the underlying method.
+ *
+ * @param methodName name of the method to delegate to
+ * @param target @see reactive
+ * @param callback @see reactive
+ */
+ function makeKeyObserver(methodName, target, callback) {
+ return (key) => {
+ key = toRaw(key);
+ observeTargetKey(target, key, callback);
+ return possiblyReactive(target[methodName](key), callback);
+ };
+ }
+ /**
+ * Creates an iterable that will delegate to the underlying iteration method and
+ * observe keys as necessary.
+ *
+ * @param methodName name of the method to delegate to
+ * @param target @see reactive
+ * @param callback @see reactive
+ */
+ function makeIteratorObserver(methodName, target, callback) {
+ return function* () {
+ observeTargetKey(target, KEYCHANGES, callback);
+ const keys = target.keys();
+ for (const item of target[methodName]()) {
+ const key = keys.next().value;
+ observeTargetKey(target, key, callback);
+ yield possiblyReactive(item, callback);
+ }
+ };
+ }
+ /**
+ * Creates a forEach function that will delegate to forEach on the underlying
+ * collection while observing key changes, and keys as they're iterated over,
+ * and making the passed keys/values reactive.
+ *
+ * @param target @see reactive
+ * @param callback @see reactive
+ */
+ function makeForEachObserver(target, callback) {
+ return function forEach(forEachCb, thisArg) {
+ observeTargetKey(target, KEYCHANGES, callback);
+ target.forEach(function (val, key, targetObj) {
+ observeTargetKey(target, key, callback);
+ forEachCb.call(thisArg, possiblyReactive(val, callback), possiblyReactive(key, callback), possiblyReactive(targetObj, callback));
+ }, thisArg);
+ };
+ }
+ /**
+ * Creates a function that will delegate to an underlying method, and check if
+ * that method has modified the presence or value of a key, and notify the
+ * reactives appropriately.
+ *
+ * @param setterName name of the method to delegate to
+ * @param getterName name of the method which should be used to retrieve the
+ * value before calling the delegate method for comparison purposes
+ * @param target @see reactive
+ */
+ function delegateAndNotify(setterName, getterName, target) {
+ return (key, value) => {
+ key = toRaw(key);
+ const hadKey = target.has(key);
+ const originalValue = target[getterName](key);
+ const ret = target[setterName](key, value);
+ const hasKey = target.has(key);
+ if (hadKey !== hasKey) {
+ notifyReactives(target, KEYCHANGES);
+ }
+ if (originalValue !== target[getterName](key)) {
+ notifyReactives(target, key);
+ }
+ return ret;
+ };
+ }
+ /**
+ * Creates a function that will clear the underlying collection and notify that
+ * the keys of the collection have changed.
+ *
+ * @param target @see reactive
+ */
+ function makeClearNotifier(target) {
+ return () => {
+ const allKeys = [...target.keys()];
+ target.clear();
+ notifyReactives(target, KEYCHANGES);
+ for (const key of allKeys) {
+ notifyReactives(target, key);
+ }
+ };
+ }
+ /**
+ * Maps raw type of an object to an object containing functions that can be used
+ * to build an appropritate proxy handler for that raw type. Eg: when making a
+ * reactive set, calling the has method should mark the key that is being
+ * retrieved as observed, and calling the add or delete method should notify the
+ * reactives that the key which is being added or deleted has been modified.
+ */
+ const rawTypeToFuncHandlers = {
+ Set: (target, callback) => ({
+ has: makeKeyObserver("has", target, callback),
+ add: delegateAndNotify("add", "has", target),
+ delete: delegateAndNotify("delete", "has", target),
+ keys: makeIteratorObserver("keys", target, callback),
+ values: makeIteratorObserver("values", target, callback),
+ entries: makeIteratorObserver("entries", target, callback),
+ [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback),
+ forEach: makeForEachObserver(target, callback),
+ clear: makeClearNotifier(target),
+ get size() {
+ observeTargetKey(target, KEYCHANGES, callback);
+ return target.size;
+ },
+ }),
+ Map: (target, callback) => ({
+ has: makeKeyObserver("has", target, callback),
+ get: makeKeyObserver("get", target, callback),
+ set: delegateAndNotify("set", "get", target),
+ delete: delegateAndNotify("delete", "has", target),
+ keys: makeIteratorObserver("keys", target, callback),
+ values: makeIteratorObserver("values", target, callback),
+ entries: makeIteratorObserver("entries", target, callback),
+ [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback),
+ forEach: makeForEachObserver(target, callback),
+ clear: makeClearNotifier(target),
+ get size() {
+ observeTargetKey(target, KEYCHANGES, callback);
+ return target.size;
+ },
+ }),
+ WeakMap: (target, callback) => ({
+ has: makeKeyObserver("has", target, callback),
+ get: makeKeyObserver("get", target, callback),
+ set: delegateAndNotify("set", "get", target),
+ delete: delegateAndNotify("delete", "has", target),
+ }),
+ };
+ /**
+ * Creates a proxy handler for collections (Set/Map/WeakMap)
+ *
+ * @param callback @see reactive
+ * @param target @see reactive
+ * @returns a proxy handler object
+ */
+ function collectionsProxyHandler(target, callback, targetRawType) {
+ // TODO: if performance is an issue we can create the special handlers lazily when each
+ // property is read.
+ const specialHandlers = rawTypeToFuncHandlers[targetRawType](target, callback);
+ return Object.assign(basicProxyHandler(callback), {
+ // FIXME: probably broken when part of prototype chain since we ignore the receiver
+ get(target, key) {
+ if (objectHasOwnProperty.call(specialHandlers, key)) {
+ return specialHandlers[key];
+ }
+ observeTargetKey(target, key, callback);
+ return possiblyReactive(target[key], callback);
+ },
+ });
}
- let currentNode = null;
- function saveCurrent() {
- let n = currentNode;
- return () => {
- currentNode = n;
- };
- }
- function getCurrent() {
- if (!currentNode) {
- throw new OwlError("No active component (a hook function should only be called in 'setup')");
- }
- return currentNode;
- }
- function useComponent() {
- return currentNode.component;
- }
- /**
- * Apply default props (only top level).
- */
- function applyDefaultProps(props, defaultProps) {
- for (let propName in defaultProps) {
- if (props[propName] === undefined) {
- props[propName] = defaultProps[propName];
- }
- }
- }
- // -----------------------------------------------------------------------------
- // Integration with reactivity system (useState)
- // -----------------------------------------------------------------------------
- const batchedRenderFunctions = new WeakMap();
- /**
- * Creates a reactive object that will be observed by the current component.
- * Reading data from the returned object (eg during rendering) will cause the
- * component to subscribe to that data and be rerendered when it changes.
- *
- * @param state the state to observe
- * @returns a reactive object that will cause the component to re-render on
- * relevant changes
- * @see reactive
- */
- function useState(state) {
- const node = getCurrent();
- let render = batchedRenderFunctions.get(node);
- if (!render) {
- const wrapper = { fn: batched(node.render.bind(node, false)) };
- render = (...args) => wrapper.fn(...args);
- batchedRenderFunctions.set(node, render);
- // manual implementation of onWillDestroy to break cyclic dependency
- node.willDestroy.push(cleanupRenderAndReactives.bind(null, wrapper, render));
- }
- return reactive(state, render);
- }
- const NO_OP = () => { };
- function cleanupRenderAndReactives(wrapper, render) {
- wrapper.fn = NO_OP;
- clearReactivesForCallback(render);
- }
- class ComponentNode {
- constructor(C, props, app, parent, parentKey) {
- this.fiber = null;
- this.bdom = null;
- this.status = 0 /* NEW */;
- this.forceNextRender = false;
- this.nextProps = null;
- this.children = Object.create(null);
- this.refs = {};
- this.willStart = [];
- this.willUpdateProps = [];
- this.willUnmount = [];
- this.mounted = [];
- this.willPatch = [];
- this.patched = [];
- this.willDestroy = [];
- currentNode = this;
- this.app = app;
- this.parent = parent;
- this.props = props;
- this.parentKey = parentKey;
- const defaultProps = C.defaultProps;
- props = Object.assign({}, props);
- if (defaultProps) {
- applyDefaultProps(props, defaultProps);
- }
- const env = (parent && parent.childEnv) || app.env;
- this.childEnv = env;
- for (const key in props) {
- const prop = props[key];
- if (prop && typeof prop === "object" && targets.has(prop)) {
- props[key] = useState(prop);
- }
- }
- this.component = new C(props, env, this);
- const ctx = Object.assign(Object.create(this.component), { this: this.component });
- this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
- this.component.setup();
- currentNode = null;
- }
- mountComponent(target, options) {
- const fiber = new MountFiber(this, target, options);
- this.app.scheduler.addFiber(fiber);
- this.initiateRender(fiber);
- }
- async initiateRender(fiber) {
- this.fiber = fiber;
- if (this.mounted.length) {
- fiber.root.mounted.push(fiber);
- }
- const component = this.component;
- try {
- await Promise.all(this.willStart.map((f) => f.call(component)));
- }
- catch (e) {
- this.app.handleError({ node: this, error: e });
- return;
- }
- if (this.status === 0 /* NEW */ && this.fiber === fiber) {
- fiber.render();
- }
- }
- async render(deep) {
- if (this.status >= 2 /* CANCELLED */) {
- return;
- }
- let current = this.fiber;
- if (current && (current.root.locked || current.bdom === true)) {
- await Promise.resolve();
- // situation may have changed after the microtask tick
- current = this.fiber;
- }
- if (current) {
- if (!current.bdom && !fibersInError.has(current)) {
- if (deep) {
- // we want the render from this point on to be with deep=true
- current.deep = deep;
- }
- return;
- }
- // if current rendering was with deep=true, we want this one to be the same
- deep = deep || current.deep;
- }
- else if (!this.bdom) {
- return;
- }
- const fiber = makeRootFiber(this);
- fiber.deep = deep;
- this.fiber = fiber;
- this.app.scheduler.addFiber(fiber);
- await Promise.resolve();
- if (this.status >= 2 /* CANCELLED */) {
- return;
- }
- // We only want to actually render the component if the following two
- // conditions are true:
- // * this.fiber: it could be null, in which case the render has been cancelled
- // * (current || !fiber.parent): if current is not null, this means that the
- // render function was called when a render was already occurring. In this
- // case, the pending rendering was cancelled, and the fiber needs to be
- // rendered to complete the work. If current is null, we check that the
- // fiber has no parent. If that is the case, the fiber was downgraded from
- // a root fiber to a child fiber in the previous microtick, because it was
- // embedded in a rendering coming from above, so the fiber will be rendered
- // in the next microtick anyway, so we should not render it again.
- if (this.fiber === fiber && (current || !fiber.parent)) {
- fiber.render();
- }
- }
- cancel() {
- this._cancel();
- delete this.parent.children[this.parentKey];
- this.app.scheduler.scheduleDestroy(this);
- }
- _cancel() {
- this.status = 2 /* CANCELLED */;
- const children = this.children;
- for (let childKey in children) {
- children[childKey]._cancel();
- }
- }
- destroy() {
- let shouldRemove = this.status === 1 /* MOUNTED */;
- this._destroy();
- if (shouldRemove) {
- this.bdom.remove();
- }
- }
- _destroy() {
- const component = this.component;
- if (this.status === 1 /* MOUNTED */) {
- for (let cb of this.willUnmount) {
- cb.call(component);
- }
- }
- for (let child of Object.values(this.children)) {
- child._destroy();
- }
- if (this.willDestroy.length) {
- try {
- for (let cb of this.willDestroy) {
- cb.call(component);
- }
- }
- catch (e) {
- this.app.handleError({ error: e, node: this });
- }
- }
- this.status = 3 /* DESTROYED */;
- }
- async updateAndRender(props, parentFiber) {
- this.nextProps = props;
- props = Object.assign({}, props);
- // update
- const fiber = makeChildFiber(this, parentFiber);
- this.fiber = fiber;
- const component = this.component;
- const defaultProps = component.constructor.defaultProps;
- if (defaultProps) {
- applyDefaultProps(props, defaultProps);
- }
- currentNode = this;
- for (const key in props) {
- const prop = props[key];
- if (prop && typeof prop === "object" && targets.has(prop)) {
- props[key] = useState(prop);
- }
- }
- currentNode = null;
- const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
- await prom;
- if (fiber !== this.fiber) {
- return;
- }
- component.props = props;
- fiber.render();
- const parentRoot = parentFiber.root;
- if (this.willPatch.length) {
- parentRoot.willPatch.push(fiber);
- }
- if (this.patched.length) {
- parentRoot.patched.push(fiber);
- }
- }
- /**
- * Finds a child that has dom that is not yet updated, and update it. This
- * method is meant to be used only in the context of repatching the dom after
- * a mounted hook failed and was handled.
- */
- updateDom() {
- if (!this.fiber) {
- return;
- }
- if (this.bdom === this.fiber.bdom) {
- // If the error was handled by some child component, we need to find it to
- // apply its change
- for (let k in this.children) {
- const child = this.children[k];
- child.updateDom();
- }
- }
- else {
- // if we get here, this is the component that handled the error and rerendered
- // itself, so we can simply patch the dom
- this.bdom.patch(this.fiber.bdom, false);
- this.fiber.appliedToDom = true;
- this.fiber = null;
- }
- }
- /**
- * Sets a ref to a given HTMLElement.
- *
- * @param name the name of the ref to set
- * @param el the HTMLElement to set the ref to. The ref is not set if the el
- * is null, but useRef will not return elements that are not in the DOM
- */
- setRef(name, el) {
- if (el) {
- this.refs[name] = el;
- }
- }
- // ---------------------------------------------------------------------------
- // Block DOM methods
- // ---------------------------------------------------------------------------
- firstNode() {
- const bdom = this.bdom;
- return bdom ? bdom.firstNode() : undefined;
- }
- mount(parent, anchor) {
- const bdom = this.fiber.bdom;
- this.bdom = bdom;
- bdom.mount(parent, anchor);
- this.status = 1 /* MOUNTED */;
- this.fiber.appliedToDom = true;
- this.children = this.fiber.childrenMap;
- this.fiber = null;
- }
- moveBeforeDOMNode(node, parent) {
- this.bdom.moveBeforeDOMNode(node, parent);
- }
- moveBeforeVNode(other, afterNode) {
- this.bdom.moveBeforeVNode(other ? other.bdom : null, afterNode);
- }
- patch() {
- if (this.fiber && this.fiber.parent) {
- // we only patch here renderings coming from above. renderings initiated
- // by the component will be patched independently in the appropriate
- // fiber.complete
- this._patch();
- this.props = this.nextProps;
- }
- }
- _patch() {
- let hasChildren = false;
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- for (let _k in this.children) {
- hasChildren = true;
- break;
- }
- const fiber = this.fiber;
- this.children = fiber.childrenMap;
- this.bdom.patch(fiber.bdom, hasChildren);
- fiber.appliedToDom = true;
- this.fiber = null;
- }
- beforeRemove() {
- this._destroy();
- }
- remove() {
- this.bdom.remove();
- }
- // ---------------------------------------------------------------------------
- // Some debug helpers
- // ---------------------------------------------------------------------------
- get name() {
- return this.component.constructor.name;
- }
- get subscriptions() {
- const render = batchedRenderFunctions.get(this);
- return render ? getSubscriptions(render) : [];
- }
+ let currentNode = null;
+ function saveCurrent() {
+ let n = currentNode;
+ return () => {
+ currentNode = n;
+ };
+ }
+ function getCurrent() {
+ if (!currentNode) {
+ throw new OwlError("No active component (a hook function should only be called in 'setup')");
+ }
+ return currentNode;
+ }
+ function useComponent() {
+ return currentNode.component;
+ }
+ /**
+ * Apply default props (only top level).
+ */
+ function applyDefaultProps(props, defaultProps) {
+ for (let propName in defaultProps) {
+ if (props[propName] === undefined) {
+ props[propName] = defaultProps[propName];
+ }
+ }
+ }
+ // -----------------------------------------------------------------------------
+ // Integration with reactivity system (useState)
+ // -----------------------------------------------------------------------------
+ const batchedRenderFunctions = new WeakMap();
+ /**
+ * Creates a reactive object that will be observed by the current component.
+ * Reading data from the returned object (eg during rendering) will cause the
+ * component to subscribe to that data and be rerendered when it changes.
+ *
+ * @param state the state to observe
+ * @returns a reactive object that will cause the component to re-render on
+ * relevant changes
+ * @see reactive
+ */
+ function useState(state) {
+ const node = getCurrent();
+ let render = batchedRenderFunctions.get(node);
+ if (!render) {
+ const wrapper = { fn: batched(node.render.bind(node, false)) };
+ render = (...args) => wrapper.fn(...args);
+ batchedRenderFunctions.set(node, render);
+ // manual implementation of onWillDestroy to break cyclic dependency
+ node.willDestroy.push(cleanupRenderAndReactives.bind(null, wrapper, render));
+ }
+ return reactive(state, render);
+ }
+ const NO_OP = () => { };
+ function cleanupRenderAndReactives(wrapper, render) {
+ wrapper.fn = NO_OP;
+ clearReactivesForCallback(render);
+ }
+ class ComponentNode {
+ constructor(C, props, app, parent, parentKey) {
+ this.fiber = null;
+ this.bdom = null;
+ this.status = 0 /* NEW */;
+ this.forceNextRender = false;
+ this.nextProps = null;
+ this.children = Object.create(null);
+ this.refs = {};
+ this.willStart = [];
+ this.willUpdateProps = [];
+ this.willUnmount = [];
+ this.mounted = [];
+ this.willPatch = [];
+ this.patched = [];
+ this.willDestroy = [];
+ currentNode = this;
+ this.app = app;
+ this.parent = parent;
+ this.props = props;
+ this.parentKey = parentKey;
+ const defaultProps = C.defaultProps;
+ props = Object.assign({}, props);
+ if (defaultProps) {
+ applyDefaultProps(props, defaultProps);
+ }
+ const env = (parent && parent.childEnv) || app.env;
+ this.childEnv = env;
+ for (const key in props) {
+ const prop = props[key];
+ if (prop && typeof prop === "object" && targets.has(prop)) {
+ props[key] = useState(prop);
+ }
+ }
+ this.component = new C(props, env, this);
+ const ctx = Object.assign(Object.create(this.component), { this: this.component });
+ this.renderFn = app.getTemplate(C.template).bind(this.component, ctx, this);
+ this.component.setup();
+ currentNode = null;
+ }
+ mountComponent(target, options) {
+ const fiber = new MountFiber(this, target, options);
+ this.app.scheduler.addFiber(fiber);
+ this.initiateRender(fiber);
+ }
+ async initiateRender(fiber) {
+ this.fiber = fiber;
+ if (this.mounted.length) {
+ fiber.root.mounted.push(fiber);
+ }
+ const component = this.component;
+ try {
+ await Promise.all(this.willStart.map((f) => f.call(component)));
+ }
+ catch (e) {
+ this.app.handleError({ node: this, error: e });
+ return;
+ }
+ if (this.status === 0 /* NEW */ && this.fiber === fiber) {
+ fiber.render();
+ }
+ }
+ async render(deep) {
+ if (this.status >= 2 /* CANCELLED */) {
+ return;
+ }
+ let current = this.fiber;
+ if (current && (current.root.locked || current.bdom === true)) {
+ await Promise.resolve();
+ // situation may have changed after the microtask tick
+ current = this.fiber;
+ }
+ if (current) {
+ if (!current.bdom && !fibersInError.has(current)) {
+ if (deep) {
+ // we want the render from this point on to be with deep=true
+ current.deep = deep;
+ }
+ return;
+ }
+ // if current rendering was with deep=true, we want this one to be the same
+ deep = deep || current.deep;
+ }
+ else if (!this.bdom) {
+ return;
+ }
+ const fiber = makeRootFiber(this);
+ fiber.deep = deep;
+ this.fiber = fiber;
+ this.app.scheduler.addFiber(fiber);
+ await Promise.resolve();
+ if (this.status >= 2 /* CANCELLED */) {
+ return;
+ }
+ // We only want to actually render the component if the following two
+ // conditions are true:
+ // * this.fiber: it could be null, in which case the render has been cancelled
+ // * (current || !fiber.parent): if current is not null, this means that the
+ // render function was called when a render was already occurring. In this
+ // case, the pending rendering was cancelled, and the fiber needs to be
+ // rendered to complete the work. If current is null, we check that the
+ // fiber has no parent. If that is the case, the fiber was downgraded from
+ // a root fiber to a child fiber in the previous microtick, because it was
+ // embedded in a rendering coming from above, so the fiber will be rendered
+ // in the next microtick anyway, so we should not render it again.
+ if (this.fiber === fiber && (current || !fiber.parent)) {
+ fiber.render();
+ }
+ }
+ cancel() {
+ this._cancel();
+ delete this.parent.children[this.parentKey];
+ this.app.scheduler.scheduleDestroy(this);
+ }
+ _cancel() {
+ this.status = 2 /* CANCELLED */;
+ const children = this.children;
+ for (let childKey in children) {
+ children[childKey]._cancel();
+ }
+ }
+ destroy() {
+ let shouldRemove = this.status === 1 /* MOUNTED */;
+ this._destroy();
+ if (shouldRemove) {
+ this.bdom.remove();
+ }
+ }
+ _destroy() {
+ const component = this.component;
+ if (this.status === 1 /* MOUNTED */) {
+ for (let cb of this.willUnmount) {
+ cb.call(component);
+ }
+ }
+ for (let child of Object.values(this.children)) {
+ child._destroy();
+ }
+ if (this.willDestroy.length) {
+ try {
+ for (let cb of this.willDestroy) {
+ cb.call(component);
+ }
+ }
+ catch (e) {
+ this.app.handleError({ error: e, node: this });
+ }
+ }
+ this.status = 3 /* DESTROYED */;
+ }
+ async updateAndRender(props, parentFiber) {
+ this.nextProps = props;
+ props = Object.assign({}, props);
+ // update
+ const fiber = makeChildFiber(this, parentFiber);
+ this.fiber = fiber;
+ const component = this.component;
+ const defaultProps = component.constructor.defaultProps;
+ if (defaultProps) {
+ applyDefaultProps(props, defaultProps);
+ }
+ currentNode = this;
+ for (const key in props) {
+ const prop = props[key];
+ if (prop && typeof prop === "object" && targets.has(prop)) {
+ props[key] = useState(prop);
+ }
+ }
+ currentNode = null;
+ const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
+ await prom;
+ if (fiber !== this.fiber) {
+ return;
+ }
+ component.props = props;
+ fiber.render();
+ const parentRoot = parentFiber.root;
+ if (this.willPatch.length) {
+ parentRoot.willPatch.push(fiber);
+ }
+ if (this.patched.length) {
+ parentRoot.patched.push(fiber);
+ }
+ }
+ /**
+ * Finds a child that has dom that is not yet updated, and update it. This
+ * method is meant to be used only in the context of repatching the dom after
+ * a mounted hook failed and was handled.
+ */
+ updateDom() {
+ if (!this.fiber) {
+ return;
+ }
+ if (this.bdom === this.fiber.bdom) {
+ // If the error was handled by some child component, we need to find it to
+ // apply its change
+ for (let k in this.children) {
+ const child = this.children[k];
+ child.updateDom();
+ }
+ }
+ else {
+ // if we get here, this is the component that handled the error and rerendered
+ // itself, so we can simply patch the dom
+ this.bdom.patch(this.fiber.bdom, false);
+ this.fiber.appliedToDom = true;
+ this.fiber = null;
+ }
+ }
+ /**
+ * Sets a ref to a given HTMLElement.
+ *
+ * @param name the name of the ref to set
+ * @param el the HTMLElement to set the ref to. The ref is not set if the el
+ * is null, but useRef will not return elements that are not in the DOM
+ */
+ setRef(name, el) {
+ if (el) {
+ this.refs[name] = el;
+ }
+ }
+ // ---------------------------------------------------------------------------
+ // Block DOM methods
+ // ---------------------------------------------------------------------------
+ firstNode() {
+ const bdom = this.bdom;
+ return bdom ? bdom.firstNode() : undefined;
+ }
+ mount(parent, anchor) {
+ const bdom = this.fiber.bdom;
+ this.bdom = bdom;
+ bdom.mount(parent, anchor);
+ this.status = 1 /* MOUNTED */;
+ this.fiber.appliedToDom = true;
+ this.children = this.fiber.childrenMap;
+ this.fiber = null;
+ }
+ moveBeforeDOMNode(node, parent) {
+ this.bdom.moveBeforeDOMNode(node, parent);
+ }
+ moveBeforeVNode(other, afterNode) {
+ this.bdom.moveBeforeVNode(other ? other.bdom : null, afterNode);
+ }
+ patch() {
+ if (this.fiber && this.fiber.parent) {
+ // we only patch here renderings coming from above. renderings initiated
+ // by the component will be patched independently in the appropriate
+ // fiber.complete
+ this._patch();
+ this.props = this.nextProps;
+ }
+ }
+ _patch() {
+ let hasChildren = false;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ for (let _k in this.children) {
+ hasChildren = true;
+ break;
+ }
+ const fiber = this.fiber;
+ this.children = fiber.childrenMap;
+ this.bdom.patch(fiber.bdom, hasChildren);
+ fiber.appliedToDom = true;
+ this.fiber = null;
+ }
+ beforeRemove() {
+ this._destroy();
+ }
+ remove() {
+ this.bdom.remove();
+ }
+ // ---------------------------------------------------------------------------
+ // Some debug helpers
+ // ---------------------------------------------------------------------------
+ get name() {
+ return this.component.constructor.name;
+ }
+ get subscriptions() {
+ const render = batchedRenderFunctions.get(this);
+ return render ? getSubscriptions(render) : [];
+ }
}
- const TIMEOUT = Symbol("timeout");
- const HOOK_TIMEOUT = {
- onWillStart: 3000,
- onWillUpdateProps: 3000,
- };
- function wrapError(fn, hookName) {
- const error = new OwlError();
- const timeoutError = new OwlError();
- const node = getCurrent();
- return (...args) => {
- const onError = (cause) => {
- error.cause = cause;
- error.message =
- cause instanceof Error
- ? `The following error occurred in ${hookName}: "${cause.message}"`
- : `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
- throw error;
- };
- let result;
- try {
- result = fn(...args);
- }
- catch (cause) {
- onError(cause);
- }
- if (!(result instanceof Promise)) {
- return result;
- }
- const timeout = HOOK_TIMEOUT[hookName];
- if (timeout) {
- const fiber = node.fiber;
- Promise.race([
- result.catch(() => { }),
- new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)),
- ]).then((res) => {
- if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
- timeoutError.message = `${hookName}'s promise hasn't resolved after ${timeout / 1000} seconds`;
- console.log(timeoutError);
- }
- });
- }
- return result.catch(onError);
- };
- }
- // -----------------------------------------------------------------------------
- // hooks
- // -----------------------------------------------------------------------------
- function onWillStart(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.willStart.push(decorate(fn.bind(node.component), "onWillStart"));
- }
- function onWillUpdateProps(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.willUpdateProps.push(decorate(fn.bind(node.component), "onWillUpdateProps"));
- }
- function onMounted(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.mounted.push(decorate(fn.bind(node.component), "onMounted"));
- }
- function onWillPatch(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.willPatch.unshift(decorate(fn.bind(node.component), "onWillPatch"));
- }
- function onPatched(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.patched.push(decorate(fn.bind(node.component), "onPatched"));
- }
- function onWillUnmount(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.willUnmount.unshift(decorate(fn.bind(node.component), "onWillUnmount"));
- }
- function onWillDestroy(fn) {
- const node = getCurrent();
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- node.willDestroy.push(decorate(fn.bind(node.component), "onWillDestroy"));
- }
- function onWillRender(fn) {
- const node = getCurrent();
- const renderFn = node.renderFn;
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- fn = decorate(fn.bind(node.component), "onWillRender");
- node.renderFn = () => {
- fn();
- return renderFn();
- };
- }
- function onRendered(fn) {
- const node = getCurrent();
- const renderFn = node.renderFn;
- const decorate = node.app.dev ? wrapError : (fn) => fn;
- fn = decorate(fn.bind(node.component), "onRendered");
- node.renderFn = () => {
- const result = renderFn();
- fn();
- return result;
- };
- }
- function onError(callback) {
- const node = getCurrent();
- let handlers = nodeErrorHandlers.get(node);
- if (!handlers) {
- handlers = [];
- nodeErrorHandlers.set(node, handlers);
- }
- handlers.push(callback.bind(node.component));
+ const TIMEOUT = Symbol("timeout");
+ const HOOK_TIMEOUT = {
+ onWillStart: 3000,
+ onWillUpdateProps: 3000,
+ };
+ function wrapError(fn, hookName) {
+ const error = new OwlError();
+ const timeoutError = new OwlError();
+ const node = getCurrent();
+ return (...args) => {
+ const onError = (cause) => {
+ error.cause = cause;
+ error.message =
+ cause instanceof Error
+ ? `The following error occurred in ${hookName}: "${cause.message}"`
+ : `Something that is not an Error was thrown in ${hookName} (see this Error's "cause" property)`;
+ throw error;
+ };
+ let result;
+ try {
+ result = fn(...args);
+ }
+ catch (cause) {
+ onError(cause);
+ }
+ if (!(result instanceof Promise)) {
+ return result;
+ }
+ const timeout = HOOK_TIMEOUT[hookName];
+ if (timeout) {
+ const fiber = node.fiber;
+ Promise.race([
+ result.catch(() => { }),
+ new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), timeout)),
+ ]).then((res) => {
+ if (res === TIMEOUT && node.fiber === fiber && node.status <= 2) {
+ timeoutError.message = `${hookName}'s promise hasn't resolved after ${timeout / 1000} seconds`;
+ console.log(timeoutError);
+ }
+ });
+ }
+ return result.catch(onError);
+ };
+ }
+ // -----------------------------------------------------------------------------
+ // hooks
+ // -----------------------------------------------------------------------------
+ function onWillStart(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.willStart.push(decorate(fn.bind(node.component), "onWillStart"));
+ }
+ function onWillUpdateProps(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.willUpdateProps.push(decorate(fn.bind(node.component), "onWillUpdateProps"));
+ }
+ function onMounted(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.mounted.push(decorate(fn.bind(node.component), "onMounted"));
+ }
+ function onWillPatch(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.willPatch.unshift(decorate(fn.bind(node.component), "onWillPatch"));
+ }
+ function onPatched(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.patched.push(decorate(fn.bind(node.component), "onPatched"));
+ }
+ function onWillUnmount(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.willUnmount.unshift(decorate(fn.bind(node.component), "onWillUnmount"));
+ }
+ function onWillDestroy(fn) {
+ const node = getCurrent();
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ node.willDestroy.push(decorate(fn.bind(node.component), "onWillDestroy"));
+ }
+ function onWillRender(fn) {
+ const node = getCurrent();
+ const renderFn = node.renderFn;
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ fn = decorate(fn.bind(node.component), "onWillRender");
+ node.renderFn = () => {
+ fn();
+ return renderFn();
+ };
+ }
+ function onRendered(fn) {
+ const node = getCurrent();
+ const renderFn = node.renderFn;
+ const decorate = node.app.dev ? wrapError : (fn) => fn;
+ fn = decorate(fn.bind(node.component), "onRendered");
+ node.renderFn = () => {
+ const result = renderFn();
+ fn();
+ return result;
+ };
+ }
+ function onError(callback) {
+ const node = getCurrent();
+ let handlers = nodeErrorHandlers.get(node);
+ if (!handlers) {
+ handlers = [];
+ nodeErrorHandlers.set(node, handlers);
+ }
+ handlers.push(callback.bind(node.component));
}
- class Component {
- constructor(props, env, node) {
- this.props = props;
- this.env = env;
- this.__owl__ = node;
- }
- setup() { }
- render(deep = false) {
- this.__owl__.render(deep === true);
- }
- }
+ class Component {
+ constructor(props, env, node) {
+ this.props = props;
+ this.env = env;
+ this.__owl__ = node;
+ }
+ setup() { }
+ render(deep = false) {
+ this.__owl__.render(deep === true);
+ }
+ }
Component.template = "";
- const VText = text("").constructor;
- class VPortal extends VText {
- constructor(selector, content) {
- super("");
- this.target = null;
- this.selector = selector;
- this.content = content;
- }
- mount(parent, anchor) {
- super.mount(parent, anchor);
- this.target = document.querySelector(this.selector);
- if (this.target) {
- this.content.mount(this.target, null);
- }
- else {
- this.content.mount(parent, anchor);
- }
- }
- beforeRemove() {
- this.content.beforeRemove();
- }
- remove() {
- if (this.content) {
- super.remove();
- this.content.remove();
- this.content = null;
- }
- }
- patch(other) {
- super.patch(other);
- if (this.content) {
- this.content.patch(other.content, true);
- }
- else {
- this.content = other.content;
- this.content.mount(this.target, null);
- }
- }
- }
- /**
- * kind of similar to , but it wraps it around a VPortal
- */
- function portalTemplate(app, bdom, helpers) {
- let { callSlot } = helpers;
- return function template(ctx, node, key = "") {
- return new VPortal(ctx.props.target, callSlot(ctx, node, key, "default", false, null));
- };
- }
- class Portal extends Component {
- setup() {
- const node = this.__owl__;
- onMounted(() => {
- const portal = node.bdom;
- if (!portal.target) {
- const target = document.querySelector(this.props.target);
- if (target) {
- portal.content.moveBeforeDOMNode(target.firstChild, target);
- }
- else {
- throw new OwlError("invalid portal target");
- }
- }
- });
- onWillUnmount(() => {
- const portal = node.bdom;
- portal.remove();
- });
- }
- }
- Portal.template = "__portal__";
- Portal.props = {
- target: {
- type: String,
- },
- slots: true,
+ const VText = text("").constructor;
+ class VPortal extends VText {
+ constructor(selector, content) {
+ super("");
+ this.target = null;
+ this.selector = selector;
+ this.content = content;
+ }
+ mount(parent, anchor) {
+ super.mount(parent, anchor);
+ this.target = document.querySelector(this.selector);
+ if (this.target) {
+ this.content.mount(this.target, null);
+ }
+ else {
+ this.content.mount(parent, anchor);
+ }
+ }
+ beforeRemove() {
+ this.content.beforeRemove();
+ }
+ remove() {
+ if (this.content) {
+ super.remove();
+ this.content.remove();
+ this.content = null;
+ }
+ }
+ patch(other) {
+ super.patch(other);
+ if (this.content) {
+ this.content.patch(other.content, true);
+ }
+ else {
+ this.content = other.content;
+ this.content.mount(this.target, null);
+ }
+ }
+ }
+ /**
+ * kind of similar to , but it wraps it around a VPortal
+ */
+ function portalTemplate(app, bdom, helpers) {
+ let { callSlot } = helpers;
+ return function template(ctx, node, key = "") {
+ return new VPortal(ctx.props.target, callSlot(ctx, node, key, "default", false, null));
+ };
+ }
+ class Portal extends Component {
+ setup() {
+ const node = this.__owl__;
+ onMounted(() => {
+ const portal = node.bdom;
+ if (!portal.target) {
+ const target = document.querySelector(this.props.target);
+ if (target) {
+ portal.content.moveBeforeDOMNode(target.firstChild, target);
+ }
+ else {
+ throw new OwlError("invalid portal target");
+ }
+ }
+ });
+ onWillUnmount(() => {
+ const portal = node.bdom;
+ portal.remove();
+ });
+ }
+ }
+ Portal.template = "__portal__";
+ Portal.props = {
+ target: {
+ type: String,
+ },
+ slots: true,
};
- // -----------------------------------------------------------------------------
- // helpers
- // -----------------------------------------------------------------------------
- const isUnionType = (t) => Array.isArray(t);
- const isBaseType = (t) => typeof t !== "object";
- const isValueType = (t) => typeof t === "object" && t && "value" in t;
- function isOptional(t) {
- return typeof t === "object" && "optional" in t ? t.optional || false : false;
- }
- function describeType(type) {
- return type === "*" || type === true ? "value" : type.name.toLowerCase();
- }
- function describe(info) {
- if (isBaseType(info)) {
- return describeType(info);
- }
- else if (isUnionType(info)) {
- return info.map(describe).join(" or ");
- }
- else if (isValueType(info)) {
- return String(info.value);
- }
- if ("element" in info) {
- return `list of ${describe({ type: info.element, optional: false })}s`;
- }
- if ("shape" in info) {
- return `object`;
- }
- return describe(info.type || "*");
- }
- function toSchema(spec) {
- return Object.fromEntries(spec.map((e) => e.endsWith("?") ? [e.slice(0, -1), { optional: true }] : [e, { type: "*", optional: false }]));
- }
- /**
- * Main validate function
- */
- function validate(obj, spec) {
- let errors = validateSchema(obj, spec);
- if (errors.length) {
- throw new OwlError("Invalid object: " + errors.join(", "));
- }
- }
- /**
- * Helper validate function, to get the list of errors. useful if one want to
- * manipulate the errors without parsing an error object
- */
- function validateSchema(obj, schema) {
- if (Array.isArray(schema)) {
- schema = toSchema(schema);
- }
- obj = toRaw(obj);
- let errors = [];
- // check if each value in obj has correct shape
- for (let key in obj) {
- if (key in schema) {
- let result = validateType(key, obj[key], schema[key]);
- if (result) {
- errors.push(result);
- }
- }
- else if (!("*" in schema)) {
- errors.push(`unknown key '${key}'`);
- }
- }
- // check that all specified keys are defined in obj
- for (let key in schema) {
- const spec = schema[key];
- if (key !== "*" && !isOptional(spec) && !(key in obj)) {
- const isObj = typeof spec === "object" && !Array.isArray(spec);
- const isAny = spec === "*" || (isObj && "type" in spec ? spec.type === "*" : isObj);
- let detail = isAny ? "" : ` (should be a ${describe(spec)})`;
- errors.push(`'${key}' is missing${detail}`);
- }
- }
- return errors;
- }
- function validateBaseType(key, value, type) {
- if (typeof type === "function") {
- if (typeof value === "object") {
- if (!(value instanceof type)) {
- return `'${key}' is not a ${describeType(type)}`;
- }
- }
- else if (typeof value !== type.name.toLowerCase()) {
- return `'${key}' is not a ${describeType(type)}`;
- }
- }
- return null;
- }
- function validateArrayType(key, value, descr) {
- if (!Array.isArray(value)) {
- return `'${key}' is not a list of ${describe(descr)}s`;
- }
- for (let i = 0; i < value.length; i++) {
- const error = validateType(`${key}[${i}]`, value[i], descr);
- if (error) {
- return error;
- }
- }
- return null;
- }
- function validateType(key, value, descr) {
- if (value === undefined) {
- return isOptional(descr) ? null : `'${key}' is undefined (should be a ${describe(descr)})`;
- }
- else if (isBaseType(descr)) {
- return validateBaseType(key, value, descr);
- }
- else if (isValueType(descr)) {
- return value === descr.value ? null : `'${key}' is not equal to '${descr.value}'`;
- }
- else if (isUnionType(descr)) {
- let validDescr = descr.find((p) => !validateType(key, value, p));
- return validDescr ? null : `'${key}' is not a ${describe(descr)}`;
- }
- let result = null;
- if ("element" in descr) {
- result = validateArrayType(key, value, descr.element);
- }
- else if ("shape" in descr) {
- if (typeof value !== "object" || Array.isArray(value)) {
- result = `'${key}' is not an object`;
- }
- else {
- const errors = validateSchema(value, descr.shape);
- if (errors.length) {
- result = `'${key}' doesn't have the correct shape (${errors.join(", ")})`;
- }
- }
- }
- else if ("values" in descr) {
- if (typeof value !== "object" || Array.isArray(value)) {
- result = `'${key}' is not an object`;
- }
- else {
- const errors = Object.entries(value)
- .map(([key, value]) => validateType(key, value, descr.values))
- .filter(Boolean);
- if (errors.length) {
- result = `some of the values in '${key}' are invalid (${errors.join(", ")})`;
- }
- }
- }
- if ("type" in descr && !result) {
- result = validateType(key, value, descr.type);
- }
- if ("validate" in descr && !result) {
- result = !descr.validate(value) ? `'${key}' is not valid` : null;
- }
- return result;
+ // -----------------------------------------------------------------------------
+ // helpers
+ // -----------------------------------------------------------------------------
+ const isUnionType = (t) => Array.isArray(t);
+ const isBaseType = (t) => typeof t !== "object";
+ const isValueType = (t) => typeof t === "object" && t && "value" in t;
+ function isOptional(t) {
+ return typeof t === "object" && "optional" in t ? t.optional || false : false;
+ }
+ function describeType(type) {
+ return type === "*" || type === true ? "value" : type.name.toLowerCase();
+ }
+ function describe(info) {
+ if (isBaseType(info)) {
+ return describeType(info);
+ }
+ else if (isUnionType(info)) {
+ return info.map(describe).join(" or ");
+ }
+ else if (isValueType(info)) {
+ return String(info.value);
+ }
+ if ("element" in info) {
+ return `list of ${describe({ type: info.element, optional: false })}s`;
+ }
+ if ("shape" in info) {
+ return `object`;
+ }
+ return describe(info.type || "*");
+ }
+ function toSchema(spec) {
+ return Object.fromEntries(spec.map((e) => e.endsWith("?") ? [e.slice(0, -1), { optional: true }] : [e, { type: "*", optional: false }]));
+ }
+ /**
+ * Main validate function
+ */
+ function validate(obj, spec) {
+ let errors = validateSchema(obj, spec);
+ if (errors.length) {
+ throw new OwlError("Invalid object: " + errors.join(", "));
+ }
+ }
+ /**
+ * Helper validate function, to get the list of errors. useful if one want to
+ * manipulate the errors without parsing an error object
+ */
+ function validateSchema(obj, schema) {
+ if (Array.isArray(schema)) {
+ schema = toSchema(schema);
+ }
+ obj = toRaw(obj);
+ let errors = [];
+ // check if each value in obj has correct shape
+ for (let key in obj) {
+ if (key in schema) {
+ let result = validateType(key, obj[key], schema[key]);
+ if (result) {
+ errors.push(result);
+ }
+ }
+ else if (!("*" in schema)) {
+ errors.push(`unknown key '${key}'`);
+ }
+ }
+ // check that all specified keys are defined in obj
+ for (let key in schema) {
+ const spec = schema[key];
+ if (key !== "*" && !isOptional(spec) && !(key in obj)) {
+ const isObj = typeof spec === "object" && !Array.isArray(spec);
+ const isAny = spec === "*" || (isObj && "type" in spec ? spec.type === "*" : isObj);
+ let detail = isAny ? "" : ` (should be a ${describe(spec)})`;
+ errors.push(`'${key}' is missing${detail}`);
+ }
+ }
+ return errors;
+ }
+ function validateBaseType(key, value, type) {
+ if (typeof type === "function") {
+ if (typeof value === "object") {
+ if (!(value instanceof type)) {
+ return `'${key}' is not a ${describeType(type)}`;
+ }
+ }
+ else if (typeof value !== type.name.toLowerCase()) {
+ return `'${key}' is not a ${describeType(type)}`;
+ }
+ }
+ return null;
+ }
+ function validateArrayType(key, value, descr) {
+ if (!Array.isArray(value)) {
+ return `'${key}' is not a list of ${describe(descr)}s`;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const error = validateType(`${key}[${i}]`, value[i], descr);
+ if (error) {
+ return error;
+ }
+ }
+ return null;
+ }
+ function validateType(key, value, descr) {
+ if (value === undefined) {
+ return isOptional(descr) ? null : `'${key}' is undefined (should be a ${describe(descr)})`;
+ }
+ else if (isBaseType(descr)) {
+ return validateBaseType(key, value, descr);
+ }
+ else if (isValueType(descr)) {
+ return value === descr.value ? null : `'${key}' is not equal to '${descr.value}'`;
+ }
+ else if (isUnionType(descr)) {
+ let validDescr = descr.find((p) => !validateType(key, value, p));
+ return validDescr ? null : `'${key}' is not a ${describe(descr)}`;
+ }
+ let result = null;
+ if ("element" in descr) {
+ result = validateArrayType(key, value, descr.element);
+ }
+ else if ("shape" in descr) {
+ if (typeof value !== "object" || Array.isArray(value)) {
+ result = `'${key}' is not an object`;
+ }
+ else {
+ const errors = validateSchema(value, descr.shape);
+ if (errors.length) {
+ result = `'${key}' doesn't have the correct shape (${errors.join(", ")})`;
+ }
+ }
+ }
+ else if ("values" in descr) {
+ if (typeof value !== "object" || Array.isArray(value)) {
+ result = `'${key}' is not an object`;
+ }
+ else {
+ const errors = Object.entries(value)
+ .map(([key, value]) => validateType(key, value, descr.values))
+ .filter(Boolean);
+ if (errors.length) {
+ result = `some of the values in '${key}' are invalid (${errors.join(", ")})`;
+ }
+ }
+ }
+ if ("type" in descr && !result) {
+ result = validateType(key, value, descr.type);
+ }
+ if ("validate" in descr && !result) {
+ result = !descr.validate(value) ? `'${key}' is not valid` : null;
+ }
+ return result;
}
- const ObjectCreate = Object.create;
- /**
- * This file contains utility functions that will be injected in each template,
- * to perform various useful tasks in the compiled code.
- */
- function withDefault(value, defaultValue) {
- return value === undefined || value === null || value === false ? defaultValue : value;
- }
- function callSlot(ctx, parent, key, name, dynamic, extra, defaultContent) {
- key = key + "__slot_" + name;
- const slots = ctx.props.slots || {};
- const { __render, __ctx, __scope } = slots[name] || {};
- const slotScope = ObjectCreate(__ctx || {});
- if (__scope) {
- slotScope[__scope] = extra;
- }
- const slotBDom = __render ? __render(slotScope, parent, key) : null;
- if (defaultContent) {
- let child1 = undefined;
- let child2 = undefined;
- if (slotBDom) {
- child1 = dynamic ? toggler(name, slotBDom) : slotBDom;
- }
- else {
- child2 = defaultContent(ctx, parent, key);
- }
- return multi([child1, child2]);
- }
- return slotBDom || text("");
- }
- function capture(ctx) {
- const result = ObjectCreate(ctx);
- for (let k in ctx) {
- result[k] = ctx[k];
- }
- return result;
- }
- function withKey(elem, k) {
- elem.key = k;
- return elem;
- }
- function prepareList(collection) {
- let keys;
- let values;
- if (Array.isArray(collection)) {
- keys = collection;
- values = collection;
- }
- else if (collection instanceof Map) {
- keys = [...collection.keys()];
- values = [...collection.values()];
- }
- else if (Symbol.iterator in Object(collection)) {
- keys = [...collection];
- values = keys;
- }
- else if (collection && typeof collection === "object") {
- values = Object.values(collection);
- keys = Object.keys(collection);
- }
- else {
- throw new OwlError(`Invalid loop expression: "${collection}" is not iterable`);
- }
- const n = values.length;
- return [keys, values, n, new Array(n)];
- }
- const isBoundary = Symbol("isBoundary");
- function setContextValue(ctx, key, value) {
- const ctx0 = ctx;
- while (!ctx.hasOwnProperty(key) && !ctx.hasOwnProperty(isBoundary)) {
- const newCtx = ctx.__proto__;
- if (!newCtx) {
- ctx = ctx0;
- break;
- }
- ctx = newCtx;
- }
- ctx[key] = value;
- }
- function toNumber(val) {
- const n = parseFloat(val);
- return isNaN(n) ? val : n;
- }
- function shallowEqual(l1, l2) {
- for (let i = 0, l = l1.length; i < l; i++) {
- if (l1[i] !== l2[i]) {
- return false;
- }
- }
- return true;
- }
- class LazyValue {
- constructor(fn, ctx, component, node, key) {
- this.fn = fn;
- this.ctx = capture(ctx);
- this.component = component;
- this.node = node;
- this.key = key;
- }
- evaluate() {
- return this.fn.call(this.component, this.ctx, this.node, this.key);
- }
- toString() {
- return this.evaluate().toString();
- }
- }
- /*
- * Safely outputs `value` as a block depending on the nature of `value`
- */
- function safeOutput(value, defaultValue) {
- if (value === undefined || value === null) {
- return defaultValue ? toggler("default", defaultValue) : toggler("undefined", text(""));
- }
- let safeKey;
- let block;
- switch (typeof value) {
- case "object":
- if (value instanceof Markup) {
- safeKey = `string_safe`;
- block = html(value);
- }
- else if (value instanceof LazyValue) {
- safeKey = `lazy_value`;
- block = value.evaluate();
- }
- else if (value instanceof String) {
- safeKey = "string_unsafe";
- block = text(value);
- }
- else {
- // Assuming it is a block
- safeKey = "block_safe";
- block = value;
- }
- break;
- case "string":
- safeKey = "string_unsafe";
- block = text(value);
- break;
- default:
- safeKey = "string_unsafe";
- block = text(String(value));
- }
- return toggler(safeKey, block);
- }
- /**
- * Validate the component props (or next props) against the (static) props
- * description. This is potentially an expensive operation: it may needs to
- * visit recursively the props and all the children to check if they are valid.
- * This is why it is only done in 'dev' mode.
- */
- function validateProps(name, props, comp) {
- const ComponentClass = typeof name !== "string"
- ? name
- : comp.constructor.components[name];
- if (!ComponentClass) {
- // this is an error, wrong component. We silently return here instead so the
- // error is triggered by the usual path ('component' function)
- return;
- }
- const schema = ComponentClass.props;
- if (!schema) {
- if (comp.__owl__.app.warnIfNoStaticProps) {
- console.warn(`Component '${ComponentClass.name}' does not have a static props description`);
- }
- return;
- }
- const defaultProps = ComponentClass.defaultProps;
- if (defaultProps) {
- let isMandatory = (name) => Array.isArray(schema)
- ? schema.includes(name)
- : name in schema && !("*" in schema) && !isOptional(schema[name]);
- for (let p in defaultProps) {
- if (isMandatory(p)) {
- throw new OwlError(`A default value cannot be defined for a mandatory prop (name: '${p}', component: ${ComponentClass.name})`);
- }
- }
- }
- const errors = validateSchema(props, schema);
- if (errors.length) {
- throw new OwlError(`Invalid props for component '${ComponentClass.name}': ` + errors.join(", "));
- }
- }
- function makeRefWrapper(node) {
- let refNames = new Set();
- return (name, fn) => {
- if (refNames.has(name)) {
- throw new OwlError(`Cannot set the same ref more than once in the same component, ref "${name}" was set multiple times in ${node.name}`);
- }
- refNames.add(name);
- return fn;
- };
- }
- const helpers = {
- withDefault,
- zero: Symbol("zero"),
- isBoundary,
- callSlot,
- capture,
- withKey,
- prepareList,
- setContextValue,
- shallowEqual,
- toNumber,
- validateProps,
- LazyValue,
- safeOutput,
- createCatcher,
- markRaw,
- OwlError,
- makeRefWrapper,
+ const ObjectCreate = Object.create;
+ /**
+ * This file contains utility functions that will be injected in each template,
+ * to perform various useful tasks in the compiled code.
+ */
+ function withDefault(value, defaultValue) {
+ return value === undefined || value === null || value === false ? defaultValue : value;
+ }
+ function callSlot(ctx, parent, key, name, dynamic, extra, defaultContent) {
+ key = key + "__slot_" + name;
+ const slots = ctx.props.slots || {};
+ const { __render, __ctx, __scope } = slots[name] || {};
+ const slotScope = ObjectCreate(__ctx || {});
+ if (__scope) {
+ slotScope[__scope] = extra;
+ }
+ const slotBDom = __render ? __render(slotScope, parent, key) : null;
+ if (defaultContent) {
+ let child1 = undefined;
+ let child2 = undefined;
+ if (slotBDom) {
+ child1 = dynamic ? toggler(name, slotBDom) : slotBDom;
+ }
+ else {
+ child2 = defaultContent(ctx, parent, key);
+ }
+ return multi([child1, child2]);
+ }
+ return slotBDom || text("");
+ }
+ function capture(ctx) {
+ const result = ObjectCreate(ctx);
+ for (let k in ctx) {
+ result[k] = ctx[k];
+ }
+ return result;
+ }
+ function withKey(elem, k) {
+ elem.key = k;
+ return elem;
+ }
+ function prepareList(collection) {
+ let keys;
+ let values;
+ if (Array.isArray(collection)) {
+ keys = collection;
+ values = collection;
+ }
+ else if (collection instanceof Map) {
+ keys = [...collection.keys()];
+ values = [...collection.values()];
+ }
+ else if (Symbol.iterator in Object(collection)) {
+ keys = [...collection];
+ values = keys;
+ }
+ else if (collection && typeof collection === "object") {
+ values = Object.values(collection);
+ keys = Object.keys(collection);
+ }
+ else {
+ throw new OwlError(`Invalid loop expression: "${collection}" is not iterable`);
+ }
+ const n = values.length;
+ return [keys, values, n, new Array(n)];
+ }
+ const isBoundary = Symbol("isBoundary");
+ function setContextValue(ctx, key, value) {
+ const ctx0 = ctx;
+ while (!ctx.hasOwnProperty(key) && !ctx.hasOwnProperty(isBoundary)) {
+ const newCtx = ctx.__proto__;
+ if (!newCtx) {
+ ctx = ctx0;
+ break;
+ }
+ ctx = newCtx;
+ }
+ ctx[key] = value;
+ }
+ function toNumber(val) {
+ const n = parseFloat(val);
+ return isNaN(n) ? val : n;
+ }
+ function shallowEqual(l1, l2) {
+ for (let i = 0, l = l1.length; i < l; i++) {
+ if (l1[i] !== l2[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+ class LazyValue {
+ constructor(fn, ctx, component, node, key) {
+ this.fn = fn;
+ this.ctx = capture(ctx);
+ this.component = component;
+ this.node = node;
+ this.key = key;
+ }
+ evaluate() {
+ return this.fn.call(this.component, this.ctx, this.node, this.key);
+ }
+ toString() {
+ return this.evaluate().toString();
+ }
+ }
+ /*
+ * Safely outputs `value` as a block depending on the nature of `value`
+ */
+ function safeOutput(value, defaultValue) {
+ if (value === undefined || value === null) {
+ return defaultValue ? toggler("default", defaultValue) : toggler("undefined", text(""));
+ }
+ let safeKey;
+ let block;
+ switch (typeof value) {
+ case "object":
+ if (value instanceof Markup) {
+ safeKey = `string_safe`;
+ block = html(value);
+ }
+ else if (value instanceof LazyValue) {
+ safeKey = `lazy_value`;
+ block = value.evaluate();
+ }
+ else if (value instanceof String) {
+ safeKey = "string_unsafe";
+ block = text(value);
+ }
+ else {
+ // Assuming it is a block
+ safeKey = "block_safe";
+ block = value;
+ }
+ break;
+ case "string":
+ safeKey = "string_unsafe";
+ block = text(value);
+ break;
+ default:
+ safeKey = "string_unsafe";
+ block = text(String(value));
+ }
+ return toggler(safeKey, block);
+ }
+ /**
+ * Validate the component props (or next props) against the (static) props
+ * description. This is potentially an expensive operation: it may needs to
+ * visit recursively the props and all the children to check if they are valid.
+ * This is why it is only done in 'dev' mode.
+ */
+ function validateProps(name, props, comp) {
+ const ComponentClass = typeof name !== "string"
+ ? name
+ : comp.constructor.components[name];
+ if (!ComponentClass) {
+ // this is an error, wrong component. We silently return here instead so the
+ // error is triggered by the usual path ('component' function)
+ return;
+ }
+ const schema = ComponentClass.props;
+ if (!schema) {
+ if (comp.__owl__.app.warnIfNoStaticProps) {
+ console.warn(`Component '${ComponentClass.name}' does not have a static props description`);
+ }
+ return;
+ }
+ const defaultProps = ComponentClass.defaultProps;
+ if (defaultProps) {
+ let isMandatory = (name) => Array.isArray(schema)
+ ? schema.includes(name)
+ : name in schema && !("*" in schema) && !isOptional(schema[name]);
+ for (let p in defaultProps) {
+ if (isMandatory(p)) {
+ throw new OwlError(`A default value cannot be defined for a mandatory prop (name: '${p}', component: ${ComponentClass.name})`);
+ }
+ }
+ }
+ const errors = validateSchema(props, schema);
+ if (errors.length) {
+ throw new OwlError(`Invalid props for component '${ComponentClass.name}': ` + errors.join(", "));
+ }
+ }
+ function makeRefWrapper(node) {
+ let refNames = new Set();
+ return (name, fn) => {
+ if (refNames.has(name)) {
+ throw new OwlError(`Cannot set the same ref more than once in the same component, ref "${name}" was set multiple times in ${node.name}`);
+ }
+ refNames.add(name);
+ return fn;
+ };
+ }
+ const helpers = {
+ withDefault,
+ zero: Symbol("zero"),
+ isBoundary,
+ callSlot,
+ capture,
+ withKey,
+ prepareList,
+ setContextValue,
+ shallowEqual,
+ toNumber,
+ validateProps,
+ LazyValue,
+ safeOutput,
+ createCatcher,
+ markRaw,
+ OwlError,
+ makeRefWrapper,
};
- /**
- * Parses an XML string into an XML document, throwing errors on parser errors
- * instead of returning an XML document containing the parseerror.
- *
- * @param xml the string to parse
- * @returns an XML document corresponding to the content of the string
- */
- function parseXML(xml) {
- const parser = new DOMParser();
- const doc = parser.parseFromString(xml, "text/xml");
- if (doc.getElementsByTagName("parsererror").length) {
- let msg = "Invalid XML in template.";
- const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
- if (parsererrorText) {
- msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
- const re = /\d+/g;
- const firstMatch = re.exec(parsererrorText);
- if (firstMatch) {
- const lineNumber = Number(firstMatch[0]);
- const line = xml.split("\n")[lineNumber - 1];
- const secondMatch = re.exec(parsererrorText);
- if (line && secondMatch) {
- const columnIndex = Number(secondMatch[0]) - 1;
- if (line[columnIndex]) {
- msg +=
- `\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
- `${line}\n${"-".repeat(columnIndex - 1)}^`;
- }
- }
- }
- }
- throw new OwlError(msg);
- }
- return doc;
+ /**
+ * Parses an XML string into an XML document, throwing errors on parser errors
+ * instead of returning an XML document containing the parseerror.
+ *
+ * @param xml the string to parse
+ * @returns an XML document corresponding to the content of the string
+ */
+ function parseXML(xml) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, "text/xml");
+ if (doc.getElementsByTagName("parsererror").length) {
+ let msg = "Invalid XML in template.";
+ const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
+ if (parsererrorText) {
+ msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
+ const re = /\d+/g;
+ const firstMatch = re.exec(parsererrorText);
+ if (firstMatch) {
+ const lineNumber = Number(firstMatch[0]);
+ const line = xml.split("\n")[lineNumber - 1];
+ const secondMatch = re.exec(parsererrorText);
+ if (line && secondMatch) {
+ const columnIndex = Number(secondMatch[0]) - 1;
+ if (line[columnIndex]) {
+ msg +=
+ `\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
+ `${line}\n${"-".repeat(columnIndex - 1)}^`;
+ }
+ }
+ }
+ }
+ throw new OwlError(msg);
+ }
+ return doc;
}
- const bdom = { text, createBlock, list, multi, html, toggler, comment };
- class TemplateSet {
- constructor(config = {}) {
- this.rawTemplates = Object.create(globalTemplates);
- this.templates = {};
- this.Portal = Portal;
- this.dev = config.dev || false;
- this.translateFn = config.translateFn;
- this.translatableAttributes = config.translatableAttributes;
- if (config.templates) {
- if (config.templates instanceof Document || typeof config.templates === "string") {
- this.addTemplates(config.templates);
- }
- else {
- for (const name in config.templates) {
- this.addTemplate(name, config.templates[name]);
- }
- }
- }
- this.getRawTemplate = config.getTemplate;
- this.customDirectives = config.customDirectives || {};
- this.runtimeUtils = { ...helpers, __globals__: config.globalValues || {} };
- this.hasGlobalValues = Boolean(config.globalValues && Object.keys(config.globalValues).length);
- }
- static registerTemplate(name, fn) {
- globalTemplates[name] = fn;
- }
- addTemplate(name, template) {
- if (name in this.rawTemplates) {
- // this check can be expensive, just silently ignore double definitions outside dev mode
- if (!this.dev) {
- return;
- }
- const rawTemplate = this.rawTemplates[name];
- const currentAsString = typeof rawTemplate === "string"
- ? rawTemplate
- : rawTemplate instanceof Element
- ? rawTemplate.outerHTML
- : rawTemplate.toString();
- const newAsString = typeof template === "string" ? template : template.outerHTML;
- if (currentAsString === newAsString) {
- return;
- }
- throw new OwlError(`Template ${name} already defined with different content`);
- }
- this.rawTemplates[name] = template;
- }
- addTemplates(xml) {
- if (!xml) {
- // empty string
- return;
- }
- xml = xml instanceof Document ? xml : parseXML(xml);
- for (const template of xml.querySelectorAll("[t-name]")) {
- const name = template.getAttribute("t-name");
- this.addTemplate(name, template);
- }
- }
- getTemplate(name) {
- var _a;
- if (!(name in this.templates)) {
- const rawTemplate = ((_a = this.getRawTemplate) === null || _a === void 0 ? void 0 : _a.call(this, name)) || this.rawTemplates[name];
- if (rawTemplate === undefined) {
- let extraInfo = "";
- try {
- const componentName = getCurrent().component.constructor.name;
- extraInfo = ` (for component "${componentName}")`;
- }
- catch { }
- throw new OwlError(`Missing template: "${name}"${extraInfo}`);
- }
- const isFn = typeof rawTemplate === "function" && !(rawTemplate instanceof Element);
- const templateFn = isFn ? rawTemplate : this._compileTemplate(name, rawTemplate);
- // first add a function to lazily get the template, in case there is a
- // recursive call to the template name
- const templates = this.templates;
- this.templates[name] = function (context, parent) {
- return templates[name].call(this, context, parent);
- };
- const template = templateFn(this, bdom, this.runtimeUtils);
- this.templates[name] = template;
- }
- return this.templates[name];
- }
- _compileTemplate(name, template) {
- throw new OwlError(`Unable to compile a template. Please use owl full build instead`);
- }
- callTemplate(owner, subTemplate, ctx, parent, key) {
- const template = this.getTemplate(subTemplate);
- return toggler(subTemplate, template.call(owner, ctx, parent, key + subTemplate));
- }
- }
- // -----------------------------------------------------------------------------
- // xml tag helper
- // -----------------------------------------------------------------------------
- const globalTemplates = {};
- function xml(...args) {
- const name = `__template__${xml.nextId++}`;
- const value = String.raw(...args);
- globalTemplates[name] = value;
- return name;
- }
- xml.nextId = 1;
+ const bdom = { text, createBlock, list, multi, html, toggler, comment };
+ class TemplateSet {
+ constructor(config = {}) {
+ this.rawTemplates = Object.create(globalTemplates);
+ this.templates = {};
+ this.Portal = Portal;
+ this.dev = config.dev || false;
+ this.translateFn = config.translateFn;
+ this.translatableAttributes = config.translatableAttributes;
+ if (config.templates) {
+ if (config.templates instanceof Document || typeof config.templates === "string") {
+ this.addTemplates(config.templates);
+ }
+ else {
+ for (const name in config.templates) {
+ this.addTemplate(name, config.templates[name]);
+ }
+ }
+ }
+ this.getRawTemplate = config.getTemplate;
+ this.customDirectives = config.customDirectives || {};
+ this.runtimeUtils = { ...helpers, __globals__: config.globalValues || {} };
+ this.hasGlobalValues = Boolean(config.globalValues && Object.keys(config.globalValues).length);
+ }
+ static registerTemplate(name, fn) {
+ globalTemplates[name] = fn;
+ }
+ addTemplate(name, template) {
+ if (name in this.rawTemplates) {
+ // this check can be expensive, just silently ignore double definitions outside dev mode
+ if (!this.dev) {
+ return;
+ }
+ const rawTemplate = this.rawTemplates[name];
+ const currentAsString = typeof rawTemplate === "string"
+ ? rawTemplate
+ : rawTemplate instanceof Element
+ ? rawTemplate.outerHTML
+ : rawTemplate.toString();
+ const newAsString = typeof template === "string" ? template : template.outerHTML;
+ if (currentAsString === newAsString) {
+ return;
+ }
+ throw new OwlError(`Template ${name} already defined with different content`);
+ }
+ this.rawTemplates[name] = template;
+ }
+ addTemplates(xml) {
+ if (!xml) {
+ // empty string
+ return;
+ }
+ xml = xml instanceof Document ? xml : parseXML(xml);
+ for (const template of xml.querySelectorAll("[t-name]")) {
+ const name = template.getAttribute("t-name");
+ this.addTemplate(name, template);
+ }
+ }
+ getTemplate(name) {
+ var _a;
+ if (!(name in this.templates)) {
+ const rawTemplate = ((_a = this.getRawTemplate) === null || _a === void 0 ? void 0 : _a.call(this, name)) || this.rawTemplates[name];
+ if (rawTemplate === undefined) {
+ let extraInfo = "";
+ try {
+ const componentName = getCurrent().component.constructor.name;
+ extraInfo = ` (for component "${componentName}")`;
+ }
+ catch { }
+ throw new OwlError(`Missing template: "${name}"${extraInfo}`);
+ }
+ const isFn = typeof rawTemplate === "function" && !(rawTemplate instanceof Element);
+ const templateFn = isFn ? rawTemplate : this._compileTemplate(name, rawTemplate);
+ // first add a function to lazily get the template, in case there is a
+ // recursive call to the template name
+ const templates = this.templates;
+ this.templates[name] = function (context, parent) {
+ return templates[name].call(this, context, parent);
+ };
+ const template = templateFn(this, bdom, this.runtimeUtils);
+ this.templates[name] = template;
+ }
+ return this.templates[name];
+ }
+ _compileTemplate(name, template) {
+ throw new OwlError(`Unable to compile a template. Please use owl full build instead`);
+ }
+ callTemplate(owner, subTemplate, ctx, parent, key) {
+ const template = this.getTemplate(subTemplate);
+ return toggler(subTemplate, template.call(owner, ctx, parent, key + subTemplate));
+ }
+ }
+ // -----------------------------------------------------------------------------
+ // xml tag helper
+ // -----------------------------------------------------------------------------
+ const globalTemplates = {};
+ function xml(...args) {
+ const name = `__template__${xml.nextId++}`;
+ const value = String.raw(...args);
+ globalTemplates[name] = value;
+ return name;
+ }
+ xml.nextId = 1;
TemplateSet.registerTemplate("__portal__", portalTemplate);
- /**
- * Owl QWeb Expression Parser
- *
- * Owl needs in various contexts to be able to understand the structure of a
- * string representing a javascript expression. The usual goal is to be able
- * to rewrite some variables. For example, if a template has
- *
- * ```xml
- * ...
- * ```
- *
- * this needs to be translated in something like this:
- *
- * ```js
- * if (context["computeSomething"]({val: context["state"].val})) { ... }
- * ```
- *
- * This file contains the implementation of an extremely naive tokenizer/parser
- * and evaluator for javascript expressions. The supported grammar is basically
- * only expressive enough to understand the shape of objects, of arrays, and
- * various operators.
- */
- //------------------------------------------------------------------------------
- // Misc types, constants and helpers
- //------------------------------------------------------------------------------
- const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date,__globals__".split(",");
- const WORD_REPLACEMENT = Object.assign(Object.create(null), {
- and: "&&",
- or: "||",
- gt: ">",
- gte: ">=",
- lt: "<",
- lte: "<=",
- });
- const STATIC_TOKEN_MAP = Object.assign(Object.create(null), {
- "{": "LEFT_BRACE",
- "}": "RIGHT_BRACE",
- "[": "LEFT_BRACKET",
- "]": "RIGHT_BRACKET",
- ":": "COLON",
- ",": "COMMA",
- "(": "LEFT_PAREN",
- ")": "RIGHT_PAREN",
- });
- // note that the space after typeof is relevant. It makes sure that the formatted
- // expression has a space after typeof. Currently we don't support delete and void
- const OPERATORS = "...,.,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ,=>,=,;,in ,new ,|,&,^,~".split(",");
- let tokenizeString = function (expr) {
- let s = expr[0];
- let start = s;
- if (s !== "'" && s !== '"' && s !== "`") {
- return false;
- }
- let i = 1;
- let cur;
- while (expr[i] && expr[i] !== start) {
- cur = expr[i];
- s += cur;
- if (cur === "\\") {
- i++;
- cur = expr[i];
- if (!cur) {
- throw new OwlError("Invalid expression");
- }
- s += cur;
- }
- i++;
- }
- if (expr[i] !== start) {
- throw new OwlError("Invalid expression");
- }
- s += start;
- if (start === "`") {
- return {
- type: "TEMPLATE_STRING",
- value: s,
- replace(replacer) {
- return s.replace(/\$\{(.*?)\}/g, (match, group) => {
- return "${" + replacer(group) + "}";
- });
- },
- };
- }
- return { type: "VALUE", value: s };
- };
- let tokenizeNumber = function (expr) {
- let s = expr[0];
- if (s && s.match(/[0-9]/)) {
- let i = 1;
- while (expr[i] && expr[i].match(/[0-9]|\./)) {
- s += expr[i];
- i++;
- }
- return { type: "VALUE", value: s };
- }
- else {
- return false;
- }
- };
- let tokenizeSymbol = function (expr) {
- let s = expr[0];
- if (s && s.match(/[a-zA-Z_\$]/)) {
- let i = 1;
- while (expr[i] && expr[i].match(/\w/)) {
- s += expr[i];
- i++;
- }
- if (s in WORD_REPLACEMENT) {
- return { type: "OPERATOR", value: WORD_REPLACEMENT[s], size: s.length };
- }
- return { type: "SYMBOL", value: s };
- }
- else {
- return false;
- }
- };
- const tokenizeStatic = function (expr) {
- const char = expr[0];
- if (char && char in STATIC_TOKEN_MAP) {
- return { type: STATIC_TOKEN_MAP[char], value: char };
- }
- return false;
- };
- const tokenizeOperator = function (expr) {
- for (let op of OPERATORS) {
- if (expr.startsWith(op)) {
- return { type: "OPERATOR", value: op };
- }
- }
- return false;
- };
- const TOKENIZERS = [
- tokenizeString,
- tokenizeNumber,
- tokenizeOperator,
- tokenizeSymbol,
- tokenizeStatic,
- ];
- /**
- * Convert a javascript expression (as a string) into a list of tokens. For
- * example: `tokenize("1 + b")` will return:
- * ```js
- * [
- * {type: "VALUE", value: "1"},
- * {type: "OPERATOR", value: "+"},
- * {type: "SYMBOL", value: "b"}
- * ]
- * ```
- */
- function tokenize(expr) {
- const result = [];
- let token = true;
- let error;
- let current = expr;
- try {
- while (token) {
- current = current.trim();
- if (current) {
- for (let tokenizer of TOKENIZERS) {
- token = tokenizer(current);
- if (token) {
- result.push(token);
- current = current.slice(token.size || token.value.length);
- break;
- }
- }
- }
- else {
- token = false;
- }
- }
- }
- catch (e) {
- error = e; // Silence all errors and throw a generic error below
- }
- if (current.length || error) {
- throw new OwlError(`Tokenizer error: could not tokenize \`${expr}\``);
- }
- return result;
- }
- //------------------------------------------------------------------------------
- // Expression "evaluator"
- //------------------------------------------------------------------------------
- const isLeftSeparator = (token) => token && (token.type === "LEFT_BRACE" || token.type === "COMMA");
- const isRightSeparator = (token) => token && (token.type === "RIGHT_BRACE" || token.type === "COMMA");
- /**
- * This is the main function exported by this file. This is the code that will
- * process an expression (given as a string) and returns another expression with
- * proper lookups in the context.
- *
- * Usually, this kind of code would be very simple to do if we had an AST (so,
- * if we had a javascript parser), since then, we would only need to find the
- * variables and replace them. However, a parser is more complicated, and there
- * are no standard builtin parser API.
- *
- * Since this method is applied to simple javasript expressions, and the work to
- * be done is actually quite simple, we actually can get away with not using a
- * parser, which helps with the code size.
- *
- * Here is the heuristic used by this method to determine if a token is a
- * variable:
- * - by default, all symbols are considered a variable
- * - unless the previous token is a dot (in that case, this is a property: `a.b`)
- * - or if the previous token is a left brace or a comma, and the next token is
- * a colon (in that case, this is an object key: `{a: b}`)
- *
- * Some specific code is also required to support arrow functions. If we detect
- * the arrow operator, then we add the current (or some previous tokens) token to
- * the list of variables so it does not get replaced by a lookup in the context
- */
- function compileExprToArray(expr) {
- const localVars = new Set();
- const tokens = tokenize(expr);
- let i = 0;
- let stack = []; // to track last opening (, [ or {
- while (i < tokens.length) {
- let token = tokens[i];
- let prevToken = tokens[i - 1];
- let nextToken = tokens[i + 1];
- let groupType = stack[stack.length - 1];
- switch (token.type) {
- case "LEFT_BRACE":
- case "LEFT_BRACKET":
- case "LEFT_PAREN":
- stack.push(token.type);
- break;
- case "RIGHT_BRACE":
- case "RIGHT_BRACKET":
- case "RIGHT_PAREN":
- stack.pop();
- }
- let isVar = token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value);
- if (token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value)) {
- if (prevToken) {
- // normalize missing tokens: {a} should be equivalent to {a:a}
- if (groupType === "LEFT_BRACE" &&
- isLeftSeparator(prevToken) &&
- isRightSeparator(nextToken)) {
- tokens.splice(i + 1, 0, { type: "COLON", value: ":" }, { ...token });
- nextToken = tokens[i + 1];
- }
- if (prevToken.type === "OPERATOR" && prevToken.value === ".") {
- isVar = false;
- }
- else if (prevToken.type === "LEFT_BRACE" || prevToken.type === "COMMA") {
- if (nextToken && nextToken.type === "COLON") {
- isVar = false;
- }
- }
- }
- }
- if (token.type === "TEMPLATE_STRING") {
- token.value = token.replace((expr) => compileExpr(expr));
- }
- if (nextToken && nextToken.type === "OPERATOR" && nextToken.value === "=>") {
- if (token.type === "RIGHT_PAREN") {
- let j = i - 1;
- while (j > 0 && tokens[j].type !== "LEFT_PAREN") {
- if (tokens[j].type === "SYMBOL" && tokens[j].originalValue) {
- tokens[j].value = tokens[j].originalValue;
- localVars.add(tokens[j].value); //] = { id: tokens[j].value, expr: tokens[j].value };
- }
- j--;
- }
- }
- else {
- localVars.add(token.value); //] = { id: token.value, expr: token.value };
- }
- }
- if (isVar) {
- token.varName = token.value;
- if (!localVars.has(token.value)) {
- token.originalValue = token.value;
- token.value = `ctx['${token.value}']`;
- }
- }
- i++;
- }
- // Mark all variables that have been used locally.
- // This assumes the expression has only one scope (incorrect but "good enough for now")
- for (const token of tokens) {
- if (token.type === "SYMBOL" && token.varName && localVars.has(token.value)) {
- token.originalValue = token.value;
- token.value = `_${token.value}`;
- token.isLocal = true;
- }
- }
- return tokens;
- }
- // Leading spaces are trimmed during tokenization, so they need to be added back for some values
- const paddedValues = new Map([["in ", " in "]]);
- function compileExpr(expr) {
- return compileExprToArray(expr)
- .map((t) => paddedValues.get(t.value) || t.value)
- .join("");
- }
- const INTERP_REGEXP = /\{\{.*?\}\}|\#\{.*?\}/g;
- function replaceDynamicParts(s, replacer) {
- let matches = s.match(INTERP_REGEXP);
- if (matches && matches[0].length === s.length) {
- return `(${replacer(s.slice(2, matches[0][0] === "{" ? -2 : -1))})`;
- }
- let r = s.replace(INTERP_REGEXP, (s) => "${" + replacer(s.slice(2, s[0] === "{" ? -2 : -1)) + "}");
- return "`" + r + "`";
- }
- function interpolate(s) {
- return replaceDynamicParts(s, compileExpr);
+ /**
+ * Owl QWeb Expression Parser
+ *
+ * Owl needs in various contexts to be able to understand the structure of a
+ * string representing a javascript expression. The usual goal is to be able
+ * to rewrite some variables. For example, if a template has
+ *
+ * ```xml
+ * ...
+ * ```
+ *
+ * this needs to be translated in something like this:
+ *
+ * ```js
+ * if (context["computeSomething"]({val: context["state"].val})) { ... }
+ * ```
+ *
+ * This file contains the implementation of an extremely naive tokenizer/parser
+ * and evaluator for javascript expressions. The supported grammar is basically
+ * only expressive enough to understand the shape of objects, of arrays, and
+ * various operators.
+ */
+ //------------------------------------------------------------------------------
+ // Misc types, constants and helpers
+ //------------------------------------------------------------------------------
+ const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,eval,void,Math,RegExp,Array,Object,Date,__globals__".split(",");
+ const WORD_REPLACEMENT = Object.assign(Object.create(null), {
+ and: "&&",
+ or: "||",
+ gt: ">",
+ gte: ">=",
+ lt: "<",
+ lte: "<=",
+ });
+ const STATIC_TOKEN_MAP = Object.assign(Object.create(null), {
+ "{": "LEFT_BRACE",
+ "}": "RIGHT_BRACE",
+ "[": "LEFT_BRACKET",
+ "]": "RIGHT_BRACKET",
+ ":": "COLON",
+ ",": "COMMA",
+ "(": "LEFT_PAREN",
+ ")": "RIGHT_PAREN",
+ });
+ // note that the space after typeof is relevant. It makes sure that the formatted
+ // expression has a space after typeof. Currently we don't support delete and void
+ const OPERATORS = "...,.,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ,=>,=,;,in ,new ,|,&,^,~".split(",");
+ let tokenizeString = function (expr) {
+ let s = expr[0];
+ let start = s;
+ if (s !== "'" && s !== '"' && s !== "`") {
+ return false;
+ }
+ let i = 1;
+ let cur;
+ while (expr[i] && expr[i] !== start) {
+ cur = expr[i];
+ s += cur;
+ if (cur === "\\") {
+ i++;
+ cur = expr[i];
+ if (!cur) {
+ throw new OwlError("Invalid expression");
+ }
+ s += cur;
+ }
+ i++;
+ }
+ if (expr[i] !== start) {
+ throw new OwlError("Invalid expression");
+ }
+ s += start;
+ if (start === "`") {
+ return {
+ type: "TEMPLATE_STRING",
+ value: s,
+ replace(replacer) {
+ return s.replace(/\$\{(.*?)\}/g, (match, group) => {
+ return "${" + replacer(group) + "}";
+ });
+ },
+ };
+ }
+ return { type: "VALUE", value: s };
+ };
+ let tokenizeNumber = function (expr) {
+ let s = expr[0];
+ if (s && s.match(/[0-9]/)) {
+ let i = 1;
+ while (expr[i] && expr[i].match(/[0-9]|\./)) {
+ s += expr[i];
+ i++;
+ }
+ return { type: "VALUE", value: s };
+ }
+ else {
+ return false;
+ }
+ };
+ let tokenizeSymbol = function (expr) {
+ let s = expr[0];
+ if (s && s.match(/[a-zA-Z_\$]/)) {
+ let i = 1;
+ while (expr[i] && expr[i].match(/\w/)) {
+ s += expr[i];
+ i++;
+ }
+ if (s in WORD_REPLACEMENT) {
+ return { type: "OPERATOR", value: WORD_REPLACEMENT[s], size: s.length };
+ }
+ return { type: "SYMBOL", value: s };
+ }
+ else {
+ return false;
+ }
+ };
+ const tokenizeStatic = function (expr) {
+ const char = expr[0];
+ if (char && char in STATIC_TOKEN_MAP) {
+ return { type: STATIC_TOKEN_MAP[char], value: char };
+ }
+ return false;
+ };
+ const tokenizeOperator = function (expr) {
+ for (let op of OPERATORS) {
+ if (expr.startsWith(op)) {
+ return { type: "OPERATOR", value: op };
+ }
+ }
+ return false;
+ };
+ const TOKENIZERS = [
+ tokenizeString,
+ tokenizeNumber,
+ tokenizeOperator,
+ tokenizeSymbol,
+ tokenizeStatic,
+ ];
+ /**
+ * Convert a javascript expression (as a string) into a list of tokens. For
+ * example: `tokenize("1 + b")` will return:
+ * ```js
+ * [
+ * {type: "VALUE", value: "1"},
+ * {type: "OPERATOR", value: "+"},
+ * {type: "SYMBOL", value: "b"}
+ * ]
+ * ```
+ */
+ function tokenize(expr) {
+ const result = [];
+ let token = true;
+ let error;
+ let current = expr;
+ try {
+ while (token) {
+ current = current.trim();
+ if (current) {
+ for (let tokenizer of TOKENIZERS) {
+ token = tokenizer(current);
+ if (token) {
+ result.push(token);
+ current = current.slice(token.size || token.value.length);
+ break;
+ }
+ }
+ }
+ else {
+ token = false;
+ }
+ }
+ }
+ catch (e) {
+ error = e; // Silence all errors and throw a generic error below
+ }
+ if (current.length || error) {
+ throw new OwlError(`Tokenizer error: could not tokenize \`${expr}\``);
+ }
+ return result;
+ }
+ //------------------------------------------------------------------------------
+ // Expression "evaluator"
+ //------------------------------------------------------------------------------
+ const isLeftSeparator = (token) => token && (token.type === "LEFT_BRACE" || token.type === "COMMA");
+ const isRightSeparator = (token) => token && (token.type === "RIGHT_BRACE" || token.type === "COMMA");
+ /**
+ * This is the main function exported by this file. This is the code that will
+ * process an expression (given as a string) and returns another expression with
+ * proper lookups in the context.
+ *
+ * Usually, this kind of code would be very simple to do if we had an AST (so,
+ * if we had a javascript parser), since then, we would only need to find the
+ * variables and replace them. However, a parser is more complicated, and there
+ * are no standard builtin parser API.
+ *
+ * Since this method is applied to simple javasript expressions, and the work to
+ * be done is actually quite simple, we actually can get away with not using a
+ * parser, which helps with the code size.
+ *
+ * Here is the heuristic used by this method to determine if a token is a
+ * variable:
+ * - by default, all symbols are considered a variable
+ * - unless the previous token is a dot (in that case, this is a property: `a.b`)
+ * - or if the previous token is a left brace or a comma, and the next token is
+ * a colon (in that case, this is an object key: `{a: b}`)
+ *
+ * Some specific code is also required to support arrow functions. If we detect
+ * the arrow operator, then we add the current (or some previous tokens) token to
+ * the list of variables so it does not get replaced by a lookup in the context
+ */
+ function compileExprToArray(expr) {
+ const localVars = new Set();
+ const tokens = tokenize(expr);
+ let i = 0;
+ let stack = []; // to track last opening (, [ or {
+ while (i < tokens.length) {
+ let token = tokens[i];
+ let prevToken = tokens[i - 1];
+ let nextToken = tokens[i + 1];
+ let groupType = stack[stack.length - 1];
+ switch (token.type) {
+ case "LEFT_BRACE":
+ case "LEFT_BRACKET":
+ case "LEFT_PAREN":
+ stack.push(token.type);
+ break;
+ case "RIGHT_BRACE":
+ case "RIGHT_BRACKET":
+ case "RIGHT_PAREN":
+ stack.pop();
+ }
+ let isVar = token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value);
+ if (token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value)) {
+ if (prevToken) {
+ // normalize missing tokens: {a} should be equivalent to {a:a}
+ if (groupType === "LEFT_BRACE" &&
+ isLeftSeparator(prevToken) &&
+ isRightSeparator(nextToken)) {
+ tokens.splice(i + 1, 0, { type: "COLON", value: ":" }, { ...token });
+ nextToken = tokens[i + 1];
+ }
+ if (prevToken.type === "OPERATOR" && prevToken.value === ".") {
+ isVar = false;
+ }
+ else if (prevToken.type === "LEFT_BRACE" || prevToken.type === "COMMA") {
+ if (nextToken && nextToken.type === "COLON") {
+ isVar = false;
+ }
+ }
+ }
+ }
+ if (token.type === "TEMPLATE_STRING") {
+ token.value = token.replace((expr) => compileExpr(expr));
+ }
+ if (nextToken && nextToken.type === "OPERATOR" && nextToken.value === "=>") {
+ if (token.type === "RIGHT_PAREN") {
+ let j = i - 1;
+ while (j > 0 && tokens[j].type !== "LEFT_PAREN") {
+ if (tokens[j].type === "SYMBOL" && tokens[j].originalValue) {
+ tokens[j].value = tokens[j].originalValue;
+ localVars.add(tokens[j].value); //] = { id: tokens[j].value, expr: tokens[j].value };
+ }
+ j--;
+ }
+ }
+ else {
+ localVars.add(token.value); //] = { id: token.value, expr: token.value };
+ }
+ }
+ if (isVar) {
+ token.varName = token.value;
+ if (!localVars.has(token.value)) {
+ token.originalValue = token.value;
+ token.value = `ctx['${token.value}']`;
+ }
+ }
+ i++;
+ }
+ // Mark all variables that have been used locally.
+ // This assumes the expression has only one scope (incorrect but "good enough for now")
+ for (const token of tokens) {
+ if (token.type === "SYMBOL" && token.varName && localVars.has(token.value)) {
+ token.originalValue = token.value;
+ token.value = `_${token.value}`;
+ token.isLocal = true;
+ }
+ }
+ return tokens;
+ }
+ // Leading spaces are trimmed during tokenization, so they need to be added back for some values
+ const paddedValues = new Map([["in ", " in "]]);
+ function compileExpr(expr) {
+ return compileExprToArray(expr)
+ .map((t) => paddedValues.get(t.value) || t.value)
+ .join("");
+ }
+ const INTERP_REGEXP = /\{\{.*?\}\}|\#\{.*?\}/g;
+ function replaceDynamicParts(s, replacer) {
+ let matches = s.match(INTERP_REGEXP);
+ if (matches && matches[0].length === s.length) {
+ return `(${replacer(s.slice(2, matches[0][0] === "{" ? -2 : -1))})`;
+ }
+ let r = s.replace(INTERP_REGEXP, (s) => "${" + replacer(s.slice(2, s[0] === "{" ? -2 : -1)) + "}");
+ return "`" + r + "`";
+ }
+ function interpolate(s) {
+ return replaceDynamicParts(s, compileExpr);
}
- const whitespaceRE = /\s+/g;
- // using a non-html document so that HTML serializes as XML instead
- // of HTML (as we will parse it as xml later)
- const xmlDoc = document.implementation.createDocument(null, null, null);
- const MODS = new Set(["stop", "capture", "prevent", "self", "synthetic"]);
- let nextDataIds = {};
- function generateId(prefix = "") {
- nextDataIds[prefix] = (nextDataIds[prefix] || 0) + 1;
- return prefix + nextDataIds[prefix];
- }
- function isProp(tag, key) {
- switch (tag) {
- case "input":
- return (key === "checked" ||
- key === "indeterminate" ||
- key === "value" ||
- key === "readonly" ||
- key === "readOnly" ||
- key === "disabled");
- case "option":
- return key === "selected" || key === "disabled";
- case "textarea":
- return key === "value" || key === "readonly" || key === "readOnly" || key === "disabled";
- case "select":
- return key === "value" || key === "disabled";
- case "button":
- case "optgroup":
- return key === "disabled";
- }
- return false;
- }
- /**
- * Returns a template literal that evaluates to str. You can add interpolation
- * sigils into the string if required
- */
- function toStringExpression(str) {
- return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``;
- }
- // -----------------------------------------------------------------------------
- // BlockDescription
- // -----------------------------------------------------------------------------
- class BlockDescription {
- constructor(target, type) {
- this.dynamicTagName = null;
- this.isRoot = false;
- this.hasDynamicChildren = false;
- this.children = [];
- this.data = [];
- this.childNumber = 0;
- this.parentVar = "";
- this.id = BlockDescription.nextBlockId++;
- this.varName = "b" + this.id;
- this.blockName = "block" + this.id;
- this.target = target;
- this.type = type;
- }
- insertData(str, prefix = "d") {
- const id = generateId(prefix);
- this.target.addLine(`let ${id} = ${str};`);
- return this.data.push(id) - 1;
- }
- insert(dom) {
- if (this.currentDom) {
- this.currentDom.appendChild(dom);
- }
- else {
- this.dom = dom;
- }
- }
- generateExpr(expr) {
- if (this.type === "block") {
- const hasChildren = this.children.length;
- let params = this.data.length ? `[${this.data.join(", ")}]` : hasChildren ? "[]" : "";
- if (hasChildren) {
- params += ", [" + this.children.map((c) => c.varName).join(", ") + "]";
- }
- if (this.dynamicTagName) {
- return `toggler(${this.dynamicTagName}, ${this.blockName}(${this.dynamicTagName})(${params}))`;
- }
- return `${this.blockName}(${params})`;
- }
- else if (this.type === "list") {
- return `list(c_block${this.id})`;
- }
- return expr;
- }
- asXmlString() {
- // Can't use outerHTML on text/comment nodes
- // append dom to any element and use innerHTML instead
- const t = xmlDoc.createElement("t");
- t.appendChild(this.dom);
- return t.innerHTML;
- }
- }
- BlockDescription.nextBlockId = 1;
- function createContext(parentCtx, params) {
- return Object.assign({
- block: null,
- index: 0,
- forceNewBlock: true,
- translate: parentCtx.translate,
- translationCtx: parentCtx.translationCtx,
- tKeyExpr: null,
- nameSpace: parentCtx.nameSpace,
- tModelSelectedExpr: parentCtx.tModelSelectedExpr,
- }, params);
- }
- class CodeTarget {
- constructor(name, on) {
- this.indentLevel = 0;
- this.loopLevel = 0;
- this.code = [];
- this.hasRoot = false;
- this.hasCache = false;
- this.shouldProtectScope = false;
- this.hasRefWrapper = false;
- this.name = name;
- this.on = on || null;
- }
- addLine(line, idx) {
- const prefix = new Array(this.indentLevel + 2).join(" ");
- if (idx === undefined) {
- this.code.push(prefix + line);
- }
- else {
- this.code.splice(idx, 0, prefix + line);
- }
- }
- generateCode() {
- let result = [];
- result.push(`function ${this.name}(ctx, node, key = "") {`);
- if (this.shouldProtectScope) {
- result.push(` ctx = Object.create(ctx);`);
- result.push(` ctx[isBoundary] = 1`);
- }
- if (this.hasRefWrapper) {
- result.push(` let refWrapper = makeRefWrapper(this.__owl__);`);
- }
- if (this.hasCache) {
- result.push(` let cache = ctx.cache || {};`);
- result.push(` let nextCache = ctx.cache = {};`);
- }
- for (let line of this.code) {
- result.push(line);
- }
- if (!this.hasRoot) {
- result.push(`return text('');`);
- }
- result.push(`}`);
- return result.join("\n ");
- }
- currentKey(ctx) {
- let key = this.loopLevel ? `key${this.loopLevel}` : "key";
- if (ctx.tKeyExpr) {
- key = `${ctx.tKeyExpr} + ${key}`;
- }
- return key;
- }
- }
- const TRANSLATABLE_ATTRS = [
- "alt",
- "aria-label",
- "aria-placeholder",
- "aria-roledescription",
- "aria-valuetext",
- "label",
- "placeholder",
- "title",
- ];
- const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
- class CodeGenerator {
- constructor(ast, options) {
- this.blocks = [];
- this.nextBlockId = 1;
- this.isDebug = false;
- this.targets = [];
- this.target = new CodeTarget("template");
- this.translatableAttributes = TRANSLATABLE_ATTRS;
- this.staticDefs = [];
- this.slotNames = new Set();
- this.helpers = new Set();
- this.translateFn = options.translateFn || ((s) => s);
- if (options.translatableAttributes) {
- const attrs = new Set(TRANSLATABLE_ATTRS);
- for (let attr of options.translatableAttributes) {
- if (attr.startsWith("-")) {
- attrs.delete(attr.slice(1));
- }
- else {
- attrs.add(attr);
- }
- }
- this.translatableAttributes = [...attrs];
- }
- this.hasSafeContext = options.hasSafeContext || false;
- this.dev = options.dev || false;
- this.ast = ast;
- this.templateName = options.name;
- if (options.hasGlobalValues) {
- this.helpers.add("__globals__");
- }
- }
- generateCode() {
- const ast = this.ast;
- this.isDebug = ast.type === 12 /* TDebug */;
- BlockDescription.nextBlockId = 1;
- nextDataIds = {};
- this.compileAST(ast, {
- block: null,
- index: 0,
- forceNewBlock: false,
- isLast: true,
- translate: true,
- translationCtx: "",
- tKeyExpr: null,
- });
- // define blocks and utility functions
- let mainCode = [` let { text, createBlock, list, multi, html, toggler, comment } = bdom;`];
- if (this.helpers.size) {
- mainCode.push(`let { ${[...this.helpers].join(", ")} } = helpers;`);
- }
- if (this.templateName) {
- mainCode.push(`// Template name: "${this.templateName}"`);
- }
- for (let { id, expr } of this.staticDefs) {
- mainCode.push(`const ${id} = ${expr};`);
- }
- // define all blocks
- if (this.blocks.length) {
- mainCode.push(``);
- for (let block of this.blocks) {
- if (block.dom) {
- let xmlString = toStringExpression(block.asXmlString());
- if (block.dynamicTagName) {
- xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`);
- xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``);
- mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`);
- }
- else {
- mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`);
- }
- }
- }
- }
- // define all slots/defaultcontent function
- if (this.targets.length) {
- for (let fn of this.targets) {
- mainCode.push("");
- mainCode = mainCode.concat(fn.generateCode());
- }
- }
- // generate main code
- mainCode.push("");
- mainCode = mainCode.concat("return " + this.target.generateCode());
- const code = mainCode.join("\n ");
- if (this.isDebug) {
- const msg = `[Owl Debug]\n${code}`;
- console.log(msg);
- }
- return code;
- }
- compileInNewTarget(prefix, ast, ctx, on) {
- const name = generateId(prefix);
- const initialTarget = this.target;
- const target = new CodeTarget(name, on);
- this.targets.push(target);
- this.target = target;
- this.compileAST(ast, createContext(ctx));
- this.target = initialTarget;
- return name;
- }
- addLine(line, idx) {
- this.target.addLine(line, idx);
- }
- define(varName, expr) {
- this.addLine(`const ${varName} = ${expr};`);
- }
- insertAnchor(block, index = block.children.length) {
- const tag = `block-child-${index}`;
- const anchor = xmlDoc.createElement(tag);
- block.insert(anchor);
- }
- createBlock(parentBlock, type, ctx) {
- const hasRoot = this.target.hasRoot;
- const block = new BlockDescription(this.target, type);
- if (!hasRoot) {
- this.target.hasRoot = true;
- block.isRoot = true;
- }
- if (parentBlock) {
- parentBlock.children.push(block);
- if (parentBlock.type === "list") {
- block.parentVar = `c_block${parentBlock.id}`;
- }
- }
- return block;
- }
- insertBlock(expression, block, ctx) {
- let blockExpr = block.generateExpr(expression);
- if (block.parentVar) {
- let key = this.target.currentKey(ctx);
- this.helpers.add("withKey");
- this.addLine(`${block.parentVar}[${ctx.index}] = withKey(${blockExpr}, ${key});`);
- return;
- }
- if (ctx.tKeyExpr) {
- blockExpr = `toggler(${ctx.tKeyExpr}, ${blockExpr})`;
- }
- if (block.isRoot) {
- if (this.target.on) {
- blockExpr = this.wrapWithEventCatcher(blockExpr, this.target.on);
- }
- this.addLine(`return ${blockExpr};`);
- }
- else {
- this.define(block.varName, blockExpr);
- }
- }
- /**
- * Captures variables that are used inside of an expression. This is useful
- * because in compiled code, almost all variables are accessed through the ctx
- * object. In the case of functions, that lookup in the context can be delayed
- * which can cause issues if the value has changed since the function was
- * defined.
- *
- * @param expr the expression to capture
- * @param forceCapture whether the expression should capture its scope even if
- * it doesn't contain a function. Useful when the expression will be used as
- * a function body.
- * @returns a new expression that uses the captured values
- */
- captureExpression(expr, forceCapture = false) {
- if (!forceCapture && !expr.includes("=>")) {
- return compileExpr(expr);
- }
- const tokens = compileExprToArray(expr);
- const mapping = new Map();
- return tokens
- .map((tok) => {
- if (tok.varName && !tok.isLocal) {
- if (!mapping.has(tok.varName)) {
- const varId = generateId("v");
- mapping.set(tok.varName, varId);
- this.define(varId, tok.value);
- }
- tok.value = mapping.get(tok.varName);
- }
- return tok.value;
- })
- .join("");
- }
- translate(str, translationCtx) {
- const match = translationRE.exec(str);
- return match[1] + this.translateFn(match[2], translationCtx) + match[3];
- }
- /**
- * @returns the newly created block name, if any
- */
- compileAST(ast, ctx) {
- switch (ast.type) {
- case 1 /* Comment */:
- return this.compileComment(ast, ctx);
- case 0 /* Text */:
- return this.compileText(ast, ctx);
- case 2 /* DomNode */:
- return this.compileTDomNode(ast, ctx);
- case 4 /* TEsc */:
- return this.compileTEsc(ast, ctx);
- case 8 /* TOut */:
- return this.compileTOut(ast, ctx);
- case 5 /* TIf */:
- return this.compileTIf(ast, ctx);
- case 9 /* TForEach */:
- return this.compileTForeach(ast, ctx);
- case 10 /* TKey */:
- return this.compileTKey(ast, ctx);
- case 3 /* Multi */:
- return this.compileMulti(ast, ctx);
- case 7 /* TCall */:
- return this.compileTCall(ast, ctx);
- case 15 /* TCallBlock */:
- return this.compileTCallBlock(ast, ctx);
- case 6 /* TSet */:
- return this.compileTSet(ast, ctx);
- case 11 /* TComponent */:
- return this.compileComponent(ast, ctx);
- case 12 /* TDebug */:
- return this.compileDebug(ast, ctx);
- case 13 /* TLog */:
- return this.compileLog(ast, ctx);
- case 14 /* TSlot */:
- return this.compileTSlot(ast, ctx);
- case 16 /* TTranslation */:
- return this.compileTTranslation(ast, ctx);
- case 17 /* TTranslationContext */:
- return this.compileTTranslationContext(ast, ctx);
- case 18 /* TPortal */:
- return this.compileTPortal(ast, ctx);
- }
- }
- compileDebug(ast, ctx) {
- this.addLine(`debugger;`);
- if (ast.content) {
- return this.compileAST(ast.content, ctx);
- }
- return null;
- }
- compileLog(ast, ctx) {
- this.addLine(`console.log(${compileExpr(ast.expr)});`);
- if (ast.content) {
- return this.compileAST(ast.content, ctx);
- }
- return null;
- }
- compileComment(ast, ctx) {
- let { block, forceNewBlock } = ctx;
- const isNewBlock = !block || forceNewBlock;
- if (isNewBlock) {
- block = this.createBlock(block, "comment", ctx);
- this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, {
- ...ctx,
- forceNewBlock: forceNewBlock && !block,
- });
- }
- else {
- const text = xmlDoc.createComment(ast.value);
- block.insert(text);
- }
- return block.varName;
- }
- compileText(ast, ctx) {
- let { block, forceNewBlock } = ctx;
- let value = ast.value;
- if (value && ctx.translate !== false) {
- value = this.translate(value, ctx.translationCtx);
- }
- if (!ctx.inPreTag) {
- value = value.replace(whitespaceRE, " ");
- }
- if (!block || forceNewBlock) {
- block = this.createBlock(block, "text", ctx);
- this.insertBlock(`text(${toStringExpression(value)})`, block, {
- ...ctx,
- forceNewBlock: forceNewBlock && !block,
- });
- }
- else {
- const createFn = ast.type === 0 /* Text */ ? xmlDoc.createTextNode : xmlDoc.createComment;
- block.insert(createFn.call(xmlDoc, value));
- }
- return block.varName;
- }
- generateHandlerCode(rawEvent, handler) {
- const modifiers = rawEvent
- .split(".")
- .slice(1)
- .map((m) => {
- if (!MODS.has(m)) {
- throw new OwlError(`Unknown event modifier: '${m}'`);
- }
- return `"${m}"`;
- });
- let modifiersCode = "";
- if (modifiers.length) {
- modifiersCode = `${modifiers.join(",")}, `;
- }
- return `[${modifiersCode}${this.captureExpression(handler)}, ctx]`;
- }
- compileTDomNode(ast, ctx) {
- var _a;
- let { block, forceNewBlock } = ctx;
- const isNewBlock = !block || forceNewBlock || ast.dynamicTag !== null || ast.ns;
- let codeIdx = this.target.code.length;
- if (isNewBlock) {
- if ((ast.dynamicTag || ctx.tKeyExpr || ast.ns) && ctx.block) {
- this.insertAnchor(ctx.block);
- }
- block = this.createBlock(block, "block", ctx);
- this.blocks.push(block);
- if (ast.dynamicTag) {
- const tagExpr = generateId("tag");
- this.define(tagExpr, compileExpr(ast.dynamicTag));
- block.dynamicTagName = tagExpr;
- }
- }
- // attributes
- const attrs = {};
- for (let key in ast.attrs) {
- let expr, attrName;
- if (key.startsWith("t-attf")) {
- expr = interpolate(ast.attrs[key]);
- const idx = block.insertData(expr, "attr");
- attrName = key.slice(7);
- attrs["block-attribute-" + idx] = attrName;
- }
- else if (key.startsWith("t-att")) {
- attrName = key === "t-att" ? null : key.slice(6);
- expr = compileExpr(ast.attrs[key]);
- if (attrName && isProp(ast.tag, attrName)) {
- if (attrName === "readonly") {
- // the property has a different name than the attribute
- attrName = "readOnly";
- }
- // we force a new string or new boolean to bypass the equality check in blockdom when patching same value
- if (attrName === "value") {
- // When the expression is falsy (except 0), fall back to an empty string
- expr = `new String((${expr}) === 0 ? 0 : ((${expr}) || ""))`;
- }
- else {
- expr = `new Boolean(${expr})`;
- }
- const idx = block.insertData(expr, "prop");
- attrs[`block-property-${idx}`] = attrName;
- }
- else {
- const idx = block.insertData(expr, "attr");
- if (key === "t-att") {
- attrs[`block-attributes`] = String(idx);
- }
- else {
- attrs[`block-attribute-${idx}`] = attrName;
- }
- }
- }
- else if (this.translatableAttributes.includes(key)) {
- const attrTranslationCtx = ((_a = ast.attrsTranslationCtx) === null || _a === void 0 ? void 0 : _a[key]) || ctx.translationCtx;
- attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx);
- }
- else {
- expr = `"${ast.attrs[key]}"`;
- attrName = key;
- attrs[key] = ast.attrs[key];
- }
- if (attrName === "value" && ctx.tModelSelectedExpr) {
- let selectedId = block.insertData(`${ctx.tModelSelectedExpr} === ${expr}`, "attr");
- attrs[`block-attribute-${selectedId}`] = "selected";
- }
- }
- // t-model
- let tModelSelectedExpr;
- if (ast.model) {
- const { hasDynamicChildren, baseExpr, expr, eventType, shouldNumberize, shouldTrim, targetAttr, specialInitTargetAttr, } = ast.model;
- const baseExpression = compileExpr(baseExpr);
- const bExprId = generateId("bExpr");
- this.define(bExprId, baseExpression);
- const expression = compileExpr(expr);
- const exprId = generateId("expr");
- this.define(exprId, expression);
- const fullExpression = `${bExprId}[${exprId}]`;
- let idx;
- if (specialInitTargetAttr) {
- let targetExpr = targetAttr in attrs && `'${attrs[targetAttr]}'`;
- if (!targetExpr && ast.attrs) {
- // look at the dynamic attribute counterpart
- const dynamicTgExpr = ast.attrs[`t-att-${targetAttr}`];
- if (dynamicTgExpr) {
- targetExpr = compileExpr(dynamicTgExpr);
- }
- }
- idx = block.insertData(`${fullExpression} === ${targetExpr}`, "prop");
- attrs[`block-property-${idx}`] = specialInitTargetAttr;
- }
- else if (hasDynamicChildren) {
- const bValueId = generateId("bValue");
- tModelSelectedExpr = `${bValueId}`;
- this.define(tModelSelectedExpr, fullExpression);
- }
- else {
- idx = block.insertData(`${fullExpression}`, "prop");
- attrs[`block-property-${idx}`] = targetAttr;
- }
- this.helpers.add("toNumber");
- let valueCode = `ev.target.${targetAttr}`;
- valueCode = shouldTrim ? `${valueCode}.trim()` : valueCode;
- valueCode = shouldNumberize ? `toNumber(${valueCode})` : valueCode;
- const handler = `[(ev) => { ${fullExpression} = ${valueCode}; }]`;
- idx = block.insertData(handler, "hdlr");
- attrs[`block-handler-${idx}`] = eventType;
- }
- // event handlers
- for (let ev in ast.on) {
- const name = this.generateHandlerCode(ev, ast.on[ev]);
- const idx = block.insertData(name, "hdlr");
- attrs[`block-handler-${idx}`] = ev;
- }
- // t-ref
- if (ast.ref) {
- if (this.dev) {
- this.helpers.add("makeRefWrapper");
- this.target.hasRefWrapper = true;
- }
- const isDynamic = INTERP_REGEXP.test(ast.ref);
- let name = `\`${ast.ref}\``;
- if (isDynamic) {
- name = replaceDynamicParts(ast.ref, (expr) => this.captureExpression(expr, true));
- }
- let setRefStr = `(el) => this.__owl__.setRef((${name}), el)`;
- if (this.dev) {
- setRefStr = `refWrapper(${name}, ${setRefStr})`;
- }
- const idx = block.insertData(setRefStr, "ref");
- attrs["block-ref"] = String(idx);
- }
- const nameSpace = ast.ns || ctx.nameSpace;
- const dom = nameSpace
- ? xmlDoc.createElementNS(nameSpace, ast.tag)
- : xmlDoc.createElement(ast.tag);
- for (const [attr, val] of Object.entries(attrs)) {
- if (!(attr === "class" && val === "")) {
- dom.setAttribute(attr, val);
- }
- }
- block.insert(dom);
- if (ast.content.length) {
- const initialDom = block.currentDom;
- block.currentDom = dom;
- const children = ast.content;
- for (let i = 0; i < children.length; i++) {
- const child = ast.content[i];
- const subCtx = createContext(ctx, {
- block,
- index: block.childNumber,
- forceNewBlock: false,
- isLast: ctx.isLast && i === children.length - 1,
- tKeyExpr: ctx.tKeyExpr,
- nameSpace,
- tModelSelectedExpr,
- inPreTag: ctx.inPreTag || ast.tag === "pre",
- });
- this.compileAST(child, subCtx);
- }
- block.currentDom = initialDom;
- }
- if (isNewBlock) {
- this.insertBlock(`${block.blockName}(ddd)`, block, ctx);
- // may need to rewrite code!
- if (block.children.length && block.hasDynamicChildren) {
- const code = this.target.code;
- const children = block.children.slice();
- let current = children.shift();
- for (let i = codeIdx; i < code.length; i++) {
- if (code[i].trimStart().startsWith(`const ${current.varName} `)) {
- code[i] = code[i].replace(`const ${current.varName}`, current.varName);
- current = children.shift();
- if (!current)
- break;
- }
- }
- this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx);
- }
- }
- return block.varName;
- }
- compileTEsc(ast, ctx) {
- let { block, forceNewBlock } = ctx;
- let expr;
- if (ast.expr === "0") {
- this.helpers.add("zero");
- expr = `ctx[zero]`;
- }
- else {
- expr = compileExpr(ast.expr);
- if (ast.defaultValue) {
- this.helpers.add("withDefault");
- // FIXME: defaultValue is not translated
- expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`;
- }
- }
- if (!block || forceNewBlock) {
- block = this.createBlock(block, "text", ctx);
- this.insertBlock(`text(${expr})`, block, { ...ctx, forceNewBlock: forceNewBlock && !block });
- }
- else {
- const idx = block.insertData(expr, "txt");
- const text = xmlDoc.createElement(`block-text-${idx}`);
- block.insert(text);
- }
- return block.varName;
- }
- compileTOut(ast, ctx) {
- let { block } = ctx;
- if (block) {
- this.insertAnchor(block);
- }
- block = this.createBlock(block, "html", ctx);
- let blockStr;
- if (ast.expr === "0") {
- this.helpers.add("zero");
- blockStr = `ctx[zero]`;
- }
- else if (ast.body) {
- let bodyValue = null;
- bodyValue = BlockDescription.nextBlockId;
- const subCtx = createContext(ctx);
- this.compileAST({ type: 3 /* Multi */, content: ast.body }, subCtx);
- this.helpers.add("safeOutput");
- blockStr = `safeOutput(${compileExpr(ast.expr)}, b${bodyValue})`;
- }
- else {
- this.helpers.add("safeOutput");
- blockStr = `safeOutput(${compileExpr(ast.expr)})`;
- }
- this.insertBlock(blockStr, block, ctx);
- return block.varName;
- }
- compileTIfBranch(content, block, ctx) {
- this.target.indentLevel++;
- let childN = block.children.length;
- this.compileAST(content, createContext(ctx, { block, index: ctx.index }));
- if (block.children.length > childN) {
- // we have some content => need to insert an anchor at correct index
- this.insertAnchor(block, childN);
- }
- this.target.indentLevel--;
- }
- compileTIf(ast, ctx, nextNode) {
- let { block, forceNewBlock } = ctx;
- const codeIdx = this.target.code.length;
- const isNewBlock = !block || (block.type !== "multi" && forceNewBlock);
- if (block) {
- block.hasDynamicChildren = true;
- }
- if (!block || (block.type !== "multi" && forceNewBlock)) {
- block = this.createBlock(block, "multi", ctx);
- }
- this.addLine(`if (${compileExpr(ast.condition)}) {`);
- this.compileTIfBranch(ast.content, block, ctx);
- if (ast.tElif) {
- for (let clause of ast.tElif) {
- this.addLine(`} else if (${compileExpr(clause.condition)}) {`);
- this.compileTIfBranch(clause.content, block, ctx);
- }
- }
- if (ast.tElse) {
- this.addLine(`} else {`);
- this.compileTIfBranch(ast.tElse, block, ctx);
- }
- this.addLine("}");
- if (isNewBlock) {
- // note: this part is duplicated from end of compiledomnode:
- if (block.children.length) {
- const code = this.target.code;
- const children = block.children.slice();
- let current = children.shift();
- for (let i = codeIdx; i < code.length; i++) {
- if (code[i].trimStart().startsWith(`const ${current.varName} `)) {
- code[i] = code[i].replace(`const ${current.varName}`, current.varName);
- current = children.shift();
- if (!current)
- break;
- }
- }
- this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx);
- }
- // note: this part is duplicated from end of compilemulti:
- const args = block.children.map((c) => c.varName).join(", ");
- this.insertBlock(`multi([${args}])`, block, ctx);
- }
- return block.varName;
- }
- compileTForeach(ast, ctx) {
- let { block } = ctx;
- if (block) {
- this.insertAnchor(block);
- }
- block = this.createBlock(block, "list", ctx);
- this.target.loopLevel++;
- const loopVar = `i${this.target.loopLevel}`;
- this.addLine(`ctx = Object.create(ctx);`);
- const vals = `v_block${block.id}`;
- const keys = `k_block${block.id}`;
- const l = `l_block${block.id}`;
- const c = `c_block${block.id}`;
- this.helpers.add("prepareList");
- this.define(`[${keys}, ${vals}, ${l}, ${c}]`, `prepareList(${compileExpr(ast.collection)});`);
- // Throw errors on duplicate keys in dev mode
- if (this.dev) {
- this.define(`keys${block.id}`, `new Set()`);
- }
- this.addLine(`for (let ${loopVar} = 0; ${loopVar} < ${l}; ${loopVar}++) {`);
- this.target.indentLevel++;
- this.addLine(`ctx[\`${ast.elem}\`] = ${keys}[${loopVar}];`);
- if (!ast.hasNoFirst) {
- this.addLine(`ctx[\`${ast.elem}_first\`] = ${loopVar} === 0;`);
- }
- if (!ast.hasNoLast) {
- this.addLine(`ctx[\`${ast.elem}_last\`] = ${loopVar} === ${keys}.length - 1;`);
- }
- if (!ast.hasNoIndex) {
- this.addLine(`ctx[\`${ast.elem}_index\`] = ${loopVar};`);
- }
- if (!ast.hasNoValue) {
- this.addLine(`ctx[\`${ast.elem}_value\`] = ${vals}[${loopVar}];`);
- }
- this.define(`key${this.target.loopLevel}`, ast.key ? compileExpr(ast.key) : loopVar);
- if (this.dev) {
- // Throw error on duplicate keys in dev mode
- this.helpers.add("OwlError");
- this.addLine(`if (keys${block.id}.has(String(key${this.target.loopLevel}))) { throw new OwlError(\`Got duplicate key in t-foreach: \${key${this.target.loopLevel}}\`)}`);
- this.addLine(`keys${block.id}.add(String(key${this.target.loopLevel}));`);
- }
- let id;
- if (ast.memo) {
- this.target.hasCache = true;
- id = generateId();
- this.define(`memo${id}`, compileExpr(ast.memo));
- this.define(`vnode${id}`, `cache[key${this.target.loopLevel}];`);
- this.addLine(`if (vnode${id}) {`);
- this.target.indentLevel++;
- this.addLine(`if (shallowEqual(vnode${id}.memo, memo${id})) {`);
- this.target.indentLevel++;
- this.addLine(`${c}[${loopVar}] = vnode${id};`);
- this.addLine(`nextCache[key${this.target.loopLevel}] = vnode${id};`);
- this.addLine(`continue;`);
- this.target.indentLevel--;
- this.addLine("}");
- this.target.indentLevel--;
- this.addLine("}");
- }
- const subCtx = createContext(ctx, { block, index: loopVar });
- this.compileAST(ast.body, subCtx);
- if (ast.memo) {
- this.addLine(`nextCache[key${this.target.loopLevel}] = Object.assign(${c}[${loopVar}], {memo: memo${id}});`);
- }
- this.target.indentLevel--;
- this.target.loopLevel--;
- this.addLine(`}`);
- if (!ctx.isLast) {
- this.addLine(`ctx = ctx.__proto__;`);
- }
- this.insertBlock("l", block, ctx);
- return block.varName;
- }
- compileTKey(ast, ctx) {
- const tKeyExpr = generateId("tKey_");
- this.define(tKeyExpr, compileExpr(ast.expr));
- ctx = createContext(ctx, {
- tKeyExpr,
- block: ctx.block,
- index: ctx.index,
- });
- return this.compileAST(ast.content, ctx);
- }
- compileMulti(ast, ctx) {
- let { block, forceNewBlock } = ctx;
- const isNewBlock = !block || forceNewBlock;
- let codeIdx = this.target.code.length;
- if (isNewBlock) {
- const n = ast.content.filter((c) => !c.hasNoRepresentation).length;
- let result = null;
- if (n <= 1) {
- for (let child of ast.content) {
- const blockName = this.compileAST(child, ctx);
- result = result || blockName;
- }
- return result;
- }
- block = this.createBlock(block, "multi", ctx);
- }
- let index = 0;
- for (let i = 0, l = ast.content.length; i < l; i++) {
- const child = ast.content[i];
- const forceNewBlock = !child.hasNoRepresentation;
- const subCtx = createContext(ctx, {
- block,
- index,
- forceNewBlock,
- isLast: ctx.isLast && i === l - 1,
- });
- this.compileAST(child, subCtx);
- if (forceNewBlock) {
- index++;
- }
- }
- if (isNewBlock) {
- if (block.hasDynamicChildren && block.children.length) {
- const code = this.target.code;
- const children = block.children.slice();
- let current = children.shift();
- for (let i = codeIdx; i < code.length; i++) {
- if (code[i].trimStart().startsWith(`const ${current.varName} `)) {
- code[i] = code[i].replace(`const ${current.varName}`, current.varName);
- current = children.shift();
- if (!current)
- break;
- }
- }
- this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx);
- }
- const args = block.children.map((c) => c.varName).join(", ");
- this.insertBlock(`multi([${args}])`, block, ctx);
- }
- return block.varName;
- }
- compileTCall(ast, ctx) {
- let { block, forceNewBlock } = ctx;
- let ctxVar = ctx.ctxVar || "ctx";
- if (ast.context) {
- ctxVar = generateId("ctx");
- this.addLine(`let ${ctxVar} = ${compileExpr(ast.context)};`);
- }
- const isDynamic = INTERP_REGEXP.test(ast.name);
- const subTemplate = isDynamic ? interpolate(ast.name) : "`" + ast.name + "`";
- if (block && !forceNewBlock) {
- this.insertAnchor(block);
- }
- block = this.createBlock(block, "multi", ctx);
- if (ast.body) {
- this.addLine(`${ctxVar} = Object.create(${ctxVar});`);
- this.addLine(`${ctxVar}[isBoundary] = 1;`);
- this.helpers.add("isBoundary");
- const subCtx = createContext(ctx, { ctxVar });
- const bl = this.compileMulti({ type: 3 /* Multi */, content: ast.body }, subCtx);
- if (bl) {
- this.helpers.add("zero");
- this.addLine(`${ctxVar}[zero] = ${bl};`);
- }
- }
- const key = this.generateComponentKey();
- if (isDynamic) {
- const templateVar = generateId("template");
- if (!this.staticDefs.find((d) => d.id === "call")) {
- this.staticDefs.push({ id: "call", expr: `app.callTemplate.bind(app)` });
- }
- this.define(templateVar, subTemplate);
- this.insertBlock(`call(this, ${templateVar}, ${ctxVar}, node, ${key})`, block, {
- ...ctx,
- forceNewBlock: !block,
- });
- }
- else {
- const id = generateId(`callTemplate_`);
- this.staticDefs.push({ id, expr: `app.getTemplate(${subTemplate})` });
- this.insertBlock(`${id}.call(this, ${ctxVar}, node, ${key})`, block, {
- ...ctx,
- forceNewBlock: !block,
- });
- }
- if (ast.body && !ctx.isLast) {
- this.addLine(`${ctxVar} = ${ctxVar}.__proto__;`);
- }
- return block.varName;
- }
- compileTCallBlock(ast, ctx) {
- let { block, forceNewBlock } = ctx;
- if (block) {
- if (!forceNewBlock) {
- this.insertAnchor(block);
- }
- }
- block = this.createBlock(block, "multi", ctx);
- this.insertBlock(compileExpr(ast.name), block, { ...ctx, forceNewBlock: !block });
- return block.varName;
- }
- compileTSet(ast, ctx) {
- this.target.shouldProtectScope = true;
- this.helpers.add("isBoundary").add("withDefault");
- const expr = ast.value ? compileExpr(ast.value || "") : "null";
- if (ast.body) {
- this.helpers.add("LazyValue");
- const bodyAst = { type: 3 /* Multi */, content: ast.body };
- const name = this.compileInNewTarget("value", bodyAst, ctx);
- let key = this.target.currentKey(ctx);
- let value = `new LazyValue(${name}, ctx, this, node, ${key})`;
- value = ast.value ? (value ? `withDefault(${expr}, ${value})` : expr) : value;
- this.addLine(`ctx[\`${ast.name}\`] = ${value};`);
- }
- else {
- let value;
- if (ast.defaultValue) {
- const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue);
- if (ast.value) {
- value = `withDefault(${expr}, ${defaultValue})`;
- }
- else {
- value = defaultValue;
- }
- }
- else {
- value = expr;
- }
- this.helpers.add("setContextValue");
- this.addLine(`setContextValue(${ctx.ctxVar || "ctx"}, "${ast.name}", ${value});`);
- }
- return null;
- }
- generateComponentKey(currentKey = "key") {
- const parts = [generateId("__")];
- for (let i = 0; i < this.target.loopLevel; i++) {
- parts.push(`\${key${i + 1}}`);
- }
- return `${currentKey} + \`${parts.join("__")}\``;
- }
- /**
- * Formats a prop name and value into a string suitable to be inserted in the
- * generated code. For example:
- *
- * Name Value Result
- * ---------------------------------------------------------
- * "number" "state" "number: ctx['state']"
- * "something" "" "something: undefined"
- * "some-prop" "state" "'some-prop': ctx['state']"
- * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
- */
- formatProp(name, value, attrsTranslationCtx, translationCtx) {
- if (name.endsWith(".translate")) {
- const attrTranslationCtx = (attrsTranslationCtx === null || attrsTranslationCtx === void 0 ? void 0 : attrsTranslationCtx[name]) || translationCtx;
- value = toStringExpression(this.translateFn(value, attrTranslationCtx));
- }
- else {
- value = this.captureExpression(value);
- }
- if (name.includes(".")) {
- let [_name, suffix] = name.split(".");
- name = _name;
- switch (suffix) {
- case "bind":
- value = `(${value}).bind(this)`;
- break;
- case "alike":
- case "translate":
- break;
- default:
- throw new OwlError(`Invalid prop suffix: ${suffix}`);
- }
- }
- name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`;
- return `${name}: ${value || undefined}`;
- }
- formatPropObject(obj, attrsTranslationCtx, translationCtx) {
- return Object.entries(obj).map(([k, v]) => this.formatProp(k, v, attrsTranslationCtx, translationCtx));
- }
- getPropString(props, dynProps) {
- let propString = `{${props.join(",")}}`;
- if (dynProps) {
- propString = `Object.assign({}, ${compileExpr(dynProps)}${props.length ? ", " + propString : ""})`;
- }
- return propString;
- }
- compileComponent(ast, ctx) {
- let { block } = ctx;
- // props
- const hasSlotsProp = "slots" in (ast.props || {});
- const props = ast.props
- ? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx)
- : [];
- // slots
- let slotDef = "";
- if (ast.slots) {
- let ctxStr = "ctx";
- if (this.target.loopLevel || !this.hasSafeContext) {
- ctxStr = generateId("ctx");
- this.helpers.add("capture");
- this.define(ctxStr, `capture(ctx)`);
- }
- let slotStr = [];
- for (let slotName in ast.slots) {
- const slotAst = ast.slots[slotName];
- const params = [];
- if (slotAst.content) {
- const name = this.compileInNewTarget("slot", slotAst.content, ctx, slotAst.on);
- params.push(`__render: ${name}.bind(this), __ctx: ${ctxStr}`);
- }
- const scope = ast.slots[slotName].scope;
- if (scope) {
- params.push(`__scope: "${scope}"`);
- }
- if (ast.slots[slotName].attrs) {
- params.push(...this.formatPropObject(ast.slots[slotName].attrs, ast.slots[slotName].attrsTranslationCtx, ctx.translationCtx));
- }
- const slotInfo = `{${params.join(", ")}}`;
- slotStr.push(`'${slotName}': ${slotInfo}`);
- }
- slotDef = `{${slotStr.join(", ")}}`;
- }
- if (slotDef && !(ast.dynamicProps || hasSlotsProp)) {
- this.helpers.add("markRaw");
- props.push(`slots: markRaw(${slotDef})`);
- }
- let propString = this.getPropString(props, ast.dynamicProps);
- let propVar;
- if ((slotDef && (ast.dynamicProps || hasSlotsProp)) || this.dev) {
- propVar = generateId("props");
- this.define(propVar, propString);
- propString = propVar;
- }
- if (slotDef && (ast.dynamicProps || hasSlotsProp)) {
- this.helpers.add("markRaw");
- this.addLine(`${propVar}.slots = markRaw(Object.assign(${slotDef}, ${propVar}.slots))`);
- }
- // cmap key
- let expr;
- if (ast.isDynamic) {
- expr = generateId("Comp");
- this.define(expr, compileExpr(ast.name));
- }
- else {
- expr = `\`${ast.name}\``;
- }
- if (this.dev) {
- this.addLine(`helpers.validateProps(${expr}, ${propVar}, this);`);
- }
- if (block && (ctx.forceNewBlock === false || ctx.tKeyExpr)) {
- // todo: check the forcenewblock condition
- this.insertAnchor(block);
- }
- let keyArg = this.generateComponentKey();
- if (ctx.tKeyExpr) {
- keyArg = `${ctx.tKeyExpr} + ${keyArg}`;
- }
- let id = generateId("comp");
- const propList = [];
- for (let p in ast.props || {}) {
- let [name, suffix] = p.split(".");
- if (!suffix) {
- propList.push(`"${name}"`);
- }
- }
- this.staticDefs.push({
- id,
- expr: `app.createComponent(${ast.isDynamic ? null : expr}, ${!ast.isDynamic}, ${!!ast.slots}, ${!!ast.dynamicProps}, [${propList}])`,
- });
- if (ast.isDynamic) {
- // If the component class changes, this can cause delayed renders to go
- // through if the key doesn't change. Use the component name for now.
- // This means that two component classes with the same name isn't supported
- // in t-component. We can generate a unique id per class later if needed.
- keyArg = `(${expr}).name + ${keyArg}`;
- }
- let blockExpr = `${id}(${propString}, ${keyArg}, node, this, ${ast.isDynamic ? expr : null})`;
- if (ast.isDynamic) {
- blockExpr = `toggler(${expr}, ${blockExpr})`;
- }
- // event handling
- if (ast.on) {
- blockExpr = this.wrapWithEventCatcher(blockExpr, ast.on);
- }
- block = this.createBlock(block, "multi", ctx);
- this.insertBlock(blockExpr, block, ctx);
- return block.varName;
- }
- wrapWithEventCatcher(expr, on) {
- this.helpers.add("createCatcher");
- let name = generateId("catcher");
- let spec = {};
- let handlers = [];
- for (let ev in on) {
- let handlerId = generateId("hdlr");
- let idx = handlers.push(handlerId) - 1;
- spec[ev] = idx;
- const handler = this.generateHandlerCode(ev, on[ev]);
- this.define(handlerId, handler);
- }
- this.staticDefs.push({ id: name, expr: `createCatcher(${JSON.stringify(spec)})` });
- return `${name}(${expr}, [${handlers.join(",")}])`;
- }
- compileTSlot(ast, ctx) {
- this.helpers.add("callSlot");
- let { block } = ctx;
- let blockString;
- let slotName;
- let dynamic = false;
- let isMultiple = false;
- if (ast.name.match(INTERP_REGEXP)) {
- dynamic = true;
- isMultiple = true;
- slotName = interpolate(ast.name);
- }
- else {
- slotName = "'" + ast.name + "'";
- isMultiple = isMultiple || this.slotNames.has(ast.name);
- this.slotNames.add(ast.name);
- }
- const attrs = { ...ast.attrs };
- const dynProps = attrs["t-props"];
- delete attrs["t-props"];
- let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
- if (isMultiple) {
- key = this.generateComponentKey(key);
- }
- const props = ast.attrs
- ? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx)
- : [];
- const scope = this.getPropString(props, dynProps);
- if (ast.defaultContent) {
- const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
- blockString = `callSlot(ctx, node, ${key}, ${slotName}, ${dynamic}, ${scope}, ${name}.bind(this))`;
- }
- else {
- if (dynamic) {
- let name = generateId("slot");
- this.define(name, slotName);
- blockString = `toggler(${name}, callSlot(ctx, node, ${key}, ${name}, ${dynamic}, ${scope}))`;
- }
- else {
- blockString = `callSlot(ctx, node, ${key}, ${slotName}, ${dynamic}, ${scope})`;
- }
- }
- // event handling
- if (ast.on) {
- blockString = this.wrapWithEventCatcher(blockString, ast.on);
- }
- if (block) {
- this.insertAnchor(block);
- }
- block = this.createBlock(block, "multi", ctx);
- this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false });
- return block.varName;
- }
- compileTTranslation(ast, ctx) {
- if (ast.content) {
- return this.compileAST(ast.content, Object.assign({}, ctx, { translate: false }));
- }
- return null;
- }
- compileTTranslationContext(ast, ctx) {
- if (ast.content) {
- return this.compileAST(ast.content, Object.assign({}, ctx, { translationCtx: ast.translationCtx }));
- }
- return null;
- }
- compileTPortal(ast, ctx) {
- if (!this.staticDefs.find((d) => d.id === "Portal")) {
- this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
- }
- let { block } = ctx;
- const name = this.compileInNewTarget("slot", ast.content, ctx);
- let ctxStr = "ctx";
- if (this.target.loopLevel || !this.hasSafeContext) {
- ctxStr = generateId("ctx");
- this.helpers.add("capture");
- this.define(ctxStr, `capture(ctx)`);
- }
- let id = generateId("comp");
- this.staticDefs.push({
- id,
- expr: `app.createComponent(null, false, true, false, false)`,
- });
- const target = compileExpr(ast.target);
- const key = this.generateComponentKey();
- const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`;
- if (block) {
- this.insertAnchor(block);
- }
- block = this.createBlock(block, "multi", ctx);
- this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false });
- return block.varName;
- }
+ const whitespaceRE = /\s+/g;
+ // using a non-html document so that HTML serializes as XML instead
+ // of HTML (as we will parse it as xml later)
+ const xmlDoc = document.implementation.createDocument(null, null, null);
+ const MODS = new Set(["stop", "capture", "prevent", "self", "synthetic"]);
+ let nextDataIds = {};
+ function generateId(prefix = "") {
+ nextDataIds[prefix] = (nextDataIds[prefix] || 0) + 1;
+ return prefix + nextDataIds[prefix];
+ }
+ function isProp(tag, key) {
+ switch (tag) {
+ case "input":
+ return (key === "checked" ||
+ key === "indeterminate" ||
+ key === "value" ||
+ key === "readonly" ||
+ key === "readOnly" ||
+ key === "disabled");
+ case "option":
+ return key === "selected" || key === "disabled";
+ case "textarea":
+ return key === "value" || key === "readonly" || key === "readOnly" || key === "disabled";
+ case "select":
+ return key === "value" || key === "disabled";
+ case "button":
+ case "optgroup":
+ return key === "disabled";
+ }
+ return false;
+ }
+ /**
+ * Returns a template literal that evaluates to str. You can add interpolation
+ * sigils into the string if required
+ */
+ function toStringExpression(str) {
+ return `\`${str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/, "\\${")}\``;
+ }
+ // -----------------------------------------------------------------------------
+ // BlockDescription
+ // -----------------------------------------------------------------------------
+ class BlockDescription {
+ constructor(target, type) {
+ this.dynamicTagName = null;
+ this.isRoot = false;
+ this.hasDynamicChildren = false;
+ this.children = [];
+ this.data = [];
+ this.childNumber = 0;
+ this.parentVar = "";
+ this.id = BlockDescription.nextBlockId++;
+ this.varName = "b" + this.id;
+ this.blockName = "block" + this.id;
+ this.target = target;
+ this.type = type;
+ }
+ insertData(str, prefix = "d") {
+ const id = generateId(prefix);
+ this.target.addLine(`let ${id} = ${str};`);
+ return this.data.push(id) - 1;
+ }
+ insert(dom) {
+ if (this.currentDom) {
+ this.currentDom.appendChild(dom);
+ }
+ else {
+ this.dom = dom;
+ }
+ }
+ generateExpr(expr) {
+ if (this.type === "block") {
+ const hasChildren = this.children.length;
+ let params = this.data.length ? `[${this.data.join(", ")}]` : hasChildren ? "[]" : "";
+ if (hasChildren) {
+ params += ", [" + this.children.map((c) => c.varName).join(", ") + "]";
+ }
+ if (this.dynamicTagName) {
+ return `toggler(${this.dynamicTagName}, ${this.blockName}(${this.dynamicTagName})(${params}))`;
+ }
+ return `${this.blockName}(${params})`;
+ }
+ else if (this.type === "list") {
+ return `list(c_block${this.id})`;
+ }
+ return expr;
+ }
+ asXmlString() {
+ // Can't use outerHTML on text/comment nodes
+ // append dom to any element and use innerHTML instead
+ const t = xmlDoc.createElement("t");
+ t.appendChild(this.dom);
+ return t.innerHTML;
+ }
+ }
+ BlockDescription.nextBlockId = 1;
+ function createContext(parentCtx, params) {
+ return Object.assign({
+ block: null,
+ index: 0,
+ forceNewBlock: true,
+ translate: parentCtx.translate,
+ translationCtx: parentCtx.translationCtx,
+ tKeyExpr: null,
+ nameSpace: parentCtx.nameSpace,
+ tModelSelectedExpr: parentCtx.tModelSelectedExpr,
+ }, params);
+ }
+ class CodeTarget {
+ constructor(name, on) {
+ this.indentLevel = 0;
+ this.loopLevel = 0;
+ this.code = [];
+ this.hasRoot = false;
+ this.hasCache = false;
+ this.shouldProtectScope = false;
+ this.hasRefWrapper = false;
+ this.name = name;
+ this.on = on || null;
+ }
+ addLine(line, idx) {
+ const prefix = new Array(this.indentLevel + 2).join(" ");
+ if (idx === undefined) {
+ this.code.push(prefix + line);
+ }
+ else {
+ this.code.splice(idx, 0, prefix + line);
+ }
+ }
+ generateCode() {
+ let result = [];
+ result.push(`function ${this.name}(ctx, node, key = "") {`);
+ if (this.shouldProtectScope) {
+ result.push(` ctx = Object.create(ctx);`);
+ result.push(` ctx[isBoundary] = 1`);
+ }
+ if (this.hasRefWrapper) {
+ result.push(` let refWrapper = makeRefWrapper(this.__owl__);`);
+ }
+ if (this.hasCache) {
+ result.push(` let cache = ctx.cache || {};`);
+ result.push(` let nextCache = ctx.cache = {};`);
+ }
+ for (let line of this.code) {
+ result.push(line);
+ }
+ if (!this.hasRoot) {
+ result.push(`return text('');`);
+ }
+ result.push(`}`);
+ return result.join("\n ");
+ }
+ currentKey(ctx) {
+ let key = this.loopLevel ? `key${this.loopLevel}` : "key";
+ if (ctx.tKeyExpr) {
+ key = `${ctx.tKeyExpr} + ${key}`;
+ }
+ return key;
+ }
+ }
+ const TRANSLATABLE_ATTRS = [
+ "alt",
+ "aria-label",
+ "aria-placeholder",
+ "aria-roledescription",
+ "aria-valuetext",
+ "label",
+ "placeholder",
+ "title",
+ ];
+ const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
+ class CodeGenerator {
+ constructor(ast, options) {
+ this.blocks = [];
+ this.nextBlockId = 1;
+ this.isDebug = false;
+ this.targets = [];
+ this.target = new CodeTarget("template");
+ this.translatableAttributes = TRANSLATABLE_ATTRS;
+ this.staticDefs = [];
+ this.slotNames = new Set();
+ this.helpers = new Set();
+ this.translateFn = options.translateFn || ((s) => s);
+ if (options.translatableAttributes) {
+ const attrs = new Set(TRANSLATABLE_ATTRS);
+ for (let attr of options.translatableAttributes) {
+ if (attr.startsWith("-")) {
+ attrs.delete(attr.slice(1));
+ }
+ else {
+ attrs.add(attr);
+ }
+ }
+ this.translatableAttributes = [...attrs];
+ }
+ this.hasSafeContext = options.hasSafeContext || false;
+ this.dev = options.dev || false;
+ this.ast = ast;
+ this.templateName = options.name;
+ if (options.hasGlobalValues) {
+ this.helpers.add("__globals__");
+ }
+ }
+ generateCode() {
+ const ast = this.ast;
+ this.isDebug = ast.type === 12 /* TDebug */;
+ BlockDescription.nextBlockId = 1;
+ nextDataIds = {};
+ this.compileAST(ast, {
+ block: null,
+ index: 0,
+ forceNewBlock: false,
+ isLast: true,
+ translate: true,
+ translationCtx: "",
+ tKeyExpr: null,
+ });
+ // define blocks and utility functions
+ let mainCode = [` let { text, createBlock, list, multi, html, toggler, comment } = bdom;`];
+ if (this.helpers.size) {
+ mainCode.push(`let { ${[...this.helpers].join(", ")} } = helpers;`);
+ }
+ if (this.templateName) {
+ mainCode.push(`// Template name: "${this.templateName}"`);
+ }
+ for (let { id, expr } of this.staticDefs) {
+ mainCode.push(`const ${id} = ${expr};`);
+ }
+ // define all blocks
+ if (this.blocks.length) {
+ mainCode.push(``);
+ for (let block of this.blocks) {
+ if (block.dom) {
+ let xmlString = toStringExpression(block.asXmlString());
+ if (block.dynamicTagName) {
+ xmlString = xmlString.replace(/^`<\w+/, `\`<\${tag || '${block.dom.nodeName}'}`);
+ xmlString = xmlString.replace(/\w+>`$/, `\${tag || '${block.dom.nodeName}'}>\``);
+ mainCode.push(`let ${block.blockName} = tag => createBlock(${xmlString});`);
+ }
+ else {
+ mainCode.push(`let ${block.blockName} = createBlock(${xmlString});`);
+ }
+ }
+ }
+ }
+ // define all slots/defaultcontent function
+ if (this.targets.length) {
+ for (let fn of this.targets) {
+ mainCode.push("");
+ mainCode = mainCode.concat(fn.generateCode());
+ }
+ }
+ // generate main code
+ mainCode.push("");
+ mainCode = mainCode.concat("return " + this.target.generateCode());
+ const code = mainCode.join("\n ");
+ if (this.isDebug) {
+ const msg = `[Owl Debug]\n${code}`;
+ console.log(msg);
+ }
+ return code;
+ }
+ compileInNewTarget(prefix, ast, ctx, on) {
+ const name = generateId(prefix);
+ const initialTarget = this.target;
+ const target = new CodeTarget(name, on);
+ this.targets.push(target);
+ this.target = target;
+ this.compileAST(ast, createContext(ctx));
+ this.target = initialTarget;
+ return name;
+ }
+ addLine(line, idx) {
+ this.target.addLine(line, idx);
+ }
+ define(varName, expr) {
+ this.addLine(`const ${varName} = ${expr};`);
+ }
+ insertAnchor(block, index = block.children.length) {
+ const tag = `block-child-${index}`;
+ const anchor = xmlDoc.createElement(tag);
+ block.insert(anchor);
+ }
+ createBlock(parentBlock, type, ctx) {
+ const hasRoot = this.target.hasRoot;
+ const block = new BlockDescription(this.target, type);
+ if (!hasRoot) {
+ this.target.hasRoot = true;
+ block.isRoot = true;
+ }
+ if (parentBlock) {
+ parentBlock.children.push(block);
+ if (parentBlock.type === "list") {
+ block.parentVar = `c_block${parentBlock.id}`;
+ }
+ }
+ return block;
+ }
+ insertBlock(expression, block, ctx) {
+ let blockExpr = block.generateExpr(expression);
+ if (block.parentVar) {
+ let key = this.target.currentKey(ctx);
+ this.helpers.add("withKey");
+ this.addLine(`${block.parentVar}[${ctx.index}] = withKey(${blockExpr}, ${key});`);
+ return;
+ }
+ if (ctx.tKeyExpr) {
+ blockExpr = `toggler(${ctx.tKeyExpr}, ${blockExpr})`;
+ }
+ if (block.isRoot) {
+ if (this.target.on) {
+ blockExpr = this.wrapWithEventCatcher(blockExpr, this.target.on);
+ }
+ this.addLine(`return ${blockExpr};`);
+ }
+ else {
+ this.define(block.varName, blockExpr);
+ }
+ }
+ /**
+ * Captures variables that are used inside of an expression. This is useful
+ * because in compiled code, almost all variables are accessed through the ctx
+ * object. In the case of functions, that lookup in the context can be delayed
+ * which can cause issues if the value has changed since the function was
+ * defined.
+ *
+ * @param expr the expression to capture
+ * @param forceCapture whether the expression should capture its scope even if
+ * it doesn't contain a function. Useful when the expression will be used as
+ * a function body.
+ * @returns a new expression that uses the captured values
+ */
+ captureExpression(expr, forceCapture = false) {
+ if (!forceCapture && !expr.includes("=>")) {
+ return compileExpr(expr);
+ }
+ const tokens = compileExprToArray(expr);
+ const mapping = new Map();
+ return tokens
+ .map((tok) => {
+ if (tok.varName && !tok.isLocal) {
+ if (!mapping.has(tok.varName)) {
+ const varId = generateId("v");
+ mapping.set(tok.varName, varId);
+ this.define(varId, tok.value);
+ }
+ tok.value = mapping.get(tok.varName);
+ }
+ return tok.value;
+ })
+ .join("");
+ }
+ translate(str, translationCtx) {
+ const match = translationRE.exec(str);
+ return match[1] + this.translateFn(match[2], translationCtx) + match[3];
+ }
+ /**
+ * @returns the newly created block name, if any
+ */
+ compileAST(ast, ctx) {
+ switch (ast.type) {
+ case 1 /* Comment */:
+ return this.compileComment(ast, ctx);
+ case 0 /* Text */:
+ return this.compileText(ast, ctx);
+ case 2 /* DomNode */:
+ return this.compileTDomNode(ast, ctx);
+ case 4 /* TEsc */:
+ return this.compileTEsc(ast, ctx);
+ case 8 /* TOut */:
+ return this.compileTOut(ast, ctx);
+ case 5 /* TIf */:
+ return this.compileTIf(ast, ctx);
+ case 9 /* TForEach */:
+ return this.compileTForeach(ast, ctx);
+ case 10 /* TKey */:
+ return this.compileTKey(ast, ctx);
+ case 3 /* Multi */:
+ return this.compileMulti(ast, ctx);
+ case 7 /* TCall */:
+ return this.compileTCall(ast, ctx);
+ case 15 /* TCallBlock */:
+ return this.compileTCallBlock(ast, ctx);
+ case 6 /* TSet */:
+ return this.compileTSet(ast, ctx);
+ case 11 /* TComponent */:
+ return this.compileComponent(ast, ctx);
+ case 12 /* TDebug */:
+ return this.compileDebug(ast, ctx);
+ case 13 /* TLog */:
+ return this.compileLog(ast, ctx);
+ case 14 /* TSlot */:
+ return this.compileTSlot(ast, ctx);
+ case 16 /* TTranslation */:
+ return this.compileTTranslation(ast, ctx);
+ case 17 /* TTranslationContext */:
+ return this.compileTTranslationContext(ast, ctx);
+ case 18 /* TPortal */:
+ return this.compileTPortal(ast, ctx);
+ }
+ }
+ compileDebug(ast, ctx) {
+ this.addLine(`debugger;`);
+ if (ast.content) {
+ return this.compileAST(ast.content, ctx);
+ }
+ return null;
+ }
+ compileLog(ast, ctx) {
+ this.addLine(`console.log(${compileExpr(ast.expr)});`);
+ if (ast.content) {
+ return this.compileAST(ast.content, ctx);
+ }
+ return null;
+ }
+ compileComment(ast, ctx) {
+ let { block, forceNewBlock } = ctx;
+ const isNewBlock = !block || forceNewBlock;
+ if (isNewBlock) {
+ block = this.createBlock(block, "comment", ctx);
+ this.insertBlock(`comment(${toStringExpression(ast.value)})`, block, {
+ ...ctx,
+ forceNewBlock: forceNewBlock && !block,
+ });
+ }
+ else {
+ const text = xmlDoc.createComment(ast.value);
+ block.insert(text);
+ }
+ return block.varName;
+ }
+ compileText(ast, ctx) {
+ let { block, forceNewBlock } = ctx;
+ let value = ast.value;
+ if (value && ctx.translate !== false) {
+ value = this.translate(value, ctx.translationCtx);
+ }
+ if (!ctx.inPreTag) {
+ value = value.replace(whitespaceRE, " ");
+ }
+ if (!block || forceNewBlock) {
+ block = this.createBlock(block, "text", ctx);
+ this.insertBlock(`text(${toStringExpression(value)})`, block, {
+ ...ctx,
+ forceNewBlock: forceNewBlock && !block,
+ });
+ }
+ else {
+ const createFn = ast.type === 0 /* Text */ ? xmlDoc.createTextNode : xmlDoc.createComment;
+ block.insert(createFn.call(xmlDoc, value));
+ }
+ return block.varName;
+ }
+ generateHandlerCode(rawEvent, handler) {
+ const modifiers = rawEvent
+ .split(".")
+ .slice(1)
+ .map((m) => {
+ if (!MODS.has(m)) {
+ throw new OwlError(`Unknown event modifier: '${m}'`);
+ }
+ return `"${m}"`;
+ });
+ let modifiersCode = "";
+ if (modifiers.length) {
+ modifiersCode = `${modifiers.join(",")}, `;
+ }
+ return `[${modifiersCode}${this.captureExpression(handler)}, ctx]`;
+ }
+ compileTDomNode(ast, ctx) {
+ var _a;
+ let { block, forceNewBlock } = ctx;
+ const isNewBlock = !block || forceNewBlock || ast.dynamicTag !== null || ast.ns;
+ let codeIdx = this.target.code.length;
+ if (isNewBlock) {
+ if ((ast.dynamicTag || ctx.tKeyExpr || ast.ns) && ctx.block) {
+ this.insertAnchor(ctx.block);
+ }
+ block = this.createBlock(block, "block", ctx);
+ this.blocks.push(block);
+ if (ast.dynamicTag) {
+ const tagExpr = generateId("tag");
+ this.define(tagExpr, compileExpr(ast.dynamicTag));
+ block.dynamicTagName = tagExpr;
+ }
+ }
+ // attributes
+ const attrs = {};
+ for (let key in ast.attrs) {
+ let expr, attrName;
+ if (key.startsWith("t-attf")) {
+ expr = interpolate(ast.attrs[key]);
+ const idx = block.insertData(expr, "attr");
+ attrName = key.slice(7);
+ attrs["block-attribute-" + idx] = attrName;
+ }
+ else if (key.startsWith("t-att")) {
+ attrName = key === "t-att" ? null : key.slice(6);
+ expr = compileExpr(ast.attrs[key]);
+ if (attrName && isProp(ast.tag, attrName)) {
+ if (attrName === "readonly") {
+ // the property has a different name than the attribute
+ attrName = "readOnly";
+ }
+ // we force a new string or new boolean to bypass the equality check in blockdom when patching same value
+ if (attrName === "value") {
+ // When the expression is falsy (except 0), fall back to an empty string
+ expr = `new String((${expr}) === 0 ? 0 : ((${expr}) || ""))`;
+ }
+ else {
+ expr = `new Boolean(${expr})`;
+ }
+ const idx = block.insertData(expr, "prop");
+ attrs[`block-property-${idx}`] = attrName;
+ }
+ else {
+ const idx = block.insertData(expr, "attr");
+ if (key === "t-att") {
+ attrs[`block-attributes`] = String(idx);
+ }
+ else {
+ attrs[`block-attribute-${idx}`] = attrName;
+ }
+ }
+ }
+ else if (this.translatableAttributes.includes(key)) {
+ const attrTranslationCtx = ((_a = ast.attrsTranslationCtx) === null || _a === void 0 ? void 0 : _a[key]) || ctx.translationCtx;
+ attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx);
+ }
+ else {
+ expr = `"${ast.attrs[key]}"`;
+ attrName = key;
+ attrs[key] = ast.attrs[key];
+ }
+ if (attrName === "value" && ctx.tModelSelectedExpr) {
+ let selectedId = block.insertData(`${ctx.tModelSelectedExpr} === ${expr}`, "attr");
+ attrs[`block-attribute-${selectedId}`] = "selected";
+ }
+ }
+ // t-model
+ let tModelSelectedExpr;
+ if (ast.model) {
+ const { hasDynamicChildren, baseExpr, expr, eventType, shouldNumberize, shouldTrim, targetAttr, specialInitTargetAttr, } = ast.model;
+ const baseExpression = compileExpr(baseExpr);
+ const bExprId = generateId("bExpr");
+ this.define(bExprId, baseExpression);
+ const expression = compileExpr(expr);
+ const exprId = generateId("expr");
+ this.define(exprId, expression);
+ const fullExpression = `${bExprId}[${exprId}]`;
+ let idx;
+ if (specialInitTargetAttr) {
+ let targetExpr = targetAttr in attrs && `'${attrs[targetAttr]}'`;
+ if (!targetExpr && ast.attrs) {
+ // look at the dynamic attribute counterpart
+ const dynamicTgExpr = ast.attrs[`t-att-${targetAttr}`];
+ if (dynamicTgExpr) {
+ targetExpr = compileExpr(dynamicTgExpr);
+ }
+ }
+ idx = block.insertData(`${fullExpression} === ${targetExpr}`, "prop");
+ attrs[`block-property-${idx}`] = specialInitTargetAttr;
+ }
+ else if (hasDynamicChildren) {
+ const bValueId = generateId("bValue");
+ tModelSelectedExpr = `${bValueId}`;
+ this.define(tModelSelectedExpr, fullExpression);
+ }
+ else {
+ idx = block.insertData(`${fullExpression}`, "prop");
+ attrs[`block-property-${idx}`] = targetAttr;
+ }
+ this.helpers.add("toNumber");
+ let valueCode = `ev.target.${targetAttr}`;
+ valueCode = shouldTrim ? `${valueCode}.trim()` : valueCode;
+ valueCode = shouldNumberize ? `toNumber(${valueCode})` : valueCode;
+ const handler = `[(ev) => { ${fullExpression} = ${valueCode}; }]`;
+ idx = block.insertData(handler, "hdlr");
+ attrs[`block-handler-${idx}`] = eventType;
+ }
+ // event handlers
+ for (let ev in ast.on) {
+ const name = this.generateHandlerCode(ev, ast.on[ev]);
+ const idx = block.insertData(name, "hdlr");
+ attrs[`block-handler-${idx}`] = ev;
+ }
+ // t-ref
+ if (ast.ref) {
+ if (this.dev) {
+ this.helpers.add("makeRefWrapper");
+ this.target.hasRefWrapper = true;
+ }
+ const isDynamic = INTERP_REGEXP.test(ast.ref);
+ let name = `\`${ast.ref}\``;
+ if (isDynamic) {
+ name = replaceDynamicParts(ast.ref, (expr) => this.captureExpression(expr, true));
+ }
+ let setRefStr = `(el) => this.__owl__.setRef((${name}), el)`;
+ if (this.dev) {
+ setRefStr = `refWrapper(${name}, ${setRefStr})`;
+ }
+ const idx = block.insertData(setRefStr, "ref");
+ attrs["block-ref"] = String(idx);
+ }
+ const nameSpace = ast.ns || ctx.nameSpace;
+ const dom = nameSpace
+ ? xmlDoc.createElementNS(nameSpace, ast.tag)
+ : xmlDoc.createElement(ast.tag);
+ for (const [attr, val] of Object.entries(attrs)) {
+ if (!(attr === "class" && val === "")) {
+ dom.setAttribute(attr, val);
+ }
+ }
+ block.insert(dom);
+ if (ast.content.length) {
+ const initialDom = block.currentDom;
+ block.currentDom = dom;
+ const children = ast.content;
+ for (let i = 0; i < children.length; i++) {
+ const child = ast.content[i];
+ const subCtx = createContext(ctx, {
+ block,
+ index: block.childNumber,
+ forceNewBlock: false,
+ isLast: ctx.isLast && i === children.length - 1,
+ tKeyExpr: ctx.tKeyExpr,
+ nameSpace,
+ tModelSelectedExpr,
+ inPreTag: ctx.inPreTag || ast.tag === "pre",
+ });
+ this.compileAST(child, subCtx);
+ }
+ block.currentDom = initialDom;
+ }
+ if (isNewBlock) {
+ this.insertBlock(`${block.blockName}(ddd)`, block, ctx);
+ // may need to rewrite code!
+ if (block.children.length && block.hasDynamicChildren) {
+ const code = this.target.code;
+ const children = block.children.slice();
+ let current = children.shift();
+ for (let i = codeIdx; i < code.length; i++) {
+ if (code[i].trimStart().startsWith(`const ${current.varName} `)) {
+ code[i] = code[i].replace(`const ${current.varName}`, current.varName);
+ current = children.shift();
+ if (!current)
+ break;
+ }
+ }
+ this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx);
+ }
+ }
+ return block.varName;
+ }
+ compileTEsc(ast, ctx) {
+ let { block, forceNewBlock } = ctx;
+ let expr;
+ if (ast.expr === "0") {
+ this.helpers.add("zero");
+ expr = `ctx[zero]`;
+ }
+ else {
+ expr = compileExpr(ast.expr);
+ if (ast.defaultValue) {
+ this.helpers.add("withDefault");
+ // FIXME: defaultValue is not translated
+ expr = `withDefault(${expr}, ${toStringExpression(ast.defaultValue)})`;
+ }
+ }
+ if (!block || forceNewBlock) {
+ block = this.createBlock(block, "text", ctx);
+ this.insertBlock(`text(${expr})`, block, { ...ctx, forceNewBlock: forceNewBlock && !block });
+ }
+ else {
+ const idx = block.insertData(expr, "txt");
+ const text = xmlDoc.createElement(`block-text-${idx}`);
+ block.insert(text);
+ }
+ return block.varName;
+ }
+ compileTOut(ast, ctx) {
+ let { block } = ctx;
+ if (block) {
+ this.insertAnchor(block);
+ }
+ block = this.createBlock(block, "html", ctx);
+ let blockStr;
+ if (ast.expr === "0") {
+ this.helpers.add("zero");
+ blockStr = `ctx[zero]`;
+ }
+ else if (ast.body) {
+ let bodyValue = null;
+ bodyValue = BlockDescription.nextBlockId;
+ const subCtx = createContext(ctx);
+ this.compileAST({ type: 3 /* Multi */, content: ast.body }, subCtx);
+ this.helpers.add("safeOutput");
+ blockStr = `safeOutput(${compileExpr(ast.expr)}, b${bodyValue})`;
+ }
+ else {
+ this.helpers.add("safeOutput");
+ blockStr = `safeOutput(${compileExpr(ast.expr)})`;
+ }
+ this.insertBlock(blockStr, block, ctx);
+ return block.varName;
+ }
+ compileTIfBranch(content, block, ctx) {
+ this.target.indentLevel++;
+ let childN = block.children.length;
+ this.compileAST(content, createContext(ctx, { block, index: ctx.index }));
+ if (block.children.length > childN) {
+ // we have some content => need to insert an anchor at correct index
+ this.insertAnchor(block, childN);
+ }
+ this.target.indentLevel--;
+ }
+ compileTIf(ast, ctx, nextNode) {
+ let { block, forceNewBlock } = ctx;
+ const codeIdx = this.target.code.length;
+ const isNewBlock = !block || (block.type !== "multi" && forceNewBlock);
+ if (block) {
+ block.hasDynamicChildren = true;
+ }
+ if (!block || (block.type !== "multi" && forceNewBlock)) {
+ block = this.createBlock(block, "multi", ctx);
+ }
+ this.addLine(`if (${compileExpr(ast.condition)}) {`);
+ this.compileTIfBranch(ast.content, block, ctx);
+ if (ast.tElif) {
+ for (let clause of ast.tElif) {
+ this.addLine(`} else if (${compileExpr(clause.condition)}) {`);
+ this.compileTIfBranch(clause.content, block, ctx);
+ }
+ }
+ if (ast.tElse) {
+ this.addLine(`} else {`);
+ this.compileTIfBranch(ast.tElse, block, ctx);
+ }
+ this.addLine("}");
+ if (isNewBlock) {
+ // note: this part is duplicated from end of compiledomnode:
+ if (block.children.length) {
+ const code = this.target.code;
+ const children = block.children.slice();
+ let current = children.shift();
+ for (let i = codeIdx; i < code.length; i++) {
+ if (code[i].trimStart().startsWith(`const ${current.varName} `)) {
+ code[i] = code[i].replace(`const ${current.varName}`, current.varName);
+ current = children.shift();
+ if (!current)
+ break;
+ }
+ }
+ this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx);
+ }
+ // note: this part is duplicated from end of compilemulti:
+ const args = block.children.map((c) => c.varName).join(", ");
+ this.insertBlock(`multi([${args}])`, block, ctx);
+ }
+ return block.varName;
+ }
+ compileTForeach(ast, ctx) {
+ let { block } = ctx;
+ if (block) {
+ this.insertAnchor(block);
+ }
+ block = this.createBlock(block, "list", ctx);
+ this.target.loopLevel++;
+ const loopVar = `i${this.target.loopLevel}`;
+ this.addLine(`ctx = Object.create(ctx);`);
+ const vals = `v_block${block.id}`;
+ const keys = `k_block${block.id}`;
+ const l = `l_block${block.id}`;
+ const c = `c_block${block.id}`;
+ this.helpers.add("prepareList");
+ this.define(`[${keys}, ${vals}, ${l}, ${c}]`, `prepareList(${compileExpr(ast.collection)});`);
+ // Throw errors on duplicate keys in dev mode
+ if (this.dev) {
+ this.define(`keys${block.id}`, `new Set()`);
+ }
+ this.addLine(`for (let ${loopVar} = 0; ${loopVar} < ${l}; ${loopVar}++) {`);
+ this.target.indentLevel++;
+ this.addLine(`ctx[\`${ast.elem}\`] = ${keys}[${loopVar}];`);
+ if (!ast.hasNoFirst) {
+ this.addLine(`ctx[\`${ast.elem}_first\`] = ${loopVar} === 0;`);
+ }
+ if (!ast.hasNoLast) {
+ this.addLine(`ctx[\`${ast.elem}_last\`] = ${loopVar} === ${keys}.length - 1;`);
+ }
+ if (!ast.hasNoIndex) {
+ this.addLine(`ctx[\`${ast.elem}_index\`] = ${loopVar};`);
+ }
+ if (!ast.hasNoValue) {
+ this.addLine(`ctx[\`${ast.elem}_value\`] = ${vals}[${loopVar}];`);
+ }
+ this.define(`key${this.target.loopLevel}`, ast.key ? compileExpr(ast.key) : loopVar);
+ if (this.dev) {
+ // Throw error on duplicate keys in dev mode
+ this.helpers.add("OwlError");
+ this.addLine(`if (keys${block.id}.has(String(key${this.target.loopLevel}))) { throw new OwlError(\`Got duplicate key in t-foreach: \${key${this.target.loopLevel}}\`)}`);
+ this.addLine(`keys${block.id}.add(String(key${this.target.loopLevel}));`);
+ }
+ let id;
+ if (ast.memo) {
+ this.target.hasCache = true;
+ id = generateId();
+ this.define(`memo${id}`, compileExpr(ast.memo));
+ this.define(`vnode${id}`, `cache[key${this.target.loopLevel}];`);
+ this.addLine(`if (vnode${id}) {`);
+ this.target.indentLevel++;
+ this.addLine(`if (shallowEqual(vnode${id}.memo, memo${id})) {`);
+ this.target.indentLevel++;
+ this.addLine(`${c}[${loopVar}] = vnode${id};`);
+ this.addLine(`nextCache[key${this.target.loopLevel}] = vnode${id};`);
+ this.addLine(`continue;`);
+ this.target.indentLevel--;
+ this.addLine("}");
+ this.target.indentLevel--;
+ this.addLine("}");
+ }
+ const subCtx = createContext(ctx, { block, index: loopVar });
+ this.compileAST(ast.body, subCtx);
+ if (ast.memo) {
+ this.addLine(`nextCache[key${this.target.loopLevel}] = Object.assign(${c}[${loopVar}], {memo: memo${id}});`);
+ }
+ this.target.indentLevel--;
+ this.target.loopLevel--;
+ this.addLine(`}`);
+ if (!ctx.isLast) {
+ this.addLine(`ctx = ctx.__proto__;`);
+ }
+ this.insertBlock("l", block, ctx);
+ return block.varName;
+ }
+ compileTKey(ast, ctx) {
+ const tKeyExpr = generateId("tKey_");
+ this.define(tKeyExpr, compileExpr(ast.expr));
+ ctx = createContext(ctx, {
+ tKeyExpr,
+ block: ctx.block,
+ index: ctx.index,
+ });
+ return this.compileAST(ast.content, ctx);
+ }
+ compileMulti(ast, ctx) {
+ let { block, forceNewBlock } = ctx;
+ const isNewBlock = !block || forceNewBlock;
+ let codeIdx = this.target.code.length;
+ if (isNewBlock) {
+ const n = ast.content.filter((c) => !c.hasNoRepresentation).length;
+ let result = null;
+ if (n <= 1) {
+ for (let child of ast.content) {
+ const blockName = this.compileAST(child, ctx);
+ result = result || blockName;
+ }
+ return result;
+ }
+ block = this.createBlock(block, "multi", ctx);
+ }
+ let index = 0;
+ for (let i = 0, l = ast.content.length; i < l; i++) {
+ const child = ast.content[i];
+ const forceNewBlock = !child.hasNoRepresentation;
+ const subCtx = createContext(ctx, {
+ block,
+ index,
+ forceNewBlock,
+ isLast: ctx.isLast && i === l - 1,
+ });
+ this.compileAST(child, subCtx);
+ if (forceNewBlock) {
+ index++;
+ }
+ }
+ if (isNewBlock) {
+ if (block.hasDynamicChildren && block.children.length) {
+ const code = this.target.code;
+ const children = block.children.slice();
+ let current = children.shift();
+ for (let i = codeIdx; i < code.length; i++) {
+ if (code[i].trimStart().startsWith(`const ${current.varName} `)) {
+ code[i] = code[i].replace(`const ${current.varName}`, current.varName);
+ current = children.shift();
+ if (!current)
+ break;
+ }
+ }
+ this.addLine(`let ${block.children.map((c) => c.varName).join(", ")};`, codeIdx);
+ }
+ const args = block.children.map((c) => c.varName).join(", ");
+ this.insertBlock(`multi([${args}])`, block, ctx);
+ }
+ return block.varName;
+ }
+ compileTCall(ast, ctx) {
+ let { block, forceNewBlock } = ctx;
+ let ctxVar = ctx.ctxVar || "ctx";
+ if (ast.context) {
+ ctxVar = generateId("ctx");
+ this.addLine(`let ${ctxVar} = ${compileExpr(ast.context)};`);
+ }
+ const isDynamic = INTERP_REGEXP.test(ast.name);
+ const subTemplate = isDynamic ? interpolate(ast.name) : "`" + ast.name + "`";
+ if (block && !forceNewBlock) {
+ this.insertAnchor(block);
+ }
+ block = this.createBlock(block, "multi", ctx);
+ if (ast.body) {
+ this.addLine(`${ctxVar} = Object.create(${ctxVar});`);
+ this.addLine(`${ctxVar}[isBoundary] = 1;`);
+ this.helpers.add("isBoundary");
+ const subCtx = createContext(ctx, { ctxVar });
+ const bl = this.compileMulti({ type: 3 /* Multi */, content: ast.body }, subCtx);
+ if (bl) {
+ this.helpers.add("zero");
+ this.addLine(`${ctxVar}[zero] = ${bl};`);
+ }
+ }
+ const key = this.generateComponentKey();
+ if (isDynamic) {
+ const templateVar = generateId("template");
+ if (!this.staticDefs.find((d) => d.id === "call")) {
+ this.staticDefs.push({ id: "call", expr: `app.callTemplate.bind(app)` });
+ }
+ this.define(templateVar, subTemplate);
+ this.insertBlock(`call(this, ${templateVar}, ${ctxVar}, node, ${key})`, block, {
+ ...ctx,
+ forceNewBlock: !block,
+ });
+ }
+ else {
+ const id = generateId(`callTemplate_`);
+ this.staticDefs.push({ id, expr: `app.getTemplate(${subTemplate})` });
+ this.insertBlock(`${id}.call(this, ${ctxVar}, node, ${key})`, block, {
+ ...ctx,
+ forceNewBlock: !block,
+ });
+ }
+ if (ast.body && !ctx.isLast) {
+ this.addLine(`${ctxVar} = ${ctxVar}.__proto__;`);
+ }
+ return block.varName;
+ }
+ compileTCallBlock(ast, ctx) {
+ let { block, forceNewBlock } = ctx;
+ if (block) {
+ if (!forceNewBlock) {
+ this.insertAnchor(block);
+ }
+ }
+ block = this.createBlock(block, "multi", ctx);
+ this.insertBlock(compileExpr(ast.name), block, { ...ctx, forceNewBlock: !block });
+ return block.varName;
+ }
+ compileTSet(ast, ctx) {
+ this.target.shouldProtectScope = true;
+ this.helpers.add("isBoundary").add("withDefault");
+ const expr = ast.value ? compileExpr(ast.value || "") : "null";
+ if (ast.body) {
+ this.helpers.add("LazyValue");
+ const bodyAst = { type: 3 /* Multi */, content: ast.body };
+ const name = this.compileInNewTarget("value", bodyAst, ctx);
+ let key = this.target.currentKey(ctx);
+ let value = `new LazyValue(${name}, ctx, this, node, ${key})`;
+ value = ast.value ? (value ? `withDefault(${expr}, ${value})` : expr) : value;
+ this.addLine(`ctx[\`${ast.name}\`] = ${value};`);
+ }
+ else {
+ let value;
+ if (ast.defaultValue) {
+ const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue);
+ if (ast.value) {
+ value = `withDefault(${expr}, ${defaultValue})`;
+ }
+ else {
+ value = defaultValue;
+ }
+ }
+ else {
+ value = expr;
+ }
+ this.helpers.add("setContextValue");
+ this.addLine(`setContextValue(${ctx.ctxVar || "ctx"}, "${ast.name}", ${value});`);
+ }
+ return null;
+ }
+ generateComponentKey(currentKey = "key") {
+ const parts = [generateId("__")];
+ for (let i = 0; i < this.target.loopLevel; i++) {
+ parts.push(`\${key${i + 1}}`);
+ }
+ return `${currentKey} + \`${parts.join("__")}\``;
+ }
+ /**
+ * Formats a prop name and value into a string suitable to be inserted in the
+ * generated code. For example:
+ *
+ * Name Value Result
+ * ---------------------------------------------------------
+ * "number" "state" "number: ctx['state']"
+ * "something" "" "something: undefined"
+ * "some-prop" "state" "'some-prop': ctx['state']"
+ * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
+ */
+ formatProp(name, value, attrsTranslationCtx, translationCtx) {
+ if (name.endsWith(".translate")) {
+ const attrTranslationCtx = (attrsTranslationCtx === null || attrsTranslationCtx === void 0 ? void 0 : attrsTranslationCtx[name]) || translationCtx;
+ value = toStringExpression(this.translateFn(value, attrTranslationCtx));
+ }
+ else {
+ value = this.captureExpression(value);
+ }
+ if (name.includes(".")) {
+ let [_name, suffix] = name.split(".");
+ name = _name;
+ switch (suffix) {
+ case "bind":
+ value = `(${value}).bind(this)`;
+ break;
+ case "alike":
+ case "translate":
+ break;
+ default:
+ throw new OwlError(`Invalid prop suffix: ${suffix}`);
+ }
+ }
+ name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`;
+ return `${name}: ${value || undefined}`;
+ }
+ formatPropObject(obj, attrsTranslationCtx, translationCtx) {
+ return Object.entries(obj).map(([k, v]) => this.formatProp(k, v, attrsTranslationCtx, translationCtx));
+ }
+ getPropString(props, dynProps) {
+ let propString = `{${props.join(",")}}`;
+ if (dynProps) {
+ propString = `Object.assign({}, ${compileExpr(dynProps)}${props.length ? ", " + propString : ""})`;
+ }
+ return propString;
+ }
+ compileComponent(ast, ctx) {
+ let { block } = ctx;
+ // props
+ const hasSlotsProp = "slots" in (ast.props || {});
+ const props = ast.props
+ ? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx)
+ : [];
+ // slots
+ let slotDef = "";
+ if (ast.slots) {
+ let ctxStr = "ctx";
+ if (this.target.loopLevel || !this.hasSafeContext) {
+ ctxStr = generateId("ctx");
+ this.helpers.add("capture");
+ this.define(ctxStr, `capture(ctx)`);
+ }
+ let slotStr = [];
+ for (let slotName in ast.slots) {
+ const slotAst = ast.slots[slotName];
+ const params = [];
+ if (slotAst.content) {
+ const name = this.compileInNewTarget("slot", slotAst.content, ctx, slotAst.on);
+ params.push(`__render: ${name}.bind(this), __ctx: ${ctxStr}`);
+ }
+ const scope = ast.slots[slotName].scope;
+ if (scope) {
+ params.push(`__scope: "${scope}"`);
+ }
+ if (ast.slots[slotName].attrs) {
+ params.push(...this.formatPropObject(ast.slots[slotName].attrs, ast.slots[slotName].attrsTranslationCtx, ctx.translationCtx));
+ }
+ const slotInfo = `{${params.join(", ")}}`;
+ slotStr.push(`'${slotName}': ${slotInfo}`);
+ }
+ slotDef = `{${slotStr.join(", ")}}`;
+ }
+ if (slotDef && !(ast.dynamicProps || hasSlotsProp)) {
+ this.helpers.add("markRaw");
+ props.push(`slots: markRaw(${slotDef})`);
+ }
+ let propString = this.getPropString(props, ast.dynamicProps);
+ let propVar;
+ if ((slotDef && (ast.dynamicProps || hasSlotsProp)) || this.dev) {
+ propVar = generateId("props");
+ this.define(propVar, propString);
+ propString = propVar;
+ }
+ if (slotDef && (ast.dynamicProps || hasSlotsProp)) {
+ this.helpers.add("markRaw");
+ this.addLine(`${propVar}.slots = markRaw(Object.assign(${slotDef}, ${propVar}.slots))`);
+ }
+ // cmap key
+ let expr;
+ if (ast.isDynamic) {
+ expr = generateId("Comp");
+ this.define(expr, compileExpr(ast.name));
+ }
+ else {
+ expr = `\`${ast.name}\``;
+ }
+ if (this.dev) {
+ this.addLine(`helpers.validateProps(${expr}, ${propVar}, this);`);
+ }
+ if (block && (ctx.forceNewBlock === false || ctx.tKeyExpr)) {
+ // todo: check the forcenewblock condition
+ this.insertAnchor(block);
+ }
+ let keyArg = this.generateComponentKey();
+ if (ctx.tKeyExpr) {
+ keyArg = `${ctx.tKeyExpr} + ${keyArg}`;
+ }
+ let id = generateId("comp");
+ const propList = [];
+ for (let p in ast.props || {}) {
+ let [name, suffix] = p.split(".");
+ if (!suffix) {
+ propList.push(`"${name}"`);
+ }
+ }
+ this.staticDefs.push({
+ id,
+ expr: `app.createComponent(${ast.isDynamic ? null : expr}, ${!ast.isDynamic}, ${!!ast.slots}, ${!!ast.dynamicProps}, [${propList}])`,
+ });
+ if (ast.isDynamic) {
+ // If the component class changes, this can cause delayed renders to go
+ // through if the key doesn't change. Use the component name for now.
+ // This means that two component classes with the same name isn't supported
+ // in t-component. We can generate a unique id per class later if needed.
+ keyArg = `(${expr}).name + ${keyArg}`;
+ }
+ let blockExpr = `${id}(${propString}, ${keyArg}, node, this, ${ast.isDynamic ? expr : null})`;
+ if (ast.isDynamic) {
+ blockExpr = `toggler(${expr}, ${blockExpr})`;
+ }
+ // event handling
+ if (ast.on) {
+ blockExpr = this.wrapWithEventCatcher(blockExpr, ast.on);
+ }
+ block = this.createBlock(block, "multi", ctx);
+ this.insertBlock(blockExpr, block, ctx);
+ return block.varName;
+ }
+ wrapWithEventCatcher(expr, on) {
+ this.helpers.add("createCatcher");
+ let name = generateId("catcher");
+ let spec = {};
+ let handlers = [];
+ for (let ev in on) {
+ let handlerId = generateId("hdlr");
+ let idx = handlers.push(handlerId) - 1;
+ spec[ev] = idx;
+ const handler = this.generateHandlerCode(ev, on[ev]);
+ this.define(handlerId, handler);
+ }
+ this.staticDefs.push({ id: name, expr: `createCatcher(${JSON.stringify(spec)})` });
+ return `${name}(${expr}, [${handlers.join(",")}])`;
+ }
+ compileTSlot(ast, ctx) {
+ this.helpers.add("callSlot");
+ let { block } = ctx;
+ let blockString;
+ let slotName;
+ let dynamic = false;
+ let isMultiple = false;
+ if (ast.name.match(INTERP_REGEXP)) {
+ dynamic = true;
+ isMultiple = true;
+ slotName = interpolate(ast.name);
+ }
+ else {
+ slotName = "'" + ast.name + "'";
+ isMultiple = isMultiple || this.slotNames.has(ast.name);
+ this.slotNames.add(ast.name);
+ }
+ const attrs = { ...ast.attrs };
+ const dynProps = attrs["t-props"];
+ delete attrs["t-props"];
+ let key = this.target.loopLevel ? `key${this.target.loopLevel}` : "key";
+ if (isMultiple) {
+ key = this.generateComponentKey(key);
+ }
+ const props = ast.attrs
+ ? this.formatPropObject(attrs, ast.attrsTranslationCtx, ctx.translationCtx)
+ : [];
+ const scope = this.getPropString(props, dynProps);
+ if (ast.defaultContent) {
+ const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
+ blockString = `callSlot(ctx, node, ${key}, ${slotName}, ${dynamic}, ${scope}, ${name}.bind(this))`;
+ }
+ else {
+ if (dynamic) {
+ let name = generateId("slot");
+ this.define(name, slotName);
+ blockString = `toggler(${name}, callSlot(ctx, node, ${key}, ${name}, ${dynamic}, ${scope}))`;
+ }
+ else {
+ blockString = `callSlot(ctx, node, ${key}, ${slotName}, ${dynamic}, ${scope})`;
+ }
+ }
+ // event handling
+ if (ast.on) {
+ blockString = this.wrapWithEventCatcher(blockString, ast.on);
+ }
+ if (block) {
+ this.insertAnchor(block);
+ }
+ block = this.createBlock(block, "multi", ctx);
+ this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false });
+ return block.varName;
+ }
+ compileTTranslation(ast, ctx) {
+ if (ast.content) {
+ return this.compileAST(ast.content, Object.assign({}, ctx, { translate: false }));
+ }
+ return null;
+ }
+ compileTTranslationContext(ast, ctx) {
+ if (ast.content) {
+ return this.compileAST(ast.content, Object.assign({}, ctx, { translationCtx: ast.translationCtx }));
+ }
+ return null;
+ }
+ compileTPortal(ast, ctx) {
+ if (!this.staticDefs.find((d) => d.id === "Portal")) {
+ this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
+ }
+ let { block } = ctx;
+ const name = this.compileInNewTarget("slot", ast.content, ctx);
+ let ctxStr = "ctx";
+ if (this.target.loopLevel || !this.hasSafeContext) {
+ ctxStr = generateId("ctx");
+ this.helpers.add("capture");
+ this.define(ctxStr, `capture(ctx)`);
+ }
+ let id = generateId("comp");
+ this.staticDefs.push({
+ id,
+ expr: `app.createComponent(null, false, true, false, false)`,
+ });
+ const target = compileExpr(ast.target);
+ const key = this.generateComponentKey();
+ const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, ${key}, node, ctx, Portal)`;
+ if (block) {
+ this.insertAnchor(block);
+ }
+ block = this.createBlock(block, "multi", ctx);
+ this.insertBlock(blockString, block, { ...ctx, forceNewBlock: false });
+ return block.varName;
+ }
}
- // -----------------------------------------------------------------------------
- // Parser
- // -----------------------------------------------------------------------------
- const cache = new WeakMap();
- function parse(xml, customDir) {
- const ctx = {
- inPreTag: false,
- customDirectives: customDir,
- };
- if (typeof xml === "string") {
- const elem = parseXML(`${xml}`).firstChild;
- return _parse(elem, ctx);
- }
- let ast = cache.get(xml);
- if (!ast) {
- // we clone here the xml to prevent modifying it in place
- ast = _parse(xml.cloneNode(true), ctx);
- cache.set(xml, ast);
- }
- return ast;
- }
- function _parse(xml, ctx) {
- normalizeXML(xml);
- return parseNode(xml, ctx) || { type: 0 /* Text */, value: "" };
- }
- function parseNode(node, ctx) {
- if (!(node instanceof Element)) {
- return parseTextCommentNode(node, ctx);
- }
- return (parseTCustom(node, ctx) ||
- parseTDebugLog(node, ctx) ||
- parseTForEach(node, ctx) ||
- parseTIf(node, ctx) ||
- parseTPortal(node, ctx) ||
- parseTCall(node, ctx) ||
- parseTCallBlock(node) ||
- parseTTranslation(node, ctx) ||
- parseTTranslationContext(node, ctx) ||
- parseTKey(node, ctx) ||
- parseTEscNode(node, ctx) ||
- parseTOutNode(node, ctx) ||
- parseTSlot(node, ctx) ||
- parseComponent(node, ctx) ||
- parseDOMNode(node, ctx) ||
- parseTSetNode(node, ctx) ||
- parseTNode(node, ctx));
- }
- // -----------------------------------------------------------------------------
- // tag
- // -----------------------------------------------------------------------------
- function parseTNode(node, ctx) {
- if (node.tagName !== "t") {
- return null;
- }
- return parseChildNodes(node, ctx);
- }
- // -----------------------------------------------------------------------------
- // Text and Comment Nodes
- // -----------------------------------------------------------------------------
- const lineBreakRE = /[\r\n]/;
- function parseTextCommentNode(node, ctx) {
- if (node.nodeType === Node.TEXT_NODE) {
- let value = node.textContent || "";
- if (!ctx.inPreTag && lineBreakRE.test(value) && !value.trim()) {
- return null;
- }
- return { type: 0 /* Text */, value };
- }
- else if (node.nodeType === Node.COMMENT_NODE) {
- return { type: 1 /* Comment */, value: node.textContent || "" };
- }
- return null;
- }
- function parseTCustom(node, ctx) {
- if (!ctx.customDirectives) {
- return null;
- }
- const nodeAttrsNames = node.getAttributeNames();
- for (let attr of nodeAttrsNames) {
- if (attr === "t-custom" || attr === "t-custom-") {
- throw new OwlError("Missing custom directive name with t-custom directive");
- }
- if (attr.startsWith("t-custom-")) {
- const directiveName = attr.split(".")[0].slice(9);
- const customDirective = ctx.customDirectives[directiveName];
- if (!customDirective) {
- throw new OwlError(`Custom directive "${directiveName}" is not defined`);
- }
- const value = node.getAttribute(attr);
- const modifiers = attr.split(".").slice(1);
- node.removeAttribute(attr);
- try {
- customDirective(node, value, modifiers);
- }
- catch (error) {
- throw new OwlError(`Custom directive "${directiveName}" throw the following error: ${error}`);
- }
- return parseNode(node, ctx);
- }
- }
- return null;
- }
- // -----------------------------------------------------------------------------
- // debugging
- // -----------------------------------------------------------------------------
- function parseTDebugLog(node, ctx) {
- if (node.hasAttribute("t-debug")) {
- node.removeAttribute("t-debug");
- const content = parseNode(node, ctx);
- const ast = {
- type: 12 /* TDebug */,
- content,
- };
- if (content === null || content === void 0 ? void 0 : content.hasNoRepresentation) {
- ast.hasNoRepresentation = true;
- }
- return ast;
- }
- if (node.hasAttribute("t-log")) {
- const expr = node.getAttribute("t-log");
- node.removeAttribute("t-log");
- const content = parseNode(node, ctx);
- const ast = {
- type: 13 /* TLog */,
- expr,
- content,
- };
- if (content === null || content === void 0 ? void 0 : content.hasNoRepresentation) {
- ast.hasNoRepresentation = true;
- }
- return ast;
- }
- return null;
- }
- // -----------------------------------------------------------------------------
- // Regular dom node
- // -----------------------------------------------------------------------------
- const hasDotAtTheEnd = /\.[\w_]+\s*$/;
- const hasBracketsAtTheEnd = /\[[^\[]+\]\s*$/;
- const ROOT_SVG_TAGS = new Set(["svg", "g", "path"]);
- function parseDOMNode(node, ctx) {
- const { tagName } = node;
- const dynamicTag = node.getAttribute("t-tag");
- node.removeAttribute("t-tag");
- if (tagName === "t" && !dynamicTag) {
- return null;
- }
- if (tagName.startsWith("block-")) {
- throw new OwlError(`Invalid tag name: '${tagName}'`);
- }
- ctx = Object.assign({}, ctx);
- if (tagName === "pre") {
- ctx.inPreTag = true;
- }
- let ns = !ctx.nameSpace && ROOT_SVG_TAGS.has(tagName) ? "http://www.w3.org/2000/svg" : null;
- const ref = node.getAttribute("t-ref");
- node.removeAttribute("t-ref");
- const nodeAttrsNames = node.getAttributeNames();
- let attrs = null;
- let attrsTranslationCtx = null;
- let on = null;
- let model = null;
- for (let attr of nodeAttrsNames) {
- const value = node.getAttribute(attr);
- if (attr === "t-on" || attr === "t-on-") {
- throw new OwlError("Missing event name with t-on directive");
- }
- if (attr.startsWith("t-on-")) {
- on = on || {};
- on[attr.slice(5)] = value;
- }
- else if (attr.startsWith("t-model")) {
- if (!["input", "select", "textarea"].includes(tagName)) {
- throw new OwlError("The t-model directive only works with ,