diff --git a/.github/workflows/mozilla-web-ext.yml b/.github/workflows/mozilla-web-ext.yml index 66e5cebd..cb8ea827 100644 --- a/.github/workflows/mozilla-web-ext.yml +++ b/.github/workflows/mozilla-web-ext.yml @@ -18,7 +18,7 @@ jobs: uses: kewisch/action-web-ext@v1 with: cmd: build - source: src + source: dist/firefox filename: "therp-timer.xpi" ignoreFiles: '[ "package.json","package-lock.json","yarn.lock" ]' diff --git a/.gitignore b/.gitignore index 485dee64..268d0e25 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ .idea + +# Visual Studio cache/options directory +.vs/ +.vscode +.history/ diff --git a/README.md b/README.md index 7c2be69c..a1c28084 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,372 @@ -### Odoo Timer Browser extension - -Odoo Timer is a Chromium/Firefox browser extension app used to log work hours -and automatically write them to **Odoo Timesheets**. - -It connects to an Odoo instance of your choice and displays issue/task -list from any accessible projects. One can start timer based on the issue, pause and end time of the issue thus recording a new billable -timesheet line record in the timesheets. - -Features: - -* Support for both issue/task list from projects. -* Start/Pause/Stop issue/task timer. -* Create odoo timesheet line record linked to analytic account. -* Add and configure a Remote host. -* Remove existing remotes. -* Switch from existing Remotes. -* Show an individuals issues/tasks or all. - - -Usage -============= -Pull latest from `git clone git@github.com:therp/odoo-timer.git` then manually -load to your browser see below steps for Chrome and Firefox. - -Chrome -------- -1. Browse to [chrome://extensions/](chrome://extensions/) -2. Select 'load unpacked' and point to the 'src' folder. -3. The app will now appear in the top-right of your Chrome browser. - -Opera ------- -1. Go to Opera Settings probably left sidebar `...` -2. Click on `Extensions` -3. Select `load unpacked` and point to the 'src' folder. -4. The app will now appear in the top-right of Opera after pin it. - -Firefox -------- -1. Download the signed XPI package from "Releases" page. -2. Load the addon. -3. The app will now appear in the top-right of your Firefox browser - -Configuration -------------- - -* When you clicking on the Odoo Timer button in Chrome for the first time, the log in screen comes up. -* Uncheck the 'Use Default Host' box at the buttom, and use the correct URL and database name. -* For Username and Password, use your regular Odoo login info. - -How it works ------------- - -![How Odoo timer app works](src/img/usage.gif "How it Works") - -* After logging in, a list of your active issues will show up. -* Use the green buttons to start and stop working on an issue. It will automatically show up on your Timesheet -* Time worked is rounded up or down to the nearest 15 minutes, this so we can keep our invoicing nice and clean. -* Tick 'all' to have other people's issue show up in this list as well; useful for multidev tasks! -* Under 'options' there's a switch to write time on ''tasks'' instead of ''issues'', we'll be using this in the future :) - -Update ------- - -1. `cd /path/to/odoo-timer` -2. `git pull origin branch` -3. update on *chrome://extensions* -4. update on *about:debugging#/runtime/this-firefox* +# 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 + +## 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. diff --git a/src/.gitignore b/dist/chrome/.gitignore similarity index 100% rename from src/.gitignore rename to dist/chrome/.gitignore diff --git a/src/css/lib/bootstrap.min.css b/dist/chrome/css/lib/bootstrap.min.css similarity index 100% rename from src/css/lib/bootstrap.min.css rename to dist/chrome/css/lib/bootstrap.min.css diff --git a/src/css/lib/font-awesome-4.6.3/css/font-awesome.min.css b/dist/chrome/css/lib/font-awesome-4.6.3/css/font-awesome.min.css similarity index 100% rename from src/css/lib/font-awesome-4.6.3/css/font-awesome.min.css rename to dist/chrome/css/lib/font-awesome-4.6.3/css/font-awesome.min.css diff --git a/src/css/lib/font-awesome-4.6.3/fonts/FontAwesome.otf b/dist/chrome/css/lib/font-awesome-4.6.3/fonts/FontAwesome.otf similarity index 100% rename from src/css/lib/font-awesome-4.6.3/fonts/FontAwesome.otf rename to dist/chrome/css/lib/font-awesome-4.6.3/fonts/FontAwesome.otf diff --git a/src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.eot b/dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.eot similarity index 100% rename from src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.eot rename to dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.eot diff --git a/src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.svg b/dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.svg similarity index 100% rename from src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.svg rename to dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.svg diff --git a/src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.ttf b/dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.ttf similarity index 100% rename from src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.ttf rename to dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.ttf diff --git a/src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff b/dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff similarity index 100% rename from src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff rename to dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff diff --git a/src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff2 b/dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff2 similarity index 100% rename from src/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff2 rename to dist/chrome/css/lib/font-awesome-4.6.3/fonts/fontawesome-webfont.woff2 diff --git a/src/css/options_main_page.css b/dist/chrome/css/options_main_page.css similarity index 82% rename from src/css/options_main_page.css rename to dist/chrome/css/options_main_page.css index a3da9935..57b866da 100644 --- a/src/css/options_main_page.css +++ b/dist/chrome/css/options_main_page.css @@ -3,9 +3,7 @@ body { padding: 0; min-width: 700px; background-color: #FFF; - font-size: 0.9em; font-family: 'Open Sans', sans-serif; - font-size: 75%; cursor: default; -webkit-user-select: none; max-height: 700px; @@ -350,8 +348,36 @@ hr { color: #17a2b8; } +.remotes-control-btns .fa-eye:hover { + color: #17a2b8; + font-size: 1.2em !important; +} + .caption-remotes { font-weight: bolder; caption-side: top; text-align: center; -} \ No newline at end of file +} + +/* OWL rewrite additions */ +.pointer{cursor:pointer;} +.hide{display:none !important;} +.app-root{min-height:100%;} +#loader-container{display:flex;align-items:center;justify-content:center;height:180px;} +.remote-link{display:block;color:inherit;text-decoration:none;} +.readmore-inline{display:inline;} +.text-muted-soft{opacity:.75;} +.remote-row-actions i{margin-right:8px;} +.login button.login{border:none;} +.current-source-chip{display:inline-block;padding:2px 8px;border-radius:999px;background:#eef3ff;margin-left:6px;font-size:11px;} +.footer-btns i,.footer-btns a{margin-right:10px;} +.issue-desc-cell{max-width:280px;} +#searchIssue{width:78%;display:inline-block;} +#limitTo{width:20%;display:inline-block;margin-left:2%;} +.pass-viewer{position:absolute;right:18px;top:11px;cursor:pointer;} +.login .form{position:relative;} +.remotes-table-info .fa{cursor:pointer;} +.inline-help{font-size:12px;opacity:.8;margin-top:8px;} +.small-note{font-size:12px;opacity:.8;} +.timesheet-download-link{display:inline-block;margin-right:8px;color:inherit;} +.no-remotes-set{padding-left:0;} diff --git a/dist/chrome/css/popup.css b/dist/chrome/css/popup.css new file mode 100644 index 00000000..5d2e998b --- /dev/null +++ b/dist/chrome/css/popup.css @@ -0,0 +1,459 @@ +:root { + --brand-blue: #33BAF6; + --brand-blue-strong: #1FA7E6; + --brand-blue-soft: #EAF8FF; + --brand-blue-ring: rgba(51, 186, 246, 0.18); + --brand-text: #3F4854; + --brand-muted: #666666; + --brand-border: #D9E4EC; + --brand-border-soft: #DDE3EE; + --brand-bg: #EEF2F7; + --brand-card: #FFFFFF; + --brand-danger: #F30C16; + --brand-success: #28A745; + --brand-warning: #F8B334; + --brand-shadow: 0 14px 40px rgba(15, 23, 42, 0.12); + --brand-shadow-soft: 0 18px 40px rgba(27, 39, 94, 0.08); +} + +html, +body { + min-width: 780px; + min-height: 760px; + width: 780px; + max-height: 820px; + margin: 0; + padding: 0; + overflow: hidden; + font-family: "Open Sans", sans-serif; + font-size: 0.93em; + background: var(--brand-bg); +} + +body.unselectable { + min-height: 760px; + width: 780px; +} + +#app { + min-height: 760px; + width: 780px; +} + +.app-root { + min-height: 760px; + width: 780px; + max-height: 820px; + overflow-y: auto; + overflow-x: hidden; + background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%); +} + +.hide { display: none !important; } +.pointer { cursor: pointer; } +.text-center { text-align: center; } +.nomargin { margin: 0; } +.right { float: right; } +.footer { width: 100%; } +.logo { text-align: center; } +.logo img { max-width: 190px; height: auto; } +.odooError { color: var(--brand-danger); font-weight: bold; } + +/* Boot loader + main loader */ +.boot-loader, +#loader-container { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + box-sizing: border-box; + text-align: center; + background: rgba(255, 255, 255, 0.96); +} +.boot-loader.hide, +#loader-container.hide { display: none !important; } +.loader-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-width: 220px; + max-width: 320px; + min-height: 160px; + padding: 28px 24px; + border: 1px solid #e8e8ef; + border-radius: 18px; + background: linear-gradient(180deg, #ffffff 0%, #fafbff 100%); + box-shadow: var(--brand-shadow-soft); +} +.loader-text { + color: #42475a; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.02em; +} +#loader-container .fa-cog, +.boot-loader .fa-cog, +.table-loading-overlay .fa-cog, +.table-loader-card .fa-cog { color: var(--brand-blue) !important; } +#loader-container .fa-cog { margin-top: 0 !important; } + +/* Login screen */ +.login-view { + min-height: 560px; + padding: 28px 24px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + box-sizing: border-box; +} + +.popup-login-shell { + position: relative; + width: min(100%, 540px); + margin: 0 auto; + background: var(--brand-card); + border: 1px solid #e6e9ef; + border-top: 4px solid var(--brand-blue); + border-radius: 18px; + box-shadow: var(--brand-shadow); + overflow: hidden; +} + +.popup-login-shell .form { + display: block; + padding: 34px 32px 28px; + position: relative; +} +.popup-login-shell .logo { margin-bottom: 18px; } +.popup-login-shell .logo img { max-width: 200px; height: auto; } + +.popup-login-shell input, +.popup-login-shell select, +#searchIssue, +#limitTo, +#remote-selection { + width: 100%; + min-height: 48px; + border-radius: 10px; + border: 1px solid var(--brand-border); + background: #fbfcfe; + color: var(--brand-text); + margin-bottom: 14px; + box-shadow: none; + transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} +.popup-login-shell input:focus, +.popup-login-shell select:focus, +#searchIssue:focus, +#limitTo:focus, +#remote-selection:focus { + outline: none; + border-color: var(--brand-blue); + box-shadow: 0 0 0 3px var(--brand-blue-ring); + background: #fff; +} +.popup-login-shell input:hover, +.popup-login-shell select:hover, +#searchIssue:hover, +#limitTo:hover, +#remote-selection:hover { border-color: var(--brand-blue); } + +.password-field { position: relative; } +.password-field input { padding-right: 44px; margin-bottom: 14px; } +.password-field .pass-viewer { + position: absolute; + top: 40%; + right: 14px; + transform: translateY(-50%); + margin: 0; + color: var(--brand-blue); + cursor: pointer; + z-index: 2; +} +.fa-eye, +.fa-eye:hover { color: var(--brand-blue); font-size: 20px; font-weight: bolder; } + +.popup-login-shell .checkbox { margin: 6px 0 14px; } +.popup-login-shell .checkbox label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +} +.popup-login-shell .checkbox input { + width: 16px; + min-height: 16px; + margin: 0; +} + +.popup-login-shell button.login, +.popup-login-shell .login { + width: 100%; + min-height: 52px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + border: 1px solid var(--brand-blue-strong); + background: linear-gradient(180deg, var(--brand-blue) 0%, var(--brand-blue-strong) 100%); + color: #fff; + box-shadow: 0 8px 18px rgba(51, 186, 246, 0.24); + transition: transform 0.12s ease, box-shadow 0.18s ease, background 0.18s ease; + margin: 0 0 15px 0; +} +.popup-login-shell button.login:hover, +.popup-login-shell .login:hover, +.popup-login-shell button.login:focus, +.popup-login-shell .login:focus { + outline: none; + background: linear-gradient(180deg, #4AC4FA 0%, #1FA7E6 100%); + border-color: var(--brand-blue-strong); + box-shadow: 0 10px 22px rgba(51, 186, 246, 0.30); +} +.popup-login-shell button.login:active, +.popup-login-shell .login:active { + transform: translateY(1px); + box-shadow: 0 6px 12px rgba(51, 186, 246, 0.22); +} + +.login-footer-bar { + width: min(100%, 540px); + margin: 0 auto; + padding: 0; + background: #f8fafc; + border: 1px solid #dfe7ef; + border-radius: 14px; + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.08); + overflow: hidden; +} +.login-footer-bar a { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 52px; + color: var(--brand-blue-strong); + text-decoration: none; + font-weight: 600; + background: linear-gradient(180deg, #ffffff 0%, #f5f9fd 100%); +} +.login-footer-bar a:hover { + color: var(--brand-blue-strong); + background: linear-gradient(180deg, #f9fcff 0%, #eef8ff 100%); +} + +.cta a, +.footer-app-opts a { color: inherit; text-decoration: none; } + +/* Main wrapper */ +#wrapper { + margin: 0; + padding: 10px 10px 24px; + min-height: 760px; + width: 780px; +} +.toolbar-row { + display: flex; + gap: 10px; + align-items: stretch; + margin-bottom: 10px; +} +#searchIssue { + background-image: url("../img/searchicon.png"); + background-position: 10px 10px; + background-repeat: no-repeat; + width: auto; + flex: 1 1 auto; + font-size: 13px; + padding: 12px 20px 12px 40px; + border-radius: 10px 0 0 10px; + margin-bottom: 0; + display: inline-block; + background-color: #fff; +} +#limitTo { + width: 88px; + min-width: 88px; + float: none; + font-size: 13px; + padding: 14px 7px 13px 7px; + border-radius: 0 10px 10px 0; + margin-bottom: 0; + display: inline-block; + margin-left: 0; + background: white; + color: var(--brand-muted); +} +#remote-selection { margin: 0 0 20px; } + +.top-actions { padding: 0; margin: 0 0 10px; } +.auto_download_timesheet { font-size: 13px; font-weight: 600; color: #414546; } +.footer-app { font-size: 15px; font-weight: bold; color: var(--brand-blue-strong) !important; } +.footer-btns { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + padding: 12px 8px; + margin: 0 0 10px 0; + background: #fff; + color: var(--brand-blue-strong); + font-size: 15px; + border: 1px solid var(--brand-border-soft); + border-radius: 12px; + text-align: center; +} +.footer-btns > i { padding: 0 4px; } +.footer-btns i, +.footer-btns a { margin-right: 4px; color: var(--brand-blue-strong); } +.action-btn { font-size: 30px; margin: 0; } +.fa-2x { margin: 0 3%; } +.td-btn { color: var(--brand-success); } +.pause-btn { color: var(--brand-warning); } +.bigstopbutton { + position: absolute; + top: 90px; + height: 20px; + border-radius: 2px; + width: 82px; + left: 16px; + background: var(--brand-success); + color: #fff; + font-size: 20px; + text-align: center; +} + +.table-bordered { border: 1px solid #dadde1; } +.table-fixed thead, +.table-fixed thead th { + position: sticky; + position: -webkit-sticky; + top: 0; +} +.table-fixed thead { background-color: #fff; } +.table td, +.table th { padding: 0.28rem; } +.table tbody > tr:hover { background: #F3FBFF; } +.active-row { background: #EAF8FF; } +.table-scroll { + position: relative; + overflow-x: auto; + overflow-y: auto; + max-width: 100%; + background: #fff; + border: 1px solid var(--brand-border-soft); + border-radius: 12px; +} +#table-task-issues { + width: 100%; + table-layout: fixed; + margin-bottom: 0; + background: #fff; +} +#table-task-issues th:first-child, +#table-task-issues td:first-child { + width: 51px; + min-width: 50px; + white-space: nowrap; +} +#table-task-issues thead th { background: #f8fafc; z-index: 2; } +#table-task-issues td, +#table-task-issues th { vertical-align: top; white-space: normal; } +.action-col { width: 42px; } +.priority-col, .priority-cell { width: 55px; text-align: center; } +.stage-col, .stage-cell { width: 100px; text-align: center; } +.item-col { width: 265px; } +.project-col, .project-cell { width: 110px; text-align: center; } +#table-task-issues th:nth-child(5), +#table-task-issues th:nth-child(6), +#table-task-issues td:nth-child(5), +#table-task-issues td:nth-child(6) { width: 55px; text-align: center; } +.issue-desc-cell { width: 290px; min-width: 0; max-width: none; } +.issue-desc-cell .remote-link, +.issue-desc-cell .readmore-inline, +.project-cell .readmore-inline, +.stage-cell .readmore-inline { display: block; } +.issue-desc-cell, .project-cell, .stage-cell { overflow-wrap: anywhere; } +.item-header-title { font-weight: 600; margin-bottom: 6px; text-align: center; } +.checked { color: orange; } +.allIssues { font-weight: normal; display: flex; align-items: center; justify-content: center; gap: 6px; margin: 0; font-size: 12px; line-height: 1.2; } +#showAllIssues { margin: 0; } + +.table-loading-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.78); + backdrop-filter: blur(1px); +} +.table-loader-card { + min-width: 190px; + padding: 16px 18px; + border-radius: 14px; + border: 1px solid #e7e9f4; + background: #ffffff; + box-shadow: 0 14px 30px rgba(17, 24, 39, 0.12); +} +.table-loader-card .loader-text { + margin-top: 10px; + color: #4b5563; + font-size: 13px; + font-weight: 600; +} + +.active-timer-running { + font-size: 13px; + color: var(--brand-blue-strong); + font-weight: bold; +} +.startTimerCount { font-size: 12px; color: var(--brand-danger); } +.morebtn, +.lessbtn { color: orange !important; cursor: pointer; } +.defaultCheckbox { width: 40px !important; display: inline !important; margin-bottom: 0 !important; } +.remote-link { display: block; color: inherit; text-decoration: none; } +.readmore-inline { display: inline; } +.text-muted-soft { opacity: .75; } +.remote-row-actions i, +.remotes-table-info .fa { margin-right: 8px; cursor: pointer; } +.inline-help { font-size: 12px; opacity: .8; margin-top: 8px; } +.small-note, +.remote-info.small-note { font-size: 12px; opacity: .8; } +.timesheet-download-link { display: inline-block; margin-right: 8px; color: inherit; } +.no-remotes-set { margin: 60px 0; font-weight: bold; padding-left: 0; } +.fun-man { font-size: 15px; } +.remote-info-block { + margin: 0 0 10px 0; + background: #fff; + border: 1px solid var(--brand-border-soft); + border-radius: 12px; + font-size: 13px; + padding: 14px 16px; + overflow-wrap: anywhere; +} +.current-source-chip { + display: inline-block; + margin-left: 6px; + padding: 3px 10px; + background: var(--brand-blue-soft); + color: var(--brand-blue-strong); + border: 1px solid rgba(51, 186, 246, 0.15); + border-radius: 999px; + font-size: 11px; +} +.info-footer { margin-top: 14px; padding: 0; } +.pen-title { padding: 50px 0; text-align: center; letter-spacing: 2px; } +.pen-title h1 { margin: 0 0 20px; font-size: 48px; font-weight: 300; } +.pen-title span { font-size: 12px; } +.pen-title span .fa, +.pen-title span a { color: var(--brand-danger); } +.pen-title span a { font-weight: 600; text-decoration: none; } diff --git a/dist/chrome/img/icon-pause-19.png b/dist/chrome/img/icon-pause-19.png new file mode 100644 index 00000000..133e4bef Binary files /dev/null and b/dist/chrome/img/icon-pause-19.png differ diff --git a/dist/chrome/img/icon-pause-38.png b/dist/chrome/img/icon-pause-38.png new file mode 100644 index 00000000..7325cce3 Binary files /dev/null and b/dist/chrome/img/icon-pause-38.png differ diff --git a/dist/chrome/img/icon-pause.png b/dist/chrome/img/icon-pause.png new file mode 100644 index 00000000..7a3763f9 Binary files /dev/null and b/dist/chrome/img/icon-pause.png differ diff --git a/dist/chrome/img/icon_128.png b/dist/chrome/img/icon_128.png new file mode 100644 index 00000000..ab68a44b Binary files /dev/null and b/dist/chrome/img/icon_128.png differ diff --git a/dist/chrome/img/icon_16.png b/dist/chrome/img/icon_16.png new file mode 100644 index 00000000..1c3fbf34 Binary files /dev/null and b/dist/chrome/img/icon_16.png differ diff --git a/dist/chrome/img/icon_19.png b/dist/chrome/img/icon_19.png new file mode 100644 index 00000000..271fb71b Binary files /dev/null and b/dist/chrome/img/icon_19.png differ diff --git a/dist/chrome/img/icon_32.png b/dist/chrome/img/icon_32.png new file mode 100644 index 00000000..0375d5ad Binary files /dev/null and b/dist/chrome/img/icon_32.png differ diff --git a/dist/chrome/img/icon_38.png b/dist/chrome/img/icon_38.png new file mode 100644 index 00000000..753ad447 Binary files /dev/null and b/dist/chrome/img/icon_38.png differ diff --git a/dist/chrome/img/icon_48.png b/dist/chrome/img/icon_48.png new file mode 100644 index 00000000..a1cd678f Binary files /dev/null and b/dist/chrome/img/icon_48.png differ diff --git a/dist/chrome/img/inactive_128.png b/dist/chrome/img/inactive_128.png new file mode 100644 index 00000000..061c5112 Binary files /dev/null and b/dist/chrome/img/inactive_128.png differ diff --git a/dist/chrome/img/inactive_16.png b/dist/chrome/img/inactive_16.png new file mode 100644 index 00000000..a28924f6 Binary files /dev/null and b/dist/chrome/img/inactive_16.png differ diff --git a/dist/chrome/img/inactive_19.png b/dist/chrome/img/inactive_19.png new file mode 100644 index 00000000..52978630 Binary files /dev/null and b/dist/chrome/img/inactive_19.png differ diff --git a/dist/chrome/img/inactive_32.png b/dist/chrome/img/inactive_32.png new file mode 100644 index 00000000..a514c830 Binary files /dev/null and b/dist/chrome/img/inactive_32.png differ diff --git a/dist/chrome/img/inactive_38.png b/dist/chrome/img/inactive_38.png new file mode 100644 index 00000000..9fe98b04 Binary files /dev/null and b/dist/chrome/img/inactive_38.png differ diff --git a/dist/chrome/img/inactive_48.png b/dist/chrome/img/inactive_48.png new file mode 100644 index 00000000..7833c5c1 Binary files /dev/null and b/dist/chrome/img/inactive_48.png differ diff --git a/dist/chrome/img/logo.png b/dist/chrome/img/logo.png new file mode 100644 index 00000000..ac2ed229 Binary files /dev/null and b/dist/chrome/img/logo.png differ diff --git a/src/img/searchicon.png b/dist/chrome/img/searchicon.png similarity index 100% rename from src/img/searchicon.png rename to dist/chrome/img/searchicon.png diff --git a/dist/chrome/js/background.js b/dist/chrome/js/background.js new file mode 100644 index 00000000..83b3a248 --- /dev/null +++ b/dist/chrome/js/background.js @@ -0,0 +1,88 @@ +try { + importScripts('browser-polyfill.js'); +} catch (_err) { + // Firefox background pages may already provide browser without importScripts. +} + +const browserApi = globalThis.browser || globalThis.chrome; + +function iconPath(file) { + return browserApi.runtime?.getURL ? browserApi.runtime.getURL(file) : file; +} + +function getIconPath(timer) { + if (timer === true) { + return { + 16: iconPath('img/icon_16.png'), + 19: iconPath('img/icon_19.png'), + 32: iconPath('img/icon_32.png'), + 38: iconPath('img/icon_38.png'), + 48: iconPath('img/icon_48.png'), + 128: iconPath('img/icon_128.png'), + }; + } + if (timer === 'pause') { + return { + 16: iconPath('img/icon-pause.png'), + 19: iconPath('img/icon-pause-19.png'), + 32: iconPath('img/icon_32.png'), + 38: iconPath('img/icon-pause-38.png'), + 48: iconPath('img/icon_48.png'), + 128: iconPath('img/icon_128.png'), + }; + } + return { + 16: iconPath('img/inactive_16.png'), + 19: iconPath('img/inactive_19.png'), + 32: iconPath('img/inactive_32.png'), + 38: iconPath('img/inactive_38.png'), + 48: iconPath('img/inactive_48.png'), + 128: iconPath('img/inactive_128.png'), + }; +} + +async function setBrowserAction(timer) { + try { + await browserApi.action.setIcon({ path: getIconPath(timer) }); + if (browserApi.action?.setTitle) { + await browserApi.action.setTitle({ + title: timer === true ? 'Therp Timer OWL — timer running' : 'Therp Timer OWL', + }); + } + } catch (err) { + console.warn('Could not update extension icon', err); + } +} + +async function syncFromStorage() { + try { + const res = await browserApi.storage.local.get(['active_timer_id']); + await setBrowserAction(Boolean(res.active_timer_id)); + } catch (err) { + console.warn('Could not sync timer icon from storage', err); + } +} + +browserApi.runtime.onInstalled.addListener(() => { + void syncFromStorage(); +}); + +if (browserApi.runtime.onStartup) { + browserApi.runtime.onStartup.addListener(() => { + void syncFromStorage(); + }); +} + +browserApi.runtime.onMessage.addListener((msg) => { + if (msg && Object.prototype.hasOwnProperty.call(msg, 'TimerActive')) { + void setBrowserAction(msg.TimerActive); + } +}); + +browserApi.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.active_timer_id) { + void setBrowserAction(Boolean(changes.active_timer_id.newValue)); + } +}); + +void syncFromStorage(); diff --git a/src/js/lib/browser-polyfill.js b/dist/chrome/js/browser-polyfill.js similarity index 100% rename from src/js/lib/browser-polyfill.js rename to dist/chrome/js/browser-polyfill.js diff --git a/dist/chrome/js/common.js b/dist/chrome/js/common.js new file mode 100644 index 00000000..5b52c637 --- /dev/null +++ b/dist/chrome/js/common.js @@ -0,0 +1,249 @@ + + +const browserApi = globalThis.browser || globalThis.chrome; + +function getCustomAlert() { + const alertFn = globalThis.alert; + if (alertFn && typeof alertFn.show === 'function') { + return alertFn; + } + return null; +} + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export async function notify(message, options = {}) { + const customAlert = getCustomAlert(); + if (customAlert) { + await customAlert.show(String(message ?? ''), ['OK'], options); + return; + } + globalThis.alert(String(message ?? '')); +} + +export async function confirmDialog(message, options = {}) { + const customAlert = getCustomAlert(); + if (customAlert) { + const result = await customAlert.show(String(message ?? ''), ['Cancel', 'OK'], options); + return result === 'OK'; + } + return globalThis.confirm(String(message ?? '')); +} + +export async function promptDialog(title, defaultValue = '', options = {}) { + const customAlert = getCustomAlert(); + if (!customAlert) { + return globalThis.prompt(String(title ?? ''), String(defaultValue ?? '')); + } + + const inputId = `therp-timer-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const escapedTitle = escapeHtml(title || 'Input'); + const escapedValue = escapeHtml(defaultValue || ''); + const accentColor = options.accentColor || customAlert.accentColor || 'orange'; + const html = ` +
+
${escapedTitle}
+ +
+ `; + + const result = await customAlert.show(html, ['close', 'Save'], { ...options, accentColor }); + if (result !== 'Save') { + return null; + } + + const el = document.getElementById(inputId); + return el ? el.value : String(defaultValue ?? ''); +} + +export const storage = { + + async get(key, fallback = null) { + const obj = await browserApi.storage.local.get(key); + return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback; + }, + async set(key, value) { + await browserApi.storage.local.set({ [key]: value }); + }, + async remove(key) { + await browserApi.storage.local.remove(key); + }, + async clear() { + await browserApi.storage.local.clear(); + }, +}; + +export function validURL(str) { + try { + const url = new URL(str); + return ["http:", "https:"].includes(url.protocol); + } catch { + return false; + } +} + +export async function readRemotes() { + const raw = await storage.get('remote_host_info', []); + if (!Array.isArray(raw)) return []; + return raw.map((item) => { + if (typeof item === 'string') { + try { return JSON.parse(item); } catch { return null; } + } + return item; + }).filter(Boolean); +} + +export async function writeRemotes(remotes) { + const payload = remotes.map((r) => JSON.stringify(r)); + await storage.set('remote_host_info', payload); +} + +export async function sendTimerStateToBackground(state) { + try { + await browserApi.runtime.sendMessage({ TimerActive: state }); + } catch (err) { + console.warn('Could not notify background', err); + } +} + +export async function clearOdooSessionCookies(host) { + if (!host) return; + try { + const cookies = await browserApi.cookies.getAll({ name: 'session_id', url: host }); + for (const cookie of cookies) { + await browserApi.cookies.remove({ + url: host, + name: cookie.name, + storeId: cookie.storeId, + }); + } + } catch (err) { + console.warn('Could not clear cookies for', host, err); + } +} + +export function normalizeHost(host) { + if (!host) return ''; + let out = host.trim(); + if (!/^https?:\/\//i.test(out)) out = 'https://' + out; + return out.replace(/\/$/, ''); +} + +export function toCSV(rows) { + if (!rows || !rows.length) return ''; + const headers = Array.from(new Set(rows.flatMap((r) => Object.keys(r)))); + const esc = (value) => { + const text = value == null ? '' : String(value).replace(/"/g, '""'); + return `"${text}"`; + }; + return [headers.join(','), ...rows.map((row) => headers.map((h) => esc(row[h])).join(','))].join('\n'); +} + +export function downloadTextFile(filename, content, mime = 'text/plain;charset=utf-8;') { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function formatDuration(ms) { + const total = Math.max(0, Math.floor(ms / 1000)); + const h = String(Math.floor(total / 3600)).padStart(2, '0'); + const m = String(Math.floor((total % 3600) / 60)).padStart(2, '0'); + const s = String(total % 60).padStart(2, '0'); + return `${h}:${m}:${s}`; +} + +export function formatHoursMins(decimalHours) { + if (decimalHours == null || Number.isNaN(Number(decimalHours))) return ''; + const value = Number(decimalHours); + const sign = value < 0 ? '-' : ''; + const abs = Math.abs(value); + const hours = Math.floor(abs); + const minutes = Math.round((abs - hours) * 60); + return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +export function priorityStars(priority) { + const n = Number(priority || 0); + return n > 0 ? Array.from({ length: n }, (_, i) => i) : []; +} + +export function matchesIssue(issue, query) { + if (!query) return true; + const q = query.trim().toLowerCase(); + if (!q) return true; + const hay = [ + issue.id, + issue.code, + issue.name, + issue.message_summary, + issue.stage_id?.[1], + issue.project_id?.[1], + issue.user_id?.[1], + issue.priority, + issue.create_date, + ].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(q); +} + +export function extractMessageSummary(summary) { + if (!summary) return ''; + try { + const match = String(summary).match(/(?=You have)(.*?)(?='><|$)/); + return match ? match[0] : String(summary).replace(/<[^>]+>/g, ' '); + } catch { + return String(summary); + } +} + +export class OdooRpc { + constructor(host = '') { this.host = host; } + setHost(host) { this.host = normalizeHost(host); } + async send(path, params = {}) { + if (!this.host) throw new Error('No Odoo host selected'); + const response = await fetch(this.host + path, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params }), + }); + let payload; + try { + payload = await response.json(); + } catch { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + if (!response.ok || payload.error) { + const err = payload.error || {}; + const msg = err.data?.message || err.message || `HTTP ${response.status}`; + const e = new Error(msg); + e.fullTrace = payload.error || payload; + throw e; + } + return payload.result; + } + login(db, login, password) { + return this.send('/web/session/authenticate', { db, login, password }); + } + getSessionInfo() { return this.send('/web/session/get_session_info', {}); } + getServerInfo() { return this.send('/web/webclient/version_info', {}); } + searchRead(model, domain, fields = []) { return this.send('/web/dataset/search_read', { model, domain, fields }); } + fieldsGet(model, attributes = []) { return this.send('/web/dataset/call_kw', { model, method: 'fields_get', args: [], kwargs: attributes.length ? { attributes } : {} }); } + call(model, method, args = [], kwargs = {}) { return this.send('/web/dataset/call_kw', { model, method, args, kwargs }); } + callBtn(model, method, args = [], kwargs = {}) { return this.send('/web/dataset/call_button', { model, method, args, kwargs }); } + async logout() { + try { await this.send('/web/session/destroy', {}); } catch (err) { console.warn('Logout endpoint failed', err); } + } +} diff --git a/dist/chrome/js/components/readmore.js b/dist/chrome/js/components/readmore.js new file mode 100644 index 00000000..8a7e82ea --- /dev/null +++ b/dist/chrome/js/components/readmore.js @@ -0,0 +1,68 @@ + +const { Component, useState } = owl; + +/** + * Create the compiled template used by the shared ReadMore component. + * + * Supports optional links for popup rows and plain text rendering for + * options page tables. + * + * @param {object} app OWL app instance. + * @param {object} bdom OWL block DOM helpers. + * @returns {Function} Compiled template. + */ +export function createReadMoreTemplate(app, bdom) { + const { text, createBlock } = bdom; + + const linkBlock = createBlock( + `` + ); + + const wrapperBlock = createBlock( + `` + ); + + const toggleBlock = createBlock( + `` + ); + + return function template(ctx) { + const displayText = + ctx.state.expanded || !ctx.needsTrim ? (ctx.props.text || '') : ctx.shortText; + + const contentNode = ctx.props.href + ? linkBlock([ctx.props.href, displayText]) + : text(displayText); + + let toggleNode = null; + if (ctx.needsTrim) { + toggleNode = toggleBlock([ + ['prevent', ctx.toggle, ctx], + ctx.state.expanded ? ' ▲' : ' ...', + ]); + } + + return wrapperBlock([], [contentNode, toggleNode]); + }; +} + +export class ReadMore extends Component { + static props = ['text', 'limit?', 'href?', 'title?']; + static template = 'ReadMore'; + + setup() { + this.state = useState({ expanded: false }); + } + + get needsTrim() { + return (this.props.text || '').length > (this.props.limit || 40); + } + + get shortText() { + return (this.props.text || '').slice(0, this.props.limit || 40); + } + + toggle() { + this.state.expanded = !this.state.expanded; + } +} diff --git a/src/js/lib/alert.js b/dist/chrome/js/lib/alert.js similarity index 100% rename from src/js/lib/alert.js rename to dist/chrome/js/lib/alert.js diff --git a/src/js/lib/ripple.js b/dist/chrome/js/lib/ripple.js similarity index 100% rename from src/js/lib/ripple.js rename to dist/chrome/js/lib/ripple.js diff --git a/dist/chrome/js/options-app.js b/dist/chrome/js/options-app.js new file mode 100644 index 00000000..4beb10c6 --- /dev/null +++ b/dist/chrome/js/options-app.js @@ -0,0 +1,308 @@ +import { + readRemotes, + writeRemotes, + validURL, + normalizeHost, + storage, + clearOdooSessionCookies, + notify, + confirmDialog, +} from './common.js'; +import { ReadMore, createReadMoreTemplate } from './components/readmore.js'; + +const { Component, mount, useState, onWillStart } = owl; + +const PAGE_ABOUT = 'about'; +const PAGE_OPTIONS = 'options'; +const DEFAULT_DATA_SOURCE = 'project.issue'; + +const STORAGE_KEYS = { + remoteHostInfo: 'remote_host_info', +}; + +/** + * Create the compiled template used by the options application. + * + * @param {object} app OWL app instance. + * @param {object} bdom OWL block DOM helpers. + * @param {object} helpers OWL template helpers. + * @returns {Function} Compiled template. + */ +function createOptionsAppTemplate(app, bdom, helpers) { + const { createBlock, list } = bdom; + const { prepareList, OwlError, withKey } = helpers; + + const readMoreName = app.createComponent('ReadMore', true, false, false, ['text', 'limit']); + const readMoreHost = app.createComponent('ReadMore', true, false, false, ['text', 'limit']); + const readMoreDatabase = app.createComponent('ReadMore', true, false, false, ['text', 'limit']); + const readMoreSource = app.createComponent('ReadMore', true, false, false, ['text', 'limit']); + 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 Issues and Tasks
  • Start and stop timer for the selected issue/task
  • Create Odoo timesheet lines against the linked analytic account
  • Show assigned issues/tasks or everyone’s items
  • Add, remove, or clear remote hosts
  • Switch between remote sessions
  • Download current month or current issue timesheets as CSV

Add Remote


Controls
` + ); + const errorBlock = createBlock(`
`); + const remotesTableBlock = createBlock( + `
List of Available Remotes
RemoteHostDatabaseSourceState
` + ); + const remoteRowBlock = createBlock( + `` + ); + + return function template(ctx, node, key = '') { + let errorNode = null; + let remoteListNode = null; + + const aboutNavClass = ctx.state.activePage === PAGE_ABOUT ? 'selected' : 'notselected'; + const optionsNavClass = ctx.state.activePage === PAGE_OPTIONS ? 'selected' : 'notselected'; + const showAboutHandler = [() => { ctx.state.activePage = PAGE_ABOUT; }, ctx]; + const showOptionsHandler = [() => { ctx.state.activePage = PAGE_OPTIONS; }, ctx]; + const aboutPageClass = ctx.state.activePage === PAGE_ABOUT ? 'active_page' : 'inactive_page'; + const optionsPageClass = ctx.state.activePage === PAGE_OPTIONS ? 'active_page' : 'inactive_page'; + + const formSubmitHandler = ['prevent', ctx.addRemote, ctx]; + const formState = ctx.state.form; + const hostInputHandler = [(ev) => { formState.remote_host = ev.target.value; }]; + const nameInputHandler = [(ev) => { formState.remote_name = ev.target.value; }]; + const databaseInputHandler = [(ev) => { formState.remote_database = ev.target.value; }]; + const issuesRadioChecked = formState.remote_datasrc === DEFAULT_DATA_SOURCE; + const tasksRadioChecked = formState.remote_datasrc === 'project.task'; + + const issuesRadioHandler = [(ev) => { + if (ev.target.checked) { + formState.remote_datasrc = DEFAULT_DATA_SOURCE; + } + }]; + + const tasksRadioHandler = [(ev) => { + if (ev.target.checked) { + formState.remote_datasrc = 'project.task'; + } + }]; + const addRemoteHandler = [ctx.addRemote, ctx]; + const reloadRemotesHandler = [ctx.loadRemotes, ctx]; + const toggleListHandler = [() => { ctx.state.showList = !ctx.state.showList; }, ctx]; + const removeAllRemotesHandler = [ctx.removeAllRemotes, ctx]; + + if (ctx.state.error) { + errorNode = errorBlock([ctx.state.error]); + } + + if (ctx.state.showList && ctx.state.remotes.length) { + ctx = Object.create(ctx); + const [remoteItems, , remoteCount, remoteChildren] = prepareList(ctx.state.remotes); + const seenRemoteKeys = new Set(); + + for (let i = 0; i < remoteCount; i++) { + ctx.remote = remoteItems[i]; + const remoteKey = ctx.remote.url + ctx.remote.database; + + if (seenRemoteKeys.has(String(remoteKey))) { + throw new OwlError(`Got duplicate key in t-foreach: ${remoteKey}`); + } + seenRemoteKeys.add(String(remoteKey)); + + const nameNode = readMoreName({ text: ctx.remote.name, limit: 18 }, key + `__1__${remoteKey}`, node, this, null); + const hostNode = readMoreHost({ text: ctx.remote.url, limit: 25 }, key + `__2__${remoteKey}`, node, this, null); + const databaseNode = readMoreDatabase({ text: ctx.remote.database, limit: 18 }, key + `__3__${remoteKey}`, node, this, null); + 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 deleteHandler = [() => ctx.removeRemote(remoteItem), ctx]; + + remoteChildren[i] = withKey( + remoteRowBlock([deleteHandler], [nameNode, hostNode, databaseNode, sourceNode, stateNode]), + remoteKey + ); + } + + ctx = ctx.__proto__; + remoteListNode = remotesTableBlock([], [list(remoteChildren)]); + } + + return rootBlock( + [ + aboutNavClass, + showAboutHandler, + optionsNavClass, + showOptionsHandler, + aboutPageClass, + optionsPageClass, + formSubmitHandler, + formState.remote_host, + hostInputHandler, + formState.remote_name, + nameInputHandler, + formState.remote_database, + databaseInputHandler, + issuesRadioChecked, + issuesRadioHandler, + tasksRadioChecked, + tasksRadioHandler, + addRemoteHandler, + reloadRemotesHandler, + toggleListHandler, + removeAllRemotesHandler, + ], + [errorNode, remoteListNode] + ); + }; +} + +/** + * Main options application component. + */ +class OptionsApp extends Component { + static components = { ReadMore }; + static template = 'OptionsApp'; + + setup() { + this.state = useState({ + activePage: PAGE_OPTIONS, + remotes: [], + showList: true, + error: '', + form: { + remote_host: '', + remote_name: '', + remote_database: '', + remote_datasrc: DEFAULT_DATA_SOURCE, + }, + }); + + onWillStart(async () => { + await this.loadRemotes(); + }); + } + + /** + * Reload remote configurations from storage. + * + * @returns {Promise} + */ + async loadRemotes() { + this.state.remotes = await readRemotes(); + } + + /** + * Reset the add-remote form back to defaults. + */ + resetRemoteForm() { + this.state.form.remote_host = ''; + this.state.form.remote_name = ''; + this.state.form.remote_database = ''; + this.state.form.remote_datasrc = DEFAULT_DATA_SOURCE; + } + + /** + * Validate current form fields and return normalized values. + * + * @returns {{host: string, name: string, database: string, datasrc: string}|null} + */ + getValidatedRemoteForm() { + const host = normalizeHost(this.state.form.remote_host || ''); + const name = (this.state.form.remote_name || '').trim(); + const database = (this.state.form.remote_database || '').trim(); + const datasrc = this.state.form.remote_datasrc || DEFAULT_DATA_SOURCE; + + if (!host || !name || !database) { + this.state.error = 'Fields cannot be empty'; + return null; + } + + if (!validURL(host)) { + this.state.error = 'Invalid URL syntax'; + return null; + } + + return { host, name, database, datasrc }; + } + + /** + * Add a new remote configuration. + * + * @returns {Promise} + */ + async addRemote() { + this.state.error = ''; + + const validated = this.getValidatedRemoteForm(); + if (!validated) { + return; + } + + 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`; + return; + } + + remotes.push({ + url: host, + name, + database, + datasrc, + state: 'Inactive', + }); + + await writeRemotes(remotes); + await this.loadRemotes(); + this.resetRemoteForm(); + await notify(`Host [${host}] added successfully.`); + } + + /** + * Remove a single remote configuration. + * + * @param {object} remote Remote row to remove. + * @returns {Promise} + */ + async removeRemote(remote) { + const confirmed = await confirmDialog(`Are you sure you want to remove remote [${remote.url}]?`); + if (!confirmed) { + return; + } + + await clearOdooSessionCookies(remote.url); + const remotes = (await readRemotes()).filter( + (currentRemote) => !(currentRemote.url === remote.url && currentRemote.database === remote.database) + ); + + await writeRemotes(remotes); + await storage.remove(remote.database); + await this.loadRemotes(); + await notify(`[${remote.url}] removed successfully!`); + } + + /** + * Remove every saved remote configuration. + * + * @returns {Promise} + */ + async removeAllRemotes() { + const confirmed = await confirmDialog('Are you sure you want to remove all remotes?'); + if (!confirmed) { + return; + } + + const remotes = await readRemotes(); + for (const remote of remotes) { + await clearOdooSessionCookies(remote.url); + await storage.remove(remote.database); + } + + await writeRemotes([]); + await storage.remove(STORAGE_KEYS.remoteHostInfo); + await this.loadRemotes(); + await notify('Host list removed successfully!'); + } +} + +const templates = { + ReadMore: createReadMoreTemplate, + OptionsApp: createOptionsAppTemplate, +}; + +mount(OptionsApp, document.getElementById('app'), { dev: true, templates }); \ No newline at end of file diff --git a/dist/chrome/js/owl.iife.js b/dist/chrome/js/owl.iife.js new file mode 100644 index 00000000..c26b6259 --- /dev/null +++ b/dist/chrome/js/owl.iife.js @@ -0,0 +1,6340 @@ +(function (exports) { + 'use strict'; + + function filterOutModifiersFromData(dataList) { + dataList = dataList.slice(); + const modifiers = []; + let elm; + while ((elm = dataList[0]) && typeof elm === "string") { + modifiers.push(dataList.shift()); + } + return { modifiers, data: dataList }; + } + const config = { + // whether or not blockdom should normalize DOM whenever a block is created. + // Normalizing dom mean removing empty text nodes (or containing only spaces) + shouldNormalizeDom: true, + // this is the main event handler. Every event handler registered with blockdom + // will go through this function, giving it the data registered in the block + // and the event + mainEventHandler: (data, ev, currentTarget) => { + if (typeof data === "function") { + data(ev); + } + else if (Array.isArray(data)) { + data = filterOutModifiersFromData(data).data; + data[0](data[1], ev); + } + return false; + }, + }; + + // ----------------------------------------------------------------------------- + // Toggler node + // ----------------------------------------------------------------------------- + class VToggler { + constructor(key, child) { + this.key = key; + this.child = child; + } + mount(parent, afterNode) { + this.parentEl = parent; + this.child.mount(parent, afterNode); + } + moveBeforeDOMNode(node, parent) { + this.child.moveBeforeDOMNode(node, parent); + } + moveBeforeVNode(other, afterNode) { + this.moveBeforeDOMNode((other && other.firstNode()) || afterNode); + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + let child1 = this.child; + let child2 = other.child; + if (this.key === other.key) { + child1.patch(child2, withBeforeRemove); + } + else { + child2.mount(this.parentEl, child1.firstNode()); + if (withBeforeRemove) { + child1.beforeRemove(); + } + child1.remove(); + this.child = child2; + this.key = other.key; + } + } + beforeRemove() { + this.child.beforeRemove(); + } + remove() { + this.child.remove(); + } + firstNode() { + return this.child.firstNode(); + } + toString() { + return this.child.toString(); + } + } + function toggler(key, child) { + return new VToggler(key, child); + } + + // Custom error class that wraps error that happen in the owl lifecycle + class OwlError extends Error { + } + + const { setAttribute: elemSetAttribute, removeAttribute } = Element.prototype; + const tokenList = DOMTokenList.prototype; + const tokenListAdd = tokenList.add; + const tokenListRemove = tokenList.remove; + const isArray = Array.isArray; + const { split, trim } = String.prototype; + const wordRegexp = /\s+/; + /** + * We regroup here all code related to updating attributes in a very loose sense: + * attributes, properties and classs are all managed by the functions in this + * file. + */ + function setAttribute(key, value) { + switch (value) { + case false: + case undefined: + removeAttribute.call(this, key); + break; + case true: + elemSetAttribute.call(this, key, ""); + break; + default: + elemSetAttribute.call(this, key, value); + } + } + function createAttrUpdater(attr) { + return function (value) { + setAttribute.call(this, attr, value); + }; + } + function attrsSetter(attrs) { + if (isArray(attrs)) { + if (attrs[0] === "class") { + setClass.call(this, attrs[1]); + } + else { + setAttribute.call(this, attrs[0], attrs[1]); + } + } + else { + for (let k in attrs) { + if (k === "class") { + setClass.call(this, attrs[k]); + } + else { + setAttribute.call(this, k, attrs[k]); + } + } + } + } + function attrsUpdater(attrs, oldAttrs) { + if (isArray(attrs)) { + const name = attrs[0]; + const val = attrs[1]; + if (name === oldAttrs[0]) { + if (val === oldAttrs[1]) { + return; + } + if (name === "class") { + updateClass.call(this, val, oldAttrs[1]); + } + else { + setAttribute.call(this, name, val); + } + } + else { + removeAttribute.call(this, oldAttrs[0]); + setAttribute.call(this, name, val); + } + } + else { + for (let k in oldAttrs) { + if (!(k in attrs)) { + if (k === "class") { + updateClass.call(this, "", oldAttrs[k]); + } + else { + removeAttribute.call(this, k); + } + } + } + for (let k in attrs) { + const val = attrs[k]; + if (val !== oldAttrs[k]) { + if (k === "class") { + updateClass.call(this, val, oldAttrs[k]); + } + else { + setAttribute.call(this, k, val); + } + } + } + } + } + function toClassObj(expr) { + const result = {}; + switch (typeof expr) { + case "string": + // we transform here a list of classes into an object: + // 'hey you' becomes {hey: true, you: true} + const str = trim.call(expr); + if (!str) { + return {}; + } + let words = split.call(str, wordRegexp); + for (let i = 0, l = words.length; i < l; i++) { + result[words[i]] = true; + } + return result; + case "object": + // this is already an object but we may need to split keys: + // {'a': true, 'b c': true} should become {a: true, b: true, c: true} + for (let key in expr) { + const value = expr[key]; + if (value) { + key = trim.call(key); + if (!key) { + continue; + } + const words = split.call(key, wordRegexp); + for (let word of words) { + result[word] = value; + } + } + } + return result; + case "undefined": + return {}; + case "number": + return { [expr]: true }; + default: + return { [expr]: true }; + } + } + function setClass(val) { + val = val === "" ? {} : toClassObj(val); + // add classes + const cl = this.classList; + for (let c in val) { + tokenListAdd.call(cl, c); + } + } + function updateClass(val, oldVal) { + oldVal = oldVal === "" ? {} : toClassObj(oldVal); + val = val === "" ? {} : toClassObj(val); + const cl = this.classList; + // remove classes + for (let c in oldVal) { + if (!(c in val)) { + tokenListRemove.call(cl, c); + } + } + // add classes + for (let c in val) { + if (!(c in oldVal)) { + tokenListAdd.call(cl, c); + } + } + } + + /** + * Creates a batched version of a callback so that all calls to it in the same + * microtick will only call the original callback once. + * + * @param callback the callback to batch + * @returns a batched version of the original callback + */ + function batched(callback) { + let scheduled = false; + return async (...args) => { + if (!scheduled) { + scheduled = true; + await Promise.resolve(); + scheduled = false; + callback(...args); + } + }; + } + /** + * Determine whether the given element is contained in its ownerDocument: + * either directly or with a shadow root in between. + */ + function inOwnerDocument(el) { + if (!el) { + return false; + } + if (el.ownerDocument.contains(el)) { + return true; + } + const rootNode = el.getRootNode(); + return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); + } + /** + * Determine whether the given element is contained in a specific root documnet: + * either directly or with a shadow root in between or in an iframe. + */ + function isAttachedToDocument(element, documentElement) { + let current = element; + const shadowRoot = documentElement.defaultView.ShadowRoot; + while (current) { + if (current === documentElement) { + return true; + } + if (current.parentNode) { + current = current.parentNode; + } + else if (current instanceof shadowRoot && current.host) { + current = current.host; + } + else { + return false; + } + } + return false; + } + function validateTarget(target) { + // Get the document and HTMLElement corresponding to the target to allow mounting in iframes + const document = target && target.ownerDocument; + if (document) { + if (!document.defaultView) { + throw new OwlError("Cannot mount a component: the target document is not attached to a window (defaultView is missing)"); + } + const HTMLElement = document.defaultView.HTMLElement; + if (target instanceof HTMLElement || target instanceof ShadowRoot) { + if (!isAttachedToDocument(target, document)) { + throw new OwlError("Cannot mount a component on a detached dom node"); + } + return; + } + } + throw new OwlError("Cannot mount component: the target is not a valid DOM element"); + } + class EventBus extends EventTarget { + trigger(name, payload) { + this.dispatchEvent(new CustomEvent(name, { detail: payload })); + } + } + function whenReady(fn) { + return new Promise(function (resolve) { + if (document.readyState !== "loading") { + resolve(true); + } + else { + document.addEventListener("DOMContentLoaded", resolve, false); + } + }).then(fn || function () { }); + } + async function loadFile(url) { + const result = await fetch(url); + if (!result.ok) { + throw new OwlError("Error while fetching xml templates"); + } + return await result.text(); + } + /* + * This class just transports the fact that a string is safe + * to be injected as HTML. Overriding a JS primitive is quite painful though + * so we need to redfine toString and valueOf. + */ + class Markup extends String { + } + function htmlEscape(str) { + if (str instanceof Markup) { + return str; + } + if (str === undefined) { + return markup(""); + } + if (typeof str === "number") { + return markup(String(str)); + } + [ + ["&", "&"], + ["<", "<"], + [">", ">"], + ["'", "'"], + ['"', """], + ["`", "`"], + ].forEach((pairs) => { + str = String(str).replace(new RegExp(pairs[0], "g"), pairs[1]); + }); + return markup(str); + } + function markup(valueOrStrings, ...placeholders) { + if (!Array.isArray(valueOrStrings)) { + return new Markup(valueOrStrings); + } + const strings = valueOrStrings; + let acc = ""; + let i = 0; + for (; i < placeholders.length; ++i) { + acc += strings[i] + htmlEscape(placeholders[i]); + } + acc += strings[i]; + return new Markup(acc); + } + + function createEventHandler(rawEvent) { + const eventName = rawEvent.split(".")[0]; + const capture = rawEvent.includes(".capture"); + if (rawEvent.includes(".synthetic")) { + return createSyntheticHandler(eventName, capture); + } + else { + return createElementHandler(eventName, capture); + } + } + // Native listener + let nextNativeEventId = 1; + function createElementHandler(evName, capture = false) { + let eventKey = `__event__${evName}_${nextNativeEventId++}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + function listener(ev) { + const currentTarget = ev.currentTarget; + if (!currentTarget || !inOwnerDocument(currentTarget)) + return; + const data = currentTarget[eventKey]; + if (!data) + return; + config.mainEventHandler(data, ev, currentTarget); + } + function setup(data) { + this[eventKey] = data; + this.addEventListener(evName, listener, { capture }); + } + function remove() { + delete this[eventKey]; + this.removeEventListener(evName, listener, { capture }); + } + function update(data) { + this[eventKey] = data; + } + return { setup, update, remove }; + } + // Synthetic handler: a form of event delegation that allows placing only one + // listener per event type. + let nextSyntheticEventId = 1; + function createSyntheticHandler(evName, capture = false) { + let eventKey = `__event__synthetic_${evName}`; + if (capture) { + eventKey = `${eventKey}_capture`; + } + setupSyntheticEvent(evName, eventKey, capture); + const currentId = nextSyntheticEventId++; + function setup(data) { + const _data = this[eventKey] || {}; + _data[currentId] = data; + this[eventKey] = _data; + } + function remove() { + delete this[eventKey]; + } + return { setup, update: setup, remove }; + } + function nativeToSyntheticEvent(eventKey, event) { + let dom = event.target; + while (dom !== null) { + const _data = dom[eventKey]; + if (_data) { + for (const data of Object.values(_data)) { + const stopped = config.mainEventHandler(data, event, dom); + if (stopped) + return; + } + } + dom = dom.parentNode; + } + } + const CONFIGURED_SYNTHETIC_EVENTS = {}; + function setupSyntheticEvent(evName, eventKey, capture = false) { + if (CONFIGURED_SYNTHETIC_EVENTS[eventKey]) { + return; + } + document.addEventListener(evName, (event) => nativeToSyntheticEvent(eventKey, event), { + capture, + }); + CONFIGURED_SYNTHETIC_EVENTS[eventKey] = true; + } + + const getDescriptor$3 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$4 = Node.prototype; + const nodeInsertBefore$3 = nodeProto$4.insertBefore; + const nodeSetTextContent$1 = getDescriptor$3(nodeProto$4, "textContent").set; + const nodeRemoveChild$3 = nodeProto$4.removeChild; + // ----------------------------------------------------------------------------- + // Multi NODE + // ----------------------------------------------------------------------------- + class VMulti { + constructor(children) { + this.children = children; + } + mount(parent, afterNode) { + const children = this.children; + const l = children.length; + const anchors = new Array(l); + for (let i = 0; i < l; i++) { + let child = children[i]; + if (child) { + child.mount(parent, afterNode); + } + else { + const childAnchor = document.createTextNode(""); + anchors[i] = childAnchor; + nodeInsertBefore$3.call(parent, childAnchor, afterNode); + } + } + this.anchors = anchors; + this.parentEl = parent; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeDOMNode(node, parent); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, node); + } + } + } + moveBeforeVNode(other, afterNode) { + if (other) { + const next = other.children[0]; + afterNode = (next ? next.firstNode() : other.anchors[0]) || null; + } + const children = this.children; + const parent = this.parentEl; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + let child = children[i]; + if (child) { + child.moveBeforeVNode(null, afterNode); + } + else { + const anchor = anchors[i]; + nodeInsertBefore$3.call(parent, anchor, afterNode); + } + } + } + patch(other, withBeforeRemove) { + if (this === other) { + return; + } + const children1 = this.children; + const children2 = other.children; + const anchors = this.anchors; + const parentEl = this.parentEl; + for (let i = 0, l = children1.length; i < l; i++) { + const vn1 = children1[i]; + const vn2 = children2[i]; + if (vn1) { + if (vn2) { + vn1.patch(vn2, withBeforeRemove); + } + else { + const afterNode = vn1.firstNode(); + const anchor = document.createTextNode(""); + anchors[i] = anchor; + nodeInsertBefore$3.call(parentEl, anchor, afterNode); + if (withBeforeRemove) { + vn1.beforeRemove(); + } + vn1.remove(); + children1[i] = undefined; + } + } + else if (vn2) { + children1[i] = vn2; + const anchor = anchors[i]; + vn2.mount(parentEl, anchor); + nodeRemoveChild$3.call(parentEl, anchor); + } + } + } + beforeRemove() { + const children = this.children; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.beforeRemove(); + } + } + } + remove() { + const parentEl = this.parentEl; + if (this.isOnlyChild) { + nodeSetTextContent$1.call(parentEl, ""); + } + else { + const children = this.children; + const anchors = this.anchors; + for (let i = 0, l = children.length; i < l; i++) { + const child = children[i]; + if (child) { + child.remove(); + } + else { + nodeRemoveChild$3.call(parentEl, anchors[i]); + } + } + } + } + firstNode() { + const child = this.children[0]; + return child ? child.firstNode() : this.anchors[0]; + } + toString() { + return this.children.map((c) => (c ? c.toString() : "")).join(""); + } + } + function multi(children) { + return new VMulti(children); + } + + const getDescriptor$2 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$3 = Node.prototype; + const characterDataProto$1 = CharacterData.prototype; + const nodeInsertBefore$2 = nodeProto$3.insertBefore; + const characterDataSetData$1 = getDescriptor$2(characterDataProto$1, "data").set; + const nodeRemoveChild$2 = nodeProto$3.removeChild; + class VSimpleNode { + constructor(text) { + this.text = text; + } + mountNode(node, parent, afterNode) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, node, afterNode); + this.el = node; + } + moveBeforeDOMNode(node, parent = this.parentEl) { + this.parentEl = parent; + nodeInsertBefore$2.call(parent, this.el, node); + } + moveBeforeVNode(other, afterNode) { + nodeInsertBefore$2.call(this.parentEl, this.el, other ? other.el : afterNode); + } + beforeRemove() { } + remove() { + nodeRemoveChild$2.call(this.parentEl, this.el); + } + firstNode() { + return this.el; + } + toString() { + return this.text; + } + } + class VText$1 extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createTextNode(toText(this.text)), parent, afterNode); + } + patch(other) { + const text2 = other.text; + if (this.text !== text2) { + characterDataSetData$1.call(this.el, toText(text2)); + this.text = text2; + } + } + } + class VComment extends VSimpleNode { + mount(parent, afterNode) { + this.mountNode(document.createComment(toText(this.text)), parent, afterNode); + } + patch() { } + } + function text(str) { + return new VText$1(str); + } + function comment(str) { + return new VComment(str); + } + function toText(value) { + switch (typeof value) { + case "string": + return value; + case "number": + return String(value); + case "boolean": + return value ? "true" : "false"; + default: + return value || ""; + } + } + + const getDescriptor$1 = (o, p) => Object.getOwnPropertyDescriptor(o, p); + const nodeProto$2 = Node.prototype; + const elementProto = Element.prototype; + const characterDataProto = CharacterData.prototype; + const characterDataSetData = getDescriptor$1(characterDataProto, "data").set; + const nodeGetFirstChild = getDescriptor$1(nodeProto$2, "firstChild").get; + const nodeGetNextSibling = getDescriptor$1(nodeProto$2, "nextSibling").get; + const NO_OP$1 = () => { }; + function makePropSetter(name) { + return function setProp(value) { + // support 0, fallback to empty string for other falsy values + this[name] = value === 0 ? 0 : value ? value.valueOf() : ""; + }; + } + const cache$1 = {}; + /** + * Compiling blocks is a multi-step process: + * + * 1. build an IntermediateTree from the HTML element. This intermediate tree + * is a binary tree structure that encode dynamic info sub nodes, and the + * path required to reach them + * 2. process the tree to build a block context, which is an object that aggregate + * all dynamic info in a list, and also, all ref indexes. + * 3. process the context to build appropriate builder/setter functions + * 4. make a dynamic block class, which will efficiently collect references and + * create/update dynamic locations/children + * + * @param str + * @returns a new block type, that can build concrete blocks + */ + function createBlock(str) { + if (str in cache$1) { + return cache$1[str]; + } + // step 0: prepare html base element + const doc = new DOMParser().parseFromString(`${str}`, "text/xml"); + const node = doc.firstChild.firstChild; + if (config.shouldNormalizeDom) { + normalizeNode(node); + } + // step 1: prepare intermediate tree + const tree = buildTree(node); + // step 2: prepare block context + const context = buildContext(tree); + // step 3: build the final block class + const template = tree.el; + const Block = buildBlock(template, context); + cache$1[str] = Block; + return Block; + } + // ----------------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------------- + function normalizeNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + if (!/\S/.test(node.textContent)) { + node.remove(); + return; + } + } + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.tagName === "pre") { + return; + } + } + for (let i = node.childNodes.length - 1; i >= 0; --i) { + normalizeNode(node.childNodes.item(i)); + } + } + function buildTree(node, parent = null, domParentTree = null) { + switch (node.nodeType) { + case Node.ELEMENT_NODE: { + // HTMLElement + let currentNS = domParentTree && domParentTree.currentNS; + const tagName = node.tagName; + let el = undefined; + const info = []; + if (tagName.startsWith("block-text-")) { + const index = parseInt(tagName.slice(11), 10); + info.push({ type: "text", idx: index }); + el = document.createTextNode(""); + } + if (tagName.startsWith("block-child-")) { + if (!domParentTree.isRef) { + addRef(domParentTree); + } + const index = parseInt(tagName.slice(12), 10); + info.push({ type: "child", idx: index }); + el = document.createTextNode(""); + } + currentNS || (currentNS = node.namespaceURI); + if (!el) { + el = currentNS + ? document.createElementNS(currentNS, tagName) + : document.createElement(tagName); + } + if (el instanceof Element) { + if (!domParentTree) { + // some html elements may have side effects when setting their attributes. + // For example, setting the src attribute of an will trigger a + // request to get the corresponding image. This is something that we + // don't want at compile time. We avoid that by putting the content of + // the block in a