From eb1924023ed77066d5fa76efe43fb95d0aeb591d Mon Sep 17 00:00:00 2001 From: KKamaa Date: Wed, 18 Mar 2026 06:06:13 +0300 Subject: [PATCH 01/13] Draft: [IMP] therp owl timer --- .gitignore | 5 + README.md | 441 ++- src/capture_popup.html | 24 + src/css/options_main_page.css | 27 +- src/css/popup.css | 700 ++-- src/img/icon-pause-19.png | Bin 0 -> 270 bytes src/img/icon-pause-38.png | Bin 0 -> 691 bytes src/img/icon-pause.png | Bin 1470 -> 691 bytes src/img/icon_128.png | Bin 7385 -> 2139 bytes src/img/icon_16.png | Bin 1189 -> 248 bytes src/img/icon_19.png | Bin 1437 -> 336 bytes src/img/icon_256.png | Bin 0 -> 3307 bytes src/img/icon_32.png | Bin 0 -> 717 bytes src/img/icon_38.png | Bin 2454 -> 865 bytes src/img/icon_48.png | Bin 3109 -> 1197 bytes src/img/icon_64.png | Bin 0 -> 1492 bytes src/img/icon_96.png | Bin 0 -> 1724 bytes src/img/inactive_128.png | Bin 0 -> 2161 bytes src/img/inactive_16.png | Bin 0 -> 256 bytes src/img/inactive_19.png | Bin 601 -> 364 bytes src/img/inactive_32.png | Bin 0 -> 790 bytes src/img/inactive_38.png | Bin 1173 -> 938 bytes src/img/inactive_48.png | Bin 0 -> 1216 bytes src/img/logo.png | Bin 6351 -> 7921 bytes src/js/background.js | 68 +- src/js/browser-polyfill.js | 1277 +++++++ src/js/common.js | 186 + src/js/options-app.js | 152 + src/js/owl.iife.js | 6340 +++++++++++++++++++++++++++++++++ src/js/popup-app.js | 795 +++++ src/manifest-firefox.json | 36 + src/manifest.json | 45 +- src/options_main_page.html | 256 +- src/options_test.html | 15 + src/popup.html | 254 +- src/popup_test.html | 15 + 36 files changed, 9683 insertions(+), 953 deletions(-) create mode 100644 src/capture_popup.html create mode 100644 src/img/icon-pause-19.png create mode 100644 src/img/icon-pause-38.png create mode 100644 src/img/icon_256.png create mode 100644 src/img/icon_32.png create mode 100644 src/img/icon_64.png create mode 100644 src/img/icon_96.png create mode 100644 src/img/inactive_128.png create mode 100644 src/img/inactive_16.png create mode 100644 src/img/inactive_32.png create mode 100644 src/img/inactive_48.png create mode 100644 src/js/browser-polyfill.js create mode 100644 src/js/common.js create mode 100644 src/js/options-app.js create mode 100644 src/js/owl.iife.js create mode 100644 src/js/popup-app.js create mode 100644 src/manifest-firefox.json create mode 100644 src/options_test.html create mode 100644 src/popup_test.html 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..bf85a84b 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/capture_popup.html b/src/capture_popup.html new file mode 100644 index 00000000..cdc086ca --- /dev/null +++ b/src/capture_popup.html @@ -0,0 +1,24 @@ +
+ + + + diff --git a/src/css/options_main_page.css b/src/css/options_main_page.css index a3da9935..c29fa979 100644 --- a/src/css/options_main_page.css +++ b/src/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; @@ -354,4 +352,27 @@ hr { 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/src/css/popup.css b/src/css/popup.css index ae34a6fe..dd38a783 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -1,329 +1,316 @@ +: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: 650px; - overflow-x: hidden !important; - overflow-y: scroll !important; + min-width: 620px; + min-height: 760px; + max-height: 820px; + margin: 0; + padding: 0; + overflow: hidden; font-family: "Open Sans", sans-serif; - font-size: 0.7em; - background: white; - max-height: 700px !important; -} - -#wrapper { - margin: 20px; - padding: 20px; -} - -.active-timer-running { - font-size: 13px; - color: #6363a1; - font-weight: bold; -} - -/* LOGIN */ -#login { - color: #666666; - font-family: "RobotoDraft", "Roboto", sans-serif; - font-size: 14px; - margin: 30px; - padding: 30px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* Pen Title */ -.pen-title { - padding: 50px 0; - text-align: center; - letter-spacing: 2px; -} - -.pen-title h1 { - margin: 0 0 20px; - font-size: 48px; - font-weight: 300; + font-size: 0.93em; + background: var(--brand-bg); } -/*This will work on every browser but Chrome Browser*/ -.table-fixed thead { - position: sticky; - position: -webkit-sticky; - top: 0; - background-color: #fff; +body.unselectable { + min-height: 760px; } -/*This will work on every browser*/ -.table-fixed thead th { - position: sticky; - position: -webkit-sticky; - top: 0; +#app { + min-height: 760px; } -.pen-title span { - font-size: 12px; +.app-root { + min-height: 760px; + max-height: 820px; + overflow-y: auto; + overflow-x: hidden; + background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%); } -.pen-title span .fa { - color: #f30c16; -} +.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; } -.pen-title span a { - color: #f30c16; +/* 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; - text-decoration: none; + 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; } -/* Form Module */ -.form-module { +.popup-login-shell { position: relative; - background: #ffffff; - width: 100%; - border-top: 5px solid #f30c16; - border-left: 1px solid #ddd; - border-right: 1px solid #ddd; - border-bottom: 1px solid #ddd; - -webkit-box-shadow: 0 0 3px rgba(0, 0, 0, 0.1); - box-shadow: 0 0 3px rgba(0, 0, 0, 0.1); + 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; } -.form-module .toggle { - cursor: pointer; - position: absolute; - top: -0; - right: -0; - background: #f30c16; - width: 30px; - height: 30px; - margin: -5px 0 0; - color: #ffffff; - font-size: 12px; - line-height: 30px; - text-align: center; -} - -.form-module .toggle .tooltip { - position: absolute; - top: 5px; - right: -65px; +.popup-login-shell .form { display: block; - background: rgba(0, 0, 0, 0.6); - width: auto; - padding: 5px; - font-size: 10px; - line-height: 1; - text-transform: uppercase; + padding: 34px 32px 28px; + position: relative; } +.popup-login-shell .logo { margin-bottom: 18px; } +.popup-login-shell .logo img { max-width: 200px; height: auto; } -.form-module .toggle .tooltip:before { - content: ""; - position: absolute; - top: 5px; - left: -5px; - display: block; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-right: 5px solid rgba(0, 0, 0, 0.6); +.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; } - -.form-module .form { - display: none; - padding: 40px; +.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; } -.form-module .form:nth-child(2) { - display: block; +.popup-login-shell .checkbox { margin: 6px 0 14px; } +.popup-login-shell .checkbox label { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; } - -.form-module h2 { - margin: 0 0 20px; - color: #f30c16; - font-size: 18px; - font-weight: 400; - line-height: 1; +.popup-login-shell .checkbox input { + width: 16px; + min-height: 16px; + margin: 0; } -.form-module input { - outline: none; - display: block; +.popup-login-shell button.login, +.popup-login-shell .login { width: 100%; - border: 1px solid #d9d9d9; - margin: 0 0 20px; - padding: 10px 15px; - -webkit-box-sizing: border-box; - box-sizing: border-box; - font-wieght: 400; - -webkit-transition: 0.3s ease; - transition: 0.3s ease; + 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; } - -.table-bordered { - border: 1px solid #dadde1; +.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); } - -.form-module input:focus { - border: 1px solid #f30c16; - color: #333333; +.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); } -.form-module button { - cursor: pointer; - background: #f30c16; +.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%; - border: 0; - padding: 10px 15px; - color: #ffffff; - -webkit-transition: 0.3s ease; - transition: 0.3s ease; + min-height: 52px; + color: var(--brand-blue-strong); + text-decoration: none; + font-weight: 600; + background: linear-gradient(180deg, #ffffff 0%, #f5f9fd 100%); } - -.form-module button:hover { - background: #d01c24; +.login-footer-bar a:hover { + color: var(--brand-blue-strong); + background: linear-gradient(180deg, #f9fcff 0%, #eef8ff 100%); } -.cta { - background: #f2f2f2; - width: 100%; - padding: 15px 40px; - -webkit-box-sizing: border-box; - box-sizing: border-box; - color: #666666; - font-size: 12px; - text-align: center; - position: fixed; - bottom: 0; - left: 0; -} +.cta a, +.footer-app-opts a { color: inherit; text-decoration: none; } -.cta a { - color: #333333; - text-decoration: none; +/* Main wrapper */ +#wrapper { + margin: 0; + padding: 18px 18px 24px; + min-height: 760px; } - -/* END LOGIN */ -.action-btn { - font-size: 4vw; - margin: 0 2%; +.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: 88%; + width: auto; + flex: 1 1 auto; font-size: 13px; padding: 12px 20px 12px 40px; - border: 1px solid #ddd; - margin-bottom: 5px; - display: inline; + border-radius: 10px 0 0 10px; + margin-bottom: 0; + display: inline-block; } - #limitTo { - width: 12%; - float: right; + width: 88px; + min-width: 88px; + float: none; font-size: 13px; padding: 14px 7px 13px 7px; - border: 1px solid #ddd; - margin-bottom: 12px; + border-radius: 0 10px 10px 0; + margin-bottom: 0; + display: inline-block; + margin-left: 0; background: white; - color: #666666; -} - -.hide { - display: none !important; -} - -#loader-container { - height: 400px; - text-align: center; - vertical-align: middle; -} - -#loader-container .fa-cog { - margin-top: 30%; - color: #f30c16; -} - -.odooError { - color: red; - font-weight: bold; + color: var(--brand-muted); } +#remote-selection { margin: 0 0 20px; } -.logo { - text-align: center; -} - -.footer { - width: 100%; -} - -.nomargin { - margin: 0; -} - -.odooError { - color: #f30c16; - font-weight: bold; -} - -.login { - font-size: 3vw; - margin: 0 0 15px 0; -} - -.defaultCheckbox { - width: 40px !important; - display: inline !important; - margin-bottom: 0 !important; -} - -.allIssues { - font-weight: normal; - display: table-cell; - vertical-align: bottom; - /*float: right;*/ -} - -/* auto download timesheet input */ - -.auto_download_timesheet { - font-size: 12px; - font-weight: bold; - color: #414546; -} - -/* End stylying for auto timesheet*/ - -.startTimerCount { - font-size: 12px; - color: red; -} - -.morebtn, -.lessbtn { - color: orange !important; - cursor: pointer; -} - -.td-btn { - color: #28a745; -} - -.text-center { +.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: 22px; + padding: 14px 10px; + 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; } - -.right { - float: right; -} - -.pointer { - cursor: pointer; -} - -.fa-2x { - margin: 0 3%; -} - +.footer-btns > i { padding: 0 10px; } +.footer-btns i, +.footer-btns a { margin-right: 10px; 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; @@ -331,94 +318,113 @@ body { border-radius: 2px; width: 82px; left: 16px; - background: #28a745; + background: var(--brand-success); color: #fff; font-size: 20px; text-align: center; } -.pause-btn { - color: #f8b334; +.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.55rem; -} - -.table tbody > tr:hover { - background: #ffff99; -} - -.active-row { - background: #ffff99; -} - -#remote-selection { - margin: 0 0 20px; -} - -#remote-selection:hover { - border: 1px solid #f30c16; -} - -#remote-selection:focus { - border: 1px solid #f30c16; - box-shadow: none; -} - -.pass-viewer { - float: right; - margin: -52px 5px 0 0; +.table th { padding: 0.55rem; } +.table tbody > tr:hover { background: #F3FBFF; } +.active-row { background: #EAF8FF; } +.table-scroll { position: relative; - z-index: 1; - cursor: pointer; -} - -.fa-eye { - color: #ff000096; - font-size: 20px; - font-weight: bolder; -} - -.fa-eye:hover { - color: red; + overflow-x: auto; + overflow-y: auto; + max-width: 100%; + background: #fff; + border: 1px solid var(--brand-border-soft); + border-radius: 12px; +} +#table-task-issues { + min-width: 930px !important; + margin-bottom: 0; + background: #fff; +} +#table-task-issues thead th { background: #f8fafc; z-index: 2; } +#table-task-issues td, +#table-task-issues th { vertical-align: top; white-space: normal; } +.issue-desc-cell { max-width: 280px; min-width: 190px; } +.checked { color: orange; } +.allIssues { font-weight: normal; display: table-cell; vertical-align: bottom; } + +.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); } - -.footer-app { - font-size: 15px; - font-weight: bold; - color: #17a2b8 !important; +.table-loader-card .loader-text { + margin-top: 10px; + color: #4b5563; + font-size: 13px; + font-weight: 600; } -.no-remotes-set { - margin: 60px 0px; +.active-timer-running { + font-size: 13px; + color: var(--brand-blue-strong); font-weight: bold; } - -.fun-man { - font-size: 15px; -} - +.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; - border: 1px solid #ddd; + background: #fff; + border: 1px solid var(--brand-border-soft); + border-radius: 12px; font-size: 13px; -} - -.footer-btns { - color: #17a2b8; - font-size: 15px; - border: 1px solid #ddd; - text-align: center; - padding: 15px; - margin: 0 0 10px 0; -} - -.footer-btns > i { - padding: 0 10px; -} - -.checked { - color: orange; -} \ No newline at end of file + 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/src/img/icon-pause-19.png b/src/img/icon-pause-19.png new file mode 100644 index 0000000000000000000000000000000000000000..58a80e0683ff9c95498abbc709c7db46d24958cf GIT binary patch literal 270 zcmV+p0rCEcP)olPZ zQZ~*brJUBmlbN+eYF&TjwtTZ?CIx`ijnRT&pQCv8KdkX4Fjs9xNgofnN_%Pi0jv2* UruBb<=>Px#07*qoM6N<$f*&G!_y7O^ literal 0 HcmV?d00001 diff --git a/src/img/icon-pause-38.png b/src/img/icon-pause-38.png new file mode 100644 index 0000000000000000000000000000000000000000..c5bef5f75859fbd9b5519a58f5fa2538986bce5a GIT binary patch literal 691 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 diff --git a/src/img/icon_16.png b/src/img/icon_16.png index 7c5ea1d48766c2270118c30fa2037a576ee93311..098418b308fce182559add11e3846a0833effa68 100644 GIT binary patch delta 231 zcmVk0BA?D}2D;_YnY$myZb-{RFAG5b2tVg2Vfk zc9f^FQ!sd;s+ZQkwx8qW}N^002ovPDHLkV1fwDW~BfC literal 1189 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJOS+@4BLl<6e(pbstU$g(vPY0F z14ES>14Ba#1H&(%P{RubhEf9thF1v;3|2E37{m+a>2e-omEi< zqI0ThXO&feBvcKMfy_qIdowib`Sj_pmo9x+S$WCA5~A#qx%ovyBajK|8W&AW&ueIc z%u!XlUsU}2&71%K|Nnmf{?(GjZ&t6q5fpUI!{dHY(cQH4>j42k6Q1_+8Fdlyoa9>b$1bbw9s5Ny%3noz7`!-pk1WDgz>*Y45jf18RT2bLWeh zv+ibO+z1SMvv%!OXBVK~uQ)q{?EU%k?~m{Qe*XsA{c3j-;#Kh|Xfwy8}F5B8& zG&Tl$4eF`F!XNkU{d)1@*UOh6^3|&svt|L^|ER9+VRiM*(9rigcD&oR?M6uGIbgs7 z!$Do+)uKgzK7Rak{P?p86P`_)^ltNJU|7AJGxzn16`zhAc{y+XtHnz`9yoBp&5e~ z-<~dxArhC96B67Qc@*rpwKs7{TeGW+oAc|-?{8QzVMB*T^NbxWOQvk;SupI%-K&=^TQ+-l`||1A z``6E}XK+ps2vD$4G10M6GSaeAo2IB2XRK(bX{lzm0Xkxq!^403@vmGjCGAHLkujeOiZjy z473f5tqcrgo*wvzq9HdwB{QuOw}#vu6;416k{}y`^V3So6N^$A98>a>QWZRN6Vp?J YQWH}u3s0s3%MJzxPgg&ebxsLQ0FbXI5C8xG diff --git a/src/img/icon_19.png b/src/img/icon_19.png index 91994b865b4d760995b2d690a010ee2e43182ed8..abaa57ff7e6c4556c1755a1854c5a02c08f32c3a 100644 GIT binary patch delta 320 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 literal 0 HcmV?d00001 diff --git a/src/img/icon_32.png b/src/img/icon_32.png new file mode 100644 index 0000000000000000000000000000000000000000..33b9ddd1b0cd837d7de0bf7d8434b68c6b616e12 GIT binary patch literal 717 zcmV;;0y6!HP)a6`7}4+-hq)YZzCAYmB@VyRTSuGSXw{SD9l>Hrf(w}Y)1 zCgLU#KTjr8s8uR4t<6=3=t?ArhiG*O!d{(+If;0Md0&TaoU9kkZ<4H%yBAWatZog^VZI>fnd38du|l(U2gBAQ)gY z$Q}T9ejUNe?s-aDEwY^=vJ*?Y8HQY+KbxBV{W4u%^UpEp zO903&m#6X6DsBYy)ZNklJZ!rpO)rAr<^5b{=F2ei%<~L0Kt@JJMn*qk{tem1EQO))GbX03H`|m0~oA^VTK3El}?RklI|~c1^o#B(UD6 zYRK;tCcW*K!iTMUk~?Aab%^mMi0<;PzX{-&4TC;istUkHdC`7yg~TT|7r&@!SE&#Y z2nkYgNA0mgF9X2vl!4_^*Xg)1HVQ45nu(XOQA5Au7=IiN9-_MUZE8%!v zg=HST_jl)=p`II^^Cw2HKL7cZUe6p#&Yu{$x+ltLXEc%8$+2tuVyw5XDPg^Lv!^#& z_rm~!#D95Jnc3<}O8v_SeqVV&S~tgJHajJQ6f2J&UMnvzTcC{UocO6PcALXF!`Y;(o^ftRI(M z70#>k+Z3}6wcjHQf0ZykhcXlZsu;8Z@j+9YKZ6uhD&~fxsL+6vq&MF002ovPDHLkV1h#Oqw4?w delta 2445 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 delta 3105 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 diff --git a/src/img/icon_64.png b/src/img/icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..8a832faf009f35bb4f1f7e0ebe9da5fedf40f782 GIT binary patch literal 1492 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 F{|&F!>x}>a literal 0 HcmV?d00001 diff --git a/src/img/inactive_16.png b/src/img/inactive_16.png new file mode 100644 index 0000000000000000000000000000000000000000..55a07836915f6b41fb0a4275f2054add0c7ae940 GIT binary patch literal 256 zcmV+b0ssDqP)1sx6a}AFse-{_MU+4Y2A3{fhMdAzPT|t-(?pFi2E`w1cO`$3Pr6)SyRdVBVTw@z z-f;y7T**xU+yOw>b#FQ63lW8~EK4Ht*4meC+rEu4k0PR`X>!agM07UB+$*JY-}mkS z05QgM&iMy`q?9h+`%o0cao=}d*VSvSb&PRemW8USdP*s=wf6b1U>wKKrfFV9M22Db z^4_Ny9m6&2!#;4FbpsAJbzE;^Z7d=L|ZPGd&hB_ zgb*DO6=Up!006kIdu|wp?RnmruIt3}Jg?PiS#ccSq-lCDrIe;=@*oH8>S(=_$*c>GWl#qMX5#bU9xZJSCd u6{VC~mgRK2-F8`)8~{K&-RXq=0rm|!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBD8ZEE?e4;KK3 zdl)*MVix**Cdy`6e+VS8`m#ZKV>pUf=f$?`H9i%d8;0}++TL( z%)({N+52`bd$iW)%j(v`Ykil zsncJ~<}NC>?z*-pk+5G}`uu%2qv%;BmXfmG*5*Zug=E3*Id36xX{c;Ic?L zpT{&raLSXXtfH~(BrI diff --git a/src/img/inactive_32.png b/src/img/inactive_32.png new file mode 100644 index 0000000000000000000000000000000000000000..09460d2badaa5e44e86cd24e0a65ab542fc8e683 GIT binary patch literal 790 zcmV+x1L^#UP)lUB6#9v zLez*YhEymDX=B!9y7T2hO4N8V8e_8WG}*kF{msjp83X_`@DBsSKaK>D01`j~NB~A} zu&}Uj%XM9EeSQ5o06;#Ucjo8kA62W>_3tMHL2zSyeEdNeh9>}^lv2X;yn$x3`Lb54 zz4$FasZ<*5>+2h^EGu0ql?E}!Kq;jF0Apig1B4KQF@`VJs_wN|ES4lu?(9UL6I1OV`T|Jv~I@Tim$GseDD zDwQW^XJ;R@)_^hAjIpp@ufOtr{|@IIVT`fXT4yqu>nkfOPXPc>?>P>F;AT3V_G-1- zHUK~_m$NIC%A;zvTIl2jEvlAx7%;`_V(Uz&P5!@LI|NICMJruZTGFMtv&m` z#Dzj3(`vOs#+dAVTV7s%yt=x&aZv$EDW;TKTI-84Nh!6~8YrbWl}eE)ilpN>?#ao? z8{4+M$;rvv0Dure=scfu&d*HK#HMKygb*;s=gMX_NR7-NhK!@z_PB&F=O+wG67Rx4CW0U-nlA++!N#Y`r1Noy@E%Su_6 z0VIF~@ZSKw0&%KC U^|pL^kN^Mx07*qoM6N<$g3Rf2l>h($ literal 0 HcmV?d00001 diff --git a/src/img/inactive_38.png b/src/img/inactive_38.png index 56a0b16a230ece1020e322b83fc9c5e9c9b4aa2f..d156ced44fb8189bee722df75300f0160718faac 100644 GIT binary patch delta 927 zcmV;Q17Q4>391K>8Gi-<005{x>8=0(193@2K~#9!?bgprV^HI3>8q=&pYH7Je96pUW|*c)Qc9kmpMQH|Vq&?|>0EUe zNdROjmAY4})qXfQIQUW1Gzx-1^-rplVj%?G#b}yFVHm1gFr}1o947!E)jt6M=yW;} zGb4(kXM=m52Y&!ar_--rUS9sTySw|{z`y{xt}6jxd3kx(vaC15FjQd}5&*u7P)fZp zN4@{C&CShCP18)vvfgPnn?)j0rfG_Lz5YiglX=L@GMP-iCZ$wD2qC59X0usiX1TYw z_x;$|*uA;AxsL(JL?Us&(P)%Ysni1ip_GyVK-YD0U4Pdum&?CTO-+3urBuvJL`2NY zhGAs4wzfXIwPS@s;Yl`|P0!5C=!HVz2{V%rg2OPx;^N}t&d|F-9HxfTFzXlQ75 zeSLj|h@g~;n3;54C&zLA@_qkly~7`C5R{FF>4 z4Q7t|2XP!H>^%p7-s%m*NSDjypW5yA&xu6BP=89H+b~2#oXuw6NT<^e0U(h`2+#AB zX_{IP1k$pstnd3S0Q8C-0Q%?dFMjTHIzcj-e6+E#@sX5LnWm}L>-95cmVF(=-LBH4 zl(JYXUOcbebGe*0JUo2&I^r(I8yy{;U0Yjw3xL)|aVC?Q1n@Pw$&tR@i44Oyl~Vey z>wnsw=e4e`uC7a^QnAr!c+3o?6qp(9cKfVWt2KfkP(&n%2t-6e2th=!ZQHrHxTx5+ z?MG3>z3V_q$>ZbWj|{^QXJ=<6$8nyvTCMAwo0~t+&(D9Y)oP7fUHLMiR|YF9EARWh z?;Rc<{_ zfP?@5`Tzg`fam}Kbua(`>RI+y?e7jT@qQ9J+u00Lr5M??VshmXv^00007bV*G` z2jT-A3pNnyb&E>?00W&#L_t(Y$K}@DZeBZdQ`_(8&s6ahyx^5K|cTZMhLUAL}lUk_TkZrKi0PK?jl^E~f;J=gtp z-Ea8(_jtrBSz$Wys-TER60Zsh{=ZfMAs}y=#?|%oIYP#^|~H6-4fKjE8&G8 z?}kFd%~+R?eZe+-t;44>x-F{O6#Oiy*1W$bDJQ6yZx&2Mw5xL3T^*K|RI%WRAmgf( zs{2ZytbgB!((ZWBM2OfvpnW>L2ey<|(PdPp4ZqpaYs<2f!%{LWi$t()>wxyyRut=W zNF>{;^w@Eqxgw$Lk%yLBo<%{yN?W zxwaW_*auF^o7QPqz@~MJPPiyz#cKvJtAFC4)#m1=vV=h=5meo?EOy5=Ib%&H=RDZA zllL&A;OT z71aenue1%TO*Xxfic0nj;eb+8twP&r)napEHuAaHHb1xh+1l+D{{w___vk%n_kW-I z3;GveVfs};h3UTlkeWVq^e{c;0000bbVXQnWMOn=I%9HWVRU5xGB7bXEif}JGC5Q* zI65^nIx#jYFf%$ZF!oy{U;qFBC3HntbYx+4WjbwdWNBu305UK!I4v+UEiyS&FgQ9j eG&(UhD=;%UFffM(oZJ8a002ovP6b4+LSTZ1P#h}& diff --git a/src/img/inactive_48.png b/src/img/inactive_48.png new file mode 100644 index 0000000000000000000000000000000000000000..978e2234593589451fe60d7fc386bca7553deb45 GIT binary patch literal 1216 zcmV;x1V8(UP) zW|slL2k;U%4ljAj0djyGAP2|+a)2Bl2gm_(fE?h}1qjlcVzDTUF?jm)>5mT_IM6jb zJUpzm7RDH_lw!shyl~;d5RsBTGR9CM z28f6dkx~O$W94$W#ag>GIXU@l5CpK($)k z7soLwrL+)2Rw|WZ5Cq&g=ao`?X=%v<0ES_xDwRqx0|a3hvPX{|82|w1+~S=(cm7N5 z?%%&(hG8h8DDrU}uQSG2LqmhCR4NVD+D#Bb2r$O@g@uK+wzjs0-9)dhu0{(B3kCpy zr62W|E?xQt04NB8GUvQ#t$hFhjEKC}I+&T6`L(I3=`BQbrBdk~=Nu+Egi5JkeSLjp zc6N5WTCKi6GBPqKgn*u&p0=Y$kA9AbKtxz56dLc{yZ3W@dwai>5}k8C(Evn5QcC{x z>C=Cwrlx*6e*F0PMDNB*5D{aHk(9Fi>eZ`fH?6?v=;#$|Ee;F}eALv`)HOCXHlmag z)>^NW;wvjF?%cU^hi=}y`TgCycYk1vZG=>Fb93Rui4*6glp!Ko=NvlcpjNB>Q>)c3 zXT4W0m-U%5X9k9bhQ3^0UUs{Ad3t*KtF3)fO75J)ix)3`y)A?m7Z;->wjCn+Cr_SO zAq4l{XA_M%=X||hkFtgtV~mI_V~mGk_*!poZ)*?)K^(_15v9)n=NzoH9uaNYzfPIPbMZN{?b~DG*=j7oO7Or z{$>Ei7^U$R$FZ^2Vp`@h<&F|BP&Pi}I7Y^pynXxjgX`C?U)if_l*?t^+1dGNl0&;y zARotZJ*y!dO2(sY02vTyY;1h{sXop*C!!RB0RR;Wg|2M?-ao5ov$L~*x3;$SbaZrl z2mm~(9zhgEQ*(23zde5ZcxCH^#uz_8KR;=V@rh?ywtB#`xMYY(%gf8r+}zwRNd?*s zzz0E4YH4YCH_5L}w(9lz?~oySVg8M#IndwV|6X@@ci-UP;Lvk#WLZxsrM4xQ_ny-0 zPP?R1YS*K|)~2=Q#+bdHB_V{?T8p%lZ_&7Ma(JOuAU{s$069PokOSlZIY17O1LOcX eKo0Oq0sIfWsdZ?_N|&wx0000{z literal 0 HcmV?d00001 diff --git a/src/img/logo.png b/src/img/logo.png index 494e4571f9a24bc354fd7728c2030e557576f0f0..ac2ed229bccfefa21f90a31720a252cd2bf1e12d 100644 GIT binary patch literal 7921 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 literal 6351 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 RKJEYj002ovPDHLkV1f_dWvT!G diff --git a/src/js/background.js b/src/js/background.js index 07d39abb..361bbe89 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -1,32 +1,42 @@ -/* - Copyright 2016 - 2022 Sunflower IT (http://sunflowerweb.nl) - License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - */ -var online = "it is online"; -console.log(online); -var TogglButton = { - setBrowserAction: function (timer) { - console.log("TIMER CHECK:" + timer); - if (timer === true) { - var imagePath = {'19': 'img/icon_19.png', '38': 'img/icon_38.png'}; - } - else if (timer === 'pause') { - var imagePath = {'19': 'img/icon-pause.png', '38': 'img/icon-pause.png'}; - } - else { - var imagePath = {'19': 'img/inactive_19.png', '38': 'img/inactive_19.png'}; - } - browser.action.setIcon({ - path: imagePath, - }); - console.log("works"); - }, -}; -browser.runtime.onMessage.addListener(async (msg, sender) => { - console.log("BG page received message", msg, "from", sender); - console.log("Stored data", await browser.storage.local.get()); - TogglButton.setBrowserAction(msg.TimerActive); -}); +importScripts('browser-polyfill.js'); + +const browserApi = globalThis.browser || globalThis.chrome; + +function iconPath(file) { + return browserApi.runtime && browserApi.runtime.getURL ? browserApi.runtime.getURL(file) : file; +} +function setBrowserAction(timer) { + let path; + if (timer === true) { + path = { '16': iconPath('img/icon_16.png'), '19': iconPath('img/icon_19.png'), '38': iconPath('img/icon_38.png') }; + } else if (timer === 'pause') { + path = { '16': iconPath('img/icon-pause.png'), '19': iconPath('img/icon-pause-19.png'), '38': iconPath('img/icon-pause-38.png') }; + } else { + path = { '16': iconPath('img/inactive_16.png'), '19': iconPath('img/inactive_19.png'), '38': iconPath('img/inactive_38.png') }; + } + try { + browserApi.action.setIcon({ path }); + } catch (err) { + console.warn('Could not update extension icon', err); + } +} +async function syncFromStorage() { + const res = await browserApi.storage.local.get(['active_timer_id']); + setBrowserAction(!!res.active_timer_id); +} + +browserApi.runtime.onInstalled.addListener(syncFromStorage); +browserApi.runtime.onStartup.addListener(syncFromStorage); +browserApi.runtime.onMessage.addListener((msg) => { + if (msg && Object.prototype.hasOwnProperty.call(msg, 'TimerActive')) { + setBrowserAction(msg.TimerActive); + } +}); +browserApi.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.active_timer_id) { + setBrowserAction(!!changes.active_timer_id.newValue); + } +}); diff --git a/src/js/browser-polyfill.js b/src/js/browser-polyfill.js new file mode 100644 index 00000000..230b7631 --- /dev/null +++ b/src/js/browser-polyfill.js @@ -0,0 +1,1277 @@ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define("webextension-polyfill", ["module"], factory); + } else if (typeof exports !== "undefined") { + factory(module); + } else { + var mod = { + exports: {} + }; + factory(mod); + global.browser = mod.exports; + } +})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (module) { + /* webextension-polyfill - v0.8.0 - Tue Apr 20 2021 11:27:38 */ + + /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ + + /* vim: set sts=2 sw=2 et tw=80: */ + + /* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + "use strict"; + + if (typeof browser === "undefined" || Object.getPrototypeOf(browser) !== Object.prototype) { + const CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE = "The message port closed before a response was received."; + const SEND_RESPONSE_DEPRECATION_WARNING = "Returning a Promise is the preferred way to send a reply from an onMessage/onMessageExternal listener, as the sendResponse will be removed from the specs (See https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage)"; // Wrapping the bulk of this polyfill in a one-time-use function is a minor + // optimization for Firefox. Since Spidermonkey does not fully parse the + // contents of a function until the first time it's called, and since it will + // never actually need to be called, this allows the polyfill to be included + // in Firefox nearly for free. + + const wrapAPIs = extensionAPIs => { + // NOTE: apiMetadata is associated to the content of the api-metadata.json file + // at build time by replacing the following "include" with the content of the + // JSON file. + const apiMetadata = { + "alarms": { + "clear": { + "minArgs": 0, + "maxArgs": 1 + }, + "clearAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "bookmarks": { + "create": { + "minArgs": 1, + "maxArgs": 1 + }, + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getChildren": { + "minArgs": 1, + "maxArgs": 1 + }, + "getRecent": { + "minArgs": 1, + "maxArgs": 1 + }, + "getSubTree": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTree": { + "minArgs": 0, + "maxArgs": 0 + }, + "move": { + "minArgs": 2, + "maxArgs": 2 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeTree": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "browserAction": { + "disable": { + "minArgs": 0, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "enable": { + "minArgs": 0, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "getBadgeBackgroundColor": { + "minArgs": 1, + "maxArgs": 1 + }, + "getBadgeText": { + "minArgs": 1, + "maxArgs": 1 + }, + "getPopup": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTitle": { + "minArgs": 1, + "maxArgs": 1 + }, + "openPopup": { + "minArgs": 0, + "maxArgs": 0 + }, + "setBadgeBackgroundColor": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setBadgeText": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setIcon": { + "minArgs": 1, + "maxArgs": 1 + }, + "setPopup": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setTitle": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "browsingData": { + "remove": { + "minArgs": 2, + "maxArgs": 2 + }, + "removeCache": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeCookies": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeDownloads": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeFormData": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeHistory": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeLocalStorage": { + "minArgs": 1, + "maxArgs": 1 + }, + "removePasswords": { + "minArgs": 1, + "maxArgs": 1 + }, + "removePluginData": { + "minArgs": 1, + "maxArgs": 1 + }, + "settings": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "commands": { + "getAll": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "contextMenus": { + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "cookies": { + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAllCookieStores": { + "minArgs": 0, + "maxArgs": 0 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "devtools": { + "inspectedWindow": { + "eval": { + "minArgs": 1, + "maxArgs": 2, + "singleCallbackArg": false + } + }, + "panels": { + "create": { + "minArgs": 3, + "maxArgs": 3, + "singleCallbackArg": true + }, + "elements": { + "createSidebarPane": { + "minArgs": 1, + "maxArgs": 1 + } + } + } + }, + "downloads": { + "cancel": { + "minArgs": 1, + "maxArgs": 1 + }, + "download": { + "minArgs": 1, + "maxArgs": 1 + }, + "erase": { + "minArgs": 1, + "maxArgs": 1 + }, + "getFileIcon": { + "minArgs": 1, + "maxArgs": 2 + }, + "open": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "pause": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeFile": { + "minArgs": 1, + "maxArgs": 1 + }, + "resume": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + }, + "show": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "extension": { + "isAllowedFileSchemeAccess": { + "minArgs": 0, + "maxArgs": 0 + }, + "isAllowedIncognitoAccess": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "history": { + "addUrl": { + "minArgs": 1, + "maxArgs": 1 + }, + "deleteAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "deleteRange": { + "minArgs": 1, + "maxArgs": 1 + }, + "deleteUrl": { + "minArgs": 1, + "maxArgs": 1 + }, + "getVisits": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "i18n": { + "detectLanguage": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAcceptLanguages": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "identity": { + "launchWebAuthFlow": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "idle": { + "queryState": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "management": { + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "getSelf": { + "minArgs": 0, + "maxArgs": 0 + }, + "setEnabled": { + "minArgs": 2, + "maxArgs": 2 + }, + "uninstallSelf": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "notifications": { + "clear": { + "minArgs": 1, + "maxArgs": 1 + }, + "create": { + "minArgs": 1, + "maxArgs": 2 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "getPermissionLevel": { + "minArgs": 0, + "maxArgs": 0 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "pageAction": { + "getPopup": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTitle": { + "minArgs": 1, + "maxArgs": 1 + }, + "hide": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setIcon": { + "minArgs": 1, + "maxArgs": 1 + }, + "setPopup": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setTitle": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "show": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "permissions": { + "contains": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "request": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "runtime": { + "getBackgroundPage": { + "minArgs": 0, + "maxArgs": 0 + }, + "getPlatformInfo": { + "minArgs": 0, + "maxArgs": 0 + }, + "openOptionsPage": { + "minArgs": 0, + "maxArgs": 0 + }, + "requestUpdateCheck": { + "minArgs": 0, + "maxArgs": 0 + }, + "sendMessage": { + "minArgs": 1, + "maxArgs": 3 + }, + "sendNativeMessage": { + "minArgs": 2, + "maxArgs": 2 + }, + "setUninstallURL": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "sessions": { + "getDevices": { + "minArgs": 0, + "maxArgs": 1 + }, + "getRecentlyClosed": { + "minArgs": 0, + "maxArgs": 1 + }, + "restore": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "storage": { + "local": { + "clear": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "managed": { + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "sync": { + "clear": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + } + }, + "tabs": { + "captureVisibleTab": { + "minArgs": 0, + "maxArgs": 2 + }, + "create": { + "minArgs": 1, + "maxArgs": 1 + }, + "detectLanguage": { + "minArgs": 0, + "maxArgs": 1 + }, + "discard": { + "minArgs": 0, + "maxArgs": 1 + }, + "duplicate": { + "minArgs": 1, + "maxArgs": 1 + }, + "executeScript": { + "minArgs": 1, + "maxArgs": 2 + }, + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getCurrent": { + "minArgs": 0, + "maxArgs": 0 + }, + "getZoom": { + "minArgs": 0, + "maxArgs": 1 + }, + "getZoomSettings": { + "minArgs": 0, + "maxArgs": 1 + }, + "goBack": { + "minArgs": 0, + "maxArgs": 1 + }, + "goForward": { + "minArgs": 0, + "maxArgs": 1 + }, + "highlight": { + "minArgs": 1, + "maxArgs": 1 + }, + "insertCSS": { + "minArgs": 1, + "maxArgs": 2 + }, + "move": { + "minArgs": 2, + "maxArgs": 2 + }, + "query": { + "minArgs": 1, + "maxArgs": 1 + }, + "reload": { + "minArgs": 0, + "maxArgs": 2 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeCSS": { + "minArgs": 1, + "maxArgs": 2 + }, + "sendMessage": { + "minArgs": 2, + "maxArgs": 3 + }, + "setZoom": { + "minArgs": 1, + "maxArgs": 2 + }, + "setZoomSettings": { + "minArgs": 1, + "maxArgs": 2 + }, + "update": { + "minArgs": 1, + "maxArgs": 2 + } + }, + "topSites": { + "get": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "webNavigation": { + "getAllFrames": { + "minArgs": 1, + "maxArgs": 1 + }, + "getFrame": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "webRequest": { + "handlerBehaviorChanged": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "windows": { + "create": { + "minArgs": 0, + "maxArgs": 1 + }, + "get": { + "minArgs": 1, + "maxArgs": 2 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 1 + }, + "getCurrent": { + "minArgs": 0, + "maxArgs": 1 + }, + "getLastFocused": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + } + }; + + if (Object.keys(apiMetadata).length === 0) { + throw new Error("api-metadata.json has not been included in browser-polyfill"); + } + /** + * A WeakMap subclass which creates and stores a value for any key which does + * not exist when accessed, but behaves exactly as an ordinary WeakMap + * otherwise. + * + * @param {function} createItem + * A function which will be called in order to create the value for any + * key which does not exist, the first time it is accessed. The + * function receives, as its only argument, the key being created. + */ + + + class DefaultWeakMap extends WeakMap { + constructor(createItem, items = undefined) { + super(items); + this.createItem = createItem; + } + + get(key) { + if (!this.has(key)) { + this.set(key, this.createItem(key)); + } + + return super.get(key); + } + + } + /** + * Returns true if the given object is an object with a `then` method, and can + * therefore be assumed to behave as a Promise. + * + * @param {*} value The value to test. + * @returns {boolean} True if the value is thenable. + */ + + + const isThenable = value => { + return value && typeof value === "object" && typeof value.then === "function"; + }; + /** + * Creates and returns a function which, when called, will resolve or reject + * the given promise based on how it is called: + * + * - If, when called, `chrome.runtime.lastError` contains a non-null object, + * the promise is rejected with that value. + * - If the function is called with exactly one argument, the promise is + * resolved to that value. + * - Otherwise, the promise is resolved to an array containing all of the + * function's arguments. + * + * @param {object} promise + * An object containing the resolution and rejection functions of a + * promise. + * @param {function} promise.resolve + * The promise's resolution function. + * @param {function} promise.reject + * The promise's rejection function. + * @param {object} metadata + * Metadata about the wrapped method which has created the callback. + * @param {boolean} metadata.singleCallbackArg + * Whether or not the promise is resolved with only the first + * argument of the callback, alternatively an array of all the + * callback arguments is resolved. By default, if the callback + * function is invoked with only a single argument, that will be + * resolved to the promise, while all arguments will be resolved as + * an array if multiple are given. + * + * @returns {function} + * The generated callback function. + */ + + + const makeCallback = (promise, metadata) => { + return (...callbackArgs) => { + if (extensionAPIs.runtime.lastError) { + promise.reject(new Error(extensionAPIs.runtime.lastError.message)); + } else if (metadata.singleCallbackArg || callbackArgs.length <= 1 && metadata.singleCallbackArg !== false) { + promise.resolve(callbackArgs[0]); + } else { + promise.resolve(callbackArgs); + } + }; + }; + + const pluralizeArguments = numArgs => numArgs == 1 ? "argument" : "arguments"; + /** + * Creates a wrapper function for a method with the given name and metadata. + * + * @param {string} name + * The name of the method which is being wrapped. + * @param {object} metadata + * Metadata about the method being wrapped. + * @param {integer} metadata.minArgs + * The minimum number of arguments which must be passed to the + * function. If called with fewer than this number of arguments, the + * wrapper will raise an exception. + * @param {integer} metadata.maxArgs + * The maximum number of arguments which may be passed to the + * function. If called with more than this number of arguments, the + * wrapper will raise an exception. + * @param {boolean} metadata.singleCallbackArg + * Whether or not the promise is resolved with only the first + * argument of the callback, alternatively an array of all the + * callback arguments is resolved. By default, if the callback + * function is invoked with only a single argument, that will be + * resolved to the promise, while all arguments will be resolved as + * an array if multiple are given. + * + * @returns {function(object, ...*)} + * The generated wrapper function. + */ + + + const wrapAsyncFunction = (name, metadata) => { + return function asyncFunctionWrapper(target, ...args) { + if (args.length < metadata.minArgs) { + throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); + } + + if (args.length > metadata.maxArgs) { + throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); + } + + return new Promise((resolve, reject) => { + if (metadata.fallbackToNoCallback) { + // This API method has currently no callback on Chrome, but it return a promise on Firefox, + // and so the polyfill will try to call it with a callback first, and it will fallback + // to not passing the callback if the first call fails. + try { + target[name](...args, makeCallback({ + resolve, + reject + }, metadata)); + } catch (cbError) { + console.warn(`${name} API method doesn't seem to support the callback parameter, ` + "falling back to call it without a callback: ", cbError); + target[name](...args); // Update the API method metadata, so that the next API calls will not try to + // use the unsupported callback anymore. + + metadata.fallbackToNoCallback = false; + metadata.noCallback = true; + resolve(); + } + } else if (metadata.noCallback) { + target[name](...args); + resolve(); + } else { + target[name](...args, makeCallback({ + resolve, + reject + }, metadata)); + } + }); + }; + }; + /** + * Wraps an existing method of the target object, so that calls to it are + * intercepted by the given wrapper function. The wrapper function receives, + * as its first argument, the original `target` object, followed by each of + * the arguments passed to the original method. + * + * @param {object} target + * The original target object that the wrapped method belongs to. + * @param {function} method + * The method being wrapped. This is used as the target of the Proxy + * object which is created to wrap the method. + * @param {function} wrapper + * The wrapper function which is called in place of a direct invocation + * of the wrapped method. + * + * @returns {Proxy} + * A Proxy object for the given method, which invokes the given wrapper + * method in its place. + */ + + + const wrapMethod = (target, method, wrapper) => { + return new Proxy(method, { + apply(targetMethod, thisObj, args) { + return wrapper.call(thisObj, target, ...args); + } + + }); + }; + + let hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); + /** + * Wraps an object in a Proxy which intercepts and wraps certain methods + * based on the given `wrappers` and `metadata` objects. + * + * @param {object} target + * The target object to wrap. + * + * @param {object} [wrappers = {}] + * An object tree containing wrapper functions for special cases. Any + * function present in this object tree is called in place of the + * method in the same location in the `target` object tree. These + * wrapper methods are invoked as described in {@see wrapMethod}. + * + * @param {object} [metadata = {}] + * An object tree containing metadata used to automatically generate + * Promise-based wrapper functions for asynchronous. Any function in + * the `target` object tree which has a corresponding metadata object + * in the same location in the `metadata` tree is replaced with an + * automatically-generated wrapper function, as described in + * {@see wrapAsyncFunction} + * + * @returns {Proxy} + */ + + const wrapObject = (target, wrappers = {}, metadata = {}) => { + let cache = Object.create(null); + let handlers = { + has(proxyTarget, prop) { + return prop in target || prop in cache; + }, + + get(proxyTarget, prop, receiver) { + if (prop in cache) { + return cache[prop]; + } + + if (!(prop in target)) { + return undefined; + } + + let value = target[prop]; + + if (typeof value === "function") { + // This is a method on the underlying object. Check if we need to do + // any wrapping. + if (typeof wrappers[prop] === "function") { + // We have a special-case wrapper for this method. + value = wrapMethod(target, target[prop], wrappers[prop]); + } else if (hasOwnProperty(metadata, prop)) { + // This is an async method that we have metadata for. Create a + // Promise wrapper for it. + let wrapper = wrapAsyncFunction(prop, metadata[prop]); + value = wrapMethod(target, target[prop], wrapper); + } else { + // This is a method that we don't know or care about. Return the + // original method, bound to the underlying object. + value = value.bind(target); + } + } else if (typeof value === "object" && value !== null && (hasOwnProperty(wrappers, prop) || hasOwnProperty(metadata, prop))) { + // This is an object that we need to do some wrapping for the children + // of. Create a sub-object wrapper for it with the appropriate child + // metadata. + value = wrapObject(value, wrappers[prop], metadata[prop]); + } else if (hasOwnProperty(metadata, "*")) { + // Wrap all properties in * namespace. + value = wrapObject(value, wrappers[prop], metadata["*"]); + } else { + // We don't need to do any wrapping for this property, + // so just forward all access to the underlying object. + Object.defineProperty(cache, prop, { + configurable: true, + enumerable: true, + + get() { + return target[prop]; + }, + + set(value) { + target[prop] = value; + } + + }); + return value; + } + + cache[prop] = value; + return value; + }, + + set(proxyTarget, prop, value, receiver) { + if (prop in cache) { + cache[prop] = value; + } else { + target[prop] = value; + } + + return true; + }, + + defineProperty(proxyTarget, prop, desc) { + return Reflect.defineProperty(cache, prop, desc); + }, + + deleteProperty(proxyTarget, prop) { + return Reflect.deleteProperty(cache, prop); + } + + }; // Per contract of the Proxy API, the "get" proxy handler must return the + // original value of the target if that value is declared read-only and + // non-configurable. For this reason, we create an object with the + // prototype set to `target` instead of using `target` directly. + // Otherwise we cannot return a custom object for APIs that + // are declared read-only and non-configurable, such as `chrome.devtools`. + // + // The proxy handlers themselves will still use the original `target` + // instead of the `proxyTarget`, so that the methods and properties are + // dereferenced via the original targets. + + let proxyTarget = Object.create(target); + return new Proxy(proxyTarget, handlers); + }; + /** + * Creates a set of wrapper functions for an event object, which handles + * wrapping of listener functions that those messages are passed. + * + * A single wrapper is created for each listener function, and stored in a + * map. Subsequent calls to `addListener`, `hasListener`, or `removeListener` + * retrieve the original wrapper, so that attempts to remove a + * previously-added listener work as expected. + * + * @param {DefaultWeakMap} wrapperMap + * A DefaultWeakMap object which will create the appropriate wrapper + * for a given listener function when one does not exist, and retrieve + * an existing one when it does. + * + * @returns {object} + */ + + + const wrapEvent = wrapperMap => ({ + addListener(target, listener, ...args) { + target.addListener(wrapperMap.get(listener), ...args); + }, + + hasListener(target, listener) { + return target.hasListener(wrapperMap.get(listener)); + }, + + removeListener(target, listener) { + target.removeListener(wrapperMap.get(listener)); + } + + }); + + const onRequestFinishedWrappers = new DefaultWeakMap(listener => { + if (typeof listener !== "function") { + return listener; + } + /** + * Wraps an onRequestFinished listener function so that it will return a + * `getContent()` property which returns a `Promise` rather than using a + * callback API. + * + * @param {object} req + * The HAR entry object representing the network request. + */ + + + return function onRequestFinished(req) { + const wrappedReq = wrapObject(req, {} + /* wrappers */ + , { + getContent: { + minArgs: 0, + maxArgs: 0 + } + }); + listener(wrappedReq); + }; + }); // Keep track if the deprecation warning has been logged at least once. + + let loggedSendResponseDeprecationWarning = false; + const onMessageWrappers = new DefaultWeakMap(listener => { + if (typeof listener !== "function") { + return listener; + } + /** + * Wraps a message listener function so that it may send responses based on + * its return value, rather than by returning a sentinel value and calling a + * callback. If the listener function returns a Promise, the response is + * sent when the promise either resolves or rejects. + * + * @param {*} message + * The message sent by the other end of the channel. + * @param {object} sender + * Details about the sender of the message. + * @param {function(*)} sendResponse + * A callback which, when called with an arbitrary argument, sends + * that value as a response. + * @returns {boolean} + * True if the wrapped listener returned a Promise, which will later + * yield a response. False otherwise. + */ + + + return function onMessage(message, sender, sendResponse) { + let didCallSendResponse = false; + let wrappedSendResponse; + let sendResponsePromise = new Promise(resolve => { + wrappedSendResponse = function (response) { + if (!loggedSendResponseDeprecationWarning) { + console.warn(SEND_RESPONSE_DEPRECATION_WARNING, new Error().stack); + loggedSendResponseDeprecationWarning = true; + } + + didCallSendResponse = true; + resolve(response); + }; + }); + let result; + + try { + result = listener(message, sender, wrappedSendResponse); + } catch (err) { + result = Promise.reject(err); + } + + const isResultThenable = result !== true && isThenable(result); // If the listener didn't returned true or a Promise, or called + // wrappedSendResponse synchronously, we can exit earlier + // because there will be no response sent from this listener. + + if (result !== true && !isResultThenable && !didCallSendResponse) { + return false; + } // A small helper to send the message if the promise resolves + // and an error if the promise rejects (a wrapped sendMessage has + // to translate the message into a resolved promise or a rejected + // promise). + + + const sendPromisedResult = promise => { + promise.then(msg => { + // send the message value. + sendResponse(msg); + }, error => { + // Send a JSON representation of the error if the rejected value + // is an instance of error, or the object itself otherwise. + let message; + + if (error && (error instanceof Error || typeof error.message === "string")) { + message = error.message; + } else { + message = "An unexpected error occurred"; + } + + sendResponse({ + __mozWebExtensionPolyfillReject__: true, + message + }); + }).catch(err => { + // Print an error on the console if unable to send the response. + console.error("Failed to send onMessage rejected reply", err); + }); + }; // If the listener returned a Promise, send the resolved value as a + // result, otherwise wait the promise related to the wrappedSendResponse + // callback to resolve and send it as a response. + + + if (isResultThenable) { + sendPromisedResult(result); + } else { + sendPromisedResult(sendResponsePromise); + } // Let Chrome know that the listener is replying. + + + return true; + }; + }); + + const wrappedSendMessageCallback = ({ + reject, + resolve + }, reply) => { + if (extensionAPIs.runtime.lastError) { + // Detect when none of the listeners replied to the sendMessage call and resolve + // the promise to undefined as in Firefox. + // See https://github.com/mozilla/webextension-polyfill/issues/130 + if (extensionAPIs.runtime.lastError.message === CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE) { + resolve(); + } else { + reject(new Error(extensionAPIs.runtime.lastError.message)); + } + } else if (reply && reply.__mozWebExtensionPolyfillReject__) { + // Convert back the JSON representation of the error into + // an Error instance. + reject(new Error(reply.message)); + } else { + resolve(reply); + } + }; + + const wrappedSendMessage = (name, metadata, apiNamespaceObj, ...args) => { + if (args.length < metadata.minArgs) { + throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); + } + + if (args.length > metadata.maxArgs) { + throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); + } + + return new Promise((resolve, reject) => { + const wrappedCb = wrappedSendMessageCallback.bind(null, { + resolve, + reject + }); + args.push(wrappedCb); + apiNamespaceObj.sendMessage(...args); + }); + }; + + const staticWrappers = { + devtools: { + network: { + onRequestFinished: wrapEvent(onRequestFinishedWrappers) + } + }, + runtime: { + onMessage: wrapEvent(onMessageWrappers), + onMessageExternal: wrapEvent(onMessageWrappers), + sendMessage: wrappedSendMessage.bind(null, "sendMessage", { + minArgs: 1, + maxArgs: 3 + }) + }, + tabs: { + sendMessage: wrappedSendMessage.bind(null, "sendMessage", { + minArgs: 2, + maxArgs: 3 + }) + } + }; + const settingMetadata = { + clear: { + minArgs: 1, + maxArgs: 1 + }, + get: { + minArgs: 1, + maxArgs: 1 + }, + set: { + minArgs: 1, + maxArgs: 1 + } + }; + apiMetadata.privacy = { + network: { + "*": settingMetadata + }, + services: { + "*": settingMetadata + }, + websites: { + "*": settingMetadata + } + }; + return wrapObject(extensionAPIs, staticWrappers, apiMetadata); + }; + + if (typeof chrome != "object" || !chrome || !chrome.runtime || !chrome.runtime.id) { + throw new Error("This script should only be loaded in a browser extension."); + } // The build process adds a UMD wrapper around this file, which makes the + // `module` variable available. + + + module.exports = wrapAPIs(chrome); + } else { + module.exports = browser; + } +}); +//# sourceMappingURL=browser-polyfill.js.map diff --git a/src/js/common.js b/src/js/common.js new file mode 100644 index 00000000..16e1da9f --- /dev/null +++ b/src/js/common.js @@ -0,0 +1,186 @@ + +const browserApi = globalThis.browser || globalThis.chrome; + +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/src/js/options-app.js b/src/js/options-app.js new file mode 100644 index 00000000..efdc3588 --- /dev/null +++ b/src/js/options-app.js @@ -0,0 +1,152 @@ + +import { readRemotes, writeRemotes, validURL, normalizeHost, storage, clearOdooSessionCookies } from './common.js'; +const { Component, mount, useState, onWillStart } = owl; + +function ReadMoreTemplate(app, bdom, helpers) { + const { text, createBlock } = bdom; + const wrap = createBlock(``); + const toggle = createBlock(``); + return function template(ctx) { + const content = text(ctx.state.expanded || !ctx.needsTrim ? (ctx.props.text || '') : ctx.shortText); + let more = null; + if (ctx.needsTrim) { + more = toggle([["prevent", ctx.toggle, ctx], ctx.state.expanded ? '▲' : '...']); + } + return wrap([], [content, more]); + }; +} + +class ReadMore extends Component { + static props = ['text', 'limit?']; + static template = 'ReadMore'; + setup(){ this.state = useState({ expanded:false }); } + get needsTrim(){ return (this.props.text || '').length > (this.props.limit || 20); } + get shortText(){ return (this.props.text || '').slice(0, this.props.limit || 20); } + toggle(){ this.state.expanded = !this.state.expanded; } +} + +function OptionsAppTemplate(app, bdom, helpers) { + let { text, createBlock, list } = bdom; + let { prepareList, OwlError, withKey } = helpers; + const comp1 = app.createComponent('ReadMore', true, false, false, ['text','limit']); + const comp2 = app.createComponent('ReadMore', true, false, false, ['text','limit']); + const comp3 = app.createComponent('ReadMore', true, false, false, ['text','limit']); + const comp4 = app.createComponent('ReadMore', true, false, false, ['text','limit']); + const comp5 = app.createComponent('ReadMore', true, false, false, ['text','limit']); + + let block1 = 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
`); + let block2 = createBlock(`
`); + let block3 = createBlock(`
List of Available Remotes
RemoteHostDatabaseSourceState
`); + let block4 = createBlock(``); + + return function template(ctx, node, key='') { + let errBlock = null, listBlock = null; + let attr1 = ctx.state.activePage === 'about' ? 'selected' : 'notselected'; + let attr2 = ctx.state.activePage === 'options' ? 'selected' : 'notselected'; + let h1 = [() => { ctx.state.activePage = 'about'; }, ctx]; + let h2 = [() => { ctx.state.activePage = 'options'; }, ctx]; + let attr3 = ctx.state.activePage === 'about' ? 'active_page' : 'inactive_page'; + let attr4 = ctx.state.activePage === 'options' ? 'active_page' : 'inactive_page'; + let submitH = ['prevent', ctx.addRemote, ctx]; + const form = ctx.state.form; + let hv = [(ev) => { form.remote_host = ev.target.value; }]; + let nv = [(ev) => { form.remote_name = ev.target.value; }]; + let dv = [(ev) => { form.remote_database = ev.target.value; }]; + let srcIssue = (form.remote_datasrc === 'project.issue'); + let srcTask = (form.remote_datasrc === 'project.task'); + let srcIssueH = [(ev) => { if (ev.target.checked) form.remote_datasrc = 'project.issue'; }]; + let srcTaskH = [(ev) => { if (ev.target.checked) form.remote_datasrc = 'project.task'; }]; + let addH = [ctx.addRemote, ctx]; + let loadH = [ctx.loadRemotes, ctx]; + let eyeH = [() => { ctx.state.showList = !ctx.state.showList; }, ctx]; + let removeAllH = [ctx.removeAllRemotes, ctx]; + if (ctx.state.error) errBlock = block2([ctx.state.error]); + if (ctx.state.showList && ctx.state.remotes.length) { + ctx = Object.create(ctx); + const [k, v, l, c] = prepareList(ctx.state.remotes); + const keys = new Set(); + for (let i = 0; i < l; i++) { + ctx.remote = k[i]; + const key1 = ctx.remote.url + ctx.remote.database; + if (keys.has(String(key1))) throw new OwlError(`Got duplicate key in t-foreach: ${key1}`); + keys.add(String(key1)); + const p1 = { text: ctx.remote.name, limit: 18 }; + const p2 = { text: ctx.remote.url, limit: 25 }; + const p3 = { text: ctx.remote.database, limit: 18 }; + const p4 = { text: ctx.remote.datasrc || 'project.issue', limit: 18 }; + const p5 = { text: ctx.remote.state || 'Inactive', limit: 18 }; + const b1 = comp1(p1, key + `__1__${key1}`, node, this, null); + const b2 = comp2(p2, key + `__2__${key1}`, node, this, null); + const b3 = comp3(p3, key + `__3__${key1}`, node, this, null); + const b4 = comp4(p4, key + `__4__${key1}`, node, this, null); + const b5 = comp5(p5, key + `__5__${key1}`, node, this, null); + const remoteItem = ctx.remote; + const delH = [() => ctx.removeRemote(remoteItem), ctx]; + c[i] = withKey(block4([delH], [b1, b2, b3, b4, b5]), key1); + } + ctx = ctx.__proto__; + listBlock = block3([], [list(c)]); + } + return block1([attr1, h1, attr2, h2, attr3, attr4, submitH, form.remote_host, hv, form.remote_name, nv, form.remote_database, dv, srcIssue, srcIssueH, srcTask, srcTaskH, addH, loadH, eyeH, removeAllH], [errBlock, listBlock]); + }; +} + +class OptionsApp extends Component { + static components = { ReadMore }; + static template = 'OptionsApp'; + setup() { + this.state = useState({ + activePage: 'options', + remotes: [], + showList: true, + error: '', + form: { remote_host: '', remote_name: '', remote_database: '', remote_datasrc: 'project.issue' }, + }); + onWillStart(async () => { await this.loadRemotes(); }); + } + async loadRemotes() { this.state.remotes = await readRemotes(); } + async addRemote() { + this.state.error = ''; + 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 || 'project.issue'; + 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(); + if (remotes.some((r) => r.url === host && r.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.state.form.remote_host = ''; + this.state.form.remote_name = ''; + this.state.form.remote_database = ''; + this.state.form.remote_datasrc = 'project.issue'; + alert(`Host [${host}] added successfully.`); + } + async removeRemote(remote) { + const ok = confirm(`Are you sure you want to remove remote [${remote.url}]?`); + if (!ok) return; + await clearOdooSessionCookies(remote.url); + const remotes = (await readRemotes()).filter((r) => !(r.url === remote.url && r.database === remote.database)); + await writeRemotes(remotes); + await storage.remove(remote.database); + await this.loadRemotes(); + alert(`[${remote.url}] removed successfully!`); + } + async removeAllRemotes() { + const ok = confirm('Are you sure you want to remove all remotes?'); + if (!ok) return; + const remotes = await readRemotes(); + for (const remote of remotes) { + await clearOdooSessionCookies(remote.url); + await storage.remove(remote.database); + } + await storage.remove('remote_host_info'); + await this.loadRemotes(); + alert('Host list removed successfully!'); + } +} + +const templates = { ReadMore: ReadMoreTemplate, OptionsApp: OptionsAppTemplate }; +mount(OptionsApp, document.getElementById('app'), { dev: true, templates }); diff --git a/src/js/owl.iife.js b/src/js/owl.iife.js new file mode 100644 index 00000000..c26b6259 --- /dev/null +++ b/src/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